Backed out changeset 87a67b857362 (bug 1388823) for various failures such as browser_firstPartyIsolation_aboutPages.js a=backout
authorWes Kocher <wkocher@mozilla.com>
Mon, 14 Aug 2017 16:59:07 -0700
changeset 646461 9084b3bb0e78efae2701e10b9187cdba14bbc565
parent 646460 d662a65a20349fd49e5f8db68fbddc45053f09ef
child 646462 f616ad97907a2e2c81aa5452ff940530704ae4ff
push id74138
push userbmo:rchien@mozilla.com
push dateTue, 15 Aug 2017 09:27:57 +0000
reviewersbackout
bugs1388823
milestone57.0a1
backs out87a67b8573623763cc882454fba1b9dc06317839
Backed out changeset 87a67b857362 (bug 1388823) for various failures such as browser_firstPartyIsolation_aboutPages.js a=backout MozReview-Commit-ID: KGFQxaQq587
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,19 +113,18 @@ 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"},
 
-  // 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"},
+  // Needed by Normandy
+  {file: "resource://gre/modules/IndexedDB.jsm"},
 
   // 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,97 +1,50 @@
 /* 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 = 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.startup = async function() {
+  await 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");
-  let modules = [
+  const 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(module);
+    Cu.unload(`resource://shield-recipe-client/${module}`);
   }
 };
 
 this.uninstall = function() {};
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/content/AboutPages.jsm
+++ /dev/null
@@ -1,213 +0,0 @@
-/* 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;
-});
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/content/about-studies/about-studies.css
+++ /dev/null
@@ -1,182 +0,0 @@
-/* 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;
-}
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/content/about-studies/about-studies.html
+++ /dev/null
@@ -1,23 +0,0 @@
-<!-- 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>
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/content/about-studies/about-studies.js
+++ /dev/null
@@ -1,140 +0,0 @@
-/* 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"),
-);
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/content/about-studies/common.js
+++ /dev/null
@@ -1,137 +0,0 @@
-/* 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);
-}
deleted file mode 100644
index e8539268cedca719b8f694b5ba7845cd27c1974c..0000000000000000000000000000000000000000
GIT binary patch
literal 0
Hc$@<O00001
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/content/about-studies/shield-studies.js
+++ /dev/null
@@ -1,148 +0,0 @@
-/* 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,
-};
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/content/shield-content-frame.js
+++ /dev/null
@@ -1,125 +0,0 @@
-/* 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);
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/content/shield-content-process.js
+++ /dev/null
@@ -1,48 +0,0 @@
-/* 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>65</em:version>
+    <em:version>55.1</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,13 +1,9 @@
 # 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 %
-  lib/ (./lib/*)
-  data/ (./data/*)
-  skin/  (skin/*)
-% resource shield-recipe-client-content %content/
-  content/ (./content/*)
-% resource shield-recipe-client-vendor %vendor/
-  vendor/ (./vendor/*)
+% resource shield-recipe-client %content/
+  content/lib/ (./lib/*)
+  content/node_modules/jexl/ (./node_modules/jexl/*)
+  content/skin/  (skin/*)
--- a/browser/extensions/shield-recipe-client/lib/ActionSandboxManager.jsm
+++ b/browser/extensions/shield-recipe-client/lib/ActionSandboxManager.jsm
@@ -67,14 +67,18 @@ 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);
-    const result = await this.evalInSandbox(`
-      asyncCallbacks.get("${callbackName}")(sandboxedDriver, ...callbackArgs);
-    `);
-    return Cu.cloneInto(result, {});
+    try {
+      const result = await this.evalInSandbox(`
+        asyncCallbacks.get("${callbackName}")(sandboxedDriver, ...callbackArgs);
+      `);
+      return Cu.cloneInto(result, {});
+    } catch (err) {
+      throw new Error(Cu.cloneInto(err.message, {}));
+    }
   }
 };
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/lib/AddonStudies.jsm
+++ /dev/null
@@ -1,324 +0,0 @@
-/* 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);
-    }
-  },
-};
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/lib/Addons.jsm
+++ /dev/null
@@ -1,135 +0,0 @@
-/* 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,20 +11,19 @@ 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;
@@ -32,17 +31,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.
    */
