Merge mozilla-central and mozilla-inbound
authorMarco Bonardo <mbonardo@mozilla.com>
Wed, 05 Oct 2011 13:01:20 +0200
changeset 78793 38a487da2def461a01e6e5691227a4c6b1fe5997
parent 78792 b6d4674960af5415e674a866708b280e62d90bc8 (current diff)
parent 78759 70e4de45a0d0f7b54e4dbc22c177e56a9c717a42 (diff)
child 78794 9424f920e6ac8d4d7ddc872ef2b0f24bc0bbe9d4
push id506
push userclegnitto@mozilla.com
push dateWed, 09 Nov 2011 02:03:18 +0000
treeherdermozilla-aurora@63587fc7bb93 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
milestone10.0a1
Merge mozilla-central and mozilla-inbound
--- a/browser/base/content/syncAddDevice.js
+++ b/browser/base/content/syncAddDevice.js
@@ -54,17 +54,17 @@ let gSyncAddDevice = {
     this.pin1.setAttribute("maxlength", PIN_PART_LENGTH);
     this.pin2.setAttribute("maxlength", PIN_PART_LENGTH);
     this.pin3.setAttribute("maxlength", PIN_PART_LENGTH);
 
     this.nextFocusEl = {pin1: this.pin2,
                         pin2: this.pin3,
                         pin3: this.wizard.getButton("next")};
 
-    this.throbber = document.getElementById("add-device-throbber");
+    this.throbber = document.getElementById("pairDeviceThrobber");
     this.errorRow = document.getElementById("errorRow");
 
     // Kick off a sync. That way the server will have the most recent data from
     // this computer and it will show up immediately on the new device.
     Weave.Utils.nextTick(Weave.Service.sync, Weave.Service);
   },
 
   onPageShow: function onPageShow() {
@@ -102,46 +102,58 @@ let gSyncAddDevice = {
         window.close();
         return false;
     }
     return true;
   },
 
   startTransfer: function startTransfer() {
     this.errorRow.hidden = true;
+    // When onAbort is called, Weave may already be gone.
+    const JPAKE_ERROR_USERABORT = Weave.JPAKE_ERROR_USERABORT;
+
     let self = this;
-    this._jpakeclient = new Weave.JPAKEClient({
+    let jpakeclient = this._jpakeclient = new Weave.JPAKEClient({
+      onPaired: function onPaired() {
+        let credentials = {account:   Weave.Service.account,
+                           password:  Weave.Service.password,
+                           synckey:   Weave.Service.passphrase,
+                           serverURL: Weave.Service.serverURL};
+        jpakeclient.sendAndComplete(credentials);
+      },
       onComplete: function onComplete() {
         delete self._jpakeclient;
         self.wizard.pageIndex = DEVICE_CONNECTED_PAGE;
+
+        // Schedule a Sync for soonish to fetch the data uploaded by the
+        // device with which we just paired.
+        Weave.SyncScheduler.scheduleNextSync(Weave.SyncScheduler.activeInterval);
       },
       onAbort: function onAbort(error) {
         delete self._jpakeclient;
 
         // Aborted by user, ignore.
-        if (!error)
+        if (error == JPAKE_ERROR_USERABORT) {
           return;
+        }
 
         self.errorRow.hidden = false;
         self.throbber.hidden = true;
         self.pin1.value = self.pin2.value = self.pin3.value = "";
         self.pin1.disabled = self.pin2.disabled = self.pin3.disabled = false;
         self.pin1.focus();
       }
     });
     this.throbber.hidden = false;
     this.pin1.disabled = this.pin2.disabled = this.pin3.disabled = true;
     this.wizard.canAdvance = false;
 
     let pin = this.pin1.value + this.pin2.value + this.pin3.value;
-    let credentials = {account: Weave.Service.account,
-                       password: Weave.Service.password,
-                       synckey: Weave.Service.passphrase,
-                       serverURL: Weave.Service.serverURL};
-    this._jpakeclient.sendWithPIN(pin, credentials);
+    let expectDelay = false;
+    jpakeclient.pairWithPIN(pin, expectDelay);
   },
 
   onWizardBack: function onWizardBack() {
     if (this.wizard.pageIndex != SYNC_KEY_PAGE)
       return true;
 
     this.wizard.pageIndex = ADD_DEVICE_PAGE;
     return false;
--- a/browser/base/content/syncAddDevice.xul
+++ b/browser/base/content/syncAddDevice.xul
@@ -47,17 +47,17 @@
 <!ENTITY % syncSetupDTD SYSTEM "chrome://browser/locale/syncSetup.dtd">
 %brandDTD;
 %syncBrandDTD;
 %syncSetupDTD;
 ]>
 <wizard xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
         xmlns:html="http://www.w3.org/1999/xhtml"
         id="wizard"
-        title="&addDevice.title.label;"
+        title="&pairDevice.title.label;"
         windowtype="Sync:AddDevice"
         persist="screenX screenY"
         onwizardnext="return gSyncAddDevice.onWizardAdvance();"
         onwizardback="return gSyncAddDevice.onWizardBack();"
         onwizardcancel="gSyncAddDevice.onWizardCancel();"
         onload="gSyncAddDevice.init();">
 
   <script type="application/javascript"
@@ -65,20 +65,20 @@
   <script type="application/javascript"
           src="chrome://browser/content/syncUtils.js"/>
   <script type="application/javascript"
           src="chrome://browser/content/utilityOverlay.js"/>
   <script type="application/javascript"
           src="chrome://global/content/printUtils.js"/>
 
   <wizardpage id="addDevicePage"
-              label="&addDevice.title.label;"
+              label="&pairDevice.title.label;"
               onpageshow="gSyncAddDevice.onPageShow();">
     <description>
-      &addDevice.dialog.description.label;
+      &pairDevice.dialog.description.label;
       <label class="text-link"
              value="&addDevice.showMeHow.label;"
              href="https://services.mozilla.com/sync/help/add-device"/>
     </description>
     <separator class="groove-thin"/>
     <description>
       &addDevice.dialog.enterCode.label;
     </description>
@@ -96,17 +96,17 @@
                />
       <textbox id="pin3"
                class="pin"
                oninput="gSyncAddDevice.onTextBoxInput(this);"
                onfocus="this.select();" 
               />
     </vbox>
     <separator class="groove-thin"/>
-    <vbox id="add-device-throbber" align="center" hidden="true">
+    <vbox id="pairDeviceThrobber" align="center" hidden="true">
       <image/>
     </vbox>
     <hbox id="errorRow" pack="center" hidden="true">
       <image class="statusIcon" status="error"/>
       <label class="status"
              value="&addDevice.dialog.tryAgain.label;"/>
     </hbox>
     <spacer flex="3"/>
--- a/browser/base/content/syncSetup.js
+++ b/browser/base/content/syncSetup.js
@@ -41,43 +41,48 @@
 
 const Ci = Components.interfaces;
 const Cc = Components.classes;
 const Cr = Components.results;
 const Cu = Components.utils;
 
 // page consts
 
-const INTRO_PAGE                    = 0;
-const NEW_ACCOUNT_START_PAGE        = 1;
-const NEW_ACCOUNT_PP_PAGE           = 2;
-const NEW_ACCOUNT_CAPTCHA_PAGE      = 3;
-const EXISTING_ACCOUNT_CONNECT_PAGE = 4;
-const EXISTING_ACCOUNT_LOGIN_PAGE   = 5;
-const OPTIONS_PAGE                  = 6;
-const OPTIONS_CONFIRM_PAGE          = 7;
-const SETUP_SUCCESS_PAGE            = 8;
+const PAIR_PAGE                     = 0;
+const INTRO_PAGE                    = 1;
+const NEW_ACCOUNT_START_PAGE        = 2;
+const EXISTING_ACCOUNT_CONNECT_PAGE = 3;
+const EXISTING_ACCOUNT_LOGIN_PAGE   = 4;
+const OPTIONS_PAGE                  = 5;
+const OPTIONS_CONFIRM_PAGE          = 6;
+const SETUP_SUCCESS_PAGE            = 7;
 
 // Broader than we'd like, but after this changed from api-secure.recaptcha.net
 // we had no choice. At least we only do this for the duration of setup.
 // See discussion in Bugs 508112 and 653307.
 const RECAPTCHA_DOMAIN = "https://www.google.com";
 
+const PIN_PART_LENGTH = 4;
+
 Cu.import("resource://services-sync/main.js");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/PlacesUtils.jsm");
 Cu.import("resource://gre/modules/PluralForm.jsm");
 
+
+function setVisibility(element, visible) {
+  element.style.visibility = visible ? "visible" : "hidden";
+}
+
 var gSyncSetup = {
   QueryInterface: XPCOMUtils.generateQI([Ci.nsISupports,
                                          Ci.nsIWebProgressListener,
                                          Ci.nsISupportsWeakReference]),
 
-  haveCaptcha: true,
   captchaBrowser: null,
   wizard: null,
   _disabledSites: [],
 
   status: {
     password: false,
     email: false,
     server: false
@@ -115,27 +120,33 @@ var gSyncSetup = {
 
     window.setTimeout(function () {
       // Force Service to be loaded so that engines are registered.
       // See Bug 670082.
       Weave.Service;
     }, 0);
 
     this.captchaBrowser = document.getElementById("captcha");
-    this.wizard = document.getElementById("accountSetup");
 
-    if (window.arguments && window.arguments[0] == true) {
-      // we're resetting sync
-      this._resettingSync = true;
-      this.wizard.pageIndex = OPTIONS_PAGE;
+    this.wizardType = null;
+    if (window.arguments && window.arguments[0]) {
+      this.wizardType = window.arguments[0];
     }
-    else {
-      this.wizard.canAdvance = false;
-      this.captchaBrowser.addProgressListener(this);
-      Weave.Svc.Prefs.set("firstSync", "notReady");
+    switch (this.wizardType) {
+      case null:
+        this.wizard.pageIndex = INTRO_PAGE;
+        // Fall through!
+      case "pair":
+        this.captchaBrowser.addProgressListener(this);
+        Weave.Svc.Prefs.set("firstSync", "notReady");
+        break;
+      case "reset":
+        this._resettingSync = true;
+        this.wizard.pageIndex = OPTIONS_PAGE;
+        break;
     }
 
     this.wizard.getButton("extra1").label =
       this._stringBundle.GetStringFromName("button.syncOptions.label");
 
     // Remember these values because the options pages change them temporarily.
     this._nextButtonLabel = this.wizard.getButton("next").label;
     this._nextButtonAccesskey = this.wizard.getButton("next")
@@ -145,24 +156,29 @@ var gSyncSetup = {
                                            .getAttribute("accesskey");
   },
 
   startNewAccountSetup: function () {
     if (!Weave.Utils.ensureMPUnlocked())
       return false;
     this._settingUpNew = true;
     this.wizard.pageIndex = NEW_ACCOUNT_START_PAGE;
-    this.loadCaptcha();
   },
 
   useExistingAccount: function () {
     if (!Weave.Utils.ensureMPUnlocked())
       return false;
     this._settingUpNew = false;
-    this.wizard.pageIndex = EXISTING_ACCOUNT_CONNECT_PAGE;
+    if (this.wizardType == "pair") {
+      // We're already pairing, so there's no point in pairing again.
+      // Go straight to the manual login page.
+      this.wizard.pageIndex = EXISTING_ACCOUNT_LOGIN_PAGE;
+    } else {
+      this.wizard.pageIndex = EXISTING_ACCOUNT_CONNECT_PAGE;
+    }
   },
 
   resetPassphrase: function resetPassphrase() {
     // Apply the existing form fields so that
     // Weave.Service.changePassphrase() has the necessary credentials.
     Weave.Service.account = document.getElementById("existingAccountName").value;
     Weave.Service.password = document.getElementById("existingPassword").value;
 
@@ -202,16 +218,30 @@ var gSyncSetup = {
   onLoginStart: function () {
     this.toggleLoginFeedback(false);
   },
 
   onLoginEnd: function () {
     this.toggleLoginFeedback(true);
   },
 
+  sendCredentialsAfterSync: function () {
+    let send = function() {
+      Services.obs.removeObserver("weave:service:sync:finish", send);
+      Services.obs.removeObserver("weave:service:sync:error", send);
+      let credentials = {account:   Weave.Service.account,
+                         password:  Weave.Service.password,
+                         synckey:   Weave.Service.passphrase,
+                         serverURL: Weave.Service.serverURL};
+      this._jpakeclient.sendAndComplete(credentials);
+    }.bind(this);
+    Services.obs.addObserver("weave:service:sync:finish", send, false);
+    Services.obs.addObserver("weave:service:sync:error", send, false);
+  },
+
   toggleLoginFeedback: function (stop) {
     document.getElementById("login-throbber").hidden = stop;
     let password = document.getElementById("existingPasswordFeedbackRow");
     let server = document.getElementById("existingServerFeedbackRow");
     let passphrase = document.getElementById("existingPassphraseFeedbackRow");
 
     if (!stop || (Weave.Status.login == Weave.LOGIN_SUCCEEDED)) {
       password.hidden = server.hidden = passphrase.hidden = true;
@@ -284,16 +314,25 @@ var gSyncSetup = {
           }
         }
         return false;
     }
     // Default, e.g. wizard's special page -1 etc.
     return true;
   },
 
+  onPINInput: function onPINInput(textbox) {
+    if (textbox && textbox.value.length == PIN_PART_LENGTH) {
+      this.nextFocusEl[textbox.id].focus();
+    }
+    this.wizard.canAdvance = (this.pin1.value.length == PIN_PART_LENGTH &&
+                              this.pin2.value.length == PIN_PART_LENGTH &&
+                              this.pin3.value.length == PIN_PART_LENGTH);
+  },
+
   onEmailInput: function () {
     // Check account validity when the user stops typing for 1 second.
     if (this._checkAccountTimer)
       window.clearTimeout(this._checkAccountTimer);
     this._checkAccountTimer = window.setTimeout(function () {
       gSyncSetup.checkAccount();
     }, 1000);
   },
@@ -330,60 +369,44 @@ var gSyncSetup = {
     this.status.email = valid;
     if (valid)
       Weave.Service.account = value;
     this.checkFields();
   },
 
   onPasswordChange: function () {
     let password = document.getElementById("weavePassword");
-    let valid, str;
-    if (password.value == document.getElementById("weavePassphrase").value) {
-      // xxxmpc - hack, sigh
-      valid = false;
-      errorString = Weave.Utils.getErrorString("change.password.pwSameAsRecoveryKey");
-    }
-    else {
-      let pwconfirm = document.getElementById("weavePasswordConfirm");
-      [valid, errorString] = gSyncUtils.validatePassword(password, pwconfirm);
-    }
+    let pwconfirm = document.getElementById("weavePasswordConfirm");
+    let [valid, errorString] = gSyncUtils.validatePassword(password, pwconfirm);
 
     let feedback = document.getElementById("passwordFeedbackRow");
     this._setFeedback(feedback, valid, errorString);
 
     this.status.password = valid;
     this.checkFields();
   },
 
-  onPassphraseGenerate: function () {
-    let passphrase = Weave.Utils.generatePassphrase();
-    Weave.Service.passphrase = passphrase;
-    let el = document.getElementById("weavePassphrase");
-    el.value = Weave.Utils.hyphenatePassphrase(passphrase);
-  },
-
   onPageShow: function() {
     switch (this.wizard.pageIndex) {
+      case PAIR_PAGE:
+        this.wizard.getButton("back").hidden = true;
+        this.wizard.getButton("extra1").hidden = true;
+        this.onPINInput();
+        this.pin1.focus();
+        break;
       case INTRO_PAGE:
+        // We may not need the captcha in the Existing Account branch of the
+        // wizard. However, we want to preload it to avoid any flickering while
+        // the Create Account page is shown.
+        this.loadCaptcha();
         this.wizard.getButton("next").hidden = true;
         this.wizard.getButton("back").hidden = true;
         this.wizard.getButton("extra1").hidden = true;
-        break;
-      case NEW_ACCOUNT_PP_PAGE:
-        document.getElementById("saveSyncKeyButton").focus();
-        let el = document.getElementById("weavePassphrase");
-        if (!el.value)
-          this.onPassphraseGenerate();
         this.checkFields();
         break;
-      case NEW_ACCOUNT_CAPTCHA_PAGE:
-        if (!this.haveCaptcha) {
-          gSyncSetup.wizard.advance();
-        }
-        break;
       case NEW_ACCOUNT_START_PAGE:
         this.wizard.getButton("extra1").hidden = false;
         this.wizard.getButton("next").hidden = false;
         this.wizard.getButton("back").hidden = false;
         this.onServerCommand();
         this.wizard.canRewind = true;
         this.checkFields();
         break;
@@ -391,27 +414,33 @@ var gSyncSetup = {
         this.wizard.getButton("next").hidden = false;
         this.wizard.getButton("back").hidden = false;
         this.wizard.getButton("extra1").hidden = false;
         this.wizard.canAdvance = false;
         this.wizard.canRewind = true;
         this.startEasySetup();
         break;
       case EXISTING_ACCOUNT_LOGIN_PAGE:
+        this.wizard.getButton("next").hidden = false;
+        this.wizard.getButton("back").hidden = false;
+        this.wizard.getButton("extra1").hidden = false;
         this.wizard.canRewind = true;
         this.checkFields();
         break;
       case SETUP_SUCCESS_PAGE:
         this.wizard.canRewind = false;
         this.wizard.canAdvance = true;
         this.wizard.getButton("back").hidden = true;
         this.wizard.getButton("next").hidden = true;
         this.wizard.getButton("cancel").hidden = true;
         this.wizard.getButton("finish").hidden = false;
         this._handleSuccess();
+        if (this.wizardType == "pair") {
+          this.completePairing();
+        }
         break;
       case OPTIONS_PAGE:
         this.wizard.canRewind = false;
         this.wizard.canAdvance = true;
         if (!this._resettingSync) {
           this.wizard.getButton("next").label =
             this._stringBundle.GetStringFromName("button.syncOptionsDone.label");
           this.wizard.getButton("next").removeAttribute("accesskey");
@@ -440,55 +469,60 @@ var gSyncSetup = {
   onWizardAdvance: function () {
     // Check pageIndex so we don't prompt before the Sync setup wizard appears.
     // This is a fallback in case the Master Password gets locked mid-wizard.
     if ((this.wizard.pageIndex >= 0) &&
         !Weave.Utils.ensureMPUnlocked()) {
       return false;
     }
       
-    if (!this.wizard.pageIndex)
-      return true;
-
     switch (this.wizard.pageIndex) {
+      case PAIR_PAGE:
+        this.startPairing();
+        return false;
       case NEW_ACCOUNT_START_PAGE:
         // If the user selects Next (e.g. by hitting enter) when we haven't
         // executed the delayed checks yet, execute them immediately.
-        if (this._checkAccountTimer)
+        if (this._checkAccountTimer) {
           this.checkAccount();
-        if (this._checkServerTimer)
+        }
+        if (this._checkServerTimer) {
           this.checkServer();
-        return this.wizard.canAdvance;
-      case NEW_ACCOUNT_CAPTCHA_PAGE:
+        }
+        if (!this.wizard.canAdvance) {
+          return false;
+        }
+
         let doc = this.captchaBrowser.contentDocument;
         let getField = function getField(field) {
           let node = doc.getElementById("recaptcha_" + field + "_field");
           return node && node.value;
         };
 
         // Display throbber
         let feedback = document.getElementById("captchaFeedback");
         let image = feedback.firstChild;
         let label = image.nextSibling;
         image.setAttribute("status", "active");
         label.value = this._stringBundle.GetStringFromName("verifying.label");
-        feedback.hidden = false;
+        setVisibility(feedback, true);
 
         let password = document.getElementById("weavePassword").value;
         let email = Weave.Utils.normalizeAccount(
           document.getElementById("weaveEmail").value);
         let challenge = getField("challenge");
         let response = getField("response");
 
         let error = Weave.Service.createAccount(email, password,
                                                 challenge, response);
 
         if (error == null) {
           Weave.Service.account = email;
           Weave.Service.password = password;
+          Weave.Service.passphrase = Weave.Utils.generatePassphrase();
           this._handleNoScript(false);
           this.wizard.pageIndex = SETUP_SUCCESS_PAGE;
           return false;
         }
 
         image.setAttribute("status", "error");
         label.value = Weave.Utils.getErrorString(error);
         return false;
@@ -524,16 +558,25 @@ var gSyncSetup = {
       case NEW_ACCOUNT_START_PAGE:
       case EXISTING_ACCOUNT_LOGIN_PAGE:
         this.wizard.pageIndex = INTRO_PAGE;
         return false;
       case EXISTING_ACCOUNT_CONNECT_PAGE:
         this.abortEasySetup();
         this.wizard.pageIndex = INTRO_PAGE;
         return false;
+      case EXISTING_ACCOUNT_LOGIN_PAGE:
+        // If we were already pairing on entry, we went straight to the manual
+        // login page. If subsequently we go back, return to the page that lets
+        // us choose whether we already have an account.
+        if (this.wizardType == "pair") {
+          this.wizard.pageIndex = INTRO_PAGE;
+          return false;
+        }
+        return true;
       case OPTIONS_CONFIRM_PAGE:
         // Backing up from the confirmation page = resetting first sync to merge.
         document.getElementById("mergeChoiceRadio").selectedIndex = 0;
         return this.returnFromOptions();
     }
     return true;
   },
 
@@ -589,16 +632,69 @@ var gSyncSetup = {
     this.wizard.getButton("back").setAttribute("accesskey",
                                                this._backButtonAccesskey);
     this.wizard.getButton("cancel").hidden = false;
     this.wizard.getButton("extra1").hidden = false;
     this.wizard.pageIndex = this._beforeOptionsPage;
     return false;
   },
 
+  startPairing: function startPairing() {
+    this.pairDeviceErrorRow.hidden = true;
+    // When onAbort is called, Weave may already be gone.
+    const JPAKE_ERROR_USERABORT = Weave.JPAKE_ERROR_USERABORT;
+
+    let self = this;
+    let jpakeclient = this._jpakeclient = new Weave.JPAKEClient({
+      onPaired: function onPaired() {
+        self.wizard.pageIndex = INTRO_PAGE;
+      },
+      onComplete: function onComplete() {
+        // This method will never be called since SendCredentialsController
+        // will take over after the wizard completes.
+      },
+      onAbort: function onAbort(error) {
+        delete self._jpakeclient;
+
+        // Aborted by user, ignore. The window is almost certainly going to close
+        // or is already closed.
+        if (error == JPAKE_ERROR_USERABORT) {
+          return;
+        }
+
+        self.pairDeviceErrorRow.hidden = false;
+        self.pairDeviceThrobber.hidden = true;
+        self.pin1.value = self.pin2.value = self.pin3.value = "";
+        self.pin1.disabled = self.pin2.disabled = self.pin3.disabled = false;
+        if (self.wizard.pageIndex == PAIR_PAGE) {
+          self.pin1.focus();
+        }
+      }
+    });
+    this.pairDeviceThrobber.hidden = false;
+    this.pin1.disabled = this.pin2.disabled = this.pin3.disabled = true;
+    this.wizard.canAdvance = false;
+
+    let pin = this.pin1.value + this.pin2.value + this.pin3.value;
+    let expectDelay = true;
+    jpakeclient.pairWithPIN(pin, expectDelay);
+  },
+
+  completePairing: function completePairing() {
+    if (!this._jpakeclient) {
+      // The channel was aborted while we were setting up the account
+      // locally. XXX TODO should we do anything here, e.g. tell
+      // the user on the last wizard page that it's ok, they just
+      // have to pair again?
+      return;
+    }
+    let controller = new Weave.SendCredentialsController(this._jpakeclient);
+    this._jpakeclient.controller = controller;
+  },
+
   startEasySetup: function () {
     // Don't do anything if we have a client already (e.g. we went to
     // Sync Options and just came back).
     if (this._jpakeclient)
       return;
 
     // When onAbort is called, Weave may already be gone
     const JPAKE_ERROR_USERABORT = Weave.JPAKE_ERROR_USERABORT;
@@ -606,16 +702,18 @@ var gSyncSetup = {
     let self = this;
     this._jpakeclient = new Weave.JPAKEClient({
       displayPIN: function displayPIN(pin) {
         document.getElementById("easySetupPIN1").value = pin.slice(0, 4);
         document.getElementById("easySetupPIN2").value = pin.slice(4, 8);
         document.getElementById("easySetupPIN3").value = pin.slice(8);
       },
 
+      onPairingStart: function onPairingStart() {},
+
       onComplete: function onComplete(credentials) {
         Weave.Service.account = credentials.account;
         Weave.Service.password = credentials.password;
         Weave.Service.passphrase = credentials.synckey;
         Weave.Service.serverURL = credentials.serverURL;
         self.wizard.pageIndex = SETUP_SUCCESS_PAGE;
       },
 
@@ -703,17 +801,17 @@ var gSyncSetup = {
     if (this._existingServerTimer)
       window.clearTimeout(this._existingServerTimer);
     this._existingServerTimer = window.setTimeout(function () {
       gSyncSetup.checkFields();
     }, 1000);
   },
 
   onServerCommand: function () {
-    document.getElementById("TOSRow").hidden = !this._usingMainServers;
+    setVisibility(document.getElementById("TOSRow"), this._usingMainServers);
     let control = document.getElementById("server");
     if (!this._usingMainServers) {
       control.setAttribute("editable", "true");
       // Force a style flush to ensure that the binding is attached.
       control.clientTop;
       control.value = "";
       control.inputField.focus();
       // checkServer() will call checkAccount() and checkFields().
@@ -957,50 +1055,59 @@ var gSyncSetup = {
 
       if (!str)
         str = Weave.Utils.getErrorString(string);
     }
     this._setFeedback(element, success, str);
   },
 
   loadCaptcha: function loadCaptcha() {
+    let captchaURI = Weave.Service.miscAPI + "captcha_html";
     // First check for NoScript and whitelist the right sites.
     this._handleNoScript(true);
-    this.captchaBrowser.loadURI(Weave.Service.miscAPI + "captcha_html");
+    if (this.captchaBrowser.currentURI.spec != captchaURI) {
+      this.captchaBrowser.loadURI(captchaURI);
+    }
   },
 
   onStateChange: function(webProgress, request, stateFlags, status) {
     // We're only looking for the end of the frame load
     if ((stateFlags & Ci.nsIWebProgressListener.STATE_STOP) == 0)
       return;
     if ((stateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK) == 0)
       return;
     if ((stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW) == 0)
       return;
 
-    // If we didn't find the captcha, assume it's not needed and move on
-    if (request.QueryInterface(Ci.nsIHttpChannel).responseStatus == 404) {
-      this.haveCaptcha = false;
-      // Hide the browser just in case we end up displaying the captcha page
-      // due to a sign up error.
-      this.captchaBrowser.hidden = true;
-      if (this.wizard.pageIndex == NEW_ACCOUNT_CAPTCHA_PAGE) {
-        this.onWizardAdvance();
-      }
-    } else {
-      this.haveCaptcha = true;
-      this.captchaBrowser.hidden = false;
-    }
+    // If we didn't find a captcha, assume it's not needed and don't show it.
+    let responseStatus = request.QueryInterface(Ci.nsIHttpChannel).responseStatus;
+    setVisibility(this.captchaBrowser, responseStatus != 404);
+    //XXX TODO we should really log any responseStatus other than 200
   },
   onProgressChange: function() {},
   onStatusChange: function() {},
   onSecurityChange: function() {},
   onLocationChange: function () {}
-}
+};
 
-// onWizardAdvance() and onPageShow() are run before init(), so we'll set
-// wizard & _stringBundle up as lazy getters.
-XPCOMUtils.defineLazyGetter(gSyncSetup, "wizard", function() {
-  return document.getElementById("accountSetup");
+// Define lazy getters for various XUL elements.
+//
+// onWizardAdvance() and onPageShow() are run before init(), so we'll even
+// define things that will almost certainly be used (like 'wizard') as a lazy
+// getter here.
+["wizard",
+ "pin1",
+ "pin2",
+ "pin3",
+ "pairDeviceErrorRow",
+ "pairDeviceThrobber"].forEach(function (id) {
+  XPCOMUtils.defineLazyGetter(gSyncSetup, id, function() {
+    return document.getElementById(id);
+  });
+});
+XPCOMUtils.defineLazyGetter(gSyncSetup, "nextFocusEl", function () {
+  return {pin1: this.pin2,
+          pin2: this.pin3,
+          pin3: this.wizard.getButton("next")};
 });
 XPCOMUtils.defineLazyGetter(gSyncSetup, "_stringBundle", function() {
   return Services.strings.createBundle("chrome://browser/locale/syncSetup.properties");
 });
--- a/browser/base/content/syncSetup.xul
+++ b/browser/base/content/syncSetup.xul
@@ -47,17 +47,18 @@
 <!DOCTYPE window [
 <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd">
 <!ENTITY % syncBrandDTD SYSTEM "chrome://browser/locale/syncBrand.dtd">
 <!ENTITY % syncSetupDTD SYSTEM "chrome://browser/locale/syncSetup.dtd">
 %brandDTD;
 %syncBrandDTD;
 %syncSetupDTD;
 ]>
-<wizard id="accountSetup" title="&accountSetupTitle.label;"
+<wizard id="wizard"
+        title="&accountSetupTitle.label;"
         windowtype="Weave:AccountSetup"
         persist="screenX screenY"
         xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
         xmlns:html="http://www.w3.org/1999/xhtml"
         onwizardnext="return gSyncSetup.onWizardAdvance()"
         onwizardback="return gSyncSetup.onWizardBack()"
         onwizardfinish="gSyncSetup.onWizardFinish()"
         onwizardcancel="gSyncSetup.onWizardCancel()"
@@ -67,39 +68,79 @@
           src="chrome://browser/content/syncSetup.js"/>
   <script type="application/javascript"
           src="chrome://browser/content/syncUtils.js"/>
   <script type="application/javascript"
           src="chrome://browser/content/utilityOverlay.js"/>
   <script type="application/javascript"
           src="chrome://global/content/printUtils.js"/>
 
+  <wizardpage id="addDevicePage"
+              label="&pairDevice.title.label;"
+              onpageshow="gSyncSetup.onPageShow()">
+    <description>
+      &pairDevice.dialog.description.label;
+      <label class="text-link"
+             value="&addDevice.showMeHow.label;"
+             href="https://services.mozilla.com/sync/help/add-device"/>
+    </description>
+    <separator class="groove-thin"/>
+    <description>
+      &addDevice.dialog.enterCode.label;
+    </description>
+    <separator class="groove-thin"/>
+    <vbox align="center">
+      <textbox id="pin1"
+               class="pin"
+               oninput="gSyncSetup.onPINInput(this);"
+               onfocus="this.select();"
+               />
+      <textbox id="pin2"
+               class="pin"
+               oninput="gSyncSetup.onPINInput(this);"
+               onfocus="this.select();"
+               />
+      <textbox id="pin3"
+               class="pin"
+               oninput="gSyncSetup.onPINInput(this);"
+               onfocus="this.select();" 
+               />
+    </vbox>
+    <separator class="groove-thin"/>
+    <vbox id="pairDeviceThrobber" align="center" hidden="true">
+      <image/>
+    </vbox>
+    <hbox id="pairDeviceErrorRow" pack="center" hidden="true">
+      <image class="statusIcon" status="error"/>
+      <label class="status"
+             value="&addDevice.dialog.tryAgain.label;"/>
+    </hbox>
+  </wizardpage>
+
   <wizardpage id="pickSetupType"
               label="&syncBrand.fullName.label;"
               onpageshow="gSyncSetup.onPageShow()">
     <vbox align="center" flex="1">
       <description style="padding: 0 7em;">
         &setup.pickSetupType.description;
       </description>
-      <spacer flex="1"/>
+      <spacer flex="3"/>
       <button id="newAccount"
               class="accountChoiceButton"
               label="&button.createNewAccount.label;"
               oncommand="gSyncSetup.startNewAccountSetup()"
               align="center"/>
-      <spacer flex="3"/>
+      <spacer flex="1"/>
     </vbox>
     <separator class="groove"/>
     <vbox align="center" flex="1">
-      <spacer flex="3"/>
-      <label value="&setup.haveAccount.label;" />
       <spacer flex="1"/>
       <button id="existingAccount"
               class="accountChoiceButton"
-              label="&button.connect.label;"
+              label="&button.haveAccount.label;"
               oncommand="gSyncSetup.useExistingAccount()"/>
       <spacer flex="3"/>
     </vbox>
   </wizardpage>
 
   <wizardpage label="&setup.newAccountDetailsPage.title.label;"
               id="newAccountStart"
               onextra1="gSyncSetup.onSyncOptions()"
@@ -189,74 +230,37 @@
                 &setup.ppLink.label;
               </label>
               &setup.tosAgree3.label;
             </description>
           </hbox>
         </row>
       </rows>
     </grid>
-  </wizardpage>
-
-  <wizardpage label="&setup.newRecoveryKeyPage.title.label;"
-              onextra1="gSyncSetup.onSyncOptions()"
-              onpageshow="gSyncSetup.onPageShow();">
-    <description>
-      &setup.newRecoveryKeyPage.description.label;
-    </description>
-    <spacer/>
-
-    <groupbox>
-      <label value="&recoveryKeyEntry.label;"
-             accesskey="&recoveryKeyEntry.accesskey;"
-             control="weavePassphrase"/>
-      <textbox id="weavePassphrase"
-               readonly="true"
-               onfocus="this.select();"/>
-    </groupbox>
-
-    <groupbox align="center">
-      <description>&recoveryKeyBackup.description;</description>
-      <hbox>
-        <button id="printSyncKeyButton"
-                label="&button.syncKeyBackup.print.label;"
-                accesskey="&button.syncKeyBackup.print.accesskey;"
-                oncommand="gSyncUtils.passphrasePrint('weavePassphrase');"/>
-        <button id="saveSyncKeyButton"
-                label="&button.syncKeyBackup.save.label;"
-                accesskey="&button.syncKeyBackup.save.accesskey;"
-                oncommand="gSyncUtils.passphraseSave('weavePassphrase');"/>
-      </hbox>
-    </groupbox>
-  </wizardpage>
-
-  <wizardpage label="&setup.captchaPage2.title.label;"
-              onextra1="gSyncSetup.onSyncOptions()"
-              onpageshow="gSyncSetup.onPageShow();">
+    <spacer flex="1"/>
     <vbox flex="1" align="center">
       <browser height="150"
-               width="450"
+               width="500"
                id="captcha"
                type="content"
                disablehistory="true"/>
       <spacer flex="1"/>
-      <hbox id="captchaFeedback" hidden="true">
+      <hbox id="captchaFeedback">
         <image class="statusIcon"/>
         <label class="status" value=" "/>
       </hbox>
-      <spacer flex="3"/>
     </vbox>
   </wizardpage>
 
   <wizardpage id="addDevice"
-              label="&addDevice.title.label;"
+              label="&pairDevice.title.label;"
               onextra1="gSyncSetup.onSyncOptions()"
               onpageshow="gSyncSetup.onPageShow()">
     <description>
-      &addDevice.setup.description.label;
+      &pairDevice.setup.description.label;
       <label class="text-link"
              value="&addDevice.showMeHow.label;"
              href="https://services.mozilla.com/sync/help/easy-setup"/>
     </description>
     <description>&addDevice.setup.enterCode.label;</description>
     <spacer flex="1"/>
     <vbox align="center" flex="1">
       <textbox id="easySetupPIN1"
--- a/browser/components/preferences/sync.js
+++ b/browser/components/preferences/sync.js
@@ -138,23 +138,33 @@ let gSyncPane = {
 
   resetPass: function () {
     if (Weave.Status.login == Weave.LOGIN_FAILED_LOGIN_REJECTED)
       gSyncUtils.resetPassword();
     else
       gSyncUtils.resetPassphrase();
   },
 
-  openSetup: function (resetSync) {
+  /**
+   * Invoke the Sync setup wizard.
+   * 
+   * @param wizardType
+   *        Indicates type of wizard to launch:
+   *          null    -- regular set up wizard
+   *          "pair"  -- pair a device first
+   *          "reset" -- reset sync
+   */
+  openSetup: function (wizardType) {
     var win = Services.wm.getMostRecentWindow("Weave:AccountSetup");
     if (win)
       win.focus();
     else {
       window.openDialog("chrome://browser/content/syncSetup.xul",
-                        "weaveSetup", "centerscreen,chrome,resizable=no", resetSync);
+                        "weaveSetup", "centerscreen,chrome,resizable=no",
+                        wizardType);
     }
   },
 
   openQuotaDialog: function () {
     let win = Services.wm.getMostRecentWindow("Sync:ViewQuota");
     if (win)
       win.focus();
     else 
@@ -170,12 +180,12 @@ let gSyncPane = {
     if (win)
       win.focus();
     else 
       window.openDialog("chrome://browser/content/syncAddDevice.xul",
                         "syncAddDevice", "centerscreen,chrome,resizable=no");
   },
 
   resetSync: function () {
-    this.openSetup(true);
+    this.openSetup("reset");
   }
 }
 
--- a/browser/components/preferences/sync.xul
+++ b/browser/components/preferences/sync.xul
@@ -69,24 +69,27 @@
             src="chrome://browser/content/preferences/sync.js"/>
     <script type="application/javascript"
             src="chrome://browser/content/syncUtils.js"/>
 
 
       <deck id="weavePrefsDeck">
         <vbox id="noAccount" align="center">
           <spacer flex="1"/>
-          <button id="setupButton"
-                  label="&setupButton.label;"
-                  accesskey="&setupButton.accesskey;"
-                  oncommand="gSyncPane.openSetup();"/>
-          <separator/>
-          <description id="syncDesc" flex="1">
+          <description id="syncDesc">
             &weaveDesc.label;
           </description>
+          <separator/>
+          <label class="text-link"
+                 onclick="event.stopPropagation(); gSyncPane.openSetup(null);"
+                 value="&setupButton.label;"/>
+          <separator/>
+          <label class="text-link"
+                 onclick="event.stopPropagation(); gSyncPane.openSetup('pair');"
+                 value="&pairDevice.label;"/>
           <spacer flex="3"/>
         </vbox>
 
         <vbox id="hasAccount">
           <groupbox class="syncGroupBox">
             <!-- label is set to account name -->
             <caption id="accountCaption" align="center">
               <image id="accountCaptionImage"/>
@@ -111,17 +114,17 @@
                 </menupopup>
               </button>
             </hbox>
 
             <hbox>
               <label id="syncAddDeviceLabel"
                      class="text-link"
                      onclick="gSyncPane.openAddDevice(); return false;"
-                     value="&addDevice.label;"/>
+                     value="&pairDevice.label;"/>
             </hbox>
 
             <vbox>
               <label value="&syncMy.label;" />
               <richlistbox id="syncEnginesList"
                            orient="vertical"
                            onselect="if (this.selectedCount) this.clearSelection();">
                 <richlistitem>
--- a/browser/locales/en-US/chrome/browser/preferences/sync.dtd
+++ b/browser/locales/en-US/chrome/browser/preferences/sync.dtd
@@ -12,17 +12,17 @@
 <!-- Manage Account -->
 <!ENTITY manageAccount.label          "Manage Account">
 <!ENTITY manageAccount.accesskey      "A">
 <!ENTITY viewQuota.label              "View Quota">
 <!ENTITY changePassword2.label        "Change Password…">
 <!ENTITY myRecoveryKey.label          "My Recovery Key">
 <!ENTITY resetSync2.label             "Reset Sync…">
 
-<!ENTITY addDevice.label              "Add a Device">
+<!ENTITY pairDevice.label             "Pair a Device">
 
 <!ENTITY syncMy.label               "Sync My">
 <!ENTITY engine.bookmarks.label     "Bookmarks">
 <!ENTITY engine.bookmarks.accesskey "m">
 <!ENTITY engine.tabs.label          "Tabs">
 <!ENTITY engine.tabs.accesskey      "T">
 <!ENTITY engine.history.label       "History">
 <!ENTITY engine.history.accesskey   "r">
--- a/browser/locales/en-US/chrome/browser/syncSetup.dtd
+++ b/browser/locales/en-US/chrome/browser/syncSetup.dtd
@@ -1,16 +1,15 @@
 <!ENTITY accountSetupTitle.label    "&syncBrand.fullName.label; Setup">
 
 <!-- First page of the wizard -->
 
 <!ENTITY setup.pickSetupType.description "Welcome, if you've never used &syncBrand.fullName.label; before, you will need to create a new account.">
 <!ENTITY button.createNewAccount.label "Create a New Account">
-<!ENTITY setup.haveAccount.label "I already have a &syncBrand.fullName.label; account.">
-<!ENTITY button.connect.label    "Connect">
+<!ENTITY button.haveAccount.label      "I Have an Account">
 
 <!ENTITY setup.choicePage.title.label     "Have you used &syncBrand.fullName.label; before?">
 <!ENTITY setup.choicePage.new.label       "I've never used &syncBrand.shortName.label; before">
 <!ENTITY setup.choicePage.existing.label  "I'm already using &syncBrand.shortName.label; on another computer">
 
 <!-- New Account AND Existing Account -->
 <!ENTITY server.label               "Server">
 <!ENTITY serverType.main.label      "&syncBrand.fullName.label; Server">
@@ -35,38 +34,36 @@
 <!ENTITY setup.tosAgree1.label      "I agree to the">
 <!ENTITY setup.tosAgree1.accesskey  "a">
 <!ENTITY setup.tosLink.label        "Terms of Service">
 <!ENTITY setup.tosAgree2.label      "and the">
 <!ENTITY setup.ppLink.label         "Privacy Policy">
 <!ENTITY setup.tosAgree3.label      "">
 <!ENTITY setup.tosAgree2.accesskey  "">
 
-<!-- New Account Page 2: Sync Key -->
+<!-- My Recovery Key dialog -->
 <!ENTITY setup.newRecoveryKeyPage.title.label "&brandShortName; Cares About Your Privacy">
 <!ENTITY setup.newRecoveryKeyPage.description.label "To ensure your total privacy, all of your data is encrypted prior to being uploaded. The Recovery Key which is necessary to decrypt your data is not uploaded.">
 <!ENTITY recoveryKeyEntry.label        "Your Recovery Key">
 <!ENTITY recoveryKeyEntry.accesskey    "K">
 <!ENTITY syncGenerateNewKey.label  "Generate a new key">
 <!ENTITY recoveryKeyBackup.description "Your Recovery Key is required to access &syncBrand.fullName.label; on other machines. Please create a backup copy. We cannot help you recover your Recovery Key.">
 
 <!ENTITY button.syncKeyBackup.print.label     "Print…">
 <!ENTITY button.syncKeyBackup.print.accesskey "P">
 <!ENTITY button.syncKeyBackup.save.label      "Save…">
 <!ENTITY button.syncKeyBackup.save.accesskey  "S">
 
-<!-- New Account Page 3: Captcha -->
-<!ENTITY setup.captchaPage2.title.label     "Please Confirm You're Not a Robot">
-<!-- Existing Account Page 1: Add Device (incl. Add a Device dialog strings) -->
-<!ENTITY addDevice.title.label              "Add a Device">
+<!-- Existing Account Page 1: Pair a Device (incl. Pair a Device dialog strings) -->
+<!ENTITY pairDevice.title.label             "Pair a Device">
 <!ENTITY addDevice.showMeHow.label          "Show me how.">
 <!ENTITY addDevice.dontHaveDevice.label     "I don't have the device with me">
-<!ENTITY addDevice.setup.description.label  "To activate, go to &syncBrand.shortName.label; Options on your other device and select &#x0022;Add a Device&#x0022;.">
+<!ENTITY pairDevice.setup.description.label  "To activate, select &#x0022;Pair a Device&#x0022; on your other device.">
 <!ENTITY addDevice.setup.enterCode.label    "Then, enter this code:">
-<!ENTITY addDevice.dialog.description.label "To activate your new device, go to &syncBrand.shortName.label; Options on the device and select &#x0022;Connect.&#x0022;">
+<!ENTITY pairDevice.dialog.description.label "To activate your new device, select &#x0022;Set Up Sync&#x0022; on the device.">
 <!ENTITY addDevice.dialog.enterCode.label   "Enter the code that the device provides:">
 <!ENTITY addDevice.dialog.tryAgain.label    "Please try again.">
 <!ENTITY addDevice.dialog.successful.label  "The device has been successfully added. The initial synchronization can take several minutes and will finish in the background.">
 <!ENTITY addDevice.dialog.recoveryKey.label     "To activate your device you will need to enter your Recovery Key. Please print or save this key and take it with you.">
 <!ENTITY addDevice.dialog.connected.label   "Device Connected">
 
 <!-- Existing Account Page 2: Manual Login -->
 <!ENTITY setup.signInPage.title.label "Sign In">
--- a/browser/themes/gnomestripe/browser/preferences/preferences.css
+++ b/browser/themes/gnomestripe/browser/preferences/preferences.css
@@ -164,17 +164,17 @@ radio[pane=paneSync] {
 #SanitizeDialogPane > groupbox {
   margin-top: 0;
 }
 
 %ifdef MOZ_SERVICES_SYNC
 /* Sync Pane */
 
 #syncDesc {
-  padding: 0 12em;
+  padding: 0 8em;
 }
 
 #accountCaptionImage {
   list-style-image: url("chrome://mozapps/skin/profile/profileicon.png");
 }
 
 #syncAddDeviceLabel {
   margin-top: 1em;
--- a/browser/themes/gnomestripe/browser/syncSetup.css
+++ b/browser/themes/gnomestripe/browser/syncSetup.css
@@ -1,28 +1,28 @@
 wizard {
   -moz-appearance: none;
   width: 55em;
-  height: 42em;
+  height: 45em;
   padding: 0;
   background-color: Window;
 }
 
 .wizard-page-box {
   -moz-appearance: none;
   padding-left: 0;
   padding-right: 0;
   margin: 0;
 }
 
 wizardpage {
   -moz-box-pack: center;
   -moz-box-align: center;
   margin: 0;
-  padding: 0 8em;
+  padding: 0 6em;
   background-color: Window;
 }
 
 .wizard-header {
   -moz-appearance: none;
   border: none;
   padding: 2em 0 1em 0;
   text-align: center;
@@ -104,17 +104,21 @@ description > .text-link:focus {
   width: 4em;
   text-align: center; 
 }
 
 #passphraseHelpSpacer {
   width: 0.5em;
 }
 
-#add-device-throbber > image,
+#pairDeviceThrobber > image,
 #login-throbber > image {
   list-style-image: url("chrome://global/skin/icons/loading_16.png");
 }
 
+#captchaFeedback {
+  visibility: hidden;
+}
+
 #successPageIcon {
   /* TODO replace this with a 128px version (bug 591122) */
   list-style-image: url("chrome://browser/skin/sync-32.png");
 }
--- a/browser/themes/pinstripe/browser/preferences/preferences.css
+++ b/browser/themes/pinstripe/browser/preferences/preferences.css
@@ -222,17 +222,17 @@ caption {
   margin-top: 0;
 }
 
 
 %ifdef MOZ_SERVICES_SYNC
 /* ----- SYNC PANE ----- */
 
 #syncDesc {
-  padding: 0 12em;
+  padding: 0 8em;
 }
 
 #accountCaptionImage {
   list-style-image: url("chrome://mozapps/skin/profile/profileicon.png");
 }
 
 #syncAddDeviceLabel {
   margin-top: 1em;
--- a/browser/themes/pinstripe/browser/syncSetup.css
+++ b/browser/themes/pinstripe/browser/syncSetup.css
@@ -1,28 +1,28 @@
 wizard {
   -moz-appearance: none;
   width: 55em;
-  height: 42em;
+  height: 45em;
   padding: 0;
   background-color: Window;
 }
 
 .wizard-page-box {
   -moz-appearance: none;
   padding-left: 0;
   padding-right: 0;
   margin: 0;
 }
 
 wizardpage {
   -moz-box-pack: center;
   -moz-box-align: center;
   margin: 0;
-  padding: 0 8em;
+  padding: 0 6em;
   background-color: Window;
 }
 
 .wizard-header {
   -moz-appearance: none;
   border: none;
   padding: 2em 0 1em 0;
   text-align: center;
@@ -103,17 +103,21 @@ description > .text-link:focus {
   width: 4em;
   text-align: center; 
 }
 
 #passphraseHelpSpacer {
   width: 0.5em;
 }
 
-#add-device-throbber > image,
+#pairDeviceThrobber > image,
 #login-throbber > image {
   list-style-image: url("chrome://global/skin/icons/loading_16.png");
 }
 
+#captchaFeedback {
+  visibility: hidden;
+}
+
 #successPageIcon {
   /* TODO replace this with a 128px version (bug 591122) */
   list-style-image: url("chrome://browser/skin/sync-32.png");
 }
--- a/browser/themes/winstripe/browser/preferences/preferences.css
+++ b/browser/themes/winstripe/browser/preferences/preferences.css
@@ -150,17 +150,17 @@ radio[pane=paneSync] {
 .bottomBox {
   padding-bottom: 4px;
 }
 
 %ifdef MOZ_SERVICES_SYNC
 /* Sync Pane */
 
 #syncDesc {
-  padding: 0 12em;
+  padding: 0 8em;
 }
 
 .syncGroupBox {
   padding: 10px;
 }
 
 #accountCaptionImage {
   list-style-image: url("chrome://mozapps/skin/profile/profileicon.png");
--- a/browser/themes/winstripe/browser/syncSetup.css
+++ b/browser/themes/winstripe/browser/syncSetup.css
@@ -1,28 +1,28 @@
 wizard {
   -moz-appearance: none;
   width: 55em;
-  height: 42em;
+  height: 45em;
   padding: 0;
   background-color: Window;
 }
 
 .wizard-page-box {
   -moz-appearance: none;
   padding-left: 0;
   padding-right: 0;
   margin: 0;
 }
 
 wizardpage {
   -moz-box-pack: center;
   -moz-box-align: center;
   margin: 0;
-  padding: 0 8em;
+  padding: 0 6em;
   background-color: Window;
 }
 
 .wizard-header {
   -moz-appearance: none;
   border: none;
   padding: 2em 0 1em 0;
   text-align: center;
@@ -104,17 +104,21 @@ description > .text-link:focus {
   width: 4em;
   text-align: center; 
 }
 
 #passphraseHelpSpacer {
   width: 0.5em;
 }
 
-#add-device-throbber > image,
+#pairDeviceThrobber > image,
 #login-throbber > image {
   list-style-image: url("chrome://global/skin/icons/loading_16.png");
 }
 
+#captchaFeedback {
+  visibility: hidden;
+}
+
 #successPageIcon {
   /* TODO replace this with a 128px version (bug 591122) */
   list-style-image: url("chrome://browser/skin/sync-32.png");
 }
--- a/mobile/chrome/content/aboutHome.xhtml
+++ b/mobile/chrome/content/aboutHome.xhtml
@@ -87,17 +87,19 @@
       <div id="locale" class="section-row" style="display: none;" onclick="openLocalePicker();" role="button">
          <div>&aboutHome.getLocale;</div>
       </div>
 
       <div id="footer-wrapper">
         <span id="feedback" style="width: &aboutHome.footerWidth;" class="section-row" pref="app.feedbackURL" onclick="openLink(this);" role="button">&aboutHome.giveFeedback;</span
         ><span id="support" style="width: &aboutHome.footerWidth;" class="section-row" pref="app.support.baseURL" onclick="openLink(this);" role="button">&aboutHome.getHelp;</span>
       </div>
-
+      <div id="sync-setup">
+        <a id="syncSetupSync" href="#" onclick="openSetupSyncWizard();">&aboutHome.setupSync;</a>
+      </div>
     </div>
   </div>
 
   <!-- l10n hack -->
   <div style="display: none">
     <span id="text-openalltabs">&aboutHome.openAllTabs;</span>
     <span id="text-notabs">&aboutHome.noTabs;</span>
     <span id="text-noaddons">&aboutHome.noAddons;</span>
@@ -157,31 +159,33 @@
       let win = getChromeWin();
       win.BrowserUI.showPanel("prefs-container");
       win.document.getElementById("prefs-languages").click();
     }
 
     function init() {
       initTabs();
       initAddons();
+      initSetupSync();
 
       let prefs = Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefService).QueryInterface(Ci.nsIPrefBranch2);
       if (prefs.getBoolPref("browser.firstrun.show.uidiscovery")) {
         startDiscovery();
         prefs.setBoolPref("browser.firstrun.show.uidiscovery", false);
         document.getElementById("locale").style.display = "block";
       } else {
         endDiscovery();
       }
 
       initLightbox();
     }
 
     function uninit() {
       uninitAddons();
+      uninitSetupSync();
     }
 
     function _readFile(aFile, aCallback) {
       let channel = NetUtil.newChannel(aFile);
       channel.contentType = "application/json";
       NetUtil.asyncFetch(channel, function(aStream, aResult) {
         if (!Components.isSuccessCode(aResult)) {
           Cu.reportError("AboutHome: Could not read from " + aFile.leafName);
@@ -434,17 +438,45 @@
    function endDiscovery() {
      let doc = getChromeWin().document;
      let broadcaster = doc.getElementById("bcast_uidiscovery");
 
      broadcaster.removeAttribute("mode");
      doc.removeEventListener("animationend", endDiscovery, false);
      doc.removeEventListener("PanBegin", endDiscovery, false);
    }
-   
+
+   function initSetupSync() {
+     let services = getChromeWin().Services; 
+     if (services.prefs.prefHasUserValue("services.sync.username")) {
+       document.getElementById("syncSetupSync").style.display = "none";
+     }    
+     services.obs.addObserver(syncConnected, "weave:service:setup-complete", false);
+     services.obs.addObserver(syncDisconnected, "weave:service:start-over", false);
+   }
+
+   function uninitSetupSync() {
+     let services = getChromeWin().Services; 
+     services.obs.removeObserver(syncConnected, "weave:service:setup-complete");
+     services.obs.removeObserver(syncDisconnected, "weave:service:start-over");
+   }
+
+   function syncConnected() {
+     document.getElementById("syncSetupSync").style.display = "none";
+   }
+
+   function syncDisconnected() {
+     document.getElementById("syncSetupSync").style.display = "inline";
+   }
+
+   function openSetupSyncWizard() {
+     let chromeWin = getChromeWin();
+     chromeWin.WeaveGlue.open();
+   }
+
    function initLightbox() {
      let prefs = getChromeWin().Services.prefs;
      let channel = prefs.getCharPref("app.update.channel");
      let startupCount = 0;
      try {
        startupCount = prefs.getIntPref("app.promo.aurora");
        if(startupCount != 6)
          prefs.setIntPref("app.promo.aurora", ++startupCount);
--- a/mobile/chrome/content/browser.xul
+++ b/mobile/chrome/content/browser.xul
@@ -563,32 +563,35 @@
     <box id="syncsetup-container" class="perm-modal-block" hidden="true">
       <dialog id="syncsetup-dialog" class="content-dialog" flex="1">
         <hbox class="prompt-title">
           <description>&sync.setup.title;</description>
         </hbox>
         <separator class="prompt-line"/>
         <vbox id="syncsetup-simple" class="syncsetup-page" flex="1">
           <scrollbox id="sync-message" class="prompt-message" orient="vertical" flex="1">
-            <description class="syncsetup-desc syncsetup-center" flex="1">&sync.setup.jpake;</description>
+            <description class="syncsetup-desc syncsetup-center" flex="1">&sync.setup.pair;</description>
             <description class="syncsetup-center link" flex="1" onclick="WeaveGlue.openTutorial();">&sync.setup.tutorial;</description>
             <separator/>
             <vbox align="center" flex="1">
               <description id="syncsetup-code1" class="syncsetup-code">....</description>
               <description id="syncsetup-code2" class="syncsetup-code">....</description>
               <description id="syncsetup-code3" class="syncsetup-code">....</description>
             </vbox>
             <separator/>
             <description class="syncsetup-center link" flex="1" onclick="WeaveGlue.openManual();">&sync.fallback;</description>
             <separator flex="1"/>
           </scrollbox>
           <hbox class="prompt-buttons" pack="center">
             <button class="prompt-button" oncommand="WeaveGlue.close();">&sync.setup.cancel;</button>
           </hbox>
         </vbox>
+        <vbox id="syncsetup-waiting" class="syncsetup-page" flex="1" hidden="true">
+          <description class="syncsetup-desc syncsetup-center" flex="1">&sync.setup.waiting;</description>
+        </vbox>
         <vbox id="syncsetup-fallback" class="syncsetup-page" flex="1" hidden="true">
           <scrollbox class="prompt-message" orient="vertical" flex="1">
             <description class="syncsetup-desc syncsetup-center" flex="1">&sync.setup.manual;</description>
             <separator/>
             <textbox id="syncsetup-account" class="prompt-edit" placeholder="&sync.account;" oninput="WeaveGlue.canConnect();"/>
             <textbox id="syncsetup-password" class="prompt-edit" placeholder="&sync.password;" type="password" oninput="WeaveGlue.canConnect();"/>
             <textbox id="syncsetup-synckey" class="prompt-edit" placeholder="&sync.recoveryKey;" oninput="WeaveGlue.canConnect();"/>
             <separator class="thin"/>
--- a/mobile/chrome/content/sync.js
+++ b/mobile/chrome/content/sync.js
@@ -103,28 +103,34 @@ let WeaveGlue = {
     }
 
     // Clear up any previous JPAKE codes
     this.abortEasySetup();
 
     // Show the connect UI
     container.hidden = false;
     document.getElementById("syncsetup-simple").hidden = false;
+    document.getElementById("syncsetup-waiting").hidden = true;
     document.getElementById("syncsetup-fallback").hidden = true;
 
     BrowserUI.pushDialog(this);
 
     let self = this;
     this.jpake = new Weave.JPAKEClient({
       displayPIN: function displayPIN(aPin) {
         document.getElementById("syncsetup-code1").value = aPin.slice(0, 4);
         document.getElementById("syncsetup-code2").value = aPin.slice(4, 8);
         document.getElementById("syncsetup-code3").value = aPin.slice(8);
       },
 
+      onPairingStart: function onPairingStart() {
+        document.getElementById("syncsetup-simple").hidden = true;
+        document.getElementById("syncsetup-waiting").hidden = false;
+      },
+
       onComplete: function onComplete(aCredentials) {
         self.jpake = null;
         self.close();
         self.setupData = aCredentials;
         self.connect();
       },
 
       onAbort: function onAbort(aError) {
--- a/mobile/locales/en-US/chrome/aboutHome.dtd
+++ b/mobile/locales/en-US/chrome/aboutHome.dtd
@@ -14,8 +14,9 @@
 <!-- LOCALIZATION NOTE:
      (aboutHome.downloadAurora): First line of a multi-line button. Treat as a title.
 -->
 <!ENTITY aboutHome.downloadAurora		"Download Aurora">
 <!-- LOCALIZATION NOTE:
      (aboutHome.forAndroid): Second line of a multi-line button. Treat as a subtitle.
 -->
 <!ENTITY aboutHome.forAndroid			"for Android">
+<!ENTITY aboutHome.setupSync      "Set Up Sync">
--- a/mobile/locales/en-US/chrome/sync.dtd
+++ b/mobile/locales/en-US/chrome/sync.dtd
@@ -3,19 +3,20 @@
 <!ENTITY sync.connect               "Connect">
 <!ENTITY sync.connected             "Connected">
 <!ENTITY sync.details               "Details">
 <!ENTITY sync.deviceName            "This device">
 <!ENTITY sync.disconnect            "Disconnect">
 <!ENTITY sync.syncNow               "Sync Now">
 
 <!ENTITY sync.setup.title           "Connect to Sync">
-<!ENTITY sync.setup.jpake           "From a Firefox Sync-connected computer, go to Sync options and select &#x0022;Add a device&#x0022;">
+<!ENTITY sync.setup.pair            "To activate, select &#x0022;Pair a Device&#x0022; on your other device.">
 <!ENTITY sync.fallback              "I'm not near my computer…">
 <!ENTITY sync.setup.manual          "Enter your Sync account information">
 <!ENTITY sync.account               "Account Name">
 <!ENTITY sync.password              "Password">
 <!ENTITY sync.recoveryKey           "Recovery Key">
 <!ENTITY sync.customServer          "Use custom server">
 <!ENTITY sync.serverURL             "Server URL">
 <!ENTITY sync.setup.connect         "Connect">
 <!ENTITY sync.setup.cancel          "Cancel">
 <!ENTITY sync.setup.tutorial        "Show me how">
+<!ENTITY sync.setup.waiting         "Pairing in progress…">
--- a/mobile/themes/core/aboutHome.css
+++ b/mobile/themes/core/aboutHome.css
@@ -239,16 +239,27 @@ body[dir="rtl"] {
   margin: 0;
 }
 
 #support {
   display: inline-block;
   margin: 0;
 }
 
+#sync-setup {
+  font-size: 18px;
+  margin-top: 24px;
+  text-align: center;
+}
+
+#syncSetupSync { 
+  text-decoration: underline; 
+  color: blue; 
+} 
+
 /* Lightbox for Aurora */
 #lightbox {
   position: fixed;
   width: 100%;
   height: 100%;
   top: 0px;
   left: 0px;
   display: none;
--- a/mobile/themes/core/browser.css
+++ b/mobile/themes/core/browser.css
@@ -1528,16 +1528,20 @@ setting {
 #syncsetup-customserver {
   -moz-margin-start: @margin_xnormal@;
 }
 
 #sync-message {
   padding-bottom: 2em;
 }
 
+#syncsetup-waiting {
+  padding: 10em 0;
+}
+
 /* content scrollbars */
 .scroller {
   opacity: 0;
   background-color: rgba(0, 0, 0, 0.4) !important;
   -moz-border-top-colors: none !important;
   -moz-border-bottom-colors: none !important;
   -moz-border-right-colors: none !important;
   -moz-border-left-colors: none !important;
--- a/services/sync/locales/en-US/errors.properties
+++ b/services/sync/locales/en-US/errors.properties
@@ -10,14 +10,13 @@ error.sync.failed_partial            = O
 error.sync.reason.server_maintenance = Firefox Sync server maintenance is underway, syncing will resume automatically.
 
 invalid-captcha = Incorrect words, try again
 weak-password   = Use a stronger password
 
 # this is the fallback, if we hit an error we didn't bother to localize
 error.reason.unknown          = Unknown error
 
-change.password.pwSameAsRecoveryKey  = Password can't match your Recovery Key
 change.password.pwSameAsPassword     = Password can't match current password
 change.password.pwSameAsUsername     = Password can't match your user name
 change.password.pwSameAsEmail        = Password can't match your email address
 change.password.mismatch             = The passwords entered do not match
 change.password.tooShort             = The password entered is too short
--- a/services/sync/modules/constants.js
+++ b/services/sync/modules/constants.js
@@ -180,16 +180,17 @@ JPAKE_ERROR_NETWORK:                   "
 JPAKE_ERROR_SERVER:                    "jpake.error.server",
 JPAKE_ERROR_TIMEOUT:                   "jpake.error.timeout",
 JPAKE_ERROR_INTERNAL:                  "jpake.error.internal",
 JPAKE_ERROR_INVALID:                   "jpake.error.invalid",
 JPAKE_ERROR_NODATA:                    "jpake.error.nodata",
 JPAKE_ERROR_KEYMISMATCH:               "jpake.error.keymismatch",
 JPAKE_ERROR_WRONGMESSAGE:              "jpake.error.wrongmessage",
 JPAKE_ERROR_USERABORT:                 "jpake.error.userabort",
+JPAKE_ERROR_DELAYUNSUPPORTED:          "jpake.error.delayunsupported",
 
 // info types for Service.getStorageInfo
 INFO_COLLECTIONS:                      "collections",
 INFO_COLLECTION_USAGE:                 "collection_usage",
 INFO_COLLECTION_COUNTS:                "collection_counts",
 INFO_QUOTA:                            "quota",
 
 // Ways that a sync can be disabled (messages only to be printed in debug log)
--- a/services/sync/modules/jpakeclient.js
+++ b/services/sync/modules/jpakeclient.js
@@ -43,183 +43,274 @@ const Cu = Components.utils;
 Cu.import("resource://services-sync/log4moz.js");
 Cu.import("resource://services-sync/rest.js");
 Cu.import("resource://services-sync/constants.js");
 Cu.import("resource://services-sync/util.js");
 
 const EXPORTED_SYMBOLS = ["JPAKEClient"];
 
 const REQUEST_TIMEOUT         = 60; // 1 minute
+const KEYEXCHANGE_VERSION     = 3;
+
 const JPAKE_SIGNERID_SENDER   = "sender";
 const JPAKE_SIGNERID_RECEIVER = "receiver";
 const JPAKE_LENGTH_SECRET     = 8;
 const JPAKE_LENGTH_CLIENTID   = 256;
 const JPAKE_VERIFY_VALUE      = "0123456789ABCDEF";
 
 
-/*
+/**
  * Client to exchange encrypted data using the J-PAKE algorithm.
  * The exchange between two clients of this type looks like this:
  * 
  * 
- * Client A                      Server                      Client B
- * ==================================================================
- *                                  |
- * retrieve channel <---------------|
- * generate random secret           |
- * show PIN = secret + channel      |                ask user for PIN
- * upload A's message 1 ----------->|
- *                                  |--------> retrieve A's message 1
- *                                  |<---------- upload B's message 1
- * retrieve B's message 1 <---------|
- * upload A's message 2 ----------->|
- *                                  |--------> retrieve A's message 2
- *                                  |                     compute key
- *                                  |<---------- upload B's message 2
- * retrieve B's message 2 <---------|
- * compute key                      |
- * upload sha256d(key) ------------>|
- *                                  |---------> retrieve sha256d(key)
- *                                  |          verify against own key
- *                                  |                    encrypt data
- *                                  |<------------------- upload data
- * retrieve data <------------------|
- * verify HMAC                      |
- * decrypt data                     |
+ *  Mobile                        Server                        Desktop
+ *  ===================================================================
+ *                                   |
+ *  retrieve channel <---------------|
+ *  generate random secret           |
+ *  show PIN = secret + channel      |                 ask user for PIN
+ *  upload Mobile's message 1 ------>|
+ *                                   |----> retrieve Mobile's message 1
+ *                                   |<----- upload Desktop's message 1
+ *  retrieve Desktop's message 1 <---|
+ *  upload Mobile's message 2 ------>|
+ *                                   |----> retrieve Mobile's message 2
+ *                                   |                      compute key
+ *                                   |<----- upload Desktop's message 2
+ *  retrieve Desktop's message 2 <---|
+ *  compute key                      |
+ *  encrypt known value ------------>|
+ *                                   |-------> retrieve encrypted value
+ *                                   | verify against local known value
+ *
+ *   At this point Desktop knows whether the PIN was entered correctly.
+ *   If it wasn't, Desktop deletes the session. If it was, the account
+ *   setup can proceed. If Desktop doesn't yet have an account set up,
+ *   it will keep the channel open and let the user connect to or
+ *   create an account.
+ *
+ *                                   |              encrypt credentials
+ *                                   |<------------- upload credentials
+ *  retrieve credentials <-----------|
+ *  verify HMAC                      |
+ *  decrypt credentials              |
+ *  delete session ----------------->|
+ *  start syncing                    |
  * 
  * 
  * Create a client object like so:
  * 
- *   let client = new JPAKEClient(observer);
+ *   let client = new JPAKEClient(controller);
+ * 
+ * The 'controller' object must implement the following methods:
+ * 
+ *   displayPIN(pin) -- Called when a PIN has been generated and is ready to
+ *     be displayed to the user. Only called on the client where the pairing
+ *     was initiated with 'receiveNoPIN()'.
  * 
- * The 'observer' object must implement the following methods:
+ *   onPairingStart() -- Called when the pairing has started and messages are
+ *     being sent back and forth over the channel. Only called on the client
+ *     where the pairing was initiated with 'receiveNoPIN()'.
  * 
- *   displayPIN(pin) -- Display the PIN to the user, only called on the client
- *     that didn't provide the PIN.
+ *   onPaired() -- Called when the device pairing has been established and
+ *     we're ready to send the credentials over. To do that, the controller
+ *     must call 'sendAndComplete()' while the channel is active.
  * 
  *   onComplete(data) -- Called after transfer has been completed. On
  *     the sending side this is called with no parameter and as soon as the
- *     data has been uploaded, which this doesn't mean the receiving side
- *     has actually retrieved them yet.
+ *     data has been uploaded. This does not mean the receiving side has
+ *     actually retrieved them yet.
  *
  *   onAbort(error) -- Called whenever an error is encountered. All errors lead
  *     to an abort and the process has to be started again on both sides.
  * 
  * To start the data transfer on the receiving side, call
  * 
  *   client.receiveNoPIN();
  * 
  * This will allocate a new channel on the server, generate a PIN, have it
  * displayed and then do the transfer once the protocol has been completed
  * with the sending side.
  * 
  * To initiate the transfer from the sending side, call
  * 
- *   client.sendWithPIN(pin, data)
+ *   client.pairWithPIN(pin, true);
+ * 
+ * Once the pairing has been established, the controller's 'onPaired()' method
+ * will be called. To then transmit the data, call
+ * 
+ *   client.sendAndComplete(data);
  * 
  * To abort the process, call
  * 
  *   client.abort();
  * 
  * Note that after completion or abort, the 'client' instance may not be reused.
  * You will have to create a new one in case you'd like to restart the process.
  */
-function JPAKEClient(observer) {
-  this.observer = observer;
+function JPAKEClient(controller) {
+  this.controller = controller;
 
   this._log = Log4Moz.repository.getLogger("Sync.JPAKEClient");
   this._log.level = Log4Moz.Level[Svc.Prefs.get(
     "log.logger.service.jpakeclient", "Debug")];
 
-  this._serverUrl = Svc.Prefs.get("jpake.serverURL");
+  this._serverURL = Svc.Prefs.get("jpake.serverURL");
   this._pollInterval = Svc.Prefs.get("jpake.pollInterval");
   this._maxTries = Svc.Prefs.get("jpake.maxTries");
-  if (this._serverUrl.slice(-1) != "/")
-    this._serverUrl += "/";
+  if (this._serverURL.slice(-1) != "/") {
+    this._serverURL += "/";
+  }
 
   this._jpake = Cc["@mozilla.org/services-crypto/sync-jpake;1"]
                   .createInstance(Ci.nsISyncJPAKE);
 
   this._setClientID();
 }
 JPAKEClient.prototype = {
 
   _chain: Async.chain,
 
   /*
    * Public API
    */
 
+  /**
+   * Initiate pairing and receive data without providing a PIN. The PIN will
+   * be generated and passed on to the controller to be displayed to the user.
+   * 
+   * This is typically called on mobile devices where typing is tedious.
+   */
   receiveNoPIN: function receiveNoPIN() {
     this._my_signerid = JPAKE_SIGNERID_RECEIVER;
     this._their_signerid = JPAKE_SIGNERID_SENDER;
 
     this._secret = this._createSecret();
 
     // Allow a large number of tries first while we wait for the PIN
     // to be entered on the other device.
     this._maxTries = Svc.Prefs.get("jpake.firstMsgMaxTries");
     this._chain(this._getChannel,
                 this._computeStepOne,
                 this._putStep,
                 this._getStep,
                 function(callback) {
+                  // We fetched the first response from the other client.
+                  // Notify controller of the pairing starting.
+                  Utils.nextTick(this.controller.onPairingStart,
+                                 this.controller);
+
                   // Now we can switch back to the smaller timeout.
                   this._maxTries = Svc.Prefs.get("jpake.maxTries");
                   callback();
                 },
                 this._computeStepTwo,
                 this._putStep,
                 this._getStep,
                 this._computeFinal,
                 this._computeKeyVerification,
                 this._putStep,
+                function(callback) {
+                  // Allow longer time-out for the last message.
+                  this._maxTries = Svc.Prefs.get("jpake.lastMsgMaxTries");
+                  callback();
+                },
                 this._getStep,
                 this._decryptData,
                 this._complete)();
   },
 
-  sendWithPIN: function sendWithPIN(pin, obj) {
+  /**
+   * Initiate pairing based on the PIN entered by the user.
+   * 
+   * This is typically called on desktop devices where typing is easier than
+   * on mobile.
+   * 
+   * @param pin
+   *        12 character string (in human-friendly base32) containing the PIN
+   *        entered by the user.
+   * @param expectDelay
+   *        Flag that indicates that a significant delay between the pairing
+   *        and the sending should be expected. v2 and earlier of the protocol
+   *        did not allow for this and the pairing to a v2 or earlier client
+   *        will be aborted if this flag is 'true'.
+   */
+  pairWithPIN: function pairWithPIN(pin, expectDelay) {
     this._my_signerid = JPAKE_SIGNERID_SENDER;
     this._their_signerid = JPAKE_SIGNERID_RECEIVER;
 
     this._channel = pin.slice(JPAKE_LENGTH_SECRET);
-    this._channelUrl = this._serverUrl + this._channel;
+    this._channelURL = this._serverURL + this._channel;
     this._secret = pin.slice(0, JPAKE_LENGTH_SECRET);
-    this._data = JSON.stringify(obj);
 
     this._chain(this._computeStepOne,
                 this._getStep,
+                function (callback) {
+                  // Ensure that the other client can deal with a delay for
+                  // the last message if that's requested by the caller.
+                  if (!expectDelay) {
+                    return callback();
+                  }
+                  if (!this._incoming.version || this._incoming.version < 3) {
+                    return this.abort(JPAKE_ERROR_DELAYUNSUPPORTED);
+                  }
+                  return callback();
+                },
                 this._putStep,
                 this._computeStepTwo,
                 this._getStep,
                 this._putStep,
                 this._computeFinal,
                 this._getStep,
-                this._encryptData,
+                this._verifyPairing)();
+  },
+
+  /**
+   * Send data after a successful pairing.
+   * 
+   * @param obj
+   *        Object containing the data to send. It will be serialized as JSON.
+   */
+  sendAndComplete: function sendAndComplete(obj) {
+    if (!this._paired || this._finished) {
+      this._log.error("Can't send data, no active pairing!");
+      throw "No active pairing!";
+    }
+    this._data = JSON.stringify(obj);
+    this._chain(this._encryptData,
                 this._putStep,
                 this._complete)();
   },
 
+  /**
+   * Abort the current pairing. The channel on the server will be deleted
+   * if the abort wasn't due to a network or server error. The controller's
+   * 'onAbort()' method is notified in all cases.
+   * 
+   * @param error [optional]
+   *        Error constant indicating the reason for the abort. Defaults to
+   *        user abort.
+   */
   abort: function abort(error) {
     this._log.debug("Aborting...");
     this._finished = true;
     let self = this;
 
     // Default to "user aborted".
-    if (!error)
+    if (!error) {
       error = JPAKE_ERROR_USERABORT;
+    }
 
-    if (error == JPAKE_ERROR_CHANNEL
-        || error == JPAKE_ERROR_NETWORK
-        || error == JPAKE_ERROR_NODATA) {
-      Utils.namedTimer(function() { this.observer.onAbort(error); }, 0,
-                       this, "_timer_onAbort");
+    if (error == JPAKE_ERROR_CHANNEL ||
+        error == JPAKE_ERROR_NETWORK ||
+        error == JPAKE_ERROR_NODATA) {
+      Utils.nextTick(function() { this.controller.onAbort(error); }, this);
     } else {
-      this._reportFailure(error, function() { self.observer.onAbort(error); });
+      this._reportFailure(error, function() { self.controller.onAbort(error); });
     }
   },
 
   /*
    * Utilities
    */
 
   _setClientID: function _setClientID() {
@@ -248,20 +339,21 @@ JPAKEClient.prototype = {
   },
 
   /*
    * Steps of J-PAKE procedure
    */
 
   _getChannel: function _getChannel(callback) {
     this._log.trace("Requesting channel.");
-    let request = this._newRequest(this._serverUrl + "new_channel");
+    let request = this._newRequest(this._serverURL + "new_channel");
     request.get(Utils.bind2(this, function handleChannel(error) {
-      if (this._finished)
+      if (this._finished) {
         return;
+      }
 
       if (error) {
         this._log.error("Error acquiring channel ID. " + error);
         this.abort(JPAKE_ERROR_CHANNEL);
         return;
       }
       if (request.response.status != 200) {
         this._log.error("Error acquiring channel ID. Server responded with HTTP "
@@ -273,65 +365,71 @@ JPAKEClient.prototype = {
       try {
         this._channel = JSON.parse(request.response.body);
       } catch (ex) {
         this._log.error("Server responded with invalid JSON.");
         this.abort(JPAKE_ERROR_CHANNEL);
         return;
       }
       this._log.debug("Using channel " + this._channel);
-      this._channelUrl = this._serverUrl + this._channel;
+      this._channelURL = this._serverURL + this._channel;
 
       // Don't block on UI code.
       let pin = this._secret + this._channel;
-      Utils.namedTimer(function() { this.observer.displayPIN(pin); }, 0,
-                       this, "_timer_displayPIN");
+      Utils.nextTick(function() { this.controller.displayPIN(pin); }, this);
       callback();
     }));
   },
 
   // Generic handler for uploading data.
   _putStep: function _putStep(callback) {
     this._log.trace("Uploading message " + this._outgoing.type);
-    let request = this._newRequest(this._channelUrl);
+    let request = this._newRequest(this._channelURL);
+    if (this._their_etag) {
+      request.setHeader("If-Match", this._their_etag);
+    } else {
+      request.setHeader("If-None-Match", "*");
+    }
     request.put(this._outgoing, Utils.bind2(this, function (error) {
-      if (this._finished)
+      if (this._finished) {
         return;
+      }
 
       if (error) {
         this._log.error("Error uploading data. " + error);
         this.abort(JPAKE_ERROR_NETWORK);
         return;
       }
       if (request.response.status != 200) {
         this._log.error("Could not upload data. Server responded with HTTP "
                         + request.response.status);
         this.abort(JPAKE_ERROR_SERVER);
         return;
       }
       // There's no point in returning early here since the next step will
       // always be a GET so let's pause for twice the poll interval.
-      this._etag = request.response.headers["etag"];
+      this._my_etag = request.response.headers["etag"];
       Utils.namedTimer(function () { callback(); }, this._pollInterval * 2,
                        this, "_pollTimer");
     }));
   },
 
   // Generic handler for polling for and retrieving data.
   _pollTries: 0,
   _getStep: function _getStep(callback) {
     this._log.trace("Retrieving next message.");
-    let request = this._newRequest(this._channelUrl);
-    if (this._etag) {
-      request.setHeader("If-None-Match", this._etag);
+    let request = this._newRequest(this._channelURL);
+    if (this._my_etag) {
+      request.setHeader("If-None-Match", this._my_etag);
     }
 
     request.get(Utils.bind2(this, function (error) {
-      if (this._finished)
+      if (this._finished) {
         return;
+      }
 
       if (error) {
         this._log.error("Error fetching data. " + error);
         this.abort(JPAKE_ERROR_NETWORK);
         return;
       }
 
       if (request.response.status == 304) {
@@ -355,31 +453,39 @@ JPAKEClient.prototype = {
       }
       if (request.response.status != 200) {
         this._log.error("Could not retrieve data. Server responded with HTTP "
                         + request.response.status);
         this.abort(JPAKE_ERROR_SERVER);
         return;
       }
 
+      this._their_etag = request.response.headers["etag"];
+      if (!this._their_etag) {
+        this._log.error("Server did not supply ETag for message: "
+                        + request.response.body);
+        this.abort(JPAKE_ERROR_SERVER);
+        return;
+      }
+
       try {
         this._incoming = JSON.parse(request.response.body);
       } catch (ex) {
         this._log.error("Server responded with invalid JSON.");
         this.abort(JPAKE_ERROR_INVALID);
         return;
       }
       this._log.trace("Fetched message " + this._incoming.type);
       callback();
     }));
   },
 
   _reportFailure: function _reportFailure(reason, callback) {
     this._log.debug("Reporting failure to server.");
-    let request = this._newRequest(this._serverUrl + "report");
+    let request = this._newRequest(this._serverURL + "report");
     request.setHeader("X-KeyExchange-Cid", this._channel);
     request.setHeader("X-KeyExchange-Log", reason);
     request.post("", Utils.bind2(this, function (error) {
       if (error) {
         this._log.warn("Report failed: " + error);
       } else if (request.response.status != 200) {
         this._log.warn("Report failed. Server responded with HTTP "
                        + request.response.status);
@@ -404,17 +510,19 @@ JPAKEClient.prototype = {
       this._log.error("JPAKE round 1 threw: " + ex);
       this.abort(JPAKE_ERROR_INTERNAL);
       return;
     }
     let one = {gx1: gx1.value,
                gx2: gx2.value,
                zkp_x1: {gr: gv1.value, b: r1.value, id: this._my_signerid},
                zkp_x2: {gr: gv2.value, b: r2.value, id: this._my_signerid}};
-    this._outgoing = {type: this._my_signerid + "1", payload: one};
+    this._outgoing = {type: this._my_signerid + "1",
+                      version: KEYEXCHANGE_VERSION,
+                      payload: one};
     this._log.trace("Generated message " + this._outgoing.type);
     callback();
   },
 
   _computeStepTwo: function _computeStepTwo(callback) {
     this._log.trace("Computing round 2.");
     if (this._incoming.type != this._their_signerid + "1") {
       this._log.error("Invalid round 1 message: "
@@ -442,17 +550,19 @@ JPAKEClient.prototype = {
                          A, gvA, rA);
     } catch (ex) {
       this._log.error("JPAKE round 2 threw: " + ex);
       this.abort(JPAKE_ERROR_INTERNAL);
       return;
     }
     let two = {A: A.value,
                zkp_A: {gr: gvA.value, b: rA.value, id: this._my_signerid}};
-    this._outgoing = {type: this._my_signerid + "2", payload: two};
+    this._outgoing = {type: this._my_signerid + "2",
+                      version: KEYEXCHANGE_VERSION,
+                      payload: two};
     this._log.trace("Generated message " + this._outgoing.type);
     callback();
   },
 
   _computeFinal: function _computeFinal(callback) {
     if (this._incoming.type != this._their_signerid + "2") {
       this._log.error("Invalid round 2 message: "
                       + JSON.stringify(this._incoming));
@@ -494,53 +604,64 @@ JPAKEClient.prototype = {
       ciphertext = Svc.Crypto.encrypt(JPAKE_VERIFY_VALUE,
                                       this._crypto_key, iv);
     } catch (ex) {
       this._log.error("Failed to encrypt key verification value.");
       this.abort(JPAKE_ERROR_INTERNAL);
       return;
     }
     this._outgoing = {type: this._my_signerid + "3",
+                      version: KEYEXCHANGE_VERSION,
                       payload: {ciphertext: ciphertext, IV: iv}};
     this._log.trace("Generated message " + this._outgoing.type);
     callback();
   },
 
-  _encryptData: function _encryptData(callback) {
+  _verifyPairing: function _verifyPairing(callback) {
     this._log.trace("Verifying their key.");
     if (this._incoming.type != this._their_signerid + "3") {
       this._log.error("Invalid round 3 data: " +
                       JSON.stringify(this._incoming));
       this.abort(JPAKE_ERROR_WRONGMESSAGE);
       return;
     }
     let step3 = this._incoming.payload;
+    let ciphertext;
     try {
       ciphertext = Svc.Crypto.encrypt(JPAKE_VERIFY_VALUE,
                                       this._crypto_key, step3.IV);
-      if (ciphertext != step3.ciphertext)
+      if (ciphertext != step3.ciphertext) {
         throw "Key mismatch!";
+      }
     } catch (ex) {
       this._log.error("Keys don't match!");
       this.abort(JPAKE_ERROR_KEYMISMATCH);
       return;
     }
 
+    this._log.debug("Verified pairing!");
+    this._paired = true;
+    Utils.nextTick(function () { this.controller.onPaired(); }, this);
+    callback();
+  },
+
+  _encryptData: function _encryptData(callback) {
     this._log.trace("Encrypting data.");
     let iv, ciphertext, hmac;
     try {
       iv = Svc.Crypto.generateRandomIV();
       ciphertext = Svc.Crypto.encrypt(this._data, this._crypto_key, iv);
       hmac = Utils.bytesAsHex(Utils.digestUTF8(ciphertext, this._hmac_hasher));
     } catch (ex) {
       this._log.error("Failed to encrypt data.");
       this.abort(JPAKE_ERROR_INTERNAL);
       return;
     }
     this._outgoing = {type: this._my_signerid + "3",
+                      version: KEYEXCHANGE_VERSION,
                       payload: {ciphertext: ciphertext, IV: iv, hmac: hmac}};
     this._log.trace("Generated message " + this._outgoing.type);
     callback();
   },
 
   _decryptData: function _decryptData(callback) {
     this._log.trace("Verifying their key.");
     if (this._incoming.type != this._their_signerid + "3") {
@@ -548,18 +669,19 @@ JPAKEClient.prototype = {
                       + JSON.stringify(this._incoming));
       this.abort(JPAKE_ERROR_WRONGMESSAGE);
       return;
     }
     let step3 = this._incoming.payload;
     try {
       let hmac = Utils.bytesAsHex(
         Utils.digestUTF8(step3.ciphertext, this._hmac_hasher));
-      if (hmac != step3.hmac)
+      if (hmac != step3.hmac) {
         throw "HMAC validation failed!";
+      }
     } catch (ex) {
       this._log.error("HMAC validation failed.");
       this.abort(JPAKE_ERROR_KEYMISMATCH);
       return;
     }
 
     this._log.trace("Decrypting data.");
     let cleartext;
@@ -582,13 +704,13 @@ JPAKEClient.prototype = {
 
     this._log.trace("Decrypted data.");
     callback();
   },
 
   _complete: function _complete() {
     this._log.debug("Exchange completed.");
     this._finished = true;
-    Utils.namedTimer(function () { this.observer.onComplete(this._newData); },
-                     0, this, "_timer_onComplete");
+    Utils.nextTick(function () { this.controller.onComplete(this._newData); },
+                   this);
   }
 
 };
--- a/services/sync/modules/main.js
+++ b/services/sync/modules/main.js
@@ -47,17 +47,18 @@ let lazies = {
   "engines/forms.js":     ["FormEngine"],
   "engines/history.js":   ["HistoryEngine"],
   "engines/prefs.js":     ["PrefsEngine"],
   "engines/passwords.js": ["PasswordEngine"],
   "engines/tabs.js":      ["TabEngine"],
   "identity.js":          ["Identity", "ID"],
   "jpakeclient.js":       ["JPAKEClient"],
   "notifications.js":     ["Notifications", "Notification", "NotificationButton"],
-  "policies.js":          ["SyncScheduler", "ErrorHandler"],
+  "policies.js":          ["SyncScheduler", "ErrorHandler",
+                           "SendCredentialsController"],
   "resource.js":          ["Resource", "AsyncResource", "Auth",
                            "BasicAuthenticator", "NoOpAuthenticator"],
   "service.js":           ["Service"],
   "status.js":            ["Status"],
   "util.js":              ['Utils', 'Svc', 'Str']
 };
 
 function lazyImport(module, dest, props) {
--- a/services/sync/modules/policies.js
+++ b/services/sync/modules/policies.js
@@ -32,18 +32,19 @@
  * use your version of this file under the terms of the MPL, indicate your
  * decision by deleting the provisions above and replace them with the notice
  * and other provisions required by the GPL or the LGPL. If you do not delete
  * the provisions above, a recipient may use your version of this file under
  * the terms of any one of the MPL, the GPL or the LGPL.
  *
  * ***** END LICENSE BLOCK ***** */
 
-
-const EXPORTED_SYMBOLS = ["SyncScheduler", "ErrorHandler"];
+const EXPORTED_SYMBOLS = ["SyncScheduler",
+                          "ErrorHandler",
+                          "SendCredentialsController"];
 
 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
 
 Cu.import("resource://services-sync/constants.js");
 Cu.import("resource://services-sync/log4moz.js");
 Cu.import("resource://services-sync/util.js");
 Cu.import("resource://services-sync/engines.js");
 Cu.import("resource://services-sync/engines/clients.js");
@@ -179,19 +180,21 @@ let SyncScheduler = {
       case "weave:service:sync:error":
         // There may be multiple clients but if the sync fails, client mode
         // should still be updated so that the next sync has a correct interval.
         this.updateClientMode();
         this.adjustSyncInterval();
         this.handleSyncError();
         break;
       case "weave:service:backoff:interval":
-        let interval = (data + Math.random() * data * 0.25) * 1000; // required backoff + up to 25%
+        let requested_interval = subject * 1000;
+        // Leave up to 25% more time for the back off.
+        let interval = requested_interval * (1 + Math.random() * 0.25);
         Status.backoffInterval = interval;
-        Status.minimumNextSync = Date.now() + data;
+        Status.minimumNextSync = Date.now() + requested_interval;
         break;
       case "weave:service:ready":
         // Applications can specify this preference if they want autoconnect
         // to happen after a fixed delay.
         let delay = Svc.Prefs.get("autoconnectDelay");
         if (delay) {
           this.delayedAutoConnect(delay);
         }
@@ -731,8 +734,100 @@ let ErrorHandler = {
           Status.sync = LOGIN_FAILED_NETWORK_ERROR;
         } else {
           Status.login = LOGIN_FAILED_NETWORK_ERROR;
         }
         break;
     }
   },
 };
+
+
+/**
+ * Send credentials over an active J-PAKE channel.
+ * 
+ * This object is designed to take over as the JPAKEClient controller,
+ * presumably replacing one that is UI-based which would either cause
+ * DOM objects to leak or the JPAKEClient to be GC'ed when the DOM
+ * context disappears. This object stays alive for the duration of the
+ * transfer by being strong-ref'ed as an nsIObserver.
+ * 
+ * Credentials are sent after the first sync has been completed
+ * (successfully or not.)
+ * 
+ * Usage:
+ * 
+ *   jpakeclient.controller = new SendCredentialsController(jpakeclient);
+ * 
+ */
+function SendCredentialsController(jpakeclient) {
+  this._log = Log4Moz.repository.getLogger("Sync.SendCredentialsController");
+  this._log.level = Log4Moz.Level[Svc.Prefs.get("log.logger.service.main")];
+
+  this._log.trace("Loading.");
+  this.jpakeclient = jpakeclient;
+
+  // Register ourselves as observers the first Sync finishing (either
+  // successfully or unsuccessfully, we don't care) or for removing
+  // this device's sync configuration, in case that happens while we
+  // haven't finished the first sync yet.
+  Services.obs.addObserver(this, "weave:service:sync:finish", false);
+  Services.obs.addObserver(this, "weave:service:sync:error",  false);
+  Services.obs.addObserver(this, "weave:service:start-over",  false);
+}
+SendCredentialsController.prototype = {
+
+  unload: function unload() {
+    this._log.trace("Unloading.");
+    try {
+      Services.obs.removeObserver(this, "weave:service:sync:finish");
+      Services.obs.removeObserver(this, "weave:service:sync:error");
+      Services.obs.removeObserver(this, "weave:service:start-over");
+    } catch (ex) {
+      // Ignore.
+    }
+  },
+
+  observe: function observe(subject, topic, data) {
+    switch (topic) {
+      case "weave:service:sync:finish":
+      case "weave:service:sync:error":
+        Utils.nextTick(this.sendCredentials, this);
+        break;
+      case "weave:service:start-over":
+        // This will call onAbort which will call unload().
+        this.jpakeclient.abort();
+        break;
+    }
+  },
+
+  sendCredentials: function sendCredentials() {
+    this._log.trace("Sending credentials.");
+    let credentials = {account:   Weave.Service.account,
+                       password:  Weave.Service.password,
+                       synckey:   Weave.Service.passphrase,
+                       serverURL: Weave.Service.serverURL};
+    this.jpakeclient.sendAndComplete(credentials);
+  },
+
+  // JPAKEClient controller API
+
+  onComplete: function onComplete() {
+    this._log.debug("Exchange was completed successfully!");
+    this.unload();
+
+    // Schedule a Sync for soonish to fetch the data uploaded by the
+    // device with which we just paired.
+    SyncScheduler.scheduleNextSync(SyncScheduler.activeInterval);
+  },
+
+  onAbort: function onAbort(error) {
+    // It doesn't really matter why we aborted, but the channel is closed
+    // for sure, so we won't be able to do anything with it.
+    this._log.debug("Exchange was aborted with error: " + error);
+    this.unload();
+  },
+
+  // Irrelevant methods for this controller:
+  displayPIN: function displayPIN() {},
+  onPairingStart: function onPairingStart() {},
+  onPaired: function onPaired() {}
+};
--- a/services/sync/services-sync.js
+++ b/services/sync/services-sync.js
@@ -21,17 +21,18 @@ pref("services.sync.engine.bookmarks", t
 pref("services.sync.engine.history", true);
 pref("services.sync.engine.passwords", true);
 pref("services.sync.engine.prefs", true);
 pref("services.sync.engine.tabs", true);
 pref("services.sync.engine.tabs.filteredUrls", "^(about:.*|chrome://weave/.*|wyciwyg:.*|file:.*)$");
 
 pref("services.sync.jpake.serverURL", "https://setup.services.mozilla.com/");
 pref("services.sync.jpake.pollInterval", 1000);
-pref("services.sync.jpake.firstMsgMaxTries", 300);
+pref("services.sync.jpake.firstMsgMaxTries", 300); // 5 minutes
+pref("services.sync.jpake.lastMsgMaxTries", 300);  // 5 minutes
 pref("services.sync.jpake.maxTries", 10);
 
 pref("services.sync.log.appender.console", "Warn");
 pref("services.sync.log.appender.dump", "Error");
 pref("services.sync.log.appender.file.level", "Trace");
 pref("services.sync.log.appender.file.logOnError", true);
 pref("services.sync.log.appender.file.logOnSuccess", false);
 pref("services.sync.log.appender.file.maxErrorAge", 864000); // 10 days
--- a/services/sync/tests/unit/head_http_server.js
+++ b/services/sync/tests/unit/head_http_server.js
@@ -1,8 +1,13 @@
+// Shared logging for all HTTP server functions.
+Cu.import("resource://services-sync/log4moz.js");
+const SYNC_HTTP_LOGGER = "Sync.Test.Server";
+const SYNC_API_VERSION = "1.1";
+
 // Use the same method that record.js does, which mirrors the server.
 // The server returns timestamps with 1/100 sec granularity. Note that this is
 // subject to change: see Bug 650435.
 function new_timestamp() {
   return Math.round(Date.now() / 10) / 100;
 }
 
 function httpd_setup (handlers) {
@@ -72,30 +77,30 @@ function readBytesFromInputStream(inputS
     count = inputStream.available();
   }
   return new BinaryInputStream(inputStream).readBytes(count);
 }
 
 /*
  * Represent a WBO on the server
  */
-function ServerWBO(id, initialPayload) {
+function ServerWBO(id, initialPayload, modified) {
   if (!id) {
     throw "No ID for ServerWBO!";
   }
   this.id = id;
   if (!initialPayload) {
     return;
   }
 
   if (typeof initialPayload == "object") {
     initialPayload = JSON.stringify(initialPayload);
   }
   this.payload = initialPayload;
-  this.modified = new_timestamp();
+  this.modified = modified || new_timestamp();
 }
 ServerWBO.prototype = {
 
   get data() {
     return JSON.parse(this.payload);
   },
 
   get: function() {
@@ -133,125 +138,234 @@ ServerWBO.prototype = {
             status = "Not Found";
             body = "Not Found";
           }
           break;
 
         case "PUT":
           self.put(readBytesFromInputStream(request.bodyInputStream));
           body = JSON.stringify(self.modified);
+          response.setHeader("Content-Type", "application/json");
           response.newModified = self.modified;
           break;
 
         case "DELETE":
           self.delete();
           let ts = new_timestamp();
           body = JSON.stringify(ts);
+          response.setHeader("Content-Type", "application/json");
           response.newModified = ts;
           break;
       }
       response.setHeader("X-Weave-Timestamp", "" + new_timestamp(), false);
       response.setStatusLine(request.httpVersion, statusCode, status);
       response.bodyOutputStream.write(body, body.length);
     };
   }
 
 };
 
 
-/*
- * Represent a collection on the server.  The 'wbo' attribute is a
+/**
+ * Represent a collection on the server. The '_wbos' attribute is a
  * mapping of id -> ServerWBO objects.
- * 
+ *
  * Note that if you want these records to be accessible individually,
- * you need to register their handlers with the server separately!
- * 
- * Passing `true` for acceptNew will allow POSTs of new WBOs to this
- * collection. New WBOs will be created and wired in on the fly.
+ * you need to register their handlers with the server separately, or use a
+ * containing HTTP server that will do so on your behalf.
+ *
+ * @param wbos
+ *        An object mapping WBO IDs to ServerWBOs.
+ * @param acceptNew
+ *        If true, POSTs to this collection URI will result in new WBOs being
+ *        created and wired in on the fly.
+ * @param timestamp
+ *        An optional timestamp value to initialize the modified time of the
+ *        collection. This should be in the format returned by new_timestamp().
+ *
+ * @return the new ServerCollection instance.
+ *
  */
-function ServerCollection(wbos, acceptNew) {
-  this.wbos = wbos || {};
+function ServerCollection(wbos, acceptNew, timestamp) {
+  this._wbos = wbos || {};
   this.acceptNew = acceptNew || false;
+
+  /*
+   * Track modified timestamp.
+   * We can't just use the timestamps of contained WBOs: an empty collection
+   * has a modified time.
+   */
+  this.timestamp = timestamp || new_timestamp();
+  this._log = Log4Moz.repository.getLogger(SYNC_HTTP_LOGGER);
 }
 ServerCollection.prototype = {
 
+  /**
+   * Convenience accessor for our WBO keys.
+   * Excludes deleted items, of course.
+   *
+   * @param filter
+   *        A predicate function (applied to the ID and WBO) which dictates
+   *        whether to include the WBO's ID in the output.
+   *
+   * @return an array of IDs.
+   */
+  keys: function keys(filter) {
+    return [id for ([id, wbo] in Iterator(this._wbos))
+               if (wbo.payload &&
+                   (!filter || filter(id, wbo)))];
+  },
+
+  /**
+   * Convenience method to get an array of WBOs.
+   * Optionally provide a filter function.
+   *
+   * @param filter
+   *        A predicate function, applied to the WBO, which dictates whether to
+   *        include the WBO in the output.
+   *
+   * @return an array of ServerWBOs.
+   */
+  wbos: function wbos(filter) {
+    let os = [wbo for ([id, wbo] in Iterator(this._wbos))
+              if (wbo.payload)];
+    if (filter) {
+      return os.filter(filter);
+    }
+    return os;
+  },
+
+  /**
+   * Convenience method to get an array of parsed ciphertexts.
+   *
+   * @return an array of the payloads of each stored WBO.
+   */
+  payloads: function () {
+    return this.wbos().map(function (wbo) {
+      return JSON.parse(JSON.parse(wbo.payload).ciphertext);
+    });
+  },
+
+  // Just for syntactic elegance.
+  wbo: function wbo(id) {
+    return this._wbos[id];
+  },
+
+  payload: function payload(id) {
+    return this.wbo(id).payload;
+  },
+
+  /**
+   * Insert the provided WBO under its ID.
+   *
+   * @return the provided WBO.
+   */
+  insertWBO: function insertWBO(wbo) {
+    return this._wbos[wbo.id] = wbo;
+  },
+
+  /**
+   * Insert the provided payload as part of a new ServerWBO with the provided
+   * ID.
+   *
+   * @param id
+   *        The GUID for the WBO.
+   * @param payload
+   *        The payload, as provided to the ServerWBO constructor.
+   * @param modified
+   *        An optional modified time for the ServerWBO.
+   *
+   * @return the inserted WBO.
+   */
+  insert: function insert(id, payload, modified) {
+    return this.insertWBO(new ServerWBO(id, payload, modified));
+  },
+
   _inResultSet: function(wbo, options) {
     return wbo.payload
            && (!options.ids || (options.ids.indexOf(wbo.id) != -1))
            && (!options.newer || (wbo.modified > options.newer));
   },
 
   count: function(options) {
     options = options || {};
     let c = 0;
-    for (let [id, wbo] in Iterator(this.wbos)) {
+    for (let [id, wbo] in Iterator(this._wbos)) {
       if (wbo.modified && this._inResultSet(wbo, options)) {
         c++;
       }
     }
     return c;
   },
 
   get: function(options) {
     let result;
     if (options.full) {
-      let data = [wbo.get() for ([id, wbo] in Iterator(this.wbos))
+      let data = [wbo.get() for ([id, wbo] in Iterator(this._wbos))
                             // Drop deleted.
                             if (wbo.modified &&
                                 this._inResultSet(wbo, options))];
       if (options.limit) {
         data = data.slice(0, options.limit);
       }
-      // Our implementation of application/newlines
+      // Our implementation of application/newlines.
       result = data.join("\n") + "\n";
+
+      // Use options as a backchannel to report count.
+      options.recordCount = data.length;
     } else {
-      let data = [id for ([id, wbo] in Iterator(this.wbos))
+      let data = [id for ([id, wbo] in Iterator(this._wbos))
                      if (this._inResultSet(wbo, options))];
       if (options.limit) {
         data = data.slice(0, options.limit);
       }
       result = JSON.stringify(data);
+      options.recordCount = data.length;
     }
     return result;
   },
 
   post: function(input) {
     input = JSON.parse(input);
     let success = [];
     let failed = {};
 
     // This will count records where we have an existing ServerWBO
     // registered with us as successful and all other records as failed.
     for each (let record in input) {
-      let wbo = this.wbos[record.id];
+      let wbo = this.wbo(record.id);
       if (!wbo && this.acceptNew) {
-        _("Creating WBO " + JSON.stringify(record.id) + " on the fly.");
+        this._log.debug("Creating WBO " + JSON.stringify(record.id) +
+                        " on the fly.");
         wbo = new ServerWBO(record.id);
-        this.wbos[record.id] = wbo;
+        this.insertWBO(wbo);
       }
       if (wbo) {
         wbo.payload = record.payload;
         wbo.modified = new_timestamp();
         success.push(record.id);
       } else {
         failed[record.id] = "no wbo configured";
       }
     }
     return {modified: new_timestamp(),
             success: success,
             failed: failed};
   },
 
   delete: function(options) {
-    for (let [id, wbo] in Iterator(this.wbos)) {
+    let deleted = [];
+    for (let [id, wbo] in Iterator(this._wbos)) {
       if (this._inResultSet(wbo, options)) {
-        _("Deleting " + JSON.stringify(wbo));
+        this._log.debug("Deleting " + JSON.stringify(wbo));
+        deleted.push(wbo.id);
         wbo.delete();
       }
     }
+    return deleted;
   },
 
   // This handler sets `newModified` on the response body if the collection
   // timestamp has changed.
   handler: function() {
     let self = this;
 
     return function(request, response) {
@@ -280,36 +394,54 @@ ServerCollection.prototype = {
       }
       if (options.limit) {
         options.limit = parseInt(options.limit, 10);
       }
 
       switch(request.method) {
         case "GET":
           body = self.get(options);
+          // "If supported by the db, this header will return the number of
+          // records total in the request body of any multiple-record GET
+          // request."
+          let records = options.recordCount;
+          self._log.info("Records: " + records);
+          if (records != null) {
+            response.setHeader("X-Weave-Records", "" + records);
+          }
           break;
 
         case "POST":
           let res = self.post(readBytesFromInputStream(request.bodyInputStream));
           body = JSON.stringify(res);
           response.newModified = res.modified;
           break;
 
         case "DELETE":
-          self.delete(options);
+          self._log.debug("Invoking ServerCollection.DELETE.");
+          let deleted = self.delete(options);
           let ts = new_timestamp();
           body = JSON.stringify(ts);
           response.newModified = ts;
+          response.deleted = deleted;
           break;
       }
       response.setHeader("X-Weave-Timestamp",
                          "" + new_timestamp(),
                          false);
       response.setStatusLine(request.httpVersion, statusCode, status);
       response.bodyOutputStream.write(body, body.length);
+
+      // Update the collection timestamp to the appropriate modified time.
+      // This is either a value set by the handler, or the current time.
+      if (request.method != "GET") {
+        this.timestamp = (response.newModified >= 0) ?
+                         response.newModified :
+                         new_timestamp();
+      }
     };
   }
 
 };
 
 /*
  * Test setup helpers.
  */
@@ -374,8 +506,434 @@ function track_collections_helper() {
     response.bodyOutputStream.write(body, body.length);
   }
   
   return {"collections": collections,
           "handler": info_collections,
           "with_updated_collection": with_updated_collection,
           "update_collection": update_collection};
 }
+
+//===========================================================================//
+// httpd.js-based Sync server.                                               //
+//===========================================================================//
+
+/**
+ * In general, the preferred way of using SyncServer is to directly introspect
+ * it. Callbacks are available for operations which are hard to verify through
+ * introspection, such as deletions.
+ *
+ * One of the goals of this server is to provide enough hooks for test code to
+ * find out what it needs without monkeypatching. Use this object as your
+ * prototype, and override as appropriate.
+ */
+let SyncServerCallback = {
+  onCollectionDeleted: function onCollectionDeleted(user, collection) {},
+  onItemDeleted: function onItemDeleted(user, collection, wboID) {}
+};
+
+/**
+ * Construct a new test Sync server. Takes a callback object (e.g.,
+ * SyncServerCallback) as input.
+ */
+function SyncServer(callback) {
+  this.callback = callback || {__proto__: SyncServerCallback};
+  this.server   = new nsHttpServer();
+  this.started  = false;
+  this.users    = {};
+  this._log     = Log4Moz.repository.getLogger(SYNC_HTTP_LOGGER);
+
+  // Install our own default handler. This allows us to mess around with the
+  // whole URL space.
+  let handler = this.server._handler;
+  handler._handleDefault = this.handleDefault.bind(this, handler);
+}
+SyncServer.prototype = {
+  port:   8080,
+  server: null,    // nsHttpServer.
+  users:  null,    // Map of username => {collections, password}.
+
+  /**
+   * Start the SyncServer's underlying HTTP server.
+   *
+   * @param port
+   *        The numeric port on which to start. A falsy value implies the
+   *        default (8080).
+   * @param cb
+   *        A callback function (of no arguments) which is invoked after
+   *        startup.
+   */
+  start: function start(port, cb) {
+    if (this.started) {
+      this._log.warn("Warning: server already started on " + this.port);
+      return;
+    }
+    if (port) {
+      this.port = port;
+    }
+    try {
+      this.server.start(this.port);
+      this.started = true;
+      if (cb) {
+        cb();
+      }
+    } catch (ex) {
+      _("==========================================");
+      _("Got exception starting Sync HTTP server on port " + this.port);
+      _("Error: " + Utils.exceptionStr(ex));
+      _("Is there a process already listening on port " + this.port + "?");
+      _("==========================================");
+      do_throw(ex);
+    }
+  },
+
+  /**
+   * Stop the SyncServer's HTTP server.
+   *
+   * @param cb
+   *        A callback function. Invoked after the server has been stopped.
+   *
+   */
+  stop: function stop(cb) {
+    if (!this.started) {
+      this._log.warn("SyncServer: Warning: server not running. Can't stop me now!");
+      return;
+    }
+
+    this.server.stop(cb);
+    this.started = false;
+  },
+
+  /**
+   * Return a server timestamp for a record.
+   * The server returns timestamps with 1/100 sec granularity. Note that this is
+   * subject to change: see Bug 650435.
+   */
+  timestamp: function timestamp() {
+    return Math.round(Date.now() / 10) / 100;
+  },
+
+  /**
+   * Create a new user, complete with an empty set of collections.
+   *
+   * @param username
+   *        The username to use. An Error will be thrown if a user by that name
+   *        already exists.
+   * @param password
+   *        A password string.
+   *
+   * @return a user object, as would be returned by server.user(username).
+   */
+  registerUser: function registerUser(username, password) {
+    if (username in this.users) {
+      throw new Error("User already exists.");
+    }
+    this.users[username] = {
+      password: password,
+      collections: {}
+    };
+    return this.user(username);
+  },
+
+  userExists: function userExists(username) {
+    return username in this.users;
+  },
+
+  getCollection: function getCollection(username, collection) {
+    return this.users[username].collections[collection];
+  },
+
+  _insertCollection: function _insertCollection(collections, collection, wbos) {
+    let coll = new ServerCollection(wbos, true);
+    coll.collectionHandler = coll.handler();
+    collections[collection] = coll;
+    return coll;
+  },
+
+  createCollection: function createCollection(username, collection, wbos) {
+    if (!(username in this.users)) {
+      throw new Error("Unknown user.");
+    }
+    let collections = this.users[username].collections;
+    if (collection in collections) {
+      throw new Error("Collection already exists.");
+    }
+    return this._insertCollection(collections, collection, wbos);
+  },
+
+  /**
+   * Accept a map like the following:
+   * {
+   *   meta: {global: {version: 1, ...}},
+   *   crypto: {"keys": {}, foo: {bar: 2}},
+   *   bookmarks: {}
+   * }
+   * to cause collections and WBOs to be created.
+   * If a collection already exists, no error is raised.
+   * If a WBO already exists, it will be updated to the new contents.
+   */
+  createContents: function createContents(username, collections) {
+    if (!(username in this.users)) {
+      throw new Error("Unknown user.");
+    }
+    let userCollections = this.users[username].collections;
+    for (let [id, contents] in Iterator(collections)) {
+      let coll = userCollections[id] ||
+                 this._insertCollection(userCollections, id);
+      for (let [wboID, payload] in Iterator(contents)) {
+        coll.insert(wboID, payload);
+      }
+    }
+  },
+
+  /**
+   * Insert a WBO in an existing collection.
+   */
+  insertWBO: function insertWBO(username, collection, wbo) {
+    if (!(username in this.users)) {
+      throw new Error("Unknown user.");
+    }
+    let userCollections = this.users[username].collections;
+    if (!(collection in userCollections)) {
+      throw new Error("Unknown collection.");
+    }
+    userCollections[collection].insertWBO(wbo);
+    return wbo;
+  },
+
+  /**
+   * Simple accessor to allow collective binding and abbreviation of a bunch of
+   * methods. Yay!
+   * Use like this:
+   *
+   *   let u = server.user("john");
+   *   u.collection("bookmarks").wbo("abcdefg").payload;  // Etc.
+   *
+   * @return a proxy for the user data stored in this server.
+   */
+  user: function user(username) {
+    let collection       = this.getCollection.bind(this, username);
+    let createCollection = this.createCollection.bind(this, username);
+    return {
+      collection:       collection,
+      createCollection: createCollection
+    };
+  },
+
+  /*
+   * Regular expressions for splitting up Sync request paths.
+   * Sync URLs are of the form:
+   *   /$apipath/$version/$user/$further
+   * where $further is usually:
+   *   storage/$collection/$wbo
+   * or
+   *   storage/$collection
+   * or
+   *   info/$op
+   * We assume for the sake of simplicity that $apipath is empty.
+   *
+   * N.B., we don't follow any kind of username spec here, because as far as I
+   * can tell there isn't one. See Bug 689671. Instead we follow the Python
+   * server code.
+   *
+   * Path: [all, version, username, first, rest]
+   * Storage: [all, collection, id?]
+   */
+  pathRE: /^\/([0-9]+(?:\.[0-9]+)?)\/([-._a-zA-Z0-9]+)\/([^\/]+)\/(.*)$/,
+  storageRE: /^([-_a-zA-Z0-9]+)(?:\/([-_a-zA-Z0-9]+)\/?)?$/,
+
+  defaultHeaders: {},
+
+  /**
+   * HTTP response utility.
+   */
+  respond: function respond(req, resp, code, status, body, headers) {
+    resp.setStatusLine(req.httpVersion, code, status);
+    for each (let [header, value] in Iterator(headers || this.defaultHeaders)) {
+      resp.setHeader(header, value);
+    }
+    resp.setHeader("X-Weave-Timestamp", "" + this.timestamp(), false);
+    resp.bodyOutputStream.write(body, body.length);
+  },
+
+  /**
+   * This is invoked by the nsHttpServer. `this` is bound to the SyncServer;
+   * `handler` is the nsHttpServer's handler.
+   *
+   * TODO: need to use the correct Sync API response codes and errors here.
+   * TODO: Basic Auth.
+   * TODO: check username in path against username in BasicAuth. 
+   */
+  handleDefault: function handleDefault(handler, req, resp) {
+    this._log.debug("SyncServer: Handling request: " + req.method + " " + req.path);
+    let parts = this.pathRE.exec(req.path);
+    if (!parts) {
+      this._log.debug("SyncServer: Unexpected request: bad URL " + req.path);
+      throw HTTP_404;
+    }
+
+    let [all, version, username, first, rest] = parts;
+    if (version != SYNC_API_VERSION) {
+      this._log.debug("SyncServer: Unknown version.");
+      throw HTTP_404;
+    }
+
+    if (!this.userExists(username)) {
+      this._log.debug("SyncServer: Unknown user.");
+      throw HTTP_401;
+    }
+
+    // Hand off to the appropriate handler for this path component.
+    if (first in this.toplevelHandlers) {
+      let handler = this.toplevelHandlers[first];
+      return handler.call(this, handler, req, resp, version, username, rest);
+    }
+    this._log.debug("SyncServer: Unknown top-level " + first);
+    throw HTTP_404;
+  },
+
+  /**
+   * Compute the object that is returned for an info/collections request.
+   */
+  infoCollections: function infoCollections(username) {
+    let responseObject = {};
+    let colls = this.users[username].collections;
+    for (let coll in colls) {
+      responseObject[coll] = colls[coll].timestamp;
+    }
+    this._log.trace("SyncServer: info/collections returning " +
+                    JSON.stringify(responseObject));
+    return responseObject;
+  },
+
+  /**
+   * Collection of the handler methods we use for top-level path components.
+   */
+  toplevelHandlers: {
+    "storage": function handleStorage(handler, req, resp, version, username, rest) {
+      let match = this.storageRE.exec(rest);
+      if (!match) {
+        this._log.warn("SyncServer: Unknown storage operation " + rest);
+        throw HTTP_404;
+      }
+      let [all, collection, wboID] = match;
+      let coll = this.getCollection(username, collection);
+      let respond = this.respond.bind(this, req, resp);
+      switch (req.method) {
+        case "GET":
+          if (!coll) {
+            // *cries inside*: Bug 687299.
+            respond(200, "OK", "[]");
+            return;
+          }
+          if (!wboID) {
+            return coll.collectionHandler(req, resp);
+          }
+          let wbo = coll.wbo(wboID);
+          if (!wbo) {
+            respond(404, "Not found", "Not found");
+            return;
+          }
+          return wbo.handler()(req, resp);
+
+        // TODO: implement handling of X-If-Unmodified-Since for write verbs.
+        case "DELETE":
+          if (!coll) {
+            respond(200, "OK", "{}");
+            return;
+          }
+          if (wboID) {
+            let wbo = coll.wbo(wboID);
+            if (wbo) {
+              wbo.delete();
+            }
+            respond(200, "OK", "{}");
+            this.callback.onItemDeleted(username, collectin, wboID);
+            return;
+          }
+          coll.collectionHandler(req, resp);
+
+          // Spot if this is a DELETE for some IDs, and don't blow away the
+          // whole collection!
+          //
+          // We already handled deleting the WBOs by invoking the deleted
+          // collection's handler. However, in the case of 
+          //
+          //   DELETE storage/foobar
+          //
+          // we also need to remove foobar from the collections map. This
+          // clause tries to differentiate the above request from
+          //
+          //  DELETE storage/foobar?ids=foo,baz
+          //
+          // and do the right thing.
+          // TODO: less hacky method.
+          if (-1 == req.queryString.indexOf("ids=")) {
+            // When you delete the entire collection, we drop it.
+            this._log.debug("Deleting entire collection.");
+            delete this.users[username].collections[collection];
+            this.callback.onCollectionDeleted(username, collection);
+          }
+
+          // Notify of item deletion.
+          let deleted = resp.deleted || [];
+          for (let i = 0; i < deleted.length; ++i) {
+            this.callback.onItemDeleted(username, collection, deleted[i]);
+          }
+          return;
+        case "POST":
+        case "PUT":
+          if (!coll) {
+            coll = this.createCollection(username, collection);
+          }
+          if (wboID) {
+            let wbo = coll.wbo(wboID);
+            if (!wbo) {
+              this._log.trace("SyncServer: creating WBO " + collection + "/" + wboID);
+              wbo = coll.insert(wboID);
+            }
+            // Rather than instantiate each WBO's handler function, do it once
+            // per request. They get hit far less often than do collections.
+            wbo.handler()(req, resp);
+            coll.timestamp = resp.newModified;
+            return resp;
+          }
+          return coll.collectionHandler(req, resp);
+        default:
+          throw "Request method " + req.method + " not implemented.";
+      }
+    },
+
+    "info": function handleInfo(handler, req, resp, version, username, rest) {
+      switch (rest) {
+        case "collections":
+          let body = JSON.stringify(this.infoCollections(username));
+          this.respond(req, resp, 200, "OK", body, {
+            "Content-Type": "application/json"
+          });
+          return;
+        case "collection_usage":
+        case "collection_counts":
+        case "quota":
+          // TODO: implement additional info methods.
+          this.respond(req, resp, 200, "OK", "TODO");
+          return;
+        default:
+          // TODO
+          this._log.warn("SyncServer: Unknown info operation " + rest);
+          throw HTTP_404;
+      }
+    }
+  }
+};
+
+/**
+ * Test helper.
+ */
+function serverForUsers(users, contents, callback) {
+  let server = new SyncServer(callback);
+  for (let [user, pass] in Iterator(users)) {
+    server.registerUser(user, pass);
+    server.createContents(user, contents);
+  }
+  server.start();
+  return server;
+}
--- a/services/sync/tests/unit/test_bookmark_engine.js
+++ b/services/sync/tests/unit/test_bookmark_engine.js
@@ -77,32 +77,35 @@ add_test(function test_ID_caching() {
 
   // And it's repeatable, even with creation enabled.
   do_check_eq(newMobileID, store.idForGUID("mobile", false));
 
   do_check_eq(store.GUIDForId(mobileID), "abcdefghijkl");
   run_next_test();
 });
 
+function serverForFoo(engine) {
+  return serverForUsers({"foo": "password"}, {
+    meta: {global: {engines: {bookmarks: {version: engine.version,
+                                          syncID: engine.syncID}}}},
+    bookmarks: {}
+  });
+}
+
 add_test(function test_processIncoming_error_orderChildren() {
   _("Ensure that _orderChildren() is called even when _processIncoming() throws an error.");
   let syncTesting = new SyncTestingInfrastructure();
   Svc.Prefs.set("clusterURL", "http://localhost:8080/");
   Svc.Prefs.set("username", "foo");
 
-  let collection = new ServerCollection();
   let engine = new BookmarksEngine();
-  let store = engine._store;
-  let global = new ServerWBO('global',
-                             {engines: {bookmarks: {version: engine.version,
-                                                    syncID: engine.syncID}}});
-  let server = httpd_setup({
-    "/1.1/foo/storage/meta/global": global.handler(),
-    "/1.1/foo/storage/bookmarks": collection.handler()
-  });
+  let store  = engine._store;
+  let server = serverForFoo(engine);
+
+  let collection = server.user("foo").collection("bookmarks");
 
   try {
 
     let folder1_id = PlacesUtils.bookmarks.createFolder(
       PlacesUtils.bookmarks.toolbarFolder, "Folder 1", 0);
     let folder1_guid = store.GUIDForId(folder1_id);
 
     let fxuri = Utils.makeURI("http://getfirefox.com/");
@@ -114,24 +117,22 @@ add_test(function test_processIncoming_e
     let bmk2_id = PlacesUtils.bookmarks.insertBookmark(
       folder1_id, tburi, PlacesUtils.bookmarks.DEFAULT_INDEX, "Get Thunderbird!");
     let bmk2_guid = store.GUIDForId(bmk2_id);
 
     // Create a server record for folder1 where we flip the order of
     // the children.
     let folder1_payload = store.createRecord(folder1_guid).cleartext;
     folder1_payload.children.reverse();
-    collection.wbos[folder1_guid] = new ServerWBO(
-      folder1_guid, encryptPayload(folder1_payload));
+    collection.insert(folder1_guid, encryptPayload(folder1_payload));
 
     // Create a bogus record that when synced down will provoke a
     // network error which in turn provokes an exception in _processIncoming.
     const BOGUS_GUID = "zzzzzzzzzzzz";
-    let bogus_record = collection.wbos[BOGUS_GUID]
-      = new ServerWBO(BOGUS_GUID, "I'm a bogus record!");
+    let bogus_record = collection.insert(BOGUS_GUID, "I'm a bogus record!");
     bogus_record.get = function get() {
       throw "Sync this!";
     };
 
     // Make the 10 minutes old so it will only be synced in the toFetch phase.
     bogus_record.modified = Date.now() / 1000 - 60 * 10;
     engine.lastSync = Date.now() / 1000 - 60;
     engine.toFetch = [BOGUS_GUID];
@@ -163,27 +164,21 @@ add_test(function test_processIncoming_e
 
 add_test(function test_restorePromptsReupload() {
   _("Ensure that restoring from a backup will reupload all records.");
   let syncTesting = new SyncTestingInfrastructure();
   Svc.Prefs.set("username", "foo");
   Service.serverURL = "http://localhost:8080/";
   Service.clusterURL = "http://localhost:8080/";
 
-  let collection = new ServerCollection({}, true);
+  let engine = new BookmarksEngine();
+  let store  = engine._store;
+  let server = serverForFoo(engine);
 
-  let engine = new BookmarksEngine();
-  let store = engine._store;
-  let global = new ServerWBO('global',
-                             {engines: {bookmarks: {version: engine.version,
-                                                    syncID: engine.syncID}}});
-  let server = httpd_setup({
-    "/1.1/foo/storage/meta/global": global.handler(),
-    "/1.1/foo/storage/bookmarks": collection.handler()
-  });
+  let collection = server.user("foo").collection("bookmarks");
 
   Svc.Obs.notify("weave:engine:start-tracking");   // We skip usual startup...
 
   try {
 
     let folder1_id = PlacesUtils.bookmarks.createFolder(
       PlacesUtils.bookmarks.toolbarFolder, "Folder 1", 0);
     let folder1_guid = store.GUIDForId(folder1_id);
@@ -225,18 +220,19 @@ add_test(function test_restorePromptsReu
     } catch(ex) {
       error = ex;
       _("Got error: " + Utils.exceptionStr(ex));
     }
     do_check_true(!error);
 
     _("Verify that there's only one bookmark on the server, and it's Thunderbird.");
     // Of course, there's also the Bookmarks Toolbar and Bookmarks Menu...
-    let wbos = [id for ([id, wbo] in Iterator(collection.wbos))
-                   if (["menu", "toolbar", "mobile", folder1_guid].indexOf(id) == -1)];
+    let wbos = collection.keys(function (id) {
+      return ["menu", "toolbar", "mobile", folder1_guid].indexOf(id) == -1;
+    });
     do_check_eq(wbos.length, 1);
     do_check_eq(wbos[0], bmk2_guid);
 
     _("Now restore from a backup.");
     PlacesUtils.restoreBookmarksFromJSONFile(backupFile);
 
     _("Ensure we have the bookmarks we expect locally.");
     let guids = store.getAllIDs();
@@ -268,34 +264,34 @@ add_test(function test_restorePromptsReu
     } catch(ex) {
       error = ex;
       _("Got error: " + Utils.exceptionStr(ex));
     }
     do_check_true(!error);
 
     _("Verify that there's only one bookmark on the server, and it's Firefox.");
     // Of course, there's also the Bookmarks Toolbar and Bookmarks Menu...
-    wbos = [JSON.parse(JSON.parse(wbo.payload).ciphertext)
-            for ([id, wbo] in Iterator(collection.wbos))
-            if (wbo.payload)];
+    let payloads     = server.user("foo").collection("bookmarks").payloads();
+    let bookmarkWBOs = payloads.filter(function (wbo) {
+                         return wbo.type == "bookmark";
+                       });
+    let folderWBOs   = payloads.filter(function (wbo) {
+                         return ((wbo.type == "folder") &&
+                                 (wbo.id   != "menu") &&
+                                 (wbo.id   != "toolbar"));
+                       });
 
-    _("WBOs: " + JSON.stringify(wbos));
-    let bookmarks = [wbo for each (wbo in wbos) if (wbo.type == "bookmark")];
-    do_check_eq(bookmarks.length, 1);
-    do_check_eq(bookmarks[0].id, newFX);
-    do_check_eq(bookmarks[0].bmkUri, fxuri.spec);
-    do_check_eq(bookmarks[0].title, "Get Firefox!");
+    do_check_eq(bookmarkWBOs.length, 1);
+    do_check_eq(bookmarkWBOs[0].id, newFX);
+    do_check_eq(bookmarkWBOs[0].bmkUri, fxuri.spec);
+    do_check_eq(bookmarkWBOs[0].title, "Get Firefox!");
 
     _("Our old friend Folder 1 is still in play.");
-    let folders = [wbo for each (wbo in wbos)
-                       if ((wbo.type == "folder") &&
-                           (wbo.id   != "menu") &&
-                           (wbo.id   != "toolbar"))];
-    do_check_eq(folders.length, 1);
-    do_check_eq(folders[0].title, "Folder 1");
+    do_check_eq(folderWBOs.length, 1);
+    do_check_eq(folderWBOs[0].title, "Folder 1");
 
   } finally {
     store.wipe();
     Svc.Prefs.resetBranch("");
     Records.clearCache();
     server.stop(run_next_test);
   }
 });
@@ -336,28 +332,21 @@ add_test(function test_mismatched_types(
     "parentid": "toolbar"
   };
 
   let syncTesting = new SyncTestingInfrastructure();
   Svc.Prefs.set("username", "foo");
   Service.serverURL = "http://localhost:8080/";
   Service.clusterURL = "http://localhost:8080/";
 
-  let collection = new ServerCollection({}, true);
+  let engine = new BookmarksEngine();
+  let store  = engine._store;
+  let server = serverForFoo(engine);
 
-  let engine = new BookmarksEngine();
-  let store = engine._store;
-  let global = new ServerWBO('global',
-                             {engines: {bookmarks: {version: engine.version,
-                                                    syncID: engine.syncID}}});
   _("GUID: " + store.GUIDForId(6, true));
-  let server = httpd_setup({
-    "/1.1/foo/storage/meta/global": global.handler(),
-    "/1.1/foo/storage/bookmarks": collection.handler()
-  });
 
   try {
     let bms = PlacesUtils.bookmarks;
     let oldR = new FakeRecord(BookmarkFolder, oldRecord);
     let newR = new FakeRecord(Livemark, newRecord);
     oldR._parent = PlacesUtils.bookmarks.toolbarFolder;
     newR._parent = PlacesUtils.bookmarks.toolbarFolder;
 
@@ -385,34 +374,29 @@ add_test(function test_mismatched_types(
 });
 
 add_test(function test_bookmark_guidMap_fail() {
   _("Ensure that failures building the GUID map cause early death.");
 
   let syncTesting = new SyncTestingInfrastructure();
   Svc.Prefs.set("clusterURL", "http://localhost:8080/");
   Svc.Prefs.set("username", "foo");
-  let collection = new ServerCollection();
   let engine = new BookmarksEngine();
   let store = engine._store;
-  let global = new ServerWBO('global',
-                             {engines: {bookmarks: {version: engine.version,
-                                                    syncID: engine.syncID}}});
-  let server = httpd_setup({
-    "/1.1/foo/storage/meta/global": global.handler(),
-    "/1.1/foo/storage/bookmarks": collection.handler()
-  });
+
+  let store  = engine._store;
+  let server = serverForFoo(engine);
+  let coll   = server.user("foo").collection("bookmarks");
 
   // Add one item to the server.
   let itemID = PlacesUtils.bookmarks.createFolder(
     PlacesUtils.bookmarks.toolbarFolder, "Folder 1", 0);
-  let itemGUID = store.GUIDForId(itemID);
+  let itemGUID    = store.GUIDForId(itemID);
   let itemPayload = store.createRecord(itemGUID).cleartext;
-  let encPayload = encryptPayload(itemPayload);
-  collection.wbos[itemGUID] = new ServerWBO(itemGUID, encPayload);
+  coll.insert(itemGUID, encryptPayload(itemPayload));
 
   engine.lastSync = 1;   // So we don't back up.
 
   // Make building the GUID map fail.
   store.getAllIDs = function () { throw "Nooo"; };
 
   // Ensure that we throw when accessing _guidMap.
   engine._syncStartup();
--- a/services/sync/tests/unit/test_bookmark_smart_bookmarks.js
+++ b/services/sync/tests/unit/test_bookmark_smart_bookmarks.js
@@ -41,17 +41,25 @@ function smartBookmarkCount() {
 function clearBookmarks() {
   _("Cleaning up existing items.");
   PlacesUtils.bookmarks.removeFolderChildren(PlacesUtils.bookmarks.bookmarksMenuFolder);
   PlacesUtils.bookmarks.removeFolderChildren(PlacesUtils.bookmarks.tagsFolder);
   PlacesUtils.bookmarks.removeFolderChildren(PlacesUtils.bookmarks.toolbarFolder);
   PlacesUtils.bookmarks.removeFolderChildren(PlacesUtils.bookmarks.unfiledBookmarksFolder);
   startCount = smartBookmarkCount();
 }
-  
+
+function serverForFoo(engine) {
+  return serverForUsers({"foo": "password"}, {
+    meta: {global: {engines: {bookmarks: {version: engine.version,
+                                          syncID: engine.syncID}}}},
+    bookmarks: {}
+  });
+}
+
 // Verify that Places smart bookmarks have their annotation uploaded and
 // handled locally.
 add_test(function test_annotation_uploaded() {
   let startCount = smartBookmarkCount();
   
   _("Start count is " + startCount);
   
   if (startCount > 0) {
@@ -97,35 +105,30 @@ add_test(function test_annotation_upload
   _("Our count has increased since we started.");
   do_check_eq(smartBookmarkCount(), startCount + 1);
 
   _("Sync record to the server.");
   Svc.Prefs.set("username", "foo");
   Service.serverURL = "http://localhost:8080/";
   Service.clusterURL = "http://localhost:8080/";
 
-  let collection = new ServerCollection({}, true);
-  let global = new ServerWBO('global',
-                             {engines: {bookmarks: {version: engine.version,
-                                                    syncID: engine.syncID}}});
-  let server = httpd_setup({
-    "/1.1/foo/storage/meta/global": global.handler(),
-    "/1.1/foo/storage/bookmarks": collection.handler()
-  });
+  let server = serverForFoo(engine);
+  let collection = server.user("foo").collection("bookmarks");
 
   try {
     engine.sync();
-    let wbos = [id for ([id, wbo] in Iterator(collection.wbos))
-                   if (["menu", "toolbar", "mobile"].indexOf(id) == -1)];
+    let wbos = collection.keys(function (id) {
+                 return ["menu", "toolbar", "mobile"].indexOf(id) == -1;
+               });
     do_check_eq(wbos.length, 1);
 
     _("Verify that the server WBO has the annotation.");
     let serverGUID = wbos[0];
     do_check_eq(serverGUID, guid);
-    let serverWBO = collection.wbos[serverGUID];
+    let serverWBO = collection.wbo(serverGUID);
     do_check_true(!!serverWBO);
     let body = JSON.parse(JSON.parse(serverWBO.payload).ciphertext);
     do_check_eq(body.queryId, "MostVisited");
 
     _("We still have the right count.");
     do_check_eq(smartBookmarkCount(), startCount + 1);
 
     _("Clear local records; now we can't find it.");
@@ -188,25 +191,19 @@ add_test(function test_smart_bookmarks_d
   
   let record = store.createRecord(mostVisitedGUID);
   
   _("Prepare sync.");
   Svc.Prefs.set("username", "foo");
   Service.serverURL = "http://localhost:8080/";
   Service.clusterURL = "http://localhost:8080/";
 
-  let collection = new ServerCollection({}, true);
-  let global = new ServerWBO('global',
-                             {engines: {bookmarks: {version: engine.version,
-                                                    syncID: engine.syncID}}});
-  let server = httpd_setup({
-    "/1.1/foo/storage/meta/global": global.handler(),
-    "/1.1/foo/storage/bookmarks": collection.handler()
-  });
-  
+  let server = serverForFoo(engine);
+  let collection = server.user("foo").collection("bookmarks");
+
   try {
     engine._syncStartup();
     
     _("Verify that mapDupe uses the anno, discovering a dupe regardless of URI.");
     do_check_eq(mostVisitedGUID, engine._mapDupe(record));
     
     record.bmkUri = "http://foo/";
     do_check_eq(mostVisitedGUID, engine._mapDupe(record));
--- a/services/sync/tests/unit/test_clients_engine.js
+++ b/services/sync/tests/unit/test_clients_engine.js
@@ -1,537 +1,544 @@
-Cu.import("resource://services-sync/constants.js");
-Cu.import("resource://services-sync/record.js");
-Cu.import("resource://services-sync/identity.js");
-Cu.import("resource://services-sync/util.js");
-Cu.import("resource://services-sync/engines.js");
-Cu.import("resource://services-sync/engines/clients.js");
-Cu.import("resource://services-sync/service.js");
-
-const MORE_THAN_CLIENTS_TTL_REFRESH = 691200; // 8 days
-const LESS_THAN_CLIENTS_TTL_REFRESH = 86400;  // 1 day
-
-add_test(function test_bad_hmac() {
-  _("Ensure that Clients engine deletes corrupt records.");
-  let global = new ServerWBO('global',
-                             {engines: {clients: {version: Clients.version,
-                                                  syncID: Clients.syncID}}});
-  let clientsColl = new ServerCollection({}, true);
-  let keysWBO = new ServerWBO("keys");
-
-  let collectionsHelper = track_collections_helper();
-  let upd = collectionsHelper.with_updated_collection;
-  let collections = collectionsHelper.collections;
-
-  // Watch for deletions in the given collection.
-  let deleted = false;
-  function trackDeletedHandler(coll, handler) {
-    let u = upd(coll, handler);
-    return function(request, response) {
-      if (request.method == "DELETE")
-        deleted = true;
-
-      return u(request, response);
-    };
-  }
-
-  let handlers = {
-    "/1.1/foo/info/collections": collectionsHelper.handler,
-    "/1.1/foo/storage/meta/global": upd("meta", global.handler()),
-    "/1.1/foo/storage/crypto/keys": upd("crypto", keysWBO.handler()),
-    "/1.1/foo/storage/clients": trackDeletedHandler("clients", clientsColl.handler())
-  };
-
-  let server = httpd_setup(handlers);
-
-  try {
-    let passphrase = "abcdeabcdeabcdeabcdeabcdea";
-    Service.serverURL = "http://localhost:8080/";
-    Service.clusterURL = "http://localhost:8080/";
-    Service.login("foo", "ilovejane", passphrase);
-
-    generateNewKeys();
-
-    _("First sync, client record is uploaded");
-    do_check_eq(0, clientsColl.count());
-    do_check_eq(Clients.lastRecordUpload, 0);
-    Clients.sync();
-    do_check_eq(1, clientsColl.count());
-    do_check_true(Clients.lastRecordUpload > 0);
-    deleted = false;    // Initial setup can wipe the server, so clean up.
-
-    _("Records now: " + clientsColl.get({}));
-    _("Change our keys and our client ID, reupload keys.");
-    Clients.localID = Utils.makeGUID();
-    Clients.resetClient();
-    generateNewKeys();
-    let serverKeys = CollectionKeys.asWBO("crypto", "keys");
-    serverKeys.encrypt(Weave.Service.syncKeyBundle);
-    do_check_true(serverKeys.upload(Weave.Service.cryptoKeysURL).success);
-
-    _("Sync.");
-    do_check_true(!deleted);
-    Clients.sync();
-
-    _("Old record was deleted, new one uploaded.");
-    do_check_true(deleted);
-    do_check_eq(1, clientsColl.count());
-    _("Records now: " + clientsColl.get({}));
-
-    _("Now change our keys but don't upload them. " +
-      "That means we get an HMAC error but redownload keys.");
-    Service.lastHMACEvent = 0;
-    Clients.localID = Utils.makeGUID();
-    Clients.resetClient();
-    generateNewKeys();
-    deleted = false;
-    do_check_eq(1, clientsColl.count());
-    Clients.sync();
-
-    _("Old record was not deleted, new one uploaded.");
-    do_check_false(deleted);
-    do_check_eq(2, clientsColl.count());
-    _("Records now: " + clientsColl.get({}));
-
-    _("Now try the scenario where our keys are wrong *and* there's a bad record.");
-    // Clean up and start fresh.
-    clientsColl.wbos = {};
-    Service.lastHMACEvent = 0;
-    Clients.localID = Utils.makeGUID();
-    Clients.resetClient();
-    deleted = false;
-    do_check_eq(0, clientsColl.count());
-
-    // Create and upload keys.
-    generateNewKeys();
-    serverKeys = CollectionKeys.asWBO("crypto", "keys");
-    serverKeys.encrypt(Weave.Service.syncKeyBundle);
-    do_check_true(serverKeys.upload(Weave.Service.cryptoKeysURL).success);
-
-    // Sync once to upload a record.
-    Clients.sync();
-    do_check_eq(1, clientsColl.count());
-
-    // Generate and upload new keys, so the old client record is wrong.
-    generateNewKeys();
-    serverKeys = CollectionKeys.asWBO("crypto", "keys");
-    serverKeys.encrypt(Weave.Service.syncKeyBundle);
-    do_check_true(serverKeys.upload(Weave.Service.cryptoKeysURL).success);
-
-    // Create a new client record and new keys. Now our keys are wrong, as well
-    // as the object on the server. We'll download the new keys and also delete
-    // the bad client record.
-    Clients.localID = Utils.makeGUID();
-    Clients.resetClient();
-    generateNewKeys();
-    let oldKey = CollectionKeys.keyForCollection();
-
-    do_check_false(deleted);
-    Clients.sync();
-    do_check_true(deleted);
-    do_check_eq(1, clientsColl.count());
-    let newKey = CollectionKeys.keyForCollection();
-    do_check_false(oldKey.equals(newKey));
-
-  } finally {
-    Svc.Prefs.resetBranch("");
-    Records.clearCache();
-    server.stop(run_next_test);
-  }
-});
-
-add_test(function test_properties() {
-  _("Test lastRecordUpload property");
-  try {
-    do_check_eq(Svc.Prefs.get("clients.lastRecordUpload"), undefined);
-    do_check_eq(Clients.lastRecordUpload, 0);
-
-    let now = Date.now();
-    Clients.lastRecordUpload = now / 1000;
-    do_check_eq(Clients.lastRecordUpload, Math.floor(now / 1000));
-  } finally {
-    Svc.Prefs.resetBranch("");
-    run_next_test();
-  }
-});
-
-add_test(function test_sync() {
-  _("Ensure that Clients engine uploads a new client record once a week.");
-  Svc.Prefs.set("clusterURL", "http://localhost:8080/");
-  Svc.Prefs.set("username", "foo");
-
-  generateNewKeys();
-
-  let global = new ServerWBO('global',
-                             {engines: {clients: {version: Clients.version,
-                                                  syncID: Clients.syncID}}});
-  let coll = new ServerCollection();
-  let clientwbo = coll.wbos[Clients.localID] = new ServerWBO(Clients.localID);
-  let server = httpd_setup({
-      "/1.1/foo/storage/meta/global": global.handler(),
-      "/1.1/foo/storage/clients": coll.handler()
-  });
-  server.registerPathHandler(
-    "/1.1/foo/storage/clients/" + Clients.localID, clientwbo.handler());
-
-  try {
-
-    _("First sync, client record is uploaded");
-    do_check_eq(clientwbo.payload, undefined);
-    do_check_eq(Clients.lastRecordUpload, 0);
-    Clients.sync();
-    do_check_true(!!clientwbo.payload);
-    do_check_true(Clients.lastRecordUpload > 0);
-
-    _("Let's time travel more than a week back, new record should've been uploaded.");
-    Clients.lastRecordUpload -= MORE_THAN_CLIENTS_TTL_REFRESH;
-    let lastweek = Clients.lastRecordUpload;
-    clientwbo.payload = undefined;
-    Clients.sync();
-    do_check_true(!!clientwbo.payload);
-    do_check_true(Clients.lastRecordUpload > lastweek);
-
-    _("Remove client record.");
-    Clients.removeClientData();
-    do_check_eq(clientwbo.payload, undefined);
-
-    _("Time travel one day back, no record uploaded.");
-    Clients.lastRecordUpload -= LESS_THAN_CLIENTS_TTL_REFRESH;
-    let yesterday = Clients.lastRecordUpload;
-    Clients.sync();
-    do_check_eq(clientwbo.payload, undefined);
-    do_check_eq(Clients.lastRecordUpload, yesterday);
-
-  } finally {
-    Svc.Prefs.resetBranch("");
-    Records.clearCache();
-    server.stop(run_next_test);
-  }
-});
-
-add_test(function test_client_name_change() {
-  _("Ensure client name change incurs a client record update.");
-
-  let tracker = Clients._tracker;
-
-  let localID = Clients.localID;
-  let initialName = Clients.localName;
-
-  Svc.Obs.notify("weave:engine:start-tracking");
-  _("initial name: " + initialName);
-
-  // Tracker already has data, so clear it.
-  tracker.clearChangedIDs();
-
-  let initialScore = tracker.score;
-
-  do_check_eq(Object.keys(tracker.changedIDs).length, 0);
-
-  Svc.Prefs.set("client.name", "new name");
-
-  _("new name: " + Clients.localName);
-  do_check_neq(initialName, Clients.localName);
-  do_check_eq(Object.keys(tracker.changedIDs).length, 1);
-  do_check_true(Clients.localID in tracker.changedIDs);
-  do_check_true(tracker.score > initialScore);
-  do_check_true(tracker.score >= SCORE_INCREMENT_XLARGE);
-
-  Svc.Obs.notify("weave:engine:stop-tracking");
-
-  run_next_test();
-});
-
-add_test(function test_send_command() {
-  _("Verifies _sendCommandToClient puts commands in the outbound queue.");
-
-  let store = Clients._store;
-  let tracker = Clients._tracker;
-  let remoteId = Utils.makeGUID();
-  let rec = new ClientsRec("clients", remoteId);
-
-  store.create(rec);
-  let remoteRecord = store.createRecord(remoteId, "clients");
-
-  let action = "testCommand";
-  let args = ["foo", "bar"];
-
-  Clients._sendCommandToClient(action, args, remoteId);
-
-  let newRecord = store._remoteClients[remoteId];
-  do_check_neq(newRecord, undefined);
-  do_check_eq(newRecord.commands.length, 1);
-
-  let command = newRecord.commands[0];
-  do_check_eq(command.command, action);
-  do_check_eq(command.args.length, 2);
-  do_check_eq(command.args, args);
-
-  do_check_neq(tracker.changedIDs[remoteId], undefined);
-
-  run_next_test();
-});
-
-add_test(function test_command_validation() {
-  _("Verifies that command validation works properly.");
-
-  let store = Clients._store;
-
-  let testCommands = [
-    ["resetAll",    [],       true ],
-    ["resetAll",    ["foo"],  false],
-    ["resetEngine", ["tabs"], true ],
-    ["resetEngine", [],       false],
-    ["wipeAll",     [],       true ],
-    ["wipeAll",     ["foo"],  false],
-    ["wipeEngine",  ["tabs"], true ],
-    ["wipeEngine",  [],       false],
-    ["logout",      [],       true ],
-    ["logout",      ["foo"],  false],
-    ["__UNKNOWN__", [],       false]
-  ];
-
-  for each (let [action, args, expectedResult] in testCommands) {
-    let remoteId = Utils.makeGUID();
-    let rec = new ClientsRec("clients", remoteId);
-
-    store.create(rec);
-    store.createRecord(remoteId, "clients");
-
-    Clients.sendCommand(action, args, remoteId);
-
-    let newRecord = store._remoteClients[remoteId];
-    do_check_neq(newRecord, undefined);
-
-    if (expectedResult) {
-      _("Ensuring command is sent: " + action);
-      do_check_eq(newRecord.commands.length, 1);
-
-      let command = newRecord.commands[0];
-      do_check_eq(command.command, action);
-      do_check_eq(command.args, args);
-
-      do_check_neq(Clients._tracker, undefined);
-      do_check_neq(Clients._tracker.changedIDs[remoteId], undefined);
-    } else {
-      _("Ensuring command is scrubbed: " + action);
-      do_check_eq(newRecord.commands, undefined);
-
-      if (store._tracker) {
-        do_check_eq(Clients._tracker[remoteId], undefined);
-      }
-    }
-
-  }
-  run_next_test();
-});
-
-add_test(function test_command_duplication() {
-  _("Ensures duplicate commands are detected and not added");
-
-  let store = Clients._store;
-  let remoteId = Utils.makeGUID();
-  let rec = new ClientsRec("clients", remoteId);
-  store.create(rec);
-  store.createRecord(remoteId, "clients");
-
-  let action = "resetAll";
-  let args = [];
-
-  Clients.sendCommand(action, args, remoteId);
-  Clients.sendCommand(action, args, remoteId);
-
-  let newRecord = store._remoteClients[remoteId];
-  do_check_eq(newRecord.commands.length, 1);
-
-  _("Check variant args length");
-  newRecord.commands = [];
-
-  action = "resetEngine";
-  Clients.sendCommand(action, [{ x: "foo" }], remoteId);
-  Clients.sendCommand(action, [{ x: "bar" }], remoteId);
-
-  _("Make sure we spot a real dupe argument.");
-  Clients.sendCommand(action, [{ x: "bar" }], remoteId);
-
-  do_check_eq(newRecord.commands.length, 2);
-
-  run_next_test();
-});
-
-add_test(function test_command_invalid_client() {
-  _("Ensures invalid client IDs are caught");
-
-  let id = Utils.makeGUID();
-  let error;
-
-  try {
-    Clients.sendCommand("wipeAll", [], id);
-  } catch (ex) {
-    error = ex;
-  }
-
-  do_check_eq(error.message.indexOf("Unknown remote client ID: "), 0);
-
-  run_next_test();
-});
-
-add_test(function test_process_incoming_commands() {
-  _("Ensures local commands are executed");
-
-  Clients.localCommands = [{ command: "logout", args: [] }];
-
-  let ev = "weave:service:logout:finish";
-
-  var handler = function() {
-    Svc.Obs.remove(ev, handler);
-    run_next_test();
-  };
-
-  Svc.Obs.add(ev, handler);
-
-  // logout command causes processIncomingCommands to return explicit false.
-  do_check_false(Clients.processIncomingCommands());
-});
-
-add_test(function test_command_sync() {
-  _("Ensure that commands are synced across clients.");
-  Svc.Prefs.set("clusterURL", "http://localhost:8080/");
-  Svc.Prefs.set("username", "foo");
-
-  generateNewKeys();
-
-  let global = new ServerWBO('global',
-                             {engines: {clients: {version: Clients.version,
-                                                  syncID: Clients.syncID}}});
-  let coll = new ServerCollection();
-  let clientwbo = coll.wbos[Clients.localID] = new ServerWBO(Clients.localID);
-  let server = httpd_setup({
-      "/1.1/foo/storage/meta/global": global.handler(),
-      "/1.1/foo/storage/clients": coll.handler()
-  });
-  let remoteId = Utils.makeGUID();
-  let remotewbo = coll.wbos[remoteId] = new ServerWBO(remoteId);
-  server.registerPathHandler(
-    "/1.1/foo/storage/clients/" + Clients.localID, clientwbo.handler());
-  server.registerPathHandler(
-    "/1.1/foo/storage/clients/" + remoteId, remotewbo.handler());
-
-  _("Create remote client record");
-  let rec = new ClientsRec("clients", remoteId);
-  Clients._store.create(rec);
-  let remoteRecord = Clients._store.createRecord(remoteId, "clients");
-  Clients.sendCommand("wipeAll", []);
-
-  let clientRecord = Clients._store._remoteClients[remoteId];
-  do_check_neq(clientRecord, undefined);
-  do_check_eq(clientRecord.commands.length, 1);
-
-  try {
-    Clients.sync();
-    do_check_neq(clientwbo.payload, undefined);
-    do_check_true(Clients.lastRecordUpload > 0);
-
-    do_check_neq(remotewbo.payload, undefined);
-
-    Svc.Prefs.set("client.GUID", remoteId);
-    Clients._resetClient();
-    do_check_eq(Clients.localID, remoteId);
-    Clients.sync();
-    do_check_neq(Clients.localCommands, undefined);
-    do_check_eq(Clients.localCommands.length, 1);
-
-    let command = Clients.localCommands[0];
-    do_check_eq(command.command, "wipeAll");
-    do_check_eq(command.args.length, 0);
-
-  } finally {
-    Svc.Prefs.resetBranch("");
-    Records.clearCache();
-    server.stop(run_next_test);
-  }
-});
-
-add_test(function test_send_uri_to_client_for_display() {
-  _("Ensure sendURIToClientForDisplay() sends command properly.");
-
-  let tracker = Clients._tracker;
-  let store = Clients._store;
-
-  let remoteId = Utils.makeGUID();
-  let rec = new ClientsRec("clients", remoteId);
-  rec.name = "remote";
-  store.create(rec);
-  let remoteRecord = store.createRecord(remoteId, "clients");
-
-  tracker.clearChangedIDs();
-  let initialScore = tracker.score;
-
-  let uri = "http://www.mozilla.org/";
-  Clients.sendURIToClientForDisplay(uri, remoteId);
-
-  let newRecord = store._remoteClients[remoteId];
-
-  do_check_neq(newRecord, undefined);
-  do_check_eq(newRecord.commands.length, 1);
-
-  let command = newRecord.commands[0];
-  do_check_eq(command.command, "displayURI");
-  do_check_eq(command.args.length, 2);
-  do_check_eq(command.args[0], uri);
-
-  do_check_true(tracker.score > initialScore);
-  do_check_true(tracker.score - initialScore >= SCORE_INCREMENT_XLARGE);
-
-  _("Ensure unknown client IDs result in exception.");
-  let unknownId = Utils.makeGUID();
-  let error;
-
-  try {
-    Clients.sendURIToClientForDisplay(uri, unknownId);
-  } catch (ex) {
-    error = ex;
-  }
-
-  do_check_eq(error.message.indexOf("Unknown remote client ID: "), 0);
-
-  run_next_test();
-});
-
-add_test(function test_receive_display_uri() {
-  _("Ensure processing of received 'displayURI' commands works.");
-
-  // We don't set up WBOs and perform syncing because other tests verify
-  // the command API works as advertised. This saves us a little work.
-
-  let uri = "http://www.mozilla.org/";
-  let remoteId = Utils.makeGUID();
-
-  let command = {
-    command: "displayURI",
-    args: [uri, remoteId],
-  };
-
-  Clients.localCommands = [command];
-
-  // Received 'displayURI' command should result in the topic defined below
-  // being called.
-  let ev = "weave:engine:clients:display-uri";
-
-  let handler = function(subject, data) {
-    Svc.Obs.remove(ev, handler);
-
-    do_check_eq(subject.uri, uri);
-    do_check_eq(subject.client, remoteId);
-    do_check_eq(data, null);
-
-    run_next_test();
-  };
-
-  Svc.Obs.add(ev, handler);
-
-  do_check_true(Clients.processIncomingCommands());
-});
-
-function run_test() {
-  initTestLogging("Trace");
-  Log4Moz.repository.getLogger("Sync.Engine.Clients").level = Log4Moz.Level.Trace;
-  run_next_test();
-}
+Cu.import("resource://services-sync/constants.js");
+Cu.import("resource://services-sync/record.js");
+Cu.import("resource://services-sync/identity.js");
+Cu.import("resource://services-sync/util.js");
+Cu.import("resource://services-sync/engines.js");
+Cu.import("resource://services-sync/engines/clients.js");
+Cu.import("resource://services-sync/service.js");
+
+const MORE_THAN_CLIENTS_TTL_REFRESH = 691200; // 8 days
+const LESS_THAN_CLIENTS_TTL_REFRESH = 86400;  // 1 day
+
+add_test(function test_bad_hmac() {
+  _("Ensure that Clients engine deletes corrupt records.");
+  let contents = {
+    meta: {global: {engines: {clients: {version: Clients.version,
+                                        syncID: Clients.syncID}}}},
+    clients: {},
+    crypto: {}
+  };
+  let deletedCollections = [];
+  let deletedItems       = [];
+  let callback = {
+    __proto__: SyncServerCallback,
+    onItemDeleted: function (username, coll, wboID) {
+      deletedItems.push(coll + "/" + wboID);
+    },
+    onCollectionDeleted: function (username, coll) {
+      deletedCollections.push(coll);
+    }
+  }
+  let server = serverForUsers({"foo": "password"}, contents, callback);
+  let user   = server.user("foo");
+
+  function check_clients_count(expectedCount) {
+    let stack = Components.stack.caller;
+    let coll  = user.collection("clients");
+
+    // Treat a non-existent collection as empty.
+    do_check_eq(expectedCount, coll ? coll.count() : 0, stack);
+  }
+
+  function check_client_deleted(id) {
+    let coll = user.collection("clients");
+    let wbo  = coll.wbo(id);
+    return !wbo || !wbo.payload;
+  }
+
+  function uploadNewKeys() {
+    generateNewKeys();
+    let serverKeys = CollectionKeys.asWBO("crypto", "keys");
+    serverKeys.encrypt(Weave.Service.syncKeyBundle);
+    do_check_true(serverKeys.upload(Weave.Service.cryptoKeysURL).success);
+  }
+
+  try {
+    let passphrase     = "abcdeabcdeabcdeabcdeabcdea";
+    Service.serverURL  = "http://localhost:8080/";
+    Service.clusterURL = "http://localhost:8080/";
+    Service.login("foo", "ilovejane", passphrase);
+
+    generateNewKeys();
+
+    _("First sync, client record is uploaded");
+    do_check_eq(Clients.lastRecordUpload, 0);
+    check_clients_count(0);
+    Clients.sync();
+    check_clients_count(1);
+    do_check_true(Clients.lastRecordUpload > 0);
+
+    // Initial setup can wipe the server, so clean up.
+    deletedCollections = [];
+    deletedItems       = [];
+
+    _("Change our keys and our client ID, reupload keys.");
+    let oldLocalID  = Clients.localID;     // Preserve to test for deletion!
+    Clients.localID = Utils.makeGUID();
+    Clients.resetClient();
+    generateNewKeys();
+    let serverKeys = CollectionKeys.asWBO("crypto", "keys");
+    serverKeys.encrypt(Weave.Service.syncKeyBundle);
+    do_check_true(serverKeys.upload(Weave.Service.cryptoKeysURL).success);
+
+    _("Sync.");
+    Clients.sync();
+
+    _("Old record " + oldLocalID + " was deleted, new one uploaded.");
+    check_clients_count(1);
+    check_client_deleted(oldLocalID);
+
+    _("Now change our keys but don't upload them. " +
+      "That means we get an HMAC error but redownload keys.");
+    Service.lastHMACEvent = 0;
+    Clients.localID = Utils.makeGUID();
+    Clients.resetClient();
+    generateNewKeys();
+    deletedCollections = [];
+    deletedItems       = [];
+    check_clients_count(1);
+    Clients.sync();
+
+    _("Old record was not deleted, new one uploaded.");
+    do_check_eq(deletedCollections.length, 0);
+    do_check_eq(deletedItems.length, 0);
+    check_clients_count(2);
+
+    _("Now try the scenario where our keys are wrong *and* there's a bad record.");
+    // Clean up and start fresh.
+    user.collection("clients")._wbos = {};
+    Service.lastHMACEvent = 0;
+    Clients.localID = Utils.makeGUID();
+    Clients.resetClient();
+    deletedCollections = [];
+    deletedItems       = [];
+    check_clients_count(0);
+
+    uploadNewKeys();
+
+    // Sync once to upload a record.
+    Clients.sync();
+    check_clients_count(1);
+
+    // Generate and upload new keys, so the old client record is wrong.
+    uploadNewKeys();
+
+    // Create a new client record and new keys. Now our keys are wrong, as well
+    // as the object on the server. We'll download the new keys and also delete
+    // the bad client record.
+    oldLocalID  = Clients.localID;         // Preserve to test for deletion!
+    Clients.localID = Utils.makeGUID();
+    Clients.resetClient();
+    generateNewKeys();
+    let oldKey = CollectionKeys.keyForCollection();
+
+    do_check_eq(deletedCollections.length, 0);
+    do_check_eq(deletedItems.length, 0);
+    Clients.sync();
+    do_check_eq(deletedItems.length, 1);
+    check_client_deleted(oldLocalID);
+    check_clients_count(1);
+    let newKey = CollectionKeys.keyForCollection();
+    do_check_false(oldKey.equals(newKey));
+
+  } finally {
+    Svc.Prefs.resetBranch("");
+    Records.clearCache();
+    server.stop(run_next_test);
+  }
+});
+
+add_test(function test_properties() {
+  _("Test lastRecordUpload property");
+  try {
+    do_check_eq(Svc.Prefs.get("clients.lastRecordUpload"), undefined);
+    do_check_eq(Clients.lastRecordUpload, 0);
+
+    let now = Date.now();
+    Clients.lastRecordUpload = now / 1000;
+    do_check_eq(Clients.lastRecordUpload, Math.floor(now / 1000));
+  } finally {
+    Svc.Prefs.resetBranch("");
+    run_next_test();
+  }
+});
+
+add_test(function test_sync() {
+  _("Ensure that Clients engine uploads a new client record once a week.");
+  Svc.Prefs.set("clusterURL", "http://localhost:8080/");
+  Svc.Prefs.set("username", "foo");
+  generateNewKeys();
+
+  let contents = {
+    meta: {global: {engines: {clients: {version: Clients.version,
+                                        syncID: Clients.syncID}}}},
+    clients: {},
+    crypto: {}
+  };
+  let server = serverForUsers({"foo": "password"}, contents);
+  let user   = server.user("foo");
+
+  function clientWBO() {
+    return user.collection("clients").wbo(Clients.localID);
+  }
+
+  try {
+
+    _("First sync. Client record is uploaded.");
+    do_check_eq(clientWBO(), undefined);
+    do_check_eq(Clients.lastRecordUpload, 0);
+    Clients.sync();
+    do_check_true(!!clientWBO().payload);
+    do_check_true(Clients.lastRecordUpload > 0);
+
+    _("Let's time travel more than a week back, new record should've been uploaded.");
+    Clients.lastRecordUpload -= MORE_THAN_CLIENTS_TTL_REFRESH;
+    let lastweek = Clients.lastRecordUpload;
+    clientWBO().payload = undefined;
+    Clients.sync();
+    do_check_true(!!clientWBO().payload);
+    do_check_true(Clients.lastRecordUpload > lastweek);
+
+    _("Remove client record.");
+    Clients.removeClientData();
+    do_check_eq(clientWBO().payload, undefined);
+
+    _("Time travel one day back, no record uploaded.");
+    Clients.lastRecordUpload -= LESS_THAN_CLIENTS_TTL_REFRESH;
+    let yesterday = Clients.lastRecordUpload;
+    Clients.sync();
+    do_check_eq(clientWBO().payload, undefined);
+    do_check_eq(Clients.lastRecordUpload, yesterday);
+
+  } finally {
+    Svc.Prefs.resetBranch("");
+    Records.clearCache();
+    server.stop(run_next_test);
+  }
+});
+
+add_test(function test_client_name_change() {
+  _("Ensure client name change incurs a client record update.");
+
+  let tracker = Clients._tracker;
+
+  let localID = Clients.localID;
+  let initialName = Clients.localName;
+
+  Svc.Obs.notify("weave:engine:start-tracking");
+  _("initial name: " + initialName);
+
+  // Tracker already has data, so clear it.
+  tracker.clearChangedIDs();
+
+  let initialScore = tracker.score;
+
+  do_check_eq(Object.keys(tracker.changedIDs).length, 0);
+
+  Svc.Prefs.set("client.name", "new name");
+
+  _("new name: " + Clients.localName);
+  do_check_neq(initialName, Clients.localName);
+  do_check_eq(Object.keys(tracker.changedIDs).length, 1);
+  do_check_true(Clients.localID in tracker.changedIDs);
+  do_check_true(tracker.score > initialScore);
+  do_check_true(tracker.score >= SCORE_INCREMENT_XLARGE);
+
+  Svc.Obs.notify("weave:engine:stop-tracking");
+
+  run_next_test();
+});
+
+add_test(function test_send_command() {
+  _("Verifies _sendCommandToClient puts commands in the outbound queue.");
+
+  let store = Clients._store;
+  let tracker = Clients._tracker;
+  let remoteId = Utils.makeGUID();
+  let rec = new ClientsRec("clients", remoteId);
+
+  store.create(rec);
+  let remoteRecord = store.createRecord(remoteId, "clients");
+
+  let action = "testCommand";
+  let args = ["foo", "bar"];
+
+  Clients._sendCommandToClient(action, args, remoteId);
+
+  let newRecord = store._remoteClients[remoteId];
+  do_check_neq(newRecord, undefined);
+  do_check_eq(newRecord.commands.length, 1);
+
+  let command = newRecord.commands[0];
+  do_check_eq(command.command, action);
+  do_check_eq(command.args.length, 2);
+  do_check_eq(command.args, args);
+
+  do_check_neq(tracker.changedIDs[remoteId], undefined);
+
+  run_next_test();
+});
+
+add_test(function test_command_validation() {
+  _("Verifies that command validation works properly.");
+
+  let store = Clients._store;
+
+  let testCommands = [
+    ["resetAll",    [],       true ],
+    ["resetAll",    ["foo"],  false],
+    ["resetEngine", ["tabs"], true ],
+    ["resetEngine", [],       false],
+    ["wipeAll",     [],       true ],
+    ["wipeAll",     ["foo"],  false],
+    ["wipeEngine",  ["tabs"], true ],
+    ["wipeEngine",  [],       false],
+    ["logout",      [],       true ],
+    ["logout",      ["foo"],  false],
+    ["__UNKNOWN__", [],       false]
+  ];
+
+  for each (let [action, args, expectedResult] in testCommands) {
+    let remoteId = Utils.makeGUID();
+    let rec = new ClientsRec("clients", remoteId);
+
+    store.create(rec);
+    store.createRecord(remoteId, "clients");
+
+    Clients.sendCommand(action, args, remoteId);
+
+    let newRecord = store._remoteClients[remoteId];
+    do_check_neq(newRecord, undefined);
+
+    if (expectedResult) {
+      _("Ensuring command is sent: " + action);
+      do_check_eq(newRecord.commands.length, 1);
+
+      let command = newRecord.commands[0];
+      do_check_eq(command.command, action);
+      do_check_eq(command.args, args);
+
+      do_check_neq(Clients._tracker, undefined);
+      do_check_neq(Clients._tracker.changedIDs[remoteId], undefined);
+    } else {
+      _("Ensuring command is scrubbed: " + action);
+      do_check_eq(newRecord.commands, undefined);
+
+      if (store._tracker) {
+        do_check_eq(Clients._tracker[remoteId], undefined);
+      }
+    }
+
+  }
+  run_next_test();
+});
+
+add_test(function test_command_duplication() {
+  _("Ensures duplicate commands are detected and not added");
+
+  let store = Clients._store;
+  let remoteId = Utils.makeGUID();
+  let rec = new ClientsRec("clients", remoteId);
+  store.create(rec);
+  store.createRecord(remoteId, "clients");
+
+  let action = "resetAll";
+  let args = [];
+
+  Clients.sendCommand(action, args, remoteId);
+  Clients.sendCommand(action, args, remoteId);
+
+  let newRecord = store._remoteClients[remoteId];
+  do_check_eq(newRecord.commands.length, 1);
+
+  _("Check variant args length");
+  newRecord.commands = [];
+
+  action = "resetEngine";
+  Clients.sendCommand(action, [{ x: "foo" }], remoteId);
+  Clients.sendCommand(action, [{ x: "bar" }], remoteId);
+
+  _("Make sure we spot a real dupe argument.");
+  Clients.sendCommand(action, [{ x: "bar" }], remoteId);
+
+  do_check_eq(newRecord.commands.length, 2);
+
+  run_next_test();
+});
+
+add_test(function test_command_invalid_client() {
+  _("Ensures invalid client IDs are caught");
+
+  let id = Utils.makeGUID();
+  let error;
+
+  try {
+    Clients.sendCommand("wipeAll", [], id);
+  } catch (ex) {
+    error = ex;
+  }
+
+  do_check_eq(error.message.indexOf("Unknown remote client ID: "), 0);
+
+  run_next_test();
+});
+
+add_test(function test_process_incoming_commands() {
+  _("Ensures local commands are executed");
+
+  Clients.localCommands = [{ command: "logout", args: [] }];
+
+  let ev = "weave:service:logout:finish";
+
+  var handler = function() {
+    Svc.Obs.remove(ev, handler);
+    run_next_test();
+  };
+
+  Svc.Obs.add(ev, handler);
+
+  // logout command causes processIncomingCommands to return explicit false.
+  do_check_false(Clients.processIncomingCommands());
+});
+
+add_test(function test_command_sync() {
+  _("Ensure that commands are synced across clients.");
+  Svc.Prefs.set("clusterURL", "http://localhost:8080/");
+  Svc.Prefs.set("username", "foo");
+  generateNewKeys();
+  let contents = {
+    meta: {global: {engines: {clients: {version: Clients.version,
+                                        syncID: Clients.syncID}}}},
+    clients: {},
+    crypto: {}
+  };
+  let server   = serverForUsers({"foo": "password"}, contents);
+  let user     = server.user("foo");
+  let remoteId = Utils.makeGUID();
+
+  function clientWBO(id) {
+    return user.collection("clients").wbo(id);
+  }
+
+  _("Create remote client record");
+  let rec = new ClientsRec("clients", remoteId);
+  Clients._store.create(rec);
+  let remoteRecord = Clients._store.createRecord(remoteId, "clients");
+  Clients.sendCommand("wipeAll", []);
+
+  let clientRecord = Clients._store._remoteClients[remoteId];
+  do_check_neq(clientRecord, undefined);
+  do_check_eq(clientRecord.commands.length, 1);
+
+  try {
+    _("Syncing.");
+    Clients.sync();
+    _("Checking record was uploaded.");
+    do_check_neq(clientWBO(Clients.localID).payload, undefined);
+    do_check_true(Clients.lastRecordUpload > 0);
+
+    do_check_neq(clientWBO(remoteId).payload, undefined);
+
+    Svc.Prefs.set("client.GUID", remoteId);
+    Clients._resetClient();
+    do_check_eq(Clients.localID, remoteId);
+    Clients.sync();
+    do_check_neq(Clients.localCommands, undefined);
+    do_check_eq(Clients.localCommands.length, 1);
+
+    let command = Clients.localCommands[0];
+    do_check_eq(command.command, "wipeAll");
+    do_check_eq(command.args.length, 0);
+
+  } finally {
+    Svc.Prefs.resetBranch("");
+    Records.clearCache();
+    server.stop(run_next_test);
+  }
+});
+
+add_test(function test_send_uri_to_client_for_display() {
+  _("Ensure sendURIToClientForDisplay() sends command properly.");
+
+  let tracker = Clients._tracker;
+  let store = Clients._store;
+
+  let remoteId = Utils.makeGUID();
+  let rec = new ClientsRec("clients", remoteId);
+  rec.name = "remote";
+  store.create(rec);
+  let remoteRecord = store.createRecord(remoteId, "clients");
+
+  tracker.clearChangedIDs();
+  let initialScore = tracker.score;
+
+  let uri = "http://www.mozilla.org/";
+  Clients.sendURIToClientForDisplay(uri, remoteId);
+
+  let newRecord = store._remoteClients[remoteId];
+
+  do_check_neq(newRecord, undefined);
+  do_check_eq(newRecord.commands.length, 1);
+
+  let command = newRecord.commands[0];
+  do_check_eq(command.command, "displayURI");
+  do_check_eq(command.args.length, 2);
+  do_check_eq(command.args[0], uri);
+
+  do_check_true(tracker.score > initialScore);
+  do_check_true(tracker.score - initialScore >= SCORE_INCREMENT_XLARGE);
+
+  _("Ensure unknown client IDs result in exception.");
+  let unknownId = Utils.makeGUID();
+  let error;
+
+  try {
+    Clients.sendURIToClientForDisplay(uri, unknownId);
+  } catch (ex) {
+    error = ex;
+  }
+
+  do_check_eq(error.message.indexOf("Unknown remote client ID: "), 0);
+
+  run_next_test();
+});
+
+add_test(function test_receive_display_uri() {
+  _("Ensure processing of received 'displayURI' commands works.");
+
+  // We don't set up WBOs and perform syncing because other tests verify
+  // the command API works as advertised. This saves us a little work.
+
+  let uri = "http://www.mozilla.org/";
+  let remoteId = Utils.makeGUID();
+
+  let command = {
+    command: "displayURI",
+    args: [uri, remoteId],
+  };
+
+  Clients.localCommands = [command];
+
+  // Received 'displayURI' command should result in the topic defined below
+  // being called.
+  let ev = "weave:engine:clients:display-uri";
+
+  let handler = function(subject, data) {
+    Svc.Obs.remove(ev, handler);
+
+    do_check_eq(subject.uri, uri);
+    do_check_eq(subject.client, remoteId);
+    do_check_eq(data, null);
+
+    run_next_test();
+  };
+
+  Svc.Obs.add(ev, handler);
+
+  do_check_true(Clients.processIncomingCommands());
+});
+
+function run_test() {
+  initTestLogging("Trace");
+  Log4Moz.repository.getLogger("Sync.Engine.Clients").level = Log4Moz.Level.Trace;
+  run_next_test();
+}
--- a/services/sync/tests/unit/test_corrupt_keys.js
+++ b/services/sync/tests/unit/test_corrupt_keys.js
@@ -136,17 +136,17 @@ add_test(function test_locally_changed_k
         visits: [{date: (modified - 5) * 1000000, type: visitType}],
         deleted: false};
       w.encrypt();
       
       let wbo = new ServerWBO(id, {ciphertext: w.ciphertext,
                                    IV: w.IV,
                                    hmac: w.hmac});
       wbo.modified = modified;
-      history.wbos[id] = wbo;
+      history.insertWBO(wbo);
       server.registerPathHandler(
         "/1.1/johndoe/storage/history/record-no--" + i,
         upd("history", wbo.handler()));
     }
     
     collections.history = Date.now()/1000;
     let old_key_time = collections.crypto;
     _("Old key time: " + old_key_time);
@@ -196,17 +196,17 @@ add_test(function test_locally_changed_k
         deleted: false};
       w.encrypt();
       w.hmac = w.hmac.toUpperCase();
       
       let wbo = new ServerWBO(id, {ciphertext: w.ciphertext,
                                    IV: w.IV,
                                    hmac: w.hmac});
       wbo.modified = modified;
-      history.wbos[id] = wbo;
+      history.insertWBO(wbo);
       server.registerPathHandler(
         "/1.1/johndoe/storage/history/record-no--" + i,
         upd("history", wbo.handler()));
     }
     collections.history = Date.now()/1000;
     
     _("Server key time hasn't changed.");
     do_check_eq(collections.crypto, old_key_time);
--- a/services/sync/tests/unit/test_engine_abort.js
+++ b/services/sync/tests/unit/test_engine_abort.js
@@ -13,17 +13,17 @@ add_test(function test_processIncoming_a
   _("Create some server data.");
   let meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL));
   meta_global.payload.engines = {rotary: {version: engine.version,
                                           syncID: engine.syncID}};
 
   let collection = new ServerCollection();
   let id = Utils.makeGUID();
   let payload = encryptPayload({id: id, denomination: "Record No. " + id});
-  collection.wbos[id] = new ServerWBO(id, payload);
+  collection.insert(id, payload);
 
   let server = sync_httpd_setup({
       "/1.1/foo/storage/rotary": collection.handler()
   });
 
   _("Fake applyIncoming to abort.");
   engine._store.applyIncoming = function (record) {
     let ex = {code: Engine.prototype.eEngineAbortApplyIncoming,
--- a/services/sync/tests/unit/test_history_engine.js
+++ b/services/sync/tests/unit/test_history_engine.js
@@ -38,17 +38,17 @@ add_test(function test_processIncoming_m
       histUri: "http://foo/bar?" + id,
         title: id,
         sortindex: i,
         visits: [{date: (modified - 5) * 1000000, type: visitType}],
         deleted: false});
     
     let wbo = new ServerWBO(id, payload);
     wbo.modified = modified;
-    collection.wbos[id] = wbo;
+    collection.insertWBO(wbo);
   }
   
   let server = sync_httpd_setup({
       "/1.1/foo/storage/history": collection.handler()
   });
 
   let engine = new HistoryEngine("history");
   let meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL));
--- a/services/sync/tests/unit/test_hmac_error.js
+++ b/services/sync/tests/unit/test_hmac_error.js
@@ -183,34 +183,34 @@ add_test(function hmac_error_during_node
   Svc.Obs.add("weave:service:sync:error", obs);
 
   // This kicks off the actual test. Split into a function here to allow this
   // source file to broadly follow actual execution order.
   function onwards() {
     _("== Invoking first sync.");
     Service.sync();
     _("We should not simultaneously have data but no keys on the server.");
-    let hasData = rotaryColl.wbos["flying"] ||
-                  rotaryColl.wbos["scotsman"];
+    let hasData = rotaryColl.wbo("flying") ||
+                  rotaryColl.wbo("scotsman");
     let hasKeys = keysWBO.modified;
 
     _("We correctly handle 401s by aborting the sync and starting again.");
     do_check_true(!hasData == !hasKeys);
 
     _("Be prepared for the second (automatic) sync...");
   }
 
   _("Make sure that syncing again causes recovery.");
   onSyncFinished = function() {
     _("== First sync done.");
     _("---------------------------");
     onSyncFinished = function() {
       _("== Second (automatic) sync done.");
-      hasData = rotaryColl.wbos["flying"] ||
-                rotaryColl.wbos["scotsman"];
+      hasData = rotaryColl.wbo("flying") ||
+                rotaryColl.wbo("scotsman");
       hasKeys = keysWBO.modified;
       do_check_true(!hasData == !hasKeys);
 
       // Kick off another sync. Can't just call it, because we're inside the
       // lock...
       Utils.nextTick(function() {
         _("Now a fresh sync will get no HMAC errors.");
         _("Partially resetting client, as if after a restart, and forcing redownload.");
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/unit/test_httpd_sync_server.js
@@ -0,0 +1,178 @@
+function run_test() {
+  run_next_test();
+}
+
+add_test(function test_creation() {
+  // Explicit callback for this one.
+  let s = new SyncServer({
+    __proto__: SyncServerCallback,
+  });
+  do_check_true(!!s);       // Just so we have a check.
+  s.start(null, function () {
+    _("Started on " + s.port);
+    s.stop(run_next_test);
+  });
+});
+
+add_test(function test_url_parsing() {
+  let s = new SyncServer();
+  let parts = s.pathRE.exec("/1.1/johnsmith/storage/crypto/keys");
+  let [all, version, username, first, rest] = parts;
+  do_check_eq(version, "1.1");
+  do_check_eq(username, "johnsmith");
+  do_check_eq(first, "storage");
+  do_check_eq(rest, "crypto/keys");
+  do_check_eq(null, s.pathRE.exec("/nothing/else"));
+  run_next_test();
+});
+
+Cu.import("resource://services-sync/rest.js");
+function localRequest(path) {
+  _("localRequest: " + path);
+  let url = "http://127.0.0.1:8080" + path;
+  _("url: " + url);
+  return new RESTRequest(url);
+}
+
+add_test(function test_basic_http() {
+  let s = new SyncServer();
+  s.registerUser("john", "password");
+  do_check_true(s.userExists("john"));
+  s.start(8080, function () {
+    _("Started on " + s.port);
+    do_check_eq(s.port, 8080);
+    Utils.nextTick(function () {
+      let req = localRequest("/1.1/john/storage/crypto/keys");
+      _("req is " + req);
+      req.get(function (err) {
+        do_check_eq(null, err);
+        Utils.nextTick(function () {
+          s.stop(run_next_test);
+        });
+      });
+    });
+  });
+});
+
+add_test(function test_info_collections() {
+  let s = new SyncServer({
+    __proto__: SyncServerCallback
+  });
+  function responseHasCorrectHeaders(r) {
+    do_check_eq(r.status, 200);
+    do_check_eq(r.headers["content-type"], "application/json");
+    do_check_true("x-weave-timestamp" in r.headers);
+  }
+
+  s.registerUser("john", "password");
+  s.start(8080, function () {
+    do_check_eq(s.port, 8080);
+    Utils.nextTick(function () {
+      let req = localRequest("/1.1/john/info/collections");
+      req.get(function (err) {
+        // Initial info/collections fetch is empty.
+        do_check_eq(null, err);
+        responseHasCorrectHeaders(this.response);
+
+        do_check_eq(this.response.body, "{}");
+        Utils.nextTick(function () {
+          // When we PUT something to crypto/keys, "crypto" appears in the response.
+          function cb(err) {
+            do_check_eq(null, err);
+            responseHasCorrectHeaders(this.response);
+            let putResponseBody = this.response.body;
+            _("PUT response body: " + JSON.stringify(putResponseBody));
+
+            req = localRequest("/1.1/john/info/collections");
+            req.get(function (err) {
+              do_check_eq(null, err);
+              responseHasCorrectHeaders(this.response);
+              let expectedColl = s.getCollection("john", "crypto");
+              do_check_true(!!expectedColl);
+              let modified = expectedColl.timestamp;
+              do_check_true(modified > 0);
+              do_check_eq(putResponseBody, modified);
+              do_check_eq(JSON.parse(this.response.body).crypto, modified);
+              Utils.nextTick(function () {
+                s.stop(run_next_test);
+              });
+            });
+          }
+          let payload = JSON.stringify({foo: "bar"});
+          localRequest("/1.1/john/storage/crypto/keys").put(payload, cb);
+        });
+      });
+    });
+  });
+});
+
+add_test(function test_storage_request() {
+  let keysURL = "/1.1/john/storage/crypto/keys?foo=bar";
+  let foosURL = "/1.1/john/storage/crypto/foos";
+  let s = new SyncServer();
+  let creation = s.timestamp();
+  s.registerUser("john", "password");
+
+  s.createContents("john", {
+    crypto: {foos: {foo: "bar"}}
+  });
+  let coll = s.user("john").collection("crypto");
+  do_check_true(!!coll);
+
+  _("We're tracking timestamps.");
+  do_check_true(coll.timestamp >= creation);
+
+  function retrieveWBONotExists(next) {
+    let req = localRequest(keysURL);
+    req.get(function (err) {
+      _("Body is " + this.response.body);
+      _("Modified is " + this.response.newModified);
+      do_check_eq(null, err);
+      do_check_eq(this.response.status, 404);
+      do_check_eq(this.response.body, "Not found");
+      Utils.nextTick(next);
+    });
+  }
+  function retrieveWBOExists(next) {
+    let req = localRequest(foosURL);
+    req.get(function (err) {
+      _("Body is " + this.response.body);
+      _("Modified is " + this.response.newModified);
+      let parsedBody = JSON.parse(this.response.body);
+      do_check_eq(parsedBody.id, "foos");
+      do_check_eq(parsedBody.modified, coll.wbo("foos").modified);
+      do_check_eq(JSON.parse(parsedBody.payload).foo, "bar");
+      Utils.nextTick(next);
+    });
+  }
+  s.start(8080, function () {
+    retrieveWBONotExists(
+      retrieveWBOExists.bind(this, function () {
+        s.stop(run_next_test);
+      })
+    );
+  });
+});
+
+add_test(function test_x_weave_records() {
+  let s = new SyncServer();
+  s.registerUser("john", "password");
+
+  s.createContents("john", {
+    crypto: {foos: {foo: "bar"},
+             bars: {foo: "baz"}}
+  });
+  s.start(8080, function () {
+    let wbo = localRequest("/1.1/john/storage/crypto/foos");
+    wbo.get(function (err) {
+      // WBO fetches don't have one.
+      do_check_false("x-weave-records" in this.response.headers);
+      let col = localRequest("/1.1/john/storage/crypto");
+      col.get(function (err) {
+        // Collection fetches do.
+        do_check_eq(this.response.headers["x-weave-records"], "2");
+        s.stop(run_next_test);
+      });
+    });
+  });
+});
--- a/services/sync/tests/unit/test_jpakeclient.js
+++ b/services/sync/tests/unit/test_jpakeclient.js
@@ -1,36 +1,42 @@
 Cu.import("resource://services-sync/log4moz.js");
 Cu.import("resource://services-sync/identity.js");
 Cu.import("resource://services-sync/jpakeclient.js");
 Cu.import("resource://services-sync/constants.js");
 Cu.import("resource://services-sync/util.js");
 
 const JPAKE_LENGTH_SECRET     = 8;
 const JPAKE_LENGTH_CLIENTID   = 256;
+const KEYEXCHANGE_VERSION     = 3;
 
 /*
  * Simple server.
  */
 
+const SERVER_MAX_GETS = 6;
+
 function check_headers(request) {
+  let stack = Components.stack.caller;
+
   // There shouldn't be any Basic auth
-  do_check_false(request.hasHeader("Authorization"));
+  do_check_false(request.hasHeader("Authorization"), stack);
 
   // Ensure key exchange ID is set and the right length
-  do_check_true(request.hasHeader("X-KeyExchange-Id"));
+  do_check_true(request.hasHeader("X-KeyExchange-Id"), stack);
   do_check_eq(request.getHeader("X-KeyExchange-Id").length,
-              JPAKE_LENGTH_CLIENTID);
+              JPAKE_LENGTH_CLIENTID, stack);
 }
 
 function new_channel() {
   // Create a new channel and register it with the server.
   let cid = Math.floor(Math.random() * 10000);
-  while (channels[cid])
+  while (channels[cid]) {
     cid = Math.floor(Math.random() * 10000);
+  }
   let channel = channels[cid] = new ServerChannel();
   server.registerPathHandler("/" + cid, channel.handler());
   return cid;
 }
 
 let server;
 let channels = {};  // Map channel -> ServerChannel object
 function server_new_channel(request, response) {
@@ -40,60 +46,79 @@ function server_new_channel(request, res
   response.setStatusLine(request.httpVersion, 200, "OK");
   response.bodyOutputStream.write(body, body.length);
 }
 
 let error_report;
 function server_report(request, response) {
   check_headers(request);
 
-  if (request.hasHeader("X-KeyExchange-Log"))
+  if (request.hasHeader("X-KeyExchange-Log")) {
     error_report = request.getHeader("X-KeyExchange-Log");
+  }
 
   if (request.hasHeader("X-KeyExchange-Cid")) {
     let cid = request.getHeader("X-KeyExchange-Cid");
     let channel = channels[cid];
-    if (channel)
+    if (channel) {
       channel.clear();
+    }
   }
 
   response.setStatusLine(request.httpVersion, 200, "OK");
 }
 
 function ServerChannel() {
-  this.data = "{}";
+  this.data = "";
+  this.etag = "";
   this.getCount = 0;
 }
 ServerChannel.prototype = {
 
   GET: function GET(request, response) {
     if (!this.data) {
       response.setStatusLine(request.httpVersion, 404, "Not Found");
       return;
     }
+
     if (request.hasHeader("If-None-Match")) {
       let etag = request.getHeader("If-None-Match");
-      if (etag == this._etag) {
+      if (etag == this.etag) {
         response.setStatusLine(request.httpVersion, 304, "Not Modified");
         return;
       }
     }
+    response.setHeader("ETag", this.etag);
     response.setStatusLine(request.httpVersion, 200, "OK");
     response.bodyOutputStream.write(this.data, this.data.length);
 
     // Automatically clear the channel after 6 successful GETs.
     this.getCount += 1;
-    if (this.getCount == 6)
+    if (this.getCount == SERVER_MAX_GETS) {
       this.clear();
+    }
   },
 
   PUT: function PUT(request, response) {
+    if (this.data) {
+      do_check_true(request.hasHeader("If-Match"));
+      let etag = request.getHeader("If-Match");
+      if (etag != this.etag) {
+        response.setHeader("ETag", this.etag);
+        response.setStatusLine(request.httpVersion, 412, "Precondition Failed");
+        return;
+      }
+    } else {
+      do_check_true(request.hasHeader("If-None-Match"));
+      do_check_eq(request.getHeader("If-None-Match"), "*");
+    }
+
     this.data = readBytesFromInputStream(request.bodyInputStream);
-    this._etag = '"' + Utils.sha1(this.data) + '"';
-    response.setHeader("ETag", this._etag);
+    this.etag = '"' + Utils.sha1(this.data) + '"';
+    response.setHeader("ETag", this.etag);
     response.setStatusLine(request.httpVersion, 200, "OK");
   },
 
   clear: function clear() {
     delete this.data;
   },
 
   handler: function handler() {
@@ -103,24 +128,47 @@ ServerChannel.prototype = {
       let method = self[request.method];
       return method.apply(self, arguments);
     };
   }
 
 };
 
 
+/**
+ * Controller that throws for everything.
+ */
+let BaseController = {
+  displayPIN: function displayPIN() {
+    do_throw("displayPIN() shouldn't have been called!");
+  },
+  onPairingStart: function onPairingStart() {
+    do_throw("onPairingStart shouldn't have been called!");
+  },
+  onAbort: function onAbort(error) {
+    do_throw("Shouldn't have aborted with " + error + "!");
+  },
+  onPaired: function onPaired() {
+    do_throw("onPaired() shouldn't have been called!");
+  },
+  onComplete: function onComplete(data) {
+    do_throw("Shouldn't have completed with " + data + "!");
+  }
+};
+
+
 const DATA = {"msg": "eggstreamly sekrit"};
 const POLLINTERVAL = 50;
 
 function run_test() {
   Svc.Prefs.set("jpake.serverURL", "http://localhost:8080/");
   Svc.Prefs.set("jpake.pollInterval", POLLINTERVAL);
-  Svc.Prefs.set("jpake.maxTries", 5);
+  Svc.Prefs.set("jpake.maxTries", 2);
   Svc.Prefs.set("jpake.firstMsgMaxTries", 5);
+  Svc.Prefs.set("jpake.lastMsgMaxTries", 5);
   // Ensure clean up
   Svc.Obs.add("profile-before-change", function() {
     Svc.Prefs.resetBranch("");
   });
 
   // Ensure PSM is initialized.
   Cc["@mozilla.org/psm;1"].getService(Ci.nsISupports);
 
@@ -129,125 +177,200 @@ function run_test() {
   let id = new Identity(PWDMGR_PASSWORD_REALM, "johndoe");
   id.password = "ilovejane";
   ID.set("WeaveID", id);
 
   server = httpd_setup({"/new_channel": server_new_channel,
                         "/report":      server_report});
 
   initTestLogging("Trace");
+  Log4Moz.repository.getLogger("Sync.JPAKEClient").level = Log4Moz.Level.Trace;
+  Log4Moz.repository.getLogger("Sync.RESTRequest").level = Log4Moz.Level.Trace;
   run_next_test();
 }
 
 
 add_test(function test_success_receiveNoPIN() {
   _("Test a successful exchange started by receiveNoPIN().");
 
   let snd = new JPAKEClient({
-    displayPIN: function displayPIN() {
-      do_throw("displayPIN shouldn't have been called!");
-    },
-    onAbort: function onAbort(error) {
-      do_throw("Shouldn't have aborted!" + error);
+    __proto__: BaseController,
+    onPaired: function onPaired() {
+      _("Pairing successful, sending final payload.");
+      do_check_true(pairingStartCalledOnReceiver);
+      Utils.nextTick(function() { snd.sendAndComplete(DATA); });
     },
     onComplete: function onComplete() {}
   });
 
+  let pairingStartCalledOnReceiver = false;
   let rec = new JPAKEClient({
+    __proto__: BaseController,
     displayPIN: function displayPIN(pin) {
       _("Received PIN " + pin + ". Entering it in the other computer...");
       this.cid = pin.slice(JPAKE_LENGTH_SECRET);
-      Utils.nextTick(function() { snd.sendWithPIN(pin, DATA); });
+      Utils.nextTick(function() { snd.pairWithPIN(pin, false); });
     },
-    onAbort: function onAbort(error) {
-      do_throw("Shouldn't have aborted! " + error);
+    onPairingStart: function onPairingStart() {
+      pairingStartCalledOnReceiver = true;
     },
-    onComplete: function onComplete(a) {
+    onComplete: function onComplete(data) {
+      do_check_true(Utils.deepEquals(DATA, data));
       // Ensure channel was cleared, no error report.
       do_check_eq(channels[this.cid].data, undefined);
       do_check_eq(error_report, undefined);
       run_next_test();
     }
   });
   rec.receiveNoPIN();
 });
 
 
-add_test(function test_firstMsgMaxTries() {
+add_test(function test_firstMsgMaxTries_timeout() {
   _("Test abort when sender doesn't upload anything.");
 
   let rec = new JPAKEClient({
+    __proto__: BaseController,
     displayPIN: function displayPIN(pin) {
       _("Received PIN " + pin + ". Doing nothing...");
       this.cid = pin.slice(JPAKE_LENGTH_SECRET);
     },
     onAbort: function onAbort(error) {
       do_check_eq(error, JPAKE_ERROR_TIMEOUT);
       // Ensure channel was cleared, error report was sent.
       do_check_eq(channels[this.cid].data, undefined);
       do_check_eq(error_report, JPAKE_ERROR_TIMEOUT);
       error_report = undefined;
       run_next_test();
+    }
+  });
+  rec.receiveNoPIN();
+});
+
+
+add_test(function test_firstMsgMaxTries() {
+  _("Test that receiver can wait longer for the first message.");
+
+  let snd = new JPAKEClient({
+    __proto__: BaseController,
+    onPaired: function onPaired() {
+      _("Pairing successful, sending final payload.");
+      Utils.nextTick(function() { snd.sendAndComplete(DATA); });
     },
-    onComplete: function onComplete() {
-      do_throw("Shouldn't have completed! ");
+    onComplete: function onComplete() {}
+  });
+
+  let rec = new JPAKEClient({
+    __proto__: BaseController,
+    displayPIN: function displayPIN(pin) {
+      // For the purpose of the tests, the poll interval is 50ms and
+      // we're polling up to 5 times for the first exchange (as
+      // opposed to 2 times for most of the other exchanges). So let's
+      // pretend it took 150ms to enter the PIN on the sender.
+      _("Received PIN " + pin + ". Waiting 150ms before entering it into sender...");
+      this.cid = pin.slice(JPAKE_LENGTH_SECRET);
+      Utils.namedTimer(function() { snd.pairWithPIN(pin, false); },
+                       150, this, "_sendTimer");
+    },
+    onPairingStart: function onPairingStart(pin) {},
+    onComplete: function onComplete(data) {
+      do_check_true(Utils.deepEquals(DATA, data));
+      // Ensure channel was cleared, no error report.
+      do_check_eq(channels[this.cid].data, undefined);
+      do_check_eq(error_report, undefined);
+      run_next_test();
     }
   });
   rec.receiveNoPIN();
 });
 
 
+add_test(function test_lastMsgMaxTries() {
+  _("Test that receiver can wait longer for the last message.");
+
+ let snd = new JPAKEClient({
+    __proto__: BaseController,
+    onPaired: function onPaired() {
+      // For the purpose of the tests, the poll interval is 50ms and
+      // we're polling up to 5 times for the last exchange (as opposed
+      // to 2 times for other exchanges). So let's pretend it took
+      // 150ms to come up with the final payload, which should require
+      // 3 polls.
+      _("Pairing successful, waiting 150ms to send final payload.");
+      Utils.namedTimer(function() { snd.sendAndComplete(DATA); },
+                       150, this, "_sendTimer");
+    },
+    onComplete: function onComplete() {}
+  });
+
+  let rec = new JPAKEClient({
+    __proto__: BaseController,
+    displayPIN: function displayPIN(pin) {
+      _("Received PIN " + pin + ". Entering it in the other computer...");
+      this.cid = pin.slice(JPAKE_LENGTH_SECRET);
+      Utils.nextTick(function() { snd.pairWithPIN(pin, false); });
+    },
+    onPairingStart: function onPairingStart(pin) {},
+    onComplete: function onComplete(data) {
+      do_check_true(Utils.deepEquals(DATA, data));
+      // Ensure channel was cleared, no error report.
+      do_check_eq(channels[this.cid].data, undefined);
+      do_check_eq(error_report, undefined);
+      run_next_test();
+    }
+  });
+
+  rec.receiveNoPIN();
+});
+
+
 add_test(function test_wrongPIN() {
   _("Test abort when PINs don't match.");
 
   let snd = new JPAKEClient({
-    displayPIN: function displayPIN() {
-      do_throw("displayPIN shouldn't have been called!");
-    },
+    __proto__: BaseController,
     onAbort: function onAbort(error) {
       do_check_eq(error, JPAKE_ERROR_KEYMISMATCH);
       do_check_eq(error_report, JPAKE_ERROR_KEYMISMATCH);
       error_report = undefined;
-    },
-    onComplete: function onComplete() {
-      do_throw("Shouldn't have completed!");
     }
   });
 
+  let pairingStartCalledOnReceiver = false;
   let rec = new JPAKEClient({
+    __proto__: BaseController,
     displayPIN: function displayPIN(pin) {
       this.cid = pin.slice(JPAKE_LENGTH_SECRET);
       let secret = pin.slice(0, JPAKE_LENGTH_SECRET);
       secret = [char for each (char in secret)].reverse().join("");
       let new_pin = secret + this.cid;
       _("Received PIN " + pin + ", but I'm entering " + new_pin);
 
-      Utils.nextTick(function() { snd.sendWithPIN(new_pin, DATA); });
+      Utils.nextTick(function() { snd.pairWithPIN(new_pin, false); });
+    },
+    onPairingStart: function onPairingStart() {
+      pairingStartCalledOnReceiver = true;
     },
     onAbort: function onAbort(error) {
+      do_check_true(pairingStartCalledOnReceiver);
       do_check_eq(error, JPAKE_ERROR_NODATA);
       // Ensure channel was cleared.
       do_check_eq(channels[this.cid].data, undefined);
       run_next_test();
-    },
-    onComplete: function onComplete() {
-      do_throw("Shouldn't have completed! ");
     }
   });
   rec.receiveNoPIN();
 });
 
 
 add_test(function test_abort_receiver() {
   _("Test user abort on receiving side.");
 
   let rec = new JPAKEClient({
-    onComplete: function onComplete(data) {
-      do_throw("onComplete shouldn't be called.");
-    },
+    __proto__: BaseController,
     onAbort: function onAbort(error) {
       // Manual abort = userabort.
       do_check_eq(error, JPAKE_ERROR_USERABORT);
       // Ensure channel was cleared.
       do_check_eq(channels[this.cid].data, undefined);
       do_check_eq(error_report, JPAKE_ERROR_USERABORT);
       error_report = undefined;
       run_next_test();
@@ -260,99 +383,143 @@ add_test(function test_abort_receiver() 
   rec.receiveNoPIN();
 });
 
 
 add_test(function test_abort_sender() {
   _("Test user abort on sending side.");
 
   let snd = new JPAKEClient({
-    displayPIN: function displayPIN() {
-      do_throw("displayPIN shouldn't have been called!");
-    },
+    __proto__: BaseController,
     onAbort: function onAbort(error) {
       // Manual abort == userabort.
       do_check_eq(error, JPAKE_ERROR_USERABORT);
       do_check_eq(error_report, JPAKE_ERROR_USERABORT);
       error_report = undefined;
-    },
-    onComplete: function onComplete() {
-      do_throw("Shouldn't have completed!");
     }
   });
 
   let rec = new JPAKEClient({
-    onComplete: function onComplete(data) {
-      do_throw("onComplete shouldn't be called.");
-    },
+    __proto__: BaseController,
     onAbort: function onAbort(error) {
       do_check_eq(error, JPAKE_ERROR_NODATA);
       // Ensure channel was cleared, no error report.
       do_check_eq(channels[this.cid].data, undefined);
       do_check_eq(error_report, undefined);
       run_next_test();
     },
     displayPIN: function displayPIN(pin) {
       _("Received PIN " + pin + ". Entering it in the other computer...");
       this.cid = pin.slice(JPAKE_LENGTH_SECRET);
-      Utils.nextTick(function() { snd.sendWithPIN(pin, DATA); });
+      Utils.nextTick(function() { snd.pairWithPIN(pin, false); });
       Utils.namedTimer(function() { snd.abort(); },
                        POLLINTERVAL, this, "_abortTimer");
-    }
+    },
+    onPairingStart: function onPairingStart(pin) {}
   });
   rec.receiveNoPIN();
 });
 
 
 add_test(function test_wrongmessage() {
   let cid = new_channel();
-  channels[cid].data = JSON.stringify({type: "receiver2", payload: {}});
+  let channel = channels[cid];
+  channel.data = JSON.stringify({type: "receiver2",
+                                 version: KEYEXCHANGE_VERSION,
+                                 payload: {}});
+  channel.etag = '"fake-etag"';
   let snd = new JPAKEClient({
+    __proto__: BaseController,
     onComplete: function onComplete(data) {
       do_throw("onComplete shouldn't be called.");
     },
     onAbort: function onAbort(error) {
       do_check_eq(error, JPAKE_ERROR_WRONGMESSAGE);
       run_next_test();
     }
   });
-  snd.sendWithPIN("01234567" + cid, DATA);
+  snd.pairWithPIN("01234567" + cid, false);
 });
 
 
 add_test(function test_error_channel() {
+  let serverURL = Svc.Prefs.get("jpake.serverURL");
   Svc.Prefs.set("jpake.serverURL", "http://localhost:12345/");
 
   let rec = new JPAKEClient({
-    onComplete: function onComplete(data) {
-      do_throw("onComplete shouldn't be called.");
-    },
+    __proto__: BaseController,
     onAbort: function onAbort(error) {
       do_check_eq(error, JPAKE_ERROR_CHANNEL);
-      Svc.Prefs.reset("jpake.serverURL");
+      Svc.Prefs.set("jpake.serverURL", serverURL);
       run_next_test();
     },
+    onPairingStart: function onPairingStart(pin) {},
     displayPIN: function displayPIN(pin) {}
   });
   rec.receiveNoPIN();
 });
 
 
 add_test(function test_error_network() {
+  let serverURL = Svc.Prefs.get("jpake.serverURL");
   Svc.Prefs.set("jpake.serverURL", "http://localhost:12345/");
 
   let snd = new JPAKEClient({
-    onComplete: function onComplete(data) {
-      do_throw("onComplete shouldn't be called.");
-    },
+    __proto__: BaseController,
     onAbort: function onAbort(error) {
       do_check_eq(error, JPAKE_ERROR_NETWORK);
-      Svc.Prefs.reset("jpake.serverURL");
+      Svc.Prefs.set("jpake.serverURL", serverURL);
+      run_next_test();
+    }
+  });
+  snd.pairWithPIN("0123456789ab", false);
+});
+
+
+add_test(function test_error_server_noETag() {
+  let cid = new_channel();
+  let channel = channels[cid];
+  channel.data = JSON.stringify({type: "receiver1",
+                                 version: KEYEXCHANGE_VERSION,
+                                 payload: {}});
+  // This naughty server doesn't supply ETag (well, it supplies empty one).
+  channel.etag = "";
+  let snd = new JPAKEClient({
+    __proto__: BaseController,
+    onAbort: function onAbort(error) {
+      do_check_eq(error, JPAKE_ERROR_SERVER);
       run_next_test();
     }
   });
-  snd.sendWithPIN("0123456789ab", DATA);
+  snd.pairWithPIN("01234567" + cid, false);
+});
+
+
+add_test(function test_error_delayNotSupported() {
+  let cid = new_channel();
+  let channel = channels[cid];
+  channel.data = JSON.stringify({type: "receiver1",
+                                 version: 2,
+                                 payload: {}});
+  channel.etag = '"fake-etag"';
+  let snd = new JPAKEClient({
+    __proto__: BaseController,
+    onAbort: function onAbort(error) {
+      do_check_eq(error, JPAKE_ERROR_DELAYUNSUPPORTED);
+      run_next_test();
+    }
+  });
+  snd.pairWithPIN("01234567" + cid, true);
+});
+
+
+add_test(function test_sendAndComplete_notPaired() {
+  let snd = new JPAKEClient({__proto__: BaseController});
+  do_check_throws(function () {
+    snd.sendAndComplete(DATA);
+  });
+  run_next_test();
 });
 
 
 add_test(function tearDown() {
   server.stop(run_next_test);
 });
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/unit/test_sendcredentials_controller.js
@@ -0,0 +1,96 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */ 
+
+Cu.import("resource://services-sync/policies.js");
+Cu.import("resource://services-sync/constants.js");
+Cu.import("resource://services-sync/service.js");
+Cu.import("resource://services-sync/util.js");
+
+function run_test() {
+  Service.account    = "johndoe";
+  Service.password   = "ilovejane";
+  Service.passphrase = Utils.generatePassphrase();
+  Service.serverURL  = "http://weave.server/";
+
+  run_next_test();
+}
+
+function make_sendCredentials_test(topic) {
+  return function test_sendCredentials() {
+    _("Test sending credentials on " + topic + " observer notification.");
+
+    let sendAndCompleteCalled = false;
+    let jpakeclient = {
+      sendAndComplete: function sendAndComplete(data) {
+        // Verify that the controller unregisters itself as an observer
+        // when the exchange is complete by faking another notification.
+        do_check_false(sendAndCompleteCalled);
+        sendAndCompleteCalled = true;
+        this.controller.onComplete();
+        Svc.Obs.notify(topic);
+
+        // Verify it sends the correct data.
+        do_check_eq(data.account,   Service.account);
+        do_check_eq(data.password,  Service.password);
+        do_check_eq(data.synckey,   Service.passphrase);
+        do_check_eq(data.serverURL, Service.serverURL);
+
+        // Verify it schedules a sync for the expected interval.
+        let expectedInterval = SyncScheduler.activeInterval;
+        do_check_true(SyncScheduler.nextSync - Date.now() <= expectedInterval);
+        SyncScheduler.setDefaults();
+
+        Utils.nextTick(run_next_test);
+      }
+    };
+    jpakeclient.controller = new SendCredentialsController(jpakeclient);
+    Svc.Obs.notify(topic);
+  };
+}
+
+add_test(make_sendCredentials_test("weave:service:sync:finish"));
+add_test(make_sendCredentials_test("weave:service:sync:error"));
+
+
+add_test(function test_abort() {
+  _("Test aborting the J-PAKE exchange.");
+
+  let jpakeclient = {
+    sendAndComplete: function sendAndComplete() {
+      do_throw("Shouldn't get here!");
+    }
+  };
+  jpakeclient.controller = new SendCredentialsController(jpakeclient);
+
+  // Verify that the controller unregisters itself when the exchange
+  // was aborted.
+  jpakeclient.controller.onAbort(JPAKE_ERROR_USERABORT);
+  Svc.Obs.notify("weave:service:sync:finish");
+  Utils.nextTick(run_next_test);
+});
+
+
+add_test(function test_startOver() {
+  _("Test wiping local Sync config aborts transaction.");
+
+  let abortCalled = false;
+  let jpakeclient = {
+    abort: function abort() {
+      abortCalled = true;
+      this.controller.onAbort(JPAKE_ERROR_USERABORT);
+    },
+    sendAndComplete: function sendAndComplete() {
+      do_throw("Shouldn't get here!");
+    }
+  };
+  jpakeclient.controller = new SendCredentialsController(jpakeclient);
+
+  Svc.Obs.notify("weave:service:start-over");
+  do_check_true(abortCalled);
+
+  // Ensure that the controller no longer does anything if a sync
+  // finishes now or -- more likely -- errors out.
+  Svc.Obs.notify("weave:service:sync:error");
+
+  Utils.nextTick(run_next_test);
+});
--- a/services/sync/tests/unit/test_syncengine_sync.js
+++ b/services/sync/tests/unit/test_syncengine_sync.js
@@ -40,54 +40,54 @@ add_test(function test_syncStartup_empty
   _("SyncEngine._syncStartup resets sync and wipes server data if there's no or an outdated global record");
 
   let syncTesting = new SyncTestingInfrastructure();
   Svc.Prefs.set("clusterURL", "http://localhost:8080/");
   Svc.Prefs.set("username", "foo");
 
   // Some server side data that's going to be wiped
   let collection = new ServerCollection();
-  collection.wbos.flying = new ServerWBO(
-      'flying', encryptPayload({id: 'flying',
-                                denomination: "LNER Class A3 4472"}));
-  collection.wbos.scotsman = new ServerWBO(
-      'scotsman', encryptPayload({id: 'scotsman',
-                                  denomination: "Flying Scotsman"}));
+  collection.insert('flying',
+                    encryptPayload({id: 'flying',
+                                    denomination: "LNER Class A3 4472"}));
+  collection.insert('scotsman',
+                    encryptPayload({id: 'scotsman',
+                                    denomination: "Flying Scotsman"}));
 
   let server = sync_httpd_setup({
       "/1.1/foo/storage/rotary": collection.handler()
   });
 
   let engine = makeRotaryEngine();
   engine._store.items = {rekolok: "Rekonstruktionslokomotive"};
   try {
 
     // Confirm initial environment
     do_check_eq(engine._tracker.changedIDs["rekolok"], undefined);
     let metaGlobal = Records.get(engine.metaURL);
     do_check_eq(metaGlobal.payload.engines, undefined);
-    do_check_true(!!collection.wbos.flying.payload);
-    do_check_true(!!collection.wbos.scotsman.payload);
+    do_check_true(!!collection.payload("flying"));
+    do_check_true(!!collection.payload("scotsman"));
 
     engine.lastSync = Date.now() / 1000;
     engine.lastSyncLocal = Date.now();
     
     // Trying to prompt a wipe -- we no longer track CryptoMeta per engine,
     // so it has nothing to check.
     engine._syncStartup();
 
     // The meta/global WBO has been filled with data about the engine
     let engineData = metaGlobal.payload.engines["rotary"];
     do_check_eq(engineData.version, engine.version);
     do_check_eq(engineData.syncID, engine.syncID);
 
     // Sync was reset and server data was wiped
     do_check_eq(engine.lastSync, 0);
-    do_check_eq(collection.wbos.flying.payload, undefined);
-    do_check_eq(collection.wbos.scotsman.payload, undefined);
+    do_check_eq(collection.payload("flying"), undefined);
+    do_check_eq(collection.payload("scotsman"), undefined);
 
   } finally {
     cleanAndGo(server);
   }
 });
 
 add_test(function test_syncStartup_serverHasNewerVersion() {
   _("SyncEngine._syncStartup ");
@@ -187,32 +187,32 @@ add_test(function test_processIncoming_c
   let syncTesting = new SyncTestingInfrastructure();
   Svc.Prefs.set("clusterURL", "http://localhost:8080/");
   Svc.Prefs.set("username", "foo");
   
   generateNewKeys();
 
   // Some server records that will be downloaded
   let collection = new ServerCollection();
-  collection.wbos.flying = new ServerWBO(
-      'flying', encryptPayload({id: 'flying',
-                                denomination: "LNER Class A3 4472"}));
-  collection.wbos.scotsman = new ServerWBO(
-      'scotsman', encryptPayload({id: 'scotsman',
-                                  denomination: "Flying Scotsman"}));
+  collection.insert('flying',
+                    encryptPayload({id: 'flying',
+                                    denomination: "LNER Class A3 4472"}));
+  collection.insert('scotsman',
+                    encryptPayload({id: 'scotsman',
+                                    denomination: "Flying Scotsman"}));
 
   // Two pathological cases involving relative URIs gone wrong.
-  collection.wbos['../pathological'] = new ServerWBO(
-      '../pathological', encryptPayload({id: '../pathological',
-                                         denomination: "Pathological Case"}));
+  let pathologicalPayload = encryptPayload({id: '../pathological',
+                                            denomination: "Pathological Case"});
+  collection.insert('../pathological', pathologicalPayload);
 
   let server = sync_httpd_setup({
       "/1.1/foo/storage/rotary": collection.handler(),
-      "/1.1/foo/storage/rotary/flying": collection.wbos.flying.handler(),
-      "/1.1/foo/storage/rotary/scotsman": collection.wbos.scotsman.handler()
+      "/1.1/foo/storage/rotary/flying": collection.wbo("flying").handler(),
+      "/1.1/foo/storage/rotary/scotsman": collection.wbo("scotsman").handler()
   });
 
   let engine = makeRotaryEngine();
   let meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL));
   meta_global.payload.engines = {rotary: {version: engine.version,
                                          syncID: engine.syncID}};
 
   try {
@@ -247,58 +247,58 @@ add_test(function test_processIncoming_r
 
   let syncTesting = new SyncTestingInfrastructure();
   Svc.Prefs.set("clusterURL", "http://localhost:8080/");
   Svc.Prefs.set("username", "foo");
   let collection = new ServerCollection();
 
   // This server record is newer than the corresponding client one,
   // so it'll update its data.
-  collection.wbos.newrecord = new ServerWBO(
-      'newrecord', encryptPayload({id: 'newrecord',
-                                   denomination: "New stuff..."}));
+  collection.insert('newrecord',
+                    encryptPayload({id: 'newrecord',
+                                    denomination: "New stuff..."}));
 
   // This server record is newer than the corresponding client one,
   // so it'll update its data.
-  collection.wbos.newerserver = new ServerWBO(
-      'newerserver', encryptPayload({id: 'newerserver',
-                                     denomination: "New data!"}));
+  collection.insert('newerserver',
+                    encryptPayload({id: 'newerserver',
+                                    denomination: "New data!"}));
 
   // This server record is 2 mins older than the client counterpart
   // but identical to it, so we're expecting the client record's
   // changedID to be reset.
-  collection.wbos.olderidentical = new ServerWBO(
-      'olderidentical', encryptPayload({id: 'olderidentical',
-                                        denomination: "Older but identical"}));
-  collection.wbos.olderidentical.modified -= 120;
+  collection.insert('olderidentical',
+                    encryptPayload({id: 'olderidentical',
+                                    denomination: "Older but identical"}));
+  collection._wbos.olderidentical.modified -= 120;
 
   // This item simply has different data than the corresponding client
   // record (which is unmodified), so it will update the client as well
-  collection.wbos.updateclient = new ServerWBO(
-      'updateclient', encryptPayload({id: 'updateclient',
-                                      denomination: "Get this!"}));
+  collection.insert('updateclient',
+                    encryptPayload({id: 'updateclient',
+                                    denomination: "Get this!"}));
 
   // This is a dupe of 'original' but with a longer GUID, so we're
   // expecting it to be marked for deletion from the server
-  collection.wbos.duplication = new ServerWBO(
-      'duplication', encryptPayload({id: 'duplication',
-                                     denomination: "Original Entry"}));
+  collection.insert('duplication',
+                    encryptPayload({id: 'duplication',
+                                    denomination: "Original Entry"}));
 
   // This is a dupe of 'long_original' but with a shorter GUID, so we're
   // expecting it to replace 'long_original'.
-  collection.wbos.dupe = new ServerWBO(
-      'dupe', encryptPayload({id: 'dupe',
-                              denomination: "Long Original Entry"}));  
+  collection.insert('dupe',
+                    encryptPayload({id: 'dupe',
+                                    denomination: "Long Original Entry"}));
 
   // This record is marked as deleted, so we're expecting the client
   // record to be removed.
-  collection.wbos.nukeme = new ServerWBO(
-      'nukeme', encryptPayload({id: 'nukeme',
-                                denomination: "Nuke me!",
-                                deleted: true}));
+  collection.insert('nukeme',
+                    encryptPayload({id: 'nukeme',
+                                    denomination: "Nuke me!",
+                                    deleted: true}));
 
   let server = sync_httpd_setup({
       "/1.1/foo/storage/rotary": collection.handler()
   });
 
   let engine = makeRotaryEngine();
   engine._store.items = {newerserver: "New data, but not as new as server!",
                          olderidentical: "Older but identical",
@@ -375,22 +375,22 @@ add_test(function test_processIncoming_m
   collection._get = collection.get;
   collection.get = function (options) {
     this.get_log.push(options);
     return this._get(options);
   };
 
   // Let's create some 234 server side records. They're all at least
   // 10 minutes old.
-  for (var i = 0; i < 234; i++) {
+  for (let i = 0; i < 234; i++) {
     let id = 'record-no-' + i;
     let payload = encryptPayload({id: id, denomination: "Record No. " + i});
     let wbo = new ServerWBO(id, payload);
     wbo.modified = Date.now()/1000 - 60*(i+10);
-    collection.wbos[id] = wbo;
+    collection.insertWBO(wbo);
   }
 
   let server = sync_httpd_setup({
       "/1.1/foo/storage/rotary": collection.handler()
   });
 
   let engine = makeRotaryEngine();
   let meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL));
@@ -451,17 +451,17 @@ add_test(function test_processIncoming_s
   };
 
   // Let's create three batches worth of server side records.
   for (var i = 0; i < MOBILE_BATCH_SIZE * 3; i++) {
     let id = 'record-no-' + i;
     let payload = encryptPayload({id: id, denomination: "Record No. " + id});
     let wbo = new ServerWBO(id, payload);
     wbo.modified = Date.now()/1000 + 60 * (i - MOBILE_BATCH_SIZE * 3);
-    collection.wbos[id] = wbo;
+    collection.insertWBO(wbo);
   }
 
   let engine = makeRotaryEngine();
   engine.enabled = true;
 
   let meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL));
   meta_global.payload.engines = {rotary: {version: engine.version,
                                          syncID: engine.syncID}};
@@ -485,17 +485,17 @@ add_test(function test_processIncoming_s
 
     // Only the first two batches have been applied.
     do_check_eq(Object.keys(engine._store.items).length,
                 MOBILE_BATCH_SIZE * 2);
 
     // The third batch is stuck in toFetch. lastSync has been moved forward to
     // the last successful item's timestamp.
     do_check_eq(engine.toFetch.length, MOBILE_BATCH_SIZE);
-    do_check_eq(engine.lastSync, collection.wbos["record-no-99"].modified);
+    do_check_eq(engine.lastSync, collection.wbo("record-no-99").modified);
 
   } finally {
     cleanAndGo(server);
   }
 });
 
 
 add_test(function test_processIncoming_resume_toFetch() {
@@ -503,36 +503,36 @@ add_test(function test_processIncoming_r
   let syncTesting = new SyncTestingInfrastructure();
   Svc.Prefs.set("clusterURL", "http://localhost:8080/");
   Svc.Prefs.set("username", "foo");
 
   const LASTSYNC = Date.now() / 1000;
 
   // Server records that will be downloaded
   let collection = new ServerCollection();
-  collection.wbos.flying = new ServerWBO(
-      'flying', encryptPayload({id: 'flying',
-                                denomination: "LNER Class A3 4472"}));
-  collection.wbos.scotsman = new ServerWBO(
-      'scotsman', encryptPayload({id: 'scotsman',
-                                  denomination: "Flying Scotsman"}));
-  collection.wbos.rekolok = new ServerWBO(
-      'rekolok', encryptPayload({id: 'rekolok',
-                                 denomination: "Rekonstruktionslokomotive"}));
-  for (var i = 0; i < 3; i++) {
+  collection.insert('flying',
+                    encryptPayload({id: 'flying',
+                                    denomination: "LNER Class A3 4472"}));
+  collection.insert('scotsman',
+                    encryptPayload({id: 'scotsman',
+                                    denomination: "Flying Scotsman"}));
+  collection.insert('rekolok',
+                    encryptPayload({id: 'rekolok',
+                                    denomination: "Rekonstruktionslokomotive"}));
+  for (let i = 0; i < 3; i++) {
     let id = 'failed' + i;
     let payload = encryptPayload({id: id, denomination: "Record No. " + i});
     let wbo = new ServerWBO(id, payload);
     wbo.modified = LASTSYNC - 10;
-    collection.wbos[id] = wbo;
+    collection.insertWBO(wbo);
   }
 
-  collection.wbos.flying.modified = collection.wbos.scotsman.modified
-    = LASTSYNC - 10;
-  collection.wbos.rekolok.modified = LASTSYNC + 10;
+  collection.wbo("flying").modified =
+    collection.wbo("scotsman").modified = LASTSYNC - 10;
+  collection._wbos.rekolok.modified = LASTSYNC + 10;
 
   // Time travel 10 seconds into the future but still download the above WBOs.
   let engine = makeRotaryEngine();
   engine.lastSync = LASTSYNC;
   engine.toFetch = ["flying", "scotsman"];
   engine.previousFailed = ["failed0", "failed1", "failed2"];
 
   let meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL));
@@ -584,17 +584,17 @@ add_test(function test_processIncoming_a
     return [failed1.id, failed2.id];
   };
 
   // Let's create less than a batch worth of server side records.
   let collection = new ServerCollection();
   for (let i = 0; i < APPLY_BATCH_SIZE - 1; i++) {
     let id = 'record-no-' + i;
     let payload = encryptPayload({id: id, denomination: "Record No. " + id});
-    collection.wbos[id] = new ServerWBO(id, payload);
+    collection.insert(id, payload);
   }
 
   let meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL));
   meta_global.payload.engines = {rotary: {version: engine.version,
                                          syncID: engine.syncID}};
   let server = sync_httpd_setup({
       "/1.1/foo/storage/rotary": collection.handler()
   });
@@ -639,17 +639,17 @@ add_test(function test_processIncoming_a
     this._applyIncomingBatch.apply(this, arguments);
   };
 
   // Let's create three batches worth of server side records.
   let collection = new ServerCollection();
   for (let i = 0; i < APPLY_BATCH_SIZE * 3; i++) {
     let id = 'record-no-' + i;
     let payload = encryptPayload({id: id, denomination: "Record No. " + id});
-    collection.wbos[id] = new ServerWBO(id, payload);
+    collection.insert(id, payload);
   }
 
   let meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL));
   meta_global.payload.engines = {rotary: {version: engine.version,
                                          syncID: engine.syncID}};
   let server = sync_httpd_setup({
       "/1.1/foo/storage/rotary": collection.handler()
   });
@@ -690,17 +690,17 @@ add_test(function test_processIncoming_n
     return [records[0].id];
   };
 
   // Create a batch of server side records.
   let collection = new ServerCollection();
   for (var i = 0; i < NUMBER_OF_RECORDS; i++) {
     let id = 'record-no-' + i;
     let payload = encryptPayload({id: id, denomination: "Record No. " + id});
-    collection.wbos[id] = new ServerWBO(id, payload);
+    collection.insert(id, payload);
   }
 
   let meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL));
   meta_global.payload.engines = {rotary: {version: engine.version,
                                          syncID: engine.syncID}};
   let server = sync_httpd_setup({
       "/1.1/foo/storage/rotary": collection.handler()
   });
@@ -777,17 +777,17 @@ add_test(function test_processIncoming_p
     return [records[0].id, records[1].id];
   };
 
   // Create a batch of server side records.
   let collection = new ServerCollection();
   for (var i = 0; i < NUMBER_OF_RECORDS; i++) {
     let id = 'record-no-' + i;
     let payload = encryptPayload({id: id, denomination: "Record No. " + i});
-    collection.wbos[id] = new ServerWBO(id, payload);
+    collection.insert(id, payload);
   }
 
   let meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL));
   meta_global.payload.engines = {rotary: {version: engine.version,
                                          syncID: engine.syncID}};
   let server = sync_httpd_setup({
       "/1.1/foo/storage/rotary": collection.handler()
   });
@@ -852,17 +852,17 @@ add_test(function test_processIncoming_f
   // Let's create three and a bit batches worth of server side records.
   let collection = new ServerCollection();
   const NUMBER_OF_RECORDS = MOBILE_BATCH_SIZE * 3 + 5;
   for (let i = 0; i < NUMBER_OF_RECORDS; i++) {
     let id = 'record-no-' + i;
     let payload = encryptPayload({id: id, denomination: "Record No. " + id});
     let wbo = new ServerWBO(id, payload);
     wbo.modified = Date.now()/1000 + 60 * (i - MOBILE_BATCH_SIZE * 3);
-    collection.wbos[id] = wbo;
+    collection.insertWBO(wbo);
   }
 
   // Engine that batches but likes to throw on a couple of records,
   // two in each batch: the even ones fail in reconcile, the odd ones
   // in applyIncoming.
   const BOGUS_RECORDS = ["record-no-" + 42,
                          "record-no-" + 23,
                          "record-no-" + (42 + MOBILE_BATCH_SIZE),
@@ -981,26 +981,26 @@ add_test(function test_processIncoming_d
 
   let syncTesting = new SyncTestingInfrastructure();
   Svc.Prefs.set("clusterURL", "http://localhost:8080/");
   Svc.Prefs.set("username", "foo");
 
   // Some good and some bogus records. One doesn't contain valid JSON,
   // the other will throw during decrypt.
   let collection = new ServerCollection();
-  collection.wbos.flying = new ServerWBO(
+  collection._wbos.flying = new ServerWBO(
       'flying', encryptPayload({id: 'flying',
                                 denomination: "LNER Class A3 4472"}));
-  collection.wbos.nojson = new ServerWBO("nojson", "This is invalid JSON");
-  collection.wbos.nojson2 = new ServerWBO("nojson2", "This is invalid JSON");
-  collection.wbos.scotsman = new ServerWBO(
+  collection._wbos.nojson = new ServerWBO("nojson", "This is invalid JSON");
+  collection._wbos.nojson2 = new ServerWBO("nojson2", "This is invalid JSON");
+  collection._wbos.scotsman = new ServerWBO(
       'scotsman', encryptPayload({id: 'scotsman',
                                   denomination: "Flying Scotsman"}));
-  collection.wbos.nodecrypt = new ServerWBO("nodecrypt", "Decrypt this!");
-  collection.wbos.nodecrypt2 = new ServerWBO("nodecrypt2", "Decrypt this!");
+  collection._wbos.nodecrypt = new ServerWBO("nodecrypt", "Decrypt this!");
+  collection._wbos.nodecrypt2 = new ServerWBO("nodecrypt2", "Decrypt this!");
 
   // Patch the fake crypto service to throw on the record above.
   Svc.Crypto._decrypt = Svc.Crypto.decrypt;
   Svc.Crypto.decrypt = function (ciphertext) {
     if (ciphertext == "Decrypt this!") {
       throw "Derp! Cipher finalized failed. Im ur crypto destroyin ur recordz.";
     }
     return this._decrypt.apply(this, arguments);
@@ -1028,17 +1028,17 @@ add_test(function test_processIncoming_d
     let observerSubject;
     let observerData;
     Svc.Obs.add("weave:engine:sync:applied", function onApplied(subject, data) {
       Svc.Obs.remove("weave:engine:sync:applied", onApplied);
       observerSubject = subject;
       observerData = data;
     });
 
-    engine.lastSync = collection.wbos.nojson.modified - 1;
+    engine.lastSync = collection.wbo("nojson").modified - 1;
     engine.sync();
 
     do_check_eq(engine.previousFailed.length, 4);
     do_check_eq(engine.previousFailed[0], "nojson");
     do_check_eq(engine.previousFailed[1], "nojson2");
     do_check_eq(engine.previousFailed[2], "nodecrypt");
     do_check_eq(engine.previousFailed[3], "nodecrypt2");
 
@@ -1055,23 +1055,23 @@ add_test(function test_processIncoming_d
 
 add_test(function test_uploadOutgoing_toEmptyServer() {
   _("SyncEngine._uploadOutgoing uploads new records to server");
 
   let syncTesting = new SyncTestingInfrastructure();
   Svc.Prefs.set("clusterURL", "http://localhost:8080/");
   Svc.Prefs.set("username", "foo");
   let collection = new ServerCollection();
-  collection.wbos.flying = new ServerWBO('flying');
-  collection.wbos.scotsman = new ServerWBO('scotsman');
+  collection._wbos.flying = new ServerWBO('flying');
+  collection._wbos.scotsman = new ServerWBO('scotsman');
 
   let server = sync_httpd_setup({
       "/1.1/foo/storage/rotary": collection.handler(),
-      "/1.1/foo/storage/rotary/flying": collection.wbos.flying.handler(),
-      "/1.1/foo/storage/rotary/scotsman": collection.wbos.scotsman.handler()
+      "/1.1/foo/storage/rotary/flying": collection.wbo("flying").handler(),
+      "/1.1/foo/storage/rotary/scotsman": collection.wbo("scotsman").handler()
   });
   generateNewKeys();
 
   let engine = makeRotaryEngine();
   engine.lastSync = 123; // needs to be non-zero so that tracker is queried
   engine._store.items = {flying: "LNER Class A3 4472",
                          scotsman: "Flying Scotsman"};
   // Mark one of these records as changed 
@@ -1080,52 +1080,52 @@ add_test(function test_uploadOutgoing_to
   let meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL));
   meta_global.payload.engines = {rotary: {version: engine.version,
                                          syncID: engine.syncID}};
 
   try {
 
     // Confirm initial environment
     do_check_eq(engine.lastSyncLocal, 0);
-    do_check_eq(collection.wbos.flying.payload, undefined);
-    do_check_eq(collection.wbos.scotsman.payload, undefined);
+    do_check_eq(collection.payload("flying"), undefined);
+    do_check_eq(collection.payload("scotsman"), undefined);
 
     engine._syncStartup();
     engine._uploadOutgoing();
 
     // Local timestamp has been set.
     do_check_true(engine.lastSyncLocal > 0);
 
     // Ensure the marked record ('scotsman') has been uploaded and is
     // no longer marked.
-    do_check_eq(collection.wbos.flying.payload, undefined);
-    do_check_true(!!collection.wbos.scotsman.payload);
-    do_check_eq(JSON.parse(collection.wbos.scotsman.data.ciphertext).id,
-                'scotsman');
-    do_check_eq(engine._tracker.changedIDs['scotsman'], undefined);
+    do_check_eq(collection.payload("flying"), undefined);
+    do_check_true(!!collection.payload("scotsman"));
+    do_check_eq(JSON.parse(collection.wbo("scotsman").data.ciphertext).id,
+                "scotsman");
+    do_check_eq(engine._tracker.changedIDs["scotsman"], undefined);
 
     // The 'flying' record wasn't marked so it wasn't uploaded
-    do_check_eq(collection.wbos.flying.payload, undefined);
+    do_check_eq(collection.payload("flying"), undefined);
 
   } finally {
     cleanAndGo(server);
   }
 });
 
 
 add_test(function test_uploadOutgoing_failed() {
   _("SyncEngine._uploadOutgoing doesn't clear the tracker of objects that failed to upload.");
 
   let syncTesting = new SyncTestingInfrastructure();
   Svc.Prefs.set("clusterURL", "http://localhost:8080/");
   Svc.Prefs.set("username", "foo");
   let collection = new ServerCollection();
   // We only define the "flying" WBO on the server, not the "scotsman"
   // and "peppercorn" ones.
-  collection.wbos.flying = new ServerWBO('flying');
+  collection._wbos.flying = new ServerWBO('flying');
 
   let server = sync_httpd_setup({
       "/1.1/foo/storage/rotary": collection.handler()
   });
 
   let engine = makeRotaryEngine();
   engine.lastSync = 123; // needs to be non-zero so that tracker is queried
   engine._store.items = {flying: "LNER Class A3 4472",
@@ -1142,29 +1142,29 @@ add_test(function test_uploadOutgoing_fa
   let meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL));
   meta_global.payload.engines = {rotary: {version: engine.version,
                                          syncID: engine.syncID}};
 
   try {
 
     // Confirm initial environment
     do_check_eq(engine.lastSyncLocal, 0);
-    do_check_eq(collection.wbos.flying.payload, undefined);
+    do_check_eq(collection.payload("flying"), undefined);
     do_check_eq(engine._tracker.changedIDs['flying'], FLYING_CHANGED);
     do_check_eq(engine._tracker.changedIDs['scotsman'], SCOTSMAN_CHANGED);
     do_check_eq(engine._tracker.changedIDs['peppercorn'], PEPPERCORN_CHANGED);
 
     engine.enabled = true;
     engine.sync();
 
     // Local timestamp has been set.
     do_check_true(engine.lastSyncLocal > 0);
 
     // Ensure the 'flying' record has been uploaded and is no longer marked.
-    do_check_true(!!collection.wbos.flying.payload);
+    do_check_true(!!collection.payload("flying"));
     do_check_eq(engine._tracker.changedIDs['flying'], undefined);
 
     // The 'scotsman' and 'peppercorn' records couldn't be uploaded so
     // they weren't cleared from the tracker.
     do_check_eq(engine._tracker.changedIDs['scotsman'], SCOTSMAN_CHANGED);
     do_check_eq(engine._tracker.changedIDs['peppercorn'], PEPPERCORN_CHANGED);
 
   } finally {
@@ -1191,41 +1191,41 @@ add_test(function test_uploadOutgoing_MA
   }(collection.post));
 
   // Create a bunch of records (and server side handlers)
   let engine = makeRotaryEngine();
   for (var i = 0; i < 234; i++) {
     let id = 'record-no-' + i;
     engine._store.items[id] = "Record No. " + i;
     engine._tracker.addChangedID(id, 0);
-    collection.wbos[id] = new ServerWBO(id);
+    collection.insert(id);
   }
 
   let meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL));
   meta_global.payload.engines = {rotary: {version: engine.version,
                                          syncID: engine.syncID}};
 
   let server = sync_httpd_setup({
       "/1.1/foo/storage/rotary": collection.handler()
   });
 
   try {
 
-    // Confirm initial environment
+    // Confirm initial environment.
     do_check_eq(noOfUploads, 0);
 
     engine._syncStartup();
     engine._uploadOutgoing();
 
-    // Ensure all records have been uploaded
+    // Ensure all records have been uploaded.
     for (i = 0; i < 234; i++) {
-      do_check_true(!!collection.wbos['record-no-'+i].payload);
+      do_check_true(!!collection.payload('record-no-' + i));
     }
 
-    // Ensure that the uploads were performed in batches of MAX_UPLOAD_RECORDS
+    // Ensure that the uploads were performed in batches of MAX_UPLOAD_RECORDS.
     do_check_eq(noOfUploads, Math.ceil(234/MAX_UPLOAD_RECORDS));
 
   } finally {
     cleanAndGo(server);
   }
 });
 
 
@@ -1246,40 +1246,40 @@ add_test(function test_syncFinish_noDele
 
 add_test(function test_syncFinish_deleteByIds() {
   _("SyncEngine._syncFinish deletes server records slated for deletion (list of record IDs).");
 
   let syncTesting = new SyncTestingInfrastructure();
   Svc.Prefs.set("clusterURL", "http://localhost:8080/");
   Svc.Prefs.set("username", "foo");
   let collection = new ServerCollection();
-  collection.wbos.flying = new ServerWBO(
+  collection._wbos.flying = new ServerWBO(
       'flying', encryptPayload({id: 'flying',
                                 denomination: "LNER Class A3 4472"}));
-  collection.wbos.scotsman = new ServerWBO(
+  collection._wbos.scotsman = new ServerWBO(
       'scotsman', encryptPayload({id: 'scotsman',
                                   denomination: "Flying Scotsman"}));
-  collection.wbos.rekolok = new ServerWBO(
+  collection._wbos.rekolok = new ServerWBO(
       'rekolok', encryptPayload({id: 'rekolok',
                                 denomination: "Rekonstruktionslokomotive"}));
 
   let server = httpd_setup({
       "/1.1/foo/storage/rotary": collection.handler()
   });
 
   let engine = makeRotaryEngine();
   try {
     engine._delete = {ids: ['flying', 'rekolok']};
     engine._syncFinish();
 
     // The 'flying' and 'rekolok' records were deleted while the
     // 'scotsman' one wasn't.
-    do_check_eq(collection.wbos.flying.payload, undefined);
-    do_check_true(!!collection.wbos.scotsman.payload);
-    do_check_eq(collection.wbos.rekolok.payload, undefined);
+    do_check_eq(collection.payload("flying"), undefined);
+    do_check_true(!!collection.payload("scotsman"));
+    do_check_eq(collection.payload("rekolok"), undefined);
 
     // The deletion todo list has been reset.
     do_check_eq(engine._delete.ids, undefined);
 
   } finally {
     cleanAndGo(server);
   }
 });
@@ -1304,17 +1304,17 @@ add_test(function test_syncFinish_delete
 
   // Create a bunch of records on the server
   let now = Date.now();
   for (var i = 0; i < 234; i++) {
     let id = 'record-no-' + i;
     let payload = encryptPayload({id: id, denomination: "Record No. " + i});
     let wbo = new ServerWBO(id, payload);
     wbo.modified = now / 1000 - 60 * (i + 110);
-    collection.wbos[id] = wbo;
+    collection.insertWBO(wbo);
   }
 
   let server = httpd_setup({
       "/1.1/foo/storage/rotary": collection.handler()
   });
 
   let engine = makeRotaryEngine();
   try {
@@ -1333,19 +1333,19 @@ add_test(function test_syncFinish_delete
 
     engine._syncFinish();
 
     // Ensure that the appropriate server data has been wiped while
     // preserving records 90 thru 200.
     for (i = 0; i < 234; i++) {
       let id = 'record-no-' + i;
       if (i <= 90 || i >= 100) {
-        do_check_eq(collection.wbos[id].payload, undefined);
+        do_check_eq(collection.payload(id), undefined);
       } else {
-        do_check_true(!!collection.wbos[id].payload);
+        do_check_true(!!collection.payload(id));
       }
     }
 
     // The deletion was done in batches
     do_check_eq(noOfUploads, 2 + 1);
 
     // The deletion todo list has been reset.
     do_check_eq(engine._delete.ids, undefined);
@@ -1385,18 +1385,19 @@ add_test(function test_sync_partialUploa
   }(collection.post));
 
   // Create a bunch of records (and server side handlers)
   for (let i = 0; i < 234; i++) {
     let id = 'record-no-' + i;
     engine._store.items[id] = "Record No. " + i;
     engine._tracker.addChangedID(id, i);
     // Let two items in the first upload batch fail.
-    if ((i != 23) && (i != 42))
-      collection.wbos[id] = new ServerWBO(id);
+    if ((i != 23) && (i != 42)) {
+      collection.insert(id);
+    }
   }
 
   let meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL));
   meta_global.payload.engines = {rotary: {version: engine.version,
                                          syncID: engine.syncID}};
 
   try {
 
@@ -1434,17 +1435,17 @@ add_test(function test_canDecrypt_noCryp
   let syncTesting = new SyncTestingInfrastructure();
   Svc.Prefs.set("clusterURL", "http://localhost:8080/");
   Svc.Prefs.set("username", "foo");
 
   // Wipe CollectionKeys so we can test the desired scenario.
   CollectionKeys.clear();
 
   let collection = new ServerCollection();
-  collection.wbos.flying = new ServerWBO(
+  collection._wbos.flying = new ServerWBO(
       'flying', encryptPayload({id: 'flying',
                                 denomination: "LNER Class A3 4472"}));
 
   let server = sync_httpd_setup({
       "/1.1/foo/storage/rotary": collection.handler()
   });
 
   let engine = makeRotaryEngine();
@@ -1462,17 +1463,17 @@ add_test(function test_canDecrypt_true()
   let syncTesting = new SyncTestingInfrastructure();
   Svc.Prefs.set("clusterURL", "http://localhost:8080/");
   Svc.Prefs.set("username", "foo");
 
   // Set up CollectionKeys, as service.js does.
   generateNewKeys();
   
   let collection = new ServerCollection();
-  collection.wbos.flying = new ServerWBO(
+  collection._wbos.flying = new ServerWBO(
       'flying', encryptPayload({id: 'flying',
                                 denomination: "LNER Class A3 4472"}));
 
   let server = sync_httpd_setup({
       "/1.1/foo/storage/rotary": collection.handler()
   });
 
   let engine = makeRotaryEngine();
@@ -1495,17 +1496,17 @@ add_test(function test_syncapplied_obser
 
   let engine = makeRotaryEngine();
 
   // Create a batch of server side records.
   let collection = new ServerCollection();
   for (var i = 0; i < NUMBER_OF_RECORDS; i++) {
     let id = 'record-no-' + i;
     let payload = encryptPayload({id: id, denomination: "Record No. " + id});
-    collection.wbos[id] = new ServerWBO(id, payload);
+    collection.insert(id, payload);
   }
 
   let meta_global = Records.set(engine.metaURL, new WBORecord(engine.metaURL));
   meta_global.payload.engines = {rotary: {version: engine.version,
                                          syncID: engine.syncID}};
   let server = httpd_setup({
     "/1.1/foo/storage/rotary": collection.handler()
   });
--- a/services/sync/tests/unit/test_syncscheduler.js
+++ b/services/sync/tests/unit/test_syncscheduler.js
@@ -573,8 +573,124 @@ add_test(function test_sync_failed_parti
   do_check_eq(SyncScheduler._syncErrors, 0);
   do_check_true(SyncScheduler.nextSync <= (Date.now() + SyncScheduler.activeInterval));
   do_check_true(SyncScheduler.syncTimer.delay <= SyncScheduler.activeInterval);
 
   Status.resetSync();
   Service.startOver();
   server.stop(run_next_test);
 });
+
+add_test(function test_sync_X_Weave_Backoff() {
+  let server = sync_httpd_setup();
+  setUp();
+
+  // Use an odd value on purpose so that it doesn't happen to coincide with one
+  // of the sync intervals.
+  const BACKOFF = 7337;
+
+  // Extend info/collections so that we can put it into server maintenance mode.
+  const INFO_COLLECTIONS = "/1.1/johndoe/info/collections";
+  let infoColl = server._handler._overridePaths[INFO_COLLECTIONS];
+  let serverBackoff = false;
+  function infoCollWithBackoff(request, response) {
+    if (serverBackoff) {
+      response.setHeader("X-Weave-Backoff", "" + BACKOFF);
+    }
+    infoColl(request, response);
+  }
+  server.registerPathHandler(INFO_COLLECTIONS, infoCollWithBackoff);
+
+  // Pretend we have two clients so that the regular sync interval is
+  // sufficiently low.
+  Clients._store.create({id: "foo", cleartext: "bar"});
+  let rec = Clients._store.createRecord("foo", "clients");
+  rec.encrypt();
+  rec.upload(Clients.engineURL + rec.id);
+
+  // Sync once to log in and get everything set up. Let's verify our initial
+  // values.
+  Service.sync();
+  do_check_eq(Status.backoffInterval, 0);
+  do_check_eq(Status.minimumNextSync, 0);
+  do_check_eq(SyncScheduler.syncInterval, SyncScheduler.activeInterval);
+  do_check_true(SyncScheduler.nextSync <=
+                Date.now() + SyncScheduler.syncInterval);
+  // Sanity check that we picked the right value for BACKOFF:
+  do_check_true(SyncScheduler.syncInterval < BACKOFF * 1000);
+
+  // Turn on server maintenance and sync again.
+  serverBackoff = true;
+  Service.sync();
+
+  do_check_true(Status.backoffInterval >= BACKOFF * 1000);
+  // Allowing 1 second worth of of leeway between when Status.minimumNextSync
+  // was set and when this line gets executed.
+  let minimumExpectedDelay = (BACKOFF - 1) * 1000;
+  do_check_true(Status.minimumNextSync >= Date.now() + minimumExpectedDelay);
+
+  // Verify that the next sync is actually going to wait that long.
+  do_check_true(SyncScheduler.nextSync >= Date.now() + minimumExpectedDelay);
+  do_check_true(SyncScheduler.syncTimer.delay >= minimumExpectedDelay);
+
+  Service.startOver();
+  server.stop(run_next_test);
+});
+
+add_test(function test_sync_503_Retry_After() {
+  let server = sync_httpd_setup();
+  setUp();
+
+  // Use an odd value on purpose so that it doesn't happen to coincide with one
+  // of the sync intervals.
+  const BACKOFF = 7337;
+
+  // Extend info/collections so that we can put it into server maintenance mode.
+  const INFO_COLLECTIONS = "/1.1/johndoe/info/collections";
+  let infoColl = server._handler._overridePaths[INFO_COLLECTIONS];
+  let serverMaintenance = false;
+  function infoCollWithMaintenance(request, response) {
+    if (!serverMaintenance) {
+      infoColl(request, response);
+      return;
+    }
+    response.setHeader("Retry-After", "" + BACKOFF);
+    response.setStatusLine(request.httpVersion, 503, "Service Unavailable");
+  }
+  server.registerPathHandler(INFO_COLLECTIONS, infoCollWithMaintenance);
+
+  // Pretend we have two clients so that the regular sync interval is
+  // sufficiently low.
+  Clients._store.create({id: "foo", cleartext: "bar"});
+  let rec = Clients._store.createRecord("foo", "clients");
+  rec.encrypt();
+  rec.upload(Clients.engineURL + rec.id);
+
+  // Sync once to log in and get everything set up. Let's verify our initial
+  // values.
+  Service.sync();
+  do_check_false(Status.enforceBackoff);
+  do_check_eq(Status.backoffInterval, 0);
+  do_check_eq(Status.minimumNextSync, 0);
+  do_check_eq(SyncScheduler.syncInterval, SyncScheduler.activeInterval);
+  do_check_true(SyncScheduler.nextSync <=
+                Date.now() + SyncScheduler.syncInterval);
+  // Sanity check that we picked the right value for BACKOFF:
+  do_check_true(SyncScheduler.syncInterval < BACKOFF * 1000);
+
+  // Turn on server maintenance and sync again.
+  serverMaintenance = true;
+  Service.sync();
+
+  do_check_true(Status.enforceBackoff);
+  do_check_true(Status.backoffInterval >= BACKOFF * 1000);
+  // Allowing 1 second worth of of leeway between when Status.minimumNextSync
+  // was set and when this line gets executed.
+  let minimumExpectedDelay = (BACKOFF - 1) * 1000;
+  do_check_true(Status.minimumNextSync >= Date.now() + minimumExpectedDelay);
+
+  // Verify that the next sync is actually going to wait that long.
+  do_check_true(SyncScheduler.nextSync >= Date.now() + minimumExpectedDelay);
+  do_check_true(SyncScheduler.syncTimer.delay >= minimumExpectedDelay);
+
+  Service.startOver();
+  server.stop(run_next_test);
+});
--- a/services/sync/tests/unit/xpcshell.ini
+++ b/services/sync/tests/unit/xpcshell.ini
@@ -34,16 +34,17 @@ skip-if = os == "android"
 # Bug 676978: test hangs on Android (see also testing/xpcshell/xpcshell.ini)
 skip-if = (os == "mac" && debug) || os == "android"
 [test_forms_store.js]
 [test_forms_tracker.js]
 [test_history_engine.js]
 [test_history_store.js]
 [test_history_tracker.js]
 [test_hmac_error.js]
+[test_httpd_sync_server.js]
 [test_interval_triggers.js]
 [test_jpakeclient.js]
 # Bug 618233: this test produces random failures on Windows 7.
 # Bug 676978: test hangs on Android (see also testing/xpcshell/xpcshell.ini)
 skip-if = os == "win" || os == "android"
 [test_keys.js]
 [test_load_modules.js]
 [test_log4moz.js]
@@ -56,16 +57,17 @@ skip-if = os == "win" || os == "android"
 [test_records_crypto.js]
 [test_records_crypto_generateEntry.js]
 [test_records_wbo.js]
 [test_resource.js]
 [test_resource_async.js]
 [test_resource_ua.js]
 [test_restrequest.js]
 [test_score_triggers.js]
+[test_sendcredentials_controller.js]
 [test_service_attributes.js]
 [test_service_changePassword.js]
 [test_service_checkAccount.js]
 [test_service_cluster.js]
 [test_service_createAccount.js]
 [test_service_detect_upgrade.js]
 [test_service_getStorageInfo.js]
 [test_service_login.js]
--- a/services/sync/tps/extensions/mozmill/install.rdf
+++ b/services/sync/tps/extensions/mozmill/install.rdf
@@ -8,17 +8,17 @@
      <em:creator>Adam Christian</em:creator>
      <em:description>A testing extension based on the Windmill Testing Framework client source</em:description>
      <em:unpack>true</em:unpack>
      <em:targetApplication>
        <!-- Firefox -->
        <Description>
          <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id>
          <em:minVersion>3.5</em:minVersion>
-         <em:maxVersion>9.*</em:maxVersion>
+         <em:maxVersion>12.*</em:maxVersion>
        </Description>
      </em:targetApplication>
      <em:targetApplication>
        <!-- Thunderbird -->
        <Description>
          <em:id>{3550f703-e582-4d05-9a08-453d09bdfdc6}</em:id>
          <em:minVersion>3.0a1pre</em:minVersion>
          <em:maxVersion>9.*</em:maxVersion>
--- a/services/sync/tps/extensions/tps/install.rdf
+++ b/services/sync/tps/extensions/tps/install.rdf
@@ -5,17 +5,17 @@
     <em:id>tps@mozilla.org</em:id>
     <em:version>0.2</em:version>
 
     <em:targetApplication>
       <!-- Firefox -->
       <Description>
         <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id>
         <em:minVersion>3.5.0</em:minVersion>
-        <em:maxVersion>10.0.*</em:maxVersion>
+        <em:maxVersion>12.0.*</em:maxVersion>
       </Description>
     </em:targetApplication>
 
     <!-- front-end metadata -->
     <em:name>TPS</em:name>
     <em:description>Sync test extension</em:description>
     <em:creator>Jonathan Griffin</em:creator>
     <em:homepageURL>http://www.mozilla.org/</em:homepageURL>