Bug 1308656 - Add shield-recipe-client as system add-on draft
authorMythmon <mcooper@mozilla.com>
Mon, 10 Oct 2016 16:14:56 -0700
changeset 452436 b986938475a0287edd4fb4e5350c605e0a66f6cb
parent 450435 5a536a16e33798fe7b16de35c968d5bc0cbf8448
child 540229 7e5162b9654e940cf92b3aa5eb91d00a4c253688
push id39406
push userbmo:mcooper@mozilla.com
push dateWed, 21 Dec 2016 19:20:27 +0000
bugs1308656
milestone53.0a1
Bug 1308656 - Add shield-recipe-client as system add-on MozReview-Commit-ID: KNTGKOFXDlH
browser/extensions/moz.build
browser/extensions/shield-recipe-client/bootstrap.js
browser/extensions/shield-recipe-client/data/EventEmitter.js
browser/extensions/shield-recipe-client/install.rdf.in
browser/extensions/shield-recipe-client/jar.mn
browser/extensions/shield-recipe-client/lib/CleanupManager.jsm
browser/extensions/shield-recipe-client/lib/EnvExpressions.jsm
browser/extensions/shield-recipe-client/lib/Heartbeat.jsm
browser/extensions/shield-recipe-client/lib/NormandyApi.jsm
browser/extensions/shield-recipe-client/lib/NormandyDriver.jsm
browser/extensions/shield-recipe-client/lib/RecipeRunner.jsm
browser/extensions/shield-recipe-client/lib/Sampling.jsm
browser/extensions/shield-recipe-client/lib/SandboxManager.jsm
browser/extensions/shield-recipe-client/lib/Storage.jsm
browser/extensions/shield-recipe-client/moz.build
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/test/.eslintrc.js
browser/extensions/shield-recipe-client/test/TestUtils.jsm
browser/extensions/shield-recipe-client/test/browser.ini
browser/extensions/shield-recipe-client/test/browser_EventEmitter.js
browser/extensions/shield-recipe-client/test/browser_Heartbeat.js
browser/extensions/shield-recipe-client/test/browser_Storage.js
browser/extensions/shield-recipe-client/test/browser_driver_uuids.js
browser/extensions/shield-recipe-client/test/browser_env_expressions.js
devtools/client/debugger/test/mochitest/head.js
layout/tools/reftest/reftest-preferences.js
testing/profiles/prefs_general.js
testing/talos/talos/config.py
testing/talos/talos/xtalos/xperf_whitelist.json
testing/xpcshell/head.js
--- a/browser/extensions/moz.build
+++ b/browser/extensions/moz.build
@@ -5,16 +5,17 @@
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 DIRS += [
     'aushelper',
     'e10srollout',
     'pdfjs',
     'pocket',
     'webcompat',
+    'shield-recipe-client',
 ]
 
 # Only include the following system add-ons if building Aurora or Nightly
 if 'a' in CONFIG['GRE_MILESTONE']:
     DIRS += [
         'flyweb',
         'formautofill',
         'presentation',
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/bootstrap.js
@@ -0,0 +1,102 @@
+/* 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/Preferences.jsm");
+
+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 PREFS = {
+  api_url: "https://self-repair.mozilla.org/api/v1",
+  dev_mode: false,
+  enabled: true,
+  startup_delay_seconds: 300,
+};
+const PREF_DEV_MODE = "extensions.shield-recipe-client.dev_mode";
+const PREF_SELF_SUPPORT_ENABLED = "browser.selfsupport.enabled";
+
+let shouldRun = true;
+
+this.install = function() {
+  // Self Repair only checks its pref on start, so if we disable it, wait until
+  // next startup to run, unless the dev_mode preference is set.
+  if (Preferences.get(PREF_SELF_SUPPORT_ENABLED, true)) {
+    Preferences.set(PREF_SELF_SUPPORT_ENABLED, false);
+    if (!Services.prefs.getBoolPref(PREF_DEV_MODE, false)) {
+      shouldRun = false;
+    }
+  }
+};
+
+this.startup = function() {
+  setDefaultPrefs();
+
+  if (!shouldRun) {
+    return;
+  }
+
+  Cu.import("resource://shield-recipe-client/lib/RecipeRunner.jsm");
+  RecipeRunner.init();
+};
+
+this.shutdown = function(data, reason) {
+  Cu.import("resource://shield-recipe-client/lib/CleanupManager.jsm");
+
+  CleanupManager.cleanup();
+
+  if (reason === REASONS.ADDON_DISABLE || reason === REASONS.ADDON_UNINSTALL) {
+    Services.prefs.setBoolPref(PREF_SELF_SUPPORT_ENABLED, true);
+  }
+
+  const modules = [
+    "data/EventEmitter.js",
+    "lib/CleanupManager.jsm",
+    "lib/EnvExpressions.jsm",
+    "lib/Heartbeat.jsm",
+    "lib/NormandyApi.jsm",
+    "lib/NormandyDriver.jsm",
+    "lib/RecipeRunner.jsm",
+    "lib/Sampling.jsm",
+    "lib/SandboxManager.jsm",
+    "lib/Storage.jsm",
+  ];
+  for (const module in modules) {
+    Cu.unload(`resource://shield-recipe-client/${module}`);
+  }
+};
+
+this.uninstall = function() {
+};
+
+function setDefaultPrefs() {
+  const branch = Services.prefs.getDefaultBranch(PREF_BRANCH);
+  for (const [key, val] of Object.entries(PREFS)) {
+    // If someone beat us to setting a default, don't overwrite it.
+    if (branch.getPrefType(key) !== branch.PREF_INVALID)
+      continue;
+    switch (typeof val) {
+      case "boolean":
+        branch.setBoolPref(key, val);
+        break;
+      case "number":
+        branch.setIntPref(key, val);
+        break;
+      case "string":
+        branch.setCharPref(key, val);
+        break;
+    }
+  }
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/data/EventEmitter.js
@@ -0,0 +1,60 @@
+/* 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/. */
+
+// This file is meant to run inside action sandboxes
+
+"use strict";
+
+
+this.EventEmitter = function(driver) {
+  if (!driver) {
+    throw new Error("driver must be provided");
+  }
+
+  const listeners = {};
+
+  return {
+    emit(eventName, event) {
+      // Fire events async
+      Promise.resolve()
+        .then(() => {
+          if (!(eventName in listeners)) {
+            driver.log(`EventEmitter: Event fired with no listeners: ${eventName}`);
+            return;
+          }
+          // freeze event to prevent handlers from modifying it
+          const frozenEvent = Object.freeze(event);
+          // Clone callbacks array to avoid problems with mutation while iterating
+          const callbacks = Array.from(listeners[eventName]);
+          for (const cb of callbacks) {
+            cb(frozenEvent);
+          }
+        });
+    },
+
+    on(eventName, callback) {
+      if (!(eventName in listeners)) {
+        listeners[eventName] = [];
+      }
+      listeners[eventName].push(callback);
+    },
+
+    off(eventName, callback) {
+      if (eventName in listeners) {
+        const index = listeners[eventName].indexOf(callback);
+        if (index !== -1) {
+          listeners[eventName].splice(index, 1);
+        }
+      }
+    },
+
+    once(eventName, callback) {
+      const inner = event => {
+        callback(event);
+        this.off(eventName, inner);
+      };
+      this.on(eventName, inner);
+    },
+  };
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/install.rdf.in
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+#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>1.0.0</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>
+        <em:maxVersion>@MOZ_APP_MAXVERSION@</em:maxVersion>
+      </Description>
+    </em:targetApplication>
+  </Description>
+</RDF>
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/jar.mn
@@ -0,0 +1,9 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+[features/shield-recipe-client@mozilla.org] chrome.jar:
+% resource shield-recipe-client %content/
+  content/lib/ (./lib/*)
+  content/data/ (./data/*)
+  content/node_modules/jexl/ (./node_modules/jexl/*)
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/CleanupManager.jsm
@@ -0,0 +1,21 @@
+/* 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";
+
+this.EXPORTED_SYMBOLS = ["CleanupManager"];
+
+const cleanupHandlers = [];
+
+this.CleanupManager = {
+  addCleanupHandler(handler) {
+    cleanupHandlers.push(handler);
+  },
+
+  cleanup() {
+    for (const handler of cleanupHandlers) {
+      handler();
+    }
+  },
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/EnvExpressions.jsm
@@ -0,0 +1,65 @@
+/* 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");
+Cu.import("resource://gre/modules/TelemetryArchive.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://shield-recipe-client/lib/Sampling.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+
+this.EXPORTED_SYMBOLS = ["EnvExpressions"];
+
+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();
+  jexl.addTransforms({
+    date: dateString => new Date(dateString),
+    stableSample: Sampling.stableSample,
+  });
+  return jexl;
+});
+
+const getLatestTelemetry = Task.async(function *() {
+  const pings = yield TelemetryArchive.promiseArchivedPingList();
+
+  // get most recent ping per type
+  const mostRecentPings = {};
+  for (const ping of pings) {
+    if (ping.type in mostRecentPings) {
+      if (mostRecentPings[ping.type].timeStampCreated < ping.timeStampCreated) {
+        mostRecentPings[ping.type] = ping;
+      }
+    } else {
+      mostRecentPings[ping.type] = ping;
+    }
+  }
+
+  const telemetry = {};
+  for (const key in mostRecentPings) {
+    const ping = mostRecentPings[key];
+    telemetry[ping.type] = yield TelemetryArchive.promiseArchivedPingById(ping.id);
+  }
+  return telemetry;
+});
+
+this.EnvExpressions = {
+  eval(expr, extraContext = {}) {
+    const context = Object.assign({telemetry: getLatestTelemetry()}, extraContext);
+    const onelineExpr = expr.replace(/[\t\n\r]/g, " ");
+    return jexl.eval(onelineExpr, context);
+  },
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/Heartbeat.jsm
@@ -0,0 +1,346 @@
+/* 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/Preferences.jsm");
+Cu.import("resource://gre/modules/TelemetryController.jsm");
+Cu.import("resource://gre/modules/Timer.jsm"); /* globals setTimeout, clearTimeout */
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://shield-recipe-client/lib/CleanupManager.jsm");
+
+Cu.importGlobalProperties(["URL"]); /* globals URL */
+
+this.EXPORTED_SYMBOLS = ["Heartbeat"];
+
+const log = Log.repository.getLogger("shield-recipe-client");
+const PREF_SURVEY_DURATION = "browser.uitour.surveyDuration";
+const NOTIFICATION_TIME = 3000;
+
+/**
+ * Show the Heartbeat UI to request user feedback.
+ *
+ * @param chromeWindow
+ *        The chrome window that the heartbeat notification is displayed in.
+ * @param eventEmitter
+ *        An EventEmitter instance to report status to.
+ * @param sandboxManager
+ *        The manager for the sandbox this was called from. Heartbeat will
+ *        increment the hold counter on the manager.
+ * @param {Object} options Options object.
+ * @param {String} options.message
+ *        The message, or question, to display on the notification.
+ * @param {String} options.thanksMessage
+ *        The thank you message to display after user votes.
+ * @param {String} options.flowId
+ *        An identifier for this rating flow. Please note that this is only used to
+ *        identify the notification box.
+ * @param {String} [options.engagementButtonLabel=null]
+ *        The text of the engagement button to use instad of stars. If this is null
+ *        or invalid, rating stars are used.
+ * @param {String} [options.learnMoreMessage=null]
+ *        The label of the learn more link. No link will be shown if this is null.
+ * @param {String} [options.learnMoreUrl=null]
+ *        The learn more URL to open when clicking on the learn more link. No learn more
+ *        will be shown if this is an invalid URL.
+ * @param {String} [options.surveyId]
+ *        An ID for the survey, reflected in the Telemetry ping.
+ * @param {Number} [options.surveyVersion]
+ *        Survey's version number, reflected in the Telemetry ping.
+ * @param {boolean} [options.testing]
+ *        Whether this is a test survey, reflected in the Telemetry ping.
+ * @param {String} [options.postAnswerURL=null]
+ *        The url to visit after the user answers the question.
+ */
+this.Heartbeat = class {
+  constructor(chromeWindow, eventEmitter, sandboxManager, options) {
+    if (typeof options.flowId !== "string") {
+      throw new Error("flowId must be a string");
+    }
+
+    if (!options.flowId) {
+      throw new Error("flowId must not be an empty string");
+    }
+
+    if (typeof options.message !== "string") {
+      throw new Error("message must be a string");
+    }
+
+    if (!options.message) {
+      throw new Error("message must not be an empty string");
+    }
+
+    if (!sandboxManager) {
+      throw new Error("sandboxManager must be provided");
+    }
+
+    if (options.postAnswerUrl) {
+      options.postAnswerUrl = new URL(options.postAnswerUrl);
+    } else {
+      options.postAnswerUrl = null;
+    }
+
+    if (options.learnMoreUrl) {
+      try {
+        options.learnMoreUrl = new URL(options.learnMoreUrl);
+      } catch (e) {
+        options.learnMoreUrl = null;
+      }
+    }
+
+    this.chromeWindow = chromeWindow;
+    this.eventEmitter = eventEmitter;
+    this.sandboxManager = sandboxManager;
+    this.options = options;
+    this.surveyResults = {};
+    this.buttons = null;
+
+    // so event handlers are consistent
+    this.handleWindowClosed = this.handleWindowClosed.bind(this);
+
+    if (this.options.engagementButtonLabel) {
+      this.buttons = [{
+        label: this.options.engagementButtonLabel,
+        callback: () => {
+          // Let the consumer know user engaged.
+          this.maybeNotifyHeartbeat("Engaged");
+
+          this.userEngaged({
+            type: "button",
+            flowId: this.options.flowId,
+          });
+
+          // Return true so that the notification bar doesn't close itself since
+          // we have a thank you message to show.
+          return true;
+        },
+      }];
+    }
+
+    this.notificationBox = this.chromeWindow.document.querySelector("#high-priority-global-notificationbox");
+    this.notice = this.notificationBox.appendNotification(
+      this.options.message,
+      "heartbeat-" + this.options.flowId,
+      "chrome://browser/skin/heartbeat-icon.svg",
+      this.notificationBox.PRIORITY_INFO_HIGH,
+      this.buttons,
+      eventType => {
+        if (eventType !== "removed") {
+          return;
+        }
+        this.maybeNotifyHeartbeat("NotificationClosed");
+      }
+    );
+
+    // Holds the rating UI
+    const frag = this.chromeWindow.document.createDocumentFragment();
+
+    // Build the heartbeat stars
+    if (!this.options.engagementButtonLabel) {
+      const numStars = this.options.engagementButtonLabel ? 0 : 5;
+      const ratingContainer = this.chromeWindow.document.createElement("hbox");
+      ratingContainer.id = "star-rating-container";
+
+      for (let i = 0; i < numStars; i++) {
+        // create a star rating element
+        const ratingElement = this.chromeWindow.document.createElement("toolbarbutton");
+
+        // style it
+        const starIndex = numStars - i;
+        ratingElement.className = "plain star-x";
+        ratingElement.id = "star" + starIndex;
+        ratingElement.setAttribute("data-score", starIndex);
+
+        // Add the click handler
+        ratingElement.addEventListener("click", ev => {
+          const rating = parseInt(ev.target.getAttribute("data-score"));
+          this.maybeNotifyHeartbeat("Voted", {score: rating});
+          this.userEngaged({type: "stars", score: rating, flowId: this.options.flowId});
+        });
+
+        ratingContainer.appendChild(ratingElement);
+      }
+
+      frag.appendChild(ratingContainer);
+    }
+
+    this.messageImage = this.chromeWindow.document.getAnonymousElementByAttribute(this.notice, "anonid", "messageImage");
+    this.messageImage.classList.add("heartbeat", "pulse-onshow");
+
+    this.messageText = this.chromeWindow.document.getAnonymousElementByAttribute(this.notice, "anonid", "messageText");
+    this.messageText.classList.add("heartbeat");
+
+    // Make sure the stars are not pushed to the right by the spacer.
+    const rightSpacer = this.chromeWindow.document.createElement("spacer");
+    rightSpacer.flex = 20;
+    frag.appendChild(rightSpacer);
+
+    // collapse the space before the stars
+    this.messageText.flex = 0;
+    const leftSpacer = this.messageText.nextSibling;
+    leftSpacer.flex = 0;
+
+    // Add Learn More Link
+    if (this.options.learnMoreMessage && this.options.learnMoreUrl) {
+      const learnMore = this.chromeWindow.document.createElement("label");
+      learnMore.className = "text-link";
+      learnMore.href = this.options.learnMoreUrl.toString();
+      learnMore.setAttribute("value", this.options.learnMoreMessage);
+      learnMore.addEventListener("click", () => this.maybeNotifyHeartbeat("LearnMore"));
+      frag.appendChild(learnMore);
+    }
+
+    // Append the fragment and apply the styling
+    this.notice.appendChild(frag);
+    this.notice.classList.add("heartbeat");
+
+    // Let the consumer know the notification was shown.
+    this.maybeNotifyHeartbeat("NotificationOffered");
+    this.chromeWindow.addEventListener("SSWindowClosing", this.handleWindowClosed);
+
+    const surveyDuration = Preferences.get(PREF_SURVEY_DURATION, 300) * 1000;
+    this.surveyEndTimer = setTimeout(() => {
+      this.maybeNotifyHeartbeat("SurveyExpired");
+      this.close();
+    }, surveyDuration);
+
+    this.sandboxManager.addHold("heartbeat");
+    CleanupManager.addCleanupHandler(() => this.close());
+  }
+
+  maybeNotifyHeartbeat(name, data = {}) {
+    if (this.pingSent) {
+      log.warn("Heartbeat event recieved after Telemetry ping sent. name:", name, "data:", data);
+      return;
+    }
+
+    const timestamp = Date.now();
+    let sendPing = false;
+    let cleanup = false;
+
+    const phases = {
+      NotificationOffered: () => {
+        this.surveyResults.flowId = this.options.flowId;
+        this.surveyResults.offeredTS = timestamp;
+      },
+      LearnMore: () => {
+        if (!this.surveyResults.learnMoreTS) {
+          this.surveyResults.learnMoreTS = timestamp;
+        }
+      },
+      Engaged: () => {
+        this.surveyResults.engagedTS = timestamp;
+      },
+      Voted: () => {
+        this.surveyResults.votedTS = timestamp;
+        this.surveyResults.score = data.score;
+      },
+      SurveyExpired: () => {
+        this.surveyResults.expiredTS = timestamp;
+      },
+      NotificationClosed: () => {
+        this.surveyResults.closedTS = timestamp;
+        cleanup = true;
+        sendPing = true;
+      },
+      WindowClosed: () => {
+        this.surveyResults.windowClosedTS = timestamp;
+        cleanup = true;
+        sendPing = true;
+      },
+      default: () => {
+        log.error("Unrecognized Heartbeat event:", name);
+      },
+    };
+
+    (phases[name] || phases.default)();
+
+    data.timestamp = timestamp;
+    data.flowId = this.options.flowId;
+    this.eventEmitter.emit(name, Cu.cloneInto(data, this.sandboxManager.sandbox));
+
+    if (sendPing) {
+      // Send the ping to Telemetry
+      const payload = Object.assign({version: 1}, this.surveyResults);
+      for (const meta of ["surveyId", "surveyVersion", "testing"]) {
+        if (this.options.hasOwnProperty(meta)) {
+          payload[meta] = this.options[meta];
+        }
+      }
+
+      log.debug("Sending telemetry");
+      TelemetryController.submitExternalPing("heartbeat", payload, {
+        addClientId: true,
+        addEnvironment: true,
+      });
+
+      // only for testing
+      this.eventEmitter.emit("TelemetrySent", Cu.cloneInto(payload, this.sandboxManager.sandbox));
+
+      // Survey is complete, clear out the expiry timer & survey configuration
+      if (this.surveyEndTimer) {
+        clearTimeout(this.surveyEndTimer);
+        this.surveyEndTimer = null;
+      }
+
+      this.pingSent = true;
+      this.surveyResults = null;
+    }
+
+    if (cleanup) {
+      this.cleanup();
+    }
+  }
+
+  userEngaged(engagementParams) {
+    // Make the heartbeat icon pulse twice
+    this.notice.label = this.options.thanksMessage;
+    this.messageImage.classList.remove("pulse-onshow");
+    this.messageImage.classList.add("pulse-twice");
+
+    // Remove all the children of the notice (rating container, and the flex)
+    while (this.notice.firstChild) {
+      this.notice.firstChild.remove();
+    }
+
+    // Open the engagement tab if we have a valid engagement URL.
+    if (this.options.postAnswerUrl) {
+      for (const key in engagementParams) {
+        this.options.postAnswerUrl.searchParams.append(key, engagementParams[key]);
+      }
+      // Open the engagement URL in a new tab.
+      this.chromeWindow.gBrowser.selectedTab = this.chromeWindow.gBrowser.addTab(this.options.postAnswerUrl.toString());
+    }
+
+    if (this.surveyEndTimer) {
+      clearTimeout(this.surveyEndTimer);
+      this.surveyEndTimer = null;
+    }
+
+    setTimeout(() => this.close(), NOTIFICATION_TIME);
+  }
+
+  handleWindowClosed() {
+    this.maybeNotifyHeartbeat("WindowClosed");
+  }
+
+  close() {
+    this.notificationBox.removeNotification(this.notice);
+    this.cleanup();
+  }
+
+  cleanup() {
+    this.sandboxManager.removeHold("heartbeat");
+    // remove listeners
+    this.chromeWindow.removeEventListener("SSWindowClosing", this.handleWindowClosed);
+    // remove references for garbage collection
+    this.chromeWindow = null;
+    this.notificationBox = null;
+    this.notification = null;
+    this.eventEmitter = null;
+    this.sandboxManager = null;
+  }
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/NormandyApi.jsm
@@ -0,0 +1,99 @@
+/* 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/. */
+/* globals URLSearchParams */
+
+"use strict";
+
+const {utils: Cu, classes: Cc, interfaces: Ci} = Components;
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/CanonicalJSON.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+
+this.EXPORTED_SYMBOLS = ["NormandyApi"];
+
+const log = Log.repository.getLogger("extensions.shield-recipe-client");
+const prefs = Services.prefs.getBranch("extensions.shield-recipe-client.");
+
+this.NormandyApi = {
+  apiCall(method, endpoint, data = {}) {
+    const api_url = prefs.getCharPref("api_url");
+    let url = `${api_url}/${endpoint}`;
+    method = method.toLowerCase();
+
+    if (method === "get") {
+      if (data === {}) {
+        const paramObj = new URLSearchParams();
+        for (const key in data) {
+          paramObj.append(key, data[key]);
+        }
+        url += "?" + paramObj.toString();
+      }
+      data = undefined;
+    }
+
+    const headers = {"Accept": "application/json"};
+    return fetch(url, {
+      body: JSON.stringify(data),
+      headers,
+    });
+  },
+
+  get(endpoint, data) {
+    return this.apiCall("get", endpoint, data);
+  },
+
+  post(endpoint, data) {
+    return this.apiCall("post", endpoint, data);
+  },
+
+  fetchRecipes: Task.async(function* (filters = {}) {
+    const recipeResponse = yield this.get("recipe/signed/", filters);
+    const rawText = yield recipeResponse.text();
+    const recipesWithSigs = JSON.parse(rawText);
+
+    const verifiedRecipes = [];
+
+    for (const {recipe, signature: {signature, x5u}} of recipesWithSigs) {
+      const serialized = CanonicalJSON.stringify(recipe);
+      if (!rawText.includes(serialized)) {
+        log.debug(rawText, serialized);
+        throw new Error("Canonical recipe serialization does not match!");
+      }
+
+      const certChainResponse = yield fetch(x5u);
+      const certChain = yield certChainResponse.text();
+      const builtSignature = `p384ecdsa=${signature}`;
+
+      const verifier = Cc["@mozilla.org/security/contentsignatureverifier;1"]
+        .createInstance(Ci.nsIContentSignatureVerifier);
+
+      if (!verifier.verifyContentSignature(serialized, builtSignature, certChain, "normandy.content-signature.mozilla.org")) {
+        throw new Error("Recipe signature is not valid");
+      }
+      verifiedRecipes.push(recipe);
+    }
+
+    log.debug(`Fetched ${verifiedRecipes.length} recipes from the server:`, verifiedRecipes.map(r => r.name).join(", "));
+
+    return verifiedRecipes;
+  }),
+
+  /**
+   * Fetch metadata about this client determined by the server.
+   * @return {object} Metadata specified by the server
+   */
+  classifyClient() {
+    return this.get("classify_client/")
+      .then(response => response.json())
+      .then(clientData => {
+        clientData.request_time = new Date(clientData.request_time);
+        return clientData;
+      });
+  },
+
+  fetchAction(name) {
+    return this.get(`action/${name}/`).then(req => req.json());
+  },
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/NormandyDriver.jsm
@@ -0,0 +1,141 @@
+/* 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";
+/* globals Components */
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+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"); /* globals setTimeout, clearTimeout */
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://shield-recipe-client/lib/Storage.jsm");
+Cu.import("resource://shield-recipe-client/lib/Heartbeat.jsm");
+
+const {generateUUID} = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
+
+this.EXPORTED_SYMBOLS = ["NormandyDriver"];
+
+const log = Log.repository.getLogger("extensions.shield-recipe-client");
+const actionLog = Log.repository.getLogger("extensions.shield-recipe-client.actions");
+
+this.NormandyDriver = function(sandboxManager, extraContext = {}) {
+  if (!sandboxManager) {
+    throw new Error("sandboxManager is required");
+  }
+  const {sandbox} = sandboxManager;
+
+  return {
+    testing: false,
+
+    get locale() {
+      return Cc["@mozilla.org/chrome/chrome-registry;1"]
+        .getService(Ci.nsIXULChromeRegistry)
+        .getSelectedLocale("browser");
+    },
+
+    log(message, level = "debug") {
+      const levels = ["debug", "info", "warn", "error"];
+      if (levels.indexOf(level) === -1) {
+        throw new Error(`Invalid log level "${level}"`);
+      }
+      actionLog[level](message);
+    },
+
+    showHeartbeat(options) {
+      log.info(`Showing heartbeat prompt "${options.message}"`);
+      const aWindow = Services.wm.getMostRecentWindow("navigator:browser");
+
+      if (!aWindow) {
+        return sandbox.Promise.reject(new sandbox.Error("No window to show heartbeat in"));
+      }
+
+      const sandboxedDriver = Cu.cloneInto(this, sandbox, {cloneFunctions: true});
+      const ee = new sandbox.EventEmitter(sandboxedDriver).wrappedJSObject;
+      const internalOptions = Object.assign({}, options, {testing: this.testing});
+      new Heartbeat(aWindow, ee, sandboxManager, internalOptions);
+      return sandbox.Promise.resolve(ee);
+    },
+
+    saveHeartbeatFlow() {
+      // no-op required by spec
+    },
+
+    client() {
+      const appinfo = {
+        version: Services.appinfo.version,
+        channel: Services.appinfo.defaultUpdateChannel,
+        isDefaultBrowser: ShellService.isDefaultBrowser() || null,
+        searchEngine: null,
+        syncSetup: Preferences.isSet("services.sync.username"),
+        plugins: {},
+        doNotTrack: Preferences.get("privacy.donottrackheader.enabled", false),
+      };
+
+      const searchEnginePromise = new Promise(resolve => {
+        Services.search.init(rv => {
+          if (Components.isSuccessCode(rv)) {
+            appinfo.searchEngine = Services.search.defaultEngine.identifier;
+          }
+          resolve();
+        });
+      });
+
+      const pluginsPromise = new Promise(resolve => {
+        AddonManager.getAddonsByTypes(["plugin"], plugins => {
+          plugins.forEach(plugin => appinfo.plugins[plugin.name] = plugin);
+          resolve();
+        });
+      });
+
+      return new sandbox.Promise(resolve => {
+        Promise.all([searchEnginePromise, pluginsPromise]).then(() => {
+          resolve(Cu.cloneInto(appinfo, sandbox));
+        });
+      });
+    },
+
+    uuid() {
+      let ret = generateUUID().toString();
+      ret = ret.slice(1, ret.length - 1);
+      return ret;
+    },
+
+    createStorage(keyPrefix) {
+      let storage;
+      try {
+        storage = Storage.makeStorage(keyPrefix, sandbox);
+      } catch (e) {
+        log.error(e.stack);
+        throw e;
+      }
+      return storage;
+    },
+
+    location() {
+      const location = Cu.cloneInto({countryCode: extraContext.country}, sandbox);
+      return sandbox.Promise.resolve(location);
+    },
+
+    setTimeout(cb, time) {
+      if (typeof cb !== "function") {
+        throw new sandbox.Error(`setTimeout must be called with a function, got "${typeof cb}"`);
+      }
+      const token = setTimeout(() => {
+        cb();
+        sandboxManager.removeHold(`setTimeout-${token}`);
+      }, time);
+      sandboxManager.addHold(`setTimeout-${token}`);
+      return Cu.cloneInto(token, sandbox);
+    },
+
+    clearTimeout(token) {
+      clearTimeout(token);
+      sandboxManager.removeHold(`setTimeout-${token}`);
+    },
+  };
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/RecipeRunner.jsm
@@ -0,0 +1,162 @@
+/* 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/Timer.jsm"); /* globals setTimeout */
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm");
+Cu.import("resource://shield-recipe-client/lib/EnvExpressions.jsm");
+Cu.import("resource://shield-recipe-client/lib/NormandyApi.jsm");
+Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm");
+Cu.importGlobalProperties(["fetch"]); /* globals fetch */
+
+this.EXPORTED_SYMBOLS = ["RecipeRunner"];
+
+const log = Log.repository.getLogger("extensions.shield-recipe-client");
+const prefs = Services.prefs.getBranch("extensions.shield-recipe-client.");
+
+this.RecipeRunner = {
+  init() {
+    if (!this.checkPrefs()) {
+      return;
+    }
+
+    let delay;
+    if (prefs.getBoolPref("dev_mode")) {
+      delay = 0;
+    } else {
+      // startup delay is in seconds
+      delay = prefs.getIntPref("startup_delay_seconds") * 1000;
+    }
+
+    setTimeout(this.start.bind(this), delay);
+  },
+
+  checkPrefs() {
+    // Only run if Unified Telemetry is enabled.
+    if (!Services.prefs.getBoolPref("toolkit.telemetry.unified")) {
+      log.info("Disabling RecipeRunner because Unified Telemetry is disabled.");
+      return false;
+    }
+
+    if (!prefs.getBoolPref("enabled")) {
+      log.info("Recipe Client is disabled.");
+      return false;
+    }
+
+    const apiUrl = prefs.getCharPref("api_url");
+    if (!apiUrl || !apiUrl.startsWith("https://")) {
+      log.error(`Non HTTPS URL provided for extensions.shield-recipe-client.api_url: ${apiUrl}`);
+      return false;
+    }
+
+    return true;
+  },
+
+  start: Task.async(function* () {
+    let recipes;
+    try {
+      recipes = yield NormandyApi.fetchRecipes({enabled: true});
+    } catch (e) {
+      const apiUrl = prefs.getCharPref("api_url");
+      log.error(`Could not fetch recipes from ${apiUrl}: "${e}"`);
+      return;
+    }
+
+    let extraContext;
+    try {
+      extraContext = yield this.getExtraContext();
+    } catch (e) {
+      log.warning(`Couldn't get extra filter context: ${e}`);
+      extraContext = {};
+    }
+
+    const recipesToRun = [];
+
+    for (const recipe of recipes) {
+      if (yield this.checkFilter(recipe, extraContext)) {
+        recipesToRun.push(recipe);
+      }
+    }
+
+    if (recipesToRun.length === 0) {
+      log.debug("No recipes to execute");
+    } else {
+      for (const recipe of recipesToRun) {
+        try {
+          log.debug(`Executing recipe "${recipe.name}" (action=${recipe.action})`);
+          yield this.executeRecipe(recipe, extraContext);
+        } catch (e) {
+          log.error(`Could not execute recipe ${recipe.name}:`, e);
+        }
+      }
+    }
+  }),
+
+  getExtraContext() {
+    return NormandyApi.classifyClient()
+      .then(clientData => ({normandy: clientData}));
+  },
+
+  /**
+   * Evaluate a recipe's filter expression against the environment.
+   * @param {object} recipe
+   * @param {string} recipe.filter The expression to evaluate against the environment.
+   * @param {object} extraContext Any extra context to provide to the filter environment.
+   * @return {boolean} The result of evaluating the filter, cast to a bool.
+   */
+  checkFilter(recipe, extraContext) {
+    return EnvExpressions.eval(recipe.filter_expression, extraContext)
+      .then(result => {
+        return !!result;
+      })
+      .catch(error => {
+        log.error(`Error checking filter for "${recipe.name}"`);
+        log.error(`Filter: "${recipe.filter_expression}"`);
+        log.error(`Error: "${error}"`);
+      });
+  },
+
+  /**
+   * Execute a recipe by fetching it action and executing it.
+   * @param  {Object} recipe A recipe to execute
+   * @promise Resolves when the action has executed
+   */
+  executeRecipe: Task.async(function* (recipe, extraContext) {
+    const sandboxManager = new SandboxManager();
+    const {sandbox} = sandboxManager;
+
+    const action = yield NormandyApi.fetchAction(recipe.action);
+    const response = yield fetch(action.implementation_url);
+
+    const actionScript = yield response.text();
+    const prepScript = `
+      var pendingAction = null;
+
+      function registerAction(name, Action) {
+        let a = new Action(sandboxedDriver, sandboxedRecipe);
+        pendingAction = a.execute()
+          .catch(err => sandboxedDriver.log(err, 'error'));
+      };
+
+      window.registerAction = registerAction;
+      window.setTimeout = sandboxedDriver.setTimeout;
+      window.clearTimeout = sandboxedDriver.clearTimeout;
+    `;
+
+    const driver = new NormandyDriver(sandboxManager, extraContext);
+    sandbox.sandboxedDriver = Cu.cloneInto(driver, sandbox, {cloneFunctions: true});
+    sandbox.sandboxedRecipe = Cu.cloneInto(recipe, sandbox);
+
+    Cu.evalInSandbox(prepScript, sandbox);
+    Cu.evalInSandbox(actionScript, sandbox);
+
+    sandboxManager.addHold("recipeExecution");
+    sandbox.pendingAction.then(() => sandboxManager.removeHold("recipeExecution"));
+  }),
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/Sampling.jsm
@@ -0,0 +1,81 @@
+/* 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/Log.jsm");
+Cu.importGlobalProperties(["crypto", "TextEncoder"]);
+
+this.EXPORTED_SYMBOLS = ["Sampling"];
+
+const log = Log.repository.getLogger("extensions.shield-recipe-client");
+
+/**
+ * Map from the range [0, 1] to [0, max(sha256)].
+ * @param  {number} frac A float from 0.0 to 1.0.
+ * @return {string} A 48 bit number represented in hex, padded to 12 characters.
+ */
+function fractionToKey(frac) {
+  const hashBits = 48;
+  const hashLength = hashBits / 4;
+
+  if (frac < 0 || frac > 1) {
+    throw new Error(`frac must be between 0 and 1 inclusive (got ${frac})`);
+  }
+
+  const mult = Math.pow(2, hashBits) - 1;
+  const inDecimal = Math.floor(frac * mult);
+  let hexDigits = inDecimal.toString(16);
+  if (hexDigits.length < hashLength) {
+    // Left pad with zeroes
+    // If N zeroes are needed, generate an array of nulls N+1 elements long,
+    // and inserts zeroes between each null.
+    hexDigits = Array(hashLength - hexDigits.length + 1).join("0") + hexDigits;
+  }
+
+  // Saturate at 2**48 - 1
+  if (hexDigits.length > hashLength) {
+    hexDigits = Array(hashLength + 1).join("f");
+  }
+
+  return hexDigits;
+}
+
+function bufferToHex(buffer) {
+  const hexCodes = [];
+  const view = new DataView(buffer);
+  for (let i = 0; i < view.byteLength; i += 4) {
+    // Using getUint32 reduces the number of iterations needed (we process 4 bytes each time)
+    const value = view.getUint32(i);
+    // toString(16) will give the hex representation of the number without padding
+    hexCodes.push(value.toString(16).padStart(8, "0"));
+  }
+
+  // Join all the hex strings into one
+  return hexCodes.join("");
+}
+
+this.Sampling = {
+  stableSample(input, rate) {
+    const hasher = crypto.subtle;
+
+    return hasher.digest("SHA-256", new TextEncoder("utf-8").encode(JSON.stringify(input)))
+      .then(hash => {
+        // truncate hash to 12 characters (2^48)
+        const inputHash = bufferToHex(hash).slice(0, 12);
+        const samplePoint = fractionToKey(rate);
+
+        if (samplePoint.length !== 12 || inputHash.length !== 12) {
+          throw new Error("Unexpected hash length");
+        }
+
+        return inputHash < samplePoint;
+
+      })
+      .catch(error => {
+        log.error(`Error: ${error}`);
+      });
+  },
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/SandboxManager.jsm
@@ -0,0 +1,65 @@
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/Services.jsm");
+
+this.EXPORTED_SYMBOLS = ["SandboxManager"];
+
+this.SandboxManager = class {
+  constructor() {
+    this._sandbox = makeSandbox();
+    this.holds = [];
+  }
+
+  get sandbox() {
+    if (this._sandbox) {
+      return this._sandbox;
+    }
+    throw new Error("Tried to use sandbox after it was nuked");
+  }
+
+  addHold(name) {
+    this.holds.push(name);
+  }
+
+  removeHold(name) {
+    const index = this.holds.indexOf(name);
+    if (index === -1) {
+      throw new Error(`Tried to remove non-existant hold "${name}"`);
+    }
+    this.holds.splice(index, 1);
+    this.tryCleanup();
+  }
+
+  tryCleanup() {
+    if (this.holds.length === 0) {
+      const sandbox = this._sandbox;
+      this._sandbox = null;
+      Cu.nukeSandbox(sandbox);
+    }
+  }
+
+  isNuked() {
+    // Do this in a promise, so other async things can resolve.
+    return new Promise((resolve, reject) => {
+      if (!this._sandbox) {
+        resolve();
+      } else {
+        reject(new Error(`Sandbox is not nuked. Holds left: ${this.holds}`));
+      }
+    });
+  }
+};
+
+
+function makeSandbox() {
+  const sandbox = new Cu.Sandbox(null, {
+    wantComponents: false,
+    wantGlobalProperties: ["URL", "URLSearchParams"],
+  });
+
+  sandbox.window = Cu.cloneInto({}, sandbox);
+
+  const url = "resource://shield-recipe-client/data/EventEmitter.js";
+  Services.scriptloader.loadSubScript(url, sandbox);
+
+  return sandbox;
+}
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/lib/Storage.jsm
@@ -0,0 +1,134 @@
+/* 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");
+Cu.import("resource://gre/modules/Log.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "JSONFile", "resource://gre/modules/JSONFile.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm");
+
+this.EXPORTED_SYMBOLS = ["Storage"];
+
+const log = Log.repository.getLogger("extensions.shield-recipe-client");
+let storePromise;
+
+function loadStorage() {
+  if (storePromise === undefined) {
+    const path = OS.Path.join(OS.Constants.Path.profileDir, "shield-recipe-client.json");
+    const storage = new JSONFile({path});
+    storePromise = Task.spawn(function* () {
+      yield storage.load();
+      return storage;
+    });
+  }
+  return storePromise;
+}
+
+this.Storage = {
+  makeStorage(prefix, sandbox) {
+    if (!sandbox) {
+      throw new Error("No sandbox passed");
+    }
+
+    const storageInterface = {
+      /**
+       * Sets an item in the prefixed storage.
+       * @returns {Promise}
+       * @resolves With the stored value, or null.
+       * @rejects Javascript exception.
+       */
+      getItem(keySuffix) {
+        return new sandbox.Promise((resolve, reject) => {
+          loadStorage()
+            .then(store => {
+              const namespace = store.data[prefix] || {};
+              const value = namespace[keySuffix] || null;
+              resolve(Cu.cloneInto(value, sandbox));
+            })
+            .catch(err => {
+              log.error(err);
+              reject(new sandbox.Error());
+            });
+        });
+      },
+
+      /**
+       * Sets an item in the prefixed storage.
+       * @returns {Promise}
+       * @resolves When the operation is completed succesfully
+       * @rejects Javascript exception.
+       */
+      setItem(keySuffix, value) {
+        return new sandbox.Promise((resolve, reject) => {
+          loadStorage()
+            .then(store => {
+              if (!(prefix in store.data)) {
+                store.data[prefix] = {};
+              }
+              store.data[prefix][keySuffix] = value;
+              store.saveSoon();
+              resolve();
+            })
+            .catch(err => {
+              log.error(err);
+              reject(new sandbox.Error());
+            });
+        });
+      },
+
+      /**
+       * Removes a single item from the prefixed storage.
+       * @returns {Promise}
+       * @resolves When the operation is completed succesfully
+       * @rejects Javascript exception.
+       */
+      removeItem(keySuffix) {
+        return new sandbox.Promise((resolve, reject) => {
+          loadStorage()
+            .then(store => {
+              if (!(prefix in store.data)) {
+                return;
+              }
+              delete store.data[prefix][keySuffix];
+              store.saveSoon();
+              resolve();
+            })
+            .catch(err => {
+              log.error(err);
+              reject(new sandbox.Error());
+            });
+        });
+      },
+
+      /**
+       * Clears all storage for the prefix.
+       * @returns {Promise}
+       * @resolves When the operation is completed succesfully
+       * @rejects Javascript exception.
+       */
+      clear() {
+        return new sandbox.Promise((resolve, reject) => {
+          return loadStorage()
+            .then(store => {
+              store.data[prefix] = {};
+              store.saveSoon();
+              resolve();
+            })
+            .catch(err => {
+              log.error(err);
+              reject(new sandbox.Error());
+            });
+        });
+      },
+    };
+
+    return Cu.cloneInto(storageInterface, sandbox, {
+      cloneFunctions: true,
+    });
+  },
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/moz.build
@@ -0,0 +1,22 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DEFINES['MOZ_APP_VERSION'] = CONFIG['MOZ_APP_VERSION']
+DEFINES['MOZ_APP_MAXVERSION'] = CONFIG['MOZ_APP_MAXVERSION']
+
+FINAL_TARGET_FILES.features['shield-recipe-client@mozilla.org'] += [
+  'bootstrap.js',
+]
+
+FINAL_TARGET_PP_FILES.features['shield-recipe-client@mozilla.org'] += [
+  'install.rdf.in'
+]
+
+BROWSER_CHROME_MANIFESTS += [
+    'test/browser.ini',
+]
+
+JAR_MANIFESTS += ['jar.mn']
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/LICENSE.txt
@@ -0,0 +1,19 @@
+Copyright (c) 2015 TechnologyAdvice
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/Jexl.js
@@ -0,0 +1,225 @@
+/*
+ * Jexl
+ * Copyright (c) 2015 TechnologyAdvice
+ */
+
+var Evaluator = require('./evaluator/Evaluator'),
+	Lexer = require('./Lexer'),
+	Parser = require('./parser/Parser'),
+	defaultGrammar = require('./grammar').elements;
+
+/**
+ * Jexl is the Javascript Expression Language, capable of parsing and
+ * evaluating basic to complex expression strings, combined with advanced
+ * xpath-like drilldown into native Javascript objects.
+ * @constructor
+ */
+function Jexl() {
+	this._customGrammar = null;
+	this._lexer = null;
+	this._transforms = {};
+}
+
+/**
+ * Adds a binary operator to Jexl at the specified precedence. The higher the
+ * precedence, the earlier the operator is applied in the order of operations.
+ * For example, * has a higher precedence than +, because multiplication comes
+ * before division.
+ *
+ * Please see grammar.js for a listing of all default operators and their
+ * precedence values in order to choose the appropriate precedence for the
+ * new operator.
+ * @param {string} operator The operator string to be added
+ * @param {number} precedence The operator's precedence
+ * @param {function} fn A function to run to calculate the result. The function
+ *      will be called with two arguments: left and right, denoting the values
+ *      on either side of the operator. It should return either the resulting
+ *      value, or a Promise that resolves with the resulting value.
+ */
+Jexl.prototype.addBinaryOp = function(operator, precedence, fn) {
+	this._addGrammarElement(operator, {
+		type: 'binaryOp',
+		precedence: precedence,
+		eval: fn
+	});
+};
+
+/**
+ * Adds a unary operator to Jexl. Unary operators are currently only supported
+ * on the left side of the value on which it will operate.
+ * @param {string} operator The operator string to be added
+ * @param {function} fn A function to run to calculate the result. The function
+ *      will be called with one argument: the literal value to the right of the
+ *      operator. It should return either the resulting value, or a Promise
+ *      that resolves with the resulting value.
+ */
+Jexl.prototype.addUnaryOp = function(operator, fn) {
+	this._addGrammarElement(operator, {
+		type: 'unaryOp',
+		weight: Infinity,
+		eval: fn
+	});
+};
+
+/**
+ * Adds or replaces a transform function in this Jexl instance.
+ * @param {string} name The name of the transform function, as it will be used
+ *      within Jexl expressions
+ * @param {function} fn The function to be executed when this transform is
+ *      invoked.  It will be provided with two arguments:
+ *          - {*} value: The value to be transformed
+ *          - {{}} args: The arguments for this transform
+ *          - {function} cb: A callback function to be called with an error
+ *            if the transform fails, or a null first argument and the
+ *            transformed value as the second argument on success.
+ */
+Jexl.prototype.addTransform = function(name, fn) {
+	this._transforms[name] = fn;
+};
+
+/**
+ * Syntactic sugar for calling {@link #addTransform} repeatedly.  This function
+ * accepts a map of one or more transform names to their transform function.
+ * @param {{}} map A map of transform names to transform functions
+ */
+Jexl.prototype.addTransforms = function(map) {
+	for (var key in map) {
+		if (map.hasOwnProperty(key))
+			this._transforms[key] = map[key];
+	}
+};
+
+/**
+ * Retrieves a previously set transform function.
+ * @param {string} name The name of the transform function
+ * @returns {function} The transform function
+ */
+Jexl.prototype.getTransform = function(name) {
+	return this._transforms[name];
+};
+
+/**
+ * Evaluates a Jexl string within an optional context.
+ * @param {string} expression The Jexl expression to be evaluated
+ * @param {Object} [context] A mapping of variables to values, which will be
+ *      made accessible to the Jexl expression when evaluating it
+ * @param {function} [cb] An optional callback function to be executed when
+ *      evaluation is complete.  It will be supplied with two arguments:
+ *          - {Error|null} err: Present if an error occurred
+ *          - {*} result: The result of the evaluation
+ * @returns {Promise<*>} resolves with the result of the evaluation.  Note that
+ *      if a callback is supplied, the returned promise will already have
+ *      a '.catch' attached to it in order to pass the error to the callback.
+ */
+Jexl.prototype.eval = function(expression, context, cb) {
+	if (typeof context === 'function') {
+		cb = context;
+		context = {};
+	}
+	else if (!context)
+		context = {};
+	var valPromise = this._eval(expression, context);
+	if (cb) {
+		// setTimeout is used for the callback to break out of the Promise's
+		// try/catch in case the callback throws.
+		var called = false;
+		return valPromise.then(function(val) {
+			called = true;
+			setTimeout(cb.bind(null, null, val), 0);
+		}).catch(function(err) {
+			if (!called)
+				setTimeout(cb.bind(null, err), 0);
+		});
+	}
+	return valPromise;
+};
+
+/**
+ * Removes a binary or unary operator from the Jexl grammar.
+ * @param {string} operator The operator string to be removed
+ */
+Jexl.prototype.removeOp = function(operator) {
+	var grammar = this._getCustomGrammar();
+	if (grammar[operator] && (grammar[operator].type == 'binaryOp' ||
+			grammar[operator].type == 'unaryOp')) {
+		delete grammar[operator];
+		this._lexer = null;
+	}
+};
+
+/**
+ * Adds an element to the grammar map used by this Jexl instance, cloning
+ * the default grammar first if necessary.
+ * @param {string} str The key string to be added
+ * @param {{type: <string>}} obj A map of configuration options for this
+ *      grammar element
+ * @private
+ */
+Jexl.prototype._addGrammarElement = function(str, obj) {
+	var grammar = this._getCustomGrammar();
+	grammar[str] = obj;
+	this._lexer = null;
+};
+
+/**
+ * Evaluates a Jexl string in the given context.
+ * @param {string} exp The Jexl expression to be evaluated
+ * @param {Object} [context] A mapping of variables to values, which will be
+ *      made accessible to the Jexl expression when evaluating it
+ * @returns {Promise<*>} resolves with the result of the evaluation.
+ * @private
+ */
+Jexl.prototype._eval = function(exp, context) {
+	var self = this,
+		grammar = this._getGrammar(),
+		parser = new Parser(grammar),
+		evaluator = new Evaluator(grammar, this._transforms, context);
+	return Promise.resolve().then(function() {
+		parser.addTokens(self._getLexer().tokenize(exp));
+		return evaluator.eval(parser.complete());
+	});
+};
+
+/**
+ * Gets the custom grammar object, creating it first if necessary. New custom
+ * grammars are created by executing a shallow clone of the default grammar
+ * map. The returned map is available to be changed.
+ * @returns {{}} a customizable grammar map.
+ * @private
+ */
+Jexl.prototype._getCustomGrammar = function() {
+	if (!this._customGrammar) {
+		this._customGrammar = {};
+		for (var key in defaultGrammar) {
+			if (defaultGrammar.hasOwnProperty(key))
+				this._customGrammar[key] = defaultGrammar[key];
+		}
+	}
+	return this._customGrammar;
+};
+
+/**
+ * Gets the grammar map currently being used by Jexl; either the default map,
+ * or a locally customized version. The returned map should never be changed
+ * in any way.
+ * @returns {{}} the grammar map currently in use.
+ * @private
+ */
+Jexl.prototype._getGrammar = function() {
+	return this._customGrammar || defaultGrammar;
+};
+
+/**
+ * Gets a Lexer instance as a singleton in reference to this Jexl instance.
+ * @returns {Lexer} an instance of Lexer, initialized with a grammar
+ *      appropriate to this Jexl instance.
+ * @private
+ */
+Jexl.prototype._getLexer = function() {
+	if (!this._lexer)
+		this._lexer = new Lexer(this._getGrammar());
+	return this._lexer;
+};
+
+module.exports = new Jexl();
+module.exports.Jexl = Jexl;
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/Lexer.js
@@ -0,0 +1,244 @@
+/*
+ * Jexl
+ * Copyright (c) 2015 TechnologyAdvice
+ */
+
+var numericRegex = /^-?(?:(?:[0-9]*\.[0-9]+)|[0-9]+)$/,
+	identRegex = /^[a-zA-Z_\$][a-zA-Z0-9_\$]*$/,
+	escEscRegex = /\\\\/,
+	preOpRegexElems = [
+		// Strings
+		"'(?:(?:\\\\')?[^'])*'",
+		'"(?:(?:\\\\")?[^"])*"',
+		// Whitespace
+		'\\s+',
+		// Booleans
+		'\\btrue\\b',
+		'\\bfalse\\b'
+	],
+	postOpRegexElems = [
+		// Identifiers
+		'\\b[a-zA-Z_\\$][a-zA-Z0-9_\\$]*\\b',
+		// Numerics (without negative symbol)
+		'(?:(?:[0-9]*\\.[0-9]+)|[0-9]+)'
+	],
+	minusNegatesAfter = ['binaryOp', 'unaryOp', 'openParen', 'openBracket',
+		'question', 'colon'];
+
+/**
+ * Lexer is a collection of stateless, statically-accessed functions for the
+ * lexical parsing of a Jexl string.  Its responsibility is to identify the
+ * "parts of speech" of a Jexl expression, and tokenize and label each, but
+ * to do only the most minimal syntax checking; the only errors the Lexer
+ * should be concerned with are if it's unable to identify the utility of
+ * any of its tokens.  Errors stemming from these tokens not being in a
+ * sensible configuration should be left for the Parser to handle.
+ * @type {{}}
+ */
+function Lexer(grammar) {
+	this._grammar = grammar;
+}
+
+/**
+ * Splits a Jexl expression string into an array of expression elements.
+ * @param {string} str A Jexl expression string
+ * @returns {Array<string>} An array of substrings defining the functional
+ *      elements of the expression.
+ */
+Lexer.prototype.getElements = function(str) {
+	var regex = this._getSplitRegex();
+	return str.split(regex).filter(function(elem) {
+		// Remove empty strings
+		return elem;
+	});
+};
+
+/**
+ * Converts an array of expression elements into an array of tokens.  Note that
+ * the resulting array may not equal the element array in length, as any
+ * elements that consist only of whitespace get appended to the previous
+ * token's "raw" property.  For the structure of a token object, please see
+ * {@link Lexer#tokenize}.
+ * @param {Array<string>} elements An array of Jexl expression elements to be
+ *      converted to tokens
+ * @returns {Array<{type, value, raw}>} an array of token objects.
+ */
+Lexer.prototype.getTokens = function(elements) {
+	var tokens = [],
+		negate = false;
+	for (var i = 0; i < elements.length; i++) {
+		if (this._isWhitespace(elements[i])) {
+			if (tokens.length)
+				tokens[tokens.length - 1].raw += elements[i];
+		}
+		else if (elements[i] === '-' && this._isNegative(tokens))
+			negate = true;
+		else {
+			if (negate) {
+				elements[i] = '-' + elements[i];
+				negate = false;
+			}
+			tokens.push(this._createToken(elements[i]));
+		}
+	}
+	// Catch a - at the end of the string. Let the parser handle that issue.
+	if (negate)
+		tokens.push(this._createToken('-'));
+	return tokens;
+};
+
+/**
+ * Converts a Jexl string into an array of tokens.  Each token is an object
+ * in the following format:
+ *
+ *     {
+ *         type: <string>,
+ *         [name]: <string>,
+ *         value: <boolean|number|string>,
+ *         raw: <string>
+ *     }
+ *
+ * Type is one of the following:
+ *
+ *      literal, identifier, binaryOp, unaryOp
+ *
+ * OR, if the token is a control character its type is the name of the element
+ * defined in the Grammar.
+ *
+ * Name appears only if the token is a control string found in
+ * {@link grammar#elements}, and is set to the name of the element.
+ *
+ * Value is the value of the token in the correct type (boolean or numeric as
+ * appropriate). Raw is the string representation of this value taken directly
+ * from the expression string, including any trailing spaces.
+ * @param {string} str The Jexl string to be tokenized
+ * @returns {Array<{type, value, raw}>} an array of token objects.
+ * @throws {Error} if the provided string contains an invalid token.
+ */
+Lexer.prototype.tokenize = function(str) {
+	var elements = this.getElements(str);
+	return this.getTokens(elements);
+};
+
+/**
+ * Creates a new token object from an element of a Jexl string. See
+ * {@link Lexer#tokenize} for a description of the token object.
+ * @param {string} element The element from which a token should be made
+ * @returns {{value: number|boolean|string, [name]: string, type: string,
+ *      raw: string}} a token object describing the provided element.
+ * @throws {Error} if the provided string is not a valid expression element.
+ * @private
+ */
+Lexer.prototype._createToken = function(element) {
+	var token = {
+		type: 'literal',
+		value: element,
+		raw: element
+	};
+	if (element[0] == '"' || element[0] == "'")
+		token.value = this._unquote(element);
+	else if (element.match(numericRegex))
+		token.value = parseFloat(element);
+	else if (element === 'true' || element === 'false')
+		token.value = element === 'true';
+	else if (this._grammar[element])
+		token.type = this._grammar[element].type;
+	else if (element.match(identRegex))
+		token.type = 'identifier';
+	else
+		throw new Error("Invalid expression token: " + element);
+	return token;
+};
+
+/**
+ * Escapes a string so that it can be treated as a string literal within a
+ * regular expression.
+ * @param {string} str The string to be escaped
+ * @returns {string} the RegExp-escaped string.
+ * @see https://developer.mozilla.org/en/docs/Web/JavaScript/Guide/Regular_Expressions
+ * @private
+ */
+Lexer.prototype._escapeRegExp = function(str) {
+	str = str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+	if (str.match(identRegex))
+		str = '\\b' + str + '\\b';
+	return str;
+};
+
+/**
+ * Gets a RegEx object appropriate for splitting a Jexl string into its core
+ * elements.
+ * @returns {RegExp} An element-splitting RegExp object
+ * @private
+ */
+Lexer.prototype._getSplitRegex = function() {
+	if (!this._splitRegex) {
+		var elemArray = Object.keys(this._grammar);
+		// Sort by most characters to least, then regex escape each
+		elemArray = elemArray.sort(function(a ,b) {
+			return b.length - a.length;
+		}).map(function(elem) {
+			return this._escapeRegExp(elem);
+		}, this);
+		this._splitRegex = new RegExp('(' + [
+			preOpRegexElems.join('|'),
+			elemArray.join('|'),
+			postOpRegexElems.join('|')
+		].join('|') + ')');
+	}
+	return this._splitRegex;
+};
+
+/**
+ * Determines whether the addition of a '-' token should be interpreted as a
+ * negative symbol for an upcoming number, given an array of tokens already
+ * processed.
+ * @param {Array<Object>} tokens An array of tokens already processed
+ * @returns {boolean} true if adding a '-' should be considered a negative
+ *      symbol; false otherwise
+ * @private
+ */
+Lexer.prototype._isNegative = function(tokens) {
+	if (!tokens.length)
+		return true;
+	return minusNegatesAfter.some(function(type) {
+		return type === tokens[tokens.length - 1].type;
+	});
+};
+
+/**
+ * A utility function to determine if a string consists of only space
+ * characters.
+ * @param {string} str A string to be tested
+ * @returns {boolean} true if the string is empty or consists of only spaces;
+ *      false otherwise.
+ * @private
+ */
+Lexer.prototype._isWhitespace = function(str) {
+	for (var i = 0; i < str.length; i++) {
+		if (str[i] != ' ')
+			return false;
+	}
+	return true;
+};
+
+/**
+ * Removes the beginning and trailing quotes from a string, unescapes any
+ * escaped quotes on its interior, and unescapes any escaped escape characters.
+ * Note that this function is not defensive; it assumes that the provided
+ * string is not empty, and that its first and last characters are actually
+ * quotes.
+ * @param {string} str A string whose first and last characters are quotes
+ * @returns {string} a string with the surrounding quotes stripped and escapes
+ *      properly processed.
+ * @private
+ */
+Lexer.prototype._unquote = function(str) {
+	var quote = str[0],
+		escQuoteRegex = new RegExp('\\\\' + quote, 'g');
+	return str.substr(1, str.length - 2)
+		.replace(escQuoteRegex, quote)
+		.replace(escEscRegex, '\\');
+};
+
+module.exports = Lexer;
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/Evaluator.js
@@ -0,0 +1,153 @@
+/*
+ * Jexl
+ * Copyright (c) 2015 TechnologyAdvice
+ */
+
+var handlers = require('./handlers');
+
+/**
+ * The Evaluator takes a Jexl expression tree as generated by the
+ * {@link Parser} and calculates its value within a given context. The
+ * collection of transforms, context, and a relative context to be used as the
+ * root for relative identifiers, are all specific to an Evaluator instance.
+ * When any of these things change, a new instance is required.  However, a
+ * single instance can be used to simultaneously evaluate many different
+ * expressions, and does not have to be reinstantiated for each.
+ * @param {{}} grammar A grammar map against which to evaluate the expression
+ *      tree
+ * @param {{}} [transforms] A map of transform names to transform functions. A
+ *      transform function takes two arguments:
+ *          - {*} val: A value to be transformed
+ *          - {{}} args: A map of argument keys to their evaluated values, as
+ *              specified in the expression string
+ *      The transform function should return either the transformed value, or
+ *      a Promises/A+ Promise object that resolves with the value and rejects
+ *      or throws only when an unrecoverable error occurs. Transforms should
+ *      generally return undefined when they don't make sense to be used on the
+ *      given value type, rather than throw/reject. An error is only
+ *      appropriate when the transform would normally return a value, but
+ *      cannot due to some other failure.
+ * @param {{}} [context] A map of variable keys to their values. This will be
+ *      accessed to resolve the value of each non-relative identifier. Any
+ *      Promise values will be passed to the expression as their resolved
+ *      value.
+ * @param {{}|Array<{}|Array>} [relativeContext] A map or array to be accessed
+ *      to resolve the value of a relative identifier.
+ * @constructor
+ */
+var Evaluator = function(grammar, transforms, context, relativeContext) {
+	this._grammar = grammar;
+	this._transforms = transforms || {};
+	this._context = context || {};
+	this._relContext = relativeContext || this._context;
+};
+
+/**
+ * Evaluates an expression tree within the configured context.
+ * @param {{}} ast An expression tree object
+ * @returns {Promise<*>} resolves with the resulting value of the expression.
+ */
+Evaluator.prototype.eval = function(ast) {
+	var self = this;
+	return Promise.resolve().then(function() {
+		return handlers[ast.type].call(self, ast);
+	});
+};
+
+/**
+ * Simultaneously evaluates each expression within an array, and delivers the
+ * response as an array with the resulting values at the same indexes as their
+ * originating expressions.
+ * @param {Array<string>} arr An array of expression strings to be evaluated
+ * @returns {Promise<Array<{}>>} resolves with the result array
+ */
+Evaluator.prototype.evalArray = function(arr) {
+	return Promise.all(arr.map(function(elem) {
+		return this.eval(elem);
+	}, this));
+};
+
+/**
+ * Simultaneously evaluates each expression within a map, and delivers the
+ * response as a map with the same keys, but with the evaluated result for each
+ * as their value.
+ * @param {{}} map A map of expression names to expression trees to be
+ *      evaluated
+ * @returns {Promise<{}>} resolves with the result map.
+ */
+Evaluator.prototype.evalMap = function(map) {
+	var keys = Object.keys(map),
+		result = {};
+	var asts = keys.map(function(key) {
+		return this.eval(map[key]);
+	}, this);
+	return Promise.all(asts).then(function(vals) {
+		vals.forEach(function(val, idx) {
+			result[keys[idx]] = val;
+		});
+		return result;
+	});
+};
+
+/**
+ * Applies a filter expression with relative identifier elements to a subject.
+ * The intent is for the subject to be an array of subjects that will be
+ * individually used as the relative context against the provided expression
+ * tree. Only the elements whose expressions result in a truthy value will be
+ * included in the resulting array.
+ *
+ * If the subject is not an array of values, it will be converted to a single-
+ * element array before running the filter.
+ * @param {*} subject The value to be filtered; usually an array. If this value is
+ *      not an array, it will be converted to an array with this value as the
+ *      only element.
+ * @param {{}} expr The expression tree to run against each subject. If the
+ *      tree evaluates to a truthy result, then the value will be included in
+ *      the returned array; otherwise, it will be eliminated.
+ * @returns {Promise<Array>} resolves with an array of values that passed the
+ *      expression filter.
+ * @private
+ */
+Evaluator.prototype._filterRelative = function(subject, expr) {
+	var promises = [];
+	if (!Array.isArray(subject))
+		subject = [subject];
+	subject.forEach(function(elem) {
+		var evalInst = new Evaluator(this._grammar, this._transforms,
+			this._context, elem);
+		promises.push(evalInst.eval(expr));
+	}, this);
+	return Promise.all(promises).then(function(values) {
+		var results = [];
+		values.forEach(function(value, idx) {
+			if (value)
+				results.push(subject[idx]);
+		});
+		return results;
+	});
+};
+
+/**
+ * Applies a static filter expression to a subject value.  If the filter
+ * expression evaluates to boolean true, the subject is returned; if false,
+ * undefined.
+ *
+ * For any other resulting value of the expression, this function will attempt
+ * to respond with the property at that name or index of the subject.
+ * @param {*} subject The value to be filtered.  Usually an Array (for which
+ *      the expression would generally resolve to a numeric index) or an
+ *      Object (for which the expression would generally resolve to a string
+ *      indicating a property name)
+ * @param {{}} expr The expression tree to run against the subject
+ * @returns {Promise<*>} resolves with the value of the drill-down.
+ * @private
+ */
+Evaluator.prototype._filterStatic = function(subject, expr) {
+	return this.eval(expr).then(function(res) {
+		if (typeof res === 'boolean')
+			return res ? subject : undefined;
+		return subject[res];
+	});
+};
+
+module.exports = Evaluator;
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/handlers.js
@@ -0,0 +1,159 @@
+/*
+ * Jexl
+ * Copyright (c) 2015 TechnologyAdvice
+ */
+
+/**
+ * Evaluates an ArrayLiteral by returning its value, with each element
+ * independently run through the evaluator.
+ * @param {{type: 'ObjectLiteral', value: <{}>}} ast An expression tree with an
+ *      ObjectLiteral as the top node
+ * @returns {Promise.<[]>} resolves to a map contained evaluated values.
+ * @private
+ */
+exports.ArrayLiteral = function(ast) {
+	return this.evalArray(ast.value);
+};
+
+/**
+ * Evaluates a BinaryExpression node by running the Grammar's evaluator for
+ * the given operator.
+ * @param {{type: 'BinaryExpression', operator: <string>, left: {},
+ *      right: {}}} ast An expression tree with a BinaryExpression as the top
+ *      node
+ * @returns {Promise<*>} resolves with the value of the BinaryExpression.
+ * @private
+ */
+exports.BinaryExpression = function(ast) {
+	var self = this;
+	return Promise.all([
+		this.eval(ast.left),
+		this.eval(ast.right)
+	]).then(function(arr) {
+		return self._grammar[ast.operator].eval(arr[0], arr[1]);
+	});
+};
+
+/**
+ * Evaluates a ConditionalExpression node by first evaluating its test branch,
+ * and resolving with the consequent branch if the test is truthy, or the
+ * alternate branch if it is not. If there is no consequent branch, the test
+ * result will be used instead.
+ * @param {{type: 'ConditionalExpression', test: {}, consequent: {},
+ *      alternate: {}}} ast An expression tree with a ConditionalExpression as
+ *      the top node
+ * @private
+ */
+exports.ConditionalExpression = function(ast) {
+	var self = this;
+	return this.eval(ast.test).then(function(res) {
+		if (res) {
+			if (ast.consequent)
+				return self.eval(ast.consequent);
+			return res;
+		}
+		return self.eval(ast.alternate);
+	});
+};
+
+/**
+ * Evaluates a FilterExpression by applying it to the subject value.
+ * @param {{type: 'FilterExpression', relative: <boolean>, expr: {},
+ *      subject: {}}} ast An expression tree with a FilterExpression as the top
+ *      node
+ * @returns {Promise<*>} resolves with the value of the FilterExpression.
+ * @private
+ */
+exports.FilterExpression = function(ast) {
+	var self = this;
+	return this.eval(ast.subject).then(function(subject) {
+		if (ast.relative)
+			return self._filterRelative(subject, ast.expr);
+		return self._filterStatic(subject, ast.expr);
+	});
+};
+
+/**
+ * Evaluates an Identifier by either stemming from the evaluated 'from'
+ * expression tree or accessing the context provided when this Evaluator was
+ * constructed.
+ * @param {{type: 'Identifier', value: <string>, [from]: {}}} ast An expression
+ *      tree with an Identifier as the top node
+ * @returns {Promise<*>|*} either the identifier's value, or a Promise that
+ *      will resolve with the identifier's value.
+ * @private
+ */
+exports.Identifier = function(ast) {
+	if (ast.from) {
+		return this.eval(ast.from).then(function(context) {
+			if (context === undefined)
+				return undefined;
+			if (Array.isArray(context))
+				context = context[0];
+			return context[ast.value];
+		});
+	}
+	else {
+		return ast.relative ? this._relContext[ast.value] :
+			this._context[ast.value];
+	}
+};
+
+/**
+ * Evaluates a Literal by returning its value property.
+ * @param {{type: 'Literal', value: <string|number|boolean>}} ast An expression
+ *      tree with a Literal as its only node
+ * @returns {string|number|boolean} The value of the Literal node
+ * @private
+ */
+exports.Literal = function(ast) {
+	return ast.value;
+};
+
+/**
+ * Evaluates an ObjectLiteral by returning its value, with each key
+ * independently run through the evaluator.
+ * @param {{type: 'ObjectLiteral', value: <{}>}} ast An expression tree with an
+ *      ObjectLiteral as the top node
+ * @returns {Promise<{}>} resolves to a map contained evaluated values.
+ * @private
+ */
+exports.ObjectLiteral = function(ast) {
+	return this.evalMap(ast.value);
+};
+
+/**
+ * Evaluates a Transform node by applying a function from the transforms map
+ * to the subject value.
+ * @param {{type: 'Transform', name: <string>, subject: {}}} ast An
+ *      expression tree with a Transform as the top node
+ * @returns {Promise<*>|*} the value of the transformation, or a Promise that
+ *      will resolve with the transformed value.
+ * @private
+ */
+exports.Transform = function(ast) {
+	var transform = this._transforms[ast.name];
+	if (!transform)
+		throw new Error("Transform '" + ast.name + "' is not defined.");
+	return Promise.all([
+		this.eval(ast.subject),
+		this.evalArray(ast.args || [])
+	]).then(function(arr) {
+		return transform.apply(null, [arr[0]].concat(arr[1]));
+	});
+};
+
+/**
+ * Evaluates a Unary expression by passing the right side through the
+ * operator's eval function.
+ * @param {{type: 'UnaryExpression', operator: <string>, right: {}}} ast An
+ *      expression tree with a UnaryExpression as the top node
+ * @returns {Promise<*>} resolves with the value of the UnaryExpression.
+ * @constructor
+ */
+exports.UnaryExpression = function(ast) {
+	var self = this;
+	return this.eval(ast.right).then(function(right) {
+		return self._grammar[ast.operator].eval(right);
+	});
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/grammar.js
@@ -0,0 +1,66 @@
+/*
+ * Jexl
+ * Copyright (c) 2015 TechnologyAdvice
+ */
+
+/**
+ * A map of all expression elements to their properties. Note that changes
+ * here may require changes in the Lexer or Parser.
+ * @type {{}}
+ */
+exports.elements = {
+	'.': {type: 'dot'},
+	'[': {type: 'openBracket'},
+	']': {type: 'closeBracket'},
+	'|': {type: 'pipe'},
+	'{': {type: 'openCurl'},
+	'}': {type: 'closeCurl'},
+	':': {type: 'colon'},
+	',': {type: 'comma'},
+	'(': {type: 'openParen'},
+	')': {type: 'closeParen'},
+	'?': {type: 'question'},
+	'+': {type: 'binaryOp', precedence: 30,
+		eval: function(left, right) { return left + right; }},
+	'-': {type: 'binaryOp', precedence: 30,
+		eval: function(left, right) { return left - right; }},
+	'*': {type: 'binaryOp', precedence: 40,
+		eval: function(left, right) { return left * right; }},
+	'/': {type: 'binaryOp', precedence: 40,
+		eval: function(left, right) { return left / right; }},
+	'//': {type: 'binaryOp', precedence: 40,
+		eval: function(left, right) { return Math.floor(left / right); }},
+	'%': {type: 'binaryOp', precedence: 50,
+		eval: function(left, right) { return left % right; }},
+	'^': {type: 'binaryOp', precedence: 50,
+		eval: function(left, right) { return Math.pow(left, right); }},
+	'==': {type: 'binaryOp', precedence: 20,
+		eval: function(left, right) { return left == right; }},
+	'!=': {type: 'binaryOp', precedence: 20,
+		eval: function(left, right) { return left != right; }},
+	'>': {type: 'binaryOp', precedence: 20,
+		eval: function(left, right) { return left > right; }},
+	'>=': {type: 'binaryOp', precedence: 20,
+		eval: function(left, right) { return left >= right; }},
+	'<': {type: 'binaryOp', precedence: 20,
+		eval: function(left, right) { return left < right; }},
+	'<=': {type: 'binaryOp', precedence: 20,
+		eval: function(left, right) { return left <= right; }},
+	'&&': {type: 'binaryOp', precedence: 10,
+		eval: function(left, right) { return left && right; }},
+	'||': {type: 'binaryOp', precedence: 10,
+		eval: function(left, right) { return left || right; }},
+	'in': {type: 'binaryOp', precedence: 20,
+		eval: function(left, right) {
+			if (typeof right === 'string')
+				return right.indexOf(left) !== -1;
+			if (Array.isArray(right)) {
+				return right.some(function(elem) {
+					return elem == left;
+				});
+			}
+			return false;
+		}},
+	'!': {type: 'unaryOp', precedence: Infinity,
+		eval: function(right) { return !right; }}
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/Parser.js
@@ -0,0 +1,188 @@
+/*
+ * Jexl
+ * Copyright (c) 2015 TechnologyAdvice
+ */
+
+var handlers = require('./handlers'),
+	states = require('./states').states;
+
+/**
+ * The Parser is a state machine that converts tokens from the {@link Lexer}
+ * into an Abstract Syntax Tree (AST), capable of being evaluated in any
+ * context by the {@link Evaluator}.  The Parser expects that all tokens
+ * provided to it are legal and typed properly according to the grammar, but
+ * accepts that the tokens may still be in an invalid order or in some other
+ * unparsable configuration that requires it to throw an Error.
+ * @param {{}} grammar The grammar map to use to parse Jexl strings
+ * @param {string} [prefix] A string prefix to prepend to the expression string
+ *      for error messaging purposes.  This is useful for when a new Parser is
+ *      instantiated to parse an subexpression, as the parent Parser's
+ *      expression string thus far can be passed for a more user-friendly
+ *      error message.
+ * @param {{}} [stopMap] A mapping of token types to any truthy value. When the
+ *      token type is encountered, the parser will return the mapped value
+ *      instead of boolean false.
+ * @constructor
+ */
+function Parser(grammar, prefix, stopMap) {
+	this._grammar = grammar;
+	this._state = 'expectOperand';
+	this._tree = null;
+	this._exprStr = prefix || '';
+	this._relative = false;
+	this._stopMap = stopMap || {};
+}
+
+/**
+ * Processes a new token into the AST and manages the transitions of the state
+ * machine.
+ * @param {{type: <string>}} token A token object, as provided by the
+ *      {@link Lexer#tokenize} function.
+ * @throws {Error} if a token is added when the Parser has been marked as
+ *      complete by {@link #complete}, or if an unexpected token type is added.
+ * @returns {boolean|*} the stopState value if this parser encountered a token
+ *      in the stopState mapb; false if tokens can continue.
+ */
+Parser.prototype.addToken = function(token) {
+	if (this._state == 'complete')
+		throw new Error('Cannot add a new token to a completed Parser');
+	var state = states[this._state],
+		startExpr = this._exprStr;
+	this._exprStr += token.raw;
+	if (state.subHandler) {
+		if (!this._subParser)
+			this._startSubExpression(startExpr);
+		var stopState = this._subParser.addToken(token);
+		if (stopState) {
+			this._endSubExpression();
+			if (this._parentStop)
+				return stopState;
+			this._state = stopState;
+		}
+	}
+	else if (state.tokenTypes[token.type]) {
+		var typeOpts = state.tokenTypes[token.type],
+			handleFunc = handlers[token.type];
+		if (typeOpts.handler)
+			handleFunc = typeOpts.handler;
+		if (handleFunc)
+			handleFunc.call(this, token);
+		if (typeOpts.toState)
+			this._state = typeOpts.toState;
+	}
+	else if (this._stopMap[token.type])
+		return this._stopMap[token.type];
+	else {
+		throw new Error('Token ' + token.raw + ' (' + token.type +
+			') unexpected in expression: ' + this._exprStr);
+	}
+	return false;
+};
+
+/**
+ * Processes an array of tokens iteratively through the {@link #addToken}
+ * function.
+ * @param {Array<{type: <string>}>} tokens An array of tokens, as provided by
+ *      the {@link Lexer#tokenize} function.
+ */
+Parser.prototype.addTokens = function(tokens) {
+	tokens.forEach(this.addToken, this);
+};
+
+/**
+ * Marks this Parser instance as completed and retrieves the full AST.
+ * @returns {{}|null} a full expression tree, ready for evaluation by the
+ *      {@link Evaluator#eval} function, or null if no tokens were passed to
+ *      the parser before complete was called
+ * @throws {Error} if the parser is not in a state where it's legal to end
+ *      the expression, indicating that the expression is incomplete
+ */
+Parser.prototype.complete = function() {
+	if (this._cursor && !states[this._state].completable)
+		throw new Error('Unexpected end of expression: ' + this._exprStr);
+	if (this._subParser)
+		this._endSubExpression();
+	this._state = 'complete';
+	return this._cursor ? this._tree : null;
+};
+
+/**
+ * Indicates whether the expression tree contains a relative path identifier.
+ * @returns {boolean} true if a relative identifier exists; false otherwise.
+ */
+Parser.prototype.isRelative = function() {
+	return this._relative;
+};
+
+/**
+ * Ends a subexpression by completing the subParser and passing its result
+ * to the subHandler configured in the current state.
+ * @private
+ */
+Parser.prototype._endSubExpression = function() {
+	states[this._state].subHandler.call(this, this._subParser.complete());
+	this._subParser = null;
+};
+
+/**
+ * Places a new tree node at the current position of the cursor (to the 'right'
+ * property) and then advances the cursor to the new node. This function also
+ * handles setting the parent of the new node.
+ * @param {{type: <string>}} node A node to be added to the AST
+ * @private
+ */
+Parser.prototype._placeAtCursor = function(node) {
+	if (!this._cursor)
+		this._tree = node;
+	else {
+		this._cursor.right = node;
+		this._setParent(node, this._cursor);
+	}
+	this._cursor = node;
+};
+
+/**
+ * Places a tree node before the current position of the cursor, replacing
+ * the node that the cursor currently points to. This should only be called in
+ * cases where the cursor is known to exist, and the provided node already
+ * contains a pointer to what's at the cursor currently.
+ * @param {{type: <string>}} node A node to be added to the AST
+ * @private
+ */
+Parser.prototype._placeBeforeCursor = function(node) {
+	this._cursor = this._cursor._parent;
+	this._placeAtCursor(node);
+};
+
+/**
+ * Sets the parent of a node by creating a non-enumerable _parent property
+ * that points to the supplied parent argument.
+ * @param {{type: <string>}} node A node of the AST on which to set a new
+ *      parent
+ * @param {{type: <string>}} parent An existing node of the AST to serve as the
+ *      parent of the new node
+ * @private
+ */
+Parser.prototype._setParent = function(node, parent) {
+	Object.defineProperty(node, '_parent', {
+		value: parent,
+		writable: true
+	});
+};
+
+/**
+ * Prepares the Parser to accept a subexpression by (re)instantiating the
+ * subParser.
+ * @param {string} [exprStr] The expression string to prefix to the new Parser
+ * @private
+ */
+Parser.prototype._startSubExpression = function(exprStr) {
+	var endStates = states[this._state].endStates;
+	if (!endStates) {
+		this._parentStop = true;
+		endStates = this._stopMap;
+	}
+	this._subParser = new Parser(this._grammar, exprStr, endStates);
+};
+
+module.exports = Parser;
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/handlers.js
@@ -0,0 +1,210 @@
+/*
+ * Jexl
+ * Copyright (c) 2015 TechnologyAdvice
+ */
+
+/**
+ * Handles a subexpression that's used to define a transform argument's value.
+ * @param {{type: <string>}} ast The subexpression tree
+ */
+exports.argVal = function(ast) {
+	this._cursor.args.push(ast);
+};
+
+/**
+ * Handles new array literals by adding them as a new node in the AST,
+ * initialized with an empty array.
+ */
+exports.arrayStart = function() {
+	this._placeAtCursor({
+		type: 'ArrayLiteral',
+		value: []
+	});
+};
+
+/**
+ * Handles a subexpression representing an element of an array literal.
+ * @param {{type: <string>}} ast The subexpression tree
+ */
+exports.arrayVal = function(ast) {
+	if (ast)
+		this._cursor.value.push(ast);
+};
+
+/**
+ * Handles tokens of type 'binaryOp', indicating an operation that has two
+ * inputs: a left side and a right side.
+ * @param {{type: <string>}} token A token object
+ */
+exports.binaryOp = function(token) {
+	var precedence = this._grammar[token.value].precedence || 0,
+		parent = this._cursor._parent;
+	while (parent && parent.operator &&
+			this._grammar[parent.operator].precedence >= precedence) {
+		this._cursor = parent;
+		parent = parent._parent;
+	}
+	var node = {
+		type: 'BinaryExpression',
+		operator: token.value,
+		left: this._cursor
+	};
+	this._setParent(this._cursor, node);
+	this._cursor = parent;
+	this._placeAtCursor(node);
+};
+
+/**
+ * Handles successive nodes in an identifier chain.  More specifically, it
+ * sets values that determine how the following identifier gets placed in the
+ * AST.
+ */
+exports.dot = function() {
+	this._nextIdentEncapsulate = this._cursor &&
+		(this._cursor.type != 'BinaryExpression' ||
+		(this._cursor.type == 'BinaryExpression' && this._cursor.right)) &&
+		this._cursor.type != 'UnaryExpression';
+	this._nextIdentRelative = !this._cursor ||
+		(this._cursor && !this._nextIdentEncapsulate);
+	if (this._nextIdentRelative)
+		this._relative = true;
+};
+
+/**
+ * Handles a subexpression used for filtering an array returned by an
+ * identifier chain.
+ * @param {{type: <string>}} ast The subexpression tree
+ */
+exports.filter = function(ast) {
+	this._placeBeforeCursor({
+		type: 'FilterExpression',
+		expr: ast,
+		relative: this._subParser.isRelative(),
+		subject: this._cursor
+	});
+};
+
+/**
+ * Handles identifier tokens by adding them as a new node in the AST.
+ * @param {{type: <string>}} token A token object
+ */
+exports.identifier = function(token) {
+	var node = {
+		type: 'Identifier',
+		value: token.value
+	};
+	if (this._nextIdentEncapsulate) {
+		node.from = this._cursor;
+		this._placeBeforeCursor(node);
+		this._nextIdentEncapsulate = false;
+	}
+	else {
+		if (this._nextIdentRelative)
+			node.relative = true;
+		this._placeAtCursor(node);
+	}
+};
+
+/**
+ * Handles literal values, such as strings, booleans, and numerics, by adding
+ * them as a new node in the AST.
+ * @param {{type: <string>}} token A token object
+ */
+exports.literal = function(token) {
+	this._placeAtCursor({
+		type: 'Literal',
+		value: token.value
+	});
+};
+
+/**
+ * Queues a new object literal key to be written once a value is collected.
+ * @param {{type: <string>}} token A token object
+ */
+exports.objKey = function(token) {
+	this._curObjKey = token.value;
+};
+
+/**
+ * Handles new object literals by adding them as a new node in the AST,
+ * initialized with an empty object.
+ */
+exports.objStart = function() {
+	this._placeAtCursor({
+		type: 'ObjectLiteral',
+		value: {}
+	});
+};
+
+/**
+ * Handles an object value by adding its AST to the queued key on the object
+ * literal node currently at the cursor.
+ * @param {{type: <string>}} ast The subexpression tree
+ */
+exports.objVal = function(ast) {
+	this._cursor.value[this._curObjKey] = ast;
+};
+
+/**
+ * Handles traditional subexpressions, delineated with the groupStart and
+ * groupEnd elements.
+ * @param {{type: <string>}} ast The subexpression tree
+ */
+exports.subExpression = function(ast) {
+	this._placeAtCursor(ast);
+};
+
+/**
+ * Handles a completed alternate subexpression of a ternary operator.
+ * @param {{type: <string>}} ast The subexpression tree
+ */
+exports.ternaryEnd = function(ast) {
+	this._cursor.alternate = ast;
+};
+
+/**
+ * Handles a completed consequent subexpression of a ternary operator.
+ * @param {{type: <string>}} ast The subexpression tree
+ */
+exports.ternaryMid = function(ast) {
+	this._cursor.consequent = ast;
+};
+
+/**
+ * Handles the start of a new ternary expression by encapsulating the entire
+ * AST in a ConditionalExpression node, and using the existing tree as the
+ * test element.
+ */
+exports.ternaryStart = function() {
+	this._tree = {
+		type: 'ConditionalExpression',
+		test: this._tree
+	};
+	this._cursor = this._tree;
+};
+
+/**
+ * Handles identifier tokens when used to indicate the name of a transform to
+ * be applied.
+ * @param {{type: <string>}} token A token object
+ */
+exports.transform = function(token) {
+	this._placeBeforeCursor({
+		type: 'Transform',
+		name: token.value,
+		args: [],
+		subject: this._cursor
+	});
+};
+
+/**
+ * Handles token of type 'unaryOp', indicating that the operation has only
+ * one input: a right side.
+ * @param {{type: <string>}} token A token object
+ */
+exports.unaryOp = function(token) {
+	this._placeAtCursor({
+		type: 'UnaryExpression',
+		operator: token.value
+	});
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/states.js
@@ -0,0 +1,154 @@
+/*
+ * Jexl
+ * Copyright (c) 2015 TechnologyAdvice
+ */
+
+var h = require('./handlers');
+
+/**
+ * A mapping of all states in the finite state machine to a set of instructions
+ * for handling or transitioning into other states. Each state can be handled
+ * in one of two schemes: a tokenType map, or a subHandler.
+ *
+ * Standard expression elements are handled through the tokenType object. This
+ * is an object map of all legal token types to encounter in this state (and
+ * any unexpected token types will generate a thrown error) to an options
+ * object that defines how they're handled.  The available options are:
+ *
+ *      {string} toState: The name of the state to which to transition
+ *          immediately after handling this token
+ *      {string} handler: The handler function to call when this token type is
+ *          encountered in this state.  If omitted, the default handler
+ *          matching the token's "type" property will be called. If the handler
+ *          function does not exist, no call will be made and no error will be
+ *          generated.  This is useful for tokens whose sole purpose is to
+ *          transition to other states.
+ *
+ * States that consume a subexpression should define a subHandler, the
+ * function to be called with an expression tree argument when the
+ * subexpression is complete. Completeness is determined through the
+ * endStates object, which maps tokens on which an expression should end to the
+ * state to which to transition once the subHandler function has been called.
+ *
+ * Additionally, any state in which it is legal to mark the AST as completed
+ * should have a 'completable' property set to boolean true.  Attempting to
+ * call {@link Parser#complete} in any state without this property will result
+ * in a thrown Error.
+ *
+ * @type {{}}
+ */
+exports.states = {
+	expectOperand: {
+		tokenTypes: {
+			literal: {toState: 'expectBinOp'},
+			identifier: {toState: 'identifier'},
+			unaryOp: {},
+			openParen: {toState: 'subExpression'},
+			openCurl: {toState: 'expectObjKey', handler: h.objStart},
+			dot: {toState: 'traverse'},
+			openBracket: {toState: 'arrayVal', handler: h.arrayStart}
+		}
+	},
+	expectBinOp: {
+		tokenTypes: {
+			binaryOp: {toState: 'expectOperand'},
+			pipe: {toState: 'expectTransform'},
+			dot: {toState: 'traverse'},
+			question: {toState: 'ternaryMid', handler: h.ternaryStart}
+		},
+		completable: true
+	},
+	expectTransform: {
+		tokenTypes: {
+			identifier: {toState: 'postTransform', handler: h.transform}
+		}
+	},
+	expectObjKey: {
+		tokenTypes: {
+			identifier: {toState: 'expectKeyValSep', handler: h.objKey},
+			closeCurl: {toState: 'expectBinOp'}
+		}
+	},
+	expectKeyValSep: {
+		tokenTypes: {
+			colon: {toState: 'objVal'}
+		}
+	},
+	postTransform: {
+		tokenTypes: {
+			openParen: {toState: 'argVal'},
+			binaryOp: {toState: 'expectOperand'},
+			dot: {toState: 'traverse'},
+			openBracket: {toState: 'filter'},
+			pipe: {toState: 'expectTransform'}
+		},
+		completable: true
+	},
+	postTransformArgs: {
+		tokenTypes: {
+			binaryOp: {toState: 'expectOperand'},
+			dot: {toState: 'traverse'},
+			openBracket: {toState: 'filter'},
+			pipe: {toState: 'expectTransform'}
+		},
+		completable: true
+	},
+	identifier: {
+		tokenTypes: {
+			binaryOp: {toState: 'expectOperand'},
+			dot: {toState: 'traverse'},
+			openBracket: {toState: 'filter'},
+			pipe: {toState: 'expectTransform'},
+			question: {toState: 'ternaryMid', handler: h.ternaryStart}
+		},
+		completable: true
+	},
+	traverse: {
+		tokenTypes: {
+			'identifier': {toState: 'identifier'}
+		}
+	},
+	filter: {
+		subHandler: h.filter,
+		endStates: {
+			closeBracket: 'identifier'
+		}
+	},
+	subExpression: {
+		subHandler: h.subExpression,
+		endStates: {
+			closeParen: 'expectBinOp'
+		}
+	},
+	argVal: {
+		subHandler: h.argVal,
+		endStates: {
+			comma: 'argVal',
+			closeParen: 'postTransformArgs'
+		}
+	},
+	objVal: {
+		subHandler: h.objVal,
+		endStates: {
+			comma: 'expectObjKey',
+			closeCurl: 'expectBinOp'
+		}
+	},
+	arrayVal: {
+		subHandler: h.arrayVal,
+		endStates: {
+			comma: 'arrayVal',
+			closeBracket: 'expectBinOp'
+		}
+	},
+	ternaryMid: {
+		subHandler: h.ternaryMid,
+		endStates: {
+			colon: 'ternaryEnd'
+		}
+	},
+	ternaryEnd: {
+		subHandler: h.ternaryEnd,
+		completable: true
+	}
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/.eslintrc.js
@@ -0,0 +1,16 @@
+"use strict";
+
+module.exports = {
+  globals: {
+    Assert: false,
+    BrowserTestUtils: false,
+    add_task: false,
+    is: false,
+    isnot: false,
+    ok: false,
+  },
+  rules: {
+    "spaced-comment": 2,
+    "space-before-function-paren": 2,
+  }
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/TestUtils.jsm
@@ -0,0 +1,21 @@
+/* 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-console */
+this.EXPORTED_SYMBOLS = ["TestUtils"];
+
+this.TestUtils = {
+  promiseTest(test) {
+    return function(assert, done) {
+      test(assert)
+      .catch(err => {
+        console.error(err);
+        assert.ok(false, err);
+      })
+      .then(() => done());
+    };
+  },
+};
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser.ini
@@ -0,0 +1,5 @@
+[browser_driver_uuids.js]
+[browser_env_expressions.js]
+[browser_EventEmitter.js]
+[browser_Storage.js]
+[browser_Heartbeat.js]
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser_EventEmitter.js
@@ -0,0 +1,92 @@
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/Log.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm", this);
+
+const sandboxManager = new SandboxManager();
+sandboxManager.addHold("test running");
+const driver = new NormandyDriver(sandboxManager);
+const sandboxedDriver = Cu.cloneInto(driver, sandboxManager.sandbox, {cloneFunctions: true});
+const eventEmitter = new sandboxManager.sandbox.EventEmitter(sandboxedDriver).wrappedJSObject;
+
+
+const evidence = {
+  a: 0,
+  b: 0,
+  c: 0,
+  log: "",
+};
+
+function listenerA(x = 1) {
+  evidence.a += x;
+  evidence.log += "a";
+}
+
+function listenerB(x = 1) {
+  evidence.b += x;
+  evidence.log += "b";
+}
+
+function listenerC(x = 1) {
+  evidence.c += x;
+  evidence.log += "c";
+}
+
+add_task(function* () {
+  // 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);
+
+  // 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");
+
+  // Spin the event loop to run events, so we can safely "off"
+  yield Promise.resolve();
+
+  // 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);
+
+  // And another unrelated event
+  eventEmitter.on("nothing");
+
+  // Spin the event loop to run events
+  yield Promise.resolve();
+
+  Assert.deepEqual(evidence, {
+    a: 111,
+    b: 11,
+    c: 1,
+    log: "abcaba",  // events are in order
+  }, "events fired as expected");
+
+  sandboxManager.removeHold("test running");
+
+  yield sandboxManager.isNuked()
+    .then(() => ok(true, "sandbox is nuked"))
+    .catch(e => ok(false, "sandbox is nuked", e));
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser_Heartbeat.js
@@ -0,0 +1,188 @@
+"use strict";
+
+const {utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Services.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/Heartbeat.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm", this);
+
+/**
+ * Assert an array is in non-descending order, and that every element is a number
+ */
+function assertOrdered(arr) {
+  for (let i = 0; i < arr.length; i++) {
+    Assert.equal(typeof arr[i], "number", `element ${i} is type "number"`);
+  }
+  for (let i = 0; i < arr.length - 1; i++) {
+    Assert.lessOrEqual(arr[i], arr[i + 1],
+      `element ${i} is less than or equal to element ${i + 1}`);
+  }
+}
+
+/* Close every notification in a target window and notification box */
+function closeAllNotifications(targetWindow, notificationBox) {
+  if (notificationBox.allNotifications.length === 0) {
+    return Promise.resolve();
+  }
+
+
+  return new Promise(resolve => {
+    const notificationSet = new Set(notificationBox.allNotifications);
+
+    const observer = new targetWindow.MutationObserver(mutations => {
+      for (const mutation of mutations) {
+        for (let i = 0; i < mutation.removedNodes.length; i++) {
+          const node = mutation.removedNodes.item(i);
+          if (notificationSet.has(node)) {
+            notificationSet.delete(node);
+          }
+        }
+      }
+      if (notificationSet.size === 0) {
+        Assert.equal(notificationBox.allNotifications.length, 0, "No notifications left");
+        observer.disconnect();
+        resolve();
+      }
+    });
+
+    observer.observe(notificationBox, {childList: true});
+
+    for (const notification of notificationBox.allNotifications) {
+      notification.close();
+    }
+  });
+}
+
+/* Check that the correct telmetry was sent */
+function assertTelemetrySent(hb, eventNames) {
+  return new Promise(resolve => {
+    hb.eventEmitter.once("TelemetrySent", payload => {
+      const events = [0];
+      for (const name of eventNames) {
+        Assert.equal(typeof payload[name], "number", `payload field ${name} is a number`);
+        events.push(payload[name]);
+      }
+      events.push(Date.now());
+
+      assertOrdered(events);
+      resolve();
+    });
+  });
+}
+
+
+const sandboxManager = new SandboxManager();
+const driver = new NormandyDriver(sandboxManager);
+sandboxManager.addHold("test running");
+const sandboxedDriver = Cu.cloneInto(driver, sandboxManager.sandbox, {cloneFunctions: true});
+
+
+// Several of the behaviors of heartbeat prompt are mutually exclusive, so checks are broken up
+// into three batches.
+
+/* Batch #1 - General UI, Stars, and telemetry data */
+add_task(function* () {
+  const eventEmitter = new sandboxManager.sandbox.EventEmitter(sandboxedDriver).wrappedJSObject;
+  const targetWindow = Services.wm.getMostRecentWindow("navigator:browser");
+  const notificationBox = targetWindow.document.querySelector("#high-priority-global-notificationbox");
+
+  const preCount = notificationBox.childElementCount;
+  const hb = new Heartbeat(targetWindow, eventEmitter, sandboxManager, {
+    testing: true,
+    flowId: "test",
+    message: "test",
+    engagementButtonLabel: undefined,
+    learnMoreMessage: "Learn More",
+    learnMoreUrl: "https://example.org/learnmore",
+  });
+
+  // Check UI
+  const learnMoreEl = hb.notice.querySelector(".text-link");
+  const messageEl = targetWindow.document.getAnonymousElementByAttribute(hb.notice, "anonid", "messageText");
+  Assert.equal(notificationBox.childElementCount, preCount + 1, "Correct number of notifications open");
+  Assert.equal(hb.notice.querySelectorAll(".star-x").length, 5, "Correct number of stars");
+  Assert.equal(hb.notice.querySelectorAll(".notification-button").length, 0, "Engagement button not shown");
+  Assert.equal(learnMoreEl.href, "https://example.org/learnmore", "Learn more url correct");
+  Assert.equal(learnMoreEl.value, "Learn More", "Learn more label correct");
+  Assert.equal(messageEl.textContent, "test", "Message is correct");
+
+  // Check that when clicking the learn more link, a tab opens with the right URL
+  const tabOpenPromise = BrowserTestUtils.waitForNewTab(targetWindow.gBrowser);
+  learnMoreEl.click();
+  const tab = yield tabOpenPromise;
+  const tabUrl = yield BrowserTestUtils.browserLoaded(
+    tab.linkedBrowser, true, url => url && url !== "about:blank");
+
+  Assert.equal(tabUrl, "https://example.org/learnmore", "Learn more link opened the right url");
+
+  const telemetrySentPromise = assertTelemetrySent(hb, ["offeredTS", "learnMoreTS", "closedTS"]);
+  // Close notification to trigger telemetry to be sent
+  yield closeAllNotifications(targetWindow, notificationBox);
+  yield telemetrySentPromise;
+  yield BrowserTestUtils.removeTab(tab);
+});
+
+
+// Batch #2 - Engagement buttons
+add_task(function* () {
+  const eventEmitter = new sandboxManager.sandbox.EventEmitter(sandboxedDriver).wrappedJSObject;
+  const targetWindow = Services.wm.getMostRecentWindow("navigator:browser");
+  const notificationBox = targetWindow.document.querySelector("#high-priority-global-notificationbox");
+  const hb = new Heartbeat(targetWindow, eventEmitter, sandboxManager, {
+    testing: true,
+    flowId: "test",
+    message: "test",
+    engagementButtonLabel: "Click me!",
+    postAnswerUrl: "https://example.org/postAnswer",
+    learnMoreMessage: "Learn More",
+    learnMoreUrl: "https://example.org/learnMore",
+  });
+  const engagementButton = hb.notice.querySelector(".notification-button");
+
+  Assert.equal(hb.notice.querySelectorAll(".star-x").length, 0, "Stars not shown");
+  Assert.ok(engagementButton, "Engagement button added");
+  Assert.equal(engagementButton.label, "Click me!", "Engagement button has correct label");
+
+  const engagementEl = hb.notice.querySelector(".notification-button");
+  const tabOpenPromise = BrowserTestUtils.waitForNewTab(targetWindow.gBrowser);
+  engagementEl.click();
+  const tab = yield tabOpenPromise;
+  const tabUrl = yield BrowserTestUtils.browserLoaded(
+        tab.linkedBrowser, true, url => url && url !== "about:blank");
+  // the postAnswer url gets query parameters appended onto the end, so use Assert.startsWith instead of Assert.equal
+  Assert.ok(tabUrl.startsWith("https://example.org/postAnswer"), "Engagement button opened the right url");
+
+  const telemetrySentPromise = assertTelemetrySent(hb, ["offeredTS", "engagedTS", "closedTS"]);
+  // Close notification to trigger telemetry to be sent
+  yield closeAllNotifications(targetWindow, notificationBox);
+  yield telemetrySentPromise;
+  yield BrowserTestUtils.removeTab(tab);
+});
+
+// Batch 3 - Closing the window while heartbeat is open
+add_task(function* () {
+  const eventEmitter = new sandboxManager.sandbox.EventEmitter(sandboxedDriver).wrappedJSObject;
+  const targetWindow = yield BrowserTestUtils.openNewBrowserWindow();
+
+  const hb = new Heartbeat(targetWindow, eventEmitter, sandboxManager, {
+    testing: true,
+    flowId: "test",
+    message: "test",
+  });
+
+  const telemetrySentPromise = assertTelemetrySent(hb, ["offeredTS", "windowClosedTS"]);
+  // triggers sending ping to normandy
+  yield BrowserTestUtils.closeWindow(targetWindow);
+  yield telemetrySentPromise;
+});
+
+
+// Cleanup
+add_task(function* () {
+  // Make sure the sandbox is clean.
+  sandboxManager.removeHold("test running");
+  yield sandboxManager.isNuked()
+    .then(() => ok(true, "sandbox is nuked"))
+    .catch(e => ok(false, "sandbox is nuked", e));
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser_Storage.js
@@ -0,0 +1,37 @@
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://shield-recipe-client/lib/Storage.jsm", this);
+
+const fakeSandbox = {Promise};
+const store1 = Storage.makeStorage("prefix1", fakeSandbox);
+const store2 = Storage.makeStorage("prefix2", fakeSandbox);
+
+add_task(function* () {
+  // Make sure values return null before being set
+  Assert.equal(yield store1.getItem("key"), null);
+  Assert.equal(yield store2.getItem("key"), null);
+
+  // Set values to check
+  yield store1.setItem("key", "value1");
+  yield store2.setItem("key", "value2");
+
+  // Check that they are available
+  Assert.equal(yield store1.getItem("key"), "value1");
+  Assert.equal(yield store2.getItem("key"), "value2");
+
+  // Remove them, and check they are gone
+  yield store1.removeItem("key");
+  yield store2.removeItem("key");
+  Assert.equal(yield store1.getItem("key"), null);
+  Assert.equal(yield store2.getItem("key"), null);
+
+  // Check that numbers are stored as numbers (not strings)
+  yield store1.setItem("number", 42);
+  Assert.equal(yield store1.getItem("number"), 42);
+
+  // Check complex types work
+  const complex = {a: 1, b: [2, 3], c: {d: 4}};
+  yield store1.setItem("complex", complex);
+  Assert.deepEqual(yield store1.getItem("complex"), complex);
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser_driver_uuids.js
@@ -0,0 +1,26 @@
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm", this);
+Cu.import("resource://shield-recipe-client/lib/SandboxManager.jsm", this);
+
+add_task(function* () {
+  const sandboxManager = new SandboxManager();
+  sandboxManager.addHold("test running");
+  let driver = new NormandyDriver(sandboxManager);
+
+  // Test that UUID look about right
+  const uuid1 = driver.uuid();
+  ok(/^[a-f0-9-]{36}$/.test(uuid1), "valid uuid format");
+
+  // Test that UUIDs are different each time
+  const uuid2 = driver.uuid();
+  isnot(uuid1, uuid2, "uuids are unique");
+
+  driver = null;
+  sandboxManager.removeHold("test running");
+
+  yield sandboxManager.isNuked()
+    .then(() => ok(true, "sandbox is nuked"))
+    .catch(e => ok(false, "sandbox is nuked", e));
+});
new file mode 100644
--- /dev/null
+++ b/browser/extensions/shield-recipe-client/test/browser_env_expressions.js
@@ -0,0 +1,56 @@
+"use strict";
+
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/TelemetryController.jsm", this);
+Cu.import("resource://gre/modules/Task.jsm", this);
+
+Cu.import("resource://shield-recipe-client/lib/EnvExpressions.jsm", this);
+Cu.import("resource://gre/modules/Log.jsm", this);
+
+add_task(function* () {
+  // setup
+  yield TelemetryController.submitExternalPing("testfoo", {foo: 1});
+  yield TelemetryController.submitExternalPing("testbar", {bar: 2});
+
+  let val;
+  // Test that basic expressions work
+  val = yield EnvExpressions.eval("2+2");
+  is(val, 4, "basic expression works");
+
+  // Test that multiline expressions work
+  val = yield EnvExpressions.eval(`
+    2
+    +
+    2
+  `);
+  is(val, 4, "multiline expression works");
+
+  // Test it can access telemetry
+  val = yield EnvExpressions.eval("telemetry");
+  is(typeof val, "object", "Telemetry is accesible");
+
+  // Test it reads different types of telemetry
+  val = yield EnvExpressions.eval("telemetry");
+  is(val.testfoo.payload.foo, 1, "value 'foo' is in mock telemetry");
+  is(val.testbar.payload.bar, 2, "value 'bar' is in mock telemetry");
+
+  // Test has a date transform
+  val = yield EnvExpressions.eval('"2016-04-22"|date');
+  const d = new Date(Date.UTC(2016, 3, 22)); // months are 0 based
+  is(val.toString(), d.toString(), "Date transform works");
+
+  // Test dates are comparable
+  const context = {someTime: Date.UTC(2016, 0, 1)};
+  val = yield EnvExpressions.eval('"2015-01-01"|date < someTime', context);
+  ok(val, "dates are comparable with less-than");
+  val = yield EnvExpressions.eval('"2017-01-01"|date > someTime', context);
+  ok(val, "dates are comparable with greater-than");
+
+  // Test stable sample returns true for matching samples
+  val = yield EnvExpressions.eval('["test"]|stableSample(1)');
+  is(val, true, "Stable sample returns true for 100% sample");
+
+  // Test stable sample returns true for matching samples
+  val = yield EnvExpressions.eval('["test"]|stableSample(0)');
+  is(val, false, "Stable sample returns false for 0% sample");
+});
--- a/devtools/client/debugger/test/mochitest/head.js
+++ b/devtools/client/debugger/test/mochitest/head.js
@@ -1,9 +1,9 @@
-/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+  /* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set ft=javascript ts=2 et sw=2 tw=80: */
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
 // shared-head.js handles imports, constants, and utility functions
 Services.scriptloader.loadSubScript("chrome://mochitests/content/browser/devtools/client/framework/test/shared-head.js", this);
@@ -1343,9 +1343,8 @@ function* initWorkerDebugger(TAB_URL, WO
                                             "jsdebugger",
                                             Toolbox.HostType.WINDOW);
 
   let debuggerPanel = toolbox.getCurrentPanel();
   let gDebugger = debuggerPanel.panelWin;
 
   return {client, tab, tabClient, workerClient, toolbox, gDebugger};
 }
-
--- a/layout/tools/reftest/reftest-preferences.js
+++ b/layout/tools/reftest/reftest-preferences.js
@@ -56,16 +56,17 @@ user_pref("layout.interruptible-reflow.e
 // Tell the search service we are running in the US.  This also has the
 // desired side-effect of preventing our geoip lookup.
 user_pref("browser.search.isUS", true);
 user_pref("browser.search.countryCode", "US");
 user_pref("browser.search.geoSpecificDefaults", false);
 
 // Make sure SelfSupport doesn't hit the network.
 user_pref("browser.selfsupport.url", "https://localhost/selfsupport-dummy/");
+user_pref("extensions.shield-recipe-client.api_url", "https://localhost/selfsupport-dummy/");
 
 // use about:blank, not browser.startup.homepage
 user_pref("browser.startup.page", 0);
 
 // Allow XUL and XBL files to be opened from file:// URIs
 user_pref("dom.allow_XUL_XBL_for_file", true);
 
 // Allow view-source URIs to be opened from URIs that share
--- a/testing/profiles/prefs_general.js
+++ b/testing/profiles/prefs_general.js
@@ -296,18 +296,19 @@ user_pref("browser.uitour.url", "http://
 
 // Tell the search service we are running in the US.  This also has the desired
 // side-effect of preventing our geoip lookup.
 user_pref("browser.search.isUS", true);
 user_pref("browser.search.countryCode", "US");
 // This will prevent HTTP requests for region defaults.
 user_pref("browser.search.geoSpecificDefaults", false);
 
-// Make sure the self support tab doesn't hit the network.
+// Make sure self support doesn't hit the network.
 user_pref("browser.selfsupport.url", "https://%(server)s/selfsupport-dummy/");
+user_pref("extensions.shield-recipe-client.api_url", "https://%(server)s/selfsupport-dummy/");
 
 user_pref("media.eme.enabled", true);
 
 user_pref("media.autoplay.enabled", true);
 
 // Don't use auto-enabled e10s
 user_pref("browser.tabs.remote.autostart.1", false);
 user_pref("browser.tabs.remote.autostart.2", false);
--- a/testing/talos/talos/config.py
+++ b/testing/talos/talos/config.py
@@ -138,16 +138,18 @@ DEFAULTS = dict(
             'http://127.0.0.1/extensions-dummy/repositoryBrowseURL',
         'extensions.getAddons.search.url':
             'http://127.0.0.1/extensions-dummy/repositorySearchURL',
         'media.gmp-manager.url':
             'http://127.0.0.1/gmpmanager-dummy/update.xml',
         'media.gmp-manager.updateEnabled': False,
         'extensions.systemAddon.update.url':
             'http://127.0.0.1/dummy-system-addons.xml',
+        'extensions.shield-recipe-client.api_url':
+            'https://127.0.0.1/selfsupport-dummy/',
         'media.navigator.enabled': True,
         'media.peerconnection.enabled': True,
         'media.navigator.permission.disabled': True,
         'media.capturestream_hints.enabled': True,
         'browser.contentHandlers.types.0.uri': 'http://127.0.0.1/rss?url=%s',
         'browser.contentHandlers.types.1.uri': 'http://127.0.0.1/rss?url=%s',
         'browser.contentHandlers.types.2.uri': 'http://127.0.0.1/rss?url=%s',
         'browser.contentHandlers.types.3.uri': 'http://127.0.0.1/rss?url=%s',
--- a/testing/talos/talos/xtalos/xperf_whitelist.json
+++ b/testing/talos/talos/xtalos/xperf_whitelist.json
@@ -9,16 +9,17 @@
  "{firefox}\\browser\\features\\aushelper@mozilla.org.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
  "{firefox}\\browser\\features\\e10srollout@mozilla.org.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
  "{firefox}\\browser\\features\\flyweb@mozilla.org.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
  "{firefox}\\browser\\features\\formautofill@mozilla.org.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
  "{firefox}\\browser\\features\\loop@mozilla.org.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
  "{firefox}\\browser\\features\\firefox@getpocket.com.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
  "{firefox}\\browser\\features\\presentation@mozilla.org.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
  "{firefox}\\browser\\features\\webcompat@mozilla.org.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
+ "{firefox}\\browser\\features\\shield-recipe-client@mozilla.org.xpi": {"mincount": 0, "maxcount": 100, "minbytes": 0, "maxbytes": 10000000},
  "{talos}\\tests\\tp5n\\tp5n.manifest": {"mincount": 0, "maxcount": 8, "minbytes": 0, "maxbytes": 32786},
  "{talos}\\talos\\tests\\tp5n\\tp5n.manifest": {"mincount": 0, "maxcount": 8, "minbytes": 0, "maxbytes": 32786},
  "{talos}\\tests\\tp5n\\tp5n.manifest.develop": {"mincount": 0, "maxcount": 8, "minbytes": 0, "maxbytes": 32786},
  "{talos}\\talos\\tests\\tp5n\\tp5n.manifest.develop": {"mincount": 0, "maxcount": 8, "minbytes": 0, "maxbytes": 32786},
  "{profile}\\localstore.rdf": {"mincount": 2, "maxcount": 2, "minbytes": 8192, "maxbytes": 8192},
  "{firefox}\\dependentlibs.list": {"mincount": 4, "maxcount": 4, "minbytes": 16384, "maxbytes": 16384},
  "{profile}\\content-prefs.sqlite": {"mincount": 6, "maxcount": 6, "minbytes": 65768, "maxbytes": 65768},
  "{profile}\\extensions.ini": {"mincount": 2, "maxcount": 2, "minbytes": 8192, "maxbytes": 8192},
--- a/testing/xpcshell/head.js
+++ b/testing/xpcshell/head.js
@@ -1603,27 +1603,28 @@ try {
     // Always use network provider for geolocation tests
     // so we bypass the OSX dialog raised by the corelocation provider
     let prefs = Components.classes["@mozilla.org/preferences-service;1"]
       .getService(Components.interfaces.nsIPrefBranch);
 
     prefs.setBoolPref("geo.provider.testing", true);
   }
 } catch (e) { }
-
 // We need to avoid hitting the network with certain components.
 try {
   if (runningInParent) {
     let prefs = Components.classes["@mozilla.org/preferences-service;1"]
       .getService(Components.interfaces.nsIPrefBranch);
 
     prefs.setCharPref("media.gmp-manager.url.override", "http://%(server)s/dummy-gmp-manager.xml");
     prefs.setCharPref("media.gmp-manager.updateEnabled", false);
     prefs.setCharPref("extensions.systemAddon.update.url", "http://%(server)s/dummy-system-addons.xml");
     prefs.setCharPref("browser.selfsupport.url", "https://%(server)s/selfsupport-dummy/");
+    prefs.setCharPref("extensions.shield-recipe-client.api_url",
+                      "https://%(server)s/selfsupport-dummy/");
     prefs.setCharPref("toolkit.telemetry.server", "https://%(server)s/telemetry-dummy");
     prefs.setCharPref("browser.search.geoip.url", "https://%(server)s/geoip-dummy");
   }
 } catch (e) { }
 
 // Make tests run consistently on DevEdition (which has a lightweight theme
 // selected by default).
 try {