-  async getClientClassification() {
+  getClientClassification() {
     if (!_classifyRequest) {
       _classifyRequest = NormandyApi.classifyClient();
     }
     return _classifyRequest;
   },
 
   clearClassifyCache() {
     _classifyRequest = null;
@@ -193,20 +192,15 @@ 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,62 +4,40 @@
 
 "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 = new mozjexl.Jexl();
+  const {Jexl} = nodeRequire("jexl/lib/Jexl.js");
+  const jexl = new 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,34 +1,29 @@
 /* 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/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/CanonicalJSON.jsm");
 Cu.import("resource://shield-recipe-client/lib/LogManager.jsm");
-
-XPCOMUtils.defineLazyModuleGetter(
-  this, "CanonicalJSON", "resource://gre/modules/CanonicalJSON.jsm");
-
+Cu.import("resource://shield-recipe-client/lib/Utils.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();
 
@@ -63,157 +58,95 @@ this.NormandyApi = {
     } else if (url.startsWith("/")) {
       return server + url;
     }
     throw new Error("Can't use relative urls");
   },
 
   async getApiUrl(name) {
     if (!indexPromise) {
-      const apiBase = new URL(prefs.getCharPref("api_url"));
+      let 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 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 = [];
+  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);
 
-    for (const objectWithSig of objectsWithSigs) {
-      const {signature, x5u} = objectWithSig.signature;
-      const object = objectWithSig[type];
+    const verifiedRecipes = [];
 
-      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.
+    for (const {recipe, signature: {signature, x5u}} of recipesWithSigs) {
+      const serialized = CanonicalJSON.stringify(recipe);
       if (!rawText.includes(serialized)) {
         log.debug(rawText, serialized);
-        throw new NormandyApi.InvalidSignatureError(
-          `Canonical ${type} serialization does not match!`);
+        throw new Error("Canonical recipe serialization does not match!");
       }
 
-      const certChainResponse = await this.get(this.absolutify(x5u));
+      const certChainResponse = await fetch(this.absolutify(x5u));
       const certChain = await certChainResponse.text();
       const builtSignature = `p384ecdsa=${signature}`;
 
       const verifier = Cc["@mozilla.org/security/contentsignatureverifier;1"]
         .createInstance(Ci.nsIContentSignatureVerifier);
 
-      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}`);
+      const valid = verifier.verifyContentSignature(
+        serialized,
+        builtSignature,
+        certChain,
+        "normandy.content-signature.mozilla.org"
+      );
+      if (!valid) {
+        throw new Error("Recipe signature is not valid");
       }
-
-      if (!valid) {
-        throw new NormandyApi.InvalidSignatureError(`${type} signature is not valid`);
-      }
-
-      verifiedObjects.push(object);
+      verifiedRecipes.push(recipe);
     }
 
     log.debug(
-      `Fetched ${verifiedObjects.length} ${type} from the server:`,
-      verifiedObjects.map(r => r.name).join(", ")
+      `Fetched ${verifiedRecipes.length} recipes from the server:`,
+      verifiedRecipes.map(r => r.name).join(", ")
     );
 
-    return verifiedObjects;
+    return verifiedRecipes;
   },
 
   /**
    * 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 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 fetchActions() {
+    const actionApiUrl = await this.getApiUrl("action-list");
+    const res = await this.get(actionApiUrl);
+    return res.json();
   },
 
   async fetchImplementation(action) {
-    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}`
-      );
+    const response = await fetch(action.implementation_url);
+    if (response.ok) {
+      return response.text();
     }
 
-    // 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;
+    throw new Error(`Failed to fetch action implementation for ${action.name}: ${response.status}`);
   },
 };
--- a/browser/extensions/shield-recipe-client/lib/NormandyDriver.jsm
+++ b/browser/extensions/shield-recipe-client/lib/NormandyDriver.jsm
@@ -1,34 +1,29 @@
 /* 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) {
@@ -156,74 +151,22 @@ 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, undefined) !== experiment.preferenceValue) {
+      if (getPref(UserPreferences, experiment.preferenceName, experiment.preferenceType) !== 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, undefined),
+      previousPreferenceValue: getPref(preferences, preferenceName, preferenceType),
       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, undefined);
+        let newValue = getPref(UserPreferences, preferenceName, preferenceType);
         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,20 +24,16 @@ 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";
@@ -126,141 +122,106 @@ 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)) {
-      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;
+      await ClientEnvironment.getClientClassification();
     }
 
     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}:`);
-            Cu.reportError(e);
-            status = Uptake.RECIPE_EXECUTION_ERROR;
+            log.error(`Could not execute recipe ${recipe.name}:`, e);
           }
         }
-
-        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(), {
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/lib/ShieldPreferences.jsm
+++ /dev/null
@@ -1,121 +0,0 @@
-/* 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,82 +11,96 @@ 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 = "extensions.shield-recipe-client.logging.level";
+const PREF_LOGGING_LEVEL = PREF_BRANCH + "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}.`)
+        }
+      }
+    }
+  },
 };
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/lib/Uptake.jsm
+++ /dev/null
@@ -1,48 +0,0 @@
-/* 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);
-  },
-};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/LICENSE.txt
@@ -0,0 +1,19 @@
+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
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/Jexl.js
@@ -0,0 +1,225 @@
+/*
+ * 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;
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/Lexer.js
@@ -0,0 +1,244 @@
+/*
+ * 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;
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/Evaluator.js
@@ -0,0 +1,153 @@
+/*
+ * 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;
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/handlers.js
@@ -0,0 +1,159 @@
+/*
+ * 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);
+	});
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/grammar.js
@@ -0,0 +1,66 @@
+/*
+ * 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; }}
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/Parser.js
@@ -0,0 +1,188 @@
+/*
+ * 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;
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/handlers.js
@@ -0,0 +1,210 @@
+/*
+ * 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
+	});
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/states.js
@@ -0,0 +1,154 @@
+/*
+ * 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,21 +4,8 @@
 
 /* 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,16 +3,17 @@
  * 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);
   }
 
@@ -44,17 +45,16 @@ 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,9 +3,15 @@
 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,22 +1,14 @@
 [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
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/browser/browser_ActionSandboxManager.js
+++ /dev/null
@@ -1,167 +0,0 @@
-"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",
-    );
-  });
-});
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/browser/browser_AddonStudies.js
+++ /dev/null
@@ -1,325 +0,0 @@
-"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."
-    );
-  }
-);
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/browser/browser_Addons.js
+++ /dev/null
@@ -1,34 +0,0 @@
-"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,18 +1,14 @@
 "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;
@@ -109,33 +105,20 @@ add_task(async function testExperiments(
     experiments.expired,
     ["expired"],
     "experiments.expired returns all expired experiment names",
   );
 
   getAll.restore();
 });
 
-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;
+add_task(async function isFirstRun() {
+  let environment = ClientEnvironment.getEnvironment();
 
-  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 initially set to true
+  ok(environment.isFirstRun, "isFirstRun has a default value");
 
-  await driver.addons.uninstall(addonId);
-}));
-
-add_task(async function isFirstRun() {
+  // isFirstRun is read from a preference
   await SpecialPowers.pushPrefEnv({set: [["extensions.shield-recipe-client.first_run", true]]});
-  const environment = ClientEnvironment.getEnvironment();
+  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,113 +21,107 @@ function listenerB(x = 1) {
   evidence.log += "b";
 }
 
 function listenerC(x = 1) {
   evidence.c += x;
   evidence.log += "c";
 }
 
-decorate_task(
-  withSandboxManager(Assert),
-  async function(sandboxManager) {
-    const eventEmitter = new EventEmitter(sandboxManager);
+add_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.");
+}));
 
-decorate_task(
-  withSandboxManager(Assert),
-  async function sandboxedEmitter(sandboxManager) {
-    const eventEmitter = new EventEmitter(sandboxManager);
+add_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,103 +86,8 @@ 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,68 +1,22 @@
 "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");
@@ -86,232 +40,43 @@ 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");
 }));
 
-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");
+add_task(withSandboxManager(Assert, async function testCreateStorage(sandboxManager) {
+  const driver = new NormandyDriver(sandboxManager);
+  sandboxManager.cloneIntoGlobal("driver", driver, {cloneFunctions: true});
 
-  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);
+  // Assertion helpers
+  sandboxManager.addGlobal("is", is);
+  sandboxManager.addGlobal("deepEqual", (...args) => Assert.deepEqual(...args));
 
-    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 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 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));
+      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() {
-        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."
-        );
-      })();
-    `);
-  })
-);
+      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");
+    })();
+  `);
+}));
--- a/browser/extensions/shield-recipe-client/test/browser/browser_PreferenceExperiments.js
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_PreferenceExperiments.js
@@ -370,19 +370,16 @@ 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",
@@ -395,17 +392,16 @@ 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,18 +1,15 @@
 "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",
@@ -67,17 +64,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();
@@ -96,46 +93,37 @@ add_task(withMockNormandyApi(async funct
 }));
 
 /**
  * Mocks RecipeRunner.loadActionSandboxManagers for testing run.
  */
 async function withMockActionSandboxManagers(actions, testFunction) {
   const managers = {};
   for (const action of actions) {
-    const manager = new ActionSandboxManager("");
-    manager.addHold("testing");
-    managers[action.name] = manager;
+    managers[action.name] = new ActionSandboxManager("");
     sinon.stub(managers[action.name], "runAsyncCallback");
   }
 
-  const loadActionSandboxManagers = sinon.stub(RecipeRunner, "loadActionSandboxManagers")
-    .resolves(managers);
+  const loadActionSandboxManagers = sinon.stub(
+    RecipeRunner,
+    "loadActionSandboxManagers",
+    async () => 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 = {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"};
+  const matchRecipe = {action: "matchAction", filter_expression: "true"};
+  const noMatchRecipe = {action: "noMatchAction", filter_expression: "false"};
+  const missingRecipe = {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();
 
@@ -146,109 +134,28 @@ 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.
-
-    // 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);
+    await matchManager.isNuked();
+    await noMatchManager.isNuked();
   });
-
-  // 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 = {id: "pass", action: "passAction", filter_expression: "true"};
-  const failRecipe = {id: "fail", action: "failAction", filter_expression: "true"};
+  const passRecipe = {action: "passAction", filter_expression: "true"};
+  const failRecipe = {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();
@@ -258,57 +165,19 @@ 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");
 
-    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);
+    await passManager.isNuked();
+    await failManager.isNuked();
   });
-
-  // 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";
@@ -319,70 +188,47 @@ add_task(withMockNormandyApi(async funct
 
   const normalManager = managers.normalAction;
   ok(
     await normalManager.evalInSandbox("window.scriptRan"),
     "Implementations are run in the sandbox",
   );
 }));
 
-decorate_task(
-  withPrefEnv({
+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({
     set: [
       ["extensions.shield-recipe-client.dev_mode", true],
       ["extensions.shield-recipe-client.first_run", false],
     ],
-  }),
-  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");
-  }
-);
+  });
+
+  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");
 
