Bug 1466933 - Implement FxA commands. r?markh, rfkelly draft
authorEdouard Oger <eoger@fastmail.com>
Wed, 30 May 2018 17:23:56 -0400
changeset 812674 fb141e270765a2a4be35bc258e15b31c8c3979df
parent 812548 a009b5249a4b78a889fdc5ffcf55ad51715cc686
push id114633
push userbmo:eoger@fastmail.com
push dateFri, 29 Jun 2018 19:35:22 +0000
reviewersmarkh, rfkelly
bugs1466933
milestone63.0a1
Bug 1466933 - Implement FxA commands. r?markh, rfkelly MozReview-Commit-ID: EXLO3vnu9vB
browser/app/profile/firefox.js
browser/base/content/browser-sync.js
browser/components/nsBrowserGlue.js
services/fxaccounts/FxAccounts.jsm
services/fxaccounts/FxAccountsClient.jsm
services/fxaccounts/FxAccountsCommands.js
services/fxaccounts/FxAccountsCommon.js
services/fxaccounts/FxAccountsMessages.js
services/fxaccounts/FxAccountsPush.js
services/fxaccounts/FxAccountsWebChannel.jsm
services/fxaccounts/moz.build
services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js
services/fxaccounts/tests/xpcshell/test_commands.js
services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js
services/fxaccounts/tests/xpcshell/test_messages.js
services/fxaccounts/tests/xpcshell/test_push_service.js
services/fxaccounts/tests/xpcshell/xpcshell.ini
services/sync/modules/policies.js
tools/lint/eslint/modules.json
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1407,18 +1407,18 @@ pref("identity.mobilepromo.ios", "https:
 // Migrate any existing Firefox Account data from the default profile to the
 // Developer Edition profile.
 #ifdef MOZ_DEV_EDITION
 pref("identity.fxaccounts.migrateToDevEdition", true);
 #else
 pref("identity.fxaccounts.migrateToDevEdition", false);
 #endif
 
-// If activated, send tab will use the new FxA messages backend.
-pref("identity.fxaccounts.messages.enabled", false);
+// If activated, send tab will use the new FxA commands backend.
+pref("identity.fxaccounts.commands.enabled", false);
 
 // On GTK, we now default to showing the menubar only when alt is pressed:
 #ifdef MOZ_WIDGET_GTK
 pref("ui.key.menuAccessKeyFocuses", true);
 #endif
 
 #ifdef NIGHTLY_BUILD
 pref("media.eme.vp9-in-mp4.enabled", true);
--- a/browser/base/content/browser-sync.js
+++ b/browser/base/content/browser-sync.js
@@ -322,34 +322,49 @@ var gSync = {
   async sendTabToDevice(url, clients, title) {
     let devices;
     try {
       devices = await fxAccounts.getDeviceList();
     } catch (e) {
       console.error("Could not get the FxA device list", e);
       devices = []; // We can still run in degraded mode.
     }
-    const toSendMessages = [];
+    const fxaCommandsDevices = [];
+    const oldSendTabClients = [];
     for (const client of clients) {
       const device = devices.find(d => d.id == client.fxaDeviceId);
-      if (device && fxAccounts.messages.canReceiveSendTabMessages(device)) {
-        toSendMessages.push(device);
+      if (!device) {
+        console.error(`Could not find associated FxA device for ${client.name}`);
+        continue;
+      } else if ((await fxAccounts.commands.sendTab.isDeviceCompatible(device))) {
+        fxaCommandsDevices.push(device);
       } else {
-        try {
-          await Weave.Service.clientsEngine.sendURIToClientForDisplay(url, client.id, title);
-        } catch (e) {
-          console.error("Could not send tab to device", e);
-        }
+        oldSendTabClients.push(client);
       }
     }
-    if (toSendMessages.length) {
+    if (fxaCommandsDevices.length) {
+      console.log(`Sending a tab to ${fxaCommandsDevices.map(d => d.name).join(", ")} using FxA commands.`);
+      const report = await fxAccounts.commands.sendTab.send(fxaCommandsDevices, {url, title});
+      for (let {device, error} of report.failed) {
+        console.error(`Failed to send a tab with FxA commands for ${device.name}.
+                       Falling back on the Sync back-end`, error);
+        const client = clients.find(c => c.fxaDeviceId == device.id);
+        if (!client) {
+          console.error(`Could not find associated Sync device for ${device.name}`);
+          continue;
+        }
+        oldSendTabClients.push(client);
+      }
+    }
+    for (let client of oldSendTabClients) {
       try {
-        await fxAccounts.messages.sendTab(toSendMessages, {url, title});
+        console.log(`Sending a tab to ${client.name} using Sync.`);
+        await Weave.Service.clientsEngine.sendURIToClientForDisplay(url, client.id, title);
       } catch (e) {
-        console.error("Could not send tab to device", e);
+        console.error("Could not send tab to device.", e);
       }
     }
   },
 
   populateSendTabToDevicesMenu(devicesPopup, url, title, createDeviceNodeFn) {
     if (!createDeviceNodeFn) {
       createDeviceNodeFn = (clientId, name, clientType, lastModified) => {
         let eltName = name ? "menuitem" : "menuseparator";
@@ -586,17 +601,23 @@ var gSync = {
   // via the various UI components.
   doSync() {
     if (!UIState.isReady()) {
       return;
     }
     const state = UIState.get();
     if (state.status == UIState.STATUS_SIGNED_IN) {
       this.updateSyncStatus({ syncing: true });
-      Services.tm.dispatchToMainThread(() => Weave.Service.sync());
+      Services.tm.dispatchToMainThread(() => {
+        // We are pretty confident that push helps us pick up all FxA commands,
+        // but some users might have issues with push, so let's unblock them
+        // by fetching the missed FxA commands on manual sync.
+        fxAccounts.commands.fetchMissedRemoteCommands();
+        Weave.Service.sync();
+      });
     }
   },
 
   openPrefs(entryPoint = "syncbutton", origin = undefined) {
     window.openPreferences("paneSync", { origin, urlParams: { entrypoint: entryPoint } });
   },
 
   openSyncedTabsPanel() {
--- a/browser/components/nsBrowserGlue.js
+++ b/browser/components/nsBrowserGlue.js
@@ -481,17 +481,17 @@ BrowserGlue.prototype = {
         this._onVerifyLoginNotification(JSON.parse(data));
         break;
       case "fxaccounts:device_disconnected":
         data = JSON.parse(data);
         if (data.isLocalDevice) {
           this._onDeviceDisconnected();
         }
         break;
-      case "fxaccounts:messages:display-tabs":
+      case "fxaccounts:commands:open-uri":
       case "weave:engine:clients:display-uris":
         this._onDisplaySyncURIs(subject);
         break;
       case "session-save":
         this._setPrefToSaveSession(true);
         subject.QueryInterface(Ci.nsISupportsPRBool);
         subject.data = true;
         break;
@@ -636,17 +636,17 @@ BrowserGlue.prototype = {
       os.addObserver(this, "browser-lastwindow-close-requested");
       os.addObserver(this, "browser-lastwindow-close-granted");
     }
     os.addObserver(this, "weave:service:ready");
     os.addObserver(this, "fxaccounts:onverified");
     os.addObserver(this, "fxaccounts:device_connected");
     os.addObserver(this, "fxaccounts:verify_login");
     os.addObserver(this, "fxaccounts:device_disconnected");
-    os.addObserver(this, "fxaccounts:messages:display-tabs");
+    os.addObserver(this, "fxaccounts:commands:open-uri");
     os.addObserver(this, "weave:engine:clients:display-uris");
     os.addObserver(this, "session-save");
     os.addObserver(this, "places-init-complete");
     os.addObserver(this, "distribution-customization-complete");
     os.addObserver(this, "handle-xul-text-link");
     os.addObserver(this, "profile-before-change");
     os.addObserver(this, "keyword-search");
     os.addObserver(this, "browser-search-engine-modified");
@@ -679,17 +679,17 @@ BrowserGlue.prototype = {
       os.removeObserver(this, "browser-lastwindow-close-requested");
       os.removeObserver(this, "browser-lastwindow-close-granted");
     }
     os.removeObserver(this, "weave:service:ready");
     os.removeObserver(this, "fxaccounts:onverified");
     os.removeObserver(this, "fxaccounts:device_connected");
     os.removeObserver(this, "fxaccounts:verify_login");
     os.removeObserver(this, "fxaccounts:device_disconnected");
-    os.removeObserver(this, "fxaccounts:messages:display-tabs");
+    os.removeObserver(this, "fxaccounts:commands:open-uri");
     os.removeObserver(this, "weave:engine:clients:display-uris");
     os.removeObserver(this, "session-save");
     if (this._bookmarksBackupIdleTime) {
       this._idleService.removeIdleObserver(this, this._bookmarksBackupIdleTime);
       delete this._bookmarksBackupIdleTime;
     }
     if (this._lateTasksIdleObserver) {
       this._idleService.removeIdleObserver(this._lateTasksIdleObserver, LATE_TASKS_IDLE_TIME_SEC);
--- a/services/fxaccounts/FxAccounts.jsm
+++ b/services/fxaccounts/FxAccounts.jsm
@@ -21,34 +21,35 @@ ChromeUtils.defineModuleGetter(this, "Fx
   "resource://gre/modules/FxAccountsConfig.jsm");
 
 ChromeUtils.defineModuleGetter(this, "jwcrypto",
   "resource://services-crypto/jwcrypto.jsm");
 
 ChromeUtils.defineModuleGetter(this, "FxAccountsOAuthGrantClient",
   "resource://gre/modules/FxAccountsOAuthGrantClient.jsm");
 
-ChromeUtils.defineModuleGetter(this, "FxAccountsMessages",
-  "resource://gre/modules/FxAccountsMessages.js");
+ChromeUtils.defineModuleGetter(this, "FxAccountsCommands",
+  "resource://gre/modules/FxAccountsCommands.js");
 
 ChromeUtils.defineModuleGetter(this, "FxAccountsProfile",
   "resource://gre/modules/FxAccountsProfile.jsm");
 
 ChromeUtils.defineModuleGetter(this, "Utils",
   "resource://services-sync/util.js");
 
 XPCOMUtils.defineLazyPreferenceGetter(this, "FXA_ENABLED",
     "identity.fxaccounts.enabled", true);
 
 // All properties exposed by the public FxAccounts API.
 var publicProperties = [
   "_withCurrentAccountState", // fxaccounts package only!
   "accountStatus",
   "canGetKeys",
   "checkVerificationStatus",
+  "commands",
   "getAccountsClient",
   "getAssertion",
   "getDeviceId",
   "getDeviceList",
   "getKeys",
   "getOAuthToken",
   "getProfileCache",
   "getPushSubscription",
@@ -56,17 +57,16 @@ var publicProperties = [
   "getSignedInUserProfile",
   "handleAccountDestroyed",
   "handleDeviceDisconnection",
   "handleEmailUpdated",
   "hasLocalSession",
   "invalidateCertificate",
   "loadAndPoll",
   "localtimeOffsetMsec",
-  "messages",
   "notifyDevices",
   "now",
   "removeCachedOAuthToken",
   "resendVerificationEmail",
   "resetCredentials",
   "sessionStatus",
   "setProfileCache",
   "setSignedInUser",
@@ -407,32 +407,32 @@ FxAccountsInternal.prototype = {
       this._profile = new FxAccountsProfile({
         fxa: this,
         profileServerUrl,
       });
     }
     return this._profile;
   },
 
-  _messages: null,
-  get messages() {
-    if (!this._messages) {
-      this._messages = new FxAccountsMessages(this);
+  _commands: null,
+  get commands() {
+    if (!this._commands) {
+      this._commands = new FxAccountsCommands(this);
     }
-    return this._messages;
+    return this._commands;
   },
 
   // A hook-point for tests who may want a mocked AccountState or mocked storage.
   newAccountState(credentials) {
     let storage = new FxAccountsStorageManager();
     storage.initialize(credentials);
     return new AccountState(storage);
   },
 
-  // "Friend" classes of FxAccounts (e.g. FxAccountsMessages) know about the
+  // "Friend" classes of FxAccounts (e.g. FxAccountsCommands) know about the
   // "current account state" system. This method allows them to read and write
   // safely in it.
   // Example of usage:
   // fxAccounts._withCurrentAccountState(async (getUserData, updateUserData) => {
   //   const userData = await getUserData(['device']);
   //   ...
   //   await updateUserData({device: null});
   // });
@@ -601,19 +601,19 @@ FxAccountsInternal.prototype = {
     // We're telling the caller that this is durable now (although is that
     // really something we should commit to? Why not let the write happen in
     // the background? Already does for updateAccountData ;)
     await currentAccountState.promiseInitialized;
     // Starting point for polling if new user
     if (!this.isUserEmailVerified(credentials)) {
       this.startVerifiedCheck(credentials);
     }
-    await this.updateDeviceRegistration();
     Services.telemetry.getHistogramById("FXA_CONFIGURED").add(1);
     await this.notifyObservers(ONLOGIN_NOTIFICATION);
+    await this.updateDeviceRegistration();
     return currentAccountState.resolve();
   },
 
   /**
    * Update account data for the currently signed in user.
    *
    * @param credentials
    *        The credentials object containing the fields to be updated.
@@ -701,27 +701,34 @@ FxAccountsInternal.prototype = {
         device: {
           id: data.deviceId,
           registrationVersion: data.deviceRegistrationVersion
         }
       });
       data = await this.currentAccountState.getUserAccountData();
     }
     const {device} = data;
-    if (!device || !device.registrationVersion ||
-        device.registrationVersion < this.DEVICE_REGISTRATION_VERSION) {
-      // There is no device registered or the device registration is outdated.
-      // Either way, we should register the device with FxA
-      // before returning the id to the caller.
+    if ((await this.checkDeviceUpdateNeeded(device))) {
       return this._registerOrUpdateDevice(data);
     }
     // Return the device id that we already registered with the server.
     return device.id;
   },
 
+  async checkDeviceUpdateNeeded(device) {
+    // There is no device registered or the device registration is outdated.
+    // Either way, we should register the device with FxA
+    // before returning the id to the caller.
+    const availableCommandsKeys = Object.keys((await this.availableCommands())).sort();
+    return !device || !device.registrationVersion ||
+           device.registrationVersion < this.DEVICE_REGISTRATION_VERSION ||
+           !device.registeredCommandsKeys ||
+           !CommonUtils.arrayEqual(device.registeredCommandsKeys, availableCommandsKeys);
+  },
+
   async getDeviceList() {
     const accountData = await this._getVerifiedAccountOrReject();
     const devices = await this.fxAccountsClient.getDeviceList(accountData.sessionToken);
 
     // Check if our push registration is still good.
     const ourDevice = devices.find(device => device.isCurrentDevice);
     if (ourDevice.pushEndpointExpired) {
       await this.fxaPushService.unsubscribe();
@@ -762,18 +769,18 @@ FxAccountsInternal.prototype = {
       log.debug("Polling aborted; Another user signing in");
       clearTimeout(this.currentTimer);
       this.currentTimer = 0;
     }
     if (this._profile) {
       this._profile.tearDown();
       this._profile = null;
     }
-    if (this._messages) {
-      this._messages = null;
+    if (this._commands) {
+      this._commands = null;
     }
     // We "abort" the accountState and assume our caller is about to throw it
     // away and replace it with a new one.
     return this.currentAccountState.abort();
   },
 
   accountStatus: function accountStatus() {
     return this.currentAccountState.getUserAccountData().then(data => {
@@ -1044,16 +1051,19 @@ FxAccountsInternal.prototype = {
                 DERIVED_KEYS_NAMES.map(k => `${k}=${updateData[k]}`).join(", "));
     }
 
     await currentState.updateUserAccountData(updateData);
     // We are now ready for business. This should only be invoked once
     // per setSignedInUser(), regardless of whether we've rebooted since
     // setSignedInUser() was called.
     await this.notifyObservers(ONVERIFIED_NOTIFICATION);
+    // Some parts of the device registration depend on the Sync keys being available,
+    // so let's re-trigger it now that we have them.
+    await this.updateDeviceRegistration();
     data = await currentState.getUserAccountData();
     return currentState.resolve(data);
   },
 
   _deriveKeys(uid, kBbytes) {
     return {
       kSync: CommonUtils.bytesAsHex(this._deriveSyncKey(kBbytes)),
       kXCS: CommonUtils.bytesAsHex(this._deriveXClientState(kBbytes)),
@@ -1678,23 +1688,27 @@ FxAccountsInternal.prototype = {
     });
   },
 
   // @returns Promise<Subscription>.
   getPushSubscription() {
     return this.fxaPushService.getSubscription();
   },
 
-  // Once FxA messages is stable, remove this, hardcode the capabilities,
-  // and reset the device registration version.
-  get deviceCapabilities() {
-    if (Services.prefs.getBoolPref("identity.fxaccounts.messages.enabled", true)) {
-      return [CAPABILITY_MESSAGES, CAPABILITY_MESSAGES_SENDTAB];
+  async availableCommands() {
+    if (!Services.prefs.getBoolPref("identity.fxaccounts.commands.enabled", true)) {
+      return {};
     }
-    return [];
+    const sendTabKey = await this.commands.sendTab.getEncryptedKey();
+    if (!sendTabKey) { // This will happen if the account is not verified yet.
+      return {};
+    }
+    return {
+      [COMMAND_SENDTAB]: sendTabKey
+    };
   },
 
   // If you change what we send to the FxA servers during device registration,
   // you'll have to bump the DEVICE_REGISTRATION_VERSION number to force older
   // devices to re-register when Firefox updates
   async _registerOrUpdateDevice(signedInUser) {
     const {sessionToken, device: currentDevice} = signedInUser;
     if (!sessionToken) {
@@ -1711,34 +1725,38 @@ FxAccountsInternal.prototype = {
         deviceOptions.pushCallback = subscription.endpoint;
         let publicKey = subscription.getKey("p256dh");
         let authKey = subscription.getKey("auth");
         if (publicKey && authKey) {
           deviceOptions.pushPublicKey = urlsafeBase64Encode(publicKey);
           deviceOptions.pushAuthKey = urlsafeBase64Encode(authKey);
         }
       }
-      deviceOptions.capabilities = this.deviceCapabilities;
+      deviceOptions.availableCommands = await this.availableCommands();
+      const availableCommandsKeys = Object.keys(deviceOptions.availableCommands).sort();
 
       let device;
       if (currentDevice && currentDevice.id) {
         log.debug("updating existing device details");
         device = await this.fxAccountsClient.updateDevice(
           sessionToken, currentDevice.id, deviceName, deviceOptions);
       } else {
         log.debug("registering new device details");
         device = await this.fxAccountsClient.registerDevice(
           sessionToken, deviceName, this._getDeviceType(), deviceOptions);
       }
 
+      // Get the freshest device props before updating them.
+      let {device: deviceProps} = await this.getSignedInUser();
       await this.currentAccountState.updateUserAccountData({
         device: {
-          ...currentDevice, // Copy the other properties (e.g. messagesIndex).
+          ...deviceProps, // Copy the other properties (e.g. handledCommands).
           id: device.id,
-          registrationVersion: this.DEVICE_REGISTRATION_VERSION
+          registrationVersion: this.DEVICE_REGISTRATION_VERSION,
+          registeredCommandsKeys: availableCommandsKeys,
         }
       });
       return device.id;
     } catch (error) {
       return this._handleDeviceError(error, sessionToken);
     }
   },
 
--- a/services/fxaccounts/FxAccountsClient.jsm
+++ b/services/fxaccounts/FxAccountsClient.jsm
@@ -376,16 +376,18 @@ this.FxAccountsClient.prototype = {
    * @param  sessionTokenHex
    *         Session token obtained from signIn
    * @param  name
    *         Device name
    * @param  type
    *         Device type (mobile|desktop)
    * @param  [options]
    *         Extra device options
+   * @param  [options.availableCommands]
+   *         Available commands for this device
    * @param  [options.pushCallback]
    *         `pushCallback` push endpoint callback
    * @param  [options.pushPublicKey]
    *         `pushPublicKey` push public key (URLSafe Base64 string)
    * @param  [options.pushAuthKey]
    *         `pushAuthKey` push auth secret (URLSafe Base64 string)
    * @return Promise
    *         Resolves to an object:
@@ -404,16 +406,17 @@ this.FxAccountsClient.prototype = {
 
     if (options.pushCallback) {
       body.pushCallback = options.pushCallback;
     }
     if (options.pushPublicKey && options.pushAuthKey) {
       body.pushPublicKey = options.pushPublicKey;
       body.pushAuthKey = options.pushAuthKey;
     }
+    body.availableCommands = options.availableCommands;
 
     return this._request(path, "POST", creds, body);
   },
 
   /**
    * Sends a message to other devices. Must conform with the push payload schema:
    * https://github.com/mozilla/fxa-auth-server/blob/master/docs/pushpayloads.schema.json
    *
@@ -442,72 +445,72 @@ this.FxAccountsClient.prototype = {
     if (excludedIds) {
       body.excluded = excludedIds;
     }
     return this._request("/account/devices/notify", "POST",
       deriveHawkCredentials(sessionTokenHex, "sessionToken"), body);
   },
 
   /**
-   * Retrieves messages from our device's message box.
+   * Retrieves pending commands for our device.
    *
-   * @method getMessages
+   * @method getCommands
    * @param  sessionTokenHex - Session token obtained from signIn
    * @param  [index] - If specified, only messages received after the one who
    *                   had that index will be retrieved.
    * @param  [limit] - Maximum number of messages to retrieve.
    */
-  getMessages(sessionTokenHex, {index, limit}) {
+  getCommands(sessionTokenHex, {index, limit}) {
     const params = new URLSearchParams();
     if (index != undefined) {
       params.set("index", index);
     }
     if (limit != undefined) {
       params.set("limit", limit);
     }
-    const path = `/account/device/messages?${params.toString()}`;
+    const path = `/account/device/commands?${params.toString()}`;
     return this._request(path, "GET",
       deriveHawkCredentials(sessionTokenHex, "sessionToken"));
   },
 
   /**
-   * Stores a message in the recipient's message box.
+   * Invokes a command on another device.
    *
-   * @method sendMessage
+   * @method invokeCommand
    * @param  sessionTokenHex - Session token obtained from signIn
-   * @param  topic
-   * @param  to - Recipient device ID.
-   * @param  data
+   * @param  command - Name of the command to invoke
+   * @param  target - Recipient device ID.
+   * @param  payload
    * @return Promise
    *         Resolves to the request's response, (which should be an empty object)
    */
-  sendMessage(sessionTokenHex, topic, to, data) {
+  invokeCommand(sessionTokenHex, command, target, payload) {
     const body = {
-      topic,
-      to,
-      data
+      command,
+      target,
+      payload
     };
-    return this._request("/account/devices/messages", "POST",
+    return this._request("/account/devices/invoke_command", "POST",
       deriveHawkCredentials(sessionTokenHex, "sessionToken"), body);
   },
 
   /**
    * Update the session or name for an existing device
    *
    * @method updateDevice
    * @param  sessionTokenHex
    *         Session token obtained from signIn
    * @param  id
    *         Device identifier
    * @param  name
    *         Device name
    * @param  [options]
    *         Extra device options
-   * @param  options.capabilities
-   *         Device capabilities
+   * @param  [options.availableCommands]
+   *         Available commands for this device
    * @param  [options.pushCallback]
    *         `pushCallback` push endpoint callback
    * @param  [options.pushPublicKey]
    *         `pushPublicKey` push public key (URLSafe Base64 string)
    * @param  [options.pushAuthKey]
    *         `pushAuthKey` push auth secret (URLSafe Base64 string)
    * @return Promise
    *         Resolves to an object:
@@ -523,17 +526,17 @@ this.FxAccountsClient.prototype = {
     let body = { id, name };
     if (options.pushCallback) {
       body.pushCallback = options.pushCallback;
     }
     if (options.pushPublicKey && options.pushAuthKey) {
       body.pushPublicKey = options.pushPublicKey;
       body.pushAuthKey = options.pushAuthKey;
     }
-    body.capabilities = options.capabilities;
+    body.availableCommands = options.availableCommands;
 
     return this._request(path, "POST", creds, body);
   },
 
   /**
    * Get a list of currently registered devices
    *
    * @method getDeviceList
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/FxAccountsCommands.js
@@ -0,0 +1,302 @@
+/* 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/. */
+
+const EXPORTED_SYMBOLS = ["SendTab", "FxAccountsCommands"];
+
+ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
+ChromeUtils.import("resource://gre/modules/Preferences.jsm");
+ChromeUtils.defineModuleGetter(this, "PushCrypto",
+  "resource://gre/modules/PushCrypto.jsm");
+ChromeUtils.import("resource://gre/modules/Services.jsm");
+ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+ChromeUtils.import("resource://services-common/observers.js");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+  BulkKeyBundle: "resource://services-sync/keys.js",
+  CommonUtils: "resource://services-common/utils.js",
+  CryptoUtils: "resource://services-crypto/utils.js",
+  CryptoWrapper: "resource://services-sync/record.js",
+});
+
+class FxAccountsCommands {
+  constructor(fxAccounts) {
+    this._fxAccounts = fxAccounts;
+    this.sendTab = new SendTab(this, fxAccounts);
+  }
+
+  async invoke(command, device, payload) {
+    const userData = await this._fxAccounts.getSignedInUser();
+    if (!userData) {
+      throw new Error("No user.");
+    }
+    const {sessionToken} = userData;
+    if (!sessionToken) {
+      throw new Error("_send called without a session token.");
+    }
+    const client = this._fxAccounts.getAccountsClient();
+    await client.invokeCommand(sessionToken, command, device.id, payload);
+    log.info(`Payload sent to device ${device.id}.`);
+  }
+
+  async consumeRemoteCommand(index) {
+    if (!Services.prefs.getBoolPref("identity.fxaccounts.commands.enabled", true)) {
+      return false;
+    }
+    log.info(`Consuming command with index ${index}.`);
+    const {messages} = await this._fetchRemoteCommands(index, 1);
+    if (messages.length != 1) {
+      log.warn(`Should have retrieved 1 and only 1 message, got ${messages.length}.`);
+    }
+    return this._fxAccounts._withCurrentAccountState(async (getUserData, updateUserData) => {
+      const {device} = await getUserData(["device"]);
+      if (!device) {
+        throw new Error("No device registration.");
+      }
+      const handledCommands = (device.handledCommands || []).concat(messages.map(m => m.index));
+      await updateUserData({
+        device: {...device, handledCommands}
+      });
+      await this._handleCommands(messages);
+
+      // Once the handledCommands array length passes a threshold, check the
+      // potentially missed remote commands in order to clear it.
+      if (handledCommands.length > 20) {
+        await this.fetchMissedRemoteCommands();
+      }
+    });
+  }
+
+  fetchMissedRemoteCommands() {
+    if (!Services.prefs.getBoolPref("identity.fxaccounts.commands.enabled", true)) {
+      return false;
+    }
+    log.info(`Consuming missed commands.`);
+    return this._fxAccounts._withCurrentAccountState(async (getUserData, updateUserData) => {
+      const {device} = await getUserData(["device"]);
+      if (!device) {
+        throw new Error("No device registration.");
+      }
+      const lastCommandIndex = device.lastCommandIndex || 0;
+      const handledCommands = device.handledCommands || [];
+      handledCommands.push(lastCommandIndex); // Because the server also returns this command.
+      const {index, messages} = await this._fetchRemoteCommands(lastCommandIndex);
+      const missedMessages = messages.filter(m => !handledCommands.includes(m.index));
+      await updateUserData({
+        device: {...device, lastCommandIndex: index, handledCommands: []}
+      });
+      if (missedMessages.length) {
+        log.info(`Handling ${missedMessages.length} missed messages`);
+        await this._handleCommands(missedMessages);
+      }
+    });
+  }
+
+  async _fetchRemoteCommands(index, limit = null) {
+    const userData = await this._fxAccounts.getSignedInUser();
+    if (!userData) {
+      throw new Error("No user.");
+    }
+    const {sessionToken} = userData;
+    if (!sessionToken) {
+      throw new Error("No session token.");
+    }
+    const client = this._fxAccounts.getAccountsClient();
+    const opts = {index};
+    if (limit != null) {
+      opts.limit = limit;
+    }
+    return client.getCommands(sessionToken, opts);
+  }
+
+  async _handleCommands(messages) {
+    const fxaDevices = await this._fxAccounts.getDeviceList();
+    for (const {data} of messages) {
+      let {command, payload, sender} = data;
+      if (sender) {
+        sender = fxaDevices.find(d => d.id == sender);
+      }
+      switch (command) {
+        case COMMAND_SENDTAB:
+          try {
+            await this.sendTab.handle(sender, payload);
+          } catch (e) {
+            log.error(`Error while handling incoming Send Tab payload.`, e);
+          }
+        default:
+          log.info(`Unknown command: ${command}.`);
+      }
+    }
+  }
+}
+
+/**
+ * Send Tab is built on top of FxA commands.
+ *
+ * Devices exchange keys wrapped in kSync between themselves (getEncryptedKey)
+ * during the device registration flow. The FxA server can theorically never
+ * retrieve the send tab keys since it doesn't know kSync.
+ */
+class SendTab {
+  constructor(commands, fxAccounts) {
+    this._commands = commands;
+    this._fxAccounts = fxAccounts;
+  }
+  /**
+   * @param {Device[]} to - Device objects (typically returned by fxAccounts.getDevicesList()).
+   * @param {Object} tab
+   * @param {string} tab.url
+   * @param {string} tab.title
+   * @returns A report object, in the shape of
+   *          {succeded: [Device], error: [{device: Device, error: Exception}]}
+   */
+  async send(to, tab) {
+    log.info(`Sending a tab to ${to.length} devices.`);
+    const encoder = new TextEncoder("utf8");
+    const data = {
+      entries: [{title: tab.title, url: tab.url}]
+    };
+    const bytes = encoder.encode(JSON.stringify(data));
+    const report = {
+      succeeded: [],
+      failed: [],
+    };
+    for (let device of to) {
+      try {
+        const encrypted = await this._encrypt(bytes, device);
+        const payload = {encrypted};
+        await this._commands.invoke(COMMAND_SENDTAB, device, payload); // FxA needs an object.
+        report.succeeded.push(device);
+      } catch (error) {
+        log.error("Error while invoking a send tab command.", error);
+        report.failed.push({device, error});
+      }
+    }
+    return report;
+  }
+
+  // Returns true if the target device is compatible with FxA Commands Send tab.
+  async isDeviceCompatible(device) {
+    if (!Services.prefs.getBoolPref("identity.fxaccounts.commands.enabled", true) ||
+        !device.availableCommands || !device.availableCommands[COMMAND_SENDTAB]) {
+      return false;
+    }
+    const {kid: theirKid} = JSON.parse(device.availableCommands[COMMAND_SENDTAB]);
+    const ourKid = await this._getKid();
+    return theirKid == ourKid;
+  }
+
+  // Handle incoming send tab payload, called by FxAccountsCommands.
+  async handle(sender, {encrypted}) {
+    if (!sender) {
+      log.warn("Incoming tab is from an unknown device (maybe disconnected?)");
+    }
+    const bytes = await this._decrypt(encrypted);
+    const decoder = new TextDecoder("utf8");
+    const data = JSON.parse(decoder.decode(bytes));
+    const current = data.hasOwnProperty("current") ? data.current :
+                                                     data.entries.length - 1;
+    const tabSender = {
+      id: sender ? sender.id : "",
+      name: sender ? sender.name : ""
+    };
+    const {title, url: uri} = data.entries[current];
+    console.log(`Tab received with FxA commands: ${title} from ${tabSender.name}.`);
+    Observers.notify("fxaccounts:commands:open-uri", [{uri, title, sender: tabSender}]);
+  }
+
+  async _getKid() {
+    let {kXCS} = await this._fxAccounts.getKeys();
+    return kXCS;
+  }
+
+  async _encrypt(bytes, device) {
+    let bundle = device.availableCommands[COMMAND_SENDTAB];
+    if (!bundle) {
+      throw new Error(`Device ${device.id} does not have send tab keys.`);
+    }
+    const json = JSON.parse(bundle);
+    const wrapper = new CryptoWrapper();
+    wrapper.deserialize({payload: json});
+    const {kSync} = await this._fxAccounts.getKeys();
+    const syncKeyBundle = BulkKeyBundle.fromHexKey(kSync);
+    let {publicKey, authSecret} = await wrapper.decrypt(syncKeyBundle);
+    authSecret = urlsafeBase64Decode(authSecret);
+    publicKey = urlsafeBase64Decode(publicKey);
+
+    const {ciphertext: encrypted} = await PushCrypto.encrypt(bytes, publicKey, authSecret);
+    return urlsafeBase64Encode(encrypted);
+  }
+
+  async _getKeys() {
+    const {device} = await this._fxAccounts.getSignedInUser();
+    return device && device.sendTabKeys;
+  }
+
+  async _decrypt(ciphertext) {
+    let {privateKey, publicKey, authSecret} = await this._getKeys();
+    publicKey = urlsafeBase64Decode(publicKey);
+    authSecret = urlsafeBase64Decode(authSecret);
+    ciphertext = new Uint8Array(urlsafeBase64Decode(ciphertext));
+    return PushCrypto.decrypt(privateKey, publicKey, authSecret,
+                              // The only Push encoding we support.
+                              {encoding: "aes128gcm"}, ciphertext);
+  }
+
+  async _generateAndPersistKeys() {
+    let [publicKey, privateKey] = await PushCrypto.generateKeys();
+    publicKey = urlsafeBase64Encode(publicKey);
+    let authSecret = PushCrypto.generateAuthenticationSecret();
+    authSecret = urlsafeBase64Encode(authSecret);
+    const sendTabKeys = {
+      publicKey,
+      privateKey,
+      authSecret
+    };
+    await this._fxAccounts._withCurrentAccountState(async (getUserData, updateUserData) => {
+      const {device} = await getUserData();
+      await updateUserData({
+        device: {
+          ...device,
+          sendTabKeys,
+        }
+      });
+    });
+    return sendTabKeys;
+  }
+
+  async getEncryptedKey() {
+    let sendTabKeys = await this._getKeys();
+    if (!sendTabKeys) {
+      sendTabKeys = await this._generateAndPersistKeys();
+    }
+    // Strip the private key from the bundle to encrypt.
+    const keyToEncrypt = {
+      publicKey: sendTabKeys.publicKey,
+      authSecret: sendTabKeys.authSecret,
+    };
+    const {kSync} = await this._fxAccounts.getSignedInUser();
+    if (!kSync) {
+      return null;
+    }
+    const wrapper = new CryptoWrapper();
+    wrapper.cleartext = keyToEncrypt;
+    const keyBundle = BulkKeyBundle.fromHexKey(kSync);
+    await wrapper.encrypt(keyBundle);
+    const kid = await this._getKid();
+    return JSON.stringify({
+      kid,
+      IV: wrapper.IV,
+      hmac: wrapper.hmac,
+      ciphertext: wrapper.ciphertext,
+    });
+  }
+}
+
+function urlsafeBase64Encode(buffer) {
+  return ChromeUtils.base64URLEncode(new Uint8Array(buffer), {pad: false});
+}
+
+function urlsafeBase64Decode(str) {
+  return ChromeUtils.base64URLDecode(str, {padding: "reject"});
+}
--- a/services/fxaccounts/FxAccountsCommon.js
+++ b/services/fxaccounts/FxAccountsCommon.js
@@ -63,24 +63,24 @@ exports.ONLOGOUT_NOTIFICATION = "fxaccou
 exports.ON_DEVICE_CONNECTED_NOTIFICATION = "fxaccounts:device_connected";
 exports.ON_DEVICE_DISCONNECTED_NOTIFICATION = "fxaccounts:device_disconnected";
 exports.ON_PROFILE_UPDATED_NOTIFICATION = "fxaccounts:profile_updated"; // Push
 exports.ON_PASSWORD_CHANGED_NOTIFICATION = "fxaccounts:password_changed";
 exports.ON_PASSWORD_RESET_NOTIFICATION = "fxaccounts:password_reset";
 exports.ON_ACCOUNT_DESTROYED_NOTIFICATION = "fxaccounts:account_destroyed";
 exports.ON_COLLECTION_CHANGED_NOTIFICATION = "sync:collection_changed";
 exports.ON_VERIFY_LOGIN_NOTIFICATION = "fxaccounts:verify_login";
+exports.ON_COMMAND_RECEIVED_NOTIFICATION = "fxaccounts:command_received";
 
 exports.FXA_PUSH_SCOPE_ACCOUNT_UPDATE = "chrome://fxa-device-update";
 
 exports.ON_PROFILE_CHANGE_NOTIFICATION = "fxaccounts:profilechange"; // WebChannel
 exports.ON_ACCOUNT_STATE_CHANGE_NOTIFICATION = "fxaccounts:statechange";
 
-exports.CAPABILITY_MESSAGES = "messages";
-exports.CAPABILITY_MESSAGES_SENDTAB = "messages.sendtab";
+exports.COMMAND_SENDTAB = "https://identity.mozilla.com/cmd/open-uri";
 
 // UI Requests.
 exports.UI_REQUEST_SIGN_IN_FLOW = "signInFlow";
 exports.UI_REQUEST_REFRESH_AUTH = "refreshAuthentication";
 
 // The OAuth client ID for Firefox Desktop
 exports.FX_OAUTH_CLIENT_ID = "5882386c6d801776";
 
deleted file mode 100644
--- a/services/fxaccounts/FxAccountsMessages.js
+++ /dev/null
@@ -1,223 +0,0 @@
-/* This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
-
-const EXPORTED_SYMBOLS = ["FxAccountsMessages", /* the rest are for testing only */
-                          "FxAccountsMessagesSender", "FxAccountsMessagesReceiver",
-                          "FxAccountsMessagesHandler"];
-
-ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
-ChromeUtils.import("resource://gre/modules/Preferences.jsm");
-ChromeUtils.defineModuleGetter(this, "PushCrypto",
-  "resource://gre/modules/PushCrypto.jsm");
-ChromeUtils.import("resource://gre/modules/Services.jsm");
-ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
-ChromeUtils.import("resource://services-common/observers.js");
-
-const AES128GCM_ENCODING = "aes128gcm"; // The only Push encoding we support.
-const TOPICS = {
-  SEND_TAB: "sendtab"
-};
-
-class FxAccountsMessages {
-  constructor(fxAccounts, options = {}) {
-    this.fxAccounts = fxAccounts;
-    this.sender = options.sender || new FxAccountsMessagesSender(fxAccounts);
-    this.receiver = options.receiver || new FxAccountsMessagesReceiver(fxAccounts);
-  }
-
-  _isDeviceMessagesAware(device) {
-    return device.capabilities && device.capabilities.includes(CAPABILITY_MESSAGES);
-  }
-
-  canReceiveSendTabMessages(device) {
-    return this._isDeviceMessagesAware(device) &&
-           device.capabilities.includes(CAPABILITY_MESSAGES_SENDTAB);
-  }
-
-  consumeRemoteMessages() {
-    if (!Services.prefs.getBoolPref("identity.fxaccounts.messages.enabled", true)) {
-      return Promise.resolve();
-    }
-    return this.receiver.consumeRemoteMessages();
-  }
-
-  /**
-   * @param {Device[]} to - Device objects (typically returned by fxAccounts.getDevicesList()).
-   * @param {Object} tab
-   * @param {string} tab.url
-   * @param {string} tab.title
-   */
-  async sendTab(to, tab) {
-    log.info(`Sending a tab to ${to.length} devices.`);
-    const ourDeviceId = await this.fxAccounts.getDeviceId();
-    const payload = {
-      topic: TOPICS.SEND_TAB,
-      data: {
-        from: ourDeviceId,
-        entries: [{title: tab.title, url: tab.url}]
-      }
-    };
-    return this.sender.send(TOPICS.SEND_TAB, to, payload);
-  }
-}
-
-class FxAccountsMessagesSender {
-  constructor(fxAccounts) {
-    this.fxAccounts = fxAccounts;
-  }
-
-  async send(topic, to, data) {
-    const userData = await this.fxAccounts.getSignedInUser();
-    if (!userData) {
-      throw new Error("No user.");
-    }
-    const {sessionToken} = userData;
-    if (!sessionToken) {
-      throw new Error("_send called without a session token.");
-    }
-    const encoder = new TextEncoder("utf8");
-    const client = this.fxAccounts.getAccountsClient();
-    for (const device of to) {
-      try {
-        const bytes = encoder.encode(JSON.stringify(data));
-        const payload = await this._encrypt(bytes, device, encoder);
-        await client.sendMessage(sessionToken, topic, device.id, payload);
-        log.info(`Payload sent to device ${device.id}.`);
-      } catch (e) {
-        log.error(`Could not send data to device ${device.id}.`, e);
-      }
-    }
-  }
-
-  async _encrypt(bytes, device) {
-    let {pushPublicKey, pushAuthKey} = device;
-    if (!pushPublicKey || !pushAuthKey) {
-      throw new Error(`Device ${device.id} does not have push keys.`);
-    }
-    pushPublicKey = ChromeUtils.base64URLDecode(pushPublicKey, {padding: "ignore"});
-    pushAuthKey = ChromeUtils.base64URLDecode(pushAuthKey, {padding: "ignore"});
-    const {ciphertext} = await PushCrypto.encrypt(bytes, pushPublicKey, pushAuthKey);
-    return ChromeUtils.base64URLEncode(ciphertext, {pad: false});
-  }
-}
-
-class FxAccountsMessagesReceiver {
-  constructor(fxAccounts, options = {}) {
-    this.fxAccounts = fxAccounts;
-    this.handler = options.handler || new FxAccountsMessagesHandler(this.fxAccounts);
-  }
-
-  async consumeRemoteMessages() {
-    log.info(`Consuming unread messages.`);
-    const messages = await this._fetchMessages();
-    if (!messages || !messages.length) {
-      log.info(`No new messages.`);
-      return;
-    }
-    const decoder = new TextDecoder("utf8");
-    const keys = await this._getOwnKeys();
-    const payloads = [];
-    for (const {index, data} of messages) {
-      try {
-        const bytes = await this._decrypt(data, keys);
-        const payload = JSON.parse(decoder.decode(bytes));
-        payloads.push(payload);
-      } catch (e) {
-        log.error(`Could not unwrap message ${index}`, e);
-      }
-    }
-    if (payloads.length) {
-      await this.handler.handle(payloads);
-    }
-  }
-
-  async _fetchMessages() {
-    return this.fxAccounts._withCurrentAccountState(async (getUserData, updateUserData) => {
-      const userData = await getUserData(["sessionToken", "device"]);
-      if (!userData) {
-        throw new Error("No user.");
-      }
-      const {sessionToken, device} = userData;
-      if (!sessionToken) {
-        throw new Error("No session token.");
-      }
-      if (!device) {
-        throw new Error("No device registration.");
-      }
-      const opts = {};
-      if (device.messagesIndex) {
-        opts.index = device.messagesIndex;
-      }
-      const client = this.fxAccounts.getAccountsClient();
-      log.info(`Fetching unread messages with ${JSON.stringify(opts)}.`);
-      const {index: newIndex, messages} = await client.getMessages(sessionToken, opts);
-      await updateUserData({
-        device: {...device, messagesIndex: newIndex}
-      });
-      return messages;
-    });
-  }
-
-  async _getOwnKeys() {
-    const subscription = await this.fxAccounts.getPushSubscription();
-    return {
-      pushPrivateKey: subscription.p256dhPrivateKey,
-      pushPublicKey: new Uint8Array(subscription.getKey("p256dh")),
-      pushAuthKey: new Uint8Array(subscription.getKey("auth"))
-    };
-  }
-
-  async _decrypt(ciphertext, {pushPrivateKey, pushPublicKey, pushAuthKey}) {
-    ciphertext = ChromeUtils.base64URLDecode(ciphertext, {padding: "reject"});
-    return PushCrypto.decrypt(pushPrivateKey, pushPublicKey,
-                              pushAuthKey,
-                              {encoding: AES128GCM_ENCODING},
-                              ciphertext);
-  }
-}
-
-class FxAccountsMessagesHandler {
-  constructor(fxAccounts) {
-    this.fxAccounts = fxAccounts;
-  }
-
-  async handle(payloads) {
-    const sendTabPayloads = [];
-    for (const payload of payloads) {
-      switch (payload.topic) {
-        case TOPICS.SEND_TAB:
-          sendTabPayloads.push(payload.data);
-        default:
-          log.info(`Unknown messages topic: ${payload.topic}.`);
-      }
-    }
-
-    // Only one type of payload so far!
-    if (sendTabPayloads.length) {
-      await this._handleSendTabPayloads(sendTabPayloads);
-    }
-  }
-
-  async _handleSendTabPayloads(payloads) {
-    const toDisplay = [];
-    const fxaDevices = await this.fxAccounts.getDeviceList();
-    for (const payload of payloads) {
-      const current = payload.hasOwnProperty("current") ? payload.current :
-                                                          payload.entries.length - 1;
-      const device = fxaDevices.find(d => d.id == payload.from);
-      if (!device) {
-        log.warn("Incoming tab is from an unknown device (maybe disconnected?)");
-      }
-      const sender = {
-        id: device ? device.id : "",
-        name: device ? device.name : ""
-      };
-      const {title, url: uri} = payload.entries[current];
-      toDisplay.push({uri, title, sender});
-    }
-
-    Observers.notify("fxaccounts:messages:display-tabs", toDisplay);
-  }
-}
-
--- a/services/fxaccounts/FxAccountsPush.js
+++ b/services/fxaccounts/FxAccountsPush.js
@@ -2,16 +2,18 @@
  * 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/. */
 
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://services-sync/util.js");
 ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
 
+XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
+
 /**
  * FxAccountsPushService manages Push notifications for Firefox Accounts in the browser
  *
  * @param [options]
  *        Object, custom options that used for testing
  * @constructor
  */
 function FxAccountsPushService(options = {}) {
@@ -153,23 +155,21 @@ FxAccountsPushService.prototype = {
     this.log.trace("FxAccountsPushService _onPushMessage");
     if (!message.data) {
       // Use the empty signal to check the verification state of the account right away
       this.log.debug("empty push message - checking account status");
       this.fxAccounts.checkVerificationStatus();
       return;
     }
     let payload = message.data.json();
-    if (payload.topic) {
-      this.log.debug(`received messages tickle with topic ${payload.topic}`);
-      this.fxAccounts.messages.consumeRemoteMessages();
-      return;
-    }
     this.log.debug(`push command: ${payload.command}`);
     switch (payload.command) {
+      case ON_COMMAND_RECEIVED_NOTIFICATION:
+        this.fxAccounts.commands.consumeRemoteCommand(payload.data.index);
+        break;
       case ON_DEVICE_CONNECTED_NOTIFICATION:
         Services.obs.notifyObservers(null, ON_DEVICE_CONNECTED_NOTIFICATION, payload.data.deviceName);
         break;
       case ON_DEVICE_DISCONNECTED_NOTIFICATION:
         this.fxAccounts.handleDeviceDisconnection(payload.data.id);
         return;
       case ON_PROFILE_UPDATED_NOTIFICATION:
         // We already have a "profile updated" notification sent via WebChannel,
--- a/services/fxaccounts/FxAccountsWebChannel.jsm
+++ b/services/fxaccounts/FxAccountsWebChannel.jsm
@@ -411,17 +411,17 @@ this.FxAccountsWebChannelHelpers.prototy
       try {
         return Services.prefs.getBoolPref(`services.sync.engine.${engineName}.available`);
       } catch (e) {
         return false;
       }
     });
   },
 
-  changePassword(credentials) {
+  async changePassword(credentials) {
     // If |credentials| has fields that aren't handled by accounts storage,
     // updateUserAccountData will throw - mainly to prevent errors in code
     // that hard-codes field names.
     // However, in this case the field names aren't really in our control.
     // We *could* still insist the server know what fields names are valid,
     // but that makes life difficult for the server when Firefox adds new
     // features (ie, new fields) - forcing the server to track a map of
     // versions to supported field names doesn't buy us much.
@@ -431,18 +431,25 @@ this.FxAccountsWebChannelHelpers.prototy
     };
     for (let name of Object.keys(credentials)) {
       if (name == "email" || name == "uid" || FxAccountsStorageManagerCanStoreField(name)) {
         newCredentials[name] = credentials[name];
       } else {
         log.info("changePassword ignoring unsupported field", name);
       }
     }
-    return this._fxAccounts.updateUserAccountData(newCredentials)
-      .then(() => this._fxAccounts.updateDeviceRegistration());
+    await this._fxAccounts.updateUserAccountData(newCredentials);
+    // Force the keys derivation, to be able to register a send-tab command
+    // in updateDeviceRegistration.
+    try {
+      await this._fxAccounts.getKeys();
+    } catch (e) {
+      log.error("getKeys errored", e);
+    }
+    await this._fxAccounts.updateDeviceRegistration();
   },
 
   /**
    * Get the hash of account name of the previously signed in account
    */
   getPreviousAccountNameHashPref() {
     try {
       return Services.prefs.getStringPref(PREF_LAST_FXA_USER);
--- a/services/fxaccounts/moz.build
+++ b/services/fxaccounts/moz.build
@@ -19,17 +19,17 @@ EXTRA_COMPONENTS += [
   'FxAccountsComponents.manifest',
   'FxAccountsPush.js',
 ]
 
 EXTRA_JS_MODULES += [
   'Credentials.jsm',
   'FxAccounts.jsm',
   'FxAccountsClient.jsm',
+  'FxAccountsCommands.js',
   'FxAccountsCommon.js',
   'FxAccountsConfig.jsm',
-  'FxAccountsMessages.js',
   'FxAccountsOAuthGrantClient.jsm',
   'FxAccountsProfile.jsm',
   'FxAccountsProfileClient.jsm',
   'FxAccountsStorage.jsm',
   'FxAccountsWebChannel.jsm',
 ]
--- a/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js
+++ b/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js
@@ -97,16 +97,19 @@ function MockFxAccounts(device = {}) {
       // we use a real accountState but mocked storage.
       let storage = new MockStorageManager();
       storage.initialize(credentials);
       return new AccountState(storage);
     },
     _getDeviceName() {
       return device.name || "mock device name";
     },
+    async availableCommands() {
+      return {};
+    },
     fxAccountsClient: new MockFxAccountsClient(device),
     fxaPushService: {
       registerPushEndpoint() {
         return new Promise((resolve) => {
           resolve({
             endpoint: "http://mochi.test:8888",
             getKey(type) {
               return ChromeUtils.base64URLDecode(
@@ -185,16 +188,17 @@ add_task(async function test_updateDevic
   const deviceId = "my device id";
   const deviceName = "phil's device";
 
   const credentials = getTestUser("pb");
   const fxa = new MockFxAccounts({ name: deviceName });
   await fxa.internal.setSignedInUser(credentials);
   await fxa.updateUserAccountData({uid: credentials.uid, device: {
     id: deviceId,
+    registeredCommandsKeys: [],
     registrationVersion: 1 // < 42
   }});
 
   const spy = {
     registerDevice: { count: 0, args: [] },
     updateDevice: { count: 0, args: [] },
     getDeviceList: { count: 0, args: [] }
   };
@@ -242,16 +246,17 @@ add_task(async function test_updateDevic
   const deviceType = "bar";
   const currentDeviceId = "my device id";
 
   const credentials = getTestUser("baz");
   const fxa = new MockFxAccounts({ name: deviceName });
   await fxa.internal.setSignedInUser(credentials);
   await fxa.updateUserAccountData({uid: credentials.uid, device: {
     id: currentDeviceId,
+    registeredCommandsKeys: [],
     registrationVersion: 1 // < 42
   }});
 
   const spy = {
     registerDevice: { count: 0, args: [] },
     updateDevice: { count: 0, args: [] },
     getDeviceList: { count: 0, args: [] }
   };
@@ -306,16 +311,17 @@ add_task(async function test_updateDevic
   const currentDeviceId = "my device id";
   const conflictingDeviceId = "conflicting device id";
 
   const credentials = getTestUser("baz");
   const fxa = new MockFxAccounts({ name: deviceName });
   await fxa.internal.setSignedInUser(credentials);
   await fxa.updateUserAccountData({uid: credentials.uid, device: {
     id: currentDeviceId,
+    registeredCommandsKeys: [],
     registrationVersion: 1 // < 42
   }});
 
   const spy = {
     registerDevice: { count: 0, args: [] },
     updateDevice: { count: 0, args: [], times: [] },
     getDeviceList: { count: 0, args: [] }
   };
@@ -448,17 +454,17 @@ add_task(async function test_getDeviceId
 add_task(async function test_getDeviceId_with_registration_version_outdated_invokes_device_registration() {
   const credentials = getTestUser("foo");
   credentials.verified = true;
   const fxa = new MockFxAccounts();
   await fxa.internal.setSignedInUser(credentials);
 
   const spy = { count: 0, args: [] };
   fxa.internal.currentAccountState.getUserAccountData =
-    () => Promise.resolve({ device: {id: "my id", registrationVersion: 0}});
+    () => Promise.resolve({ device: {id: "my id", registrationVersion: 0, registeredCommandsKeys: []}});
   fxa.internal._registerOrUpdateDevice = function() {
     spy.count += 1;
     spy.args.push(arguments);
     return Promise.resolve("wibble");
   };
 
   const result = await fxa.internal.getDeviceId();
 
@@ -471,17 +477,17 @@ add_task(async function test_getDeviceId
 add_task(async function test_getDeviceId_with_device_id_and_uptodate_registration_version_doesnt_invoke_device_registration() {
   const credentials = getTestUser("foo");
   credentials.verified = true;
   const fxa = new MockFxAccounts();
   await fxa.internal.setSignedInUser(credentials);
 
   const spy = { count: 0 };
   fxa.internal.currentAccountState.getUserAccountData =
-    () => Promise.resolve({ device: {id: "foo's device id", registrationVersion: DEVICE_REGISTRATION_VERSION}});
+    async () => ({ device: {id: "foo's device id", registrationVersion: DEVICE_REGISTRATION_VERSION, registeredCommandsKeys: []}});
   fxa.internal._registerOrUpdateDevice = function() {
     spy.count += 1;
     return Promise.resolve("bar");
   };
 
   const result = await fxa.internal.getDeviceId();
 
   Assert.equal(spy.count, 0);
@@ -522,29 +528,30 @@ add_task(async function test_migration_t
   accountData.deviceId = "mydeviceid";
   accountData.deviceRegistrationVersion = DEVICE_REGISTRATION_VERSION;
 
   const result = await fxa.internal.getDeviceId();
   Assert.equal(result, "mydeviceid");
 
   const state = fxa.internal.currentAccountState;
   const data = await state.getUserAccountData();
-  Assert.deepEqual(data.device, {id: "mydeviceid", registrationVersion: DEVICE_REGISTRATION_VERSION});
+  Assert.deepEqual(data.device, {id: "mydeviceid", registrationVersion: DEVICE_REGISTRATION_VERSION, registeredCommandsKeys: []});
   Assert.ok(!data.deviceId);
   Assert.ok(!data.deviceRegistrationVersion);
 });
 
 add_task(async function test_devicelist_pushendpointexpired() {
   const deviceId = "mydeviceid";
   const credentials = getTestUser("baz");
   credentials.verified = true;
   const fxa = new MockFxAccounts();
   await fxa.internal.setSignedInUser(credentials);
   await fxa.updateUserAccountData({uid: credentials.uid, device: {
     id: deviceId,
+    registeredCommandsKeys: [],
     registrationVersion: 1 // < 42
   }});
 
   const spy = {
     updateDevice: { count: 0, args: [] },
     getDeviceList: { count: 0, args: [] }
   };
   const client = fxa.internal.fxAccountsClient;
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_commands.js
@@ -0,0 +1,108 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.import("resource://testing-common/Assert.jsm");
+ChromeUtils.import("resource://gre/modules/FxAccountsCommands.js");
+
+add_task(async function test_sendtab_isDeviceCompatible() {
+  const fxAccounts = {
+    getKeys() {
+      return {
+        kXCS: "abcd"
+      };
+    }
+  };
+  const sendTab = new SendTab(null, fxAccounts);
+  let device = {name: "My device"};
+  Assert.ok(!(await sendTab.isDeviceCompatible(device)));
+  device = {name: "My device", availableCommands: {}};
+  Assert.ok(!(await sendTab.isDeviceCompatible(device)));
+  device = {name: "My device", availableCommands: {
+    "https://identity.mozilla.com/cmd/open-uri": JSON.stringify({
+      kid: "dcba"
+    })
+  }};
+  Assert.ok(!(await sendTab.isDeviceCompatible(device)));
+  device = {name: "My device", availableCommands: {
+    "https://identity.mozilla.com/cmd/open-uri": JSON.stringify({
+      kid: "abcd"
+    })
+  }};
+  Assert.ok((await sendTab.isDeviceCompatible(device)));
+});
+
+add_task(async function test_sendtab_send() {
+  const commands = {
+    invoke: sinon.spy((cmd, device, payload) => {
+      if (device.name == "Device 1") {
+        throw new Error("Invoke error!");
+      }
+      Assert.equal(payload.encrypted, "encryptedpayload");
+    })
+  };
+  const sendTab = new SendTab(commands, null);
+  sendTab._encrypt = (bytes, device) => {
+    if (device.name == "Device 2") {
+      throw new Error("Encrypt error!");
+    }
+    return "encryptedpayload";
+  };
+  const to = [
+    {name: "Device 1"},
+    {name: "Device 2"},
+    {name: "Device 3"},
+  ];
+  const tab = {title: "Foo", url: "https://foo.bar/"};
+  const report = await sendTab.send(to, tab);
+  Assert.equal(report.succeeded.length, 1);
+  Assert.equal(report.failed.length, 2);
+  Assert.equal(report.succeeded[0].name, "Device 3");
+  Assert.equal(report.failed[0].device.name, "Device 1");
+  Assert.equal(report.failed[0].error.message, "Invoke error!");
+  Assert.equal(report.failed[1].device.name, "Device 2");
+  Assert.equal(report.failed[1].error.message, "Encrypt error!");
+  Assert.ok(commands.invoke.calledTwice);
+});
+
+add_task(async function test_commands_fetchMissedRemoteCommands() {
+  const accountState = {
+    data: {
+      device: {
+        handledCommands: [8, 9, 10, 11],
+        lastCommandIndex: 11,
+      }
+    }
+  };
+  const fxAccounts = {
+    async _withCurrentAccountState(cb) {
+      const get = () => accountState.data;
+      const set = (val) => { accountState.data = val; };
+      await cb(get, set);
+    }
+  };
+  const commands = new FxAccountsCommands(fxAccounts);
+  commands._fetchRemoteCommands = () => {
+    return {
+      index: 12,
+      messages: [
+        {
+          index: 11,
+          data: {}
+        },
+        {
+          index: 12,
+          data: {}
+        }
+      ]
+    };
+  };
+  commands._handleCommands = sinon.spy();
+  await commands.fetchMissedRemoteCommands();
+
+  Assert.equal(accountState.data.device.handledCommands.length, 0);
+  Assert.equal(accountState.data.device.lastCommandIndex, 12);
+  const callArgs = commands._handleCommands.args[0][0];
+  Assert.equal(callArgs[0].index, 12);
+});
--- a/services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js
+++ b/services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js
@@ -43,16 +43,17 @@ function createFxAccounts() {
       async registerDevice() {
         return { id: "deviceAAAAAA" };
       },
       async recoveryEmailStatus() {
         return { verified: true };
       },
       async signOut() {},
     },
+    updateDeviceRegistration() {},
     _getDeviceName() {
       return "mock device name";
     },
     observerPreloads: [],
     fxaPushService: {
       async registerPushEndpoint() {
         return {
           endpoint: "http://mochi.test:8888",
deleted file mode 100644
--- a/services/fxaccounts/tests/xpcshell/test_messages.js
+++ /dev/null
@@ -1,210 +0,0 @@
-/* Any copyright is dedicated to the Public Domain.
- * http://creativecommons.org/publicdomain/zero/1.0/ */
-
-"use strict";
-
-ChromeUtils.import("resource://testing-common/Assert.jsm");
-ChromeUtils.import("resource://gre/modules/FxAccountsMessages.js");
-
-add_task(async function test_sendTab() {
-  const fxAccounts = {
-    async getDeviceId() {
-      return "my-device-id";
-    }
-  };
-  const sender = {
-    send: sinon.spy()
-  };
-  const fxAccountsMessages = new FxAccountsMessages(fxAccounts, {sender});
-
-  const to = [{
-    id: "deviceid-1",
-    pushPublicKey: "pubkey-1",
-    pushAuthKey: "authkey-1"
-  }];
-  const tab = {url: "https://foo.com", title: "Foo"};
-  await fxAccountsMessages.sendTab(to, tab);
-  Assert.ok(sender.send.calledOnce);
-  Assert.equal(sender.send.args[0][0], "sendtab");
-  Assert.deepEqual(sender.send.args[0][1], to);
-  Assert.deepEqual(sender.send.args[0][2], {
-    topic: "sendtab",
-    data: {
-      from: "my-device-id",
-      entries: [{title: "Foo", url: "https://foo.com"}]
-    }
-  });
-});
-
-add_task(async function test_consumeRemoteMessages() {
-  const fxAccounts = {};
-  const receiver = {
-    consumeRemoteMessages: sinon.spy()
-  };
-  const fxAccountsMessages = new FxAccountsMessages(fxAccounts, {receiver});
-  fxAccountsMessages.consumeRemoteMessages();
-  Assert.ok(receiver.consumeRemoteMessages.calledOnce);
-});
-
-add_task(async function test_canReceiveSendTabMessages() {
-  const fxAccounts = {};
-  const messages = new FxAccountsMessages(fxAccounts);
-  Assert.ok(!messages.canReceiveSendTabMessages({id: "device-id-1"}));
-  Assert.ok(!messages.canReceiveSendTabMessages({id: "device-id-1", capabilities: []}));
-  Assert.ok(!messages.canReceiveSendTabMessages({id: "device-id-1", capabilities: ["messages"]}));
-  Assert.ok(messages.canReceiveSendTabMessages({id: "device-id-1", capabilities: ["messages", "messages.sendtab"]}));
-});
-
-add_task(async function test_sender_send() {
-  const sandbox = sinon.sandbox.create();
-  const fxaClient = {
-    sendMessage: sinon.spy()
-  };
-  const sessionToken = "toktok";
-  const fxAccounts = {
-    async getSignedInUser() {
-      return {sessionToken};
-    },
-    getAccountsClient() {
-      return fxaClient;
-    }
-  };
-  const sender = new FxAccountsMessagesSender(fxAccounts);
-  sandbox.stub(sender, "_encrypt").callsFake((_, device) => {
-    if (device.pushPublicKey == "pubkey-1") {
-      return "encrypted-text-1";
-    }
-    return "encrypted-text-2";
-  });
-
-  const topic = "mytopic";
-  const to = [{
-    id: "deviceid-1",
-    pushPublicKey: "pubkey-1",
-    pushAuthKey: "authkey-1"
-  }, {
-    id: "deviceid-2",
-    pushPublicKey: "pubkey-2",
-    pushAuthKey: "authkey-2"
-  }];
-  const payload = {foo: "bar"};
-
-  await sender.send(topic, to, payload);
-
-  Assert.ok(fxaClient.sendMessage.calledTwice);
-  const checkCallArgs = (callNum, deviceId, encrypted) => {
-    Assert.equal(fxaClient.sendMessage.args[callNum][0], sessionToken);
-    Assert.equal(fxaClient.sendMessage.args[callNum][1], topic);
-    Assert.equal(fxaClient.sendMessage.args[callNum][2], deviceId);
-    Assert.equal(fxaClient.sendMessage.args[callNum][3], encrypted);
-  };
-  checkCallArgs(0, "deviceid-1", "encrypted-text-1");
-  checkCallArgs(1, "deviceid-2", "encrypted-text-2");
-  sandbox.restore();
-});
-
-add_task(async function test_receiver_consumeRemoteMessages() {
-  const fxaClient = {
-    getMessages: sinon.spy(async () => {
-      return {
-        index: "idx-2",
-        messages: [{
-          index: "idx-1",
-          data: "#giberish#"
-        }, {
-          index: "idx-2",
-          data: "#encrypted#"
-        }]
-      };
-    })
-  };
-  const fxAccounts = {
-    accountState: {sessionToken: "toktok", device: {}},
-    _withCurrentAccountState(fun) {
-      const get = () => this.accountState;
-      const update = (obj) => { this.accountState = {...this.accountState, ...obj}; };
-      return fun(get, update);
-    },
-    getAccountsClient() {
-      return fxaClient;
-    }
-  };
-  const sandbox = sinon.sandbox.create();
-  const messagesHandler = {
-    handle: sinon.spy()
-  };
-  const receiver = new FxAccountsMessagesReceiver(fxAccounts, {
-    handler: messagesHandler
-  });
-  sandbox.stub(receiver, "_getOwnKeys").callsFake(async () => {});
-  sandbox.stub(receiver, "_decrypt").callsFake((ciphertext) => {
-    if (ciphertext == "#encrypted#") {
-      return new TextEncoder("utf-8").encode(JSON.stringify({"foo": "bar"}));
-    }
-    throw new Error("Boom!");
-  });
-
-  await receiver.consumeRemoteMessages();
-
-  Assert.ok(fxaClient.getMessages.calledOnce);
-  Assert.equal(fxaClient.getMessages.args[0][0], "toktok");
-  Assert.deepEqual(fxaClient.getMessages.args[0][1], {});
-  Assert.ok(messagesHandler.handle.calledOnce);
-  Assert.deepEqual(messagesHandler.handle.args[0][0], [{"foo": "bar"}]);
-  fxaClient.getMessages.reset();
-
-  await receiver.consumeRemoteMessages();
-
-  Assert.ok(fxaClient.getMessages.calledOnce);
-  Assert.equal(fxaClient.getMessages.args[0][0], "toktok");
-  Assert.deepEqual(fxaClient.getMessages.args[0][1], {"index": "idx-2"});
-
-  sandbox.restore();
-});
-
-add_task(async function test_handler_handle_sendtab() {
-  const fxAccounts = {
-    async getDeviceList() {
-      return [{id: "1234a", name: "My Computer"}];
-    }
-  };
-  const handler = new FxAccountsMessagesHandler(fxAccounts);
-  const payloads = [{
-    topic: "sendtab",
-    data: {
-      from: "1234a",
-      current: 0,
-      entries: [{title: "Foo", url: "https://foo.com"},
-                {title: "Bar", url: "https://bar.com"}]
-    }
-  }, {
-    topic: "sendtab",
-    data: {
-      from: "unknown_device",
-      entries: [{title: "Foo2", url: "https://foo2.com"},
-                {title: "Bar2", url: "https://bar2.com"}]
-    }
-  }, {
-    topic: "unknowntopic",
-    data: {foo: "bar"}
-  }];
-  const notificationPromise = promiseObserver("fxaccounts:messages:display-tabs");
-  await handler.handle(payloads);
-  const {subject} = await notificationPromise;
-  const toDisplay = subject.wrappedJSObject.object;
-  const expected = [
-    {uri: "https://foo.com", title: "Foo", sender: {id: "1234a", name: "My Computer"}},
-    {uri: "https://bar2.com", title: "Bar2", sender: {id: "", name: ""}}
-  ];
-  Assert.deepEqual(toDisplay, expected);
-});
-
-function promiseObserver(aTopic) {
-  return new Promise(resolve => {
-    Services.obs.addObserver(function onNotification(subject, topic, data) {
-      Services.obs.removeObserver(onNotification, topic);
-        resolve({subject, data});
-      }, aTopic);
-  });
-}
-
--- a/services/fxaccounts/tests/xpcshell/test_push_service.js
+++ b/services/fxaccounts/tests/xpcshell/test_push_service.js
@@ -404,32 +404,35 @@ add_test(function observePushTopicPasswo
 
   pushService._onPasswordChanged = function() {
     run_next_test();
   };
 
   pushService.observe(msg, mockPushService.pushTopic, FXA_PUSH_SCOPE_ACCOUNT_UPDATE);
 });
 
-add_task(async function messagesTickle() {
+add_task(async function commandReceived() {
   let msg = {
     data: {
       json: () => ({
-        topic: "sendtab"
+        command: "fxaccounts:command_received",
+        data: {
+          url: "https://api.accounts.firefox.com/auth/v1/account/device/commands?index=42&limit=1"
+        }
       })
     },
     QueryInterface() {
       return this;
     }
   };
 
   let fxAccountsMock = {};
   const promiseConsumeRemoteMessagesCalled = new Promise(res => {
-    fxAccountsMock.messages = {
-      consumeRemoteMessages() {
+    fxAccountsMock.commands = {
+      consumeRemoteCommand() {
         res();
       }
     };
   });
 
   let pushService = new FxAccountsPushService({
     pushService: mockPushService,
     fxAccounts: fxAccountsMock,
--- a/services/fxaccounts/tests/xpcshell/xpcshell.ini
+++ b/services/fxaccounts/tests/xpcshell/xpcshell.ini
@@ -4,19 +4,19 @@ skip-if = (toolkit == 'android' || appna
 support-files =
   !/services/common/tests/unit/head_helpers.js
   !/services/common/tests/unit/head_http.js
 
 [test_accounts.js]
 [test_accounts_config.js]
 [test_accounts_device_registration.js]
 [test_client.js]
+[test_commands.js]
 [test_credentials.js]
 [test_loginmgr_storage.js]
-[test_messages.js]
 [test_oauth_grant_client.js]
 [test_oauth_grant_client_server.js]
 [test_oauth_tokens.js]
 [test_oauth_token_storage.js]
 [test_profile_client.js]
 [test_push_service.js]
 [test_web_channel.js]
 [test_profile.js]
--- a/services/sync/modules/policies.js
+++ b/services/sync/modules/policies.js
@@ -528,21 +528,16 @@ SyncScheduler.prototype = {
       return;
     }
 
     if (!Async.isAppReady()) {
       this._log.debug("Not initiating sync: app is shutting down");
       return;
     }
     Services.tm.dispatchToMainThread(() => {
-      // Terrible hack below: we do the fxa messages polling in the sync
-      // scheduler to get free post-wake/link-state etc detection.
-      fxAccounts.messages.consumeRemoteMessages().catch(e => {
-        this._log.error("Error while polling for FxA messages.", e);
-      });
       this.service.sync({engines, why});
     });
   },
 
   /**
    * Set a timer for the next sync
    */
   scheduleNextSync(interval, {engines = null, why = null} = {}) {
--- a/tools/lint/eslint/modules.json
+++ b/tools/lint/eslint/modules.json
@@ -70,18 +70,18 @@
   "forms.jsm": ["FormData"],
   "FormAutofillHeuristics.jsm": ["FormAutofillHeuristics", "LabelUtils"],
   "FormAutofillSync.jsm": ["AddressesEngine", "CreditCardsEngine"],
   "FormAutofillUtils.jsm": ["FormAutofillUtils", "AddressDataLoader"],
   "FrameScriptManager.jsm": ["getNewLoaderID"],
   "fxa_utils.js": ["initializeIdentityWithTokenServerResponse"],
   "fxaccounts.jsm": ["Authentication"],
   "FxAccounts.jsm": ["fxAccounts", "FxAccounts"],
-  "FxAccountsCommon.js": ["log", "logPII", "FXACCOUNTS_PERMISSION", "DATA_FORMAT_VERSION", "DEFAULT_STORAGE_FILENAME", "ASSERTION_LIFETIME", "ASSERTION_USE_PERIOD", "CERT_LIFETIME", "KEY_LIFETIME", "POLL_SESSION", "ONLOGIN_NOTIFICATION", "ONVERIFIED_NOTIFICATION", "ONLOGOUT_NOTIFICATION", "ON_DEVICE_CONNECTED_NOTIFICATION", "ON_DEVICE_DISCONNECTED_NOTIFICATION", "ON_PROFILE_UPDATED_NOTIFICATION", "ON_PASSWORD_CHANGED_NOTIFICATION", "ON_PASSWORD_RESET_NOTIFICATION", "ON_VERIFY_LOGIN_NOTIFICATION", "ON_ACCOUNT_DESTROYED_NOTIFICATION", "ON_COLLECTION_CHANGED_NOTIFICATION", "FXA_PUSH_SCOPE_ACCOUNT_UPDATE", "ON_PROFILE_CHANGE_NOTIFICATION", "ON_ACCOUNT_STATE_CHANGE_NOTIFICATION", "CAPABILITY_MESSAGES", "CAPABILITY_MESSAGES_SENDTAB", "UI_REQUEST_SIGN_IN_FLOW", "UI_REQUEST_REFRESH_AUTH", "FX_OAUTH_CLIENT_ID", "WEBCHANNEL_ID", "PREF_LAST_FXA_USER", "ERRNO_ACCOUNT_ALREADY_EXISTS", "ERRNO_ACCOUNT_DOES_NOT_EXIST", "ERRNO_INCORRECT_PASSWORD", "ERRNO_UNVERIFIED_ACCOUNT", "ERRNO_INVALID_VERIFICATION_CODE", "ERRNO_NOT_VALID_JSON_BODY", "ERRNO_INVALID_BODY_PARAMETERS", "ERRNO_MISSING_BODY_PARAMETERS", "ERRNO_INVALID_REQUEST_SIGNATURE", "ERRNO_INVALID_AUTH_TOKEN", "ERRNO_INVALID_AUTH_TIMESTAMP", "ERRNO_MISSING_CONTENT_LENGTH", "ERRNO_REQUEST_BODY_TOO_LARGE", "ERRNO_TOO_MANY_CLIENT_REQUESTS", "ERRNO_INVALID_AUTH_NONCE", "ERRNO_ENDPOINT_NO_LONGER_SUPPORTED", "ERRNO_INCORRECT_LOGIN_METHOD", "ERRNO_INCORRECT_KEY_RETRIEVAL_METHOD", "ERRNO_INCORRECT_API_VERSION", "ERRNO_INCORRECT_EMAIL_CASE", "ERRNO_ACCOUNT_LOCKED", "ERRNO_ACCOUNT_UNLOCKED", "ERRNO_UNKNOWN_DEVICE", "ERRNO_DEVICE_SESSION_CONFLICT", "ERRNO_SERVICE_TEMP_UNAVAILABLE", "ERRNO_PARSE", "ERRNO_NETWORK", "ERRNO_UNKNOWN_ERROR", "OAUTH_SERVER_ERRNO_OFFSET", "ERRNO_UNKNOWN_CLIENT_ID", "ERRNO_INCORRECT_CLIENT_SECRET", "ERRNO_INCORRECT_REDIRECT_URI", "ERRNO_INVALID_FXA_ASSERTION", "ERRNO_UNKNOWN_CODE", "ERRNO_INCORRECT_CODE", "ERRNO_EXPIRED_CODE", "ERRNO_OAUTH_INVALID_TOKEN", "ERRNO_INVALID_REQUEST_PARAM", "ERRNO_INVALID_RESPONSE_TYPE", "ERRNO_UNAUTHORIZED", "ERRNO_FORBIDDEN", "ERRNO_INVALID_CONTENT_TYPE", "ERROR_ACCOUNT_ALREADY_EXISTS", "ERROR_ACCOUNT_DOES_NOT_EXIST", "ERROR_ACCOUNT_LOCKED", "ERROR_ACCOUNT_UNLOCKED", "ERROR_ALREADY_SIGNED_IN_USER", "ERROR_DEVICE_SESSION_CONFLICT", "ERROR_ENDPOINT_NO_LONGER_SUPPORTED", "ERROR_INCORRECT_API_VERSION", "ERROR_INCORRECT_EMAIL_CASE", "ERROR_INCORRECT_KEY_RETRIEVAL_METHOD", "ERROR_INCORRECT_LOGIN_METHOD", "ERROR_INVALID_EMAIL", "ERROR_INVALID_AUDIENCE", "ERROR_INVALID_AUTH_TOKEN", "ERROR_INVALID_AUTH_TIMESTAMP", "ERROR_INVALID_AUTH_NONCE", "ERROR_INVALID_BODY_PARAMETERS", "ERROR_INVALID_PASSWORD", "ERROR_INVALID_VERIFICATION_CODE", "ERROR_INVALID_REFRESH_AUTH_VALUE", "ERROR_INVALID_REQUEST_SIGNATURE", "ERROR_INTERNAL_INVALID_USER", "ERROR_MISSING_BODY_PARAMETERS", "ERROR_MISSING_CONTENT_LENGTH", "ERROR_NO_TOKEN_SESSION", "ERROR_NO_SILENT_REFRESH_AUTH", "ERROR_NOT_VALID_JSON_BODY", "ERROR_OFFLINE", "ERROR_PERMISSION_DENIED", "ERROR_REQUEST_BODY_TOO_LARGE", "ERROR_SERVER_ERROR", "ERROR_SYNC_DISABLED", "ERROR_TOO_MANY_CLIENT_REQUESTS", "ERROR_SERVICE_TEMP_UNAVAILABLE", "ERROR_UI_ERROR", "ERROR_UI_REQUEST", "ERROR_PARSE", "ERROR_NETWORK", "ERROR_UNKNOWN", "ERROR_UNKNOWN_DEVICE", "ERROR_UNVERIFIED_ACCOUNT", "ERROR_UNKNOWN_CLIENT_ID", "ERROR_INCORRECT_CLIENT_SECRET", "ERROR_INCORRECT_REDIRECT_URI", "ERROR_INVALID_FXA_ASSERTION", "ERROR_UNKNOWN_CODE", "ERROR_INCORRECT_CODE", "ERROR_EXPIRED_CODE", "ERROR_OAUTH_INVALID_TOKEN", "ERROR_INVALID_REQUEST_PARAM", "ERROR_INVALID_RESPONSE_TYPE", "ERROR_UNAUTHORIZED", "ERROR_FORBIDDEN", "ERROR_INVALID_CONTENT_TYPE", "ERROR_NO_ACCOUNT", "ERROR_AUTH_ERROR", "ERROR_INVALID_PARAMETER", "ERROR_CODE_METHOD_NOT_ALLOWED", "ERROR_MSG_METHOD_NOT_ALLOWED", "DERIVED_KEYS_NAMES", "FXA_PWDMGR_PLAINTEXT_FIELDS", "FXA_PWDMGR_SECURE_FIELDS", "FXA_PWDMGR_MEMORY_FIELDS", "FXA_PWDMGR_REAUTH_WHITELIST", "FXA_PWDMGR_HOST", "FXA_PWDMGR_REALM", "SERVER_ERRNO_TO_ERROR", "ERROR_TO_GENERAL_ERROR_CLASS"],
-  "FxAccountsMessages.js": ["FxAccountsMessages", "FxAccountsMessagesSender", "FxAccountsMessagesReceiver", "FxAccountsMessagesHandler"],
+  "FxAccountsCommands.js": ["SendTab", "FxAccountsCommands"],
+  "FxAccountsCommon.js": ["log", "logPII", "FXACCOUNTS_PERMISSION", "DATA_FORMAT_VERSION", "DEFAULT_STORAGE_FILENAME", "ASSERTION_LIFETIME", "ASSERTION_USE_PERIOD", "CERT_LIFETIME", "KEY_LIFETIME", "POLL_SESSION", "ONLOGIN_NOTIFICATION", "ONVERIFIED_NOTIFICATION", "ONLOGOUT_NOTIFICATION", "ON_COMMAND_RECEIVED_NOTIFICATION", "ON_DEVICE_CONNECTED_NOTIFICATION", "ON_DEVICE_DISCONNECTED_NOTIFICATION", "ON_PROFILE_UPDATED_NOTIFICATION", "ON_PASSWORD_CHANGED_NOTIFICATION", "ON_PASSWORD_RESET_NOTIFICATION", "ON_VERIFY_LOGIN_NOTIFICATION", "ON_ACCOUNT_DESTROYED_NOTIFICATION", "ON_COLLECTION_CHANGED_NOTIFICATION", "FXA_PUSH_SCOPE_ACCOUNT_UPDATE", "ON_PROFILE_CHANGE_NOTIFICATION", "ON_ACCOUNT_STATE_CHANGE_NOTIFICATION", "COMMAND_SENDTAB", "UI_REQUEST_SIGN_IN_FLOW", "UI_REQUEST_REFRESH_AUTH", "FX_OAUTH_CLIENT_ID", "WEBCHANNEL_ID", "PREF_LAST_FXA_USER", "ERRNO_ACCOUNT_ALREADY_EXISTS", "ERRNO_ACCOUNT_DOES_NOT_EXIST", "ERRNO_INCORRECT_PASSWORD", "ERRNO_UNVERIFIED_ACCOUNT", "ERRNO_INVALID_VERIFICATION_CODE", "ERRNO_NOT_VALID_JSON_BODY", "ERRNO_INVALID_BODY_PARAMETERS", "ERRNO_MISSING_BODY_PARAMETERS", "ERRNO_INVALID_REQUEST_SIGNATURE", "ERRNO_INVALID_AUTH_TOKEN", "ERRNO_INVALID_AUTH_TIMESTAMP", "ERRNO_MISSING_CONTENT_LENGTH", "ERRNO_REQUEST_BODY_TOO_LARGE", "ERRNO_TOO_MANY_CLIENT_REQUESTS", "ERRNO_INVALID_AUTH_NONCE", "ERRNO_ENDPOINT_NO_LONGER_SUPPORTED", "ERRNO_INCORRECT_LOGIN_METHOD", "ERRNO_INCORRECT_KEY_RETRIEVAL_METHOD", "ERRNO_INCORRECT_API_VERSION", "ERRNO_INCORRECT_EMAIL_CASE", "ERRNO_ACCOUNT_LOCKED", "ERRNO_ACCOUNT_UNLOCKED", "ERRNO_UNKNOWN_DEVICE", "ERRNO_DEVICE_SESSION_CONFLICT", "ERRNO_SERVICE_TEMP_UNAVAILABLE", "ERRNO_PARSE", "ERRNO_NETWORK", "ERRNO_UNKNOWN_ERROR", "OAUTH_SERVER_ERRNO_OFFSET", "ERRNO_UNKNOWN_CLIENT_ID", "ERRNO_INCORRECT_CLIENT_SECRET", "ERRNO_INCORRECT_REDIRECT_URI", "ERRNO_INVALID_FXA_ASSERTION", "ERRNO_UNKNOWN_CODE", "ERRNO_INCORRECT_CODE", "ERRNO_EXPIRED_CODE", "ERRNO_OAUTH_INVALID_TOKEN", "ERRNO_INVALID_REQUEST_PARAM", "ERRNO_INVALID_RESPONSE_TYPE", "ERRNO_UNAUTHORIZED", "ERRNO_FORBIDDEN", "ERRNO_INVALID_CONTENT_TYPE", "ERROR_ACCOUNT_ALREADY_EXISTS", "ERROR_ACCOUNT_DOES_NOT_EXIST", "ERROR_ACCOUNT_LOCKED", "ERROR_ACCOUNT_UNLOCKED", "ERROR_ALREADY_SIGNED_IN_USER", "ERROR_DEVICE_SESSION_CONFLICT", "ERROR_ENDPOINT_NO_LONGER_SUPPORTED", "ERROR_INCORRECT_API_VERSION", "ERROR_INCORRECT_EMAIL_CASE", "ERROR_INCORRECT_KEY_RETRIEVAL_METHOD", "ERROR_INCORRECT_LOGIN_METHOD", "ERROR_INVALID_EMAIL", "ERROR_INVALID_AUDIENCE", "ERROR_INVALID_AUTH_TOKEN", "ERROR_INVALID_AUTH_TIMESTAMP", "ERROR_INVALID_AUTH_NONCE", "ERROR_INVALID_BODY_PARAMETERS", "ERROR_INVALID_PASSWORD", "ERROR_INVALID_VERIFICATION_CODE", "ERROR_INVALID_REFRESH_AUTH_VALUE", "ERROR_INVALID_REQUEST_SIGNATURE", "ERROR_INTERNAL_INVALID_USER", "ERROR_MISSING_BODY_PARAMETERS", "ERROR_MISSING_CONTENT_LENGTH", "ERROR_NO_TOKEN_SESSION", "ERROR_NO_SILENT_REFRESH_AUTH", "ERROR_NOT_VALID_JSON_BODY", "ERROR_OFFLINE", "ERROR_PERMISSION_DENIED", "ERROR_REQUEST_BODY_TOO_LARGE", "ERROR_SERVER_ERROR", "ERROR_SYNC_DISABLED", "ERROR_TOO_MANY_CLIENT_REQUESTS", "ERROR_SERVICE_TEMP_UNAVAILABLE", "ERROR_UI_ERROR", "ERROR_UI_REQUEST", "ERROR_PARSE", "ERROR_NETWORK", "ERROR_UNKNOWN", "ERROR_UNKNOWN_DEVICE", "ERROR_UNVERIFIED_ACCOUNT", "ERROR_UNKNOWN_CLIENT_ID", "ERROR_INCORRECT_CLIENT_SECRET", "ERROR_INCORRECT_REDIRECT_URI", "ERROR_INVALID_FXA_ASSERTION", "ERROR_UNKNOWN_CODE", "ERROR_INCORRECT_CODE", "ERROR_EXPIRED_CODE", "ERROR_OAUTH_INVALID_TOKEN", "ERROR_INVALID_REQUEST_PARAM", "ERROR_INVALID_RESPONSE_TYPE", "ERROR_UNAUTHORIZED", "ERROR_FORBIDDEN", "ERROR_INVALID_CONTENT_TYPE", "ERROR_NO_ACCOUNT", "ERROR_AUTH_ERROR", "ERROR_INVALID_PARAMETER", "ERROR_CODE_METHOD_NOT_ALLOWED", "ERROR_MSG_METHOD_NOT_ALLOWED", "DERIVED_KEYS_NAMES", "FXA_PWDMGR_PLAINTEXT_FIELDS", "FXA_PWDMGR_SECURE_FIELDS", "FXA_PWDMGR_MEMORY_FIELDS", "FXA_PWDMGR_REAUTH_WHITELIST", "FXA_PWDMGR_HOST", "FXA_PWDMGR_REALM", "SERVER_ERRNO_TO_ERROR", "ERROR_TO_GENERAL_ERROR_CLASS"],
   "FxAccountsOAuthGrantClient.jsm": ["FxAccountsOAuthGrantClient", "FxAccountsOAuthGrantClientError"],
   "FxAccountsProfileClient.jsm": ["FxAccountsProfileClient", "FxAccountsProfileClientError"],
   "FxAccountsPush.js": ["FxAccountsPushService"],
   "FxAccountsStorage.jsm": ["FxAccountsStorageManagerCanStoreField", "FxAccountsStorageManager"],
   "FxAccountsWebChannel.jsm": ["EnsureFxAccountsWebChannel"],
   "gDevTools.jsm": ["gDevTools", "gDevToolsBrowser"],
   "gDevTools.jsm": ["gDevTools", "gDevToolsBrowser"],
   "Geometry.jsm": ["Point", "Rect"],