Bug 1388823 - Sync shield-recipe-client v65 from GitHub (commit ff1b680c) r?Gijs draft
authorMike Cooper <mcooper@mozilla.com>
Thu, 10 Aug 2017 10:22:32 -0700
changeset 645928 3aa77b9d1b796d5301eb7e7a61264deb7efd609a
parent 644236 878f3ec362ff257c1d66df8ce670555d35e4fad0
child 726081 5ce668fa58592ba06bf177dd5b468be83f9efcd1
push id73944
push userbmo:mcooper@mozilla.com
push dateMon, 14 Aug 2017 15:45:53 +0000
reviewersGijs
bugs1388823
milestone57.0a1
Bug 1388823 - Sync shield-recipe-client v65 from GitHub (commit ff1b680c) r?Gijs MozReview-Commit-ID: F8uuFRyS4li
browser/base/content/test/static/browser_all_files_referenced.js
browser/extensions/shield-recipe-client/bootstrap.js
browser/extensions/shield-recipe-client/content/AboutPages.jsm
browser/extensions/shield-recipe-client/content/about-studies/about-studies.css
browser/extensions/shield-recipe-client/content/about-studies/about-studies.html
browser/extensions/shield-recipe-client/content/about-studies/about-studies.js
browser/extensions/shield-recipe-client/content/about-studies/common.js
browser/extensions/shield-recipe-client/content/about-studies/img/shield-logo.png
browser/extensions/shield-recipe-client/content/about-studies/shield-studies.js
browser/extensions/shield-recipe-client/content/shield-content-frame.js
browser/extensions/shield-recipe-client/content/shield-content-process.js
browser/extensions/shield-recipe-client/install.rdf.in
browser/extensions/shield-recipe-client/jar.mn
browser/extensions/shield-recipe-client/lib/ActionSandboxManager.jsm
browser/extensions/shield-recipe-client/lib/AddonStudies.jsm
browser/extensions/shield-recipe-client/lib/Addons.jsm
browser/extensions/shield-recipe-client/lib/ClientEnvironment.jsm
browser/extensions/shield-recipe-client/lib/FilterExpressions.jsm
browser/extensions/shield-recipe-client/lib/NormandyApi.jsm
browser/extensions/shield-recipe-client/lib/NormandyDriver.jsm
browser/extensions/shield-recipe-client/lib/PreferenceExperiments.jsm
browser/extensions/shield-recipe-client/lib/RecipeRunner.jsm
browser/extensions/shield-recipe-client/lib/ShieldPreferences.jsm
browser/extensions/shield-recipe-client/lib/ShieldRecipeClient.jsm
browser/extensions/shield-recipe-client/lib/Uptake.jsm
browser/extensions/shield-recipe-client/node_modules/jexl/LICENSE.txt
browser/extensions/shield-recipe-client/node_modules/jexl/lib/Jexl.js
browser/extensions/shield-recipe-client/node_modules/jexl/lib/Lexer.js
browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/Evaluator.js
browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/handlers.js
browser/extensions/shield-recipe-client/node_modules/jexl/lib/grammar.js
browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/Parser.js
browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/handlers.js
browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/states.js
browser/extensions/shield-recipe-client/skin/osx/Heartbeat.css
browser/extensions/shield-recipe-client/skin/shared/Heartbeat.css
browser/extensions/shield-recipe-client/test/browser/.eslintrc.js
browser/extensions/shield-recipe-client/test/browser/browser.ini
browser/extensions/shield-recipe-client/test/browser/browser_ActionSandboxManager.js
browser/extensions/shield-recipe-client/test/browser/browser_AddonStudies.js
browser/extensions/shield-recipe-client/test/browser/browser_Addons.js
browser/extensions/shield-recipe-client/test/browser/browser_ClientEnvironment.js
browser/extensions/shield-recipe-client/test/browser/browser_EventEmitter.js
browser/extensions/shield-recipe-client/test/browser/browser_FilterExpressions.js
browser/extensions/shield-recipe-client/test/browser/browser_NormandyDriver.js
browser/extensions/shield-recipe-client/test/browser/browser_PreferenceExperiments.js
browser/extensions/shield-recipe-client/test/browser/browser_RecipeRunner.js
browser/extensions/shield-recipe-client/test/browser/browser_ShieldRecipeClient.js
browser/extensions/shield-recipe-client/test/browser/browser_about_preferences.js
browser/extensions/shield-recipe-client/test/browser/browser_about_studies.js
browser/extensions/shield-recipe-client/test/browser/fixtures/addon-fixture/manifest.json
browser/extensions/shield-recipe-client/test/browser/fixtures/normandy.xpi
browser/extensions/shield-recipe-client/test/browser/head.js
browser/extensions/shield-recipe-client/test/unit/head_xpc.js
browser/extensions/shield-recipe-client/test/unit/invalid_recipe_signature_api/api/v1/index.json
browser/extensions/shield-recipe-client/test/unit/invalid_recipe_signature_api/api/v1/recipe/signed/index.json
browser/extensions/shield-recipe-client/test/unit/invalid_recipe_signature_api/normandy.content-signature.mozilla.org-20210705.dev.chain
browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/console-log/implementation/sha384-RGx3rydrSq53UfmW9kFcK0mQYra67XIvZvr4MhmAe--ljiiMQOtgM7Cmca48um3v
browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/console-log/index.json
browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/index.json
browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/opt-out-study/implementation/sha384-HM_avYcD00o27ufwU1V7PIBtiuMAXML6MMwlYrDEqDX-XzGVuOfL52RCM680JExN
browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/opt-out-study/index.json
browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/preference-experiment/implementation/sha384-KQgG38GQ7KZAb2VIB48ANQO6nBcxZoLm2ORzUviRT5nAvSywyPjZ5cJIElw6iXIt
browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/preference-experiment/index.json
browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/show-heartbeat/implementation/sha384-dEGiyKPEln8Ns5cQHzGpMIGdirSAAX0X-Kwlu-U3sJ05yNbO-ANij_a6c5SyL7G4
browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/show-heartbeat/index.json
browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/signed/index.json
browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/index.json
browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/recipe/signed/index.json
browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/recipe/signed/normandy.content-signature.mozilla.org-20210705.dev.chain
browser/extensions/shield-recipe-client/test/unit/mock_api/normandy.content-signature.mozilla.org-20210705.dev.chain
browser/extensions/shield-recipe-client/test/unit/test_ActionSandboxManager.js
browser/extensions/shield-recipe-client/test/unit/test_NormandyApi.js
browser/extensions/shield-recipe-client/test/unit/test_SandboxManager.js
browser/extensions/shield-recipe-client/test/unit/xpcshell.ini
browser/extensions/shield-recipe-client/vendor/LICENSE_THIRDPARTY
browser/extensions/shield-recipe-client/vendor/PropTypes.js
browser/extensions/shield-recipe-client/vendor/React.js
browser/extensions/shield-recipe-client/vendor/ReactDOM.js
browser/extensions/shield-recipe-client/vendor/classnames.js
browser/extensions/shield-recipe-client/vendor/mozjexl.js
--- a/browser/base/content/test/static/browser_all_files_referenced.js
+++ b/browser/base/content/test/static/browser_all_files_referenced.js
@@ -113,18 +113,19 @@ var whitelist = [
   {file: "resource://gre/chrome/en-US/locale/en-US/global-platform/win/intl.properties",
    platforms: ["linux", "macosx"]},
   {file: "resource://gre/chrome/en-US/locale/en-US/global-platform/win/platformKeys.properties",
    platforms: ["linux", "macosx"]},
 
   // browser/extensions/pdfjs/content/web/viewer.js#7450
   {file: "resource://pdf.js/web/debugger.js"},
 
-  // Needed by Normandy
-  {file: "resource://gre/modules/IndexedDB.jsm"},
+  // These are used in content processes. They are actually referenced.
+  {file: "resource://shield-recipe-client-content/shield-content-frame.js"},
+  {file: "resource://shield-recipe-client-content/shield-content-process.js"},
 
   // New L10n API that is not yet used in production
   {file: "resource://gre/modules/Localization.jsm"},
 
   // Starting from here, files in the whitelist are bugs that need fixing.
   // Bug 1339420
   {file: "chrome://branding/content/icon128.png"},
   // Bug 1339424 (wontfix?)
--- a/browser/extensions/shield-recipe-client/bootstrap.js
+++ b/browser/extensions/shield-recipe-client/bootstrap.js
@@ -1,50 +1,97 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 "use strict";
 
 const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/AppConstants.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "LogManager",
   "resource://shield-recipe-client/lib/LogManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "ShieldRecipeClient",
   "resource://shield-recipe-client/lib/ShieldRecipeClient.jsm");
 
+const DEFAULT_PREFS = {
+  "extensions.shield-recipe-client.api_url": "https://normandy.cdn.mozilla.net/api/v1",
+  "extensions.shield-recipe-client.dev_mode": false,
+  "extensions.shield-recipe-client.enabled": true,
+  "extensions.shield-recipe-client.startup_delay_seconds": 300,
+  "extensions.shield-recipe-client.logging.level": Log.Level.Warn,
+  "extensions.shield-recipe-client.user_id": "",
+  "extensions.shield-recipe-client.run_interval_seconds": 86400, // 24 hours
+  "extensions.shield-recipe-client.first_run": true,
+  "extensions.shield-recipe-client.shieldLearnMoreUrl": (
+    "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/shield"
+  ),
+  "app.shield.optoutstudies.enabled": AppConstants.MOZ_DATA_REPORTING,
+};
+
 this.install = function() {};
 
-this.startup = async function() {
-  await ShieldRecipeClient.startup();
+this.startup = function() {
+  // Initialize preference defaults before anything else happens.
+  const prefBranch = Services.prefs.getDefaultBranch("");
+  for (const [name, value] of Object.entries(DEFAULT_PREFS)) {
+    switch (typeof value) {
+      case "string":
+        prefBranch.setCharPref(name, value);
+        break;
+      case "number":
+        prefBranch.setIntPref(name, value);
+        break;
+      case "boolean":
+        prefBranch.setBoolPref(name, value);
+        break;
+      default:
+        throw new Error(`Invalid default preference type ${typeof value}`);
+    }
+  }
+
+  ShieldRecipeClient.startup();
 };
 
 this.shutdown = function(data, reason) {
   ShieldRecipeClient.shutdown(reason);
 
   // Unload add-on modules. We don't do this in ShieldRecipeClient so that
   // modules are not unloaded accidentally during tests.
   const log = LogManager.getLogger("bootstrap");
-  const modules = [
+  let modules = [
     "lib/ActionSandboxManager.jsm",
+    "lib/Addons.jsm",
+    "lib/AddonStudies.jsm",
     "lib/CleanupManager.jsm",
     "lib/ClientEnvironment.jsm",
     "lib/FilterExpressions.jsm",
     "lib/EventEmitter.jsm",
     "lib/Heartbeat.jsm",
     "lib/LogManager.jsm",
     "lib/NormandyApi.jsm",
     "lib/NormandyDriver.jsm",
     "lib/PreferenceExperiments.jsm",
     "lib/RecipeRunner.jsm",
     "lib/Sampling.jsm",
     "lib/SandboxManager.jsm",
+    "lib/ShieldPreferences.jsm",
     "lib/ShieldRecipeClient.jsm",
     "lib/Storage.jsm",
+    "lib/Uptake.jsm",
     "lib/Utils.jsm",
-  ];
+  ].map(m => `resource://shield-recipe-client/${m}`);
+  modules = modules.concat([
+    "AboutPages.jsm",
+  ].map(m => `resource://shield-recipe-client-content/${m}`));
+  modules = modules.concat([
+    "mozjexl.js",
+  ].map(m => `resource://shield-recipe-client-vendor/${m}`));
+
   for (const module of modules) {
     log.debug(`Unloading ${module}`);
-    Cu.unload(`resource://shield-recipe-client/${module}`);
+    Cu.unload(module);
   }
 };
 
 this.uninstall = function() {};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/content/AboutPages.jsm
@@ -0,0 +1,213 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+const { interfaces: Ci, results: Cr, manager: Cm, utils: Cu } = Components;
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(
+  this, "CleanupManager", "resource://shield-recipe-client/lib/CleanupManager.jsm",
+);
+XPCOMUtils.defineLazyModuleGetter(
+  this, "AddonStudies", "resource://shield-recipe-client/lib/AddonStudies.jsm",
+);
+
+this.EXPORTED_SYMBOLS = ["AboutPages"];
+
+const SHIELD_LEARN_MORE_URL_PREF = "extensions.shield-recipe-client.shieldLearnMoreUrl";
+
+// Due to bug 1051238 frame scripts are cached forever, so we can't update them
+// as a restartless add-on. The Math.random() is the work around for this.
+const PROCESS_SCRIPT = (
+  `resource://shield-recipe-client-content/shield-content-process.js?${Math.random()}`
+);
+const FRAME_SCRIPT = (
+  `resource://shield-recipe-client-content/shield-content-frame.js?${Math.random()}`
+);
+
+/**
+ * Class for managing an about: page that Shield provides. Adapted from
+ * browser/extensions/pocket/content/AboutPocket.jsm.
+ *
+ * @implements nsIFactory
+ * @implements nsIAboutModule
+ */
+class AboutPage {
+  constructor({chromeUrl, aboutHost, classId, description, uriFlags}) {
+    this.chromeUrl = chromeUrl;
+    this.aboutHost = aboutHost;
+    this.classId = Components.ID(classId);
+    this.description = description;
+    this.uriFlags = uriFlags;
+  }
+
+  getURIFlags() {
+    return this.uriFlags;
+  }
+
+  newChannel(uri, loadInfo) {
+    const newURI = Services.io.newURI(this.chromeUrl);
+    const channel = Services.io.newChannelFromURIWithLoadInfo(newURI, loadInfo);
+    channel.originalURI = uri;
+
+    if (this.uriFlags & Ci.nsIAboutModule.URI_SAFE_FOR_UNTRUSTED_CONTENT) {
+      const principal = Services.scriptSecurityManager.createCodebasePrincipal(uri, {});
+      channel.owner = principal;
+    }
+    return channel;
+  }
+
+  createInstance(outer, iid) {
+    if (outer !== null) {
+      throw Cr.NS_ERROR_NO_AGGREGATION;
+    }
+    return this.QueryInterface(iid);
+  }
+
+  /**
+   * Register this about: page with XPCOM. This must be called once in each
+   * process (parent and content) to correctly initialize the page.
+   */
+  register() {
+    Cm.QueryInterface(Ci.nsIComponentRegistrar).registerFactory(
+      this.classId,
+      this.description,
+      `@mozilla.org/network/protocol/about;1?what=${this.aboutHost}`,
+      this,
+    );
+  }
+
+  /**
+   * Unregister this about: page with XPCOM. This must be called before the
+   * add-on is cleaned up if the page has been registered.
+   */
+  unregister() {
+    Cm.QueryInterface(Ci.nsIComponentRegistrar).unregisterFactory(this.classId, this);
+  }
+}
+AboutPage.prototype.QueryInterface = XPCOMUtils.generateQI([Ci.nsIAboutModule]);
+
+/**
+ * The module exported by this file.
+ */
+this.AboutPages = {
+  async init() {
+    // Load scripts in content processes and tabs
+    Services.ppmm.loadProcessScript(PROCESS_SCRIPT, true);
+    Services.mm.loadFrameScript(FRAME_SCRIPT, true);
+
+    // Register about: pages and their listeners
+    this.aboutStudies.register();
+    this.aboutStudies.registerParentListeners();
+
+    CleanupManager.addCleanupHandler(() => {
+      // Stop loading processs scripts and notify existing scripts to clean up.
+      Services.ppmm.removeDelayedProcessScript(PROCESS_SCRIPT);
+      Services.ppmm.broadcastAsyncMessage("Shield:ShuttingDown");
+      Services.mm.removeDelayedFrameScript(FRAME_SCRIPT);
+      Services.mm.broadcastAsyncMessage("Shield:ShuttingDown");
+
+      // Clean up about pages
+      this.aboutStudies.unregisterParentListeners();
+      this.aboutStudies.unregister();
+    });
+  },
+};
+
+/**
+ * about:studies page for displaying in-progress and past Shield studies.
+ * @type {AboutPage}
+ * @implements {nsIMessageListener}
+ */
+XPCOMUtils.defineLazyGetter(this.AboutPages, "aboutStudies", () => {
+  const aboutStudies = new AboutPage({
+    chromeUrl: "resource://shield-recipe-client-content/about-studies/about-studies.html",
+    aboutHost: "studies",
+    classId: "{6ab96943-a163-482c-9622-4faedc0e827f}",
+    description: "Shield Study Listing",
+    uriFlags: (
+      Ci.nsIAboutModule.ALLOW_SCRIPT
+      | Ci.nsIAboutModule.URI_SAFE_FOR_UNTRUSTED_CONTENT
+      | Ci.nsIAboutModule.URI_MUST_LOAD_IN_CHILD
+    ),
+  });
+
+  // Extra methods for about:study-specific behavior.
+  Object.assign(aboutStudies, {
+    /**
+     * Register listeners for messages from the content processes.
+     */
+    registerParentListeners() {
+      Services.mm.addMessageListener("Shield:GetStudyList", this);
+      Services.mm.addMessageListener("Shield:RemoveStudy", this);
+      Services.mm.addMessageListener("Shield:OpenOldDataPreferences", this);
+    },
+
+    /**
+     * Unregister listeners for messages from the content process.
+     */
+    unregisterParentListeners() {
+      Services.mm.removeMessageListener("Shield:GetStudyList", this);
+      Services.mm.removeMessageListener("Shield:RemoveStudy", this);
+      Services.mm.removeMessageListener("Shield:OpenOldDataPreferences", this);
+    },
+
+    /**
+     * Dispatch messages from the content process to the appropriate handler.
+     * @param {Object} message
+     *   See the nsIMessageListener documentation for details about this object.
+     */
+    receiveMessage(message) {
+      switch (message.name) {
+        case "Shield:GetStudyList":
+          this.sendStudyList(message.target);
+          break;
+        case "Shield:RemoveStudy":
+          this.removeStudy(message.data);
+          break;
+        case "Shield:OpenOldDataPreferences":
+          this.openOldDataPreferences();
+          break;
+      }
+    },
+
+    /**
+     * Fetch a list of studies from storage and send it to the process that
+     * requested them.
+     * @param {<browser>} target
+     *   XUL <browser> element for the tab containing the about:studies page
+     *   that requested a study list.
+     */
+    async sendStudyList(target) {
+      target.messageManager.sendAsyncMessage("Shield:ReceiveStudyList", {
+        studies: await AddonStudies.getAll(),
+      });
+    },
+
+    /**
+     * Disable an active study and remove its add-on.
+     * @param {String} studyName
+     */
+    async removeStudy(recipeId) {
+      await AddonStudies.stop(recipeId);
+
+      // Update any open tabs with the new study list now that it has changed.
+      Services.mm.broadcastAsyncMessage("Shield:ReceiveStudyList", {
+        studies: await AddonStudies.getAll(),
+      });
+    },
+
+    openOldDataPreferences() {
+      const browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
+      browserWindow.openAdvancedPreferences("dataChoicesTab", {origin: "aboutStudies"});
+    },
+
+    getShieldLearnMoreHref() {
+      return Services.urlFormatter.formatURLPref(SHIELD_LEARN_MORE_URL_PREF);
+    },
+  });
+
+  return aboutStudies;
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/content/about-studies/about-studies.css
@@ -0,0 +1,182 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+:root {
+  --icon-background-color-1: #0A84FF;
+  --icon-background-color-2: #008EA4;
+  --icon-background-color-3: #ED00B5;
+  --icon-background-color-4: #058B00;
+  --icon-background-color-5: #A47F00;
+  --icon-background-color-6: #FF0039;
+  --icon-background-disabled-color: #737373;
+  --body-text-disabled-color: #737373;
+  --info-box-background-color: #D7D7DB;
+  --info-box-border-color: #98979C;
+  --study-status-active-color: #058B00;
+  --study-status-disabled-color: #737373;
+}
+
+html,
+body,
+#app {
+  height: 100%;
+  width: 100%;
+}
+
+button > .button-box {
+  padding-left: 10px;
+  padding-right: 10px;
+}
+
+.about-studies-container {
+  display: flex;
+  flex-direction: row;
+  font-size: 1.25rem;
+  min-height: 100%;
+  width: 100%;
+}
+
+#categories {
+  flex: 0 0;
+  margin: 0;
+  min-width: 200px;
+  padding: 40px 0 0;
+}
+
+#categories .category {
+  align-items: center;
+  display: flex;
+  flex-direction: row;
+}
+
+.main-content {
+  flex: 1;
+}
+
+.info-box {
+  margin-bottom: 10px;
+  text-align: center;
+}
+
+.info-box-content {
+  align-items: center;
+  background: var(--info-box-background-color);
+  border: 1px solid var(--info-box-border-color);
+  display: inline-flex;
+  padding: 10px 15px;
+}
+
+.info-box-content > * {
+  margin-right: 10px;
+}
+
+.info-box-content > *:last-child {
+  margin-right: 0;
+}
+
+.study-list {
+  list-style-type: none;
+  margin: 0;
+  padding: 0;
+}
+
+.study {
+  align-items: center;
+  border-bottom: 1px solid var(--in-content-border-color);
+  display: flex;
+  flex-direction: row;
+  padding: 10px;
+}
+
+.study.disabled {
+  color: var(--body-text-disabled-color);
+}
+
+.study .study-status {
+  color: var(--study-status-active-color);
+  font-weight: bold;
+}
+
+.study.disabled .study-status {
+  color: var(--study-status-disabled-color);
+}
+
+.study:last-child {
+  border-bottom: none;
+}
+
+.study > * {
+  margin-right: 15px;
+}
+
+.study > *:last-child {
+  margin-right: 0;
+}
+
+.study-icon {
+  color: #FFF;
+  flex: 0 0 40px;
+  font-size: 26px;
+  height: 40px;
+  line-height: 40px;
+  text-align: center;
+  text-transform: capitalize;
+}
+
+.study:nth-child(6n+0) .study-icon {
+  background: var(--icon-background-color-1);
+}
+
+.study:nth-child(6n+1) .study-icon {
+  background: var(--icon-background-color-2);
+}
+
+.study:nth-child(6n+2) .study-icon {
+  background: var(--icon-background-color-3);
+}
+
+.study:nth-child(6n+3) .study-icon {
+  background: var(--icon-background-color-4);
+}
+
+.study:nth-child(6n+4) .study-icon {
+  background: var(--icon-background-color-5);
+}
+
+.study:nth-child(6n+5) .study-icon {
+  background: var(--icon-background-color-6);
+}
+
+.study.disabled .study-icon {
+  background: var(--icon-background-disabled-color);
+}
+
+.study-details {
+  flex: 1;
+  overflow: hidden;
+}
+
+.study-name {
+  font-weight: bold;
+  margin-bottom: 0.3em;
+}
+
+.study-description {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  width: 100%;
+}
+
+.study-description > * {
+  margin-right: 5px;
+}
+
+.study-description > *:last-child {
+  margin-right: 0;
+}
+
+.study-actions {
+  flex: 0 0;
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/content/about-studies/about-studies.html
@@ -0,0 +1,23 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this
+   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <title>about:studies</title>
+    <link rel="stylesheet" href="chrome://global/skin/global.css">
+    <link rel="stylesheet" href="chrome://global/skin/in-content/common.css">
+    <link rel="stylesheet" href="resource://shield-recipe-client-content/about-studies/about-studies.css">
+  </head>
+  <body>
+    <div id="app"></div>
+    <script src="resource://shield-recipe-client-vendor/React.js"></script>
+    <script src="resource://shield-recipe-client-vendor/ReactDOM.js"></script>
+    <script src="resource://shield-recipe-client-vendor/PropTypes.js"></script>
+    <script src="resource://shield-recipe-client-vendor/classnames.js"></script>
+    <script src="resource://shield-recipe-client-content/about-studies/common.js"></script>
+    <script src="resource://shield-recipe-client-content/about-studies/shield-studies.js"></script>
+    <script src="resource://shield-recipe-client-content/about-studies/about-studies.js"></script>
+  </body>
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/content/about-studies/about-studies.js
@@ -0,0 +1,140 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+/* global classnames PropTypes r React ReactDOM ShieldStudies */
+
+/**
+ * Mapping of pages displayed on the sidebar. Keys are the value used in the
+ * URL hash to identify the current page.
+ *
+ * Pages will appear in the sidebar in the order they are defined here. If the
+ * URL doesn't contain a hash, the first page will be displayed in the content area.
+ */
+const PAGES = new Map([
+  ["shieldStudies", {
+    name: "Shield Studies",
+    component: ShieldStudies,
+    icon: "resource://shield-recipe-client-content/about-studies/img/shield-logo.png",
+  }],
+]);
+
+/**
+ * Handle basic layout and routing within about:studies.
+ */
+class AboutStudies extends React.Component {
+  constructor(props) {
+    super(props);
+
+    let hash = new URL(window.location).hash.slice(1);
+    if (!PAGES.has(hash)) {
+      hash = "shieldStudies";
+    }
+
+    this.state = {
+      currentPageId: hash,
+    };
+
+    this.handleEvent = this.handleEvent.bind(this);
+  }
+
+  componentDidMount() {
+    window.addEventListener("hashchange", this);
+  }
+
+  componentWillUnmount() {
+    window.removeEventListener("hashchange", this);
+  }
+
+  handleEvent(event) {
+    const newHash = new URL(event.newURL).hash.slice(1);
+    if (PAGES.has(newHash)) {
+      this.setState({currentPageId: newHash});
+    }
+  }
+
+  render() {
+    const currentPageId = this.state.currentPageId;
+    const pageEntries = Array.from(PAGES.entries());
+    const currentPage = PAGES.get(currentPageId);
+
+    return (
+      r("div", {className: "about-studies-container"},
+        r(Sidebar, {},
+          pageEntries.map(([id, page]) => (
+            r(SidebarItem, {
+              key: id,
+              pageId: id,
+              selected: id === currentPageId,
+              page,
+            })
+          )),
+        ),
+        r(Content, {},
+          currentPage && r(currentPage.component)
+        ),
+      )
+    );
+  }
+}
+
+class Sidebar extends React.Component {
+  render() {
+    return r("ul", {id: "categories"}, this.props.children);
+  }
+}
+Sidebar.propTypes = {
+  children: PropTypes.node,
+};
+
+class SidebarItem extends React.Component {
+  constructor(props) {
+    super(props);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleClick() {
+    window.location = `#${this.props.pageId}`;
+  }
+
+  render() {
+    const { page, selected } = this.props;
+    return (
+      r("li", {
+        className: classnames("category", {selected}),
+        onClick: this.handleClick,
+      },
+        page.icon && r("img", {className: "category-icon", src: page.icon}),
+        r("span", {className: "category-name"}, page.name),
+      )
+    );
+  }
+}
+SidebarItem.propTypes = {
+  pageId: PropTypes.string.isRequired,
+  page: PropTypes.shape({
+    icon: PropTypes.string,
+    name: PropTypes.string.isRequired,
+  }).isRequired,
+  selected: PropTypes.bool,
+};
+
+class Content extends React.Component {
+  render() {
+    return (
+      r("div", {className: "main-content"},
+        r("div", {className: "content-box"},
+          this.props.children,
+        ),
+      )
+    );
+  }
+}
+Content.propTypes = {
+  children: PropTypes.node,
+};
+
+ReactDOM.render(
+  r(AboutStudies),
+  document.getElementById("app"),
+);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/content/about-studies/common.js
@@ -0,0 +1,137 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+/* eslint-disable no-unused-vars */
+/* global PropTypes React */
+
+/**
+ * Shorthand for creating elements (to avoid using a JSX preprocessor)
+ */
+const r = React.createElement;
+
+/**
+ * Information box used at the top of listings.
+ */
+window.InfoBox = class InfoBox extends React.Component {
+  render() {
+    return (
+      r("div", {className: "info-box"},
+        r("div", {className: "info-box-content"},
+          this.props.children,
+        ),
+      )
+    );
+  }
+};
+window.InfoBox.propTypes = {
+  children: PropTypes.node,
+};
+
+/**
+ * Button using in-product styling.
+ */
+window.FxButton = class FxButton extends React.Component {
+  render() {
+    return (
+      r("button", Object.assign({}, this.props, {children: undefined}),
+        r("div", {className: "button-box"},
+          this.props.children,
+        ),
+      )
+    );
+  }
+};
+window.FxButton.propTypes = {
+  children: PropTypes.node,
+};
+
+/**
+ * Wrapper class for a value that is provided by the frame script.
+ *
+ * Emits a "GetRemoteValue:{name}" page event on load to fetch the initial
+ * value, and listens for "ReceiveRemoveValue:{name}" page callbacks to receive
+ * the value when it updates.
+ *
+ * @example
+ * const myRemoteValue = new RemoteValue("MyValue", 5);
+ * class MyComponent extends React.Component {
+ *   constructor(props) {
+ *     super(props);
+ *     this.state = {
+ *       myValue: null,
+ *     };
+ *   }
+ *
+ *   componentWillMount() {
+ *     myRemoteValue.subscribe(this);
+ *   }
+ *
+ *   componentWillUnmount() {
+ *     myRemoteValue.unsubscribe(this);
+ *   }
+ *
+ *   receiveRemoteValue(name, value) {
+ *     this.setState({myValue: value});
+ *   }
+ *
+ *   render() {
+ *     return r("div", {}, this.state.myValue);
+ *   }
+ * }
+ */
+class RemoteValue {
+  constructor(name, defaultValue = null) {
+    this.name = name;
+    this.handlers = [];
+    this.value = defaultValue;
+
+    document.addEventListener(`ReceiveRemoteValue:${this.name}`, this);
+    sendPageEvent(`GetRemoteValue:${this.name}`);
+  }
+
+  /**
+   * Subscribe to this value as it updates. Handlers are called with the current
+   * value immediately after subscribing.
+   * @param {Object} handler
+   *   Object with a receiveRemoteValue(name, value) method that is called with
+   *   the name and value of this RemoteValue when it is updated.
+   */
+  subscribe(handler) {
+    this.handlers.push(handler);
+    handler.receiveRemoteValue(this.name, this.value);
+  }
+
+  /**
+   * Remove a previously-registered handler.
+   * @param {Object} handler
+   */
+  unsubscribe(handler) {
+    this.handlers = this.handlers.filter(h => h !== handler);
+  }
+
+  handleEvent(event) {
+    this.value = event.detail;
+    for (const handler of this.handlers) {
+      handler.receiveRemoteValue(this.name, this.value);
+    }
+  }
+}
+
+/**
+ * Collection of RemoteValue instances used within the page.
+ */
+const remoteValues = {
+  StudyList: new RemoteValue("StudyList", []),
+  ShieldLearnMoreHref: new RemoteValue("ShieldLearnMoreHref", ""),
+};
+
+/**
+ * Dispatches a page event to the privileged frame script for this tab.
+ * @param {String} action
+ * @param {Object} data
+ */
+function sendPageEvent(action, data) {
+  const event = new CustomEvent("ShieldPageEvent", {bubbles: true, detail: {action, data}});
+  document.dispatchEvent(event);
+}
new file mode 100644
index 0000000000000000000000000000000000000000..e8539268cedca719b8f694b5ba7845cd27c1974c
GIT binary patch
literal 5426
zc$@(;70v32P)<h;3K|Lk000e1NJLTq001xm001xu1^@s6R|5Hm0000PbVXQnQ*UN;
zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU`)Ja4^RCwB?T4|6R<#~Skn){xe-C6A+
z?a89m#btw7aY(Wg8B*{Ar((NIs_ZzG*o3$$m4u{<q+GblDZ`HhjE%wN3p)-Kmk}5c
z{sCC#(BYMYCD6Ge?P`zNYj$_;>7G9FzF*HQS}k!Ar$wXQ+3By}<9VO=dB2wAI1czY
zb?Ow_+uL#U=n({iA<WFoAel^}zrPPZ`q7VY!;Lp$d1(nFBfId6=YN5=);0_d4&dOy
zgV?p}Htp44y}D`ZmaRQwnX#dX@$qZY>GZ(ZSY}v}WqVUk&-=9Ag}%PtbDK8xoE;q<
zz4WQOJ~i>i>#v*p_U*&TlP57dH;2!C_H#IJ;1whiNxc2`Tln%<zJiOHi<q9CMpsuC
z^7%Y=@7^tJyOHeSL&a4GJLK_rpeQo@em^aifT}3)d3|u;_$MYN`fbY^$YjO_Xu-jW
ziOU1?b3gAdmCNmC&zu$am~|6#v%f<$8smHL?uiqf{y^Z5PMvxWM~)mtEE>gg|MlEV
ztyaD8)?06%pUq}3gd^b#nTweV^!$w9=c~!Gf|iyRWM{HaR7KcC)?IBbq-F*<3LNbt
zM~=X<EZ_3-N<SGmkV>Wc-hS)NVXx0OP%c-7mU6kI&*wv>T1Bl^Log6Py<P`@M#~9<
zn`_dAcqFX{S(e4;&9hu8LDe)Ai$##Zl=UDAbWH(Upf%4@KrZarv*-QA#l^8uC^Qz2
z$1;kd=zI3;#kq6maMMjUt#u%q&0>6f{9~`b_S%2Y^$wrk@1vVx=sKdY7;2R&Jy3;2
zhVaoeNRp&ggJrs=hmae#_?shU+ZH)!q3M%hn)I3bgGJX)V=a@eEz^Q&)#>{tW!wvX
zet-hhl4?PrP^@fdZy&wq-h2Oc^XAR(c64;Cb>QgHqg$T-?srdBDY7v#m_OGvH*HQM
znl>9ue^ga&7%P(l5T6@{fvRpmr-xT6HI&4uQ|dJYd>;5bD*Rp#JQM`F&2^d`p#}Ia
zHkuu?$YDNg+ZHDWqE2h`_0q~R-LL5nJ@ijos20Z7)WCrQ2foaUwWZVI%WR-WRfVzq
zxhzYlQ<SQD4Hbe;nGCE{b(E_HO696Bwp=#I_&R0FqSJ;|mk|mF;PFY&ynxRy5!6(1
zdOb2ZrBS3ca!i9t>w7f9x=dMyOpe$hLzEv-6ogIbwlwl9E8b_Hef9xz;HzsKSX#;r
zgu`JGX<`nF3VB?}qiUEamMU&U%QY1Ey2yrFsFqEb20_S_MP|GKG9qBZ7dH{_vEWPS
zWTc7uN&uC-j9N)SX@wxATOxCkS|M24<e&^MJLgr%8C9H|DUE{XC8sonopK=;CsN7I
z%v{s7d({D|a^1FUf$5;%i_;e;@W$cy$T*-@lu@sdp*Ba79|m~CHd3v`SP_Cr%z%$h
zE#_W(f^}%2IuxG;uN;J3^}r~pbSg0NUYG?hY6T6(iU(%N1HBA#P(j|XL>)*<4Vs4_
zOu>*92b(vgkf2Ofbw>o)>-8Gz93a|q*Z~fWO3<lM<V(dmVsvAq&x9wc!y7jcOp&4S
zDj8mfKStm8>QKBkMUo&!2Ee8zO%;SRpzNa`{ct1`{)B}<ivdS230gFD8zuz>x*xT?
zM9yjCm><;~IabtAFM6RD$c`EnHQB`wMw3V-$zJCG|Bi9b8K4570#+a!J_OP=T>Ix2
zq4>#Z#UglVf>~^W4NQ%2F2T}OP`n9tCKO2#u&dS$#QhqVBOFU2c98`W*_1_DQt7*v
z_?_$_3T~xfw6l@qm_;e4@0vv)F7EGzKJO96GkpjOwwnxhl>=n9BMI!gOe4F*16ig2
zc?^mc-DG-Uz!C<qqqewK=tf5v#0J!f27R6&r96&8Z4sK|C#My`E*w3+Hf>WpL2^{8
zA%cvf@zY^BeC>y<k#nj+P;x}bxh9n>=-L#;-9S$BLQz#)uuUTi4rhYzriGn4al4}k
z10}&ovN$EVan1M0j_BC41iQZ&c?6H&brks=WlAw9VycLcpk87bx8x<U9{)m;WcP$5
zjFlY9mZ^wmL;3(BvY=dt8()2$0|CF^YBnYTqefo13rHe86mNov;C+fb|8cKP@jJf9
zuytY%U~|U}NO`*9tp#C9HHx_5u0elHyY8l&SFzpo9AZ2dQV^LE*HQ!Vj@vUB9Wn)o
zqaTh&jCBrBG_6(R#edSO_Gm_pe^wN&;Us05t|6k&g|9)k45T9gbVrAw27tCmw`g!|
zIKxiBjsB>zCL)f;bu-GE;^EdR*5_w7l@IrTrUc>p$O&hi1JniUa>GkSa$(UddvUYm
ztcpFiU>p%eh7KCnRSNEgXdnD?gz5%V#2cvTHSKGz;fNmN=ED)ucLU{q?l|smZ1=*2
zo~*3O7ygizG1oajVoYaGh4)0G&6{`;PIYquW*Rn#ofJhUxwXLXs?ygsx8J;q*_Kc{
zWq^r55wLJXhgVkN!q=vNrJH#!Zn;qlwvZG8U#r37;IS|a1cSA84rrQYG*i`d#6^uP
zPDy0Nb;`YVLDei~{(<BUZtB~MvGW;ZE{@^a?i;|6=T6$BI(H4J0TZQJ&f@dtdSXpC
z&0wT8)R?%39f(B2m30otid+?J(x55kUQzW8<>(fjTl}2rW>)xb+hV6pkP^Uf`WC!#
z;uVZ9okdT}Fm-TR538g>nX0?+u^kcdHS0-k5p!lm&#**^2|KQIjL=bC=Riv`nP&mT
z)lS(EN>gB)nu<(3EeS?~Xk>0(P~l2+Z3{|)L6W&W(t()Vg}2A{Bj=n)YhV++aG8oD
zOe+b)xIi29;}&rv!p&<zr*M12DjPP+)->xpDK4*bpu2nH0uLxno>vLCoIaa6Ek~H0
zaSgXu<+bIwkmC=^IxyI_jidqayP4OadzPpMIuVd*LL+u!gAMxIuymlwa1DZ3<-FCx
zrHpV^O}52tWQ{MAuq<JCcz9`@12op<NF<4c7&Ts+1w3BTH&yDcHKs2|V3k|F<hYNt
z#I<M!)OCR!U3a2HOV3h*^JW&IfFD66?s7Xpgz?K+*6pgek88<=q9ji45pGk1fF8>f
zRpq&ZrHHH3XgcHJYi^wb)6-MgI1MuV02Xzu)4inI>s4~1Ob}8U1?ad%$4Pdt6?cxN
zhi>iIiL;Z(P^M=smS)8a&=TmR8QH?j$|Aln@~?Pg-}A^Vt-w)S?bD3E)F^Hy2bqjE
zD<tl8g0qJ|b7c*)SX)~gCN588*J+`VkzG?Szw%0hg&gvfRH9zO%1jPZm#b)PrD<!$
z?WmC5Of%~w174C3x_CfuWIKL5{zK8kW){YYn}N>w0M0EOC4CPJwSNp-+jpVcvmM8$
zeuvf|_n*}^;6jsrCMAveX$zB?IcypV)7<T&AW-IrlWC6DKl^8&S?>VNz4NwZ<%xl!
zbt)9*`A_a0L4ICE&+t5wgNvl$X+l#}!5SXLi@Cx$j!nLY&R7b&uf2!LE`*cQ2N91G
zjFx7^x}AwZEN6i0`Z~}Vb?1=3+WR&9=gF_5HJ%p5YFd_{N@}4b22E&w6U#;`wmGd(
zJS#Mpm#H2Vp+cz!bEnUo&J7F>u61B^bhJc+{CK%i+0Hb|I|L7X^{;W=b=Tp4JMskh
zzklv-{NugfL0@t^CdcM5n)x--w05qzf<k>!WPpY^43R!k>wf&~<!A7jyS{|oH{9cf
zoHJm#CpTq=I%iY;W2!ltF<yA#c|7sNlL9(?&_s6e{nMxG_uO;uS_f{w{dSshD`%&s
zrnd7IM@M@akAM3qymvB-RKJbZ8yAHt<>(uAUF_5R+ZTU^r@#0P#wRY}{>OLYa_J1L
zcn)zI-sejO^0hg1#Cu7K10Ns!1gx?L-~8p@;HAtH@Ri$KQOHH?NFOv%*=4B3lSvwG
zJZMjQ(cRTS@S&_MFXQ^_uOEN#!3WpR1Lx15Bb?RG^Tw3BxuwyNUc%_n!_a0V?ASU_
z!w0bgu^?->DPrPZo%<oau=8v9!T#^yXQ$u5uDvo^BtML*+lc0h<7kO&LzupQeb1f9
z96N(5@$95?iYWM$C{C7TO_pS~_E6y)1l4n+11J-ted3NmM1x7uXTqT{L+JfOhu#72
z53O+^9F8EJZa*@1VN67#T&|*bQx`h=dr&Hv@R81s^cQHR)f!#8ir-xNIa;b)ap2TX
zaq|dKFHLZD+Y!cB$<a#-XVBBS3th1=T9qFB;@r33kb!Y61v}6Xk&Ziah$6NfF{&=l
zBoJ`guwhU_DiIUWw;LjIWB0~)+1B-Pn&^e_^)A7u!aHqsDy&#Igtl~qxZOeiQi!OO
z#yLq7%n|nmFu9Py-@Wu{csJ#c@P(;M+QJyOqY^bQT_h^@;JVH|uxcJ0zjP4c7(L5U
zg~7~ryq-HMesEoy3c?`RMS>o&r^Er(smVs8(LyX9Kfw}xodaCB(MV)E5{(=;bVH~?
z-W}`OK+#g_C|-)9UZMxfE<8-iH*per`aB56Wa!nVW^#4AM{ymfm<5dV-b++Gg9{7C
zh@rfK&z+`Jx1H53Pe~=!R3PY1hoSl@fDzv+z4C@zB9S;j=iG-nz}QcvQs~~e@jy*%
zDNq9=?(OYrM>4HpDdR_RJPbKdb(w=P%L8~_rH+Z!Iai1@3I_E+Of6<$PzC?V&iirv
z^dZbw=MeNqP&X}czmU*;v*_m3%zRZ8FGo<E@gbH_&=L(1d|V+|E|;-m$BtL%#Kw&q
z*E`VJ*-6c+6~mi{UkZ=}U>FglqO-jf{e5YgZ*?r4Os&oZYq~PC2pKMOcNSevf?t!7
zqi$R_^6-Ww96SFGSlOvG>A2IP*raUqVY*YLS%X+Ee=a33-_{aDIOrG7a>RL;JRXnl
zzkK;JR#sNlJK*jZm(kbPcXD80@R$50(O~F7+pq0KC}m^rcoZw=qfo<D(HR}b-FX(0
z+u^OpI;mHCRjn3lB>^LS9vIJ^M35SsPGg(Xz`xBI6tbCWD_BAPLL9mCAw(%oovksk
zDa_3)Pqu8`y7k3v+qRuA6bfs4(Fa;+W*QTd6PTNyLw|q&x0`z}#bOx)n>w*$TQ`<-
z70e#o2+JS^<S|7T<;Zgb64KgnmlX4_qoP7ktr$gQXO=Kq&L9@|i9#208jT{Rj40H3
zc{6MNa3^YY13g`FB#F5=6I?H>UwV3bo)H0R^ow;4<Q5mPl*{2_CPUR8d9}N%>u8lA
z#5Wkag^`=KqMZtM>Rk`AzwU$@GQ@6#P{WNy*%8+vXI0-URkApC>M(M(DFl5%Q9sS!
zi+hO_I2f~UcOo~TVMDiy-i{X3YVJ_QZ*I11-TK@6?z``$NF;)e&JJ;|bAY8ZpFGU)
zp8U|YL*HPJS)G>1krWA>Pu_7oY+n%*ucfj4egbN&Oa{pUE<$F*tp(o2W?aXpl3zUe
z6rTF+gNU^H1;&L8=l23OT<k{*$enD#?6DSj<2r_W+lj$c=v0GD2FyOYMn)dxCdO}x
zBGdySAJzw`iHWnleFG`cFu(WOKQwghi#f^)7wyt=0Xv2UaK~-epin8{(*9nQCxZwi
z4Y3i$HNen8W7|rhYmK_{Vu?gu_blS6Ff82>6P@F@`rn@*dd?&<^*X^)*0E(Uh1Nt=
zY}GS_Ir_KVw(I#@Zn^otFI+g!k{?r3lj2<Wim6hmta(qydT3%|V&jwF`qq1tg%E3>
z08y<<Hvat8gE)R@9_fuf^xrpzQ11$AOGGiI>~5Vn&6$hYC?K@H)4;968wAAn-iVI;
z`4&vP(u;h-z~;?iTtC!9ddv_REK;%3GKI$;du&T@Z|`JtTD%f5Ydi53ClAq8;$7m~
zi92`i{_5hwg4+#s9ZVc|-+2qR+?c@Z<r>cZbO7^5((r{XVyBvjKMkY=J$HhWcq5lZ
z8r=O<HB^O0{b}xS2Qn}82;+PEd>HQO0Pk1vD=03G1**wU|LLb6WaAk|&EjpI>oUOU
zx%%)@)Oqgx$+JKC>6z1KKg(}#7?=G1fH$;Wc<mh=AI&15gt6heB9b>wBivmSkRt|#
zWP0KE1S#Tm8ggtg<8m|1ooL6xxey$!hM{X>80u~lj<S5={=)&=vv=?J9(dq^FR^_b
zxT_D66>A;f7iw1@>?yzMBpItd`skyhOG``JTarnU;t0*Al5FG9iT81M^a57qWW-xE
zB(BLL+`EiGx(rWDhffKjp7*1i4UzFN8g9a<lr6;D91QoT(L++DSgKOwEit^)GmuOs
z-g@}qf7wOR$Nc<!V@Gc7!FH{2VD;7L)$d^&xSN@oPCWheGlyoTrw8Nlgu64%Lw3-I
z>A3|QK5+(PnMIVB`TeqnNZLfGqYR(NkIHNqg?UXdRWwP8vNMVvqRNopCos<q(<BE9
zg*-YoY&fy=)}1?VzWJ6Kzuac{eAp4^vF}O;ICa18V0z7Fv#}?hc;e03Y<5eMBH-?n
z*_4d{WrcKpb^&7(vzVODQPb0*S7d}EBnhH2jjSqKlOeRHVn{?nsMcu4)(ycn2I-yo
zg?WBYee{v9fBlX`JW*xXd|2eKwC5u`AT{0_<cS&HeDgP$o16Rb=;*P}^S#_$#QmgK
z!l9tpepxOQF}JXS`K3H%D1byXK;lj&Y3!rH#1wF`E%${7O9MXdy8Esthc|D2;MQA5
z1Or~(aj!m_N<qa(J;(u8guLj!eV@8Nm1=$Y(4j-$&n@NxtYrDUSAnz?Gl-AIs{V}~
z=<804`)V}Z@b5}heHE%Tig2-5BJI_?LK^4uTefU@iK&vG!49y-`Y6Xocc768&J-6K
ziL2+*?dcbf9zOEVm&V8cfj;wa5qf<-5!p($=Du%kyoGPl9~;PcPo-KaJ9pml$i991
zzFDbO?a9lNVvPNLu=#&Ga7CvRX(w1!`v(RdP9&56PV@6$W@obZ6TF7FDY681_gEE_
zN+m&sEKx}9o*En&_yJ8;-`~)&A<wU$@+<kq4z%=tHsud<KyvqHIUD?sH7N4A;o;$L
z&`j|Sg2DFbsp)+Siwn0dEG&Lp(^M;+ZaYG}eUN1Ot6rb?4F5BpsmUoJ9+S*yl7v6>
c_#XiV0CwRC6nI;bQ~&?~07*qoM6N<$g4ErHNdN!<
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/content/about-studies/shield-studies.js
@@ -0,0 +1,148 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+/* global classnames FxButton InfoBox PropTypes r React remoteValues sendPageEvent */
+
+window.ShieldStudies = class ShieldStudies extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      learnMoreHref: null,
+    };
+  }
+
+  componentDidMount() {
+    remoteValues.ShieldLearnMoreHref.subscribe(this);
+  }
+
+  componentWillUnmount() {
+    remoteValues.ShieldLearnMoreHref.unsubscribe(this);
+  }
+
+  receiveRemoteValue(name, value) {
+    this.setState({
+      learnMoreHref: value,
+    });
+  }
+
+  render() {
+    return (
+      r("div", {},
+        r(InfoBox, {},
+          r("span", {}, "What's this? Firefox may install and run studies from time to time."),
+          r("a", {id: "shield-studies-learn-more", href: this.state.learnMoreHref}, "Learn more"),
+          r(UpdatePreferencesButton, {}, "Update Preferences"),
+        ),
+        r(StudyList),
+      )
+    );
+  }
+};
+
+class UpdatePreferencesButton extends React.Component {
+  constructor(props) {
+    super(props);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleClick() {
+    sendPageEvent("NavigateToDataPreferences");
+  }
+
+  render() {
+    return r(
+      FxButton,
+      Object.assign({
+        id: "shield-studies-update-preferences",
+        onClick: this.handleClick,
+      }, this.props),
+    );
+  }
+}
+
+class StudyList extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      studies: [],
+    };
+  }
+
+  componentDidMount() {
+    remoteValues.StudyList.subscribe(this);
+  }
+
+  componentWillUnmount() {
+    remoteValues.StudyList.unsubscribe(this);
+  }
+
+  receiveRemoteValue(name, value) {
+    const studies = value.slice();
+
+    // Sort by active status, then by start date descending.
+    studies.sort((a, b) => {
+      if (a.active !== b.active) {
+        return a.active ? -1 : 1;
+      }
+      return b.studyStartDate - a.studyStartDate;
+    });
+
+    this.setState({studies});
+  }
+
+  render() {
+    return (
+      r("ul", {className: "study-list"},
+        this.state.studies.map(study => (
+          r(StudyListItem, {key: study.name, study})
+        ))
+      )
+    );
+  }
+}
+
+class StudyListItem extends React.Component {
+  constructor(props) {
+    super(props);
+    this.handleClickRemove = this.handleClickRemove.bind(this);
+  }
+
+  handleClickRemove() {
+    sendPageEvent("RemoveStudy", this.props.study.recipeId);
+  }
+
+  render() {
+    const study = this.props.study;
+    return (
+      r("li", {
+        className: classnames("study", {disabled: !study.active}),
+        "data-study-name": study.name,
+      },
+        r("div", {className: "study-icon"},
+          study.name.slice(0, 1)
+        ),
+        r("div", {className: "study-details"},
+          r("div", {className: "study-name"}, study.name),
+          r("div", {className: "study-description", title: study.description},
+            r("span", {className: "study-status"}, study.active ? "Active" : "Complete"),
+            r("span", {}, "\u2022"), // &bullet;
+            r("span", {}, study.description),
+          ),
+        ),
+        r("div", {className: "study-actions"},
+          study.active &&
+            r(FxButton, {className: "remove-button", onClick: this.handleClickRemove}, "Remove"),
+        ),
+      )
+    );
+  }
+}
+StudyListItem.propTypes = {
+  study: PropTypes.shape({
+    recipeId: PropTypes.number.isRequired,
+    name: PropTypes.string.isRequired,
+    active: PropTypes.boolean,
+    description: PropTypes.string.isRequired,
+  }).isRequired,
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/content/shield-content-frame.js
@@ -0,0 +1,125 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+/**
+ * Listen for DOM events bubbling up from the about:studies page, and perform
+ * privileged actions in response to them. If we need to do anything that the
+ * content process can't handle (such as reading IndexedDB), we send a message
+ * to the parent process and handle it there.
+ *
+ * This file is loaded as a frame script. It will be loaded once per tab that
+ * is opened.
+ */
+
+/* global content addMessageListener removeMessageListener sendAsyncMessage */
+
+const { utils: Cu } = Components;
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+const frameGlobal = {};
+XPCOMUtils.defineLazyModuleGetter(
+  frameGlobal, "AboutPages", "resource://shield-recipe-client-content/AboutPages.jsm",
+);
+
+const USE_OLD_PREF_ORGANIZATION_PREF = "browser.preferences.useOldOrganization";
+
+/**
+ * Handles incoming events from the parent process and about:studies.
+ * @implements nsIMessageListener
+ * @implements EventListener
+ */
+class ShieldFrameListener {
+  handleEvent(event) {
+    // Abort if the current page isn't about:studies.
+    if (!this.ensureTrustedOrigin()) {
+      return;
+    }
+
+    // We waited until after we received an event to register message listeners
+    // in order to save resources for tabs that don't ever load about:studies.
+    addMessageListener("Shield:ShuttingDown", this);
+    addMessageListener("Shield:ReceiveStudyList", this);
+
+    switch (event.detail.action) {
+      // Actions that require the parent process
+      case "GetRemoteValue:StudyList":
+        sendAsyncMessage("Shield:GetStudyList");
+        break;
+      case "RemoveStudy":
+        sendAsyncMessage("Shield:RemoveStudy", event.detail.data);
+        break;
+      // Actions that can be performed in the content process
+      case "GetRemoteValue:ShieldLearnMoreHref":
+        this.triggerPageCallback(
+          "ReceiveRemoteValue:ShieldLearnMoreHref",
+          frameGlobal.AboutPages.aboutStudies.getShieldLearnMoreHref()
+        );
+        break;
+      case "NavigateToDataPreferences":
+        this.navigateToDataPreferences();
+        break;
+    }
+  }
+
+  /**
+   * Check that the current webpage's origin is about:studies.
+   * @return {Boolean}
+   */
+  ensureTrustedOrigin() {
+    return content.document.documentURI.startsWith("about:studies");
+  }
+
+  /**
+   * Handle messages from the parent process.
+   * @param {Object} message
+   *   See the nsIMessageListener docs.
+   */
+  receiveMessage(message) {
+    switch (message.name) {
+      case "Shield:ReceiveStudyList":
+        this.triggerPageCallback("ReceiveRemoteValue:StudyList", message.data.studies);
+        break;
+      case "Shield:ShuttingDown":
+        this.onShutdown();
+        break;
+    }
+  }
+
+  /**
+   * Trigger an event to communicate with the unprivileged about: page.
+   * @param {String} type
+   * @param {Object} detail
+   */
+  triggerPageCallback(type, detail) {
+    // Do not communicate with untrusted pages.
+    if (!this.ensureTrustedOrigin()) {
+      return;
+    }
+
+    // Clone details and use the event class from the unprivileged context.
+    const event = new content.document.defaultView.CustomEvent(type, {
+      bubbles: true,
+      detail: Cu.cloneInto(detail, content.document.defaultView),
+    });
+    content.document.dispatchEvent(event);
+  }
+
+  onShutdown() {
+    removeMessageListener("Shield:SendStudyList", this);
+    removeMessageListener("Shield:ShuttingDown", this);
+    removeEventListener("Shield", this);
+  }
+
+  navigateToDataPreferences() {
+    if (Services.prefs.getBoolPref(USE_OLD_PREF_ORGANIZATION_PREF)) {
+      sendAsyncMessage("Shield:OpenOldDataPreferences");
+    } else {
+      content.location = "about:preferences#privacy-reports";
+    }
+  }
+}
+
+addEventListener("ShieldPageEvent", new ShieldFrameListener(), false, true);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/content/shield-content-process.js
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+/**
+ * Registers about: pages provided by Shield, and listens for a shutdown event
+ * from the add-on before un-registering them.
+ *
+ * This file is loaded as a process script. It is executed once for each
+ * process, including the parent one.
+ */
+
+const { utils: Cu } = Components;
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://shield-recipe-client-content/AboutPages.jsm");
+
+class ShieldChildListener {
+  onStartup() {
+    Services.cpmm.addMessageListener("Shield:ShuttingDown", this, true);
+    AboutPages.aboutStudies.register();
+  }
+
+  onShutdown() {
+    AboutPages.aboutStudies.unregister();
+    Services.cpmm.removeMessageListener("Shield:ShuttingDown", this);
+
+    // Unload AboutPages.jsm in case the add-on is reinstalled and we need to
+    // load a new version of it.
+    Cu.unload("resource://shield-recipe-client-content/AboutPages.jsm");
+  }
+
+  receiveMessage(message) {
+    switch (message.name) {
+      case "Shield:ShuttingDown":
+        this.onShutdown();
+        break;
+    }
+  }
+}
+
+// Only register in content processes; the parent process handles registration
+// separately.
+if (Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_CONTENT) {
+  const listener = new ShieldChildListener();
+  listener.onStartup();
+}
--- a/browser/extensions/shield-recipe-client/install.rdf.in
+++ b/browser/extensions/shield-recipe-client/install.rdf.in
@@ -3,17 +3,17 @@
 #filter substitution
 
 <RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:em="http://www.mozilla.org/2004/em-rdf#">
   <Description about="urn:mozilla:install-manifest">
     <em:id>shield-recipe-client@mozilla.org</em:id>
     <em:type>2</em:type>
     <em:bootstrap>true</em:bootstrap>
     <em:unpack>false</em:unpack>
-    <em:version>55.1</em:version>
+    <em:version>65</em:version>
     <em:name>Shield Recipe Client</em:name>
     <em:description>Client to download and run recipes for SHIELD, Heartbeat, etc.</em:description>
     <em:multiprocessCompatible>true</em:multiprocessCompatible>
 
     <em:targetApplication>
       <Description>
         <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id>
         <em:minVersion>@MOZ_APP_VERSION@</em:minVersion>
--- a/browser/extensions/shield-recipe-client/jar.mn
+++ b/browser/extensions/shield-recipe-client/jar.mn
@@ -1,9 +1,13 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 [features/shield-recipe-client@mozilla.org] chrome.jar:
-% resource shield-recipe-client %content/
-  content/lib/ (./lib/*)
-  content/node_modules/jexl/ (./node_modules/jexl/*)
-  content/skin/  (skin/*)
+% resource shield-recipe-client %
+  lib/ (./lib/*)
+  data/ (./data/*)
+  skin/  (skin/*)
+% resource shield-recipe-client-content %content/
+  content/ (./content/*)
+% resource shield-recipe-client-vendor %vendor/
+  vendor/ (./vendor/*)
--- a/browser/extensions/shield-recipe-client/lib/ActionSandboxManager.jsm
+++ b/browser/extensions/shield-recipe-client/lib/ActionSandboxManager.jsm
@@ -67,18 +67,14 @@ this.ActionSandboxManager = class extend
   async runAsyncCallback(callbackName, ...args) {
     const callbackWasRegistered = this.evalInSandbox(`asyncCallbacks.has("${callbackName}")`);
     if (!callbackWasRegistered) {
       log.debug(`Script did not register a callback with the name "${callbackName}"`);
       return undefined;
     }
 
     this.cloneIntoGlobal("callbackArgs", args);
-    try {
-      const result = await this.evalInSandbox(`
-        asyncCallbacks.get("${callbackName}")(sandboxedDriver, ...callbackArgs);
-      `);
-      return Cu.cloneInto(result, {});
-    } catch (err) {
-      throw new Error(Cu.cloneInto(err.message, {}));
-    }
+    const result = await this.evalInSandbox(`
+      asyncCallbacks.get("${callbackName}")(sandboxedDriver, ...callbackArgs);
+    `);
+    return Cu.cloneInto(result, {});
   }
 };
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/AddonStudies.jsm
@@ -0,0 +1,324 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+/**
+ * @typedef {Object} Study
+ * @property {Number} recipeId
+ *   ID of the recipe that created the study. Used as the primary key of the
+ *   study.
+ * @property {string} name
+ *   Name of the study
+ * @property {string} description
+ *   Description of the study and its intent.
+ * @property {boolean} active
+ *   Is the study still running?
+ * @property {string} addonId
+ *   Add-on ID for this particular study.
+ * @property {string} addonUrl
+ *   URL that the study add-on was installed from.
+ * @property {string} addonVersion
+ *   Study add-on version number
+ * @property {string} studyStartDate
+ *   Date when the study was started.
+ * @property {Date} studyEndDate
+ *   Date when the study was ended.
+ */
+
+const {utils: Cu, interfaces: Ci} = Components;
+Cu.import("resource://gre/modules/osfile.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", "resource://gre/modules/FileUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "IndexedDB", "resource://gre/modules/IndexedDB.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", "resource://gre/modules/AddonManager.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Addons", "resource://shield-recipe-client/lib/Addons.jsm");
+XPCOMUtils.defineLazyModuleGetter(
+  this, "CleanupManager", "resource://shield-recipe-client/lib/CleanupManager.jsm"
+);
+XPCOMUtils.defineLazyModuleGetter(this, "LogManager", "resource://shield-recipe-client/lib/LogManager.jsm");
+
+Cu.importGlobalProperties(["fetch"]); /* globals fetch */
+
+this.EXPORTED_SYMBOLS = ["AddonStudies"];
+
+const DB_NAME = "shield";
+const STORE_NAME = "addon-studies";
+const DB_OPTIONS = {
+  version: 1,
+  storage: "persistent",
+};
+const STUDY_ENDED_TOPIC = "shield-study-ended";
+const log = LogManager.getLogger("addon-studies");
+
+/**
+ * Create a new connection to the database.
+ */
+function openDatabase() {
+  return IndexedDB.open(DB_NAME, DB_OPTIONS, db => {
+    db.createObjectStore(STORE_NAME, {
+      keyPath: "recipeId",
+    });
+  });
+}
+
+/**
+ * Cache the database connection so that it is shared among multiple operations.
+ */
+let databasePromise;
+async function getDatabase() {
+  if (!databasePromise) {
+    databasePromise = openDatabase();
+  }
+  return databasePromise;
+}
+
+/**
+ * Get a transaction for interacting with the study store.
+ *
+ * NOTE: Methods on the store returned by this function MUST be called
+ * synchronously, otherwise the transaction with the store will expire.
+ * This is why the helper takes a database as an argument; if we fetched the
+ * database in the helper directly, the helper would be async and the
+ * transaction would expire before methods on the store were called.
+ */
+function getStore(db) {
+  return db.objectStore(STORE_NAME, "readwrite");
+}
+
+/**
+ * Mark a study object as having ended. Modifies the study in-place.
+ * @param {IDBDatabase} db
+ * @param {Study} study
+ */
+async function markAsEnded(db, study) {
+  study.active = false;
+  study.studyEndDate = new Date();
+  await getStore(db).put(study);
+  Services.obs.notifyObservers(study, STUDY_ENDED_TOPIC);
+}
+
+this.AddonStudies = {
+  /**
+   * Test wrapper that temporarily replaces the stored studies with the given
+   * ones. The original stored studies are restored upon completion.
+   *
+   * This is defined here instead of in test code since it needs to access the
+   * getDatabase, which we don't expose to avoid outside modules relying on the
+   * type of storage used for studies.
+   *
+   * @param {Array} [studies=[]]
+   */
+  withStudies(studies = []) {
+    return function wrapper(testFunction) {
+      return async function wrappedTestFunction(...args) {
+        const oldStudies = await AddonStudies.getAll();
+        let db = await getDatabase();
+        await AddonStudies.clear();
+        for (const study of studies) {
+          await getStore(db).add(study);
+        }
+
+        try {
+          await testFunction(...args, studies);
+        } finally {
+          db = await getDatabase(); // Re-acquire in case the test closed the connection.
+          await AddonStudies.clear();
+          for (const study of oldStudies) {
+            await getStore(db).add(study);
+          }
+
+          await AddonStudies.close();
+        }
+      };
+    };
+  },
+
+  async init() {
+    // If an active study's add-on has been removed since we last ran, stop the
+    // study.
+    const activeStudies = (await this.getAll()).filter(study => study.active);
+    const db = await getDatabase();
+    for (const study of activeStudies) {
+      const addon = await AddonManager.getAddonByID(study.addonId);
+      if (!addon) {
+        await markAsEnded(db, study);
+      }
+    }
+    await this.close();
+
+    // Listen for add-on uninstalls so we can stop the corresponding studies.
+    AddonManager.addAddonListener(this);
+    CleanupManager.addCleanupHandler(() => {
+      AddonManager.removeAddonListener(this);
+    });
+  },
+
+  /**
+   * If a study add-on is uninstalled, mark the study as having ended.
+   * @param {Addon} addon
+   */
+  async onUninstalled(addon) {
+    const activeStudies = (await this.getAll()).filter(study => study.active);
+    const matchingStudy = activeStudies.find(study => study.addonId === addon.id);
+    if (matchingStudy) {
+      // Use a dedicated DB connection instead of the shared one so that we can
+      // close it without fear of affecting other users of the shared connection.
+      const db = await openDatabase();
+      await markAsEnded(db, matchingStudy);
+      await db.close();
+    }
+  },
+
+  /**
+   * Remove all stored studies.
+   */
+  async clear() {
+    const db = await getDatabase();
+    await getStore(db).clear();
+  },
+
+  /**
+   * Close the current database connection if it is open.
+   */
+  async close() {
+    if (databasePromise) {
+      const promise = databasePromise;
+      databasePromise = null;
+      const db = await promise;
+      await db.close();
+    }
+  },
+
+  /**
+   * Test whether there is a study in storage for the given recipe ID.
+   * @param {Number} recipeId
+   * @returns {Boolean}
+   */
+  async has(recipeId) {
+    const db = await getDatabase();
+    const study = await getStore(db).get(recipeId);
+    return !!study;
+  },
+
+  /**
+   * Fetch a study from storage.
+   * @param {Number} recipeId
+   * @return {Study}
+   */
+  async get(recipeId) {
+    const db = await getDatabase();
+    return getStore(db).get(recipeId);
+  },
+
+  /**
+   * Fetch all studies in storage.
+   * @return {Array<Study>}
+   */
+  async getAll() {
+    const db = await getDatabase();
+    return getStore(db).getAll();
+  },
+
+  /**
+   * Start a new study. Installs an add-on and stores the study info.
+   * @param {Object} options
+   * @param {Number} options.recipeId
+   * @param {String} options.name
+   * @param {String} options.description
+   * @param {String} options.addonUrl
+   * @throws
+   *   If any of the required options aren't given.
+   *   If a study for the given recipeID already exists in storage.
+   *   If add-on installation fails.
+   */
+  async start({recipeId, name, description, addonUrl}) {
+    if (!recipeId || !name || !description || !addonUrl) {
+      throw new Error("Required arguments (recipeId, name, description, addonUrl) missing.");
+    }
+
+    const db = await getDatabase();
+    if (await getStore(db).get(recipeId)) {
+      throw new Error(`A study for recipe ${recipeId} already exists.`);
+    }
+
+    const addonFile = await this.downloadAddonToTemporaryFile(addonUrl);
+    const install = await AddonManager.getInstallForFile(addonFile);
+    const study = {
+      recipeId,
+      name,
+      description,
+      addonId: install.addon.id,
+      addonVersion: install.addon.version,
+      addonUrl,
+      active: true,
+      studyStartDate: new Date(),
+    };
+
+    try {
+      await getStore(db).add(study);
+      await Addons.applyInstall(install, false);
+      return study;
+    } catch (err) {
+      await getStore(db).delete(recipeId);
+      throw err;
+    } finally {
+      Services.obs.notifyObservers(addonFile, "flush-cache-entry");
+      await OS.File.remove(addonFile.path);
+    }
+  },
+
+  /**
+   * Download a remote add-on and store it in a temporary nsIFile.
+   * @param {String} addonUrl
+   * @returns {nsIFile}
+   */
+  async downloadAddonToTemporaryFile(addonUrl) {
+    const response = await fetch(addonUrl);
+    if (!response.ok) {
+      throw new Error(`Download for ${addonUrl} failed: ${response.status} ${response.statusText}`);
+    }
+
+    // Create temporary file to store add-on.
+    const path = OS.Path.join(OS.Constants.Path.tmpDir, "study.xpi");
+    const {file, path: uniquePath} = await OS.File.openUnique(path);
+
+    // Write the add-on to the file
+    try {
+      const xpiArrayBufferView = new Uint8Array(await response.arrayBuffer());
+      await file.write(xpiArrayBufferView);
+    } finally {
+      await file.close();
+    }
+
+    return new FileUtils.File(uniquePath);
+  },
+
+  /**
+   * Stop an active study, uninstalling the associated add-on.
+   * @param {Number} recipeId
+   * @throws
+   *   If no study is found with the given recipeId.
+   *   If the study is already inactive.
+   */
+  async stop(recipeId) {
+    const db = await getDatabase();
+    const study = await getStore(db).get(recipeId);
+    if (!study) {
+      throw new Error(`No study found for recipe ${recipeId}`);
+    }
+    if (!study.active) {
+      throw new Error(`Cannot stop study for recipe ${recipeId}; it is already inactive.`);
+    }
+
+    await markAsEnded(db, study);
+
+    try {
+      await Addons.uninstall(study.addonId);
+    } catch (err) {
+      log.warn(`Could not uninstall addon ${study.addonId} for recipe ${study.recipeId}:`, err);
+    }
+  },
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/Addons.jsm
@@ -0,0 +1,135 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", "resource://gre/modules/AddonManager.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Extension", "resource://gre/modules/Extension.jsm");
+XPCOMUtils.defineLazyModuleGetter(
+  this, "CleanupManager", "resource://shield-recipe-client/lib/CleanupManager.jsm"
+);
+
+this.EXPORTED_SYMBOLS = ["Addons"];
+
+/**
+ * SafeAddons store info about an add-on. They are single-depth
+ * objects to simplify cloning, and have no methods so they are safe
+ * to pass to sandboxes and filter expressions.
+ *
+ * @typedef {Object} SafeAddon
+ * @property {string} id
+ *   Add-on id, such as "shield-recipe-client@mozilla.com" or "{4ea51ac2-adf2-4af8-a69d-17b48c558a12}"
+ * @property {Date} installDate
+ * @property {boolean} isActive
+ * @property {string} name
+ * @property {string} type
+ *   "extension", "theme", etc.
+ * @property {string} version
+ */
+
+this.Addons = {
+  /**
+   * Get information about an installed add-on by ID.
+   *
+   * @param {string} addonId
+   * @returns {SafeAddon?} Add-on with given ID, or null if not found.
+   * @throws If addonId is not specified or not a string.
+   */
+  async get(addonId) {
+    const addon = await AddonManager.getAddonByID(addonId);
+    if (!addon) {
+      return null;
+    }
+    return this.serializeForSandbox(addon);
+  },
+
+  /**
+   * Get information about all installed add-ons.
+   * @async
+   * @returns {Array<SafeAddon>}
+   */
+  async getAll(addonId) {
+    const addons = await AddonManager.getAllAddons();
+    return addons.map(this.serializeForSandbox.bind(this));
+  },
+
+  /**
+   * Installs an add-on
+   *
+   * @param {string} addonUrl
+   *   Url to download the .xpi for the add-on from.
+   * @param {object} options
+   * @param {boolean} options.update=false
+   *   If true, will update an existing installed add-on with the same ID.
+   * @async
+   * @returns {string}
+   *   Add-on ID that was installed
+   * @throws {string}
+   *   If the add-on can not be installed, or overwriting is disabled and an
+   *   add-on with a matching ID is already installed.
+   */
+  async install(addonUrl, options) {
+    const installObj = await AddonManager.getInstallForURL(addonUrl, null, "application/x-xpinstall");
+    return this.applyInstall(installObj, options);
+  },
+
+  async applyInstall(addonInstall, {update = false} = {}) {
+    const result = new Promise((resolve, reject) => addonInstall.addListener({
+      onInstallStarted(cbInstall) {
+        if (cbInstall.existingAddon && !update) {
+          reject(new Error(`
+            Cannot install add-on ${cbInstall.addon.id}; an existing add-on
+            with the same ID exists and updating is disabled.
+          `));
+          return false;
+        }
+        return true;
+      },
+      onInstallEnded(cbInstall, addon) {
+        resolve(addon.id);
+      },
+      onInstallFailed(cbInstall) {
+        reject(new Error(`AddonInstall error code: [${cbInstall.error}]`));
+      },
+      onDownloadFailed(cbInstall) {
+        reject(new Error(`Download failed: [${cbInstall.sourceURI.spec}]`));
+      },
+    }));
+    addonInstall.install();
+    return result;
+  },
+
+  /**
+   * Uninstalls an add-on by ID.
+   * @param addonId {string} Add-on ID to uninstall.
+   * @async
+   * @throws If no add-on with `addonId` is installed.
+   */
+  async uninstall(addonId) {
+    const addon = await AddonManager.getAddonByID(addonId);
+    if (addon === null) {
+      throw new Error(`No addon with ID [${addonId}] found.`);
+    }
+    addon.uninstall();
+    return null;
+  },
+
+  /**
+   * Make a safe serialization of an add-on
+   * @param addon {Object} An add-on object as returned from AddonManager.
+   */
+  serializeForSandbox(addon) {
+    return {
+      id: addon.id,
+      installDate: new Date(addon.installDate),
+      isActive: addon.isActive,
+      name: addon.name,
+      type: addon.type,
+      version: addon.version,
+    };
+  },
+};
--- a/browser/extensions/shield-recipe-client/lib/ClientEnvironment.jsm
+++ b/browser/extensions/shield-recipe-client/lib/ClientEnvironment.jsm
@@ -11,19 +11,20 @@ Cu.import("resource://gre/modules/XPCOMU
 
 XPCOMUtils.defineLazyModuleGetter(this, "ShellService", "resource:///modules/ShellService.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", "resource://gre/modules/AddonManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "TelemetryArchive", "resource://gre/modules/TelemetryArchive.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "NormandyApi", "resource://shield-recipe-client/lib/NormandyApi.jsm");
 XPCOMUtils.defineLazyModuleGetter(
     this,
     "PreferenceExperiments",
-    "resource://shield-recipe-client/lib/PreferenceExperiments.jsm",
+    "resource://shield-recipe-client/lib/PreferenceExperiments.jsm"
 );
 XPCOMUtils.defineLazyModuleGetter(this, "Utils", "resource://shield-recipe-client/lib/Utils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Addons", "resource://shield-recipe-client/lib/Addons.jsm");
 
 const {generateUUID} = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
 
 this.EXPORTED_SYMBOLS = ["ClientEnvironment"];
 
 // Cached API request for client attributes that are determined by the Normandy
 // service.
 let _classifyRequest = null;
@@ -31,17 +32,17 @@ let _classifyRequest = null;
 this.ClientEnvironment = {
   /**
    * Fetches information about the client that is calculated on the server,
    * like geolocation and the current time.
    *
    * The server request is made lazily and is cached for the entire browser
    * session.
    */
-  getClientClassification() {
+  async getClientClassification() {
     if (!_classifyRequest) {
       _classifyRequest = NormandyApi.classifyClient();
     }
     return _classifyRequest;
   },
 
   clearClassifyCache() {
     _classifyRequest = null;
@@ -192,15 +193,20 @@ this.ClientEnvironment = {
         } else {
           names.active.push(experiment.name);
         }
       }
 
       return names;
     });
 
+    XPCOMUtils.defineLazyGetter(environment, "addons", async () => {
+      const addons = await Addons.getAll();
+      return Utils.keyBy(addons, "id");
+    });
+
     XPCOMUtils.defineLazyGetter(environment, "isFirstRun", () => {
       return Preferences.get("extensions.shield-recipe-client.first_run");
     });
 
     return environment;
   },
 };
--- a/browser/extensions/shield-recipe-client/lib/FilterExpressions.jsm
+++ b/browser/extensions/shield-recipe-client/lib/FilterExpressions.jsm
@@ -4,40 +4,62 @@
 
 "use strict";
 
 const {utils: Cu} = Components;
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://shield-recipe-client/lib/Sampling.jsm");
 Cu.import("resource://shield-recipe-client/lib/PreferenceFilters.jsm");
 
+XPCOMUtils.defineLazyModuleGetter(this, "mozjexl", "resource://shield-recipe-client-vendor/mozjexl.js");
+
 this.EXPORTED_SYMBOLS = ["FilterExpressions"];
 
-XPCOMUtils.defineLazyGetter(this, "nodeRequire", () => {
-  const {Loader, Require} = Cu.import("resource://gre/modules/commonjs/toolkit/loader.js", {});
-  const loader = new Loader({
-    paths: {
-      "": "resource://shield-recipe-client/node_modules/",
-    },
-  });
-  return new Require(loader, {});
-});
-
 XPCOMUtils.defineLazyGetter(this, "jexl", () => {
-  const {Jexl} = nodeRequire("jexl/lib/Jexl.js");
-  const jexl = new Jexl();
+  const jexl = new mozjexl.Jexl();
   jexl.addTransforms({
     date: dateString => new Date(dateString),
     stableSample: Sampling.stableSample,
     bucketSample: Sampling.bucketSample,
     preferenceValue: PreferenceFilters.preferenceValue,
     preferenceIsUserSet: PreferenceFilters.preferenceIsUserSet,
     preferenceExists: PreferenceFilters.preferenceExists,
+    keys,
   });
+  jexl.addBinaryOp("intersect", 40, operatorIntersect);
   return jexl;
 });
 
 this.FilterExpressions = {
   eval(expr, context = {}) {
     const onelineExpr = expr.replace(/[\t\n\r]/g, " ");
     return jexl.eval(onelineExpr, context);
   },
 };
+
+/**
+ * Return an array of the given object's own keys (specifically, its enumerable
+ * properties), or undefined if the argument isn't an object.
+ * @param {Object} obj
+ * @return {Array[String]|undefined}
+ */
+function keys(obj) {
+  if (typeof obj !== "object" || obj === null) {
+    return undefined;
+  }
+
+  return Object.keys(obj);
+}
+
+/**
+ * Find all the values that are present in both lists. Returns undefined if
+ * the arguments are not both Arrays.
+ * @param {Array} listA
+ * @param {Array} listB
+ * @return {Array|undefined}
+ */
+function operatorIntersect(listA, listB) {
+  if (!Array.isArray(listA) || !Array.isArray(listB)) {
+    return undefined;
+  }
+
+  return listA.filter(item => listB.includes(item));
+}
--- a/browser/extensions/shield-recipe-client/lib/NormandyApi.jsm
+++ b/browser/extensions/shield-recipe-client/lib/NormandyApi.jsm
@@ -1,29 +1,34 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 const {utils: Cu, classes: Cc, interfaces: Ci} = Components;
 Cu.import("resource://gre/modules/Services.jsm");
-Cu.import("resource://gre/modules/CanonicalJSON.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://shield-recipe-client/lib/LogManager.jsm");
-Cu.import("resource://shield-recipe-client/lib/Utils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(
+  this, "CanonicalJSON", "resource://gre/modules/CanonicalJSON.jsm");
+
 Cu.importGlobalProperties(["fetch", "URL"]); /* globals fetch, URL */
 
 this.EXPORTED_SYMBOLS = ["NormandyApi"];
 
 const log = LogManager.getLogger("normandy-api");
 const prefs = Services.prefs.getBranch("extensions.shield-recipe-client.");
 
 let indexPromise = null;
 
 this.NormandyApi = {
+  InvalidSignatureError: class InvalidSignatureError extends Error {},
+
   clearIndexCache() {
     indexPromise = null;
   },
 
   apiCall(method, endpoint, data = {}) {
     const url = new URL(endpoint);
     method = method.toLowerCase();
 
@@ -58,95 +63,157 @@ this.NormandyApi = {
     } else if (url.startsWith("/")) {
       return server + url;
     }
     throw new Error("Can't use relative urls");
   },
 
   async getApiUrl(name) {
     if (!indexPromise) {
-      let apiBase = new URL(prefs.getCharPref("api_url"));
+      const apiBase = new URL(prefs.getCharPref("api_url"));
       if (!apiBase.pathname.endsWith("/")) {
         apiBase.pathname += "/";
       }
       indexPromise = this.get(apiBase.toString()).then(res => res.json());
     }
     const index = await indexPromise;
     if (!(name in index)) {
       throw new Error(`API endpoint with name "${name}" not found.`);
     }
     const url = index[name];
     return this.absolutify(url);
   },
 
-  async fetchRecipes(filters = {enabled: true}) {
-    const signedRecipesUrl = await this.getApiUrl("recipe-signed");
-    const recipesResponse = await this.get(signedRecipesUrl, filters);
-    const rawText = await recipesResponse.text();
-    const recipesWithSigs = JSON.parse(rawText);
+  async fetchSignedObjects(type, filters) {
+    const signedObjectsUrl = await this.getApiUrl(`${type}-signed`);
+    const objectsResponse = await this.get(signedObjectsUrl, filters);
+    const rawText = await objectsResponse.text();
+    const objectsWithSigs = JSON.parse(rawText);
+
+    const verifiedObjects = [];
 
-    const verifiedRecipes = [];
+    for (const objectWithSig of objectsWithSigs) {
+      const {signature, x5u} = objectWithSig.signature;
+      const object = objectWithSig[type];
 
-    for (const {recipe, signature: {signature, x5u}} of recipesWithSigs) {
-      const serialized = CanonicalJSON.stringify(recipe);
+      const serialized = CanonicalJSON.stringify(object);
+      // Check that the rawtext (the object and the signature)
+      // includes the CanonicalJSON version of the object. This isn't
+      // strictly needed, but it is a great benefit for debugging
+      // signature problems.
       if (!rawText.includes(serialized)) {
         log.debug(rawText, serialized);
-        throw new Error("Canonical recipe serialization does not match!");
+        throw new NormandyApi.InvalidSignatureError(
+          `Canonical ${type} serialization does not match!`);
       }
 
-      const certChainResponse = await fetch(this.absolutify(x5u));
+      const certChainResponse = await this.get(this.absolutify(x5u));
       const certChain = await certChainResponse.text();
       const builtSignature = `p384ecdsa=${signature}`;
 
       const verifier = Cc["@mozilla.org/security/contentsignatureverifier;1"]
         .createInstance(Ci.nsIContentSignatureVerifier);
 
-      const valid = verifier.verifyContentSignature(
-        serialized,
-        builtSignature,
-        certChain,
-        "normandy.content-signature.mozilla.org"
-      );
+      let valid;
+      try {
+        valid = verifier.verifyContentSignature(
+          serialized,
+          builtSignature,
+          certChain,
+          "normandy.content-signature.mozilla.org"
+        );
+      } catch (err) {
+        throw new NormandyApi.InvalidSignatureError(`${type} signature validation failed: ${err}`);
+      }
+
       if (!valid) {
-        throw new Error("Recipe signature is not valid");
+        throw new NormandyApi.InvalidSignatureError(`${type} signature is not valid`);
       }
-      verifiedRecipes.push(recipe);
+
+      verifiedObjects.push(object);
     }
 
     log.debug(
-      `Fetched ${verifiedRecipes.length} recipes from the server:`,
-      verifiedRecipes.map(r => r.name).join(", ")
+      `Fetched ${verifiedObjects.length} ${type} from the server:`,
+      verifiedObjects.map(r => r.name).join(", ")
     );
 
-    return verifiedRecipes;
+    return verifiedObjects;
   },
 
   /**
    * Fetch metadata about this client determined by the server.
    * @return {object} Metadata specified by the server
    */
   async classifyClient() {
     const classifyClientUrl = await this.getApiUrl("classify-client");
     const response = await this.get(classifyClientUrl);
     const clientData = await response.json();
     clientData.request_time = new Date(clientData.request_time);
     return clientData;
   },
 
   /**
    * Fetch an array of available actions from the server.
+   * @param filters
+   * @param filters.enabled {boolean} If true, only returns enabled
+   * recipes. Default true.
    * @resolves {Array}
    */
-  async fetchActions() {
-    const actionApiUrl = await this.getApiUrl("action-list");
-    const res = await this.get(actionApiUrl);
-    return res.json();
+  async fetchRecipes(filters = {enabled: true}) {
+    return this.fetchSignedObjects("recipe", filters);
+  },
+
+  /**
+   * Fetch an array of available actions from the server.
+   * @resolves {Array}
+   */
+  async fetchActions(filters = {}) {
+    return this.fetchSignedObjects("action", filters);
   },
 
   async fetchImplementation(action) {
-    const response = await fetch(action.implementation_url);
-    if (response.ok) {
-      return response.text();
+    const implementationUrl = new URL(this.absolutify(action.implementation_url));
+
+    // fetch implementation
+    const response = await fetch(implementationUrl);
+    if (!response.ok) {
+      throw new Error(
+        `Failed to fetch action implementation for ${action.name}: ${response.status}`
+      );
+    }
+    const responseText = await response.text();
+
+    // Try to verify integrity of the implementation text.  If the
+    // integrity value doesn't match the content or uses an unknown
+    // algorithm, fail.
+
+    // Get the last non-empty portion of the url path, and split it
+    // into two to get the aglorithm and hash.
+    const parts = implementationUrl.pathname.split("/");
+    const lastNonEmpty = parts.filter(p => p !== "").slice(-1)[0];
+    const [algorithm, ...hashParts] = lastNonEmpty.split("-");
+    const expectedHash = hashParts.join("-");
+
+    if (algorithm !== "sha384") {
+      throw new Error(
+        `Failed to fetch action implemenation for ${action.name}: ` +
+        `Unexpected integrity algorithm, expected "sha384", got ${algorithm}`
+      );
     }
 
-    throw new Error(`Failed to fetch action implementation for ${action.name}: ${response.status}`);
+    // verify integrity hash
+    const hasher = Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash);
+    hasher.init(hasher.SHA384);
+    const dataToHash = new TextEncoder().encode(responseText);
+    hasher.update(dataToHash, dataToHash.length);
+    const useBase64 = true;
+    const hash = hasher.finish(useBase64).replace(/\+/g, "-").replace(/\//g, "_");
+    if (hash !== expectedHash) {
+      throw new Error(
+        `Failed to fetch action implementation for ${action.name}: ` +
+        `Integrity hash does not match content. Expected ${expectedHash} got ${hash}.`
+      );
+    }
+
+    return responseText;
   },
 };
--- a/browser/extensions/shield-recipe-client/lib/NormandyDriver.jsm
+++ b/browser/extensions/shield-recipe-client/lib/NormandyDriver.jsm
@@ -1,29 +1,34 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Preferences.jsm");
 Cu.import("resource:///modules/ShellService.jsm");
 Cu.import("resource://gre/modules/AddonManager.jsm");
 Cu.import("resource://gre/modules/Timer.jsm");
+Cu.import("resource://shield-recipe-client/lib/Addons.jsm");
 Cu.import("resource://shield-recipe-client/lib/LogManager.jsm");
 Cu.import("resource://shield-recipe-client/lib/Storage.jsm");
 Cu.import("resource://shield-recipe-client/lib/Heartbeat.jsm");
 Cu.import("resource://shield-recipe-client/lib/FilterExpressions.jsm");
 Cu.import("resource://shield-recipe-client/lib/ClientEnvironment.jsm");
 Cu.import("resource://shield-recipe-client/lib/PreferenceExperiments.jsm");
 Cu.import("resource://shield-recipe-client/lib/Sampling.jsm");
 
+XPCOMUtils.defineLazyModuleGetter(
+  this, "AddonStudies", "resource://shield-recipe-client/lib/AddonStudies.jsm");
+
 const {generateUUID} = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
 
 this.EXPORTED_SYMBOLS = ["NormandyDriver"];
 
 const log = LogManager.getLogger("normandy-driver");
 const actionLog = LogManager.getLogger("normandy-driver.actions");
 
 this.NormandyDriver = function(sandboxManager) {
@@ -151,22 +156,74 @@ this.NormandyDriver = function(sandboxMa
       return Cu.cloneInto(token, sandbox);
     },
 
     clearTimeout(token) {
       clearTimeout(token);
       sandboxManager.removeHold(`setTimeout-${token}`);
     },
 
+    addons: {
+      get: sandboxManager.wrapAsync(Addons.get.bind(Addons), {cloneInto: true}),
+      install: sandboxManager.wrapAsync(Addons.install.bind(Addons)),
+      uninstall: sandboxManager.wrapAsync(Addons.uninstall.bind(Addons)),
+    },
+
     // Sampling
     ratioSample: sandboxManager.wrapAsync(Sampling.ratioSample),
 
     // Preference Experiment API
     preferenceExperiments: {
       start: sandboxManager.wrapAsync(PreferenceExperiments.start, {cloneArguments: true}),
       markLastSeen: sandboxManager.wrapAsync(PreferenceExperiments.markLastSeen),
       stop: sandboxManager.wrapAsync(PreferenceExperiments.stop),
       get: sandboxManager.wrapAsync(PreferenceExperiments.get, {cloneInto: true}),
       getAllActive: sandboxManager.wrapAsync(PreferenceExperiments.getAllActive, {cloneInto: true}),
       has: sandboxManager.wrapAsync(PreferenceExperiments.has),
     },
+
+    // Study storage API
+    studies: {
+      start: sandboxManager.wrapAsync(
+        AddonStudies.start.bind(AddonStudies),
+        {cloneArguments: true, cloneInto: true}
+      ),
+      stop: sandboxManager.wrapAsync(AddonStudies.stop.bind(AddonStudies)),
+      get: sandboxManager.wrapAsync(AddonStudies.get.bind(AddonStudies), {cloneInto: true}),
+      getAll: sandboxManager.wrapAsync(AddonStudies.getAll.bind(AddonStudies), {cloneInto: true}),
+      has: sandboxManager.wrapAsync(AddonStudies.has.bind(AddonStudies)),
+    },
+
+    // Preference read-only API
+    preferences: {
+      getBool: wrapPrefGetter(Services.prefs.getBoolPref),
+      getInt: wrapPrefGetter(Services.prefs.getIntPref),
+      getChar: wrapPrefGetter(Services.prefs.getCharPref),
+      has(name) {
+        return Services.prefs.getPrefType(name) !== Services.prefs.PREF_INVALID;
+      },
+    },
   };
 };
+
+/**
+ * Wrap a getter form nsIPrefBranch for use in the sandbox.
+ *
+ * We don't want to export the getters directly in case they add parameters that
+ * aren't safe for the sandbox without us noticing; wrapping helps prevent
+ * passing unknown parameters.
+ *
+ * @param {Function} getter
+ *   Function on an nsIPrefBranch that fetches a preference value.
+ * @return {Function}
+ */
+function wrapPrefGetter(getter) {
+  return (value, defaultValue = undefined) => {
+    // Passing undefined as the defaultValue disables throwing exceptions when
+    // the pref is missing or the type doesn't match, so we need to specifically
+    // exclude it if we don't want default value behavior.
+    const args = [value];
+    if (defaultValue !== undefined) {
+      args.push(defaultValue);
+    }
+    return getter.apply(null, args);
+  };
+}
--- a/browser/extensions/shield-recipe-client/lib/PreferenceExperiments.jsm
+++ b/browser/extensions/shield-recipe-client/lib/PreferenceExperiments.jsm
@@ -144,17 +144,17 @@ this.PreferenceExperiments = {
   async init() {
     for (const experiment of await this.getAllActive()) {
       // Set experiment default preferences, since they don't persist between restarts
       if (experiment.preferenceBranchType === "default") {
         setPref(DefaultPreferences, experiment.preferenceName, experiment.preferenceType, experiment.preferenceValue);
       }
 
       // Check that the current value of the preference is still what we set it to
-      if (getPref(UserPreferences, experiment.preferenceName, experiment.preferenceType) !== experiment.preferenceValue) {
+      if (getPref(UserPreferences, experiment.preferenceName, experiment.preferenceType, undefined) !== experiment.preferenceValue) {
         // if not, stop the experiment, and skip the remaining steps
         log.info(`Stopping experiment "${experiment.name}" because its value changed`);
         await this.stop(experiment.name, false);
         continue;
       }
 
       // Notify Telemetry of experiments we're running, since they don't persist between restarts
       TelemetryEnvironment.setExperimentActive(experiment.name, experiment.branch);
@@ -237,17 +237,17 @@ this.PreferenceExperiments = {
     const experiment = {
       name,
       branch,
       expired: false,
       lastSeen: new Date().toJSON(),
       preferenceName,
       preferenceValue,
       preferenceType,
-      previousPreferenceValue: getPref(preferences, preferenceName, preferenceType),
+      previousPreferenceValue: getPref(preferences, preferenceName, preferenceType, undefined),
       preferenceBranchType,
     };
 
     const prevPrefType = Services.prefs.getPrefType(preferenceName);
     const givenPrefType = PREFERENCE_TYPE_MAP[preferenceType];
 
     if (!preferenceType || !givenPrefType) {
       throw new Error(`Invalid preferenceType provided (given "${preferenceType}")`);
@@ -284,17 +284,17 @@ this.PreferenceExperiments = {
       throw new Error(
         `An observer for the preference experiment ${experimentName} is already active.`
       );
     }
 
     const observerInfo = {
       preferenceName,
       observer() {
-        let newValue = getPref(UserPreferences, preferenceName, preferenceType);
+        let newValue = getPref(UserPreferences, preferenceName, preferenceType, undefined);
         if (newValue !== preferenceValue) {
           PreferenceExperiments.stop(experimentName, false)
                                .catch(Cu.reportError);
         }
       },
     };
     experimentObservers.set(experimentName, observerInfo);
     Services.prefs.addObserver(preferenceName, observerInfo.observer);
--- a/browser/extensions/shield-recipe-client/lib/RecipeRunner.jsm
+++ b/browser/extensions/shield-recipe-client/lib/RecipeRunner.jsm
@@ -24,16 +24,20 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 XPCOMUtils.defineLazyModuleGetter(this, "SandboxManager",
                                   "resource://shield-recipe-client/lib/SandboxManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "ClientEnvironment",
                                   "resource://shield-recipe-client/lib/ClientEnvironment.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "CleanupManager",
                                   "resource://shield-recipe-client/lib/CleanupManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "ActionSandboxManager",
                                   "resource://shield-recipe-client/lib/ActionSandboxManager.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AddonStudies",
+                                  "resource://shield-recipe-client/lib/AddonStudies.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Uptake",
+                                  "resource://shield-recipe-client/lib/Uptake.jsm");
 
 Cu.importGlobalProperties(["fetch"]);
 
 this.EXPORTED_SYMBOLS = ["RecipeRunner"];
 
 const log = LogManager.getLogger("recipe-runner");
 const prefs = Services.prefs.getBranch("extensions.shield-recipe-client.");
 const TIMER_NAME = "recipe-client-addon-run";
@@ -122,106 +126,141 @@ this.RecipeRunner = {
     const runInterval = prefs.getIntPref(RUN_INTERVAL_PREF);
     timerManager.registerTimer(TIMER_NAME, () => this.run(), runInterval);
   },
 
   async run() {
     this.clearCaches();
     // Unless lazy classification is enabled, prep the classify cache.
     if (!Preferences.get("extensions.shield-recipe-client.experiments.lazy_classify", false)) {
-      await ClientEnvironment.getClientClassification();
+      try {
+        await ClientEnvironment.getClientClassification();
+      } catch (err) {
+        // Try to go on without this data; the filter expressions will
+        // gracefully fail without this info if they need it.
+      }
+    }
+
+    // Fetch recipes before execution in case we fail and exit early.
+    let recipes;
+    try {
+      recipes = await NormandyApi.fetchRecipes({enabled: true});
+    } catch (e) {
+      const apiUrl = prefs.getCharPref("api_url");
+      log.error(`Could not fetch recipes from ${apiUrl}: "${e}"`);
+
+      let status = Uptake.RUNNER_SERVER_ERROR;
+      if (/NetworkError/.test(e)) {
+        status = Uptake.RUNNER_NETWORK_ERROR;
+      } else if (e instanceof NormandyApi.InvalidSignatureError) {
+        status = Uptake.RUNNER_INVALID_SIGNATURE;
+      }
+      Uptake.reportRunner(status);
+      return;
     }
 
     const actionSandboxManagers = await this.loadActionSandboxManagers();
     Object.values(actionSandboxManagers).forEach(manager => manager.addHold("recipeRunner"));
 
     // Run pre-execution hooks. If a hook fails, we don't run recipes with that
     // action to avoid inconsistencies.
     for (const [actionName, manager] of Object.entries(actionSandboxManagers)) {
       try {
         await manager.runAsyncCallback("preExecution");
         manager.disabled = false;
       } catch (err) {
         log.error(`Could not run pre-execution hook for ${actionName}:`, err.message);
         manager.disabled = true;
+        Uptake.reportAction(actionName, Uptake.ACTION_PRE_EXECUTION_ERROR);
       }
     }
 
-    // Fetch recipes from the API
-    let recipes;
-    try {
-      recipes = await NormandyApi.fetchRecipes({enabled: true});
-    } catch (e) {
-      const apiUrl = prefs.getCharPref("api_url");
-      log.error(`Could not fetch recipes from ${apiUrl}: "${e}"`);
-      return;
-    }
-
     // Evaluate recipe filters
     const recipesToRun = [];
     for (const recipe of recipes) {
       if (await this.checkFilter(recipe)) {
         recipesToRun.push(recipe);
       }
     }
 
     // Execute recipes, if we have any.
     if (recipesToRun.length === 0) {
       log.debug("No recipes to execute");
     } else {
       for (const recipe of recipesToRun) {
         const manager = actionSandboxManagers[recipe.action];
+        let status;
         if (!manager) {
           log.error(
             `Could not execute recipe ${recipe.name}:`,
             `Action ${recipe.action} is either missing or invalid.`
           );
+          status = Uptake.RECIPE_INVALID_ACTION;
         } else if (manager.disabled) {
           log.warn(
             `Skipping recipe ${recipe.name} because ${recipe.action} failed during pre-execution.`
           );
+          status = Uptake.RECIPE_ACTION_DISABLED;
         } else {
           try {
             log.info(`Executing recipe "${recipe.name}" (action=${recipe.action})`);
             await manager.runAsyncCallback("action", recipe);
+            status = Uptake.RECIPE_SUCCESS;
           } catch (e) {
-            log.error(`Could not execute recipe ${recipe.name}:`, e);
+            log.error(`Could not execute recipe ${recipe.name}:`);
+            Cu.reportError(e);
+            status = Uptake.RECIPE_EXECUTION_ERROR;
           }
         }
+
+        Uptake.reportRecipe(recipe.id, status);
       }
     }
 
     // Run post-execution hooks
     for (const [actionName, manager] of Object.entries(actionSandboxManagers)) {
       // Skip if pre-execution failed.
       if (manager.disabled) {
         log.info(`Skipping post-execution hook for ${actionName} due to earlier failure.`);
         continue;
       }
 
       try {
         await manager.runAsyncCallback("postExecution");
+        Uptake.reportAction(actionName, Uptake.ACTION_SUCCESS);
       } catch (err) {
         log.info(`Could not run post-execution hook for ${actionName}:`, err.message);
+        Uptake.reportAction(actionName, Uptake.ACTION_POST_EXECUTION_ERROR);
       }
     }
 
     // Nuke sandboxes
     Object.values(actionSandboxManagers).forEach(manager => manager.removeHold("recipeRunner"));
+
+    // Close storage connections
+    await AddonStudies.close();
+
+    Uptake.reportRunner(Uptake.RUNNER_SUCCESS);
   },
 
   async loadActionSandboxManagers() {
     const actions = await NormandyApi.fetchActions();
     const actionSandboxManagers = {};
     for (const action of actions) {
       try {
         const implementation = await NormandyApi.fetchImplementation(action);
         actionSandboxManagers[action.name] = new ActionSandboxManager(implementation);
       } catch (err) {
         log.warn(`Could not fetch implementation for ${action.name}:`, err);
+
+        let status = Uptake.ACTION_SERVER_ERROR;
+        if (/NetworkError/.test(err)) {
+          status = Uptake.ACTION_NETWORK_ERROR;
+        }
+        Uptake.reportAction(action.name, status);
       }
     }
     return actionSandboxManagers;
   },
 
   getFilterContext(recipe) {
     return {
       normandy: Object.assign(ClientEnvironment.getEnvironment(), {
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/ShieldPreferences.jsm
@@ -0,0 +1,121 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(
+  this, "AppConstants", "resource://gre/modules/AppConstants.jsm"
+);
+XPCOMUtils.defineLazyModuleGetter(
+  this, "CleanupManager", "resource://shield-recipe-client/lib/CleanupManager.jsm"
+);
+
+this.EXPORTED_SYMBOLS = ["ShieldPreferences"];
+
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+const NS_PREFBRANCH_PREFCHANGE_TOPIC_ID = "nsPref:changed"; // from modules/libpref/nsIPrefBranch.idl
+const FHR_UPLOAD_ENABLED_PREF = "datareporting.healthreport.uploadEnabled";
+const OPT_OUT_STUDIES_ENABLED_PREF = "app.shield.optoutstudies.enabled";
+
+/**
+ * Handles Shield-specific preferences, including their UI.
+ */
+this.ShieldPreferences = {
+  init() {
+    // If the FHR pref was disabled since our last run, disable opt-out as well.
+    if (!Services.prefs.getBoolPref(FHR_UPLOAD_ENABLED_PREF)) {
+      Services.prefs.setBoolPref(OPT_OUT_STUDIES_ENABLED_PREF, false);
+    }
+
+    // Watch for changes to the FHR pref
+    Services.prefs.addObserver(FHR_UPLOAD_ENABLED_PREF, this);
+    CleanupManager.addCleanupHandler(() => {
+      Services.prefs.removeObserver(FHR_UPLOAD_ENABLED_PREF, this);
+    });
+
+    // Disabled outside of en-* locales temporarily (bug 1377192).
+    // Disabled when MOZ_DATA_REPORTING is false since the FHR UI is also hidden
+    // when data reporting is false.
+    if (AppConstants.MOZ_DATA_REPORTING && Services.locale.getAppLocaleAsLangTag().startsWith("en")) {
+      Services.obs.addObserver(this, "advanced-pane-loaded");
+      CleanupManager.addCleanupHandler(() => {
+        Services.obs.removeObserver(this, "advanced-pane-loaded");
+      });
+    }
+  },
+
+  observe(subject, topic, data) {
+    switch (topic) {
+      // Add the opt-out-study checkbox to the Privacy preferences when it is shown.
+      case "advanced-pane-loaded":
+        if (!Services.prefs.getBoolPref("browser.preferences.useOldOrganization", false)) {
+          this.injectOptOutStudyCheckbox(subject.document);
+        }
+        break;
+      // If the FHR pref changes, set the opt-out-study pref to the value it is changing to.
+      case NS_PREFBRANCH_PREFCHANGE_TOPIC_ID:
+        if (data === FHR_UPLOAD_ENABLED_PREF) {
+          const fhrUploadEnabled = Services.prefs.getBoolPref(FHR_UPLOAD_ENABLED_PREF);
+          Services.prefs.setBoolPref(OPT_OUT_STUDIES_ENABLED_PREF, fhrUploadEnabled);
+        }
+        break;
+    }
+  },
+
+  /**
+   * Injects the opt-out-study preference checkbox into about:preferences and
+   * handles events coming from the UI for it.
+   */
+  injectOptOutStudyCheckbox(doc) {
+    const container = doc.createElementNS(XUL_NS, "vbox");
+    container.classList.add("indent");
+
+    const hContainer = doc.createElementNS(XUL_NS, "hbox");
+    hContainer.setAttribute("align", "center");
+    container.appendChild(hContainer);
+
+    const checkbox = doc.createElementNS(XUL_NS, "checkbox");
+    checkbox.setAttribute("id", "optOutStudiesEnabled");
+    checkbox.setAttribute("label", "Allow Firefox to install and run studies");
+    checkbox.setAttribute("preference", OPT_OUT_STUDIES_ENABLED_PREF);
+    checkbox.setAttribute("disabled", !Services.prefs.getBoolPref(FHR_UPLOAD_ENABLED_PREF));
+    hContainer.appendChild(checkbox);
+
+    const viewStudies = doc.createElementNS(XUL_NS, "label");
+    viewStudies.setAttribute("id", "viewShieldStudies");
+    viewStudies.setAttribute("href", "about:studies");
+    viewStudies.setAttribute("useoriginprincipal", true);
+    viewStudies.textContent = "View Firefox Studies";
+    viewStudies.classList.add("learnMore", "text-link");
+    hContainer.appendChild(viewStudies);
+
+    // <prefrence> elements for prefs that we need to monitor while the page is open.
+    const optOutPref = doc.createElementNS(XUL_NS, "preference");
+    optOutPref.setAttribute("id", OPT_OUT_STUDIES_ENABLED_PREF);
+    optOutPref.setAttribute("name", OPT_OUT_STUDIES_ENABLED_PREF);
+    optOutPref.setAttribute("type", "bool");
+
+    // Weirdly, FHR doesn't have a <preference> element on the page, so we create it.
+    const fhrPref = doc.createElementNS(XUL_NS, "preference");
+    fhrPref.setAttribute("id", FHR_UPLOAD_ENABLED_PREF);
+    fhrPref.setAttribute("name", FHR_UPLOAD_ENABLED_PREF);
+    fhrPref.setAttribute("type", "bool");
+    fhrPref.addEventListener("change", function(event) {
+      // Avoid reference to the document directly, to avoid leaks.
+      const eventTargetCheckbox = event.target.ownerDocument.getElementById("optOutStudiesEnabled");
+      eventTargetCheckbox.disabled = !Services.prefs.getBoolPref(FHR_UPLOAD_ENABLED_PREF);
+    });
+
+    // Actually inject the elements we've created.
+    const parent = doc.getElementById("submitHealthReportBox").closest("vbox");
+    parent.appendChild(container);
+
+    const preferences = doc.getElementById("privacyPreferences");
+    preferences.appendChild(optOutPref);
+    preferences.appendChild(fhrPref);
+  },
+};
--- a/browser/extensions/shield-recipe-client/lib/ShieldRecipeClient.jsm
+++ b/browser/extensions/shield-recipe-client/lib/ShieldRecipeClient.jsm
@@ -11,96 +11,82 @@ Cu.import("resource://gre/modules/XPCOMU
 XPCOMUtils.defineLazyModuleGetter(this, "LogManager",
   "resource://shield-recipe-client/lib/LogManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "RecipeRunner",
   "resource://shield-recipe-client/lib/RecipeRunner.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "CleanupManager",
   "resource://shield-recipe-client/lib/CleanupManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PreferenceExperiments",
   "resource://shield-recipe-client/lib/PreferenceExperiments.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AboutPages",
+  "resource://shield-recipe-client-content/AboutPages.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ShieldPreferences",
+  "resource://shield-recipe-client/lib/ShieldPreferences.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AddonStudies",
+  "resource://shield-recipe-client/lib/AddonStudies.jsm");
 
 this.EXPORTED_SYMBOLS = ["ShieldRecipeClient"];
 
 const {PREF_STRING, PREF_BOOL, PREF_INT} = Ci.nsIPrefBranch;
 
 const REASONS = {
   APP_STARTUP: 1,      // The application is starting up.
   APP_SHUTDOWN: 2,     // The application is shutting down.
   ADDON_ENABLE: 3,     // The add-on is being enabled.
   ADDON_DISABLE: 4,    // The add-on is being disabled. (Also sent during uninstallation)
   ADDON_INSTALL: 5,    // The add-on is being installed.
   ADDON_UNINSTALL: 6,  // The add-on is being uninstalled.
   ADDON_UPGRADE: 7,    // The add-on is being upgraded.
   ADDON_DOWNGRADE: 8,  // The add-on is being downgraded.
 };
-const PREF_BRANCH = "extensions.shield-recipe-client.";
-const DEFAULT_PREFS = {
-  api_url: ["https://normandy.cdn.mozilla.net/api/v1", PREF_STRING],
-  dev_mode: [false, PREF_BOOL],
-  enabled: [true, PREF_BOOL],
-  startup_delay_seconds: [300, PREF_INT],
-  "logging.level": [Log.Level.Warn, PREF_INT],
-  user_id: ["", PREF_STRING],
-  run_interval_seconds: [86400, PREF_INT], // 24 hours
-  first_run: [true, PREF_BOOL],
-};
 const PREF_DEV_MODE = "extensions.shield-recipe-client.dev_mode";
-const PREF_LOGGING_LEVEL = PREF_BRANCH + "logging.level";
+const PREF_LOGGING_LEVEL = "extensions.shield-recipe-client.logging.level";
 
 let log = null;
 
 /**
  * Handles startup and shutdown of the entire add-on. Bootsrap.js defers to this
  * module for most tasks so that we can more easily test startup and shutdown
  * (bootstrap.js is difficult to import in tests).
  */
 this.ShieldRecipeClient = {
   async startup() {
-    ShieldRecipeClient.setDefaultPrefs();
-
     // Setup logging and listen for changes to logging prefs
     LogManager.configure(Services.prefs.getIntPref(PREF_LOGGING_LEVEL));
     Services.prefs.addObserver(PREF_LOGGING_LEVEL, LogManager.configure);
     CleanupManager.addCleanupHandler(
       () => Services.prefs.removeObserver(PREF_LOGGING_LEVEL, LogManager.configure),
     );
     log = LogManager.getLogger("bootstrap");
 
+    try {
+      await AboutPages.init();
+    } catch (err) {
+      log.error("Failed to initialize about pages:", err);
+    }
+
+    try {
+      await AddonStudies.init();
+    } catch (err) {
+      log.error("Failed to initialize addon studies:", err);
+    }
+
     // Initialize experiments first to avoid a race between initializing prefs
     // and recipes rolling back pref changes when experiments end.
     try {
       await PreferenceExperiments.init();
     } catch (err) {
       log.error("Failed to initialize preference experiments:", err);
     }
 
+    try {
+      ShieldPreferences.init();
+    } catch (err) {
+      log.error("Failed to initialize preferences UI:", err);
+    }
+
     await RecipeRunner.init();
   },
 
   shutdown(reason) {
     CleanupManager.cleanup();
   },
-
-  setDefaultPrefs() {
-    for (const [key, [val, type]] of Object.entries(DEFAULT_PREFS)) {
-      const fullKey = PREF_BRANCH + key;
-      // If someone beat us to setting a default, don't overwrite it.
-      if (!Services.prefs.prefHasUserValue(fullKey)) {
-        switch (type) {
-          case PREF_BOOL:
-            Services.prefs.setBoolPref(fullKey, val);
-            break;
-
-          case PREF_INT:
-            Services.prefs.setIntPref(fullKey, val);
-            break;
-
-          case PREF_STRING:
-            Services.prefs.setStringPref(fullKey, val);
-            break;
-
-          default:
-            throw new TypeError(`Unexpected type (${type}) for preference ${fullKey}.`)
-        }
-      }
-    }
-  },
 };
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/Uptake.jsm
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(
+  this, "UptakeTelemetry", "resource://services-common/uptake-telemetry.js");
+
+this.EXPORTED_SYMBOLS = ["Uptake"];
+
+const SOURCE_PREFIX = "shield-recipe-client";
+
+this.Uptake = {
+  // Action uptake
+  ACTION_NETWORK_ERROR: UptakeTelemetry.STATUS.NETWORK_ERROR,
+  ACTION_PRE_EXECUTION_ERROR: UptakeTelemetry.STATUS.CUSTOM_1_ERROR,
+  ACTION_POST_EXECUTION_ERROR: UptakeTelemetry.STATUS.CUSTOM_2_ERROR,
+  ACTION_SERVER_ERROR: UptakeTelemetry.STATUS.SERVER_ERROR,
+  ACTION_SUCCESS: UptakeTelemetry.STATUS.SUCCESS,
+
+  // Per-recipe uptake
+  RECIPE_ACTION_DISABLED: UptakeTelemetry.STATUS.CUSTOM_1_ERROR,
+  RECIPE_EXECUTION_ERROR: UptakeTelemetry.STATUS.APPLY_ERROR,
+  RECIPE_INVALID_ACTION: UptakeTelemetry.STATUS.DOWNLOAD_ERROR,
+  RECIPE_SUCCESS: UptakeTelemetry.STATUS.SUCCESS,
+
+  // Uptake for the runner as a whole
+  RUNNER_INVALID_SIGNATURE: UptakeTelemetry.STATUS.SIGNATURE_ERROR,
+  RUNNER_NETWORK_ERROR: UptakeTelemetry.STATUS.NETWORK_ERROR,
+  RUNNER_SERVER_ERROR: UptakeTelemetry.STATUS.SERVER_ERROR,
+  RUNNER_SUCCESS: UptakeTelemetry.STATUS.SUCCESS,
+
+  reportRunner(status) {
+    UptakeTelemetry.report(`${SOURCE_PREFIX}/runner`, status);
+  },
+
+  reportRecipe(recipeId, status) {
+    UptakeTelemetry.report(`${SOURCE_PREFIX}/recipe/${recipeId}`, status);
+  },
+
+  reportAction(actionName, status) {
+    UptakeTelemetry.report(`${SOURCE_PREFIX}/action/${actionName}`, status);
+  },
+};
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/node_modules/jexl/LICENSE.txt
+++ /dev/null
@@ -1,19 +0,0 @@
-Copyright (c) 2015 TechnologyAdvice
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
\ No newline at end of file
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/node_modules/jexl/lib/Jexl.js
+++ /dev/null
@@ -1,225 +0,0 @@
-/*
- * Jexl
- * Copyright (c) 2015 TechnologyAdvice
- */
-
-var Evaluator = require('./evaluator/Evaluator'),
-	Lexer = require('./Lexer'),
-	Parser = require('./parser/Parser'),
-	defaultGrammar = require('./grammar').elements;
-
-/**
- * Jexl is the Javascript Expression Language, capable of parsing and
- * evaluating basic to complex expression strings, combined with advanced
- * xpath-like drilldown into native Javascript objects.
- * @constructor
- */
-function Jexl() {
-	this._customGrammar = null;
-	this._lexer = null;
-	this._transforms = {};
-}
-
-/**
- * Adds a binary operator to Jexl at the specified precedence. The higher the
- * precedence, the earlier the operator is applied in the order of operations.
- * For example, * has a higher precedence than +, because multiplication comes
- * before division.
- *
- * Please see grammar.js for a listing of all default operators and their
- * precedence values in order to choose the appropriate precedence for the
- * new operator.
- * @param {string} operator The operator string to be added
- * @param {number} precedence The operator's precedence
- * @param {function} fn A function to run to calculate the result. The function
- *      will be called with two arguments: left and right, denoting the values
- *      on either side of the operator. It should return either the resulting
- *      value, or a Promise that resolves with the resulting value.
- */
-Jexl.prototype.addBinaryOp = function(operator, precedence, fn) {
-	this._addGrammarElement(operator, {
-		type: 'binaryOp',
-		precedence: precedence,
-		eval: fn
-	});
-};
-
-/**
- * Adds a unary operator to Jexl. Unary operators are currently only supported
- * on the left side of the value on which it will operate.
- * @param {string} operator The operator string to be added
- * @param {function} fn A function to run to calculate the result. The function
- *      will be called with one argument: the literal value to the right of the
- *      operator. It should return either the resulting value, or a Promise
- *      that resolves with the resulting value.
- */
-Jexl.prototype.addUnaryOp = function(operator, fn) {
-	this._addGrammarElement(operator, {
-		type: 'unaryOp',
-		weight: Infinity,
-		eval: fn
-	});
-};
-
-/**
- * Adds or replaces a transform function in this Jexl instance.
- * @param {string} name The name of the transform function, as it will be used
- *      within Jexl expressions
- * @param {function} fn The function to be executed when this transform is
- *      invoked.  It will be provided with two arguments:
- *          - {*} value: The value to be transformed
- *          - {{}} args: The arguments for this transform
- *          - {function} cb: A callback function to be called with an error
- *            if the transform fails, or a null first argument and the
- *            transformed value as the second argument on success.
- */
-Jexl.prototype.addTransform = function(name, fn) {
-	this._transforms[name] = fn;
-};
-
-/**
- * Syntactic sugar for calling {@link #addTransform} repeatedly.  This function
- * accepts a map of one or more transform names to their transform function.
- * @param {{}} map A map of transform names to transform functions
- */
-Jexl.prototype.addTransforms = function(map) {
-	for (var key in map) {
-		if (map.hasOwnProperty(key))
-			this._transforms[key] = map[key];
-	}
-};
-
-/**
- * Retrieves a previously set transform function.
- * @param {string} name The name of the transform function
- * @returns {function} The transform function
- */
-Jexl.prototype.getTransform = function(name) {
-	return this._transforms[name];
-};
-
-/**
- * Evaluates a Jexl string within an optional context.
- * @param {string} expression The Jexl expression to be evaluated
- * @param {Object} [context] A mapping of variables to values, which will be
- *      made accessible to the Jexl expression when evaluating it
- * @param {function} [cb] An optional callback function to be executed when
- *      evaluation is complete.  It will be supplied with two arguments:
- *          - {Error|null} err: Present if an error occurred
- *          - {*} result: The result of the evaluation
- * @returns {Promise<*>} resolves with the result of the evaluation.  Note that
- *      if a callback is supplied, the returned promise will already have
- *      a '.catch' attached to it in order to pass the error to the callback.
- */
-Jexl.prototype.eval = function(expression, context, cb) {
-	if (typeof context === 'function') {
-		cb = context;
-		context = {};
-	}
-	else if (!context)
-		context = {};
-	var valPromise = this._eval(expression, context);
-	if (cb) {
-		// setTimeout is used for the callback to break out of the Promise's
-		// try/catch in case the callback throws.
-		var called = false;
-		return valPromise.then(function(val) {
-			called = true;
-			setTimeout(cb.bind(null, null, val), 0);
-		}).catch(function(err) {
-			if (!called)
-				setTimeout(cb.bind(null, err), 0);
-		});
-	}
-	return valPromise;
-};
-
-/**
- * Removes a binary or unary operator from the Jexl grammar.
- * @param {string} operator The operator string to be removed
- */
-Jexl.prototype.removeOp = function(operator) {
-	var grammar = this._getCustomGrammar();
-	if (grammar[operator] && (grammar[operator].type == 'binaryOp' ||
-			grammar[operator].type == 'unaryOp')) {
-		delete grammar[operator];
-		this._lexer = null;
-	}
-};
-
-/**
- * Adds an element to the grammar map used by this Jexl instance, cloning
- * the default grammar first if necessary.
- * @param {string} str The key string to be added
- * @param {{type: <string>}} obj A map of configuration options for this
- *      grammar element
- * @private
- */
-Jexl.prototype._addGrammarElement = function(str, obj) {
-	var grammar = this._getCustomGrammar();
-	grammar[str] = obj;
-	this._lexer = null;
-};
-
-/**
- * Evaluates a Jexl string in the given context.
- * @param {string} exp The Jexl expression to be evaluated
- * @param {Object} [context] A mapping of variables to values, which will be
- *      made accessible to the Jexl expression when evaluating it
- * @returns {Promise<*>} resolves with the result of the evaluation.
- * @private
- */
-Jexl.prototype._eval = function(exp, context) {
-	var self = this,
-		grammar = this._getGrammar(),
-		parser = new Parser(grammar),
-		evaluator = new Evaluator(grammar, this._transforms, context);
-	return Promise.resolve().then(function() {
-		parser.addTokens(self._getLexer().tokenize(exp));
-		return evaluator.eval(parser.complete());
-	});
-};
-
-/**
- * Gets the custom grammar object, creating it first if necessary. New custom
- * grammars are created by executing a shallow clone of the default grammar
- * map. The returned map is available to be changed.
- * @returns {{}} a customizable grammar map.
- * @private
- */
-Jexl.prototype._getCustomGrammar = function() {
-	if (!this._customGrammar) {
-		this._customGrammar = {};
-		for (var key in defaultGrammar) {
-			if (defaultGrammar.hasOwnProperty(key))
-				this._customGrammar[key] = defaultGrammar[key];
-		}
-	}
-	return this._customGrammar;
-};
-
-/**
- * Gets the grammar map currently being used by Jexl; either the default map,
- * or a locally customized version. The returned map should never be changed
- * in any way.
- * @returns {{}} the grammar map currently in use.
- * @private
- */
-Jexl.prototype._getGrammar = function() {
-	return this._customGrammar || defaultGrammar;
-};
-
-/**
- * Gets a Lexer instance as a singleton in reference to this Jexl instance.
- * @returns {Lexer} an instance of Lexer, initialized with a grammar
- *      appropriate to this Jexl instance.
- * @private
- */
-Jexl.prototype._getLexer = function() {
-	if (!this._lexer)
-		this._lexer = new Lexer(this._getGrammar());
-	return this._lexer;
-};
-
-module.exports = new Jexl();
-module.exports.Jexl = Jexl;
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/node_modules/jexl/lib/Lexer.js
+++ /dev/null
@@ -1,244 +0,0 @@
-/*
- * Jexl
- * Copyright (c) 2015 TechnologyAdvice
- */
-
-var numericRegex = /^-?(?:(?:[0-9]*\.[0-9]+)|[0-9]+)$/,
-	identRegex = /^[a-zA-Z_\$][a-zA-Z0-9_\$]*$/,
-	escEscRegex = /\\\\/,
-	preOpRegexElems = [
-		// Strings
-		"'(?:(?:\\\\')?[^'])*'",
-		'"(?:(?:\\\\")?[^"])*"',
-		// Whitespace
-		'\\s+',
-		// Booleans
-		'\\btrue\\b',
-		'\\bfalse\\b'
-	],
-	postOpRegexElems = [
-		// Identifiers
-		'\\b[a-zA-Z_\\$][a-zA-Z0-9_\\$]*\\b',
-		// Numerics (without negative symbol)
-		'(?:(?:[0-9]*\\.[0-9]+)|[0-9]+)'
-	],
-	minusNegatesAfter = ['binaryOp', 'unaryOp', 'openParen', 'openBracket',
-		'question', 'colon'];
-
-/**
- * Lexer is a collection of stateless, statically-accessed functions for the
- * lexical parsing of a Jexl string.  Its responsibility is to identify the
- * "parts of speech" of a Jexl expression, and tokenize and label each, but
- * to do only the most minimal syntax checking; the only errors the Lexer
- * should be concerned with are if it's unable to identify the utility of
- * any of its tokens.  Errors stemming from these tokens not being in a
- * sensible configuration should be left for the Parser to handle.
- * @type {{}}
- */
-function Lexer(grammar) {
-	this._grammar = grammar;
-}
-
-/**
- * Splits a Jexl expression string into an array of expression elements.
- * @param {string} str A Jexl expression string
- * @returns {Array<string>} An array of substrings defining the functional
- *      elements of the expression.
- */
-Lexer.prototype.getElements = function(str) {
-	var regex = this._getSplitRegex();
-	return str.split(regex).filter(function(elem) {
-		// Remove empty strings
-		return elem;
-	});
-};
-
-/**
- * Converts an array of expression elements into an array of tokens.  Note that
- * the resulting array may not equal the element array in length, as any
- * elements that consist only of whitespace get appended to the previous
- * token's "raw" property.  For the structure of a token object, please see
- * {@link Lexer#tokenize}.
- * @param {Array<string>} elements An array of Jexl expression elements to be
- *      converted to tokens
- * @returns {Array<{type, value, raw}>} an array of token objects.
- */
-Lexer.prototype.getTokens = function(elements) {
-	var tokens = [],
-		negate = false;
-	for (var i = 0; i < elements.length; i++) {
-		if (this._isWhitespace(elements[i])) {
-			if (tokens.length)
-				tokens[tokens.length - 1].raw += elements[i];
-		}
-		else if (elements[i] === '-' && this._isNegative(tokens))
-			negate = true;
-		else {
-			if (negate) {
-				elements[i] = '-' + elements[i];
-				negate = false;
-			}
-			tokens.push(this._createToken(elements[i]));
-		}
-	}
-	// Catch a - at the end of the string. Let the parser handle that issue.
-	if (negate)
-		tokens.push(this._createToken('-'));
-	return tokens;
-};
-
-/**
- * Converts a Jexl string into an array of tokens.  Each token is an object
- * in the following format:
- *
- *     {
- *         type: <string>,
- *         [name]: <string>,
- *         value: <boolean|number|string>,
- *         raw: <string>
- *     }
- *
- * Type is one of the following:
- *
- *      literal, identifier, binaryOp, unaryOp
- *
- * OR, if the token is a control character its type is the name of the element
- * defined in the Grammar.
- *
- * Name appears only if the token is a control string found in
- * {@link grammar#elements}, and is set to the name of the element.
- *
- * Value is the value of the token in the correct type (boolean or numeric as
- * appropriate). Raw is the string representation of this value taken directly
- * from the expression string, including any trailing spaces.
- * @param {string} str The Jexl string to be tokenized
- * @returns {Array<{type, value, raw}>} an array of token objects.
- * @throws {Error} if the provided string contains an invalid token.
- */
-Lexer.prototype.tokenize = function(str) {
-	var elements = this.getElements(str);
-	return this.getTokens(elements);
-};
-
-/**
- * Creates a new token object from an element of a Jexl string. See
- * {@link Lexer#tokenize} for a description of the token object.
- * @param {string} element The element from which a token should be made
- * @returns {{value: number|boolean|string, [name]: string, type: string,
- *      raw: string}} a token object describing the provided element.
- * @throws {Error} if the provided string is not a valid expression element.
- * @private
- */
-Lexer.prototype._createToken = function(element) {
-	var token = {
-		type: 'literal',
-		value: element,
-		raw: element
-	};
-	if (element[0] == '"' || element[0] == "'")
-		token.value = this._unquote(element);
-	else if (element.match(numericRegex))
-		token.value = parseFloat(element);
-	else if (element === 'true' || element === 'false')
-		token.value = element === 'true';
-	else if (this._grammar[element])
-		token.type = this._grammar[element].type;
-	else if (element.match(identRegex))
-		token.type = 'identifier';
-	else
-		throw new Error("Invalid expression token: " + element);
-	return token;
-};
-
-/**
- * Escapes a string so that it can be treated as a string literal within a
- * regular expression.
- * @param {string} str The string to be escaped
- * @returns {string} the RegExp-escaped string.
- * @see https://developer.mozilla.org/en/docs/Web/JavaScript/Guide/Regular_Expressions
- * @private
- */
-Lexer.prototype._escapeRegExp = function(str) {
-	str = str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
-	if (str.match(identRegex))
-		str = '\\b' + str + '\\b';
-	return str;
-};
-
-/**
- * Gets a RegEx object appropriate for splitting a Jexl string into its core
- * elements.
- * @returns {RegExp} An element-splitting RegExp object
- * @private
- */
-Lexer.prototype._getSplitRegex = function() {
-	if (!this._splitRegex) {
-		var elemArray = Object.keys(this._grammar);
-		// Sort by most characters to least, then regex escape each
-		elemArray = elemArray.sort(function(a ,b) {
-			return b.length - a.length;
-		}).map(function(elem) {
-			return this._escapeRegExp(elem);
-		}, this);
-		this._splitRegex = new RegExp('(' + [
-			preOpRegexElems.join('|'),
-			elemArray.join('|'),
-			postOpRegexElems.join('|')
-		].join('|') + ')');
-	}
-	return this._splitRegex;
-};
-
-/**
- * Determines whether the addition of a '-' token should be interpreted as a
- * negative symbol for an upcoming number, given an array of tokens already
- * processed.
- * @param {Array<Object>} tokens An array of tokens already processed
- * @returns {boolean} true if adding a '-' should be considered a negative
- *      symbol; false otherwise
- * @private
- */
-Lexer.prototype._isNegative = function(tokens) {
-	if (!tokens.length)
-		return true;
-	return minusNegatesAfter.some(function(type) {
-		return type === tokens[tokens.length - 1].type;
-	});
-};
-
-/**
- * A utility function to determine if a string consists of only space
- * characters.
- * @param {string} str A string to be tested
- * @returns {boolean} true if the string is empty or consists of only spaces;
- *      false otherwise.
- * @private
- */
-Lexer.prototype._isWhitespace = function(str) {
-	for (var i = 0; i < str.length; i++) {
-		if (str[i] != ' ')
-			return false;
-	}
-	return true;
-};
-
-/**
- * Removes the beginning and trailing quotes from a string, unescapes any
- * escaped quotes on its interior, and unescapes any escaped escape characters.
- * Note that this function is not defensive; it assumes that the provided
- * string is not empty, and that its first and last characters are actually
- * quotes.
- * @param {string} str A string whose first and last characters are quotes
- * @returns {string} a string with the surrounding quotes stripped and escapes
- *      properly processed.
- * @private
- */
-Lexer.prototype._unquote = function(str) {
-	var quote = str[0],
-		escQuoteRegex = new RegExp('\\\\' + quote, 'g');
-	return str.substr(1, str.length - 2)
-		.replace(escQuoteRegex, quote)
-		.replace(escEscRegex, '\\');
-};
-
-module.exports = Lexer;
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/Evaluator.js
+++ /dev/null
@@ -1,153 +0,0 @@
-/*
- * Jexl
- * Copyright (c) 2015 TechnologyAdvice
- */
-
-var handlers = require('./handlers');
-
-/**
- * The Evaluator takes a Jexl expression tree as generated by the
- * {@link Parser} and calculates its value within a given context. The
- * collection of transforms, context, and a relative context to be used as the
- * root for relative identifiers, are all specific to an Evaluator instance.
- * When any of these things change, a new instance is required.  However, a
- * single instance can be used to simultaneously evaluate many different
- * expressions, and does not have to be reinstantiated for each.
- * @param {{}} grammar A grammar map against which to evaluate the expression
- *      tree
- * @param {{}} [transforms] A map of transform names to transform functions. A
- *      transform function takes two arguments:
- *          - {*} val: A value to be transformed
- *          - {{}} args: A map of argument keys to their evaluated values, as
- *              specified in the expression string
- *      The transform function should return either the transformed value, or
- *      a Promises/A+ Promise object that resolves with the value and rejects
- *      or throws only when an unrecoverable error occurs. Transforms should
- *      generally return undefined when they don't make sense to be used on the
- *      given value type, rather than throw/reject. An error is only
- *      appropriate when the transform would normally return a value, but
- *      cannot due to some other failure.
- * @param {{}} [context] A map of variable keys to their values. This will be
- *      accessed to resolve the value of each non-relative identifier. Any
- *      Promise values will be passed to the expression as their resolved
- *      value.
- * @param {{}|Array<{}|Array>} [relativeContext] A map or array to be accessed
- *      to resolve the value of a relative identifier.
- * @constructor
- */
-var Evaluator = function(grammar, transforms, context, relativeContext) {
-	this._grammar = grammar;
-	this._transforms = transforms || {};
-	this._context = context || {};
-	this._relContext = relativeContext || this._context;
-};
-
-/**
- * Evaluates an expression tree within the configured context.
- * @param {{}} ast An expression tree object
- * @returns {Promise<*>} resolves with the resulting value of the expression.
- */
-Evaluator.prototype.eval = function(ast) {
-	var self = this;
-	return Promise.resolve().then(function() {
-		return handlers[ast.type].call(self, ast);
-	});
-};
-
-/**
- * Simultaneously evaluates each expression within an array, and delivers the
- * response as an array with the resulting values at the same indexes as their
- * originating expressions.
- * @param {Array<string>} arr An array of expression strings to be evaluated
- * @returns {Promise<Array<{}>>} resolves with the result array
- */
-Evaluator.prototype.evalArray = function(arr) {
-	return Promise.all(arr.map(function(elem) {
-		return this.eval(elem);
-	}, this));
-};
-
-/**
- * Simultaneously evaluates each expression within a map, and delivers the
- * response as a map with the same keys, but with the evaluated result for each
- * as their value.
- * @param {{}} map A map of expression names to expression trees to be
- *      evaluated
- * @returns {Promise<{}>} resolves with the result map.
- */
-Evaluator.prototype.evalMap = function(map) {
-	var keys = Object.keys(map),
-		result = {};
-	var asts = keys.map(function(key) {
-		return this.eval(map[key]);
-	}, this);
-	return Promise.all(asts).then(function(vals) {
-		vals.forEach(function(val, idx) {
-			result[keys[idx]] = val;
-		});
-		return result;
-	});
-};
-
-/**
- * Applies a filter expression with relative identifier elements to a subject.
- * The intent is for the subject to be an array of subjects that will be
- * individually used as the relative context against the provided expression
- * tree. Only the elements whose expressions result in a truthy value will be
- * included in the resulting array.
- *
- * If the subject is not an array of values, it will be converted to a single-
- * element array before running the filter.
- * @param {*} subject The value to be filtered; usually an array. If this value is
- *      not an array, it will be converted to an array with this value as the
- *      only element.
- * @param {{}} expr The expression tree to run against each subject. If the
- *      tree evaluates to a truthy result, then the value will be included in
- *      the returned array; otherwise, it will be eliminated.
- * @returns {Promise<Array>} resolves with an array of values that passed the
- *      expression filter.
- * @private
- */
-Evaluator.prototype._filterRelative = function(subject, expr) {
-	var promises = [];
-	if (!Array.isArray(subject))
-		subject = [subject];
-	subject.forEach(function(elem) {
-		var evalInst = new Evaluator(this._grammar, this._transforms,
-			this._context, elem);
-		promises.push(evalInst.eval(expr));
-	}, this);
-	return Promise.all(promises).then(function(values) {
-		var results = [];
-		values.forEach(function(value, idx) {
-			if (value)
-				results.push(subject[idx]);
-		});
-		return results;
-	});
-};
-
-/**
- * Applies a static filter expression to a subject value.  If the filter
- * expression evaluates to boolean true, the subject is returned; if false,
- * undefined.
- *
- * For any other resulting value of the expression, this function will attempt
- * to respond with the property at that name or index of the subject.
- * @param {*} subject The value to be filtered.  Usually an Array (for which
- *      the expression would generally resolve to a numeric index) or an
- *      Object (for which the expression would generally resolve to a string
- *      indicating a property name)
- * @param {{}} expr The expression tree to run against the subject
- * @returns {Promise<*>} resolves with the value of the drill-down.
- * @private
- */
-Evaluator.prototype._filterStatic = function(subject, expr) {
-	return this.eval(expr).then(function(res) {
-		if (typeof res === 'boolean')
-			return res ? subject : undefined;
-		return subject[res];
-	});
-};
-
-module.exports = Evaluator;
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/handlers.js
+++ /dev/null
@@ -1,159 +0,0 @@
-/*
- * Jexl
- * Copyright (c) 2015 TechnologyAdvice
- */
-
-/**
- * Evaluates an ArrayLiteral by returning its value, with each element
- * independently run through the evaluator.
- * @param {{type: 'ObjectLiteral', value: <{}>}} ast An expression tree with an
- *      ObjectLiteral as the top node
- * @returns {Promise.<[]>} resolves to a map contained evaluated values.
- * @private
- */
-exports.ArrayLiteral = function(ast) {
-	return this.evalArray(ast.value);
-};
-
-/**
- * Evaluates a BinaryExpression node by running the Grammar's evaluator for
- * the given operator.
- * @param {{type: 'BinaryExpression', operator: <string>, left: {},
- *      right: {}}} ast An expression tree with a BinaryExpression as the top
- *      node
- * @returns {Promise<*>} resolves with the value of the BinaryExpression.
- * @private
- */
-exports.BinaryExpression = function(ast) {
-	var self = this;
-	return Promise.all([
-		this.eval(ast.left),
-		this.eval(ast.right)
-	]).then(function(arr) {
-		return self._grammar[ast.operator].eval(arr[0], arr[1]);
-	});
-};
-
-/**
- * Evaluates a ConditionalExpression node by first evaluating its test branch,
- * and resolving with the consequent branch if the test is truthy, or the
- * alternate branch if it is not. If there is no consequent branch, the test
- * result will be used instead.
- * @param {{type: 'ConditionalExpression', test: {}, consequent: {},
- *      alternate: {}}} ast An expression tree with a ConditionalExpression as
- *      the top node
- * @private
- */
-exports.ConditionalExpression = function(ast) {
-	var self = this;
-	return this.eval(ast.test).then(function(res) {
-		if (res) {
-			if (ast.consequent)
-				return self.eval(ast.consequent);
-			return res;
-		}
-		return self.eval(ast.alternate);
-	});
-};
-
-/**
- * Evaluates a FilterExpression by applying it to the subject value.
- * @param {{type: 'FilterExpression', relative: <boolean>, expr: {},
- *      subject: {}}} ast An expression tree with a FilterExpression as the top
- *      node
- * @returns {Promise<*>} resolves with the value of the FilterExpression.
- * @private
- */
-exports.FilterExpression = function(ast) {
-	var self = this;
-	return this.eval(ast.subject).then(function(subject) {
-		if (ast.relative)
-			return self._filterRelative(subject, ast.expr);
-		return self._filterStatic(subject, ast.expr);
-	});
-};
-
-/**
- * Evaluates an Identifier by either stemming from the evaluated 'from'
- * expression tree or accessing the context provided when this Evaluator was
- * constructed.
- * @param {{type: 'Identifier', value: <string>, [from]: {}}} ast An expression
- *      tree with an Identifier as the top node
- * @returns {Promise<*>|*} either the identifier's value, or a Promise that
- *      will resolve with the identifier's value.
- * @private
- */
-exports.Identifier = function(ast) {
-	if (ast.from) {
-		return this.eval(ast.from).then(function(context) {
-			if (context === undefined)
-				return undefined;
-			if (Array.isArray(context))
-				context = context[0];
-			return context[ast.value];
-		});
-	}
-	else {
-		return ast.relative ? this._relContext[ast.value] :
-			this._context[ast.value];
-	}
-};
-
-/**
- * Evaluates a Literal by returning its value property.
- * @param {{type: 'Literal', value: <string|number|boolean>}} ast An expression
- *      tree with a Literal as its only node
- * @returns {string|number|boolean} The value of the Literal node
- * @private
- */
-exports.Literal = function(ast) {
-	return ast.value;
-};
-
-/**
- * Evaluates an ObjectLiteral by returning its value, with each key
- * independently run through the evaluator.
- * @param {{type: 'ObjectLiteral', value: <{}>}} ast An expression tree with an
- *      ObjectLiteral as the top node
- * @returns {Promise<{}>} resolves to a map contained evaluated values.
- * @private
- */
-exports.ObjectLiteral = function(ast) {
-	return this.evalMap(ast.value);
-};
-
-/**
- * Evaluates a Transform node by applying a function from the transforms map
- * to the subject value.
- * @param {{type: 'Transform', name: <string>, subject: {}}} ast An
- *      expression tree with a Transform as the top node
- * @returns {Promise<*>|*} the value of the transformation, or a Promise that
- *      will resolve with the transformed value.
- * @private
- */
-exports.Transform = function(ast) {
-	var transform = this._transforms[ast.name];
-	if (!transform)
-		throw new Error("Transform '" + ast.name + "' is not defined.");
-	return Promise.all([
-		this.eval(ast.subject),
-		this.evalArray(ast.args || [])
-	]).then(function(arr) {
-		return transform.apply(null, [arr[0]].concat(arr[1]));
-	});
-};
-
-/**
- * Evaluates a Unary expression by passing the right side through the
- * operator's eval function.
- * @param {{type: 'UnaryExpression', operator: <string>, right: {}}} ast An
- *      expression tree with a UnaryExpression as the top node
- * @returns {Promise<*>} resolves with the value of the UnaryExpression.
- * @constructor
- */
-exports.UnaryExpression = function(ast) {
-	var self = this;
-	return this.eval(ast.right).then(function(right) {
-		return self._grammar[ast.operator].eval(right);
-	});
-};
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/node_modules/jexl/lib/grammar.js
+++ /dev/null
@@ -1,66 +0,0 @@
-/*
- * Jexl
- * Copyright (c) 2015 TechnologyAdvice
- */
-
-/**
- * A map of all expression elements to their properties. Note that changes
- * here may require changes in the Lexer or Parser.
- * @type {{}}
- */
-exports.elements = {
-	'.': {type: 'dot'},
-	'[': {type: 'openBracket'},
-	']': {type: 'closeBracket'},
-	'|': {type: 'pipe'},
-	'{': {type: 'openCurl'},
-	'}': {type: 'closeCurl'},
-	':': {type: 'colon'},
-	',': {type: 'comma'},
-	'(': {type: 'openParen'},
-	')': {type: 'closeParen'},
-	'?': {type: 'question'},
-	'+': {type: 'binaryOp', precedence: 30,
-		eval: function(left, right) { return left + right; }},
-	'-': {type: 'binaryOp', precedence: 30,
-		eval: function(left, right) { return left - right; }},
-	'*': {type: 'binaryOp', precedence: 40,
-		eval: function(left, right) { return left * right; }},
-	'/': {type: 'binaryOp', precedence: 40,
-		eval: function(left, right) { return left / right; }},
-	'//': {type: 'binaryOp', precedence: 40,
-		eval: function(left, right) { return Math.floor(left / right); }},
-	'%': {type: 'binaryOp', precedence: 50,
-		eval: function(left, right) { return left % right; }},
-	'^': {type: 'binaryOp', precedence: 50,
-		eval: function(left, right) { return Math.pow(left, right); }},
-	'==': {type: 'binaryOp', precedence: 20,
-		eval: function(left, right) { return left == right; }},
-	'!=': {type: 'binaryOp', precedence: 20,
-		eval: function(left, right) { return left != right; }},
-	'>': {type: 'binaryOp', precedence: 20,
-		eval: function(left, right) { return left > right; }},
-	'>=': {type: 'binaryOp', precedence: 20,
-		eval: function(left, right) { return left >= right; }},
-	'<': {type: 'binaryOp', precedence: 20,
-		eval: function(left, right) { return left < right; }},
-	'<=': {type: 'binaryOp', precedence: 20,
-		eval: function(left, right) { return left <= right; }},
-	'&&': {type: 'binaryOp', precedence: 10,
-		eval: function(left, right) { return left && right; }},
-	'||': {type: 'binaryOp', precedence: 10,
-		eval: function(left, right) { return left || right; }},
-	'in': {type: 'binaryOp', precedence: 20,
-		eval: function(left, right) {
-			if (typeof right === 'string')
-				return right.indexOf(left) !== -1;
-			if (Array.isArray(right)) {
-				return right.some(function(elem) {
-					return elem == left;
-				});
-			}
-			return false;
-		}},
-	'!': {type: 'unaryOp', precedence: Infinity,
-		eval: function(right) { return !right; }}
-};
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/Parser.js
+++ /dev/null
@@ -1,188 +0,0 @@
-/*
- * Jexl
- * Copyright (c) 2015 TechnologyAdvice
- */
-
-var handlers = require('./handlers'),
-	states = require('./states').states;
-
-/**
- * The Parser is a state machine that converts tokens from the {@link Lexer}
- * into an Abstract Syntax Tree (AST), capable of being evaluated in any
- * context by the {@link Evaluator}.  The Parser expects that all tokens
- * provided to it are legal and typed properly according to the grammar, but
- * accepts that the tokens may still be in an invalid order or in some other
- * unparsable configuration that requires it to throw an Error.
- * @param {{}} grammar The grammar map to use to parse Jexl strings
- * @param {string} [prefix] A string prefix to prepend to the expression string
- *      for error messaging purposes.  This is useful for when a new Parser is
- *      instantiated to parse an subexpression, as the parent Parser's
- *      expression string thus far can be passed for a more user-friendly
- *      error message.
- * @param {{}} [stopMap] A mapping of token types to any truthy value. When the
- *      token type is encountered, the parser will return the mapped value
- *      instead of boolean false.
- * @constructor
- */
-function Parser(grammar, prefix, stopMap) {
-	this._grammar = grammar;
-	this._state = 'expectOperand';
-	this._tree = null;
-	this._exprStr = prefix || '';
-	this._relative = false;
-	this._stopMap = stopMap || {};
-}
-
-/**
- * Processes a new token into the AST and manages the transitions of the state
- * machine.
- * @param {{type: <string>}} token A token object, as provided by the
- *      {@link Lexer#tokenize} function.
- * @throws {Error} if a token is added when the Parser has been marked as
- *      complete by {@link #complete}, or if an unexpected token type is added.
- * @returns {boolean|*} the stopState value if this parser encountered a token
- *      in the stopState mapb; false if tokens can continue.
- */
-Parser.prototype.addToken = function(token) {
-	if (this._state == 'complete')
-		throw new Error('Cannot add a new token to a completed Parser');
-	var state = states[this._state],
-		startExpr = this._exprStr;
-	this._exprStr += token.raw;
-	if (state.subHandler) {
-		if (!this._subParser)
-			this._startSubExpression(startExpr);
-		var stopState = this._subParser.addToken(token);
-		if (stopState) {
-			this._endSubExpression();
-			if (this._parentStop)
-				return stopState;
-			this._state = stopState;
-		}
-	}
-	else if (state.tokenTypes[token.type]) {
-		var typeOpts = state.tokenTypes[token.type],
-			handleFunc = handlers[token.type];
-		if (typeOpts.handler)
-			handleFunc = typeOpts.handler;
-		if (handleFunc)
-			handleFunc.call(this, token);
-		if (typeOpts.toState)
-			this._state = typeOpts.toState;
-	}
-	else if (this._stopMap[token.type])
-		return this._stopMap[token.type];
-	else {
-		throw new Error('Token ' + token.raw + ' (' + token.type +
-			') unexpected in expression: ' + this._exprStr);
-	}
-	return false;
-};
-
-/**
- * Processes an array of tokens iteratively through the {@link #addToken}
- * function.
- * @param {Array<{type: <string>}>} tokens An array of tokens, as provided by
- *      the {@link Lexer#tokenize} function.
- */
-Parser.prototype.addTokens = function(tokens) {
-	tokens.forEach(this.addToken, this);
-};
-
-/**
- * Marks this Parser instance as completed and retrieves the full AST.
- * @returns {{}|null} a full expression tree, ready for evaluation by the
- *      {@link Evaluator#eval} function, or null if no tokens were passed to
- *      the parser before complete was called
- * @throws {Error} if the parser is not in a state where it's legal to end
- *      the expression, indicating that the expression is incomplete
- */
-Parser.prototype.complete = function() {
-	if (this._cursor && !states[this._state].completable)
-		throw new Error('Unexpected end of expression: ' + this._exprStr);
-	if (this._subParser)
-		this._endSubExpression();
-	this._state = 'complete';
-	return this._cursor ? this._tree : null;
-};
-
-/**
- * Indicates whether the expression tree contains a relative path identifier.
- * @returns {boolean} true if a relative identifier exists; false otherwise.
- */
-Parser.prototype.isRelative = function() {
-	return this._relative;
-};
-
-/**
- * Ends a subexpression by completing the subParser and passing its result
- * to the subHandler configured in the current state.
- * @private
- */
-Parser.prototype._endSubExpression = function() {
-	states[this._state].subHandler.call(this, this._subParser.complete());
-	this._subParser = null;
-};
-
-/**
- * Places a new tree node at the current position of the cursor (to the 'right'
- * property) and then advances the cursor to the new node. This function also
- * handles setting the parent of the new node.
- * @param {{type: <string>}} node A node to be added to the AST
- * @private
- */
-Parser.prototype._placeAtCursor = function(node) {
-	if (!this._cursor)
-		this._tree = node;
-	else {
-		this._cursor.right = node;
-		this._setParent(node, this._cursor);
-	}
-	this._cursor = node;
-};
-
-/**
- * Places a tree node before the current position of the cursor, replacing
- * the node that the cursor currently points to. This should only be called in
- * cases where the cursor is known to exist, and the provided node already
- * contains a pointer to what's at the cursor currently.
- * @param {{type: <string>}} node A node to be added to the AST
- * @private
- */
-Parser.prototype._placeBeforeCursor = function(node) {
-	this._cursor = this._cursor._parent;
-	this._placeAtCursor(node);
-};
-
-/**
- * Sets the parent of a node by creating a non-enumerable _parent property
- * that points to the supplied parent argument.
- * @param {{type: <string>}} node A node of the AST on which to set a new
- *      parent
- * @param {{type: <string>}} parent An existing node of the AST to serve as the
- *      parent of the new node
- * @private
- */
-Parser.prototype._setParent = function(node, parent) {
-	Object.defineProperty(node, '_parent', {
-		value: parent,
-		writable: true
-	});
-};
-
-/**
- * Prepares the Parser to accept a subexpression by (re)instantiating the
- * subParser.
- * @param {string} [exprStr] The expression string to prefix to the new Parser
- * @private
- */
-Parser.prototype._startSubExpression = function(exprStr) {
-	var endStates = states[this._state].endStates;
-	if (!endStates) {
-		this._parentStop = true;
-		endStates = this._stopMap;
-	}
-	this._subParser = new Parser(this._grammar, exprStr, endStates);
-};
-
-module.exports = Parser;
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/handlers.js
+++ /dev/null
@@ -1,210 +0,0 @@
-/*
- * Jexl
- * Copyright (c) 2015 TechnologyAdvice
- */
-
-/**
- * Handles a subexpression that's used to define a transform argument's value.
- * @param {{type: <string>}} ast The subexpression tree
- */
-exports.argVal = function(ast) {
-	this._cursor.args.push(ast);
-};
-
-/**
- * Handles new array literals by adding them as a new node in the AST,
- * initialized with an empty array.
- */
-exports.arrayStart = function() {
-	this._placeAtCursor({
-		type: 'ArrayLiteral',
-		value: []
-	});
-};
-
-/**
- * Handles a subexpression representing an element of an array literal.
- * @param {{type: <string>}} ast The subexpression tree
- */
-exports.arrayVal = function(ast) {
-	if (ast)
-		this._cursor.value.push(ast);
-};
-
-/**
- * Handles tokens of type 'binaryOp', indicating an operation that has two
- * inputs: a left side and a right side.
- * @param {{type: <string>}} token A token object
- */
-exports.binaryOp = function(token) {
-	var precedence = this._grammar[token.value].precedence || 0,
-		parent = this._cursor._parent;
-	while (parent && parent.operator &&
-			this._grammar[parent.operator].precedence >= precedence) {
-		this._cursor = parent;
-		parent = parent._parent;
-	}
-	var node = {
-		type: 'BinaryExpression',
-		operator: token.value,
-		left: this._cursor
-	};
-	this._setParent(this._cursor, node);
-	this._cursor = parent;
-	this._placeAtCursor(node);
-};
-
-/**
- * Handles successive nodes in an identifier chain.  More specifically, it
- * sets values that determine how the following identifier gets placed in the
- * AST.
- */
-exports.dot = function() {
-	this._nextIdentEncapsulate = this._cursor &&
-		(this._cursor.type != 'BinaryExpression' ||
-		(this._cursor.type == 'BinaryExpression' && this._cursor.right)) &&
-		this._cursor.type != 'UnaryExpression';
-	this._nextIdentRelative = !this._cursor ||
-		(this._cursor && !this._nextIdentEncapsulate);
-	if (this._nextIdentRelative)
-		this._relative = true;
-};
-
-/**
- * Handles a subexpression used for filtering an array returned by an
- * identifier chain.
- * @param {{type: <string>}} ast The subexpression tree
- */
-exports.filter = function(ast) {
-	this._placeBeforeCursor({
-		type: 'FilterExpression',
-		expr: ast,
-		relative: this._subParser.isRelative(),
-		subject: this._cursor
-	});
-};
-
-/**
- * Handles identifier tokens by adding them as a new node in the AST.
- * @param {{type: <string>}} token A token object
- */
-exports.identifier = function(token) {
-	var node = {
-		type: 'Identifier',
-		value: token.value
-	};
-	if (this._nextIdentEncapsulate) {
-		node.from = this._cursor;
-		this._placeBeforeCursor(node);
-		this._nextIdentEncapsulate = false;
-	}
-	else {
-		if (this._nextIdentRelative)
-			node.relative = true;
-		this._placeAtCursor(node);
-	}
-};
-
-/**
- * Handles literal values, such as strings, booleans, and numerics, by adding
- * them as a new node in the AST.
- * @param {{type: <string>}} token A token object
- */
-exports.literal = function(token) {
-	this._placeAtCursor({
-		type: 'Literal',
-		value: token.value
-	});
-};
-
-/**
- * Queues a new object literal key to be written once a value is collected.
- * @param {{type: <string>}} token A token object
- */
-exports.objKey = function(token) {
-	this._curObjKey = token.value;
-};
-
-/**
- * Handles new object literals by adding them as a new node in the AST,
- * initialized with an empty object.
- */
-exports.objStart = function() {
-	this._placeAtCursor({
-		type: 'ObjectLiteral',
-		value: {}
-	});
-};
-
-/**
- * Handles an object value by adding its AST to the queued key on the object
- * literal node currently at the cursor.
- * @param {{type: <string>}} ast The subexpression tree
- */
-exports.objVal = function(ast) {
-	this._cursor.value[this._curObjKey] = ast;
-};
-
-/**
- * Handles traditional subexpressions, delineated with the groupStart and
- * groupEnd elements.
- * @param {{type: <string>}} ast The subexpression tree
- */
-exports.subExpression = function(ast) {
-	this._placeAtCursor(ast);
-};
-
-/**
- * Handles a completed alternate subexpression of a ternary operator.
- * @param {{type: <string>}} ast The subexpression tree
- */
-exports.ternaryEnd = function(ast) {
-	this._cursor.alternate = ast;
-};
-
-/**
- * Handles a completed consequent subexpression of a ternary operator.
- * @param {{type: <string>}} ast The subexpression tree
- */
-exports.ternaryMid = function(ast) {
-	this._cursor.consequent = ast;
-};
-
-/**
- * Handles the start of a new ternary expression by encapsulating the entire
- * AST in a ConditionalExpression node, and using the existing tree as the
- * test element.
- */
-exports.ternaryStart = function() {
-	this._tree = {
-		type: 'ConditionalExpression',
-		test: this._tree
-	};
-	this._cursor = this._tree;
-};
-
-/**
- * Handles identifier tokens when used to indicate the name of a transform to
- * be applied.
- * @param {{type: <string>}} token A token object
- */
-exports.transform = function(token) {
-	this._placeBeforeCursor({
-		type: 'Transform',
-		name: token.value,
-		args: [],
-		subject: this._cursor
-	});
-};
-
-/**
- * Handles token of type 'unaryOp', indicating that the operation has only
- * one input: a right side.
- * @param {{type: <string>}} token A token object
- */
-exports.unaryOp = function(token) {
-	this._placeAtCursor({
-		type: 'UnaryExpression',
-		operator: token.value
-	});
-};
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/states.js
+++ /dev/null
@@ -1,154 +0,0 @@
-/*
- * Jexl
- * Copyright (c) 2015 TechnologyAdvice
- */
-
-var h = require('./handlers');
-
-/**
- * A mapping of all states in the finite state machine to a set of instructions
- * for handling or transitioning into other states. Each state can be handled
- * in one of two schemes: a tokenType map, or a subHandler.
- *
- * Standard expression elements are handled through the tokenType object. This
- * is an object map of all legal token types to encounter in this state (and
- * any unexpected token types will generate a thrown error) to an options
- * object that defines how they're handled.  The available options are:
- *
- *      {string} toState: The name of the state to which to transition
- *          immediately after handling this token
- *      {string} handler: The handler function to call when this token type is
- *          encountered in this state.  If omitted, the default handler
- *          matching the token's "type" property will be called. If the handler
- *          function does not exist, no call will be made and no error will be
- *          generated.  This is useful for tokens whose sole purpose is to
- *          transition to other states.
- *
- * States that consume a subexpression should define a subHandler, the
- * function to be called with an expression tree argument when the
- * subexpression is complete. Completeness is determined through the
- * endStates object, which maps tokens on which an expression should end to the
- * state to which to transition once the subHandler function has been called.
- *
- * Additionally, any state in which it is legal to mark the AST as completed
- * should have a 'completable' property set to boolean true.  Attempting to
- * call {@link Parser#complete} in any state without this property will result
- * in a thrown Error.
- *
- * @type {{}}
- */
-exports.states = {
-	expectOperand: {
-		tokenTypes: {
-			literal: {toState: 'expectBinOp'},
-			identifier: {toState: 'identifier'},
-			unaryOp: {},
-			openParen: {toState: 'subExpression'},
-			openCurl: {toState: 'expectObjKey', handler: h.objStart},
-			dot: {toState: 'traverse'},
-			openBracket: {toState: 'arrayVal', handler: h.arrayStart}
-		}
-	},
-	expectBinOp: {
-		tokenTypes: {
-			binaryOp: {toState: 'expectOperand'},
-			pipe: {toState: 'expectTransform'},
-			dot: {toState: 'traverse'},
-			question: {toState: 'ternaryMid', handler: h.ternaryStart}
-		},
-		completable: true
-	},
-	expectTransform: {
-		tokenTypes: {
-			identifier: {toState: 'postTransform', handler: h.transform}
-		}
-	},
-	expectObjKey: {
-		tokenTypes: {
-			identifier: {toState: 'expectKeyValSep', handler: h.objKey},
-			closeCurl: {toState: 'expectBinOp'}
-		}
-	},
-	expectKeyValSep: {
-		tokenTypes: {
-			colon: {toState: 'objVal'}
-		}
-	},
-	postTransform: {
-		tokenTypes: {
-			openParen: {toState: 'argVal'},
-			binaryOp: {toState: 'expectOperand'},
-			dot: {toState: 'traverse'},
-			openBracket: {toState: 'filter'},
-			pipe: {toState: 'expectTransform'}
-		},
-		completable: true
-	},
-	postTransformArgs: {
-		tokenTypes: {
-			binaryOp: {toState: 'expectOperand'},
-			dot: {toState: 'traverse'},
-			openBracket: {toState: 'filter'},
-			pipe: {toState: 'expectTransform'}
-		},
-		completable: true
-	},
-	identifier: {
-		tokenTypes: {
-			binaryOp: {toState: 'expectOperand'},
-			dot: {toState: 'traverse'},
-			openBracket: {toState: 'filter'},
-			pipe: {toState: 'expectTransform'},
-			question: {toState: 'ternaryMid', handler: h.ternaryStart}
-		},
-		completable: true
-	},
-	traverse: {
-		tokenTypes: {
-			'identifier': {toState: 'identifier'}
-		}
-	},
-	filter: {
-		subHandler: h.filter,
-		endStates: {
-			closeBracket: 'identifier'
-		}
-	},
-	subExpression: {
-		subHandler: h.subExpression,
-		endStates: {
-			closeParen: 'expectBinOp'
-		}
-	},
-	argVal: {
-		subHandler: h.argVal,
-		endStates: {
-			comma: 'argVal',
-			closeParen: 'postTransformArgs'
-		}
-	},
-	objVal: {
-		subHandler: h.objVal,
-		endStates: {
-			comma: 'expectObjKey',
-			closeCurl: 'expectBinOp'
-		}
-	},
-	arrayVal: {
-		subHandler: h.arrayVal,
-		endStates: {
-			comma: 'arrayVal',
-			closeBracket: 'expectBinOp'
-		}
-	},
-	ternaryMid: {
-		subHandler: h.ternaryMid,
-		endStates: {
-			colon: 'ternaryEnd'
-		}
-	},
-	ternaryEnd: {
-		subHandler: h.ternaryEnd,
-		completable: true
-	}
-};
--- a/browser/extensions/shield-recipe-client/skin/osx/Heartbeat.css
+++ b/browser/extensions/shield-recipe-client/skin/osx/Heartbeat.css
@@ -4,8 +4,21 @@
 
 /* Notification overrides for Heartbeat UI */
 
 notification.heartbeat {
   background-image: linear-gradient(-179deg, #FBFBFB 0%, #EBEBEB 100%) !important;
   border-bottom: 1px solid #C1C1C1 !important;
   height: 40px;
 }
+
+/* In themes/osx/global/notification.css the close icon is inverted because notifications
+   on OSX are usually dark. Heartbeat is light, so override that behaviour. */
+
+notification.heartbeat[type="info"] .close-icon:not(:hover) {
+  -moz-image-region: rect(0, 16px, 16px, 0) !important;
+}
+
+@media (min-resolution: 2dppx) {
+  notification.heartbeat[type="info"] .close-icon:not(:hover) {
+    -moz-image-region: rect(0, 32px, 32px, 0) !important;
+  }
+}
--- a/browser/extensions/shield-recipe-client/skin/shared/Heartbeat.css
+++ b/browser/extensions/shield-recipe-client/skin/shared/Heartbeat.css
@@ -3,17 +3,16 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /* Notification overrides for Heartbeat UI */
 
 notification.heartbeat {
   background-color: #F1F1F1 !important;
   border-bottom: 1px solid #C1C1C1 !important;
   height: 40px;
-  color: #333 !important;
 }
 
 @keyframes pulse-onshow {
   0% {
     opacity: 0;
     transform: scale(1);
   }
 
@@ -45,16 +44,17 @@ notification.heartbeat {
   }
 
   100% {
     transform: scale(1);
   }
 }
 
 .messageText.heartbeat {
+  color: #333 !important;
   margin-inline-end: 12px !important; /* The !important is required to override OSX default style. */
   margin-inline-start: 0;
   text-shadow: none;
 }
 
 .messageImage.heartbeat {
   height: 24px !important;
   margin-inline-end: 8px !important;
--- a/browser/extensions/shield-recipe-client/test/browser/.eslintrc.js
+++ b/browser/extensions/shield-recipe-client/test/browser/.eslintrc.js
@@ -3,15 +3,9 @@
 module.exports = {
   extends: [
     "plugin:mozilla/browser-test"
   ],
 
   plugins: [
     "mozilla"
   ],
-
-  globals: {
-    // Bug 1366720 - SimpleTest isn't being exported correctly, so list
-    // it here for now.
-    "SimpleTest": false
-  }
 };
--- a/browser/extensions/shield-recipe-client/test/browser/browser.ini
+++ b/browser/extensions/shield-recipe-client/test/browser/browser.ini
@@ -1,14 +1,22 @@
 [DEFAULT]
+support-files =
+  action_server.sjs
+  fixtures/normandy.xpi
 head = head.js
+[browser_ActionSandboxManager.js]
+[browser_Addons.js]
+[browser_AddonStudies.js]
 [browser_NormandyDriver.js]
 [browser_FilterExpressions.js]
 [browser_EventEmitter.js]
 [browser_Storage.js]
 [browser_Heartbeat.js]
 [browser_RecipeRunner.js]
-support-files =
-  action_server.sjs
 [browser_LogManager.js]
 [browser_ClientEnvironment.js]
 [browser_ShieldRecipeClient.js]
 [browser_PreferenceExperiments.js]
+[browser_about_studies.js]
+[browser_about_preferences.js]
+# Skip this test when FHR/Telemetry aren't available.
+skip-if = !healthreport || !telemetry
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_ActionSandboxManager.js
@@ -0,0 +1,167 @@
+"use strict";
+
+Cu.import("resource://shield-recipe-client/lib/ActionSandboxManager.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm", this);
+
+async function withManager(script, testFunction) {
+  const manager = new ActionSandboxManager(script);
+  manager.addHold("testing");
+  await testFunction(manager);
+  manager.removeHold("testing");
+}
+
+add_task(async function testMissingCallbackName() {
+  await withManager("1 + 1", async manager => {
+    is(
+      await manager.runAsyncCallback("missingCallback"),
+      undefined,
+      "runAsyncCallback returns undefined when given a missing callback name",
+    );
+  });
+});
+
+add_task(async function testCallback() {
+  const script = `
+    registerAsyncCallback("testCallback", async function(normandy) {
+      return 5;
+    });
+  `;
+
+  await withManager(script, async manager => {
+    const result = await manager.runAsyncCallback("testCallback");
+    is(result, 5, "runAsyncCallback executes the named callback inside the sandbox");
+  });
+});
+
+add_task(async function testArguments() {
+  const script = `
+    registerAsyncCallback("testCallback", async function(normandy, a, b) {
+      return a + b;
+    });
+  `;
+
+  await withManager(script, async manager => {
+    const result = await manager.runAsyncCallback("testCallback", 4, 6);
+    is(result, 10, "runAsyncCallback passes arguments to the callback");
+  });
+});
+
+add_task(async function testCloning() {
+  const script = `
+    registerAsyncCallback("testCallback", async function(normandy, obj) {
+      return {foo: "bar", baz: obj.baz};
+    });
+  `;
+
+  await withManager(script, async manager => {
+    const result = await manager.runAsyncCallback("testCallback", {baz: "biff"});
+
+    Assert.deepEqual(
+      result,
+      {foo: "bar", baz: "biff"},
+      (
+        "runAsyncCallback clones arguments into the sandbox and return values into the " +
+        "context it was called from"
+      ),
+    );
+  });
+});
+
+add_task(async function testError() {
+  const script = `
+    registerAsyncCallback("testCallback", async function(normandy) {
+      throw new Error("WHY")
+    });
+  `;
+
+  await withManager(script, async manager => {
+    try {
+      await manager.runAsyncCallback("testCallback");
+      ok(false, "runAsnycCallbackFromScript throws errors when raised by the sandbox");
+    } catch (err) {
+      is(err.message, "WHY", "runAsnycCallbackFromScript throws errors when raised by the sandbox");
+    }
+  });
+});
+
+add_task(async function testDriver() {
+  // The value returned by runAsyncCallback is cloned without the cloneFunctions
+  // option, so we can't inspect the driver itself since its methods will not be
+  // present. Instead, we inspect the properties on it available to the sandbox.
+  const script = `
+    registerAsyncCallback("testCallback", async function(normandy) {
+      return Object.keys(normandy);
+    });
+  `;
+
+  await withManager(script, async manager => {
+    const sandboxDriverKeys = await manager.runAsyncCallback("testCallback");
+    const referenceDriver = new NormandyDriver(manager);
+    for (const prop of Object.keys(referenceDriver)) {
+      ok(sandboxDriverKeys.includes(prop), `runAsyncCallback's driver has the "${prop}" property.`);
+    }
+  });
+});
+
+add_task(async function testGlobalObject() {
+  // Test that window is an alias for the global object, and that it
+  // has some expected functions available on it.
+  const script = `
+    window.setOnWindow = "set";
+    this.setOnGlobal = "set";
+
+    registerAsyncCallback("testCallback", async function(normandy) {
+      return {
+        setOnWindow: setOnWindow,
+        setOnGlobal: window.setOnGlobal,
+        setTimeoutExists: setTimeout !== undefined,
+        clearTimeoutExists: clearTimeout !== undefined,
+      };
+    });
+  `;
+
+  await withManager(script, async manager => {
+    const result = await manager.runAsyncCallback("testCallback");
+    Assert.deepEqual(result, {
+      setOnWindow: "set",
+      setOnGlobal: "set",
+      setTimeoutExists: true,
+      clearTimeoutExists: true,
+    }, "sandbox.window is the global object and has expected functions.");
+  });
+});
+
+add_task(async function testRegisterActionShim() {
+  const recipe = {
+    foo: "bar",
+  };
+  const script = `
+    class TestAction {
+      constructor(driver, recipe) {
+        this.driver = driver;
+        this.recipe = recipe;
+      }
+
+      execute() {
+        return new Promise(resolve => {
+          resolve({
+            foo: this.recipe.foo,
+            isDriver: "log" in this.driver,
+          });
+        });
+      }
+    }
+
+    registerAction('test-action', TestAction);
+  `;
+
+  await withManager(script, async manager => {
+    const result = await manager.runAsyncCallback("action", recipe);
+    is(result.foo, "bar", "registerAction registers an async callback for actions");
+    is(
+      result.isDriver,
+      true,
+      "registerAction passes the driver to the action class constructor",
+    );
+  });
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_AddonStudies.js
@@ -0,0 +1,325 @@
+"use strict";
+
+Cu.import("resource://gre/modules/IndexedDB.jsm", this);
+Cu.import("resource://testing-common/TestUtils.jsm", this);
+Cu.import("resource://testing-common/AddonTestUtils.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/Addons.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/AddonStudies.jsm", this);
+
+// Initialize test utils
+AddonTestUtils.initMochitest(this);
+
+decorate_task(
+  AddonStudies.withStudies(),
+  async function testGetMissing() {
+    is(
+      await AddonStudies.get("does-not-exist"),
+      null,
+      "get returns null when the requested study does not exist"
+    );
+  }
+);
+
+decorate_task(
+  AddonStudies.withStudies([
+    studyFactory({name: "test-study"}),
+  ]),
+  async function testGet([study]) {
+    const storedStudy = await AddonStudies.get(study.recipeId);
+    Assert.deepEqual(study, storedStudy, "get retrieved a study from storage.");
+  }
+);
+
+decorate_task(
+  AddonStudies.withStudies([
+    studyFactory(),
+    studyFactory(),
+  ]),
+  async function testGetAll(studies) {
+    const storedStudies = await AddonStudies.getAll();
+    Assert.deepEqual(
+      new Set(storedStudies),
+      new Set(studies),
+      "getAll returns every stored study.",
+    );
+  }
+);
+
+decorate_task(
+  AddonStudies.withStudies([
+    studyFactory({name: "test-study"}),
+  ]),
+  async function testHas([study]) {
+    let hasStudy = await AddonStudies.has(study.recipeId);
+    ok(hasStudy, "has returns true for a study that exists in storage.");
+
+    hasStudy = await AddonStudies.has("does-not-exist");
+    ok(!hasStudy, "has returns false for a study that doesn't exist in storage.");
+  }
+);
+
+decorate_task(
+  AddonStudies.withStudies(),
+  async function testCloseDatabase() {
+    await AddonStudies.close();
+    const openSpy = sinon.spy(IndexedDB, "open");
+    sinon.assert.notCalled(openSpy);
+
+    // Using studies at all should open the database, but only once.
+    await AddonStudies.has("foo");
+    await AddonStudies.get("foo");
+    sinon.assert.calledOnce(openSpy);
+
+    // close can be called multiple times
+    await AddonStudies.close();
+    await AddonStudies.close();
+
+    // After being closed, new operations cause the database to be opened again
+    await AddonStudies.has("test-study");
+    sinon.assert.calledTwice(openSpy);
+
+    openSpy.restore();
+  }
+);
+
+decorate_task(
+  AddonStudies.withStudies([
+    studyFactory({name: "test-study1"}),
+    studyFactory({name: "test-study2"}),
+  ]),
+  async function testClear([study1, study2]) {
+    const hasAll = (
+      (await AddonStudies.has(study1.recipeId)) &&
+      (await AddonStudies.has(study2.recipeId))
+    );
+    ok(hasAll, "Before calling clear, both studies are in storage.");
+
+    await AddonStudies.clear();
+    const hasAny = (
+      (await AddonStudies.has(study1.recipeId)) ||
+      (await AddonStudies.has(study2.recipeId))
+    );
+    ok(!hasAny, "After calling clear, all studies are removed from storage.");
+  }
+);
+
+let _startArgsFactoryId = 0;
+function startArgsFactory(args) {
+  return Object.assign({
+    recipeId: _startArgsFactoryId++,
+    name: "Test",
+    description: "Test",
+    addonUrl: "http://test/addon.xpi",
+  }, args);
+}
+
+add_task(async function testStartRequiredArguments() {
+  const requiredArguments = startArgsFactory();
+  for (const key in requiredArguments) {
+    const args = Object.assign({}, requiredArguments);
+    delete args[key];
+    Assert.rejects(
+      AddonStudies.start(args),
+      /Required arguments/,
+      `start rejects when missing required argument ${key}.`
+    );
+  }
+});
+
+decorate_task(
+  AddonStudies.withStudies([
+    studyFactory(),
+  ]),
+  async function testStartExisting([study]) {
+    Assert.rejects(
+      AddonStudies.start(startArgsFactory({recipeId: study.recipeId})),
+      /already exists/,
+      "start rejects when a study exists with the given recipeId already."
+    );
+  }
+);
+
+decorate_task(
+  withStub(Addons, "applyInstall"),
+  withWebExtension(),
+  async function testStartAddonCleanup(applyInstallStub, [addonId, addonFile]) {
+    applyInstallStub.rejects(new Error("Fake failure"));
+
+    const addonUrl = Services.io.newFileURI(addonFile).spec;
+    await Assert.rejects(
+      AddonStudies.start(startArgsFactory({addonUrl})),
+      /Fake failure/,
+      "start rejects when the Addons.applyInstall function rejects"
+    );
+
+    const addon = await Addons.get(addonId);
+    ok(!addon, "If something fails during start after the add-on is installed, it is uninstalled.");
+  }
+);
+
+const testOverwriteId = "testStartAddonNoOverwrite@example.com";
+decorate_task(
+  withInstalledWebExtension({version: "1.0", id: testOverwriteId}),
+  withWebExtension({version: "2.0", id: testOverwriteId}),
+  async function testStartAddonNoOverwrite([installedId, installedFile], [id, addonFile]) {
+    const addonUrl = Services.io.newFileURI(addonFile).spec;
+    await Assert.rejects(
+      AddonStudies.start(startArgsFactory({addonUrl})),
+      /updating is disabled/,
+      "start rejects when the study add-on is already installed"
+    );
+
+    await Addons.uninstall(testOverwriteId);
+  }
+);
+
+decorate_task(
+  withWebExtension({version: "2.0"}),
+  async function testStart([addonId, addonFile]) {
+    const startupPromise = AddonTestUtils.promiseWebExtensionStartup(addonId);
+    const addonUrl = Services.io.newFileURI(addonFile).spec;
+
+    let addon = await Addons.get(addonId);
+    is(addon, null, "Before start is called, the add-on is not installed.");
+
+    const args = startArgsFactory({
+      name: "Test Study",
+      description: "Test Desc",
+      addonUrl,
+    });
+    await AddonStudies.start(args);
+    await startupPromise;
+
+    addon = await Addons.get(addonId);
+    ok(addon, "After start is called, the add-on is installed.");
+
+    const study = await AddonStudies.get(args.recipeId);
+    Assert.deepEqual(
+      study,
+      {
+        recipeId: args.recipeId,
+        name: args.name,
+        description: args.description,
+        addonId,
+        addonVersion: "2.0",
+        addonUrl,
+        active: true,
+        studyStartDate: study.studyStartDate,
+      },
+      "start saves study data to storage",
+    );
+    ok(study.studyStartDate, "start assigns a value to the study start date.");
+
+    await Addons.uninstall(addonId);
+  }
+);
+
+decorate_task(
+  AddonStudies.withStudies(),
+  async function testStopNoStudy() {
+    await Assert.rejects(
+      AddonStudies.stop("does-not-exist"),
+      /No study found/,
+      "stop rejects when no study exists for the given recipe."
+    );
+  }
+);
+
+decorate_task(
+  AddonStudies.withStudies([
+    studyFactory({active: false}),
+  ]),
+  async function testStopInactiveStudy([study]) {
+    await Assert.rejects(
+      AddonStudies.stop(study.recipeId),
+      /already inactive/,
+      "stop rejects when the requested study is already inactive."
+    );
+  }
+);
+
+const testStopId = "testStop@example.com";
+decorate_task(
+  AddonStudies.withStudies([
+    studyFactory({active: true, addonId: testStopId, studyEndDate: null}),
+  ]),
+  withInstalledWebExtension({id: testStopId}),
+  async function testStop([study], [addonId, addonFile]) {
+    await AddonStudies.stop(study.recipeId);
+    const newStudy = await AddonStudies.get(study.recipeId);
+    ok(!newStudy.active, "stop marks the study as inactive.");
+    ok(newStudy.studyEndDate, "stop saves the study end date.");
+
+    const addon = await Addons.get(addonId);
+    is(addon, null, "stop uninstalls the study add-on.");
+  }
+);
+
+decorate_task(
+  AddonStudies.withStudies([
+    studyFactory({active: true, addonId: "testStopWarn@example.com", studyEndDate: null}),
+  ]),
+  async function testStopWarn([study]) {
+    const addon = await Addons.get("testStopWarn@example.com");
+    is(addon, null, "Before start is called, the add-on is not installed.");
+
+    // If the add-on is not installed, log a warning to the console, but do not
+    // throw.
+    await new Promise(resolve => {
+      SimpleTest.waitForExplicitFinish();
+      SimpleTest.monitorConsole(resolve, [{message: /Could not uninstall addon/}]);
+      AddonStudies.stop(study.recipeId).then(() => SimpleTest.endMonitorConsole());
+    });
+  }
+);
+
+decorate_task(
+  AddonStudies.withStudies([
+    studyFactory({active: true, addonId: "does.not.exist@example.com", studyEndDate: null}),
+    studyFactory({active: true, addonId: "installed@example.com"}),
+    studyFactory({active: false, addonId: "already.gone@example.com", studyEndDate: new Date(2012, 1)}),
+  ]),
+  withInstalledWebExtension({id: "installed@example.com"}),
+  async function testInit([activeStudy, activeInstalledStudy, inactiveStudy]) {
+    await AddonStudies.init();
+
+    const newActiveStudy = await AddonStudies.get(activeStudy.recipeId);
+    ok(!newActiveStudy.active, "init marks studies as inactive if their add-on is not installed.");
+    ok(
+      newActiveStudy.studyEndDate,
+      "init sets the study end date if a study's add-on is not installed."
+    );
+
+    const newInactiveStudy = await AddonStudies.get(inactiveStudy.recipeId);
+    is(
+      newInactiveStudy.studyEndDate.getFullYear(),
+      2012,
+      "init does not modify inactive studies."
+    );
+
+    const newActiveInstalledStudy = await AddonStudies.get(activeInstalledStudy.recipeId);
+    Assert.deepEqual(
+      activeInstalledStudy,
+      newActiveInstalledStudy,
+      "init does not modify studies whose add-on is still installed."
+    );
+  }
+);
+
+decorate_task(
+  AddonStudies.withStudies([
+    studyFactory({active: true, addonId: "installed@example.com", studyEndDate: null}),
+  ]),
+  withInstalledWebExtension({id: "installed@example.com"}),
+  async function testInit([study], [id, addonFile]) {
+    await Addons.uninstall(id);
+    await TestUtils.topicObserved("shield-study-ended");
+
+    const newStudy = await AddonStudies.get(study.recipeId);
+    ok(!newStudy.active, "Studies are marked as inactive when their add-on is uninstalled.");
+    ok(
+      newStudy.studyEndDate,
+      "The study end date is set when the add-on for the study is uninstalled."
+    );
+  }
+);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_Addons.js
@@ -0,0 +1,34 @@
+"use strict";
+
+Cu.import("resource://testing-common/AddonTestUtils.jsm", this);
+Cu.import("resource://gre/modules/Services.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/Addons.jsm", this);
+
+// Initialize test utils
+AddonTestUtils.initMochitest(this);
+
+const testInstallId = "testInstallUpdate@example.com";
+decorate_task(
+  withInstalledWebExtension({version: "1.0", id: testInstallId}),
+  withWebExtension({version: "2.0", id: testInstallId}),
+  async function testInstallUpdate([id1, addonFile1], [id2, addonFile2]) {
+    // Fail to install the 2.0 add-on without updating enabled
+    const newAddonUrl = Services.io.newFileURI(addonFile2).spec;
+    await Assert.rejects(
+      Addons.install(newAddonUrl, {update: false}),
+      /updating is disabled/,
+      "install rejects when the study add-on is already installed and updating is disabled"
+    );
+
+    // Install the new add-on with updating enabled
+    const startupPromise = AddonTestUtils.promiseWebExtensionStartup(testInstallId);
+    await Addons.install(newAddonUrl, {update: true});
+
+    const addon = await startupPromise;
+    is(
+      addon.version,
+      "2.0",
+      "install can successfully update an already-installed addon when updating is enabled."
+    );
+  }
+);
--- a/browser/extensions/shield-recipe-client/test/browser/browser_ClientEnvironment.js
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_ClientEnvironment.js
@@ -1,14 +1,18 @@
 "use strict";
 
+Cu.import("resource://gre/modules/Services.jsm", this);
 Cu.import("resource://gre/modules/TelemetryController.jsm", this);
+Cu.import("resource://gre/modules/AddonManager.jsm", this);
+Cu.import("resource://testing-common/AddonTestUtils.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/ClientEnvironment.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/PreferenceExperiments.jsm", this);
 
+
 add_task(async function testTelemetry() {
   // setup
   await TelemetryController.submitExternalPing("testfoo", {foo: 1});
   await TelemetryController.submitExternalPing("testbar", {bar: 2});
   const environment = ClientEnvironment.getEnvironment();
 
   // Test it can access telemetry
   const telemetry = await environment.telemetry;
@@ -105,20 +109,33 @@ add_task(async function testExperiments(
     experiments.expired,
     ["expired"],
     "experiments.expired returns all expired experiment names",
   );
 
   getAll.restore();
 });
 
-add_task(async function isFirstRun() {
-  let environment = ClientEnvironment.getEnvironment();
+add_task(withDriver(Assert, async function testAddonsInContext(driver) {
+  // Create before install so that the listener is added before startup completes.
+  const startupPromise = AddonTestUtils.promiseWebExtensionStartup("normandydriver@example.com");
+  const addonId = await driver.addons.install(TEST_XPI_URL);
+  await startupPromise;
 
-  // isFirstRun is initially set to true
-  ok(environment.isFirstRun, "isFirstRun has a default value");
+  const environment = ClientEnvironment.getEnvironment();
+  const addons = await environment.addons;
+  Assert.deepEqual(addons[addonId], {
+    id: [addonId],
+    name: "normandy_fixture",
+    version: "1.0",
+    installDate: addons[addonId].installDate,
+    isActive: true,
+    type: "extension",
+  }, "addons should be available in context");
 
-  // isFirstRun is read from a preference
+  await driver.addons.uninstall(addonId);
+}));
+
+add_task(async function isFirstRun() {
   await SpecialPowers.pushPrefEnv({set: [["extensions.shield-recipe-client.first_run", true]]});
-  environment = ClientEnvironment.getEnvironment();
+  const environment = ClientEnvironment.getEnvironment();
   ok(environment.isFirstRun, "isFirstRun is read from preferences");
 });
-
--- a/browser/extensions/shield-recipe-client/test/browser/browser_EventEmitter.js
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_EventEmitter.js
@@ -21,107 +21,113 @@ function listenerB(x = 1) {
   evidence.log += "b";
 }
 
 function listenerC(x = 1) {
   evidence.c += x;
   evidence.log += "c";
 }
 
-add_task(withSandboxManager(Assert, async function(sandboxManager) {
-  const eventEmitter = new EventEmitter(sandboxManager);
+decorate_task(
+  withSandboxManager(Assert),
+  async function(sandboxManager) {
+    const eventEmitter = new EventEmitter(sandboxManager);
 
-  // Fire an unrelated event, to make sure nothing goes wrong
-  eventEmitter.on("nothing");
+    // Fire an unrelated event, to make sure nothing goes wrong
+    eventEmitter.on("nothing");
 
-  // bind listeners
-  eventEmitter.on("event", listenerA);
-  eventEmitter.on("event", listenerB);
-  eventEmitter.once("event", listenerC);
+    // bind listeners
+    eventEmitter.on("event", listenerA);
+    eventEmitter.on("event", listenerB);
+    eventEmitter.once("event", listenerC);
 
-  // one event for all listeners
-  eventEmitter.emit("event");
-  // another event for a and b, since c should have turned off already
-  eventEmitter.emit("event", 10);
+    // one event for all listeners
+    eventEmitter.emit("event");
+    // another event for a and b, since c should have turned off already
+    eventEmitter.emit("event", 10);
 
-  // make sure events haven't actually fired yet, just queued
-  Assert.deepEqual(evidence, {
-    a: 0,
-    b: 0,
-    c: 0,
-    log: "",
-  }, "events are fired async");
+    // make sure events haven't actually fired yet, just queued
+    Assert.deepEqual(evidence, {
+      a: 0,
+      b: 0,
+      c: 0,
+      log: "",
+    }, "events are fired async");
 
-  // Spin the event loop to run events, so we can safely "off"
-  await Promise.resolve();
+    // Spin the event loop to run events, so we can safely "off"
+    await Promise.resolve();
 
-  // Check intermediate event results
-  Assert.deepEqual(evidence, {
-    a: 11,
-    b: 11,
-    c: 1,
-    log: "abcab",
-  }, "intermediate events are fired");
+    // Check intermediate event results
+    Assert.deepEqual(evidence, {
+      a: 11,
+      b: 11,
+      c: 1,
+      log: "abcab",
+    }, "intermediate events are fired");
 
-  // one more event for a
-  eventEmitter.off("event", listenerB);
-  eventEmitter.emit("event", 100);
+    // one more event for a
+    eventEmitter.off("event", listenerB);
+    eventEmitter.emit("event", 100);
 
-  // And another unrelated event
-  eventEmitter.on("nothing");
+    // And another unrelated event
+    eventEmitter.on("nothing");
 
-  // Spin the event loop to run events
-  await Promise.resolve();
+    // Spin the event loop to run events
+    await Promise.resolve();
 
-  Assert.deepEqual(evidence, {
-    a: 111,
-    b: 11,
-    c: 1,
-    log: "abcaba",  // events are in order
-  }, "events fired as expected");
+    Assert.deepEqual(evidence, {
+      a: 111,
+      b: 11,
+      c: 1,
+      log: "abcaba",  // events are in order
+    }, "events fired as expected");
 
-  // Test that mutating the data passed to the event doesn't actually
-  // mutate it for other events.
-  let handlerRunCount = 0;
-  const mutationHandler = data => {
-    handlerRunCount++;
-    data.count++;
-    is(data.count, 1, "Event data is not mutated between handlers.");
-  };
-  eventEmitter.on("mutationTest", mutationHandler);
-  eventEmitter.on("mutationTest", mutationHandler);
+    // Test that mutating the data passed to the event doesn't actually
+    // mutate it for other events.
+    let handlerRunCount = 0;
+    const mutationHandler = data => {
+      handlerRunCount++;
+      data.count++;
+      is(data.count, 1, "Event data is not mutated between handlers.");
+    };
+    eventEmitter.on("mutationTest", mutationHandler);
+    eventEmitter.on("mutationTest", mutationHandler);
 
-  const data = {count: 0};
-  eventEmitter.emit("mutationTest", data);
-  await Promise.resolve();
+    const data = {count: 0};
+    eventEmitter.emit("mutationTest", data);
+    await Promise.resolve();
 
-  is(handlerRunCount, 2, "Mutation handler was executed twice.");
-  is(data.count, 0, "Event data cannot be mutated by handlers.");
-}));
+    is(handlerRunCount, 2, "Mutation handler was executed twice.");
+    is(data.count, 0, "Event data cannot be mutated by handlers.");
+  }
+);
 
-add_task(withSandboxManager(Assert, async function sandboxedEmitter(sandboxManager) {
-  const eventEmitter = new EventEmitter(sandboxManager);
+decorate_task(
+  withSandboxManager(Assert),
+  async function sandboxedEmitter(sandboxManager) {
+    const eventEmitter = new EventEmitter(sandboxManager);
 
-  // Event handlers inside the sandbox should be run in response to
-  // events triggered outside the sandbox.
-  sandboxManager.addGlobal("emitter", eventEmitter.createSandboxedEmitter());
-  sandboxManager.evalInSandbox(`
-    this.eventCounts = {on: 0, once: 0};
-    emitter.on("event", value => {
-      this.eventCounts.on += value;
-    });
-    emitter.once("eventOnce", value => {
-      this.eventCounts.once += value;
-    });
-  `);
+    // Event handlers inside the sandbox should be run in response to
+    // events triggered outside the sandbox.
+    sandboxManager.addGlobal("emitter", eventEmitter.createSandboxedEmitter());
+    sandboxManager.evalInSandbox(`
+      this.eventCounts = {on: 0, once: 0};
+      emitter.on("event", value => {
+        this.eventCounts.on += value;
+      });
+      emitter.once("eventOnce", value => {
+        this.eventCounts.once += value;
+      });
+    `);
 
-  eventEmitter.emit("event", 5);
-  eventEmitter.emit("event", 10);
-  eventEmitter.emit("eventOnce", 5);
-  eventEmitter.emit("eventOnce", 10);
-  await Promise.resolve();
+    eventEmitter.emit("event", 5);
+    eventEmitter.emit("event", 10);
+    eventEmitter.emit("eventOnce", 5);
+    eventEmitter.emit("eventOnce", 10);
+    await Promise.resolve();
 
-  const eventCounts = sandboxManager.evalInSandbox("this.eventCounts");
-  Assert.deepEqual(eventCounts, {
-    on: 15,
-    once: 5,
-  }, "Events emitted outside a sandbox trigger handlers within a sandbox.");
-}));
+    const eventCounts = sandboxManager.evalInSandbox("this.eventCounts");
+    Assert.deepEqual(eventCounts, {
+      on: 15,
+      once: 5,
+    }, "Events emitted outside a sandbox trigger handlers within a sandbox.");
+  }
+);
--- a/browser/extensions/shield-recipe-client/test/browser/browser_FilterExpressions.js
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_FilterExpressions.js
@@ -86,8 +86,103 @@ add_task(async function() {
   ok(val, "preferenceIsUserSet expression determines if user's preference has been set");
 
   // Compare if the preference has _any_ value, whether it's user-set or default,
   val = await FilterExpressions.eval('"normandy.test.nonexistant"|preferenceExists == true');
   ok(!val, "preferenceExists expression determines if preference exists at all");
   val = await FilterExpressions.eval('"normandy.test.value"|preferenceExists == true');
   ok(val, "preferenceExists expression fails existence check appropriately");
 });
+
+// keys tests
+add_task(async function testKeys() {
+  let val;
+
+  // Test an object defined in JEXL
+  val = await FilterExpressions.eval("{foo: 1, bar: 2}|keys");
+  Assert.deepEqual(
+    new Set(val),
+    new Set(["foo", "bar"]),
+    "keys returns the keys from an object in JEXL",
+  );
+
+  // Test an object in the context
+  let context = {ctxObject: {baz: "string", biff: NaN}};
+  val = await FilterExpressions.eval("ctxObject|keys", context);
+
+  Assert.deepEqual(
+    new Set(val),
+    new Set(["baz", "biff"]),
+    "keys returns the keys from an object in the context",
+  );
+
+  // Test that values from the prototype are not included
+  context = {ctxObject: Object.create({fooProto: 7})};
+  context.ctxObject.baz = 8;
+  context.ctxObject.biff = 5;
+  is(
+    await FilterExpressions.eval("ctxObject.fooProto", context),
+    7,
+    "Prototype properties are accessible via property access",
+  );
+  val = await FilterExpressions.eval("ctxObject|keys", context);
+  Assert.deepEqual(
+    new Set(val),
+    new Set(["baz", "biff"]),
+    "keys does not return properties from the object's prototype chain",
+  );
+
+  // Return undefined for non-objects
+  is(
+    await FilterExpressions.eval("ctxObject|keys", {ctxObject: 45}),
+    undefined,
+    "keys returns undefined for numbers",
+  );
+  is(
+    await FilterExpressions.eval("ctxObject|keys", {ctxObject: null}),
+    undefined,
+    "keys returns undefined for null",
+  );
+});
+
+// intersect tests
+add_task(async function testIntersect() {
+  let val;
+
+  val = await FilterExpressions.eval("[1, 2, 3] intersect [4, 2, 6, 7, 3]");
+  Assert.deepEqual(
+    new Set(val),
+    new Set([2, 3]),
+    "intersect finds the common elements between two lists in JEXL",
+  );
+
+  const context = {left: [5, 7], right: [4, 5, 3]};
+  val = await FilterExpressions.eval("left intersect right", context);
+  Assert.deepEqual(
+    new Set(val),
+    new Set([5]),
+    "intersect finds the common elements between two lists in the context",
+  );
+
+  val = await FilterExpressions.eval("['string', 2] intersect [4, 'string', 'other', 3]");
+  Assert.deepEqual(
+    new Set(val),
+    new Set(["string"]),
+    "intersect can compare strings",
+  );
+
+  // Return undefined when intersecting things that aren't lists.
+  is(
+    await FilterExpressions.eval("5 intersect 7"),
+    undefined,
+    "intersect returns undefined for numbers",
+  );
+  is(
+    await FilterExpressions.eval("val intersect other", {val: null, other: null}),
+    undefined,
+    "intersect returns undefined for null",
+  );
+  is(
+    await FilterExpressions.eval("5 intersect [1, 2, 5]"),
+    undefined,
+    "intersect returns undefined if only one operand is a list",
+  );
+});
--- a/browser/extensions/shield-recipe-client/test/browser/browser_NormandyDriver.js
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_NormandyDriver.js
@@ -1,22 +1,68 @@
 "use strict";
 
+Cu.import("resource://gre/modules/AppConstants.jsm");
+Cu.import("resource://testing-common/AddonTestUtils.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/AddonStudies.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm", this);
 
 add_task(withDriver(Assert, async function uuids(driver) {
   // Test that it is a UUID
   const uuid1 = driver.uuid();
   ok(UUID_REGEX.test(uuid1), "valid uuid format");
 
   // Test that UUIDs are different each time
   const uuid2 = driver.uuid();
   isnot(uuid1, uuid2, "uuids are unique");
 }));
 
+add_task(withDriver(Assert, async function installXpi(driver) {
+  // Test that we can install an XPI from any URL
+  // Create before install so that the listener is added before startup completes.
+  const startupPromise = AddonTestUtils.promiseWebExtensionStartup("normandydriver@example.com");
+
+  var addonId = await driver.addons.install(TEST_XPI_URL);
+  is(addonId, "normandydriver@example.com", "Expected test addon was installed");
+  isnot(addonId, null, "Addon install was successful");
+
+  // Wait until the add-on is fully started up to uninstall it.
+  await startupPromise;
+
+  const uninstallMsg = await driver.addons.uninstall(addonId);
+  is(uninstallMsg, null, `Uninstall returned an unexpected message [${uninstallMsg}]`);
+}));
+
+add_task(withDriver(Assert, async function uninstallInvalidAddonId(driver) {
+  const invalidAddonId = "not_a_valid_xpi_id@foo.bar";
+  try {
+    await driver.addons.uninstall(invalidAddonId);
+    ok(false, `Uninstalling an invalid XPI should fail. addons.uninstall resolved successfully though.`);
+  } catch (e) {
+    ok(true, `This is the expected failure`);
+  }
+}));
+
+
+add_task(withDriver(Assert, async function installXpiBadURL(driver) {
+  let xpiUrl;
+  if (AppConstants.platform === "win") {
+    xpiUrl = "file:///C:/invalid_xpi.xpi";
+  } else {
+    xpiUrl = "file:///tmp/invalid_xpi.xpi";
+  }
+
+  try {
+    await driver.addons.install(xpiUrl);
+    ok(false, "Installation succeeded on an XPI that doesn't exist");
+  } catch (reason) {
+    ok(true, `Installation was rejected: [${reason}]`);
+  }
+}));
+
 add_task(withDriver(Assert, async function userId(driver) {
   // Test that userId is a UUID
   ok(UUID_REGEX.test(driver.userId), "userId is a uuid");
 }));
 
 add_task(withDriver(Assert, async function syncDeviceCounts(driver) {
   let client = await driver.client();
   is(client.syncMobileDevices, 0, "syncMobileDevices defaults to zero");
@@ -40,43 +86,232 @@ add_task(withDriver(Assert, async functi
   let client = await driver.client();
   is(client.distribution, "default", "distribution has a default value");
 
   await SpecialPowers.pushPrefEnv({set: [["distribution.id", "funnelcake"]]});
   client = await driver.client();
   is(client.distribution, "funnelcake", "distribution is read from preferences");
 }));
 
-add_task(withSandboxManager(Assert, async function testCreateStorage(sandboxManager) {
-  const driver = new NormandyDriver(sandboxManager);
-  sandboxManager.cloneIntoGlobal("driver", driver, {cloneFunctions: true});
+decorate_task(
+  withSandboxManager(Assert),
+  async function testCreateStorage(sandboxManager) {
+    const driver = new NormandyDriver(sandboxManager);
+    sandboxManager.cloneIntoGlobal("driver", driver, {cloneFunctions: true});
+
+    // Assertion helpers
+    sandboxManager.addGlobal("is", is);
+    sandboxManager.addGlobal("deepEqual", (...args) => Assert.deepEqual(...args));
+
+    await sandboxManager.evalInSandbox(`
+      (async function sandboxTest() {
+        const store = driver.createStorage("testprefix");
+        const otherStore = driver.createStorage("othertestprefix");
+        await store.clear();
+        await otherStore.clear();
+
+        await store.setItem("willremove", 7);
+        await otherStore.setItem("willremove", 4);
+        is(await store.getItem("willremove"), 7, "createStorage stores sandbox values");
+        is(
+          await otherStore.getItem("willremove"),
+          4,
+          "values are not shared between createStorage stores",
+        );
+
+        const deepValue = {"foo": ["bar", "baz"]};
+        await store.setItem("deepValue", deepValue);
+        deepEqual(await store.getItem("deepValue"), deepValue, "createStorage clones stored values");
+
+        await store.removeItem("willremove");
+        is(await store.getItem("willremove"), null, "createStorage removes items");
+
+        is('prefix' in store, false, "createStorage doesn't expose non-whitelist attributes");
+      })();
+    `);
+  }
+);
+
+add_task(withDriver(Assert, async function getAddon(driver, sandboxManager) {
+  const ADDON_ID = "normandydriver@example.com";
+  let addon = await driver.addons.get(ADDON_ID);
+  Assert.equal(addon, null, "Add-on is not yet installed");
+
+  await driver.addons.install(TEST_XPI_URL);
+  addon = await driver.addons.get(ADDON_ID);
+
+  Assert.notEqual(addon, null, "Add-on object was returned");
+  ok(addon.installDate instanceof sandboxManager.sandbox.Date, "installDate should be a Date object");
 
-  // Assertion helpers
-  sandboxManager.addGlobal("is", is);
-  sandboxManager.addGlobal("deepEqual", (...args) => Assert.deepEqual(...args));
+  Assert.deepEqual(addon, {
+    id: "normandydriver@example.com",
+    name: "normandy_fixture",
+    version: "1.0",
+    installDate: addon.installDate,
+    isActive: true,
+    type: "extension",
+  }, "Add-on is installed");
+
+  await driver.addons.uninstall(ADDON_ID);
+  addon = await driver.addons.get(ADDON_ID);
+
+  Assert.equal(addon, null, "Add-on has been uninstalled");
+}));
+
+decorate_task(
+  withSandboxManager(Assert),
+  async function testAddonsGetWorksInSandbox(sandboxManager) {
+    const driver = new NormandyDriver(sandboxManager);
+    sandboxManager.cloneIntoGlobal("driver", driver, {cloneFunctions: true});
+
+    // Assertion helpers
+    sandboxManager.addGlobal("is", is);
+    sandboxManager.addGlobal("deepEqual", (...args) => Assert.deepEqual(...args));
+
+    const ADDON_ID = "normandydriver@example.com";
+
+    await driver.addons.install(TEST_XPI_URL);
 
-  await sandboxManager.evalInSandbox(`
-    (async function sandboxTest() {
-      const store = driver.createStorage("testprefix");
-      const otherStore = driver.createStorage("othertestprefix");
-      await store.clear();
-      await otherStore.clear();
+    await sandboxManager.evalInSandbox(`
+      (async function sandboxTest() {
+        const addon = await driver.addons.get("${ADDON_ID}");
+
+        deepEqual(addon, {
+          id: "${ADDON_ID}",
+          name: "normandy_fixture",
+          version: "1.0",
+          installDate: addon.installDate,
+          isActive: true,
+          type: "extension",
+        }, "Add-on is accesible in the driver");
+      })();
+    `);
+
+    await driver.addons.uninstall(ADDON_ID);
+  }
+);
+
+decorate_task(
+  withSandboxManager(Assert),
+  withWebExtension({id: "driver-addon-studies@example.com"}),
+  async function testAddonStudies(sandboxManager, [addonId, addonFile]) {
+    const addonUrl = Services.io.newFileURI(addonFile).spec;
+    const driver = new NormandyDriver(sandboxManager);
+    sandboxManager.cloneIntoGlobal("driver", driver, {cloneFunctions: true});
+
+    // Assertion helpers
+    sandboxManager.addGlobal("is", is);
+    sandboxManager.addGlobal("ok", ok);
 
-      await store.setItem("willremove", 7);
-      await otherStore.setItem("willremove", 4);
-      is(await store.getItem("willremove"), 7, "createStorage stores sandbox values");
-      is(
-        await otherStore.getItem("willremove"),
-        4,
-        "values are not shared between createStorage stores",
-      );
+    await sandboxManager.evalInSandbox(`
+      (async function sandboxTest() {
+        const recipeId = 5;
+        let hasStudy = await driver.studies.has(recipeId);
+        ok(!hasStudy, "studies.has returns false if the study hasn't been started yet.");
+
+        await driver.studies.start({
+          recipeId,
+          name: "fake",
+          description: "fake",
+          addonUrl: "${addonUrl}",
+        });
+        hasStudy = await driver.studies.has(recipeId);
+        ok(hasStudy, "studies.has returns true after the study has been started.");
+
+        let study = await driver.studies.get(recipeId);
+        is(
+          study.addonId,
+          "driver-addon-studies@example.com",
+          "studies.get fetches studies from within a sandbox."
+        );
+        ok(study.active, "Studies are marked as active after being started by the driver.");
+
+        await driver.studies.stop(recipeId);
+        study = await driver.studies.get(recipeId);
+        ok(!study.active, "Studies are marked as inactive after being stopped by the driver.");
+      })();
+    `);
+  }
+);
+
+decorate_task(
+  withPrefEnv({
+    set: [
+      ["test.char", "a string"],
+      ["test.int", 5],
+      ["test.bool", true],
+    ],
+  }),
+  withSandboxManager(Assert, async function testPreferences(sandboxManager) {
+    const driver = new NormandyDriver(sandboxManager);
+    sandboxManager.cloneIntoGlobal("driver", driver, {cloneFunctions: true});
+
+    // Assertion helpers
+    sandboxManager.addGlobal("is", is);
+    sandboxManager.addGlobal("ok", ok);
+    sandboxManager.addGlobal("assertThrows", Assert.throws.bind(Assert));
 
-      const deepValue = {"foo": ["bar", "baz"]};
-      await store.setItem("deepValue", deepValue);
-      deepEqual(await store.getItem("deepValue"), deepValue, "createStorage clones stored values");
-
-      await store.removeItem("willremove");
-      is(await store.getItem("willremove"), null, "createStorage removes items");
-
-      is('prefix' in store, false, "createStorage doesn't expose non-whitelist attributes");
-    })();
-  `);
-}));
+    await sandboxManager.evalInSandbox(`
+      (async function sandboxTest() {
+        ok(
+          driver.preferences.getBool("test.bool"),
+          "preferences.getBool can retrieve boolean preferences."
+        );
+        is(
+          driver.preferences.getInt("test.int"),
+          5,
+          "preferences.getInt can retrieve integer preferences."
+        );
+        is(
+          driver.preferences.getChar("test.char"),
+          "a string",
+          "preferences.getChar can retrieve string preferences."
+        );
+        assertThrows(
+          () => driver.preferences.getChar("test.int"),
+          "preferences.getChar throws when retreiving a non-string preference."
+        );
+        assertThrows(
+          () => driver.preferences.getInt("test.bool"),
+          "preferences.getInt throws when retreiving a non-integer preference."
+        );
+        assertThrows(
+          () => driver.preferences.getBool("test.char"),
+          "preferences.getBool throws when retreiving a non-boolean preference."
+        );
+        assertThrows(
+          () => driver.preferences.getChar("test.does.not.exist"),
+          "preferences.getChar throws when retreiving a non-existant preference."
+        );
+        assertThrows(
+          () => driver.preferences.getInt("test.does.not.exist"),
+          "preferences.getInt throws when retreiving a non-existant preference."
+        );
+        assertThrows(
+          () => driver.preferences.getBool("test.does.not.exist"),
+          "preferences.getBool throws when retreiving a non-existant preference."
+        );
+        ok(
+          driver.preferences.getBool("test.does.not.exist", true),
+          "preferences.getBool returns a default value if the preference doesn't exist."
+        );
+        is(
+          driver.preferences.getInt("test.does.not.exist", 7),
+          7,
+          "preferences.getInt returns a default value if the preference doesn't exist."
+        );
+        is(
+          driver.preferences.getChar("test.does.not.exist", "default"),
+          "default",
+          "preferences.getChar returns a default value if the preference doesn't exist."
+        );
+        ok(
+          driver.preferences.has("test.char"),
+          "preferences.has returns true if the given preference exists."
+        );
+        ok(
+          !driver.preferences.has("test.does.not.exist"),
+          "preferences.has returns false if the given preference does not exist."
+        );
+      })();
+    `);
+  })
+);
--- a/browser/extensions/shield-recipe-client/test/browser/browser_PreferenceExperiments.js
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_PreferenceExperiments.js
@@ -370,16 +370,19 @@ add_task(withMockExperiments(withMockPre
 
   stopObserver.restore();
   PreferenceExperiments.stopAllObservers();
 })));
 
 // stop should also support user pref experiments
 add_task(withMockExperiments(withMockPreferences(async function(experiments, mockPreferences) {
   const stopObserver = sinon.stub(PreferenceExperiments, "stopObserver");
+  const hasObserver = sinon.stub(PreferenceExperiments, "hasObserver");
+  hasObserver.returns(true);
+
   mockPreferences.set("fake.preference", "experimentvalue", "user");
   experiments.test = experimentFactory({
     name: "test",
     expired: false,
     preferenceName: "fake.preference",
     preferenceValue: "experimentvalue",
     preferenceType: "string",
     previousPreferenceValue: "oldvalue",
@@ -392,16 +395,17 @@ add_task(withMockExperiments(withMockPre
   is(experiments.test.expired, true, "stop marked the experiment as expired");
   is(
     Preferences.get("fake.preference"),
     "oldvalue",
     "stop reverted the preference to its previous value",
   );
 
   stopObserver.restore();
+  hasObserver.restore();
 })));
 
 // stop should not call stopObserver if there is no observer registered.
 add_task(withMockExperiments(withMockPreferences(async function(experiments) {
   const stopObserver = sinon.spy(PreferenceExperiments, "stopObserver");
   experiments.test = experimentFactory({name: "test", expired: false});
 
   await PreferenceExperiments.stop("test");
--- a/browser/extensions/shield-recipe-client/test/browser/browser_RecipeRunner.js
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_RecipeRunner.js
@@ -1,15 +1,18 @@
 "use strict";
 
+Cu.import("resource://testing-common/TestUtils.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/RecipeRunner.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/ClientEnvironment.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/CleanupManager.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/NormandyApi.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/ActionSandboxManager.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/AddonStudies.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/Uptake.jsm", this);
 
 add_task(async function getFilterContext() {
   const recipe = {id: 17, arguments: {foo: "bar"}, unrelated: false};
   const context = RecipeRunner.getFilterContext(recipe);
 
   // Test for expected properties in the filter expression context.
   const expectedNormandyKeys = [
     "channel",
@@ -64,17 +67,17 @@ add_task(async function checkFilter() {
 });
 
 add_task(withMockNormandyApi(async function testClientClassificationCache() {
   const getStub = sinon.stub(ClientEnvironment, "getClientClassification")
     .returns(Promise.resolve(false));
 
   await SpecialPowers.pushPrefEnv({set: [
     ["extensions.shield-recipe-client.api_url",
-     "https://example.com/selfsupport-dummy"],
+      "https://example.com/selfsupport-dummy"],
   ]});
 
   // When the experiment pref is false, eagerly call getClientClassification.
   await SpecialPowers.pushPrefEnv({set: [
     ["extensions.shield-recipe-client.experiments.lazy_classify", false],
   ]});
   ok(!getStub.called, "getClientClassification hasn't been called");
   await RecipeRunner.run();
@@ -93,37 +96,46 @@ add_task(withMockNormandyApi(async funct
 }));
 
 /**
  * Mocks RecipeRunner.loadActionSandboxManagers for testing run.
  */
 async function withMockActionSandboxManagers(actions, testFunction) {
   const managers = {};
   for (const action of actions) {
-    managers[action.name] = new ActionSandboxManager("");
+    const manager = new ActionSandboxManager("");
+    manager.addHold("testing");
+    managers[action.name] = manager;
     sinon.stub(managers[action.name], "runAsyncCallback");
   }
 
-  const loadActionSandboxManagers = sinon.stub(
-    RecipeRunner,
-    "loadActionSandboxManagers",
-    async () => managers,
-  );
+  const loadActionSandboxManagers = sinon.stub(RecipeRunner, "loadActionSandboxManagers")
+    .resolves(managers);
   await testFunction(managers);
   loadActionSandboxManagers.restore();
+
+  for (const manager of Object.values(managers)) {
+    manager.removeHold("testing");
+    await manager.isNuked();
+  }
 }
 
 add_task(withMockNormandyApi(async function testRun(mockApi) {
+  const closeSpy = sinon.spy(AddonStudies, "close");
+  const reportRunner = sinon.stub(Uptake, "reportRunner");
+  const reportAction = sinon.stub(Uptake, "reportAction");
+  const reportRecipe = sinon.stub(Uptake, "reportRecipe");
+
   const matchAction = {name: "matchAction"};
   const noMatchAction = {name: "noMatchAction"};
   mockApi.actions = [matchAction, noMatchAction];
 
-  const matchRecipe = {action: "matchAction", filter_expression: "true"};
-  const noMatchRecipe = {action: "noMatchAction", filter_expression: "false"};
-  const missingRecipe = {action: "missingAction", filter_expression: "true"};
+  const matchRecipe = {id: "match", action: "matchAction", filter_expression: "true"};
+  const noMatchRecipe = {id: "noMatch", action: "noMatchAction", filter_expression: "false"};
+  const missingRecipe = {id: "missing", action: "missingAction", filter_expression: "true"};
   mockApi.recipes = [matchRecipe, noMatchRecipe, missingRecipe];
 
   await withMockActionSandboxManagers(mockApi.actions, async managers => {
     const matchManager = managers.matchAction;
     const noMatchManager = managers.noMatchAction;
 
     await RecipeRunner.run();
 
@@ -134,28 +146,109 @@ add_task(withMockNormandyApi(async funct
 
     // noMatch should be called for preExecution and postExecution, and skipped
     // for action since the filter expression does not match.
     sinon.assert.calledWith(noMatchManager.runAsyncCallback, "preExecution");
     sinon.assert.neverCalledWith(noMatchManager.runAsyncCallback, "action", noMatchRecipe);
     sinon.assert.calledWith(noMatchManager.runAsyncCallback, "postExecution");
 
     // missing is never called at all due to no matching action/manager.
-    await matchManager.isNuked();
-    await noMatchManager.isNuked();
+
+    // Test uptake reporting
+    sinon.assert.calledWith(reportRunner, Uptake.RUNNER_SUCCESS);
+    sinon.assert.calledWith(reportAction, "matchAction", Uptake.ACTION_SUCCESS);
+    sinon.assert.calledWith(reportAction, "noMatchAction", Uptake.ACTION_SUCCESS);
+    sinon.assert.calledWith(reportRecipe, "match", Uptake.RECIPE_SUCCESS);
+    sinon.assert.neverCalledWith(reportRecipe, "noMatch", Uptake.RECIPE_SUCCESS);
+    sinon.assert.calledWith(reportRecipe, "missing", Uptake.RECIPE_INVALID_ACTION);
   });
+
+  // Ensure storage is closed after the run.
+  sinon.assert.calledOnce(closeSpy);
+
+  closeSpy.restore();
+  reportRunner.restore();
+  reportAction.restore();
+  reportRecipe.restore();
+}));
+
+add_task(withMockNormandyApi(async function testRunRecipeError(mockApi) {
+  const reportRecipe = sinon.stub(Uptake, "reportRecipe");
+
+  const action = {name: "action"};
+  mockApi.actions = [action];
+
+  const recipe = {id: "recipe", action: "action", filter_expression: "true"};
+  mockApi.recipes = [recipe];
+
+  await withMockActionSandboxManagers(mockApi.actions, async managers => {
+    const manager = managers.action;
+    manager.runAsyncCallback.callsFake(async callbackName => {
+      if (callbackName === "action") {
+        throw new Error("Action execution failure");
+      }
+    });
+
+    await RecipeRunner.run();
+
+    // Uptake should report that the recipe threw an exception
+    sinon.assert.calledWith(reportRecipe, "recipe", Uptake.RECIPE_EXECUTION_ERROR);
+  });
+
+  reportRecipe.restore();
+}));
+
+add_task(withMockNormandyApi(async function testRunFetchFail(mockApi) {
+  const closeSpy = sinon.spy(AddonStudies, "close");
+  const reportRunner = sinon.stub(Uptake, "reportRunner");
+
+  const action = {name: "action"};
+  mockApi.actions = [action];
+  mockApi.fetchRecipes.rejects(new Error("Signature not valid"));
+
+  await withMockActionSandboxManagers(mockApi.actions, async managers => {
+    const manager = managers.action;
+    await RecipeRunner.run();
+
+    // If the recipe fetch failed, do not run anything.
+    sinon.assert.notCalled(manager.runAsyncCallback);
+    sinon.assert.calledWith(reportRunner, Uptake.RUNNER_SERVER_ERROR);
+
+    // Test that network errors report a specific uptake error
+    reportRunner.reset();
+    mockApi.fetchRecipes.rejects(new Error("NetworkError: The system was down"));
+    await RecipeRunner.run();
+    sinon.assert.calledWith(reportRunner, Uptake.RUNNER_NETWORK_ERROR);
+
+    // Test that signature issues report a specific uptake error
+    reportRunner.reset();
+    mockApi.fetchRecipes.rejects(new NormandyApi.InvalidSignatureError("Signature fail"));
+    await RecipeRunner.run();
+    sinon.assert.calledWith(reportRunner, Uptake.RUNNER_INVALID_SIGNATURE);
+  });
+
+  // If the recipe fetch failed, we don't need to call close since nothing
+  // opened a connection in the first place.
+  sinon.assert.notCalled(closeSpy);
+
+  closeSpy.restore();
+  reportRunner.restore();
 }));
 
 add_task(withMockNormandyApi(async function testRunPreExecutionFailure(mockApi) {
+  const closeSpy = sinon.spy(AddonStudies, "close");
+  const reportAction = sinon.stub(Uptake, "reportAction");
+  const reportRecipe = sinon.stub(Uptake, "reportRecipe");
+
   const passAction = {name: "passAction"};
   const failAction = {name: "failAction"};
   mockApi.actions = [passAction, failAction];
 
-  const passRecipe = {action: "passAction", filter_expression: "true"};
-  const failRecipe = {action: "failAction", filter_expression: "true"};
+  const passRecipe = {id: "pass", action: "passAction", filter_expression: "true"};
+  const failRecipe = {id: "fail", action: "failAction", filter_expression: "true"};
   mockApi.recipes = [passRecipe, failRecipe];
 
   await withMockActionSandboxManagers(mockApi.actions, async managers => {
     const passManager = managers.passAction;
     const failManager = managers.failAction;
     failManager.runAsyncCallback.returns(Promise.reject(new Error("oh no")));
 
     await RecipeRunner.run();
@@ -165,19 +258,57 @@ add_task(withMockNormandyApi(async funct
     sinon.assert.calledWith(passManager.runAsyncCallback, "action", passRecipe);
     sinon.assert.calledWith(passManager.runAsyncCallback, "postExecution");
 
     // fail should only be called for preExecution, since it fails during that
     sinon.assert.calledWith(failManager.runAsyncCallback, "preExecution");
     sinon.assert.neverCalledWith(failManager.runAsyncCallback, "action", failRecipe);
     sinon.assert.neverCalledWith(failManager.runAsyncCallback, "postExecution");
 
-    await passManager.isNuked();
-    await failManager.isNuked();
+    sinon.assert.calledWith(reportAction, "passAction", Uptake.ACTION_SUCCESS);
+    sinon.assert.calledWith(reportAction, "failAction", Uptake.ACTION_PRE_EXECUTION_ERROR);
+    sinon.assert.calledWith(reportRecipe, "fail", Uptake.RECIPE_ACTION_DISABLED);
   });
+
+  // Ensure storage is closed after the run, despite the failures.
+  sinon.assert.calledOnce(closeSpy);
+  closeSpy.restore();
+  reportAction.restore();
+  reportRecipe.restore();
+}));
+
+add_task(withMockNormandyApi(async function testRunPostExecutionFailure(mockApi) {
+  const reportAction = sinon.stub(Uptake, "reportAction");
+
+  const failAction = {name: "failAction"};
+  mockApi.actions = [failAction];
+
+  const failRecipe = {action: "failAction", filter_expression: "true"};
+  mockApi.recipes = [failRecipe];
+
+  await withMockActionSandboxManagers(mockApi.actions, async managers => {
+    const failManager = managers.failAction;
+    failManager.runAsyncCallback.callsFake(async callbackName => {
+      if (callbackName === "postExecution") {
+        throw new Error("postExecution failure");
+      }
+    });
+
+    await RecipeRunner.run();
+
+    // fail should be called for every stage
+    sinon.assert.calledWith(failManager.runAsyncCallback, "preExecution");
+    sinon.assert.calledWith(failManager.runAsyncCallback, "action", failRecipe);
+    sinon.assert.calledWith(failManager.runAsyncCallback, "postExecution");
+
+    // Uptake should report a post-execution error
+    sinon.assert.calledWith(reportAction, "failAction", Uptake.ACTION_POST_EXECUTION_ERROR);
+  });
+
+  reportAction.restore();
 }));
 
 add_task(withMockNormandyApi(async function testLoadActionSandboxManagers(mockApi) {
   mockApi.actions = [
     {name: "normalAction"},
     {name: "missingImpl"},
   ];
   mockApi.implementations.normalAction = "window.scriptRan = true";
@@ -188,47 +319,70 @@ add_task(withMockNormandyApi(async funct
 
   const normalManager = managers.normalAction;
   ok(
     await normalManager.evalInSandbox("window.scriptRan"),
     "Implementations are run in the sandbox",
   );
 }));
 
-add_task(async function testStartup() {
-  const runStub = sinon.stub(RecipeRunner, "run");
-  const addCleanupHandlerStub = sinon.stub(CleanupManager, "addCleanupHandler");
-  const updateRunIntervalStub = sinon.stub(RecipeRunner, "updateRunInterval");
-
-  // in dev mode
-  await SpecialPowers.pushPrefEnv({
+decorate_task(
+  withPrefEnv({
     set: [
       ["extensions.shield-recipe-client.dev_mode", true],
       ["extensions.shield-recipe-client.first_run", false],
     ],
-  });
-
-  RecipeRunner.init();
-  ok(runStub.called, "RecipeRunner.run is called immediately when in dev mode");
-  ok(addCleanupHandlerStub.called, "A cleanup function is registered when in dev mode");
-  ok(updateRunIntervalStub.called, "A timer is registered when in dev mode");
+  }),
+  withStub(RecipeRunner, "run"),
+  withStub(CleanupManager, "addCleanupHandler"),
+  withStub(RecipeRunner, "updateRunInterval"),
+  async function testInitDevMode(runStub, addCleanupHandlerStub, updateRunIntervalStub) {
+    RecipeRunner.init();
+    ok(runStub.called, "RecipeRunner.run is called immediately when in dev mode");
+    ok(addCleanupHandlerStub.called, "A cleanup function is registered when in dev mode");
+    ok(updateRunIntervalStub.called, "A timer is registered when in dev mode");
+  }
+);
 
-  runStub.reset();
-  addCleanupHandlerStub.reset();
-  updateRunIntervalStub.reset();
-
-  // not in dev mode
-  await SpecialPowers.pushPrefEnv({
+decorate_task(
+  withPrefEnv({
     set: [
       ["extensions.shield-recipe-client.dev_mode", false],
       ["extensions.shield-recipe-client.first_run", false],
     ],
-  });
+  }),
+  withStub(RecipeRunner, "run"),
+  withStub(CleanupManager, "addCleanupHandler"),
+  withStub(RecipeRunner, "updateRunInterval"),
+  async function testInit(runStub, addCleanupHandlerStub, updateRunIntervalStub) {
+    RecipeRunner.init();
+    ok(!runStub.called, "RecipeRunner.run is not called immediately when not in dev mode");
+    ok(addCleanupHandlerStub.called, "A cleanup function is registered when not in dev mode");
+    ok(updateRunIntervalStub.called, "A timer is registered when not in dev mode");
+  }
+);
 
-  RecipeRunner.init();
-  ok(!runStub.called, "RecipeRunner.run is not called immediately when not in dev mode");
-  ok(addCleanupHandlerStub.called, "A cleanup function is registered when not in dev mode");
-  ok(updateRunIntervalStub.called, "A timer is registered when not in dev mode");
+decorate_task(
+  withPrefEnv({
+    set: [
+      ["extensions.shield-recipe-client.dev_mode", false],
+      ["extensions.shield-recipe-client.first_run", true],
+    ],
+  }),
+  withStub(RecipeRunner, "run"),
+  withStub(RecipeRunner, "registerTimer"),
+  withStub(CleanupManager, "addCleanupHandler"),
+  withStub(RecipeRunner, "updateRunInterval"),
+  async function testInitFirstRun(runStub, registerTimerStub) {
+    RecipeRunner.init();
+    ok(!runStub.called, "RecipeRunner.run is not called immediately");
+    ok(!registerTimerStub.called, "RecipeRunner.registerTimer is not called immediately");
 
-  runStub.restore();
-  addCleanupHandlerStub.restore();
-  updateRunIntervalStub.restore();
-});
+    Services.obs.notifyObservers(null, "sessionstore-windows-restored");
+    await TestUtils.topicObserved("shield-init-complete");
+    ok(runStub.called, "RecipeRunner.run is called after the UI is available");
+    ok(registerTimerStub.called, "RecipeRunner.registerTimer is called after the UI is available");
+    ok(
+      !Services.prefs.getBoolPref("extensions.shield-recipe-client.first_run"),
+      "On first run, the first run pref is set to false after the UI is available"
+    );
+  }
+);
--- a/browser/extensions/shield-recipe-client/test/browser/browser_ShieldRecipeClient.js
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_ShieldRecipeClient.js
@@ -1,30 +1,67 @@
 "use strict";
 
 Cu.import("resource://shield-recipe-client/lib/ShieldRecipeClient.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/RecipeRunner.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/PreferenceExperiments.jsm", this);
+Cu.import("resource://shield-recipe-client-content/AboutPages.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/AddonStudies.jsm", this);
 
-add_task(async function testStartup() {
-  sinon.stub(RecipeRunner, "init");
-  sinon.stub(PreferenceExperiments, "init");
+function withStubInits(testFunction) {
+  return decorate(
+    withStub(AboutPages, "init"),
+    withStub(AddonStudies, "init"),
+    withStub(PreferenceExperiments, "init"),
+    withStub(RecipeRunner, "init"),
+    testFunction
+  );
+}
 
-  await ShieldRecipeClient.startup();
-  ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
-  ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
+decorate_task(
+  withStubInits,
+  async function testStartup() {
+    await ShieldRecipeClient.startup();
+    ok(AboutPages.init.called, "startup calls AboutPages.init");
+    ok(AddonStudies.init.called, "startup calls AddonStudies.init");
+    ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
+    ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
+  }
+);
 
-  PreferenceExperiments.init.restore();
-  RecipeRunner.init.restore();
-});
+decorate_task(
+  withStubInits,
+  async function testStartupPrefInitFail() {
+    PreferenceExperiments.init.returns(Promise.reject(new Error("oh no")));
 
-add_task(async function testStartupPrefInitFail() {
-  sinon.stub(RecipeRunner, "init");
-  sinon.stub(PreferenceExperiments, "init").returns(Promise.reject(new Error("oh no")));
+    await ShieldRecipeClient.startup();
+    ok(AboutPages.init.called, "startup calls AboutPages.init");
+    ok(AddonStudies.init.called, "startup calls AddonStudies.init");
+    ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
+    ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
+  }
+);
+
+decorate_task(
+  withStubInits,
+  async function testStartupAboutPagesInitFail() {
+    AboutPages.init.returns(Promise.reject(new Error("oh no")));
 
-  await ShieldRecipeClient.startup();
-  ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
-  // Even if PreferenceExperiments.init fails, RecipeRunner.init should be called.
-  ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
+    await ShieldRecipeClient.startup();
+    ok(AboutPages.init.called, "startup calls AboutPages.init");
+    ok(AddonStudies.init.called, "startup calls AddonStudies.init");
+    ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
+    ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
+  }
+);
 
-  PreferenceExperiments.init.restore();
-  RecipeRunner.init.restore();
-});
+decorate_task(
+  withStubInits,
+  async function testStartupAddonStudiesInitFail() {
+    AddonStudies.init.returns(Promise.reject(new Error("oh no")));
+
+    await ShieldRecipeClient.startup();
+    ok(AboutPages.init.called, "startup calls AboutPages.init");
+    ok(AddonStudies.init.called, "startup calls AddonStudies.init");
+    ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
+    ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
+  }
+);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_about_preferences.js
@@ -0,0 +1,177 @@
+"use strict";
+
+Cu.import("resource://gre/modules/Services.jsm", this);
+
+const OPT_OUT_PREF = "app.shield.optoutstudies.enabled";
+const FHR_PREF = "datareporting.healthreport.uploadEnabled";
+
+function withPrivacyPrefs(testFunc) {
+  return async (...args) => (
+    BrowserTestUtils.withNewTab("about:preferences#privacy", async browser => (
+      testFunc(...args, browser)
+    ))
+  );
+}
+
+decorate_task(
+  withPrefEnv({
+    set: [[OPT_OUT_PREF, true]],
+  }),
+  withPrivacyPrefs,
+  async function testCheckedOnLoad(browser) {
+    const checkbox = browser.contentDocument.getElementById("optOutStudiesEnabled");
+    ok(checkbox.checked, "Opt-out checkbox is checked on load when the pref is true");
+  }
+);
+
+decorate_task(
+  withPrefEnv({
+    set: [[OPT_OUT_PREF, false]],
+  }),
+  withPrivacyPrefs,
+  async function testUncheckedOnLoad(browser) {
+    const checkbox = browser.contentDocument.getElementById("optOutStudiesEnabled");
+    ok(!checkbox.checked, "Opt-out checkbox is unchecked on load when the pref is false");
+  }
+);
+
+decorate_task(
+  withPrefEnv({
+    set: [[FHR_PREF, true]],
+  }),
+  withPrivacyPrefs,
+  async function testEnabledOnLoad(browser) {
+    const checkbox = browser.contentDocument.getElementById("optOutStudiesEnabled");
+    ok(!checkbox.disabled, "Opt-out checkbox is enabled on load when the FHR pref is true");
+  }
+);
+
+decorate_task(
+  withPrefEnv({
+    set: [[FHR_PREF, false]],
+  }),
+  withPrivacyPrefs,
+  async function testDisabledOnLoad(browser) {
+    const checkbox = browser.contentDocument.getElementById("optOutStudiesEnabled");
+    ok(checkbox.disabled, "Opt-out checkbox is disabled on load when the FHR pref is false");
+  }
+);
+
+decorate_task(
+  withPrefEnv({
+    set: [
+      [FHR_PREF, true],
+      [OPT_OUT_PREF, true],
+    ],
+  }),
+  withPrivacyPrefs,
+  async function testCheckboxes(browser) {
+    const optOutCheckbox = browser.contentDocument.getElementById("optOutStudiesEnabled");
+    const fhrCheckbox = browser.contentDocument.getElementById("submitHealthReportBox");
+
+    optOutCheckbox.click();
+    ok(
+      !Services.prefs.getBoolPref(OPT_OUT_PREF),
+      "Unchecking the opt-out checkbox sets the pref to false."
+    );
+    optOutCheckbox.click();
+    ok(
+      Services.prefs.getBoolPref(OPT_OUT_PREF),
+      "Checking the opt-out checkbox sets the pref to true."
+    );
+
+    fhrCheckbox.click();
+    ok(
+      !Services.prefs.getBoolPref(OPT_OUT_PREF),
+      "Unchecking the FHR checkbox sets the opt-out pref to false."
+    );
+    ok(
+      optOutCheckbox.disabled,
+      "Unchecking the FHR checkbox disables the opt-out checkbox."
+    );
+    ok(
+      !optOutCheckbox.checked,
+      "Unchecking the FHR checkbox unchecks the opt-out checkbox."
+    );
+
+    fhrCheckbox.click();
+    ok(
+      Services.prefs.getBoolPref(OPT_OUT_PREF),
+      "Checking the FHR checkbox sets the opt-out pref to true."
+    );
+    ok(
+      !optOutCheckbox.disabled,
+      "Checking the FHR checkbox enables the opt-out checkbox."
+    );
+    ok(
+      optOutCheckbox.checked,
+      "Checking the FHR checkbox checks the opt-out checkbox."
+    );
+  }
+);
+
+decorate_task(
+  withPrefEnv({
+    set: [
+      [FHR_PREF, true],
+      [OPT_OUT_PREF, true],
+    ],
+  }),
+  withPrivacyPrefs,
+  async function testPrefWatchers(browser) {
+    const optOutCheckbox = browser.contentDocument.getElementById("optOutStudiesEnabled");
+
+    Services.prefs.setBoolPref(OPT_OUT_PREF, false);
+    ok(
+      !optOutCheckbox.checked,
+      "Disabling the opt-out pref unchecks the opt-out checkbox."
+    );
+    Services.prefs.setBoolPref(OPT_OUT_PREF, true);
+    ok(
+      optOutCheckbox.checked,
+      "Enabling the opt-out pref checks the opt-out checkbox."
+    );
+
+    Services.prefs.setBoolPref(FHR_PREF, false);
+    ok(
+      !Services.prefs.getBoolPref(OPT_OUT_PREF),
+      "Disabling the FHR pref sets the opt-out pref to false."
+    );
+    ok(
+      optOutCheckbox.disabled,
+      "Disabling the FHR pref disables the opt-out checkbox."
+    );
+    ok(
+      !optOutCheckbox.checked,
+      "Disabling the FHR pref unchecks the opt-out checkbox."
+    );
+
+    Services.prefs.setBoolPref(FHR_PREF, true);
+    ok(
+      Services.prefs.getBoolPref(OPT_OUT_PREF),
+      "Enabling the FHR pref sets the opt-out pref to true."
+    );
+    ok(
+      !optOutCheckbox.disabled,
+      "Enabling the FHR pref enables the opt-out checkbox."
+    );
+    ok(
+      optOutCheckbox.checked,
+      "Enabling the FHR pref checks the opt-out checkbox."
+    );
+  }
+);
+
+decorate_task(
+  withPrivacyPrefs,
+  async function testViewStudiesLink(browser) {
+    browser.contentDocument.getElementById("viewShieldStudies").click();
+    await BrowserTestUtils.waitForLocationChange(gBrowser);
+
+    is(
+      browser.currentURI.spec,
+      "about:studies",
+      "Clicking the view studies link opens about:studies."
+    );
+  }
+);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_about_studies.js
@@ -0,0 +1,184 @@
+"use strict";
+
+Cu.import("resource://shield-recipe-client/lib/AddonStudies.jsm", this);
+Cu.import("resource://shield-recipe-client-content/AboutPages.jsm", this);
+
+function withAboutStudies(testFunc) {
+  return async (...args) => (
+    BrowserTestUtils.withNewTab("about:studies", async browser => (
+      testFunc(...args, browser)
+    ))
+  );
+}
+
+decorate_task(
+  withAboutStudies,
+  async function testAboutStudiesWorks(browser) {
+    ok(browser.contentDocument.getElementById("app"), "App element was found");
+  }
+);
+
+decorate_task(
+  withPrefEnv({
+    set: [["extensions.shield-recipe-client.shieldLearnMoreUrl", "http://test/%OS%/"]],
+  }),
+  withAboutStudies,
+  async function testLearnMore(browser) {
+    ContentTask.spawn(browser, null, () => {
+      content.document.getElementById("shield-studies-learn-more").click();
+    });
+    await BrowserTestUtils.waitForLocationChange(gBrowser);
+
+    const location = browser.currentURI.spec;
+    is(
+      location,
+      AboutPages.aboutStudies.getShieldLearnMoreHref(),
+      "Clicking Learn More opens the correct page on SUMO.",
+    );
+    ok(!location.includes("%OS%"), "The Learn More URL is formatted.");
+  }
+);
+
+decorate_task(
+  withPrefEnv({
+    set: [["browser.preferences.useOldOrganization", false]],
+  }),
+  withAboutStudies,
+  async function testUpdatePreferencesNewOrganization(browser) {
+    ContentTask.spawn(browser, null, () => {
+      content.document.getElementById("shield-studies-update-preferences").click();
+    });
+    await BrowserTestUtils.waitForLocationChange(gBrowser);
+
+    is(
+      browser.currentURI.spec,
+      "about:preferences#privacy-reports",
+      "Clicking Update Preferences opens the privacy section of the new about:prefernces.",
+    );
+  }
+);
+
+decorate_task(
+  withPrefEnv({
+    set: [["browser.preferences.useOldOrganization", true]],
+  }),
+  withAboutStudies,
+  async function testUpdatePreferencesOldOrganization(browser) {
+    // We have to use gBrowser instead of browser in most spots since we're
+    // dealing with a new tab outside of the about:studies tab.
+    const tab = await BrowserTestUtils.switchTab(gBrowser, () => {
+      ContentTask.spawn(browser, null, () => {
+        content.document.getElementById("shield-studies-update-preferences").click();
+      });
+    });
+
+    if (gBrowser.contentDocument.readyState !== "complete") {
+      await BrowserTestUtils.waitForEvent(gBrowser.contentWindow, "load");
+    }
+
+    const location = gBrowser.contentWindow.location.href;
+    is(
+      location,
+      "about:preferences#advanced",
+      "Clicking Update Preferences opens the advanced section of the old about:prefernces.",
+    );
+
+    const dataChoicesTab = gBrowser.contentDocument.getElementById("dataChoicesTab");
+    ok(
+      dataChoicesTab.selected,
+      "Click Update preferences selects the Data Choices tab in the old about:preferences."
+    );
+
+    await BrowserTestUtils.removeTab(tab);
+  }
+);
+
+decorate_task(
+  AddonStudies.withStudies([
+    // Sort order should be study3, study1, study2 (order by enabled, then most recent).
+    studyFactory({
+      name: "A Fake Study",
+      active: true,
+      description: "A fake description",
+      studyStartDate: new Date(2017),
+    }),
+    studyFactory({
+      name: "B Fake Study",
+      active: false,
+      description: "A fake description",
+      studyStartDate: new Date(2019),
+    }),
+    studyFactory({
+      name: "C Fake Study",
+      active: true,
+      description: "A fake description",
+      studyStartDate: new Date(2018),
+    }),
+  ]),
+  withAboutStudies,
+  async function testStudyListing([study1, study2, study3], browser) {
+    await ContentTask.spawn(browser, [study1, study2, study3], async ([cStudy1, cStudy2, cStudy3]) => {
+      const doc = content.document;
+
+      function getStudyRow(docElem, studyName) {
+        return docElem.querySelector(`.study[data-study-name="${studyName}"]`);
+      }
+
+      await ContentTaskUtils.waitForCondition(() => doc.querySelectorAll(".study-list .study").length);
+      const studyRows = doc.querySelectorAll(".study-list .study");
+
+      const names = Array.from(studyRows).map(row => row.querySelector(".study-name").textContent);
+      Assert.deepEqual(
+        names,
+        [cStudy3.name, cStudy1.name, cStudy2.name],
+        "Studies are sorted first by enabled status, and then by descending start date."
+      );
+
+      const study1Row = getStudyRow(doc, cStudy1.name);
+      ok(
+        study1Row.querySelector(".study-description").textContent.includes(cStudy1.description),
+        "Study descriptions are shown in about:studies."
+      );
+      is(
+        study1Row.querySelector(".study-status").textContent,
+        "Active",
+        "Active studies show an 'Active' indicator."
+      );
+      ok(
+        study1Row.querySelector(".remove-button"),
+        "Active studies show a remove button"
+      );
+      is(
+        study1Row.querySelector(".study-icon").textContent.toLowerCase(),
+        "a",
+        "Study icons use the first letter of the study name."
+      );
+
+      const study2Row = getStudyRow(doc, cStudy2.name);
+      is(
+        study2Row.querySelector(".study-status").textContent,
+        "Complete",
+        "Inactive studies are marked as complete."
+      );
+      ok(
+        !study2Row.querySelector(".remove-button"),
+        "Inactive studies do not show a remove button"
+      );
+
+      study1Row.querySelector(".remove-button").click();
+      await ContentTaskUtils.waitForCondition(() => (
+        getStudyRow(doc, cStudy1.name).matches(".disabled")
+      ));
+      ok(
+        getStudyRow(doc, cStudy1.name).matches(".disabled"),
+        "Clicking the remove button updates the UI to show that the study has been disabled."
+      );
+    });
+
+    const updatedStudy1 = await AddonStudies.get(study1.recipeId);
+    ok(
+      !updatedStudy1.active,
+      "Clicking the remove button marks the study as inactive in storage."
+    );
+  }
+);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser/fixtures/addon-fixture/manifest.json
@@ -0,0 +1,11 @@
+{
+    "manifest_version": 2,
+    "name": "normandy_fixture",
+    "version": "1.0",
+    "description": "Dummy test fixture that's a webextension",
+    "applications": {
+        "gecko": {
+            "id": "normandydriver@example.com"
+        }
+    }
+}
new file mode 100644
index 0000000000000000000000000000000000000000..71a6f8fe7cd90427ca710599e7125f986e8dd24d
GIT binary patch
literal 4230
zc$|$_Wl$7s*Ir-=rC})%M7qI6knWBpBwguNmXL-eq@`85q>)xax_c>!U0guAS)@x)
zKKy3h@15tF=b8D=J?F=D=00bxb6@wLOGg6-2m$~A1OVgwE_K=~oAsYW0Duu00Pv@(
zrKB&%rv_0LfVucNIyqVLzjC+yZsaSPw0}<|=5w4UhNvgEH_b&*6)ca!aA_2EeY3(t
z*CeZ1FLJR#&lDZxbkA7P2nvKAvwjLDSs{C|8nT&iJkvDhnSIlK<)3oy`(ZcW$7VAY
zcO-7C;{&aPFa39|0Gm2ub+;8fT448QfTo}yod|((Vh)ML<vI|N*9`@52e8P7C#}hL
zG6~Y+iOYeJT{sm%{;!R_Lb{&?vA#Bzw<P#fgbSf1o5vj?2qS0viu|4(HJR_@uh;rU
zx(Wm)A++$S<RvKWsECZ!)G1y7shCHa;wOcd3!#%Su{BJ-S54xHuA+bzdVn0}^m^`&
z`&kKP|7%Va50ApcafI*n2)oLmS3ei_QBdk2a3xvtN(gB})OTDlqJ8t>@LrITn2scX
zT-q=ToC{o9!<rAorJ|QM^z{W)AJ72hT?vAZ?e|7Hc7DyY9AnS71Y>o*P&{P2A~PNO
zXkEn`bpHx66?g*l+8Q_vQhLPP`7F}aK^8MW)r30)av^0#A7&Lals_1PhJs{I4m>!1
zaVFfQzG*vV@)IYZ8i6z%f1uOE4c$^Nr?!Q)Mm<#tZ9LoaZ7!5}L$VI%cX%(;&?jfe
zhbD=0#zvHEIN8Se_&BA4IpiC7*{Z?jL-*fL+nA~vqbp#vwF?<SwPmY>yFQN}ZmB<i
zS*fXLN^iU><+m}#TrfuNLwmd%oFKOlpp=s#!a2rvJhC6j5+X2x6sw6!e&Eu;y+S$t
zM9ukwD-}zcclt*mL5uWNNoH>K9=3ER=BZ*$E;Zg4TyOG<l+J&~S~FxHnZVjx@Ld;&
zI4P;K$zykagg3&K{6;r<*}!kqV~PC+rFpi*pD^Pyb`C4?#k2EE&-t&mY)Q0?#T_Af
z1EIs@ouO`nn_OtA+&9<0+IdecXs(_sv@a5x$OO?Bb%)97nZ_Wce<z*z&+$B+qs$aw
z_9zw^eNU^`yD#jsFrxT+fZFP*&uGAURM(w*2%*xFymLf-h{3d&N__;KzJO2=N*W7k
zYkeXYe~D^BQqpS2DH>3HV|s#?aViTA2+kPi@SDr*7NILps3m>?Z+MJBUb!yKO|3n3
zBrWKKQuJQI>7g{JN$E^Q8xfHy0Wa8oB|Pz^yt+fKqEn$Dl&}8Tmhd#+8c6w-C(a&Q
z%{Jh%C^^Bx{kH}R2|~89T4%$f{JeAJ+6Lf}orrLWpwp6>CtM8tI}8PMU)C!{qDiss
z1e9sP7pHsv%K=mS2HjLZ`A#x2hOGp27)F|?36}i|$LmHN^KiM&{UYx;a%l(;RrA3N
zKc@@7gWg7JvZR@F*5zoHY3%*LxmZ}KldS~!{OeS%9xUu7xRqR)-UqT+a^RpyLyk5Z
zW8bn{lq<_~%NmpHE<m_?6H<VWkrxWn&0+CjRH<&+NU{XI`4ZTG_;=@W5l3jMn|w|g
ztHQ=rXRmnZkI?s=Rc>mM*CBM!bZQ)3Q7dB=Ga7W72H|gtobgBr31f1GjGcEG<QWVN
zJL@+#+UFmut|y(!+D{HHcYX&-)d#aYQtAr(>Q_;|0Bd7mWitCh$!;RDzPm2_tzgx^
zHO5cCj|cSy#Pzmd9^@~hYX`{Rr`sR>MEhYZ@DqoZYoQ<_eq(iNxF<O#GyCCJsZXRO
zoW}TL-o@_TMkfA35MIi+UvxD?D72Rwpp!rQ=#^f@VbVd&eEcZ!)^ga0^VV+v2O`CW
z{&@R0QmHX9yQm~6_`A!^^<7ufE<E7_TmO`sI#phU3<VdK8=a^V%+XGv(AVXfe%pFI
zGx{cC=!1JmxuR_@N>GI+K_36i$%{SC%wIieFB_nliOxtKZB>u_gnbfh(ub11?s+4q
zV*YGTJ<`&xv;h2#)qD+{U(C0%U2sn(O(0Ew3z&mY1bycLQ9hN1u4bM3-D4vx3LWlm
zuPEr2;faqRW&|d_#xo(}9UXC0x!qp?+$Ve56p^l9Uk3%T9WdaLURF~yat2?kEwwIy
zqQ^;WtpizJ<>Rm8AFg2GX4SM}o0;hbruum2t;j6jjD}ol6D>@a9_+LW&H#m)e`f?M
zbj2!#Eur<d`-YE4$I|`G6(n|j=5{HwQ6P=PQ0sbcmnORJlCbCdGTD|MEovK=!9GNA
zR!ak+mp^$9GiIp-3AIcRxuww%Sj+;ck=nbIOS9{+kcE(V=7=R?UzOB8MGRVdgZN^g
zZxgt~$2<ol;uCtS4Q5Qv%A5b48*$em!93c}14o~c+uy%`CCdFSdf7N?m|^cHoqd$m
zmwk;(8w}159=Vbe;Cx3=ZW;}*MxNDLA<L!u`AMKjz}3d&v*C}@+n%9y%D>K~+bnB!
zOvcZ&eVQS3$*dd<NkGN6v|qHW=u3B8@dN(BjE9m3nQxLm7>|dS3SwMcv<3{yHw!=E
zSM?|?Dvmw5Lwb;@%*?CQ^Mvs}d|4=47;0bA8mzWLVwKc|uPy{XRL&(tB>kvT_9H!=
zlJr;Q!lQ$D)wn-YbCO&6?qcV>9oS5i5LnQRW_agy82oe_wp-@UbLyA+IWwZzECTU+
z6d!`$;YJ3}XN*x#EqH!VQ~#7#?^v?ff5nc4uwV@N8p|-rPCt{pa8j1LNSp(;AF8y=
zW3Kk#jAnm!yj-hSG1JJXqfDUG(b4EsNiZ~4fIVRN{h`E50|ArCfd%$?G)>lT6iIzG
zrbh2XcG+lWsAwS4T?~YsD&x#UB40nF)3XxW$U4#EUMWs12(&BBQo-HB|Hvc>SoBR&
z1tu{D2g}X@=J>4^FGJ+L`)TnGepn5e65*#=#)1XOq@-|wN7mDcPC90s4cea=yt{1V
zj^-p7Kdoq}vcaS$RGBJ#X5u3v)&Z93G>!Yyqt7mrAc;6<2FYeBF(8RSv(hnQ!eN;k
znPMO}0g$sZ4Kj5iTj!}o2IB7HSzcLPEsgH`ef8LiA<7uv#am4K-N1w1GwWZ;#L^^m
z_b#KJ5)r_(KT3!P`cAu6BX_AE4HHteV&2R-lk7_6+b2zIyvHG9z<#uvdjPlS_K#c9
zUV^nl<Qq}h(wEG6wwA4BfdIvp*5^gj*GlV5#hzIzq0D)F8**uBIcqwWECe17hKIuA
zh=t%I8H$9Zc{nnU-5t3$-?jsGnBN=gWt%lRU-}uK`j(h*m0jjxa_W6D_24A86s{VF
zgUZ8vPZ88!@uJUqG_3N4$A&B=DH^nT!(S^&n!>SaAwTM}`k*+cWw2t`xkiZ+p}kai
z&r?TQ>WALo{XE`QM9K?DrQcH>R=OKc!m-yf7G4)sIp0%Lz7Bx*EsZ!HuDq$6zp69L
z#!tT3g?}AQd&*ieX-T-<v<();(DMuwG{y?>&(`$ZseN`3LztT>ESwwtvfiI0p74&g
zHY$kv+Ss-A)W&|=G{xRY@*S5^a_i$g*roU=&-U|`&`()?SO-dDac(1+xRtt)3JG$1
zR=U(n9n$dXDn=gO!BncdJ3~Olg{^r%g+aaqtvYiNx*)6EiHYdaCSB%?5u1Q&xqA1C
z=Z<k)-lAc}$;9dCj&@->wMEa)eK>XVkWuz~!6T1fNVrI<(;$MVUvJHFcPM533<WU4
zI+{5B5mV4EkG2BrX;q3>5Fq0A2}^xr(}ypt@SDv|eVC+?jop)kB4Gvc@%s+6F$|w^
z!cMO{c;9#QeG@oMai>_iNhS!nG9AI{?%+KUJ(Ecy3I1&xzT(x0L;ge~V)hy5xQIF;
zXpOmJ4dioqlz_x73fYcbOtx=~gHC?t?iA=PpB4YE#<7@Q*XobCj;hHuRG*n=eC}Ql
zxhn{t2B2i5$+k=dWSQRaZt_|;JAZXENK*sNy4X01!ciRrTHb_#liz3(y2@y6r?aiR
zG*GM<;b@41+<5$H%aaGQ+<^<9T!tgI)_Mxc;V%;qa;M|s$21vR#wqjLj+uP#Mk8iC
zXYTAkR3KjW>^z+A?%#9=S=rXB=sMryXAR(zOcmNidv233!rMaC2H@is{oGNV9PbO)
zUk&k*po<qnOIK}cX?aAS^R_)2sxgH$01^A;eHZQJ1?+3dL(=}rwarCrQo0-4Y-C5z
zmZ#0!plu0d^d|OYBX;oujRGsSk6{w0ZiXcRDDJX+`5Chu$&W=bciW1VACDs*hW=oN
z7B^JeVPk$-tv}h|(N!yrvc85<J(|N?8Dpy~bu(ILt0O|rPq?Ld$gEZYbFzFkJ82Dh
z&%_O0FuCfEJ6}TUIq$VEfIVLfVIEgWrR5y<Jd51E1EE&wHgj~Q7oPAcs&QnJK#Oq=
z3U=44XwACmvbzaYgBvU+H8`v*1PgJ7owF@8KIjU!F^|SUW`f>QYX!M|?yWi-nPT~#
zvfmbOLUm|1(?J0}0RP-8M-_i-cdBl;z$3(2&e~}*)7)KpA;#l5MKm?RYc6k`_7W2%
z<m9ZM$*G2-{*ABme_qMKxKTafwMcyWExNu<VgM}wX6fu`XY204|H9qH*+3r`fK!*t
zX5#5X2*3iKVgUgE+iDQsLeecrq;wt_o*a&A&^g%K8KnQvtt;P`(@mqi>;|IRGaSmc
zxNE)G6r$g)s==r1j7Rh~i%Lb*$!J(T)DeUVuPZ7kE_!<voPS|1kJ<eWMba8DjrWP2
z_iJ`;<ACm8u=`TVb>j$#f@k4HOOsdeN$}-Ihwg^8?n<8CL3$}_H+Ivn^(rXiCof1|
zuE$<f2q0%uckbx?#RtmIucn6HqEx`&ec*qVgxMWyjSOpQ@bOO&Yw&T8jf~go2~G<w
ztsH5LXw~ZthO6(`Qf+R!HQ5x0g2XopH)(8BY}TluTo=u0+(3WPd#|wQn#p{5!fWKK
z!7>H+?L%XupZ`*|-u(LM@$iWU6w05d-dH9ZTBBZ~7_>L-x>^>zl$1|4#ndx0vX&^T
zU=HQy2mF<EeO!#oi(BM-o0R`#Uft~o$27+^c)9r0k$)k5<oO8cIi#t5yUgjIt#H5<
zAYP)o%-I1_M0LdVZSEmjdbQX*`O(-gSW}49ed0&SG{9~Aa4~z8WT0}R1bukPx<7qO
za!ZnrpP%CFD>tlLYFOAHoPT%L|A}BMz`ywHKNA>n{{6rGi9gMSuX%|9|G(e&cT#_s
p`M;zHNd8&$zfbu4oBwx08_7Q^pre6{_vZre_R!w)GA8|l{s)EOuulL0
--- a/browser/extensions/shield-recipe-client/test/browser/head.js
+++ b/browser/extensions/shield-recipe-client/test/browser/head.js
@@ -1,12 +1,14 @@
 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
 
 Cu.import("resource://gre/modules/Preferences.jsm", this);
+Cu.import("resource://testing-common/AddonTestUtils.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/Addons.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/NormandyApi.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/Utils.jsm", this);
 
 // Load mocking/stubbing library, sinon
 // docs: http://sinonjs.org/docs/
 const loader = Cc["@mozilla.org/moz/jssubscript-loader;1"].getService(Ci.mozIJSSubScriptLoader);
@@ -21,56 +23,129 @@ sinon.assert.fail = function(message) {
 registerCleanupFunction(async function() {
   // Cleanup window or the test runner will throw an error
   delete window.sinon;
 });
 
 
 this.UUID_REGEX = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/;
 
-this.withSandboxManager = function(Assert, testFunction) {
-  return async function inner() {
-    const sandboxManager = new SandboxManager();
-    sandboxManager.addHold("test running");
+this.TEST_XPI_URL = (function() {
+  const dir = getChromeDir(getResolvedURI(gTestPath));
+  dir.append("fixtures");
+  dir.append("normandy.xpi");
+  return Services.io.newFileURI(dir).spec;
+})();
+
+this.withWebExtension = function(manifestOverrides = {}) {
+  return function wrapper(testFunction) {
+    return async function wrappedTestFunction(...args) {
+      const random = Math.random().toString(36).replace(/0./, "").substr(-3);
+      let id = `normandydriver_${random}@example.com`;
+      if ("id" in manifestOverrides) {
+        id = manifestOverrides.id;
+        delete manifestOverrides.id;
+      }
+
+      const manifest = Object.assign({
+        manifest_version: 2,
+        name: "normandy_fixture",
+        version: "1.0",
+        description: "Dummy test fixture that's a webextension",
+        applications: {
+          gecko: { id },
+        },
+      }, manifestOverrides);
+
+      const addonFile = AddonTestUtils.createTempWebExtensionFile({manifest});
+
+      // Workaround: Add-on files are cached by URL, and
+      // createTempWebExtensionFile re-uses filenames if the previous file has
+      // been deleted. So we need to flush the cache to avoid it.
+      Services.obs.notifyObservers(addonFile, "flush-cache-entry");
 
-    await testFunction(sandboxManager);
+      try {
+        await testFunction(...args, [id, addonFile]);
+      } finally {
+        AddonTestUtils.cleanupTempXPIs();
+      }
+    };
+  };
+};
 
-    sandboxManager.removeHold("test running");
-    await sandboxManager.isNuked()
-      .then(() => Assert.ok(true, "sandbox is nuked"))
-      .catch(e => Assert.ok(false, "sandbox is nuked", e));
+this.withInstalledWebExtension = function(manifestOverrides = {}) {
+  return function wrapper(testFunction) {
+    return decorate(
+      withWebExtension(manifestOverrides),
+      async function wrappedTestFunction(...args) {
+        const [id, file] = args[args.length - 1];
+        const startupPromise = AddonTestUtils.promiseWebExtensionStartup(id);
+        const url = Services.io.newFileURI(file).spec;
+        await Addons.install(url);
+        await startupPromise;
+        try {
+          await testFunction(...args);
+        } finally {
+          if (await Addons.get(id)) {
+            await Addons.uninstall(id);
+          }
+        }
+      }
+    );
+  };
+};
+
+this.withSandboxManager = function(Assert) {
+  return function wrapper(testFunction) {
+    return async function wrappedTestFunction(...args) {
+      const sandboxManager = new SandboxManager();
+      sandboxManager.addHold("test running");
+
+      await testFunction(...args, sandboxManager);
+
+      sandboxManager.removeHold("test running");
+      await sandboxManager.isNuked()
+        .then(() => Assert.ok(true, "sandbox is nuked"))
+        .catch(e => Assert.ok(false, "sandbox is nuked", e));
+    };
   };
 };
 
 this.withDriver = function(Assert, testFunction) {
-  return withSandboxManager(Assert, async function inner(sandboxManager) {
+  return withSandboxManager(Assert)(async function inner(...args) {
+    const sandboxManager = args[args.length - 1];
     const driver = new NormandyDriver(sandboxManager);
-    await testFunction(driver);
+    await testFunction(driver, ...args);
   });
 };
 
 this.withMockNormandyApi = function(testFunction) {
   return async function inner(...args) {
     const mockApi = {actions: [], recipes: [], implementations: {}};
 
-    sinon.stub(NormandyApi, "fetchActions", async () => mockApi.actions);
-    sinon.stub(NormandyApi, "fetchRecipes", async () => mockApi.recipes);
-    sinon.stub(NormandyApi, "fetchImplementation", async action => {
-      const impl = mockApi.implementations[action.name];
-      if (!impl) {
-        throw new Error("Missing");
+    // Use callsFake instead of resolves so that the current values in mockApi are used.
+    mockApi.fetchActions = sinon.stub(NormandyApi, "fetchActions").callsFake(async () => mockApi.actions);
+    mockApi.fetchRecipes = sinon.stub(NormandyApi, "fetchRecipes").callsFake(async () => mockApi.recipes);
+    mockApi.fetchImplementation = sinon.stub(NormandyApi, "fetchImplementation").callsFake(
+      async action => {
+        const impl = mockApi.implementations[action.name];
+        if (!impl) {
+          throw new Error("Missing");
+        }
+        return impl;
       }
-      return impl;
-    });
+    );
 
-    await testFunction(mockApi, ...args);
-
-    NormandyApi.fetchActions.restore();
-    NormandyApi.fetchRecipes.restore();
-    NormandyApi.fetchImplementation.restore();
+    try {
+      await testFunction(mockApi, ...args);
+    } finally {
+      mockApi.fetchActions.restore();
+      mockApi.fetchRecipes.restore();
+      mockApi.fetchImplementation.restore();
+    }
   };
 };
 
 const preferenceBranches = {
   user: Preferences,
   default: new Preferences({defaultBranch: true}),
 };
 
@@ -113,8 +188,91 @@ class MockPreferences {
           preferenceBranch.set(name, oldValue);
         } else {
           preferenceBranch.reset(name);
         }
       }
     }
   }
 }
+
+this.withPrefEnv = function(inPrefs) {
+  return function wrapper(testFunc) {
+    return async function inner(...args) {
+      await SpecialPowers.pushPrefEnv(inPrefs);
+      try {
+        await testFunc(...args);
+      } finally {
+        await SpecialPowers.popPrefEnv();
+      }
+    };
+  };
+};
+
+/**
+ * Combine a list of functions right to left. The rightmost function is passed
+ * to the preceeding function as the argument; the result of this is passed to
+ * the next function until all are exhausted. For example, this:
+ *
+ * decorate(func1, func2, func3);
+ *
+ * is equivalent to this:
+ *
+ * func1(func2(func3));
+ */
+this.decorate = function(...args) {
+  const funcs = Array.from(args);
+  let decorated = funcs.pop();
+  funcs.reverse();
+  for (const func of funcs) {
+    decorated = func(decorated);
+  }
+  return decorated;
+};
+
+/**
+ * Wrapper around add_task for declaring tests that use several with-style
+ * wrappers. The last argument should be your test function; all other arguments
+ * should be functions that accept a single test function argument.
+ *
+ * The arguments are combined using decorate and passed to add_task as a single
+ * test function.
+ *
+ * @param {[Function]} args
+ * @example
+ *   decorate_task(
+ *     withMockPreferences,
+ *     withMockNormandyApi,
+ *     async function myTest(mockPreferences, mockApi) {
+ *       // Do a test
+ *     }
+ *   );
+ */
+this.decorate_task = function(...args) {
+  return add_task(decorate(...args));
+};
+
+let _studyFactoryId = 0;
+this.studyFactory = function(attrs) {
+  return Object.assign({
+    recipeId: _studyFactoryId++,
+    name: "Test study",
+    description: "fake",
+    active: true,
+    addonId: "fake@example.com",
+    addonUrl: "http://test/addon.xpi",
+    addonVersion: "1.0.0",
+    studyStartDate: new Date(),
+  }, attrs);
+};
+
+this.withStub = function(...stubArgs) {
+  return function wrapper(testFunction) {
+    return async function wrappedTestFunction(...args) {
+      const stub = sinon.stub(...stubArgs);
+      try {
+        await testFunction(...args, stub);
+      } finally {
+        stub.restore();
+      }
+    };
+  };
+};
--- a/browser/extensions/shield-recipe-client/test/unit/head_xpc.js
+++ b/browser/extensions/shield-recipe-client/test/unit/head_xpc.js
@@ -18,24 +18,25 @@ if (!extensionDir.exists()) {
   extensionDir = extensionDir.parent;
   extensionDir.append(EXTENSION_ID + ".xpi");
 }
 Components.manager.addBootstrappedManifestLocation(extensionDir);
 
 // ================================================
 // Load mocking/stubbing library, sinon
 // docs: http://sinonjs.org/releases/v2.3.2/
+/* exported sinon */
 Cu.import("resource://gre/modules/Timer.jsm");
 const {Loader} = Cu.import("resource://gre/modules/commonjs/toolkit/loader.js", {});
 const loader = new Loader.Loader({
   paths: {
     "": "resource://testing-common/",
   },
   globals: {
     setTimeout,
     setInterval,
     clearTimeout,
     clearInterval,
   },
 });
 const require = Loader.Require(loader, {id: ""});
-const sinon = require("sinon-2.3.2");
+this.sinon = require("sinon-2.3.2");
 // ================================================
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/unit/invalid_recipe_signature_api/api/v1/index.json
@@ -0,0 +1,4 @@
+{
+  "recipe-signed": "/api/v1/recipe/signed/",
+  "classify-client": "/api/v1/classify_client/"
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/unit/invalid_recipe_signature_api/api/v1/recipe/signed/index.json
@@ -0,0 +1,1 @@
+[{"recipe":{"action":"console-log","arguments":{"message":"this signature does not match this recipe"},"channels":[],"countries":[],"enabled":true,"extra_filter_expression":"true || true","filter_expression":"true || true","id":1,"last_updated":"2017-02-17T18:29:09.839239Z","locales":[],"name":"system-addon-test","revision_id":"b2cb8a26e132182d7d02cf50695d2c7f06cf3b954ff2ff63bca49d724ee91950"},"signature":{"public_key":"MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEVEKiCAIkwRg1VFsP8JOYdSF6a3qvgbRPoEK9eTuLbrB6QixozscKR4iWJ8ZOOX6RPCRgFdfVDoZqjFBFNJN9QtRBk0mVtHbnErx64d2vMF0oWencS1hyLW2whgOgOz7p","signature":"p4g3eurmPsJK5UcGT97BRyKstpwZ_2mNJkDGpd6QXlkXfvgwprjeyb5yeIEkKUXqc6krWid4obB_OP9-CwOi9tvKY1pV8p98CT5BhF0IVgpF3b7KBW1a0BVdg5owoG5W","timestamp":"2017-02-17T18:29:09.847614Z","x5u":"/normandy.content-signature.mozilla.org-20210705.dev.chain"}}]
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/unit/invalid_recipe_signature_api/normandy.content-signature.mozilla.org-20210705.dev.chain
@@ -0,0 +1,123 @@
+-----BEGIN CERTIFICATE-----
+MIIGRTCCBC2gAwIBAgIEAQAABTANBgkqhkiG9w0BAQwFADBrMQswCQYDVQQGEwJV
+UzEQMA4GA1UEChMHQWxsaXpvbTEXMBUGA1UECxMOQ2xvdWQgU2VydmljZXMxMTAv
+BgNVBAMTKERldnppbGxhIFNpZ25pbmcgU2VydmljZXMgSW50ZXJtZWRpYXRlIDEw
+HhcNMTYwNzA2MjE1NzE1WhcNMjEwNzA1MjE1NzE1WjCBrzELMAkGA1UEBhMCVVMx
+EzARBgNVBAgTCkNhbGlmb3JuaWExHDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRp
+b24xFzAVBgNVBAsTDkNsb3VkIFNlcnZpY2VzMS8wLQYDVQQDEyZub3JtYW5keS5j
+b250ZW50LXNpZ25hdHVyZS5tb3ppbGxhLm9yZzEjMCEGCSqGSIb3DQEJARYUc2Vj
+dXJpdHlAbW96aWxsYS5vcmcwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARUQqIIAiTB
+GDVUWw/wk5h1IXpreq+BtE+gQr15O4tusHpCLGjOxwpHiJYnxk45fpE8JGAV19UO
+hmqMUEU0k31C1EGTSZW0ducSvHrh3a8wXShZ6dxLWHItbbCGA6A7PumjggJYMIIC
+VDAdBgNVHQ4EFgQUVfksSjlZ0i1TBiS1vcoObaMeXn0wge8GA1UdIwSB5zCB5IAU
+/YboUIXAovChEpudDBuodHKbjUuhgcWkgcIwgb8xCzAJBgNVBAYTAlVTMQswCQYD
+VQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UEChMdQ29udGVu
+dCBTaWduYXR1cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5jb250ZW50LXNp
+Z25hdHVyZS5yb290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNlYytkZXZyb290
+Y29udGVudHNpZ25hdHVyZUBtb3ppbGxhLmNvbYIEAQAABDAMBgNVHRMBAf8EAjAA
+MA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAzBEBgNVHR8E
+PTA7MDmgN6A1hjNodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmRldi5tb3phd3Mu
+bmV0L2NhL2NybC5wZW0wQgYJYIZIAYb4QgEEBDUWM2h0dHBzOi8vY29udGVudC1z
+aWduYXR1cmUuZGV2Lm1vemF3cy5uZXQvY2EvY3JsLnBlbTBOBggrBgEFBQcBAQRC
+MEAwPgYIKwYBBQUHMAKGMmh0dHBzOi8vY29udGVudC1zaWduYXR1cmUuZGV2Lm1v
+emF3cy5uZXQvY2EvY2EucGVtMDEGA1UdEQQqMCiCJm5vcm1hbmR5LmNvbnRlbnQt
+c2lnbmF0dXJlLm1vemlsbGEub3JnMA0GCSqGSIb3DQEBDAUAA4ICAQCwb+8JTAB7
+ZfQmFqPUIV2cQQv696AaDPQCtA9YS4zmUfcLMvfZVAbK397zFr0RMDdLiTUQDoeq
+rBEmPXhJRPiv6JAK4n7Jf6Y6XfXcNxx+q3garR09Vm/0CnEq/iV+ZAtPkoKIO9kr
+Nkzecd894yQCF4hIuPQ5qtMySeqJmH3Dp13eq4T0Oew1Bu32rNHuBJh2xYBkWdun
+aAw/YX0I5EqZBP/XA6gbiA160tTK+hnpnlMtw/ljkvfhHbWpICD4aSiTL8L3vABQ
+j7bqjMKR5xDkuGWshZfcmonpvQhGTye/RZ1vz5IzA3VOJt1mz5bdZlitpaOm/Yv0
+x6aODz8GP/PiRWFQ5CW8Uf/7pGc5rSyvnfZV2ix8EzFlo8cUtuN1fjrPFPOFOLvG
+iiB6S9nlXiKBGYIDdd8V8iC5xJpzjiAWJQigwSNzuc2K30+iPo3w0zazkwe5V8jW
+gj6gItYxh5xwVQTPHD0EOd9HvV1ou42+rH5Y+ISFUm25zz02UtUHEK0BKtL0lmdt
+DwVq5jcHn6bx2/iwUtlKvPXtfM/6JjTJlkLZLtS7U5/pwcS0owo9zAL0qg3bdm16
++v/olmPqQFLUHmamJTzv3rojj5X/uVdx1HMM3wBjV9tRYoYaZw9RIInRmM8Z1pHv
+JJ+CIZgCyd5vgp57BKiodRZcgHoCH+BkOQ==
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIHijCCBXKgAwIBAgIEAQAABDANBgkqhkiG9w0BAQwFADCBvzELMAkGA1UEBhMC
+VVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MSYwJAYDVQQK
+Ex1Db250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEmMCQGA1UEAxMdZGV2LmNv
+bnRlbnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG9w0BCQEWLGNsb3Vkc2Vj
+K2RldnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEuY29tMB4XDTE2MDcwNjIx
+NDkyNloXDTIxMDcwNTIxNDkyNlowazELMAkGA1UEBhMCVVMxEDAOBgNVBAoTB0Fs
+bGl6b20xFzAVBgNVBAsTDkNsb3VkIFNlcnZpY2VzMTEwLwYDVQQDEyhEZXZ6aWxs
+YSBTaWduaW5nIFNlcnZpY2VzIEludGVybWVkaWF0ZSAxMIICIjANBgkqhkiG9w0B
+AQEFAAOCAg8AMIICCgKCAgEAypIfUURYgWzGw8G/1Pz9zW+Tsjirx2owThiv2gys
+wJiWL/9/2gzKOrYDEqlDUudfA/BjVRtT9+NbYgnhaCkNfADOAacWS83aMhedAqhP
+bVd5YhGQdpijI7f1AVTSb0ehrU2nhOZHvHX5Tk2fbRx3ryefIazNTLFGpiMBbsNv
+tSI/+fjW8s0MhKNqlLnk6a9mZKo0mEy7HjGYV8nzsgI17rKLx/s2HG4TFG0+JQzc
+UGlum3Tg58ritDzWdyKIkmKNZ48oLBX99Qc8j8B1UyiLv6TZmjVX0I+Ds7eSWHZk
+0axLEpTyf2r7fHvN4iDNCPajw+ZpuuBfbs80Ha8b8MHvnpf9fbwiirodNQOVpY4c
+t5E3Us3eYwBKdqDEbECWxCKGAS2/iVVUCNKHsg0sSxgqcwxrxyrddQRUQ0EM38DZ
+F/vHt+vTdHt07kezbjJe0Kklel59uSpghA0iL4vxzbTns1fuwYOgVrNGs3acTkiB
+GhFOxRXUPGtpdYmv+AaR9WlWJQY1GIEoVrinPVH7bcCwyh1CcUbHL+oAFTcmc6kZ
+7azNg21tWILIRL7R0IZYQm0tF5TTwCsjVC7FuHaBtkxtVrrZqeKjvVXQ8TK5VoI0
+BUQ6BKHGeTtm+0HBpheYBDy3wkOsEGbGHLEM6cMeiz6PyCXF8wXli8Qb/TjN3LHZ
+e30CAwEAAaOCAd8wggHbMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGG
+MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMDMB0GA1UdDgQWBBT9huhQhcCi8KESm50M
+G6h0cpuNSzCB7AYDVR0jBIHkMIHhgBSDx8s0qJaMyQCehKcuzgzVNRA75qGBxaSB
+wjCBvzELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFp
+biBWaWV3MSYwJAYDVQQKEx1Db250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEm
+MCQGA1UEAxMdZGV2LmNvbnRlbnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG
+9w0BCQEWLGNsb3Vkc2VjK2RldnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEu
+Y29tggEBMEIGCWCGSAGG+EIBBAQ1FjNodHRwczovL2NvbnRlbnQtc2lnbmF0dXJl
+LmRldi5tb3phd3MubmV0L2NhL2NybC5wZW0wTgYIKwYBBQUHAQEEQjBAMD4GCCsG
+AQUFBzAChjJodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmRldi5tb3phd3MubmV0
+L2NhL2NhLnBlbTANBgkqhkiG9w0BAQwFAAOCAgEAbum0z0ccqI1Wp49VtsGmUPHA
+gjPPy2Xa5NePmqY87WrGdhjN3xbLVb3hx8T2N6pqDjMY2rEynXKEOek3oJkQ3C6J
+8AFP6Y93gaAlNz6EA0J0mqdW5TMI8YEYsu2ma+dQQ8wm9f/5b+/Y8qwfhztP06W5
+H6IG04/RvgUwYMnSR4QvT309fu5UmCRUDzsO53ZmQCfmN94u3NxHc4S6n0Q6AKAM
+kh5Ld9SQnlqqqDykzn7hYDi8nTLWc7IYqkGfNMilDEKbAl4CjnSfyEvpdFAJ9sPR
+UL+kaWFSMvaqIPNpxS5OpoPZjmxEc9HHnCHxtfDHWdXTJILjijPrCdMaxOCHfIqV
+5roOJggI4RZ0YM68IL1MfN4IEVOrHhKjDHtd1gcmy2KU4jfj9LQq9YTnyvZ2z1yS
+lS310HG3or1K8Nnu5Utfe7T6ppX8bLRMkS1/w0p7DKxHaf4D/GJcCtM9lcSt9JpW
+6ACKFikjWR4ZxczYKgApc0wcjd7XBuO5777xtOeyEUDHdDft3jiXA91dYM5UAzc3
+69z/3zmaELzo0gWcrjLXh7fU9AvbU4EUF6rwzxbPGF78jJcGK+oBf8uWUCkBykDt
+VsAEZI1u4EDg8e/C1nFqaH9gNMArAgquYIB9rve+hdprIMnva0S147pflWopBWcb
+jwzgpfquuYnnxe0CNBA=
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIH3DCCBcSgAwIBAgIBATANBgkqhkiG9w0BAQwFADCBvzELMAkGA1UEBhMCVVMx
+CzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MSYwJAYDVQQKEx1D
+b250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEmMCQGA1UEAxMdZGV2LmNvbnRl
+bnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG9w0BCQEWLGNsb3Vkc2VjK2Rl
+dnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEuY29tMB4XDTE2MDcwNjE4MTUy
+MloXDTI2MDcwNDE4MTUyMlowgb8xCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJDQTEW
+MBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UEChMdQ29udGVudCBTaWduYXR1
+cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5jb250ZW50LXNpZ25hdHVyZS5y
+b290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNlYytkZXZyb290Y29udGVudHNp
+Z25hdHVyZUBtb3ppbGxhLmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
+ggIBAJcPcXhD8MzF6OTn5qZ0L7lX1+PEgLKhI9g1HxxDYDVup4Zm0kZhPGmFSlml
+6eVO99OvvHdAlHhQGCIG7h+w1cp66mWjfpcvtQH23uRoKZfiW3jy1jUWrvdXolxR
+t1taZosjzo+9OP8TvG6LpJj7AvqUiYD4wYnQJtt0jNRN4d6MUfQwiavSS5uTBuxd
+ZJ4TsPvEI+Iv4A4PSobSzxkg79LTMvsGtDLQv7nN5hMs9T18EL5GnIKoqnSQCU0d
+n2CN7S3QiQ+cbORWsSYqCTj1gUbFh6X3duEB/ypLcyWFbqeJmPHikjY8q8pLjZSB
+IYiTJYLyvYlKdM5QleC/xuBNnMPCftrwwLHjWE4Dd7C9t7k0R5xyOetuiHLCwOcQ
+tuckp7RgFKoviMNv3gdkzwVapOklcsaRkRscv6OMTKJNsdJVIDLrPF1wMABhbEQB
+64BL0uL4lkFtpXXbJzQ6mgUNQveJkfUWOoB+cA/6GtI4J0aQfvQgloCYI6jxNn/e
+Nvk5OV9KFOhXS2dnDft3wHU46sg5yXOuds1u6UrOoATBNFlkS95m4zIX1Svu091+
+CKTiLK85+ZiFtAlU2bPr3Bk3GhL3Z586ae6a4QUEx6SPQVXc18ezB4qxKqFc+avI
+ylccYMRhVP+ruADxtUM5Vy6x3U8BwBK5RLdecRx2FEBDImY1AgMBAAGjggHfMIIB
+2zAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAWBgNVHSUBAf8EDDAK
+BggrBgEFBQcDAzAdBgNVHQ4EFgQUg8fLNKiWjMkAnoSnLs4M1TUQO+YwgewGA1Ud
+IwSB5DCB4YAUg8fLNKiWjMkAnoSnLs4M1TUQO+ahgcWkgcIwgb8xCzAJBgNVBAYT
+AlVTMQswCQYDVQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UE
+ChMdQ29udGVudCBTaWduYXR1cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5j
+b250ZW50LXNpZ25hdHVyZS5yb290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNl
+YytkZXZyb290Y29udGVudHNpZ25hdHVyZUBtb3ppbGxhLmNvbYIBATBCBglghkgB
+hvhCAQQENRYzaHR0cHM6Ly9jb250ZW50LXNpZ25hdHVyZS5kZXYubW96YXdzLm5l
+dC9jYS9jcmwucGVtME4GCCsGAQUFBwEBBEIwQDA+BggrBgEFBQcwAoYyaHR0cHM6
+Ly9jb250ZW50LXNpZ25hdHVyZS5kZXYubW96YXdzLm5ldC9jYS9jYS5wZW0wDQYJ
+KoZIhvcNAQEMBQADggIBAAAQ+fotZE79FfZ8Lz7eiTUzlwHXCdSE2XD3nMROu6n6
+uLTBPrf2C+k+U1FjKVvL5/WCUj6hIzP2X6Sb8+o0XHX9mKN0yoMORTEYJOnazYPK
+KSUUFnE4vGgQkr6k/31gGRMTICdnf3VOOAlUCQ5bOmGIuWi401E3sbd85U+TJe0A
+nHlU+XjtfzlqcBvQivdbA0s+GEG55uRPvn952aTBEMHfn+2JqKeLShl4AtUAfu+h
+6md3Z2HrEC7B3GK8ekWPu0G/ZuWTuFvOimZ+5C8IPRJXcIR/siPQl1x6dpTCew6t
+lPVcVuvg6SQwzvxetkNrGUe2Wb2s9+PK2PUvfOS8ee25SNmfG3XK9qJpqGUhzSBX
+T8QQgyxd0Su5G7Wze7aaHZd/fqIm/G8YFR0HiC2xni/lnDTXFDPCe+HCnSk0bH6U
+wpr6I1yK8oZ2IdnNVfuABGMmGOhvSQ8r7//ea9WKhCsGNQawpVWVioY7hpyNAJ0O
+Vn4xqG5f6allz8lgpwAQ+AeEEClHca6hh6mj9KhD1Of1CC2Vx52GHNh/jMYEc3/g
+zLKniencBqn3Y2XH2daITGJddcleN09+a1NaTkT3hgr7LumxM8EVssPkC+z9j4Vf
+Gbste+8S5QCMhh00g5vR9QF8EaFqdxCdSxrsA4GmpCa5UQl8jtCnpp2DLKXuOh72
+-----END CERTIFICATE-----
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/console-log/implementation/sha384-RGx3rydrSq53UfmW9kFcK0mQYra67XIvZvr4MhmAe--ljiiMQOtgM7Cmca48um3v
@@ -0,0 +1,1 @@
+(function(a){function b(d){if(c[d])return c[d].exports;var e=c[d]={exports:{},id:d,loaded:!1};return a[d].call(e.exports,e,e.exports,b),e.loaded=!0,e.exports}var c={};return b.m=a,b.c=c,b.p="",b(0)})([function(a,b,c){"use strict";Object.defineProperty(b,"__esModule",{value:!0});var d=c(1);class e extends d.Action{async execute(){this.normandy.log(this.recipe.arguments.message,"info")}}b.default=e,(0,d.registerAction)("console-log",e)},function(a,b){(function(c){"use strict";Object.defineProperty(b,"__esModule",{value:!0});b.Action=class{constructor(g,h){this.normandy=g,this.recipe=h}};const e=b.registerAction=c&&c.registerAction||window&&window.registerAction||function(){},f=b.registerAsyncCallback=c&&c.registerAsyncCallback||window&&window.registerAsyncCallback||function(){}}).call(b,function(){return this}())}]);
\ No newline at end of file
--- a/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/console-log/index.json
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/console-log/index.json
@@ -9,11 +9,11 @@
       }
     },
     "required": [
       "message"
     ],
     "title": "Log a message to the console",
     "type": "object"
   },
-  "implementation_url": "https://localhost:8000/api/v1/action/console-log/implementation/0765c6302db2846b85543eea743f959c19b42559/",
-  "name": "console-log"
+  "name": "console-log",
+  "implementation_url": "https://localhost:8443/api/v1/action/console-log/implementation/sha384-RGx3rydrSq53UfmW9kFcK0mQYra67XIvZvr4MhmAe--ljiiMQOtgM7Cmca48um3v/"
 }
--- a/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/index.json
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/index.json
@@ -10,22 +10,54 @@
         }
       },
       "required": [
         "message"
       ],
       "title": "Log a message to the console",
       "type": "object"
     },
-    "implementation_url": "https://localhost:8000/api/v1/action/console-log/implementation/0765c6302db2846b85543eea743f959c19b42559/",
+    "implementation_url": "https://localhost:8443/api/v1/action/console-log/implementation/sha384-RGx3rydrSq53UfmW9kFcK0mQYra67XIvZvr4MhmAe--ljiiMQOtgM7Cmca48um3v/",
     "name": "console-log"
   },
   {
     "arguments_schema": {
       "$schema": "http://json-schema.org/draft-04/schema#",
+      "properties": {
+        "addonUrl": {
+          "description": "URL of the add-on XPI file",
+          "format": "uri",
+          "minLength": 1,
+          "type": "string"
+        },
+        "description": {
+          "description": "User-facing description of the study",
+          "minLength": 1,
+          "type": "string"
+        },
+        "name": {
+          "description": "User-facing name of the study",
+          "minLength": 1,
+          "type": "string"
+        }
+      },
+      "required": [
+        "name",
+        "description",
+        "addonUrl"
+      ],
+      "title": "Enroll a user in an opt-out SHIELD study",
+      "type": "object"
+    },
+    "implementation_url": "https://localhost:8443/api/v1/action/opt-out-study/implementation/sha384-HM_avYcD00o27ufwU1V7PIBtiuMAXML6MMwlYrDEqDX-XzGVuOfL52RCM680JExN/",
+    "name": "opt-out-study"
+  },
+  {
+    "arguments_schema": {
+      "$schema": "http://json-schema.org/draft-04/schema#",
       "description": "This action shows a single survey.",
       "properties": {
         "engagementButtonLabel": {
           "default": "",
           "description": "Text for the engagement button. If specified, this button will be shown instead of rating stars.",
           "type": "string"
         },
         "includeTelemetryUUID": {
@@ -48,16 +80,26 @@
           "description": "Message to show to the user",
           "type": "string"
         },
         "postAnswerUrl": {
           "default": "",
           "description": "URL to redirect the user to after rating Firefox or clicking the engagement button",
           "type": "string"
         },
+        "repeatEvery": {
+          "default": null,
+          "description": "How often (in days) the action is displayed.",
+          "type": "number"
+        },
+        "repeatOption": {
+          "default": "once",
+          "description": "Determines how often an action executes. (once|nag|xdays)",
+          "type": "string"
+        },
         "surveyId": {
           "description": "Slug uniquely identifying this survey in telemetry",
           "type": "string"
         },
         "thanksMessage": {
           "default": "",
           "description": "Thanks message to show to the user after they've rated Firefox",
           "type": "string"
@@ -66,12 +108,98 @@
       "required": [
         "surveyId",
         "message",
         "thanksMessage"
       ],
       "title": "Show a Heartbeat survey.",
       "type": "object"
     },
-    "implementation_url": "https://localhost:8000/api/v1/action/show-heartbeat/implementation/448bbffce82ff27b3d532fd7196bcb190be5c067/",
+    "implementation_url": "https://localhost:8443/api/v1/action/show-heartbeat/implementation/sha384-dEGiyKPEln8Ns5cQHzGpMIGdirSAAX0X-Kwlu-U3sJ05yNbO-ANij_a6c5SyL7G4/",
     "name": "show-heartbeat"
+  },
+  {
+    "arguments_schema": {
+      "$schema": "http://json-schema.org/draft-04/schema#",
+      "properties": {
+        "branches": {
+          "description": "List of experimental branches",
+          "items": {
+            "properties": {
+              "ratio": {
+                "default": 1,
+                "description": "Ratio of users who should be grouped into this branch",
+                "minimum": 1,
+                "type": "integer"
+              },
+              "slug": {
+                "description": "Unique identifier for this branch of the experiment",
+                "pattern": "^[A-Za-z0-9\\-_]+$",
+                "type": "string"
+              },
+              "value": {
+                "description": "Value to set the preference to for this branch",
+                "type": [
+                  "string",
+                  "number",
+                  "boolean"
+                ]
+              }
+            },
+            "required": [
+              "slug",
+              "value",
+              "ratio"
+            ],
+            "type": "object"
+          },
+          "minItems": 1,
+          "type": "array"
+        },
+        "experimentDocumentUrl": {
+          "default": "",
+          "description": "URL of a document describing the experiment",
+          "format": "uri",
+          "type": "string"
+        },
+        "preferenceBranchType": {
+          "default": "default",
+          "descript": "Controls whether the default or user value of the preference is modified",
+          "enum": [
+            "user",
+            "default"
+          ],
+          "type": "string"
+        },
+        "preferenceName": {
+          "default": "",
+          "description": "Full dotted-path of the preference that controls this experiment",
+          "type": "string"
+        },
+        "preferenceType": {
+          "default": "boolean",
+          "description": "Data type of the preference that controls this experiment",
+          "enum": [
+            "string",
+            "integer",
+            "boolean"
+          ],
+          "type": "string"
+        },
+        "slug": {
+          "description": "Unique identifier for this experiment",
+          "pattern": "^[A-Za-z0-9\\-_]+$",
+          "type": "string"
+        }
+      },
+      "required": [
+        "slug",
+        "preferenceName",
+        "preferenceType",
+        "branches"
+      ],
+      "title": "Run a feature experiment activated by a preference.",
+      "type": "object"
+    },
+    "implementation_url": "https://localhost:8443/api/v1/action/preference-experiment/implementation/sha384-KQgG38GQ7KZAb2VIB48ANQO6nBcxZoLm2ORzUviRT5nAvSywyPjZ5cJIElw6iXIt/",
+    "name": "preference-experiment"
   }
 ]
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/opt-out-study/implementation/sha384-HM_avYcD00o27ufwU1V7PIBtiuMAXML6MMwlYrDEqDX-XzGVuOfL52RCM680JExN
@@ -0,0 +1,1 @@
+(function(a){function b(d){if(c[d])return c[d].exports;var e=c[d]={exports:{},id:d,loaded:!1};return a[d].call(e.exports,e,e.exports,b),e.loaded=!0,e.exports}var c={};return b.m=a,b.c=c,b.p="",b(0)})([function(a,b,c){"use strict";async function e(i){const j=i.studies;if(void 0===j)return void i.log("Client does not support studies, aborting.","info");const k=(await j.getAll()).filter((l)=>l.active);for(const l of k)if(!g.includes(l.recipeId)){i.log("Stopping study for recipe ${study.recipeId}.","debug");try{await j.stop(l.recipeId)}catch(m){i.log(`Error while stopping study for recipe ${l.recipeId}: ${m}`,"error")}}}Object.defineProperty(b,"__esModule",{value:!0}),b.resetAction=function(){g=[]},b.postExecutionHook=e;var f=c(1);let g=[];class h extends f.Action{async execute(){const i=this.recipe.id;var j=this.recipe.arguments;const k=j.name,l=j.description,m=j.addonUrl,n=this.normandy.studies;if(void 0===n)return void this.normandy.log("Client does not support studies, aborting.","info");g.push(i);const o=await n.has(i);o?this.normandy.log(`Study for recipe ${i} already exists`,"debug"):(this.normandy.log(`Starting study for recipe ${i}`,"debug"),await n.start({recipeId:i,name:k,description:l,addonUrl:m}))}}b.default=h,(0,f.registerAction)("opt-out-study",h),(0,f.registerAsyncCallback)("postExecution",e)},function(a,b){(function(c){"use strict";Object.defineProperty(b,"__esModule",{value:!0});b.Action=class{constructor(g,h){this.normandy=g,this.recipe=h}};const e=b.registerAction=c&&c.registerAction||window&&window.registerAction||function(){},f=b.registerAsyncCallback=c&&c.registerAsyncCallback||window&&window.registerAsyncCallback||function(){}}).call(b,function(){return this}())}]);
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/opt-out-study/index.json
@@ -0,0 +1,32 @@
+{
+  "arguments_schema": {
+    "$schema": "http://json-schema.org/draft-04/schema#",
+    "properties": {
+      "addonUrl": {
+        "description": "URL of the add-on XPI file",
+        "format": "uri",
+        "minLength": 1,
+        "type": "string"
+      },
+      "description": {
+        "description": "User-facing description of the study",
+        "minLength": 1,
+        "type": "string"
+      },
+      "name": {
+        "description": "User-facing name of the study",
+        "minLength": 1,
+        "type": "string"
+      }
+    },
+    "required": [
+      "name",
+      "description",
+      "addonUrl"
+    ],
+    "title": "Enroll a user in an opt-out SHIELD study",
+    "type": "object"
+  },
+  "implementation_url": "https://localhost:8443/api/v1/action/opt-out-study/implementation/sha384-HM_avYcD00o27ufwU1V7PIBtiuMAXML6MMwlYrDEqDX-XzGVuOfL52RCM680JExN/",
+  "name": "opt-out-study"
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/preference-experiment/implementation/sha384-KQgG38GQ7KZAb2VIB48ANQO6nBcxZoLm2ORzUviRT5nAvSywyPjZ5cJIElw6iXIt
@@ -0,0 +1,2 @@
+(function(a){function b(d){if(c[d])return c[d].exports;var e=c[d]={exports:{},id:d,loaded:!1};return a[d].call(e.exports,e,e.exports,b),e.loaded=!0,e.exports}var c={};return b.m=a,b.c=c,b.p="",b(0)})([function(a,b,c){"use strict";async function e(i){if(void 0===i.preferenceExperiments)return void i.log("Client does not support preference experiments, aborting.","info");const j=await i.preferenceExperiments.getAllActive();for(const k of j)g.includes(k.name)||(await i.preferenceExperiments.stop(k.name,!0))}Object.defineProperty(b,"__esModule",{value:!0}),b.resetAction=function(){g=[]},b.postExecutionHook=e;var f=c(1);let g=[];class h extends f.Action{async execute(){var i=this.recipe.arguments;const j=i.slug,k=i.preferenceName,l=i.preferenceBranchType,m=i.branches,n=i.preferenceType,o=this.normandy.preferenceExperiments;if(void 0===o)return void this.normandy.log("Client does not support preference experiments, aborting.","info");g.push(j);const p=await o.has(j);if(!p){const q=await o.getAllActive(),r=q.some((t)=>t.preferenceName===k);if(r)return void this.normandy.log(`Experiment ${j} ignored; another active experiment is already using the
+	          ${k} preference.`,"warn");const s=await this.chooseBranch(m);await o.start({name:j,branch:s.slug,preferenceName:k,preferenceValue:s.value,preferenceBranchType:l,preferenceType:n})}else{const q=await o.get(j);q.expired?this.normandy.log(`Experiment ${j} has expired, aborting.`,"debug"):await o.markLastSeen(j)}}async chooseBranch(i){const j=this.recipe.arguments.slug,k=i.map((n)=>n.ratio),l=`${this.normandy.userId}-${j}-branch`,m=await this.normandy.ratioSample(l,k);return i[m]}}b.default=h,(0,f.registerAction)("preference-experiment",h),(0,f.registerAsyncCallback)("postExecution",e)},function(a,b){(function(c){"use strict";Object.defineProperty(b,"__esModule",{value:!0});b.Action=class{constructor(g,h){this.normandy=g,this.recipe=h}};const e=b.registerAction=c&&c.registerAction||window&&window.registerAction||function(){},f=b.registerAsyncCallback=c&&c.registerAsyncCallback||window&&window.registerAsyncCallback||function(){}}).call(b,function(){return this}())}]);
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/preference-experiment/index.json
@@ -0,0 +1,86 @@
+{
+  "arguments_schema": {
+    "$schema": "http://json-schema.org/draft-04/schema#",
+    "properties": {
+      "branches": {
+        "description": "List of experimental branches",
+        "items": {
+          "properties": {
+            "ratio": {
+              "default": 1,
+              "description": "Ratio of users who should be grouped into this branch",
+              "minimum": 1,
+              "type": "integer"
+            },
+            "slug": {
+              "description": "Unique identifier for this branch of the experiment",
+              "pattern": "^[A-Za-z0-9\\-_]+$",
+              "type": "string"
+            },
+            "value": {
+              "description": "Value to set the preference to for this branch",
+              "type": [
+                "string",
+                "number",
+                "boolean"
+              ]
+            }
+          },
+          "required": [
+            "slug",
+            "value",
+            "ratio"
+          ],
+          "type": "object"
+        },
+        "minItems": 1,
+        "type": "array"
+      },
+      "experimentDocumentUrl": {
+        "default": "",
+        "description": "URL of a document describing the experiment",
+        "format": "uri",
+        "type": "string"
+      },
+      "preferenceBranchType": {
+        "default": "default",
+        "descript": "Controls whether the default or user value of the preference is modified",
+        "enum": [
+          "user",
+          "default"
+        ],
+        "type": "string"
+      },
+      "preferenceName": {
+        "default": "",
+        "description": "Full dotted-path of the preference that controls this experiment",
+        "type": "string"
+      },
+      "preferenceType": {
+        "default": "boolean",
+        "description": "Data type of the preference that controls this experiment",
+        "enum": [
+          "string",
+          "integer",
+          "boolean"
+        ],
+        "type": "string"
+      },
+      "slug": {
+        "description": "Unique identifier for this experiment",
+        "pattern": "^[A-Za-z0-9\\-_]+$",
+        "type": "string"
+      }
+    },
+    "required": [
+      "slug",
+      "preferenceName",
+      "preferenceType",
+      "branches"
+    ],
+    "title": "Run a feature experiment activated by a preference.",
+    "type": "object"
+  },
+  "implementation_url": "https://localhost:8443/api/v1/action/preference-experiment/implementation/sha384-KQgG38GQ7KZAb2VIB48ANQO6nBcxZoLm2ORzUviRT5nAvSywyPjZ5cJIElw6iXIt/",
+  "name": "preference-experiment"
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/show-heartbeat/implementation/sha384-dEGiyKPEln8Ns5cQHzGpMIGdirSAAX0X-Kwlu-U3sJ05yNbO-ANij_a6c5SyL7G4
@@ -0,0 +1,1 @@
+(function(a){function b(d){if(c[d])return c[d].exports;var e=c[d]={exports:{},id:d,loaded:!1};return a[d].call(e.exports,e,e.exports,b),e.loaded=!0,e.exports}var c={};return b.m=a,b.c=c,b.p="",b(0)})([function(a,b,c){"use strict";Object.defineProperty(b,"__esModule",{value:!0});var d=Object.assign||function(k){for(var m,l=1;l<arguments.length;l++)for(var n in m=arguments[l],m)Object.prototype.hasOwnProperty.call(m,n)&&(k[n]=m[n]);return k},e=c(1);const g=24*(3600*1e3);class j extends e.Action{constructor(k,l){super(k,l),this.storage=k.createStorage(l.id),this.heartbeatStorage=k.createStorage("normandy-heartbeat"),this.updateLastInteraction=this.updateLastInteraction.bind(this),this.updateLastShown=this.updateLastShown.bind(this)}generateSurveyId(){var k=this.recipe.arguments;const l=k.includeTelemetryUUID,m=k.surveyId,n=this.normandy.userId;let o=m;return l&&!!n&&(o=`${m}::${n}`),o}async heartbeatShownRecently(){const k=await this.heartbeatStorage.getItem("lastShown"),l=k?new Date-parseFloat(k):Infinity;return l<g}async getLastShown(){const k=await this.storage.getItem("lastShown");return"undefined"==typeof k?null:parseFloat(k)}async hasShownBefore(){return null!==(await this.storage.getItem("lastShown"))}async shownAtleastDaysAgo(k){const l=await this.hasShownBefore();if(!l)return!1;const m=await this.getLastShown(),n=Date.now()-m;return n<g*k}async getLastInteraction(){const k=await this.storage.getItem("lastInteraction");return"undefined"==typeof k?null:parseFloat(k)}async sinceLastInteraction(){const k=await this.getLastInteraction();return"undefined"==typeof k?null:Date.now()-k}async hasHadInteraction(){const k=await this.getLastInteraction();return!!k}async heartbeatHasExecuted(){let k=!1;var l=this.recipe.arguments;const m=l.repeatOption,n=l.repeatEvery;switch(m){default:case"once":k=await this.hasShownBefore();break;case"nag":k=await this.hasHadInteraction();break;case"xdays":k=await this.shownAtleastDaysAgo(n);}return k}async shouldNotExecute(){return!this.normandy.testing&&((await this.heartbeatShownRecently())||(await this.heartbeatHasExecuted()))}async execute(){var k=this.recipe.arguments;const l=k.message,m=k.engagementButtonLabel,n=k.thanksMessage,o=k.postAnswerUrl,p=k.learnMoreMessage,q=k.learnMoreUrl;if(await this.shouldNotExecute())return;this.client=await this.normandy.client();const r=this.normandy.userId,s=this.generateSurveyId(),t={surveyId:s,message:l,engagementButtonLabel:m,thanksMessage:n,learnMoreMessage:p,learnMoreUrl:q,postAnswerUrl:this.generatePostURL(o,r),flowId:this.normandy.uuid(),surveyVersion:this.recipe.revision_id};this.normandy.testing&&(t.testing=1);const u=await this.normandy.showHeartbeat(t);["Voted","Engaged"].forEach((w)=>{u.on(w,this.updateLastInteraction)}),this.updateLastShown()}updateLastShown(){this.storage.setItem("lastShown",Date.now()),this.heartbeatStorage.setItem("lastShown",Date.now())}updateLastInteraction(){this.storage.setItem("lastInteraction",Date.now())}getGAParams(){let k=this.recipe.arguments.message||"";k=k.replace(/\s+/g,""),k=encodeURIComponent(k);const l=new URL("http://mozilla.com");return l.searchParams.set("message",k),k=l.search.replace("?message=",""),{utm_source:"firefox",utm_medium:this.recipe.action,utm_campaign:k}}generatePostURL(k,l){if(!k)return k;const m=d({source:"heartbeat",surveyversion:56,updateChannel:this.client.channel,fxVersion:this.client.version,isDefaultBrowser:this.client.isDefaultBrowser?1:0,searchEngine:this.client.searchEngine,syncSetup:this.client.syncSetup?1:0},this.getGAParams());this.recipe.arguments.includeTelemetryUUID&&l&&(m.userId=l),this.normandy.testing&&(m.testing=1);const n=new URL(k);for(const o in m)m.hasOwnProperty(o)&&n.searchParams.set(o,m[o]);return n.href}}b.default=j,(0,e.registerAction)("show-heartbeat",j)},function(a,b){(function(c){"use strict";Object.defineProperty(b,"__esModule",{value:!0});b.Action=class{constructor(g,h){this.normandy=g,this.recipe=h}};const e=b.registerAction=c&&c.registerAction||window&&window.registerAction||function(){},f=b.registerAsyncCallback=c&&c.registerAsyncCallback||window&&window.registerAsyncCallback||function(){}}).call(b,function(){return this}())}]);
\ No newline at end of file
--- a/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/show-heartbeat/index.json
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/show-heartbeat/index.json
@@ -28,16 +28,26 @@
         "description": "Message to show to the user",
         "type": "string"
       },
       "postAnswerUrl": {
         "default": "",
         "description": "URL to redirect the user to after rating Firefox or clicking the engagement button",
         "type": "string"
       },
+      "repeatEvery": {
+        "default": null,
+        "description": "How often (in days) the action is displayed.",
+        "type": "number"
+      },
+      "repeatOption": {
+        "default": "once",
+        "description": "Determines how often an action executes. (once|nag|xdays)",
+        "type": "string"
+      },
       "surveyId": {
         "description": "Slug uniquely identifying this survey in telemetry",
         "type": "string"
       },
       "thanksMessage": {
         "default": "",
         "description": "Thanks message to show to the user after they've rated Firefox",
         "type": "string"
@@ -46,11 +56,11 @@
     "required": [
       "surveyId",
       "message",
       "thanksMessage"
     ],
     "title": "Show a Heartbeat survey.",
     "type": "object"
   },
-  "implementation_url": "https://localhost:8000/api/v1/action/show-heartbeat/implementation/448bbffce82ff27b3d532fd7196bcb190be5c067/",
+  "implementation_url": "https://localhost:8443/api/v1/action/show-heartbeat/implementation/sha384-dEGiyKPEln8Ns5cQHzGpMIGdirSAAX0X-Kwlu-U3sJ05yNbO-ANij_a6c5SyL7G4/",
   "name": "show-heartbeat"
 }
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/signed/index.json
@@ -0,0 +1,1 @@
+[{"action":{"arguments_schema":{"$schema":"http://json-schema.org/draft-04/schema#","properties":{"message":{"default":"","description":"Message to log to the console","type":"string"}},"required":["message"],"title":"Log a message to the console","type":"object"},"implementation_url":"/api/v1/action/console-log/implementation/sha384-RGx3rydrSq53UfmW9kFcK0mQYra67XIvZvr4MhmAe--ljiiMQOtgM7Cmca48um3v/","name":"console-log"},"signature":{"public_key":"MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEVEKiCAIkwRg1VFsP8JOYdSF6a3qvgbRPoEK9eTuLbrB6QixozscKR4iWJ8ZOOX6RPCRgFdfVDoZqjFBFNJN9QtRBk0mVtHbnErx64d2vMF0oWencS1hyLW2whgOgOz7p","signature":"_PnFvgyHCF2ncR50U4nKbWhZcK7OZejvjSWlcAE8kHDDlXoJirtNnN48jEAMEzt1FfacqWjT4QF3aEV1te35FgKQCwjtiDyjmrQR4cQb3T3aqUFGZNSzCBIEWMTN6RoY","timestamp":"2017-08-03T21:10:32.538860Z","x5u":"/normandy.content-signature.mozilla.org-20210705.dev.chain"}},{"action":{"arguments_schema":{"$schema":"http://json-schema.org/draft-04/schema#","properties":{"addonUrl":{"description":"URL of the add-on XPI file","format":"uri","minLength":1,"type":"string"},"description":{"description":"User-facing description of the study","minLength":1,"type":"string"},"name":{"description":"User-facing name of the study","minLength":1,"type":"string"}},"required":["name","description","addonUrl"],"title":"Enroll a user in an opt-out SHIELD study","type":"object"},"implementation_url":"/api/v1/action/opt-out-study/implementation/sha384-HM_avYcD00o27ufwU1V7PIBtiuMAXML6MMwlYrDEqDX-XzGVuOfL52RCM680JExN/","name":"opt-out-study"},"signature":{"public_key":"MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEVEKiCAIkwRg1VFsP8JOYdSF6a3qvgbRPoEK9eTuLbrB6QixozscKR4iWJ8ZOOX6RPCRgFdfVDoZqjFBFNJN9QtRBk0mVtHbnErx64d2vMF0oWencS1hyLW2whgOgOz7p","signature":"RllXGVzj-c0ZaHxzKE6k-GJXOtEl-NNprMi4I-TvbBDZUlhq4UPOQidpCvn74A41M6cV7bb9qxw-M1Z9t4BqUMQaOT4yXiEU9DoBMBASA-C_xf7VXtwWwH8GvT8C4yh_","timestamp":"2017-08-03T21:10:32.562478Z","x5u":"/normandy.content-signature.mozilla.org-20210705.dev.chain"}},{"action":{"arguments_schema":{"$schema":"http://json-schema.org/draft-04/schema#","description":"This action shows a single survey.","properties":{"engagementButtonLabel":{"default":"","description":"Text for the engagement button. If specified, this button will be shown instead of rating stars.","type":"string"},"includeTelemetryUUID":{"default":false,"description":"Include unique user ID in post-answer-url and Telemetry","type":"boolean"},"learnMoreMessage":{"default":"","description":"Message to show to the user to learn more","type":"string"},"learnMoreUrl":{"default":"","description":"URL to show to the user when they click Learn More","type":"string"},"message":{"default":"","description":"Message to show to the user","type":"string"},"postAnswerUrl":{"default":"","description":"URL to redirect the user to after rating Firefox or clicking the engagement button","type":"string"},"repeatEvery":{"default":null,"description":"How often (in days) the action is displayed.","type":"number"},"repeatOption":{"default":"once","description":"Determines how often an action executes. (once|nag|xdays)","type":"string"},"surveyId":{"description":"Slug uniquely identifying this survey in telemetry","type":"string"},"thanksMessage":{"default":"","description":"Thanks message to show to the user after they've rated Firefox","type":"string"}},"required":["surveyId","message","thanksMessage"],"title":"Show a Heartbeat survey.","type":"object"},"implementation_url":"/api/v1/action/show-heartbeat/implementation/sha384-dEGiyKPEln8Ns5cQHzGpMIGdirSAAX0X-Kwlu-U3sJ05yNbO-ANij_a6c5SyL7G4/","name":"show-heartbeat"},"signature":{"public_key":"MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEVEKiCAIkwRg1VFsP8JOYdSF6a3qvgbRPoEK9eTuLbrB6QixozscKR4iWJ8ZOOX6RPCRgFdfVDoZqjFBFNJN9QtRBk0mVtHbnErx64d2vMF0oWencS1hyLW2whgOgOz7p","signature":"XQLZ7g20PkKdjLgGWqVUTIu-bkeSeOTa6MEaEcfI6lpH1UcJ9YtTz3Ff73GoT2DnVSXgrH00jyxggOT-wLVSX3tJBeAEg_9MpepZoeDQ5XRqV0kIY6emjUP3YKemFGIw","timestamp":"2017-08-03T21:10:32.510980Z","x5u":"/normandy.content-signature.mozilla.org-20210705.dev.chain"}},{"action":{"arguments_schema":{"$schema":"http://json-schema.org/draft-04/schema#","properties":{"branches":{"description":"List of experimental branches","items":{"properties":{"ratio":{"default":1,"description":"Ratio of users who should be grouped into this branch","minimum":1,"type":"integer"},"slug":{"description":"Unique identifier for this branch of the experiment","pattern":"^[A-Za-z0-9\\-_]+$","type":"string"},"value":{"description":"Value to set the preference to for this branch","type":["string","number","boolean"]}},"required":["slug","value","ratio"],"type":"object"},"minItems":1,"type":"array"},"experimentDocumentUrl":{"default":"","description":"URL of a document describing the experiment","format":"uri","type":"string"},"preferenceBranchType":{"default":"default","descript":"Controls whether the default or user value of the preference is modified","enum":["user","default"],"type":"string"},"preferenceName":{"default":"","description":"Full dotted-path of the preference that controls this experiment","type":"string"},"preferenceType":{"default":"boolean","description":"Data type of the preference that controls this experiment","enum":["string","integer","boolean"],"type":"string"},"slug":{"description":"Unique identifier for this experiment","pattern":"^[A-Za-z0-9\\-_]+$","type":"string"}},"required":["slug","preferenceName","preferenceType","branches"],"title":"Run a feature experiment activated by a preference.","type":"object"},"implementation_url":"/api/v1/action/preference-experiment/implementation/sha384-KQgG38GQ7KZAb2VIB48ANQO6nBcxZoLm2ORzUviRT5nAvSywyPjZ5cJIElw6iXIt/","name":"preference-experiment"},"signature":{"public_key":"MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEVEKiCAIkwRg1VFsP8JOYdSF6a3qvgbRPoEK9eTuLbrB6QixozscKR4iWJ8ZOOX6RPCRgFdfVDoZqjFBFNJN9QtRBk0mVtHbnErx64d2vMF0oWencS1hyLW2whgOgOz7p","signature":"DqIks6qG4fWYl9riLAwFor-CGI2KrnW0bxiwV2PU45Nb2ziHMaAdm8yGaPrcZ58qvGnHZkHxgIuqyAUHzmzAAr662pmfjiTc75UXWtx4il94sqVLBF6G28U2taEU1GpJ","timestamp":"2017-08-03T20:53:52.948142Z","x5u":"/normandy.content-signature.mozilla.org-20210705.dev.chain"}}]
--- a/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/index.json
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/index.json
@@ -1,8 +1,9 @@
 {
   "action-list": "/api/v1/action/",
+  "action-signed": "/api/v1/action/signed/",
   "classify-client": "/api/v1/classify_client/",
   "filters": "/api/v1/filters/",
   "recipe-list": "/api/v1/recipe/",
   "recipe-signed": "/api/v1/recipe/signed/",
   "reciperevision-list": "/api/v1/recipe_revision/"
 }
--- a/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/recipe/signed/index.json
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/recipe/signed/index.json
@@ -1,1 +1,1 @@
-[{"recipe":{"action":"console-log","arguments":{"message":"asdfasfda sdf sa"},"channels":[],"countries":[],"enabled":true,"extra_filter_expression":"true || true","filter_expression":"true || true","id":1,"last_updated":"2017-02-17T18:29:09.839239Z","locales":[],"name":"system-addon-test","revision_id":"b2cb8a26e132182d7d02cf50695d2c7f06cf3b954ff2ff63bca49d724ee91950"},"signature":{"public_key":"MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEVEKiCAIkwRg1VFsP8JOYdSF6a3qvgbRPoEK9eTuLbrB6QixozscKR4iWJ8ZOOX6RPCRgFdfVDoZqjFBFNJN9QtRBk0mVtHbnErx64d2vMF0oWencS1hyLW2whgOgOz7p","signature":"p4g3eurmPsJK5UcGT97BRyKstpwZ_2mNJkDGpd6QXlkXfvgwprjeyb5yeIEkKUXqc6krWid4obB_OP9-CwOi9tvKY1pV8p98CT5BhF0IVgpF3b7KBW1a0BVdg5owoG5W","timestamp":"2017-02-17T18:29:09.847614Z","x5u":"/api/v1/recipe/signed/normandy.content-signature.mozilla.org-20210705.dev.chain"}}]
+[{"recipe":{"action":"console-log","arguments":{"message":"asdfasfda sdf sa"},"channels":[],"countries":[],"enabled":true,"extra_filter_expression":"true || true","filter_expression":"true || true","id":1,"last_updated":"2017-02-17T18:29:09.839239Z","locales":[],"name":"system-addon-test","revision_id":"b2cb8a26e132182d7d02cf50695d2c7f06cf3b954ff2ff63bca49d724ee91950"},"signature":{"public_key":"MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEVEKiCAIkwRg1VFsP8JOYdSF6a3qvgbRPoEK9eTuLbrB6QixozscKR4iWJ8ZOOX6RPCRgFdfVDoZqjFBFNJN9QtRBk0mVtHbnErx64d2vMF0oWencS1hyLW2whgOgOz7p","signature":"p4g3eurmPsJK5UcGT97BRyKstpwZ_2mNJkDGpd6QXlkXfvgwprjeyb5yeIEkKUXqc6krWid4obB_OP9-CwOi9tvKY1pV8p98CT5BhF0IVgpF3b7KBW1a0BVdg5owoG5W","timestamp":"2017-02-17T18:29:09.847614Z","x5u":"/normandy.content-signature.mozilla.org-20210705.dev.chain"}}]
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/recipe/signed/normandy.content-signature.mozilla.org-20210705.dev.chain
+++ /dev/null
@@ -1,123 +0,0 @@
------BEGIN CERTIFICATE-----
-MIIGRTCCBC2gAwIBAgIEAQAABTANBgkqhkiG9w0BAQwFADBrMQswCQYDVQQGEwJV
-UzEQMA4GA1UEChMHQWxsaXpvbTEXMBUGA1UECxMOQ2xvdWQgU2VydmljZXMxMTAv
-BgNVBAMTKERldnppbGxhIFNpZ25pbmcgU2VydmljZXMgSW50ZXJtZWRpYXRlIDEw
-HhcNMTYwNzA2MjE1NzE1WhcNMjEwNzA1MjE1NzE1WjCBrzELMAkGA1UEBhMCVVMx
-EzARBgNVBAgTCkNhbGlmb3JuaWExHDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRp
-b24xFzAVBgNVBAsTDkNsb3VkIFNlcnZpY2VzMS8wLQYDVQQDEyZub3JtYW5keS5j
-b250ZW50LXNpZ25hdHVyZS5tb3ppbGxhLm9yZzEjMCEGCSqGSIb3DQEJARYUc2Vj
-dXJpdHlAbW96aWxsYS5vcmcwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARUQqIIAiTB
-GDVUWw/wk5h1IXpreq+BtE+gQr15O4tusHpCLGjOxwpHiJYnxk45fpE8JGAV19UO
-hmqMUEU0k31C1EGTSZW0ducSvHrh3a8wXShZ6dxLWHItbbCGA6A7PumjggJYMIIC
-VDAdBgNVHQ4EFgQUVfksSjlZ0i1TBiS1vcoObaMeXn0wge8GA1UdIwSB5zCB5IAU
-/YboUIXAovChEpudDBuodHKbjUuhgcWkgcIwgb8xCzAJBgNVBAYTAlVTMQswCQYD
-VQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UEChMdQ29udGVu
-dCBTaWduYXR1cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5jb250ZW50LXNp
-Z25hdHVyZS5yb290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNlYytkZXZyb290
-Y29udGVudHNpZ25hdHVyZUBtb3ppbGxhLmNvbYIEAQAABDAMBgNVHRMBAf8EAjAA
-MA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAzBEBgNVHR8E
-PTA7MDmgN6A1hjNodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmRldi5tb3phd3Mu
-bmV0L2NhL2NybC5wZW0wQgYJYIZIAYb4QgEEBDUWM2h0dHBzOi8vY29udGVudC1z
-aWduYXR1cmUuZGV2Lm1vemF3cy5uZXQvY2EvY3JsLnBlbTBOBggrBgEFBQcBAQRC
-MEAwPgYIKwYBBQUHMAKGMmh0dHBzOi8vY29udGVudC1zaWduYXR1cmUuZGV2Lm1v
-emF3cy5uZXQvY2EvY2EucGVtMDEGA1UdEQQqMCiCJm5vcm1hbmR5LmNvbnRlbnQt
-c2lnbmF0dXJlLm1vemlsbGEub3JnMA0GCSqGSIb3DQEBDAUAA4ICAQCwb+8JTAB7
-ZfQmFqPUIV2cQQv696AaDPQCtA9YS4zmUfcLMvfZVAbK397zFr0RMDdLiTUQDoeq
-rBEmPXhJRPiv6JAK4n7Jf6Y6XfXcNxx+q3garR09Vm/0CnEq/iV+ZAtPkoKIO9kr
-Nkzecd894yQCF4hIuPQ5qtMySeqJmH3Dp13eq4T0Oew1Bu32rNHuBJh2xYBkWdun
-aAw/YX0I5EqZBP/XA6gbiA160tTK+hnpnlMtw/ljkvfhHbWpICD4aSiTL8L3vABQ
-j7bqjMKR5xDkuGWshZfcmonpvQhGTye/RZ1vz5IzA3VOJt1mz5bdZlitpaOm/Yv0
-x6aODz8GP/PiRWFQ5CW8Uf/7pGc5rSyvnfZV2ix8EzFlo8cUtuN1fjrPFPOFOLvG
-iiB6S9nlXiKBGYIDdd8V8iC5xJpzjiAWJQigwSNzuc2K30+iPo3w0zazkwe5V8jW
-gj6gItYxh5xwVQTPHD0EOd9HvV1ou42+rH5Y+ISFUm25zz02UtUHEK0BKtL0lmdt
-DwVq5jcHn6bx2/iwUtlKvPXtfM/6JjTJlkLZLtS7U5/pwcS0owo9zAL0qg3bdm16
-+v/olmPqQFLUHmamJTzv3rojj5X/uVdx1HMM3wBjV9tRYoYaZw9RIInRmM8Z1pHv
-JJ+CIZgCyd5vgp57BKiodRZcgHoCH+BkOQ==
------END CERTIFICATE-----
------BEGIN CERTIFICATE-----
-MIIHijCCBXKgAwIBAgIEAQAABDANBgkqhkiG9w0BAQwFADCBvzELMAkGA1UEBhMC
-VVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MSYwJAYDVQQK
-Ex1Db250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEmMCQGA1UEAxMdZGV2LmNv
-bnRlbnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG9w0BCQEWLGNsb3Vkc2Vj
-K2RldnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEuY29tMB4XDTE2MDcwNjIx
-NDkyNloXDTIxMDcwNTIxNDkyNlowazELMAkGA1UEBhMCVVMxEDAOBgNVBAoTB0Fs
-bGl6b20xFzAVBgNVBAsTDkNsb3VkIFNlcnZpY2VzMTEwLwYDVQQDEyhEZXZ6aWxs
-YSBTaWduaW5nIFNlcnZpY2VzIEludGVybWVkaWF0ZSAxMIICIjANBgkqhkiG9w0B
-AQEFAAOCAg8AMIICCgKCAgEAypIfUURYgWzGw8G/1Pz9zW+Tsjirx2owThiv2gys
-wJiWL/9/2gzKOrYDEqlDUudfA/BjVRtT9+NbYgnhaCkNfADOAacWS83aMhedAqhP
-bVd5YhGQdpijI7f1AVTSb0ehrU2nhOZHvHX5Tk2fbRx3ryefIazNTLFGpiMBbsNv
-tSI/+fjW8s0MhKNqlLnk6a9mZKo0mEy7HjGYV8nzsgI17rKLx/s2HG4TFG0+JQzc
-UGlum3Tg58ritDzWdyKIkmKNZ48oLBX99Qc8j8B1UyiLv6TZmjVX0I+Ds7eSWHZk
-0axLEpTyf2r7fHvN4iDNCPajw+ZpuuBfbs80Ha8b8MHvnpf9fbwiirodNQOVpY4c
-t5E3Us3eYwBKdqDEbECWxCKGAS2/iVVUCNKHsg0sSxgqcwxrxyrddQRUQ0EM38DZ
-F/vHt+vTdHt07kezbjJe0Kklel59uSpghA0iL4vxzbTns1fuwYOgVrNGs3acTkiB
-GhFOxRXUPGtpdYmv+AaR9WlWJQY1GIEoVrinPVH7bcCwyh1CcUbHL+oAFTcmc6kZ
-7azNg21tWILIRL7R0IZYQm0tF5TTwCsjVC7FuHaBtkxtVrrZqeKjvVXQ8TK5VoI0
-BUQ6BKHGeTtm+0HBpheYBDy3wkOsEGbGHLEM6cMeiz6PyCXF8wXli8Qb/TjN3LHZ
-e30CAwEAAaOCAd8wggHbMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGG
-MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMDMB0GA1UdDgQWBBT9huhQhcCi8KESm50M
-G6h0cpuNSzCB7AYDVR0jBIHkMIHhgBSDx8s0qJaMyQCehKcuzgzVNRA75qGBxaSB
-wjCBvzELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFp
-biBWaWV3MSYwJAYDVQQKEx1Db250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEm
-MCQGA1UEAxMdZGV2LmNvbnRlbnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG
-9w0BCQEWLGNsb3Vkc2VjK2RldnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEu
-Y29tggEBMEIGCWCGSAGG+EIBBAQ1FjNodHRwczovL2NvbnRlbnQtc2lnbmF0dXJl
-LmRldi5tb3phd3MubmV0L2NhL2NybC5wZW0wTgYIKwYBBQUHAQEEQjBAMD4GCCsG
-AQUFBzAChjJodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmRldi5tb3phd3MubmV0
-L2NhL2NhLnBlbTANBgkqhkiG9w0BAQwFAAOCAgEAbum0z0ccqI1Wp49VtsGmUPHA
-gjPPy2Xa5NePmqY87WrGdhjN3xbLVb3hx8T2N6pqDjMY2rEynXKEOek3oJkQ3C6J
-8AFP6Y93gaAlNz6EA0J0mqdW5TMI8YEYsu2ma+dQQ8wm9f/5b+/Y8qwfhztP06W5
-H6IG04/RvgUwYMnSR4QvT309fu5UmCRUDzsO53ZmQCfmN94u3NxHc4S6n0Q6AKAM
-kh5Ld9SQnlqqqDykzn7hYDi8nTLWc7IYqkGfNMilDEKbAl4CjnSfyEvpdFAJ9sPR
-UL+kaWFSMvaqIPNpxS5OpoPZjmxEc9HHnCHxtfDHWdXTJILjijPrCdMaxOCHfIqV
-5roOJggI4RZ0YM68IL1MfN4IEVOrHhKjDHtd1gcmy2KU4jfj9LQq9YTnyvZ2z1yS
-lS310HG3or1K8Nnu5Utfe7T6ppX8bLRMkS1/w0p7DKxHaf4D/GJcCtM9lcSt9JpW
-6ACKFikjWR4ZxczYKgApc0wcjd7XBuO5777xtOeyEUDHdDft3jiXA91dYM5UAzc3
-69z/3zmaELzo0gWcrjLXh7fU9AvbU4EUF6rwzxbPGF78jJcGK+oBf8uWUCkBykDt
-VsAEZI1u4EDg8e/C1nFqaH9gNMArAgquYIB9rve+hdprIMnva0S147pflWopBWcb
-jwzgpfquuYnnxe0CNBA=
------END CERTIFICATE-----
------BEGIN CERTIFICATE-----
-MIIH3DCCBcSgAwIBAgIBATANBgkqhkiG9w0BAQwFADCBvzELMAkGA1UEBhMCVVMx
-CzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MSYwJAYDVQQKEx1D
-b250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEmMCQGA1UEAxMdZGV2LmNvbnRl
-bnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG9w0BCQEWLGNsb3Vkc2VjK2Rl
-dnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEuY29tMB4XDTE2MDcwNjE4MTUy
-MloXDTI2MDcwNDE4MTUyMlowgb8xCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJDQTEW
-MBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UEChMdQ29udGVudCBTaWduYXR1
-cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5jb250ZW50LXNpZ25hdHVyZS5y
-b290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNlYytkZXZyb290Y29udGVudHNp
-Z25hdHVyZUBtb3ppbGxhLmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
-ggIBAJcPcXhD8MzF6OTn5qZ0L7lX1+PEgLKhI9g1HxxDYDVup4Zm0kZhPGmFSlml
-6eVO99OvvHdAlHhQGCIG7h+w1cp66mWjfpcvtQH23uRoKZfiW3jy1jUWrvdXolxR
-t1taZosjzo+9OP8TvG6LpJj7AvqUiYD4wYnQJtt0jNRN4d6MUfQwiavSS5uTBuxd
-ZJ4TsPvEI+Iv4A4PSobSzxkg79LTMvsGtDLQv7nN5hMs9T18EL5GnIKoqnSQCU0d
-n2CN7S3QiQ+cbORWsSYqCTj1gUbFh6X3duEB/ypLcyWFbqeJmPHikjY8q8pLjZSB
-IYiTJYLyvYlKdM5QleC/xuBNnMPCftrwwLHjWE4Dd7C9t7k0R5xyOetuiHLCwOcQ
-tuckp7RgFKoviMNv3gdkzwVapOklcsaRkRscv6OMTKJNsdJVIDLrPF1wMABhbEQB
-64BL0uL4lkFtpXXbJzQ6mgUNQveJkfUWOoB+cA/6GtI4J0aQfvQgloCYI6jxNn/e
-Nvk5OV9KFOhXS2dnDft3wHU46sg5yXOuds1u6UrOoATBNFlkS95m4zIX1Svu091+
-CKTiLK85+ZiFtAlU2bPr3Bk3GhL3Z586ae6a4QUEx6SPQVXc18ezB4qxKqFc+avI
-ylccYMRhVP+ruADxtUM5Vy6x3U8BwBK5RLdecRx2FEBDImY1AgMBAAGjggHfMIIB
-2zAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAWBgNVHSUBAf8EDDAK
-BggrBgEFBQcDAzAdBgNVHQ4EFgQUg8fLNKiWjMkAnoSnLs4M1TUQO+YwgewGA1Ud
-IwSB5DCB4YAUg8fLNKiWjMkAnoSnLs4M1TUQO+ahgcWkgcIwgb8xCzAJBgNVBAYT
-AlVTMQswCQYDVQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UE
-ChMdQ29udGVudCBTaWduYXR1cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5j
-b250ZW50LXNpZ25hdHVyZS5yb290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNl
-YytkZXZyb290Y29udGVudHNpZ25hdHVyZUBtb3ppbGxhLmNvbYIBATBCBglghkgB
-hvhCAQQENRYzaHR0cHM6Ly9jb250ZW50LXNpZ25hdHVyZS5kZXYubW96YXdzLm5l
-dC9jYS9jcmwucGVtME4GCCsGAQUFBwEBBEIwQDA+BggrBgEFBQcwAoYyaHR0cHM6
-Ly9jb250ZW50LXNpZ25hdHVyZS5kZXYubW96YXdzLm5ldC9jYS9jYS5wZW0wDQYJ
-KoZIhvcNAQEMBQADggIBAAAQ+fotZE79FfZ8Lz7eiTUzlwHXCdSE2XD3nMROu6n6
-uLTBPrf2C+k+U1FjKVvL5/WCUj6hIzP2X6Sb8+o0XHX9mKN0yoMORTEYJOnazYPK
-KSUUFnE4vGgQkr6k/31gGRMTICdnf3VOOAlUCQ5bOmGIuWi401E3sbd85U+TJe0A
-nHlU+XjtfzlqcBvQivdbA0s+GEG55uRPvn952aTBEMHfn+2JqKeLShl4AtUAfu+h
-6md3Z2HrEC7B3GK8ekWPu0G/ZuWTuFvOimZ+5C8IPRJXcIR/siPQl1x6dpTCew6t
-lPVcVuvg6SQwzvxetkNrGUe2Wb2s9+PK2PUvfOS8ee25SNmfG3XK9qJpqGUhzSBX
-T8QQgyxd0Su5G7Wze7aaHZd/fqIm/G8YFR0HiC2xni/lnDTXFDPCe+HCnSk0bH6U
-wpr6I1yK8oZ2IdnNVfuABGMmGOhvSQ8r7//ea9WKhCsGNQawpVWVioY7hpyNAJ0O
-Vn4xqG5f6allz8lgpwAQ+AeEEClHca6hh6mj9KhD1Of1CC2Vx52GHNh/jMYEc3/g
-zLKniencBqn3Y2XH2daITGJddcleN09+a1NaTkT3hgr7LumxM8EVssPkC+z9j4Vf
-Gbste+8S5QCMhh00g5vR9QF8EaFqdxCdSxrsA4GmpCa5UQl8jtCnpp2DLKXuOh72
------END CERTIFICATE-----
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/normandy.content-signature.mozilla.org-20210705.dev.chain
@@ -0,0 +1,123 @@
+-----BEGIN CERTIFICATE-----
+MIIGRTCCBC2gAwIBAgIEAQAABTANBgkqhkiG9w0BAQwFADBrMQswCQYDVQQGEwJV
+UzEQMA4GA1UEChMHQWxsaXpvbTEXMBUGA1UECxMOQ2xvdWQgU2VydmljZXMxMTAv
+BgNVBAMTKERldnppbGxhIFNpZ25pbmcgU2VydmljZXMgSW50ZXJtZWRpYXRlIDEw
+HhcNMTYwNzA2MjE1NzE1WhcNMjEwNzA1MjE1NzE1WjCBrzELMAkGA1UEBhMCVVMx
+EzARBgNVBAgTCkNhbGlmb3JuaWExHDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRp
+b24xFzAVBgNVBAsTDkNsb3VkIFNlcnZpY2VzMS8wLQYDVQQDEyZub3JtYW5keS5j
+b250ZW50LXNpZ25hdHVyZS5tb3ppbGxhLm9yZzEjMCEGCSqGSIb3DQEJARYUc2Vj
+dXJpdHlAbW96aWxsYS5vcmcwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARUQqIIAiTB
+GDVUWw/wk5h1IXpreq+BtE+gQr15O4tusHpCLGjOxwpHiJYnxk45fpE8JGAV19UO
+hmqMUEU0k31C1EGTSZW0ducSvHrh3a8wXShZ6dxLWHItbbCGA6A7PumjggJYMIIC
+VDAdBgNVHQ4EFgQUVfksSjlZ0i1TBiS1vcoObaMeXn0wge8GA1UdIwSB5zCB5IAU
+/YboUIXAovChEpudDBuodHKbjUuhgcWkgcIwgb8xCzAJBgNVBAYTAlVTMQswCQYD
+VQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UEChMdQ29udGVu
+dCBTaWduYXR1cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5jb250ZW50LXNp
+Z25hdHVyZS5yb290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNlYytkZXZyb290
+Y29udGVudHNpZ25hdHVyZUBtb3ppbGxhLmNvbYIEAQAABDAMBgNVHRMBAf8EAjAA
+MA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAzBEBgNVHR8E
+PTA7MDmgN6A1hjNodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmRldi5tb3phd3Mu
+bmV0L2NhL2NybC5wZW0wQgYJYIZIAYb4QgEEBDUWM2h0dHBzOi8vY29udGVudC1z
+aWduYXR1cmUuZGV2Lm1vemF3cy5uZXQvY2EvY3JsLnBlbTBOBggrBgEFBQcBAQRC
+MEAwPgYIKwYBBQUHMAKGMmh0dHBzOi8vY29udGVudC1zaWduYXR1cmUuZGV2Lm1v
+emF3cy5uZXQvY2EvY2EucGVtMDEGA1UdEQQqMCiCJm5vcm1hbmR5LmNvbnRlbnQt
+c2lnbmF0dXJlLm1vemlsbGEub3JnMA0GCSqGSIb3DQEBDAUAA4ICAQCwb+8JTAB7
+ZfQmFqPUIV2cQQv696AaDPQCtA9YS4zmUfcLMvfZVAbK397zFr0RMDdLiTUQDoeq
+rBEmPXhJRPiv6JAK4n7Jf6Y6XfXcNxx+q3garR09Vm/0CnEq/iV+ZAtPkoKIO9kr
+Nkzecd894yQCF4hIuPQ5qtMySeqJmH3Dp13eq4T0Oew1Bu32rNHuBJh2xYBkWdun
+aAw/YX0I5EqZBP/XA6gbiA160tTK+hnpnlMtw/ljkvfhHbWpICD4aSiTL8L3vABQ
+j7bqjMKR5xDkuGWshZfcmonpvQhGTye/RZ1vz5IzA3VOJt1mz5bdZlitpaOm/Yv0
+x6aODz8GP/PiRWFQ5CW8Uf/7pGc5rSyvnfZV2ix8EzFlo8cUtuN1fjrPFPOFOLvG
+iiB6S9nlXiKBGYIDdd8V8iC5xJpzjiAWJQigwSNzuc2K30+iPo3w0zazkwe5V8jW
+gj6gItYxh5xwVQTPHD0EOd9HvV1ou42+rH5Y+ISFUm25zz02UtUHEK0BKtL0lmdt
+DwVq5jcHn6bx2/iwUtlKvPXtfM/6JjTJlkLZLtS7U5/pwcS0owo9zAL0qg3bdm16
++v/olmPqQFLUHmamJTzv3rojj5X/uVdx1HMM3wBjV9tRYoYaZw9RIInRmM8Z1pHv
+JJ+CIZgCyd5vgp57BKiodRZcgHoCH+BkOQ==
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIHijCCBXKgAwIBAgIEAQAABDANBgkqhkiG9w0BAQwFADCBvzELMAkGA1UEBhMC
+VVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MSYwJAYDVQQK
+Ex1Db250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEmMCQGA1UEAxMdZGV2LmNv
+bnRlbnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG9w0BCQEWLGNsb3Vkc2Vj
+K2RldnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEuY29tMB4XDTE2MDcwNjIx
+NDkyNloXDTIxMDcwNTIxNDkyNlowazELMAkGA1UEBhMCVVMxEDAOBgNVBAoTB0Fs
+bGl6b20xFzAVBgNVBAsTDkNsb3VkIFNlcnZpY2VzMTEwLwYDVQQDEyhEZXZ6aWxs
+YSBTaWduaW5nIFNlcnZpY2VzIEludGVybWVkaWF0ZSAxMIICIjANBgkqhkiG9w0B
+AQEFAAOCAg8AMIICCgKCAgEAypIfUURYgWzGw8G/1Pz9zW+Tsjirx2owThiv2gys
+wJiWL/9/2gzKOrYDEqlDUudfA/BjVRtT9+NbYgnhaCkNfADOAacWS83aMhedAqhP
+bVd5YhGQdpijI7f1AVTSb0ehrU2nhOZHvHX5Tk2fbRx3ryefIazNTLFGpiMBbsNv
+tSI/+fjW8s0MhKNqlLnk6a9mZKo0mEy7HjGYV8nzsgI17rKLx/s2HG4TFG0+JQzc
+UGlum3Tg58ritDzWdyKIkmKNZ48oLBX99Qc8j8B1UyiLv6TZmjVX0I+Ds7eSWHZk
+0axLEpTyf2r7fHvN4iDNCPajw+ZpuuBfbs80Ha8b8MHvnpf9fbwiirodNQOVpY4c
+t5E3Us3eYwBKdqDEbECWxCKGAS2/iVVUCNKHsg0sSxgqcwxrxyrddQRUQ0EM38DZ
+F/vHt+vTdHt07kezbjJe0Kklel59uSpghA0iL4vxzbTns1fuwYOgVrNGs3acTkiB
+GhFOxRXUPGtpdYmv+AaR9WlWJQY1GIEoVrinPVH7bcCwyh1CcUbHL+oAFTcmc6kZ
+7azNg21tWILIRL7R0IZYQm0tF5TTwCsjVC7FuHaBtkxtVrrZqeKjvVXQ8TK5VoI0
+BUQ6BKHGeTtm+0HBpheYBDy3wkOsEGbGHLEM6cMeiz6PyCXF8wXli8Qb/TjN3LHZ
+e30CAwEAAaOCAd8wggHbMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGG
+MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMDMB0GA1UdDgQWBBT9huhQhcCi8KESm50M
+G6h0cpuNSzCB7AYDVR0jBIHkMIHhgBSDx8s0qJaMyQCehKcuzgzVNRA75qGBxaSB
+wjCBvzELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFp
+biBWaWV3MSYwJAYDVQQKEx1Db250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEm
+MCQGA1UEAxMdZGV2LmNvbnRlbnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG
+9w0BCQEWLGNsb3Vkc2VjK2RldnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEu
+Y29tggEBMEIGCWCGSAGG+EIBBAQ1FjNodHRwczovL2NvbnRlbnQtc2lnbmF0dXJl
+LmRldi5tb3phd3MubmV0L2NhL2NybC5wZW0wTgYIKwYBBQUHAQEEQjBAMD4GCCsG
+AQUFBzAChjJodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmRldi5tb3phd3MubmV0
+L2NhL2NhLnBlbTANBgkqhkiG9w0BAQwFAAOCAgEAbum0z0ccqI1Wp49VtsGmUPHA
+gjPPy2Xa5NePmqY87WrGdhjN3xbLVb3hx8T2N6pqDjMY2rEynXKEOek3oJkQ3C6J
+8AFP6Y93gaAlNz6EA0J0mqdW5TMI8YEYsu2ma+dQQ8wm9f/5b+/Y8qwfhztP06W5
+H6IG04/RvgUwYMnSR4QvT309fu5UmCRUDzsO53ZmQCfmN94u3NxHc4S6n0Q6AKAM
+kh5Ld9SQnlqqqDykzn7hYDi8nTLWc7IYqkGfNMilDEKbAl4CjnSfyEvpdFAJ9sPR
+UL+kaWFSMvaqIPNpxS5OpoPZjmxEc9HHnCHxtfDHWdXTJILjijPrCdMaxOCHfIqV
+5roOJggI4RZ0YM68IL1MfN4IEVOrHhKjDHtd1gcmy2KU4jfj9LQq9YTnyvZ2z1yS
+lS310HG3or1K8Nnu5Utfe7T6ppX8bLRMkS1/w0p7DKxHaf4D/GJcCtM9lcSt9JpW
+6ACKFikjWR4ZxczYKgApc0wcjd7XBuO5777xtOeyEUDHdDft3jiXA91dYM5UAzc3
+69z/3zmaELzo0gWcrjLXh7fU9AvbU4EUF6rwzxbPGF78jJcGK+oBf8uWUCkBykDt
+VsAEZI1u4EDg8e/C1nFqaH9gNMArAgquYIB9rve+hdprIMnva0S147pflWopBWcb
+jwzgpfquuYnnxe0CNBA=
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIH3DCCBcSgAwIBAgIBATANBgkqhkiG9w0BAQwFADCBvzELMAkGA1UEBhMCVVMx
+CzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MSYwJAYDVQQKEx1D
+b250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEmMCQGA1UEAxMdZGV2LmNvbnRl
+bnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG9w0BCQEWLGNsb3Vkc2VjK2Rl
+dnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEuY29tMB4XDTE2MDcwNjE4MTUy
+MloXDTI2MDcwNDE4MTUyMlowgb8xCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJDQTEW
+MBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UEChMdQ29udGVudCBTaWduYXR1
+cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5jb250ZW50LXNpZ25hdHVyZS5y
+b290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNlYytkZXZyb290Y29udGVudHNp
+Z25hdHVyZUBtb3ppbGxhLmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
+ggIBAJcPcXhD8MzF6OTn5qZ0L7lX1+PEgLKhI9g1HxxDYDVup4Zm0kZhPGmFSlml
+6eVO99OvvHdAlHhQGCIG7h+w1cp66mWjfpcvtQH23uRoKZfiW3jy1jUWrvdXolxR
+t1taZosjzo+9OP8TvG6LpJj7AvqUiYD4wYnQJtt0jNRN4d6MUfQwiavSS5uTBuxd
+ZJ4TsPvEI+Iv4A4PSobSzxkg79LTMvsGtDLQv7nN5hMs9T18EL5GnIKoqnSQCU0d
+n2CN7S3QiQ+cbORWsSYqCTj1gUbFh6X3duEB/ypLcyWFbqeJmPHikjY8q8pLjZSB
+IYiTJYLyvYlKdM5QleC/xuBNnMPCftrwwLHjWE4Dd7C9t7k0R5xyOetuiHLCwOcQ
+tuckp7RgFKoviMNv3gdkzwVapOklcsaRkRscv6OMTKJNsdJVIDLrPF1wMABhbEQB
+64BL0uL4lkFtpXXbJzQ6mgUNQveJkfUWOoB+cA/6GtI4J0aQfvQgloCYI6jxNn/e
+Nvk5OV9KFOhXS2dnDft3wHU46sg5yXOuds1u6UrOoATBNFlkS95m4zIX1Svu091+
+CKTiLK85+ZiFtAlU2bPr3Bk3GhL3Z586ae6a4QUEx6SPQVXc18ezB4qxKqFc+avI
+ylccYMRhVP+ruADxtUM5Vy6x3U8BwBK5RLdecRx2FEBDImY1AgMBAAGjggHfMIIB
+2zAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAWBgNVHSUBAf8EDDAK
+BggrBgEFBQcDAzAdBgNVHQ4EFgQUg8fLNKiWjMkAnoSnLs4M1TUQO+YwgewGA1Ud
+IwSB5DCB4YAUg8fLNKiWjMkAnoSnLs4M1TUQO+ahgcWkgcIwgb8xCzAJBgNVBAYT
+AlVTMQswCQYDVQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UE
+ChMdQ29udGVudCBTaWduYXR1cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5j
+b250ZW50LXNpZ25hdHVyZS5yb290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNl
+YytkZXZyb290Y29udGVudHNpZ25hdHVyZUBtb3ppbGxhLmNvbYIBATBCBglghkgB
+hvhCAQQENRYzaHR0cHM6Ly9jb250ZW50LXNpZ25hdHVyZS5kZXYubW96YXdzLm5l
+dC9jYS9jcmwucGVtME4GCCsGAQUFBwEBBEIwQDA+BggrBgEFBQcwAoYyaHR0cHM6
+Ly9jb250ZW50LXNpZ25hdHVyZS5kZXYubW96YXdzLm5ldC9jYS9jYS5wZW0wDQYJ
+KoZIhvcNAQEMBQADggIBAAAQ+fotZE79FfZ8Lz7eiTUzlwHXCdSE2XD3nMROu6n6
+uLTBPrf2C+k+U1FjKVvL5/WCUj6hIzP2X6Sb8+o0XHX9mKN0yoMORTEYJOnazYPK
+KSUUFnE4vGgQkr6k/31gGRMTICdnf3VOOAlUCQ5bOmGIuWi401E3sbd85U+TJe0A
+nHlU+XjtfzlqcBvQivdbA0s+GEG55uRPvn952aTBEMHfn+2JqKeLShl4AtUAfu+h
+6md3Z2HrEC7B3GK8ekWPu0G/ZuWTuFvOimZ+5C8IPRJXcIR/siPQl1x6dpTCew6t
+lPVcVuvg6SQwzvxetkNrGUe2Wb2s9+PK2PUvfOS8ee25SNmfG3XK9qJpqGUhzSBX
+T8QQgyxd0Su5G7Wze7aaHZd/fqIm/G8YFR0HiC2xni/lnDTXFDPCe+HCnSk0bH6U
+wpr6I1yK8oZ2IdnNVfuABGMmGOhvSQ8r7//ea9WKhCsGNQawpVWVioY7hpyNAJ0O
+Vn4xqG5f6allz8lgpwAQ+AeEEClHca6hh6mj9KhD1Of1CC2Vx52GHNh/jMYEc3/g
+zLKniencBqn3Y2XH2daITGJddcleN09+a1NaTkT3hgr7LumxM8EVssPkC+z9j4Vf
+Gbste+8S5QCMhh00g5vR9QF8EaFqdxCdSxrsA4GmpCa5UQl8jtCnpp2DLKXuOh72
+-----END CERTIFICATE-----
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/unit/test_ActionSandboxManager.js
+++ /dev/null
@@ -1,169 +0,0 @@
-"use strict";
-
-Cu.import("resource://shield-recipe-client/lib/ActionSandboxManager.jsm");
-Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm");
-
-async function withManager(script, testFunction) {
-  const manager = new ActionSandboxManager(script);
-  manager.addHold("testing");
-  await testFunction(manager);
-  manager.removeHold("testing");
-}
-
-add_task(async function testMissingCallbackName() {
-  await withManager("1 + 1", async manager => {
-    equal(
-      await manager.runAsyncCallback("missingCallback"),
-      undefined,
-      "runAsyncCallback returns undefined when given a missing callback name",
-    );
-  });
-});
-
-add_task(async function testCallback() {
-  const script = `
-    registerAsyncCallback("testCallback", async function(normandy) {
-      return 5;
-    });
-  `;
-
-  await withManager(script, async manager => {
-    const result = await manager.runAsyncCallback("testCallback");
-    equal(result, 5, "runAsyncCallback executes the named callback inside the sandbox");
-  });
-});
-
-add_task(async function testArguments() {
-  const script = `
-    registerAsyncCallback("testCallback", async function(normandy, a, b) {
-      return a + b;
-    });
-  `;
-
-  await withManager(script, async manager => {
-    const result = await manager.runAsyncCallback("testCallback", 4, 6);
-    equal(result, 10, "runAsyncCallback passes arguments to the callback");
-  });
-});
-
-add_task(async function testCloning() {
-  const script = `
-    registerAsyncCallback("testCallback", async function(normandy, obj) {
-      return {foo: "bar", baz: obj.baz};
-    });
-  `;
-
-  await withManager(script, async manager => {
-    const result = await manager.runAsyncCallback("testCallback", {baz: "biff"});
-
-    deepEqual(
-      result,
-      {foo: "bar", baz: "biff"},
-      (
-        "runAsyncCallback clones arguments into the sandbox and return values into the " +
-        "context it was called from"
-      ),
-    );
-  });
-});
-
-add_task(async function testError() {
-  const script = `
-    registerAsyncCallback("testCallback", async function(normandy) {
-      throw new Error("WHY")
-    });
-  `;
-
-  await withManager(script, async manager => {
-    try {
-      await manager.runAsyncCallback("testCallback");
-      ok(false, "runAsnycCallbackFromScript throws errors when raised by the sandbox");
-    } catch (err) {
-      equal(err.message, "WHY", "runAsnycCallbackFromScript clones error messages");
-    }
-  });
-});
-
-add_task(async function testDriver() {
-  const script = `
-    registerAsyncCallback("testCallback", async function(normandy) {
-      return normandy;
-    });
-  `;
-
-  await withManager(script, async manager => {
-    const sandboxDriver = await manager.runAsyncCallback("testCallback");
-    const referenceDriver = new NormandyDriver(manager);
-    equal(
-      sandboxDriver.constructor.name,
-      "NormandyDriver",
-      "runAsyncCallback passes a driver as the first parameter",
-    );
-    for (const prop in referenceDriver) {
-      ok(prop in sandboxDriver, "runAsyncCallback passes a driver as the first parameter");
-    }
-  });
-});
-
-add_task(async function testGlobalObject() {
-  // Test that window is an alias for the global object, and that it
-  // has some expected functions available on it.
-  const script = `
-    window.setOnWindow = "set";
-    this.setOnGlobal = "set";
-
-    registerAsyncCallback("testCallback", async function(normandy) {
-      return {
-        setOnWindow: setOnWindow,
-        setOnGlobal: window.setOnGlobal,
-        setTimeoutExists: setTimeout !== undefined,
-        clearTimeoutExists: clearTimeout !== undefined,
-      };
-    });
-  `;
-
-  await withManager(script, async manager => {
-    const result = await manager.runAsyncCallback("testCallback");
-    Assert.deepEqual(result, {
-      setOnWindow: "set",
-      setOnGlobal: "set",
-      setTimeoutExists: true,
-      clearTimeoutExists: true,
-    }, "sandbox.window is the global object and has expected functions.");
-  });
-});
-
-add_task(async function testRegisterActionShim() {
-  const recipe = {
-    foo: "bar",
-  };
-  const script = `
-    class TestAction {
-      constructor(driver, recipe) {
-        this.driver = driver;
-        this.recipe = recipe;
-      }
-
-      execute() {
-        return new Promise(resolve => {
-          resolve({
-            foo: this.recipe.foo,
-            isDriver: "log" in this.driver,
-          });
-        });
-      }
-    }
-
-    registerAction('test-action', TestAction);
-  `;
-
-  await withManager(script, async manager => {
-    const result = await manager.runAsyncCallback("action", recipe);
-    equal(result.foo, "bar", "registerAction registers an async callback for actions");
-    equal(
-      result.isDriver,
-      true,
-      "registerAction passes the driver to the action class constructor",
-    );
-  });
-});
--- a/browser/extensions/shield-recipe-client/test/unit/test_NormandyApi.js
+++ b/browser/extensions/shield-recipe-client/test/unit/test_NormandyApi.js
@@ -1,26 +1,43 @@
+/* globals sinon */
 "use strict";
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://testing-common/httpd.js");
+Cu.import("resource://gre/modules/CanonicalJSON.jsm", this);
 Cu.import("resource://gre/modules/osfile.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/NormandyApi.jsm", this);
 
 load("utils.js"); /* globals withMockPreferences */
 
+class MockResponse {
+  constructor(content) {
+    this.content = content;
+  }
+
+  async text() {
+    return this.content;
+  }
+
+  async json() {
+    return JSON.parse(this.content);
+  }
+}
+
 function withServer(server, task) {
   return withMockPreferences(async function inner(preferences) {
     const serverUrl = `http://localhost:${server.identity.primaryPort}`;
     preferences.set("extensions.shield-recipe-client.api_url", `${serverUrl}/api/v1`);
     preferences.set(
       "security.content.signature.root_hash",
       // Hash of the key that signs the normandy dev certificates
       "4C:35:B1:C3:E3:12:D9:55:E7:78:ED:D0:A7:E7:8A:38:83:04:EF:01:BF:FA:03:29:B2:46:9F:3C:C5:EC:36:04"
     );
+    NormandyApi.clearIndexCache();
 
     try {
       await task(serverUrl, preferences);
     } finally {
       await new Promise(resolve => server.stop(resolve));
     }
   });
 }
@@ -32,19 +49,19 @@ function makeScriptServer(scriptPath) {
   server.start(-1);
   return server;
 }
 
 function withScriptServer(scriptPath, task) {
   return withServer(makeScriptServer(scriptPath), task);
 }
 
-function makeMockApiServer() {
+function makeMockApiServer(directory) {
   const server = new HttpServer();
-  server.registerDirectory("/", do_get_file("mock_api"));
+  server.registerDirectory("/", directory);
 
   server.setIndexHandler(async function(request, response) {
     response.processAsync();
     const dir = request.getProperty("directory");
     const index = dir.clone();
     index.append("index.json");
 
     if (!index.exists()) {
@@ -65,17 +82,17 @@ function makeMockApiServer() {
     }
   });
 
   server.start(-1);
   return server;
 }
 
 function withMockApiServer(task) {
-  return withServer(makeMockApiServer(), task);
+  return withServer(makeMockApiServer(do_get_file("mock_api")), task);
 }
 
 add_task(withMockApiServer(async function test_get(serverUrl) {
   // Test that NormandyApi can fetch from the test server.
   const response = await NormandyApi.get(`${serverUrl}/api/v1/`);
   const data = await response.json();
   equal(data["recipe-list"], "/api/v1/recipe/", "Expected data in response");
 }));
@@ -83,21 +100,17 @@ add_task(withMockApiServer(async functio
 add_task(withMockApiServer(async function test_getApiUrl(serverUrl) {
   const apiBase = `${serverUrl}/api/v1`;
   // Test that NormandyApi can use the self-describing API's index
   const recipeListUrl = await NormandyApi.getApiUrl("action-list");
   equal(recipeListUrl, `${apiBase}/action/`, "Can retrieve action-list URL from API");
 }));
 
 add_task(withMockApiServer(async function test_getApiUrlSlashes(serverUrl, preferences) {
-  const fakeResponse = {
-    async json() {
-      return {"test-endpoint": `${serverUrl}/test/`};
-    },
-  };
+  const fakeResponse = new MockResponse(JSON.stringify({"test-endpoint": `${serverUrl}/test/`}));
   const mockGet = sinon.stub(NormandyApi, "get", async () => fakeResponse);
 
   // without slash
   {
     NormandyApi.clearIndexCache();
     preferences.set("extensions.shield-recipe-client.api_url", `${serverUrl}/api/v1`);
     const endpoint = await NormandyApi.getApiUrl("test-endpoint");
     equal(endpoint, `${serverUrl}/test/`);
@@ -120,30 +133,103 @@ add_task(withMockApiServer(async functio
 }));
 
 add_task(withMockApiServer(async function test_fetchRecipes() {
   const recipes = await NormandyApi.fetchRecipes();
   equal(recipes.length, 1);
   equal(recipes[0].name, "system-addon-test");
 }));
 
+add_task(async function test_fetchSignedObjects_canonical_mismatch() {
+  const getApiUrl = sinon.stub(NormandyApi, "getApiUrl");
+
+  // The object is non-canonical (it has whitespace, properties are out of order)
+  const response = new MockResponse(`[
+    {
+      "object": {"b": 1, "a": 2},
+      "signature": {"signature": "", "x5u": ""}
+    }
+  ]`);
+  const get = sinon.stub(NormandyApi, "get").resolves(response);
+
+  try {
+    await NormandyApi.fetchSignedObjects("object");
+    ok(false, "fetchSignedObjects did not throw for canonical JSON mismatch");
+  } catch (err) {
+    ok(err instanceof NormandyApi.InvalidSignatureError, "Error is an InvalidSignatureError");
+    ok(/Canonical/.test(err), "Error is due to canonical JSON mismatch");
+  }
+
+  getApiUrl.restore();
+  get.restore();
+});
+
+// Test validation errors due to validation throwing an exception (e.g. when
+// parameters passed to validation are malformed).
+add_task(async function test_fetchSignedObjects_validation_error() {
+  const getApiUrl = sinon.stub(NormandyApi, "getApiUrl").resolves("http://localhost/object/");
+
+  // Mock two URLs: object and the x5u
+  const get = sinon.stub(NormandyApi, "get").callsFake(async url => {
+    if (url.endsWith("object/")) {
+      return new MockResponse(CanonicalJSON.stringify([
+        {
+          object: {a: 1, b: 2},
+          signature: {signature: "invalidsignature", x5u: "http://localhost/x5u/"},
+        },
+      ]));
+    } else if (url.endsWith("x5u/")) {
+      return new MockResponse("certchain");
+    }
+
+    return null;
+  });
+
+  // Validation should fail due to a malformed x5u and signature.
+  try {
+    await NormandyApi.fetchSignedObjects("object");
+    ok(false, "fetchSignedObjects did not throw for a validation error");
+  } catch (err) {
+    ok(err instanceof NormandyApi.InvalidSignatureError, "Error is an InvalidSignatureError");
+    ok(/signature/.test(err), "Error is due to a validation error");
+  }
+
+  getApiUrl.restore();
+  get.restore();
+});
+
+// Test validation errors due to validation returning false (e.g. when parameters
+// passed to validation are correctly formed, but not valid for the data).
+const invalidSignatureServer = makeMockApiServer(do_get_file("invalid_recipe_signature_api"));
+add_task(withServer(invalidSignatureServer, async function test_fetchSignedObjects_invalid_signature() {
+  try {
+    await NormandyApi.fetchSignedObjects("recipe");
+    ok(false, "fetchSignedObjects did not throw for an invalid signature");
+  } catch (err) {
+    ok(err instanceof NormandyApi.InvalidSignatureError, "Error is an InvalidSignatureError");
+    ok(/signature/.test(err), "Error is due to an invalid signature");
+  }
+}));
+
 add_task(withMockApiServer(async function test_classifyClient() {
   const classification = await NormandyApi.classifyClient();
   Assert.deepEqual(classification, {
     country: "US",
     request_time: new Date("2017-02-22T17:43:24.657841Z"),
   });
 }));
 
 add_task(withMockApiServer(async function test_fetchActions() {
   const actions = await NormandyApi.fetchActions();
-  equal(actions.length, 2);
+  equal(actions.length, 4);
   const actionNames = actions.map(a => a.name);
   ok(actionNames.includes("console-log"));
+  ok(actionNames.includes("opt-out-study"));
   ok(actionNames.includes("show-heartbeat"));
+  ok(actionNames.includes("preference-experiment"));
 }));
 
 add_task(withScriptServer("query_server.sjs", async function test_getTestServer(serverUrl) {
   // Test that NormandyApi can fetch from the test server.
   const response = await NormandyApi.get(serverUrl);
   const data = await response.json();
   Assert.deepEqual(data, {queryString: {}, body: {}}, "NormandyApi returned incorrect server data.");
 }));
@@ -163,25 +249,26 @@ add_task(withScriptServer("query_server.
   const response = await NormandyApi.post(serverUrl, {foo: "bar", baz: "biff"});
   const data = await response.json();
   Assert.deepEqual(
     data, {queryString: {}, body: {foo: "bar", baz: "biff"}},
     "NormandyApi sent an incorrect query string."
   );
 }));
 
-add_task(withScriptServer("echo_server.sjs", async function test_fetchImplementation(serverUrl) {
-  const action = {
-    implementation_url: `${serverUrl}?status=200&body=testcontent`,
-  };
-  equal(
-    await NormandyApi.fetchImplementation(action),
-    "testcontent",
-    "fetchImplementation fetches the content at the correct URL",
-  );
+add_task(withMockApiServer(async function test_fetchImplementation_itWorksWithRealData() {
+  const [action] = await NormandyApi.fetchActions();
+  const implementation = await NormandyApi.fetchImplementation(action);
+
+  const decoder = new TextDecoder();
+  const relativePath = `mock_api${action.implementation_url}`;
+  const file = do_get_file(relativePath);
+  const expected = decoder.decode(await OS.File.read(file.path));
+
+  equal(implementation, expected);
 }));
 
 add_task(withScriptServer(
   "echo_server.sjs",
   async function test_fetchImplementationFail(serverUrl) {
     const action = {
       implementation_url: `${serverUrl}?status=500&body=servererror`,
     };
--- a/browser/extensions/shield-recipe-client/test/unit/test_SandboxManager.js
+++ b/browser/extensions/shield-recipe-client/test/unit/test_SandboxManager.js
@@ -1,15 +1,15 @@
 "use strict";
 
 Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm");
 
 // wrapAsync should wrap privileged Promises with Promises that are usable by
 // the sandbox.
-add_task(async function() {
+add_task(function* () {
   const manager = new SandboxManager();
   manager.addHold("testing");
 
   manager.cloneIntoGlobal("driver", {
     async privileged() {
       return "privileged";
     },
     wrapped: manager.wrapAsync(async function() {
@@ -20,17 +20,17 @@ add_task(async function() {
       return this.aValue;
     }),
   }, {cloneFunctions: true});
 
   // Assertion helpers
   manager.addGlobal("ok", ok);
   manager.addGlobal("equal", equal);
 
-  const sandboxResult = await new Promise(resolve => {
+  const sandboxResult = yield new Promise(resolve => {
     manager.addGlobal("resolve", result => resolve(result));
     manager.evalInSandbox(`
       // Unwrapped privileged promises are not accessible in the sandbox
       try {
         const privilegedResult = driver.privileged().then(() => false);
         ok(false, "The sandbox could not use a privileged Promise");
       } catch (err) { }
 
@@ -40,31 +40,31 @@ add_task(async function() {
 
       // Resolve the Promise around the sandbox with the wrapped result to test
       // that the Promise in the sandbox works.
       wrappedResult.then(resolve);
     `);
   });
   equal(sandboxResult, "wrapped", "wrapAsync methods return Promises that work in the sandbox");
 
-  await manager.evalInSandbox(`
+  yield manager.evalInSandbox(`
     (async function sandboxTest() {
       equal(
         await driver.wrappedThis(),
         "aValue",
         "wrapAsync preserves the behavior of the this keyword",
       );
     })();
   `);
 
   manager.removeHold("testing");
 });
 
 // wrapAsync cloning options
-add_task(async function() {
+add_task(function* () {
   const manager = new SandboxManager();
   manager.addHold("testing");
 
   // clonedArgument stores the argument passed to cloned(), which we use to test
   // that arguments from within the sandbox are cloned outside.
   let clonedArgument = null;
   manager.cloneIntoGlobal("driver", {
     uncloned: manager.wrapAsync(async function() {
@@ -75,17 +75,17 @@ add_task(async function() {
       return {value: "cloned"};
     }, {cloneInto: true, cloneArguments: true}),
   }, {cloneFunctions: true});
 
   // Assertion helpers
   manager.addGlobal("ok", ok);
   manager.addGlobal("deepEqual", deepEqual);
 
-  await new Promise(resolve => {
+  yield new Promise(resolve => {
     manager.addGlobal("resolve", resolve);
     manager.evalInSandbox(`
       (async function() {
         // The uncloned return value should be privileged and inaccesible.
         const uncloned = await driver.uncloned();
         ok(!("value" in uncloned), "The sandbox could not use an uncloned return value");
 
         // The cloned return value should be usable.
--- a/browser/extensions/shield-recipe-client/test/unit/xpcshell.ini
+++ b/browser/extensions/shield-recipe-client/test/unit/xpcshell.ini
@@ -1,11 +1,13 @@
 [DEFAULT]
 head = head_xpc.js
 support-files =
   mock_api/**
+  invalid_recipe_signature_api/**
   query_server.sjs
   echo_server.sjs
   utils.js
 
 [test_NormandyApi.js]
 [test_Sampling.js]
 [test_SandboxManager.js]
+[test_Utils.js]
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/vendor/LICENSE_THIRDPARTY
@@ -0,0 +1,262 @@
+fbjs@0.8.14 BSD-3-Clause
+BSD License
+
+For fbjs software
+
+Copyright (c) 2013-present, Facebook, Inc.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+ * Neither the name Facebook nor the names of its contributors may be used to
+   endorse or promote products derived from this software without specific
+   prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
+react-dom@15.6.1 BSD-3-Clause
+BSD License
+
+For React software
+
+Copyright (c) 2013-present, Facebook, Inc.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+ * Neither the name Facebook nor the names of its contributors may be used to
+   endorse or promote products derived from this software without specific
+   prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
+object-assign@4.1.1 MIT
+The MIT License (MIT)
+
+Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+react@15.6.1 BSD-3-Clause
+BSD License
+
+For React software
+
+Copyright (c) 2013-present, Facebook, Inc.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+ * Neither the name Facebook nor the names of its contributors may be used to
+   endorse or promote products derived from this software without specific
+   prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
+prop-types@15.5.10 BSD-3-Clause
+BSD License
+
+For React software
+
+Copyright (c) 2013-present, Facebook, Inc.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+ * Neither the name Facebook nor the names of its contributors may be used to
+   endorse or promote products derived from this software without specific
+   prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+create-react-class@15.6.0 BSD-3-Clause
+BSD License
+
+For React software
+
+Copyright (c) 2013-present, Facebook, Inc.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+ * Neither the name Facebook nor the names of its contributors may be used to
+   endorse or promote products derived from this software without specific
+   prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
+mozjexl@1.1.5 MIT
+Copyright for portions of mozJexl are held by TechnologyAdvice, 2015 as part of Jexl.
+All other copyright for mozJexl are held by the Mozilla Foundation, 2017.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+process@0.11.10 MIT
+(The MIT License)
+
+Copyright (c) 2013 Roman Shtylman <shtylman@gmail.com>
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+'Software'), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+
+classnames@2.2.5 MIT
+The MIT License (MIT)
+
+Copyright (c) 2016 Jed Watson
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/vendor/PropTypes.js
@@ -0,0 +1,1 @@
+/* eslint-disable */this.PropTypes=function(a){function b(d){if(c[d])return c[d].exports;var e=c[d]={i:d,l:!1,exports:{}};return a[d].call(e.exports,e,e.exports,b),e.l=!0,e.exports}var c={};return b.m=a,b.c=c,b.d=function(a,c,d){b.o(a,c)||Object.defineProperty(a,c,{configurable:!1,enumerable:!0,get:d})},b.n=function(a){var c=a&&a.__esModule?function(){return a['default']}:function(){return a};return b.d(c,'a',c),c},b.o=function(a,b){return Object.prototype.hasOwnProperty.call(a,b)},b.p='',b(b.s=100)}({0:function(a){'use strict';var g=function(){};!1,a.exports=function(h,i,j,a,b,c,d,e){if(g(i),!h){var f;if(void 0===i)f=new Error('Minified exception occurred; use the non-minified dev environment for the full error message and additional helpful warnings.');else{var k=[j,a,b,c,d,e],l=0;f=new Error(i.replace(/%s/g,function(){return k[l++]})),f.name='Invariant Violation'}throw f.framesToPop=1,f}}},100:function(a,b,c){a.exports=c(101)()},101:function(a,b,c){'use strict';var d=c(5),e=c(0),f=c(19);a.exports=function(){function a(a,b,c,d,g,h){h===f||e(!1,'Calling PropTypes validators directly is not supported by the `prop-types` package. Use PropTypes.checkPropTypes() to call them. Read more at http://fb.me/use-check-prop-types')}function b(){return a}a.isRequired=a;var c={array:a,bool:a,func:a,number:a,object:a,string:a,symbol:a,any:a,arrayOf:b,element:a,instanceOf:b,node:a,objectOf:b,oneOf:b,oneOfType:b,shape:b};return c.checkPropTypes=d,c.PropTypes=c,c}},19:function(a){'use strict';a.exports='SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED'},5:function(a){'use strict';function b(a){return function(){return a}}var c=function(){};c.thatReturns=b,c.thatReturnsFalse=b(!1),c.thatReturnsTrue=b(!0),c.thatReturnsNull=b(null),c.thatReturnsThis=function(){return this},c.thatReturnsArgument=function(a){return a},a.exports=c}});this.EXPORTED_SYMBOLS = ["PropTypes"];
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/vendor/React.js
@@ -0,0 +1,5 @@
+/* eslint-disable */this.React=function(e){function t(o){if(n[o])return n[o].exports;var r=n[o]={i:o,l:!1,exports:{}};return e[o].call(r.exports,r,r.exports,t),r.l=!0,r.exports}var n={};return t.m=e,t.c=n,t.d=function(e,n,o){t.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:o})},t.n=function(e){var n=e&&e.__esModule?function(){return e['default']}:function(){return e};return t.d(n,'a',n),n},t.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},t.p='',t(t.s=102)}([function(e){'use strict';var t=function(){};!1,e.exports=function(n,o,r,a,i,p,s,e){if(t(o),!n){var d;if(void 0===o)d=new Error('Minified exception occurred; use the non-minified dev environment for the full error message and additional helpful warnings.');else{var l=[r,a,i,p,s,e],u=0;d=new Error(o.replace(/%s/g,function(){return l[u++]})),d.name='Invariant Violation'}throw d.framesToPop=1,d}}},function(e,t,n){'use strict';var o=n(5);e.exports=o},,function(e){'use strict';/*
+object-assign
+(c) Sindre Sorhus
+@license MIT
+*/function t(e){if(null===e||e===void 0)throw new TypeError('Object.assign cannot be called with null or undefined');return Object(e)}var n=Object.getOwnPropertySymbols,o=Object.prototype.hasOwnProperty,r=Object.prototype.propertyIsEnumerable;e.exports=function(){try{if(!Object.assign)return!1;var e=new String('abc');if(e[5]='de','5'===Object.getOwnPropertyNames(e)[0])return!1;for(var t={},n=0;10>n;n++)t['_'+String.fromCharCode(n)]=n;var o=Object.getOwnPropertyNames(t).map(function(e){return t[e]});if('0123456789'!==o.join(''))return!1;var r={};return['a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t'].forEach(function(e){r[e]=e}),'abcdefghijklmnopqrst'===Object.keys(Object.assign({},r)).join('')}catch(e){return!1}}()?Object.assign:function(e){for(var a,p,d=t(e),l=1;l<arguments.length;l++){for(var s in a=Object(arguments[l]),a)o.call(a,s)&&(d[s]=a[s]);if(n){p=n(a);for(var u=0;u<p.length;u++)r.call(a,p[u])&&(d[p[u]]=a[p[u]])}}return d}},,function(e){'use strict';function t(e){return function(){return e}}var n=function(){};n.thatReturns=t,n.thatReturnsFalse=t(!1),n.thatReturnsTrue=t(!0),n.thatReturnsNull=t(null),n.thatReturnsThis=function(){return this},n.thatReturnsArgument=function(e){return e},e.exports=n},,function(e,t,n){'use strict';function o(e){return e.ref!==void 0}function r(e){return e.key!==void 0}var a,i,p=n(3),s=n(8),d=n(1),l=n(22),u=Object.prototype.hasOwnProperty,c=n(23),m={key:!0,ref:!0,__self:!0,__source:!0},f=function(e,t,n,o,r,a,i){return!1,{$$typeof:c,type:e,key:t,ref:n,props:i,_owner:a}};f.createElement=function(e,t,n){var a,p={},d=null,l=null,c=null,y=null;if(null!=t)for(a in o(t)&&(l=t.ref),r(t)&&(d=''+t.key),c=void 0===t.__self?null:t.__self,y=void 0===t.__source?null:t.__source,t)u.call(t,a)&&!m.hasOwnProperty(a)&&(p[a]=t[a]);var h=arguments.length-2;if(1==h)p.children=n;else if(1<h){for(var g=Array(h),b=0;b<h;b++)g[b]=arguments[b+2];!1,p.children=g}if(e&&e.defaultProps){var i=e.defaultProps;for(a in i)void 0===p[a]&&(p[a]=i[a])}return f(e,d,l,c,y,s.current,p)},f.createFactory=function(e){var t=f.createElement.bind(null,e);return t.type=e,t},f.cloneAndReplaceKey=function(e,t){var n=f(e.type,t,e.ref,e._self,e._source,e._owner,e.props);return n},f.cloneElement=function(e,t,n){var a,d=p({},e.props),l=e.key,c=e.ref,y=e._self,h=e._source,g=e._owner;if(null!=t){o(t)&&(c=t.ref,g=s.current),r(t)&&(l=''+t.key);var b;for(a in e.type&&e.type.defaultProps&&(b=e.type.defaultProps),t)u.call(t,a)&&!m.hasOwnProperty(a)&&(d[a]=void 0===t[a]&&void 0!==b?b[a]:t[a])}var E=arguments.length-2;if(1==E)d.children=n;else if(1<E){for(var x=Array(E),P=0;P<E;P++)x[P]=arguments[P+2];d.children=x}return f(e.type,l,c,y,h,g,d)},f.isValidElement=function(e){return'object'==typeof e&&null!==e&&e.$$typeof===c},e.exports=f},function(e){'use strict';e.exports={current:null}},,function(e){'use strict';e.exports=function(e){for(var t=arguments.length-1,n='Minified React error #'+e+'; visit http://facebook.github.io/react/docs/error-decoder.html?invariant='+e,o=0;o<t;o++)n+='&args[]='+encodeURIComponent(arguments[o+1]);n+=' for the full message or use the non-minified dev environment for full errors and additional helpful warnings.';var r=new Error(n);throw r.name='Invariant Violation',r.framesToPop=1,r}},,,function(e,t,n){'use strict';var o=n(3),r=n(20),a=n(35),i=n(40),p=n(7),s=n(41),d=n(44),l=n(45),u=n(47),c=p.createElement,m=p.createFactory,f=p.cloneElement;var y={Children:{map:a.map,forEach:a.forEach,count:a.count,toArray:a.toArray,only:u},Component:r.Component,PureComponent:r.PureComponent,createElement:c,cloneElement:f,isValidElement:p.isValidElement,PropTypes:s,createClass:l,createFactory:m,createMixin:function(e){return e},DOM:i,version:d,__spread:o};e.exports=y},function(e){'use strict';!1,e.exports={}},,,,,function(e){'use strict';e.exports='SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED'},function(e,t,n){'use strict';function o(e,t,n){this.props=e,this.context=t,this.refs=l,this.updater=n||s}function r(e,t,n){this.props=e,this.context=t,this.refs=l,this.updater=n||s}function a(){}var i=n(10),p=n(3),s=n(21),d=n(22),l=n(14),u=n(0),c=n(34);o.prototype.isReactComponent={},o.prototype.setState=function(e,t){'object'==typeof e||'function'==typeof e||null==e?void 0:i('85'),this.updater.enqueueSetState(this,e),t&&this.updater.enqueueCallback(this,t,'setState')},o.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this),e&&this.updater.enqueueCallback(this,e,'forceUpdate')};a.prototype=o.prototype,r.prototype=new a,r.prototype.constructor=r,p(r.prototype,o.prototype),r.prototype.isPureReactComponent=!0,e.exports={Component:o,PureComponent:r}},function(e,t,n){'use strict';function o(){}var r=n(1);e.exports={isMounted:function(){return!1},enqueueCallback:function(){},enqueueForceUpdate:function(e){o(e,'forceUpdate')},enqueueReplaceState:function(e){o(e,'replaceState')},enqueueSetState:function(e){o(e,'setState')}}},function(e){'use strict';e.exports=!1},function(e){'use strict';var t='function'==typeof Symbol&&Symbol['for']&&Symbol['for']('react.element')||60103;e.exports=t},,,,,function(e,t,n){'use strict';var o=n(42);e.exports=function(e){return o(e,!1)}},,,,,,function(e){'use strict';e.exports=function(){}},function(e,t,n){'use strict';function o(e){return(''+e).replace(h,'$&/')}function r(e,t){this.func=e,this.context=t,this.count=0}function a(e,t){var n=e.func,o=e.context;n.call(o,t,e.count++)}function i(e,t,n,o){this.result=e,this.keyPrefix=t,this.func=n,this.context=o,this.count=0}function p(e,t,n){var r=e.result,a=e.keyPrefix,i=e.func,p=e.context,d=i.call(p,t,e.count++);Array.isArray(d)?s(d,r,n,c.thatReturnsArgument):null!=d&&(u.isValidElement(d)&&(d=u.cloneAndReplaceKey(d,a+(d.key&&(!t||t.key!==d.key)?o(d.key)+'/':'')+n)),r.push(d))}function s(e,t,n,r,a){var s='';null!=n&&(s=o(n)+'/');var d=i.getPooled(t,s,r,a);m(e,p,d),i.release(d)}function d(){return null}var l=n(36),u=n(7),c=n(5),m=n(37),f=l.twoArgumentPooler,y=l.fourArgumentPooler,h=/\/+/g;r.prototype.destructor=function(){this.func=null,this.context=null,this.count=0},l.addPoolingTo(r,f),i.prototype.destructor=function(){this.result=null,this.keyPrefix=null,this.func=null,this.context=null,this.count=0},l.addPoolingTo(i,y);e.exports={forEach:function(e,t,n){if(null==e)return e;var o=r.getPooled(t,n);m(e,a,o),r.release(o)},map:function(e,t,n){if(null==e)return e;var o=[];return s(e,o,null,t,n),o},mapIntoWithKeyPrefixInternal:s,count:function(e){return m(e,d,null)},toArray:function(e){var t=[];return s(e,t,null,c.thatReturnsArgument),t}}},function(e,t,n){'use strict';var o=n(10),r=n(0),a=function(e){var t=this;if(t.instancePool.length){var n=t.instancePool.pop();return t.call(n,e),n}return new t(e)},i=function(e){var t=this;e instanceof t?void 0:o('25'),e.destructor(),t.instancePool.length<t.poolSize&&t.instancePool.push(e)};e.exports={addPoolingTo:function(e,t){var n=e;return n.instancePool=[],n.getPooled=t||a,n.poolSize||(n.poolSize=10),n.release=i,n},oneArgumentPooler:a,twoArgumentPooler:function(e,t){var n=this;if(n.instancePool.length){var o=n.instancePool.pop();return n.call(o,e,t),o}return new n(e,t)},threeArgumentPooler:function(e,t,n){var o=this;if(o.instancePool.length){var r=o.instancePool.pop();return o.call(r,e,t,n),r}return new o(e,t,n)},fourArgumentPooler:function(e,t,n,o){var r=this;if(r.instancePool.length){var a=r.instancePool.pop();return r.call(a,e,t,n,o),a}return new r(e,t,n,o)}}},function(e,t,n){'use strict';function o(e,t){return e&&'object'==typeof e&&null!=e.key?l.escape(e.key):t.toString(36)}function r(e,t,n,d){var u=typeof e;if(('undefined'==u||'boolean'==u)&&(e=null),null===e||'string'==u||'number'==u||'object'==u&&e.$$typeof===p)return n(d,e,''===t?c+o(e,0):t),1;var f,y,h=0,g=''===t?c:t+m;if(Array.isArray(e))for(var b=0;b<e.length;b++)f=e[b],y=g+o(f,b),h+=r(f,y,n,d);else{var i=s(e);if(i){var E,x=i.call(e);if(i!==e.entries)for(var P=0;!(E=x.next()).done;)f=E.value,y=g+o(f,P++),h+=r(f,y,n,d);else for(var _;!(E=x.next()).done;)_=E.value,_&&(f=_[1],y=g+l.escape(_[0])+m+o(f,0),h+=r(f,y,n,d))}else if('object'==u){var N='',I=e+'';a('31','[object Object]'===I?'object with keys {'+Object.keys(e).join(', ')+'}':I,N)}}return h}var a=n(10),i=n(8),p=n(23),s=n(38),d=n(0),l=n(39),u=n(1),c='.',m=':';e.exports=function(e,t,n){return null==e?0:r(e,'',t,n)}},function(e){'use strict';var t='function'==typeof Symbol&&Symbol.iterator;e.exports=function(e){var n=e&&(t&&e[t]||e['@@iterator']);if('function'==typeof n)return n}},function(e){'use strict';e.exports={escape:function(e){var t=/[=:]/g,n={"=":'=0',":":'=2'},o=(''+e).replace(t,function(e){return n[e]});return'$'+o},unescape:function(e){var t=/(=0|=2)/g,n={"=0":'=',"=2":':'},o='.'===e[0]&&'$'===e[1]?e.substring(2):e.substring(1);return(''+o).replace(t,function(e){return n[e]})}}},function(e,t,n){'use strict';var o=n(7),r=o.createFactory;var a={a:r('a'),abbr:r('abbr'),address:r('address'),area:r('area'),article:r('article'),aside:r('aside'),audio:r('audio'),b:r('b'),base:r('base'),bdi:r('bdi'),bdo:r('bdo'),big:r('big'),blockquote:r('blockquote'),body:r('body'),br:r('br'),button:r('button'),canvas:r('canvas'),caption:r('caption'),cite:r('cite'),code:r('code'),col:r('col'),colgroup:r('colgroup'),data:r('data'),datalist:r('datalist'),dd:r('dd'),del:r('del'),details:r('details'),dfn:r('dfn'),dialog:r('dialog'),div:r('div'),dl:r('dl'),dt:r('dt'),em:r('em'),embed:r('embed'),fieldset:r('fieldset'),figcaption:r('figcaption'),figure:r('figure'),footer:r('footer'),form:r('form'),h1:r('h1'),h2:r('h2'),h3:r('h3'),h4:r('h4'),h5:r('h5'),h6:r('h6'),head:r('head'),header:r('header'),hgroup:r('hgroup'),hr:r('hr'),html:r('html'),i:r('i'),iframe:r('iframe'),img:r('img'),input:r('input'),ins:r('ins'),kbd:r('kbd'),keygen:r('keygen'),label:r('label'),legend:r('legend'),li:r('li'),link:r('link'),main:r('main'),map:r('map'),mark:r('mark'),menu:r('menu'),menuitem:r('menuitem'),meta:r('meta'),meter:r('meter'),nav:r('nav'),noscript:r('noscript'),object:r('object'),ol:r('ol'),optgroup:r('optgroup'),option:r('option'),output:r('output'),p:r('p'),param:r('param'),picture:r('picture'),pre:r('pre'),progress:r('progress'),q:r('q'),rp:r('rp'),rt:r('rt'),ruby:r('ruby'),s:r('s'),samp:r('samp'),script:r('script'),section:r('section'),select:r('select'),small:r('small'),source:r('source'),span:r('span'),strong:r('strong'),style:r('style'),sub:r('sub'),summary:r('summary'),sup:r('sup'),table:r('table'),tbody:r('tbody'),td:r('td'),textarea:r('textarea'),tfoot:r('tfoot'),th:r('th'),thead:r('thead'),time:r('time'),title:r('title'),tr:r('tr'),track:r('track'),u:r('u'),ul:r('ul'),var:r('var'),video:r('video'),wbr:r('wbr'),circle:r('circle'),clipPath:r('clipPath'),defs:r('defs'),ellipse:r('ellipse'),g:r('g'),image:r('image'),line:r('line'),linearGradient:r('linearGradient'),mask:r('mask'),path:r('path'),pattern:r('pattern'),polygon:r('polygon'),polyline:r('polyline'),radialGradient:r('radialGradient'),rect:r('rect'),stop:r('stop'),svg:r('svg'),text:r('text'),tspan:r('tspan')};e.exports=a},function(e,t,n){'use strict';var o=n(7),r=o.isValidElement,a=n(28);e.exports=a(r)},function(e,t,n){'use strict';var o=n(5),r=n(0),a=n(1),p=n(19),i=n(43);e.exports=function(e,t){function n(e){var t=e&&(b&&e[b]||e[E]);if('function'==typeof t)return t}function s(e,t){return e===t?0!==e||1/e==1/t:e!==e&&t!==t}function d(e){this.message=e,this.stack=''}function l(e){function n(n,o,a,i,s,l,u){if(i=i||x,l=l||a,u!==p)if(t)r(!1,'Calling PropTypes validators directly is not supported by the `prop-types` package. Use `PropTypes.checkPropTypes()` to call them. Read more at http://fb.me/use-check-prop-types');else;return null==o[a]?n?null===o[a]?new d('The '+s+' `'+l+'` is marked as required '+('in `'+i+'`, but its value is `null`.')):new d('The '+s+' `'+l+'` is marked as required in '+('`'+i+'`, but its value is `undefined`.')):null:e(o,a,i,s,l)}var o=n.bind(null,!1);return o.isRequired=n.bind(null,!0),o}function u(e){return l(function(t,n,o,r,a){var i=t[n],p=f(i);if(p!==e){var s=y(i);return new d('Invalid '+r+' `'+a+'` of type '+('`'+s+'` supplied to `'+o+'`, expected ')+('`'+e+'`.'))}return null})}function c(t){switch(typeof t){case'number':case'string':case'undefined':return!0;case'boolean':return!t;case'object':if(Array.isArray(t))return t.every(c);if(null===t||e(t))return!0;var o=n(t);if(o){var r,a=o.call(t);if(o!==t.entries){for(;!(r=a.next()).done;)if(!c(r.value))return!1;}else for(;!(r=a.next()).done;){var i=r.value;if(i&&!c(i[1]))return!1}}else return!1;return!0;default:return!1;}}function m(e,t){return'symbol'===e||'Symbol'===t['@@toStringTag']||'function'==typeof Symbol&&t instanceof Symbol}function f(e){var t=typeof e;return Array.isArray(e)?'array':e instanceof RegExp?'object':m(t,e)?'symbol':t}function y(e){if('undefined'==typeof e||null===e)return''+e;var t=f(e);if('object'===t){if(e instanceof Date)return'date';if(e instanceof RegExp)return'regexp'}return t}function h(e){var t=y(e);return'array'===t||'object'===t?'an '+t:'boolean'===t||'date'===t||'regexp'===t?'a '+t:t}function g(e){return e.constructor&&e.constructor.name?e.constructor.name:x}var b='function'==typeof Symbol&&Symbol.iterator,E='@@iterator',x='<<anonymous>>',P={array:u('array'),bool:u('boolean'),func:u('function'),number:u('number'),object:u('object'),string:u('string'),symbol:u('symbol'),any:function(){return l(o.thatReturnsNull)}(),arrayOf:function(e){return l(function(t,n,o,r,a){if('function'!=typeof e)return new d('Property `'+a+'` of component `'+o+'` has invalid PropType notation inside arrayOf.');var s=t[n];if(!Array.isArray(s)){var l=f(s);return new d('Invalid '+r+' `'+a+'` of type '+('`'+l+'` supplied to `'+o+'`, expected an array.'))}for(var u,c=0;c<s.length;c++)if(u=e(s,c,o,r,a+'['+c+']',p),u instanceof Error)return u;return null})},element:function(){return l(function(t,n,o,r,a){var i=t[n];if(!e(i)){var p=f(i);return new d('Invalid '+r+' `'+a+'` of type '+('`'+p+'` supplied to `'+o+'`, expected a single ReactElement.'))}return null})}(),instanceOf:function(e){return l(function(t,n,o,r,a){if(!(t[n]instanceof e)){var i=e.name||x,p=g(t[n]);return new d('Invalid '+r+' `'+a+'` of type '+('`'+p+'` supplied to `'+o+'`, expected ')+('instance of `'+i+'`.'))}return null})},node:function(){return l(function(e,t,n,o,r){return c(e[t])?null:new d('Invalid '+o+' `'+r+'` supplied to '+('`'+n+'`, expected a ReactNode.'))})}(),objectOf:function(e){return l(function(t,n,o,r,a){if('function'!=typeof e)return new d('Property `'+a+'` of component `'+o+'` has invalid PropType notation inside objectOf.');var i=t[n],s=f(i);if('object'!==s)return new d('Invalid '+r+' `'+a+'` of type '+('`'+s+'` supplied to `'+o+'`, expected an object.'));for(var l in i)if(i.hasOwnProperty(l)){var u=e(i,l,o,r,a+'.'+l,p);if(u instanceof Error)return u}return null})},oneOf:function(e){return Array.isArray(e)?l(function(t,n,o,r,a){for(var p=t[n],l=0;l<e.length;l++)if(s(p,e[l]))return null;var i=JSON.stringify(e);return new d('Invalid '+r+' `'+a+'` of value `'+p+'` '+('supplied to `'+o+'`, expected one of '+i+'.'))}):(void 0,o.thatReturnsNull)},oneOfType:function(e){if(!Array.isArray(e))return void 0,o.thatReturnsNull;for(var t,n=0;n<e.length;n++)if(t=e[n],'function'!=typeof t)return a(!1,'Invalid argument supplid to oneOfType. Expected an array of check functions, but received %s at index %s.',h(t),n),o.thatReturnsNull;return l(function(t,n,o,r,a){for(var s,l=0;l<e.length;l++)if(s=e[l],null==s(t,n,o,r,a,p))return null;return new d('Invalid '+r+' `'+a+'` supplied to '+('`'+o+'`.'))})},shape:function(e){return l(function(t,n,o,r,a){var i=t[n],s=f(i);if('object'!==s)return new d('Invalid '+r+' `'+a+'` of type `'+s+'` '+('supplied to `'+o+'`, expected `object`.'));for(var l in e){var u=e[l];if(u){var c=u(i,l,o,r,a+'.'+l,p);if(c)return c}}return null})}};return d.prototype=Error.prototype,P.checkPropTypes=i,P.PropTypes=P,P}},function(e){'use strict';e.exports=function(){}},function(e){'use strict';e.exports='15.6.1'},function(e,t,n){'use strict';var o=n(20),r=o.Component,a=n(7),i=a.isValidElement,p=n(21),s=n(46);e.exports=s(r,i,p)},function(e,t,n){'use strict';function o(e){return e}var r=n(3),a=n(14),i=n(0);var p,s='mixins';p={},e.exports=function(e,t,n){function p(e,t){var n=g.hasOwnProperty(t)?g[t]:null;P.hasOwnProperty(t)&&i('OVERRIDE_BASE'===n,'ReactClassInterface: You are attempting to override `%s` from your class specification. Ensure that your method names do not overlap with React methods.',t),e&&i('DEFINE_MANY'===n||'DEFINE_MANY_MERGED'===n,'ReactClassInterface: You are attempting to define `%s` on your component more than once. This conflict may be due to a mixin.',t)}function d(e,n){if(!n){return}i('function'!=typeof n,'ReactClass: You\'re attempting to use a component class or function as a mixin. Instead, just use a regular object.'),i(!t(n),'ReactClass: You\'re attempting to use a component as a mixin. Instead, just use a regular object.');var o=e.prototype,r=o.__reactAutoBindPairs;for(var a in n.hasOwnProperty(s)&&b.mixins(e,n.mixins),n)if(n.hasOwnProperty(a)&&a!=s){var d=n[a],l=o.hasOwnProperty(a);if(p(l,a),b.hasOwnProperty(a))b[a](e,d);else{var u=g.hasOwnProperty(a),f='function'==typeof d&&!u&&!l&&!1!==n.autobind;if(f)r.push(a,d),o[a]=d;else if(l){var y=g[a];i(u&&('DEFINE_MANY_MERGED'===y||'DEFINE_MANY'===y),'ReactClass: Unexpected spec policy %s for key %s when mixing in component specs.',y,a),'DEFINE_MANY_MERGED'===y?o[a]=c(o[a],d):'DEFINE_MANY'===y&&(o[a]=m(o[a],d))}else o[a]=d,!1}}}function l(e,t){if(t)for(var n in t){var o=t[n];if(t.hasOwnProperty(n)){i(!(n in b),'ReactClass: You are attempting to define a reserved property, `%s`, that shouldn\'t be on the "statics" key. Define it as an instance property instead; it will still be accessible on the constructor.',n);i(!(n in e),'ReactClass: You are attempting to define `%s` on your component more than once. This conflict may be due to a mixin.',n),e[n]=o}}}function u(e,t){for(var n in i(e&&t&&'object'==typeof e&&'object'==typeof t,'mergeIntoWithNoDuplicateKeys(): Cannot merge non-objects.'),t)t.hasOwnProperty(n)&&(i(void 0===e[n],'mergeIntoWithNoDuplicateKeys(): Tried to merge two objects with the same key: `%s`. This conflict may be due to a mixin; in particular, this may be caused by two getInitialState() or getDefaultProps() methods returning objects with clashing keys.',n),e[n]=t[n]);return e}function c(e,t){return function(){var n=e.apply(this,arguments),o=t.apply(this,arguments);if(null==n)return o;if(null==o)return n;var r={};return u(r,n),u(r,o),r}}function m(e,t){return function(){e.apply(this,arguments),t.apply(this,arguments)}}function f(e,t){var n=t.bind(e);return n}function y(e){for(var t=e.__reactAutoBindPairs,n=0;n<t.length;n+=2){var o=t[n],r=t[n+1];e[o]=f(e,r)}}var h=[],g={mixins:'DEFINE_MANY',statics:'DEFINE_MANY',propTypes:'DEFINE_MANY',contextTypes:'DEFINE_MANY',childContextTypes:'DEFINE_MANY',getDefaultProps:'DEFINE_MANY_MERGED',getInitialState:'DEFINE_MANY_MERGED',getChildContext:'DEFINE_MANY_MERGED',render:'DEFINE_ONCE',componentWillMount:'DEFINE_MANY',componentDidMount:'DEFINE_MANY',componentWillReceiveProps:'DEFINE_MANY',shouldComponentUpdate:'DEFINE_ONCE',componentWillUpdate:'DEFINE_MANY',componentDidUpdate:'DEFINE_MANY',componentWillUnmount:'DEFINE_MANY',updateComponent:'OVERRIDE_BASE'},b={displayName:function(e,t){e.displayName=t},mixins:function(e,t){if(t)for(var n=0;n<t.length;n++)d(e,t[n])},childContextTypes:function(e,t){!1,e.childContextTypes=r({},e.childContextTypes,t)},contextTypes:function(e,t){!1,e.contextTypes=r({},e.contextTypes,t)},getDefaultProps:function(e,t){e.getDefaultProps=e.getDefaultProps?c(e.getDefaultProps,t):t},propTypes:function(e,t){!1,e.propTypes=r({},e.propTypes,t)},statics:function(e,t){l(e,t)},autobind:function(){}},E={componentDidMount:function(){this.__isMounted=!0}},x={componentWillUnmount:function(){this.__isMounted=!1}},P={replaceState:function(e,t){this.updater.enqueueReplaceState(this,e,t)},isMounted:function(){return!1,!!this.__isMounted}},_=function(){};return r(_.prototype,e.prototype,P),function(e){var t=o(function(e,o,r){!1,this.__reactAutoBindPairs.length&&y(this),this.props=e,this.context=o,this.refs=a,this.updater=r||n,this.state=null;var p=this.getInitialState?this.getInitialState():null;!1,i('object'==typeof p&&!Array.isArray(p),'%s.getInitialState(): must return an object or null',t.displayName||'ReactCompositeComponent'),this.state=p});for(var r in t.prototype=new _,t.prototype.constructor=t,t.prototype.__reactAutoBindPairs=[],h.forEach(d.bind(null,t)),d(t,E),d(t,e),d(t,x),t.getDefaultProps&&(t.defaultProps=t.getDefaultProps()),!1,i(t.prototype.render,'createClass(...): Class specification must implement a `render` method.'),!1,g)t.prototype[r]||(t.prototype[r]=null);return t}}},function(e,t,n){'use strict';var o=n(10),r=n(7),a=n(0);e.exports=function(e){return r.isValidElement(e)?void 0:o('143'),e}},,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,function(e,t,n){'use strict';e.exports=n(13)}]);this.EXPORTED_SYMBOLS = ["React"];
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/vendor/ReactDOM.js
@@ -0,0 +1,18 @@
+/* eslint-disable */this.ReactDOM=function(e){function t(o){if(n[o])return n[o].exports;var a=n[o]={i:o,l:!1,exports:{}};return e[o].call(a.exports,a,a.exports,t),a.l=!0,a.exports}var n={};return t.m=e,t.c=n,t.d=function(e,n,o){t.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:o})},t.n=function(e){var n=e&&e.__esModule?function(){return e['default']}:function(){return e};return t.d(n,'a',n),n},t.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},t.p='',t(t.s=103)}([function(e){'use strict';var t=function(){};!1,e.exports=function(n,o,r,a,i,s,d,e){if(t(o),!n){var p;if(void 0===o)p=new Error('Minified exception occurred; use the non-minified dev environment for the full error message and additional helpful warnings.');else{var l=[r,a,i,s,d,e],u=0;p=new Error(o.replace(/%s/g,function(){return l[u++]})),p.name='Invariant Violation'}throw p.framesToPop=1,p}}},function(e,t,n){'use strict';var o=n(5);e.exports=o},function(e){'use strict';e.exports=function(e){for(var t=arguments.length-1,n='Minified React error #'+e+'; visit http://facebook.github.io/react/docs/error-decoder.html?invariant='+e,o=0;o<t;o++)n+='&args[]='+encodeURIComponent(arguments[o+1]);n+=' for the full message or use the non-minified dev environment for full errors and additional helpful warnings.';var a=new Error(n);throw a.name='Invariant Violation',a.framesToPop=1,a}},function(e){'use strict';/*
+object-assign
+(c) Sindre Sorhus
+@license MIT
+*/function t(e){if(null===e||e===void 0)throw new TypeError('Object.assign cannot be called with null or undefined');return Object(e)}var n=Object.getOwnPropertySymbols,o=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable;e.exports=function(){try{if(!Object.assign)return!1;var e=new String('abc');if(e[5]='de','5'===Object.getOwnPropertyNames(e)[0])return!1;for(var t={},n=0;10>n;n++)t['_'+String.fromCharCode(n)]=n;var o=Object.getOwnPropertyNames(t).map(function(e){return t[e]});if('0123456789'!==o.join(''))return!1;var a={};return['a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t'].forEach(function(e){a[e]=e}),'abcdefghijklmnopqrst'===Object.keys(Object.assign({},a)).join('')}catch(e){return!1}}()?Object.assign:function(e){for(var r=t(e),d=1,s,p;d<arguments.length;d++){for(var l in s=Object(arguments[d]),s)o.call(s,l)&&(r[l]=s[l]);if(n){p=n(s);for(var u=0;u<p.length;u++)a.call(s,p[u])&&(r[p[u]]=s[p[u]])}}return r}},function(e,t,n){'use strict';function o(e,t){return 1===e.nodeType&&e.getAttribute(c)===t+''||8===e.nodeType&&e.nodeValue===' react-text: '+t+' '||8===e.nodeType&&e.nodeValue===' react-empty: '+t+' '}function a(e){for(var t;t=e._renderedComponent;)e=t;return e}function r(e,t){var n=a(e);n._hostNode=t,t[h]=n}function i(e,t){if(!(e._flags&m.hasCachedChildNodes)){var n=e._renderedChildren,i=t.firstChild;outer:for(var d in n)if(n.hasOwnProperty(d)){var p=n[d],l=a(p)._domID;if(0!==l){for(;null!==i;i=i.nextSibling)if(o(i,l)){r(p,i);continue outer}s('32',l)}}e._flags|=m.hasCachedChildNodes}}function d(e){if(e[h])return e[h];for(var t=[];!e[h];)if(t.push(e),e.parentNode)e=e.parentNode;else return null;for(var n,o;e&&(o=e[h]);e=t.pop())n=o,t.length&&i(o,e);return n}var s=n(2),p=n(16),l=n(66),u=n(0),c=p.ID_ATTRIBUTE_NAME,m=l,h='__reactInternalInstance$'+Math.random().toString(36).slice(2);e.exports={getClosestInstanceFromNode:d,getInstanceFromNode:function(e){var t=d(e);return null!=t&&t._hostNode===e?t:null},getNodeFromInstance:function(e){if(void 0===e._hostNode?s('33'):void 0,e._hostNode)return e._hostNode;for(var t=[];!e._hostNode;)t.push(e),e._hostParent?void 0:s('34'),e=e._hostParent;for(;t.length;e=t.pop())i(e,e._hostNode);return e._hostNode},precacheChildNodes:i,precacheNode:r,uncacheNode:function(e){var t=e._hostNode;t&&(delete t[h],e._hostNode=null)}}},function(e){'use strict';function t(e){return function(){return e}}var n=function(){};n.thatReturns=t,n.thatReturnsFalse=t(!1),n.thatReturnsTrue=t(!0),n.thatReturnsNull=t(null),n.thatReturnsThis=function(){return this},n.thatReturnsArgument=function(e){return e},e.exports=n},function(e){'use strict';var t=!!('undefined'!=typeof window&&window.document&&window.document.createElement),n={canUseDOM:t,canUseWorkers:'undefined'!=typeof Worker,canUseEventListeners:t&&!!(window.addEventListener||window.attachEvent),canUseViewport:t&&!!window.screen,isInWorker:!t};e.exports=n},function(e,t,n){'use strict';function o(e){return e.ref!==void 0}function a(e){return e.key!==void 0}var r=n(3),d=n(8),i=n(1),s=n(22),p=Object.prototype.hasOwnProperty,l=n(23),u={key:!0,ref:!0,__self:!0,__source:!0},c=function(e,t,n,o,a,r,i){return!1,{$$typeof:l,type:e,key:t,ref:n,props:i,_owner:r}},m,h;c.createElement=function(e,t,n){var r={},s=null,l=null,m=null,h=null,g;if(null!=t)for(g in o(t)&&(l=t.ref),a(t)&&(s=''+t.key),m=void 0===t.__self?null:t.__self,h=void 0===t.__source?null:t.__source,t)p.call(t,g)&&!u.hasOwnProperty(g)&&(r[g]=t[g]);var f=arguments.length-2;if(1==f)r.children=n;else if(1<f){for(var y=Array(f),_=0;_<f;_++)y[_]=arguments[_+2];!1,r.children=y}if(e&&e.defaultProps){var i=e.defaultProps;for(g in i)void 0===r[g]&&(r[g]=i[g])}return c(e,s,l,m,h,d.current,r)},c.createFactory=function(e){var t=c.createElement.bind(null,e);return t.type=e,t},c.cloneAndReplaceKey=function(e,t){var n=c(e.type,t,e.ref,e._self,e._source,e._owner,e.props);return n},c.cloneElement=function(e,t,n){var s=r({},e.props),l=e.key,m=e.ref,h=e._self,g=e._source,f=e._owner,y;if(null!=t){o(t)&&(m=t.ref,f=d.current),a(t)&&(l=''+t.key);var _;for(y in e.type&&e.type.defaultProps&&(_=e.type.defaultProps),t)p.call(t,y)&&!u.hasOwnProperty(y)&&(s[y]=void 0===t[y]&&void 0!==_?_[y]:t[y])}var C=arguments.length-2;if(1==C)s.children=n;else if(1<C){for(var b=Array(C),E=0;E<C;E++)b[E]=arguments[E+2];s.children=b}return c(e.type,l,m,h,g,f,s)},c.isValidElement=function(e){return'object'==typeof e&&null!==e&&e.$$typeof===l},e.exports=c},function(e){'use strict';e.exports={current:null}},function(e){'use strict';e.exports={debugTool:null}},function(e){'use strict';e.exports=function(e){for(var t=arguments.length-1,n='Minified React error #'+e+'; visit http://facebook.github.io/react/docs/error-decoder.html?invariant='+e,o=0;o<t;o++)n+='&args[]='+encodeURIComponent(arguments[o+1]);n+=' for the full message or use the non-minified dev environment for full errors and additional helpful warnings.';var a=new Error(n);throw a.name='Invariant Violation',a.framesToPop=1,a}},function(e,t,n){'use strict';function o(){x.ReactReconcileTransaction&&E?void 0:s('123')}function a(){this.reinitializeTransaction(),this.dirtyComponentsLength=null,this.callbackQueue=l.getPooled(),this.reconcileTransaction=x.ReactReconcileTransaction.getPooled(!0)}function r(e,t){return e._mountOrder-t._mountOrder}function i(e){var t=e.dirtyComponentsLength;t===f.length?void 0:s('124',t,f.length),f.sort(r),y++;for(var n=0;n<t;n++){var o=f[n],a=o._pendingCallbacks;o._pendingCallbacks=null;var i;if(c.logTopLevelRenders){var d=o;o._currentElement.type.isReactTopLevelWrapper&&(d=o._renderedComponent),i='React update: '+d.getName(),console.time(i)}if(m.performUpdateIfNecessary(o,e.reconcileTransaction,y),i&&console.timeEnd(i),a)for(var p=0;p<a.length;p++)e.callbackQueue.enqueue(a[p],o.getPublicInstance())}}function d(e){return o(),E.isBatchingUpdates?void(f.push(e),null==e._updateBatchNumber&&(e._updateBatchNumber=y+1)):void E.batchedUpdates(d,e)}var s=n(2),p=n(3),l=n(70),u=n(15),c=n(71),m=n(17),h=n(29),g=n(0),f=[],y=0,_=l.getPooled(),C=!1,E=null,b=[{initialize:function(){this.dirtyComponentsLength=f.length},close:function(){this.dirtyComponentsLength===f.length?f.length=0:(f.splice(0,this.dirtyComponentsLength),v())}},{initialize:function(){this.callbackQueue.reset()},close:function(){this.callbackQueue.notifyAll()}}];p(a.prototype,h,{getTransactionWrappers:function(){return b},destructor:function(){this.dirtyComponentsLength=null,l.release(this.callbackQueue),this.callbackQueue=null,x.ReactReconcileTransaction.release(this.reconcileTransaction),this.reconcileTransaction=null},perform:function(e,t,n){return h.perform.call(this,this.reconcileTransaction.perform,this.reconcileTransaction,e,t,n)}}),u.addPoolingTo(a);var v=function(){for(;f.length||C;){if(f.length){var e=a.getPooled();e.perform(i,null,e),a.release(e)}if(C){C=!1;var t=_;_=l.getPooled(),t.notifyAll(),l.release(t)}}},x={ReactReconcileTransaction:null,batchedUpdates:function(t,n,a,r,i,d){return o(),E.batchedUpdates(t,n,a,r,i,d)},enqueueUpdate:d,flushBatchedUpdates:v,injection:{injectReconcileTransaction:function(e){e?void 0:s('126'),x.ReactReconcileTransaction=e},injectBatchingStrategy:function(e){e?void 0:s('127'),'function'==typeof e.batchedUpdates?void 0:s('128'),'boolean'==typeof e.isBatchingUpdates?void 0:s('129'),E=e}},asap:function(e,t){E.isBatchingUpdates?void 0:s('125'),_.enqueue(e,t),C=!0}};e.exports=x},function(e,t,n){'use strict';function o(e,t,n,o){!1,this.dispatchConfig=e,this._targetInst=t,this.nativeEvent=n;var a=this.constructor.Interface;for(var r in a)if(a.hasOwnProperty(r)){var d=a[r];d?this[r]=d(n):'target'==r?this.target=o:this[r]=n[r]}var s=null==n.defaultPrevented?!1===n.returnValue:n.defaultPrevented;return this.isDefaultPrevented=s?i.thatReturnsTrue:i.thatReturnsFalse,this.isPropagationStopped=i.thatReturnsFalse,this}var a=n(3),r=n(15),i=n(5),d=n(1),s='function'==typeof Proxy,p=['dispatchConfig','_targetInst','nativeEvent','isDefaultPrevented','isPropagationStopped','_dispatchListeners','_dispatchInstances'],l={type:null,target:null,currentTarget:i.thatReturnsNull,eventPhase:null,bubbles:null,cancelable:null,timeStamp:function(e){return e.timeStamp||Date.now()},defaultPrevented:null,isTrusted:null};a(o.prototype,{preventDefault:function(){this.defaultPrevented=!0;var e=this.nativeEvent;e&&(e.preventDefault?e.preventDefault():'unknown'!=typeof e.returnValue&&(e.returnValue=!1),this.isDefaultPrevented=i.thatReturnsTrue)},stopPropagation:function(){var e=this.nativeEvent;e&&(e.stopPropagation?e.stopPropagation():'unknown'!=typeof e.cancelBubble&&(e.cancelBubble=!0),this.isPropagationStopped=i.thatReturnsTrue)},persist:function(){this.isPersistent=i.thatReturnsTrue},isPersistent:i.thatReturnsFalse,destructor:function(){var e=this.constructor.Interface;for(var t in e)this[t]=null;for(var n=0;n<p.length;n++)this[p[n]]=null}}),o.Interface=l,!1,o.augmentClass=function(e,t){var n=this,o=function(){};o.prototype=n.prototype;var i=new o;a(i,e.prototype),e.prototype=i,e.prototype.constructor=e,e.Interface=a({},n.Interface,t),e.augmentClass=n.augmentClass,r.addPoolingTo(e,r.fourArgumentPooler)},r.addPoolingTo(o,r.fourArgumentPooler),e.exports=o},function(e,t,n){'use strict';var o=n(3),a=n(20),r=n(35),i=n(40),d=n(7),s=n(41),p=n(44),l=n(45),u=n(47),c=d.createElement,m=d.createFactory,h=d.cloneElement;var g={Children:{map:r.map,forEach:r.forEach,count:r.count,toArray:r.toArray,only:u},Component:a.Component,PureComponent:a.PureComponent,createElement:c,cloneElement:h,isValidElement:d.isValidElement,PropTypes:s,createClass:l,createFactory:m,createMixin:function(e){return e},DOM:i,version:p,__spread:o};e.exports=g},function(e){'use strict';!1,e.exports={}},function(e,t,n){'use strict';var o=n(2),a=n(0),r=function(e){var t=this;if(t.instancePool.length){var n=t.instancePool.pop();return t.call(n,e),n}return new t(e)},i=function(e){var t=this;e instanceof t?void 0:o('25'),e.destructor(),t.instancePool.length<t.poolSize&&t.instancePool.push(e)};e.exports={addPoolingTo:function(e,t){var n=e;return n.instancePool=[],n.getPooled=t||r,n.poolSize||(n.poolSize=10),n.release=i,n},oneArgumentPooler:r,twoArgumentPooler:function(e,t){var n=this;if(n.instancePool.length){var o=n.instancePool.pop();return n.call(o,e,t),o}return new n(e,t)},threeArgumentPooler:function(e,t,n){var o=this;if(o.instancePool.length){var a=o.instancePool.pop();return o.call(a,e,t,n),a}return new o(e,t,n)},fourArgumentPooler:function(e,t,n,o){var a=this;if(a.instancePool.length){var r=a.instancePool.pop();return a.call(r,e,t,n,o),r}return new a(e,t,n,o)}}},function(e,t,n){'use strict';function o(e,t){return(e&t)===t}var a=n(2),r=n(0),i={MUST_USE_PROPERTY:1,HAS_BOOLEAN_VALUE:4,HAS_NUMERIC_VALUE:8,HAS_POSITIVE_NUMERIC_VALUE:24,HAS_OVERLOADED_BOOLEAN_VALUE:32,injectDOMPropertyConfig:function(e){var t=i,n=e.Properties||{},r=e.DOMAttributeNamespaces||{},d=e.DOMAttributeNames||{},p=e.DOMPropertyNames||{},l=e.DOMMutationMethods||{};for(var u in e.isCustomAttribute&&s._isCustomAttributeFunctions.push(e.isCustomAttribute),n){s.properties.hasOwnProperty(u)?a('48',u):void 0;var c=u.toLowerCase(),m=n[u],h={attributeName:c,attributeNamespace:null,propertyName:u,mutationMethod:null,mustUseProperty:o(m,t.MUST_USE_PROPERTY),hasBooleanValue:o(m,t.HAS_BOOLEAN_VALUE),hasNumericValue:o(m,t.HAS_NUMERIC_VALUE),hasPositiveNumericValue:o(m,t.HAS_POSITIVE_NUMERIC_VALUE),hasOverloadedBooleanValue:o(m,t.HAS_OVERLOADED_BOOLEAN_VALUE)};if(1>=h.hasBooleanValue+h.hasNumericValue+h.hasOverloadedBooleanValue?void 0:a('50',u),!1,d.hasOwnProperty(u)){var g=d[u];h.attributeName=g,!1}r.hasOwnProperty(u)&&(h.attributeNamespace=r[u]),p.hasOwnProperty(u)&&(h.propertyName=p[u]),l.hasOwnProperty(u)&&(h.mutationMethod=l[u]),s.properties[u]=h}}},d=':A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD',s={ID_ATTRIBUTE_NAME:'data-reactid',ROOT_ATTRIBUTE_NAME:'data-reactroot',ATTRIBUTE_NAME_START_CHAR:d,ATTRIBUTE_NAME_CHAR:d+'\\-.0-9\\u00B7\\u0300-\\u036F\\u203F-\\u2040',properties:{},getPossibleStandardName:null,_isCustomAttributeFunctions:[],isCustomAttribute:function(e){for(var t=0,n;t<s._isCustomAttributeFunctions.length;t++)if(n=s._isCustomAttributeFunctions[t],n(e))return!0;return!1},injection:i};e.exports=s},function(e,t,n){'use strict';function o(){a.attachRefs(this,this._currentElement)}var a=n(112),r=n(9),i=n(1);e.exports={mountComponent:function(e,t,n,a,r,i){var d=e.mountComponent(t,n,a,r,i);return e._currentElement&&null!=e._currentElement.ref&&t.getReactMountReady().enqueue(o,e),!1,d},getHostNode:function(e){return e.getHostNode()},unmountComponent:function(e,t){!1,a.detachRefs(e,e._currentElement),e.unmountComponent(t),!1},receiveComponent:function(e,t,n,r){var i=e._currentElement;if(t!==i||r!==e._context){var d=a.shouldUpdateRefs(i,t);d&&a.detachRefs(e,i),e.receiveComponent(t,n,r),d&&e._currentElement&&null!=e._currentElement.ref&&n.getReactMountReady().enqueue(o,e),!1}},performUpdateIfNecessary:function(e,t,n){return e._updateBatchNumber===n?void(!1,e.performUpdateIfNecessary(t),!1):void void 0}}},function(e,t,n){'use strict';function o(e){if(l){var t=e.node,n=e.children;if(n.length)for(var o=0;o<n.length;o++)u(t,n[o],null);else null==e.html?null!=e.text&&p(t,e.text):d(t,e.html)}}function a(){return this.node.nodeName}function r(e){return{node:e,children:[],html:null,text:null,toString:a}}var i=n(55),d=n(31),s=n(56),p=n(75),l='undefined'!=typeof document&&'number'==typeof document.documentMode||'undefined'!=typeof navigator&&'string'==typeof navigator.userAgent&&/\bEdge\/\d/.test(navigator.userAgent),u=s(function(e,t,n){t.node.nodeType===11||t.node.nodeType===1&&'object'===t.node.nodeName.toLowerCase()&&(null==t.node.namespaceURI||t.node.namespaceURI===i.html)?(o(t),e.insertBefore(t.node,n)):(e.insertBefore(t.node,n),o(t))});r.insertTreeBefore=u,r.replaceChildWithTree=function(e,t){e.parentNode.replaceChild(t.node,e),o(t)},r.queueChild=function(e,t){l?e.children.push(t):e.node.appendChild(t.node)},r.queueHTML=function(e,t){l?e.html=t:d(e.node,t)},r.queueText=function(e,t){l?e.text=t:p(e.node,t)},e.exports=r},function(e){'use strict';e.exports='SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED'},function(e,t,n){'use strict';function o(e,t,n){this.props=e,this.context=t,this.refs=l,this.updater=n||s}function a(e,t,n){this.props=e,this.context=t,this.refs=l,this.updater=n||s}function r(){}var i=n(10),d=n(3),s=n(21),p=n(22),l=n(14),u=n(0),c=n(34);o.prototype.isReactComponent={},o.prototype.setState=function(e,t){'object'==typeof e||'function'==typeof e||null==e?void 0:i('85'),this.updater.enqueueSetState(this,e),t&&this.updater.enqueueCallback(this,t,'setState')},o.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this),e&&this.updater.enqueueCallback(this,e,'forceUpdate')};r.prototype=o.prototype,a.prototype=new r,a.prototype.constructor=a,d(a.prototype,o.prototype),a.prototype.isPureReactComponent=!0,e.exports={Component:o,PureComponent:a}},function(e,t,n){'use strict';function o(){}var a=n(1);e.exports={isMounted:function(){return!1},enqueueCallback:function(){},enqueueForceUpdate:function(e){o(e,'forceUpdate')},enqueueReplaceState:function(e){o(e,'replaceState')},enqueueSetState:function(e){o(e,'setState')}}},function(e){'use strict';e.exports=!1},function(e){'use strict';var t='function'==typeof Symbol&&Symbol['for']&&Symbol['for']('react.element')||60103;e.exports=t},function(e,t,n){'use strict';function o(e,t,n){var o=t.dispatchConfig.phasedRegistrationNames[n];return h(e,o)}function a(e,t,n){var a=o(e,n,t);a&&(n._dispatchListeners=u(n._dispatchListeners,a),n._dispatchInstances=u(n._dispatchInstances,e))}function r(e){e&&e.dispatchConfig.phasedRegistrationNames&&l.traverseTwoPhase(e._targetInst,a,e)}function i(e){if(e&&e.dispatchConfig.phasedRegistrationNames){var t=e._targetInst,n=t?l.getParentInstance(t):null;l.traverseTwoPhase(n,a,e)}}function d(e,t,n){if(n&&n.dispatchConfig.registrationName){var o=n.dispatchConfig.registrationName,a=h(e,o);a&&(n._dispatchListeners=u(n._dispatchListeners,a),n._dispatchInstances=u(n._dispatchInstances,e))}}function s(e){e&&e.dispatchConfig.registrationName&&d(e._targetInst,null,e)}var p=n(25),l=n(49),u=n(67),c=n(68),m=n(1),h=p.getListener;e.exports={accumulateTwoPhaseDispatches:function(e){c(e,r)},accumulateTwoPhaseDispatchesSkipTarget:function(e){c(e,i)},accumulateDirectDispatches:function(e){c(e,s)},accumulateEnterLeaveDispatches:function(e,t,n,o){l.traverseEnterLeave(n,o,d,e,t)}}},function(e,t,n){'use strict';function o(e){return'button'===e||'input'===e||'select'===e||'textarea'===e}function a(e,t,n){return('onClick'===e||'onClickCapture'===e||'onDoubleClick'===e||'onDoubleClickCapture'===e||'onMouseDown'===e||'onMouseDownCapture'===e||'onMouseMove'===e||'onMouseMoveCapture'===e||'onMouseUp'===e||'onMouseUpCapture'===e)&&!!(n.disabled&&o(t))}var r=n(2),d=n(48),i=n(49),s=n(50),p=n(67),l=n(68),u=n(0),c={},m=null,h=function(e,t){e&&(i.executeDispatchesInOrder(e,t),!e.isPersistent()&&e.constructor.release(e))},g=function(t){return h(t,!0)},f=function(t){return h(t,!1)},y=function(e){return'.'+e._rootNodeID},_={injection:{injectEventPluginOrder:d.injectEventPluginOrder,injectEventPluginsByName:d.injectEventPluginsByName},putListener:function(e,t,n){'function'==typeof n?void 0:r('94',t,typeof n);var o=y(e),a=c[t]||(c[t]={});a[o]=n;var i=d.registrationNameModules[t];i&&i.didPutListener&&i.didPutListener(e,t,n)},getListener:function(e,t){var n=c[t];if(a(t,e._currentElement.type,e._currentElement.props))return null;var o=y(e);return n&&n[o]},deleteListener:function(e,t){var n=d.registrationNameModules[t];n&&n.willDeleteListener&&n.willDeleteListener(e,t);var o=c[t];if(o){var a=y(e);delete o[a]}},deleteAllListeners:function(e){var t=y(e);for(var n in c)if(c.hasOwnProperty(n)&&c[n][t]){var o=d.registrationNameModules[n];o&&o.willDeleteListener&&o.willDeleteListener(e,n),delete c[n][t]}},extractEvents:function(e,t,n,o){for(var a=d.plugins,r=0,i,s;r<a.length;r++)if(s=a[r],s){var l=s.extractEvents(e,t,n,o);l&&(i=p(i,l))}return i},enqueueEvents:function(e){e&&(m=p(m,e))},processEventQueue:function(e){var t=m;m=null,e?l(t,g):l(t,f),!m?void 0:r('95'),s.rethrowCaughtError()},__purge:function(){c={}},__getListenerBank:function(){return c}};e.exports=_},function(e,t,n){'use strict';function o(e,t,n,o){return a.call(this,e,t,n,o)}var a=n(12),r=n(51);a.augmentClass(o,{view:function(e){if(e.view)return e.view;var t=r(e);if(t.window===t)return t;var n=t.ownerDocument;return n?n.defaultView||n.parentWindow:window},detail:function(e){return e.detail||0}}),e.exports=o},function(e){'use strict';e.exports={remove:function(e){e._reactInternalInstance=void 0},get:function(e){return e._reactInternalInstance},has:function(e){return e._reactInternalInstance!==void 0},set:function(e,t){e._reactInternalInstance=t}}},function(e,t,n){'use strict';var o=n(42);e.exports=function(e){return o(e,!1)}},function(e,t,n){'use strict';var o=n(2),a=n(0),r={};e.exports={reinitializeTransaction:function(){this.transactionWrappers=this.getTransactionWrappers(),this.wrapperInitData?this.wrapperInitData.length=0:this.wrapperInitData=[],this._isInTransaction=!1},_isInTransaction:!1,getTransactionWrappers:null,isInTransaction:function(){return!!this._isInTransaction},perform:function(t,n,r,a,i,s,d,e){!this.isInTransaction()?void 0:o('27');var p,l;try{this._isInTransaction=!0,p=!0,this.initializeAll(0),l=t.call(n,r,a,i,s,d,e),p=!1}finally{try{if(p)try{this.closeAll(0)}catch(e){}else this.closeAll(0)}finally{this._isInTransaction=!1}}return l},initializeAll:function(e){for(var t=this.transactionWrappers,n=e,o;n<t.length;n++){o=t[n];try{this.wrapperInitData[n]=r,this.wrapperInitData[n]=o.initialize?o.initialize.call(this):null}finally{if(this.wrapperInitData[n]===r)try{this.initializeAll(n+1)}catch(e){}}}},closeAll:function(e){this.isInTransaction()?void 0:o('28');for(var t=this.transactionWrappers,n=e;n<t.length;n++){var a=t[n],i=this.wrapperInitData[n],d;try{d=!0,i!==r&&a.close&&a.close.call(this,i),d=!1}finally{if(d)try{this.closeAll(n+1)}catch(t){}}}this.wrapperInitData.length=0}}},function(e,t,n){'use strict';function o(e,t,n,o){return a.call(this,e,t,n,o)}var a=n(26),r=n(74),i=n(53);a.augmentClass(o,{screenX:null,screenY:null,clientX:null,clientY:null,ctrlKey:null,shiftKey:null,altKey:null,metaKey:null,getModifierState:i,button:function(e){var t=e.button;return'which'in e?t:2===t?2:4===t?1:0},buttons:null,relatedTarget:function(e){return e.relatedTarget||(e.fromElement===e.srcElement?e.toElement:e.fromElement)},pageX:function(e){return'pageX'in e?e.pageX:e.clientX+r.currentScrollLeft},pageY:function(e){return'pageY'in e?e.pageY:e.clientY+r.currentScrollTop}}),e.exports=o},function(e,t,n){'use strict';var o=n(6),a=n(55),r=/^[ \r\n\t\f]/,i=/<(!--|link|noscript|meta|script|style)[ \r\n\t\f\/>]/,d=n(56),s=d(function(e,t){if(e.namespaceURI===a.svg&&!('innerHTML'in e)){p=p||document.createElement('div'),p.innerHTML='<svg>'+t+'</svg>';for(var n=p.firstChild;n.firstChild;)e.appendChild(n.firstChild)}else e.innerHTML=t}),p;if(o.canUseDOM){var l=document.createElement('div');l.innerHTML=' ',''===l.innerHTML&&(s=function(e,t){if(e.parentNode&&e.parentNode.replaceChild(e,e),r.test(t)||'<'===t[0]&&i.test(t)){e.innerHTML='\uFEFF'+t;var n=e.firstChild;1===n.data.length?e.removeChild(n):n.deleteData(0,1)}else e.innerHTML=t}),l=null}e.exports=s},function(e){'use strict';function t(e){var t=''+e,o=n.exec(t);if(!o)return t;var a='',r=0,i=0,d;for(r=o.index;r<t.length;r++){switch(t.charCodeAt(r)){case 34:d='&quot;';break;case 38:d='&amp;';break;case 39:d='&#x27;';break;case 60:d='&lt;';break;case 62:d='&gt;';break;default:continue;}i!==r&&(a+=t.substring(i,r)),i=r+1,a+=d}return i===r?a:a+t.substring(i,r)}var n=/["'&<>]/;e.exports=function(e){return'boolean'==typeof e||'number'==typeof e?''+e:t(e)}},function(e,t,n){'use strict';function o(e){return Object.prototype.hasOwnProperty.call(e,h)||(e[h]=c++,l[e[h]]={}),l[e[h]]}var a=n(3),r=n(48),i=n(133),d=n(74),s=n(134),p=n(52),l={},u=!1,c=0,m={topAbort:'abort',topAnimationEnd:s('animationend')||'animationend',topAnimationIteration:s('animationiteration')||'animationiteration',topAnimationStart:s('animationstart')||'animationstart',topBlur:'blur',topCanPlay:'canplay',topCanPlayThrough:'canplaythrough',topChange:'change',topClick:'click',topCompositionEnd:'compositionend',topCompositionStart:'compositionstart',topCompositionUpdate:'compositionupdate',topContextMenu:'contextmenu',topCopy:'copy',topCut:'cut',topDoubleClick:'dblclick',topDrag:'drag',topDragEnd:'dragend',topDragEnter:'dragenter',topDragExit:'dragexit',topDragLeave:'dragleave',topDragOver:'dragover',topDragStart:'dragstart',topDrop:'drop',topDurationChange:'durationchange',topEmptied:'emptied',topEncrypted:'encrypted',topEnded:'ended',topError:'error',topFocus:'focus',topInput:'input',topKeyDown:'keydown',topKeyPress:'keypress',topKeyUp:'keyup',topLoadedData:'loadeddata',topLoadedMetadata:'loadedmetadata',topLoadStart:'loadstart',topMouseDown:'mousedown',topMouseMove:'mousemove',topMouseOut:'mouseout',topMouseOver:'mouseover',topMouseUp:'mouseup',topPaste:'paste',topPause:'pause',topPlay:'play',topPlaying:'playing',topProgress:'progress',topRateChange:'ratechange',topScroll:'scroll',topSeeked:'seeked',topSeeking:'seeking',topSelectionChange:'selectionchange',topStalled:'stalled',topSuspend:'suspend',topTextInput:'textInput',topTimeUpdate:'timeupdate',topTouchCancel:'touchcancel',topTouchEnd:'touchend',topTouchMove:'touchmove',topTouchStart:'touchstart',topTransitionEnd:s('transitionend')||'transitionend',topVolumeChange:'volumechange',topWaiting:'waiting',topWheel:'wheel'},h='_reactListenersID'+(Math.random()+'').slice(2),g=a({},i,{ReactEventListener:null,injection:{injectReactEventListener:function(e){e.setHandleTopLevel(g.handleTopLevel),g.ReactEventListener=e}},setEnabled:function(e){g.ReactEventListener&&g.ReactEventListener.setEnabled(e)},isEnabled:function(){return!!(g.ReactEventListener&&g.ReactEventListener.isEnabled())},listenTo:function(e,t){for(var n=t,a=o(n),d=r.registrationNameDependencies[e],s=0,i;s<d.length;s++)i=d[s],a.hasOwnProperty(i)&&a[i]||('topWheel'===i?p('wheel')?g.ReactEventListener.trapBubbledEvent('topWheel','wheel',n):p('mousewheel')?g.ReactEventListener.trapBubbledEvent('topWheel','mousewheel',n):g.ReactEventListener.trapBubbledEvent('topWheel','DOMMouseScroll',n):'topScroll'===i?p('scroll',!0)?g.ReactEventListener.trapCapturedEvent('topScroll','scroll',n):g.ReactEventListener.trapBubbledEvent('topScroll','scroll',g.ReactEventListener.WINDOW_HANDLE):'topFocus'===i||'topBlur'===i?(p('focus',!0)?(g.ReactEventListener.trapCapturedEvent('topFocus','focus',n),g.ReactEventListener.trapCapturedEvent('topBlur','blur',n)):p('focusin')&&(g.ReactEventListener.trapBubbledEvent('topFocus','focusin',n),g.ReactEventListener.trapBubbledEvent('topBlur','focusout',n)),a.topBlur=!0,a.topFocus=!0):m.hasOwnProperty(i)&&g.ReactEventListener.trapBubbledEvent(i,m[i],n),a[i]=!0)},trapBubbledEvent:function(e,t,n){return g.ReactEventListener.trapBubbledEvent(e,t,n)},trapCapturedEvent:function(e,t,n){return g.ReactEventListener.trapCapturedEvent(e,t,n)},supportsEventPageXY:function(){if(!document.createEvent)return!1;var e=document.createEvent('MouseEvent');return null!=e&&'pageX'in e},ensureScrollValueMonitoring:function(){if(void 0==f&&(f=g.supportsEventPageXY()),!f&&!u){var e=d.refreshScrollValues;g.ReactEventListener.monitorScrollValue(e),u=!0}}}),f;e.exports=g},function(e){'use strict';e.exports=function(){}},function(e,t,n){'use strict';function o(e){return(''+e).replace(f,'$&/')}function a(e,t){this.func=e,this.context=t,this.count=0}function r(e,t){var n=e.func,o=e.context;n.call(o,t,e.count++)}function i(e,t,n,o){this.result=e,this.keyPrefix=t,this.func=n,this.context=o,this.count=0}function d(e,t,n){var a=e.result,r=e.keyPrefix,i=e.func,d=e.context,p=i.call(d,t,e.count++);Array.isArray(p)?s(p,a,n,c.thatReturnsArgument):null!=p&&(u.isValidElement(p)&&(p=u.cloneAndReplaceKey(p,r+(p.key&&(!t||t.key!==p.key)?o(p.key)+'/':'')+n)),a.push(p))}function s(e,t,n,a,r){var s='';null!=n&&(s=o(n)+'/');var p=i.getPooled(t,s,a,r);m(e,d,p),i.release(p)}function p(){return null}var l=n(36),u=n(7),c=n(5),m=n(37),h=l.twoArgumentPooler,g=l.fourArgumentPooler,f=/\/+/g;a.prototype.destructor=function(){this.func=null,this.context=null,this.count=0},l.addPoolingTo(a,h),i.prototype.destructor=function(){this.result=null,this.keyPrefix=null,this.func=null,this.context=null,this.count=0},l.addPoolingTo(i,g);e.exports={forEach:function(e,t,n){if(null==e)return e;var o=a.getPooled(t,n);m(e,r,o),a.release(o)},map:function(e,t,n){if(null==e)return e;var o=[];return s(e,o,null,t,n),o},mapIntoWithKeyPrefixInternal:s,count:function(e){return m(e,p,null)},toArray:function(e){var t=[];return s(e,t,null,c.thatReturnsArgument),t}}},function(e,t,n){'use strict';var o=n(10),a=n(0),r=function(e){var t=this;if(t.instancePool.length){var n=t.instancePool.pop();return t.call(n,e),n}return new t(e)},i=function(e){var t=this;e instanceof t?void 0:o('25'),e.destructor(),t.instancePool.length<t.poolSize&&t.instancePool.push(e)};e.exports={addPoolingTo:function(e,t){var n=e;return n.instancePool=[],n.getPooled=t||r,n.poolSize||(n.poolSize=10),n.release=i,n},oneArgumentPooler:r,twoArgumentPooler:function(e,t){var n=this;if(n.instancePool.length){var o=n.instancePool.pop();return n.call(o,e,t),o}return new n(e,t)},threeArgumentPooler:function(e,t,n){var o=this;if(o.instancePool.length){var a=o.instancePool.pop();return o.call(a,e,t,n),a}return new o(e,t,n)},fourArgumentPooler:function(e,t,n,o){var a=this;if(a.instancePool.length){var r=a.instancePool.pop();return a.call(r,e,t,n,o),r}return new a(e,t,n,o)}}},function(e,t,n){'use strict';function o(e,t){return e&&'object'==typeof e&&null!=e.key?l.escape(e.key):t.toString(36)}function a(e,t,n,p){var u=typeof e;if(('undefined'==u||'boolean'==u)&&(e=null),null===e||'string'==u||'number'==u||'object'==u&&e.$$typeof===d)return n(p,e,''===t?c+o(e,0):t),1;var h=0,g=''===t?c:t+m,f,y;if(Array.isArray(e))for(var _=0;_<e.length;_++)f=e[_],y=g+o(f,_),h+=a(f,y,n,p);else{var i=s(e);if(i){var C=i.call(e),b;if(i!==e.entries)for(var E=0;!(b=C.next()).done;)f=b.value,y=g+o(f,E++),h+=a(f,y,n,p);else for(var v;!(b=C.next()).done;)v=b.value,v&&(f=v[1],y=g+l.escape(v[0])+m+o(f,0),h+=a(f,y,n,p))}else if('object'==u){var x='',N=e+'';r('31','[object Object]'===N?'object with keys {'+Object.keys(e).join(', ')+'}':N,x)}}return h}var r=n(10),i=n(8),d=n(23),s=n(38),p=n(0),l=n(39),u=n(1),c='.',m=':';e.exports=function(e,t,n){return null==e?0:a(e,'',t,n)}},function(e){'use strict';var t='function'==typeof Symbol&&Symbol.iterator;e.exports=function(e){var n=e&&(t&&e[t]||e['@@iterator']);if('function'==typeof n)return n}},function(e){'use strict';e.exports={escape:function(e){var t=/[=:]/g,n={"=":'=0',":":'=2'},o=(''+e).replace(t,function(e){return n[e]});return'$'+o},unescape:function(e){var t=/(=0|=2)/g,n={"=0":'=',"=2":':'},o='.'===e[0]&&'$'===e[1]?e.substring(2):e.substring(1);return(''+o).replace(t,function(e){return n[e]})}}},function(e,t,n){'use strict';var o=n(7),a=o.createFactory;var r={a:a('a'),abbr:a('abbr'),address:a('address'),area:a('area'),article:a('article'),aside:a('aside'),audio:a('audio'),b:a('b'),base:a('base'),bdi:a('bdi'),bdo:a('bdo'),big:a('big'),blockquote:a('blockquote'),body:a('body'),br:a('br'),button:a('button'),canvas:a('canvas'),caption:a('caption'),cite:a('cite'),code:a('code'),col:a('col'),colgroup:a('colgroup'),data:a('data'),datalist:a('datalist'),dd:a('dd'),del:a('del'),details:a('details'),dfn:a('dfn'),dialog:a('dialog'),div:a('div'),dl:a('dl'),dt:a('dt'),em:a('em'),embed:a('embed'),fieldset:a('fieldset'),figcaption:a('figcaption'),figure:a('figure'),footer:a('footer'),form:a('form'),h1:a('h1'),h2:a('h2'),h3:a('h3'),h4:a('h4'),h5:a('h5'),h6:a('h6'),head:a('head'),header:a('header'),hgroup:a('hgroup'),hr:a('hr'),html:a('html'),i:a('i'),iframe:a('iframe'),img:a('img'),input:a('input'),ins:a('ins'),kbd:a('kbd'),keygen:a('keygen'),label:a('label'),legend:a('legend'),li:a('li'),link:a('link'),main:a('main'),map:a('map'),mark:a('mark'),menu:a('menu'),menuitem:a('menuitem'),meta:a('meta'),meter:a('meter'),nav:a('nav'),noscript:a('noscript'),object:a('object'),ol:a('ol'),optgroup:a('optgroup'),option:a('option'),output:a('output'),p:a('p'),param:a('param'),picture:a('picture'),pre:a('pre'),progress:a('progress'),q:a('q'),rp:a('rp'),rt:a('rt'),ruby:a('ruby'),s:a('s'),samp:a('samp'),script:a('script'),section:a('section'),select:a('select'),small:a('small'),source:a('source'),span:a('span'),strong:a('strong'),style:a('style'),sub:a('sub'),summary:a('summary'),sup:a('sup'),table:a('table'),tbody:a('tbody'),td:a('td'),textarea:a('textarea'),tfoot:a('tfoot'),th:a('th'),thead:a('thead'),time:a('time'),title:a('title'),tr:a('tr'),track:a('track'),u:a('u'),ul:a('ul'),var:a('var'),video:a('video'),wbr:a('wbr'),circle:a('circle'),clipPath:a('clipPath'),defs:a('defs'),ellipse:a('ellipse'),g:a('g'),image:a('image'),line:a('line'),linearGradient:a('linearGradient'),mask:a('mask'),path:a('path'),pattern:a('pattern'),polygon:a('polygon'),polyline:a('polyline'),radialGradient:a('radialGradient'),rect:a('rect'),stop:a('stop'),svg:a('svg'),text:a('text'),tspan:a('tspan')};e.exports=r},function(e,t,n){'use strict';var o=n(7),a=o.isValidElement,r=n(28);e.exports=r(a)},function(e,t,n){'use strict';var o=n(5),a=n(0),r=n(1),d=n(19),i=n(43);e.exports=function(e,t){function n(e){var t=e&&(_&&e[_]||e[C]);if('function'==typeof t)return t}function s(e,t){return e===t?0!==e||1/e==1/t:e!==e&&t!==t}function p(e){this.message=e,this.stack=''}function l(e){function n(n,o,r,i,s,l,u){if(i=i||b,l=l||r,u!==d)if(t)a(!1,'Calling PropTypes validators directly is not supported by the `prop-types` package. Use `PropTypes.checkPropTypes()` to call them. Read more at http://fb.me/use-check-prop-types');else;return null==o[r]?n?null===o[r]?new p('The '+s+' `'+l+'` is marked as required '+('in `'+i+'`, but its value is `null`.')):new p('The '+s+' `'+l+'` is marked as required in '+('`'+i+'`, but its value is `undefined`.')):null:e(o,r,i,s,l)}var o=n.bind(null,!1);return o.isRequired=n.bind(null,!0),o}function u(e){return l(function(t,n,o,a,r){var i=t[n],d=h(i);if(d!==e){var s=g(i);return new p('Invalid '+a+' `'+r+'` of type '+('`'+s+'` supplied to `'+o+'`, expected ')+('`'+e+'`.'))}return null})}function c(t){switch(typeof t){case'number':case'string':case'undefined':return!0;case'boolean':return!t;case'object':if(Array.isArray(t))return t.every(c);if(null===t||e(t))return!0;var o=n(t);if(o){var a=o.call(t),r;if(o!==t.entries){for(;!(r=a.next()).done;)if(!c(r.value))return!1;}else for(;!(r=a.next()).done;){var i=r.value;if(i&&!c(i[1]))return!1}}else return!1;return!0;default:return!1;}}function m(e,t){return'symbol'===e||'Symbol'===t['@@toStringTag']||'function'==typeof Symbol&&t instanceof Symbol}function h(e){var t=typeof e;return Array.isArray(e)?'array':e instanceof RegExp?'object':m(t,e)?'symbol':t}function g(e){if('undefined'==typeof e||null===e)return''+e;var t=h(e);if('object'===t){if(e instanceof Date)return'date';if(e instanceof RegExp)return'regexp'}return t}function f(e){var t=g(e);return'array'===t||'object'===t?'an '+t:'boolean'===t||'date'===t||'regexp'===t?'a '+t:t}function y(e){return e.constructor&&e.constructor.name?e.constructor.name:b}var _='function'==typeof Symbol&&Symbol.iterator,C='@@iterator',b='<<anonymous>>',E={array:u('array'),bool:u('boolean'),func:u('function'),number:u('number'),object:u('object'),string:u('string'),symbol:u('symbol'),any:function(){return l(o.thatReturnsNull)}(),arrayOf:function(e){return l(function(t,n,o,a,r){if('function'!=typeof e)return new p('Property `'+r+'` of component `'+o+'` has invalid PropType notation inside arrayOf.');var s=t[n];if(!Array.isArray(s)){var l=h(s);return new p('Invalid '+a+' `'+r+'` of type '+('`'+l+'` supplied to `'+o+'`, expected an array.'))}for(var u=0,i;u<s.length;u++)if(i=e(s,u,o,a,r+'['+u+']',d),i instanceof Error)return i;return null})},element:function(){return l(function(t,n,o,a,r){var i=t[n];if(!e(i)){var d=h(i);return new p('Invalid '+a+' `'+r+'` of type '+('`'+d+'` supplied to `'+o+'`, expected a single ReactElement.'))}return null})}(),instanceOf:function(e){return l(function(t,n,o,a,r){if(!(t[n]instanceof e)){var i=e.name||b,d=y(t[n]);return new p('Invalid '+a+' `'+r+'` of type '+('`'+d+'` supplied to `'+o+'`, expected ')+('instance of `'+i+'`.'))}return null})},node:function(){return l(function(e,t,n,o,a){return c(e[t])?null:new p('Invalid '+o+' `'+a+'` supplied to '+('`'+n+'`, expected a ReactNode.'))})}(),objectOf:function(e){return l(function(t,n,o,a,r){if('function'!=typeof e)return new p('Property `'+r+'` of component `'+o+'` has invalid PropType notation inside objectOf.');var i=t[n],s=h(i);if('object'!==s)return new p('Invalid '+a+' `'+r+'` of type '+('`'+s+'` supplied to `'+o+'`, expected an object.'));for(var l in i)if(i.hasOwnProperty(l)){var u=e(i,l,o,a,r+'.'+l,d);if(u instanceof Error)return u}return null})},oneOf:function(e){return Array.isArray(e)?l(function(t,n,o,a,r){for(var d=t[n],l=0;l<e.length;l++)if(s(d,e[l]))return null;var i=JSON.stringify(e);return new p('Invalid '+a+' `'+r+'` of value `'+d+'` '+('supplied to `'+o+'`, expected one of '+i+'.'))}):(void 0,o.thatReturnsNull)},oneOfType:function(e){if(!Array.isArray(e))return void 0,o.thatReturnsNull;for(var t=0,n;t<e.length;t++)if(n=e[t],'function'!=typeof n)return r(!1,'Invalid argument supplid to oneOfType. Expected an array of check functions, but received %s at index %s.',f(n),t),o.thatReturnsNull;return l(function(t,n,o,a,r){for(var s=0,i;s<e.length;s++)if(i=e[s],null==i(t,n,o,a,r,d))return null;return new p('Invalid '+a+' `'+r+'` supplied to '+('`'+o+'`.'))})},shape:function(e){return l(function(t,n,o,a,r){var i=t[n],s=h(i);if('object'!==s)return new p('Invalid '+a+' `'+r+'` of type `'+s+'` '+('supplied to `'+o+'`, expected `object`.'));for(var l in e){var u=e[l];if(u){var c=u(i,l,o,a,r+'.'+l,d);if(c)return c}}return null})}};return p.prototype=Error.prototype,E.checkPropTypes=i,E.PropTypes=E,E}},function(e){'use strict';e.exports=function(){}},function(e){'use strict';e.exports='15.6.1'},function(e,t,n){'use strict';var o=n(20),a=o.Component,r=n(7),i=r.isValidElement,d=n(21),s=n(46);e.exports=s(a,i,d)},function(e,t,n){'use strict';function o(e){return e}var a=n(3),r=n(14),i=n(0);var d='mixins',s;s={},e.exports=function(e,t,n){function s(e,t){var n=y.hasOwnProperty(t)?y[t]:null;E.hasOwnProperty(t)&&i('OVERRIDE_BASE'===n,'ReactClassInterface: You are attempting to override `%s` from your class specification. Ensure that your method names do not overlap with React methods.',t),e&&i('DEFINE_MANY'===n||'DEFINE_MANY_MERGED'===n,'ReactClassInterface: You are attempting to define `%s` on your component more than once. This conflict may be due to a mixin.',t)}function p(e,n){if(!n){return}i('function'!=typeof n,'ReactClass: You\'re attempting to use a component class or function as a mixin. Instead, just use a regular object.'),i(!t(n),'ReactClass: You\'re attempting to use a component as a mixin. Instead, just use a regular object.');var o=e.prototype,a=o.__reactAutoBindPairs;for(var r in n.hasOwnProperty(d)&&_.mixins(e,n.mixins),n)if(n.hasOwnProperty(r)&&r!=d){var p=n[r],l=o.hasOwnProperty(r);if(s(l,r),_.hasOwnProperty(r))_[r](e,p);else{var u=y.hasOwnProperty(r),h='function'==typeof p&&!u&&!l&&!1!==n.autobind;if(h)a.push(r,p),o[r]=p;else if(l){var g=y[r];i(u&&('DEFINE_MANY_MERGED'===g||'DEFINE_MANY'===g),'ReactClass: Unexpected spec policy %s for key %s when mixing in component specs.',g,r),'DEFINE_MANY_MERGED'===g?o[r]=c(o[r],p):'DEFINE_MANY'===g&&(o[r]=m(o[r],p))}else o[r]=p,!1}}}function l(e,t){if(t)for(var n in t){var o=t[n];if(t.hasOwnProperty(n)){i(!(n in _),'ReactClass: You are attempting to define a reserved property, `%s`, that shouldn\'t be on the "statics" key. Define it as an instance property instead; it will still be accessible on the constructor.',n);i(!(n in e),'ReactClass: You are attempting to define `%s` on your component more than once. This conflict may be due to a mixin.',n),e[n]=o}}}function u(e,t){for(var n in i(e&&t&&'object'==typeof e&&'object'==typeof t,'mergeIntoWithNoDuplicateKeys(): Cannot merge non-objects.'),t)t.hasOwnProperty(n)&&(i(void 0===e[n],'mergeIntoWithNoDuplicateKeys(): Tried to merge two objects with the same key: `%s`. This conflict may be due to a mixin; in particular, this may be caused by two getInitialState() or getDefaultProps() methods returning objects with clashing keys.',n),e[n]=t[n]);return e}function c(e,t){return function(){var n=e.apply(this,arguments),o=t.apply(this,arguments);if(null==n)return o;if(null==o)return n;var a={};return u(a,n),u(a,o),a}}function m(e,t){return function(){e.apply(this,arguments),t.apply(this,arguments)}}function h(e,t){var n=t.bind(e);return n}function g(e){for(var t=e.__reactAutoBindPairs,n=0;n<t.length;n+=2){var o=t[n],a=t[n+1];e[o]=h(e,a)}}var f=[],y={mixins:'DEFINE_MANY',statics:'DEFINE_MANY',propTypes:'DEFINE_MANY',contextTypes:'DEFINE_MANY',childContextTypes:'DEFINE_MANY',getDefaultProps:'DEFINE_MANY_MERGED',getInitialState:'DEFINE_MANY_MERGED',getChildContext:'DEFINE_MANY_MERGED',render:'DEFINE_ONCE',componentWillMount:'DEFINE_MANY',componentDidMount:'DEFINE_MANY',componentWillReceiveProps:'DEFINE_MANY',shouldComponentUpdate:'DEFINE_ONCE',componentWillUpdate:'DEFINE_MANY',componentDidUpdate:'DEFINE_MANY',componentWillUnmount:'DEFINE_MANY',updateComponent:'OVERRIDE_BASE'},_={displayName:function(e,t){e.displayName=t},mixins:function(e,t){if(t)for(var n=0;n<t.length;n++)p(e,t[n])},childContextTypes:function(e,t){!1,e.childContextTypes=a({},e.childContextTypes,t)},contextTypes:function(e,t){!1,e.contextTypes=a({},e.contextTypes,t)},getDefaultProps:function(e,t){e.getDefaultProps=e.getDefaultProps?c(e.getDefaultProps,t):t},propTypes:function(e,t){!1,e.propTypes=a({},e.propTypes,t)},statics:function(e,t){l(e,t)},autobind:function(){}},C={componentDidMount:function(){this.__isMounted=!0}},b={componentWillUnmount:function(){this.__isMounted=!1}},E={replaceState:function(e,t){this.updater.enqueueReplaceState(this,e,t)},isMounted:function(){return!1,!!this.__isMounted}},v=function(){};return a(v.prototype,e.prototype,E),function(e){var t=o(function(e,o,a){!1,this.__reactAutoBindPairs.length&&g(this),this.props=e,this.context=o,this.refs=r,this.updater=a||n,this.state=null;var d=this.getInitialState?this.getInitialState():null;!1,i('object'==typeof d&&!Array.isArray(d),'%s.getInitialState(): must return an object or null',t.displayName||'ReactCompositeComponent'),this.state=d});for(var a in t.prototype=new v,t.prototype.constructor=t,t.prototype.__reactAutoBindPairs=[],f.forEach(p.bind(null,t)),p(t,C),p(t,e),p(t,b),t.getDefaultProps&&(t.defaultProps=t.getDefaultProps()),!1,i(t.prototype.render,'createClass(...): Class specification must implement a `render` method.'),!1,y)t.prototype[a]||(t.prototype[a]=null);return t}}},function(e,t,n){'use strict';var o=n(10),a=n(7),r=n(0);e.exports=function(e){return a.isValidElement(e)?void 0:o('143'),e}},function(e,t,n){'use strict';function o(){if(s)for(var e in p){var t=p[e],n=s.indexOf(e);if(-1<n?void 0:i('96',e),!l.plugins[n]){t.extractEvents?void 0:i('97',e),l.plugins[n]=t;var o=t.eventTypes;for(var r in o)a(o[r],t,r)?void 0:i('98',r,e)}}}function a(e,t,n){!l.eventNameDispatchConfigs.hasOwnProperty(n)?void 0:i('99',n),l.eventNameDispatchConfigs[n]=e;var o=e.phasedRegistrationNames;if(o){for(var a in o)if(o.hasOwnProperty(a)){var d=o[a];r(d,t,n)}return!0}return!!e.registrationName&&(r(e.registrationName,t,n),!0)}function r(e,t,n){!l.registrationNameModules[e]?void 0:i('100',e),l.registrationNameModules[e]=t,l.registrationNameDependencies[e]=t.eventTypes[n].dependencies}var i=n(2),d=n(0),s=null,p={},l={plugins:[],eventNameDispatchConfigs:{},registrationNameModules:{},registrationNameDependencies:{},possibleRegistrationNames:null,injectEventPluginOrder:function(e){!s?void 0:i('101'),s=Array.prototype.slice.call(e),o()},injectEventPluginsByName:function(e){var t=!1;for(var n in e)if(e.hasOwnProperty(n)){var a=e[n];p.hasOwnProperty(n)&&p[n]===a||(p[n]?i('102',n):void 0,p[n]=a,t=!0)}t&&o()},getPluginModuleForEvent:function(e){var t=e.dispatchConfig;if(t.registrationName)return l.registrationNameModules[t.registrationName]||null;if(void 0!==t.phasedRegistrationNames){var n=t.phasedRegistrationNames;for(var o in n)if(n.hasOwnProperty(o)){var a=l.registrationNameModules[n[o]];if(a)return a}}return null},_resetEventPlugins:function(){for(var e in s=null,p)p.hasOwnProperty(e)&&delete p[e];l.plugins.length=0;var t=l.eventNameDispatchConfigs;for(var n in t)t.hasOwnProperty(n)&&delete t[n];var o=l.registrationNameModules;for(var a in o)o.hasOwnProperty(a)&&delete o[a]}};e.exports=l},function(e,t,n){'use strict';function o(e,t,n,o){var a=e.type||'unknown-event';e.currentTarget=u.getNodeFromInstance(o),t?i.invokeGuardedCallbackWithCatch(a,n,e):i.invokeGuardedCallback(a,n,e),e.currentTarget=null}function a(e){var t=e._dispatchListeners,n=e._dispatchInstances;if(!1,Array.isArray(t)){for(var o=0;o<t.length&&!e.isPropagationStopped();o++)if(t[o](e,n[o]))return n[o];}else if(t&&t(e,n))return n;return null}var r=n(2),i=n(50),d=n(0),s=n(1),p,l;var u={isEndish:function(e){return'topMouseUp'===e||'topTouchEnd'===e||'topTouchCancel'===e},isMoveish:function(e){return'topMouseMove'===e||'topTouchMove'===e},isStartish:function(e){return'topMouseDown'===e||'topTouchStart'===e},executeDirectDispatch:function(e){var t=e._dispatchListeners,n=e._dispatchInstances;Array.isArray(t)?r('103'):void 0,e.currentTarget=t?u.getNodeFromInstance(n):null;var o=t?t(e):null;return e.currentTarget=null,e._dispatchListeners=null,e._dispatchInstances=null,o},executeDispatchesInOrder:function(e,t){var n=e._dispatchListeners,a=e._dispatchInstances;if(!1,Array.isArray(n))for(var r=0;r<n.length&&!e.isPropagationStopped();r++)o(e,t,n[r],a[r]);else n&&o(e,t,n,a);e._dispatchListeners=null,e._dispatchInstances=null},executeDispatchesInOrderStopAtTrue:function(e){var t=a(e);return e._dispatchInstances=null,e._dispatchListeners=null,t},hasDispatches:function(e){return!!e._dispatchListeners},getInstanceFromNode:function(e){return p.getInstanceFromNode(e)},getNodeFromInstance:function(e){return p.getNodeFromInstance(e)},isAncestor:function(e,t){return l.isAncestor(e,t)},getLowestCommonAncestor:function(e,t){return l.getLowestCommonAncestor(e,t)},getParentInstance:function(e){return l.getParentInstance(e)},traverseTwoPhase:function(e,t,n){return l.traverseTwoPhase(e,t,n)},traverseEnterLeave:function(e,t,n,o,a){return l.traverseEnterLeave(e,t,n,o,a)},injection:{injectComponentTree:function(e){p=e,!1},injectTreeTraversal:function(e){l=e,!1}}};e.exports=u},function(e){'use strict';function t(e,t,o){try{t(o)}catch(e){null==n&&(n=e)}}var n=null;e.exports={invokeGuardedCallback:t,invokeGuardedCallbackWithCatch:t,rethrowCaughtError:function(){if(n){var e=n;throw n=null,e}}}},function(e){'use strict';e.exports=function(e){var t=e.target||e.srcElement||window;return t.correspondingUseElement&&(t=t.correspondingUseElement),3===t.nodeType?t.parentNode:t}},function(e,t,n){'use strict';var o=n(6),a;/**
+ * Checks if an event is supported in the current execution environment.
+ *
+ * NOTE: This will not work correctly for non-generic events such as `change`,
+ * `reset`, `load`, `error`, and `select`.
+ *
+ * Borrows from Modernizr.
+ *
+ * @param {string} eventNameSuffix Event name, e.g. "click".
+ * @param {?boolean} capture Check if the capture phase is supported.
+ * @return {boolean} True if the event is supported.
+ * @internal
+ * @license Modernizr 3.0.0pre (Custom Build) | MIT
+ */o.canUseDOM&&(a=document.implementation&&document.implementation.hasFeature&&!0!==document.implementation.hasFeature('','')),e.exports=function(e,t){if(!o.canUseDOM||t&&!('addEventListener'in document))return!1;var n='on'+e,r=n in document;if(!r){var i=document.createElement('div');i.setAttribute(n,'return;'),r='function'==typeof i[n]}return!r&&a&&'wheel'===e&&(r=document.implementation.hasFeature('Events.wheel','3.0')),r}},function(e){'use strict';function t(e){var t=this,o=t.nativeEvent;if(o.getModifierState)return o.getModifierState(e);var a=n[e];return!!a&&!!o[a]}var n={Alt:'altKey',Control:'ctrlKey',Meta:'metaKey',Shift:'shiftKey'};e.exports=function(){return t}},function(e,t,n){'use strict';function o(e,t){return Array.isArray(t)&&(t=t[1]),t?t.nextSibling:e.firstChild}function a(e,t,n){p.insertTreeBefore(e,t,n)}function r(e,t,n){Array.isArray(t)?d(e,t[0],t[1],n):f(e,t,n)}function i(e,t){if(Array.isArray(t)){var n=t[1];t=t[0],s(e,t,n),e.removeChild(n)}e.removeChild(t)}function d(e,t,n,o){for(var a=t,r;r=a.nextSibling,f(e,a,o),a!==n;)a=r}function s(e,t,n){for(;;){var o=t.nextSibling;if(o===n)break;else e.removeChild(o)}}var p=n(18),l=n(118),u=n(4),c=n(9),m=n(56),h=n(31),g=n(75),f=m(function(e,t,n){e.insertBefore(t,n)}),y=l.dangerouslyReplaceNodeWithMarkup;e.exports={dangerouslyReplaceNodeWithMarkup:y,replaceDelimitedText:function(e,t,n){var o=e.parentNode,a=e.nextSibling;a===t?n&&f(o,document.createTextNode(n),a):n?(g(a,n),s(o,a,t)):s(o,e,t),!1},processUpdates:function(e,t){for(var n=0,d;n<t.length;n++)switch(d=t[n],d.type){case'INSERT_MARKUP':a(e,d.content,o(e,d.afterNode)),!1;break;case'MOVE_EXISTING':r(e,d.fromNode,o(e,d.afterNode)),!1;break;case'SET_MARKUP':h(e,d.content),!1;break;case'TEXT_CONTENT':g(e,d.content),!1;break;case'REMOVE_NODE':i(e,d.fromNode),!1;}}}},function(e){'use strict';e.exports={html:'http://www.w3.org/1999/xhtml',mathml:'http://www.w3.org/1998/Math/MathML',svg:'http://www.w3.org/2000/svg'}},function(e){'use strict';e.exports=function(e){return'undefined'!=typeof MSApp&&MSApp.execUnsafeLocalFunction?function(t,n,o,a){MSApp.execUnsafeLocalFunction(function(){return e(t,n,o,a)})}:e}},function(e,t,n){'use strict';function o(e){null==e.checkedLink||null==e.valueLink?void 0:d('87')}function a(e){o(e),null==e.value&&null==e.onChange?void 0:d('88')}function r(e){o(e),null==e.checked&&null==e.onChange?void 0:d('89')}function i(e){if(e){var t=e.getName();if(t)return' Check the render method of `'+t+'`.'}return''}var d=n(2),s=n(136),p=n(28),l=n(13),u=p(l.isValidElement),c=n(0),m=n(1),h={button:!0,checkbox:!0,image:!0,hidden:!0,radio:!0,reset:!0,submit:!0},g={value:function(e,t){return!e[t]||h[e.type]||e.onChange||e.readOnly||e.disabled?null:new Error('You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`.')},checked:function(e,t){return!e[t]||e.onChange||e.readOnly||e.disabled?null:new Error('You provided a `checked` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultChecked`. Otherwise, set either `onChange` or `readOnly`.')},onChange:u.func},f={};e.exports={checkPropTypes:function(e,t,n){for(var o in g){if(g.hasOwnProperty(o))var a=g[o](t,o,e,'prop',null,s);if(a instanceof Error&&!(a.message in f)){f[a.message]=!0;var r=i(n);void 0}}},getValue:function(e){return e.valueLink?(a(e),e.valueLink.value):e.value},getChecked:function(e){return e.checkedLink?(r(e),e.checkedLink.value):e.checked},executeOnChange:function(e,t){return e.valueLink?(a(e),e.valueLink.requestChange(t.target.value)):e.checkedLink?(r(e),e.checkedLink.requestChange(t.target.checked)):e.onChange?e.onChange.call(void 0,t):void 0}}},function(e,t,n){'use strict';var o=n(2),a=n(0),r=!1,i={replaceNodeWithMarkup:null,processChildrenUpdates:null,injection:{injectEnvironment:function(e){!r?void 0:o('104'),i.replaceNodeWithMarkup=e.replaceNodeWithMarkup,i.processChildrenUpdates=e.processChildrenUpdates,r=!0}}};e.exports=i},function(e){'use strict';function t(e,t){return e===t?0!==e||0!==t||1/e==1/t:e!==e&&t!==t}var n=Object.prototype.hasOwnProperty;e.exports=function(e,o){if(t(e,o))return!0;if('object'!=typeof e||null===e||'object'!=typeof o||null===o)return!1;var a=Object.keys(e),r=Object.keys(o);if(a.length!==r.length)return!1;for(var d=0;d<a.length;d++)if(!n.call(o,a[d])||!t(e[a[d]],o[a[d]]))return!1;return!0}},function(e){'use strict';e.exports=function(e,t){var n=null===e||!1===e,o=null===t||!1===t;if(n||o)return n==o;var a=typeof e,r=typeof t;return'string'==a||'number'==a?'string'==r||'number'==r:'object'==r&&e.type===t.type&&e.key===t.key}},function(e){'use strict';e.exports={escape:function(e){var t=/[=:]/g,n={"=":'=0',":":'=2'},o=(''+e).replace(t,function(e){return n[e]});return'$'+o},unescape:function(e){var t=/(=0|=2)/g,n={"=0":'=',"=2":':'},o='.'===e[0]&&'$'===e[1]?e.substring(2):e.substring(1);return(''+o).replace(t,function(e){return n[e]})}}},function(e,t,n){'use strict';function o(e){l.enqueueUpdate(e)}function a(e){var t=typeof e;if('object'!=t)return t;var n=e.constructor&&e.constructor.name||t,o=Object.keys(e);return 0<o.length&&20>o.length?n+' (keys: '+o.join(', ')+')':n}function r(e){var t=s.get(e);if(!t){return null}return!1,t}var i=n(2),d=n(8),s=n(27),p=n(9),l=n(11),u=n(0),c=n(1),m={isMounted:function(e){var t=s.get(e);return!!t&&!!t._renderedComponent},enqueueCallback:function(e,t,n){m.validateCallback(t,n);var a=r(e);return a?void(a._pendingCallbacks?a._pendingCallbacks.push(t):a._pendingCallbacks=[t],o(a)):null},enqueueCallbackInternal:function(e,t){e._pendingCallbacks?e._pendingCallbacks.push(t):e._pendingCallbacks=[t],o(e)},enqueueForceUpdate:function(e){var t=r(e,'forceUpdate');t&&(t._pendingForceUpdate=!0,o(t))},enqueueReplaceState:function(e,t,n){var a=r(e,'replaceState');a&&(a._pendingStateQueue=[t],a._pendingReplaceState=!0,n!==void 0&&null!==n&&(m.validateCallback(n,'replaceState'),a._pendingCallbacks?a._pendingCallbacks.push(n):a._pendingCallbacks=[n]),o(a))},enqueueSetState:function(e,t){var n=r(e,'setState');if(n){var a=n._pendingStateQueue||(n._pendingStateQueue=[]);a.push(t),o(n)}},enqueueElementInternal:function(e,t,n){e._pendingElement=t,e._context=n,o(e)},validateCallback:function(e,t){!e||'function'==typeof e?void 0:i('122',t,a(e))}};e.exports=m},function(e,t,n){'use strict';var o=n(3),a=n(5),r=n(1);e.exports=a},function(e){'use strict';e.exports=function(e){var t=e.keyCode,n;return'charCode'in e?(n=e.charCode,0===n&&13===t&&(n=13)):n=t,32<=n||13===n?n:0}},,function(e){'use strict';e.exports={hasCachedChildNodes:1}},function(e,t,n){'use strict';var o=n(2),a=n(0);e.exports=function(e,t){return null==t?o('30'):void 0,null==e?t:Array.isArray(e)?Array.isArray(t)?(e.push.apply(e,t),e):(e.push(t),e):Array.isArray(t)?[e].concat(t):[e,t]}},function(e){'use strict';e.exports=function(e,t,n){Array.isArray(e)?e.forEach(t,n):e&&t.call(n,e)}},function(e,t,n){'use strict';var o=n(6),a=null;e.exports=function(){return!a&&o.canUseDOM&&(a='textContent'in document.documentElement?'textContent':'innerText'),a}},function(e,t,n){'use strict';function o(e,t){if(!(e instanceof t))throw new TypeError('Cannot call a class as a function')}var a=n(2),r=n(15),i=n(0),d=function(){function e(t){o(this,e),this._callbacks=null,this._contexts=null,this._arg=t}return e.prototype.enqueue=function(e,t){this._callbacks=this._callbacks||[],this._callbacks.push(e),this._contexts=this._contexts||[],this._contexts.push(t)},e.prototype.notifyAll=function(){var e=this._callbacks,t=this._contexts,n=this._arg;if(e&&t){e.length===t.length?void 0:a('24'),this._callbacks=null,this._contexts=null;for(var o=0;o<e.length;o++)e[o].call(t[o],n);e.length=0,t.length=0}},e.prototype.checkpoint=function(){return this._callbacks?this._callbacks.length:0},e.prototype.rollback=function(e){this._callbacks&&this._contexts&&(this._callbacks.length=e,this._contexts.length=e)},e.prototype.reset=function(){this._callbacks=null,this._contexts=null},e.prototype.destructor=function(){this.reset()},e}();e.exports=r.addPoolingTo(d)},function(e){'use strict';e.exports={logTopLevelRenders:!1}},function(e,t,n){'use strict';function o(e){var t=e.type,n=e.nodeName;return n&&'input'===n.toLowerCase()&&('checkbox'===t||'radio'===t)}function a(e){return e._wrapperState.valueTracker}function r(e,t){e._wrapperState.valueTracker=t}function i(e){delete e._wrapperState.valueTracker}function d(e){var t;return e&&(t=o(e)?''+e.checked:e.value),t}var s=n(4),p={_getTrackerFromNode:function(e){return a(s.getInstanceFromNode(e))},track:function(e){if(!a(e)){var t=s.getNodeFromInstance(e),n=o(t)?'checked':'value',d=Object.getOwnPropertyDescriptor(t.constructor.prototype,n),p=''+t[n];t.hasOwnProperty(n)||'function'!=typeof d.get||'function'!=typeof d.set||(Object.defineProperty(t,n,{enumerable:d.enumerable,configurable:!0,get:function(){return d.get.call(this)},set:function(e){p=''+e,d.set.call(this,e)}}),r(e,{getValue:function(){return p},setValue:function(e){p=''+e},stopTracking:function(){i(e),delete t[n]}}))}},updateValueIfChanged:function(e){if(!e)return!1;var t=a(e);if(!t)return p.track(e),!0;var n=t.getValue(),o=d(s.getNodeFromInstance(e));return o!==n&&(t.setValue(o),!0)},stopTracking:function(e){var t=a(e);t&&t.stopTracking()}};e.exports=p},function(e){'use strict';var t={color:!0,date:!0,datetime:!0,"datetime-local":!0,email:!0,month:!0,number:!0,password:!0,range:!0,search:!0,tel:!0,text:!0,time:!0,url:!0,week:!0};e.exports=function(e){var n=e&&e.nodeName&&e.nodeName.toLowerCase();return'input'===n?!!t[e.type]:!('textarea'!==n)}},function(e){'use strict';var t={currentScrollLeft:0,currentScrollTop:0,refreshScrollValues:function(e){t.currentScrollLeft=e.x,t.currentScrollTop=e.y}};e.exports=t},function(e,t,n){'use strict';var o=n(6),a=n(32),r=n(31),i=function(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&3===n.nodeType)return void(n.nodeValue=t)}e.textContent=t};o.canUseDOM&&!('textContent'in document.documentElement)&&(i=function(e,t){return 3===e.nodeType?void(e.nodeValue=t):void r(e,a(t))}),e.exports=i},function(e){'use strict';e.exports=function(e){try{e.focus()}catch(t){}}},function(e){'use strict';function t(e,t){return e+t.charAt(0).toUpperCase()+t.substring(1)}var n={animationIterationCount:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},o=['Webkit','ms','Moz','O'];Object.keys(n).forEach(function(e){o.forEach(function(o){n[t(o,e)]=n[e]})});e.exports={isUnitlessNumber:n,shorthandPropertyExpansions:{background:{backgroundAttachment:!0,backgroundColor:!0,backgroundImage:!0,backgroundPositionX:!0,backgroundPositionY:!0,backgroundRepeat:!0},backgroundPosition:{backgroundPositionX:!0,backgroundPositionY:!0},border:{borderWidth:!0,borderStyle:!0,borderColor:!0},borderBottom:{borderBottomWidth:!0,borderBottomStyle:!0,borderBottomColor:!0},borderLeft:{borderLeftWidth:!0,borderLeftStyle:!0,borderLeftColor:!0},borderRight:{borderRightWidth:!0,borderRightStyle:!0,borderRightColor:!0},borderTop:{borderTopWidth:!0,borderTopStyle:!0,borderTopColor:!0},font:{fontStyle:!0,fontVariant:!0,fontWeight:!0,fontSize:!0,lineHeight:!0,fontFamily:!0},outline:{outlineWidth:!0,outlineStyle:!0,outlineColor:!0}}}},function(e,t,n){'use strict';function o(e){return!!c.hasOwnProperty(e)||!u.hasOwnProperty(e)&&(l.test(e)?(c[e]=!0,!0):(u[e]=!0,void 0,!1))}function a(e,t){return null==t||e.hasBooleanValue&&!t||e.hasNumericValue&&isNaN(t)||e.hasPositiveNumericValue&&1>t||e.hasOverloadedBooleanValue&&!1===t}var r=n(16),i=n(4),d=n(9),s=n(132),p=n(1),l=new RegExp('^['+r.ATTRIBUTE_NAME_START_CHAR+']['+r.ATTRIBUTE_NAME_CHAR+']*$'),u={},c={},m={createMarkupForID:function(e){return r.ID_ATTRIBUTE_NAME+'='+s(e)},setAttributeForID:function(e,t){e.setAttribute(r.ID_ATTRIBUTE_NAME,t)},createMarkupForRoot:function(){return r.ROOT_ATTRIBUTE_NAME+'=""'},setAttributeForRoot:function(e){e.setAttribute(r.ROOT_ATTRIBUTE_NAME,'')},createMarkupForProperty:function(e,t){var n=r.properties.hasOwnProperty(e)?r.properties[e]:null;if(n){if(a(n,t))return'';var o=n.attributeName;return n.hasBooleanValue||n.hasOverloadedBooleanValue&&!0===t?o+'=""':o+'='+s(t)}return r.isCustomAttribute(e)?null==t?'':e+'='+s(t):null},createMarkupForCustomAttribute:function(e,t){return o(e)&&null!=t?e+'='+s(t):''},setValueForProperty:function(e,t,n){var o=r.properties.hasOwnProperty(t)?r.properties[t]:null;if(o){var i=o.mutationMethod;if(i)i(e,n);else{if(a(o,n))return void this.deleteValueForProperty(e,t);if(o.mustUseProperty)e[o.propertyName]=n;else{var d=o.attributeName,s=o.attributeNamespace;s?e.setAttributeNS(s,d,''+n):o.hasBooleanValue||o.hasOverloadedBooleanValue&&!0===n?e.setAttribute(d,''):e.setAttribute(d,''+n)}}}else if(r.isCustomAttribute(t))return void m.setValueForAttribute(e,t,n)},setValueForAttribute:function(e,t,n){if(o(t)){null==n?e.removeAttribute(t):e.setAttribute(t,''+n)}},deleteValueForAttribute:function(e,t){e.removeAttribute(t),!1},deleteValueForProperty:function(e,t){var n=r.properties.hasOwnProperty(t)?r.properties[t]:null;if(n){var o=n.mutationMethod;if(o)o(e,void 0);else if(n.mustUseProperty){var a=n.propertyName;e[a]=!n.hasBooleanValue&&''}else e.removeAttribute(n.attributeName)}else r.isCustomAttribute(t)&&e.removeAttribute(t)}};e.exports=m},function(e,t,n){'use strict';function o(){if(this._rootNodeID&&this._wrapperState.pendingUpdate){this._wrapperState.pendingUpdate=!1;var e=this._currentElement.props,t=d.getValue(e);null!=t&&a(this,!!e.multiple,t)}}function a(e,t,n){var o=s.getNodeFromInstance(e).options,a,r;if(t){for(a={},r=0;r<n.length;r++)a[''+n[r]]=!0;for(r=0;r<o.length;r++){var i=a.hasOwnProperty(o[r].value);o[r].selected!==i&&(o[r].selected=i)}}else{for(a=''+n,r=0;r<o.length;r++)if(o[r].value===a)return void(o[r].selected=!0);o.length&&(o[0].selected=!0)}}function r(e){var t=this._currentElement.props,n=d.executeOnChange(t,e);return this._rootNodeID&&(this._wrapperState.pendingUpdate=!0),p.asap(o,this),n}var i=n(3),d=n(57),s=n(4),p=n(11),l=n(1),u=!1,c=!1,m=['value','defaultValue'];e.exports={getHostProps:function(e,t){return i({},t,{onChange:e._wrapperState.onChange,value:void 0})},mountWrapper:function(e,t){var n=d.getValue(t);e._wrapperState={pendingUpdate:!1,initialValue:null==n?t.defaultValue:n,listeners:null,onChange:r.bind(e),wasMultiple:!!t.multiple},t.value===void 0||t.defaultValue===void 0||c||(void 0,c=!0)},getSelectValueContext:function(e){return e._wrapperState.initialValue},postUpdateWrapper:function(e){var t=e._currentElement.props;e._wrapperState.initialValue=void 0;var n=e._wrapperState.wasMultiple;e._wrapperState.wasMultiple=!!t.multiple;var o=d.getValue(t);null==o?n!==!!t.multiple&&(null==t.defaultValue?a(e,!!t.multiple,t.multiple?[]:''):a(e,!!t.multiple,t.defaultValue)):(e._wrapperState.pendingUpdate=!1,a(e,!!t.multiple,o))}}},function(e){function t(){throw new Error('setTimeout has not been defined')}function n(){throw new Error('clearTimeout has not been defined')}function o(e){if(l===setTimeout)return setTimeout(e,0);if((l===t||!l)&&setTimeout)return l=setTimeout,setTimeout(e,0);try{return l(e,0)}catch(t){try{return l.call(null,e,0)}catch(t){return l.call(this,e,0)}}}function a(e){if(u===clearTimeout)return clearTimeout(e);if((u===n||!u)&&clearTimeout)return u=clearTimeout,clearTimeout(e);try{return u(e)}catch(t){try{return u.call(null,e)}catch(t){return u.call(this,e)}}}function r(){m&&g&&(m=!1,g.length?c=g.concat(c):h=-1,c.length&&d())}function d(){if(!m){var e=o(r);m=!0;for(var t=c.length;t;){for(g=c,c=[];++h<t;)g&&g[h].run();h=-1,t=c.length}g=null,m=!1,a(e)}}function s(e,t){this.fun=e,this.array=t}function i(){}var p=e.exports={},l,u;(function(){try{l='function'==typeof setTimeout?setTimeout:t}catch(n){l=t}try{u='function'==typeof clearTimeout?clearTimeout:n}catch(t){u=n}})();var c=[],m=!1,h=-1,g;p.nextTick=function(e){var t=Array(arguments.length-1);if(1<arguments.length)for(var n=1;n<arguments.length;n++)t[n-1]=arguments[n];c.push(new s(e,t)),1!==c.length||m||o(d)},s.prototype.run=function(){this.fun.apply(null,this.array)},p.title='browser',p.browser=!0,p.env={},p.argv=[],p.version='',p.versions={},p.on=i,p.addListener=i,p.once=i,p.off=i,p.removeListener=i,p.removeAllListeners=i,p.emit=i,p.prependListener=i,p.prependOnceListener=i,p.listeners=function(){return[]},p.binding=function(){throw new Error('process.binding is not supported')},p.cwd=function(){return'/'},p.chdir=function(){throw new Error('process.chdir is not supported')},p.umask=function(){return 0}},function(e,t,n){'use strict';function o(e){if(e){var t=e.getName();if(t)return' Check the render method of `'+t+'`.'}return''}function a(e){return'function'==typeof e&&'undefined'!=typeof e.prototype&&'function'==typeof e.prototype.mountComponent&&'function'==typeof e.prototype.receiveComponent}function r(e){var t;if(null===e||!1===e)t=p.create(r);else if('object'==typeof e){var n=e,d=n.type;if('function'!=typeof d&&'string'!=typeof d){var s='';!1,s+=o(n._owner),i('130',null==d?d:typeof d,s)}'string'==typeof n.type?t=l.createInternalComponent(n):a(n.type)?(t=new n.type(n),!t.getHostNode&&(t.getHostNode=t.getNativeNode)):t=new h(n)}else'string'==typeof e||'number'==typeof e?t=l.createInstanceForText(e):i('131',typeof e);return!1,t._mountIndex=0,t._mountImage=null,!1,!1,t}var i=n(2),d=n(3),s=n(141),p=n(83),l=n(84),u=n(142),c=n(0),m=n(1),h=function(e){this.construct(e)};d(h.prototype,s,{_instantiateReactComponent:r}),e.exports=r},function(e,t,n){'use strict';var o=n(2),a=n(13),r=n(0),i={HOST:0,COMPOSITE:1,EMPTY:2,getType:function(e){if(null===e||!1===e)return i.EMPTY;return a.isValidElement(e)?'function'==typeof e.type?i.COMPOSITE:i.HOST:void o('26',e)}};e.exports=i},function(e){'use strict';var t={create:function(e){return n(e)}},n;t.injection={injectEmptyComponentFactory:function(e){n=e}},e.exports=t},function(e,t,n){'use strict';var o=n(2),a=n(0),r=null,i=null;e.exports={createInternalComponent:function(e){return r?void 0:o('111',e.type),new r(e)},createInstanceForText:function(e){return new i(e)},isTextComponent:function(e){return e instanceof i},injection:{injectGenericComponentClass:function(e){r=e},injectTextComponentClass:function(e){i=e}}}},function(e,t,n){'use strict';function o(e,t){return e&&'object'==typeof e&&null!=e.key?l.escape(e.key):t.toString(36)}function a(e,t,n,p){var u=typeof e;if(('undefined'==u||'boolean'==u)&&(e=null),null===e||'string'==u||'number'==u||'object'==u&&e.$$typeof===d)return n(p,e,''===t?c+o(e,0):t),1;var h=0,g=''===t?c:t+m,f,y;if(Array.isArray(e))for(var _=0;_<e.length;_++)f=e[_],y=g+o(f,_),h+=a(f,y,n,p);else{var i=s(e);if(i){var C=i.call(e),b;if(i!==e.entries)for(var E=0;!(b=C.next()).done;)f=b.value,y=g+o(f,E++),h+=a(f,y,n,p);else for(var v;!(b=C.next()).done;)v=b.value,v&&(f=v[1],y=g+l.escape(v[0])+m+o(f,0),h+=a(f,y,n,p))}else if('object'==u){var x='',N=e+'';r('31','[object Object]'===N?'object with keys {'+Object.keys(e).join(', ')+'}':N,x)}}return h}var r=n(2),i=n(8),d=n(143),s=n(144),p=n(0),l=n(61),u=n(1),c='.',m=':';e.exports=function(e,t,n){return null==e?0:a(e,'',t,n)}},function(e,t,n){'use strict';function o(e){var t=Function.prototype.toString,n=Object.prototype.hasOwnProperty,o=RegExp('^'+t.call(n).replace(/[\\^$.*+?()[\]{}|]/g,'\\$&').replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,'$1.*?')+'$');try{var a=t.call(e);return o.test(a)}catch(e){return!1}}function a(e){var t=h(e);if(t){var n=t.childIDs;g(e),n.forEach(a)}}function r(e,t,n){return'\n    in '+(e||'Unknown')+(t?' (at '+t.fileName.replace(/^.*[\\\/]/,'')+':'+t.lineNumber+')':n?' (created by '+n+')':'')}function i(e){return null==e?'#empty':'string'==typeof e||'number'==typeof e?'#text':'string'==typeof e.type?e.type:e.type.displayName||e.type.name||'Unknown'}function d(e){var t=P.getDisplayName(e),n=P.getElement(e),o=P.getOwnerID(e),a;return o&&(a=P.getDisplayName(o)),void 0,r(t,n&&n._source,a)}var s=n(10),p=n(8),l=n(0),u=n(1),c='function'==typeof Array.from&&'function'==typeof Map&&o(Map)&&null!=Map.prototype&&'function'==typeof Map.prototype.keys&&o(Map.prototype.keys)&&'function'==typeof Set&&o(Set)&&null!=Set.prototype&&'function'==typeof Set.prototype.keys&&o(Set.prototype.keys),m,h,g,f,y,_,C;if(c){var b=new Map,E=new Set;m=function(e,t){b.set(e,t)},h=function(e){return b.get(e)},g=function(e){b['delete'](e)},f=function(){return Array.from(b.keys())},y=function(e){E.add(e)},_=function(e){E['delete'](e)},C=function(){return Array.from(E.keys())}}else{var v={},x={},N=function(e){return'.'+e},T=function(e){return parseInt(e.substr(1),10)};m=function(e,t){var n=N(e);v[n]=t},h=function(e){var t=N(e);return v[t]},g=function(e){var t=N(e);delete v[t]},f=function(){return Object.keys(v).map(T)},y=function(e){var t=N(e);x[t]=!0},_=function(e){var t=N(e);delete x[t]},C=function(){return Object.keys(x).map(T)}}var k=[],P={onSetChildren:function(e,t){var n=h(e);n?void 0:s('144'),n.childIDs=t;for(var o=0;o<t.length;o++){var a=t[o],r=h(a);r?void 0:s('140'),null!=r.childIDs||'object'!=typeof r.element||null==r.element?void 0:s('141'),r.isMounted?void 0:s('71'),null==r.parentID&&(r.parentID=e),r.parentID===e?void 0:s('142',a,r.parentID,e)}},onBeforeMountComponent:function(e,t,n){m(e,{element:t,parentID:n,text:null,childIDs:[],isMounted:!1,updateCount:0})},onBeforeUpdateComponent:function(e,t){var n=h(e);n&&n.isMounted&&(n.element=t)},onMountComponent:function(e){var t=h(e);t?void 0:s('144'),t.isMounted=!0;var n=0===t.parentID;n&&y(e)},onUpdateComponent:function(e){var t=h(e);t&&t.isMounted&&t.updateCount++},onUnmountComponent:function(e){var t=h(e);if(t){t.isMounted=!1;var n=0===t.parentID;n&&_(e)}k.push(e)},purgeUnmountedComponents:function(){if(!P._preventPurging){for(var e=0,t;e<k.length;e++)t=k[e],a(t);k.length=0}},isMounted:function(e){var t=h(e);return!!t&&t.isMounted},getCurrentStackAddendum:function(e){var t='';if(e){var n=i(e),o=e._owner;t+=r(n,e._source,o&&o.getName())}var a=p.current,d=a&&a._debugID;return t+=P.getStackAddendumByID(d),t},getStackAddendumByID:function(e){for(var t='';e;)t+=d(e),e=P.getParentID(e);return t},getChildIDs:function(e){var t=h(e);return t?t.childIDs:[]},getDisplayName:function(e){var t=P.getElement(e);return t?i(t):null},getElement:function(e){var t=h(e);return t?t.element:null},getOwnerID:function(e){var t=P.getElement(e);return t&&t._owner?t._owner._debugID:null},getParentID:function(e){var t=h(e);return t?t.parentID:null},getSource:function(e){var t=h(e),n=t?t.element:null,o=null==n?null:n._source;return o},getText:function(e){var t=P.getElement(e);return'string'==typeof t?t:'number'==typeof t?''+t:null},getUpdateCount:function(e){var t=h(e);return t?t.updateCount:0},getRootIDs:C,getRegisteredIDs:f,pushNonStandardWarningStack:function(e,t){if('function'==typeof console.reactStack){var n=[],o=p.current,a=o&&o._debugID;try{for(e&&n.push({name:a?P.getDisplayName(a):null,fileName:t?t.fileName:null,lineNumber:t?t.lineNumber:null});a;){var r=P.getElement(a),i=P.getParentID(a),d=P.getOwnerID(a),s=d?P.getDisplayName(d):null,l=r&&r._source;n.push({name:s,fileName:l?l.fileName:null,lineNumber:l?l.lineNumber:null}),a=i}}catch(e){}console.reactStack(n)}},popNonStandardWarningStack:function(){'function'!=typeof console.reactStackEnd||console.reactStackEnd()}};e.exports=P},function(e,t,n){'use strict';var o=n(5);e.exports={listen:function(e,t,n){return e.addEventListener?(e.addEventListener(t,n,!1),{remove:function(){e.removeEventListener(t,n,!1)}}):e.attachEvent?(e.attachEvent('on'+t,n),{remove:function(){e.detachEvent('on'+t,n)}}):void 0},capture:function(e,t,n){return e.addEventListener?(e.addEventListener(t,n,!0),{remove:function(){e.removeEventListener(t,n,!0)}}):(!1,{remove:o})},registerDefault:function(){}}},function(e,t,n){'use strict';function o(e){return r(document.documentElement,e)}var a=n(156),r=n(158),i=n(76),d=n(89),s={hasSelectionCapabilities:function(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&('input'===t&&'text'===e.type||'textarea'===t||'true'===e.contentEditable)},getSelectionInformation:function(){var e=d();return{focusedElem:e,selectionRange:s.hasSelectionCapabilities(e)?s.getSelection(e):null}},restoreSelection:function(e){var t=d(),n=e.focusedElem,a=e.selectionRange;t!==n&&o(n)&&(s.hasSelectionCapabilities(n)&&s.setSelection(n,a),i(n))},getSelection:function(e){var t;if('selectionStart'in e)t={start:e.selectionStart,end:e.selectionEnd};else if(document.selection&&e.nodeName&&'input'===e.nodeName.toLowerCase()){var n=document.selection.createRange();n.parentElement()===e&&(t={start:-n.moveStart('character',-e.value.length),end:-n.moveEnd('character',-e.value.length)})}else t=a.getOffsets(e);return t||{start:0,end:0}},setSelection:function(e,t){var n=t.start,o=t.end;if(void 0===o&&(o=n),'selectionStart'in e)e.selectionStart=n,e.selectionEnd=Math.min(o,e.value.length);else if(document.selection&&e.nodeName&&'input'===e.nodeName.toLowerCase()){var r=e.createTextRange();r.collapse(!0),r.moveStart('character',n),r.moveEnd('character',o-n),r.select()}else a.setOffsets(e,t)}};e.exports=s},function(e){'use strict';e.exports=function(e){if(e=e||('undefined'==typeof document?void 0:document),'undefined'==typeof e)return null;try{return e.activeElement||e.body}catch(t){return e.body}}},function(e,t,n){'use strict';function o(e,t){for(var n=Math.min(e.length,t.length),o=0;o<n;o++)if(e.charAt(o)!==t.charAt(o))return o;return e.length===t.length?-1:n}function a(e){return e?e.nodeType===F?e.documentElement:e.firstChild:null}function r(e){return e.getAttribute&&e.getAttribute(O)||''}function i(e,t,n,o,a){var r;if(v.logTopLevelRenders){var i=e._currentElement.props.child,d=i.type;r='React mount: '+('string'==typeof d?d:d.displayName||d.name),console.time(r)}var s=k.mountComponent(e,n,null,b(e,t),a,0);r&&console.timeEnd(r),e._renderedComponent._topLevelWrapper=e,H._mountImageIntoNode(s,t,e,o,n)}function d(e,t,n,o){var a=I.ReactReconcileTransaction.getPooled(!n&&E.useCreateElement);a.perform(i,null,e,t,a,n,o),I.ReactReconcileTransaction.release(a)}function s(e,t,n){for(!1,k.unmountComponent(e,n),!1,t.nodeType===F&&(t=t.documentElement);t.lastChild;)t.removeChild(t.lastChild)}function p(e){var t=a(e);if(t){var n=C.getInstanceFromNode(t);return!!(n&&n._hostParent)}}function l(e){return!!(e&&(e.nodeType===U||e.nodeType===F||e.nodeType===V))}function u(e){var t=a(e),n=t&&C.getInstanceFromNode(t);return n&&!n._hostParent?n:null}function c(e){var t=u(e);return t?t._hostContainerInfo._topLevelWrapper:null}var m=n(2),h=n(18),g=n(16),f=n(13),y=n(33),_=n(8),C=n(4),b=n(173),E=n(174),v=n(71),x=n(27),N=n(9),T=n(175),k=n(17),P=n(62),I=n(11),M=n(14),S=n(81),w=n(0),R=n(31),D=n(60),A=n(1),O=g.ID_ATTRIBUTE_NAME,L=g.ROOT_ATTRIBUTE_NAME,U=1,F=9,V=11,j={},B=1,W=function(){this.rootID=B++};W.prototype.isReactComponent={},!1,W.prototype.render=function(){return this.props.child},W.isReactTopLevelWrapper=!0;var H={TopLevelWrapper:W,_instancesByReactRootID:j,scrollMonitor:function(e,t){t()},_updateRootComponent:function(e,t,n,o,a){return H.scrollMonitor(o,function(){P.enqueueElementInternal(e,t,n),a&&P.enqueueCallbackInternal(e,a)}),e},_renderNewRootComponent:function(e,t,n,o){void 0,l(t)?void 0:m('37'),y.ensureScrollValueMonitoring();var a=S(e,!1);I.batchedUpdates(d,a,t,n,o);var r=a._instance.rootID;return j[r]=a,a},renderSubtreeIntoContainer:function(e,t,n,o){return null!=e&&x.has(e)?void 0:m('38'),H._renderSubtreeIntoContainer(e,t,n,o)},_renderSubtreeIntoContainer:function(e,t,n,o){P.validateCallback(o,'ReactDOM.render'),f.isValidElement(t)?void 0:m('39','string'==typeof t?' Instead of passing a string like \'div\', pass React.createElement(\'div\') or <div />.':'function'==typeof t?' Instead of passing a class like Foo, pass React.createElement(Foo) or <Foo />.':null!=t&&void 0!==t.props?' This may be caused by unintentionally loading two independent copies of React.':''),void 0;var i=f.createElement(W,{child:t}),d;if(e){var s=x.get(e);d=s._processChildContext(s._context)}else d=M;var l=c(n);if(l){var u=l._currentElement,h=u.props.child;if(D(h,t)){var g=l._renderedComponent.getPublicInstance(),y=o&&function(){o.call(g)};return H._updateRootComponent(l,i,d,n,y),g}H.unmountComponentAtNode(n)}var _=a(n),C=_&&!!r(_),b=p(n),E=H._renderNewRootComponent(i,n,C&&!l&&!b,d)._renderedComponent.getPublicInstance();return o&&o.call(E),E},render:function(e,t,n){return H._renderSubtreeIntoContainer(null,e,t,n)},unmountComponentAtNode:function(e){void 0,l(e)?void 0:m('40'),!1;var t=c(e);if(!t){var n=p(e),o=1===e.nodeType&&e.hasAttribute(L);return!1,!1}return delete j[t._instance.rootID],I.batchedUpdates(s,t,e,!1),!0},_mountImageIntoNode:function(e,t,n,r,i){if(l(t)?void 0:m('41'),r){var d=a(t);if(T.canReuseMarkup(e,d))return void C.precacheNode(n,d);var s=d.getAttribute(T.CHECKSUM_ATTR_NAME);d.removeAttribute(T.CHECKSUM_ATTR_NAME);var p=d.outerHTML;d.setAttribute(T.CHECKSUM_ATTR_NAME,s);var u=e,c=o(u,p),g=' (client) '+u.substring(c-20,c+20)+'\n (server) '+p.substring(c-20,c+20);t.nodeType===F?m('42',g):void 0,!1}if(t.nodeType===F?m('43'):void 0,i.useCreateElement){for(;t.lastChild;)t.removeChild(t.lastChild);h.insertTreeBefore(t,e,null)}else R(t,e),C.precacheNode(n,t.firstChild)}};e.exports=H},function(e,t,n){'use strict';var o=n(82);e.exports=function(e){for(var t;(t=e._renderedNodeType)===o.COMPOSITE;)e=e._renderedComponent;if(t===o.HOST)return e._renderedComponent;return t===o.EMPTY?null:void 0}},,,,,,,,,,,,function(e,t,n){'use strict';e.exports=n(104)},function(e,t,n){'use strict';var o=n(4),a=n(105),r=n(90),i=n(17),d=n(11),s=n(177),p=n(178),l=n(91),u=n(179),c=n(1);a.inject();var m={findDOMNode:p,render:r.render,unmountComponentAtNode:r.unmountComponentAtNode,version:s,unstable_batchedUpdates:d.batchedUpdates,unstable_renderSubtreeIntoContainer:u};'undefined'!=typeof __REACT_DEVTOOLS_GLOBAL_HOOK__&&'function'==typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.inject&&__REACT_DEVTOOLS_GLOBAL_HOOK__.inject({ComponentTree:{getClosestInstanceFromNode:o.getClosestInstanceFromNode,getNodeFromInstance:function(e){return e._renderedComponent&&(e=l(e)),e?o.getNodeFromInstance(e):null}},Mount:r,Reconciler:i});e.exports=m},function(e,t,n){'use strict';var o=n(106),a=n(107),r=n(111),i=n(114),d=n(115),s=n(116),p=n(117),l=n(123),u=n(4),c=n(148),m=n(149),h=n(150),g=n(151),f=n(152),y=n(154),_=n(155),C=n(161),b=n(162),E=n(163),v=!1;e.exports={inject:function(){v||(v=!0,y.EventEmitter.injectReactEventListener(f),y.EventPluginHub.injectEventPluginOrder(i),y.EventPluginUtils.injectComponentTree(u),y.EventPluginUtils.injectTreeTraversal(m),y.EventPluginHub.injectEventPluginsByName({SimpleEventPlugin:E,EnterLeaveEventPlugin:d,ChangeEventPlugin:r,SelectEventPlugin:b,BeforeInputEventPlugin:a}),y.HostComponent.injectGenericComponentClass(l),y.HostComponent.injectTextComponentClass(h),y.DOMProperty.injectDOMPropertyConfig(o),y.DOMProperty.injectDOMPropertyConfig(s),y.DOMProperty.injectDOMPropertyConfig(C),y.EmptyComponent.injectEmptyComponentFactory(function(e){return new c(e)}),y.Updates.injectReconcileTransaction(_),y.Updates.injectBatchingStrategy(g),y.Component.injectEnvironment(p))}}},function(e){'use strict';e.exports={Properties:{"aria-current":0,"aria-details":0,"aria-disabled":0,"aria-hidden":0,"aria-invalid":0,"aria-keyshortcuts":0,"aria-label":0,"aria-roledescription":0,"aria-autocomplete":0,"aria-checked":0,"aria-expanded":0,"aria-haspopup":0,"aria-level":0,"aria-modal":0,"aria-multiline":0,"aria-multiselectable":0,"aria-orientation":0,"aria-placeholder":0,"aria-pressed":0,"aria-readonly":0,"aria-required":0,"aria-selected":0,"aria-sort":0,"aria-valuemax":0,"aria-valuemin":0,"aria-valuenow":0,"aria-valuetext":0,"aria-atomic":0,"aria-busy":0,"aria-live":0,"aria-relevant":0,"aria-dropeffect":0,"aria-grabbed":0,"aria-activedescendant":0,"aria-colcount":0,"aria-colindex":0,"aria-colspan":0,"aria-controls":0,"aria-describedby":0,"aria-errormessage":0,"aria-flowto":0,"aria-labelledby":0,"aria-owns":0,"aria-posinset":0,"aria-rowcount":0,"aria-rowindex":0,"aria-rowspan":0,"aria-setsize":0},DOMAttributeNames:{},DOMPropertyNames:{}}},function(e,t,n){'use strict';function o(e){return(e.ctrlKey||e.altKey||e.metaKey)&&!(e.ctrlKey&&e.altKey)}function a(e){return'topCompositionStart'===e?T.compositionStart:'topCompositionEnd'===e?T.compositionEnd:'topCompositionUpdate'===e?T.compositionUpdate:void 0}function r(e,t){return'topKeyDown'===e&&t.keyCode===_}function i(e,t){return'topKeyUp'===e?-1!==y.indexOf(t.keyCode):'topKeyDown'===e?t.keyCode!==_:'topKeyPress'==e||'topMouseDown'==e||'topBlur'==e}function d(e){var t=e.detail;return'object'==typeof t&&'data'in t?t.data:null}function s(e,t,n,o){var s,p;if(C?s=a(e):P?i(e,n)&&(s=T.compositionEnd):r(e,n)&&(s=T.compositionStart),!s)return null;v&&(P||s!==T.compositionStart?s===T.compositionEnd&&P&&(p=P.getData()):P=h.getPooled(o));var l=g.getPooled(s,t,n,o);if(p)l.data=p;else{var u=d(n);null!==u&&(l.data=u)}return c.accumulateTwoPhaseDispatches(l),l}function p(e,t){switch(e){case'topCompositionEnd':return d(t);case'topKeyPress':var n=t.which;return n===x?(k=!0,N):null;case'topTextInput':var o=t.data;return o===N&&k?null:o;default:return null;}}function l(e,t){if(P){if('topCompositionEnd'===e||!C&&i(e,t)){var n=P.getData();return h.release(P),P=null,n}return null}return'topPaste'===e?null:'topKeyPress'===e?t.which&&!o(t)?String.fromCharCode(t.which):null:'topCompositionEnd'===e?v?null:t.data:null}function u(e,t,n,o){var a;if(a=E?p(e,n):l(e,n),!a)return null;var r=f.getPooled(T.beforeInput,t,n,o);return r.data=a,c.accumulateTwoPhaseDispatches(r),r}var c=n(24),m=n(6),h=n(108),g=n(109),f=n(110),y=[9,13,27,32],_=229,C=m.canUseDOM&&'CompositionEvent'in window,b=null;m.canUseDOM&&'documentMode'in document&&(b=document.documentMode);var E=m.canUseDOM&&'TextEvent'in window&&!b&&!function(){var e=window.opera;return'object'==typeof e&&'function'==typeof e.version&&12>=parseInt(e.version(),10)}(),v=m.canUseDOM&&(!C||b&&8<b&&11>=b),x=32,N=' ',T={beforeInput:{phasedRegistrationNames:{bubbled:'onBeforeInput',captured:'onBeforeInputCapture'},dependencies:['topCompositionEnd','topKeyPress','topTextInput','topPaste']},compositionEnd:{phasedRegistrationNames:{bubbled:'onCompositionEnd',captured:'onCompositionEndCapture'},dependencies:['topBlur','topCompositionEnd','topKeyDown','topKeyPress','topKeyUp','topMouseDown']},compositionStart:{phasedRegistrationNames:{bubbled:'onCompositionStart',captured:'onCompositionStartCapture'},dependencies:['topBlur','topCompositionStart','topKeyDown','topKeyPress','topKeyUp','topMouseDown']},compositionUpdate:{phasedRegistrationNames:{bubbled:'onCompositionUpdate',captured:'onCompositionUpdateCapture'},dependencies:['topBlur','topCompositionUpdate','topKeyDown','topKeyPress','topKeyUp','topMouseDown']}},k=!1,P=null;e.exports={eventTypes:T,extractEvents:function(e,t,n,o){return[s(e,t,n,o),u(e,t,n,o)]}}},function(e,t,n){'use strict';function o(e){this._root=e,this._startText=this.getText(),this._fallbackText=null}var a=n(3),r=n(15),i=n(69);a(o.prototype,{destructor:function(){this._root=null,this._startText=null,this._fallbackText=null},getText:function(){return'value'in this._root?this._root.value:this._root[i()]},getData:function(){if(this._fallbackText)return this._fallbackText;var e=this._startText,t=e.length,n=this.getText(),o=n.length,a,r;for(a=0;a<t&&e[a]===n[a];a++);var i=t-a;for(r=1;r<=i&&e[t-r]===n[o-r];r++);var d=1<r?1-r:void 0;return this._fallbackText=n.slice(a,d),this._fallbackText}}),r.addPoolingTo(o),e.exports=o},function(e,t,n){'use strict';function o(e,t,n,o){return a.call(this,e,t,n,o)}var a=n(12);a.augmentClass(o,{data:null}),e.exports=o},function(e,t,n){'use strict';function o(e,t,n,o){return a.call(this,e,t,n,o)}var a=n(12);a.augmentClass(o,{data:null}),e.exports=o},function(e,t,n){'use strict';function o(e,t,n){var o=k.getPooled(w.change,e,t,n);return o.type='change',v.accumulateTwoPhaseDispatches(o),o}function a(e){var t=e.nodeName&&e.nodeName.toLowerCase();return'select'===t||'input'===t&&'file'===e.type}function r(e){var t=o(D,e,I(e));T.batchedUpdates(i,t)}function i(e){E.enqueueEvents(e),E.processEventQueue(!1)}function d(e,t){R=e,D=t,R.attachEvent('onchange',r)}function s(){R&&(R.detachEvent('onchange',r),R=null,D=null)}function p(e,t){var n=P.updateValueIfChanged(e),o=!0===t.simulated&&L._allowSimulatedPassThrough;if(n||o)return e}function l(e,t){if('topChange'===e)return t}function u(e,t,n){'topFocus'===e?(s(),d(t,n)):'topBlur'===e&&s()}function c(e,t){R=e,D=t,R.attachEvent('onpropertychange',h)}function m(){R&&(R.detachEvent('onpropertychange',h),R=null,D=null)}function h(e){'value'!==e.propertyName||p(D,e)&&r(e)}function g(e,t,n){'topFocus'===e?(m(),c(t,n)):'topBlur'===e&&m()}function f(e,t,n){if('topSelectionChange'===e||'topKeyUp'===e||'topKeyDown'===e)return p(D,n)}function y(e){var t=e.nodeName;return t&&'input'===t.toLowerCase()&&('checkbox'===e.type||'radio'===e.type)}function _(e,t,n){if('topClick'===e)return p(t,n)}function C(e,t,n){if('topInput'===e||'topChange'===e)return p(t,n)}function b(e,t){if(null!=e){var n=e._wrapperState||t._wrapperState;if(n&&n.controlled&&'number'===t.type){var o=''+t.value;t.getAttribute('value')!==o&&t.setAttribute('value',o)}}}var E=n(25),v=n(24),x=n(6),N=n(4),T=n(11),k=n(12),P=n(72),I=n(51),M=n(52),S=n(73),w={change:{phasedRegistrationNames:{bubbled:'onChange',captured:'onChangeCapture'},dependencies:['topBlur','topChange','topClick','topFocus','topInput','topKeyDown','topKeyUp','topSelectionChange']}},R=null,D=null,A=!1;x.canUseDOM&&(A=M('change')&&(!document.documentMode||8<document.documentMode));var O=!1;x.canUseDOM&&(O=M('input')&&(!('documentMode'in document)||9<document.documentMode));var L={eventTypes:w,_allowSimulatedPassThrough:!0,_isInputEventSupported:O,extractEvents:function(e,t,n,r){var i=t?N.getNodeFromInstance(t):window,d,s;if(a(i)?A?d=l:s=u:S(i)?O?d=C:(d=f,s=g):y(i)&&(d=_),d){var p=d(e,t,n);if(p){var c=o(p,n,r);return c}}s&&s(e,i,t),'topBlur'===e&&b(t,i)}};e.exports=L},function(e,t,n){'use strict';function o(e,t,n){'function'==typeof e?e(t.getPublicInstance()):r.addComponentAsRefTo(t,e,n)}function a(e,t,n){'function'==typeof e?e(null):r.removeComponentAsRefFrom(t,e,n)}var r=n(113),i={};i.attachRefs=function(e,t){if(null!==t&&'object'==typeof t){var n=t.ref;null!=n&&o(n,e,t._owner)}},i.shouldUpdateRefs=function(e,t){var n=null,o=null;null!==e&&'object'==typeof e&&(n=e.ref,o=e._owner);var a=null,r=null;return null!==t&&'object'==typeof t&&(a=t.ref,r=t._owner),n!==a||'string'==typeof a&&r!==o},i.detachRefs=function(e,t){if(null!==t&&'object'==typeof t){var n=t.ref;null!=n&&a(n,e,t._owner)}},e.exports=i},function(e,t,n){'use strict';function o(e){return!!(e&&'function'==typeof e.attachRef&&'function'==typeof e.detachRef)}var a=n(2),r=n(0);e.exports={addComponentAsRefTo:function(e,t,n){o(n)?void 0:a('119'),n.attachRef(t,e)},removeComponentAsRefFrom:function(e,t,n){o(n)?void 0:a('120');var r=n.getPublicInstance();r&&r.refs[t]===e.getPublicInstance()&&n.detachRef(t)}}},function(e){'use strict';e.exports=['ResponderEventPlugin','SimpleEventPlugin','TapEventPlugin','EnterLeaveEventPlugin','ChangeEventPlugin','SelectEventPlugin','BeforeInputEventPlugin']},function(e,t,n){'use strict';var o=n(24),a=n(4),r=n(30),i={mouseEnter:{registrationName:'onMouseEnter',dependencies:['topMouseOut','topMouseOver']},mouseLeave:{registrationName:'onMouseLeave',dependencies:['topMouseOut','topMouseOver']}};e.exports={eventTypes:i,extractEvents:function(e,t,n,d){if('topMouseOver'===e&&(n.relatedTarget||n.fromElement))return null;if('topMouseOut'!==e&&'topMouseOver'!==e)return null;var s;if(d.window===d)s=d;else{var p=d.ownerDocument;s=p?p.defaultView||p.parentWindow:window}var l,u;if('topMouseOut'===e){l=t;var c=n.relatedTarget||n.toElement;u=c?a.getClosestInstanceFromNode(c):null}else l=null,u=t;if(l===u)return null;var m=null==l?s:a.getNodeFromInstance(l),h=null==u?s:a.getNodeFromInstance(u),g=r.getPooled(i.mouseLeave,l,n,d);g.type='mouseleave',g.target=m,g.relatedTarget=h;var f=r.getPooled(i.mouseEnter,u,n,d);return f.type='mouseenter',f.target=h,f.relatedTarget=m,o.accumulateEnterLeaveDispatches(g,f,l,u),[g,f]}}},function(e,t,n){'use strict';var o=n(16),a=o.injection.MUST_USE_PROPERTY,r=o.injection.HAS_BOOLEAN_VALUE,i=o.injection.HAS_NUMERIC_VALUE,d=o.injection.HAS_POSITIVE_NUMERIC_VALUE,s=o.injection.HAS_OVERLOADED_BOOLEAN_VALUE,p={isCustomAttribute:RegExp.prototype.test.bind(new RegExp('^(data|aria)-['+o.ATTRIBUTE_NAME_CHAR+']*$')),Properties:{accept:0,acceptCharset:0,accessKey:0,action:0,allowFullScreen:r,allowTransparency:0,alt:0,as:0,async:r,autoComplete:0,autoPlay:r,capture:r,cellPadding:0,cellSpacing:0,charSet:0,challenge:0,checked:a|r,cite:0,classID:0,className:0,cols:d,colSpan:0,content:0,contentEditable:0,contextMenu:0,controls:r,coords:0,crossOrigin:0,data:0,dateTime:0,default:r,defer:r,dir:0,disabled:r,download:s,draggable:0,encType:0,form:0,formAction:0,formEncType:0,formMethod:0,formNoValidate:r,formTarget:0,frameBorder:0,headers:0,height:0,hidden:r,high:0,href:0,hrefLang:0,htmlFor:0,httpEquiv:0,icon:0,id:0,inputMode:0,integrity:0,is:0,keyParams:0,keyType:0,kind:0,label:0,lang:0,list:0,loop:r,low:0,manifest:0,marginHeight:0,marginWidth:0,max:0,maxLength:0,media:0,mediaGroup:0,method:0,min:0,minLength:0,multiple:a|r,muted:a|r,name:0,nonce:0,noValidate:r,open:r,optimum:0,pattern:0,placeholder:0,playsInline:r,poster:0,preload:0,profile:0,radioGroup:0,readOnly:r,referrerPolicy:0,rel:0,required:r,reversed:r,role:0,rows:d,rowSpan:i,sandbox:0,scope:0,scoped:r,scrolling:0,seamless:r,selected:a|r,shape:0,size:d,sizes:0,span:d,spellCheck:0,src:0,srcDoc:0,srcLang:0,srcSet:0,start:i,step:0,style:0,summary:0,tabIndex:0,target:0,title:0,type:0,useMap:0,value:0,width:0,wmode:0,wrap:0,about:0,datatype:0,inlist:0,prefix:0,property:0,resource:0,typeof:0,vocab:0,autoCapitalize:0,autoCorrect:0,autoSave:0,color:0,itemProp:0,itemScope:r,itemType:0,itemID:0,itemRef:0,results:0,security:0,unselectable:0},DOMAttributeNames:{acceptCharset:'accept-charset',className:'class',htmlFor:'for',httpEquiv:'http-equiv'},DOMPropertyNames:{},DOMMutationMethods:{value:function(e,t){return null==t?e.removeAttribute('value'):void('number'!==e.type||!1===e.hasAttribute('value')?e.setAttribute('value',''+t):e.validity&&!e.validity.badInput&&e.ownerDocument.activeElement!==e&&e.setAttribute('value',''+t))}}};e.exports=p},function(e,t,n){'use strict';var o=n(54),a=n(122),r={processChildrenUpdates:a.dangerouslyProcessChildrenUpdates,replaceNodeWithMarkup:o.dangerouslyReplaceNodeWithMarkup};e.exports=r},function(e,t,n){'use strict';var o=n(2),a=n(18),r=n(6),i=n(119),d=n(5),s=n(0);e.exports={dangerouslyReplaceNodeWithMarkup:function(e,t){if(r.canUseDOM?void 0:o('56'),t?void 0:o('57'),'HTML'===e.nodeName?o('58'):void 0,'string'==typeof t){var n=i(t,d)[0];e.parentNode.replaceChild(n,e)}else a.replaceChildWithTree(e,t)}}},function(e,t,n){'use strict';function o(e){var t=e.match(p);return t&&t[1].toLowerCase()}var a=n(6),r=n(120),i=n(121),d=n(0),s=a.canUseDOM?document.createElement('div'):null,p=/^\s*<(\w+)/;e.exports=function(e,t){var n=s;!!s?void 0:d(!1);var a=o(e),p=a&&i(a);if(p){n.innerHTML=p[1]+e+p[2];for(var l=p[0];l--;)n=n.lastChild}else n.innerHTML=e;var u=n.getElementsByTagName('script');u.length&&(t?void 0:d(!1),r(u).forEach(t));for(var c=Array.from(n.childNodes);n.lastChild;)n.removeChild(n.lastChild);return c}},function(e,t,n){'use strict';function o(e){var t=e.length;if(Array.isArray(e)||'object'!=typeof e&&'function'!=typeof e?r(!1):void 0,'number'==typeof t?void 0:r(!1),0===t||t-1 in e?void 0:r(!1),'function'==typeof e.callee?r(!1):void 0,e.hasOwnProperty)try{return Array.prototype.slice.call(e)}catch(t){}for(var n=Array(t),o=0;o<t;o++)n[o]=e[o];return n}function a(e){return!!e&&('object'==typeof e||'function'==typeof e)&&'length'in e&&!('setInterval'in e)&&'number'!=typeof e.nodeType&&(Array.isArray(e)||'callee'in e||'item'in e)}var r=n(0);e.exports=function(e){return a(e)?Array.isArray(e)?e.slice():o(e):[e]}},function(e,t,n){'use strict';var o=n(6),a=n(0),r=o.canUseDOM?document.createElement('div'):null,i={},d=[1,'<select multiple="true">','</select>'],s=[1,'<table>','</table>'],p=[3,'<table><tbody><tr>','</tr></tbody></table>'],l=[1,'<svg xmlns="http://www.w3.org/2000/svg">','</svg>'],u={"*":[1,'?<div>','</div>'],area:[1,'<map>','</map>'],col:[2,'<table><tbody></tbody><colgroup>','</colgroup></table>'],legend:[1,'<fieldset>','</fieldset>'],param:[1,'<object>','</object>'],tr:[2,'<table><tbody>','</tbody></table>'],optgroup:d,option:d,caption:s,colgroup:s,tbody:s,tfoot:s,thead:s,td:p,th:p};['circle','clipPath','defs','ellipse','g','image','line','linearGradient','mask','path','pattern','polygon','polyline','radialGradient','rect','stop','text','tspan'].forEach(function(e){u[e]=l,i[e]=!0}),e.exports=function(e){return r?void 0:a(!1),u.hasOwnProperty(e)||(e='*'),i.hasOwnProperty(e)||(r.innerHTML='*'===e?'<link />':'<'+e+'></'+e+'>',i[e]=!r.firstChild),i[e]?u[e]:null}},function(e,t,n){'use strict';var o=n(54),a=n(4);e.exports={dangerouslyProcessChildrenUpdates:function(e,t){var n=a.getNodeFromInstance(e);o.processUpdates(n,t)}}},function(e,t,n){'use strict';function o(e){if(e){var t=e._currentElement._owner||null;if(t){var n=t.getName();if(n)return' This DOM node was rendered by `'+n+'`.'}}return''}function a(e){if('object'==typeof e){if(Array.isArray(e))return'['+e.map(a).join(', ')+']';var t=[];for(var n in e)if(Object.prototype.hasOwnProperty.call(e,n)){var o=/^[a-z$_][\w$_]*$/i.test(n)?n:JSON.stringify(n);t.push(o+': '+a(e[n]))}return'{'+t.join(', ')+'}'}return'string'==typeof e?JSON.stringify(e):'function'==typeof e?'[function object]':e+''}function r(e,t){t&&(ae[e._tag]&&(null==t.children&&null==t.dangerouslySetInnerHTML?void 0:y('137',e._tag,e._currentElement._owner?' Check the render method of '+e._currentElement._owner.getName()+'.':'')),null!=t.dangerouslySetInnerHTML&&(null==t.children?void 0:y('60'),'object'==typeof t.dangerouslySetInnerHTML&&$ in t.dangerouslySetInnerHTML?void 0:y('61')),!1,null==t.style||'object'==typeof t.style?void 0:y('62',o(e)))}function i(e,t,n,o){if(!(o instanceof L)){var a=e._hostContainerInfo,r=a._node&&a._node.nodeType===J,i=r?a._node:a._ownerDocument;z(t,i),o.getReactMountReady().enqueue(d,{inst:e,registrationName:t,listener:n})}}function d(){var e=this;T.putListener(e.inst,e.registrationName,e.listener)}function s(){var e=this;S.postMountWrapper(e)}function p(){var e=this;D.postMountWrapper(e)}function l(){var e=this;w.postMountWrapper(e)}function u(){W.track(this)}function c(){var e=this;e._rootNodeID?void 0:y('63');var t=Y(e);switch(t?void 0:y('64'),e._tag){case'iframe':case'object':e._wrapperState.listeners=[P.trapBubbledEvent('topLoad','load',t)];break;case'video':case'audio':for(var n in e._wrapperState.listeners=[],te)te.hasOwnProperty(n)&&e._wrapperState.listeners.push(P.trapBubbledEvent(n,te[n],t));break;case'source':e._wrapperState.listeners=[P.trapBubbledEvent('topError','error',t)];break;case'img':e._wrapperState.listeners=[P.trapBubbledEvent('topError','error',t),P.trapBubbledEvent('topLoad','load',t)];break;case'form':e._wrapperState.listeners=[P.trapBubbledEvent('topReset','reset',t),P.trapBubbledEvent('topSubmit','submit',t)];break;case'input':case'select':case'textarea':e._wrapperState.listeners=[P.trapBubbledEvent('topInvalid','invalid',t)];}}function m(){R.postUpdateWrapper(this)}function h(e){de.call(ie,e)||(re.test(e)?void 0:y('65',e),ie[e]=!0)}function g(e,t){return 0<=e.indexOf('-')||null!=t.is}function f(e){var t=e.type;h(t),this._currentElement=e,this._tag=t.toLowerCase(),this._namespaceURI=null,this._renderedChildren=null,this._previousStyle=null,this._previousStyleCopy=null,this._hostNode=null,this._hostParent=null,this._rootNodeID=0,this._domID=0,this._hostContainerInfo=null,this._wrapperState=null,this._topLevelWrapper=null,this._flags=0,!1}var y=n(2),_=n(3),C=n(124),b=n(125),E=n(18),v=n(55),x=n(16),N=n(78),T=n(25),k=n(48),P=n(33),I=n(66),M=n(4),S=n(135),w=n(137),R=n(79),D=n(138),A=n(9),O=n(139),L=n(146),U=n(5),F=n(32),V=n(0),j=n(52),B=n(59),W=n(72),H=n(63),q=n(1),K=T.deleteListener,Y=M.getNodeFromInstance,z=P.listenTo,X=k.registrationNameModules,G={string:!0,number:!0},Q='style',$='__html',Z={children:null,dangerouslySetInnerHTML:null,suppressContentEditableWarning:null},J=11,ee={};var te={topAbort:'abort',topCanPlay:'canplay',topCanPlayThrough:'canplaythrough',topDurationChange:'durationchange',topEmptied:'emptied',topEncrypted:'encrypted',topEnded:'ended',topError:'error',topLoadedData:'loadeddata',topLoadedMetadata:'loadedmetadata',topLoadStart:'loadstart',topPause:'pause',topPlay:'play',topPlaying:'playing',topProgress:'progress',topRateChange:'ratechange',topSeeked:'seeked',topSeeking:'seeking',topStalled:'stalled',topSuspend:'suspend',topTimeUpdate:'timeupdate',topVolumeChange:'volumechange',topWaiting:'waiting'},ne={area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0},oe={listing:!0,pre:!0,textarea:!0},ae=_({menuitem:!0},ne),re=/^[a-zA-Z][a-zA-Z:_\.\-\d]*$/,ie={},de={}.hasOwnProperty,se=1;f.displayName='ReactDOMComponent',f.Mixin={mountComponent:function(e,t,n,o){this._rootNodeID=se++,this._domID=n._idCounter++,this._hostParent=t,this._hostContainerInfo=n;var a=this._currentElement.props;switch(this._tag){case'audio':case'form':case'iframe':case'img':case'link':case'object':case'source':case'video':this._wrapperState={listeners:null},e.getReactMountReady().enqueue(c,this);break;case'input':S.mountWrapper(this,a,t),a=S.getHostProps(this,a),e.getReactMountReady().enqueue(u,this),e.getReactMountReady().enqueue(c,this);break;case'option':w.mountWrapper(this,a,t),a=w.getHostProps(this,a);break;case'select':R.mountWrapper(this,a,t),a=R.getHostProps(this,a),e.getReactMountReady().enqueue(c,this);break;case'textarea':D.mountWrapper(this,a,t),a=D.getHostProps(this,a),e.getReactMountReady().enqueue(u,this),e.getReactMountReady().enqueue(c,this);}r(this,a);var i,d;null==t?n._tag&&(i=n._namespaceURI,d=n._tag):(i=t._namespaceURI,d=t._tag),(null==i||i===v.svg&&'foreignobject'===d)&&(i=v.html),i===v.html&&('svg'===this._tag?i=v.svg:'math'===this._tag&&(i=v.mathml)),this._namespaceURI=i;var m;if(e.useCreateElement){var h=n._ownerDocument,g;if(!(i===v.html))g=h.createElementNS(i,this._currentElement.type);else if('script'===this._tag){var f=h.createElement('div'),y=this._currentElement.type;f.innerHTML='<'+y+'></'+y+'>',g=f.removeChild(f.firstChild)}else g=a.is?h.createElement(this._currentElement.type,a.is):h.createElement(this._currentElement.type);M.precacheNode(this,g),this._flags|=I.hasCachedChildNodes,this._hostParent||N.setAttributeForRoot(g),this._updateDOMProperties(null,a,e);var _=E(g);this._createInitialChildren(e,a,o,_),m=_}else{var b=this._createOpenTagMarkupAndPutListeners(e,a),x=this._createContentMarkup(e,a,o);m=!x&&ne[this._tag]?b+'/>':b+'>'+x+'</'+this._currentElement.type+'>'}switch(this._tag){case'input':e.getReactMountReady().enqueue(s,this),a.autoFocus&&e.getReactMountReady().enqueue(C.focusDOMComponent,this);break;case'textarea':e.getReactMountReady().enqueue(p,this),a.autoFocus&&e.getReactMountReady().enqueue(C.focusDOMComponent,this);break;case'select':a.autoFocus&&e.getReactMountReady().enqueue(C.focusDOMComponent,this);break;case'button':a.autoFocus&&e.getReactMountReady().enqueue(C.focusDOMComponent,this);break;case'option':e.getReactMountReady().enqueue(l,this);}return m},_createOpenTagMarkupAndPutListeners:function(e,t){var n='<'+this._currentElement.type;for(var o in t)if(t.hasOwnProperty(o)){var a=t[o];if(null!=a)if(X.hasOwnProperty(o))a&&i(this,o,a,e);else{o==Q&&(a&&(!1,a=this._previousStyleCopy=_({},t.style)),a=b.createMarkupForStyles(a,this));var r=null;null!=this._tag&&g(this._tag,t)?!Z.hasOwnProperty(o)&&(r=N.createMarkupForCustomAttribute(o,a)):r=N.createMarkupForProperty(o,a),r&&(n+=' '+r)}}return e.renderToStaticMarkup?n:(this._hostParent||(n+=' '+N.createMarkupForRoot()),n+=' '+N.createMarkupForID(this._domID),n)},_createContentMarkup:function(e,t,n){var o='',a=t.dangerouslySetInnerHTML;if(null!=a)null!=a.__html&&(o=a.__html);else{var r=G[typeof t.children]?t.children:null,i=null==r?t.children:null;if(null!=r)o=F(r),!1;else if(null!=i){var d=this.mountChildren(i,e,n);o=d.join('')}}return oe[this._tag]&&'\n'===o.charAt(0)?'\n'+o:o},_createInitialChildren:function(e,t,n,o){var a=t.dangerouslySetInnerHTML;if(null!=a)null!=a.__html&&E.queueHTML(o,a.__html);else{var r=G[typeof t.children]?t.children:null,d=null==r?t.children:null;if(null!=r)''!==r&&(!1,E.queueText(o,r));else if(null!=d)for(var s=this.mountChildren(d,e,n),p=0;p<s.length;p++)E.queueChild(o,s[p])}},receiveComponent:function(e,t,n){var o=this._currentElement;this._currentElement=e,this.updateComponent(t,o,e,n)},updateComponent:function(e,t,n,o){var a=t.props,i=this._currentElement.props;switch(this._tag){case'input':a=S.getHostProps(this,a),i=S.getHostProps(this,i);break;case'option':a=w.getHostProps(this,a),i=w.getHostProps(this,i);break;case'select':a=R.getHostProps(this,a),i=R.getHostProps(this,i);break;case'textarea':a=D.getHostProps(this,a),i=D.getHostProps(this,i);}switch(r(this,i),this._updateDOMProperties(a,i,e),this._updateDOMChildren(a,i,e,o),this._tag){case'input':S.updateWrapper(this);break;case'textarea':D.updateWrapper(this);break;case'select':e.getReactMountReady().enqueue(m,this);}},_updateDOMProperties:function(e,t,n){var o,a,r;for(o in e)if(!t.hasOwnProperty(o)&&e.hasOwnProperty(o)&&null!=e[o])if(o===Q){var d=this._previousStyleCopy;for(a in d)d.hasOwnProperty(a)&&(r=r||{},r[a]='');this._previousStyleCopy=null}else X.hasOwnProperty(o)?e[o]&&K(this,o):g(this._tag,e)?Z.hasOwnProperty(o)||N.deleteValueForAttribute(Y(this),o):(x.properties[o]||x.isCustomAttribute(o))&&N.deleteValueForProperty(Y(this),o);for(o in t){var s=t[o],p=o===Q?this._previousStyleCopy:null==e?void 0:e[o];if(t.hasOwnProperty(o)&&s!==p&&(null!=s||null!=p))if(o===Q){if(s?(!1,s=this._previousStyleCopy=_({},s)):this._previousStyleCopy=null,p){for(a in p)!p.hasOwnProperty(a)||s&&s.hasOwnProperty(a)||(r=r||{},r[a]='');for(a in s)s.hasOwnProperty(a)&&p[a]!==s[a]&&(r=r||{},r[a]=s[a])}else r=s;}else if(X.hasOwnProperty(o))s?i(this,o,s,n):p&&K(this,o);else if(g(this._tag,t))Z.hasOwnProperty(o)||N.setValueForAttribute(Y(this),o,s);else if(x.properties[o]||x.isCustomAttribute(o)){var l=Y(this);null==s?N.deleteValueForProperty(l,o):N.setValueForProperty(l,o,s)}}r&&b.setValueForStyles(Y(this),r,this)},_updateDOMChildren:function(e,t,n,o){var a=G[typeof e.children]?e.children:null,r=G[typeof t.children]?t.children:null,i=e.dangerouslySetInnerHTML&&e.dangerouslySetInnerHTML.__html,d=t.dangerouslySetInnerHTML&&t.dangerouslySetInnerHTML.__html,s=null==a?e.children:null,p=null==r?t.children:null;null!=s&&null==p?this.updateChildren(null,n,o):(null!=a||null!=i)&&!(null!=r||null!=d)&&(this.updateTextContent(''),!1),null==r?null==d?null!=p&&(!1,this.updateChildren(p,n,o)):(i!==d&&this.updateMarkup(''+d),!1):a!==r&&(this.updateTextContent(''+r),!1)},getHostNode:function(){return Y(this)},unmountComponent:function(e){switch(this._tag){case'audio':case'form':case'iframe':case'img':case'link':case'object':case'source':case'video':var t=this._wrapperState.listeners;if(t)for(var n=0;n<t.length;n++)t[n].remove();break;case'input':case'textarea':W.stopTracking(this);break;case'html':case'head':case'body':y('66',this._tag);}this.unmountChildren(e),M.uncacheNode(this),T.deleteAllListeners(this),this._rootNodeID=0,this._domID=0,this._wrapperState=null,!1},getPublicInstance:function(){return Y(this)}},_(f.prototype,f.Mixin,O.Mixin),e.exports=f},function(e,t,n){'use strict';var o=n(4),a=n(76);e.exports={focusDOMComponent:function(){a(o.getNodeFromInstance(this))}}},function(e,t,n){'use strict';var o=n(77),a=n(6),r=n(9),i=n(126),d=n(128),s=n(129),p=n(131),l=n(1),u=p(function(e){return s(e)}),c=!1,m='cssFloat';if(a.canUseDOM){var h=document.createElement('div').style;try{h.font=''}catch(t){c=!0}document.documentElement.style.cssFloat===void 0&&(m='styleFloat')}e.exports={createMarkupForStyles:function(e,t){var n='';for(var o in e)if(e.hasOwnProperty(o)){var a=0===o.indexOf('--'),r=e[o];!1,null!=r&&(n+=u(o)+':',n+=d(o,r,t,a)+';')}return n||null},setValueForStyles:function(e,t,n){var a=e.style;for(var r in t)if(t.hasOwnProperty(r)){var i=0===r.indexOf('--');var s=d(r,t[r],n,i);if(('float'==r||'cssFloat'==r)&&(r=m),i)a.setProperty(r,s);else if(s)a[r]=s;else{var p=c&&o.shorthandPropertyExpansions[r];if(p)for(var l in p)a[l]='';else a[r]=''}}}}},function(e,t,n){'use strict';var o=n(127),a=/^-ms-/;e.exports=function(e){return o(e.replace(a,'ms-'))}},function(e){'use strict';var t=/-(.)/g;e.exports=function(e){return e.replace(t,function(e,t){return t.toUpperCase()})}},function(e,t,n){'use strict';var o=n(77),a=n(1),r=o.isUnitlessNumber;e.exports=function(e,t,n,o){var a=null==t||'boolean'==typeof t||''===t;if(a)return'';var i=isNaN(t);if(o||i||0===t||r.hasOwnProperty(e)&&r[e])return''+t;if('string'==typeof t){t=t.trim()}return t+'px'}},function(e,t,n){'use strict';var o=n(130),a=/^ms-/;e.exports=function(e){return o(e).replace(a,'-ms-')}},function(e){'use strict';var t=/([A-Z])/g;e.exports=function(e){return e.replace(t,'-$1').toLowerCase()}},function(e){'use strict';e.exports=function(e){var t={};return function(n){return t.hasOwnProperty(n)||(t[n]=e.call(this,n)),t[n]}}},function(e,t,n){'use strict';var o=n(32);e.exports=function(e){return'"'+o(e)+'"'}},function(e,t,n){'use strict';function o(e){a.enqueueEvents(e),a.processEventQueue(!1)}var a=n(25);e.exports={handleTopLevel:function(e,t,n,r){var i=a.extractEvents(e,t,n,r);o(i)}}},function(e,t,n){'use strict';function o(e,t){var n={};return n[e.toLowerCase()]=t.toLowerCase(),n['Webkit'+e]='webkit'+t,n['Moz'+e]='moz'+t,n['ms'+e]='MS'+t,n['O'+e]='o'+t.toLowerCase(),n}var a=n(6),r={animationend:o('Animation','AnimationEnd'),animationiteration:o('Animation','AnimationIteration'),animationstart:o('Animation','AnimationStart'),transitionend:o('Transition','TransitionEnd')},i={},d={};a.canUseDOM&&(d=document.createElement('div').style,!('AnimationEvent'in window)&&(delete r.animationend.animation,delete r.animationiteration.animation,delete r.animationstart.animation),!('TransitionEvent'in window)&&delete r.transitionend.transition),e.exports=function(e){if(i[e])return i[e];if(!r[e])return e;var t=r[e];for(var n in t)if(t.hasOwnProperty(n)&&n in d)return i[e]=t[n];return''}},function(e,t,n){'use strict';function o(){this._rootNodeID&&h.updateWrapper(this)}function a(e){var t='checkbox'===e.type||'radio'===e.type;return t?null!=e.checked:null!=e.value}function r(e){var t=this._currentElement.props,n=p.executeOnChange(t,e);u.asap(o,this);var a=t.name;if('radio'===t.type&&null!=a){for(var r=l.getNodeFromInstance(this),d=r;d.parentNode;)d=d.parentNode;for(var s=d.querySelectorAll('input[name='+JSON.stringify(''+a)+'][type="radio"]'),c=0,m;c<s.length;c++)if(m=s[c],m!==r&&m.form===r.form){var h=l.getInstanceFromNode(m);h?void 0:i('90'),u.asap(o,h)}}return n}var i=n(2),d=n(3),s=n(78),p=n(57),l=n(4),u=n(11),c=n(0),m=n(1),h={getHostProps:function(e,t){var n=p.getValue(t),o=p.getChecked(t),a=d({type:void 0,step:void 0,min:void 0,max:void 0},t,{defaultChecked:void 0,defaultValue:void 0,value:null==n?e._wrapperState.initialValue:n,checked:null==o?e._wrapperState.initialChecked:o,onChange:e._wrapperState.onChange});return a},mountWrapper:function(e,t){var n=t.defaultValue;e._wrapperState={initialChecked:null==t.checked?t.defaultChecked:t.checked,initialValue:null==t.value?n:t.value,listeners:null,onChange:r.bind(e),controlled:a(t)}},updateWrapper:function(e){var t=e._currentElement.props;var n=t.checked;null!=n&&s.setValueForProperty(l.getNodeFromInstance(e),'checked',n||!1);var o=l.getNodeFromInstance(e),a=p.getValue(t);if(!(null!=a))null==t.value&&null!=t.defaultValue&&o.defaultValue!==''+t.defaultValue&&(o.defaultValue=''+t.defaultValue),null==t.checked&&null!=t.defaultChecked&&(o.defaultChecked=!!t.defaultChecked);else if(0===a&&''===o.value)o.value='0';else if('number'===t.type){var r=parseFloat(o.value,10)||0;(a!=r||a==r&&o.value!=a)&&(o.value=''+a)}else o.value!==''+a&&(o.value=''+a)},postMountWrapper:function(e){var t=e._currentElement.props,n=l.getNodeFromInstance(e);switch(t.type){case'submit':case'reset':break;case'color':case'date':case'datetime':case'datetime-local':case'month':case'time':case'week':n.value='',n.value=n.defaultValue;break;default:n.value=n.value;}var o=n.name;''!==o&&(n.name=''),n.defaultChecked=!n.defaultChecked,n.defaultChecked=!n.defaultChecked,''!==o&&(n.name=o)}};e.exports=h},function(e){'use strict';e.exports='SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED'},function(e,t,n){'use strict';function o(e){var t='';return r.Children.forEach(e,function(e){null==e||('string'==typeof e||'number'==typeof e?t+=e:!p&&(p=!0,void 0))}),t}var a=n(3),r=n(13),i=n(4),d=n(79),s=n(1),p=!1;e.exports={mountWrapper:function(e,t,n){var a=null;if(null!=n){var r=n;'optgroup'===r._tag&&(r=r._hostParent),null!=r&&'select'===r._tag&&(a=d.getSelectValueContext(r))}var s=null;if(null!=a){var p;if(p=null==t.value?o(t.children):t.value+'',s=!1,Array.isArray(a)){for(var l=0;l<a.length;l++)if(''+a[l]===p){s=!0;break}}else s=''+a===p}e._wrapperState={selected:s}},postMountWrapper:function(e){var t=e._currentElement.props;if(null!=t.value){var n=i.getNodeFromInstance(e);n.setAttribute('value',t.value)}},getHostProps:function(e,t){var n=a({selected:void 0,children:void 0},t);null!=e._wrapperState.selected&&(n.selected=e._wrapperState.selected);var r=o(t.children);return r&&(n.children=r),n}}},function(e,t,n){'use strict';function o(){this._rootNodeID&&c.updateWrapper(this)}function a(e){var t=this._currentElement.props,n=d.executeOnChange(t,e);return p.asap(o,this),n}var r=n(2),i=n(3),d=n(57),s=n(4),p=n(11),l=n(0),u=n(1),c={getHostProps:function(e,t){null==t.dangerouslySetInnerHTML?void 0:r('91');var n=i({},t,{value:void 0,defaultValue:void 0,children:''+e._wrapperState.initialValue,onChange:e._wrapperState.onChange});return n},mountWrapper:function(e,t){var n=d.getValue(t),o=n;if(null==n){var i=t.defaultValue,s=t.children;null!=s&&(!1,null==i?void 0:r('92'),Array.isArray(s)&&(1>=s.length?void 0:r('93'),s=s[0]),i=''+s),null==i&&(i=''),o=i}e._wrapperState={initialValue:''+o,listeners:null,onChange:a.bind(e)}},updateWrapper:function(e){var t=e._currentElement.props,n=s.getNodeFromInstance(e),o=d.getValue(t);if(null!=o){var a=''+o;a!==n.value&&(n.value=a),null==t.defaultValue&&(n.defaultValue=a)}null!=t.defaultValue&&(n.defaultValue=t.defaultValue)},postMountWrapper:function(e){var t=s.getNodeFromInstance(e),n=t.textContent;n===e._wrapperState.initialValue&&(t.value=n)}};e.exports=c},function(e,t,n){'use strict';function o(e,t,n){return{type:'INSERT_MARKUP',content:e,fromIndex:null,fromNode:null,toIndex:n,afterNode:t}}function a(e,t,n){return{type:'MOVE_EXISTING',content:null,fromIndex:e._mountIndex,fromNode:g.getHostNode(e),toIndex:n,afterNode:t}}function r(e,t){return{type:'REMOVE_NODE',content:null,fromIndex:e._mountIndex,fromNode:t,toIndex:null,afterNode:null}}function i(e){return{type:'SET_MARKUP',content:e,fromIndex:null,fromNode:null,toIndex:null,afterNode:null}}function d(e){return{type:'TEXT_CONTENT',content:e,fromIndex:null,fromNode:null,toIndex:null,afterNode:null}}function s(e,t){return t&&(e=e||[],e.push(t)),e}function p(e,t){u.processChildrenUpdates(e,t)}var l=n(2),u=n(58),c=n(27),m=n(9),h=n(8),g=n(17),f=n(140),y=n(5),_=n(145),C=n(0);e.exports={Mixin:{_reconcilerInstantiateChildren:function(e,t,n){return f.instantiateChildren(e,t,n)},_reconcilerUpdateChildren:function(e,t,n,o,a,r){var i=0,d;return d=_(t,i),f.updateChildren(e,d,n,o,a,this,this._hostContainerInfo,r,i),d},mountChildren:function(e,t,n){var o=this._reconcilerInstantiateChildren(e,t,n);this._renderedChildren=o;var a=[],r=0;for(var i in o)if(o.hasOwnProperty(i)){var d=o[i];var s=g.mountComponent(d,t,this,this._hostContainerInfo,n,0);d._mountIndex=r++,a.push(s)}return!1,a},updateTextContent:function(e){var t=this._renderedChildren;for(var n in f.unmountChildren(t,!1),t)t.hasOwnProperty(n)&&l('118');var o=[d(e)];p(this,o)},updateMarkup:function(e){var t=this._renderedChildren;for(var n in f.unmountChildren(t,!1),t)t.hasOwnProperty(n)&&l('118');var o=[i(e)];p(this,o)},updateChildren:function(e,t,n){this._updateChildren(e,t,n)},_updateChildren:function(e,t,n){var o=Math.max,a=this._renderedChildren,r={},i=[],d=this._reconcilerUpdateChildren(a,e,i,r,t,n);if(d||a){var l=null,u=0,c=0,m=0,h=null,f;for(f in d)if(d.hasOwnProperty(f)){var y=a&&a[f],_=d[f];y===_?(l=s(l,this.moveChild(y,h,u,c)),c=o(y._mountIndex,c),y._mountIndex=u):(y&&(c=o(y._mountIndex,c)),l=s(l,this._mountChildAtIndex(_,i[m],h,u,t,n)),m++),u++,h=g.getHostNode(_)}for(f in r)r.hasOwnProperty(f)&&(l=s(l,this._unmountChild(a[f],r[f])));l&&p(this,l),this._renderedChildren=d,!1}},unmountChildren:function(e){var t=this._renderedChildren;f.unmountChildren(t,e),this._renderedChildren=null},moveChild:function(e,t,n,o){if(e._mountIndex<o)return a(e,t,n)},createChild:function(e,t,n){return o(n,t,e._mountIndex)},removeChild:function(e,t){return r(e,t)},_mountChildAtIndex:function(e,t,n,o){return e._mountIndex=o,this.createChild(e,n,t)},_unmountChild:function(e,t){var n=this.removeChild(e,t);return e._mountIndex=null,n}}}},function(e,t,n){'use strict';(function(t){function o(e,t,n){var o=e[n]===void 0;!1,null!=t&&o&&(e[n]=r(t,!0))}var a=n(17),r=n(81),i=n(61),d=n(60),s=n(85),p=n(1);e.exports={instantiateChildren:function(e,t,n,a){if(null==e)return null;var r={};return s(e,o,r),r},updateChildren:function(e,t,n,o,i,s,p,l,u){if(t||e){var c,m;for(c in t)if(t.hasOwnProperty(c)){m=e&&e[c];var h=m&&m._currentElement,g=t[c];if(null!=m&&d(h,g))a.receiveComponent(m,g,i,l),t[c]=m;else{m&&(o[c]=a.getHostNode(m),a.unmountComponent(m,!1));var f=r(g,!0);t[c]=f;var y=a.mountComponent(f,i,s,p,l,u);n.push(y)}}for(c in e)e.hasOwnProperty(c)&&!(t&&t.hasOwnProperty(c))&&(m=e[c],o[c]=a.getHostNode(m),a.unmountComponent(m,!1))}},unmountChildren:function(e,t){for(var n in e)if(e.hasOwnProperty(n)){var o=e[n];a.unmountComponent(o,t)}}}}).call(t,n(80))},function(e,t,n){'use strict';function o(){}function a(){}function r(e){return!!(e.prototype&&e.prototype.isReactComponent)}function i(e){return!!(e.prototype&&e.prototype.isPureReactComponent)}function d(e,t,n){if(0===t)return e();g.debugTool.onBeginLifeCycleTimer(t,n);try{return e()}finally{g.debugTool.onEndLifeCycleTimer(t,n)}}var s=n(2),p=n(3),l=n(13),u=n(58),c=n(8),m=n(50),h=n(27),g=n(9),f=n(82),y=n(17);var _=n(14),C=n(0),b=n(59),E=n(60),v=n(1),x={ImpureClass:0,PureClass:1,StatelessFunctional:2};o.prototype.render=function(){var e=h.get(this)._currentElement.type,t=e(this.props,this.context,this.updater);return a(e,t),t};var N=1;e.exports={construct:function(e){this._currentElement=e,this._rootNodeID=0,this._compositeType=null,this._instance=null,this._hostParent=null,this._hostContainerInfo=null,this._updateBatchNumber=null,this._pendingElement=null,this._pendingStateQueue=null,this._pendingReplaceState=!1,this._pendingForceUpdate=!1,this._renderedNodeType=null,this._renderedComponent=null,this._context=null,this._mountOrder=0,this._topLevelWrapper=null,this._pendingCallbacks=null,this._calledComponentWillUnmount=!1,!1},mountComponent:function(e,t,n,d){var p=this;this._context=d,this._mountOrder=N++,this._hostParent=t,this._hostContainerInfo=n;var u=this._currentElement.props,c=this._processContext(d),m=this._currentElement.type,g=e.getUpdateQueue(),f=r(m),y=this._constructComponent(f,u,c,g),C;f||null!=y&&null!=y.render?i(m)?this._compositeType=x.PureClass:this._compositeType=x.ImpureClass:(C=y,a(m,C),null===y||!1===y||l.isValidElement(y)?void 0:s('105',m.displayName||m.name||'Component'),y=new o(m),this._compositeType=x.StatelessFunctional);y.props=u,y.context=c,y.refs=_,y.updater=g,this._instance=y,h.set(y,this),!1;var b=y.state;void 0===b&&(y.state=b=null),'object'!=typeof b||Array.isArray(b)?s('106',this.getName()||'ReactCompositeComponent'):void 0,this._pendingStateQueue=null,this._pendingReplaceState=!1,this._pendingForceUpdate=!1;var E;return E=y.unstable_handleError?this.performInitialMountWithErrorHandling(C,t,n,e,d):this.performInitialMount(C,t,n,e,d),y.componentDidMount&&e.getReactMountReady().enqueue(y.componentDidMount,y),E},_constructComponent:function(e,t,n,o){return this._constructComponentWithoutOwner(e,t,n,o)},_constructComponentWithoutOwner:function(e,t,n,o){var a=this._currentElement.type;return e?new a(t,n,o):a(t,n,o)},performInitialMountWithErrorHandling:function(t,n,o,a,r){var i=a.checkpoint(),d;try{d=this.performInitialMount(t,n,o,a,r)}catch(s){a.rollback(i),this._instance.unstable_handleError(s),this._pendingStateQueue&&(this._instance.state=this._processPendingState(this._instance.props,this._instance.context)),i=a.checkpoint(),this._renderedComponent.unmountComponent(!0),a.rollback(i),d=this.performInitialMount(t,n,o,a,r)}return d},performInitialMount:function(e,t,n,o,a){var r=this._instance,i=0;!1,r.componentWillMount&&(r.componentWillMount(),this._pendingStateQueue&&(r.state=this._processPendingState(r.props,r.context))),e===void 0&&(e=this._renderValidatedComponent());var d=f.getType(e);this._renderedNodeType=d;var s=this._instantiateReactComponent(e,d!==f.EMPTY);this._renderedComponent=s;var p=y.mountComponent(s,o,t,n,this._processChildContext(a),i);return p},getHostNode:function(){return y.getHostNode(this._renderedComponent)},unmountComponent:function(e){if(this._renderedComponent){var t=this._instance;if(t.componentWillUnmount&&!t._calledComponentWillUnmount)if(t._calledComponentWillUnmount=!0,e){var n=this.getName()+'.componentWillUnmount()';m.invokeGuardedCallback(n,t.componentWillUnmount.bind(t))}else t.componentWillUnmount();this._renderedComponent&&(y.unmountComponent(this._renderedComponent,e),this._renderedNodeType=null,this._renderedComponent=null,this._instance=null),this._pendingStateQueue=null,this._pendingReplaceState=!1,this._pendingForceUpdate=!1,this._pendingCallbacks=null,this._pendingElement=null,this._context=null,this._rootNodeID=0,this._topLevelWrapper=null,h.remove(t)}},_maskContext:function(e){var t=this._currentElement.type,n=t.contextTypes;if(!n)return _;var o={};for(var a in n)o[a]=e[a];return o},_processContext:function(e){var t=this._maskContext(e);return t},_processChildContext:function(e){var t=this._currentElement.type,n=this._instance,o;if(n.getChildContext&&(o=n.getChildContext()),o){for(var a in'object'==typeof t.childContextTypes?void 0:s('107',this.getName()||'ReactCompositeComponent'),!1,o)a in t.childContextTypes?void 0:s('108',this.getName()||'ReactCompositeComponent',a);return p({},e,o)}return e},_checkContextTypes:function(){},receiveComponent:function(e,t,n){var o=this._currentElement,a=this._context;this._pendingElement=null,this.updateComponent(t,o,e,a,n)},performUpdateIfNecessary:function(e){null==this._pendingElement?null!==this._pendingStateQueue||this._pendingForceUpdate?this.updateComponent(e,this._currentElement,this._currentElement,this._context,this._context):this._updateBatchNumber=null:y.receiveComponent(this,this._pendingElement,e,this._context)},updateComponent:function(e,t,n,o,a){var r=this._instance;null!=r?void 0:s('136',this.getName()||'ReactCompositeComponent');var i=!1,d;this._context===a?d=r.context:(d=this._processContext(a),i=!0);var p=t.props,l=n.props;t!==n&&(i=!0),i&&r.componentWillReceiveProps&&r.componentWillReceiveProps(l,d);var u=this._processPendingState(l,d),c=!0;this._pendingForceUpdate||(r.shouldComponentUpdate?c=r.shouldComponentUpdate(l,u,d):this._compositeType===x.PureClass&&(c=!b(p,l)||!b(r.state,u))),!1,this._updateBatchNumber=null,c?(this._pendingForceUpdate=!1,this._performComponentUpdate(n,l,u,d,e,a)):(this._currentElement=n,this._context=a,r.props=l,r.state=u,r.context=d)},_processPendingState:function(e,t){var n=this._instance,o=this._pendingStateQueue,a=this._pendingReplaceState;if(this._pendingReplaceState=!1,this._pendingStateQueue=null,!o)return n.state;if(a&&1===o.length)return o[0];for(var r=p({},a?o[0]:n.state),d=a?1:0,i;d<o.length;d++)i=o[d],p(r,'function'==typeof i?i.call(n,r,e,t):i);return r},_performComponentUpdate:function(e,t,n,o,a,r){var i=this,d=this._instance,s=!!d.componentDidUpdate,p,l,u;s&&(p=d.props,l=d.state,u=d.context),d.componentWillUpdate&&d.componentWillUpdate(t,n,o),this._currentElement=e,this._context=r,d.props=t,d.state=n,d.context=o,this._updateRenderedComponent(a,r),s&&a.getReactMountReady().enqueue(d.componentDidUpdate.bind(d,p,l,u),d)},_updateRenderedComponent:function(e,t){var n=this._renderedComponent,o=n._currentElement,a=this._renderValidatedComponent();if(!1,E(o,a))y.receiveComponent(n,a,e,this._processChildContext(t));else{var r=y.getHostNode(n);y.unmountComponent(n,!1);var i=f.getType(a);this._renderedNodeType=i;var d=this._instantiateReactComponent(a,i!==f.EMPTY);this._renderedComponent=d;var s=y.mountComponent(d,e,this._hostParent,this._hostContainerInfo,this._processChildContext(t),0);this._replaceNodeWithMarkup(r,s,n)}},_replaceNodeWithMarkup:function(e,t,n){u.replaceNodeWithMarkup(e,t,n)},_renderValidatedComponentWithoutOwnerOrContext:function(){var e=this._instance,t;return t=e.render(),!1,t},_renderValidatedComponent:function(){var e;if(this._compositeType!==x.StatelessFunctional){c.current=this;try{e=this._renderValidatedComponentWithoutOwnerOrContext()}finally{c.current=null}}else e=this._renderValidatedComponentWithoutOwnerOrContext();return null===e||!1===e||l.isValidElement(e)?void 0:s('109',this.getName()||'ReactCompositeComponent'),e},attachRef:function(e,t){var n=this.getPublicInstance();null!=n?void 0:s('110');var o=t.getPublicInstance();var a=n.refs===_?n.refs={}:n.refs;a[e]=o},detachRef:function(e){var t=this.getPublicInstance().refs;delete t[e]},getName:function(){var e=this._currentElement.type,t=this._instance&&this._instance.constructor;return e.displayName||t&&t.displayName||e.name||t&&t.name||null},getPublicInstance:function(){var e=this._instance;return this._compositeType===x.StatelessFunctional?null:e},_instantiateReactComponent:null}},function(e){'use strict';var t=1;e.exports=function(){return t++}},function(e){'use strict';var t='function'==typeof Symbol&&Symbol['for']&&Symbol['for']('react.element')||60103;e.exports=t},function(e){'use strict';var t='function'==typeof Symbol&&Symbol.iterator;e.exports=function(e){var n=e&&(t&&e[t]||e['@@iterator']);if('function'==typeof n)return n}},function(e,t,n){'use strict';(function(t){function o(e,t,n){if(e&&'object'==typeof e){var o=e,a=o[n]===void 0;!1,a&&null!=t&&(o[n]=t)}}var a=n(61),r=n(85),i=n(1);'undefined'!=typeof t&&{NODE_ENV:'production'}&&!1,e.exports=function(e,t){if(null==e)return e;var n={};return r(e,o,n),n}}).call(t,n(80))},function(e,t,n){'use strict';function o(e){this.reinitializeTransaction(),this.renderToStaticMarkup=e,this.useCreateElement=!1,this.updateQueue=new s(this)}var a=n(3),r=n(15),i=n(29),d=n(9),s=n(147),p=[];var l={enqueue:function(){}};a(o.prototype,i,{getTransactionWrappers:function(){return p},getReactMountReady:function(){return l},getUpdateQueue:function(){return this.updateQueue},destructor:function(){},checkpoint:function(){},rollback:function(){}}),r.addPoolingTo(o),e.exports=o},function(e,t,n){'use strict';function o(e,t){if(!(e instanceof t))throw new TypeError('Cannot call a class as a function')}function a(){}var r=n(62),i=n(1),d=function(){function e(t){o(this,e),this.transaction=t}return e.prototype.isMounted=function(){return!1},e.prototype.enqueueCallback=function(e,t,n){this.transaction.isInTransaction()&&r.enqueueCallback(e,t,n)},e.prototype.enqueueForceUpdate=function(e){this.transaction.isInTransaction()?r.enqueueForceUpdate(e):a(e,'forceUpdate')},e.prototype.enqueueReplaceState=function(e,t){this.transaction.isInTransaction()?r.enqueueReplaceState(e,t):a(e,'replaceState')},e.prototype.enqueueSetState=function(e,t){this.transaction.isInTransaction()?r.enqueueSetState(e,t):a(e,'setState')},e}();e.exports=d},function(e,t,n){'use strict';var o=n(3),a=n(18),r=n(4),i=function(){this._currentElement=null,this._hostNode=null,this._hostParent=null,this._hostContainerInfo=null,this._domID=0};o(i.prototype,{mountComponent:function(e,t,n){var o=n._idCounter++;this._domID=o,this._hostParent=t,this._hostContainerInfo=n;var i=' react-empty: '+this._domID+' ';if(e.useCreateElement){var d=n._ownerDocument,s=d.createComment(i);return r.precacheNode(this,s),a(s)}return e.renderToStaticMarkup?'':'<!--'+i+'-->'},receiveComponent:function(){},getHostNode:function(){return r.getNodeFromInstance(this)},unmountComponent:function(){r.uncacheNode(this)}}),e.exports=i},function(e,t,n){'use strict';function o(e,t){'_hostNode'in e?void 0:a('33'),'_hostNode'in t?void 0:a('33');for(var n=0,o=e;o;o=o._hostParent)n++;for(var r=0,i=t;i;i=i._hostParent)r++;for(;0<n-r;)e=e._hostParent,n--;for(;0<r-n;)t=t._hostParent,r--;for(var d=n;d--;){if(e===t)return e;e=e._hostParent,t=t._hostParent}return null}var a=n(2),r=n(0);e.exports={isAncestor:function(e,t){for(('_hostNode'in e)?void 0:a('35'),('_hostNode'in t)?void 0:a('35');t;){if(t===e)return!0;t=t._hostParent}return!1},getLowestCommonAncestor:o,getParentInstance:function(e){return'_hostNode'in e?void 0:a('36'),e._hostParent},traverseTwoPhase:function(e,t,n){for(var o=[];e;)o.push(e),e=e._hostParent;var a;for(a=o.length;0<a--;)t(o[a],'captured',n);for(a=0;a<o.length;a++)t(o[a],'bubbled',n)},traverseEnterLeave:function(e,t,n,a,r){for(var d=e&&t?o(e,t):null,s=[];e&&e!==d;)s.push(e),e=e._hostParent;for(var p=[];t&&t!==d;)p.push(t),t=t._hostParent;var l;for(l=0;l<s.length;l++)n(s[l],'bubbled',a);for(l=p.length;0<l--;)n(p[l],'captured',r)}}},function(e,t,n){'use strict';var o=n(2),a=n(3),r=n(54),i=n(18),d=n(4),s=n(32),p=n(0),l=n(63),u=function(e){this._currentElement=e,this._stringText=''+e,this._hostNode=null,this._hostParent=null,this._domID=0,this._mountIndex=0,this._closingComment=null,this._commentNodes=null};a(u.prototype,{mountComponent:function(e,t,n){var o=n._idCounter++,a=' react-text: '+o+' ',r=' /react-text ';if(this._domID=o,this._hostParent=t,e.useCreateElement){var p=n._ownerDocument,l=p.createComment(a),u=p.createComment(r),c=i(p.createDocumentFragment());return i.queueChild(c,i(l)),this._stringText&&i.queueChild(c,i(p.createTextNode(this._stringText))),i.queueChild(c,i(u)),d.precacheNode(this,l),this._closingComment=u,c}var m=s(this._stringText);return e.renderToStaticMarkup?m:'<!--'+a+'-->'+m+'<!--'+r+'-->'},receiveComponent:function(e){if(e!==this._currentElement){this._currentElement=e;var t=''+e;if(t!==this._stringText){this._stringText=t;var n=this.getHostNode();r.replaceDelimitedText(n[0],n[1],t)}}},getHostNode:function(){var e=this._commentNodes;if(e)return e;if(!this._closingComment)for(var t=d.getNodeFromInstance(this),n=t.nextSibling;;){if(null==n?o('67',this._domID):void 0,8===n.nodeType&&' /react-text '===n.nodeValue){this._closingComment=n;break}n=n.nextSibling}return e=[this._hostNode,this._closingComment],this._commentNodes=e,e},unmountComponent:function(){this._closingComment=null,this._commentNodes=null,d.uncacheNode(this)}}),e.exports=u},function(e,t,n){'use strict';function o(){this.reinitializeTransaction()}var a=n(3),r=n(11),i=n(29),d=n(5),s={initialize:d,close:r.flushBatchedUpdates.bind(r)},p=[s,{initialize:d,close:function(){u.isBatchingUpdates=!1}}];a(o.prototype,i,{getTransactionWrappers:function(){return p}});var l=new o,u={isBatchingUpdates:!1,batchedUpdates:function(t,n,o,a,r,i){var e=u.isBatchingUpdates;return u.isBatchingUpdates=!0,e?t(n,o,a,r,i):l.perform(t,null,n,o,a,r,i)}};e.exports=u},function(e,t,n){'use strict';function o(e){for(;e._hostParent;)e=e._hostParent;var t=u.getNodeFromInstance(e),n=t.parentNode;return u.getClosestInstanceFromNode(n)}function a(e,t){this.topLevelType=e,this.nativeEvent=t,this.ancestors=[]}function r(e){var t=m(e.nativeEvent),n=u.getClosestInstanceFromNode(t),a=n;do e.ancestors.push(a),a=a&&o(a);while(a);for(var r=0;r<e.ancestors.length;r++)n=e.ancestors[r],g._handleTopLevel(e.topLevelType,n,e.nativeEvent,m(e.nativeEvent))}function i(e){var t=h(window);e(t)}var d=n(3),s=n(87),p=n(6),l=n(15),u=n(4),c=n(11),m=n(51),h=n(153);d(a.prototype,{destructor:function(){this.topLevelType=null,this.nativeEvent=null,this.ancestors.length=0}}),l.addPoolingTo(a,l.twoArgumentPooler);var g={_enabled:!0,_handleTopLevel:null,WINDOW_HANDLE:p.canUseDOM?window:null,setHandleTopLevel:function(e){g._handleTopLevel=e},setEnabled:function(e){g._enabled=!!e},isEnabled:function(){return g._enabled},trapBubbledEvent:function(e,t,n){return n?s.listen(n,t,g.dispatchEvent.bind(null,e)):null},trapCapturedEvent:function(e,t,n){return n?s.capture(n,t,g.dispatchEvent.bind(null,e)):null},monitorScrollValue:function(e){var t=i.bind(null,e);s.listen(window,'scroll',t)},dispatchEvent:function(e,t){if(g._enabled){var n=a.getPooled(e,t);try{c.batchedUpdates(r,n)}finally{a.release(n)}}}};e.exports=g},function(e){'use strict';e.exports=function(e){return e.Window&&e instanceof e.Window?{x:e.pageXOffset||e.document.documentElement.scrollLeft,y:e.pageYOffset||e.document.documentElement.scrollTop}:{x:e.scrollLeft,y:e.scrollTop}}},function(e,t,n){'use strict';var o=n(16),a=n(25),r=n(49),i=n(58),d=n(83),s=n(33),p=n(84),l=n(11),u={Component:i.injection,DOMProperty:o.injection,EmptyComponent:d.injection,EventPluginHub:a.injection,EventPluginUtils:r.injection,EventEmitter:s.injection,HostComponent:p.injection,Updates:l.injection};e.exports=u},function(e,t,n){'use strict';function o(e){this.reinitializeTransaction(),this.renderToStaticMarkup=!1,this.reactMountReady=r.getPooled(null),this.useCreateElement=e}var a=n(3),r=n(70),i=n(15),d=n(33),s=n(88),p=n(9),l=n(29),u=n(62),c={initialize:s.getSelectionInformation,close:s.restoreSelection},m=[c,{initialize:function(){var e=d.isEnabled();return d.setEnabled(!1),e},close:function(e){d.setEnabled(e)}},{initialize:function(){this.reactMountReady.reset()},close:function(){this.reactMountReady.notifyAll()}}];a(o.prototype,l,{getTransactionWrappers:function(){return m},getReactMountReady:function(){return this.reactMountReady},getUpdateQueue:function(){return u},checkpoint:function(){return this.reactMountReady.checkpoint()},rollback:function(e){this.reactMountReady.rollback(e)},destructor:function(){r.release(this.reactMountReady),this.reactMountReady=null}}),i.addPoolingTo(o),e.exports=o},function(e,t,n){'use strict';function o(e,t,n,o){return e===n&&t===o}var a=Math.min,r=n(6),i=n(157),d=n(69),s=r.canUseDOM&&'selection'in document&&!('getSelection'in window),p={getOffsets:s?function(e){var t=document.selection,n=t.createRange(),o=n.text.length,a=n.duplicate();a.moveToElementText(e),a.setEndPoint('EndToStart',n);var r=a.text.length;return{start:r,end:r+o}}:function(e){var t=window.getSelection&&window.getSelection();if(!t||0===t.rangeCount)return null;var n=t.anchorNode,a=t.anchorOffset,r=t.focusNode,i=t.focusOffset,d=t.getRangeAt(0);try{d.startContainer.nodeType,d.endContainer.nodeType}catch(t){return null}var s=o(t.anchorNode,t.anchorOffset,t.focusNode,t.focusOffset),p=s?0:d.toString().length,l=d.cloneRange();l.selectNodeContents(e),l.setEnd(d.startContainer,d.startOffset);var u=o(l.startContainer,l.startOffset,l.endContainer,l.endOffset),c=u?0:l.toString().length,m=c+p,h=document.createRange();h.setStart(n,a),h.setEnd(r,i);var g=h.collapsed;return{start:g?m:c,end:g?c:m}},setOffsets:s?function(e,t){var n=document.selection.createRange().duplicate(),o,a;void 0===t.end?(o=t.start,a=o):t.start>t.end?(o=t.end,a=t.start):(o=t.start,a=t.end),n.moveToElementText(e),n.moveStart('character',o),n.setEndPoint('EndToStart',n),n.moveEnd('character',a-o),n.select()}:function(e,t){if(window.getSelection){var n=window.getSelection(),o=e[d()].length,r=a(t.start,o),s=void 0===t.end?r:a(t.end,o);if(!n.extend&&r>s){var p=s;s=r,r=p}var l=i(e,r),u=i(e,s);if(l&&u){var c=document.createRange();c.setStart(l.node,l.offset),n.removeAllRanges(),r>s?(n.addRange(c),n.extend(u.node,u.offset)):(c.setEnd(u.node,u.offset),n.addRange(c))}}}};e.exports=p},function(e){'use strict';function t(e){for(;e&&e.firstChild;)e=e.firstChild;return e}function n(e){for(;e;){if(e.nextSibling)return e.nextSibling;e=e.parentNode}}e.exports=function(e,o){for(var a=t(e),r=0,i=0;a;){if(3===a.nodeType){if(i=r+a.textContent.length,r<=o&&i>=o)return{node:a,offset:o-r};r=i}a=t(n(a))}}},function(e,t,n){'use strict';function o(e,t){return e&&t&&(e===t||!a(e)&&(a(t)?o(e,t.parentNode):'contains'in e?e.contains(t):!!e.compareDocumentPosition&&!!(16&e.compareDocumentPosition(t))))}var a=n(159);e.exports=o},function(e,t,n){'use strict';var o=n(160);e.exports=function(e){return o(e)&&3==e.nodeType}},function(e){'use strict';e.exports=function(e){var t=e?e.ownerDocument||e:document,n=t.defaultView||window;return!!(e&&('function'==typeof n.Node?e instanceof n.Node:'object'==typeof e&&'number'==typeof e.nodeType&&'string'==typeof e.nodeName))}},function(e){'use strict';var t={xlink:'http://www.w3.org/1999/xlink',xml:'http://www.w3.org/XML/1998/namespace'},n={accentHeight:'accent-height',accumulate:0,additive:0,alignmentBaseline:'alignment-baseline',allowReorder:'allowReorder',alphabetic:0,amplitude:0,arabicForm:'arabic-form',ascent:0,attributeName:'attributeName',attributeType:'attributeType',autoReverse:'autoReverse',azimuth:0,baseFrequency:'baseFrequency',baseProfile:'baseProfile',baselineShift:'baseline-shift',bbox:0,begin:0,bias:0,by:0,calcMode:'calcMode',capHeight:'cap-height',clip:0,clipPath:'clip-path',clipRule:'clip-rule',clipPathUnits:'clipPathUnits',colorInterpolation:'color-interpolation',colorInterpolationFilters:'color-interpolation-filters',colorProfile:'color-profile',colorRendering:'color-rendering',contentScriptType:'contentScriptType',contentStyleType:'contentStyleType',cursor:0,cx:0,cy:0,d:0,decelerate:0,descent:0,diffuseConstant:'diffuseConstant',direction:0,display:0,divisor:0,dominantBaseline:'dominant-baseline',dur:0,dx:0,dy:0,edgeMode:'edgeMode',elevation:0,enableBackground:'enable-background',end:0,exponent:0,externalResourcesRequired:'externalResourcesRequired',fill:0,fillOpacity:'fill-opacity',fillRule:'fill-rule',filter:0,filterRes:'filterRes',filterUnits:'filterUnits',floodColor:'flood-color',floodOpacity:'flood-opacity',focusable:0,fontFamily:'font-family',fontSize:'font-size',fontSizeAdjust:'font-size-adjust',fontStretch:'font-stretch',fontStyle:'font-style',fontVariant:'font-variant',fontWeight:'font-weight',format:0,from:0,fx:0,fy:0,g1:0,g2:0,glyphName:'glyph-name',glyphOrientationHorizontal:'glyph-orientation-horizontal',glyphOrientationVertical:'glyph-orientation-vertical',glyphRef:'glyphRef',gradientTransform:'gradientTransform',gradientUnits:'gradientUnits',hanging:0,horizAdvX:'horiz-adv-x',horizOriginX:'horiz-origin-x',ideographic:0,imageRendering:'image-rendering',in:0,in2:0,intercept:0,k:0,k1:0,k2:0,k3:0,k4:0,kernelMatrix:'kernelMatrix',kernelUnitLength:'kernelUnitLength',kerning:0,keyPoints:'keyPoints',keySplines:'keySplines',keyTimes:'keyTimes',lengthAdjust:'lengthAdjust',letterSpacing:'letter-spacing',lightingColor:'lighting-color',limitingConeAngle:'limitingConeAngle',local:0,markerEnd:'marker-end',markerMid:'marker-mid',markerStart:'marker-start',markerHeight:'markerHeight',markerUnits:'markerUnits',markerWidth:'markerWidth',mask:0,maskContentUnits:'maskContentUnits',maskUnits:'maskUnits',mathematical:0,mode:0,numOctaves:'numOctaves',offset:0,opacity:0,operator:0,order:0,orient:0,orientation:0,origin:0,overflow:0,overlinePosition:'overline-position',overlineThickness:'overline-thickness',paintOrder:'paint-order',panose1:'panose-1',pathLength:'pathLength',patternContentUnits:'patternContentUnits',patternTransform:'patternTransform',patternUnits:'patternUnits',pointerEvents:'pointer-events',points:0,pointsAtX:'pointsAtX',pointsAtY:'pointsAtY',pointsAtZ:'pointsAtZ',preserveAlpha:'preserveAlpha',preserveAspectRatio:'preserveAspectRatio',primitiveUnits:'primitiveUnits',r:0,radius:0,refX:'refX',refY:'refY',renderingIntent:'rendering-intent',repeatCount:'repeatCount',repeatDur:'repeatDur',requiredExtensions:'requiredExtensions',requiredFeatures:'requiredFeatures',restart:0,result:0,rotate:0,rx:0,ry:0,scale:0,seed:0,shapeRendering:'shape-rendering',slope:0,spacing:0,specularConstant:'specularConstant',specularExponent:'specularExponent',speed:0,spreadMethod:'spreadMethod',startOffset:'startOffset',stdDeviation:'stdDeviation',stemh:0,stemv:0,stitchTiles:'stitchTiles',stopColor:'stop-color',stopOpacity:'stop-opacity',strikethroughPosition:'strikethrough-position',strikethroughThickness:'strikethrough-thickness',string:0,stroke:0,strokeDasharray:'stroke-dasharray',strokeDashoffset:'stroke-dashoffset',strokeLinecap:'stroke-linecap',strokeLinejoin:'stroke-linejoin',strokeMiterlimit:'stroke-miterlimit',strokeOpacity:'stroke-opacity',strokeWidth:'stroke-width',surfaceScale:'surfaceScale',systemLanguage:'systemLanguage',tableValues:'tableValues',targetX:'targetX',targetY:'targetY',textAnchor:'text-anchor',textDecoration:'text-decoration',textRendering:'text-rendering',textLength:'textLength',to:0,transform:0,u1:0,u2:0,underlinePosition:'underline-position',underlineThickness:'underline-thickness',unicode:0,unicodeBidi:'unicode-bidi',unicodeRange:'unicode-range',unitsPerEm:'units-per-em',vAlphabetic:'v-alphabetic',vHanging:'v-hanging',vIdeographic:'v-ideographic',vMathematical:'v-mathematical',values:0,vectorEffect:'vector-effect',version:0,vertAdvY:'vert-adv-y',vertOriginX:'vert-origin-x',vertOriginY:'vert-origin-y',viewBox:'viewBox',viewTarget:'viewTarget',visibility:0,widths:0,wordSpacing:'word-spacing',writingMode:'writing-mode',x:0,xHeight:'x-height',x1:0,x2:0,xChannelSelector:'xChannelSelector',xlinkActuate:'xlink:actuate',xlinkArcrole:'xlink:arcrole',xlinkHref:'xlink:href',xlinkRole:'xlink:role',xlinkShow:'xlink:show',xlinkTitle:'xlink:title',xlinkType:'xlink:type',xmlBase:'xml:base',xmlns:0,xmlnsXlink:'xmlns:xlink',xmlLang:'xml:lang',xmlSpace:'xml:space',y:0,y1:0,y2:0,yChannelSelector:'yChannelSelector',z:0,zoomAndPan:'zoomAndPan'},o={Properties:{},DOMAttributeNamespaces:{xlinkActuate:t.xlink,xlinkArcrole:t.xlink,xlinkHref:t.xlink,xlinkRole:t.xlink,xlinkShow:t.xlink,xlinkTitle:t.xlink,xlinkType:t.xlink,xmlBase:t.xml,xmlLang:t.xml,xmlSpace:t.xml},DOMAttributeNames:{}};Object.keys(n).forEach(function(e){o.Properties[e]=0,n[e]&&(o.DOMAttributeNames[e]=n[e])}),e.exports=o},function(e,t,n){'use strict';function o(e){if('selectionStart'in e&&s.hasSelectionCapabilities(e))return{start:e.selectionStart,end:e.selectionEnd};if(window.getSelection){var t=window.getSelection();return{anchorNode:t.anchorNode,anchorOffset:t.anchorOffset,focusNode:t.focusNode,focusOffset:t.focusOffset}}if(document.selection){var n=document.selection.createRange();return{parentElement:n.parentElement(),text:n.text,top:n.boundingTop,left:n.boundingLeft}}}function a(e,t){if(_||null==g||g!==l())return null;var n=o(g);if(!y||!c(y,n)){y=n;var a=p.getPooled(h.select,f,e,t);return a.type='select',a.target=g,r.accumulateTwoPhaseDispatches(a),a}return null}var r=n(24),i=n(6),d=n(4),s=n(88),p=n(12),l=n(89),u=n(73),c=n(59),m=i.canUseDOM&&'documentMode'in document&&11>=document.documentMode,h={select:{phasedRegistrationNames:{bubbled:'onSelect',captured:'onSelectCapture'},dependencies:['topBlur','topContextMenu','topFocus','topKeyDown','topKeyUp','topMouseDown','topMouseUp','topSelectionChange']}},g=null,f=null,y=null,_=!1,C=!1;e.exports={eventTypes:h,extractEvents:function(e,t,n,o){if(!C)return null;var r=t?d.getNodeFromInstance(t):window;switch(e){case'topFocus':(u(r)||'true'===r.contentEditable)&&(g=r,f=t,y=null);break;case'topBlur':g=null,f=null,y=null;break;case'topMouseDown':_=!0;break;case'topContextMenu':case'topMouseUp':return _=!1,a(n,o);case'topSelectionChange':if(m)break;case'topKeyDown':case'topKeyUp':return a(n,o);}return null},didPutListener:function(e,t){'onSelect'===t&&(C=!0)}}},function(e,t,n){'use strict';function o(e){return'.'+e._rootNodeID}function a(e){return'button'===e||'input'===e||'select'===e||'textarea'===e}var r=n(2),i=n(87),d=n(24),s=n(4),p=n(164),l=n(165),u=n(12),c=n(166),m=n(167),h=n(30),g=n(169),f=n(170),y=n(171),_=n(26),C=n(172),b=n(5),E=n(64),v=n(0),x={},N={};['abort','animationEnd','animationIteration','animationStart','blur','canPlay','canPlayThrough','click','contextMenu','copy','cut','doubleClick','drag','dragEnd','dragEnter','dragExit','dragLeave','dragOver','dragStart','drop','durationChange','emptied','encrypted','ended','error','focus','input','invalid','keyDown','keyPress','keyUp','load','loadedData','loadedMetadata','loadStart','mouseDown','mouseMove','mouseOut','mouseOver','mouseUp','paste','pause','play','playing','progress','rateChange','reset','scroll','seeked','seeking','stalled','submit','suspend','timeUpdate','touchCancel','touchEnd','touchMove','touchStart','transitionEnd','volumeChange','waiting','wheel'].forEach(function(e){var t=e[0].toUpperCase()+e.slice(1),n='on'+t,o='top'+t,a={phasedRegistrationNames:{bubbled:n,captured:n+'Capture'},dependencies:[o]};x[e]=a,N[o]=a});var T={};e.exports={eventTypes:x,extractEvents:function(e,t,n,o){var a=N[e];if(!a)return null;var i;switch(e){case'topAbort':case'topCanPlay':case'topCanPlayThrough':case'topDurationChange':case'topEmptied':case'topEncrypted':case'topEnded':case'topError':case'topInput':case'topInvalid':case'topLoad':case'topLoadedData':case'topLoadedMetadata':case'topLoadStart':case'topPause':case'topPlay':case'topPlaying':case'topProgress':case'topRateChange':case'topReset':case'topSeeked':case'topSeeking':case'topStalled':case'topSubmit':case'topSuspend':case'topTimeUpdate':case'topVolumeChange':case'topWaiting':i=u;break;case'topKeyPress':if(0===E(n))return null;case'topKeyDown':case'topKeyUp':i=m;break;case'topBlur':case'topFocus':i=c;break;case'topClick':if(2===n.button)return null;case'topDoubleClick':case'topMouseDown':case'topMouseMove':case'topMouseUp':case'topMouseOut':case'topMouseOver':case'topContextMenu':i=h;break;case'topDrag':case'topDragEnd':case'topDragEnter':case'topDragExit':case'topDragLeave':case'topDragOver':case'topDragStart':case'topDrop':i=g;break;case'topTouchCancel':case'topTouchEnd':case'topTouchMove':case'topTouchStart':i=f;break;case'topAnimationEnd':case'topAnimationIteration':case'topAnimationStart':i=p;break;case'topTransitionEnd':i=y;break;case'topScroll':i=_;break;case'topWheel':i=C;break;case'topCopy':case'topCut':case'topPaste':i=l;}i?void 0:r('86',e);var s=i.getPooled(a,t,n,o);return d.accumulateTwoPhaseDispatches(s),s},didPutListener:function(e,t){if('onClick'===t&&!a(e._tag)){var n=o(e),r=s.getNodeFromInstance(e);T[n]||(T[n]=i.listen(r,'click',b))}},willDeleteListener:function(e,t){if('onClick'===t&&!a(e._tag)){var n=o(e);T[n].remove(),delete T[n]}}}},function(e,t,n){'use strict';function o(e,t,n,o){return a.call(this,e,t,n,o)}var a=n(12);a.augmentClass(o,{animationName:null,elapsedTime:null,pseudoElement:null}),e.exports=o},function(e,t,n){'use strict';function o(e,t,n,o){return a.call(this,e,t,n,o)}var a=n(12);a.augmentClass(o,{clipboardData:function(e){return'clipboardData'in e?e.clipboardData:window.clipboardData}}),e.exports=o},function(e,t,n){'use strict';function o(e,t,n,o){return a.call(this,e,t,n,o)}var a=n(26);a.augmentClass(o,{relatedTarget:null}),e.exports=o},function(e,t,n){'use strict';function o(e,t,n,o){return a.call(this,e,t,n,o)}var a=n(26),r=n(64),i=n(168),d=n(53);a.augmentClass(o,{key:i,location:null,ctrlKey:null,shiftKey:null,altKey:null,metaKey:null,repeat:null,locale:null,getModifierState:d,charCode:function(e){return'keypress'===e.type?r(e):0},keyCode:function(e){return'keydown'===e.type||'keyup'===e.type?e.keyCode:0},which:function(e){return'keypress'===e.type?r(e):'keydown'===e.type||'keyup'===e.type?e.keyCode:0}}),e.exports=o},function(e,t,n){'use strict';var o=n(64),a={Esc:'Escape',Spacebar:' ',Left:'ArrowLeft',Up:'ArrowUp',Right:'ArrowRight',Down:'ArrowDown',Del:'Delete',Win:'OS',Menu:'ContextMenu',Apps:'ContextMenu',Scroll:'ScrollLock',MozPrintableKey:'Unidentified'},r={8:'Backspace',9:'Tab',12:'Clear',13:'Enter',16:'Shift',17:'Control',18:'Alt',19:'Pause',20:'CapsLock',27:'Escape',32:' ',33:'PageUp',34:'PageDown',35:'End',36:'Home',37:'ArrowLeft',38:'ArrowUp',39:'ArrowRight',40:'ArrowDown',45:'Insert',46:'Delete',112:'F1',113:'F2',114:'F3',115:'F4',116:'F5',117:'F6',118:'F7',119:'F8',120:'F9',121:'F10',122:'F11',123:'F12',144:'NumLock',145:'ScrollLock',224:'Meta'};e.exports=function(e){if(e.key){var t=a[e.key]||e.key;if('Unidentified'!==t)return t}if('keypress'===e.type){var n=o(e);return 13===n?'Enter':String.fromCharCode(n)}return'keydown'===e.type||'keyup'===e.type?r[e.keyCode]||'Unidentified':''}},function(e,t,n){'use strict';function o(e,t,n,o){return a.call(this,e,t,n,o)}var a=n(30);a.augmentClass(o,{dataTransfer:null}),e.exports=o},function(e,t,n){'use strict';function o(e,t,n,o){return a.call(this,e,t,n,o)}var a=n(26),r=n(53);a.augmentClass(o,{touches:null,targetTouches:null,changedTouches:null,altKey:null,metaKey:null,ctrlKey:null,shiftKey:null,getModifierState:r}),e.exports=o},function(e,t,n){'use strict';function o(e,t,n,o){return a.call(this,e,t,n,o)}var a=n(12);a.augmentClass(o,{propertyName:null,elapsedTime:null,pseudoElement:null}),e.exports=o},function(e,t,n){'use strict';function o(e,t,n,o){return a.call(this,e,t,n,o)}var a=n(30);a.augmentClass(o,{deltaX:function(e){return'deltaX'in e?e.deltaX:'wheelDeltaX'in e?-e.wheelDeltaX:0},deltaY:function(e){return'deltaY'in e?e.deltaY:'wheelDeltaY'in e?-e.wheelDeltaY:'wheelDelta'in e?-e.wheelDelta:0},deltaZ:null,deltaMode:null}),e.exports=o},function(e,t,n){'use strict';var o=n(63);e.exports=function(e,t){var n={_topLevelWrapper:e,_idCounter:1,_ownerDocument:t?t.nodeType===9?t:t.ownerDocument:null,_node:t,_tag:t?t.nodeName.toLowerCase():null,_namespaceURI:t?t.namespaceURI:null};return!1,n}},function(e){'use strict';e.exports={useCreateElement:!0,useFiber:!1}},function(e,t,n){'use strict';var o=n(176),a=/\/?>/,r=/^<\!\-\-/,i={CHECKSUM_ATTR_NAME:'data-react-checksum',addChecksumToMarkup:function(e){var t=o(e);return r.test(e)?e:e.replace(a,' '+i.CHECKSUM_ATTR_NAME+'="'+t+'"$&')},canReuseMarkup:function(e,t){var n=t.getAttribute(i.CHECKSUM_ATTR_NAME);n=n&&parseInt(n,10);var a=o(e);return a===n}};e.exports=i},function(e){'use strict';var t=65521;e.exports=function(e){for(var o=1,a=0,r=0,i=e.length,d=-4&i;r<d;){for(var s=Math.min(r+4096,d);r<s;r+=4)a+=(o+=e.charCodeAt(r))+(o+=e.charCodeAt(r+1))+(o+=e.charCodeAt(r+2))+(o+=e.charCodeAt(r+3));o%=t,a%=t}for(;r<i;r++)a+=o+=e.charCodeAt(r);return o%=t,a%=t,o|a<<16}},function(e){'use strict';e.exports='15.6.1'},function(e,t,n){'use strict';var o=n(2),a=n(8),r=n(4),i=n(27),d=n(91),s=n(0),p=n(1);e.exports=function(e){if(null==e)return null;if(1===e.nodeType)return e;var t=i.get(e);return t?(t=d(t),t?r.getNodeFromInstance(t):null):void('function'==typeof e.render?o('44'):o('45',Object.keys(e)))}},function(e,t,n){'use strict';var o=n(90);e.exports=o.renderSubtreeIntoContainer}]);this.EXPORTED_SYMBOLS = ["ReactDOM"];
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/vendor/classnames.js
@@ -0,0 +1,1 @@
+/* eslint-disable */this.classnames=function(a){function b(d){if(c[d])return c[d].exports;var e=c[d]={i:d,l:!1,exports:{}};return a[d].call(e.exports,e,e.exports,b),e.l=!0,e.exports}var c={};return b.m=a,b.c=c,b.d=function(a,c,d){b.o(a,c)||Object.defineProperty(a,c,{configurable:!1,enumerable:!0,get:d})},b.n=function(a){var c=a&&a.__esModule?function(){return a['default']}:function(){return a};return b.d(c,'a',c),c},b.o=function(a,b){return Object.prototype.hasOwnProperty.call(a,b)},b.p='',b(b.s=92)}({92:function(a,b){var c,d;(function(){'use strict';function e(){for(var a,b=[],c=0;c<arguments.length;c++)if(a=arguments[c],a){var d=typeof a;if('string'==d||'number'==d)b.push(a);else if(Array.isArray(a))b.push(e.apply(null,a));else if('object'==d)for(var g in a)f.call(a,g)&&a[g]&&b.push(g)}return b.join(' ')}var f={}.hasOwnProperty;'undefined'!=typeof a&&a.exports?a.exports=e:(c=[],d=function(){return e}.apply(b,c),!(d!==void 0&&(a.exports=d)))})()}});this.EXPORTED_SYMBOLS = ["classnames"];
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/vendor/mozjexl.js
@@ -0,0 +1,1 @@
+/* eslint-disable */this.mozjexl=function(a){function b(d){if(c[d])return c[d].exports;var e=c[d]={i:d,l:!1,exports:{}};return a[d].call(e.exports,e,e.exports,b),e.l=!0,e.exports}var c={};return b.m=a,b.c=c,b.d=function(a,c,d){b.o(a,c)||Object.defineProperty(a,c,{configurable:!1,enumerable:!0,get:d})},b.n=function(a){var c=a&&a.__esModule?function(){return a['default']}:function(){return a};return b.d(c,'a',c),c},b.o=function(a,b){return Object.prototype.hasOwnProperty.call(a,b)},b.p='',b(b.s=93)}({65:function(a,b){b.argVal=function(a){this._cursor.args.push(a)},b.arrayStart=function(){this._placeAtCursor({type:'ArrayLiteral',value:[]})},b.arrayVal=function(a){a&&this._cursor.value.push(a)},b.binaryOp=function(a){for(var b=this._grammar[a.value].precedence||0,c=this._cursor._parent;c&&c.operator&&this._grammar[c.operator].precedence>=b;)this._cursor=c,c=c._parent;var d={type:'BinaryExpression',operator:a.value,left:this._cursor};this._setParent(this._cursor,d),this._cursor=c,this._placeAtCursor(d)},b.dot=function(){this._nextIdentEncapsulate=this._cursor&&('BinaryExpression'!=this._cursor.type||'BinaryExpression'==this._cursor.type&&this._cursor.right)&&'UnaryExpression'!=this._cursor.type,this._nextIdentRelative=!this._cursor||this._cursor&&!this._nextIdentEncapsulate,this._nextIdentRelative&&(this._relative=!0)},b.filter=function(a){this._placeBeforeCursor({type:'FilterExpression',expr:a,relative:this._subParser.isRelative(),subject:this._cursor})},b.identifier=function(a){var b={type:'Identifier',value:a.value};this._nextIdentEncapsulate?(b.from=this._cursor,this._placeBeforeCursor(b),this._nextIdentEncapsulate=!1):(this._nextIdentRelative&&(b.relative=!0),this._placeAtCursor(b))},b.literal=function(a){this._placeAtCursor({type:'Literal',value:a.value})},b.objKey=function(a){this._curObjKey=a.value},b.objStart=function(){this._placeAtCursor({type:'ObjectLiteral',value:{}})},b.objVal=function(a){this._cursor.value[this._curObjKey]=a},b.subExpression=function(a){this._placeAtCursor(a)},b.ternaryEnd=function(a){this._cursor.alternate=a},b.ternaryMid=function(a){this._cursor.consequent=a},b.ternaryStart=function(){this._tree={type:'ConditionalExpression',test:this._tree},this._cursor=this._tree},b.transform=function(a){this._placeBeforeCursor({type:'Transform',name:a.value,args:[],subject:this._cursor})},b.unaryOp=function(a){this._placeAtCursor({type:'UnaryExpression',operator:a.value})}},93:function(a,b,c){function d(){this._customGrammar=null,this._lexer=null,this._transforms={}}var e=c(94),f=c(96),g=c(97),h=c(99).elements;d.prototype.addBinaryOp=function(a,b,c){this._addGrammarElement(a,{type:'binaryOp',precedence:b,eval:c})},d.prototype.addUnaryOp=function(a,b){this._addGrammarElement(a,{type:'unaryOp',weight:Infinity,eval:b})},d.prototype.addTransform=function(a,b){this._transforms[a]=b},d.prototype.addTransforms=function(a){for(var b in a)a.hasOwnProperty(b)&&(this._transforms[b]=a[b])},d.prototype.getTransform=function(a){return this._transforms[a]},d.prototype.eval=function(a,b,c){'function'==typeof b?(c=b,b={}):!b&&(b={});var d=this._eval(a,b);if(c){var e=!1;return d.then(function(a){e=!0,setTimeout(c.bind(null,null,a),0)}).catch(function(a){e||setTimeout(c.bind(null,a),0)})}return d},d.prototype.removeOp=function(a){var b=this._getCustomGrammar();b[a]&&('binaryOp'==b[a].type||'unaryOp'==b[a].type)&&(delete b[a],this._lexer=null)},d.prototype._addGrammarElement=function(a,b){var c=this._getCustomGrammar();c[a]=b,this._lexer=null},d.prototype._eval=function(a,b){var c=this,d=this._getGrammar(),f=new g(d),h=new e(d,this._transforms,b);return Promise.resolve().then(function(){return f.addTokens(c._getLexer().tokenize(a)),h.eval(f.complete())})},d.prototype._getCustomGrammar=function(){if(!this._customGrammar)for(var a in this._customGrammar={},h)h.hasOwnProperty(a)&&(this._customGrammar[a]=h[a]);return this._customGrammar},d.prototype._getGrammar=function(){return this._customGrammar||h},d.prototype._getLexer=function(){return this._lexer||(this._lexer=new f(this._getGrammar())),this._lexer},a.exports=new d,a.exports.Jexl=d},94:function(a,b,c){var d=c(95),e=function(a,b,c,d){this._grammar=a,this._transforms=b||{},this._context=c||{},this._relContext=d||this._context};e.prototype.eval=function(a){var b=this;return Promise.resolve().then(function(){return d[a.type].call(b,a)})},e.prototype.evalArray=function(a){return Promise.all(a.map(function(a){return this.eval(a)},this))},e.prototype.evalMap=function(a){var b=Object.keys(a),c={},d=b.map(function(b){return this.eval(a[b])},this);return Promise.all(d).then(function(a){return a.forEach(function(a,d){c[b[d]]=a}),c})},e.prototype._filterRelative=function(a,b){if(void 0!==a){var c=[];return Array.isArray(a)||(a=[a]),a.forEach(function(a){var d=new e(this._grammar,this._transforms,this._context,a);c.push(d.eval(b))},this),Promise.all(c).then(function(b){var c=[];return b.forEach(function(b,d){b&&c.push(a[d])}),c})}},e.prototype._filterStatic=function(a,b){return this.eval(b).then(function(b){return'boolean'==typeof b?b?a:void 0:void 0===a?void 0:a[b]})},a.exports=e},95:function(a,b){b.ArrayLiteral=function(a){return this.evalArray(a.value)},b.BinaryExpression=function(a){var b=this;return Promise.all([this.eval(a.left),this.eval(a.right)]).then(function(c){return b._grammar[a.operator].eval(c[0],c[1])})},b.ConditionalExpression=function(a){var b=this;return this.eval(a.test).then(function(c){return c?a.consequent?b.eval(a.consequent):c:b.eval(a.alternate)})},b.FilterExpression=function(a){var b=this;return this.eval(a.subject).then(function(c){return a.relative?b._filterRelative(c,a.expr):b._filterStatic(c,a.expr)})},b.Identifier=function(a){return a.from?this.eval(a.from).then(function(b){if(void 0!==b)return Array.isArray(b)&&(b=b[0]),b[a.value]}):a.relative?this._relContext[a.value]:this._context[a.value]},b.Literal=function(a){return a.value},b.ObjectLiteral=function(a){return this.evalMap(a.value)},b.Transform=function(a){var b=this._transforms[a.name];if(!b)throw new Error('Transform \''+a.name+'\' is not defined.');return Promise.all([this.eval(a.subject),this.evalArray(a.args||[])]).then(function(a){return b.apply(null,[a[0]].concat(a[1]))})},b.UnaryExpression=function(a){var b=this;return this.eval(a.right).then(function(c){return b._grammar[a.operator].eval(c)})}},96:function(a){function b(a){this._grammar=a}var c=/^-?(?:(?:[0-9]*\.[0-9]+)|[0-9]+)$/,d=/^[a-zA-Z_\$][a-zA-Z0-9_\$]*$/,e=/\\\\/,f=['\'(?:(?:\\\\\')?[^\'])*\'','"(?:(?:\\\\")?[^"])*"','\\s+','\\btrue\\b','\\bfalse\\b'],g=['\\b[a-zA-Z_\\$][a-zA-Z0-9_\\$]*\\b','(?:(?:[0-9]*\\.[0-9]+)|[0-9]+)'],h=['binaryOp','unaryOp','openParen','openBracket','question','colon'];b.prototype.getElements=function(a){var b=this._getSplitRegex();return a.split(b).filter(function(a){return a})},b.prototype.getTokens=function(a){for(var b=[],c=!1,d=0;d<a.length;d++)this._isWhitespace(a[d])?b.length&&(b[b.length-1].raw+=a[d]):'-'===a[d]&&this._isNegative(b)?c=!0:(c&&(a[d]='-'+a[d],c=!1),b.push(this._createToken(a[d])));return c&&b.push(this._createToken('-')),b},b.prototype.tokenize=function(a){var b=this.getElements(a);return this.getTokens(b)},b.prototype._createToken=function(a){var b={type:'literal',value:a,raw:a};if('"'==a[0]||'\''==a[0])b.value=this._unquote(a);else if(a.match(c))b.value=parseFloat(a);else if('true'===a||'false'===a)b.value='true'==a;else if(this._grammar[a])b.type=this._grammar[a].type;else if(a.match(d))b.type='identifier';else throw new Error('Invalid expression token: '+a);return b},b.prototype._escapeRegExp=function(a){return a=a.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'),a.match(d)&&(a='\\b'+a+'\\b'),a},b.prototype._getSplitRegex=function(){if(!this._splitRegex){var a=Object.keys(this._grammar);a=a.sort(function(c,a){return a.length-c.length}).map(function(a){return this._escapeRegExp(a)},this),this._splitRegex=new RegExp('('+[f.join('|'),a.join('|'),g.join('|')].join('|')+')')}return this._splitRegex},b.prototype._isNegative=function(a){return!a.length||h.some(function(b){return b===a[a.length-1].type})};var i=/^\s*$/;b.prototype._isWhitespace=function(a){return i.test(a)},b.prototype._unquote=function(a){var b=a[0],c=new RegExp('\\\\'+b,'g');return a.substr(1,a.length-2).replace(c,b).replace(e,'\\')},a.exports=b},97:function(a,b,c){function d(a,b,c){this._grammar=a,this._state='expectOperand',this._tree=null,this._exprStr=b||'',this._relative=!1,this._stopMap=c||{}}var e=c(65),f=c(98).states;d.prototype.addToken=function(a){if('complete'==this._state)throw new Error('Cannot add a new token to a completed Parser');var b=f[this._state],c=this._exprStr;if(this._exprStr+=a.raw,b.subHandler){this._subParser||this._startSubExpression(c);var d=this._subParser.addToken(a);if(d){if(this._endSubExpression(),this._parentStop)return d;this._state=d}}else if(b.tokenTypes[a.type]){var g=b.tokenTypes[a.type],h=e[a.type];g.handler&&(h=g.handler),h&&h.call(this,a),g.toState&&(this._state=g.toState)}else{if(this._stopMap[a.type])return this._stopMap[a.type];throw new Error('Token '+a.raw+' ('+a.type+') unexpected in expression: '+this._exprStr)}return!1},d.prototype.addTokens=function(a){a.forEach(this.addToken,this)},d.prototype.complete=function(){if(this._cursor&&!f[this._state].completable)throw new Error('Unexpected end of expression: '+this._exprStr);return this._subParser&&this._endSubExpression(),this._state='complete',this._cursor?this._tree:null},d.prototype.isRelative=function(){return this._relative},d.prototype._endSubExpression=function(){f[this._state].subHandler.call(this,this._subParser.complete()),this._subParser=null},d.prototype._placeAtCursor=function(a){this._cursor?(this._cursor.right=a,this._setParent(a,this._cursor)):this._tree=a,this._cursor=a},d.prototype._placeBeforeCursor=function(a){this._cursor=this._cursor._parent,this._placeAtCursor(a)},d.prototype._setParent=function(a,b){Object.defineProperty(a,'_parent',{value:b,writable:!0})},d.prototype._startSubExpression=function(a){var b=f[this._state].endStates;b||(this._parentStop=!0,b=this._stopMap),this._subParser=new d(this._grammar,a,b)},a.exports=d},98:function(a,b,c){var d=c(65);b.states={expectOperand:{tokenTypes:{literal:{toState:'expectBinOp'},identifier:{toState:'identifier'},unaryOp:{},openParen:{toState:'subExpression'},openCurl:{toState:'expectObjKey',handler:d.objStart},dot:{toState:'traverse'},openBracket:{toState:'arrayVal',handler:d.arrayStart}}},expectBinOp:{tokenTypes:{binaryOp:{toState:'expectOperand'},pipe:{toState:'expectTransform'},dot:{toState:'traverse'},question:{toState:'ternaryMid',handler:d.ternaryStart}},completable:!0},expectTransform:{tokenTypes:{identifier:{toState:'postTransform',handler:d.transform}}},expectObjKey:{tokenTypes:{identifier:{toState:'expectKeyValSep',handler:d.objKey},closeCurl:{toState:'expectBinOp'}}},expectKeyValSep:{tokenTypes:{colon:{toState:'objVal'}}},postTransform:{tokenTypes:{openParen:{toState:'argVal'},binaryOp:{toState:'expectOperand'},dot:{toState:'traverse'},openBracket:{toState:'filter'},pipe:{toState:'expectTransform'}},completable:!0},postTransformArgs:{tokenTypes:{binaryOp:{toState:'expectOperand'},dot:{toState:'traverse'},openBracket:{toState:'filter'},pipe:{toState:'expectTransform'}},completable:!0},identifier:{tokenTypes:{binaryOp:{toState:'expectOperand'},dot:{toState:'traverse'},openBracket:{toState:'filter'},pipe:{toState:'expectTransform'},question:{toState:'ternaryMid',handler:d.ternaryStart}},completable:!0},traverse:{tokenTypes:{identifier:{toState:'identifier'}}},filter:{subHandler:d.filter,endStates:{closeBracket:'identifier'}},subExpression:{subHandler:d.subExpression,endStates:{closeParen:'expectBinOp'}},argVal:{subHandler:d.argVal,endStates:{comma:'argVal',closeParen:'postTransformArgs'}},objVal:{subHandler:d.objVal,endStates:{comma:'expectObjKey',closeCurl:'expectBinOp'}},arrayVal:{subHandler:d.arrayVal,endStates:{comma:'arrayVal',closeBracket:'expectBinOp'}},ternaryMid:{subHandler:d.ternaryMid,endStates:{colon:'ternaryEnd'}},ternaryEnd:{subHandler:d.ternaryEnd,completable:!0}}},99:function(a,b){b.elements={".":{type:'dot'},"[":{type:'openBracket'},"]":{type:'closeBracket'},"|":{type:'pipe'},"{":{type:'openCurl'},"}":{type:'closeCurl'},":":{type:'colon'},",":{type:'comma'},"(":{type:'openParen'},")":{type:'closeParen'},"?":{type:'question'},"+":{type:'binaryOp',precedence:30,eval:function(a,b){return a+b}},"-":{type:'binaryOp',precedence:30,eval:function(a,b){return a-b}},"*":{type:'binaryOp',precedence:40,eval:function(a,b){return a*b}},"/":{type:'binaryOp',precedence:40,eval:function(a,b){return a/b}},"//":{type:'binaryOp',precedence:40,eval:function(a,b){return Math.floor(a/b)}},"%":{type:'binaryOp',precedence:50,eval:function(a,b){return a%b}},"^":{type:'binaryOp',precedence:50,eval:function(a,b){return Math.pow(a,b)}},"==":{type:'binaryOp',precedence:20,eval:function(a,b){return a==b}},"!=":{type:'binaryOp',precedence:20,eval:function(a,b){return a!=b}},">":{type:'binaryOp',precedence:20,eval:function(a,b){return a>b}},">=":{type:'binaryOp',precedence:20,eval:function(a,b){return a>=b}},"<":{type:'binaryOp',precedence:20,eval:function(a,b){return a<b}},"<=":{type:'binaryOp',precedence:20,eval:function(a,b){return a<=b}},"&&":{type:'binaryOp',precedence:10,eval:function(a,b){return a&&b}},"||":{type:'binaryOp',precedence:10,eval:function(a,b){return a||b}},in:{type:'binaryOp',precedence:20,eval:function(a,b){return'string'==typeof b?-1!==b.indexOf(a):!!Array.isArray(b)&&b.some(function(b){return b==a})}},"!":{type:'unaryOp',precedence:Infinity,eval:function(a){return!a}}}}});this.EXPORTED_SYMBOLS = ["mozjexl"];
\ No newline at end of file