-decorate_task(
-  withPrefEnv({
+  runStub.reset();
+  addCleanupHandlerStub.reset();
+  updateRunIntervalStub.reset();
+
+  // not in dev mode
+  await SpecialPowers.pushPrefEnv({
     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");
-  }
-);
+  });
 
-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");
+  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");
 
-    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"
-    );
-  }
-);
+  runStub.restore();
+  addCleanupHandlerStub.restore();
+  updateRunIntervalStub.restore();
+});
--- a/browser/extensions/shield-recipe-client/test/browser/browser_ShieldRecipeClient.js
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_ShieldRecipeClient.js
@@ -1,67 +1,30 @@
 "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);
 
-function withStubInits(testFunction) {
-  return decorate(
-    withStub(AboutPages, "init"),
-    withStub(AddonStudies, "init"),
-    withStub(PreferenceExperiments, "init"),
-    withStub(RecipeRunner, "init"),
-    testFunction
-  );
-}
+add_task(async function testStartup() {
+  sinon.stub(RecipeRunner, "init");
+  sinon.stub(PreferenceExperiments, "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");
-  }
-);
+  await ShieldRecipeClient.startup();
+  ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
+  ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
 
-decorate_task(
-  withStubInits,
-  async function testStartupPrefInitFail() {
-    PreferenceExperiments.init.returns(Promise.reject(new Error("oh no")));
+  PreferenceExperiments.init.restore();
+  RecipeRunner.init.restore();
+});
 
