Bug 1388823 - Sync shield-recipe-client v65 from GitHub (commit ff1b680c) r?Gijs draft
authorMike Cooper <mcooper@mozilla.com>
Thu, 10 Aug 2017 10:22:32 -0700
changeset 647786 84d95cf021e3a4acf10bb20210f06cd7533d64c0
parent 647785 1d38626ba9686d119e489db538ce84f8f9854217
child 726633 bb1bb29a99cc216adfd685836f2b295b76503ec6
push id74539
push userbmo:mkelly@mozilla.com
push dateWed, 16 Aug 2017 22:39:32 +0000
reviewersGijs
bugs1388823
milestone57.0a1
Bug 1388823 - Sync shield-recipe-client v65 from GitHub (commit ff1b680c) r?Gijs MozReview-Commit-ID: F8uuFRyS4li
browser/base/content/test/static/browser_all_files_referenced.js
browser/extensions/shield-recipe-client/bootstrap.js
browser/extensions/shield-recipe-client/content/AboutPages.jsm
browser/extensions/shield-recipe-client/content/about-studies/about-studies.css
browser/extensions/shield-recipe-client/content/about-studies/about-studies.html
browser/extensions/shield-recipe-client/content/about-studies/about-studies.js
browser/extensions/shield-recipe-client/content/about-studies/common.js
browser/extensions/shield-recipe-client/content/about-studies/img/shield-logo.png
browser/extensions/shield-recipe-client/content/about-studies/shield-studies.js
browser/extensions/shield-recipe-client/content/shield-content-frame.js
browser/extensions/shield-recipe-client/content/shield-content-process.js
browser/extensions/shield-recipe-client/install.rdf.in
browser/extensions/shield-recipe-client/jar.mn
browser/extensions/shield-recipe-client/lib/ActionSandboxManager.jsm
browser/extensions/shield-recipe-client/lib/AddonStudies.jsm
browser/extensions/shield-recipe-client/lib/Addons.jsm
browser/extensions/shield-recipe-client/lib/ClientEnvironment.jsm
browser/extensions/shield-recipe-client/lib/FilterExpressions.jsm
browser/extensions/shield-recipe-client/lib/NormandyApi.jsm
browser/extensions/shield-recipe-client/lib/NormandyDriver.jsm
browser/extensions/shield-recipe-client/lib/PreferenceExperiments.jsm
browser/extensions/shield-recipe-client/lib/RecipeRunner.jsm
browser/extensions/shield-recipe-client/lib/ShieldPreferences.jsm
browser/extensions/shield-recipe-client/lib/ShieldRecipeClient.jsm
browser/extensions/shield-recipe-client/lib/Uptake.jsm
browser/extensions/shield-recipe-client/node_modules/jexl/LICENSE.txt
browser/extensions/shield-recipe-client/node_modules/jexl/lib/Jexl.js
browser/extensions/shield-recipe-client/node_modules/jexl/lib/Lexer.js
browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/Evaluator.js
browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/handlers.js
browser/extensions/shield-recipe-client/node_modules/jexl/lib/grammar.js
browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/Parser.js
browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/handlers.js
browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/states.js
browser/extensions/shield-recipe-client/skin/osx/Heartbeat.css
browser/extensions/shield-recipe-client/skin/shared/Heartbeat.css
browser/extensions/shield-recipe-client/test/browser/.eslintrc.js
browser/extensions/shield-recipe-client/test/browser/browser.ini
browser/extensions/shield-recipe-client/test/browser/browser_ActionSandboxManager.js
browser/extensions/shield-recipe-client/test/browser/browser_AddonStudies.js
browser/extensions/shield-recipe-client/test/browser/browser_Addons.js
browser/extensions/shield-recipe-client/test/browser/browser_ClientEnvironment.js
browser/extensions/shield-recipe-client/test/browser/browser_EventEmitter.js
browser/extensions/shield-recipe-client/test/browser/browser_FilterExpressions.js
browser/extensions/shield-recipe-client/test/browser/browser_NormandyDriver.js
browser/extensions/shield-recipe-client/test/browser/browser_PreferenceExperiments.js
browser/extensions/shield-recipe-client/test/browser/browser_RecipeRunner.js
browser/extensions/shield-recipe-client/test/browser/browser_ShieldRecipeClient.js
browser/extensions/shield-recipe-client/test/browser/browser_about_preferences.js
browser/extensions/shield-recipe-client/test/browser/browser_about_studies.js
browser/extensions/shield-recipe-client/test/browser/fixtures/addon-fixture/manifest.json
browser/extensions/shield-recipe-client/test/browser/fixtures/normandy.xpi
browser/extensions/shield-recipe-client/test/browser/head.js
browser/extensions/shield-recipe-client/test/unit/head_xpc.js
browser/extensions/shield-recipe-client/test/unit/invalid_recipe_signature_api/api/v1/index.json
browser/extensions/shield-recipe-client/test/unit/invalid_recipe_signature_api/api/v1/recipe/signed/index.json
browser/extensions/shield-recipe-client/test/unit/invalid_recipe_signature_api/normandy.content-signature.mozilla.org-20210705.dev.chain
browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/console-log/implementation/sha384-RGx3rydrSq53UfmW9kFcK0mQYra67XIvZvr4MhmAe--ljiiMQOtgM7Cmca48um3v
browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/console-log/index.json
browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/index.json
browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/opt-out-study/implementation/sha384-HM_avYcD00o27ufwU1V7PIBtiuMAXML6MMwlYrDEqDX-XzGVuOfL52RCM680JExN
browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/opt-out-study/index.json
browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/preference-experiment/implementation/sha384-KQgG38GQ7KZAb2VIB48ANQO6nBcxZoLm2ORzUviRT5nAvSywyPjZ5cJIElw6iXIt
browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/preference-experiment/index.json
browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/show-heartbeat/implementation/sha384-dEGiyKPEln8Ns5cQHzGpMIGdirSAAX0X-Kwlu-U3sJ05yNbO-ANij_a6c5SyL7G4
browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/show-heartbeat/index.json
browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/signed/index.json
browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/index.json
browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/recipe/signed/index.json
browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/recipe/signed/normandy.content-signature.mozilla.org-20210705.dev.chain
browser/extensions/shield-recipe-client/test/unit/mock_api/normandy.content-signature.mozilla.org-20210705.dev.chain
browser/extensions/shield-recipe-client/test/unit/test_ActionSandboxManager.js
browser/extensions/shield-recipe-client/test/unit/test_NormandyApi.js
browser/extensions/shield-recipe-client/test/unit/test_SandboxManager.js
browser/extensions/shield-recipe-client/test/unit/xpcshell.ini
browser/extensions/shield-recipe-client/vendor/LICENSE_THIRDPARTY
browser/extensions/shield-recipe-client/vendor/PropTypes.js
browser/extensions/shield-recipe-client/vendor/React.js
browser/extensions/shield-recipe-client/vendor/ReactDOM.js
browser/extensions/shield-recipe-client/vendor/classnames.js
browser/extensions/shield-recipe-client/vendor/mozjexl.js
--- a/browser/base/content/test/static/browser_all_files_referenced.js
+++ b/browser/base/content/test/static/browser_all_files_referenced.js
@@ -113,18 +113,19 @@ var whitelist = [
   {file: "resource://gre/chrome/en-US/locale/en-US/global-platform/win/intl.properties",
    platforms: ["linux", "macosx"]},
   {file: "resource://gre/chrome/en-US/locale/en-US/global-platform/win/platformKeys.properties",
    platforms: ["linux", "macosx"]},
 
   // browser/extensions/pdfjs/content/web/viewer.js#7450
   {file: "resource://pdf.js/web/debugger.js"},
 
-  // Needed by Normandy
-  {file: "resource://gre/modules/IndexedDB.jsm"},
+  // These are used in content processes. They are actually referenced.
+  {file: "resource://shield-recipe-client-content/shield-content-frame.js"},
+  {file: "resource://shield-recipe-client-content/shield-content-process.js"},
 
   // New L10n API that is not yet used in production
   {file: "resource://gre/modules/Localization.jsm"},
 
   // Starting from here, files in the whitelist are bugs that need fixing.
   // Bug 1339420
   {file: "chrome://branding/content/icon128.png"},
   // Bug 1339424 (wontfix?)
--- a/browser/extensions/shield-recipe-client/bootstrap.js
+++ b/browser/extensions/shield-recipe-client/bootstrap.js
@@ -1,50 +1,97 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 "use strict";
 
 const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/AppConstants.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "LogManager",
   "resource://shield-recipe-client/lib/LogManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "ShieldRecipeClient",
   "resource://shield-recipe-client/lib/ShieldRecipeClient.jsm");
 
+const DEFAULT_PREFS = {
+  "extensions.shield-recipe-client.api_url": "https://normandy.cdn.mozilla.net/api/v1",
+  "extensions.shield-recipe-client.dev_mode": false,
+  "extensions.shield-recipe-client.enabled": true,
+  "extensions.shield-recipe-client.startup_delay_seconds": 300,
+  "extensions.shield-recipe-client.logging.level": Log.Level.Warn,
+  "extensions.shield-recipe-client.user_id": "",
+  "extensions.shield-recipe-client.run_interval_seconds": 86400, // 24 hours
+  "extensions.shield-recipe-client.first_run": true,
+  "extensions.shield-recipe-client.shieldLearnMoreUrl": (
+    "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/shield"
+  ),
+  "app.shield.optoutstudies.enabled": AppConstants.MOZ_DATA_REPORTING,
+};
+
 this.install = function() {};
 
-this.startup = async function() {
-  await ShieldRecipeClient.startup();
+this.startup = function() {
+  // Initialize preference defaults before anything else happens.
+  const prefBranch = Services.prefs.getDefaultBranch("");
+  for (const [name, value] of Object.entries(DEFAULT_PREFS)) {
+    switch (typeof value) {
+      case "string":
+        prefBranch.setCharPref(name, value);
+        break;
+      case "number":
+        prefBranch.setIntPref(name, value);
+        break;
+      case "boolean":
+        prefBranch.setBoolPref(name, value);
+        break;
+      default:
+        throw new Error(`Invalid default preference type ${typeof value}`);
+    }
+  }
+
+  ShieldRecipeClient.startup();
 };
 
 this.shutdown = function(data, reason) {
   ShieldRecipeClient.shutdown(reason);
 
   // Unload add-on modules. We don't do this in ShieldRecipeClient so that
   // modules are not unloaded accidentally during tests.
   const log = LogManager.getLogger("bootstrap");
-  const modules = [
+  let modules = [
     "lib/ActionSandboxManager.jsm",
+    "lib/Addons.jsm",
+    "lib/AddonStudies.jsm",
     "lib/CleanupManager.jsm",
     "lib/ClientEnvironment.jsm",
     "lib/FilterExpressions.jsm",
     "lib/EventEmitter.jsm",
     "lib/Heartbeat.jsm",
     "lib/LogManager.jsm",
     "lib/NormandyApi.jsm",
     "lib/NormandyDriver.jsm",
     "lib/PreferenceExperiments.jsm",
     "lib/RecipeRunner.jsm",
     "lib/Sampling.jsm",
     "lib/SandboxManager.jsm",
+    "lib/ShieldPreferences.jsm",
     "lib/ShieldRecipeClient.jsm",
     "lib/Storage.jsm",
+    "lib/Uptake.jsm",
     "lib/Utils.jsm",
-  ];
+  ].map(m => `resource://shield-recipe-client/${m}`);
+  modules = modules.concat([
+    "AboutPages.jsm",
+  ].map(m => `resource://shield-recipe-client-content/${m}`));
+  modules = modules.concat([
+    "mozjexl.js",
+  ].map(m => `resource://shield-recipe-client-vendor/${m}`));
+
   for (const module of modules) {
     log.debug(`Unloading ${module}`);
-    Cu.unload(`resource://shield-recipe-client/${module}`);
+    Cu.unload(module);
   }
 };
 
 this.uninstall = function() {};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/content/AboutPages.jsm
@@ -0,0 +1,218 @@
+/* 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) {
+      try {
+        target.messageManager.sendAsyncMessage("Shield:ReceiveStudyList", {
+          studies: await AddonStudies.getAll(),
+        });
+      } catch (err) {
+        // The child process might be gone, so no need to throw here.
+        Cu.reportError(err);
+      }
+    },
+
+    /**
+     * Disable an active study and remove its add-on.
+     * @param {String} studyName
+     */
+    async removeStudy(recipeId) {
+      await AddonStudies.stop(recipeId);
+
+      // Update any open tabs with the new study list now that it has changed.
+      Services.mm.broadcastAsyncMessage("Shield:ReceiveStudyList", {
+        studies: await AddonStudies.getAll(),
+      });
+    },
+
+    openOldDataPreferences() {
+      const browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
+      browserWindow.openAdvancedPreferences("dataChoicesTab", {origin: "aboutStudies"});
+    },
+
+    getShieldLearnMoreHref() {
+      return Services.urlFormatter.formatURLPref(SHIELD_LEARN_MORE_URL_PREF);
+    },
+  });
+
+  return aboutStudies;
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/content/about-studies/about-studies.css
@@ -0,0 +1,182 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+:root {
+  --icon-background-color-1: #0A84FF;
+  --icon-background-color-2: #008EA4;
+  --icon-background-color-3: #ED00B5;
+  --icon-background-color-4: #058B00;
+  --icon-background-color-5: #A47F00;
+  --icon-background-color-6: #FF0039;
+  --icon-background-disabled-color: #737373;
+  --body-text-disabled-color: #737373;
+  --info-box-background-color: #D7D7DB;
+  --info-box-border-color: #98979C;
+  --study-status-active-color: #058B00;
+  --study-status-disabled-color: #737373;
+}
+
+html,
+body,
+#app {
+  height: 100%;
+  width: 100%;
+}
+
+button > .button-box {
+  padding-left: 10px;
+  padding-right: 10px;
+}
+
+.about-studies-container {
+  display: flex;
+  flex-direction: row;
+  font-size: 1.25rem;
+  min-height: 100%;
+  width: 100%;
+}
+
+#categories {
+  flex: 0 0;
+  margin: 0;
+  min-width: 200px;
+  padding: 40px 0 0;
+}
+
+#categories .category {
+  align-items: center;
+  display: flex;
+  flex-direction: row;
+}
+
+.main-content {
+  flex: 1;
+}
+
+.info-box {
+  margin-bottom: 10px;
+  text-align: center;
+}
+
+.info-box-content {
+  align-items: center;
+  background: var(--info-box-background-color);
+  border: 1px solid var(--info-box-border-color);
+  display: inline-flex;
+  padding: 10px 15px;
+}
+
+.info-box-content > * {
+  margin-right: 10px;
+}
+
+.info-box-content > *:last-child {
+  margin-right: 0;
+}
+
+.study-list {
+  list-style-type: none;
+  margin: 0;
+  padding: 0;
+}
+
+.study {
+  align-items: center;
+  border-bottom: 1px solid var(--in-content-border-color);
+  display: flex;
+  flex-direction: row;
+  padding: 10px;
+}
+
+.study.disabled {
+  color: var(--body-text-disabled-color);
+}
+
+.study .study-status {
+  color: var(--study-status-active-color);
+  font-weight: bold;
+}
+
+.study.disabled .study-status {
+  color: var(--study-status-disabled-color);
+}
+
+.study:last-child {
+  border-bottom: none;
+}
+
+.study > * {
+  margin-right: 15px;
+}
+
+.study > *:last-child {
+  margin-right: 0;
+}
+
+.study-icon {
+  color: #FFF;
+  flex: 0 0 40px;
+  font-size: 26px;
+  height: 40px;
+  line-height: 40px;
+  text-align: center;
+  text-transform: capitalize;
+}
+
+.study:nth-child(6n+0) .study-icon {
+  background: var(--icon-background-color-1);
+}
+
+.study:nth-child(6n+1) .study-icon {
+  background: var(--icon-background-color-2);
+}
+
+.study:nth-child(6n+2) .study-icon {
+  background: var(--icon-background-color-3);
+}
+
+.study:nth-child(6n+3) .study-icon {
+  background: var(--icon-background-color-4);
+}
+
+.study:nth-child(6n+4) .study-icon {
+  background: var(--icon-background-color-5);
+}
+
+.study:nth-child(6n+5) .study-icon {
+  background: var(--icon-background-color-6);
+}
+
+.study.disabled .study-icon {
+  background: var(--icon-background-disabled-color);
+}
+
+.study-details {
+  flex: 1;
+  overflow: hidden;
+}
+
+.study-name {
+  font-weight: bold;
+  margin-bottom: 0.3em;
+}
+
+.study-description {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  width: 100%;
+}
+
+.study-description > * {
+  margin-right: 5px;
+}
+
+.study-description > *:last-child {
+  margin-right: 0;
+}
+
+.study-actions {
+  flex: 0 0;
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/content/about-studies/about-studies.html
@@ -0,0 +1,23 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this
+   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <title>about:studies</title>
+    <link rel="stylesheet" href="chrome://global/skin/global.css">
+    <link rel="stylesheet" href="chrome://global/skin/in-content/common.css">
+    <link rel="stylesheet" href="resource://shield-recipe-client-content/about-studies/about-studies.css">
+  </head>
+  <body>
+    <div id="app"></div>
+    <script src="resource://shield-recipe-client-vendor/React.js"></script>
+    <script src="resource://shield-recipe-client-vendor/ReactDOM.js"></script>
+    <script src="resource://shield-recipe-client-vendor/PropTypes.js"></script>
+    <script src="resource://shield-recipe-client-vendor/classnames.js"></script>
+    <script src="resource://shield-recipe-client-content/about-studies/common.js"></script>
+    <script src="resource://shield-recipe-client-content/about-studies/shield-studies.js"></script>
+    <script src="resource://shield-recipe-client-content/about-studies/about-studies.js"></script>
+  </body>
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/content/about-studies/about-studies.js
@@ -0,0 +1,140 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+/* global classnames PropTypes r React ReactDOM ShieldStudies */
+
+/**
+ * Mapping of pages displayed on the sidebar. Keys are the value used in the
+ * URL hash to identify the current page.
+ *
+ * Pages will appear in the sidebar in the order they are defined here. If the
+ * URL doesn't contain a hash, the first page will be displayed in the content area.
+ */
+const PAGES = new Map([
+  ["shieldStudies", {
+    name: "Shield Studies",
+    component: ShieldStudies,
+    icon: "resource://shield-recipe-client-content/about-studies/img/shield-logo.png",
+  }],
+]);
+
+/**
+ * Handle basic layout and routing within about:studies.
+ */
+class AboutStudies extends React.Component {
+  constructor(props) {
+    super(props);
+
+    let hash = new URL(window.location).hash.slice(1);
+    if (!PAGES.has(hash)) {
+      hash = "shieldStudies";
+    }
+
+    this.state = {
+      currentPageId: hash,
+    };
+
+    this.handleEvent = this.handleEvent.bind(this);
+  }
+
+  componentDidMount() {
+    window.addEventListener("hashchange", this);
+  }
+
+  componentWillUnmount() {
+    window.removeEventListener("hashchange", this);
+  }
+
+  handleEvent(event) {
+    const newHash = new URL(event.newURL).hash.slice(1);
+    if (PAGES.has(newHash)) {
+      this.setState({currentPageId: newHash});
+    }
+  }
+
+  render() {
+    const currentPageId = this.state.currentPageId;
+    const pageEntries = Array.from(PAGES.entries());
+    const currentPage = PAGES.get(currentPageId);
+
+    return (
+      r("div", {className: "about-studies-container"},
+        r(Sidebar, {},
+          pageEntries.map(([id, page]) => (
+            r(SidebarItem, {
+              key: id,
+              pageId: id,
+              selected: id === currentPageId,
+              page,
+            })
+          )),
+        ),
+        r(Content, {},
+          currentPage && r(currentPage.component)
+        ),
+      )
+    );
+  }
+}
+
+class Sidebar extends React.Component {
+  render() {
+    return r("ul", {id: "categories"}, this.props.children);
+  }
+}
+Sidebar.propTypes = {
+  children: PropTypes.node,
+};
+
+class SidebarItem extends React.Component {
+  constructor(props) {
+    super(props);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleClick() {
+    window.location = `#${this.props.pageId}`;
+  }
+
+  render() {
+    const { page, selected } = this.props;
+    return (
+      r("li", {
+        className: classnames("category", {selected}),
+        onClick: this.handleClick,
+      },
+        page.icon && r("img", {className: "category-icon", src: page.icon}),
+        r("span", {className: "category-name"}, page.name),
+      )
+    );
+  }
+}
+SidebarItem.propTypes = {
+  pageId: PropTypes.string.isRequired,
+  page: PropTypes.shape({
+    icon: PropTypes.string,
+    name: PropTypes.string.isRequired,
+  }).isRequired,
+  selected: PropTypes.bool,
+};
+
+class Content extends React.Component {
+  render() {
+    return (
+      r("div", {className: "main-content"},
+        r("div", {className: "content-box"},
+          this.props.children,
+        ),
+      )
+    );
+  }
+}
+Content.propTypes = {
+  children: PropTypes.node,
+};
+
+ReactDOM.render(
+  r(AboutStudies),
+  document.getElementById("app"),
+);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/content/about-studies/common.js
@@ -0,0 +1,137 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+/* eslint-disable no-unused-vars */
+/* global PropTypes React */
+
+/**
+ * Shorthand for creating elements (to avoid using a JSX preprocessor)
+ */
+const r = React.createElement;
+
+/**
+ * Information box used at the top of listings.
+ */
+window.InfoBox = class InfoBox extends React.Component {
+  render() {
+    return (
+      r("div", {className: "info-box"},
+        r("div", {className: "info-box-content"},
+          this.props.children,
+        ),
+      )
+    );
+  }
+};
+window.InfoBox.propTypes = {
+  children: PropTypes.node,
+};
+
+/**
+ * Button using in-product styling.
+ */
+window.FxButton = class FxButton extends React.Component {
+  render() {
+    return (
+      r("button", Object.assign({}, this.props, {children: undefined}),
+        r("div", {className: "button-box"},
+          this.props.children,
+        ),
+      )
+    );
+  }
+};
+window.FxButton.propTypes = {
+  children: PropTypes.node,
+};
+
+/**
+ * Wrapper class for a value that is provided by the frame script.
+ *
+ * Emits a "GetRemoteValue:{name}" page event on load to fetch the initial
+ * value, and listens for "ReceiveRemoveValue:{name}" page callbacks to receive
+ * the value when it updates.
+ *
+ * @example
+ * const myRemoteValue = new RemoteValue("MyValue", 5);
+ * class MyComponent extends React.Component {
+ *   constructor(props) {
+ *     super(props);
+ *     this.state = {
+ *       myValue: null,
+ *     };
+ *   }
+ *
+ *   componentWillMount() {
+ *     myRemoteValue.subscribe(this);
+ *   }
+ *
+ *   componentWillUnmount() {
+ *     myRemoteValue.unsubscribe(this);
+ *   }
+ *
+ *   receiveRemoteValue(name, value) {
+ *     this.setState({myValue: value});
+ *   }
+ *
+ *   render() {
+ *     return r("div", {}, this.state.myValue);
+ *   }
+ * }
+ */
+class RemoteValue {
+  constructor(name, defaultValue = null) {
+    this.name = name;
+    this.handlers = [];
+    this.value = defaultValue;
+
+    document.addEventListener(`ReceiveRemoteValue:${this.name}`, this);
+    sendPageEvent(`GetRemoteValue:${this.name}`);
+  }
+
+  /**
+   * Subscribe to this value as it updates. Handlers are called with the current
+   * value immediately after subscribing.
+   * @param {Object} handler
+   *   Object with a receiveRemoteValue(name, value) method that is called with
+   *   the name and value of this RemoteValue when it is updated.
+   */
+  subscribe(handler) {
+    this.handlers.push(handler);
+    handler.receiveRemoteValue(this.name, this.value);
+  }
+
+  /**
+   * Remove a previously-registered handler.
+   * @param {Object} handler
+   */
+  unsubscribe(handler) {
+    this.handlers = this.handlers.filter(h => h !== handler);
+  }
+
+  handleEvent(event) {
+    this.value = event.detail;
+    for (const handler of this.handlers) {
+      handler.receiveRemoteValue(this.name, this.value);
+    }
+  }
+}
+
+/**
+ * Collection of RemoteValue instances used within the page.
+ */
+const remoteValues = {
+  StudyList: new RemoteValue("StudyList", []),
+  ShieldLearnMoreHref: new RemoteValue("ShieldLearnMoreHref", ""),
+};
+
+/**
+ * Dispatches a page event to the privileged frame script for this tab.
+ * @param {String} action
+ * @param {Object} data
+ */
+function sendPageEvent(action, data) {
+  const event = new CustomEvent("ShieldPageEvent", {bubbles: true, detail: {action, data}});
+  document.dispatchEvent(event);
+}
new file mode 100644
index 0000000000000000000000000000000000000000..e8539268cedca719b8f694b5ba7845cd27c1974c
GIT binary patch
literal 5426
zc$@(;70v32P)<h;3K|Lk000e1NJLTq001xm001xu1^@s6R|5Hm0000PbVXQnQ*UN;
zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU`)Ja4^RCwB?T4|6R<#~Skn){xe-C6A+
z?a89m#btw7aY(Wg8B*{Ar((NIs_ZzG*o3$$m4u{<q+GblDZ`HhjE%wN3p)-Kmk}5c
z{sCC#(BYMYCD6Ge?P`zNYj$_;>7G9FzF*HQS}k!Ar$wXQ+3By}<9VO=dB2wAI1czY
zb?Ow_+uL#U=n({iA<WFoAel^}zrPPZ`q7VY!;Lp$d1(nFBfId6=YN5=);0_d4&dOy
zgV?p}Htp44y}D`ZmaRQwnX#dX@$qZY>GZ(ZSY}v}WqVUk&-=9Ag}%PtbDK8xoE;q<
zz4WQOJ~i>i>#v*p_U*&TlP57dH;2!C_H#IJ;1whiNxc2`Tln%<zJiOHi<q9CMpsuC
z^7%Y=@7^tJyOHeSL&a4GJLK_rpeQo@em^aifT}3)d3|u;_$MYN`fbY^$YjO_Xu-jW
ziOU1?b3gAdmCNmC&zu$am~|6#v%f<$8smHL?uiqf{y^Z5PMvxWM~)mtEE>gg|MlEV
ztyaD8)?06%pUq}3gd^b#nTweV^!$w9=c~!Gf|iyRWM{HaR7KcC)?IBbq-F*<3LNbt
zM~=X<EZ_3-N<SGmkV>Wc-hS)NVXx0OP%c-7mU6kI&*wv>T1Bl^Log6Py<P`@M#~9<
zn`_dAcqFX{S(e4;&9hu8LDe)Ai$##Zl=UDAbWH(Upf%4@KrZarv*-QA#l^8uC^Qz2
z$1;kd=zI3;#kq6maMMjUt#u%q&0>6f{9~`b_S%2Y^$wrk@1vVx=sKdY7;2R&Jy3;2
zhVaoeNRp&ggJrs=hmae#_?shU+ZH)!q3M%hn)I3bgGJX)V=a@eEz^Q&)#>{tW!wvX
zet-hhl4?PrP^@fdZy&wq-h2Oc^XAR(c64;Cb>QgHqg$T-?srdBDY7v#m_OGvH*HQM
znl>9ue^ga&7%P(l5T6@{fvRpmr-xT6HI&4uQ|dJYd>;5bD*Rp#JQM`F&2^d`p#}Ia
zHkuu?$YDNg+ZHDWqE2h`_0q~R-LL5nJ@ijos20Z7)WCrQ2foaUwWZVI%WR-WRfVzq
zxhzYlQ<SQD4Hbe;nGCE{b(E_HO696Bwp=#I_&R0FqSJ;|mk|mF;PFY&ynxRy5!6(1
zdOb2ZrBS3ca!i9t>w7f9x=dMyOpe$hLzEv-6ogIbwlwl9E8b_Hef9xz;HzsKSX#;r
zgu`JGX<`nF3VB?}qiUEamMU&U%QY1Ey2yrFsFqEb20_S_MP|GKG9qBZ7dH{_vEWPS
zWTc7uN&uC-j9N)SX@wxATOxCkS|M24<e&^MJLgr%8C9H|DUE{XC8sonopK=;CsN7I
z%v{s7d({D|a^1FUf$5;%i_;e;@W$cy$T*-@lu@sdp*Ba79|m~CHd3v`SP_Cr%z%$h
zE#_W(f^}%2IuxG;uN;J3^}r~pbSg0NUYG?hY6T6(iU(%N1HBA#P(j|XL>)*<4Vs4_
zOu>*92b(vgkf2Ofbw>o)>-8Gz93a|q*Z~fWO3<lM<V(dmVsvAq&x9wc!y7jcOp&4S
zDj8mfKStm8>QKBkMUo&!2Ee8zO%;SRpzNa`{ct1`{)B}<ivdS230gFD8zuz>x*xT?
zM9yjCm><;~IabtAFM6RD$c`EnHQB`wMw3V-$zJCG|Bi9b8K4570#+a!J_OP=T>Ix2
zq4>#Z#UglVf>~^W4NQ%2F2T}OP`n9tCKO2#u&dS$#QhqVBOFU2c98`W*_1_DQt7*v
z_?_$_3T~xfw6l@qm_;e4@0vv)F7EGzKJO96GkpjOwwnxhl>=n9BMI!gOe4F*16ig2
zc?^mc-DG-Uz!C<qqqewK=tf5v#0J!f27R6&r96&8Z4sK|C#My`E*w3+Hf>WpL2^{8
zA%cvf@zY^BeC>y<k#nj+P;x}bxh9n>=-L#;-9S$BLQz#)uuUTi4rhYzriGn4al4}k
z10}&ovN$EVan1M0j_BC41iQZ&c?6H&brks=WlAw9VycLcpk87bx8x<U9{)m;WcP$5
zjFlY9mZ^wmL;3(BvY=dt8()2$0|CF^YBnYTqefo13rHe86mNov;C+fb|8cKP@jJf9
zuytY%U~|U}NO`*9tp#C9HHx_5u0elHyY8l&SFzpo9AZ2dQV^LE*HQ!Vj@vUB9Wn)o
zqaTh&jCBrBG_6(R#edSO_Gm_pe^wN&;Us05t|6k&g|9)k45T9gbVrAw27tCmw`g!|
zIKxiBjsB>zCL)f;bu-GE;^EdR*5_w7l@IrTrUc>p$O&hi1JniUa>GkSa$(UddvUYm
ztcpFiU>p%eh7KCnRSNEgXdnD?gz5%V#2cvTHSKGz;fNmN=ED)ucLU{q?l|smZ1=*2
zo~*3O7ygizG1oajVoYaGh4)0G&6{`;PIYquW*Rn#ofJhUxwXLXs?ygsx8J;q*_Kc{
zWq^r55wLJXhgVkN!q=vNrJH#!Zn;qlwvZG8U#r37;IS|a1cSA84rrQYG*i`d#6^uP
zPDy0Nb;`YVLDei~{(<BUZtB~MvGW;ZE{@^a?i;|6=T6$BI(H4J0TZQJ&f@dtdSXpC
z&0wT8)R?%39f(B2m30otid+?J(x55kUQzW8<>(fjTl}2rW>)xb+hV6pkP^Uf`WC!#
z;uVZ9okdT}Fm-TR538g>nX0?+u^kcdHS0-k5p!lm&#**^2|KQIjL=bC=Riv`nP&mT
z)lS(EN>gB)nu<(3EeS?~Xk>0(P~l2+Z3{|)L6W&W(t()Vg}2A{Bj=n)YhV++aG8oD
zOe+b)xIi29;}&rv!p&<zr*M12DjPP+)->xpDK4*bpu2nH0uLxno>vLCoIaa6Ek~H0
zaSgXu<+bIwkmC=^IxyI_jidqayP4OadzPpMIuVd*LL+u!gAMxIuymlwa1DZ3<-FCx
zrHpV^O}52tWQ{MAuq<JCcz9`@12op<NF<4c7&Ts+1w3BTH&yDcHKs2|V3k|F<hYNt
z#I<M!)OCR!U3a2HOV3h*^JW&IfFD66?s7Xpgz?K+*6pgek88<=q9ji45pGk1fF8>f
zRpq&ZrHHH3XgcHJYi^wb)6-MgI1MuV02Xzu)4inI>s4~1Ob}8U1?ad%$4Pdt6?cxN
zhi>iIiL;Z(P^M=smS)8a&=TmR8QH?j$|Aln@~?Pg-}A^Vt-w)S?bD3E)F^Hy2bqjE
zD<tl8g0qJ|b7c*)SX)~gCN588*J+`VkzG?Szw%0hg&gvfRH9zO%1jPZm#b)PrD<!$
z?WmC5Of%~w174C3x_CfuWIKL5{zK8kW){YYn}N>w0M0EOC4CPJwSNp-+jpVcvmM8$
zeuvf|_n*}^;6jsrCMAveX$zB?IcypV)7<T&AW-IrlWC6DKl^8&S?>VNz4NwZ<%xl!
zbt)9*`A_a0L4ICE&+t5wgNvl$X+l#}!5SXLi@Cx$j!nLY&R7b&uf2!LE`*cQ2N91G
zjFx7^x}AwZEN6i0`Z~}Vb?1=3+WR&9=gF_5HJ%p5YFd_{N@}4b22E&w6U#;`wmGd(
zJS#Mpm#H2Vp+cz!bEnUo&J7F>u61B^bhJc+{CK%i+0Hb|I|L7X^{;W=b=Tp4JMskh
zzklv-{NugfL0@t^CdcM5n)x--w05qzf<k>!WPpY^43R!k>wf&~<!A7jyS{|oH{9cf
zoHJm#CpTq=I%iY;W2!ltF<yA#c|7sNlL9(?&_s6e{nMxG_uO;uS_f{w{dSshD`%&s
zrnd7IM@M@akAM3qymvB-RKJbZ8yAHt<>(uAUF_5R+ZTU^r@#0P#wRY}{>OLYa_J1L
zcn)zI-sejO^0hg1#Cu7K10Ns!1gx?L-~8p@;HAtH@Ri$KQOHH?NFOv%*=4B3lSvwG
zJZMjQ(cRTS@S&_MFXQ^_uOEN#!3WpR1Lx15Bb?RG^Tw3BxuwyNUc%_n!_a0V?ASU_
z!w0bgu^?->DPrPZo%<oau=8v9!T#^yXQ$u5uDvo^BtML*+lc0h<7kO&LzupQeb1f9
z96N(5@$95?iYWM$C{C7TO_pS~_E6y)1l4n+11J-ted3NmM1x7uXTqT{L+JfOhu#72
z53O+^9F8EJZa*@1VN67#T&|*bQx`h=dr&Hv@R81s^cQHR)f!#8ir-xNIa;b)ap2TX
zaq|dKFHLZD+Y!cB$<a#-XVBBS3th1=T9qFB;@r33kb!Y61v}6Xk&Ziah$6NfF{&=l
zBoJ`guwhU_DiIUWw;LjIWB0~)+1B-Pn&^e_^)A7u!aHqsDy&#Igtl~qxZOeiQi!OO
z#yLq7%n|nmFu9Py-@Wu{csJ#c@P(;M+QJyOqY^bQT_h^@;JVH|uxcJ0zjP4c7(L5U
zg~7~ryq-HMesEoy3c?`RMS>o&r^Er(smVs8(LyX9Kfw}xodaCB(MV)E5{(=;bVH~?
z-W}`OK+#g_C|-)9UZMxfE<8-iH*per`aB56Wa!nVW^#4AM{ymfm<5dV-b++Gg9{7C
zh@rfK&z+`Jx1H53Pe~=!R3PY1hoSl@fDzv+z4C@zB9S;j=iG-nz}QcvQs~~e@jy*%
zDNq9=?(OYrM>4HpDdR_RJPbKdb(w=P%L8~_rH+Z!Iai1@3I_E+Of6<$PzC?V&iirv
z^dZbw=MeNqP&X}czmU*;v*_m3%zRZ8FGo<E@gbH_&=L(1d|V+|E|;-m$BtL%#Kw&q
z*E`VJ*-6c+6~mi{UkZ=}U>FglqO-jf{e5YgZ*?r4Os&oZYq~PC2pKMOcNSevf?t!7
zqi$R_^6-Ww96SFGSlOvG>A2IP*raUqVY*YLS%X+Ee=a33-_{aDIOrG7a>RL;JRXnl
zzkK;JR#sNlJK*jZm(kbPcXD80@R$50(O~F7+pq0KC}m^rcoZw=qfo<D(HR}b-FX(0
z+u^OpI;mHCRjn3lB>^LS9vIJ^M35SsPGg(Xz`xBI6tbCWD_BAPLL9mCAw(%oovksk
zDa_3)Pqu8`y7k3v+qRuA6bfs4(Fa;+W*QTd6PTNyLw|q&x0`z}#bOx)n>w*$TQ`<-
z70e#o2+JS^<S|7T<;Zgb64KgnmlX4_qoP7ktr$gQXO=Kq&L9@|i9#208jT{Rj40H3
zc{6MNa3^YY13g`FB#F5=6I?H>UwV3bo)H0R^ow;4<Q5mPl*{2_CPUR8d9}N%>u8lA
z#5Wkag^`=KqMZtM>Rk`AzwU$@GQ@6#P{WNy*%8+vXI0-URkApC>M(M(DFl5%Q9sS!
zi+hO_I2f~UcOo~TVMDiy-i{X3YVJ_QZ*I11-TK@6?z``$NF;)e&JJ;|bAY8ZpFGU)
zp8U|YL*HPJS)G>1krWA>Pu_7oY+n%*ucfj4egbN&Oa{pUE<$F*tp(o2W?aXpl3zUe
z6rTF+gNU^H1;&L8=l23OT<k{*$enD#?6DSj<2r_W+lj$c=v0GD2FyOYMn)dxCdO}x
zBGdySAJzw`iHWnleFG`cFu(WOKQwghi#f^)7wyt=0Xv2UaK~-epin8{(*9nQCxZwi
z4Y3i$HNen8W7|rhYmK_{Vu?gu_blS6Ff82>6P@F@`rn@*dd?&<^*X^)*0E(Uh1Nt=
zY}GS_Ir_KVw(I#@Zn^otFI+g!k{?r3lj2<Wim6hmta(qydT3%|V&jwF`qq1tg%E3>
z08y<<Hvat8gE)R@9_fuf^xrpzQ11$AOGGiI>~5Vn&6$hYC?K@H)4;968wAAn-iVI;
z`4&vP(u;h-z~;?iTtC!9ddv_REK;%3GKI$;du&T@Z|`JtTD%f5Ydi53ClAq8;$7m~
zi92`i{_5hwg4+#s9ZVc|-+2qR+?c@Z<r>cZbO7^5((r{XVyBvjKMkY=J$HhWcq5lZ
z8r=O<HB^O0{b}xS2Qn}82;+PEd>HQO0Pk1vD=03G1**wU|LLb6WaAk|&EjpI>oUOU
zx%%)@)Oqgx$+JKC>6z1KKg(}#7?=G1fH$;Wc<mh=AI&15gt6heB9b>wBivmSkRt|#
zWP0KE1S#Tm8ggtg<8m|1ooL6xxey$!hM{X>80u~lj<S5={=)&=vv=?J9(dq^FR^_b
zxT_D66>A;f7iw1@>?yzMBpItd`skyhOG``JTarnU;t0*Al5FG9iT81M^a57qWW-xE
zB(BLL+`EiGx(rWDhffKjp7*1i4UzFN8g9a<lr6;D91QoT(L++DSgKOwEit^)GmuOs
z-g@}qf7wOR$Nc<!V@Gc7!FH{2VD;7L)$d^&xSN@oPCWheGlyoTrw8Nlgu64%Lw3-I
z>A3|QK5+(PnMIVB`TeqnNZLfGqYR(NkIHNqg?UXdRWwP8vNMVvqRNopCos<q(<BE9
zg*-YoY&fy=)}1?VzWJ6Kzuac{eAp4^vF}O;ICa18V0z7Fv#}?hc;e03Y<5eMBH-?n
z*_4d{WrcKpb^&7(vzVODQPb0*S7d}EBnhH2jjSqKlOeRHVn{?nsMcu4)(ycn2I-yo
zg?WBYee{v9fBlX`JW*xXd|2eKwC5u`AT{0_<cS&HeDgP$o16Rb=;*P}^S#_$#QmgK
z!l9tpepxOQF}JXS`K3H%D1byXK;lj&Y3!rH#1wF`E%${7O9MXdy8Esthc|D2;MQA5
z1Or~(aj!m_N<qa(J;(u8guLj!eV@8Nm1=$Y(4j-$&n@NxtYrDUSAnz?Gl-AIs{V}~
z=<804`)V}Z@b5}heHE%Tig2-5BJI_?LK^4uTefU@iK&vG!49y-`Y6Xocc768&J-6K
ziL2+*?dcbf9zOEVm&V8cfj;wa5qf<-5!p($=Du%kyoGPl9~;PcPo-KaJ9pml$i991
zzFDbO?a9lNVvPNLu=#&Ga7CvRX(w1!`v(RdP9&56PV@6$W@obZ6TF7FDY681_gEE_
zN+m&sEKx}9o*En&_yJ8;-`~)&A<wU$@+<kq4z%=tHsud<KyvqHIUD?sH7N4A;o;$L
z&`j|Sg2DFbsp)+Siwn0dEG&Lp(^M;+ZaYG}eUN1Ot6rb?4F5BpsmUoJ9+S*yl7v6>
c_#XiV0CwRC6nI;bQ~&?~07*qoM6N<$g4ErHNdN!<
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/content/about-studies/shield-studies.js
@@ -0,0 +1,148 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+/* global classnames FxButton InfoBox PropTypes r React remoteValues sendPageEvent */
+
+window.ShieldStudies = class ShieldStudies extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      learnMoreHref: null,
+    };
+  }
+
+  componentDidMount() {
+    remoteValues.ShieldLearnMoreHref.subscribe(this);
+  }
+
+  componentWillUnmount() {
+    remoteValues.ShieldLearnMoreHref.unsubscribe(this);
+  }
+
+  receiveRemoteValue(name, value) {
+    this.setState({
+      learnMoreHref: value,
+    });
+  }
+
+  render() {
+    return (
+      r("div", {},
+        r(InfoBox, {},
+          r("span", {}, "What's this? Firefox may install and run studies from time to time."),
+          r("a", {id: "shield-studies-learn-more", href: this.state.learnMoreHref}, "Learn more"),
+          r(UpdatePreferencesButton, {}, "Update Preferences"),
+        ),
+        r(StudyList),
+      )
+    );
+  }
+};
+
+class UpdatePreferencesButton extends React.Component {
+  constructor(props) {
+    super(props);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleClick() {
+    sendPageEvent("NavigateToDataPreferences");
+  }
+
+  render() {
+    return r(
+      FxButton,
+      Object.assign({
+        id: "shield-studies-update-preferences",
+        onClick: this.handleClick,
+      }, this.props),
+    );
+  }
+}
+
+class StudyList extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      studies: [],
+    };
+  }
+
+  componentDidMount() {
+    remoteValues.StudyList.subscribe(this);
+  }
+
+  componentWillUnmount() {
+    remoteValues.StudyList.unsubscribe(this);
+  }
+
+  receiveRemoteValue(name, value) {
+    const studies = value.slice();
+
+    // Sort by active status, then by start date descending.
+    studies.sort((a, b) => {
+      if (a.active !== b.active) {
+        return a.active ? -1 : 1;
+      }
+      return b.studyStartDate - a.studyStartDate;
+    });
+
+    this.setState({studies});
+  }
+
+  render() {
+    return (
+      r("ul", {className: "study-list"},
+        this.state.studies.map(study => (
+          r(StudyListItem, {key: study.name, study})
+        ))
+      )
+    );
+  }
+}
+
+class StudyListItem extends React.Component {
+  constructor(props) {
+    super(props);
+    this.handleClickRemove = this.handleClickRemove.bind(this);
+  }
+
+  handleClickRemove() {
+    sendPageEvent("RemoveStudy", this.props.study.recipeId);
+  }
+
+  render() {
+    const study = this.props.study;
+    return (
+      r("li", {
+        className: classnames("study", {disabled: !study.active}),
+        "data-study-name": study.name,
+      },
+        r("div", {className: "study-icon"},
+          study.name.slice(0, 1)
+        ),
+        r("div", {className: "study-details"},
+          r("div", {className: "study-name"}, study.name),
+          r("div", {className: "study-description", title: study.description},
+            r("span", {className: "study-status"}, study.active ? "Active" : "Complete"),
+            r("span", {}, "\u2022"), // &bullet;
+            r("span", {}, study.description),
+          ),
+        ),
+        r("div", {className: "study-actions"},
+          study.active &&
+            r(FxButton, {className: "remove-button", onClick: this.handleClickRemove}, "Remove"),
+        ),
+      )
+    );
+  }
+}
+StudyListItem.propTypes = {
+  study: PropTypes.shape({
+    recipeId: PropTypes.number.isRequired,
+    name: PropTypes.string.isRequired,
+    active: PropTypes.boolean,
+    description: PropTypes.string.isRequired,
+  }).isRequired,
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/content/shield-content-frame.js
@@ -0,0 +1,125 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+/**
+ * Listen for DOM events bubbling up from the about:studies page, and perform
+ * privileged actions in response to them. If we need to do anything that the
+ * content process can't handle (such as reading IndexedDB), we send a message
+ * to the parent process and handle it there.
+ *
+ * This file is loaded as a frame script. It will be loaded once per tab that
+ * is opened.
+ */
+
+/* global content addMessageListener removeMessageListener sendAsyncMessage */
+
+const { utils: Cu } = Components;
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+const frameGlobal = {};
+XPCOMUtils.defineLazyModuleGetter(
+  frameGlobal, "AboutPages", "resource://shield-recipe-client-content/AboutPages.jsm",
+);
+
+const USE_OLD_PREF_ORGANIZATION_PREF = "browser.preferences.useOldOrganization";
+
+/**
+ * Handles incoming events from the parent process and about:studies.
+ * @implements nsIMessageListener
+ * @implements EventListener
+ */
+class ShieldFrameListener {
+  handleEvent(event) {
+    // Abort if the current page isn't about:studies.
+    if (!this.ensureTrustedOrigin()) {
+      return;
+    }
+
+    // We waited until after we received an event to register message listeners
+    // in order to save resources for tabs that don't ever load about:studies.
+    addMessageListener("Shield:ShuttingDown", this);
+    addMessageListener("Shield:ReceiveStudyList", this);
+
+    switch (event.detail.action) {
+      // Actions that require the parent process
+      case "GetRemoteValue:StudyList":
+        sendAsyncMessage("Shield:GetStudyList");
+        break;
+      case "RemoveStudy":
+        sendAsyncMessage("Shield:RemoveStudy", event.detail.data);
+        break;
+      // Actions that can be performed in the content process
+      case "GetRemoteValue:ShieldLearnMoreHref":
+        this.triggerPageCallback(
+          "ReceiveRemoteValue:ShieldLearnMoreHref",
+          frameGlobal.AboutPages.aboutStudies.getShieldLearnMoreHref()
+        );
+        break;
+      case "NavigateToDataPreferences":
+        this.navigateToDataPreferences();
+        break;
+    }
+  }
+
+  /**
+   * Check that the current webpage's origin is about:studies.
+   * @return {Boolean}
+   */
+  ensureTrustedOrigin() {
+    return content.document.documentURI.startsWith("about:studies");
+  }
+
+  /**
+   * Handle messages from the parent process.
+   * @param {Object} message
+   *   See the nsIMessageListener docs.
+   */
+  receiveMessage(message) {
+    switch (message.name) {
+      case "Shield:ReceiveStudyList":
+        this.triggerPageCallback("ReceiveRemoteValue:StudyList", message.data.studies);
+        break;
+      case "Shield:ShuttingDown":
+        this.onShutdown();
+        break;
+    }
+  }
+
+  /**
+   * Trigger an event to communicate with the unprivileged about: page.
+   * @param {String} type
+   * @param {Object} detail
+   */
+  triggerPageCallback(type, detail) {
+    // Do not communicate with untrusted pages.
+    if (!this.ensureTrustedOrigin()) {
+      return;
+    }
+
+    // Clone details and use the event class from the unprivileged context.
+    const event = new content.document.defaultView.CustomEvent(type, {
+      bubbles: true,
+      detail: Cu.cloneInto(detail, content.document.defaultView),
+    });
+    content.document.dispatchEvent(event);
+  }
+
+  onShutdown() {
+    removeMessageListener("Shield:SendStudyList", this);
+    removeMessageListener("Shield:ShuttingDown", this);
+    removeEventListener("Shield", this);
+  }
+
+  navigateToDataPreferences() {
+    if (Services.prefs.getBoolPref(USE_OLD_PREF_ORGANIZATION_PREF)) {
+      sendAsyncMessage("Shield:OpenOldDataPreferences");
+    } else {
+      content.location = "about:preferences#privacy-reports";
+    }
+  }
+}
+
+addEventListener("ShieldPageEvent", new ShieldFrameListener(), false, true);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/content/shield-content-process.js
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+/**
+ * Registers about: pages provided by Shield, and listens for a shutdown event
+ * from the add-on before un-registering them.
+ *
+ * This file is loaded as a process script. It is executed once for each
+ * process, including the parent one.
+ */
+
+const { utils: Cu } = Components;
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://shield-recipe-client-content/AboutPages.jsm");
+
+class ShieldChildListener {
+  onStartup() {
+    Services.cpmm.addMessageListener("Shield:ShuttingDown", this, true);
+    AboutPages.aboutStudies.register();
+  }
+
+  onShutdown() {
+    AboutPages.aboutStudies.unregister();
+    Services.cpmm.removeMessageListener("Shield:ShuttingDown", this);
+
+    // Unload AboutPages.jsm in case the add-on is reinstalled and we need to
+    // load a new version of it.
+    Cu.unload("resource://shield-recipe-client-content/AboutPages.jsm");
+  }
+
+  receiveMessage(message) {
+    switch (message.name) {
+      case "Shield:ShuttingDown":
+        this.onShutdown();
+        break;
+    }
+  }
+}
+
+// Only register in content processes; the parent process handles registration
+// separately.
+if (Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_CONTENT) {
+  const listener = new ShieldChildListener();
+  listener.onStartup();
+}
--- a/browser/extensions/shield-recipe-client/install.rdf.in
+++ b/browser/extensions/shield-recipe-client/install.rdf.in
@@ -3,17 +3,17 @@
 #filter substitution
 
 <RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:em="http://www.mozilla.org/2004/em-rdf#">
   <Description about="urn:mozilla:install-manifest">
     <em:id>shield-recipe-client@mozilla.org</em:id>
     <em:type>2</em:type>
     <em:bootstrap>true</em:bootstrap>
     <em:unpack>false</em:unpack>
-    <em:version>55.1</em:version>
+    <em:version>65</em:version>
     <em:name>Shield Recipe Client</em:name>
     <em:description>Client to download and run recipes for SHIELD, Heartbeat, etc.</em:description>
     <em:multiprocessCompatible>true</em:multiprocessCompatible>
 
     <em:targetApplication>
       <Description>
         <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id>
         <em:minVersion>@MOZ_APP_VERSION@</em:minVersion>
--- a/browser/extensions/shield-recipe-client/jar.mn
+++ b/browser/extensions/shield-recipe-client/jar.mn
@@ -1,9 +1,13 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 [features/shield-recipe-client@mozilla.org] chrome.jar:
-% resource shield-recipe-client %content/
-  content/lib/ (./lib/*)
-  content/node_modules/jexl/ (./node_modules/jexl/*)
-  content/skin/  (skin/*)
+% resource shield-recipe-client %
+  lib/ (./lib/*)
+  data/ (./data/*)
+  skin/  (skin/*)
+% resource shield-recipe-client-content %content/
+  content/ (./content/*)
+% resource shield-recipe-client-vendor %vendor/
+  vendor/ (./vendor/*)
--- a/browser/extensions/shield-recipe-client/lib/ActionSandboxManager.jsm
+++ b/browser/extensions/shield-recipe-client/lib/ActionSandboxManager.jsm
@@ -67,18 +67,14 @@ this.ActionSandboxManager = class extend
   async runAsyncCallback(callbackName, ...args) {
     const callbackWasRegistered = this.evalInSandbox(`asyncCallbacks.has("${callbackName}")`);
     if (!callbackWasRegistered) {
       log.debug(`Script did not register a callback with the name "${callbackName}"`);
       return undefined;
     }
 
     this.cloneIntoGlobal("callbackArgs", args);
-    try {
-      const result = await this.evalInSandbox(`
-        asyncCallbacks.get("${callbackName}")(sandboxedDriver, ...callbackArgs);
-      `);
-      return Cu.cloneInto(result, {});
-    } catch (err) {
-      throw new Error(Cu.cloneInto(err.message, {}));
-    }
+    const result = await this.evalInSandbox(`
+      asyncCallbacks.get("${callbackName}")(sandboxedDriver, ...callbackArgs);
+    `);
+    return Cu.cloneInto(result, {});
   }
 };
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/AddonStudies.jsm
@@ -0,0 +1,324 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+/**
+ * @typedef {Object} Study
+ * @property {Number} recipeId
+ *   ID of the recipe that created the study. Used as the primary key of the
+ *   study.
+ * @property {string} name
+ *   Name of the study
+ * @property {string} description
+ *   Description of the study and its intent.
+ * @property {boolean} active
+ *   Is the study still running?
+ * @property {string} addonId
+ *   Add-on ID for this particular study.
+ * @property {string} addonUrl
+ *   URL that the study add-on was installed from.
+ * @property {string} addonVersion
+ *   Study add-on version number
+ * @property {string} studyStartDate
+ *   Date when the study was started.
+ * @property {Date} studyEndDate
+ *   Date when the study was ended.
+ */
+
+const {utils: Cu, interfaces: Ci} = Components;
+Cu.import("resource://gre/modules/osfile.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", "resource://gre/modules/FileUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "IndexedDB", "resource://gre/modules/IndexedDB.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", "resource://gre/modules/AddonManager.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Addons", "resource://shield-recipe-client/lib/Addons.jsm");
+XPCOMUtils.defineLazyModuleGetter(
+  this, "CleanupManager", "resource://shield-recipe-client/lib/CleanupManager.jsm"
+);
+XPCOMUtils.defineLazyModuleGetter(this, "LogManager", "resource://shield-recipe-client/lib/LogManager.jsm");
+
+Cu.importGlobalProperties(["fetch"]); /* globals fetch */
+
+this.EXPORTED_SYMBOLS = ["AddonStudies"];
+
+const DB_NAME = "shield";
+const STORE_NAME = "addon-studies";
+const DB_OPTIONS = {
+  version: 1,
+  storage: "persistent",
+};
+const STUDY_ENDED_TOPIC = "shield-study-ended";
+const log = LogManager.getLogger("addon-studies");
+
+/**
+ * Create a new connection to the database.
+ */
+function openDatabase() {
+  return IndexedDB.open(DB_NAME, DB_OPTIONS, db => {
+    db.createObjectStore(STORE_NAME, {
+      keyPath: "recipeId",
+    });
+  });
+}
+
+/**
+ * Cache the database connection so that it is shared among multiple operations.
+ */
+let databasePromise;
+async function getDatabase() {
+  if (!databasePromise) {
+    databasePromise = openDatabase();
+  }
+  return databasePromise;
+}
+
+/**
+ * Get a transaction for interacting with the study store.
+ *
+ * NOTE: Methods on the store returned by this function MUST be called
+ * synchronously, otherwise the transaction with the store will expire.
+ * This is why the helper takes a database as an argument; if we fetched the
+ * database in the helper directly, the helper would be async and the
+ * transaction would expire before methods on the store were called.
+ */
+function getStore(db) {
+  return db.objectStore(STORE_NAME, "readwrite");
+}
+
+/**
+ * Mark a study object as having ended. Modifies the study in-place.
+ * @param {IDBDatabase} db
+ * @param {Study} study
+ */
+async function markAsEnded(db, study) {
+  study.active = false;
+  study.studyEndDate = new Date();
+  await getStore(db).put(study);
+  Services.obs.notifyObservers(study, STUDY_ENDED_TOPIC);
+}
+
+this.AddonStudies = {
+  /**
+   * Test wrapper that temporarily replaces the stored studies with the given
+   * ones. The original stored studies are restored upon completion.
+   *
+   * This is defined here instead of in test code since it needs to access the
+   * getDatabase, which we don't expose to avoid outside modules relying on the
+   * type of storage used for studies.
+   *
+   * @param {Array} [studies=[]]
+   */
+  withStudies(studies = []) {
+    return function wrapper(testFunction) {
+      return async function wrappedTestFunction(...args) {
+        const oldStudies = await AddonStudies.getAll();
+        let db = await getDatabase();
+        await AddonStudies.clear();
+        for (const study of studies) {
+          await getStore(db).add(study);
+        }
+
+        try {
+          await testFunction(...args, studies);
+        } finally {
+          db = await getDatabase(); // Re-acquire in case the test closed the connection.
+          await AddonStudies.clear();
+          for (const study of oldStudies) {
+            await getStore(db).add(study);
+          }
+
+          await AddonStudies.close();
+        }
+      };
+    };
+  },
+
+  async init() {
+    // If an active study's add-on has been removed since we last ran, stop the
+    // study.
+    const activeStudies = (await this.getAll()).filter(study => study.active);
+    const db = await getDatabase();
+    for (const study of activeStudies) {
+      const addon = await AddonManager.getAddonByID(study.addonId);
+      if (!addon) {
+        await markAsEnded(db, study);
+      }
+    }
+    await this.close();
+
+    // Listen for add-on uninstalls so we can stop the corresponding studies.
+    AddonManager.addAddonListener(this);
+    CleanupManager.addCleanupHandler(() => {
+      AddonManager.removeAddonListener(this);
+    });
+  },
+
+  /**
+   * If a study add-on is uninstalled, mark the study as having ended.
+   * @param {Addon} addon
+   */
+  async onUninstalled(addon) {
+    const activeStudies = (await this.getAll()).filter(study => study.active);
+    const matchingStudy = activeStudies.find(study => study.addonId === addon.id);
+    if (matchingStudy) {
+      // Use a dedicated DB connection instead of the shared one so that we can
+      // close it without fear of affecting other users of the shared connection.
+      const db = await openDatabase();
+      await markAsEnded(db, matchingStudy);
+      await db.close();
+    }
+  },
+
+  /**
+   * Remove all stored studies.
+   */
+  async clear() {
+    const db = await getDatabase();
+    await getStore(db).clear();
+  },
+
+  /**
+   * Close the current database connection if it is open.
+   */
+  async close() {
+    if (databasePromise) {
+      const promise = databasePromise;
+      databasePromise = null;
+      const db = await promise;
+      await db.close();
+    }
+  },
+
+  /**
+   * Test whether there is a study in storage for the given recipe ID.
+   * @param {Number} recipeId
+   * @returns {Boolean}
+   */
+  async has(recipeId) {
+    const db = await getDatabase();
+    const study = await getStore(db).get(recipeId);
+    return !!study;
+  },
+
+  /**
+   * Fetch a study from storage.
+   * @param {Number} recipeId
+   * @return {Study}
+   */
+  async get(recipeId) {
+    const db = await getDatabase();
+    return getStore(db).get(recipeId);
+  },
+
+  /**
+   * Fetch all studies in storage.
+   * @return {Array<Study>}
+   */
+  async getAll() {
+    const db = await getDatabase();
+    return getStore(db).getAll();
+  },
+
+  /**
+   * Start a new study. Installs an add-on and stores the study info.
+   * @param {Object} options
+   * @param {Number} options.recipeId
+   * @param {String} options.name
+   * @param {String} options.description
+   * @param {String} options.addonUrl
+   * @throws
+   *   If any of the required options aren't given.
+   *   If a study for the given recipeID already exists in storage.
+   *   If add-on installation fails.
+   */
+  async start({recipeId, name, description, addonUrl}) {
+    if (!recipeId || !name || !description || !addonUrl) {
+      throw new Error("Required arguments (recipeId, name, description, addonUrl) missing.");
+    }
+
+    const db = await getDatabase();
+    if (await getStore(db).get(recipeId)) {
+      throw new Error(`A study for recipe ${recipeId} already exists.`);
+    }
+
+    const addonFile = await this.downloadAddonToTemporaryFile(addonUrl);
+    const install = await AddonManager.getInstallForFile(addonFile);
+    const study = {
+      recipeId,
+      name,
+      description,
+      addonId: install.addon.id,
+      addonVersion: install.addon.version,
+      addonUrl,
+      active: true,
+      studyStartDate: new Date(),
+    };
+
+    try {
+      await getStore(db).add(study);
+      await Addons.applyInstall(install, false);
+      return study;
+    } catch (err) {
+      await getStore(db).delete(recipeId);
+      throw err;
+    } finally {
+      Services.obs.notifyObservers(addonFile, "flush-cache-entry");
+      await OS.File.remove(addonFile.path);
+    }
+  },
+
+  /**
+   * Download a remote add-on and store it in a temporary nsIFile.
+   * @param {String} addonUrl
+   * @returns {nsIFile}
+   */
+  async downloadAddonToTemporaryFile(addonUrl) {
+    const response = await fetch(addonUrl);
+    if (!response.ok) {
+      throw new Error(`Download for ${addonUrl} failed: ${response.status} ${response.statusText}`);
+    }
+
+    // Create temporary file to store add-on.
+    const path = OS.Path.join(OS.Constants.Path.tmpDir, "study.xpi");
+    const {file, path: uniquePath} = await OS.File.openUnique(path);
+
+    // Write the add-on to the file
+    try {
+      const xpiArrayBufferView = new Uint8Array(await response.arrayBuffer());
+      await file.write(xpiArrayBufferView);
+    } finally {
+      await file.close();
+    }
+
+    return new FileUtils.File(uniquePath);
+  },
+
+  /**
+   * Stop an active study, uninstalling the associated add-on.
+   * @param {Number} recipeId
+   * @throws
+   *   If no study is found with the given recipeId.
+   *   If the study is already inactive.
+   */
+  async stop(recipeId) {
+    const db = await getDatabase();
+    const study = await getStore(db).get(recipeId);
+    if (!study) {
+      throw new Error(`No study found for recipe ${recipeId}`);
+    }
+    if (!study.active) {
+      throw new Error(`Cannot stop study for recipe ${recipeId}; it is already inactive.`);
+    }
+
+    await markAsEnded(db, study);
+
+    try {
+      await Addons.uninstall(study.addonId);
+    } catch (err) {
+      log.warn(`Could not uninstall addon ${study.addonId} for recipe ${study.recipeId}:`, err);
+    }
+  },
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/Addons.jsm
@@ -0,0 +1,135 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", "resource://gre/modules/AddonManager.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Extension", "resource://gre/modules/Extension.jsm");
+XPCOMUtils.defineLazyModuleGetter(
+  this, "CleanupManager", "resource://shield-recipe-client/lib/CleanupManager.jsm"
+);
+
+this.EXPORTED_SYMBOLS = ["Addons"];
+
+/**
+ * SafeAddons store info about an add-on. They are single-depth
+ * objects to simplify cloning, and have no methods so they are safe
+ * to pass to sandboxes and filter expressions.
+ *
+ * @typedef {Object} SafeAddon
+ * @property {string} id
+ *   Add-on id, such as "shield-recipe-client@mozilla.com" or "{4ea51ac2-adf2-4af8-a69d-17b48c558a12}"
+ * @property {Date} installDate
+ * @property {boolean} isActive
+ * @property {string} name
+ * @property {string} type
+ *   "extension", "theme", etc.
+ * @property {string} version
+ */
+
+this.Addons = {
+  /**
+   * Get information about an installed add-on by ID.
+   *
+   * @param {string} addonId
+   * @returns {SafeAddon?} Add-on with given ID, or null if not found.
+   * @throws If addonId is not specified or not a string.
+   */
+  async get(addonId) {
+    const addon = await AddonManager.getAddonByID(addonId);
+    if (!addon) {
+      return null;
+    }
+    return this.serializeForSandbox(addon);
+  },
+
+  /**
+   * Get information about all installed add-ons.
+   * @async
+   * @returns {Array<SafeAddon>}
+   */
+  async getAll(addonId) {
+    const addons = await AddonManager.getAllAddons();
+    return addons.map(this.serializeForSandbox.bind(this));
+  },
+
+  /**
+   * Installs an add-on
+   *
+   * @param {string} addonUrl
+   *   Url to download the .xpi for the add-on from.
+   * @param {object} options
+   * @param {boolean} options.update=false
+   *   If true, will update an existing installed add-on with the same ID.
+   * @async
+   * @returns {string}
+   *   Add-on ID that was installed
+   * @throws {string}
+   *   If the add-on can not be installed, or overwriting is disabled and an
+   *   add-on with a matching ID is already installed.
+   */
+  async install(addonUrl, options) {
+    const installObj = await AddonManager.getInstallForURL(addonUrl, null, "application/x-xpinstall");
+    return this.applyInstall(installObj, options);
+  },
+
+  async applyInstall(addonInstall, {update = false} = {}) {
+    const result = new Promise((resolve, reject) => addonInstall.addListener({
+      onInstallStarted(cbInstall) {
+        if (cbInstall.existingAddon && !update) {
+          reject(new Error(`
+            Cannot install add-on ${cbInstall.addon.id}; an existing add-on
+            with the same ID exists and updating is disabled.
+          `));
+          return false;
+        }
+        return true;
+      },
+      onInstallEnded(cbInstall, addon) {
+        resolve(addon.id);
+      },
+      onInstallFailed(cbInstall) {
+        reject(new Error(`AddonInstall error code: [${cbInstall.error}]`));
+      },
+      onDownloadFailed(cbInstall) {
+        reject(new Error(`Download failed: [${cbInstall.sourceURI.spec}]`));
+      },
+    }));
+    addonInstall.install();
+    return result;
+  },
+
+  /**
+   * Uninstalls an add-on by ID.
+   * @param addonId {string} Add-on ID to uninstall.
+   * @async
+   * @throws If no add-on with `addonId` is installed.
+   */
+  async uninstall(addonId) {
+    const addon = await AddonManager.getAddonByID(addonId);
+    if (addon === null) {
+      throw new Error(`No addon with ID [${addonId}] found.`);
+    }
+    addon.uninstall();
+    return null;
+  },
+
+  /**
+   * Make a safe serialization of an add-on
+   * @param addon {Object} An add-on object as returned from AddonManager.
+   */
+  serializeForSandbox(addon) {
+    return {
+      id: addon.id,
+      installDate: new Date(addon.installDate),
+      isActive: addon.isActive,
+      name: addon.name,
+      type: addon.type,
+      version: addon.version,
+    };
+  },
+};
--- a/browser/extensions/shield-recipe-client/lib/ClientEnvironment.jsm
+++ b/browser/extensions/shield-recipe-client/lib/ClientEnvironment.jsm
@@ -11,19 +11,20 @@ Cu.import("resource://gre/modules/XPCOMU
 
 XPCOMUtils.defineLazyModuleGetter(this, "ShellService", "resource:///modules/ShellService.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", "resource://gre/modules/AddonManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "TelemetryArchive", "resource://gre/modules/TelemetryArchive.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "NormandyApi", "resource://shield-recipe-client/lib/NormandyApi.jsm");
 XPCOMUtils.defineLazyModuleGetter(
     this,
     "PreferenceExperiments",
-    "resource://shield-recipe-client/lib/PreferenceExperiments.jsm",
+    "resource://shield-recipe-client/lib/PreferenceExperiments.jsm"
 );
 XPCOMUtils.defineLazyModuleGetter(this, "Utils", "resource://shield-recipe-client/lib/Utils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Addons", "resource://shield-recipe-client/lib/Addons.jsm");
 
 const {generateUUID} = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
 
 this.EXPORTED_SYMBOLS = ["ClientEnvironment"];
 
 // Cached API request for client attributes that are determined by the Normandy
 // service.
 let _classifyRequest = null;
@@ -31,17 +32,17 @@ let _classifyRequest = null;
 this.ClientEnvironment = {
   /**
    * Fetches information about the client that is calculated on the server,
    * like geolocation and the current time.
    *
    * The server request is made lazily and is cached for the entire browser
    * session.
    */
-  getClientClassification() {
+  async getClientClassification() {
     if (!_classifyRequest) {
       _classifyRequest = NormandyApi.classifyClient();
     }
     return _classifyRequest;
   },
 
   clearClassifyCache() {
     _classifyRequest = null;
@@ -192,15 +193,20 @@ this.ClientEnvironment = {
         } else {
           names.active.push(experiment.name);
         }
       }
 
       return names;
     });
 
+    XPCOMUtils.defineLazyGetter(environment, "addons", async () => {
+      const addons = await Addons.getAll();
+      return Utils.keyBy(addons, "id");
+    });
+
     XPCOMUtils.defineLazyGetter(environment, "isFirstRun", () => {
       return Preferences.get("extensions.shield-recipe-client.first_run");
     });
 
     return environment;
   },
 };
--- a/browser/extensions/shield-recipe-client/lib/FilterExpressions.jsm
+++ b/browser/extensions/shield-recipe-client/lib/FilterExpressions.jsm
@@ -4,40 +4,62 @@
 
 "use strict";
 
 const {utils: Cu} = Components;
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://shield-recipe-client/lib/Sampling.jsm");
 Cu.import("resource://shield-recipe-client/lib/PreferenceFilters.jsm");
 
+XPCOMUtils.defineLazyModuleGetter(this, "mozjexl", "resource://shield-recipe-client-vendor/mozjexl.js");
+
 this.EXPORTED_SYMBOLS = ["FilterExpressions"];
 
-XPCOMUtils.defineLazyGetter(this, "nodeRequire", () => {
-  const {Loader, Require} = Cu.import("resource://gre/modules/commonjs/toolkit/loader.js", {});
-  const loader = new Loader({
-    paths: {
-      "": "resource://shield-recipe-client/node_modules/",
-    },
-  });
-  return new Require(loader, {});
-});
-
 XPCOMUtils.defineLazyGetter(this, "jexl", () => {
-  const {Jexl} = nodeRequire("jexl/lib/Jexl.js");
-  const jexl = new Jexl();
+  const jexl = new mozjexl.Jexl();
   jexl.addTransforms({
     date: dateString => new Date(dateString),
     stableSample: Sampling.stableSample,
     bucketSample: Sampling.bucketSample,
     preferenceValue: PreferenceFilters.preferenceValue,
     preferenceIsUserSet: PreferenceFilters.preferenceIsUserSet,
     preferenceExists: PreferenceFilters.preferenceExists,
+    keys,
   });
+  jexl.addBinaryOp("intersect", 40, operatorIntersect);
   return jexl;
 });
 
 this.FilterExpressions = {
   eval(expr, context = {}) {
     const onelineExpr = expr.replace(/[\t\n\r]/g, " ");
     return jexl.eval(onelineExpr, context);
   },
 };
+
+/**
+ * Return an array of the given object's own keys (specifically, its enumerable
+ * properties), or undefined if the argument isn't an object.
+ * @param {Object} obj
+ * @return {Array[String]|undefined}
+ */
+function keys(obj) {
+  if (typeof obj !== "object" || obj === null) {
+    return undefined;
+  }
+
+  return Object.keys(obj);
+}
+
+/**
+ * Find all the values that are present in both lists. Returns undefined if
+ * the arguments are not both Arrays.
+ * @param {Array} listA
+ * @param {Array} listB
+ * @return {Array|undefined}
+ */
+function operatorIntersect(listA, listB) {
+  if (!Array.isArray(listA) || !Array.isArray(listB)) {
+    return undefined;
+  }
+
+  return listA.filter(item => listB.includes(item));
+}
--- a/browser/extensions/shield-recipe-client/lib/NormandyApi.jsm
+++ b/browser/extensions/shield-recipe-client/lib/NormandyApi.jsm
@@ -1,29 +1,34 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 const {utils: Cu, classes: Cc, interfaces: Ci} = Components;
 Cu.import("resource://gre/modules/Services.jsm");
-Cu.import("resource://gre/modules/CanonicalJSON.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://shield-recipe-client/lib/LogManager.jsm");
-Cu.import("resource://shield-recipe-client/lib/Utils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(
+  this, "CanonicalJSON", "resource://gre/modules/CanonicalJSON.jsm");
+
 Cu.importGlobalProperties(["fetch", "URL"]); /* globals fetch, URL */
 
 this.EXPORTED_SYMBOLS = ["NormandyApi"];
 
 const log = LogManager.getLogger("normandy-api");
 const prefs = Services.prefs.getBranch("extensions.shield-recipe-client.");
 
 let indexPromise = null;
 
 this.NormandyApi = {
+  InvalidSignatureError: class InvalidSignatureError extends Error {},
+
   clearIndexCache() {
     indexPromise = null;
   },
 
   apiCall(method, endpoint, data = {}) {
     const url = new URL(endpoint);
     method = method.toLowerCase();
 
@@ -58,95 +63,157 @@ this.NormandyApi = {
     } else if (url.startsWith("/")) {
       return server + url;
     }
     throw new Error("Can't use relative urls");
   },
 
   async getApiUrl(name) {
     if (!indexPromise) {
-      let apiBase = new URL(prefs.getCharPref("api_url"));
+      const apiBase = new URL(prefs.getCharPref("api_url"));
       if (!apiBase.pathname.endsWith("/")) {
         apiBase.pathname += "/";
       }
       indexPromise = this.get(apiBase.toString()).then(res => res.json());
     }
     const index = await indexPromise;
     if (!(name in index)) {
       throw new Error(`API endpoint with name "${name}" not found.`);
     }
     const url = index[name];
     return this.absolutify(url);
   },
 
-  async fetchRecipes(filters = {enabled: true}) {
-    const signedRecipesUrl = await this.getApiUrl("recipe-signed");
-    const recipesResponse = await this.get(signedRecipesUrl, filters);
-    const rawText = await recipesResponse.text();
-    const recipesWithSigs = JSON.parse(rawText);
+  async fetchSignedObjects(type, filters) {
+    const signedObjectsUrl = await this.getApiUrl(`${type}-signed`);
+    const objectsResponse = await this.get(signedObjectsUrl, filters);
+    const rawText = await objectsResponse.text();
+    const objectsWithSigs = JSON.parse(rawText);
+
+    const verifiedObjects = [];
 
-    const verifiedRecipes = [];
+    for (const objectWithSig of objectsWithSigs) {
+      const {signature, x5u} = objectWithSig.signature;
+      const object = objectWithSig[type];
 
-    for (const {recipe, signature: {signature, x5u}} of recipesWithSigs) {
-      const serialized = CanonicalJSON.stringify(recipe);
+      const serialized = CanonicalJSON.stringify(object);
+      // Check that the rawtext (the object and the signature)
+      // includes the CanonicalJSON version of the object. This isn't
+      // strictly needed, but it is a great benefit for debugging
+      // signature problems.
       if (!rawText.includes(serialized)) {
         log.debug(rawText, serialized);
-        throw new Error("Canonical recipe serialization does not match!");
+        throw new NormandyApi.InvalidSignatureError(
+          `Canonical ${type} serialization does not match!`);
       }
 
-      const certChainResponse = await fetch(this.absolutify(x5u));
+      const certChainResponse = await this.get(this.absolutify(x5u));
       const certChain = await certChainResponse.text();
       const builtSignature = `p384ecdsa=${signature}`;
 
       const verifier = Cc["@mozilla.org/security/contentsignatureverifier;1"]
         .createInstance(Ci.nsIContentSignatureVerifier);
 
-      const valid = verifier.verifyContentSignature(
-        serialized,
-        builtSignature,
-        certChain,
-        "normandy.content-signature.mozilla.org"
-      );
+      let valid;
+      try {
+        valid = verifier.verifyContentSignature(
+          serialized,
+          builtSignature,
+          certChain,
+          "normandy.content-signature.mozilla.org"
+        );
+      } catch (err) {
+        throw new NormandyApi.InvalidSignatureError(`${type} signature validation failed: ${err}`);
+      }
+
       if (!valid) {
-        throw new Error("Recipe signature is not valid");
+        throw new NormandyApi.InvalidSignatureError(`${type} signature is not valid`);
       }
-      verifiedRecipes.push(recipe);
+
+      verifiedObjects.push(object);
     }
 
     log.debug(
-      `Fetched ${verifiedRecipes.length} recipes from the server:`,
-      verifiedRecipes.map(r => r.name).join(", ")
+      `Fetched ${verifiedObjects.length} ${type} from the server:`,
+      verifiedObjects.map(r => r.name).join(", ")
     );
 
-    return verifiedRecipes;
+    return verifiedObjects;
   },
 
   /**
    * Fetch metadata about this client determined by the server.
    * @return {object} Metadata specified by the server
    */
   async classifyClient() {
     const classifyClientUrl = await this.getApiUrl("classify-client");
     const response = await this.get(classifyClientUrl);
     const clientData = await response.json();
     clientData.request_time = new Date(clientData.request_time);
     return clientData;
   },
 
   /**
    * Fetch an array of available actions from the server.
+   * @param filters
+   * @param filters.enabled {boolean} If true, only returns enabled
+   * recipes. Default true.
    * @resolves {Array}
    */
-  async fetchActions() {
-    const actionApiUrl = await this.getApiUrl("action-list");
-    const res = await this.get(actionApiUrl);
-    return res.json();
+  async fetchRecipes(filters = {enabled: true}) {
+    return this.fetchSignedObjects("recipe", filters);
+  },
+
+  /**
+   * Fetch an array of available actions from the server.
+   * @resolves {Array}
+   */
+  async fetchActions(filters = {}) {
+    return this.fetchSignedObjects("action", filters);
   },
 
   async fetchImplementation(action) {
-    const response = await fetch(action.implementation_url);
-    if (response.ok) {
-      return response.text();
+    const implementationUrl = new URL(this.absolutify(action.implementation_url));
+
+    // fetch implementation
+    const response = await fetch(implementationUrl);
+    if (!response.ok) {
+      throw new Error(
+        `Failed to fetch action implementation for ${action.name}: ${response.status}`
+      );
+    }
+    const responseText = await response.text();
+
+    // Try to verify integrity of the implementation text.  If the
+    // integrity value doesn't match the content or uses an unknown
+    // algorithm, fail.
+
+    // Get the last non-empty portion of the url path, and split it
+    // into two to get the aglorithm and hash.
+    const parts = implementationUrl.pathname.split("/");
+    const lastNonEmpty = parts.filter(p => p !== "").slice(-1)[0];
+    const [algorithm, ...hashParts] = lastNonEmpty.split("-");
+    const expectedHash = hashParts.join("-");
+
+    if (algorithm !== "sha384") {
+      throw new Error(
+        `Failed to fetch action implemenation for ${action.name}: ` +
+        `Unexpected integrity algorithm, expected "sha384", got ${algorithm}`
+      );
     }
 
-    throw new Error(`Failed to fetch action implementation for ${action.name}: ${response.status}`);
+    // verify integrity hash
+    const hasher = Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash);
+    hasher.init(hasher.SHA384);
+    const dataToHash = new TextEncoder().encode(responseText);
+    hasher.update(dataToHash, dataToHash.length);
+    const useBase64 = true;
+    const hash = hasher.finish(useBase64).replace(/\+/g, "-").replace(/\//g, "_");
+    if (hash !== expectedHash) {
+      throw new Error(
+        `Failed to fetch action implementation for ${action.name}: ` +
+        `Integrity hash does not match content. Expected ${expectedHash} got ${hash}.`
+      );
+    }
+
+    return responseText;
   },
 };
--- a/browser/extensions/shield-recipe-client/lib/NormandyDriver.jsm
+++ b/browser/extensions/shield-recipe-client/lib/NormandyDriver.jsm
@@ -1,29 +1,34 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Preferences.jsm");
 Cu.import("resource:///modules/ShellService.jsm");
 Cu.import("resource://gre/modules/AddonManager.jsm");
 Cu.import("resource://gre/modules/Timer.jsm");
+Cu.import("resource://shield-recipe-client/lib/Addons.jsm");
 Cu.import("resource://shield-recipe-client/lib/LogManager.jsm");
 Cu.import("resource://shield-recipe-client/lib/Storage.jsm");
 Cu.import("resource://shield-recipe-client/lib/Heartbeat.jsm");
 Cu.import("resource://shield-recipe-client/lib/FilterExpressions.jsm");
 Cu.import("resource://shield-recipe-client/lib/ClientEnvironment.jsm");
 Cu.import("resource://shield-recipe-client/lib/PreferenceExperiments.jsm");
 Cu.import("resource://shield-recipe-client/lib/Sampling.jsm");
 
+XPCOMUtils.defineLazyModuleGetter(
+  this, "AddonStudies", "resource://shield-recipe-client/lib/AddonStudies.jsm");
+
 const {generateUUID} = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
 
 this.EXPORTED_SYMBOLS = ["NormandyDriver"];
 
 const log = LogManager.getLogger("normandy-driver");
 const actionLog = LogManager.getLogger("normandy-driver.actions");
 
 this.NormandyDriver = function(sandboxManager) {
@@ -151,22 +156,74 @@ this.NormandyDriver = function(sandboxMa
       return Cu.cloneInto(token, sandbox);
     },
 
     clearTimeout(token) {
       clearTimeout(token);
       sandboxManager.removeHold(`setTimeout-${token}`);
     },
 
+    addons: {
+      get: sandboxManager.wrapAsync(Addons.get.bind(Addons), {cloneInto: true}),
+      install: sandboxManager.wrapAsync(Addons.install.bind(Addons)),
+      uninstall: sandboxManager.wrapAsync(Addons.uninstall.bind(Addons)),
+    },
+
     // Sampling
     ratioSample: sandboxManager.wrapAsync(Sampling.ratioSample),
 
     // Preference Experiment API
     preferenceExperiments: {
       start: sandboxManager.wrapAsync(PreferenceExperiments.start, {cloneArguments: true}),
       markLastSeen: sandboxManager.wrapAsync(PreferenceExperiments.markLastSeen),
       stop: sandboxManager.wrapAsync(PreferenceExperiments.stop),
       get: sandboxManager.wrapAsync(PreferenceExperiments.get, {cloneInto: true}),
       getAllActive: sandboxManager.wrapAsync(PreferenceExperiments.getAllActive, {cloneInto: true}),
       has: sandboxManager.wrapAsync(PreferenceExperiments.has),
     },
+
+    // Study storage API
+    studies: {
+      start: sandboxManager.wrapAsync(
+        AddonStudies.start.bind(AddonStudies),
+        {cloneArguments: true, cloneInto: true}
+      ),
+      stop: sandboxManager.wrapAsync(AddonStudies.stop.bind(AddonStudies)),
+      get: sandboxManager.wrapAsync(AddonStudies.get.bind(AddonStudies), {cloneInto: true}),
+      getAll: sandboxManager.wrapAsync(AddonStudies.getAll.bind(AddonStudies), {cloneInto: true}),
+      has: sandboxManager.wrapAsync(AddonStudies.has.bind(AddonStudies)),
+    },
+
+    // Preference read-only API
+    preferences: {
+      getBool: wrapPrefGetter(Services.prefs.getBoolPref),
+      getInt: wrapPrefGetter(Services.prefs.getIntPref),
+      getChar: wrapPrefGetter(Services.prefs.getCharPref),
+      has(name) {
+        return Services.prefs.getPrefType(name) !== Services.prefs.PREF_INVALID;
+      },
+    },
   };
 };
+
+/**
+ * Wrap a getter form nsIPrefBranch for use in the sandbox.
+ *
+ * We don't want to export the getters directly in case they add parameters that
+ * aren't safe for the sandbox without us noticing; wrapping helps prevent
+ * passing unknown parameters.
+ *
+ * @param {Function} getter
+ *   Function on an nsIPrefBranch that fetches a preference value.
+ * @return {Function}
+ */
+function wrapPrefGetter(getter) {
+  return (value, defaultValue = undefined) => {
+    // Passing undefined as the defaultValue disables throwing exceptions when
+    // the pref is missing or the type doesn't match, so we need to specifically
+    // exclude it if we don't want default value behavior.
+    const args = [value];
+    if (defaultValue !== undefined) {
+      args.push(defaultValue);
+    }
+    return getter.apply(null, args);
+  };
+}
--- a/browser/extensions/shield-recipe-client/lib/PreferenceExperiments.jsm
+++ b/browser/extensions/shield-recipe-client/lib/PreferenceExperiments.jsm
@@ -144,17 +144,17 @@ this.PreferenceExperiments = {
   async init() {
     for (const experiment of await this.getAllActive()) {
       // Set experiment default preferences, since they don't persist between restarts
       if (experiment.preferenceBranchType === "default") {
         setPref(DefaultPreferences, experiment.preferenceName, experiment.preferenceType, experiment.preferenceValue);
       }
 
       // Check that the current value of the preference is still what we set it to
-      if (getPref(UserPreferences, experiment.preferenceName, experiment.preferenceType) !== experiment.preferenceValue) {
+      if (getPref(UserPreferences, experiment.preferenceName, experiment.preferenceType, undefined) !== experiment.preferenceValue) {
         // if not, stop the experiment, and skip the remaining steps
         log.info(`Stopping experiment "${experiment.name}" because its value changed`);
         await this.stop(experiment.name, false);
         continue;
       }
 
       // Notify Telemetry of experiments we're running, since they don't persist between restarts
       TelemetryEnvironment.setExperimentActive(experiment.name, experiment.branch);
@@ -237,17 +237,17 @@ this.PreferenceExperiments = {
     const experiment = {
       name,
       branch,
       expired: false,
       lastSeen: new Date().toJSON(),
       preferenceName,
       preferenceValue,
       preferenceType,
-      previousPreferenceValue: getPref(preferences, preferenceName, preferenceType),
+      previousPreferenceValue: getPref(preferences, preferenceName, preferenceType, undefined),
       preferenceBranchType,
     };
 
     const prevPrefType = Services.prefs.getPrefType(preferenceName);
     const givenPrefType = PREFERENCE_TYPE_MAP[preferenceType];
 
     if (!preferenceType || !givenPrefType) {
       throw new Error(`Invalid preferenceType provided (given "${preferenceType}")`);
@@ -284,17 +284,17 @@ this.PreferenceExperiments = {
       throw new Error(
         `An observer for the preference experiment ${experimentName} is already active.`
       );
     }
 
     const observerInfo = {
       preferenceName,
       observer() {
-        let newValue = getPref(UserPreferences, preferenceName, preferenceType);
+        let newValue = getPref(UserPreferences, preferenceName, preferenceType, undefined);
         if (newValue !== preferenceValue) {
           PreferenceExperiments.stop(experimentName, false)
                                .catch(Cu.reportError);
         }
       },
     };
     experimentObservers.set(experimentName, observerInfo);
     Services.prefs.addObserver(preferenceName, observerInfo.observer);
--- a/browser/extensions/shield-recipe-client/lib/RecipeRunner.jsm
+++ b/browser/extensions/shield-recipe-client/lib/RecipeRunner.jsm
@@ -24,16 +24,20 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 XPCOMUtils.defineLazyModuleGetter(this, "SandboxManager",
                                   "resource://shield-recipe-client/lib/SandboxManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "ClientEnvironment",
                                   "resource://shield-recipe-client/lib/ClientEnvironment.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "CleanupManager",
                                   "resource://shield-recipe-client/lib/CleanupManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "ActionSandboxManager",
                                   "resource://shield-recipe-client/lib/ActionSandboxManager.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AddonStudies",
+                                  "resource://shield-recipe-client/lib/AddonStudies.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Uptake",
+                                  "resource://shield-recipe-client/lib/Uptake.jsm");
 
 Cu.importGlobalProperties(["fetch"]);
 
 this.EXPORTED_SYMBOLS = ["RecipeRunner"];
 
 const log = LogManager.getLogger("recipe-runner");
 const prefs = Services.prefs.getBranch("extensions.shield-recipe-client.");
 const TIMER_NAME = "recipe-client-addon-run";
@@ -122,106 +126,141 @@ this.RecipeRunner = {
     const runInterval = prefs.getIntPref(RUN_INTERVAL_PREF);
     timerManager.registerTimer(TIMER_NAME, () => this.run(), runInterval);
   },
 
   async run() {
     this.clearCaches();
     // Unless lazy classification is enabled, prep the classify cache.
     if (!Preferences.get("extensions.shield-recipe-client.experiments.lazy_classify", false)) {
-      await ClientEnvironment.getClientClassification();
+      try {
+        await ClientEnvironment.getClientClassification();
+      } catch (err) {
+        // Try to go on without this data; the filter expressions will
+        // gracefully fail without this info if they need it.
+      }
+    }
+
+    // Fetch recipes before execution in case we fail and exit early.
+    let recipes;
+    try {
+      recipes = await NormandyApi.fetchRecipes({enabled: true});
+    } catch (e) {
+      const apiUrl = prefs.getCharPref("api_url");
+      log.error(`Could not fetch recipes from ${apiUrl}: "${e}"`);
+
+      let status = Uptake.RUNNER_SERVER_ERROR;
+      if (/NetworkError/.test(e)) {
+        status = Uptake.RUNNER_NETWORK_ERROR;
+      } else if (e instanceof NormandyApi.InvalidSignatureError) {
+        status = Uptake.RUNNER_INVALID_SIGNATURE;
+      }
+      Uptake.reportRunner(status);
+      return;
     }
 
     const actionSandboxManagers = await this.loadActionSandboxManagers();
     Object.values(actionSandboxManagers).forEach(manager => manager.addHold("recipeRunner"));
 
     // Run pre-execution hooks. If a hook fails, we don't run recipes with that
     // action to avoid inconsistencies.
     for (const [actionName, manager] of Object.entries(actionSandboxManagers)) {
       try {
         await manager.runAsyncCallback("preExecution");
         manager.disabled = false;
       } catch (err) {
         log.error(`Could not run pre-execution hook for ${actionName}:`, err.message);
         manager.disabled = true;
+        Uptake.reportAction(actionName, Uptake.ACTION_PRE_EXECUTION_ERROR);
       }
     }
 
-    // Fetch recipes from the API
-    let recipes;
-    try {
-      recipes = await NormandyApi.fetchRecipes({enabled: true});
-    } catch (e) {
-      const apiUrl = prefs.getCharPref("api_url");
-      log.error(`Could not fetch recipes from ${apiUrl}: "${e}"`);
-      return;
-    }
-
     // Evaluate recipe filters
     const recipesToRun = [];
     for (const recipe of recipes) {
       if (await this.checkFilter(recipe)) {
         recipesToRun.push(recipe);
       }
     }
 
     // Execute recipes, if we have any.
     if (recipesToRun.length === 0) {
       log.debug("No recipes to execute");
     } else {
       for (const recipe of recipesToRun) {
         const manager = actionSandboxManagers[recipe.action];
+        let status;
         if (!manager) {
           log.error(
             `Could not execute recipe ${recipe.name}:`,
             `Action ${recipe.action} is either missing or invalid.`
           );
+          status = Uptake.RECIPE_INVALID_ACTION;
         } else if (manager.disabled) {
           log.warn(
             `Skipping recipe ${recipe.name} because ${recipe.action} failed during pre-execution.`
           );
+          status = Uptake.RECIPE_ACTION_DISABLED;
         } else {
           try {
             log.info(`Executing recipe "${recipe.name}" (action=${recipe.action})`);
             await manager.runAsyncCallback("action", recipe);
+            status = Uptake.RECIPE_SUCCESS;
           } catch (e) {
-            log.error(`Could not execute recipe ${recipe.name}:`, e);
+            log.error(`Could not execute recipe ${recipe.name}:`);
+            Cu.reportError(e);
+            status = Uptake.RECIPE_EXECUTION_ERROR;
           }
         }
+
+        Uptake.reportRecipe(recipe.id, status);
       }
     }
 
     // Run post-execution hooks
     for (const [actionName, manager] of Object.entries(actionSandboxManagers)) {
       // Skip if pre-execution failed.
       if (manager.disabled) {
         log.info(`Skipping post-execution hook for ${actionName} due to earlier failure.`);
         continue;
       }
 
       try {
         await manager.runAsyncCallback("postExecution");
+        Uptake.reportAction(actionName, Uptake.ACTION_SUCCESS);
       } catch (err) {
         log.info(`Could not run post-execution hook for ${actionName}:`, err.message);
+        Uptake.reportAction(actionName, Uptake.ACTION_POST_EXECUTION_ERROR);
       }
     }
 
     // Nuke sandboxes
     Object.values(actionSandboxManagers).forEach(manager => manager.removeHold("recipeRunner"));
+
+    // Close storage connections
+    await AddonStudies.close();
+
+    Uptake.reportRunner(Uptake.RUNNER_SUCCESS);
   },
 
   async loadActionSandboxManagers() {
     const actions = await NormandyApi.fetchActions();
     const actionSandboxManagers = {};
     for (const action of actions) {
       try {
         const implementation = await NormandyApi.fetchImplementation(action);
         actionSandboxManagers[action.name] = new ActionSandboxManager(implementation);
       } catch (err) {
         log.warn(`Could not fetch implementation for ${action.name}:`, err);
+
+        let status = Uptake.ACTION_SERVER_ERROR;
+        if (/NetworkError/.test(err)) {
+          status = Uptake.ACTION_NETWORK_ERROR;
+        }
+        Uptake.reportAction(action.name, status);
       }
     }
     return actionSandboxManagers;
   },
 
   getFilterContext(recipe) {
     return {
       normandy: Object.assign(ClientEnvironment.getEnvironment(), {
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/ShieldPreferences.jsm
@@ -0,0 +1,121 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(
+  this, "AppConstants", "resource://gre/modules/AppConstants.jsm"
+);
+XPCOMUtils.defineLazyModuleGetter(
+  this, "CleanupManager", "resource://shield-recipe-client/lib/CleanupManager.jsm"
+);
+
+this.EXPORTED_SYMBOLS = ["ShieldPreferences"];
+
+const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+const NS_PREFBRANCH_PREFCHANGE_TOPIC_ID = "nsPref:changed"; // from modules/libpref/nsIPrefBranch.idl
+const FHR_UPLOAD_ENABLED_PREF = "datareporting.healthreport.uploadEnabled";
+const OPT_OUT_STUDIES_ENABLED_PREF = "app.shield.optoutstudies.enabled";
+
+/**
+ * Handles Shield-specific preferences, including their UI.
+ */
+this.ShieldPreferences = {
+  init() {
+    // If the FHR pref was disabled since our last run, disable opt-out as well.
+    if (!Services.prefs.getBoolPref(FHR_UPLOAD_ENABLED_PREF)) {
+      Services.prefs.setBoolPref(OPT_OUT_STUDIES_ENABLED_PREF, false);
+    }
+
+    // Watch for changes to the FHR pref
+    Services.prefs.addObserver(FHR_UPLOAD_ENABLED_PREF, this);
+    CleanupManager.addCleanupHandler(() => {
+      Services.prefs.removeObserver(FHR_UPLOAD_ENABLED_PREF, this);
+    });
+
+    // Disabled outside of en-* locales temporarily (bug 1377192).
+    // Disabled when MOZ_DATA_REPORTING is false since the FHR UI is also hidden
+    // when data reporting is false.
+    if (AppConstants.MOZ_DATA_REPORTING && Services.locale.getAppLocaleAsLangTag().startsWith("en")) {
+      Services.obs.addObserver(this, "advanced-pane-loaded");
+      CleanupManager.addCleanupHandler(() => {
+        Services.obs.removeObserver(this, "advanced-pane-loaded");
+      });
+    }
+  },
+
+  observe(subject, topic, data) {
+    switch (topic) {
+      // Add the opt-out-study checkbox to the Privacy preferences when it is shown.
+      case "advanced-pane-loaded":
+        if (!Services.prefs.getBoolPref("browser.preferences.useOldOrganization", false)) {
+          this.injectOptOutStudyCheckbox(subject.document);
+        }
+        break;
+      // If the FHR pref changes, set the opt-out-study pref to the value it is changing to.
+      case NS_PREFBRANCH_PREFCHANGE_TOPIC_ID:
+        if (data === FHR_UPLOAD_ENABLED_PREF) {
+          const fhrUploadEnabled = Services.prefs.getBoolPref(FHR_UPLOAD_ENABLED_PREF);
+          Services.prefs.setBoolPref(OPT_OUT_STUDIES_ENABLED_PREF, fhrUploadEnabled);
+        }
+        break;
+    }
+  },
+
+  /**
+   * Injects the opt-out-study preference checkbox into about:preferences and
+   * handles events coming from the UI for it.
+   */
+  injectOptOutStudyCheckbox(doc) {
+    const container = doc.createElementNS(XUL_NS, "vbox");
+    container.classList.add("indent");
+
+    const hContainer = doc.createElementNS(XUL_NS, "hbox");
+    hContainer.setAttribute("align", "center");
+    container.appendChild(hContainer);
+
+    const checkbox = doc.createElementNS(XUL_NS, "checkbox");
+    checkbox.setAttribute("id", "optOutStudiesEnabled");
+    checkbox.setAttribute("label", "Allow Firefox to install and run studies");
+    checkbox.setAttribute("preference", OPT_OUT_STUDIES_ENABLED_PREF);
+    checkbox.setAttribute("disabled", !Services.prefs.getBoolPref(FHR_UPLOAD_ENABLED_PREF));
+    hContainer.appendChild(checkbox);
+
+    const viewStudies = doc.createElementNS(XUL_NS, "label");
+    viewStudies.setAttribute("id", "viewShieldStudies");
+    viewStudies.setAttribute("href", "about:studies");
+    viewStudies.setAttribute("useoriginprincipal", true);
+    viewStudies.textContent = "View Firefox Studies";
+    viewStudies.classList.add("learnMore", "text-link");
+    hContainer.appendChild(viewStudies);
+
+    // <prefrence> elements for prefs that we need to monitor while the page is open.
+    const optOutPref = doc.createElementNS(XUL_NS, "preference");
+    optOutPref.setAttribute("id", OPT_OUT_STUDIES_ENABLED_PREF);
+    optOutPref.setAttribute("name", OPT_OUT_STUDIES_ENABLED_PREF);
+    optOutPref.setAttribute("type", "bool");
+
+    // Weirdly, FHR doesn't have a <preference> element on the page, so we create it.
+    const fhrPref = doc.createElementNS(XUL_NS, "preference");
+    fhrPref.setAttribute("id", FHR_UPLOAD_ENABLED_PREF);
+    fhrPref.setAttribute("name", FHR_UPLOAD_ENABLED_PREF);
+    fhrPref.setAttribute("type", "bool");
+    fhrPref.addEventListener("change", function(event) {
+      // Avoid reference to the document directly, to avoid leaks.
+      const eventTargetCheckbox = event.target.ownerDocument.getElementById("optOutStudiesEnabled");
+      eventTargetCheckbox.disabled = !Services.prefs.getBoolPref(FHR_UPLOAD_ENABLED_PREF);
+    });
+
+    // Actually inject the elements we've created.
+    const parent = doc.getElementById("submitHealthReportBox").closest("vbox");
+    parent.appendChild(container);
+
+    const preferences = doc.getElementById("privacyPreferences");
+    preferences.appendChild(optOutPref);
+    preferences.appendChild(fhrPref);
+  },
+};
--- a/browser/extensions/shield-recipe-client/lib/ShieldRecipeClient.jsm
+++ b/browser/extensions/shield-recipe-client/lib/ShieldRecipeClient.jsm
@@ -11,96 +11,82 @@ Cu.import("resource://gre/modules/XPCOMU
 XPCOMUtils.defineLazyModuleGetter(this, "LogManager",
   "resource://shield-recipe-client/lib/LogManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "RecipeRunner",
   "resource://shield-recipe-client/lib/RecipeRunner.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "CleanupManager",
   "resource://shield-recipe-client/lib/CleanupManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PreferenceExperiments",
   "resource://shield-recipe-client/lib/PreferenceExperiments.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AboutPages",
+  "resource://shield-recipe-client-content/AboutPages.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ShieldPreferences",
+  "resource://shield-recipe-client/lib/ShieldPreferences.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AddonStudies",
+  "resource://shield-recipe-client/lib/AddonStudies.jsm");
 
 this.EXPORTED_SYMBOLS = ["ShieldRecipeClient"];
 
 const {PREF_STRING, PREF_BOOL, PREF_INT} = Ci.nsIPrefBranch;
 
 const REASONS = {
   APP_STARTUP: 1,      // The application is starting up.
   APP_SHUTDOWN: 2,     // The application is shutting down.
   ADDON_ENABLE: 3,     // The add-on is being enabled.
   ADDON_DISABLE: 4,    // The add-on is being disabled. (Also sent during uninstallation)
   ADDON_INSTALL: 5,    // The add-on is being installed.
   ADDON_UNINSTALL: 6,  // The add-on is being uninstalled.
   ADDON_UPGRADE: 7,    // The add-on is being upgraded.
   ADDON_DOWNGRADE: 8,  // The add-on is being downgraded.
 };
-const PREF_BRANCH = "extensions.shield-recipe-client.";
-const DEFAULT_PREFS = {
-  api_url: ["https://normandy.cdn.mozilla.net/api/v1", PREF_STRING],
-  dev_mode: [false, PREF_BOOL],
-  enabled: [true, PREF_BOOL],
-  startup_delay_seconds: [300, PREF_INT],
-  "logging.level": [Log.Level.Warn, PREF_INT],
-  user_id: ["", PREF_STRING],
-  run_interval_seconds: [86400, PREF_INT], // 24 hours
-  first_run: [true, PREF_BOOL],
-};
 const PREF_DEV_MODE = "extensions.shield-recipe-client.dev_mode";
-const PREF_LOGGING_LEVEL = PREF_BRANCH + "logging.level";
+const PREF_LOGGING_LEVEL = "extensions.shield-recipe-client.logging.level";
 
 let log = null;
 
 /**
  * Handles startup and shutdown of the entire add-on. Bootsrap.js defers to this
  * module for most tasks so that we can more easily test startup and shutdown
  * (bootstrap.js is difficult to import in tests).
  */
 this.ShieldRecipeClient = {
   async startup() {
-    ShieldRecipeClient.setDefaultPrefs();
-
     // Setup logging and listen for changes to logging prefs
     LogManager.configure(Services.prefs.getIntPref(PREF_LOGGING_LEVEL));
     Services.prefs.addObserver(PREF_LOGGING_LEVEL, LogManager.configure);
     CleanupManager.addCleanupHandler(
       () => Services.prefs.removeObserver(PREF_LOGGING_LEVEL, LogManager.configure),
     );
     log = LogManager.getLogger("bootstrap");
 
+    try {
+      await AboutPages.init();
+    } catch (err) {
+      log.error("Failed to initialize about pages:", err);
+    }
+
+    try {
+      await AddonStudies.init();
+    } catch (err) {
+      log.error("Failed to initialize addon studies:", err);
+    }
+
     // Initialize experiments first to avoid a race between initializing prefs
     // and recipes rolling back pref changes when experiments end.
     try {
       await PreferenceExperiments.init();
     } catch (err) {
       log.error("Failed to initialize preference experiments:", err);
     }
 
+    try {
+      ShieldPreferences.init();
+    } catch (err) {
+      log.error("Failed to initialize preferences UI:", err);
+    }
+
     await RecipeRunner.init();
   },
 
   shutdown(reason) {
     CleanupManager.cleanup();
   },
-
-  setDefaultPrefs() {
-    for (const [key, [val, type]] of Object.entries(DEFAULT_PREFS)) {
-      const fullKey = PREF_BRANCH + key;
-      // If someone beat us to setting a default, don't overwrite it.
-      if (!Services.prefs.prefHasUserValue(fullKey)) {
-        switch (type) {
-          case PREF_BOOL:
-            Services.prefs.setBoolPref(fullKey, val);
-            break;
-
-          case PREF_INT:
-            Services.prefs.setIntPref(fullKey, val);
-            break;
-
-          case PREF_STRING:
-            Services.prefs.setStringPref(fullKey, val);
-            break;
-
-          default:
-            throw new TypeError(`Unexpected type (${type}) for preference ${fullKey}.`)
-        }
-      }
-    }
-  },
 };
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/Uptake.jsm
@@ -0,0 +1,48 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(
+  this, "UptakeTelemetry", "resource://services-common/uptake-telemetry.js");
+
+this.EXPORTED_SYMBOLS = ["Uptake"];
+
+const SOURCE_PREFIX = "shield-recipe-client";
+
+this.Uptake = {
+  // Action uptake
+  ACTION_NETWORK_ERROR: UptakeTelemetry.STATUS.NETWORK_ERROR,
+  ACTION_PRE_EXECUTION_ERROR: UptakeTelemetry.STATUS.CUSTOM_1_ERROR,
+  ACTION_POST_EXECUTION_ERROR: UptakeTelemetry.STATUS.CUSTOM_2_ERROR,
+  ACTION_SERVER_ERROR: UptakeTelemetry.STATUS.SERVER_ERROR,
+  ACTION_SUCCESS: UptakeTelemetry.STATUS.SUCCESS,
+
+  // Per-recipe uptake
+  RECIPE_ACTION_DISABLED: UptakeTelemetry.STATUS.CUSTOM_1_ERROR,
+  RECIPE_EXECUTION_ERROR: UptakeTelemetry.STATUS.APPLY_ERROR,
+  RECIPE_INVALID_ACTION: UptakeTelemetry.STATUS.DOWNLOAD_ERROR,
+  RECIPE_SUCCESS: UptakeTelemetry.STATUS.SUCCESS,
+
+  // Uptake for the runner as a whole
+  RUNNER_INVALID_SIGNATURE: UptakeTelemetry.STATUS.SIGNATURE_ERROR,
+  RUNNER_NETWORK_ERROR: UptakeTelemetry.STATUS.NETWORK_ERROR,
+  RUNNER_SERVER_ERROR: UptakeTelemetry.STATUS.SERVER_ERROR,
+  RUNNER_SUCCESS: UptakeTelemetry.STATUS.SUCCESS,
+
+  reportRunner(status) {
+    UptakeTelemetry.report(`${SOURCE_PREFIX}/runner`, status);
+  },
+
+  reportRecipe(recipeId, status) {
+    UptakeTelemetry.report(`${SOURCE_PREFIX}/recipe/${recipeId}`, status);
+  },
+
+  reportAction(actionName, status) {
+    UptakeTelemetry.report(`${SOURCE_PREFIX}/action/${actionName}`, status);
+  },
+};
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/node_modules/jexl/LICENSE.txt
+++ /dev/null
@@ -1,19 +0,0 @@
-Copyright (c) 2015 TechnologyAdvice
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
\ No newline at end of file
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/node_modules/jexl/lib/Jexl.js
+++ /dev/null
@@ -1,225 +0,0 @@
-/*
- * Jexl
- * Copyright (c) 2015 TechnologyAdvice
- */
-
-var Evaluator = require('./evaluator/Evaluator'),
-	Lexer = require('./Lexer'),
-	Parser = require('./parser/Parser'),
-	defaultGrammar = require('./grammar').elements;
-
-/**
- * Jexl is the Javascript Expression Language, capable of parsing and
- * evaluating basic to complex expression strings, combined with advanced
- * xpath-like drilldown into native Javascript objects.
- * @constructor
- */
-function Jexl() {
-	this._customGrammar = null;
-	this._lexer = null;
-	this._transforms = {};
-}
-
-/**
- * Adds a binary operator to Jexl at the specified precedence. The higher the
- * precedence, the earlier the operator is applied in the order of operations.
- * For example, * has a higher precedence than +, because multiplication comes
- * before division.
- *
- * Please see grammar.js for a listing of all default operators and their
- * precedence values in order to choose the appropriate precedence for the
- * new operator.
- * @param {string} operator The operator string to be added
- * @param {number} precedence The operator's precedence
- * @param {function} fn A function to run to calculate the result. The function
- *      will be called with two arguments: left and right, denoting the values
- *      on either side of the operator. It should return either the resulting
- *      value, or a Promise that resolves with the resulting value.
- */
-Jexl.prototype.addBinaryOp = function(operator, precedence, fn) {
-	this._addGrammarElement(operator, {
-		type: 'binaryOp',
-		precedence: precedence,
-		eval: fn
-	});
-};
-
-/**
- * Adds a unary operator to Jexl. Unary operators are currently only supported
- * on the left side of the value on which it will operate.
- * @param {string} operator The operator string to be added
- * @param {function} fn A function to run to calculate the result. The function
- *      will be called with one argument: the literal value to the right of the
- *      operator. It should return either the resulting value, or a Promise
- *      that resolves with the resulting value.
- */
-Jexl.prototype.addUnaryOp = function(operator, fn) {
-	this._addGrammarElement(operator, {
-		type: 'unaryOp',
-		weight: Infinity,
-		eval: fn
-	});
-};
-
-/**
- * Adds or replaces a transform function in this Jexl instance.
- * @param {string} name The name of the transform function, as it will be used
- *      within Jexl expressions
- * @param {function} fn The function to be executed when this transform is
- *      invoked.  It will be provided with two arguments:
- *          - {*} value: The value to be transformed
- *          - {{}} args: The arguments for this transform
- *          - {function} cb: A callback function to be called with an error
- *            if the transform fails, or a null first argument and the
- *            transformed value as the second argument on success.
- */
-Jexl.prototype.addTransform = function(name, fn) {
-	this._transforms[name] = fn;
-};
-
-/**
- * Syntactic sugar for calling {@link #addTransform} repeatedly.  This function
- * accepts a map of one or more transform names to their transform function.
- * @param {{}} map A map of transform names to transform functions
- */
-Jexl.prototype.addTransforms = function(map) {
-	for (var key in map) {
-		if (map.hasOwnProperty(key))
-			this._transforms[key] = map[key];
-	}
-};
-
-/**
- * Retrieves a previously set transform function.
- * @param {string} name The name of the transform function
- * @returns {function} The transform function
- */
-Jexl.prototype.getTransform = function(name) {
-	return this._transforms[name];
-};
-
-/**
- * Evaluates a Jexl string within an optional context.
- * @param {string} expression The Jexl expression to be evaluated
- * @param {Object} [context] A mapping of variables to values, which will be
- *      made accessible to the Jexl expression when evaluating it
- * @param {function} [cb] An optional callback function to be executed when
- *      evaluation is complete.  It will be supplied with two arguments:
- *          - {Error|null} err: Present if an error occurred
- *          - {*} result: The result of the evaluation
- * @returns {Promise<*>} resolves with the result of the evaluation.  Note that
- *      if a callback is supplied, the returned promise will already have
- *      a '.catch' attached to it in order to pass the error to the callback.
- */
-Jexl.prototype.eval = function(expression, context, cb) {
-	if (typeof context === 'function') {
-		cb = context;
-		context = {};
-	}
-	else if (!context)
-		context = {};
-	var valPromise = this._eval(expression, context);
-	if (cb) {
-		// setTimeout is used for the callback to break out of the Promise's
-		// try/catch in case the callback throws.
-		var called = false;
-		return valPromise.then(function(val) {
-			called = true;
-			setTimeout(cb.bind(null, null, val), 0);
-		}).catch(function(err) {
-			if (!called)
-				setTimeout(cb.bind(null, err), 0);
-		});
-	}
-	return valPromise;
-};
-
-/**
- * Removes a binary or unary operator from the Jexl grammar.
- * @param {string} operator The operator string to be removed
- */
-Jexl.prototype.removeOp = function(operator) {
-	var grammar = this._getCustomGrammar();
-	if (grammar[operator] && (grammar[operator].type == 'binaryOp' ||
-			grammar[operator].type == 'unaryOp')) {
-		delete grammar[operator];
-		this._lexer = null;
-	}
-};
-
-/**
- * Adds an element to the grammar map used by this Jexl instance, cloning
- * the default grammar first if necessary.
- * @param {string} str The key string to be added
- * @param {{type: <string>}} obj A map of configuration options for this
- *      grammar element
- * @private
- */
-Jexl.prototype._addGrammarElement = function(str, obj) {
-	var grammar = this._getCustomGrammar();
-	grammar[str] = obj;
-	this._lexer = null;
-};
-
-/**
- * Evaluates a Jexl string in the given context.
- * @param {string} exp The Jexl expression to be evaluated
- * @param {Object} [context] A mapping of variables to values, which will be
- *      made accessible to the Jexl expression when evaluating it
- * @returns {Promise<*>} resolves with the result of the evaluation.
- * @private
- */
-Jexl.prototype._eval = function(exp, context) {
-	var self = this,
-		grammar = this._getGrammar(),
-		parser = new Parser(grammar),
-		evaluator = new Evaluator(grammar, this._transforms, context);
-	return Promise.resolve().then(function() {
-		parser.addTokens(self._getLexer().tokenize(exp));
-		return evaluator.eval(parser.complete());
-	});
-};
-
-/**
- * Gets the custom grammar object, creating it first if necessary. New custom
- * grammars are created by executing a shallow clone of the default grammar
- * map. The returned map is available to be changed.
- * @returns {{}} a customizable grammar map.
- * @private
- */
-Jexl.prototype._getCustomGrammar = function() {
-	if (!this._customGrammar) {
-		this._customGrammar = {};
-		for (var key in defaultGrammar) {
-			if (defaultGrammar.hasOwnProperty(key))
-				this._customGrammar[key] = defaultGrammar[key];
-		}
-	}
-	return this._customGrammar;
-};
-
-/**
- * Gets the grammar map currently being used by Jexl; either the default map,
- * or a locally customized version. The returned map should never be changed
- * in any way.
- * @returns {{}} the grammar map currently in use.
- * @private
- */
-Jexl.prototype._getGrammar = function() {
-	return this._customGrammar || defaultGrammar;
-};
-
-/**
- * Gets a Lexer instance as a singleton in reference to this Jexl instance.
- * @returns {Lexer} an instance of Lexer, initialized with a grammar
- *      appropriate to this Jexl instance.
- * @private
- */
-Jexl.prototype._getLexer = function() {
-	if (!this._lexer)
-		this._lexer = new Lexer(this._getGrammar());
-	return this._lexer;
-};
-
-module.exports = new Jexl();
-module.exports.Jexl = Jexl;
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/node_modules/jexl/lib/Lexer.js
+++ /dev/null
@@ -1,244 +0,0 @@
-/*
- * Jexl
- * Copyright (c) 2015 TechnologyAdvice
- */
-
-var numericRegex = /^-?(?:(?:[0-9]*\.[0-9]+)|[0-9]+)$/,
-	identRegex = /^[a-zA-Z_\$][a-zA-Z0-9_\$]*$/,
-	escEscRegex = /\\\\/,
-	preOpRegexElems = [
-		// Strings
-		"'(?:(?:\\\\')?[^'])*'",
-		'"(?:(?:\\\\")?[^"])*"',
-		// Whitespace
-		'\\s+',
-		// Booleans
-		'\\btrue\\b',
-		'\\bfalse\\b'
-	],
-	postOpRegexElems = [
-		// Identifiers
-		'\\b[a-zA-Z_\\$][a-zA-Z0-9_\\$]*\\b',
-		// Numerics (without negative symbol)
-		'(?:(?:[0-9]*\\.[0-9]+)|[0-9]+)'
-	],
-	minusNegatesAfter = ['binaryOp', 'unaryOp', 'openParen', 'openBracket',
-		'question', 'colon'];
-
-/**
- * Lexer is a collection of stateless, statically-accessed functions for the
- * lexical parsing of a Jexl string.  Its responsibility is to identify the
- * "parts of speech" of a Jexl expression, and tokenize and label each, but
- * to do only the most minimal syntax checking; the only errors the Lexer
- * should be concerned with are if it's unable to identify the utility of
- * any of its tokens.  Errors stemming from these tokens not being in a
- * sensible configuration should be left for the Parser to handle.
- * @type {{}}
- */
-function Lexer(grammar) {
-	this._grammar = grammar;
-}
-
-/**
- * Splits a Jexl expression string into an array of expression elements.
- * @param {string} str A Jexl expression string
- * @returns {Array<string>} An array of substrings defining the functional
- *      elements of the expression.
- */
-Lexer.prototype.getElements = function(str) {
-	var regex = this._getSplitRegex();
-	return str.split(regex).filter(function(elem) {
-		// Remove empty strings
-		return elem;
-	});
-};
-
-/**
- * Converts an array of expression elements into an array of tokens.  Note that
- * the resulting array may not equal the element array in length, as any
- * elements that consist only of whitespace get appended to the previous
- * token's "raw" property.  For the structure of a token object, please see
- * {@link Lexer#tokenize}.
- * @param {Array<string>} elements An array of Jexl expression elements to be
- *      converted to tokens
- * @returns {Array<{type, value, raw}>} an array of token objects.
- */
-Lexer.prototype.getTokens = function(elements) {
-	var tokens = [],
-		negate = false;
-	for (var i = 0; i < elements.length; i++) {
-		if (this._isWhitespace(elements[i])) {
-			if (tokens.length)
-				tokens[tokens.length - 1].raw += elements[i];
-		}
-		else if (elements[i] === '-' && this._isNegative(tokens))
-			negate = true;
-		else {
-			if (negate) {
-				elements[i] = '-' + elements[i];
-				negate = false;
-			}
-			tokens.push(this._createToken(elements[i]));
-		}
-	}
-	// Catch a - at the end of the string. Let the parser handle that issue.
-	if (negate)
-		tokens.push(this._createToken('-'));
-	return tokens;
-};
-
-/**
- * Converts a Jexl string into an array of tokens.  Each token is an object
- * in the following format:
- *
- *     {
- *         type: <string>,
- *         [name]: <string>,
- *         value: <boolean|number|string>,
- *         raw: <string>
- *     }
- *
- * Type is one of the following:
- *
- *      literal, identifier, binaryOp, unaryOp
- *
- * OR, if the token is a control character its type is the name of the element
- * defined in the Grammar.
- *
- * Name appears only if the token is a control string found in
- * {@link grammar#elements}, and is set to the name of the element.
- *
- * Value is the value of the token in the correct type (boolean or numeric as
- * appropriate). Raw is the string representation of this value taken directly
- * from the expression string, including any trailing spaces.
- * @param {string} str The Jexl string to be tokenized
- * @returns {Array<{type, value, raw}>} an array of token objects.
- * @throws {Error} if the provided string contains an invalid token.
- */
-Lexer.prototype.tokenize = function(str) {
-	var elements = this.getElements(str);
-	return this.getTokens(elements);
-};
-
-/**
- * Creates a new token object from an element of a Jexl string. See
- * {@link Lexer#tokenize} for a description of the token object.
- * @param {string} element The element from which a token should be made
- * @returns {{value: number|boolean|string, [name]: string, type: string,
- *      raw: string}} a token object describing the provided element.
- * @throws {Error} if the provided string is not a valid expression element.
- * @private
- */
-Lexer.prototype._createToken = function(element) {
-	var token = {
-		type: 'literal',
-		value: element,
-		raw: element
-	};
-	if (element[0] == '"' || element[0] == "'")
-		token.value = this._unquote(element);
-	else if (element.match(numericRegex))
-		token.value = parseFloat(element);
-	else if (element === 'true' || element === 'false')
-		token.value = element === 'true';
-	else if (this._grammar[element])
-		token.type = this._grammar[element].type;
-	else if (element.match(identRegex))
-		token.type = 'identifier';
-	else
-		throw new Error("Invalid expression token: " + element);
-	return token;
-};
-
-/**
- * Escapes a string so that it can be treated as a string literal within a
- * regular expression.
- * @param {string} str The string to be escaped
- * @returns {string} the RegExp-escaped string.
- * @see https://developer.mozilla.org/en/docs/Web/JavaScript/Guide/Regular_Expressions
- * @private
- */
-Lexer.prototype._escapeRegExp = function(str) {
-	str = str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
-	if (str.match(identRegex))
-		str = '\\b' + str + '\\b';
-	return str;
-};
-
-/**
- * Gets a RegEx object appropriate for splitting a Jexl string into its core
- * elements.
- * @returns {RegExp} An element-splitting RegExp object
- * @private
- */
-Lexer.prototype._getSplitRegex = function() {
-	if (!this._splitRegex) {
-		var elemArray = Object.keys(this._grammar);
-		// Sort by most characters to least, then regex escape each
-		elemArray = elemArray.sort(function(a ,b) {
-			return b.length - a.length;
-		}).map(function(elem) {
-			return this._escapeRegExp(elem);
-		}, this);
-		this._splitRegex = new RegExp('(' + [
-			preOpRegexElems.join('|'),
-			elemArray.join('|'),
-			postOpRegexElems.join('|')
-		].join('|') + ')');
-	}
-	return this._splitRegex;
-};
-
-/**
- * Determines whether the addition of a '-' token should be interpreted as a
- * negative symbol for an upcoming number, given an array of tokens already
- * processed.
- * @param {Array<Object>} tokens An array of tokens already processed
- * @returns {boolean} true if adding a '-' should be considered a negative
- *      symbol; false otherwise
- * @private
- */
-Lexer.prototype._isNegative = function(tokens) {
-	if (!tokens.length)
-		return true;
-	return minusNegatesAfter.some(function(type) {
-		return type === tokens[tokens.length - 1].type;
-	});
-};
-
-/**
- * A utility function to determine if a string consists of only space
- * characters.
- * @param {string} str A string to be tested
- * @returns {boolean} true if the string is empty or consists of only spaces;
- *      false otherwise.
- * @private
- */
-Lexer.prototype._isWhitespace = function(str) {
-	for (var i = 0; i < str.length; i++) {
-		if (str[i] != ' ')
-			return false;
-	}
-	return true;
-};
-
-/**
- * Removes the beginning and trailing quotes from a string, unescapes any
- * escaped quotes on its interior, and unescapes any escaped escape characters.
- * Note that this function is not defensive; it assumes that the provided
- * string is not empty, and that its first and last characters are actually
- * quotes.
- * @param {string} str A string whose first and last characters are quotes
- * @returns {string} a string with the surrounding quotes stripped and escapes
- *      properly processed.
- * @private
- */
-Lexer.prototype._unquote = function(str) {
-	var quote = str[0],
-		escQuoteRegex = new RegExp('\\\\' + quote, 'g');
-	return str.substr(1, str.length - 2)
-		.replace(escQuoteRegex, quote)
-		.replace(escEscRegex, '\\');
-};
-
-module.exports = Lexer;
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/Evaluator.js
+++ /dev/null
@@ -1,153 +0,0 @@
-/*
- * Jexl
- * Copyright (c) 2015 TechnologyAdvice
- */
-
-var handlers = require('./handlers');
-
-/**
- * The Evaluator takes a Jexl expression tree as generated by the
- * {@link Parser} and calculates its value within a given context. The
- * collection of transforms, context, and a relative context to be used as the
- * root for relative identifiers, are all specific to an Evaluator instance.
- * When any of these things change, a new instance is required.  However, a
- * single instance can be used to simultaneously evaluate many different
- * expressions, and does not have to be reinstantiated for each.
- * @param {{}} grammar A grammar map against which to evaluate the expression
- *      tree
- * @param {{}} [transforms] A map of transform names to transform functions. A
- *      transform function takes two arguments:
- *          - {*} val: A value to be transformed
- *          - {{}} args: A map of argument keys to their evaluated values, as
- *              specified in the expression string
- *      The transform function should return either the transformed value, or
- *      a Promises/A+ Promise object that resolves with the value and rejects
- *      or throws only when an unrecoverable error occurs. Transforms should
- *      generally return undefined when they don't make sense to be used on the
- *      given value type, rather than throw/reject. An error is only
- *      appropriate when the transform would normally return a value, but
- *      cannot due to some other failure.
- * @param {{}} [context] A map of variable keys to their values. This will be
- *      accessed to resolve the value of each non-relative identifier. Any
- *      Promise values will be passed to the expression as their resolved
- *      value.
- * @param {{}|Array<{}|Array>} [relativeContext] A map or array to be accessed
- *      to resolve the value of a relative identifier.
- * @constructor
- */
-var Evaluator = function(grammar, transforms, context, relativeContext) {
-	this._grammar = grammar;
-	this._transforms = transforms || {};
-	this._context = context || {};
-	this._relContext = relativeContext || this._context;
-};
-
-/**
- * Evaluates an expression tree within the configured context.
- * @param {{}} ast An expression tree object
- * @returns {Promise<*>} resolves with the resulting value of the expression.
- */
-Evaluator.prototype.eval = function(ast) {
-	var self = this;
-	return Promise.resolve().then(function() {
-		return handlers[ast.type].call(self, ast);
-	});
-};
-
-/**
- * Simultaneously evaluates each expression within an array, and delivers the
- * response as an array with the resulting values at the same indexes as their
- * originating expressions.
- * @param {Array<string>} arr An array of expression strings to be evaluated
- * @returns {Promise<Array<{}>>} resolves with the result array
- */
-Evaluator.prototype.evalArray = function(arr) {
-	return Promise.all(arr.map(function(elem) {
-		return this.eval(elem);
-	}, this));
-};
-
-/**
- * Simultaneously evaluates each expression within a map, and delivers the
- * response as a map with the same keys, but with the evaluated result for each
- * as their value.
- * @param {{}} map A map of expression names to expression trees to be
- *      evaluated
- * @returns {Promise<{}>} resolves with the result map.
- */
-Evaluator.prototype.evalMap = function(map) {
-	var keys = Object.keys(map),
-		result = {};
-	var asts = keys.map(function(key) {
-		return this.eval(map[key]);
-	}, this);
-	return Promise.all(asts).then(function(vals) {
-		vals.forEach(function(val, idx) {
-			result[keys[idx]] = val;
-		});
-		return result;
-	});
-};
-
-/**
- * Applies a filter expression with relative identifier elements to a subject.
- * The intent is for the subject to be an array of subjects that will be
- * individually used as the relative context against the provided expression
- * tree. Only the elements whose expressions result in a truthy value will be
- * included in the resulting array.
- *
- * If the subject is not an array of values, it will be converted to a single-
- * element array before running the filter.
- * @param {*} subject The value to be filtered; usually an array. If this value is
- *      not an array, it will be converted to an array with this value as the
- *      only element.
- * @param {{}} expr The expression tree to run against each subject. If the
- *      tree evaluates to a truthy result, then the value will be included in
- *      the returned array; otherwise, it will be eliminated.
- * @returns {Promise<Array>} resolves with an array of values that passed the
- *      expression filter.
- * @private
- */
-Evaluator.prototype._filterRelative = function(subject, expr) {
-	var promises = [];
-	if (!Array.isArray(subject))
-		subject = [subject];
-	subject.forEach(function(elem) {
-		var evalInst = new Evaluator(this._grammar, this._transforms,
-			this._context, elem);
-		promises.push(evalInst.eval(expr));
-	}, this);
-	return Promise.all(promises).then(function(values) {
-		var results = [];
-		values.forEach(function(value, idx) {
-			if (value)
-				results.push(subject[idx]);
-		});
-		return results;
-	});
-};
-
-/**
- * Applies a static filter expression to a subject value.  If the filter
- * expression evaluates to boolean true, the subject is returned; if false,
- * undefined.
- *
- * For any other resulting value of the expression, this function will attempt
- * to respond with the property at that name or index of the subject.
- * @param {*} subject The value to be filtered.  Usually an Array (for which
- *      the expression would generally resolve to a numeric index) or an
- *      Object (for which the expression would generally resolve to a string
- *      indicating a property name)
- * @param {{}} expr The expression tree to run against the subject
- * @returns {Promise<*>} resolves with the value of the drill-down.
- * @private
- */
-Evaluator.prototype._filterStatic = function(subject, expr) {
-	return this.eval(expr).then(function(res) {
-		if (typeof res === 'boolean')
-			return res ? subject : undefined;
-		return subject[res];
-	});
-};
-
-module.exports = Evaluator;
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/handlers.js
+++ /dev/null
@@ -1,159 +0,0 @@
-/*
- * Jexl
- * Copyright (c) 2015 TechnologyAdvice
- */
-
-/**
- * Evaluates an ArrayLiteral by returning its value, with each element
- * independently run through the evaluator.
- * @param {{type: 'ObjectLiteral', value: <{}>}} ast An expression tree with an
- *      ObjectLiteral as the top node
- * @returns {Promise.<[]>} resolves to a map contained evaluated values.
- * @private
- */
-exports.ArrayLiteral = function(ast) {
-	return this.evalArray(ast.value);
-};
-
-/**
- * Evaluates a BinaryExpression node by running the Grammar's evaluator for
- * the given operator.
- * @param {{type: 'BinaryExpression', operator: <string>, left: {},
- *      right: {}}} ast An expression tree with a BinaryExpression as the top
- *      node
- * @returns {Promise<*>} resolves with the value of the BinaryExpression.
- * @private
- */
-exports.BinaryExpression = function(ast) {
-	var self = this;
-	return Promise.all([
-		this.eval(ast.left),
-		this.eval(ast.right)
-	]).then(function(arr) {
-		return self._grammar[ast.operator].eval(arr[0], arr[1]);
-	});
-};
-
-/**
- * Evaluates a ConditionalExpression node by first evaluating its test branch,
- * and resolving with the consequent branch if the test is truthy, or the
- * alternate branch if it is not. If there is no consequent branch, the test
- * result will be used instead.
- * @param {{type: 'ConditionalExpression', test: {}, consequent: {},
- *      alternate: {}}} ast An expression tree with a ConditionalExpression as
- *      the top node
- * @private
- */
-exports.ConditionalExpression = function(ast) {
-	var self = this;
-	return this.eval(ast.test).then(function(res) {
-		if (res) {
-			if (ast.consequent)
-				return self.eval(ast.consequent);
-			return res;
-		}
-		return self.eval(ast.alternate);
-	});
-};
-
-/**
- * Evaluates a FilterExpression by applying it to the subject value.
- * @param {{type: 'FilterExpression', relative: <boolean>, expr: {},
- *      subject: {}}} ast An expression tree with a FilterExpression as the top
- *      node
- * @returns {Promise<*>} resolves with the value of the FilterExpression.
- * @private
- */
-exports.FilterExpression = function(ast) {
-	var self = this;
-	return this.eval(ast.subject).then(function(subject) {
-		if (ast.relative)
-			return self._filterRelative(subject, ast.expr);
-		return self._filterStatic(subject, ast.expr);
-	});
-};
-
-/**
- * Evaluates an Identifier by either stemming from the evaluated 'from'
- * expression tree or accessing the context provided when this Evaluator was
- * constructed.
- * @param {{type: 'Identifier', value: <string>, [from]: {}}} ast An expression
- *      tree with an Identifier as the top node
- * @returns {Promise<*>|*} either the identifier's value, or a Promise that
- *      will resolve with the identifier's value.
- * @private
- */
-exports.Identifier = function(ast) {
-	if (ast.from) {
-		return this.eval(ast.from).then(function(context) {
-			if (context === undefined)
-				return undefined;
-			if (Array.isArray(context))
-				context = context[0];
-			return context[ast.value];
-		});
-	}
-	else {
-		return ast.relative ? this._relContext[ast.value] :
-			this._context[ast.value];
-	}
-};
-
-/**
- * Evaluates a Literal by returning its value property.
- * @param {{type: 'Literal', value: <string|number|boolean>}} ast An expression
- *      tree with a Literal as its only node
- * @returns {string|number|boolean} The value of the Literal node
- * @private
- */
-exports.Literal = function(ast) {
-	return ast.value;
-};
-
-/**
- * Evaluates an ObjectLiteral by returning its value, with each key
- * independently run through the evaluator.
- * @param {{type: 'ObjectLiteral', value: <{}>}} ast An expression tree with an
- *      ObjectLiteral as the top node
- * @returns {Promise<{}>} resolves to a map contained evaluated values.
- * @private
- */
-exports.ObjectLiteral = function(ast) {
-	return this.evalMap(ast.value);
-};
-
-/**
- * Evaluates a Transform node by applying a function from the transforms map
- * to the subject value.
- * @param {{type: 'Transform', name: <string>, subject: {}}} ast An
- *      expression tree with a Transform as the top node
- * @returns {Promise<*>|*} the value of the transformation, or a Promise that
- *      will resolve with the transformed value.
- * @private
- */
-exports.Transform = function(ast) {
-	var transform = this._transforms[ast.name];
-	if (!transform)
-		throw new Error("Transform '" + ast.name + "' is not defined.");
-	return Promise.all([
-		this.eval(ast.subject),
-		this.evalArray(ast.args || [])
-	]).then(function(arr) {
-		return transform.apply(null, [arr[0]].concat(arr[1]));
-	});
-};
-
-/**
- * Evaluates a Unary expression by passing the right side through the
- * operator's eval function.
- * @param {{type: 'UnaryExpression', operator: <string>, right: {}}} ast An
- *      expression tree with a UnaryExpression as the top node
- * @returns {Promise<*>} resolves with the value of the UnaryExpression.
- * @constructor
- */
-exports.UnaryExpression = function(ast) {
-	var self = this;
-	return this.eval(ast.right).then(function(right) {
-		return self._grammar[ast.operator].eval(right);
-	});
-};
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/node_modules/jexl/lib/grammar.js
+++ /dev/null
@@ -1,66 +0,0 @@
-/*
- * Jexl
- * Copyright (c) 2015 TechnologyAdvice
- */
-
-/**
- * A map of all expression elements to their properties. Note that changes
- * here may require changes in the Lexer or Parser.
- * @type {{}}
- */
-exports.elements = {
-	'.': {type: 'dot'},
-	'[': {type: 'openBracket'},
-	']': {type: 'closeBracket'},
-	'|': {type: 'pipe'},
-	'{': {type: 'openCurl'},
-	'}': {type: 'closeCurl'},
-	':': {type: 'colon'},
-	',': {type: 'comma'},
-	'(': {type: 'openParen'},
-	')': {type: 'closeParen'},
-	'?': {type: 'question'},
-	'+': {type: 'binaryOp', precedence: 30,
-		eval: function(left, right) { return left + right; }},
-	'-': {type: 'binaryOp', precedence: 30,
-		eval: function(left, right) { return left - right; }},
-	'*': {type: 'binaryOp', precedence: 40,
-		eval: function(left, right) { return left * right; }},
-	'/': {type: 'binaryOp', precedence: 40,
-		eval: function(left, right) { return left / right; }},
-	'//': {type: 'binaryOp', precedence: 40,
-		eval: function(left, right) { return Math.floor(left / right); }},
-	'%': {type: 'binaryOp', precedence: 50,
-		eval: function(left, right) { return left % right; }},
-	'^': {type: 'binaryOp', precedence: 50,
-		eval: function(left, right) { return Math.pow(left, right); }},
-	'==': {type: 'binaryOp', precedence: 20,
-		eval: function(left, right) { return left == right; }},
-	'!=': {type: 'binaryOp', precedence: 20,
-		eval: function(left, right) { return left != right; }},
-	'>': {type: 'binaryOp', precedence: 20,
-		eval: function(left, right) { return left > right; }},
-	'>=': {type: 'binaryOp', precedence: 20,
-		eval: function(left, right) { return left >= right; }},
-	'<': {type: 'binaryOp', precedence: 20,
-		eval: function(left, right) { return left < right; }},
-	'<=': {type: 'binaryOp', precedence: 20,
-		eval: function(left, right) { return left <= right; }},
-	'&&': {type: 'binaryOp', precedence: 10,
-		eval: function(left, right) { return left && right; }},
-	'||': {type: 'binaryOp', precedence: 10,
-		eval: function(left, right) { return left || right; }},
-	'in': {type: 'binaryOp', precedence: 20,
-		eval: function(left, right) {
-			if (typeof right === 'string')
-				return right.indexOf(left) !== -1;
-			if (Array.isArray(right)) {
-				return right.some(function(elem) {
-					return elem == left;
-				});
-			}
-			return false;
-		}},
-	'!': {type: 'unaryOp', precedence: Infinity,
-		eval: function(right) { return !right; }}
-};
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/Parser.js
+++ /dev/null
@@ -1,188 +0,0 @@
-/*
- * Jexl
- * Copyright (c) 2015 TechnologyAdvice
- */
-
-var handlers = require('./handlers'),
-	states = require('./states').states;
-
-/**
- * The Parser is a state machine that converts tokens from the {@link Lexer}
- * into an Abstract Syntax Tree (AST), capable of being evaluated in any
- * context by the {@link Evaluator}.  The Parser expects that all tokens
- * provided to it are legal and typed properly according to the grammar, but
- * accepts that the tokens may still be in an invalid order or in some other
- * unparsable configuration that requires it to throw an Error.
- * @param {{}} grammar The grammar map to use to parse Jexl strings
- * @param {string} [prefix] A string prefix to prepend to the expression string
- *      for error messaging purposes.  This is useful for when a new Parser is
- *      instantiated to parse an subexpression, as the parent Parser's
- *      expression string thus far can be passed for a more user-friendly
- *      error message.
- * @param {{}} [stopMap] A mapping of token types to any truthy value. When the
- *      token type is encountered, the parser will return the mapped value
- *      instead of boolean false.
- * @constructor
- */
-function Parser(grammar, prefix, stopMap) {
-	this._grammar = grammar;
-	this._state = 'expectOperand';
-	this._tree = null;
-	this._exprStr = prefix || '';
-	this._relative = false;
-	this._stopMap = stopMap || {};
-}
-
-/**
- * Processes a new token into the AST and manages the transitions of the state
- * machine.
- * @param {{type: <string>}} token A token object, as provided by the
- *      {@link Lexer#tokenize} function.
- * @throws {Error} if a token is added when the Parser has been marked as
- *      complete by {@link #complete}, or if an unexpected token type is added.
- * @returns {boolean|*} the stopState value if this parser encountered a token
- *      in the stopState mapb; false if tokens can continue.
- */
-Parser.prototype.addToken = function(token) {
-	if (this._state == 'complete')
-		throw new Error('Cannot add a new token to a completed Parser');
-	var state = states[this._state],
-		startExpr = this._exprStr;
-	this._exprStr += token.raw;
-	if (state.subHandler) {
-		if (!this._subParser)
-			this._startSubExpression(startExpr);
-		var stopState = this._subParser.addToken(token);
-		if (stopState) {
-			this._endSubExpression();
-			if (this._parentStop)
-				return stopState;
-			this._state = stopState;
-		}
-	}
-	else if (state.tokenTypes[token.type]) {
-		var typeOpts = state.tokenTypes[token.type],
-			handleFunc = handlers[token.type];
-		if (typeOpts.handler)
-			handleFunc = typeOpts.handler;
-		if (handleFunc)
-			handleFunc.call(this, token);
-		if (typeOpts.toState)
-			this._state = typeOpts.toState;
-	}
-	else if (this._stopMap[token.type])
-		return this._stopMap[token.type];
-	else {
-		throw new Error('Token ' + token.raw + ' (' + token.type +
-			') unexpected in expression: ' + this._exprStr);
-	}
-	return false;
-};
-
-/**
- * Processes an array of tokens iteratively through the {@link #addToken}
- * function.
- * @param {Array<{type: <string>}>} tokens An array of tokens, as provided by
- *      the {@link Lexer#tokenize} function.
- */
-Parser.prototype.addTokens = function(tokens) {
-	tokens.forEach(this.addToken, this);
-};
-
-/**
- * Marks this Parser instance as completed and retrieves the full AST.
- * @returns {{}|null} a full expression tree, ready for evaluation by the
- *      {@link Evaluator#eval} function, or null if no tokens were passed to
- *      the parser before complete was called
- * @throws {Error} if the parser is not in a state where it's legal to end
- *      the expression, indicating that the expression is incomplete
- */
-Parser.prototype.complete = function() {
-	if (this._cursor && !states[this._state].completable)
-		throw new Error('Unexpected end of expression: ' + this._exprStr);
-	if (this._subParser)
-		this._endSubExpression();
-	this._state = 'complete';
-	return this._cursor ? this._tree : null;
-};
-
-/**
- * Indicates whether the expression tree contains a relative path identifier.
- * @returns {boolean} true if a relative identifier exists; false otherwise.
- */
-Parser.prototype.isRelative = function() {
-	return this._relative;
-};
-
-/**
- * Ends a subexpression by completing the subParser and passing its result
- * to the subHandler configured in the current state.
- * @private
- */
-Parser.prototype._endSubExpression = function() {
-	states[this._state].subHandler.call(this, this._subParser.complete());
-	this._subParser = null;
-};
-
-/**
- * Places a new tree node at the current position of the cursor (to the 'right'
- * property) and then advances the cursor to the new node. This function also
- * handles setting the parent of the new node.
- * @param {{type: <string>}} node A node to be added to the AST
- * @private
- */
-Parser.prototype._placeAtCursor = function(node) {
-	if (!this._cursor)
-		this._tree = node;
-	else {
-		this._cursor.right = node;
-		this._setParent(node, this._cursor);
-	}
-	this._cursor = node;
-};
-
-/**
- * Places a tree node before the current position of the cursor, replacing
- * the node that the cursor currently points to. This should only be called in
- * cases where the cursor is known to exist, and the provided node already
- * contains a pointer to what's at the cursor currently.
- * @param {{type: <string>}} node A node to be added to the AST
- * @private
- */
-Parser.prototype._placeBeforeCursor = function(node) {
-	this._cursor = this._cursor._parent;
-	this._placeAtCursor(node);
-};
-
-/**
- * Sets the parent of a node by creating a non-enumerable _parent property
- * that points to the supplied parent argument.
- * @param {{type: <string>}} node A node of the AST on which to set a new
- *      parent
- * @param {{type: <string>}} parent An existing node of the AST to serve as the
- *      parent of the new node
- * @private
- */
-Parser.prototype._setParent = function(node, parent) {
-	Object.defineProperty(node, '_parent', {
-		value: parent,
-		writable: true
-	});
-};
-
-/**
- * Prepares the Parser to accept a subexpression by (re)instantiating the
- * subParser.
- * @param {string} [exprStr] The expression string to prefix to the new Parser
- * @private
- */
-Parser.prototype._startSubExpression = function(exprStr) {
-	var endStates = states[this._state].endStates;
-	if (!endStates) {
-		this._parentStop = true;
-		endStates = this._stopMap;
-	}
-	this._subParser = new Parser(this._grammar, exprStr, endStates);
-};
-
-module.exports = Parser;
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/handlers.js
+++ /dev/null
@@ -1,210 +0,0 @@
-/*
- * Jexl
- * Copyright (c) 2015 TechnologyAdvice
- */
-
-/**
- * Handles a subexpression that's used to define a transform argument's value.
- * @param {{type: <string>}} ast The subexpression tree
- */
-exports.argVal = function(ast) {
-	this._cursor.args.push(ast);
-};
-
-/**
- * Handles new array literals by adding them as a new node in the AST,
- * initialized with an empty array.
- */
-exports.arrayStart = function() {
-	this._placeAtCursor({
-		type: 'ArrayLiteral',
-		value: []
-	});
-};
-
-/**
- * Handles a subexpression representing an element of an array literal.
- * @param {{type: <string>}} ast The subexpression tree
- */
-exports.arrayVal = function(ast) {
-	if (ast)
-		this._cursor.value.push(ast);
-};
-
-/**
- * Handles tokens of type 'binaryOp', indicating an operation that has two
- * inputs: a left side and a right side.
- * @param {{type: <string>}} token A token object
- */
-exports.binaryOp = function(token) {
-	var precedence = this._grammar[token.value].precedence || 0,
-		parent = this._cursor._parent;
-	while (parent && parent.operator &&
-			this._grammar[parent.operator].precedence >= precedence) {
-		this._cursor = parent;
-		parent = parent._parent;
-	}
-	var node = {
-		type: 'BinaryExpression',
-		operator: token.value,
-		left: this._cursor
-	};
-	this._setParent(this._cursor, node);
-	this._cursor = parent;
-	this._placeAtCursor(node);
-};
-
-/**
- * Handles successive nodes in an identifier chain.  More specifically, it
- * sets values that determine how the following identifier gets placed in the
- * AST.
- */
-exports.dot = function() {
-	this._nextIdentEncapsulate = this._cursor &&
-		(this._cursor.type != 'BinaryExpression' ||
-		(this._cursor.type == 'BinaryExpression' && this._cursor.right)) &&
-		this._cursor.type != 'UnaryExpression';
-	this._nextIdentRelative = !this._cursor ||
-		(this._cursor && !this._nextIdentEncapsulate);
-	if (this._nextIdentRelative)
-		this._relative = true;
-};
-
-/**
- * Handles a subexpression used for filtering an array returned by an
- * identifier chain.
- * @param {{type: <string>}} ast The subexpression tree
- */
-exports.filter = function(ast) {
-	this._placeBeforeCursor({
-		type: 'FilterExpression',
-		expr: ast,
-		relative: this._subParser.isRelative(),
-		subject: this._cursor
-	});
-};
-
-/**
- * Handles identifier tokens by adding them as a new node in the AST.
- * @param {{type: <string>}} token A token object
- */
-exports.identifier = function(token) {
-	var node = {
-		type: 'Identifier',
-		value: token.value
-	};
-	if (this._nextIdentEncapsulate) {
-		node.from = this._cursor;
-		this._placeBeforeCursor(node);
-		this._nextIdentEncapsulate = false;
-	}
-	else {
-		if (this._nextIdentRelative)
-			node.relative = true;
-		this._placeAtCursor(node);
-	}
-};
-
-/**
- * Handles literal values, such as strings, booleans, and numerics, by adding
- * them as a new node in the AST.
- * @param {{type: <string>}} token A token object
- */
-exports.literal = function(token) {
-	this._placeAtCursor({
-		type: 'Literal',
-		value: token.value
-	});
-};
-
-/**
- * Queues a new object literal key to be written once a value is collected.
- * @param {{type: <string>}} token A token object
- */
-exports.objKey = function(token) {
-	this._curObjKey = token.value;
-};
-
-/**
- * Handles new object literals by adding them as a new node in the AST,
- * initialized with an empty object.
- */
-exports.objStart = function() {
-	this._placeAtCursor({
-		type: 'ObjectLiteral',
-		value: {}
-	});
-};
-
-/**
- * Handles an object value by adding its AST to the queued key on the object
- * literal node currently at the cursor.
- * @param {{type: <string>}} ast The subexpression tree
- */
-exports.objVal = function(ast) {
-	this._cursor.value[this._curObjKey] = ast;
-};
-
-/**
- * Handles traditional subexpressions, delineated with the groupStart and
- * groupEnd elements.
- * @param {{type: <string>}} ast The subexpression tree
- */
-exports.subExpression = function(ast) {
-	this._placeAtCursor(ast);
-};
-
-/**
- * Handles a completed alternate subexpression of a ternary operator.
- * @param {{type: <string>}} ast The subexpression tree
- */
-exports.ternaryEnd = function(ast) {
-	this._cursor.alternate = ast;
-};
-
-/**
- * Handles a completed consequent subexpression of a ternary operator.
- * @param {{type: <string>}} ast The subexpression tree
- */
-exports.ternaryMid = function(ast) {
-	this._cursor.consequent = ast;
-};
-
-/**
- * Handles the start of a new ternary expression by encapsulating the entire
- * AST in a ConditionalExpression node, and using the existing tree as the
- * test element.
- */
-exports.ternaryStart = function() {
-	this._tree = {
-		type: 'ConditionalExpression',
-		test: this._tree
-	};
-	this._cursor = this._tree;
-};
-
-/**
- * Handles identifier tokens when used to indicate the name of a transform to
- * be applied.
- * @param {{type: <string>}} token A token object
- */
-exports.transform = function(token) {
-	this._placeBeforeCursor({
-		type: 'Transform',
-		name: token.value,
-		args: [],
-		subject: this._cursor
-	});
-};
-
-/**
- * Handles token of type 'unaryOp', indicating that the operation has only
- * one input: a right side.
- * @param {{type: <string>}} token A token object
- */
-exports.unaryOp = function(token) {
-	this._placeAtCursor({
-		type: 'UnaryExpression',
-		operator: token.value
-	});
-};
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/states.js
+++ /dev/null
@@ -1,154 +0,0 @@
-/*
- * Jexl
- * Copyright (c) 2015 TechnologyAdvice
- */
-
-var h = require('./handlers');
-
-/**
- * A mapping of all states in the finite state machine to a set of instructions
- * for handling or transitioning into other states. Each state can be handled
- * in one of two schemes: a tokenType map, or a subHandler.
- *
- * Standard expression elements are handled through the tokenType object. This
- * is an object map of all legal token types to encounter in this state (and
- * any unexpected token types will generate a thrown error) to an options
- * object that defines how they're handled.  The available options are:
- *
- *      {string} toState: The name of the state to which to transition
- *          immediately after handling this token
- *      {string} handler: The handler function to call when this token type is
- *          encountered in this state.  If omitted, the default handler
- *          matching the token's "type" property will be called. If the handler
- *          function does not exist, no call will be made and no error will be
- *          generated.  This is useful for tokens whose sole purpose is to
- *          transition to other states.
- *
- * States that consume a subexpression should define a subHandler, the
- * function to be called with an expression tree argument when the
- * subexpression is complete. Completeness is determined through the
- * endStates object, which maps tokens on which an expression should end to the
- * state to which to transition once the subHandler function has been called.
- *
- * Additionally, any state in which it is legal to mark the AST as completed
- * should have a 'completable' property set to boolean true.  Attempting to
- * call {@link Parser#complete} in any state without this property will result
- * in a thrown Error.
- *
- * @type {{}}
- */
-exports.states = {
-	expectOperand: {
-		tokenTypes: {
-			literal: {toState: 'expectBinOp'},
-			identifier: {toState: 'identifier'},
-			unaryOp: {},
-			openParen: {toState: 'subExpression'},
-			openCurl: {toState: 'expectObjKey', handler: h.objStart},
-			dot: {toState: 'traverse'},
-			openBracket: {toState: 'arrayVal', handler: h.arrayStart}
-		}
-	},
-	expectBinOp: {
-		tokenTypes: {
-			binaryOp: {toState: 'expectOperand'},
-			pipe: {toState: 'expectTransform'},
-			dot: {toState: 'traverse'},
-			question: {toState: 'ternaryMid', handler: h.ternaryStart}
-		},
-		completable: true
-	},
-	expectTransform: {
-		tokenTypes: {
-			identifier: {toState: 'postTransform', handler: h.transform}
-		}
-	},
-	expectObjKey: {
-		tokenTypes: {
-			identifier: {toState: 'expectKeyValSep', handler: h.objKey},
-			closeCurl: {toState: 'expectBinOp'}
-		}
-	},
-	expectKeyValSep: {
-		tokenTypes: {
-			colon: {toState: 'objVal'}
-		}
-	},
-	postTransform: {
-		tokenTypes: {
-			openParen: {toState: 'argVal'},
-			binaryOp: {toState: 'expectOperand'},
-			dot: {toState: 'traverse'},
-			openBracket: {toState: 'filter'},
-			pipe: {toState: 'expectTransform'}
-		},
-		completable: true
-	},
-	postTransformArgs: {
-		tokenTypes: {
-			binaryOp: {toState: 'expectOperand'},
-			dot: {toState: 'traverse'},
-			openBracket: {toState: 'filter'},
-			pipe: {toState: 'expectTransform'}
-		},
-		completable: true
-	},
-	identifier: {
-		tokenTypes: {
-			binaryOp: {toState: 'expectOperand'},
-			dot: {toState: 'traverse'},
-			openBracket: {toState: 'filter'},
-			pipe: {toState: 'expectTransform'},
-			question: {toState: 'ternaryMid', handler: h.ternaryStart}
-		},
-		completable: true
-	},
-	traverse: {
-		tokenTypes: {
-			'identifier': {toState: 'identifier'}
-		}
-	},
-	filter: {
-		subHandler: h.filter,
-		endStates: {
-			closeBracket: 'identifier'
-		}
-	},
-	subExpression: {
-		subHandler: h.subExpression,
-		endStates: {
-			closeParen: 'expectBinOp'
-		}
-	},
-	argVal: {
-		subHandler: h.argVal,
-		endStates: {
-			comma: 'argVal',
-			closeParen: 'postTransformArgs'
-		}
-	},
-	objVal: {
-		subHandler: h.objVal,
-		endStates: {
-			comma: 'expectObjKey',
-			closeCurl: 'expectBinOp'
-		}
-	},
-	arrayVal: {
-		subHandler: h.arrayVal,
-		endStates: {
-			comma: 'arrayVal',
-			closeBracket: 'expectBinOp'
-		}
-	},
-	ternaryMid: {
-		subHandler: h.ternaryMid,
-		endStates: {
-			colon: 'ternaryEnd'
-		}
-	},
-	ternaryEnd: {
-		subHandler: h.ternaryEnd,
-		completable: true
-	}
-};
--- a/browser/extensions/shield-recipe-client/skin/osx/Heartbeat.css
+++ b/browser/extensions/shield-recipe-client/skin/osx/Heartbeat.css
@@ -4,8 +4,21 @@
 
 /* Notification overrides for Heartbeat UI */
 
 notification.heartbeat {
   background-image: linear-gradient(-179deg, #FBFBFB 0%, #EBEBEB 100%) !important;
   border-bottom: 1px solid #C1C1C1 !important;
   height: 40px;
 }
+
+/* In themes/osx/global/notification.css the close icon is inverted because notifications
+   on OSX are usually dark. Heartbeat is light, so override that behaviour. */
+
+notification.heartbeat[type="info"] .close-icon:not(:hover) {
+  -moz-image-region: rect(0, 16px, 16px, 0) !important;
+}
+
+@media (min-resolution: 2dppx) {
+  notification.heartbeat[type="info"] .close-icon:not(:hover) {
+    -moz-image-region: rect(0, 32px, 32px, 0) !important;
+  }
+}
--- a/browser/extensions/shield-recipe-client/skin/shared/Heartbeat.css
+++ b/browser/extensions/shield-recipe-client/skin/shared/Heartbeat.css
@@ -3,17 +3,16 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /* Notification overrides for Heartbeat UI */
 
 notification.heartbeat {
   background-color: #F1F1F1 !important;
   border-bottom: 1px solid #C1C1C1 !important;
   height: 40px;
-  color: #333 !important;
 }
 
 @keyframes pulse-onshow {
   0% {
     opacity: 0;
     transform: scale(1);
   }
 
@@ -45,16 +44,17 @@ notification.heartbeat {
   }
 
   100% {
     transform: scale(1);
   }
 }
 
 .messageText.heartbeat {
+  color: #333 !important;
   margin-inline-end: 12px !important; /* The !important is required to override OSX default style. */
   margin-inline-start: 0;
   text-shadow: none;
 }
 
 .messageImage.heartbeat {
   height: 24px !important;
   margin-inline-end: 8px !important;
--- a/browser/extensions/shield-recipe-client/test/browser/.eslintrc.js
+++ b/browser/extensions/shield-recipe-client/test/browser/.eslintrc.js
@@ -3,15 +3,9 @@
 module.exports = {
   extends: [
     "plugin:mozilla/browser-test"
   ],
 
   plugins: [
     "mozilla"
   ],
-
-  globals: {
-    // Bug 1366720 - SimpleTest isn't being exported correctly, so list
-    // it here for now.
-    "SimpleTest": false
-  }
 };
--- a/browser/extensions/shield-recipe-client/test/browser/browser.ini
+++ b/browser/extensions/shield-recipe-client/test/browser/browser.ini
@@ -1,14 +1,22 @@
 [DEFAULT]
+support-files =
+  action_server.sjs
+  fixtures/normandy.xpi
 head = head.js
+[browser_ActionSandboxManager.js]
+[browser_Addons.js]
+[browser_AddonStudies.js]
 [browser_NormandyDriver.js]
 [browser_FilterExpressions.js]
 [browser_EventEmitter.js]
 [browser_Storage.js]
 [browser_Heartbeat.js]
 [browser_RecipeRunner.js]
-support-files =
-  action_server.sjs
 [browser_LogManager.js]
 [browser_ClientEnvironment.js]
 [browser_ShieldRecipeClient.js]
 [browser_PreferenceExperiments.js]
+[browser_about_studies.js]
+[browser_about_preferences.js]
+# Skip this test when FHR/Telemetry aren't available.
+skip-if = !healthreport || !telemetry
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_ActionSandboxManager.js
@@ -0,0 +1,167 @@
+"use strict";
+
+Cu.import("resource://shield-recipe-client/lib/ActionSandboxManager.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm", this);
+
+async function withManager(script, testFunction) {
+  const manager = new ActionSandboxManager(script);
+  manager.addHold("testing");
+  await testFunction(manager);
+  manager.removeHold("testing");
+}
+
+add_task(async function testMissingCallbackName() {
+  await withManager("1 + 1", async manager => {
+    is(
+      await manager.runAsyncCallback("missingCallback"),
+      undefined,
+      "runAsyncCallback returns undefined when given a missing callback name",
+    );
+  });
+});
+
+add_task(async function testCallback() {
+  const script = `
+    registerAsyncCallback("testCallback", async function(normandy) {
+      return 5;
+    });
+  `;
+
+  await withManager(script, async manager => {
+    const result = await manager.runAsyncCallback("testCallback");
+    is(result, 5, "runAsyncCallback executes the named callback inside the sandbox");
+  });
+});
+
+add_task(async function testArguments() {
+  const script = `
+    registerAsyncCallback("testCallback", async function(normandy, a, b) {
+      return a + b;
+    });
+  `;
+
+  await withManager(script, async manager => {
+    const result = await manager.runAsyncCallback("testCallback", 4, 6);
+    is(result, 10, "runAsyncCallback passes arguments to the callback");
+  });
+});
+
+add_task(async function testCloning() {
+  const script = `
+    registerAsyncCallback("testCallback", async function(normandy, obj) {
+      return {foo: "bar", baz: obj.baz};
+    });
+  `;
+
+  await withManager(script, async manager => {
+    const result = await manager.runAsyncCallback("testCallback", {baz: "biff"});
+
+    Assert.deepEqual(
+      result,
+      {foo: "bar", baz: "biff"},
+      (
+        "runAsyncCallback clones arguments into the sandbox and return values into the " +
+        "context it was called from"
+      ),
+    );
+  });
+});
+
+add_task(async function testError() {
+  const script = `
+    registerAsyncCallback("testCallback", async function(normandy) {
+      throw new Error("WHY")
+    });
+  `;
+
+  await withManager(script, async manager => {
+    try {
+      await manager.runAsyncCallback("testCallback");
+      ok(false, "runAsnycCallbackFromScript throws errors when raised by the sandbox");
+    } catch (err) {
+      is(err.message, "WHY", "runAsnycCallbackFromScript throws errors when raised by the sandbox");
+    }
+  });
+});
+
+add_task(async function testDriver() {
+  // The value returned by runAsyncCallback is cloned without the cloneFunctions
+  // option, so we can't inspect the driver itself since its methods will not be
+  // present. Instead, we inspect the properties on it available to the sandbox.
+  const script = `
+    registerAsyncCallback("testCallback", async function(normandy) {
+      return Object.keys(normandy);
+    });
+  `;
+
+  await withManager(script, async manager => {
+    const sandboxDriverKeys = await manager.runAsyncCallback("testCallback");
+    const referenceDriver = new NormandyDriver(manager);
+    for (const prop of Object.keys(referenceDriver)) {
+      ok(sandboxDriverKeys.includes(prop), `runAsyncCallback's driver has the "${prop}" property.`);
+    }
+  });
+});
+
+add_task(async function testGlobalObject() {
+  // Test that window is an alias for the global object, and that it
+  // has some expected functions available on it.
+  const script = `
+    window.setOnWindow = "set";
+    this.setOnGlobal = "set";
+
+    registerAsyncCallback("testCallback", async function(normandy) {
+      return {
+        setOnWindow: setOnWindow,
+        setOnGlobal: window.setOnGlobal,
+        setTimeoutExists: setTimeout !== undefined,
+        clearTimeoutExists: clearTimeout !== undefined,
+      };
+    });
+  `;
+
+  await withManager(script, async manager => {
+    const result = await manager.runAsyncCallback("testCallback");
+    Assert.deepEqual(result, {
+      setOnWindow: "set",
+      setOnGlobal: "set",
+      setTimeoutExists: true,
+      clearTimeoutExists: true,
+    }, "sandbox.window is the global object and has expected functions.");
+  });
+});
+
+add_task(async function testRegisterActionShim() {
+  const recipe = {
+    foo: "bar",
+  };
+  const script = `
+    class TestAction {
+      constructor(driver, recipe) {
+        this.driver = driver;
+        this.recipe = recipe;
+      }
+
+      execute() {
+        return new Promise(resolve => {
+          resolve({
+            foo: this.recipe.foo,
+            isDriver: "log" in this.driver,
+          });
+        });
+      }
+    }
+
+    registerAction('test-action', TestAction);
+  `;
+
+  await withManager(script, async manager => {
+    const result = await manager.runAsyncCallback("action", recipe);
+    is(result.foo, "bar", "registerAction registers an async callback for actions");
+    is(
+      result.isDriver,
+      true,
+      "registerAction passes the driver to the action class constructor",
+    );
+  });
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_AddonStudies.js
@@ -0,0 +1,325 @@
+"use strict";
+
+Cu.import("resource://gre/modules/IndexedDB.jsm", this);
+Cu.import("resource://testing-common/TestUtils.jsm", this);
+Cu.import("resource://testing-common/AddonTestUtils.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/Addons.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/AddonStudies.jsm", this);
+
+// Initialize test utils
+AddonTestUtils.initMochitest(this);
+
+decorate_task(
+  AddonStudies.withStudies(),
+  async function testGetMissing() {
+    is(
+      await AddonStudies.get("does-not-exist"),
+      null,
+      "get returns null when the requested study does not exist"
+    );
+  }
+);
+
+decorate_task(
+  AddonStudies.withStudies([
+    studyFactory({name: "test-study"}),
+  ]),
+  async function testGet([study]) {
+    const storedStudy = await AddonStudies.get(study.recipeId);
+    Assert.deepEqual(study, storedStudy, "get retrieved a study from storage.");
+  }
+);
+
+decorate_task(
+  AddonStudies.withStudies([
+    studyFactory(),
+    studyFactory(),
+  ]),
+  async function testGetAll(studies) {
+    const storedStudies = await AddonStudies.getAll();
+    Assert.deepEqual(
+      new Set(storedStudies),
+      new Set(studies),
+      "getAll returns every stored study.",
+    );
+  }
+);
+
+decorate_task(
+  AddonStudies.withStudies([
+    studyFactory({name: "test-study"}),
+  ]),
+  async function testHas([study]) {
+    let hasStudy = await AddonStudies.has(study.recipeId);
+    ok(hasStudy, "has returns true for a study that exists in storage.");
+
+    hasStudy = await AddonStudies.has("does-not-exist");
+    ok(!hasStudy, "has returns false for a study that doesn't exist in storage.");
+  }
+);
+
+decorate_task(
+  AddonStudies.withStudies(),
+  async function testCloseDatabase() {
+    await AddonStudies.close();
+    const openSpy = sinon.spy(IndexedDB, "open");
+    sinon.assert.notCalled(openSpy);
+
+    // Using studies at all should open the database, but only once.
+    await AddonStudies.has("foo");
+    await AddonStudies.get("foo");
+    sinon.assert.calledOnce(openSpy);
+
+    // close can be called multiple times
+    await AddonStudies.close();
+    await AddonStudies.close();
+
+    // After being closed, new operations cause the database to be opened again
+    await AddonStudies.has("test-study");
+    sinon.assert.calledTwice(openSpy);
+
+    openSpy.restore();
+  }
+);
+
+decorate_task(
+  AddonStudies.withStudies([
+    studyFactory({name: "test-study1"}),
+    studyFactory({name: "test-study2"}),
+  ]),
+  async function testClear([study1, study2]) {
+    const hasAll = (
+      (await AddonStudies.has(study1.recipeId)) &&
+      (await AddonStudies.has(study2.recipeId))
+    );
+    ok(hasAll, "Before calling clear, both studies are in storage.");
+
+    await AddonStudies.clear();
+    const hasAny = (
+      (await AddonStudies.has(study1.recipeId)) ||
+      (await AddonStudies.has(study2.recipeId))
+    );
+    ok(!hasAny, "After calling clear, all studies are removed from storage.");
+  }
+);
+
+let _startArgsFactoryId = 0;
+function startArgsFactory(args) {
+  return Object.assign({
+    recipeId: _startArgsFactoryId++,
+    name: "Test",
+    description: "Test",
+    addonUrl: "http://test/addon.xpi",
+  }, args);
+}
+
+add_task(async function testStartRequiredArguments() {
+  const requiredArguments = startArgsFactory();
+  for (const key in requiredArguments) {
+    const args = Object.assign({}, requiredArguments);
+    delete args[key];
+    Assert.rejects(
+      AddonStudies.start(args),
+      /Required arguments/,
+      `start rejects when missing required argument ${key}.`
+    );
+  }
+});
+
+decorate_task(
+  AddonStudies.withStudies([
+    studyFactory(),
+  ]),
+  async function testStartExisting([study]) {
+    Assert.rejects(
+      AddonStudies.start(startArgsFactory({recipeId: study.recipeId})),
+      /already exists/,
+      "start rejects when a study exists with the given recipeId already."
+    );
+  }
+);
+
+decorate_task(
+  withStub(Addons, "applyInstall"),
+  withWebExtension(),
+  async function testStartAddonCleanup(applyInstallStub, [addonId, addonFile]) {
+    applyInstallStub.rejects(new Error("Fake failure"));
+
+    const addonUrl = Services.io.newFileURI(addonFile).spec;
+    await Assert.rejects(
+      AddonStudies.start(startArgsFactory({addonUrl})),
+      /Fake failure/,
+      "start rejects when the Addons.applyInstall function rejects"
+    );
+
+    const addon = await Addons.get(addonId);
+    ok(!addon, "If something fails during start after the add-on is installed, it is uninstalled.");
+  }
+);
+
+const testOverwriteId = "testStartAddonNoOverwrite@example.com";
+decorate_task(
+  withInstalledWebExtension({version: "1.0", id: testOverwriteId}),
+  withWebExtension({version: "2.0", id: testOverwriteId}),
+  async function testStartAddonNoOverwrite([installedId, installedFile], [id, addonFile]) {
+    const addonUrl = Services.io.newFileURI(addonFile).spec;
+    await Assert.rejects(
+      AddonStudies.start(startArgsFactory({addonUrl})),
+      /updating is disabled/,
+      "start rejects when the study add-on is already installed"
+    );
+
+    await Addons.uninstall(testOverwriteId);
+  }
+);
+
+decorate_task(
+  withWebExtension({version: "2.0"}),
+  async function testStart([addonId, addonFile]) {
+    const startupPromise = AddonTestUtils.promiseWebExtensionStartup(addonId);
+    const addonUrl = Services.io.newFileURI(addonFile).spec;
+
+    let addon = await Addons.get(addonId);
+    is(addon, null, "Before start is called, the add-on is not installed.");
+
+    const args = startArgsFactory({
+      name: "Test Study",
+      description: "Test Desc",
+      addonUrl,
+    });
+    await AddonStudies.start(args);
+    await startupPromise;
+
+    addon = await Addons.get(addonId);
+    ok(addon, "After start is called, the add-on is installed.");
+
+    const study = await AddonStudies.get(args.recipeId);
+    Assert.deepEqual(
+      study,
+      {
+        recipeId: args.recipeId,
+        name: args.name,
+        description: args.description,
+        addonId,
+        addonVersion: "2.0",
+        addonUrl,
+        active: true,
+        studyStartDate: study.studyStartDate,
+      },
+      "start saves study data to storage",
+    );
+    ok(study.studyStartDate, "start assigns a value to the study start date.");
+
+    await Addons.uninstall(addonId);
+  }
+);
+
+decorate_task(
+  AddonStudies.withStudies(),
+  async function testStopNoStudy() {
+    await Assert.rejects(
+      AddonStudies.stop("does-not-exist"),
+      /No study found/,
+      "stop rejects when no study exists for the given recipe."
+    );
+  }
+);
+
+decorate_task(
+  AddonStudies.withStudies([
+    studyFactory({active: false}),
+  ]),
+  async function testStopInactiveStudy([study]) {
+    await Assert.rejects(
+      AddonStudies.stop(study.recipeId),
+      /already inactive/,
+      "stop rejects when the requested study is already inactive."
+    );
+  }
+);
+
+const testStopId = "testStop@example.com";
+decorate_task(
+  AddonStudies.withStudies([
+    studyFactory({active: true, addonId: testStopId, studyEndDate: null}),
+  ]),
+  withInstalledWebExtension({id: testStopId}),
+  async function testStop([study], [addonId, addonFile]) {
+    await AddonStudies.stop(study.recipeId);
+    const newStudy = await AddonStudies.get(study.recipeId);
+    ok(!newStudy.active, "stop marks the study as inactive.");
+    ok(newStudy.studyEndDate, "stop saves the study end date.");
+
+    const addon = await Addons.get(addonId);
+    is(addon, null, "stop uninstalls the study add-on.");
+  }
+);
+
+decorate_task(
+  AddonStudies.withStudies([
+    studyFactory({active: true, addonId: "testStopWarn@example.com", studyEndDate: null}),
+  ]),
+  async function testStopWarn([study]) {
+    const addon = await Addons.get("testStopWarn@example.com");
+    is(addon, null, "Before start is called, the add-on is not installed.");
+
+    // If the add-on is not installed, log a warning to the console, but do not
+    // throw.
+    await new Promise(resolve => {
+      SimpleTest.waitForExplicitFinish();
+      SimpleTest.monitorConsole(resolve, [{message: /Could not uninstall addon/}]);
+      AddonStudies.stop(study.recipeId).then(() => SimpleTest.endMonitorConsole());
+    });
+  }
+);
+
+decorate_task(
+  AddonStudies.withStudies([
+    studyFactory({active: true, addonId: "does.not.exist@example.com", studyEndDate: null}),
+    studyFactory({active: true, addonId: "installed@example.com"}),
+    studyFactory({active: false, addonId: "already.gone@example.com", studyEndDate: new Date(2012, 1)}),
+  ]),
+  withInstalledWebExtension({id: "installed@example.com"}),
+  async function testInit([activeStudy, activeInstalledStudy, inactiveStudy]) {
+    await AddonStudies.init();
+
+    const newActiveStudy = await AddonStudies.get(activeStudy.recipeId);
+    ok(!newActiveStudy.active, "init marks studies as inactive if their add-on is not installed.");
+    ok(
+      newActiveStudy.studyEndDate,
+      "init sets the study end date if a study's add-on is not installed."
+    );
+
+    const newInactiveStudy = await AddonStudies.get(inactiveStudy.recipeId);
+    is(
+      newInactiveStudy.studyEndDate.getFullYear(),
+      2012,
+      "init does not modify inactive studies."
+    );
+
+    const newActiveInstalledStudy = await AddonStudies.get(activeInstalledStudy.recipeId);
+    Assert.deepEqual(
+      activeInstalledStudy,
+      newActiveInstalledStudy,
+      "init does not modify studies whose add-on is still installed."
+    );
+  }
+);
+
+decorate_task(
+  AddonStudies.withStudies([
+    studyFactory({active: true, addonId: "installed@example.com", studyEndDate: null}),
+  ]),
+  withInstalledWebExtension({id: "installed@example.com"}),
+  async function testInit([study], [id, addonFile]) {
+    await Addons.uninstall(id);
+    await TestUtils.topicObserved("shield-study-ended");
+
+    const newStudy = await AddonStudies.get(study.recipeId);
+    ok(!newStudy.active, "Studies are marked as inactive when their add-on is uninstalled.");
+    ok(
+      newStudy.studyEndDate,
+      "The study end date is set when the add-on for the study is uninstalled."
+    );
+  }
+);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_Addons.js
@@ -0,0 +1,34 @@
+"use strict";
+
+Cu.import("resource://testing-common/AddonTestUtils.jsm", this);
+Cu.import("resource://gre/modules/Services.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/Addons.jsm", this);
+
+// Initialize test utils
+AddonTestUtils.initMochitest(this);
+
+const testInstallId = "testInstallUpdate@example.com";
+decorate_task(
+  withInstalledWebExtension({version: "1.0", id: testInstallId}),
+  withWebExtension({version: "2.0", id: testInstallId}),
+  async function testInstallUpdate([id1, addonFile1], [id2, addonFile2]) {
+    // Fail to install the 2.0 add-on without updating enabled
+    const newAddonUrl = Services.io.newFileURI(addonFile2).spec;
+    await Assert.rejects(
+      Addons.install(newAddonUrl, {update: false}),
+      /updating is disabled/,
+      "install rejects when the study add-on is already installed and updating is disabled"
+    );
+
+    // Install the new add-on with updating enabled
+    const startupPromise = AddonTestUtils.promiseWebExtensionStartup(testInstallId);
+    await Addons.install(newAddonUrl, {update: true});
+
+    const addon = await startupPromise;
+    is(
+      addon.version,
+      "2.0",
+      "install can successfully update an already-installed addon when updating is enabled."
+    );
+  }
+);
--- a/browser/extensions/shield-recipe-client/test/browser/browser_ClientEnvironment.js
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_ClientEnvironment.js
@@ -1,14 +1,18 @@
 "use strict";
 
+Cu.import("resource://gre/modules/Services.jsm", this);
 Cu.import("resource://gre/modules/TelemetryController.jsm", this);
+Cu.import("resource://gre/modules/AddonManager.jsm", this);
+Cu.import("resource://testing-common/AddonTestUtils.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/ClientEnvironment.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/PreferenceExperiments.jsm", this);
 
+
 add_task(async function testTelemetry() {
   // setup
   await TelemetryController.submitExternalPing("testfoo", {foo: 1});
   await TelemetryController.submitExternalPing("testbar", {bar: 2});
   const environment = ClientEnvironment.getEnvironment();
 
   // Test it can access telemetry
   const telemetry = await environment.telemetry;
@@ -105,20 +109,33 @@ add_task(async function testExperiments(
     experiments.expired,
     ["expired"],
     "experiments.expired returns all expired experiment names",
   );
 
   getAll.restore();
 });
 
-add_task(async function isFirstRun() {
-  let environment = ClientEnvironment.getEnvironment();
+add_task(withDriver(Assert, async function testAddonsInContext(driver) {
+  // Create before install so that the listener is added before startup completes.
+  const startupPromise = AddonTestUtils.promiseWebExtensionStartup("normandydriver@example.com");
+  const addonId = await driver.addons.install(TEST_XPI_URL);
+  await startupPromise;
 
-  // isFirstRun is initially set to true
-  ok(environment.isFirstRun, "isFirstRun has a default value");
+  const environment = ClientEnvironment.getEnvironment();
+  const addons = await environment.addons;
+  Assert.deepEqual(addons[addonId], {
+    id: [addonId],
+    name: "normandy_fixture",
+    version: "1.0",
+    installDate: addons[addonId].installDate,
+    isActive: true,
+    type: "extension",
+  }, "addons should be available in context");
 
-  // isFirstRun is read from a preference
+  await driver.addons.uninstall(addonId);
+}));
+
+add_task(async function isFirstRun() {
   await SpecialPowers.pushPrefEnv({set: [["extensions.shield-recipe-client.first_run", true]]});
-  environment = ClientEnvironment.getEnvironment();
+  const environment = ClientEnvironment.getEnvironment();
   ok(environment.isFirstRun, "isFirstRun is read from preferences");
 });
-
--- a/browser/extensions/shield-recipe-client/test/browser/browser_EventEmitter.js
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_EventEmitter.js
@@ -21,107 +21,113 @@ function listenerB(x = 1) {
   evidence.log += "b";
 }
 
 function listenerC(x = 1) {
   evidence.c += x;
   evidence.log += "c";
 }
 
-add_task(withSandboxManager(Assert, async function(sandboxManager) {
-  const eventEmitter = new EventEmitter(sandboxManager);
+decorate_task(
+  withSandboxManager(Assert),
+  async function(sandboxManager) {
+    const eventEmitter = new EventEmitter(sandboxManager);
 
-  // Fire an unrelated event, to make sure nothing goes wrong
-  eventEmitter.on("nothing");
+    // Fire an unrelated event, to make sure nothing goes wrong
+    eventEmitter.on("nothing");
 
-  // bind listeners
-  eventEmitter.on("event", listenerA);
-  eventEmitter.on("event", listenerB);
-  eventEmitter.once("event", listenerC);
+    // bind listeners
+    eventEmitter.on("event", listenerA);
+    eventEmitter.on("event", listenerB);
+    eventEmitter.once("event", listenerC);
 
-  // one event for all listeners
-  eventEmitter.emit("event");
-  // another event for a and b, since c should have turned off already
-  eventEmitter.emit("event", 10);
+    // one event for all listeners
+    eventEmitter.emit("event");
+    // another event for a and b, since c should have turned off already
+    eventEmitter.emit("event", 10);
 
-  // make sure events haven't actually fired yet, just queued
-  Assert.deepEqual(evidence, {
-    a: 0,
-    b: 0,
-    c: 0,
-    log: "",
-  }, "events are fired async");
+    // make sure events haven't actually fired yet, just queued
+    Assert.deepEqual(evidence, {
+      a: 0,
+      b: 0,
+      c: 0,
+      log: "",
+    }, "events are fired async");
 
-  // Spin the event loop to run events, so we can safely "off"
-  await Promise.resolve();
+    // Spin the event loop to run events, so we can safely "off"
+    await Promise.resolve();
 
-  // Check intermediate event results
-  Assert.deepEqual(evidence, {
-    a: 11,
-    b: 11,
-    c: 1,
-    log: "abcab",
-  }, "intermediate events are fired");
+    // Check intermediate event results
+    Assert.deepEqual(evidence, {
+      a: 11,
+      b: 11,
+      c: 1,
+      log: "abcab",
+    }, "intermediate events are fired");
 
-  // one more event for a
-  eventEmitter.off("event", listenerB);
-  eventEmitter.emit("event", 100);
+    // one more event for a
+    eventEmitter.off("event", listenerB);
+    eventEmitter.emit("event", 100);
 
-  // And another unrelated event
-  eventEmitter.on("nothing");
+    // And another unrelated event
+    eventEmitter.on("nothing");
 
-  // Spin the event loop to run events
-  await Promise.resolve();
+    // Spin the event loop to run events
+    await Promise.resolve();
 
-  Assert.deepEqual(evidence, {
-    a: 111,
-    b: 11,
-    c: 1,
-    log: "abcaba",  // events are in order
-  }, "events fired as expected");
+    Assert.deepEqual(evidence, {
+      a: 111,
+      b: 11,
+      c: 1,
+      log: "abcaba",  // events are in order
+    }, "events fired as expected");
 
-  // Test that mutating the data passed to the event doesn't actually
-  // mutate it for other events.
-  let handlerRunCount = 0;
-  const mutationHandler = data => {
-    handlerRunCount++;
-    data.count++;
-    is(data.count, 1, "Event data is not mutated between handlers.");
-  };
-  eventEmitter.on("mutationTest", mutationHandler);
-  eventEmitter.on("mutationTest", mutationHandler);
+    // Test that mutating the data passed to the event doesn't actually
+    // mutate it for other events.
+    let handlerRunCount = 0;
+    const mutationHandler = data => {
+      handlerRunCount++;
+      data.count++;
+      is(data.count, 1, "Event data is not mutated between handlers.");
+    };
+    eventEmitter.on("mutationTest", mutationHandler);
+    eventEmitter.on("mutationTest", mutationHandler);
 
-  const data = {count: 0};
-  eventEmitter.emit("mutationTest", data);
-  await Promise.resolve();
+    const data = {count: 0};
+    eventEmitter.emit("mutationTest", data);
+    await Promise.resolve();
 
-  is(handlerRunCount, 2, "Mutation handler was executed twice.");
-  is(data.count, 0, "Event data cannot be mutated by handlers.");
-}));
+    is(handlerRunCount, 2, "Mutation handler was executed twice.");
+    is(data.count, 0, "Event data cannot be mutated by handlers.");
+  }
+);
 
-add_task(withSandboxManager(Assert, async function sandboxedEmitter(sandboxManager) {
-  const eventEmitter = new EventEmitter(sandboxManager);
+decorate_task(
+  withSandboxManager(Assert),
+  async function sandboxedEmitter(sandboxManager) {
+    const eventEmitter = new EventEmitter(sandboxManager);
 
-  // Event handlers inside the sandbox should be run in response to
-  // events triggered outside the sandbox.
-  sandboxManager.addGlobal("emitter", eventEmitter.createSandboxedEmitter());
-  sandboxManager.evalInSandbox(`
-    this.eventCounts = {on: 0, once: 0};
-    emitter.on("event", value => {
-      this.eventCounts.on += value;
-    });
-    emitter.once("eventOnce", value => {
-      this.eventCounts.once += value;
-    });
-  `);
+    // Event handlers inside the sandbox should be run in response to
+    // events triggered outside the sandbox.
+    sandboxManager.addGlobal("emitter", eventEmitter.createSandboxedEmitter());
+    sandboxManager.evalInSandbox(`
+      this.eventCounts = {on: 0, once: 0};
+      emitter.on("event", value => {
+        this.eventCounts.on += value;
+      });
+      emitter.once("eventOnce", value => {
+        this.eventCounts.once += value;
+      });
+    `);
 
-  eventEmitter.emit("event", 5);
-  eventEmitter.emit("event", 10);
-  eventEmitter.emit("eventOnce", 5);
-  eventEmitter.emit("eventOnce", 10);
-  await Promise.resolve();
+    eventEmitter.emit("event", 5);
+    eventEmitter.emit("event", 10);
+    eventEmitter.emit("eventOnce", 5);
+    eventEmitter.emit("eventOnce", 10);
+    await Promise.resolve();
 
-  const eventCounts = sandboxManager.evalInSandbox("this.eventCounts");
-  Assert.deepEqual(eventCounts, {
-    on: 15,
-    once: 5,
-  }, "Events emitted outside a sandbox trigger handlers within a sandbox.");
-}));
+    const eventCounts = sandboxManager.evalInSandbox("this.eventCounts");
+    Assert.deepEqual(eventCounts, {
+      on: 15,
+      once: 5,
+    }, "Events emitted outside a sandbox trigger handlers within a sandbox.");
+  }
+);
--- a/browser/extensions/shield-recipe-client/test/browser/browser_FilterExpressions.js
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_FilterExpressions.js
@@ -86,8 +86,103 @@ add_task(async function() {
   ok(val, "preferenceIsUserSet expression determines if user's preference has been set");
 
   // Compare if the preference has _any_ value, whether it's user-set or default,
   val = await FilterExpressions.eval('"normandy.test.nonexistant"|preferenceExists == true');
   ok(!val, "preferenceExists expression determines if preference exists at all");
   val = await FilterExpressions.eval('"normandy.test.value"|preferenceExists == true');
   ok(val, "preferenceExists expression fails existence check appropriately");
 });
+
+// keys tests
+add_task(async function testKeys() {
+  let val;
+
+  // Test an object defined in JEXL
+  val = await FilterExpressions.eval("{foo: 1, bar: 2}|keys");
+  Assert.deepEqual(
+    new Set(val),
+    new Set(["foo", "bar"]),
+    "keys returns the keys from an object in JEXL",
+  );
+
+  // Test an object in the context
+  let context = {ctxObject: {baz: "string", biff: NaN}};
+  val = await FilterExpressions.eval("ctxObject|keys", context);
+
+  Assert.deepEqual(
+    new Set(val),
+    new Set(["baz", "biff"]),
+    "keys returns the keys from an object in the context",
+  );
+
+  // Test that values from the prototype are not included
+  context = {ctxObject: Object.create({fooProto: 7})};
+  context.ctxObject.baz = 8;
+  context.ctxObject.biff = 5;
+  is(
+    await FilterExpressions.eval("ctxObject.fooProto", context),
+    7,
+    "Prototype properties are accessible via property access",
+  );
+  val = await FilterExpressions.eval("ctxObject|keys", context);
+  Assert.deepEqual(
+    new Set(val),
+    new Set(["baz", "biff"]),
+    "keys does not return properties from the object's prototype chain",
+  );
+
+  // Return undefined for non-objects
+  is(
+    await FilterExpressions.eval("ctxObject|keys", {ctxObject: 45}),
+    undefined,
+    "keys returns undefined for numbers",
+  );
+  is(
+    await FilterExpressions.eval("ctxObject|keys", {ctxObject: null}),
+    undefined,
+    "keys returns undefined for null",
+  );
+});
+
+// intersect tests
+add_task(async function testIntersect() {
+  let val;
+
+  val = await FilterExpressions.eval("[1, 2, 3] intersect [4, 2, 6, 7, 3]");
+  Assert.deepEqual(
+    new Set(val),
+    new Set([2, 3]),
+    "intersect finds the common elements between two lists in JEXL",
+  );
+
+  const context = {left: [5, 7], right: [4, 5, 3]};
+  val = await FilterExpressions.eval("left intersect right", context);
+  Assert.deepEqual(
+    new Set(val),
+    new Set([5]),
+    "intersect finds the common elements between two lists in the context",
+  );
+
+  val = await FilterExpressions.eval("['string', 2] intersect [4, 'string', 'other', 3]");
+  Assert.deepEqual(
+    new Set(val),
+    new Set(["string"]),
+    "intersect can compare strings",
+  );
+
+  // Return undefined when intersecting things that aren't lists.
+  is(
+    await FilterExpressions.eval("5 intersect 7"),
+    undefined,
+    "intersect returns undefined for numbers",
+  );
+  is(
+    await FilterExpressions.eval("val intersect other", {val: null, other: null}),
+    undefined,
+    "intersect returns undefined for null",
+  );
+  is(
+    await FilterExpressions.eval("5 intersect [1, 2, 5]"),
+    undefined,
+    "intersect returns undefined if only one operand is a list",
+  );
+});
--- a/browser/extensions/shield-recipe-client/test/browser/browser_NormandyDriver.js
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_NormandyDriver.js
@@ -1,22 +1,68 @@
 "use strict";
 
+Cu.import("resource://gre/modules/AppConstants.jsm");
+Cu.import("resource://testing-common/AddonTestUtils.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/AddonStudies.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm", this);
 
 add_task(withDriver(Assert, async function uuids(driver) {
   // Test that it is a UUID
   const uuid1 = driver.uuid();
   ok(UUID_REGEX.test(uuid1), "valid uuid format");
 
   // Test that UUIDs are different each time
   const uuid2 = driver.uuid();
   isnot(uuid1, uuid2, "uuids are unique");
 }));
 
+add_task(withDriver(Assert, async function installXpi(driver) {
+  // Test that we can install an XPI from any URL
+  // Create before install so that the listener is added before startup completes.
+  const startupPromise = AddonTestUtils.promiseWebExtensionStartup("normandydriver@example.com");
+
+  var addonId = await driver.addons.install(TEST_XPI_URL);
+  is(addonId, "normandydriver@example.com", "Expected test addon was installed");
+  isnot(addonId, null, "Addon install was successful");
+
+  // Wait until the add-on is fully started up to uninstall it.
+  await startupPromise;
+
+  const uninstallMsg = await driver.addons.uninstall(addonId);
+  is(uninstallMsg, null, `Uninstall returned an unexpected message [${uninstallMsg}]`);
+}));
+
+add_task(withDriver(Assert, async function uninstallInvalidAddonId(driver) {
+  const invalidAddonId = "not_a_valid_xpi_id@foo.bar";
+  try {
+    await driver.addons.uninstall(invalidAddonId);
+    ok(false, `Uninstalling an invalid XPI should fail. addons.uninstall resolved successfully though.`);
+  } catch (e) {
+    ok(true, `This is the expected failure`);
+  }
+}));
+
+
+add_task(withDriver(Assert, async function installXpiBadURL(driver) {
+  let xpiUrl;
+  if (AppConstants.platform === "win") {
+    xpiUrl = "file:///C:/invalid_xpi.xpi";
+  } else {
+    xpiUrl = "file:///tmp/invalid_xpi.xpi";
+  }
+
+  try {
+    await driver.addons.install(xpiUrl);
+    ok(false, "Installation succeeded on an XPI that doesn't exist");
+  } catch (reason) {
+    ok(true, `Installation was rejected: [${reason}]`);
+  }
+}));
+
 add_task(withDriver(Assert, async function userId(driver) {
   // Test that userId is a UUID
   ok(UUID_REGEX.test(driver.userId), "userId is a uuid");
 }));
 
 add_task(withDriver(Assert, async function syncDeviceCounts(driver) {
   let client = await driver.client();
   is(client.syncMobileDevices, 0, "syncMobileDevices defaults to zero");
@@ -40,43 +86,232 @@ add_task(withDriver(Assert, async functi
   let client = await driver.client();
   is(client.distribution, "default", "distribution has a default value");
 
   await SpecialPowers.pushPrefEnv({set: [["distribution.id", "funnelcake"]]});
   client = await driver.client();
   is(client.distribution, "funnelcake", "distribution is read from preferences");
 }));
 
-add_task(withSandboxManager(Assert, async function testCreateStorage(sandboxManager) {
-  const driver = new NormandyDriver(sandboxManager);
-  sandboxManager.cloneIntoGlobal("driver", driver, {cloneFunctions: true});
+decorate_task(
+  withSandboxManager(Assert),
+  async function testCreateStorage(sandboxManager) {
+    const driver = new NormandyDriver(sandboxManager);
+    sandboxManager.cloneIntoGlobal("driver", driver, {cloneFunctions: true});
+
+    // Assertion helpers
+    sandboxManager.addGlobal("is", is);
+    sandboxManager.addGlobal("deepEqual", (...args) => Assert.deepEqual(...args));
+
+    await sandboxManager.evalInSandbox(`
+      (async function sandboxTest() {
+        const store = driver.createStorage("testprefix");
+        const otherStore = driver.createStorage("othertestprefix");
+        await store.clear();
+        await otherStore.clear();
+
+        await store.setItem("willremove", 7);
+        await otherStore.setItem("willremove", 4);
+        is(await store.getItem("willremove"), 7, "createStorage stores sandbox values");
+        is(
+          await otherStore.getItem("willremove"),
+          4,
+          "values are not shared between createStorage stores",
+        );
+
+        const deepValue = {"foo": ["bar", "baz"]};
+        await store.setItem("deepValue", deepValue);
+        deepEqual(await store.getItem("deepValue"), deepValue, "createStorage clones stored values");
+
+        await store.removeItem("willremove");
+        is(await store.getItem("willremove"), null, "createStorage removes items");
+
+        is('prefix' in store, false, "createStorage doesn't expose non-whitelist attributes");
+      })();
+    `);
+  }
+);
+
+add_task(withDriver(Assert, async function getAddon(driver, sandboxManager) {
+  const ADDON_ID = "normandydriver@example.com";
+  let addon = await driver.addons.get(ADDON_ID);
+  Assert.equal(addon, null, "Add-on is not yet installed");
+
+  await driver.addons.install(TEST_XPI_URL);
+  addon = await driver.addons.get(ADDON_ID);
+
+  Assert.notEqual(addon, null, "Add-on object was returned");
+  ok(addon.installDate instanceof sandboxManager.sandbox.Date, "installDate should be a Date object");
 
-  // Assertion helpers
-  sandboxManager.addGlobal("is", is);
-  sandboxManager.addGlobal("deepEqual", (...args) => Assert.deepEqual(...args));
+  Assert.deepEqual(addon, {
+    id: "normandydriver@example.com",
+    name: "normandy_fixture",
+    version: "1.0",
+    installDate: addon.installDate,
+    isActive: true,
+    type: "extension",
+  }, "Add-on is installed");
+
+  await driver.addons.uninstall(ADDON_ID);
+  addon = await driver.addons.get(ADDON_ID);
+
+  Assert.equal(addon, null, "Add-on has been uninstalled");
+}));
+
+decorate_task(
+  withSandboxManager(Assert),
+  async function testAddonsGetWorksInSandbox(sandboxManager) {
+    const driver = new NormandyDriver(sandboxManager);
+    sandboxManager.cloneIntoGlobal("driver", driver, {cloneFunctions: true});
+
+    // Assertion helpers
+    sandboxManager.addGlobal("is", is);
+    sandboxManager.addGlobal("deepEqual", (...args) => Assert.deepEqual(...args));
+
+    const ADDON_ID = "normandydriver@example.com";
+
+    await driver.addons.install(TEST_XPI_URL);
 
-  await sandboxManager.evalInSandbox(`
-    (async function sandboxTest() {
-      const store = driver.createStorage("testprefix");
-      const otherStore = driver.createStorage("othertestprefix");
-      await store.clear();
-      await otherStore.clear();
+    await sandboxManager.evalInSandbox(`
+      (async function sandboxTest() {
+        const addon = await driver.addons.get("${ADDON_ID}");
+
+        deepEqual(addon, {
+          id: "${ADDON_ID}",
+          name: "normandy_fixture",
+          version: "1.0",
+          installDate: addon.installDate,
+          isActive: true,
+          type: "extension",
+        }, "Add-on is accesible in the driver");
+      })();
+    `);
+
+    await driver.addons.uninstall(ADDON_ID);
+  }
+);
+
+decorate_task(
+  withSandboxManager(Assert),
+  withWebExtension({id: "driver-addon-studies@example.com"}),
+  async function testAddonStudies(sandboxManager, [addonId, addonFile]) {
+    const addonUrl = Services.io.newFileURI(addonFile).spec;
+    const driver = new NormandyDriver(sandboxManager);
+    sandboxManager.cloneIntoGlobal("driver", driver, {cloneFunctions: true});
+
+    // Assertion helpers
+    sandboxManager.addGlobal("is", is);
+    sandboxManager.addGlobal("ok", ok);
 
-      await store.setItem("willremove", 7);
-      await otherStore.setItem("willremove", 4);
-      is(await store.getItem("willremove"), 7, "createStorage stores sandbox values");
-      is(
-        await otherStore.getItem("willremove"),
-        4,
-        "values are not shared between createStorage stores",
-      );
+    await sandboxManager.evalInSandbox(`
+      (async function sandboxTest() {
+        const recipeId = 5;
+        let hasStudy = await driver.studies.has(recipeId);
+        ok(!hasStudy, "studies.has returns false if the study hasn't been started yet.");
+
+        await driver.studies.start({
+          recipeId,
+          name: "fake",
+          description: "fake",
+          addonUrl: "${addonUrl}",
+        });
+        hasStudy = await driver.studies.has(recipeId);
+        ok(hasStudy, "studies.has returns true after the study has been started.");
+
+        let study = await driver.studies.get(recipeId);
+        is(
+          study.addonId,
+          "driver-addon-studies@example.com",
+          "studies.get fetches studies from within a sandbox."
+        );
+        ok(study.active, "Studies are marked as active after being started by the driver.");
+
+        await driver.studies.stop(recipeId);
+        study = await driver.studies.get(recipeId);
+        ok(!study.active, "Studies are marked as inactive after being stopped by the driver.");
+      })();
+    `);
+  }
+);
+
+decorate_task(
+  withPrefEnv({
+    set: [
+      ["test.char", "a string"],
+      ["test.int", 5],
+      ["test.bool", true],
+    ],
+  }),
+  withSandboxManager(Assert, async function testPreferences(sandboxManager) {
+    const driver = new NormandyDriver(sandboxManager);
+    sandboxManager.cloneIntoGlobal("driver", driver, {cloneFunctions: true});
+
+    // Assertion helpers
+    sandboxManager.addGlobal("is", is);
+    sandboxManager.addGlobal("ok", ok);
+    sandboxManager.addGlobal("assertThrows", Assert.throws.bind(Assert));
 
-      const deepValue = {"foo": ["bar", "baz"]};
-      await store.setItem("deepValue", deepValue);
-      deepEqual(await store.getItem("deepValue"), deepValue, "createStorage clones stored values");
-
-      await store.removeItem("willremove");
-      is(await store.getItem("willremove"), null, "createStorage removes items");
-
-      is('prefix' in store, false, "createStorage doesn't expose non-whitelist attributes");
-    })();
-  `);
-}));
+    await sandboxManager.evalInSandbox(`
+      (async function sandboxTest() {
+        ok(
+          driver.preferences.getBool("test.bool"),
+          "preferences.getBool can retrieve boolean preferences."
+        );
+        is(
+          driver.preferences.getInt("test.int"),
+          5,
+          "preferences.getInt can retrieve integer preferences."
+        );
+        is(
+          driver.preferences.getChar("test.char"),
+          "a string",
+          "preferences.getChar can retrieve string preferences."
+        );
+        assertThrows(
+          () => driver.preferences.getChar("test.int"),
+          "preferences.getChar throws when retreiving a non-string preference."
+        );
+        assertThrows(
+          () => driver.preferences.getInt("test.bool"),
+          "preferences.getInt throws when retreiving a non-integer preference."
+        );
+        assertThrows(
+          () => driver.preferences.getBool("test.char"),
+          "preferences.getBool throws when retreiving a non-boolean preference."
+        );
+        assertThrows(
+          () => driver.preferences.getChar("test.does.not.exist"),
+          "preferences.getChar throws when retreiving a non-existant preference."
+        );
+        assertThrows(
+          () => driver.preferences.getInt("test.does.not.exist"),
+          "preferences.getInt throws when retreiving a non-existant preference."
+        );
+        assertThrows(
+          () => driver.preferences.getBool("test.does.not.exist"),
+          "preferences.getBool throws when retreiving a non-existant preference."
+        );
+        ok(
+          driver.preferences.getBool("test.does.not.exist", true),
+          "preferences.getBool returns a default value if the preference doesn't exist."
+        );
+        is(
+          driver.preferences.getInt("test.does.not.exist", 7),
+          7,
+          "preferences.getInt returns a default value if the preference doesn't exist."
+        );
+        is(
+          driver.preferences.getChar("test.does.not.exist", "default"),
+          "default",
+          "preferences.getChar returns a default value if the preference doesn't exist."
+        );
+        ok(
+          driver.preferences.has("test.char"),
+          "preferences.has returns true if the given preference exists."
+        );
+        ok(
+          !driver.preferences.has("test.does.not.exist"),
+          "preferences.has returns false if the given preference does not exist."
+        );
+      })();
+    `);
+  })
+);
--- a/browser/extensions/shield-recipe-client/test/browser/browser_PreferenceExperiments.js
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_PreferenceExperiments.js
@@ -370,16 +370,19 @@ add_task(withMockExperiments(withMockPre
 
   stopObserver.restore();
   PreferenceExperiments.stopAllObservers();
 })));
 
 // stop should also support user pref experiments
 add_task(withMockExperiments(withMockPreferences(async function(experiments, mockPreferences) {
   const stopObserver = sinon.stub(PreferenceExperiments, "stopObserver");
+  const hasObserver = sinon.stub(PreferenceExperiments, "hasObserver");
+  hasObserver.returns(true);
+
   mockPreferences.set("fake.preference", "experimentvalue", "user");
   experiments.test = experimentFactory({
     name: "test",
     expired: false,
     preferenceName: "fake.preference",
     preferenceValue: "experimentvalue",
     preferenceType: "string",
     previousPreferenceValue: "oldvalue",
@@ -392,16 +395,17 @@ add_task(withMockExperiments(withMockPre
   is(experiments.test.expired, true, "stop marked the experiment as expired");
   is(
     Preferences.get("fake.preference"),
     "oldvalue",
     "stop reverted the preference to its previous value",
   );
 
   stopObserver.restore();
+  hasObserver.restore();
 })));
 
 // stop should not call stopObserver if there is no observer registered.
 add_task(withMockExperiments(withMockPreferences(async function(experiments) {
   const stopObserver = sinon.spy(PreferenceExperiments, "stopObserver");
   experiments.test = experimentFactory({name: "test", expired: false});
 
   await PreferenceExperiments.stop("test");
--- a/browser/extensions/shield-recipe-client/test/browser/browser_RecipeRunner.js
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_RecipeRunner.js
@@ -1,15 +1,18 @@
 "use strict";
 
+Cu.import("resource://testing-common/TestUtils.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/RecipeRunner.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/ClientEnvironment.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/CleanupManager.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/NormandyApi.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/ActionSandboxManager.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/AddonStudies.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/Uptake.jsm", this);
 
 add_task(async function getFilterContext() {
   const recipe = {id: 17, arguments: {foo: "bar"}, unrelated: false};
   const context = RecipeRunner.getFilterContext(recipe);
 
   // Test for expected properties in the filter expression context.
   const expectedNormandyKeys = [
     "channel",
@@ -64,17 +67,17 @@ add_task(async function checkFilter() {
 });
 
 add_task(withMockNormandyApi(async function testClientClassificationCache() {
   const getStub = sinon.stub(ClientEnvironment, "getClientClassification")
     .returns(Promise.resolve(false));
 
   await SpecialPowers.pushPrefEnv({set: [
     ["extensions.shield-recipe-client.api_url",
-     "https://example.com/selfsupport-dummy"],
+      "https://example.com/selfsupport-dummy"],
   ]});
 
   // When the experiment pref is false, eagerly call getClientClassification.
   await SpecialPowers.pushPrefEnv({set: [
     ["extensions.shield-recipe-client.experiments.lazy_classify", false],
   ]});
   ok(!getStub.called, "getClientClassification hasn't been called");
   await RecipeRunner.run();
@@ -93,37 +96,46 @@ add_task(withMockNormandyApi(async funct
 }));
 
 /**
  * Mocks RecipeRunner.loadActionSandboxManagers for testing run.
  */
 async function withMockActionSandboxManagers(actions, testFunction) {
   const managers = {};
   for (const action of actions) {
-    managers[action.name] = new ActionSandboxManager("");
+    const manager = new ActionSandboxManager("");
+    manager.addHold("testing");
+    managers[action.name] = manager;
     sinon.stub(managers[action.name], "runAsyncCallback");
   }
 
-  const loadActionSandboxManagers = sinon.stub(
-    RecipeRunner,
-    "loadActionSandboxManagers",
-    async () => managers,
-  );
+  const loadActionSandboxManagers = sinon.stub(RecipeRunner, "loadActionSandboxManagers")
+    .resolves(managers);
   await testFunction(managers);
   loadActionSandboxManagers.restore();
+
+  for (const manager of Object.values(managers)) {
+    manager.removeHold("testing");
+    await manager.isNuked();
+  }
 }
 
 add_task(withMockNormandyApi(async function testRun(mockApi) {
+  const closeSpy = sinon.spy(AddonStudies, "close");
+  const reportRunner = sinon.stub(Uptake, "reportRunner");
+  const reportAction = sinon.stub(Uptake, "reportAction");
+  const reportRecipe = sinon.stub(Uptake, "reportRecipe");
+
   const matchAction = {name: "matchAction"};
   const noMatchAction = {name: "noMatchAction"};
   mockApi.actions = [matchAction, noMatchAction];
 
-  const matchRecipe = {action: "matchAction", filter_expression: "true"};
-  const noMatchRecipe = {action: "noMatchAction", filter_expression: "false"};
-  const missingRecipe = {action: "missingAction", filter_expression: "true"};
+  const matchRecipe = {id: "match", action: "matchAction", filter_expression: "true"};
+  const noMatchRecipe = {id: "noMatch", action: "noMatchAction", filter_expression: "false"};
+  const missingRecipe = {id: "missing", action: "missingAction", filter_expression: "true"};
   mockApi.recipes = [matchRecipe, noMatchRecipe, missingRecipe];
 
   await withMockActionSandboxManagers(mockApi.actions, async managers => {
     const matchManager = managers.matchAction;
     const noMatchManager = managers.noMatchAction;
 
     await RecipeRunner.run();
 
@@ -134,28 +146,109 @@ add_task(withMockNormandyApi(async funct
 
     // noMatch should be called for preExecution and postExecution, and skipped
     // for action since the filter expression does not match.
     sinon.assert.calledWith(noMatchManager.runAsyncCallback, "preExecution");
     sinon.assert.neverCalledWith(noMatchManager.runAsyncCallback, "action", noMatchRecipe);
     sinon.assert.calledWith(noMatchManager.runAsyncCallback, "postExecution");
 
     // missing is never called at all due to no matching action/manager.
-    await matchManager.isNuked();
-    await noMatchManager.isNuked();
+
+    // Test uptake reporting
+    sinon.assert.calledWith(reportRunner, Uptake.RUNNER_SUCCESS);
+    sinon.assert.calledWith(reportAction, "matchAction", Uptake.ACTION_SUCCESS);
+    sinon.assert.calledWith(reportAction, "noMatchAction", Uptake.ACTION_SUCCESS);
+    sinon.assert.calledWith(reportRecipe, "match", Uptake.RECIPE_SUCCESS);
+    sinon.assert.neverCalledWith(reportRecipe, "noMatch", Uptake.RECIPE_SUCCESS);
+    sinon.assert.calledWith(reportRecipe, "missing", Uptake.RECIPE_INVALID_ACTION);
   });
+
+  // Ensure storage is closed after the run.
+  sinon.assert.calledOnce(closeSpy);
+
+  closeSpy.restore();
+  reportRunner.restore();
+  reportAction.restore();
+  reportRecipe.restore();
+}));
+
+add_task(withMockNormandyApi(async function testRunRecipeError(mockApi) {
+  const reportRecipe = sinon.stub(Uptake, "reportRecipe");
+
+  const action = {name: "action"};
+  mockApi.actions = [action];
+
+  const recipe = {id: "recipe", action: "action", filter_expression: "true"};
+  mockApi.recipes = [recipe];
+
+  await withMockActionSandboxManagers(mockApi.actions, async managers => {
+    const manager = managers.action;
+    manager.runAsyncCallback.callsFake(async callbackName => {
+      if (callbackName === "action") {
+        throw new Error("Action execution failure");
+      }
+    });
+
+    await RecipeRunner.run();
+
+    // Uptake should report that the recipe threw an exception
+    sinon.assert.calledWith(reportRecipe, "recipe", Uptake.RECIPE_EXECUTION_ERROR);
+  });
+
+  reportRecipe.restore();
+}));
+
+add_task(withMockNormandyApi(async function testRunFetchFail(mockApi) {
+  const closeSpy = sinon.spy(AddonStudies, "close");
+  const reportRunner = sinon.stub(Uptake, "reportRunner");
+
+  const action = {name: "action"};
+  mockApi.actions = [action];
+  mockApi.fetchRecipes.rejects(new Error("Signature not valid"));
+
+  await withMockActionSandboxManagers(mockApi.actions, async managers => {
+    const manager = managers.action;
+    await RecipeRunner.run();
+
+    // If the recipe fetch failed, do not run anything.
+    sinon.assert.notCalled(manager.runAsyncCallback);
+    sinon.assert.calledWith(reportRunner, Uptake.RUNNER_SERVER_ERROR);
+
+    // Test that network errors report a specific uptake error
+    reportRunner.reset();
+    mockApi.fetchRecipes.rejects(new Error("NetworkError: The system was down"));
+    await RecipeRunner.run();
+    sinon.assert.calledWith(reportRunner, Uptake.RUNNER_NETWORK_ERROR);
+
+    // Test that signature issues report a specific uptake error
+    reportRunner.reset();
+    mockApi.fetchRecipes.rejects(new NormandyApi.InvalidSignatureError("Signature fail"));
+    await RecipeRunner.run();
+    sinon.assert.calledWith(reportRunner, Uptake.RUNNER_INVALID_SIGNATURE);
+  });
+
+  // If the recipe fetch failed, we don't need to call close since nothing
+  // opened a connection in the first place.
+  sinon.assert.notCalled(closeSpy);
+
+  closeSpy.restore();
+  reportRunner.restore();
 }));
 
 add_task(withMockNormandyApi(async function testRunPreExecutionFailure(mockApi) {
+  const closeSpy = sinon.spy(AddonStudies, "close");
+  const reportAction = sinon.stub(Uptake, "reportAction");
+  const reportRecipe = sinon.stub(Uptake, "reportRecipe");
+
   const passAction = {name: "passAction"};
   const failAction = {name: "failAction"};
   mockApi.actions = [passAction, failAction];
 
-  const passRecipe = {action: "passAction", filter_expression: "true"};
-  const failRecipe = {action: "failAction", filter_expression: "true"};
+  const passRecipe = {id: "pass", action: "passAction", filter_expression: "true"};
+  const failRecipe = {id: "fail", action: "failAction", filter_expression: "true"};
   mockApi.recipes = [passRecipe, failRecipe];
 
   await withMockActionSandboxManagers(mockApi.actions, async managers => {
     const passManager = managers.passAction;
     const failManager = managers.failAction;
     failManager.runAsyncCallback.returns(Promise.reject(new Error("oh no")));
 
     await RecipeRunner.run();
@@ -165,19 +258,57 @@ add_task(withMockNormandyApi(async funct
     sinon.assert.calledWith(passManager.runAsyncCallback, "action", passRecipe);
     sinon.assert.calledWith(passManager.runAsyncCallback, "postExecution");
 
     // fail should only be called for preExecution, since it fails during that
     sinon.assert.calledWith(failManager.runAsyncCallback, "preExecution");
     sinon.assert.neverCalledWith(failManager.runAsyncCallback, "action", failRecipe);
     sinon.assert.neverCalledWith(failManager.runAsyncCallback, "postExecution");
 
-    await passManager.isNuked();
-    await failManager.isNuked();
+    sinon.assert.calledWith(reportAction, "passAction", Uptake.ACTION_SUCCESS);
+    sinon.assert.calledWith(reportAction, "failAction", Uptake.ACTION_PRE_EXECUTION_ERROR);
+    sinon.assert.calledWith(reportRecipe, "fail", Uptake.RECIPE_ACTION_DISABLED);
   });
+
+  // Ensure storage is closed after the run, despite the failures.
+  sinon.assert.calledOnce(closeSpy);
+  closeSpy.restore();
+  reportAction.restore();
+  reportRecipe.restore();
+}));
+
+add_task(withMockNormandyApi(async function testRunPostExecutionFailure(mockApi) {
+  const reportAction = sinon.stub(Uptake, "reportAction");
+
+  const failAction = {name: "failAction"};
+  mockApi.actions = [failAction];
+
+  const failRecipe = {action: "failAction", filter_expression: "true"};
+  mockApi.recipes = [failRecipe];
+
+  await withMockActionSandboxManagers(mockApi.actions, async managers => {
+    const failManager = managers.failAction;
+    failManager.runAsyncCallback.callsFake(async callbackName => {
+      if (callbackName === "postExecution") {
+        throw new Error("postExecution failure");
+      }
+    });
+
+    await RecipeRunner.run();
+
+    // fail should be called for every stage
+    sinon.assert.calledWith(failManager.runAsyncCallback, "preExecution");
+    sinon.assert.calledWith(failManager.runAsyncCallback, "action", failRecipe);
+    sinon.assert.calledWith(failManager.runAsyncCallback, "postExecution");
+
+    // Uptake should report a post-execution error
+    sinon.assert.calledWith(reportAction, "failAction", Uptake.ACTION_POST_EXECUTION_ERROR);
+  });
+
+  reportAction.restore();
 }));
 
 add_task(withMockNormandyApi(async function testLoadActionSandboxManagers(mockApi) {
   mockApi.actions = [
     {name: "normalAction"},
     {name: "missingImpl"},
   ];
   mockApi.implementations.normalAction = "window.scriptRan = true";
@@ -188,47 +319,70 @@ add_task(withMockNormandyApi(async funct
 
   const normalManager = managers.normalAction;
   ok(
     await normalManager.evalInSandbox("window.scriptRan"),
     "Implementations are run in the sandbox",
   );
 }));
 
-add_task(async function testStartup() {
-  const runStub = sinon.stub(RecipeRunner, "run");
-  const addCleanupHandlerStub = sinon.stub(CleanupManager, "addCleanupHandler");
-  const updateRunIntervalStub = sinon.stub(RecipeRunner, "updateRunInterval");
-
-  // in dev mode
-  await SpecialPowers.pushPrefEnv({
+decorate_task(
+  withPrefEnv({
     set: [
       ["extensions.shield-recipe-client.dev_mode", true],
       ["extensions.shield-recipe-client.first_run", false],
     ],
-  });
-
-  RecipeRunner.init();
-  ok(runStub.called, "RecipeRunner.run is called immediately when in dev mode");
-  ok(addCleanupHandlerStub.called, "A cleanup function is registered when in dev mode");
-  ok(updateRunIntervalStub.called, "A timer is registered when in dev mode");
+  }),
+  withStub(RecipeRunner, "run"),
+  withStub(CleanupManager, "addCleanupHandler"),
+  withStub(RecipeRunner, "updateRunInterval"),
+  async function testInitDevMode(runStub, addCleanupHandlerStub, updateRunIntervalStub) {
+    RecipeRunner.init();
+    ok(runStub.called, "RecipeRunner.run is called immediately when in dev mode");
+    ok(addCleanupHandlerStub.called, "A cleanup function is registered when in dev mode");
+    ok(updateRunIntervalStub.called, "A timer is registered when in dev mode");
+  }
+);
 
-  runStub.reset();
-  addCleanupHandlerStub.reset();
-  updateRunIntervalStub.reset();
-
-  // not in dev mode
-  await SpecialPowers.pushPrefEnv({
+decorate_task(
+  withPrefEnv({
     set: [
       ["extensions.shield-recipe-client.dev_mode", false],
       ["extensions.shield-recipe-client.first_run", false],
     ],
-  });
+  }),
+  withStub(RecipeRunner, "run"),
+  withStub(CleanupManager, "addCleanupHandler"),
+  withStub(RecipeRunner, "updateRunInterval"),
+  async function testInit(runStub, addCleanupHandlerStub, updateRunIntervalStub) {
+    RecipeRunner.init();
+    ok(!runStub.called, "RecipeRunner.run is not called immediately when not in dev mode");
+    ok(addCleanupHandlerStub.called, "A cleanup function is registered when not in dev mode");
+    ok(updateRunIntervalStub.called, "A timer is registered when not in dev mode");
+  }
+);
 
-  RecipeRunner.init();
-  ok(!runStub.called, "RecipeRunner.run is not called immediately when not in dev mode");
-  ok(addCleanupHandlerStub.called, "A cleanup function is registered when not in dev mode");
-  ok(updateRunIntervalStub.called, "A timer is registered when not in dev mode");
+decorate_task(
+  withPrefEnv({
+    set: [
+      ["extensions.shield-recipe-client.dev_mode", false],
+      ["extensions.shield-recipe-client.first_run", true],
+    ],
+  }),
+  withStub(RecipeRunner, "run"),
+  withStub(RecipeRunner, "registerTimer"),
+  withStub(CleanupManager, "addCleanupHandler"),
+  withStub(RecipeRunner, "updateRunInterval"),
+  async function testInitFirstRun(runStub, registerTimerStub) {
+    RecipeRunner.init();
+    ok(!runStub.called, "RecipeRunner.run is not called immediately");
+    ok(!registerTimerStub.called, "RecipeRunner.registerTimer is not called immediately");
 
-  runStub.restore();
-  addCleanupHandlerStub.restore();
-  updateRunIntervalStub.restore();
-});
+    Services.obs.notifyObservers(null, "sessionstore-windows-restored");
+    await TestUtils.topicObserved("shield-init-complete");
+    ok(runStub.called, "RecipeRunner.run is called after the UI is available");
+    ok(registerTimerStub.called, "RecipeRunner.registerTimer is called after the UI is available");
+    ok(
+      !Services.prefs.getBoolPref("extensions.shield-recipe-client.first_run"),
+      "On first run, the first run pref is set to false after the UI is available"
+    );
+  }
+);
--- a/browser/extensions/shield-recipe-client/test/browser/browser_ShieldRecipeClient.js
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_ShieldRecipeClient.js
@@ -1,30 +1,67 @@
 "use strict";
 
 Cu.import("resource://shield-recipe-client/lib/ShieldRecipeClient.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/RecipeRunner.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/PreferenceExperiments.jsm", this);
+Cu.import("resource://shield-recipe-client-content/AboutPages.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/AddonStudies.jsm", this);
 
-add_task(async function testStartup() {
-  sinon.stub(RecipeRunner, "init");
-  sinon.stub(PreferenceExperiments, "init");
+function withStubInits(testFunction) {
+  return decorate(
+    withStub(AboutPages, "init"),
+    withStub(AddonStudies, "init"),
+    withStub(PreferenceExperiments, "init"),
+    withStub(RecipeRunner, "init"),
+    testFunction
+  );
+}
 
-  await ShieldRecipeClient.startup();
-  ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
-  ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
+decorate_task(
+  withStubInits,
+  async function testStartup() {
+    await ShieldRecipeClient.startup();
+    ok(AboutPages.init.called, "startup calls AboutPages.init");
+    ok(AddonStudies.init.called, "startup calls AddonStudies.init");
+    ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
+    ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
+  }
+);
 
-  PreferenceExperiments.init.restore();
-  RecipeRunner.init.restore();
-});
+decorate_task(
+  withStubInits,
+  async function testStartupPrefInitFail() {
+    PreferenceExperiments.init.returns(Promise.reject(new Error("oh no")));
 
-add_task(async function testStartupPrefInitFail() {
-  sinon.stub(RecipeRunner, "init");
-  sinon.stub(PreferenceExperiments, "init").returns(Promise.reject(new Error("oh no")));
+    await ShieldRecipeClient.startup();
+    ok(AboutPages.init.called, "startup calls AboutPages.init");
+    ok(AddonStudies.init.called, "startup calls AddonStudies.init");
+    ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
+    ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
+  }
+);
+
+decorate_task(
+  withStubInits,
+  async function testStartupAboutPagesInitFail() {
+    AboutPages.init.returns(Promise.reject(new Error("oh no")));
 
-  await ShieldRecipeClient.startup();
-  ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
-  // Even if PreferenceExperiments.init fails, RecipeRunner.init should be called.
-  ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
+    await ShieldRecipeClient.startup();
+    ok(AboutPages.init.called, "startup calls AboutPages.init");
+    ok(AddonStudies.init.called, "startup calls AddonStudies.init");
+    ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
+    ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
+  }
+);
 
-  PreferenceExperiments.init.restore();
-  RecipeRunner.init.restore();
-});
+decorate_task(
+  withStubInits,
+  async function testStartupAddonStudiesInitFail() {
+    AddonStudies.init.returns(Promise.reject(new Error("oh no")));
+
+    await ShieldRecipeClient.startup();
+    ok(AboutPages.init.called, "startup calls AboutPages.init");
+    ok(AddonStudies.init.called, "startup calls AddonStudies.init");
+    ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
+    ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
+  }
+);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_about_preferences.js
@@ -0,0 +1,177 @@
+"use strict";
+
+Cu.import("resource://gre/modules/Services.jsm", this);
+
+const OPT_OUT_PREF = "app.shield.optoutstudies.enabled";
+const FHR_PREF = "datareporting.healthreport.uploadEnabled";
+
+function withPrivacyPrefs(testFunc) {
+  return async (...args) => (
+    BrowserTestUtils.withNewTab("about:preferences#privacy", async browser => (
+      testFunc(...args, browser)
+    ))
+  );
+}
+
+decorate_task(
+  withPrefEnv({
+    set: [[OPT_OUT_PREF, true]],
+  }),
+  withPrivacyPrefs,
+  async function testCheckedOnLoad(browser) {
+    const checkbox = browser.contentDocument.getElementById("optOutStudiesEnabled");
+    ok(checkbox.checked, "Opt-out checkbox is checked on load when the pref is true");
+  }
+);
+
+decorate_task(
+  withPrefEnv({
+    set: [[OPT_OUT_PREF, false]],
+  }),
+  withPrivacyPrefs,
+  async function testUncheckedOnLoad(browser) {
+    const checkbox = browser.contentDocument.getElementById("optOutStudiesEnabled");
+    ok(!checkbox.checked, "Opt-out checkbox is unchecked on load when the pref is false");
+  }
+);
+
+decorate_task(
+  withPrefEnv({
+    set: [[FHR_PREF, true]],
+  }),
+  withPrivacyPrefs,
+  async function testEnabledOnLoad(browser) {
+    const checkbox = browser.contentDocument.getElementById("optOutStudiesEnabled");
+    ok(!checkbox.disabled, "Opt-out checkbox is enabled on load when the FHR pref is true");
+  }
+);
+
+decorate_task(
+  withPrefEnv({
+    set: [[FHR_PREF, false]],
+  }),
+  withPrivacyPrefs,
+  async function testDisabledOnLoad(browser) {
+    const checkbox = browser.contentDocument.getElementById("optOutStudiesEnabled");
+    ok(checkbox.disabled, "Opt-out checkbox is disabled on load when the FHR pref is false");
+  }
+);
+
+decorate_task(
+  withPrefEnv({
+    set: [
+      [FHR_PREF, true],
+      [OPT_OUT_PREF, true],
+    ],
+  }),
+  withPrivacyPrefs,
+  async function testCheckboxes(browser) {
+    const optOutCheckbox = browser.contentDocument.getElementById("optOutStudiesEnabled");
+    const fhrCheckbox = browser.contentDocument.getElementById("submitHealthReportBox");
+
+    optOutCheckbox.click();
+    ok(
+      !Services.prefs.getBoolPref(OPT_OUT_PREF),
+      "Unchecking the opt-out checkbox sets the pref to false."
+    );
+    optOutCheckbox.click();
+    ok(
+      Services.prefs.getBoolPref(OPT_OUT_PREF),
+      "Checking the opt-out checkbox sets the pref to true."
+    );
+
+    fhrCheckbox.click();
+    ok(
+      !Services.prefs.getBoolPref(OPT_OUT_PREF),
+      "Unchecking the FHR checkbox sets the opt-out pref to false."
+    );
+    ok(
+      optOutCheckbox.disabled,
+      "Unchecking the FHR checkbox disables the opt-out checkbox."
+    );
+    ok(
+      !optOutCheckbox.checked,
+      "Unchecking the FHR checkbox unchecks the opt-out checkbox."
+    );
+
+    fhrCheckbox.click();
+    ok(
+      Services.prefs.getBoolPref(OPT_OUT_PREF),
+      "Checking the FHR checkbox sets the opt-out pref to true."
+    );
+    ok(
+      !optOutCheckbox.disabled,
+      "Checking the FHR checkbox enables the opt-out checkbox."
+    );
+    ok(
+      optOutCheckbox.checked,
+      "Checking the FHR checkbox checks the opt-out checkbox."
+    );
+  }
+);
+
+decorate_task(
+  withPrefEnv({
+    set: [
+      [FHR_PREF, true],
+      [OPT_OUT_PREF, true],
+    ],
+  }),
+  withPrivacyPrefs,
+  async function testPrefWatchers(browser) {
+    const optOutCheckbox = browser.contentDocument.getElementById("optOutStudiesEnabled");
+
+    Services.prefs.setBoolPref(OPT_OUT_PREF, false);
+    ok(
+      !optOutCheckbox.checked,
+      "Disabling the opt-out pref unchecks the opt-out checkbox."
+    );
+    Services.prefs.setBoolPref(OPT_OUT_PREF, true);
+    ok(
+      optOutCheckbox.checked,
+      "Enabling the opt-out pref checks the opt-out checkbox."
+    );
+
+    Services.prefs.setBoolPref(FHR_PREF, false);
+    ok(
+      !Services.prefs.getBoolPref(OPT_OUT_PREF),
+      "Disabling the FHR pref sets the opt-out pref to false."
+    );
+    ok(
+      optOutCheckbox.disabled,
+      "Disabling the FHR pref disables the opt-out checkbox."
+    );
+    ok(
+      !optOutCheckbox.checked,
+      "Disabling the FHR pref unchecks the opt-out checkbox."
+    );
+
+    Services.prefs.setBoolPref(FHR_PREF, true);
+    ok(
+      Services.prefs.getBoolPref(OPT_OUT_PREF),
+      "Enabling the FHR pref sets the opt-out pref to true."
+    );
+    ok(
+      !optOutCheckbox.disabled,
+      "Enabling the FHR pref enables the opt-out checkbox."
+    );
+    ok(
+      optOutCheckbox.checked,
+      "Enabling the FHR pref checks the opt-out checkbox."
+    );
+  }
+);
+
+decorate_task(
+  withPrivacyPrefs,
+  async function testViewStudiesLink(browser) {
+    browser.contentDocument.getElementById("viewShieldStudies").click();
+    await BrowserTestUtils.waitForLocationChange(gBrowser);
+
+    is(
+      browser.currentURI.spec,
+      "about:studies",
+      "Clicking the view studies link opens about:studies."
+    );
+  }
+);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser/browser_about_studies.js
@@ -0,0 +1,184 @@
+"use strict";
+
+Cu.import("resource://shield-recipe-client/lib/AddonStudies.jsm", this);
+Cu.import("resource://shield-recipe-client-content/AboutPages.jsm", this);
+
+function withAboutStudies(testFunc) {
+  return async (...args) => (
+    BrowserTestUtils.withNewTab("about:studies", async browser => (
+      testFunc(...args, browser)
+    ))
+  );
+}
+
+decorate_task(
+  withAboutStudies,
+  async function testAboutStudiesWorks(browser) {
+    ok(browser.contentDocument.getElementById("app"), "App element was found");
+  }
+);
+
+decorate_task(
+  withPrefEnv({
+    set: [["extensions.shield-recipe-client.shieldLearnMoreUrl", "http://test/%OS%/"]],
+  }),
+  withAboutStudies,
+  async function testLearnMore(browser) {
+    ContentTask.spawn(browser, null, () => {
+      content.document.getElementById("shield-studies-learn-more").click();
+    });
+    await BrowserTestUtils.waitForLocationChange(gBrowser);
+
+    const location = browser.currentURI.spec;
+    is(
+      location,
+      AboutPages.aboutStudies.getShieldLearnMoreHref(),
+      "Clicking Learn More opens the correct page on SUMO.",
+    );
+    ok(!location.includes("%OS%"), "The Learn More URL is formatted.");
+  }
+);
+
+decorate_task(
+  withPrefEnv({
+    set: [["browser.preferences.useOldOrganization", false]],
+  }),
+  withAboutStudies,
+  async function testUpdatePreferencesNewOrganization(browser) {
+    ContentTask.spawn(browser, null, () => {
+      content.document.getElementById("shield-studies-update-preferences").click();
+    });
+    await BrowserTestUtils.waitForLocationChange(gBrowser);
+
+    is(
+      browser.currentURI.spec,
+      "about:preferences#privacy-reports",
+      "Clicking Update Preferences opens the privacy section of the new about:prefernces.",
+    );
+  }
+);
+
+decorate_task(
+  withPrefEnv({
+    set: [["browser.preferences.useOldOrganization", true]],
+  }),
+  withAboutStudies,
+  async function testUpdatePreferencesOldOrganization(browser) {
+    // We have to use gBrowser instead of browser in most spots since we're
+    // dealing with a new tab outside of the about:studies tab.
+    const tab = await BrowserTestUtils.switchTab(gBrowser, () => {
+      ContentTask.spawn(browser, null, () => {
+        content.document.getElementById("shield-studies-update-preferences").click();
+      });
+    });
+
+    if (gBrowser.contentDocument.readyState !== "complete") {
+      await BrowserTestUtils.waitForEvent(gBrowser.contentWindow, "load");
+    }
+
+    const location = gBrowser.contentWindow.location.href;
+    is(
+      location,
+      "about:preferences#advanced",
+      "Clicking Update Preferences opens the advanced section of the old about:prefernces.",
+    );
+
+    const dataChoicesTab = gBrowser.contentDocument.getElementById("dataChoicesTab");
+    ok(
+      dataChoicesTab.selected,
+      "Click Update preferences selects the Data Choices tab in the old about:preferences."
+    );
+
+    await BrowserTestUtils.removeTab(tab);
+  }
+);
+
+decorate_task(
+  AddonStudies.withStudies([
+    // Sort order should be study3, study1, study2 (order by enabled, then most recent).
+    studyFactory({
+      name: "A Fake Study",
+      active: true,
+      description: "A fake description",
+      studyStartDate: new Date(2017),
+    }),
+    studyFactory({
+      name: "B Fake Study",
+      active: false,
+      description: "A fake description",
+      studyStartDate: new Date(2019),
+    }),
+    studyFactory({
+      name: "C Fake Study",
+      active: true,
+      description: "A fake description",
+      studyStartDate: new Date(2018),
+    }),
+  ]),
+  withAboutStudies,
+  async function testStudyListing([study1, study2, study3], browser) {
+    await ContentTask.spawn(browser, [study1, study2, study3], async ([cStudy1, cStudy2, cStudy3]) => {
+      const doc = content.document;
+
+      function getStudyRow(docElem, studyName) {
+        return docElem.querySelector(`.study[data-study-name="${studyName}"]`);
+      }
+
+      await ContentTaskUtils.waitForCondition(() => doc.querySelectorAll(".study-list .study").length);
+      const studyRows = doc.querySelectorAll(".study-list .study");
+
+      const names = Array.from(studyRows).map(row => row.querySelector(".study-name").textContent);
+      Assert.deepEqual(
+        names,
+        [cStudy3.name, cStudy1.name, cStudy2.name],
+        "Studies are sorted first by enabled status, and then by descending start date."
+      );
+
+      const study1Row = getStudyRow(doc, cStudy1.name);
+      ok(
+        study1Row.querySelector(".study-description").textContent.includes(cStudy1.description),
+        "Study descriptions are shown in about:studies."
+      );
+      is(
+        study1Row.querySelector(".study-status").textContent,
+        "Active",
+        "Active studies show an 'Active' indicator."
+      );
+      ok(
+        study1Row.querySelector(".remove-button"),
+        "Active studies show a remove button"
+      );
+      is(
+        study1Row.querySelector(".study-icon").textContent.toLowerCase(),
+        "a",
+        "Study icons use the first letter of the study name."
+      );
+
+      const study2Row = getStudyRow(doc, cStudy2.name);
+      is(
+        study2Row.querySelector(".study-status").textContent,
+        "Complete",
+        "Inactive studies are marked as complete."
+      );
+      ok(
+        !study2Row.querySelector(".remove-button"),
+        "Inactive studies do not show a remove button"
+      );
+
+      study1Row.querySelector(".remove-button").click();
+      await ContentTaskUtils.waitForCondition(() => (
+        getStudyRow(doc, cStudy1.name).matches(".disabled")
+      ));
+      ok(
+        getStudyRow(doc, cStudy1.name).matches(".disabled"),
+        "Clicking the remove button updates the UI to show that the study has been disabled."
+      );
+    });
+
+    const updatedStudy1 = await AddonStudies.get(study1.recipeId);
+    ok(
+      !updatedStudy1.active,
+      "Clicking the remove button marks the study as inactive in storage."
+    );
+  }
+);
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser/fixtures/addon-fixture/manifest.json
@@ -0,0 +1,11 @@
+{
+    "manifest_version": 2,
+    "name": "normandy_fixture",
+    "version": "1.0",
+    "description": "Dummy test fixture that's a webextension",
+    "applications": {
+        "gecko": {
+            "id": "normandydriver@example.com"
+        }
+    }
+}
new file mode 100644
index 0000000000000000000000000000000000000000..71a6f8fe7cd90427ca710599e7125f986e8dd24d
GIT binary patch
literal 4230
zc$|$_Wl$7s*Ir-=rC})%M7qI6knWBpBwguNmXL-eq@`85q>)xax_c>!U0guAS)@x)
zKKy3h@15tF=b8D=J?F=D=00bxb6@wLOGg6-2m$~A1OVgwE_K=~oAsYW0Duu00Pv@(
zrKB&%rv_0LfVucNIyqVLzjC+yZsaSPw0}<|=5w4UhNvgEH_b&*6)ca!aA_2EeY3(t
z*CeZ1FLJR#&lDZxbkA7P2nvKAvwjLDSs{C|8nT&iJkvDhnSIlK<)3oy`(ZcW$7VAY
zcO-7C;{&aPFa39|0Gm2ub+;8fT448QfTo}yod|((Vh)ML<vI|N*9`@52e8P7C#}hL
zG6~Y+iOYeJT{sm%{;!R_Lb{&?vA#Bzw<P#fgbSf1o5vj?2qS0viu|4(HJR_@uh;rU
zx(Wm)A++$S<RvKWsECZ!)G1y7shCHa;wOcd3!#%Su{BJ-S54xHuA+bzdVn0}^m^`&
z`&kKP|7%Va50ApcafI*n2)oLmS3ei_QBdk2a3xvtN(gB})OTDlqJ8t>@LrITn2scX
zT-q=ToC{o9!<rAorJ|QM^z{W)AJ72hT?vAZ?e|7Hc7DyY9AnS71Y>o*P&{P2A~PNO
zXkEn`bpHx66?g*l+8Q_vQhLPP`7F}aK^8MW)r30)av^0#A7&Lals_1PhJs{I4m>!1
zaVFfQzG*vV@)IYZ8i6z%f1uOE4c$^Nr?!Q)Mm<#tZ9LoaZ7!5}L$VI%cX%(;&?jfe
zhbD=0#zvHEIN8Se_&BA4IpiC7*{Z?jL-*fL+nA~vqbp#vwF?<SwPmY>yFQN}ZmB<i
zS*fXLN^iU><+m}#TrfuNLwmd%oFKOlpp=s#!a2rvJhC6j5+X2x6sw6!e&Eu;y+S$t
zM9ukwD-}zcclt*mL5uWNNoH>K9=3ER=BZ*$E;Zg4TyOG<l+J&~S~FxHnZVjx@Ld;&
zI4P;K$zykagg3&K{6;r<*}!kqV~PC+rFpi*pD^Pyb`C4?#k2EE&-t&mY)Q0?#T_Af
z1EIs@ouO`nn_OtA+&9<0+IdecXs(_sv@a5x$OO?Bb%)97nZ_Wce<z*z&+$B+qs$aw
z_9zw^eNU^`yD#jsFrxT+fZFP*&uGAURM(w*2%*xFymLf-h{3d&N__;KzJO2=N*W7k
zYkeXYe~D^BQqpS2DH>3HV|s#?aViTA2+kPi@SDr*7NILps3m>?Z+MJBUb!yKO|3n3
zBrWKKQuJQI>7g{JN$E^Q8xfHy0Wa8oB|Pz^yt+fKqEn$Dl&}8Tmhd#+8c6w-C(a&Q
z%{Jh%C^^Bx{kH}R2|~89T4%$f{JeAJ+6Lf}orrLWpwp6>CtM8tI}8PMU)C!{qDiss
z1e9sP7pHsv%K=mS2HjLZ`A#x2hOGp27)F|?36}i|$LmHN^KiM&{UYx;a%l(;RrA3N
zKc@@7gWg7JvZR@F*5zoHY3%*LxmZ}KldS~!{OeS%9xUu7xRqR)-UqT+a^RpyLyk5Z
zW8bn{lq<_~%NmpHE<m_?6H<VWkrxWn&0+CjRH<&+NU{XI`4ZTG_;=@W5l3jMn|w|g
ztHQ=rXRmnZkI?s=Rc>mM*CBM!bZQ)3Q7dB=Ga7W72H|gtobgBr31f1GjGcEG<QWVN
zJL@+#+UFmut|y(!+D{HHcYX&-)d#aYQtAr(>Q_;|0Bd7mWitCh$!;RDzPm2_tzgx^
zHO5cCj|cSy#Pzmd9^@~hYX`{Rr`sR>MEhYZ@DqoZYoQ<_eq(iNxF<O#GyCCJsZXRO
zoW}TL-o@_TMkfA35MIi+UvxD?D72Rwpp!rQ=#^f@VbVd&eEcZ!)^ga0^VV+v2O`CW
z{&@R0QmHX9yQm~6_`A!^^<7ufE<E7_TmO`sI#phU3<VdK8=a^V%+XGv(AVXfe%pFI
zGx{cC=!1JmxuR_@N>GI+K_36i$%{SC%wIieFB_nliOxtKZB>u_gnbfh(ub11?s+4q
zV*YGTJ<`&xv;h2#)qD+{U(C0%U2sn(O(0Ew3z&mY1bycLQ9hN1u4bM3-D4vx3LWlm
zuPEr2;faqRW&|d_#xo(}9UXC0x!qp?+$Ve56p^l9Uk3%T9WdaLURF~yat2?kEwwIy
zqQ^;WtpizJ<>Rm8AFg2GX4SM}o0;hbruum2t;j6jjD}ol6D>@a9_+LW&H#m)e`f?M
zbj2!#Eur<d`-YE4$I|`G6(n|j=5{HwQ6P=PQ0sbcmnORJlCbCdGTD|MEovK=!9GNA
zR!ak+mp^$9GiIp-3AIcRxuww%Sj+;ck=nbIOS9{+kcE(V=7=R?UzOB8MGRVdgZN^g
zZxgt~$2<ol;uCtS4Q5Qv%A5b48*$em!93c}14o~c+uy%`CCdFSdf7N?m|^cHoqd$m
zmwk;(8w}159=Vbe;Cx3=ZW;}*MxNDLA<L!u`AMKjz}3d&v*C}@+n%9y%D>K~+bnB!
zOvcZ&eVQS3$*dd<NkGN6v|qHW=u3B8@dN(BjE9m3nQxLm7>|dS3SwMcv<3{yHw!=E
zSM?|?Dvmw5Lwb;@%*?CQ^Mvs}d|4=47;0bA8mzWLVwKc|uPy{XRL&(tB>kvT_9H!=
zlJr;Q!lQ$D)wn-YbCO&6?qcV>9oS5i5LnQRW_agy82oe_wp-@UbLyA+IWwZzECTU+
z6d!`$;YJ3}XN*x#EqH!VQ~#7#?^v?ff5nc4uwV@N8p|-rPCt{pa8j1LNSp(;AF8y=
zW3Kk#jAnm!yj-hSG1JJXqfDUG(b4EsNiZ~4fIVRN{h`E50|ArCfd%$?G)>lT6iIzG
zrbh2XcG+lWsAwS4T?~YsD&x#UB40nF)3XxW$U4#EUMWs12(&BBQo-HB|Hvc>SoBR&
z1tu{D2g}X@=J>4^FGJ+L`)TnGepn5e65*#=#)1XOq@-|wN7mDcPC90s4cea=yt{1V
zj^-p7Kdoq}vcaS$RGBJ#X5u3v)&Z93G>!Yyqt7mrAc;6<2FYeBF(8RSv(hnQ!eN;k
znPMO}0g$sZ4Kj5iTj!}o2IB7HSzcLPEsgH`ef8LiA<7uv#am4K-N1w1GwWZ;#L^^m
z_b#KJ5)r_(KT3!P`cAu6BX_AE4HHteV&2R-lk7_6+b2zIyvHG9z<#uvdjPlS_K#c9
zUV^nl<Qq}h(wEG6wwA4BfdIvp*5^gj*GlV5#hzIzq0D)F8**uBIcqwWECe17hKIuA
zh=t%I8H$9Zc{nnU-5t3$-?jsGnBN=gWt%lRU-}uK`j(h*m0jjxa_W6D_24A86s{VF
zgUZ8vPZ88!@uJUqG_3N4$A&B=DH^nT!(S^&n!>SaAwTM}`k*+cWw2t`xkiZ+p}kai
z&r?TQ>WALo{XE`QM9K?DrQcH>R=OKc!m-yf7G4)sIp0%Lz7Bx*EsZ!HuDq$6zp69L
z#!tT3g?}AQd&*ieX-T-<v<();(DMuwG{y?>&(`$ZseN`3LztT>ESwwtvfiI0p74&g
zHY$kv+Ss-A)W&|=G{xRY@*S5^a_i$g*roU=&-U|`&`()?SO-dDac(1+xRtt)3JG$1
zR=U(n9n$dXDn=gO!BncdJ3~Olg{^r%g+aaqtvYiNx*)6EiHYdaCSB%?5u1Q&xqA1C
z=Z<k)-lAc}$;9dCj&@->wMEa)eK>XVkWuz~!6T1fNVrI<(;$MVUvJHFcPM533<WU4
zI+{5B5mV4EkG2BrX;q3>5Fq0A2}^xr(}ypt@SDv|eVC+?jop)kB4Gvc@%s+6F$|w^
z!cMO{c;9#QeG@oMai>_iNhS!nG9AI{?%+KUJ(Ecy3I1&xzT(x0L;ge~V)hy5xQIF;
zXpOmJ4dioqlz_x73fYcbOtx=~gHC?t?iA=PpB4YE#<7@Q*XobCj;hHuRG*n=eC}Ql
zxhn{t2B2i5$+k=dWSQRaZt_|;JAZXENK*sNy4X01!ciRrTHb_#liz3(y2@y6r?aiR
zG*GM<;b@41+<5$H%aaGQ+<^<9T!tgI)_Mxc;V%;qa;M|s$21vR#wqjLj+uP#Mk8iC
zXYTAkR3KjW>^z+A?%#9=S=rXB=sMryXAR(zOcmNidv233!rMaC2H@is{oGNV9PbO)
zUk&k*po<qnOIK}cX?aAS^R_)2sxgH$01^A;eHZQJ1?+3dL(=}rwarCrQo0-4Y-C5z
zmZ#0!plu0d^d|OYBX;oujRGsSk6{w0ZiXcRDDJX+`5Chu$&W=bciW1VACDs*hW=oN
z7B^JeVPk$-tv}h|(N!yrvc85<J(|N?8Dpy~bu(ILt0O|rPq?Ld$gEZYbFzFkJ82Dh
z&%_O0FuCfEJ6}TUIq$VEfIVLfVIEgWrR5y<Jd51E1EE&wHgj~Q7oPAcs&QnJK#Oq=
z3U=44XwACmvbzaYgBvU+H8`v*1PgJ7owF@8KIjU!F^|SUW`f>QYX!M|?yWi-nPT~#
zvfmbOLUm|1(?J0}0RP-8M-_i-cdBl;z$3(2&e~}*)7)KpA;#l5MKm?RYc6k`_7W2%
z<m9ZM$*G2-{*ABme_qMKxKTafwMcyWExNu<VgM}wX6fu`XY204|H9qH*+3r`fK!*t
zX5#5X2*3iKVgUgE+iDQsLeecrq;wt_o*a&A&^g%K8KnQvtt;P`(@mqi>;|IRGaSmc
zxNE)G6r$g)s==r1j7Rh~i%Lb*$!J(T)DeUVuPZ7kE_!<voPS|1kJ<eWMba8DjrWP2
z_iJ`;<ACm8u=`TVb>j$#f@k4HOOsdeN$}-Ihwg^8?n<8CL3$}_H+Ivn^(rXiCof1|
zuE$<f2q0%uckbx?#RtmIucn6HqEx`&ec*qVgxMWyjSOpQ@bOO&Yw&T8jf~go2~G<w
ztsH5LXw~ZthO6(`Qf+R!HQ5x0g2XopH)(8BY}TluTo=u0+(3WPd#|wQn#p{5!fWKK
z!7>H+?L%XupZ`*|-u(LM@$iWU6w05d-dH9ZTBBZ~7_>L-x>^>zl$1|4#ndx0vX&^T
zU=HQy2mF<EeO!#oi(BM-o0R`#Uft~o$27+^c)9r0k$)k5<oO8cIi#t5yUgjIt#H5<
zAYP)o%-I1_M0LdVZSEmjdbQX*`O(-gSW}49ed0&SG{9~Aa4~z8WT0}R1bukPx<7qO
za!ZnrpP%CFD>tlLYFOAHoPT%L|A}BMz`ywHKNA>n{{6rGi9gMSuX%|9|G(e&cT#_s
p`M;zHNd8&$zfbu4oBwx08_7Q^pre6{_vZre_R!w)GA8|l{s)EOuulL0
--- a/browser/extensions/shield-recipe-client/test/browser/head.js
+++ b/browser/extensions/shield-recipe-client/test/browser/head.js
@@ -1,12 +1,14 @@
 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
 
 Cu.import("resource://gre/modules/Preferences.jsm", this);
+Cu.import("resource://testing-common/AddonTestUtils.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/Addons.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/NormandyApi.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/Utils.jsm", this);
 
 // Load mocking/stubbing library, sinon
 // docs: http://sinonjs.org/docs/
 const loader = Cc["@mozilla.org/moz/jssubscript-loader;1"].getService(Ci.mozIJSSubScriptLoader);
@@ -21,56 +23,129 @@ sinon.assert.fail = function(message) {
 registerCleanupFunction(async function() {
   // Cleanup window or the test runner will throw an error
   delete window.sinon;
 });
 
 
 this.UUID_REGEX = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/;
 
-this.withSandboxManager = function(Assert, testFunction) {
-  return async function inner() {
-    const sandboxManager = new SandboxManager();
-    sandboxManager.addHold("test running");
+this.TEST_XPI_URL = (function() {
+  const dir = getChromeDir(getResolvedURI(gTestPath));
+  dir.append("fixtures");
+  dir.append("normandy.xpi");
+  return Services.io.newFileURI(dir).spec;
+})();
+
+this.withWebExtension = function(manifestOverrides = {}) {
+  return function wrapper(testFunction) {
+    return async function wrappedTestFunction(...args) {
+      const random = Math.random().toString(36).replace(/0./, "").substr(-3);
+      let id = `normandydriver_${random}@example.com`;
+      if ("id" in manifestOverrides) {
+        id = manifestOverrides.id;
+        delete manifestOverrides.id;
+      }
+
+      const manifest = Object.assign({
+        manifest_version: 2,
+        name: "normandy_fixture",
+        version: "1.0",
+        description: "Dummy test fixture that's a webextension",
+        applications: {
+          gecko: { id },
+        },
+      }, manifestOverrides);
+
+      const addonFile = AddonTestUtils.createTempWebExtensionFile({manifest});
+
+      // Workaround: Add-on files are cached by URL, and
+      // createTempWebExtensionFile re-uses filenames if the previous file has
+      // been deleted. So we need to flush the cache to avoid it.
+      Services.obs.notifyObservers(addonFile, "flush-cache-entry");
 
-    await testFunction(sandboxManager);
+      try {
+        await testFunction(...args, [id, addonFile]);
+      } finally {
+        AddonTestUtils.cleanupTempXPIs();
+      }
+    };
+  };
+};
 
-    sandboxManager.removeHold("test running");
-    await sandboxManager.isNuked()
-      .then(() => Assert.ok(true, "sandbox is nuked"))
-      .catch(e => Assert.ok(false, "sandbox is nuked", e));
+this.withInstalledWebExtension = function(manifestOverrides = {}) {
+  return function wrapper(testFunction) {
+    return decorate(
+      withWebExtension(manifestOverrides),
+      async function wrappedTestFunction(...args) {
+        const [id, file] = args[args.length - 1];
+        const startupPromise = AddonTestUtils.promiseWebExtensionStartup(id);
+        const url = Services.io.newFileURI(file).spec;
+        await Addons.install(url);
+        await startupPromise;
+        try {
+          await testFunction(...args);
+        } finally {
+          if (await Addons.get(id)) {
+            await Addons.uninstall(id);
+          }
+        }
+      }
+    );
+  };
+};
+
+this.withSandboxManager = function(Assert) {
+  return function wrapper(testFunction) {
+    return async function wrappedTestFunction(...args) {
+      const sandboxManager = new SandboxManager();
+      sandboxManager.addHold("test running");
+
+      await testFunction(...args, sandboxManager);
+
+      sandboxManager.removeHold("test running");
+      await sandboxManager.isNuked()
+        .then(() => Assert.ok(true, "sandbox is nuked"))
+        .catch(e => Assert.ok(false, "sandbox is nuked", e));
+    };
   };
 };
 
 this.withDriver = function(Assert, testFunction) {
-  return withSandboxManager(Assert, async function inner(sandboxManager) {
+  return withSandboxManager(Assert)(async function inner(...args) {
+    const sandboxManager = args[args.length - 1];
     const driver = new NormandyDriver(sandboxManager);
-    await testFunction(driver);
+    await testFunction(driver, ...args);
   });
 };
 
 this.withMockNormandyApi = function(testFunction) {
   return async function inner(...args) {
     const mockApi = {actions: [], recipes: [], implementations: {}};
 
-    sinon.stub(NormandyApi, "fetchActions", async () => mockApi.actions);
-    sinon.stub(NormandyApi, "fetchRecipes", async () => mockApi.recipes);
-    sinon.stub(NormandyApi, "fetchImplementation", async action => {
-      const impl = mockApi.implementations[action.name];
-      if (!impl) {
-        throw new Error("Missing");
+    // Use callsFake instead of resolves so that the current values in mockApi are used.
+    mockApi.fetchActions = sinon.stub(NormandyApi, "fetchActions").callsFake(async () => mockApi.actions);
+    mockApi.fetchRecipes = sinon.stub(NormandyApi, "fetchRecipes").callsFake(async () => mockApi.recipes);
+    mockApi.fetchImplementation = sinon.stub(NormandyApi, "fetchImplementation").callsFake(
+      async action => {
+        const impl = mockApi.implementations[action.name];
+        if (!impl) {
+          throw new Error("Missing");
+        }
+        return impl;
       }
-      return impl;
-    });
+    );
 
-    await testFunction(mockApi, ...args);
-
-    NormandyApi.fetchActions.restore();
-    NormandyApi.fetchRecipes.restore();
-    NormandyApi.fetchImplementation.restore();
+    try {
+      await testFunction(mockApi, ...args);
+    } finally {
+      mockApi.fetchActions.restore();
+      mockApi.fetchRecipes.restore();
+      mockApi.fetchImplementation.restore();
+    }
   };
 };
 
 const preferenceBranches = {
   user: Preferences,
   default: new Preferences({defaultBranch: true}),
 };
 
@@ -113,8 +188,91 @@ class MockPreferences {
           preferenceBranch.set(name, oldValue);
         } else {
           preferenceBranch.reset(name);
         }
       }
     }
   }
 }
+
+this.withPrefEnv = function(inPrefs) {
+  return function wrapper(testFunc) {
+    return async function inner(...args) {
+      await SpecialPowers.pushPrefEnv(inPrefs);
+      try {
+        await testFunc(...args);
+      } finally {
+        await SpecialPowers.popPrefEnv();
+      }
+    };
+  };
+};
+
+/**
+ * Combine a list of functions right to left. The rightmost function is passed
+ * to the preceeding function as the argument; the result of this is passed to
+ * the next function until all are exhausted. For example, this:
+ *
+ * decorate(func1, func2, func3);
+ *
+ * is equivalent to this:
+ *
+ * func1(func2(func3));
+ */
+this.decorate = function(...args) {
+  const funcs = Array.from(args);
+  let decorated = funcs.pop();
+  funcs.reverse();
+  for (const func of funcs) {
+    decorated = func(decorated);
+  }
+  return decorated;
+};
+
+/**
+ * Wrapper around add_task for declaring tests that use several with-style
+ * wrappers. The last argument should be your test function; all other arguments
+ * should be functions that accept a single test function argument.
+ *
+ * The arguments are combined using decorate and passed to add_task as a single
+ * test function.
+ *
+ * @param {[Function]} args
+ * @example
+ *   decorate_task(
+ *     withMockPreferences,
+ *     withMockNormandyApi,
+ *     async function myTest(mockPreferences, mockApi) {
+ *       // Do a test
+ *     }
+ *   );
+ */
+this.decorate_task = function(...args) {
+  return add_task(decorate(...args));
+};
+
+let _studyFactoryId = 0;
+this.studyFactory = function(attrs) {
+  return Object.assign({
+    recipeId: _studyFactoryId++,
+    name: "Test study",
+    description: "fake",
+    active: true,
+    addonId: "fake@example.com",
+    addonUrl: "http://test/addon.xpi",
+    addonVersion: "1.0.0",
+    studyStartDate: new Date(),
+  }, attrs);
+};
+
+this.withStub = function(...stubArgs) {
+  return function wrapper(testFunction) {
+    return async function wrappedTestFunction(...args) {
+      const stub = sinon.stub(...stubArgs);
+      try {
+        await testFunction(...args, stub);
+      } finally {
+        stub.restore();
+      }
+    };
+  };
+};
--- a/browser/extensions/shield-recipe-client/test/unit/head_xpc.js
+++ b/browser/extensions/shield-recipe-client/test/unit/head_xpc.js
@@ -18,24 +18,25 @@ if (!extensionDir.exists()) {
   extensionDir = extensionDir.parent;
   extensionDir.append(EXTENSION_ID + ".xpi");
 }
 Components.manager.addBootstrappedManifestLocation(extensionDir);
 
 // ================================================
 // Load mocking/stubbing library, sinon
 // docs: http://sinonjs.org/releases/v2.3.2/
+/* exported sinon */
 Cu.import("resource://gre/modules/Timer.jsm");
 const {Loader} = Cu.import("resource://gre/modules/commonjs/toolkit/loader.js", {});
 const loader = new Loader.Loader({
   paths: {
     "": "resource://testing-common/",
   },
   globals: {
     setTimeout,
     setInterval,
     clearTimeout,
     clearInterval,
   },
 });
 const require = Loader.Require(loader, {id: ""});
-const sinon = require("sinon-2.3.2");
+this.sinon = require("sinon-2.3.2");
 // ================================================
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/unit/invalid_recipe_signature_api/api/v1/index.json
@@ -0,0 +1,4 @@
+{
+  "recipe-signed": "/api/v1/recipe/signed/",
+  "classify-client": "/api/v1/classify_client/"
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/unit/invalid_recipe_signature_api/api/v1/recipe/signed/index.json
@@ -0,0 +1,1 @@
+[{"recipe":{"action":"console-log","arguments":{"message":"this signature does not match this recipe"},"channels":[],"countries":[],"enabled":true,"extra_filter_expression":"true || true","filter_expression":"true || true","id":1,"last_updated":"2017-02-17T18:29:09.839239Z","locales":[],"name":"system-addon-test","revision_id":"b2cb8a26e132182d7d02cf50695d2c7f06cf3b954ff2ff63bca49d724ee91950"},"signature":{"public_key":"MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEVEKiCAIkwRg1VFsP8JOYdSF6a3qvgbRPoEK9eTuLbrB6QixozscKR4iWJ8ZOOX6RPCRgFdfVDoZqjFBFNJN9QtRBk0mVtHbnErx64d2vMF0oWencS1hyLW2whgOgOz7p","signature":"p4g3eurmPsJK5UcGT97BRyKstpwZ_2mNJkDGpd6QXlkXfvgwprjeyb5yeIEkKUXqc6krWid4obB_OP9-CwOi9tvKY1pV8p98CT5BhF0IVgpF3b7KBW1a0BVdg5owoG5W","timestamp":"2017-02-17T18:29:09.847614Z","x5u":"/normandy.content-signature.mozilla.org-20210705.dev.chain"}}]
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/unit/invalid_recipe_signature_api/normandy.content-signature.mozilla.org-20210705.dev.chain
@@ -0,0 +1,123 @@
+-----BEGIN CERTIFICATE-----
+MIIGRTCCBC2gAwIBAgIEAQAABTANBgkqhkiG9w0BAQwFADBrMQswCQYDVQQGEwJV
+UzEQMA4GA1UEChMHQWxsaXpvbTEXMBUGA1UECxMOQ2xvdWQgU2VydmljZXMxMTAv
+BgNVBAMTKERldnppbGxhIFNpZ25pbmcgU2VydmljZXMgSW50ZXJtZWRpYXRlIDEw
+HhcNMTYwNzA2MjE1NzE1WhcNMjEwNzA1MjE1NzE1WjCBrzELMAkGA1UEBhMCVVMx
+EzARBgNVBAgTCkNhbGlmb3JuaWExHDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRp
+b24xFzAVBgNVBAsTDkNsb3VkIFNlcnZpY2VzMS8wLQYDVQQDEyZub3JtYW5keS5j
+b250ZW50LXNpZ25hdHVyZS5tb3ppbGxhLm9yZzEjMCEGCSqGSIb3DQEJARYUc2Vj
+dXJpdHlAbW96aWxsYS5vcmcwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARUQqIIAiTB
+GDVUWw/wk5h1IXpreq+BtE+gQr15O4tusHpCLGjOxwpHiJYnxk45fpE8JGAV19UO
+hmqMUEU0k31C1EGTSZW0ducSvHrh3a8wXShZ6dxLWHItbbCGA6A7PumjggJYMIIC
+VDAdBgNVHQ4EFgQUVfksSjlZ0i1TBiS1vcoObaMeXn0wge8GA1UdIwSB5zCB5IAU
+/YboUIXAovChEpudDBuodHKbjUuhgcWkgcIwgb8xCzAJBgNVBAYTAlVTMQswCQYD
+VQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UEChMdQ29udGVu
+dCBTaWduYXR1cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5jb250ZW50LXNp
+Z25hdHVyZS5yb290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNlYytkZXZyb290
+Y29udGVudHNpZ25hdHVyZUBtb3ppbGxhLmNvbYIEAQAABDAMBgNVHRMBAf8EAjAA
+MA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAzBEBgNVHR8E
+PTA7MDmgN6A1hjNodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmRldi5tb3phd3Mu
+bmV0L2NhL2NybC5wZW0wQgYJYIZIAYb4QgEEBDUWM2h0dHBzOi8vY29udGVudC1z
+aWduYXR1cmUuZGV2Lm1vemF3cy5uZXQvY2EvY3JsLnBlbTBOBggrBgEFBQcBAQRC
+MEAwPgYIKwYBBQUHMAKGMmh0dHBzOi8vY29udGVudC1zaWduYXR1cmUuZGV2Lm1v
+emF3cy5uZXQvY2EvY2EucGVtMDEGA1UdEQQqMCiCJm5vcm1hbmR5LmNvbnRlbnQt
+c2lnbmF0dXJlLm1vemlsbGEub3JnMA0GCSqGSIb3DQEBDAUAA4ICAQCwb+8JTAB7
+ZfQmFqPUIV2cQQv696AaDPQCtA9YS4zmUfcLMvfZVAbK397zFr0RMDdLiTUQDoeq
+rBEmPXhJRPiv6JAK4n7Jf6Y6XfXcNxx+q3garR09Vm/0CnEq/iV+ZAtPkoKIO9kr
+Nkzecd894yQCF4hIuPQ5qtMySeqJmH3Dp13eq4T0Oew1Bu32rNHuBJh2xYBkWdun
+aAw/YX0I5EqZBP/XA6gbiA160tTK+hnpnlMtw/ljkvfhHbWpICD4aSiTL8L3vABQ
+j7bqjMKR5xDkuGWshZfcmonpvQhGTye/RZ1vz5IzA3VOJt1mz5bdZlitpaOm/Yv0
+x6aODz8GP/PiRWFQ5CW8Uf/7pGc5rSyvnfZV2ix8EzFlo8cUtuN1fjrPFPOFOLvG
+iiB6S9nlXiKBGYIDdd8V8iC5xJpzjiAWJQigwSNzuc2K30+iPo3w0zazkwe5V8jW
+gj6gItYxh5xwVQTPHD0EOd9HvV1ou42+rH5Y+ISFUm25zz02UtUHEK0BKtL0lmdt
+DwVq5jcHn6bx2/iwUtlKvPXtfM/6JjTJlkLZLtS7U5/pwcS0owo9zAL0qg3bdm16
++v/olmPqQFLUHmamJTzv3rojj5X/uVdx1HMM3wBjV9tRYoYaZw9RIInRmM8Z1pHv
+JJ+CIZgCyd5vgp57BKiodRZcgHoCH+BkOQ==
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIHijCCBXKgAwIBAgIEAQAABDANBgkqhkiG9w0BAQwFADCBvzELMAkGA1UEBhMC
+VVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MSYwJAYDVQQK
+Ex1Db250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEmMCQGA1UEAxMdZGV2LmNv
+bnRlbnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG9w0BCQEWLGNsb3Vkc2Vj
+K2RldnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEuY29tMB4XDTE2MDcwNjIx
+NDkyNloXDTIxMDcwNTIxNDkyNlowazELMAkGA1UEBhMCVVMxEDAOBgNVBAoTB0Fs
+bGl6b20xFzAVBgNVBAsTDkNsb3VkIFNlcnZpY2VzMTEwLwYDVQQDEyhEZXZ6aWxs
+YSBTaWduaW5nIFNlcnZpY2VzIEludGVybWVkaWF0ZSAxMIICIjANBgkqhkiG9w0B
+AQEFAAOCAg8AMIICCgKCAgEAypIfUURYgWzGw8G/1Pz9zW+Tsjirx2owThiv2gys
+wJiWL/9/2gzKOrYDEqlDUudfA/BjVRtT9+NbYgnhaCkNfADOAacWS83aMhedAqhP
+bVd5YhGQdpijI7f1AVTSb0ehrU2nhOZHvHX5Tk2fbRx3ryefIazNTLFGpiMBbsNv
+tSI/+fjW8s0MhKNqlLnk6a9mZKo0mEy7HjGYV8nzsgI17rKLx/s2HG4TFG0+JQzc
+UGlum3Tg58ritDzWdyKIkmKNZ48oLBX99Qc8j8B1UyiLv6TZmjVX0I+Ds7eSWHZk
+0axLEpTyf2r7fHvN4iDNCPajw+ZpuuBfbs80Ha8b8MHvnpf9fbwiirodNQOVpY4c
+t5E3Us3eYwBKdqDEbECWxCKGAS2/iVVUCNKHsg0sSxgqcwxrxyrddQRUQ0EM38DZ
+F/vHt+vTdHt07kezbjJe0Kklel59uSpghA0iL4vxzbTns1fuwYOgVrNGs3acTkiB
+GhFOxRXUPGtpdYmv+AaR9WlWJQY1GIEoVrinPVH7bcCwyh1CcUbHL+oAFTcmc6kZ
+7azNg21tWILIRL7R0IZYQm0tF5TTwCsjVC7FuHaBtkxtVrrZqeKjvVXQ8TK5VoI0
+BUQ6BKHGeTtm+0HBpheYBDy3wkOsEGbGHLEM6cMeiz6PyCXF8wXli8Qb/TjN3LHZ
+e30CAwEAAaOCAd8wggHbMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGG
+MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMDMB0GA1UdDgQWBBT9huhQhcCi8KESm50M
+G6h0cpuNSzCB7AYDVR0jBIHkMIHhgBSDx8s0qJaMyQCehKcuzgzVNRA75qGBxaSB
+wjCBvzELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFp
+biBWaWV3MSYwJAYDVQQKEx1Db250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEm
+MCQGA1UEAxMdZGV2LmNvbnRlbnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG
+9w0BCQEWLGNsb3Vkc2VjK2RldnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEu
+Y29tggEBMEIGCWCGSAGG+EIBBAQ1FjNodHRwczovL2NvbnRlbnQtc2lnbmF0dXJl
+LmRldi5tb3phd3MubmV0L2NhL2NybC5wZW0wTgYIKwYBBQUHAQEEQjBAMD4GCCsG
+AQUFBzAChjJodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmRldi5tb3phd3MubmV0
+L2NhL2NhLnBlbTANBgkqhkiG9w0BAQwFAAOCAgEAbum0z0ccqI1Wp49VtsGmUPHA
+gjPPy2Xa5NePmqY87WrGdhjN3xbLVb3hx8T2N6pqDjMY2rEynXKEOek3oJkQ3C6J
+8AFP6Y93gaAlNz6EA0J0mqdW5TMI8YEYsu2ma+dQQ8wm9f/5b+/Y8qwfhztP06W5
+H6IG04/RvgUwYMnSR4QvT309fu5UmCRUDzsO53ZmQCfmN94u3NxHc4S6n0Q6AKAM
+kh5Ld9SQnlqqqDykzn7hYDi8nTLWc7IYqkGfNMilDEKbAl4CjnSfyEvpdFAJ9sPR
+UL+kaWFSMvaqIPNpxS5OpoPZjmxEc9HHnCHxtfDHWdXTJILjijPrCdMaxOCHfIqV
+5roOJggI4RZ0YM68IL1MfN4IEVOrHhKjDHtd1gcmy2KU4jfj9LQq9YTnyvZ2z1yS
+lS310HG3or1K8Nnu5Utfe7T6ppX8bLRMkS1/w0p7DKxHaf4D/GJcCtM9lcSt9JpW
+6ACKFikjWR4ZxczYKgApc0wcjd7XBuO5777xtOeyEUDHdDft3jiXA91dYM5UAzc3
+69z/3zmaELzo0gWcrjLXh7fU9AvbU4EUF6rwzxbPGF78jJcGK+oBf8uWUCkBykDt
+VsAEZI1u4EDg8e/C1nFqaH9gNMArAgquYIB9rve+hdprIMnva0S147pflWopBWcb
+jwzgpfquuYnnxe0CNBA=
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIH3DCCBcSgAwIBAgIBATANBgkqhkiG9w0BAQwFADCBvzELMAkGA1UEBhMCVVMx
+CzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MSYwJAYDVQQKEx1D
+b250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEmMCQGA1UEAxMdZGV2LmNvbnRl
+bnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG9w0BCQEWLGNsb3Vkc2VjK2Rl
+dnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEuY29tMB4XDTE2MDcwNjE4MTUy
+MloXDTI2MDcwNDE4MTUyMlowgb8xCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJDQTEW
+MBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UEChMdQ29udGVudCBTaWduYXR1
+cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5jb250ZW50LXNpZ25hdHVyZS5y
+b290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNlYytkZXZyb290Y29udGVudHNp
+Z25hdHVyZUBtb3ppbGxhLmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
+ggIBAJcPcXhD8MzF6OTn5qZ0L7lX1+PEgLKhI9g1HxxDYDVup4Zm0kZhPGmFSlml
+6eVO99OvvHdAlHhQGCIG7h+w1cp66mWjfpcvtQH23uRoKZfiW3jy1jUWrvdXolxR
+t1taZosjzo+9OP8TvG6LpJj7AvqUiYD4wYnQJtt0jNRN4d6MUfQwiavSS5uTBuxd
+ZJ4TsPvEI+Iv4A4PSobSzxkg79LTMvsGtDLQv7nN5hMs9T18EL5GnIKoqnSQCU0d
+n2CN7S3QiQ+cbORWsSYqCTj1gUbFh6X3duEB/ypLcyWFbqeJmPHikjY8q8pLjZSB
+IYiTJYLyvYlKdM5QleC/xuBNnMPCftrwwLHjWE4Dd7C9t7k0R5xyOetuiHLCwOcQ
+tuckp7RgFKoviMNv3gdkzwVapOklcsaRkRscv6OMTKJNsdJVIDLrPF1wMABhbEQB
+64BL0uL4lkFtpXXbJzQ6mgUNQveJkfUWOoB+cA/6GtI4J0aQfvQgloCYI6jxNn/e
+Nvk5OV9KFOhXS2dnDft3wHU46sg5yXOuds1u6UrOoATBNFlkS95m4zIX1Svu091+
+CKTiLK85+ZiFtAlU2bPr3Bk3GhL3Z586ae6a4QUEx6SPQVXc18ezB4qxKqFc+avI
+ylccYMRhVP+ruADxtUM5Vy6x3U8BwBK5RLdecRx2FEBDImY1AgMBAAGjggHfMIIB
+2zAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAWBgNVHSUBAf8EDDAK
+BggrBgEFBQcDAzAdBgNVHQ4EFgQUg8fLNKiWjMkAnoSnLs4M1TUQO+YwgewGA1Ud
+IwSB5DCB4YAUg8fLNKiWjMkAnoSnLs4M1TUQO+ahgcWkgcIwgb8xCzAJBgNVBAYT
+AlVTMQswCQYDVQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UE
+ChMdQ29udGVudCBTaWduYXR1cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5j
+b250ZW50LXNpZ25hdHVyZS5yb290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNl
+YytkZXZyb290Y29udGVudHNpZ25hdHVyZUBtb3ppbGxhLmNvbYIBATBCBglghkgB
+hvhCAQQENRYzaHR0cHM6Ly9jb250ZW50LXNpZ25hdHVyZS5kZXYubW96YXdzLm5l
+dC9jYS9jcmwucGVtME4GCCsGAQUFBwEBBEIwQDA+BggrBgEFBQcwAoYyaHR0cHM6
+Ly9jb250ZW50LXNpZ25hdHVyZS5kZXYubW96YXdzLm5ldC9jYS9jYS5wZW0wDQYJ
+KoZIhvcNAQEMBQADggIBAAAQ+fotZE79FfZ8Lz7eiTUzlwHXCdSE2XD3nMROu6n6
+uLTBPrf2C+k+U1FjKVvL5/WCUj6hIzP2X6Sb8+o0XHX9mKN0yoMORTEYJOnazYPK
+KSUUFnE4vGgQkr6k/31gGRMTICdnf3VOOAlUCQ5bOmGIuWi401E3sbd85U+TJe0A
+nHlU+XjtfzlqcBvQivdbA0s+GEG55uRPvn952aTBEMHfn+2JqKeLShl4AtUAfu+h
+6md3Z2HrEC7B3GK8ekWPu0G/ZuWTuFvOimZ+5C8IPRJXcIR/siPQl1x6dpTCew6t
+lPVcVuvg6SQwzvxetkNrGUe2Wb2s9+PK2PUvfOS8ee25SNmfG3XK9qJpqGUhzSBX
+T8QQgyxd0Su5G7Wze7aaHZd/fqIm/G8YFR0HiC2xni/lnDTXFDPCe+HCnSk0bH6U
+wpr6I1yK8oZ2IdnNVfuABGMmGOhvSQ8r7//ea9WKhCsGNQawpVWVioY7hpyNAJ0O
+Vn4xqG5f6allz8lgpwAQ+AeEEClHca6hh6mj9KhD1Of1CC2Vx52GHNh/jMYEc3/g
+zLKniencBqn3Y2XH2daITGJddcleN09+a1NaTkT3hgr7LumxM8EVssPkC+z9j4Vf
+Gbste+8S5QCMhh00g5vR9QF8EaFqdxCdSxrsA4GmpCa5UQl8jtCnpp2DLKXuOh72
+-----END CERTIFICATE-----
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/console-log/implementation/sha384-RGx3rydrSq53UfmW9kFcK0mQYra67XIvZvr4MhmAe--ljiiMQOtgM7Cmca48um3v
@@ -0,0 +1,1 @@
+(function(a){function b(d){if(c[d])return c[d].exports;var e=c[d]={exports:{},id:d,loaded:!1};return a[d].call(e.exports,e,e.exports,b),e.loaded=!0,e.exports}var c={};return b.m=a,b.c=c,b.p="",b(0)})([function(a,b,c){"use strict";Object.defineProperty(b,"__esModule",{value:!0});var d=c(1);class e extends d.Action{async execute(){this.normandy.log(this.recipe.arguments.message,"info")}}b.default=e,(0,d.registerAction)("console-log",e)},function(a,b){(function(c){"use strict";Object.defineProperty(b,"__esModule",{value:!0});b.Action=class{constructor(g,h){this.normandy=g,this.recipe=h}};const e=b.registerAction=c&&c.registerAction||window&&window.registerAction||function(){},f=b.registerAsyncCallback=c&&c.registerAsyncCallback||window&&window.registerAsyncCallback||function(){}}).call(b,function(){return this}())}]);
\ No newline at end of file
--- a/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/console-log/index.json
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/console-log/index.json
@@ -9,11 +9,11 @@
       }
     },
     "required": [
       "message"
     ],
     "title": "Log a message to the console",
     "type": "object"
   },
-  "implementation_url": "https://localhost:8000/api/v1/action/console-log/implementation/0765c6302db2846b85543eea743f959c19b42559/",
-  "name": "console-log"
+  "name": "console-log",
+  "implementation_url": "https://localhost:8443/api/v1/action/console-log/implementation/sha384-RGx3rydrSq53UfmW9kFcK0mQYra67XIvZvr4MhmAe--ljiiMQOtgM7Cmca48um3v/"
 }
--- a/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/index.json
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/index.json
@@ -10,22 +10,54 @@
         }
       },
       "required": [
         "message"
       ],
       "title": "Log a message to the console",
       "type": "object"
     },
-    "implementation_url": "https://localhost:8000/api/v1/action/console-log/implementation/0765c6302db2846b85543eea743f959c19b42559/",
+    "implementation_url": "https://localhost:8443/api/v1/action/console-log/implementation/sha384-RGx3rydrSq53UfmW9kFcK0mQYra67XIvZvr4MhmAe--ljiiMQOtgM7Cmca48um3v/",
     "name": "console-log"
   },
   {
     "arguments_schema": {
       "$schema": "http://json-schema.org/draft-04/schema#",
+      "properties": {
+        "addonUrl": {
+          "description": "URL of the add-on XPI file",
+          "format": "uri",
+          "minLength": 1,
+          "type": "string"
+        },
+        "description": {
+          "description": "User-facing description of the study",
+          "minLength": 1,
+          "type": "string"
+        },
+        "name": {
+          "description": "User-facing name of the study",
+          "minLength": 1,
+          "type": "string"
+        }
+      },
+      "required": [
+        "name",
+        "description",
+        "addonUrl"
+      ],
+      "title": "Enroll a user in an opt-out SHIELD study",
+      "type": "object"
+    },
+    "implementation_url": "https://localhost:8443/api/v1/action/opt-out-study/implementation/sha384-HM_avYcD00o27ufwU1V7PIBtiuMAXML6MMwlYrDEqDX-XzGVuOfL52RCM680JExN/",
+    "name": "opt-out-study"
+  },
+  {
+    "arguments_schema": {
+      "$schema": "http://json-schema.org/draft-04/schema#",
       "description": "This action shows a single survey.",
       "properties": {
         "engagementButtonLabel": {
           "default": "",
           "description": "Text for the engagement button. If specified, this button will be shown instead of rating stars.",
           "type": "string"
         },
         "includeTelemetryUUID": {
@@ -48,16 +80,26 @@
           "description": "Message to show to the user",
           "type": "string"
         },
         "postAnswerUrl": {
           "default": "",
           "description": "URL to redirect the user to after rating Firefox or clicking the engagement button",
           "type": "string"
         },
+        "repeatEvery": {
+          "default": null,
+          "description": "How often (in days) the action is displayed.",
+          "type": "number"
+        },
+        "repeatOption": {
+          "default": "once",
+          "description": "Determines how often an action executes. (once|nag|xdays)",
+          "type": "string"
+        },
         "surveyId": {
           "description": "Slug uniquely identifying this survey in telemetry",
           "type": "string"
         },
         "thanksMessage": {
           "default": "",
           "description": "Thanks message to show to the user after they've rated Firefox",
           "type": "string"
@@ -66,12 +108,98 @@
       "required": [
         "surveyId",
         "message",
         "thanksMessage"
       ],
       "title": "Show a Heartbeat survey.",
       "type": "object"
     },
-    "implementation_url": "https://localhost:8000/api/v1/action/show-heartbeat/implementation/448bbffce82ff27b3d532fd7196bcb190be5c067/",
+    "implementation_url": "https://localhost:8443/api/v1/action/show-heartbeat/implementation/sha384-dEGiyKPEln8Ns5cQHzGpMIGdirSAAX0X-Kwlu-U3sJ05yNbO-ANij_a6c5SyL7G4/",
     "name": "show-heartbeat"
+  },
+  {
+    "arguments_schema": {
+      "$schema": "http://json-schema.org/draft-04/schema#",
+      "properties": {
+        "branches": {
+          "description": "List of experimental branches",
+          "items": {
+            "properties": {
+              "ratio": {
+                "default": 1,
+                "description": "Ratio of users who should be grouped into this branch",
+                "minimum": 1,
+                "type": "integer"
+              },
+              "slug": {
+                "description": "Unique identifier for this branch of the experiment",
+                "pattern": "^[A-Za-z0-9\\-_]+$",
+                "type": "string"
+              },
+              "value": {
+                "description": "Value to set the preference to for this branch",
+                "type": [
+                  "string",
+                  "number",
+                  "boolean"
+                ]
+              }
+            },
+            "required": [
+              "slug",
+              "value",
+              "ratio"
+            ],
+            "type": "object"
+          },
+          "minItems": 1,
+          "type": "array"
+        },
+        "experimentDocumentUrl": {
+          "default": "",
+          "description": "URL of a document describing the experiment",
+          "format": "uri",
+          "type": "string"
+        },
+        "preferenceBranchType": {
+          "default": "default",
+          "descript": "Controls whether the default or user value of the preference is modified",
+          "enum": [
+            "user",
+            "default"
+          ],
+          "type": "string"
+        },
+        "preferenceName": {
+          "default": "",
+          "description": "Full dotted-path of the preference that controls this experiment",
+          "type": "string"
+        },
+        "preferenceType": {
+          "default": "boolean",
+          "description": "Data type of the preference that controls this experiment",
+          "enum": [
+            "string",
+            "integer",
+            "boolean"
+          ],
+          "type": "string"
+        },
+        "slug": {
+          "description": "Unique identifier for this experiment",
+          "pattern": "^[A-Za-z0-9\\-_]+$",
+          "type": "string"
+        }
+      },
+      "required": [
+        "slug",
+        "preferenceName",
+        "preferenceType",
+        "branches"
+      ],
+      "title": "Run a feature experiment activated by a preference.",
+      "type": "object"
+    },
+    "implementation_url": "https://localhost:8443/api/v1/action/preference-experiment/implementation/sha384-KQgG38GQ7KZAb2VIB48ANQO6nBcxZoLm2ORzUviRT5nAvSywyPjZ5cJIElw6iXIt/",
+    "name": "preference-experiment"
   }
 ]
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/opt-out-study/implementation/sha384-HM_avYcD00o27ufwU1V7PIBtiuMAXML6MMwlYrDEqDX-XzGVuOfL52RCM680JExN
@@ -0,0 +1,1 @@
+(function(a){function b(d){if(c[d])return c[d].exports;var e=c[d]={exports:{},id:d,loaded:!1};return a[d].call(e.exports,e,e.exports,b),e.loaded=!0,e.exports}var c={};return b.m=a,b.c=c,b.p="",b(0)})([function(a,b,c){"use strict";async function e(i){const j=i.studies;if(void 0===j)return void i.log("Client does not support studies, aborting.","info");const k=(await j.getAll()).filter((l)=>l.active);for(const l of k)if(!g.includes(l.recipeId)){i.log("Stopping study for recipe ${study.recipeId}.","debug");try{await j.stop(l.recipeId)}catch(m){i.log(`Error while stopping study for recipe ${l.recipeId}: ${m}`,"error")}}}Object.defineProperty(b,"__esModule",{value:!0}),b.resetAction=function(){g=[]},b.postExecutionHook=e;var f=c(1);let g=[];class h extends f.Action{async execute(){const i=this.recipe.id;var j=this.recipe.arguments;const k=j.name,l=j.description,m=j.addonUrl,n=this.normandy.studies;if(void 0===n)return void this.normandy.log("Client does not support studies, aborting.","info");g.push(i);const o=await n.has(i);o?this.normandy.log(`Study for recipe ${i} already exists`,"debug"):(this.normandy.log(`Starting study for recipe ${i}`,"debug"),await n.start({recipeId:i,name:k,description:l,addonUrl:m}))}}b.default=h,(0,f.registerAction)("opt-out-study",h),(0,f.registerAsyncCallback)("postExecution",e)},function(a,b){(function(c){"use strict";Object.defineProperty(b,"__esModule",{value:!0});b.Action=class{constructor(g,h){this.normandy=g,this.recipe=h}};const e=b.registerAction=c&&c.registerAction||window&&window.registerAction||function(){},f=b.registerAsyncCallback=c&&c.registerAsyncCallback||window&&window.registerAsyncCallback||function(){}}).call(b,function(){return this}())}]);
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/opt-out-study/index.json
@@ -0,0 +1,32 @@
+{
+  "arguments_schema": {
+    "$schema": "http://json-schema.org/draft-04/schema#",
+    "properties": {
+      "addonUrl": {
+        "description": "URL of the add-on XPI file",
+        "format": "uri",
+        "minLength": 1,
+        "type": "string"
+      },
+      "description": {
+        "description": "User-facing description of the study",
+        "minLength": 1,
+        "type": "string"
+      },
+      "name": {
+        "description": "User-facing name of the study",
+        "minLength": 1,
+        "type": "string"
+      }
+    },
+    "required": [
+      "name",
+      "description",
+      "addonUrl"
+    ],
+    "title": "Enroll a user in an opt-out SHIELD study",
+    "type": "object"
+  },
+  "implementation_url": "https://localhost:8443/api/v1/action/opt-out-study/implementation/sha384-HM_avYcD00o27ufwU1V7PIBtiuMAXML6MMwlYrDEqDX-XzGVuOfL52RCM680JExN/",
+  "name": "opt-out-study"
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/preference-experiment/implementation/sha384-KQgG38GQ7KZAb2VIB48ANQO6nBcxZoLm2ORzUviRT5nAvSywyPjZ5cJIElw6iXIt
@@ -0,0 +1,2 @@
+(function(a){function b(d){if(c[d])return c[d].exports;var e=c[d]={exports:{},id:d,loaded:!1};return a[d].call(e.exports,e,e.exports,b),e.loaded=!0,e.exports}var c={};return b.m=a,b.c=c,b.p="",b(0)})([function(a,b,c){"use strict";async function e(i){if(void 0===i.preferenceExperiments)return void i.log("Client does not support preference experiments, aborting.","info");const j=await i.preferenceExperiments.getAllActive();for(const k of j)g.includes(k.name)||(await i.preferenceExperiments.stop(k.name,!0))}Object.defineProperty(b,"__esModule",{value:!0}),b.resetAction=function(){g=[]},b.postExecutionHook=e;var f=c(1);let g=[];class h extends f.Action{async execute(){var i=this.recipe.arguments;const j=i.slug,k=i.preferenceName,l=i.preferenceBranchType,m=i.branches,n=i.preferenceType,o=this.normandy.preferenceExperiments;if(void 0===o)return void this.normandy.log("Client does not support preference experiments, aborting.","info");g.push(j);const p=await o.has(j);if(!p){const q=await o.getAllActive(),r=q.some((t)=>t.preferenceName===k);if(r)return void this.normandy.log(`Experiment ${j} ignored; another active experiment is already using the
+	          ${k} preference.`,"warn");const s=await this.chooseBranch(m);await o.start({name:j,branch:s.slug,preferenceName:k,preferenceValue:s.value,preferenceBranchType:l,preferenceType:n})}else{const q=await o.get(j);q.expired?this.normandy.log(`Experiment ${j} has expired, aborting.`,"debug"):await o.markLastSeen(j)}}async chooseBranch(i){const j=this.recipe.arguments.slug,k=i.map((n)=>n.ratio),l=`${this.normandy.userId}-${j}-branch`,m=await this.normandy.ratioSample(l,k);return i[m]}}b.default=h,(0,f.registerAction)("preference-experiment",h),(0,f.registerAsyncCallback)("postExecution",e)},function(a,b){(function(c){"use strict";Object.defineProperty(b,"__esModule",{value:!0});b.Action=class{constructor(g,h){this.normandy=g,this.recipe=h}};const e=b.registerAction=c&&c.registerAction||window&&window.registerAction||function(){},f=b.registerAsyncCallback=c&&c.registerAsyncCallback||window&&window.registerAsyncCallback||function(){}}).call(b,function(){return this}())}]);
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/preference-experiment/index.json
@@ -0,0 +1,86 @@
+{
+  "arguments_schema": {
+    "$schema": "http://json-schema.org/draft-04/schema#",
+    "properties": {
+      "branches": {
+        "description": "List of experimental branches",
+        "items": {
+          "properties": {
+            "ratio": {
+              "default": 1,
+              "description": "Ratio of users who should be grouped into this branch",
+              "minimum": 1,
+              "type": "integer"
+            },
+            "slug": {
+              "description": "Unique identifier for this branch of the experiment",
+              "pattern": "^[A-Za-z0-9\\-_]+$",
+              "type": "string"
+            },
+            "value": {
+              "description": "Value to set the preference to for this branch",
+              "type": [
+                "string",
+                "number",
+                "boolean"
+              ]
+            }
+          },
+          "required": [
+            "slug",
+            "value",
+            "ratio"
+          ],
+          "type": "object"
+        },
+        "minItems": 1,
+        "type": "array"
+      },
+      "experimentDocumentUrl": {
+        "default": "",
+        "description": "URL of a document describing the experiment",
+        "format": "uri",
+        "type": "string"
+      },
+      "preferenceBranchType": {
+        "default": "default",
+        "descript": "Controls whether the default or user value of the preference is modified",
+        "enum": [
+          "user",
+          "default"
+        ],
+        "type": "string"
+      },
+      "preferenceName": {
+        "default": "",
+        "description": "Full dotted-path of the preference that controls this experiment",
+        "type": "string"
+      },
+      "preferenceType": {
+        "default": "boolean",
+        "description": "Data type of the preference that controls this experiment",
+        "enum": [
+          "string",
+          "integer",
+          "boolean"
+        ],
+        "type": "string"
+      },
+      "slug": {
+        "description": "Unique identifier for this experiment",
+        "pattern": "^[A-Za-z0-9\\-_]+$",
+        "type": "string"
+      }
+    },
+    "required": [
+      "slug",
+      "preferenceName",
+      "preferenceType",
+      "branches"
+    ],
+    "title": "Run a feature experiment activated by a preference.",
+    "type": "object"
+  },
+  "implementation_url": "https://localhost:8443/api/v1/action/preference-experiment/implementation/sha384-KQgG38GQ7KZAb2VIB48ANQO6nBcxZoLm2ORzUviRT5nAvSywyPjZ5cJIElw6iXIt/",
+  "name": "preference-experiment"
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/show-heartbeat/implementation/sha384-dEGiyKPEln8Ns5cQHzGpMIGdirSAAX0X-Kwlu-U3sJ05yNbO-ANij_a6c5SyL7G4
@@ -0,0 +1,1 @@
+(function(a){function b(d){if(c[d])return c[d].exports;var e=c[d]={exports:{},id:d,loaded:!1};return a[d].call(e.exports,e,e.exports,b),e.loaded=!0,e.exports}var c={};return b.m=a,b.c=c,b.p="",b(0)})([function(a,b,c){"use strict";Object.defineProperty(b,"__esModule",{value:!0});var d=Object.assign||function(k){for(var m,l=1;l<arguments.length;l++)for(var n in m=arguments[l],m)Object.prototype.hasOwnProperty.call(m,n)&&(k[n]=m[n]);return k},e=c(1);const g=24*(3600*1e3);class j extends e.Action{constructor(k,l){super(k,l),this.storage=k.createStorage(l.id),this.heartbeatStorage=k.createStorage("normandy-heartbeat"),this.updateLastInteraction=this.updateLastInteraction.bind(this),this.updateLastShown=this.updateLastShown.bind(this)}generateSurveyId(){var k=this.recipe.arguments;const l=k.includeTelemetryUUID,m=k.surveyId,n=this.normandy.userId;let o=m;return l&&!!n&&(o=`${m}::${n}`),o}async heartbeatShownRecently(){const k=await this.heartbeatStorage.getItem("lastShown"),l=k?new Date-parseFloat(k):Infinity;return l<g}async getLastShown(){const k=await this.storage.getItem("lastShown");return"undefined"==typeof k?null:parseFloat(k)}async hasShownBefore(){return null!==(await this.storage.getItem("lastShown"))}async shownAtleastDaysAgo(k){const l=await this.hasShownBefore();if(!l)return!1;const m=await this.getLastShown(),n=Date.now()-m;return n<g*k}async getLastInteraction(){const k=await this.storage.getItem("lastInteraction");return"undefined"==typeof k?null:parseFloat(k)}async sinceLastInteraction(){const k=await this.getLastInteraction();return"undefined"==typeof k?null:Date.now()-k}async hasHadInteraction(){const k=await this.getLastInteraction();return!!k}async heartbeatHasExecuted(){let k=!1;var l=this.recipe.arguments;const m=l.repeatOption,n=l.repeatEvery;switch(m){default:case"once":k=await this.hasShownBefore();break;case"nag":k=await this.hasHadInteraction();break;case"xdays":k=await this.shownAtleastDaysAgo(n);}return k}async shouldNotExecute(){return!this.normandy.testing&&((await this.heartbeatShownRecently())||(await this.heartbeatHasExecuted()))}async execute(){var k=this.recipe.arguments;const l=k.message,m=k.engagementButtonLabel,n=k.thanksMessage,o=k.postAnswerUrl,p=k.learnMoreMessage,q=k.learnMoreUrl;if(await this.shouldNotExecute())return;this.client=await this.normandy.client();const r=this.normandy.userId,s=this.generateSurveyId(),t={surveyId:s,message:l,engagementButtonLabel:m,thanksMessage:n,learnMoreMessage:p,learnMoreUrl:q,postAnswerUrl:this.generatePostURL(o,r),flowId:this.normandy.uuid(),surveyVersion:this.recipe.revision_id};this.normandy.testing&&(t.testing=1);const u=await this.normandy.showHeartbeat(t);["Voted","Engaged"].forEach((w)=>{u.on(w,this.updateLastInteraction)}),this.updateLastShown()}updateLastShown(){this.storage.setItem("lastShown",Date.now()),this.heartbeatStorage.setItem("lastShown",Date.now())}updateLastInteraction(){this.storage.setItem("lastInteraction",Date.now())}getGAParams(){let k=this.recipe.arguments.message||"";k=k.replace(/\s+/g,""),k=encodeURIComponent(k);const l=new URL("http://mozilla.com");return l.searchParams.set("message",k),k=l.search.replace("?message=",""),{utm_source:"firefox",utm_medium:this.recipe.action,utm_campaign:k}}generatePostURL(k,l){if(!k)return k;const m=d({source:"heartbeat",surveyversion:56,updateChannel:this.client.channel,fxVersion:this.client.version,isDefaultBrowser:this.client.isDefaultBrowser?1:0,searchEngine:this.client.searchEngine,syncSetup:this.client.syncSetup?1:0},this.getGAParams());this.recipe.arguments.includeTelemetryUUID&&l&&(m.userId=l),this.normandy.testing&&(m.testing=1);const n=new URL(k);for(const o in m)m.hasOwnProperty(o)&&n.searchParams.set(o,m[o]);return n.href}}b.default=j,(0,e.registerAction)("show-heartbeat",j)},function(a,b){(function(c){"use strict";Object.defineProperty(b,"__esModule",{value:!0});b.Action=class{constructor(g,h){this.normandy=g,this.recipe=h}};const e=b.registerAction=c&&c.registerAction||window&&window.registerAction||function(){},f=b.registerAsyncCallback=c&&c.registerAsyncCallback||window&&window.registerAsyncCallback||function(){}}).call(b,function(){return this}())}]);
\ No newline at end of file
--- a/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/show-heartbeat/index.json
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/show-heartbeat/index.json
@@ -28,16 +28,26 @@
         "description": "Message to show to the user",
         "type": "string"
       },
       "postAnswerUrl": {
         "default": "",
         "description": "URL to redirect the user to after rating Firefox or clicking the engagement button",
         "type": "string"
       },
+      "repeatEvery": {
+        "default": null,
+        "description": "How often (in days) the action is displayed.",
+        "type": "number"
+      },
+      "repeatOption": {
+        "default": "once",
+        "description": "Determines how often an action executes. (once|nag|xdays)",
+        "type": "string"
+      },
       "surveyId": {
         "description": "Slug uniquely identifying this survey in telemetry",
         "type": "string"
       },
       "thanksMessage": {
         "default": "",
         "description": "Thanks message to show to the user after they've rated Firefox",
         "type": "string"
@@ -46,11 +56,11 @@
     "required": [
       "surveyId",
       "message",
       "thanksMessage"
     ],
     "title": "Show a Heartbeat survey.",
     "type": "object"
   },
-  "implementation_url": "https://localhost:8000/api/v1/action/show-heartbeat/implementation/448bbffce82ff27b3d532fd7196bcb190be5c067/",
+  "implementation_url": "https://localhost:8443/api/v1/action/show-heartbeat/implementation/sha384-dEGiyKPEln8Ns5cQHzGpMIGdirSAAX0X-Kwlu-U3sJ05yNbO-ANij_a6c5SyL7G4/",
   "name": "show-heartbeat"
 }
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/action/signed/index.json
@@ -0,0 +1,1 @@
+[{"action":{"arguments_schema":{"$schema":"http://json-schema.org/draft-04/schema#","properties":{"message":{"default":"","description":"Message to log to the console","type":"string"}},"required":["message"],"title":"Log a message to the console","type":"object"},"implementation_url":"/api/v1/action/console-log/implementation/sha384-RGx3rydrSq53UfmW9kFcK0mQYra67XIvZvr4MhmAe--ljiiMQOtgM7Cmca48um3v/","name":"console-log"},"signature":{"public_key":"MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEVEKiCAIkwRg1VFsP8JOYdSF6a3qvgbRPoEK9eTuLbrB6QixozscKR4iWJ8ZOOX6RPCRgFdfVDoZqjFBFNJN9QtRBk0mVtHbnErx64d2vMF0oWencS1hyLW2whgOgOz7p","signature":"_PnFvgyHCF2ncR50U4nKbWhZcK7OZejvjSWlcAE8kHDDlXoJirtNnN48jEAMEzt1FfacqWjT4QF3aEV1te35FgKQCwjtiDyjmrQR4cQb3T3aqUFGZNSzCBIEWMTN6RoY","timestamp":"2017-08-03T21:10:32.538860Z","x5u":"/normandy.content-signature.mozilla.org-20210705.dev.chain"}},{"action":{"arguments_schema":{"$schema":"http://json-schema.org/draft-04/schema#","properties":{"addonUrl":{"description":"URL of the add-on XPI file","format":"uri","minLength":1,"type":"string"},"description":{"description":"User-facing description of the study","minLength":1,"type":"string"},"name":{"description":"User-facing name of the study","minLength":1,"type":"string"}},"required":["name","description","addonUrl"],"title":"Enroll a user in an opt-out SHIELD study","type":"object"},"implementation_url":"/api/v1/action/opt-out-study/implementation/sha384-HM_avYcD00o27ufwU1V7PIBtiuMAXML6MMwlYrDEqDX-XzGVuOfL52RCM680JExN/","name":"opt-out-study"},"signature":{"public_key":"MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEVEKiCAIkwRg1VFsP8JOYdSF6a3qvgbRPoEK9eTuLbrB6QixozscKR4iWJ8ZOOX6RPCRgFdfVDoZqjFBFNJN9QtRBk0mVtHbnErx64d2vMF0oWencS1hyLW2whgOgOz7p","signature":"RllXGVzj-c0ZaHxzKE6k-GJXOtEl-NNprMi4I-TvbBDZUlhq4UPOQidpCvn74A41M6cV7bb9qxw-M1Z9t4BqUMQaOT4yXiEU9DoBMBASA-C_xf7VXtwWwH8GvT8C4yh_","timestamp":"2017-08-03T21:10:32.562478Z","x5u":"/normandy.content-signature.mozilla.org-20210705.dev.chain"}},{"action":{"arguments_schema":{"$schema":"http://json-schema.org/draft-04/schema#","description":"This action shows a single survey.","properties":{"engagementButtonLabel":{"default":"","description":"Text for the engagement button. If specified, this button will be shown instead of rating stars.","type":"string"},"includeTelemetryUUID":{"default":false,"description":"Include unique user ID in post-answer-url and Telemetry","type":"boolean"},"learnMoreMessage":{"default":"","description":"Message to show to the user to learn more","type":"string"},"learnMoreUrl":{"default":"","description":"URL to show to the user when they click Learn More","type":"string"},"message":{"default":"","description":"Message to show to the user","type":"string"},"postAnswerUrl":{"default":"","description":"URL to redirect the user to after rating Firefox or clicking the engagement button","type":"string"},"repeatEvery":{"default":null,"description":"How often (in days) the action is displayed.","type":"number"},"repeatOption":{"default":"once","description":"Determines how often an action executes. (once|nag|xdays)","type":"string"},"surveyId":{"description":"Slug uniquely identifying this survey in telemetry","type":"string"},"thanksMessage":{"default":"","description":"Thanks message to show to the user after they've rated Firefox","type":"string"}},"required":["surveyId","message","thanksMessage"],"title":"Show a Heartbeat survey.","type":"object"},"implementation_url":"/api/v1/action/show-heartbeat/implementation/sha384-dEGiyKPEln8Ns5cQHzGpMIGdirSAAX0X-Kwlu-U3sJ05yNbO-ANij_a6c5SyL7G4/","name":"show-heartbeat"},"signature":{"public_key":"MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEVEKiCAIkwRg1VFsP8JOYdSF6a3qvgbRPoEK9eTuLbrB6QixozscKR4iWJ8ZOOX6RPCRgFdfVDoZqjFBFNJN9QtRBk0mVtHbnErx64d2vMF0oWencS1hyLW2whgOgOz7p","signature":"XQLZ7g20PkKdjLgGWqVUTIu-bkeSeOTa6MEaEcfI6lpH1UcJ9YtTz3Ff73GoT2DnVSXgrH00jyxggOT-wLVSX3tJBeAEg_9MpepZoeDQ5XRqV0kIY6emjUP3YKemFGIw","timestamp":"2017-08-03T21:10:32.510980Z","x5u":"/normandy.content-signature.mozilla.org-20210705.dev.chain"}},{"action":{"arguments_schema":{"$schema":"http://json-schema.org/draft-04/schema#","properties":{"branches":{"description":"List of experimental branches","items":{"properties":{"ratio":{"default":1,"description":"Ratio of users who should be grouped into this branch","minimum":1,"type":"integer"},"slug":{"description":"Unique identifier for this branch of the experiment","pattern":"^[A-Za-z0-9\\-_]+$","type":"string"},"value":{"description":"Value to set the preference to for this branch","type":["string","number","boolean"]}},"required":["slug","value","ratio"],"type":"object"},"minItems":1,"type":"array"},"experimentDocumentUrl":{"default":"","description":"URL of a document describing the experiment","format":"uri","type":"string"},"preferenceBranchType":{"default":"default","descript":"Controls whether the default or user value of the preference is modified","enum":["user","default"],"type":"string"},"preferenceName":{"default":"","description":"Full dotted-path of the preference that controls this experiment","type":"string"},"preferenceType":{"default":"boolean","description":"Data type of the preference that controls this experiment","enum":["string","integer","boolean"],"type":"string"},"slug":{"description":"Unique identifier for this experiment","pattern":"^[A-Za-z0-9\\-_]+$","type":"string"}},"required":["slug","preferenceName","preferenceType","branches"],"title":"Run a feature experiment activated by a preference.","type":"object"},"implementation_url":"/api/v1/action/preference-experiment/implementation/sha384-KQgG38GQ7KZAb2VIB48ANQO6nBcxZoLm2ORzUviRT5nAvSywyPjZ5cJIElw6iXIt/","name":"preference-experiment"},"signature":{"public_key":"MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEVEKiCAIkwRg1VFsP8JOYdSF6a3qvgbRPoEK9eTuLbrB6QixozscKR4iWJ8ZOOX6RPCRgFdfVDoZqjFBFNJN9QtRBk0mVtHbnErx64d2vMF0oWencS1hyLW2whgOgOz7p","signature":"DqIks6qG4fWYl9riLAwFor-CGI2KrnW0bxiwV2PU45Nb2ziHMaAdm8yGaPrcZ58qvGnHZkHxgIuqyAUHzmzAAr662pmfjiTc75UXWtx4il94sqVLBF6G28U2taEU1GpJ","timestamp":"2017-08-03T20:53:52.948142Z","x5u":"/normandy.content-signature.mozilla.org-20210705.dev.chain"}}]
--- a/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/index.json
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/index.json
@@ -1,8 +1,9 @@
 {
   "action-list": "/api/v1/action/",
+  "action-signed": "/api/v1/action/signed/",
   "classify-client": "/api/v1/classify_client/",
   "filters": "/api/v1/filters/",
   "recipe-list": "/api/v1/recipe/",
   "recipe-signed": "/api/v1/recipe/signed/",
   "reciperevision-list": "/api/v1/recipe_revision/"
 }
--- a/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/recipe/signed/index.json
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/recipe/signed/index.json
@@ -1,1 +1,1 @@
-[{"recipe":{"action":"console-log","arguments":{"message":"asdfasfda sdf sa"},"channels":[],"countries":[],"enabled":true,"extra_filter_expression":"true || true","filter_expression":"true || true","id":1,"last_updated":"2017-02-17T18:29:09.839239Z","locales":[],"name":"system-addon-test","revision_id":"b2cb8a26e132182d7d02cf50695d2c7f06cf3b954ff2ff63bca49d724ee91950"},"signature":{"public_key":"MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEVEKiCAIkwRg1VFsP8JOYdSF6a3qvgbRPoEK9eTuLbrB6QixozscKR4iWJ8ZOOX6RPCRgFdfVDoZqjFBFNJN9QtRBk0mVtHbnErx64d2vMF0oWencS1hyLW2whgOgOz7p","signature":"p4g3eurmPsJK5UcGT97BRyKstpwZ_2mNJkDGpd6QXlkXfvgwprjeyb5yeIEkKUXqc6krWid4obB_OP9-CwOi9tvKY1pV8p98CT5BhF0IVgpF3b7KBW1a0BVdg5owoG5W","timestamp":"2017-02-17T18:29:09.847614Z","x5u":"/api/v1/recipe/signed/normandy.content-signature.mozilla.org-20210705.dev.chain"}}]
+[{"recipe":{"action":"console-log","arguments":{"message":"asdfasfda sdf sa"},"channels":[],"countries":[],"enabled":true,"extra_filter_expression":"true || true","filter_expression":"true || true","id":1,"last_updated":"2017-02-17T18:29:09.839239Z","locales":[],"name":"system-addon-test","revision_id":"b2cb8a26e132182d7d02cf50695d2c7f06cf3b954ff2ff63bca49d724ee91950"},"signature":{"public_key":"MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEVEKiCAIkwRg1VFsP8JOYdSF6a3qvgbRPoEK9eTuLbrB6QixozscKR4iWJ8ZOOX6RPCRgFdfVDoZqjFBFNJN9QtRBk0mVtHbnErx64d2vMF0oWencS1hyLW2whgOgOz7p","signature":"p4g3eurmPsJK5UcGT97BRyKstpwZ_2mNJkDGpd6QXlkXfvgwprjeyb5yeIEkKUXqc6krWid4obB_OP9-CwOi9tvKY1pV8p98CT5BhF0IVgpF3b7KBW1a0BVdg5owoG5W","timestamp":"2017-02-17T18:29:09.847614Z","x5u":"/normandy.content-signature.mozilla.org-20210705.dev.chain"}}]
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/unit/mock_api/api/v1/recipe/signed/normandy.content-signature.mozilla.org-20210705.dev.chain
+++ /dev/null
@@ -1,123 +0,0 @@
------BEGIN CERTIFICATE-----
-MIIGRTCCBC2gAwIBAgIEAQAABTANBgkqhkiG9w0BAQwFADBrMQswCQYDVQQGEwJV
-UzEQMA4GA1UEChMHQWxsaXpvbTEXMBUGA1UECxMOQ2xvdWQgU2VydmljZXMxMTAv
-BgNVBAMTKERldnppbGxhIFNpZ25pbmcgU2VydmljZXMgSW50ZXJtZWRpYXRlIDEw
-HhcNMTYwNzA2MjE1NzE1WhcNMjEwNzA1MjE1NzE1WjCBrzELMAkGA1UEBhMCVVMx
-EzARBgNVBAgTCkNhbGlmb3JuaWExHDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRp
-b24xFzAVBgNVBAsTDkNsb3VkIFNlcnZpY2VzMS8wLQYDVQQDEyZub3JtYW5keS5j
-b250ZW50LXNpZ25hdHVyZS5tb3ppbGxhLm9yZzEjMCEGCSqGSIb3DQEJARYUc2Vj
-dXJpdHlAbW96aWxsYS5vcmcwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARUQqIIAiTB
-GDVUWw/wk5h1IXpreq+BtE+gQr15O4tusHpCLGjOxwpHiJYnxk45fpE8JGAV19UO
-hmqMUEU0k31C1EGTSZW0ducSvHrh3a8wXShZ6dxLWHItbbCGA6A7PumjggJYMIIC
-VDAdBgNVHQ4EFgQUVfksSjlZ0i1TBiS1vcoObaMeXn0wge8GA1UdIwSB5zCB5IAU
-/YboUIXAovChEpudDBuodHKbjUuhgcWkgcIwgb8xCzAJBgNVBAYTAlVTMQswCQYD
-VQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UEChMdQ29udGVu
-dCBTaWduYXR1cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5jb250ZW50LXNp
-Z25hdHVyZS5yb290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNlYytkZXZyb290
-Y29udGVudHNpZ25hdHVyZUBtb3ppbGxhLmNvbYIEAQAABDAMBgNVHRMBAf8EAjAA
-MA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAzBEBgNVHR8E
-PTA7MDmgN6A1hjNodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmRldi5tb3phd3Mu
-bmV0L2NhL2NybC5wZW0wQgYJYIZIAYb4QgEEBDUWM2h0dHBzOi8vY29udGVudC1z
-aWduYXR1cmUuZGV2Lm1vemF3cy5uZXQvY2EvY3JsLnBlbTBOBggrBgEFBQcBAQRC
-MEAwPgYIKwYBBQUHMAKGMmh0dHBzOi8vY29udGVudC1zaWduYXR1cmUuZGV2Lm1v
-emF3cy5uZXQvY2EvY2EucGVtMDEGA1UdEQQqMCiCJm5vcm1hbmR5LmNvbnRlbnQt
-c2lnbmF0dXJlLm1vemlsbGEub3JnMA0GCSqGSIb3DQEBDAUAA4ICAQCwb+8JTAB7
-ZfQmFqPUIV2cQQv696AaDPQCtA9YS4zmUfcLMvfZVAbK397zFr0RMDdLiTUQDoeq
-rBEmPXhJRPiv6JAK4n7Jf6Y6XfXcNxx+q3garR09Vm/0CnEq/iV+ZAtPkoKIO9kr
-Nkzecd894yQCF4hIuPQ5qtMySeqJmH3Dp13eq4T0Oew1Bu32rNHuBJh2xYBkWdun
-aAw/YX0I5EqZBP/XA6gbiA160tTK+hnpnlMtw/ljkvfhHbWpICD4aSiTL8L3vABQ
-j7bqjMKR5xDkuGWshZfcmonpvQhGTye/RZ1vz5IzA3VOJt1mz5bdZlitpaOm/Yv0
-x6aODz8GP/PiRWFQ5CW8Uf/7pGc5rSyvnfZV2ix8EzFlo8cUtuN1fjrPFPOFOLvG
-iiB6S9nlXiKBGYIDdd8V8iC5xJpzjiAWJQigwSNzuc2K30+iPo3w0zazkwe5V8jW
-gj6gItYxh5xwVQTPHD0EOd9HvV1ou42+rH5Y+ISFUm25zz02UtUHEK0BKtL0lmdt
-DwVq5jcHn6bx2/iwUtlKvPXtfM/6JjTJlkLZLtS7U5/pwcS0owo9zAL0qg3bdm16
-+v/olmPqQFLUHmamJTzv3rojj5X/uVdx1HMM3wBjV9tRYoYaZw9RIInRmM8Z1pHv
-JJ+CIZgCyd5vgp57BKiodRZcgHoCH+BkOQ==
------END CERTIFICATE-----
------BEGIN CERTIFICATE-----
-MIIHijCCBXKgAwIBAgIEAQAABDANBgkqhkiG9w0BAQwFADCBvzELMAkGA1UEBhMC
-VVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MSYwJAYDVQQK
-Ex1Db250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEmMCQGA1UEAxMdZGV2LmNv
-bnRlbnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG9w0BCQEWLGNsb3Vkc2Vj
-K2RldnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEuY29tMB4XDTE2MDcwNjIx
-NDkyNloXDTIxMDcwNTIxNDkyNlowazELMAkGA1UEBhMCVVMxEDAOBgNVBAoTB0Fs
-bGl6b20xFzAVBgNVBAsTDkNsb3VkIFNlcnZpY2VzMTEwLwYDVQQDEyhEZXZ6aWxs
-YSBTaWduaW5nIFNlcnZpY2VzIEludGVybWVkaWF0ZSAxMIICIjANBgkqhkiG9w0B
-AQEFAAOCAg8AMIICCgKCAgEAypIfUURYgWzGw8G/1Pz9zW+Tsjirx2owThiv2gys
-wJiWL/9/2gzKOrYDEqlDUudfA/BjVRtT9+NbYgnhaCkNfADOAacWS83aMhedAqhP
-bVd5YhGQdpijI7f1AVTSb0ehrU2nhOZHvHX5Tk2fbRx3ryefIazNTLFGpiMBbsNv
-tSI/+fjW8s0MhKNqlLnk6a9mZKo0mEy7HjGYV8nzsgI17rKLx/s2HG4TFG0+JQzc
-UGlum3Tg58ritDzWdyKIkmKNZ48oLBX99Qc8j8B1UyiLv6TZmjVX0I+Ds7eSWHZk
-0axLEpTyf2r7fHvN4iDNCPajw+ZpuuBfbs80Ha8b8MHvnpf9fbwiirodNQOVpY4c
-t5E3Us3eYwBKdqDEbECWxCKGAS2/iVVUCNKHsg0sSxgqcwxrxyrddQRUQ0EM38DZ
-F/vHt+vTdHt07kezbjJe0Kklel59uSpghA0iL4vxzbTns1fuwYOgVrNGs3acTkiB
-GhFOxRXUPGtpdYmv+AaR9WlWJQY1GIEoVrinPVH7bcCwyh1CcUbHL+oAFTcmc6kZ
-7azNg21tWILIRL7R0IZYQm0tF5TTwCsjVC7FuHaBtkxtVrrZqeKjvVXQ8TK5VoI0
-BUQ6BKHGeTtm+0HBpheYBDy3wkOsEGbGHLEM6cMeiz6PyCXF8wXli8Qb/TjN3LHZ
-e30CAwEAAaOCAd8wggHbMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGG
-MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMDMB0GA1UdDgQWBBT9huhQhcCi8KESm50M
-G6h0cpuNSzCB7AYDVR0jBIHkMIHhgBSDx8s0qJaMyQCehKcuzgzVNRA75qGBxaSB
-wjCBvzELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFp
-biBWaWV3MSYwJAYDVQQKEx1Db250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEm
-MCQGA1UEAxMdZGV2LmNvbnRlbnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG
-9w0BCQEWLGNsb3Vkc2VjK2RldnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEu
-Y29tggEBMEIGCWCGSAGG+EIBBAQ1FjNodHRwczovL2NvbnRlbnQtc2lnbmF0dXJl
-LmRldi5tb3phd3MubmV0L2NhL2NybC5wZW0wTgYIKwYBBQUHAQEEQjBAMD4GCCsG
-AQUFBzAChjJodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmRldi5tb3phd3MubmV0
-L2NhL2NhLnBlbTANBgkqhkiG9w0BAQwFAAOCAgEAbum0z0ccqI1Wp49VtsGmUPHA
-gjPPy2Xa5NePmqY87WrGdhjN3xbLVb3hx8T2N6pqDjMY2rEynXKEOek3oJkQ3C6J
-8AFP6Y93gaAlNz6EA0J0mqdW5TMI8YEYsu2ma+dQQ8wm9f/5b+/Y8qwfhztP06W5
-H6IG04/RvgUwYMnSR4QvT309fu5UmCRUDzsO53ZmQCfmN94u3NxHc4S6n0Q6AKAM
-kh5Ld9SQnlqqqDykzn7hYDi8nTLWc7IYqkGfNMilDEKbAl4CjnSfyEvpdFAJ9sPR
-UL+kaWFSMvaqIPNpxS5OpoPZjmxEc9HHnCHxtfDHWdXTJILjijPrCdMaxOCHfIqV
-5roOJggI4RZ0YM68IL1MfN4IEVOrHhKjDHtd1gcmy2KU4jfj9LQq9YTnyvZ2z1yS
-lS310HG3or1K8Nnu5Utfe7T6ppX8bLRMkS1/w0p7DKxHaf4D/GJcCtM9lcSt9JpW
-6ACKFikjWR4ZxczYKgApc0wcjd7XBuO5777xtOeyEUDHdDft3jiXA91dYM5UAzc3
-69z/3zmaELzo0gWcrjLXh7fU9AvbU4EUF6rwzxbPGF78jJcGK+oBf8uWUCkBykDt
-VsAEZI1u4EDg8e/C1nFqaH9gNMArAgquYIB9rve+hdprIMnva0S147pflWopBWcb
-jwzgpfquuYnnxe0CNBA=
------END CERTIFICATE-----
------BEGIN CERTIFICATE-----
-MIIH3DCCBcSgAwIBAgIBATANBgkqhkiG9w0BAQwFADCBvzELMAkGA1UEBhMCVVMx
-CzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MSYwJAYDVQQKEx1D
-b250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEmMCQGA1UEAxMdZGV2LmNvbnRl
-bnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG9w0BCQEWLGNsb3Vkc2VjK2Rl
-dnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEuY29tMB4XDTE2MDcwNjE4MTUy
-MloXDTI2MDcwNDE4MTUyMlowgb8xCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJDQTEW
-MBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UEChMdQ29udGVudCBTaWduYXR1
-cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5jb250ZW50LXNpZ25hdHVyZS5y
-b290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNlYytkZXZyb290Y29udGVudHNp
-Z25hdHVyZUBtb3ppbGxhLmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
-ggIBAJcPcXhD8MzF6OTn5qZ0L7lX1+PEgLKhI9g1HxxDYDVup4Zm0kZhPGmFSlml
-6eVO99OvvHdAlHhQGCIG7h+w1cp66mWjfpcvtQH23uRoKZfiW3jy1jUWrvdXolxR
-t1taZosjzo+9OP8TvG6LpJj7AvqUiYD4wYnQJtt0jNRN4d6MUfQwiavSS5uTBuxd
-ZJ4TsPvEI+Iv4A4PSobSzxkg79LTMvsGtDLQv7nN5hMs9T18EL5GnIKoqnSQCU0d
-n2CN7S3QiQ+cbORWsSYqCTj1gUbFh6X3duEB/ypLcyWFbqeJmPHikjY8q8pLjZSB
-IYiTJYLyvYlKdM5QleC/xuBNnMPCftrwwLHjWE4Dd7C9t7k0R5xyOetuiHLCwOcQ
-tuckp7RgFKoviMNv3gdkzwVapOklcsaRkRscv6OMTKJNsdJVIDLrPF1wMABhbEQB
-64BL0uL4lkFtpXXbJzQ6mgUNQveJkfUWOoB+cA/6GtI4J0aQfvQgloCYI6jxNn/e
-Nvk5OV9KFOhXS2dnDft3wHU46sg5yXOuds1u6UrOoATBNFlkS95m4zIX1Svu091+
-CKTiLK85+ZiFtAlU2bPr3Bk3GhL3Z586ae6a4QUEx6SPQVXc18ezB4qxKqFc+avI
-ylccYMRhVP+ruADxtUM5Vy6x3U8BwBK5RLdecRx2FEBDImY1AgMBAAGjggHfMIIB
-2zAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAWBgNVHSUBAf8EDDAK
-BggrBgEFBQcDAzAdBgNVHQ4EFgQUg8fLNKiWjMkAnoSnLs4M1TUQO+YwgewGA1Ud
-IwSB5DCB4YAUg8fLNKiWjMkAnoSnLs4M1TUQO+ahgcWkgcIwgb8xCzAJBgNVBAYT
-AlVTMQswCQYDVQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UE
-ChMdQ29udGVudCBTaWduYXR1cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5j
-b250ZW50LXNpZ25hdHVyZS5yb290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNl
-YytkZXZyb290Y29udGVudHNpZ25hdHVyZUBtb3ppbGxhLmNvbYIBATBCBglghkgB
-hvhCAQQENRYzaHR0cHM6Ly9jb250ZW50LXNpZ25hdHVyZS5kZXYubW96YXdzLm5l
-dC9jYS9jcmwucGVtME4GCCsGAQUFBwEBBEIwQDA+BggrBgEFBQcwAoYyaHR0cHM6
-Ly9jb250ZW50LXNpZ25hdHVyZS5kZXYubW96YXdzLm5ldC9jYS9jYS5wZW0wDQYJ
-KoZIhvcNAQEMBQADggIBAAAQ+fotZE79FfZ8Lz7eiTUzlwHXCdSE2XD3nMROu6n6
-uLTBPrf2C+k+U1FjKVvL5/WCUj6hIzP2X6Sb8+o0XHX9mKN0yoMORTEYJOnazYPK
-KSUUFnE4vGgQkr6k/31gGRMTICdnf3VOOAlUCQ5bOmGIuWi401E3sbd85U+TJe0A
-nHlU+XjtfzlqcBvQivdbA0s+GEG55uRPvn952aTBEMHfn+2JqKeLShl4AtUAfu+h
-6md3Z2HrEC7B3GK8ekWPu0G/ZuWTuFvOimZ+5C8IPRJXcIR/siPQl1x6dpTCew6t
-lPVcVuvg6SQwzvxetkNrGUe2Wb2s9+PK2PUvfOS8ee25SNmfG3XK9qJpqGUhzSBX
-T8QQgyxd0Su5G7Wze7aaHZd/fqIm/G8YFR0HiC2xni/lnDTXFDPCe+HCnSk0bH6U
-wpr6I1yK8oZ2IdnNVfuABGMmGOhvSQ8r7//ea9WKhCsGNQawpVWVioY7hpyNAJ0O
-Vn4xqG5f6allz8lgpwAQ+AeEEClHca6hh6mj9KhD1Of1CC2Vx52GHNh/jMYEc3/g
-zLKniencBqn3Y2XH2daITGJddcleN09+a1NaTkT3hgr7LumxM8EVssPkC+z9j4Vf
-Gbste+8S5QCMhh00g5vR9QF8EaFqdxCdSxrsA4GmpCa5UQl8jtCnpp2DLKXuOh72
------END CERTIFICATE-----
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/unit/mock_api/normandy.content-signature.mozilla.org-20210705.dev.chain
@@ -0,0 +1,123 @@
+-----BEGIN CERTIFICATE-----
+MIIGRTCCBC2gAwIBAgIEAQAABTANBgkqhkiG9w0BAQwFADBrMQswCQYDVQQGEwJV
+UzEQMA4GA1UEChMHQWxsaXpvbTEXMBUGA1UECxMOQ2xvdWQgU2VydmljZXMxMTAv
+BgNVBAMTKERldnppbGxhIFNpZ25pbmcgU2VydmljZXMgSW50ZXJtZWRpYXRlIDEw
+HhcNMTYwNzA2MjE1NzE1WhcNMjEwNzA1MjE1NzE1WjCBrzELMAkGA1UEBhMCVVMx
+EzARBgNVBAgTCkNhbGlmb3JuaWExHDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRp
+b24xFzAVBgNVBAsTDkNsb3VkIFNlcnZpY2VzMS8wLQYDVQQDEyZub3JtYW5keS5j
+b250ZW50LXNpZ25hdHVyZS5tb3ppbGxhLm9yZzEjMCEGCSqGSIb3DQEJARYUc2Vj
+dXJpdHlAbW96aWxsYS5vcmcwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARUQqIIAiTB
+GDVUWw/wk5h1IXpreq+BtE+gQr15O4tusHpCLGjOxwpHiJYnxk45fpE8JGAV19UO
+hmqMUEU0k31C1EGTSZW0ducSvHrh3a8wXShZ6dxLWHItbbCGA6A7PumjggJYMIIC
+VDAdBgNVHQ4EFgQUVfksSjlZ0i1TBiS1vcoObaMeXn0wge8GA1UdIwSB5zCB5IAU
+/YboUIXAovChEpudDBuodHKbjUuhgcWkgcIwgb8xCzAJBgNVBAYTAlVTMQswCQYD
+VQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UEChMdQ29udGVu
+dCBTaWduYXR1cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5jb250ZW50LXNp
+Z25hdHVyZS5yb290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNlYytkZXZyb290
+Y29udGVudHNpZ25hdHVyZUBtb3ppbGxhLmNvbYIEAQAABDAMBgNVHRMBAf8EAjAA
+MA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAzBEBgNVHR8E
+PTA7MDmgN6A1hjNodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmRldi5tb3phd3Mu
+bmV0L2NhL2NybC5wZW0wQgYJYIZIAYb4QgEEBDUWM2h0dHBzOi8vY29udGVudC1z
+aWduYXR1cmUuZGV2Lm1vemF3cy5uZXQvY2EvY3JsLnBlbTBOBggrBgEFBQcBAQRC
+MEAwPgYIKwYBBQUHMAKGMmh0dHBzOi8vY29udGVudC1zaWduYXR1cmUuZGV2Lm1v
+emF3cy5uZXQvY2EvY2EucGVtMDEGA1UdEQQqMCiCJm5vcm1hbmR5LmNvbnRlbnQt
+c2lnbmF0dXJlLm1vemlsbGEub3JnMA0GCSqGSIb3DQEBDAUAA4ICAQCwb+8JTAB7
+ZfQmFqPUIV2cQQv696AaDPQCtA9YS4zmUfcLMvfZVAbK397zFr0RMDdLiTUQDoeq
+rBEmPXhJRPiv6JAK4n7Jf6Y6XfXcNxx+q3garR09Vm/0CnEq/iV+ZAtPkoKIO9kr
+Nkzecd894yQCF4hIuPQ5qtMySeqJmH3Dp13eq4T0Oew1Bu32rNHuBJh2xYBkWdun
+aAw/YX0I5EqZBP/XA6gbiA160tTK+hnpnlMtw/ljkvfhHbWpICD4aSiTL8L3vABQ
+j7bqjMKR5xDkuGWshZfcmonpvQhGTye/RZ1vz5IzA3VOJt1mz5bdZlitpaOm/Yv0
+x6aODz8GP/PiRWFQ5CW8Uf/7pGc5rSyvnfZV2ix8EzFlo8cUtuN1fjrPFPOFOLvG
+iiB6S9nlXiKBGYIDdd8V8iC5xJpzjiAWJQigwSNzuc2K30+iPo3w0zazkwe5V8jW
+gj6gItYxh5xwVQTPHD0EOd9HvV1ou42+rH5Y+ISFUm25zz02UtUHEK0BKtL0lmdt
+DwVq5jcHn6bx2/iwUtlKvPXtfM/6JjTJlkLZLtS7U5/pwcS0owo9zAL0qg3bdm16
++v/olmPqQFLUHmamJTzv3rojj5X/uVdx1HMM3wBjV9tRYoYaZw9RIInRmM8Z1pHv
+JJ+CIZgCyd5vgp57BKiodRZcgHoCH+BkOQ==
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIHijCCBXKgAwIBAgIEAQAABDANBgkqhkiG9w0BAQwFADCBvzELMAkGA1UEBhMC
+VVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MSYwJAYDVQQK
+Ex1Db250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEmMCQGA1UEAxMdZGV2LmNv
+bnRlbnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG9w0BCQEWLGNsb3Vkc2Vj
+K2RldnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEuY29tMB4XDTE2MDcwNjIx
+NDkyNloXDTIxMDcwNTIxNDkyNlowazELMAkGA1UEBhMCVVMxEDAOBgNVBAoTB0Fs
+bGl6b20xFzAVBgNVBAsTDkNsb3VkIFNlcnZpY2VzMTEwLwYDVQQDEyhEZXZ6aWxs
+YSBTaWduaW5nIFNlcnZpY2VzIEludGVybWVkaWF0ZSAxMIICIjANBgkqhkiG9w0B
+AQEFAAOCAg8AMIICCgKCAgEAypIfUURYgWzGw8G/1Pz9zW+Tsjirx2owThiv2gys
+wJiWL/9/2gzKOrYDEqlDUudfA/BjVRtT9+NbYgnhaCkNfADOAacWS83aMhedAqhP
+bVd5YhGQdpijI7f1AVTSb0ehrU2nhOZHvHX5Tk2fbRx3ryefIazNTLFGpiMBbsNv
+tSI/+fjW8s0MhKNqlLnk6a9mZKo0mEy7HjGYV8nzsgI17rKLx/s2HG4TFG0+JQzc
+UGlum3Tg58ritDzWdyKIkmKNZ48oLBX99Qc8j8B1UyiLv6TZmjVX0I+Ds7eSWHZk
+0axLEpTyf2r7fHvN4iDNCPajw+ZpuuBfbs80Ha8b8MHvnpf9fbwiirodNQOVpY4c
+t5E3Us3eYwBKdqDEbECWxCKGAS2/iVVUCNKHsg0sSxgqcwxrxyrddQRUQ0EM38DZ
+F/vHt+vTdHt07kezbjJe0Kklel59uSpghA0iL4vxzbTns1fuwYOgVrNGs3acTkiB
+GhFOxRXUPGtpdYmv+AaR9WlWJQY1GIEoVrinPVH7bcCwyh1CcUbHL+oAFTcmc6kZ
+7azNg21tWILIRL7R0IZYQm0tF5TTwCsjVC7FuHaBtkxtVrrZqeKjvVXQ8TK5VoI0
+BUQ6BKHGeTtm+0HBpheYBDy3wkOsEGbGHLEM6cMeiz6PyCXF8wXli8Qb/TjN3LHZ
+e30CAwEAAaOCAd8wggHbMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGG
+MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMDMB0GA1UdDgQWBBT9huhQhcCi8KESm50M
+G6h0cpuNSzCB7AYDVR0jBIHkMIHhgBSDx8s0qJaMyQCehKcuzgzVNRA75qGBxaSB
+wjCBvzELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFp
+biBWaWV3MSYwJAYDVQQKEx1Db250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEm
+MCQGA1UEAxMdZGV2LmNvbnRlbnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG
+9w0BCQEWLGNsb3Vkc2VjK2RldnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEu
+Y29tggEBMEIGCWCGSAGG+EIBBAQ1FjNodHRwczovL2NvbnRlbnQtc2lnbmF0dXJl
+LmRldi5tb3phd3MubmV0L2NhL2NybC5wZW0wTgYIKwYBBQUHAQEEQjBAMD4GCCsG
+AQUFBzAChjJodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmRldi5tb3phd3MubmV0
+L2NhL2NhLnBlbTANBgkqhkiG9w0BAQwFAAOCAgEAbum0z0ccqI1Wp49VtsGmUPHA
+gjPPy2Xa5NePmqY87WrGdhjN3xbLVb3hx8T2N6pqDjMY2rEynXKEOek3oJkQ3C6J
+8AFP6Y93gaAlNz6EA0J0mqdW5TMI8YEYsu2ma+dQQ8wm9f/5b+/Y8qwfhztP06W5
+H6IG04/RvgUwYMnSR4QvT309fu5UmCRUDzsO53ZmQCfmN94u3NxHc4S6n0Q6AKAM
+kh5Ld9SQnlqqqDykzn7hYDi8nTLWc7IYqkGfNMilDEKbAl4CjnSfyEvpdFAJ9sPR
+UL+kaWFSMvaqIPNpxS5OpoPZjmxEc9HHnCHxtfDHWdXTJILjijPrCdMaxOCHfIqV
+5roOJggI4RZ0YM68IL1MfN4IEVOrHhKjDHtd1gcmy2KU4jfj9LQq9YTnyvZ2z1yS
+lS310HG3or1K8Nnu5Utfe7T6ppX8bLRMkS1/w0p7DKxHaf4D/GJcCtM9lcSt9JpW
+6ACKFikjWR4ZxczYKgApc0wcjd7XBuO5777xtOeyEUDHdDft3jiXA91dYM5UAzc3
+69z/3zmaELzo0gWcrjLXh7fU9AvbU4EUF6rwzxbPGF78jJcGK+oBf8uWUCkBykDt
+VsAEZI1u4EDg8e/C1nFqaH9gNMArAgquYIB9rve+hdprIMnva0S147pflWopBWcb
+jwzgpfquuYnnxe0CNBA=
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIH3DCCBcSgAwIBAgIBATANBgkqhkiG9w0BAQwFADCBvzELMAkGA1UEBhMCVVMx
+CzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MSYwJAYDVQQKEx1D
+b250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEmMCQGA1UEAxMdZGV2LmNvbnRl
+bnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG9w0BCQEWLGNsb3Vkc2VjK2Rl
+dnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEuY29tMB4XDTE2MDcwNjE4MTUy
+MloXDTI2MDcwNDE4MTUyMlowgb8xCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJDQTEW
+MBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UEChMdQ29udGVudCBTaWduYXR1
+cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5jb250ZW50LXNpZ25hdHVyZS5y
+b290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNlYytkZXZyb290Y29udGVudHNp
+Z25hdHVyZUBtb3ppbGxhLmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
+ggIBAJcPcXhD8MzF6OTn5qZ0L7lX1+PEgLKhI9g1HxxDYDVup4Zm0kZhPGmFSlml
+6eVO99OvvHdAlHhQGCIG7h+w1cp66mWjfpcvtQH23uRoKZfiW3jy1jUWrvdXolxR
+t1taZosjzo+9OP8TvG6LpJj7AvqUiYD4wYnQJtt0jNRN4d6MUfQwiavSS5uTBuxd
+ZJ4TsPvEI+Iv4A4PSobSzxkg79LTMvsGtDLQv7nN5hMs9T18EL5GnIKoqnSQCU0d
+n2CN7S3QiQ+cbORWsSYqCTj1gUbFh6X3duEB/ypLcyWFbqeJmPHikjY8q8pLjZSB
+IYiTJYLyvYlKdM5QleC/xuBNnMPCftrwwLHjWE4Dd7C9t7k0R5xyOetuiHLCwOcQ
+tuckp7RgFKoviMNv3gdkzwVapOklcsaRkRscv6OMTKJNsdJVIDLrPF1wMABhbEQB
+64BL0uL4lkFtpXXbJzQ6mgUNQveJkfUWOoB+cA/6GtI4J0aQfvQgloCYI6jxNn/e
+Nvk5OV9KFOhXS2dnDft3wHU46sg5yXOuds1u6UrOoATBNFlkS95m4zIX1Svu091+
+CKTiLK85+ZiFtAlU2bPr3Bk3GhL3Z586ae6a4QUEx6SPQVXc18ezB4qxKqFc+avI
+ylccYMRhVP+ruADxtUM5Vy6x3U8BwBK5RLdecRx2FEBDImY1AgMBAAGjggHfMIIB
+2zAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAWBgNVHSUBAf8EDDAK
+BggrBgEFBQcDAzAdBgNVHQ4EFgQUg8fLNKiWjMkAnoSnLs4M1TUQO+YwgewGA1Ud
+IwSB5DCB4YAUg8fLNKiWjMkAnoSnLs4M1TUQO+ahgcWkgcIwgb8xCzAJBgNVBAYT
+AlVTMQswCQYDVQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UE
+ChMdQ29udGVudCBTaWduYXR1cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5j
+b250ZW50LXNpZ25hdHVyZS5yb290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNl
+YytkZXZyb290Y29udGVudHNpZ25hdHVyZUBtb3ppbGxhLmNvbYIBATBCBglghkgB
+hvhCAQQENRYzaHR0cHM6Ly9jb250ZW50LXNpZ25hdHVyZS5kZXYubW96YXdzLm5l
+dC9jYS9jcmwucGVtME4GCCsGAQUFBwEBBEIwQDA+BggrBgEFBQcwAoYyaHR0cHM6
+Ly9jb250ZW50LXNpZ25hdHVyZS5kZXYubW96YXdzLm5ldC9jYS9jYS5wZW0wDQYJ
+KoZIhvcNAQEMBQADggIBAAAQ+fotZE79FfZ8Lz7eiTUzlwHXCdSE2XD3nMROu6n6
+uLTBPrf2C+k+U1FjKVvL5/WCUj6hIzP2X6Sb8+o0XHX9mKN0yoMORTEYJOnazYPK
+KSUUFnE4vGgQkr6k/31gGRMTICdnf3VOOAlUCQ5bOmGIuWi401E3sbd85U+TJe0A
+nHlU+XjtfzlqcBvQivdbA0s+GEG55uRPvn952aTBEMHfn+2JqKeLShl4AtUAfu+h
+6md3Z2HrEC7B3GK8ekWPu0G/ZuWTuFvOimZ+5C8IPRJXcIR/siPQl1x6dpTCew6t
+lPVcVuvg6SQwzvxetkNrGUe2Wb2s9+PK2PUvfOS8ee25SNmfG3XK9qJpqGUhzSBX
+T8QQgyxd0Su5G7Wze7aaHZd/fqIm/G8YFR0HiC2xni/lnDTXFDPCe+HCnSk0bH6U
+wpr6I1yK8oZ2IdnNVfuABGMmGOhvSQ8r7//ea9WKhCsGNQawpVWVioY7hpyNAJ0O
+Vn4xqG5f6allz8lgpwAQ+AeEEClHca6hh6mj9KhD1Of1CC2Vx52GHNh/jMYEc3/g
+zLKniencBqn3Y2XH2daITGJddcleN09+a1NaTkT3hgr7LumxM8EVssPkC+z9j4Vf
+Gbste+8S5QCMhh00g5vR9QF8EaFqdxCdSxrsA4GmpCa5UQl8jtCnpp2DLKXuOh72
+-----END CERTIFICATE-----
deleted file mode 100644
--- a/browser/extensions/shield-recipe-client/test/unit/test_ActionSandboxManager.js
+++ /dev/null
@@ -1,169 +0,0 @@
-"use strict";
-
-Cu.import("resource://shield-recipe-client/lib/ActionSandboxManager.jsm");
-Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm");
-
-async function withManager(script, testFunction) {
-  const manager = new ActionSandboxManager(script);
-  manager.addHold("testing");
-  await testFunction(manager);
-  manager.removeHold("testing");
-}
-
-add_task(async function testMissingCallbackName() {
-  await withManager("1 + 1", async manager => {
-    equal(
-      await manager.runAsyncCallback("missingCallback"),
-      undefined,
-      "runAsyncCallback returns undefined when given a missing callback name",
-    );
-  });
-});
-
-add_task(async function testCallback() {
-  const script = `
-    registerAsyncCallback("testCallback", async function(normandy) {
-      return 5;
-    });
-  `;
-
-  await withManager(script, async manager => {
-    const result = await manager.runAsyncCallback("testCallback");
-    equal(result, 5, "runAsyncCallback executes the named callback inside the sandbox");
-  });
-});
-
-add_task(async function testArguments() {
-  const script = `
-    registerAsyncCallback("testCallback", async function(normandy, a, b) {
-      return a + b;
-    });
-  `;
-
-  await withManager(script, async manager => {
-    const result = await manager.runAsyncCallback("testCallback", 4, 6);
-    equal(result, 10, "runAsyncCallback passes arguments to the callback");
-  });
-});
-
-add_task(async function testCloning() {
-  const script = `
-    registerAsyncCallback("testCallback", async function(normandy, obj) {
-      return {foo: "bar", baz: obj.baz};
-    });
-  `;
-
-  await withManager(script, async manager => {
-    const result = await manager.runAsyncCallback("testCallback", {baz: "biff"});
-
-    deepEqual(
-      result,
-      {foo: "bar", baz: "biff"},
-      (
-        "runAsyncCallback clones arguments into the sandbox and return values into the " +
-        "context it was called from"
-      ),
-    );
-  });
-});
-
-add_task(async function testError() {
-  const script = `
-    registerAsyncCallback("testCallback", async function(normandy) {
-      throw new Error("WHY")
-    });
-  `;
-
-  await withManager(script, async manager => {
-    try {
-      await manager.runAsyncCallback("testCallback");
-      ok(false, "runAsnycCallbackFromScript throws errors when raised by the sandbox");
-    } catch (err) {
-      equal(err.message, "WHY", "runAsnycCallbackFromScript clones error messages");
-    }
-  });
-});
-
-add_task(async function testDriver() {
-  const script = `
-    registerAsyncCallback("testCallback", async function(normandy) {
-      return normandy;
-    });
-  `;
-
-  await withManager(script, async manager => {
-    const sandboxDriver = await manager.runAsyncCallback("testCallback");
-    const referenceDriver = new NormandyDriver(manager);
-    equal(
-      sandboxDriver.constructor.name,
-      "NormandyDriver",
-      "runAsyncCallback passes a driver as the first parameter",
-    );
-    for (const prop in referenceDriver) {
-      ok(prop in sandboxDriver, "runAsyncCallback passes a driver as the first parameter");
-    }
-  });
-});
-
-add_task(async function testGlobalObject() {
-  // Test that window is an alias for the global object, and that it
-  // has some expected functions available on it.
-  const script = `
-    window.setOnWindow = "set";
-    this.setOnGlobal = "set";
-
-    registerAsyncCallback("testCallback", async function(normandy) {
-      return {
-        setOnWindow: setOnWindow,
-        setOnGlobal: window.setOnGlobal,
-        setTimeoutExists: setTimeout !== undefined,
-        clearTimeoutExists: clearTimeout !== undefined,
-      };
-    });
-  `;
-
-  await withManager(script, async manager => {
-    const result = await manager.runAsyncCallback("testCallback");
-    Assert.deepEqual(result, {
-      setOnWindow: "set",
-      setOnGlobal: "set",
-      setTimeoutExists: true,
-      clearTimeoutExists: true,
-    }, "sandbox.window is the global object and has expected functions.");
-  });
-});
-
-add_task(async function testRegisterActionShim() {
-  const recipe = {
-    foo: "bar",
-  };
-  const script = `
-    class TestAction {
-      constructor(driver, recipe) {
-        this.driver = driver;
-        this.recipe = recipe;
-      }
-
-      execute() {
-        return new Promise(resolve => {
-          resolve({
-            foo: this.recipe.foo,
-            isDriver: "log" in this.driver,
-          });
-        });
-      }
-    }
-
-    registerAction('test-action', TestAction);
-  `;
-
-  await withManager(script, async manager => {
-    const result = await manager.runAsyncCallback("action", recipe);
-    equal(result.foo, "bar", "registerAction registers an async callback for actions");
-    equal(
-      result.isDriver,
-      true,
-      "registerAction passes the driver to the action class constructor",
-    );
-  });
-});
--- a/browser/extensions/shield-recipe-client/test/unit/test_NormandyApi.js
+++ b/browser/extensions/shield-recipe-client/test/unit/test_NormandyApi.js
@@ -1,26 +1,43 @@
+/* globals sinon */
 "use strict";
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://testing-common/httpd.js");
+Cu.import("resource://gre/modules/CanonicalJSON.jsm", this);
 Cu.import("resource://gre/modules/osfile.jsm", this);
 Cu.import("resource://shield-recipe-client/lib/NormandyApi.jsm", this);
 
 load("utils.js"); /* globals withMockPreferences */
 
+class MockResponse {
+  constructor(content) {
+    this.content = content;
+  }
+
+  async text() {
+    return this.content;
+  }
+
+  async json() {
+    return JSON.parse(this.content);
+  }
+}
+
 function withServer(server, task) {
   return withMockPreferences(async function inner(preferences) {
     const serverUrl = `http://localhost:${server.identity.primaryPort}`;
     preferences.set("extensions.shield-recipe-client.api_url", `${serverUrl}/api/v1`);
     preferences.set(
       "security.content.signature.root_hash",
       // Hash of the key that signs the normandy dev certificates
       "4C:35:B1:C3:E3:12:D9:55:E7:78:ED:D0:A7:E7:8A:38:83:04:EF:01:BF:FA:03:29:B2:46:9F:3C:C5:EC:36:04"
     );
+    NormandyApi.clearIndexCache();
 
     try {
       await task(serverUrl, preferences);
     } finally {
       await new Promise(resolve => server.stop(resolve));
     }
   });
 }
@@ -32,19 +49,19 @@ function makeScriptServer(scriptPath) {
   server.start(-1);
   return server;
 }
 
 function withScriptServer(scriptPath, task) {
   return withServer(makeScriptServer(scriptPath), task);
 }
 
-function makeMockApiServer() {
+function makeMockApiServer(directory) {
   const server = new HttpServer();
-  server.registerDirectory("/", do_get_file("mock_api"));
+  server.registerDirectory("/", directory);
 
   server.setIndexHandler(async function(request, response) {
     response.processAsync();
     const dir = request.getProperty("directory");
     const index = dir.clone();
     index.append("index.json");
 
     if (!index.exists()) {
@@ -65,17 +82,17 @@ function makeMockApiServer() {
     }
   });
 
   server.start(-1);
   return server;
 }
 
 function withMockApiServer(task) {
-  return withServer(makeMockApiServer(), task);
+  return withServer(makeMockApiServer(do_get_file("mock_api")), task);
 }
 
 add_task(withMockApiServer(async function test_get(serverUrl) {
   // Test that NormandyApi can fetch from the test server.
   const response = await NormandyApi.get(`${serverUrl}/api/v1/`);
   const data = await response.json();
   equal(data["recipe-list"], "/api/v1/recipe/", "Expected data in response");
 }));
@@ -83,21 +100,17 @@ add_task(withMockApiServer(async functio
 add_task(withMockApiServer(async function test_getApiUrl(serverUrl) {
   const apiBase = `${serverUrl}/api/v1`;
   // Test that NormandyApi can use the self-describing API's index
   const recipeListUrl = await NormandyApi.getApiUrl("action-list");
   equal(recipeListUrl, `${apiBase}/action/`, "Can retrieve action-list URL from API");
 }));
 
 add_task(withMockApiServer(async function test_getApiUrlSlashes(serverUrl, preferences) {
-  const fakeResponse = {
-    async json() {
-      return {"test-endpoint": `${serverUrl}/test/`};
-    },
-  };
+  const fakeResponse = new MockResponse(JSON.stringify({"test-endpoint": `${serverUrl}/test/`}));
   const mockGet = sinon.stub(NormandyApi, "get", async () => fakeResponse);
 
   // without slash
   {
     NormandyApi.clearIndexCache();
     preferences.set("extensions.shield-recipe-client.api_url", `${serverUrl}/api/v1`);
     const endpoint = await NormandyApi.getApiUrl("test-endpoint");
     equal(endpoint, `${serverUrl}/test/`);
@@ -120,30 +133,103 @@ add_task(withMockApiServer(async functio
 }));
 
 add_task(withMockApiServer(async function test_fetchRecipes() {
   const recipes = await NormandyApi.fetchRecipes();
   equal(recipes.length, 1);
   equal(recipes[0].name, "system-addon-test");
 }));
 
+add_task(async function test_fetchSignedObjects_canonical_mismatch() {
+  const getApiUrl = sinon.stub(NormandyApi, "getApiUrl");
+
+  // The object is non-canonical (it has whitespace, properties are out of order)
+  const response = new MockResponse(`[
+    {
+      "object": {"b": 1, "a": 2},
+      "signature": {"signature": "", "x5u": ""}
+    }
+  ]`);
+  const get = sinon.stub(NormandyApi, "get").resolves(response);
+
+  try {
+    await NormandyApi.fetchSignedObjects("object");
+    ok(false, "fetchSignedObjects did not throw for canonical JSON mismatch");
+  } catch (err) {
+    ok(err instanceof NormandyApi.InvalidSignatureError, "Error is an InvalidSignatureError");
+    ok(/Canonical/.test(err), "Error is due to canonical JSON mismatch");
+  }
+
+  getApiUrl.restore();
+  get.restore();
+});
+
+// Test validation errors due to validation throwing an exception (e.g. when
+// parameters passed to validation are malformed).
+add_task(async function test_fetchSignedObjects_validation_error() {
+  const getApiUrl = sinon.stub(NormandyApi, "getApiUrl").resolves("http://localhost/object/");
+
+  // Mock two URLs: object and the x5u
+  const get = sinon.stub(NormandyApi, "get").callsFake(async url => {
+    if (url.endsWith("object/")) {
+      return new MockResponse(CanonicalJSON.stringify([
+        {
+          object: {a: 1, b: 2},
+          signature: {signature: "invalidsignature", x5u: "http://localhost/x5u/"},
+        },
+      ]));
+    } else if (url.endsWith("x5u/")) {
+      return new MockResponse("certchain");
+    }
+
+    return null;
+  });
+
+  // Validation should fail due to a malformed x5u and signature.
+  try {
+    await NormandyApi.fetchSignedObjects("object");
+    ok(false, "fetchSignedObjects did not throw for a validation error");
+  } catch (err) {
+    ok(err instanceof NormandyApi.InvalidSignatureError, "Error is an InvalidSignatureError");
+    ok(/signature/.test(err), "Error is due to a validation error");
+  }
+
+  getApiUrl.restore();
+  get.restore();
+});
+
+// Test validation errors due to validation returning false (e.g. when parameters
+// passed to validation are correctly formed, but not valid for the data).
+const invalidSignatureServer = makeMockApiServer(do_get_file("invalid_recipe_signature_api"));
+add_task(withServer(invalidSignatureServer, async function test_fetchSignedObjects_invalid_signature() {
+  try {
+    await NormandyApi.fetchSignedObjects("recipe");
+    ok(false, "fetchSignedObjects did not throw for an invalid signature");
+  } catch (err) {
+    ok(err instanceof NormandyApi.InvalidSignatureError, "Error is an InvalidSignatureError");
+    ok(/signature/.test(err), "Error is due to an invalid signature");
+  }
+}));
+
 add_task(withMockApiServer(async function test_classifyClient() {
   const classification = await NormandyApi.classifyClient();
   Assert.deepEqual(classification, {
     country: "US",
     request_time: new Date("2017-02-22T17:43:24.657841Z"),
   });
 }));
 
 add_task(withMockApiServer(async function test_fetchActions() {
   const actions = await NormandyApi.fetchActions();
-  equal(actions.length, 2);
+  equal(actions.length, 4);
   const actionNames = actions.map(a => a.name);
   ok(actionNames.includes("console-log"));
+  ok(actionNames.includes("opt-out-study"));
   ok(actionNames.includes("show-heartbeat"));
+  ok(actionNames.includes("preference-experiment"));
 }));
 
 add_task(withScriptServer("query_server.sjs", async function test_getTestServer(serverUrl) {
   // Test that NormandyApi can fetch from the test server.
   const response = await NormandyApi.get(serverUrl);
   const data = await response.json();
   Assert.deepEqual(data, {queryString: {}, body: {}}, "NormandyApi returned incorrect server data.");
 }));
@@ -163,25 +249,26 @@ add_task(withScriptServer("query_server.
   const response = await NormandyApi.post(serverUrl, {foo: "bar", baz: "biff"});
   const data = await response.json();
   Assert.deepEqual(
     data, {queryString: {}, body: {foo: "bar", baz: "biff"}},
     "NormandyApi sent an incorrect query string."
   );
 }));
 
-add_task(withScriptServer("echo_server.sjs", async function test_fetchImplementation(serverUrl) {
-  const action = {
-    implementation_url: `${serverUrl}?status=200&body=testcontent`,
-  };
-  equal(
-    await NormandyApi.fetchImplementation(action),
-    "testcontent",
-    "fetchImplementation fetches the content at the correct URL",
-  );
+add_task(withMockApiServer(async function test_fetchImplementation_itWorksWithRealData() {
+  const [action] = await NormandyApi.fetchActions();
+  const implementation = await NormandyApi.fetchImplementation(action);
+
+  const decoder = new TextDecoder();
+  const relativePath = `mock_api${action.implementation_url}`;
+  const file = do_get_file(relativePath);
+  const expected = decoder.decode(await OS.File.read(file.path));
+
+  equal(implementation, expected);
 }));
 
 add_task(withScriptServer(
   "echo_server.sjs",
   async function test_fetchImplementationFail(serverUrl) {
     const action = {
       implementation_url: `${serverUrl}?status=500&body=servererror`,
     };
--- a/browser/extensions/shield-recipe-client/test/unit/test_SandboxManager.js
+++ b/browser/extensions/shield-recipe-client/test/unit/test_SandboxManager.js
@@ -1,15 +1,15 @@
 "use strict";
 
 Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm");
 
 // wrapAsync should wrap privileged Promises with Promises that are usable by
 // the sandbox.
-add_task(async function() {
+add_task(function* () {
   const manager = new SandboxManager();
   manager.addHold("testing");
 
   manager.cloneIntoGlobal("driver", {
     async privileged() {
       return "privileged";
     },
     wrapped: manager.wrapAsync(async function() {
@@ -20,17 +20,17 @@ add_task(async function() {
       return this.aValue;
     }),
   }, {cloneFunctions: true});
 
   // Assertion helpers
   manager.addGlobal("ok", ok);
   manager.addGlobal("equal", equal);
 
-  const sandboxResult = await new Promise(resolve => {
+  const sandboxResult = yield new Promise(resolve => {
     manager.addGlobal("resolve", result => resolve(result));
     manager.evalInSandbox(`
       // Unwrapped privileged promises are not accessible in the sandbox
       try {
         const privilegedResult = driver.privileged().then(() => false);
         ok(false, "The sandbox could not use a privileged Promise");
       } catch (err) { }
 
@@ -40,31 +40,31 @@ add_task(async function() {
 
       // Resolve the Promise around the sandbox with the wrapped result to test
       // that the Promise in the sandbox works.
       wrappedResult.then(resolve);
     `);
   });
   equal(sandboxResult, "wrapped", "wrapAsync methods return Promises that work in the sandbox");
 
-  await manager.evalInSandbox(`
+  yield manager.evalInSandbox(`
     (async function sandboxTest() {
       equal(
         await driver.wrappedThis(),
         "aValue",
         "wrapAsync preserves the behavior of the this keyword",
       );
     })();
   `);
 
   manager.removeHold("testing");
 });
 
 // wrapAsync cloning options
-add_task(async function() {
+add_task(function* () {
   const manager = new SandboxManager();
   manager.addHold("testing");
 
   // clonedArgument stores the argument passed to cloned(), which we use to test
   // that arguments from within the sandbox are cloned outside.
   let clonedArgument = null;
   manager.cloneIntoGlobal("driver", {
     uncloned: manager.wrapAsync(async function() {
@@ -75,17 +75,17 @@ add_task(async function() {
       return {value: "cloned"};
     }, {cloneInto: true, cloneArguments: true}),
   }, {cloneFunctions: true});
 
   // Assertion helpers
   manager.addGlobal("ok", ok);
   manager.addGlobal("deepEqual", deepEqual);
 
-  await new Promise(resolve => {
+  yield new Promise(resolve => {
     manager.addGlobal("resolve", resolve);
     manager.evalInSandbox(`
       (async function() {
         // The uncloned return value should be privileged and inaccesible.
         const uncloned = await driver.uncloned();
         ok(!("value" in uncloned), "The sandbox could not use an uncloned return value");
 
         // The cloned return value should be usable.
--- a/browser/extensions/shield-recipe-client/test/unit/xpcshell.ini
+++ b/browser/extensions/shield-recipe-client/test/unit/xpcshell.ini
@@ -1,11 +1,13 @@
 [DEFAULT]
 head = head_xpc.js
 support-files =
   mock_api/**
+  invalid_recipe_signature_api/**
   query_server.sjs
   echo_server.sjs
   utils.js
 
 [test_NormandyApi.js]
 [test_Sampling.js]
 [test_SandboxManager.js]
+[test_Utils.js]
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/vendor/LICENSE_THIRDPARTY
@@ -0,0 +1,262 @@
+fbjs@0.8.14 BSD-3-Clause
+BSD License
+
+For fbjs software
+
+Copyright (c) 2013-present, Facebook, Inc.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+ * Neither the name Facebook nor the names of its contributors may be used to
+   endorse or promote products derived from this software without specific
+   prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
+react-dom@15.6.1 BSD-3-Clause
+BSD License
+
+For React software
+
+Copyright (c) 2013-present, Facebook, Inc.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+ * Neither the name Facebook nor the names of its contributors may be used to
+   endorse or promote products derived from this software without specific
+   prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
+object-assign@4.1.1 MIT
+The MIT License (MIT)
+
+Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+react@15.6.1 BSD-3-Clause
+BSD License
+
+For React software
+
+Copyright (c) 2013-present, Facebook, Inc.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+ * Neither the name Facebook nor the names of its contributors may be used to
+   endorse or promote products derived from this software without specific
+   prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
+prop-types@15.5.10 BSD-3-Clause
+BSD License
+
+For React software
+
+Copyright (c) 2013-present, Facebook, Inc.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+ * Neither the name Facebook nor the names of its contributors may be used to
+   endorse or promote products derived from this software without specific
+   prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+create-react-class@15.6.0 BSD-3-Clause
+BSD License
+
+For React software
+
+Copyright (c) 2013-present, Facebook, Inc.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+ * Neither the name Facebook nor the names of its contributors may be used to
+   endorse or promote products derived from this software without specific
+   prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
+mozjexl@1.1.5 MIT
+Copyright for portions of mozJexl are held by TechnologyAdvice, 2015 as part of Jexl.
+All other copyright for mozJexl are held by the Mozilla Foundation, 2017.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+process@0.11.10 MIT
+(The MIT License)
+
+Copyright (c) 2013 Roman Shtylman <shtylman@gmail.com>
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+'Software'), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+
+classnames@2.2.5 MIT
+The MIT License (MIT)
+
+Copyright (c) 2016 Jed Watson
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/vendor/PropTypes.js
@@ -0,0 +1,1 @@
+/* eslint-disable */this.PropTypes=function(a){function b(d){if(c[d])return c[d].exports;var e=c[d]={i:d,l:!1,exports:{}};return a[d].call(e.exports,e,e.exports,b),e.l=!0,e.exports}var c={};return b.m=a,b.c=c,b.d=function(a,c,d){b.o(a,c)||Object.defineProperty(a,c,{configurable:!1,enumerable:!0,get:d})},b.n=function(a){var c=a&&a.__esModule?function(){return a['default']}:function(){return a};return b.d(c,'a',c),c},b.o=function(a,b){return Object.prototype.hasOwnProperty.call(a,b)},b.p='',b(b.s=100)}({0:function(a){'use strict';var g=function(){};!1,a.exports=function(h,i,j,a,b,c,d,e){if(g(i),!h){var f;if(void 0===i)f=new Error('Minified exception occurred; use the non-minified dev environment for the full error message and additional helpful warnings.');else{var k=[j,a,b,c,d,e],l=0;f=new Error(i.replace(/%s/g,function(){return k[l++]})),f.name='Invariant Violation'}throw f.framesToPop=1,f}}},100:function(a,b,c){a.exports=c(101)()},101:function(a,b,c){'use strict';var d=c(5),e=c(0),f=c(19);a.exports=function(){function a(a,b,c,d,g,h){h===f||e(!1,'Calling PropTypes validators directly is not supported by the `prop-types` package. Use PropTypes.checkPropTypes() to call them. Read more at http://fb.me/use-check-prop-types')}function b(){return a}a.isRequired=a;var c={array:a,bool:a,func:a,number:a,object:a,string:a,symbol:a,any:a,arrayOf:b,element:a,instanceOf:b,node:a,objectOf:b,oneOf:b,oneOfType:b,shape:b};return c.checkPropTypes=d,c.PropTypes=c,c}},19:function(a){'use strict';a.exports='SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED'},5:function(a){'use strict';function b(a){return function(){return a}}var c=function(){};c.thatReturns=b,c.thatReturnsFalse=b(!1),c.thatReturnsTrue=b(!0),c.thatReturnsNull=b(null),c.thatReturnsThis=function(){return this},c.thatReturnsArgument=function(a){return a},a.exports=c}});this.EXPORTED_SYMBOLS = ["PropTypes"];
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/vendor/React.js
@@ -0,0 +1,5 @@
+/* eslint-disable */this.React=function(e){function t(o){if(n[o])return n[o].exports;var r=n[o]={i:o,l:!1,exports:{}};return e[o].call(r.exports,r,r.exports,t),r.l=!0,r.exports}var n={};return t.m=e,t.c=n,t.d=function(e,n,o){t.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:o})},t.n=function(e){var n=e&&e.__esModule?function(){return e['default']}:function(){return e};return t.d(n,'a',n),n},t.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},t.p='',t(t.s=102)}([function(e){'use strict';var t=function(){};!1,e.exports=function(n,o,r,a,i,p,s,e){if(t(o),!n){var d;if(void 0===o)d=new Error('Minified exception occurred; use the non-minified dev environment for the full error message and additional helpful warnings.');else{var l=[r,a,i,p,s,e],u=0;d=new Error(o.replace(/%s/g,function(){return l[u++]})),d.name='Invariant Violation'}throw d.framesToPop=1,d}}},function(e,t,n){'use strict';var o=n(5);e.exports=o},,function(e){'use strict';/*
+object-assign
+(c) Sindre Sorhus
+@license MIT
+*/function t(e){if(null===e||e===void 0)throw new TypeError('Object.assign cannot be called with null or undefined');return Object(e)}var n=Object.getOwnPropertySymbols,o=Object.prototype.hasOwnProperty,r=Object.prototype.propertyIsEnumerable;e.exports=function(){try{if(!Object.assign)return!1;var e=new String('abc');if(e[5]='de','5'===Object.getOwnPropertyNames(e)[0])return!1;for(var t={},n=0;10>n;n++)t['_'+String.fromCharCode(n)]=n;var o=Object.getOwnPropertyNames(t).map(function(e){return t[e]});if('0123456789'!==o.join(''))return!1;var r={};return['a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t'].forEach(function(e){r[e]=e}),'abcdefghijklmnopqrst'===Object.keys(Object.assign({},r)).join('')}catch(e){return!1}}()?Object.assign:function(e){for(var a,p,d=t(e),l=1;l<arguments.length;l++){for(var s in a=Object(arguments[l]),a)o.call(a,s)&&(d[s]=a[s]);if(n){p=n(a);for(var u=0;u<p.length;u++)r.call(a,p[u])&&(d[p[u]]=a[p[u]])}}return d}},,function(e){'use strict';function t(e){return function(){return e}}var n=function(){};n.thatReturns=t,n.thatReturnsFalse=t(!1),n.thatReturnsTrue=t(!0),n.thatReturnsNull=t(null),n.thatReturnsThis=function(){return this},n.thatReturnsArgument=function(e){return e},e.exports=n},,function(e,t,n){'use strict';function o(e){return e.ref!==void 0}function r(e){return e.key!==void 0}var a,i,p=n(3),s=n(8),d=n(1),l=n(22),u=Object.prototype.hasOwnProperty,c=n(23),m={key:!0,ref:!0,__self:!0,__source:!0},f=function(e,t,n,o,r,a,i){return!1,{$$typeof:c,type:e,key:t,ref:n,props:i,_owner:a}};f.createElement=function(e,t,n){var a,p={},d=null,l=null,c=null,y=null;if(null!=t)for(a in o(t)&&(l=t.ref),r(t)&&(d=''+t.key),c=void 0===t.__self?null:t.__self,y=void 0===t.__source?null:t.__source,t)u.call(t,a)&&!m.hasOwnProperty(a)&&(p[a]=t[a]);var h=arguments.length-2;if(1==h)p.children=n;else if(1<h){for(var g=Array(h),b=0;b<h;b++)g[b]=arguments[b+2];!1,p.children=g}if(e&&e.defaultProps){var i=e.defaultProps;for(a in i)void 0===p[a]&&(p[a]=i[a])}return f(e,d,l,c,y,s.current,p)},f.createFactory=function(e){var t=f.createElement.bind(null,e);return t.type=e,t},f.cloneAndReplaceKey=function(e,t){var n=f(e.type,t,e.ref,e._self,e._source,e._owner,e.props);return n},f.cloneElement=function(e,t,n){var a,d=p({},e.props),l=e.key,c=e.ref,y=e._self,h=e._source,g=e._owner;if(null!=t){o(t)&&(c=t.ref,g=s.current),r(t)&&(l=''+t.key);var b;for(a in e.type&&e.type.defaultProps&&(b=e.type.defaultProps),t)u.call(t,a)&&!m.hasOwnProperty(a)&&(d[a]=void 0===t[a]&&void 0!==b?b[a]:t[a])}var E=arguments.length-2;if(1==E)d.children=n;else if(1<E){for(var x=Array(E),P=0;P<E;P++)x[P]=arguments[P+2];d.children=x}return f(e.type,l,c,y,h,g,d)},f.isValidElement=function(e){return'object'==typeof e&&null!==e&&e.$$typeof===c},e.exports=f},function(e){'use strict';e.exports={current:null}},,function(e){'use strict';e.exports=function(e){for(var t=arguments.length-1,n='Minified React error #'+e+'; visit http://facebook.github.io/react/docs/error-decoder.html?invariant='+e,o=0;o<t;o++)n+='&args[]='+encodeURIComponent(arguments[o+1]);n+=' for the full message or use the non-minified dev environment for full errors and additional helpful warnings.';var r=new Error(n);throw r.name='Invariant Violation',r.framesToPop=1,r}},,,function(e,t,n){'use strict';var o=n(3),r=n(20),a=n(35),i=n(40),p=n(7),s=n(41),d=n(44),l=n(45),u=n(47),c=p.createElement,m=p.createFactory,f=p.cloneElement;var y={Children:{map:a.map,forEach:a.forEach,count:a.count,toArray:a.toArray,only:u},Component:r.Component,PureComponent:r.PureComponent,createElement:c,cloneElement:f,isValidElement:p.isValidElement,PropTypes:s,createClass:l,createFactory:m,createMixin:function(e){return e},DOM:i,version:d,__spread:o};e.exports=y},function(e){'use strict';!1,e.exports={}},,,,,function(e){'use strict';e.exports='SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED'},function(e,t,n){'use strict';function o(e,t,n){this.props=e,this.context=t,this.refs=l,this.updater=n||s}function r(e,t,n){this.props=e,this.context=t,this.refs=l,this.updater=n||s}function a(){}var i=n(10),p=n(3),s=n(21),d=n(22),l=n(14),u=n(0),c=n(34);o.prototype.isReactComponent={},o.prototype.setState=function(e,t){'object'==typeof e||'function'==typeof e||null==e?void 0:i('85'),this.updater.enqueueSetState(this,e),t&&this.updater.enqueueCallback(this,t,'setState')},o.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this),e&&this.updater.enqueueCallback(this,e,'forceUpdate')};a.prototype=o.prototype,r.prototype=new a,r.prototype.constructor=r,p(r.prototype,o.prototype),r.prototype.isPureReactComponent=!0,e.exports={Component:o,PureComponent:r}},function(e,t,n){'use strict';function o(){}var r=n(1);e.exports={isMounted:function(){return!1},enqueueCallback:function(){},enqueueForceUpdate:function(e){o(e,'forceUpdate')},enqueueReplaceState:function(e){o(e,'replaceState')},enqueueSetState:function(e){o(e,'setState')}}},function(e){'use strict';e.exports=!1},function(e){'use strict';var t='function'==typeof Symbol&&Symbol['for']&&Symbol['for']('react.element')||60103;e.exports=t},,,,,function(e,t,n){'use strict';var o=n(42);e.exports=function(e){return o(e,!1)}},,,,,,function(e){'use strict';e.exports=function(){}},function(e,t,n){'use strict';function o(e){return(''+e).replace(h,'$&/')}function r(e,t){this.func=e,this.context=t,this.count=0}function a(e,t){var n=e.func,o=e.context;n.call(o,t,e.count++)}function i(e,t,n,o){this.result=e,this.keyPrefix=t,this.func=n,this.context=o,this.count=0}function p(e,t,n){var r=e.result,a=e.keyPrefix,i=e.func,p=e.context,d=i.call(p,t,e.count++);Array.isArray(d)?s(d,r,n,c.thatReturnsArgument):null!=d&&(u.isValidElement(d)&&(d=u.cloneAndReplaceKey(d,a+(d.key&&(!t||t.key!==d.key)?o(d.key)+'/':'')+n)),r.push(d))}function s(e,t,n,r,a){var s='';null!=n&&(s=o(n)+'/');var d=i.getPooled(t,s,r,a);m(e,p,d),i.release(d)}function d(){return null}var l=n(36),u=n(7),c=n(5),m=n(37),f=l.twoArgumentPooler,y=l.fourArgumentPooler,h=/\/+/g;r.prototype.destructor=function(){this.func=null,this.context=null,this.count=0},l.addPoolingTo(r,f),i.prototype.destructor=function(){this.result=null,this.keyPrefix=null,this.func=null,this.context=null,this.count=0},l.addPoolingTo(i,y);e.exports={forEach:function(e,t,n){if(null==e)return e;var o=r.getPooled(t,n);m(e,a,o),r.release(o)},map:function(e,t,n){if(null==e)return e;var o=[];return s(e,o,null,t,n),o},mapIntoWithKeyPrefixInternal:s,count:function(e){return m(e,d,null)},toArray:function(e){var t=[];return s(e,t,null,c.thatReturnsArgument),t}}},function(e,t,n){'use strict';var o=n(10),r=n(0),a=function(e){var t=this;if(t.instancePool.length){var n=t.instancePool.pop();return t.call(n,e),n}return new t(e)},i=function(e){var t=this;e instanceof t?void 0:o('25'),e.destructor(),t.instancePool.length<t.poolSize&&t.instancePool.push(e)};e.exports={addPoolingTo:function(e,t){var n=e;return n.instancePool=[],n.getPooled=t||a,n.poolSize||(n.poolSize=10),n.release=i,n},oneArgumentPooler:a,twoArgumentPooler:function(e,t){var n=this;if(n.instancePool.length){var o=n.instancePool.pop();return n.call(o,e,t),o}return new n(e,t)},threeArgumentPooler:function(e,t,n){var o=this;if(o.instancePool.length){var r=o.instancePool.pop();return o.call(r,e,t,n),r}return new o(e,t,n)},fourArgumentPooler:function(e,t,n,o){var r=this;if(r.instancePool.length){var a=r.instancePool.pop();return r.call(a,e,t,n,o),a}return new r(e,t,n,o)}}},function(e,t,n){'use strict';function o(e,t){return e&&'object'==typeof e&&null!=e.key?l.escape(e.key):t.toString(36)}function r(e,t,n,d){var u=typeof e;if(('undefined'==u||'boolean'==u)&&(e=null),null===e||'string'==u||'number'==u||'object'==u&&e.$$typeof===p)return n(d,e,''===t?c+o(e,0):t),1;var f,y,h=0,g=''===t?c:t+m;if(Array.isArray(e))for(var b=0;b<e.length;b++)f=e[b],y=g+o(f,b),h+=r(f,y,n,d);else{var i=s(e);if(i){var E,x=i.call(e);if(i!==e.entries)for(var P=0;!(E=x.next()).done;)f=E.value,y=g+o(f,P++),h+=r(f,y,n,d);else for(var _;!(E=x.next()).done;)_=E.value,_&&(f=_[1],y=g+l.escape(_[0])+m+o(f,0),h+=r(f,y,n,d))}else if('object'==u){var N='',I=e+'';a('31','[object Object]'===I?'object with keys {'+Object.keys(e).join(', ')+'}':I,N)}}return h}var a=n(10),i=n(8),p=n(23),s=n(38),d=n(0),l=n(39),u=n(1),c='.',m=':';e.exports=function(e,t,n){return null==e?0:r(e,'',t,n)}},function(e){'use strict';var t='function'==typeof Symbol&&Symbol.iterator;e.exports=function(e){var n=e&&(t&&e[t]||e['@@iterator']);if('function'==typeof n)return n}},function(e){'use strict';e.exports={escape:function(e){var t=/[=:]/g,n={"=":'=0',":":'=2'},o=(''+e).replace(t,function(e){return n[e]});return'$'+o},unescape:function(e){var t=/(=0|=2)/g,n={"=0":'=',"=2":':'},o='.'===e[0]&&'$'===e[1]?e.substring(2):e.substring(1);return(''+o).replace(t,function(e){return n[e]})}}},function(e,t,n){'use strict';var o=n(7),r=o.createFactory;var a={a:r('a'),abbr:r('abbr'),address:r('address'),area:r('area'),article:r('article'),aside:r('aside'),audio:r('audio'),b:r('b'),base:r('base'),bdi:r('bdi'),bdo:r('bdo'),big:r('big'),blockquote:r('blockquote'),body:r('body'),br:r('br'),button:r('button'),canvas:r('canvas'),caption:r('caption'),cite:r('cite'),code:r('code'),col:r('col'),colgroup:r('colgroup'),data:r('data'),datalist:r('datalist'),dd:r('dd'),del:r('del'),details:r('details'),dfn:r('dfn'),dialog:r('dialog'),div:r('div'),dl:r('dl'),dt:r('dt'),em:r('em'),embed:r('embed'),fieldset:r('fieldset'),figcaption:r('figcaption'),figure:r('figure'),footer:r('footer'),form:r('form'),h1:r('h1'),h2:r('h2'),h3:r('h3'),h4:r('h4'),h5:r('h5'),h6:r('h6'),head:r('head'),header:r('header'),hgroup:r('hgroup'),hr:r('hr'),html:r('html'),i:r('i'),iframe:r('iframe'),img:r('img'),input:r('input'),ins:r('ins'),kbd:r('kbd'),keygen:r('keygen'),label:r('label'),legend:r('legend'),li:r('li'),link:r('link'),main:r('main'),map:r('map'),mark:r('mark'),menu:r('menu'),menuitem:r('menuitem'),meta:r('meta'),meter:r('meter'),nav:r('nav'),noscript:r('noscript'),object:r('object'),ol:r('ol'),optgroup:r('optgroup'),option:r('option'),output:r('output'),p:r('p'),param:r('param'),picture:r('picture'),pre:r('pre'),progress:r('progress'),q:r('q'),rp:r('rp'),rt:r('rt'),ruby:r('ruby'),s:r('s'),samp:r('samp'),script:r('script'),section:r('section'),select:r('select'),small:r('small'),source:r('source'),span:r('span'),strong:r('strong'),style:r('style'),sub:r('sub'),summary:r('summary'),sup:r('sup'),table:r('table'),tbody:r('tbody'),td:r('td'),textarea:r('textarea'),tfoot:r('tfoot'),th:r('th'),thead:r('thead'),time:r('time'),title:r('title'),tr:r('tr'),track:r('track'),u:r('u'),ul:r('ul'),var:r('var'),video:r('video'),wbr:r('wbr'),circle:r('circle'),clipPath:r('clipPath'),defs:r('defs'),ellipse:r('ellipse'),g:r('g'),image:r('image'),line:r('line'),linearGradient:r('linearGradient'),mask:r('mask'),path:r('path'),pattern:r('pattern'),polygon:r('polygon'),polyline:r('polyline'),radialGradient:r('radialGradient'),rect:r('rect'),stop:r('stop'),svg:r('svg'),text:r('text'),tspan:r('tspan')};e.exports=a},function(e,t,n){'use strict';var o=n(7),r=o.isValidElement,a=n(28);e.exports=a(r)},function(e,t,n){'use strict';var o=n(5),r=n(0),a=n(1),p=n(19),i=n(43);e.exports=function(e,t){function n(e){var t=e&&(b&&e[b]||e[E]);if('function'==typeof t)return t}function s(e,t){return e===t?0!==e||1/e==1/t:e!==e&&t!==t}function d(e){this.message=e,this.stack=''}function l(e){function n(n,o,a,i,s,l,u){if(i=i||x,l=l||a,u!==p)if(t)r(!1,'Calling PropTypes validators directly is not supported by the `prop-types` package. Use `PropTypes.checkPropTypes()` to call them. Read more at http://fb.me/use-check-prop-types');else;return null==o[a]?n?null===o[a]?new d('The '+s+' `'+l+'` is marked as required '+('in `'+i+'`, but its value is `null`.')):new d('The '+s+' `'+l+'` is marked as required in '+('`'+i+'`, but its value is `undefined`.')):null:e(o,a,i,s,l)}var o=n.bind(null,!1);return o.isRequired=n.bind(null,!0),o}function u(e){return l(function(t,n,o,r,a){var i=t[n],p=f(i);if(p!==e){var s=y(i);return new d('Invalid '+r+' `'+a+'` of type '+('`'+s+'` supplied to `'+o+'`, expected ')+('`'+e+'`.'))}return null})}function c(t){switch(typeof t){case'number':case'string':case'undefined':return!0;case'boolean':return!t;case'object':if(Array.isArray(t))return t.every(c);if(null===t||e(t))return!0;var o=n(t);if(o){var r,a=o.call(t);if(o!==t.entries){for(;!(r=a.next()).done;)if(!c(r.value))return!1;}else for(;!(r=a.next()).done;){var i=r.value;if(i&&!c(i[1]))return!1}}else return!1;return!0;default:return!1;}}function m(e,t){return'symbol'===e||'Symbol'===t['@@toStringTag']||'function'==typeof Symbol&&t instanceof Symbol}function f(e){var t=typeof e;return Array.isArray(e)?'array':e instanceof RegExp?'object':m(t,e)?'symbol':t}function y(e){if('undefined'==typeof e||null===e)return''+e;var t=f(e);if('object'===t){if(e instanceof Date)return'date';if(e instanceof RegExp)return'regexp'}return t}function h(e){var t=y(e);return'array'===t||'object'===t?'an '+t:'boolean'===t||'date'===t||'regexp'===t?'a '+t:t}function g(e){return e.constructor&&e.constructor.name?e.constructor.name:x}var b='function'==typeof Symbol&&Symbol.iterator,E='@@iterator',x='<<anonymous>>',P={array:u('array'),bool:u('boolean'),func:u('function'),number:u('number'),object:u('object'),string:u('string'),symbol:u('symbol'),any:function(){return l(o.thatReturnsNull)}(),arrayOf:function(e){return l(function(t,n,o,r,a){if('function'!=typeof e)return new d('Property `'+a+'` of component `'+o+'` has invalid PropType notation inside arrayOf.');var s=t[n];if(!Array.isArray(s)){var l=f(s);return new d('Invalid '+r+' `'+a+'` of type '+('`'+l+'` supplied to `'+o+'`, expected an array.'))}for(var u,c=0;c<s.length;c++)if(u=e(s,c,o,r,a+'['+c+']',p),u instanceof Error)return u;return null})},element:function(){return l(function(t,n,o,r,a){var i=t[n];if(!e(i)){var p=f(i);return new d('Invalid '+r+' `'+a+'` of type '+('`'+p+'` supplied to `'+o+'`, expected a single ReactElement.'))}return null})}(),instanceOf:function(e){return l(function(t,n,o,r,a){if(!(t[n]instanceof e)){var i=e.name||x,p=g(t[n]);return new d('Invalid '+r+' `'+a+'` of type '+('`'+p+'` supplied to `'+o+'`, expected ')+('instance of `'+i+'`.'))}return null})},node:function(){return l(function(e,t,n,o,r){return c(e[t])?null:new d('Invalid '+o+' `'+r+'` supplied to '+('`'+n+'`, expected a ReactNode.'))})}(),objectOf:function(e){return l(function(t,n,o,r,a){if('function'!=typeof e)return new d('Property `'+a+'` of component `'+o+'` has invalid PropType notation inside objectOf.');var i=t[n],s=f(i);if('object'!==s)return new d('Invalid '+r+' `'+a+'` of type '+('`'+s+'` supplied to `'+o+'`, expected an object.'));for(var l in i)if(i.hasOwnProperty(l)){var u=e(i,l,o,r,a+'.'+l,p);if(u instanceof Error)return u}return null})},oneOf:function(e){return Array.isArray(e)?l(function(t,n,o,r,a){for(var p=t[n],l=0;l<e.length;l++)if(s(p,e[l]))return null;var i=JSON.stringify(e);return new d('Invalid '+r+' `'+a+'` of value `'+p+'` '+('supplied to `'+o+'`, expected one of '+i+'.'))}):(void 0,o.thatReturnsNull)},oneOfType:function(e){if(!Array.isArray(e))return void 0,o.thatReturnsNull;for(var t,n=0;n<e.length;n++)if(t=e[n],'function'!=typeof t)return a(!1,'Invalid argument supplid to oneOfType. Expected an array of check functions, but received %s at index %s.',h(t),n),o.thatReturnsNull;return l(function(t,n,o,r,a){for(var s,l=0;l<e.length;l++)if(s=e[l],null==s(t,n,o,r,a,p))return null;return new d('Invalid '+r+' `'+a+'` supplied to '+('`'+o+'`.'))})},shape:function(e){return l(function(t,n,o,r,a){var i=t[n],s=f(i);if('object'!==s)return new d('Invalid '+r+' `'+a+'` of type `'+s+'` '+('supplied to `'+o+'`, expected `object`.'));for(var l in e){var u=e[l];if(u){var c=u(i,l,o,r,a+'.'+l,p);if(c)return c}}return null})}};return d.prototype=Error.prototype,P.checkPropTypes=i,P.PropTypes=P,P}},function(e){'use strict';e.exports=function(){}},function(e){'use strict';e.exports='15.6.1'},function(e,t,n){'use strict';var o=n(20),r=o.Component,a=n(7),i=a.isValidElement,p=n(21),s=n(46);e.exports=s(r,i,p)},function(e,t,n){'use strict';function o(e){return e}var r=n(3),a=n(14),i=n(0);var p,s='mixins';p={},e.exports=function(e,t,n){function p(e,t){var n=g.hasOwnProperty(t)?g[t]:null;P.hasOwnProperty(t)&&i('OVERRIDE_BASE'===n,'ReactClassInterface: You are attempting to override `%s` from your class specification. Ensure that your method names do not overlap with React methods.',t),e&&i('DEFINE_MANY'===n||'DEFINE_MANY_MERGED'===n,'ReactClassInterface: You are attempting to define `%s` on your component more than once. This conflict may be due to a mixin.',t)}function d(e,n){if(!n){return}i('function'!=typeof n,'ReactClass: You\'re attempting to use a component class or function as a mixin. Instead, just use a regular object.'),i(!t(n),'ReactClass: You\'re attempting to use a component as a mixin. Instead, just use a regular object.');var o=e.prototype,r=o.__reactAutoBindPairs;for(var a in n.hasOwnProperty(s)&&b.mixins(e,n.mixins),n)if(n.hasOwnProperty(a)&&a!=s){var d=n[a],l=o.hasOwnProperty(a);if(p(l,a),b.hasOwnProperty(a))b[a](e,d);else{var u=g.hasOwnProperty(a),f='function'==typeof d&&!u&&!l&&!1!==n.autobind;if(f)r.push(a,d),o[a]=d;else if(l){var y=g[a];i(u&&('DEFINE_MANY_MERGED'===y||'DEFINE_MANY'===y),'ReactClass: Unexpected spec policy %s for key %s when mixing in component specs.',y,a),'DEFINE_MANY_MERGED'===y?o[a]=c(o[a],d):'DEFINE_MANY'===y&&(o[a]=m(o[a],d))}else o[a]=d,!1}}}function l(e,t){if(t)for(var n in t){var o=t[n];if(t.hasOwnProperty(n)){i(!(n in b),'ReactClass: You are attempting to define a reserved property, `%s`, that shouldn\'t be on the "statics" key. Define it as an instance property instead; it will still be accessible on the constructor.',n);i(!(n in e),'ReactClass: You are attempting to define `%s` on your component more than once. This conflict may be due to a mixin.',n),e[n]=o}}}function u(e,t){for(var n in i(e&&t&&'object'==typeof e&&'object'==typeof t,'mergeIntoWithNoDuplicateKeys(): Cannot merge non-objects.'),t)t.hasOwnProperty(n)&&(i(void 0===e[n],'mergeIntoWithNoDuplicateKeys(): Tried to merge two objects with the same key: `%s`. This conflict may be due to a mixin; in particular, this may be caused by two getInitialState() or getDefaultProps() methods returning objects with clashing keys.',n),e[n]=t[n]);return e}function c(e,t){return function(){var n=e.apply(this,arguments),o=t.apply(this,arguments);if(null==n)return o;if(null==o)return n;var r={};return u(r,n),u(r,o),r}}function m(e,t){return function(){e.apply(this,arguments),t.apply(this,arguments)}}function f(e,t){var n=t.bind(e);return n}function y(e){for(var t=e.__reactAutoBindPairs,n=0;n<t.length;n+=2){var o=t[n],r=t[n+1];e[o]=f(e,r)}}var h=[],g={mixins:'DEFINE_MANY',statics:'DEFINE_MANY',propTypes:'DEFINE_MANY',contextTypes:'DEFINE_MANY',childContextTypes:'DEFINE_MANY',getDefaultProps:'DEFINE_MANY_MERGED',getInitialState:'DEFINE_MANY_MERGED',getChildContext:'DEFINE_MANY_MERGED',render:'DEFINE_ONCE',componentWillMount:'DEFINE_MANY',componentDidMount:'DEFINE_MANY',componentWillReceiveProps:'DEFINE_MANY',shouldComponentUpdate:'DEFINE_ONCE',componentWillUpdate:'DEFINE_MANY',componentDidUpdate:'DEFINE_MANY',componentWillUnmount:'DEFINE_MANY',updateComponent:'OVERRIDE_BASE'},b={displayName:function(e,t){e.displayName=t},mixins:function(e,t){if(t)for(var n=0;n<t.length;n++)d(e,t[n])},childContextTypes:function(e,t){!1,e.childContextTypes=r({},e.childContextTypes,t)},contextTypes:function(e,t){!1,e.contextTypes=r({},e.contextTypes,t)},getDefaultProps:function(e,t){e.getDefaultProps=e.getDefaultProps?c(e.getDefaultProps,t):t},propTypes:function(e,t){!1,e.propTypes=r({},e.propTypes,t)},statics:function(e,t){l(e,t)},autobind:function(){}},E={componentDidMount:function(){this.__isMounted=!0}},x={componentWillUnmount:function(){this.__isMounted=!1}},P={replaceState:function(e,t){this.updater.enqueueReplaceState(this,e,t)},isMounted:function(){return!1,!!this.__isMounted}},_=function(){};return r(_.prototype,e.prototype,P),function(e){var t=o(function(e,o,r){!1,this.__reactAutoBindPairs.length&&y(this),this.props=e,this.context=o,this.refs=a,this.updater=r||n,this.state=null;var p=this.getInitialState?this.getInitialState():null;!1,i('object'==typeof p&&!Array.isArray(p),'%s.getInitialState(): must return an object or null',t.displayName||'ReactCompositeComponent'),this.state=p});for(var r in t.prototype=new _,t.prototype.constructor=t,t.prototype.__reactAutoBindPairs=[],h.forEach(d.bind(null,t)),d(t,E),d(t,e),d(t,x),t.getDefaultProps&&(t.defaultProps=t.getDefaultProps()),!1,i(t.prototype.render,'createClass(...): Class specification must implement a `render` method.'),!1,g)t.prototype[r]||(t.prototype[r]=null);return t}}},function(e,t,n){'use strict';var o=n(10),r=n(7),a=n(0);e.exports=function(e){return r.isValidElement(e)?void 0:o('143'),e}},,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,function(e,t,n){'use strict';e.exports=n(13)}]);this.EXPORTED_SYMBOLS = ["React"];
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/vendor/ReactDOM.js
@@ -0,0 +1,18 @@
+/* eslint-disable */this.ReactDOM=function(e){function t(o){if(n[o])return n[o].exports;var a=n[o]={i:o,l:!1,exports:{}};return e[o].call(a.exports,a,a.exports,t),a.l=!0,a.exports}var n={};return t.m=e,t.c=n,t.d=function(e,n,o){t.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:o})},t.n=function(e){var n=e&&e.__esModule?function(){return e['default']}:function(){return e};return t.d(n,'a',n),n},t.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},t.p='',t(t.s=103)}([function(e){'use strict';var t=function(){};!1,e.exports=function(n,o,r,a,i,s,d,e){if(t(o),!n){var p;if(void 0===o)p=new Error('Minified exception occurred; use the non-minified dev environment for the full error message and additional helpful warnings.');else{var l=[r,a,i,s,d,e],u=0;p=new Error(o.replace(/%s/g,function(){return l[u++]})),p.name='Invariant Violation'}throw p.framesToPop=1,p}}},function(e,t,n){'use strict';var o=n(5);e.exports=o},function(e){'use strict';e.exports=function(e){for(var t=arguments.length-1,n='Minified React error #'+e+'; visit http://facebook.github.io/react/docs/error-decoder.html?invariant='+e,o=0;o<t;o++)n+='&args[]='+encodeURIComponent(arguments[o+1]);n+=' for the full message or use the non-minified dev environment for full errors and additional helpful warnings.';var a=new Error(n);throw a.name='Invariant Violation',a.framesToPop=1,a}},function(e){'use strict';/*
+object-assign
+(c) Sindre Sorhus
+@license MIT
+*/function t(e){if(null===e||e===void 0)throw new TypeError('Object.assign cannot be called with null or undefined');return Object(e)}var n=Object.getOwnPropertySymbols,o=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable;e.exports=function(){try{if(!Object.assign)return!1;var e=new String('abc');if(e[5]='de','5'===Object.getOwnPropertyNames(e)[0])return!1;for(var t={},n=0;10>n;n++)t['_'+String.fromCharCode(n)]=n;var o=Object.getOwnPropertyNames(t).map(function(e){return t[e]});if('0123456789'!==o.join(''))return!1;var a={};return['a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t'].forEach(function(e){a[e]=e}),'abcdefghijklmnopqrst'===Object.keys(Object.assign({},a)).join('')}catch(e){return!1}}()?Object.assign:function(e){for(var r=t(e),d=1,s,p;d<arguments.length;d++){for(var l in s=Object(arguments[d]),s)o.call(s,l)&&(r[l]=s[l]);if(n){p=n(s);for(var u=0;u<p.length;u++)a.call(s,p[u])&&(r[p[u]]=s[p[u]])}}return r}},function(e,t,n){'use strict';function o(e,t){return 1===e.nodeType&&e.getAttribute(c)===t+''||8===e.nodeType&&e.nodeValue===' react-text: '+t+' '||8===e.nodeType&&e.nodeValue===' react-empty: '+t+' '}function a(e){for(var t;t=e._renderedComponent;)e=t;return e}function r(e,t){var n=a(e);n._hostNode=t,t[h]=n}function i(e,t){if(!(e._flags&m.hasCachedChildNodes)){var n=e._renderedChildren,i=t.firstChild;outer:for(var d in n)if(n.hasOwnProperty(d)){var p=n[d],l=a(p)._domID;if(0!==l){for(;null!==i;i=i.nextSibling)if(o(i,l)){r(p,i);continue outer}s('32',l)}}e._flags|=m.hasCachedChildNodes}}function d(e){if(e[h])return e[h];for(var t=[];!e[h];)if(t.push(e),e.parentNode)e=e.parentNode;else return null;for(var n,o;e&&(o=e[h]);e=t.pop())n=o,t.length&&i(o,e);return n}var s=n(2),p=n(16),l=n(66),u=n(0),c=p.ID_ATTRIBUTE_NAME,m=l,h='__reactInternalInstance$'+Math.random().toString(36).slice(2);e.exports={getClosestInstanceFromNode:d,getInstanceFromNode:function(e){var t=d(e);return null!=t&&t._hostNode===e?t:null},getNodeFromInstance:function(e){if(void 0===e._hostNode?s('33'):void 0,e._hostNode)return e._hostNode;for(var t=[];!e._hostNode;)t.push(e),e._hostParent?void 0:s('34'),e=e._hostParent;for(;t.length;e=t.pop())i(e,e._hostNode);return e._hostNode},precacheChildNodes:i,precacheNode:r,uncacheNode:function(e){var t=e._hostNode;t&&(delete t[h],e._hostNode=null)}}},function(e){'use strict';function t(e){return function(){return e}}var n=function(){};n.thatReturns=t,n.thatReturnsFalse=t(!1),n.thatReturnsTrue=t(!0),n.thatReturnsNull=t(null),n.thatReturnsThis=function(){return this},n.thatReturnsArgument=function(e){return e},e.exports=n},function(e){'use strict';var t=!!('undefined'!=typeof window&&window.document&&window.document.createElement),n={canUseDOM:t,canUseWorkers:'undefined'!=typeof Worker,canUseEventListeners:t&&!!(window.addEventListener||window.attachEvent),canUseViewport:t&&!!window.screen,isInWorker:!t};e.exports=n},function(e,t,n){'use strict';function o(e){return e.ref!==void 0}function a(e){return e.key!==void 0}var r=n(3),d=n(8),i=n(1),s=n(22),p=Object.prototype.hasOwnProperty,l=n(23),u={key:!0,ref:!0,__self:!0,__source:!0},c=function(e,t,n,o,a,r,i){return!1,{$$typeof:l,type:e,key:t,ref:n,props:i,_owner:r}},m,h;c.createElement=function(e,t,n){var r={},s=null,l=null,m=null,h=null,g;if(null!=t)for(g in o(t)&&(l=t.ref),a(t)&&(s=''+t.key),m=void 0===t.__self?null:t.__self,h=void 0===t.__source?null:t.__source,t)p.call(t,g)&&!u.hasOwnProperty(g)&&(r[g]=t[g]);var f=arguments.length-2;if(1==f)r.children=n;else if(1<f){for(var y=Array(f),_=0;_<f;_++)y[_]=arguments[_+2];!1,r.children=y}if(e&&e.defaultProps){var i=e.defaultProps;for(g in i)void 0===r[g]&&(r[g]=i[g])}return c(e,s,l,m,h,d.current,r)},c.createFactory=function(e){var t=c.createElement.bind(null,e);return t.type=e,t},c.cloneAndReplaceKey=function(e,t){var n=c(e.type,t,e.ref,e._self,e._source,e._owner,e.props);return n},c.cloneElement=function(e,t,n){var s=r({},e.props),l=e.key,m=e.ref,h=e._self,g=e._source,f=e._owner,y;if(null!=t){o(t)&&(m=t.ref,f=d.current),a(t)&&(l=''+t.key);var _;for(y in e.type&&e.type.defaultProps&&(_=e.type.defaultProps),t)p.call(t,y)&&!u.hasOwnProperty(y)&&(s[y]=void 0===t[y]&&void 0!==_?_[y]:t[y])}var C=arguments.length-2;if(1==C)s.children=n;else if(1<C){for(var b=Array(C),E=0;E<C;E++)b[E]=arguments[E+2];s.children=b}return c(e.type,l,m,h,g,f,s)},c.isValidElement=function(e){return'object'==typeof e&&null!==e&&e.$$typeof===l},e.exports=c},function(e){'use strict';e.exports={current:null}},function(e){'use strict';e.exports={debugTool:null}},function(e){'use strict';e.exports=function(e){for(var t=arguments.length-1,n='Minified React error #'+e+'; visit http://facebook.github.io/react/docs/error-decoder.html?invariant='+e,o=0;o<t;o++)n+='&args[]='+encodeURIComponent(arguments[o+1]);n+=' for the full message or use the non-minified dev environment for full errors and additional helpful warnings.';var a=new Error(n);throw a.name='Invariant Violation',a.framesToPop=1,a}},function(e,t,n){'use strict';function o(){x.ReactReconcileTransaction&&E?void 0:s('123')}function a(){this.reinitializeTransaction(),this.dirtyComponentsLength=null,this.callbackQueue=l.getPooled(),this.reconcileTransaction=x.ReactReconcileTransaction.getPooled(!0)}function r(e,t){return e._mountOrder-t._mountOrder}function i(e){var t=e.dirtyComponentsLength;t===f.length?void 0:s('124',t,f.length),f.sort(r),y++;for(var n=0;n<t;n++){var o=f[n],a=o._pendingCallbacks;o._pendingCallbacks=null;var i;if(c.logTopLevelRenders){var d=o;o._currentElement.type.isReactTopLevelWrapper&&(d=o._renderedComponent),i='React update: '+d.getName(),console.time(i)}if(m.performUpdateIfNecessary(o,e.reconcileTransaction,y),i&&console.timeEnd(i),a)for(var p=0;p<a.length;p++)e.callbackQueue.enqueue(a[p],o.getPublicInstance())}}function d(e){return o(),E.isBatchingUpdates?void(f.push(e),null==e._updateBatchNumber&&(e._updateBatchNumber=y+1)):void E.batchedUpdates(d,e)}var s=n(2),p=n(3),l=n(70),u=n(15),c=n(71),m=n(17),h=n(29),g=n(0),f=[],y=0,_=l.getPooled(),C=!1,E=null,b=[{initialize:function(){this.dirtyComponentsLength=f.length},close:function(){this.dirtyComponentsLength===f.length?f.length=0:(f.splice(0,this.dirtyComponentsLength),v())}},{initialize:function(){this.callbackQueue.reset()},close:function(){this.callbackQueue.notifyAll()}}];p(a.prototype,h,{getTransactionWrappers:function(){return b},destructor:function(){this.dirtyComponentsLength=null,l.release(this.callbackQueue),this.callbackQueue=null,x.ReactReconcileTransaction.release(this.reconcileTransaction),this.reconcileTransaction=null},perform:function(e,t,n){return h.perform.call(this,this.reconcileTransaction.perform,this.reconcileTransaction,e,t,n)}}),u.addPoolingTo(a);var v=function(){for(;f.length||C;){if(f.length){var e=a.getPooled();e.perform(i,null,e),a.release(e)}if(C){C=!1;var t=_;_=l.getPooled(),t.notifyAll(),l.release(t)}}},x={ReactReconcileTransaction:null,batchedUpdates:function(t,n,a,r,i,d){return o(),E.batchedUpdates(t,n,a,r,i,d)},enqueueUpdate:d,flushBatchedUpdates:v,injection:{injectReconcileTransaction:function(e){e?void 0:s('126'),x.ReactReconcileTransaction=e},injectBatchingStrategy:function(e){e?void 0:s('127'),'function'==typeof e.batchedUpdates?void 0:s('128'),'boolean'==typeof e.isBatchingUpdates?void 0:s('129'),E=e}},asap:function(e,t){E.isBatchingUpdates?void 0:s('125'),_.enqueue(e,t),C=!0}};e.exports=x},function(e,t,n){'use strict';function o(e,t,n,o){!1,this.dispatchConfig=e,this._targetInst=t,this.nativeEvent=n;var a=this.constructor.Interface;for(var r in a)if(a.hasOwnProperty(r)){var d=a[r];d?this[r]=d(n):'target'==r?this.target=o:this[r]=n[r]}var s=null==n.defaultPrevented?!1===n.returnValue:n.defaultPrevented;return this.isDefaultPrevented=s?i.thatReturnsTrue:i.thatReturnsFalse,this.isPropagationStopped=i.thatReturnsFalse,this}var a=n(3),r=n(15),i=n(5),d=n(1),s='function'==typeof Proxy,p=['dispatchConfig','_targetInst','nativeEvent','isDefaultPrevented','isPropagationStopped','_dispatchListeners','_dispatchInstances'],l={type:null,target:null,currentTarget:i.thatReturnsNull,eventPhase:null,bubbles:null,cancelable:null,timeStamp:function(e){return e.timeStamp||Date.now()},defaultPrevented:null,isTrusted:null};a(o.prototype,{preventDefault:function(){this.defaultPrevented=!0;var e=this.nativeEvent;e&&(e.preventDefault?e.preventDefault():'unknown'!=typeof e.returnValue&&(e.returnValue=!1),this.isDefaultPrevented=i.thatReturnsTrue)},stopPropagation:function(){var e=this.nativeEvent;e&&(e.stopPropagation?e.stopPropagation():'unknown'!=typeof e.cancelBubble&&(e.cancelBubble=!0),this.isPropagationStopped=i.thatReturnsTrue)},persist:function(){this.isPersistent=i.thatReturnsTrue},isPersistent:i.thatReturnsFalse,destructor:function(){var e=this.constructor.Interface;for(var t in e)this[t]=null;for(var n=0;n<p.length;n++)this[p[n]]=null}}),o.Interface=l,!1,o.augmentClass=function(e,t){var n=this,o=function(){};o.prototype=n.prototype;var i=new o;a(i,e.prototype),e.prototype=i,e.prototype.constructor=e,e.Interface=a({},n.Interface,t),e.augmentClass=n.augmentClass,r.addPoolingTo(e,r.fourArgumentPooler)},r.addPoolingTo(o,r.fourArgumentPooler),e.exports=o},function(e,t,n){'use strict';var o=n(3),a=n(20),r=n(35),i=n(40),d=n(7),s=n(41),p=n(44),l=n(45),u=n(47),c=d.createElement,m=d.createFactory,h=d.cloneElement;var g={Children:{map:r.map,forEach:r.forEach,count:r.count,toArray:r.toArray,only:u},Component:a.Component,PureComponent:a.PureComponent,createElement:c,cloneElement:h,isValidElement:d.isValidElement,PropTypes:s,createClass:l,createFactory:m,createMixin:function(e){return e},DOM:i,version:p,__spread:o};e.exports=g},function(e){'use strict';!1,e.exports={}},function(e,t,n){'use strict';var o=n(2),a=n(0),r=function(e){var t=this;if(t.instancePool.length){var n=t.instancePool.pop();return t.call(n,e),n}return new t(e)},i=function(e){var t=this;e instanceof t?void 0:o('25'),e.destructor(),t.instancePool.length<t.poolSize&&t.instancePool.push(e)};e.exports={addPoolingTo:function(e,t){var n=e;return n.instancePool=[],n.getPooled=t||r,n.poolSize||(n.poolSize=10),n.release=i,n},oneArgumentPooler:r,twoArgumentPooler:function(e,t){var n=this;if(n.instancePool.length){var o=n.instancePool.pop();return n.call(o,e,t),o}return new n(e,t)},threeArgumentPooler:function(e,t,n){var o=this;if(o.instancePool.length){var a=o.instancePool.pop();return o.call(a,e,t,n),a}return new o(e,t,n)},fourArgumentPooler:function(e,t,n,o){var a=this;if(a.instancePool.length){var r=a.instancePool.pop();return a.call(r,e,t,n,o),r}return new a(e,t,n,o)}}},function(e,t,n){'use strict';function o(e,t){return(e&t)===t}var a=n(2),r=n(0),i={MUST_USE_PROPERTY:1,HAS_BOOLEAN_VALUE:4,HAS_NUMERIC_VALUE:8,HAS_POSITIVE_NUMERIC_VALUE:24,HAS_OVERLOADED_BOOLEAN_VALUE:32,injectDOMPropertyConfig:function(e){var t=i,n=e.Properties||{},r=e.DOMAttributeNamespaces||{},d=e.DOMAttributeNames||{},p=e.DOMPropertyNames||{},l=e.DOMMutationMethods||{};for(var u in e.isCustomAttribute&&s._isCustomAttributeFunctions.push(e.isCustomAttribute),n){s.properties.hasOwnProperty(u)?a('48',u):void 0;var c=u.toLowerCase(),m=n[u],h={attributeName:c,attributeNamespace:null,propertyName:u,mutationMethod:null,mustUseProperty:o(m,t.MUST_USE_PROPERTY),hasBooleanValue:o(m,t.HAS_BOOLEAN_VALUE),hasNumericValue:o(m,t.HAS_NUMERIC_VALUE),hasPositiveNumericValue:o(m,t.HAS_POSITIVE_NUMERIC_VALUE),hasOverloadedBooleanValue:o(m,t.HAS_OVERLOADED_BOOLEAN_VALUE)};if(1>=h.hasBooleanValue+h.hasNumericValue+h.hasOverloadedBooleanValue?void 0:a('50',u),!1,d.hasOwnProperty(u)){var g=d[u];h.attributeName=g,!1}r.hasOwnProperty(u)&&(h.attributeNamespace=r[u]),p.hasOwnProperty(u)&&(h.propertyName=p[u]),l.hasOwnProperty(u)&&(h.mutationMethod=l[u]),s.properties[u]=h}}},d=':A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD',s={ID_ATTRIBUTE_NAME:'data-reactid',ROOT_ATTRIBUTE_NAME:'data-reactroot',ATTRIBUTE_NAME_START_CHAR:d,ATTRIBUTE_NAME_CHAR:d+'\\-.0-9\\u00B7\\u0300-\\u036F\\u203F-\\u2040',properties:{},getPossibleStandardName:null,_isCustomAttributeFunctions:[],isCustomAttribute:function(e){for(var t=0,n;t<s._isCustomAttributeFunctions.length;t++)if(n=s._isCustomAttributeFunctions[t],n(e))return!0;return!1},injection:i};e.exports=s},function(e,t,n){'use strict';function o(){a.attachRefs(this,this._currentElement)}var a=n(112),r=n(9),i=n(1);e.exports={mountComponent:function(e,t,n,a,r,i){var d=e.mountComponent(t,n,a,r,i);return e._currentElement&&null!=e._currentElement.ref&&t.getReactMountReady().enqueue(o,e),!1,d},getHostNode:function(e){return e.getHostNode()},unmountComponent:function(e,t){!1,a.detachRefs(e,e._currentElement),e.unmountComponent(t),!1},receiveComponent:function(e,t,n,r){var i=e._currentElement;if(t!==i||r!==e._context){var d=a.shouldUpdateRefs(i,t);d&&a.detachRefs(e,i),e.receiveComponent(t,n,r),d&&e._currentElement&&null!=e._currentElement.ref&&n.getReactMountReady().enqueue(o,e),!1}},performUpdateIfNecessary:function(e,t,n){return e._updateBatchNumber===n?void(!1,e.performUpdateIfNecessary(t),!1):void void 0}}},function(e,t,n){'use strict';function o(e){if(l){var t=e.node,n=e.children;if(n.length)for(var o=0;o<n.length;o++)u(t,n[o],null);else null==e.html?null!=e.text&&p(t,e.text):d(t,e.html)}}function a(){return this.node.nodeName}function r(e){return{node:e,children:[],html:null,text:null,toString:a}}var i=n(55),d=n(31),s=n(56),p=n(75),l='undefined'!=typeof document&&'number'==typeof document.documentMode||'undefined'!=typeof navigator&&'string'==typeof navigator.userAgent&&/\bEdge\/\d/.test(navigator.userAgent),u=s(function(e,t,n){t.node.nodeType===11||t.node.nodeType===1&&'object'===t.node.nodeName.toLowerCase()&&(null==t.node.namespaceURI||t.node.namespaceURI===i.html)?(o(t),e.insertBefore(t.node,n)):(e.insertBefore(t.node,n),o(t))});r.insertTreeBefore=u,r.replaceChildWithTree=function(e,t){e.parentNode.replaceChild(t.node,e),o(t)},r.queueChild=function(e,t){l?e.children.push(t):e.node.appendChild(t.node)},r.queueHTML=function(e,t){l?e.html=t:d(e.node,t)},r.queueText=function(e,t){l?e.text=t:p(e.node,t)},e.exports=r},function(e){'use strict';e.exports='SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED'},function(e,t,n){'use strict';function o(e,t,n){this.props=e,this.context=t,this.refs=l,this.updater=n||s}function a(e,t,n){this.props=e,this.context=t,this.refs=l,this.updater=n||s}function r(){}var i=n(10),d=n(3),s=n(21),p=n(22),l=n(14),u=n(0),c=n(34);o.prototype.isReactComponent={},o.prototype.setState=function(e,t){'object'==typeof e||'function'==typeof e||null==e?void 0:i('85'),this.updater.enqueueSetState(this,e),t&&this.updater.enqueueCallback(this,t,'setState')},o.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this),e&&this.updater.enqueueCallback(this,e,'forceUpdate')};r.prototype=o.prototype,a.prototype=new r,a.prototype.constructor=a,d(a.prototype,o.prototype),a.prototype.isPureReactComponent=!0,e.exports={Component:o,PureComponent:a}},function(e,t,n){'use strict';function o(){}var a=n(1);e.exports={isMounted:function(){return!1},enqueueCallback:function(){},enqueueForceUpdate:function(e){o(e,'forceUpdate')},enqueueReplaceState:function(e){o(e,'replaceState')},enqueueSetState:function(e){o(e,'setState')}}},function(e){'use strict';e.exports=!1},function(e){'use strict';var t='function'==typeof Symbol&&Symbol['for']&&Symbol['for']('react.element')||60103;e.exports=t},function(e,t,n){'use strict';function o(e,t,n){var o=t.dispatchConfig.phasedRegistrationNames[n];return h(e,o)}function a(e,t,n){var a=o(e,n,t);a&&(n._dispatchListeners=u(n._dispatchListeners,a),n._dispatchInstances=u(n._dispatchInstances,e))}function r(e){e&&e.dispatchConfig.phasedRegistrationNames&&l.traverseTwoPhase(e._targetInst,a,e)}function i(e){if(e&&e.dispatchConfig.phasedRegistrationNames){var t=e._targetInst,n=t?l.getParentInstance(t):null;l.traverseTwoPhase(n,a,e)}}function d(e,t,n){if(n&&n.dispatchConfig.registrationName){var o=n.dispatchConfig.registrationName,a=h(e,o);a&&(n._dispatchListeners=u(n._dispatchListeners,a),n._dispatchInstances=u(n._dispatchInstances,e))}}function s(e){e&&e.dispatchConfig.registrationName&&d(e._targetInst,null,e)}var p=n(25),l=n(49),u=n(67),c=n(68),m=n(1),h=p.getListener;e.exports={accumulateTwoPhaseDispatches:function(e){c(e,r)},accumulateTwoPhaseDispatchesSkipTarget:function(e){c(e,i)},accumulateDirectDispatches:function(e){c(e,s)},accumulateEnterLeaveDispatches:function(e,t,n,o){l.traverseEnterLeave(n,o,d,e,t)}}},function(e,t,n){'use strict';function o(e){return'button'===e||'input'===e||'select'===e||'textarea'===e}function a(e,t,n){return('onClick'===e||'onClickCapture'===e||'onDoubleClick'===e||'onDoubleClickCapture'===e||'onMouseDown'===e||'onMouseDownCapture'===e||'onMouseMove'===e||'onMouseMoveCapture'===e||'onMouseUp'===e||'onMouseUpCapture'===e)&&!!(n.disabled&&o(t))}var r=n(2),d=n(48),i=n(49),s=n(50),p=n(67),l=n(68),u=n(0),c={},m=null,h=function(e,t){e&&(i.executeDispatchesInOrder(e,t),!e.isPersistent()&&e.constructor.release(e))},g=function(t){return h(t,!0)},f=function(t){return h(t,!1)},y=function(e){return'.'+e._rootNodeID},_={injection:{injectEventPluginOrder:d.injectEventPluginOrder,injectEventPluginsByName:d.injectEventPluginsByName},putListener:function(e,t,n){'function'==typeof n?void 0:r('94',t,typeof n);var o=y(e),a=c[t]||(c[t]={});a[o]=n;var i=d.registrationNameModules[t];i&&i.didPutListener&&i.didPutListener(e,t,n)},getListener:function(e,t){var n=c[t];if(a(t,e._currentElement.type,e._currentElement.props))return null;var o=y(e);return n&&n[o]},deleteListener:function(e,t){var n=d.registrationNameModules[t];n&&n.willDeleteListener&&n.willDeleteListener(e,t);var o=c[t];if(o){var a=y(e);delete o[a]}},deleteAllListeners:function(e){var t=y(e);for(var n in c)if(c.hasOwnProperty(n)&&c[n][t]){var o=d.registrationNameModules[n];o&&o.willDeleteListener&&o.willDeleteListener(e,n),delete c[n][t]}},extractEvents:function(e,t,n,o){for(var a=d.plugins,r=0,i,s;r<a.length;r++)if(s=a[r],s){var l=s.extractEvents(e,t,n,o);l&&(i=p(i,l))}return i},enqueueEvents:function(e){e&&(m=p(m,e))},processEventQueue:function(e){var t=m;m=null,e?l(t,g):l(t,f),!m?void 0:r('95'),s.rethrowCaughtError()},__purge:function(){c={}},__getListenerBank:function(){return c}};e.exports=_},function(e,t,n){'use strict';function o(e,t,n,o){return a.call(this,e,t,n,o)}var a=n(12),r=n(51);a.augmentClass(o,{view:function(e){if(e.view)return e.view;var t=r(e);if(t.window===t)return t;var n=t.ownerDocument;return n?n.defaultView||n.parentWindow:window},detail:function(e){return e.detail||0}}),e.exports=o},function(e){'use strict';e.exports={remove:function(e){e._reactInternalInstance=void 0},get:function(e){return e._reactInternalInstance},has:function(e){return e._reactInternalInstance!==void 0},set:function(e,t){e._reactInternalInstance=t}}},function(e,t,n){'use strict';var o=n(42);e.exports=function(e){return o(e,!1)}},function(e,t,n){'use strict';var o=n(2),a=n(0),r={};e.exports={reinitializeTransaction:function(){this.transactionWrappers=this.getTransactionWrappers(),this.wrapperInitData?this.wrapperInitData.length=0:this.wrapperInitData=[],this._isInTransaction=!1},_isInTransaction:!1,getTransactionWrappers:null,isInTransaction:function(){return!!this._isInTransaction},perform:function(t,n,r,a,i,s,d,e){!this.isInTransaction()?void 0:o('27');var p,l;try{this._isInTransaction=!0,p=!0,this.initializeAll(0),l=t.call(n,r,a,i,s,d,e),p=!1}finally{try{if(p)try{this.closeAll(0)}catch(e){}else this.closeAll(0)}finally{this._isInTransaction=!1}}return l},initializeAll:function(e){for(var t=this.transactionWrappers,n=e,o;n<t.length;n++){o=t[n];try{this.wrapperInitData[n]=r,this.wrapperInitData[n]=o.initialize?o.initialize.call(this):null}finally{if(this.wrapperInitData[n]===r)try{this.initializeAll(n+1)}catch(e){}}}},closeAll:function(e){this.isInTransaction()?void 0:o('28');for(var t=this.transactionWrappers,n=e;n<t.length;n++){var a=t[n],i=this.wrapperInitData[n],d;try{d=!0,i!==r&&a.close&&a.close.call(this,i),d=!1}finally{if(d)try{this.closeAll(n+1)}catch(t){}}}this.wrapperInitData.length=0}}},function(e,t,n){'use strict';function o(e,t,n,o){return a.call(this,e,t,n,o)}var a=n(26),r=n(74),i=n(53);a.augmentClass(o,{screenX:null,screenY:null,clientX:null,clientY:null,ctrlKey:null,shiftKey:null,altKey:null,metaKey:null,getModifierState:i,button:function(e){var t=e.button;return'which'in e?t:2===t?2:4===t?1:0},buttons:null,relatedTarget:function(e){return e.relatedTarget||(e.fromElement===e.srcElement?e.toElement:e.fromElement)},pageX:function(e){return'pageX'in e?e.pageX:e.clientX+r.currentScrollLeft},pageY:function(e){return'pageY'in e?e.pageY:e.clientY+r.currentScrollTop}}),e.exports=o},function(e,t,n){'use strict';var o=n(6),a=n(55),r=/^[ \r\n\t\f]/,i=/<(!--|link|noscript|meta|script|style)[ \r\n\t\f\/>]/,d=n(56),s=d(function(e,t){if(e.namespaceURI===a.svg&&!('innerHTML'in e)){p=p||document.createElement('div'),p.innerHTML='<svg>'+t+'</svg>';for(var n=p.firstChild;n.firstChild;)e.appendChild(n.firstChild)}else e.innerHTML=t}),p;if(o.canUseDOM){var l=document.createElement('div');l.innerHTML=' ',''===l.innerHTML&&(s=function(e,t){if(e.parentNode&&e.parentNode.replaceChild(e,e),r.test(t)||'<'===t[0]&&i.test(t)){e.innerHTML='\uFEFF'+t;var n=e.firstChild;1===n.data.length?e.removeChild(n):n.deleteData(0,1)}else e.innerHTML=t}),l=null}e.exports=s},function(e){'use strict';function t(e){var t=''+e,o=n.exec(t);if(!o)return t;var a='',r=0,i=0,d;for(r=o.index;r<t.length;r++){switch(t.charCodeAt(r)){case 34:d='&quot;';break;case 38:d='&amp;';break;case 39:d='&#x27;';break;case 60:d='&lt;';break;case 62:d='&gt;';break;default:continue;}i!==r&&(a+=t.substring(i,r)),i=r+1,a+=d}return i===r?a:a+t.substring(i,r)}var n=/["'&<>]/;e.exports=function(e){return'boolean'==typeof e||'number'==typeof e?''+e:t(e)}},function(e,t,n){'use strict';function o(e){return Object.prototype.hasOwnProperty.call(e,h)||(e[h]=c++,l[e[h]]={}),l[e[h]]}var a=n(3),r=n(48),i=n(133),d=n(74),s=n(134),p=n(52),l={},u=!1,c=0,m={topAbort:'abort',topAnimationEnd:s('animationend')||'animationen