Bug 1466933 - Implement FxA commands. r=markh,rfkelly
authorEdouard Oger <eoger@fastmail.com>
Wed, 30 May 2018 17:23:56 -0400
changeset 479917 b636a45b545e8739212cce05b25db9f0f373cfe0
parent 479916 62da3d173f63caa61210218b3c14aa9c560c54c5
child 479983 987ea0d6a000b95cf93928b25a74a7fb1dfe37b2
child 479984 260d05b38752cc901f956b5a303da994e29f9556
push id9719
push userffxbld-merge
push dateFri, 24 Aug 2018 17:49:46 +0000
treeherdermozilla-beta@719ec98fba77 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmarkh, rfkelly
bugs1466933
milestone63.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 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
@@ -1409,18 +1409,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;
@@ -632,17 +632,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");
@@ -675,17 +675,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"],