-    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")));
+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");
-  }
-);
+  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");
 
-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");
-  }
-);
+  PreferenceExperiments.init.restore();
+  RecipeRunner.init.restore();
+});
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/browser/browser_about_preferences.js
+++ /dev/null
@@ -1,177 +0,0 @@
-"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."
-    );
-  }
-);
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/browser/browser_about_studies.js
+++ /dev/null
@@ -1,184 +0,0 @@
-"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."
-    );
-  }
-);
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/browser/fixtures/addon-fixture/manifest.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
-    "manifest_version": 2,
-    "name": "normandy_fixture",
-    "version": "1.0",
-    "description": "Dummy test fixture that's a webextension",
-    "applications": {
-        "gecko": {
-            "id": "normandydriver@example.com"
-        }
-    }
-}
deleted file mode 100644
index 71a6f8fe7cd90427ca710599e7125f986e8dd24d..0000000000000000000000000000000000000000
GIT binary patch
literal 0
Hc$@<O00001
--- a/browser/extensions/shield-recipe-client/test/browser/head.js
+++ b/browser/extensions/shield-recipe-client/test/browser/head.js
@@ -1,14 +1,12 @@
 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);
@@ -23,129 +21,56 @@ 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.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");
+this.withSandboxManager = function(Assert, testFunction) {
+  return async function inner() {
+    const sandboxManager = new SandboxManager();
+    sandboxManager.addHold("test running");
 
-      try {
-        await testFunction(...args, [id, addonFile]);
-      } finally {
-        AddonTestUtils.cleanupTempXPIs();
-      }
-    };
-  };
-};
+    await testFunction(sandboxManager);
 
