Bug 1308656 - Add shield-recipe-client as system add-on r=Gijs,rhelmer
authorMythmon <mcooper@mozilla.com>
Mon, 10 Oct 2016 16:14:56 -0700
changeset 371835 b35d6cb8df5dfa3cc762bfc81aac0736acca121d
parent 371834 31cb40156b9d96323907c5406f224c7ba1ecac2f
child 371836 21ce95f1dfa8e77ca8a5fb81064b8c9712ca3222
push id6996
push userjlorenzo@mozilla.com
push dateMon, 06 Mar 2017 20:48:21 +0000
treeherdermozilla-beta@d89512dab048 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersGijs, rhelmer
bugs1308656
milestone53.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1308656 - Add shield-recipe-client as system add-on r=Gijs,rhelmer 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
@@ -57,16 +57,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
@@ -298,18 +298,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 {