-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));
-    };
+    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(...args) {
-    const sandboxManager = args[args.length - 1];
+  return withSandboxManager(Assert, async function inner(sandboxManager) {
     const driver = new NormandyDriver(sandboxManager);
-    await testFunction(driver, ...args);
+    await testFunction(driver);
   });
 };
 
 this.withMockNormandyApi = function(testFunction) {
   return async function inner(...args) {
     const mockApi = {actions: [], recipes: [], implementations: {}};
 
-    // 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;
+    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");
       }
-    );
+      return impl;
+    });
 
-    try {
-      await testFunction(mockApi, ...args);
-    } finally {
-      mockApi.fetchActions.restore();
-      mockApi.fetchRecipes.restore();
-      mockApi.fetchImplementation.restore();
-    }
+    await testFunction(mockApi, ...args);
+
+    NormandyApi.fetchActions.restore();
+    NormandyApi.fetchRecipes.restore();
+    NormandyApi.fetchImplementation.restore();
   };
 };
 
 const preferenceBranches = {
   user: Preferences,
   default: new Preferences({defaultBranch: true}),
 };
 
@@ -188,91 +113,8 @@ 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,25 +18,24 @@ 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: ""});
-this.sinon = require("sinon-2.3.2");
+const sinon = require("sinon-2.3.2");
 // ================================================
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/unit/invalid_recipe_signature_api/api/v1/index.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
-  "recipe-signed": "/api/v1/recipe/signed/",
-  "classify-client": "/api/v1/classify_client/"
-}
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/unit/invalid_recipe_signature_api/api/v1/recipe/signed/index.json
+++ /dev/null
@@ -1,1 +0,0 @@
-[{"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"}}]
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/unit/invalid_recipe_signature_api/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-----
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/console-log/implementation/sha384-RGx3rydrSq53UfmW9kFcK0mQYra67XIvZvr4MhmAe--ljiiMQOtgM7Cmca48um3v
+++ /dev/null
@@ -1,1 +0,0 @@
-(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"
   },
-  "name": "console-log",
-  "implementation_url": "https://localhost:8443/api/v1/action/console-log/implementation/sha384-RGx3rydrSq53UfmW9kFcK0mQYra67XIvZvr4MhmAe--ljiiMQOtgM7Cmca48um3v/"
+  "implementation_url": "https://localhost:8000/api/v1/action/console-log/implementation/0765c6302db2846b85543eea743f959c19b42559/",
+  "name": "console-log"
 }
--- 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,54 +10,22 @@
         }
       },
       "required": [
         "message"
       ],
       "title": "Log a message to the console",
       "type": "object"
     },
-    "implementation_url": "https://localhost:8443/api/v1/action/console-log/implementation/sha384-RGx3rydrSq53UfmW9kFcK0mQYra67XIvZvr4MhmAe--ljiiMQOtgM7Cmca48um3v/",
+    "implementation_url": "https://localhost:8000/api/v1/action/console-log/implementation/0765c6302db2846b85543eea743f959c19b42559/",
     "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": {
@@ -80,26 +48,16 @@
           "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"
@@ -108,98 +66,12 @@
       "required": [
         "surveyId",
         "message",
         "thanksMessage"
       ],
       "title": "Show a Heartbeat survey.",
       "type": "object"
     },
-    "implementation_url": "https://localhost:8443/api/v1/action/show-heartbeat/implementation/sha384-dEGiyKPEln8Ns5cQHzGpMIGdirSAAX0X-Kwlu-U3sJ05yNbO-ANij_a6c5SyL7G4/",
+    "implementation_url": "https://localhost:8000/api/v1/action/show-heartbeat/implementation/448bbffce82ff27b3d532fd7196bcb190be5c067/",
     "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"
   }
 ]
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/opt-out-study/implementation/sha384-HM_avYcD00o27ufwU1V7PIBtiuMAXML6MMwlYrDEqDX-XzGVuOfL52RCM680JExN
+++ /dev/null
@@ -1,1 +0,0 @@
-(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
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/opt-out-study/index.json
+++ /dev/null
@@ -1,32 +0,0 @@
-{
-  "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"
-}
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/preference-experiment/implementation/sha384-KQgG38GQ7KZAb2VIB48ANQO6nBcxZoLm2ORzUviRT5nAvSywyPjZ5cJIElw6iXIt
+++ /dev/null
@@ -1,2 +0,0 @@
-(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
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/preference-experiment/index.json
+++ /dev/null
@@ -1,86 +0,0 @@
-{
-  "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"
-}
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/show-heartbeat/implementation/sha384-dEGiyKPEln8Ns5cQHzGpMIGdirSAAX0X-Kwlu-U3sJ05yNbO-ANij_a6c5SyL7G4
+++ /dev/null
@@ -1,1 +0,0 @@
-(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,26 +28,16 @@
         "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"
@@ -56,11 +46,11 @@
     "required": [
       "surveyId",
       "message",
       "thanksMessage"
     ],
     "title": "Show a Heartbeat survey.",
     "type": "object"
   },
-  "implementation_url": "https://localhost:8443/api/v1/action/show-heartbeat/implementation/sha384-dEGiyKPEln8Ns5cQHzGpMIGdirSAAX0X-Kwlu-U3sJ05yNbO-ANij_a6c5SyL7G4/",
+  "implementation_url": "https://localhost:8000/api/v1/action/show-heartbeat/implementation/448bbffce82ff27b3d532fd7196bcb190be5c067/",
   "name": "show-heartbeat"
 }
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/signed/index.json
+++ /dev/null
@@ -1,1 +0,0 @@
-[{"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,9 +1,8 @@
 {
   "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":"/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":"/api/v1/recipe/signed/normandy.content-signature.mozilla.org-20210705.dev.chain"}}]
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/recipe/signed/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/mock_api/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/test_ActionSandboxManager.js
@@ -0,0 +1,169 @@
+"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,43 +1,26 @@
-/* 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));
     }
   });
 }
@@ -49,19 +32,19 @@ function makeScriptServer(scriptPath) {
   server.start(-1);
   return server;
 }
 
 function withScriptServer(scriptPath, task) {
   return withServer(makeScriptServer(scriptPath), task);
 }
 
-function makeMockApiServer(directory) {
+function makeMockApiServer() {
   const server = new HttpServer();
-  server.registerDirectory("/", directory);
+  server.registerDirectory("/", do_get_file("mock_api"));
 
   server.setIndexHandler(async function(request, response) {
     response.processAsync();
     const dir = request.getProperty("directory");
     const index = dir.clone();
     index.append("index.json");
 
     if (!index.exists()) {
@@ -82,17 +65,17 @@ function makeMockApiServer(directory) {
     }
   });
 
   server.start(-1);
   return server;
 }
 
 function withMockApiServer(task) {
-  return withServer(makeMockApiServer(do_get_file("mock_api")), task);
+  return withServer(makeMockApiServer(), 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");
 }));
@@ -100,17 +83,21 @@ 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 = new MockResponse(JSON.stringify({"test-endpoint": `${serverUrl}/test/`}));
+  const fakeResponse = {
+    async json() {
+      return {"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/`);
@@ -133,103 +120,30 @@ 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, 4);
+  equal(actions.length, 2);
   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.");
 }));
@@ -249,26 +163,25 @@ 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(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_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(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(function* () {
+add_task(async 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(function* () {
       return this.aValue;
     }),
   }, {cloneFunctions: true});
 
   // Assertion helpers
   manager.addGlobal("ok", ok);
   manager.addGlobal("equal", equal);
 
-  const sandboxResult = yield new Promise(resolve => {
+  const sandboxResult = await 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(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");
 
-  yield manager.evalInSandbox(`
+  await 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(function* () {
+add_task(async 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(function* () {
       return {value: "cloned"};
     }, {cloneInto: true, cloneArguments: true}),
   }, {cloneFunctions: true});
 
   // Assertion helpers
   manager.addGlobal("ok", ok);
   manager.addGlobal("deepEqual", deepEqual);
 
-  yield new Promise(resolve => {
+  await 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,13 +1,11 @@
 [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]
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/vendor/LICENSE_THIRDPARTY
+++ /dev/null
@@ -1,262 +0,0 @@
-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.
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/vendor/PropTypes.js
+++ /dev/null
@@ -1,1 +0,0 @@
-/* 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
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/vendor/React.js
+++ /dev/null
@@ -1,5 +0,0 @@
-/* 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
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/vendor/ReactDOM.js
+++ /dev/null
@@ -1,18 +0,0 @@
-/* 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.eventNameD