Bug 1153504 - Add per-origin push quotas. r=nsm,mt,markh
authorKit Cambridge <kcambridge@mozilla.com>
Thu, 25 Jun 2015 14:52:57 -0700
changeset 252938 340a785af78cea73e1f9b7d5980499ea5b97ddaf
parent 252937 3d389e79aef66b911b123e1e78d5a16bda9fbfe8
child 252939 90f2847947753a80a396213f29acd8aeb2900b3e
push id29052
push userryanvm@gmail.com
push dateWed, 15 Jul 2015 17:20:06 +0000
treeherdermozilla-central@04fa2c708b30 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnsm, mt, markh
bugs1153504
milestone42.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1153504 - Add per-origin push quotas. r=nsm,mt,markh
dom/push/PushDB.jsm
dom/push/PushNotificationService.js
dom/push/PushRecord.jsm
dom/push/PushService.jsm
dom/push/PushServiceHttp2.jsm
dom/push/PushServiceWebSocket.jsm
dom/push/moz.build
dom/push/test/worker.js
dom/push/test/xpcshell/head.js
dom/push/test/xpcshell/test_clearAll_successful.js
dom/push/test/xpcshell/test_notification_ack.js
dom/push/test/xpcshell/test_notification_duplicate.js
dom/push/test/xpcshell/test_notification_error.js
dom/push/test/xpcshell/test_notification_http2.js
dom/push/test/xpcshell/test_notification_incomplete.js
dom/push/test/xpcshell/test_notification_version_string.js
dom/push/test/xpcshell/test_quota_exceeded.js
dom/push/test/xpcshell/test_quota_observer.js
dom/push/test/xpcshell/test_register_5xxCode_http2.js
dom/push/test/xpcshell/test_register_case.js
dom/push/test/xpcshell/test_register_flush.js
dom/push/test/xpcshell/test_register_invalid_channel.js
dom/push/test/xpcshell/test_register_invalid_endpoint.js
dom/push/test/xpcshell/test_register_invalid_json.js
dom/push/test/xpcshell/test_register_no_id.js
dom/push/test/xpcshell/test_register_request_queue.js
dom/push/test/xpcshell/test_register_rollback.js
dom/push/test/xpcshell/test_register_success.js
dom/push/test/xpcshell/test_register_timeout.js
dom/push/test/xpcshell/test_register_wrong_id.js
dom/push/test/xpcshell/test_register_wrong_type.js
dom/push/test/xpcshell/test_registration_error.js
dom/push/test/xpcshell/test_registration_none.js
dom/push/test/xpcshell/test_registration_success.js
dom/push/test/xpcshell/test_registration_success_http2.js
dom/push/test/xpcshell/test_resubscribe_4xxCode_http2.js
dom/push/test/xpcshell/test_resubscribe_5xxCode_http2.js
dom/push/test/xpcshell/test_resubscribe_listening_for_msg_error_http2.js
dom/push/test/xpcshell/test_unregister_empty_scope.js
dom/push/test/xpcshell/test_unregister_error.js
dom/push/test/xpcshell/test_unregister_invalid_json.js
dom/push/test/xpcshell/test_unregister_not_found.js
dom/push/test/xpcshell/test_unregister_success.js
dom/push/test/xpcshell/test_unregister_success_http2.js
dom/push/test/xpcshell/test_webapps_cleardata.js
dom/push/test/xpcshell/xpcshell.ini
modules/libpref/init/all.js
--- a/dom/push/PushDB.jsm
+++ b/dom/push/PushDB.jsm
@@ -18,53 +18,103 @@ const Cu = Components.utils;
 Cu.import("resource://gre/modules/IndexedDBHelper.jsm");
 Cu.import("resource://gre/modules/Preferences.jsm");
 Cu.importGlobalProperties(["indexedDB"]);
 
 const prefs = new Preferences("dom.push.");
 
 this.EXPORTED_SYMBOLS = ["PushDB"];
 
-this.PushDB = function PushDB(dbName, dbVersion, dbStoreName, schemaFunction) {
+this.PushDB = function PushDB(dbName, dbVersion, dbStoreName, keyPath, model) {
   debug("PushDB()");
   this._dbStoreName = dbStoreName;
-  this._schemaFunction = schemaFunction;
+  this._keyPath = keyPath;
+  this._model = model;
 
   // set the indexeddb database
   this.initDBHelper(dbName, dbVersion,
                     [dbStoreName]);
   gDebuggingEnabled = prefs.get("debug");
   prefs.observe("debug", this);
 };
 
 this.PushDB.prototype = {
   __proto__: IndexedDBHelper.prototype,
 
+  toPushRecord: function(record) {
+    if (!record) {
+      return;
+    }
+    return new this._model(record);
+  },
+
+  isValidRecord: function(record) {
+    return record && typeof record.scope == "string" &&
+           typeof record.originAttributes == "string" &&
+           record.quota >= 0 &&
+           typeof record[this._keyPath] == "string";
+  },
+
   upgradeSchema: function(aTransaction, aDb, aOldVersion, aNewVersion) {
-    if (this._schemaFunction) {
-      this._schemaFunction(aTransaction, aDb, aOldVersion, aNewVersion, this);
+    if (aOldVersion <= 3) {
+      //XXXnsm We haven't shipped Push during this upgrade, so I'm just going to throw old
+      //registrations away without even informing the app.
+      if (aDb.objectStoreNames.contains(this._dbStoreName)) {
+        aDb.deleteObjectStore(this._dbStoreName);
+      }
+
+      let objectStore = aDb.createObjectStore(this._dbStoreName,
+                                              { keyPath: this._keyPath });
+
+      // index to fetch records based on endpoints. used by unregister
+      objectStore.createIndex("pushEndpoint", "pushEndpoint", { unique: true });
+
+      // index to fetch records by identifiers.
+      // In the current security model, the originAttributes distinguish between
+      // different 'apps' on the same origin. Since ServiceWorkers are
+      // same-origin to the scope they are registered for, the attributes and
+      // scope are enough to reconstruct a valid principal.
+      objectStore.createIndex("identifiers", ["scope", "originAttributes"], { unique: true });
+      objectStore.createIndex("originAttributes", "originAttributes", { unique: false });
+    }
+
+    if (aOldVersion < 4) {
+      let objectStore = aTransaction.objectStore(this._dbStoreName);
+
+      // index to fetch active and expired registrations.
+      objectStore.createIndex("quota", "quota", { unique: false });
     }
   },
 
   /*
    * @param aRecord
    *        The record to be added.
    */
 
   put: function(aRecord) {
     debug("put()" + JSON.stringify(aRecord));
+    if (!this.isValidRecord(aRecord)) {
+      return Promise.reject(new TypeError(
+        "Scope, originAttributes, and quota are required! " +
+          JSON.stringify(aRecord)
+        )
+      );
+    }
 
     return new Promise((resolve, reject) =>
       this.newTxn(
         "readwrite",
         this._dbStoreName,
-        function txnCb(aTxn, aStore) {
-          aStore.put(aRecord).onsuccess = function setTxnResult(aEvent) {
+        (aTxn, aStore) => {
+          aTxn.result = undefined;
+
+          aStore.put(aRecord).onsuccess = aEvent => {
             debug("Request successful. Updated record ID: " +
                   aEvent.target.result);
+            aTxn.result = this.toPushRecord(aRecord);
           };
         },
         resolve,
         reject
       )
     );
   },
 
@@ -106,43 +156,43 @@ this.PushDB.prototype = {
 
   getByPushEndpoint: function(aPushEndpoint) {
     debug("getByPushEndpoint()");
 
     return new Promise((resolve, reject) =>
       this.newTxn(
         "readonly",
         this._dbStoreName,
-        function txnCb(aTxn, aStore) {
+        (aTxn, aStore) => {
           aTxn.result = undefined;
 
           let index = aStore.index("pushEndpoint");
-          index.get(aPushEndpoint).onsuccess = function setTxnResult(aEvent) {
-            aTxn.result = aEvent.target.result;
+          index.get(aPushEndpoint).onsuccess = aEvent => {
+            aTxn.result = this.toPushRecord(aEvent.target.result);
             debug("Fetch successful " + aEvent.target.result);
           };
         },
         resolve,
         reject
       )
     );
   },
 
   getByKeyID: function(aKeyID) {
     debug("getByKeyID()");
 
     return new Promise((resolve, reject) =>
       this.newTxn(
         "readonly",
         this._dbStoreName,
-        function txnCb(aTxn, aStore) {
+        (aTxn, aStore) => {
           aTxn.result = undefined;
 
-          aStore.get(aKeyID).onsuccess = function setTxnResult(aEvent) {
-            aTxn.result = aEvent.target.result;
+          aStore.get(aKeyID).onsuccess = aEvent => {
+            aTxn.result = this.toPushRecord(aEvent.target.result);
             debug("Fetch successful " + aEvent.target.result);
           };
         },
         resolve,
         reject
       )
     );
   },
@@ -156,47 +206,48 @@ this.PushDB.prototype = {
                new TypeError("Scope and originAttributes are required! " +
                              JSON.stringify(aPageRecord)));
     }
 
     return new Promise((resolve, reject) =>
       this.newTxn(
         "readonly",
         this._dbStoreName,
-        function txnCb(aTxn, aStore) {
+        (aTxn, aStore) => {
           aTxn.result = undefined;
 
           let index = aStore.index("identifiers");
           let request = index.get(IDBKeyRange.only([aPageRecord.scope, aPageRecord.originAttributes]));
-          request.onsuccess = function setTxnResult(aEvent) {
-            aTxn.result = aEvent.target.result;
-          }
+          request.onsuccess = aEvent => {
+            aTxn.result = this.toPushRecord(aEvent.target.result);
+          };
         },
         resolve,
         reject
       )
     );
   },
 
   _getAllByKey: function(aKeyName, aKeyValue) {
     return new Promise((resolve, reject) =>
       this.newTxn(
         "readonly",
         this._dbStoreName,
-        function txnCb(aTxn, aStore) {
+        (aTxn, aStore) => {
           aTxn.result = undefined;
 
           let index = aStore.index(aKeyName);
           // It seems ok to use getAll here, since unlike contacts or other
           // high storage APIs, we don't expect more than a handful of
           // registrations per domain, and usually only one.
           let getAllReq = index.mozGetAll(aKeyValue);
-          getAllReq.onsuccess = function setTxnResult(aEvent) {
-            aTxn.result = aEvent.target.result;
-          }
+          getAllReq.onsuccess = aEvent => {
+            aTxn.result = aEvent.target.result.map(
+              record => this.toPushRecord(record));
+          };
         },
         resolve,
         reject
       )
     );
   },
 
   // aOriginAttributes must be a string!
@@ -209,20 +260,98 @@ this.PushDB.prototype = {
 
   getAllKeyIDs: function() {
     debug("getAllKeyIDs()");
 
     return new Promise((resolve, reject) =>
       this.newTxn(
         "readonly",
         this._dbStoreName,
-        function txnCb(aTxn, aStore) {
+        (aTxn, aStore) => {
           aTxn.result = undefined;
-          aStore.mozGetAll().onsuccess = function(event) {
-            aTxn.result = event.target.result;
+          aStore.mozGetAll().onsuccess = event => {
+            aTxn.result = event.target.result.map(
+              record => this.toPushRecord(record));
+          };
+        },
+        resolve,
+        reject
+      )
+    );
+  },
+
+  _getAllByPushQuota: function(range) {
+    debug("getAllByPushQuota()");
+
+    return new Promise((resolve, reject) =>
+      this.newTxn(
+        "readonly",
+        this._dbStoreName,
+        (aTxn, aStore) => {
+          aTxn.result = [];
+
+          let index = aStore.index("quota");
+          index.openCursor(range).onsuccess = event => {
+            let cursor = event.target.result;
+            if (cursor) {
+              aTxn.result.push(this.toPushRecord(cursor.value));
+              cursor.continue();
+            }
+          };
+        },
+        resolve,
+        reject
+      )
+    );
+  },
+
+  getAllUnexpired: function() {
+    debug("getAllUnexpired()");
+    return this._getAllByPushQuota(IDBKeyRange.lowerBound(1));
+  },
+
+  getAllExpired: function() {
+    debug("getAllExpired()");
+    return this._getAllByPushQuota(IDBKeyRange.only(0));
+  },
+
+  /**
+   * Updates an existing push registration.
+   *
+   * @param {String} aKeyID The registration ID.
+   * @param {Function} aUpdateFunc A function that receives the existing
+   *  registration record as its argument, and returns a new record. If the
+   *  function returns `null` or `undefined`, the record will not be updated.
+   *  If the record does not exist, the function will not be called.
+   * @returns {Promise} A promise resolved with either the updated record, or
+   *  `undefined` if the record was not updated.
+   */
+  update: function(aKeyID, aUpdateFunc) {
+    return new Promise((resolve, reject) =>
+      this.newTxn(
+        "readwrite",
+        this._dbStoreName,
+        (aTxn, aStore) => {
+          aStore.get(aKeyID).onsuccess = aEvent => {
+            aTxn.result = undefined;
+
+            let record = aEvent.target.result;
+            if (!record) {
+              debug("update: Key ID " + aKeyID + " does not exist");
+              return;
+            }
+            let newRecord = aUpdateFunc(this.toPushRecord(record));
+            if (!this.isValidRecord(newRecord)) {
+              debug("update: Ignoring invalid update for key ID " + aKeyID);
+              return;
+            }
+            aStore.put(newRecord).onsuccess = aEvent => {
+              debug("update: Update successful for key ID " + aKeyID);
+              aTxn.result = newRecord;
+            };
           };
         },
         resolve,
         reject
       )
     );
   },
 
--- a/dom/push/PushNotificationService.js
+++ b/dom/push/PushNotificationService.js
@@ -34,17 +34,21 @@ PushNotificationService.prototype = {
   contractID: "@mozilla.org/push/NotificationService;1",
 
   _xpcom_factory: XPCOMUtils.generateSingletonFactory(PushNotificationService),
   QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver,
                                          Ci.nsISupportsWeakReference,
                                          Ci.nsIPushNotificationService]),
 
   register: function register(scope, originAttributes) {
-    return PushService._register({scope, originAttributes});
+    return PushService._register({
+      scope: scope,
+      originAttributes: originAttributes,
+      maxQuota: Infinity,
+    });
   },
 
   unregister: function unregister(scope, originAttributes) {
     return PushService._unregister({scope, originAttributes});
   },
 
   registration: function registration(scope, originAttributes) {
     return PushService._registration({scope, originAttributes});
new file mode 100644
--- /dev/null
+++ b/dom/push/PushRecord.jsm
@@ -0,0 +1,183 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+const Cr = Components.results;
+
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
+                                  "resource://gre/modules/PlacesUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
+                                  "resource://gre/modules/PrivateBrowsingUtils.jsm");
+
+this.EXPORTED_SYMBOLS = ["PushRecord"];
+
+const prefs = new Preferences("dom.push.");
+
+// History transition types that can fire an `pushsubscriptionchange` event
+// when the user visits a site with expired push registrations. Visits only
+// count if the user sees the origin in the address bar. This excludes embedded
+// resources, downloads, and framed links.
+const QUOTA_REFRESH_TRANSITIONS_SQL = [
+  Ci.nsINavHistoryService.TRANSITION_LINK,
+  Ci.nsINavHistoryService.TRANSITION_TYPED,
+  Ci.nsINavHistoryService.TRANSITION_BOOKMARK,
+  Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT,
+  Ci.nsINavHistoryService.TRANSITION_REDIRECT_TEMPORARY
+].join(",");
+
+function PushRecord(props) {
+  this.pushEndpoint = props.pushEndpoint;
+  this.scope = props.scope;
+  this.origin = Services.io.newURI(this.scope, null, null).prePath;
+  this.originAttributes = props.originAttributes;
+  this.pushCount = props.pushCount || 0;
+  this.lastPush = props.lastPush || 0;
+  this.setQuota(props.quota);
+}
+
+PushRecord.prototype = {
+  setQuota(suggestedQuota) {
+    this.quota = (!isNaN(suggestedQuota) && suggestedQuota >= 0) ?
+                 suggestedQuota : prefs.get("maxQuotaPerSubscription");
+  },
+
+  updateQuota(lastVisit) {
+    if (this.isExpired() || !this.quotaApplies()) {
+      // Ignore updates if the registration is already expired, or isn't
+      // subject to quota.
+      return;
+    }
+    if (lastVisit < 0) {
+      // If the user cleared their history, but retained the push permission,
+      // mark the registration as expired.
+      this.quota = 0;
+      return;
+    }
+    let currentQuota;
+    if (lastVisit > this.lastPush) {
+      // If the user visited the site since the last time we received a
+      // notification, reset the quota.
+      let daysElapsed = (Date.now() - lastVisit) / 24 / 60 / 60 / 1000;
+      currentQuota = Math.min(
+        Math.round(8 * Math.pow(daysElapsed, -0.8)),
+        prefs.get("maxQuotaPerSubscription")
+      );
+    } else {
+      // The user hasn't visited the site since the last notification.
+      currentQuota = this.quota;
+    }
+    this.quota = Math.max(currentQuota - 1, 0);
+  },
+
+  receivedPush(lastVisit) {
+    this.updateQuota(lastVisit);
+    this.pushCount++;
+    this.lastPush = Date.now();
+  },
+
+  /**
+   * Queries the Places database for the last time a user visited the site
+   * associated with a push registration.
+   *
+   * @returns {Promise} A promise resolved with either the last time the user
+   *  visited the site, or `-Infinity` if the site is not in the user's history.
+   *  The time is expressed in milliseconds since Epoch.
+   */
+  getLastVisit() {
+    if (!this.quotaApplies() || this.isTabOpen()) {
+      // If the registration isn't subject to quota, or the user already
+      // has the site open, skip the Places query.
+      return Promise.resolve(Date.now());
+    }
+    return PlacesUtils.withConnectionWrapper("PushRecord.getLastVisit", db => {
+      // We're using a custom query instead of `nsINavHistoryQueryOptions`
+      // because the latter doesn't expose a way to filter by transition type:
+      // `setTransitions` performs a logical "and," but we want an "or." We
+      // also avoid an unneeded left join on `moz_favicons`, and an `ORDER BY`
+      // clause that emits a suboptimal index warning.
+      return db.executeCached(
+        `SELECT MAX(p.last_visit_date)
+         FROM moz_places p
+         INNER JOIN moz_historyvisits h ON p.id = h.place_id
+         WHERE (
+           p.url >= :urlLowerBound AND p.url <= :urlUpperBound AND
+           h.visit_type IN (${QUOTA_REFRESH_TRANSITIONS_SQL})
+         )
+        `,
+        {
+          // Restrict the query to all pages for this origin.
+          urlLowerBound: this.origin,
+          urlUpperBound: this.origin + "\x7f"
+        }
+      );
+    }).then(rows => {
+      if (!rows.length) {
+        return -Infinity;
+      }
+      // Places records times in microseconds.
+      let lastVisit = rows[0].getResultByIndex(0);
+      return lastVisit / 1000;
+    });
+  },
+
+  isTabOpen() {
+    let windows = Services.wm.getEnumerator("navigator:browser");
+    while (windows.hasMoreElements()) {
+      let window = windows.getNext();
+      if (window.closed || PrivateBrowsingUtils.isWindowPrivate(window)) {
+        continue;
+      }
+      // `gBrowser` on Desktop; `BrowserApp` on Fennec.
+      let tabs = window.gBrowser ? window.gBrowser.tabContainer.children :
+                 window.BrowserApp.tabs;
+      for (let tab of tabs) {
+        // `linkedBrowser` on Desktop; `browser` on Fennec.
+        let tabURI = (tab.linkedBrowser || tab.browser).currentURI;
+        if (tabURI.prePath == this.origin) {
+          return true;
+        }
+      }
+    }
+    return false;
+  },
+
+  quotaApplies() {
+    return Number.isFinite(this.quota);
+  },
+
+  isExpired() {
+    return this.quota === 0;
+  },
+
+  toRegistration() {
+    return {
+      pushEndpoint: this.pushEndpoint,
+      lastPush: this.lastPush,
+      pushCount: this.pushCount,
+    };
+  },
+
+  toRegister() {
+    return {
+      pushEndpoint: this.pushEndpoint,
+    };
+  },
+};
+
+// Mark the `origin` property as non-enumerable to avoid storing the
+// registration origin in IndexedDB.
+Object.defineProperty(PushRecord.prototype, "origin", {
+  configurable: true,
+  enumerable: false,
+  writable: true,
+});
--- a/dom/push/PushService.jsm
+++ b/dom/push/PushService.jsm
@@ -78,16 +78,17 @@ const UNINIT_EVENT = 3;
  * (PushServiceWebSocket) to communicate with the server and PushDB (IndexedDB)
  * for persistence.
  */
 this.PushService = {
   _service: null,
   _state: PUSH_SERVICE_UNINIT,
   _db: null,
   _options: null,
+  _alarmID: null,
 
   // When serverURI changes (this is used for testing), db is cleaned up and a
   // a new db is started. This events must be sequential.
   _serverURIProcessQueue: null,
   _serverURIProcessEnqueue: function(op) {
     if (!this._serverURIProcessQueue) {
       this._serverURIProcessQueue = Promise.resolve();
     }
@@ -176,21 +177,21 @@ this.PushService = {
       this._setState(PUSH_SERVICE_ACTIVE_OFFLINE);
     } else {
       if (this._state == PUSH_SERVICE_RUNNING) {
         // PushService was not in the offline state, but got notification to
         // go online (a offline notification has not been sent).
         // Disconnect first.
         this._service.disconnect();
       }
-      this._db.getAllKeyIDs()
-        .then(keyIDs => {
-          if (keyIDs.length > 0) {
+      this._db.getAllUnexpired()
+        .then(records => {
+          if (records.length > 0) {
             // if there are request waiting
-            this._service.connect(keyIDs);
+            this._service.connect(records);
           }
         });
       this._setState(PUSH_SERVICE_RUNNING);
     }
   },
 
   _changeStateConnectionEnabledEvent: function(enabled) {
     debug("changeStateConnectionEnabledEvent: " + enabled);
@@ -237,16 +238,20 @@ this.PushService = {
         } else if (aData == "dom.push.connection.enabled") {
           this._changeStateConnectionEnabledEvent(prefs.get("connection.enabled"));
 
         } else if (aData == "dom.push.debug") {
           gDebuggingEnabled = prefs.get("debug");
         }
         break;
 
+      case "idle-daily":
+        this._dropExpiredRegistrations();
+        break;
+
       case "webapps-clear-data":
         debug("webapps-clear-data");
 
         let data = aSubject
                      .QueryInterface(Ci.mozIApplicationClearPrivateDataParams);
         if (!data) {
           debug("webapps-clear-data: Failed to get information about " +
                 "application");
@@ -254,36 +259,36 @@ this.PushService = {
         }
 
         var originAttributes =
           ChromeUtils.originAttributesToSuffix({ appId: data.appId,
                                                  inBrowser: data.browserOnly });
         this._db.getAllByOriginAttributes(originAttributes)
           .then(records => {
             records.forEach(record => {
-              this._db.delete(this._service.getKeyFromRecord(record))
+              this._db.delete(record.keyID)
                 .then(_ => {
                   // courtesy, but don't establish a connection
                   // just for it
                   if (this._ws) {
                     debug("Had a connection, so telling the server");
-                    this._sendRequest("unregister", {channelID: records.channelID})
+                    this._sendRequest("unregister", {channelID: record.channelID})
                         .catch(function(e) {
                           debug("Unregister errored " + e);
                         });
                   }
                 }, err => {
-                  debug("webapps-clear-data: " + scope +
-                        " Could not delete entry " + records.channelID);
+                  debug("webapps-clear-data: " + record.scope +
+                        " Could not delete entry " + record.channelID);
 
                   // courtesy, but don't establish a connection
                   // just for it
                   if (this._ws) {
                     debug("Had a connection, so telling the server");
-                    this._sendRequest("unregister", {channelID: records.channelID})
+                    this._sendRequest("unregister", {channelID: record.channelID})
                         .catch(function(e) {
                           debug("Unregister errored " + e);
                         });
                   }
                   throw "Database error";
                 });
             });
           }, _ => {
@@ -432,20 +437,21 @@ this.PushService = {
         } catch(e) {}
       }
       if (!service) {
         this._setState(PUSH_SERVICE_INIT);
         return;
       }
 
       // Start service.
-      this._startService(service, uri, false, options);
-      // Before completing the activation check prefs. This will first check
-      // connection.enabled pref and then check offline state.
-      this._changeStateConnectionEnabledEvent(prefs.get("connection.enabled"));
+      this._startService(service, uri, false, options).then(_ => {
+        // Before completing the activation check prefs. This will first check
+        // connection.enabled pref and then check offline state.
+        this._changeStateConnectionEnabledEvent(prefs.get("connection.enabled"));
+      });
 
     } else {
       // This is only used for testing. Different tests require connecting to
       // slightly different URLs.
       prefs.observe("serverURL", this);
 
       this._serverURIProcessEnqueue(_ =>
         this._changeServerURL(prefs.get("serverURL"), STARTING_SERVICE_EVENT));
@@ -454,18 +460,16 @@ this.PushService = {
 
   _startObservers: function() {
     debug("startObservers");
 
     if (this._state != PUSH_SERVICE_ACTIVATING) {
       return;
     }
 
-    this._alarmID = null;
-
     Services.obs.addObserver(this, "webapps-clear-data", false);
 
     // On B2G the NetworkManager interface fires a network-active-changed
     // event.
     //
     // The "active network" is based on priority - i.e. Wi-Fi has higher
     // priority than data. The PushService should just use the preferred
     // network, and not care about all interface changes.
@@ -481,16 +485,19 @@ this.PushService = {
     // On B2G both events fire, one after the other, when the network goes
     // online, so we explicitly check for the presence of NetworkManager and
     // don't add an observer for offline-status-changed on B2G.
     this._networkStateChangeEventName = this.getNetworkStateChangeEventName();
     Services.obs.addObserver(this, this._networkStateChangeEventName, false);
 
     // Used to monitor if the user wishes to disable Push.
     prefs.observe("connection.enabled", this);
+
+    // Used to prune expired registrations and notify dormant service workers.
+    Services.obs.addObserver(this, "idle-daily", false);
   },
 
   _startService: function(service, serverURI, event, options = {}) {
     debug("startService");
 
     if (this._state != PUSH_SERVICE_ACTIVATING) {
       return;
     }
@@ -577,30 +584,31 @@ this.PushService = {
     if (this._state < PUSH_SERVICE_ACTIVATING) {
       return;
     }
 
     prefs.ignore("debug", this);
     prefs.ignore("connection.enabled", this);
 
     Services.obs.removeObserver(this, this._networkStateChangeEventName);
-    Services.obs.removeObserver(this, "webapps-clear-data", false);
+    Services.obs.removeObserver(this, "webapps-clear-data");
+    Services.obs.removeObserver(this, "idle-daily");
   },
 
   uninit: function() {
     debug("uninit()");
 
     if (this._state == PUSH_SERVICE_UNINIT) {
       return;
     }
 
     this._setState(PUSH_SERVICE_UNINIT);
 
     prefs.ignore("serverURL", this);
-    Services.obs.removeObserver(this, "xpcom-shutdown", false);
+    Services.obs.removeObserver(this, "xpcom-shutdown");
 
     this._serverURIProcessEnqueue(_ =>
             this._changeServerURL("", UNINIT_EVENT));
     debug("shutdown complete!");
   },
 
   /** |delay| should be in milliseconds. */
   setAlarm: function(delay) {
@@ -648,88 +656,118 @@ this.PushService = {
     }
   },
 
   dropRegistrations: function() {
     return this._notifyAllAppsRegister()
       .then(_ => this._db.drop());
   },
 
+  _notifySubscriptionChangeObservers: function(record) {
+    let globalMM = Cc['@mozilla.org/globalmessagemanager;1']
+                     .getService(Ci.nsIMessageListenerManager);
+    // Notify XPCOM observers.
+    Services.obs.notifyObservers(
+      null,
+      "push-subscription-change",
+      record.scope
+    );
+
+    let data = {
+      originAttributes: record.originAttributes,
+      scope: record.scope
+    };
+
+    globalMM.broadcastAsyncMessage('pushsubscriptionchange', data);
+  },
+
   // Fires a push-register system message to all applications that have
   // registration.
   _notifyAllAppsRegister: function() {
     debug("notifyAllAppsRegister()");
     // records are objects describing the registration as stored in IndexedDB.
-    return this._db.getAllKeyIDs()
-      .then(records => {
-        let globalMM = Cc['@mozilla.org/globalmessagemanager;1']
-                         .getService(Ci.nsIMessageListenerManager);
-        for (let record of records) {
-          // Notify XPCOM observers.
-          Services.obs.notifyObservers(
-            null,
-            "push-subscription-change",
-            scope
-          );
-
-          let data = {
-            originAttributes: record.originAttributes,
-            scope: record.scope
-          };
-
-          globalMM.broadcastAsyncMessage('pushsubscriptionchange', data);
-        }
-      });
+    return this._db.getAllUnexpired().then(records =>
+      records.forEach(record =>
+        this._notifySubscriptionChangeObservers(record)
+      )
+    );
   },
 
   dropRegistrationAndNotifyApp: function(aKeyId) {
-    return this._db.getByKeyID(aKeyId)
-      .then(record => {
-        let globalMM = Cc['@mozilla.org/globalmessagemanager;1']
-                         .getService(Ci.nsIMessageListenerManager);
-        Services.obs.notifyObservers(
-          null,
-          "push-subscription-change",
-          record.scope
-        );
-
-        let data = {
-          originAttributes: record.originAttributes,
-          scope: record.scope
-        };
-
-        globalMM.broadcastAsyncMessage('pushsubscriptionchange', data);
-      })
-      .then(_ => this._db.delete(aKeyId));
+    return this._db.getByKeyID(aKeyId).then(record => {
+      this._notifySubscriptionChangeObservers(record);
+      return this._db.delete(aKeyId);
+    });
   },
 
   updateRegistrationAndNotifyApp: function(aOldKey, aRecord) {
     return this._db.delete(aOldKey)
-      .then(_ => this._db.put(aRecord)
-        .then(record => {
-          let globalMM = Cc['@mozilla.org/globalmessagemanager;1']
-                           .getService(Ci.nsIMessageListenerManager);
-          Services.obs.notifyObservers(
-            null,
-            "push-subscription-change",
-            record.scope
-          );
-
-          let data = {
-            originAttributes: record.originAttributes,
-            scope: record.scope
-          };
-
-          globalMM.broadcastAsyncMessage('pushsubscriptionchange', data);
-        }));
+      .then(_ => this._db.put(aRecord))
+      .then(record => this._notifySubscriptionChangeObservers(record));
   },
 
-  receivedPushMessage: function(aPushRecord, message) {
-    this._db.put(aPushRecord)
-      .then(_ => this._notifyApp(aPushRecord, message));
+  /**
+   * Dispatches an incoming message to a service worker, recalculating the
+   * quota for the associated push registration. If the quota is exceeded,
+   * the registration and message will be dropped, and the worker will not
+   * be notified.
+   *
+   * @param {String} keyID The push registration ID.
+   * @param {String} message The message contents.
+   * @param {Function} updateFunc A function that receives the existing
+   *  registration record as its argument, and returns a new record. If the
+   *  function returns `null` or `undefined`, the record will not be updated.
+   *  `PushServiceWebSocket` uses this to drop incoming updates with older
+   *  versions.
+   */
+  receivedPushMessage: function(keyID, message, updateFunc) {
+    debug("receivedPushMessage()");
+
+    let shouldNotify = false;
+    this.getByKeyID(keyID).then(record => {
+      if (!record) {
+        throw new Error("No record for key ID " + keyID);
+      }
+      return record.getLastVisit();
+    }).then(lastVisit => {
+      // As a special case, don't notify the service worker if the user
+      // cleared their history.
+      shouldNotify = isFinite(lastVisit);
+      return this._db.update(keyID, record => {
+        let newRecord = updateFunc(record);
+        if (!newRecord) {
+          return null;
+        }
+        if (newRecord.isExpired()) {
+          // Because `unregister` is advisory only, we can still receive messages
+          // for stale registrations from the server.
+          debug("receivedPushMessage: Ignoring update for expired key ID " + keyID);
+          return null;
+        }
+        newRecord.receivedPush(lastVisit);
+        return newRecord;
+      });
+    }).then(record => {
+      if (!record) {
+        return null;
+      }
+      if (shouldNotify) {
+        this._notifyApp(record, message);
+      }
+      if (record.isExpired()) {
+        // Drop the registration in the background. If the user returns to the
+        // site, the service worker will be notified on the next `idle-daily`
+        // event.
+        this._sendRequest("unregister", record).catch(error => {
+          debug("receivedPushMessage: Unregister error: " + error);
+        });
+      }
+    }).catch(error => {
+      debug("receivedPushMessage: Error notifying app: " + error);
+    });
   },
 
   _notifyApp: function(aPushRecord, message) {
     if (!aPushRecord || !aPushRecord.scope ||
         aPushRecord.originAttributes === undefined) {
       debug("notifyApp() something is undefined.  Dropping notification: " +
         JSON.stringify(aPushRecord) );
       return;
@@ -770,18 +808,18 @@ this.PushService = {
                  .getService(Ci.nsIMessageListenerManager);
     globalMM.broadcastAsyncMessage('push', data);
   },
 
   getByKeyID: function(aKeyID) {
     return this._db.getByKeyID(aKeyID);
   },
 
-  getAllKeyIDs: function() {
-    return this._db.getAllKeyIDs();
+  getAllUnexpired: function() {
+    return this._db.getAllUnexpired();
   },
 
   _sendRequest: function(action, aRecord) {
     if (this._state == PUSH_SERVICE_CONNECTION_DISABLE) {
       return Promise.reject({state: 0, error: "Service not active"});
     } else if (this._state == PUSH_SERVICE_ACTIVE_OFFLINE) {
       return Promise.reject({state: 0, error: "NetworkError"});
     }
@@ -791,55 +829,67 @@ this.PushService = {
   /**
    * Called on message from the child process. aPageRecord is an object sent by
    * navigator.push, identifying the sending page and other fields.
    */
   _registerWithServer: function(aPageRecord) {
     debug("registerWithServer()" + JSON.stringify(aPageRecord));
 
     return this._sendRequest("register", aPageRecord)
-      .then(pushRecord => this._onRegisterSuccess(pushRecord),
+      .then(record => this._onRegisterSuccess(record),
             err => this._onRegisterError(err))
-      .then(pushRecord => {
+      .then(record => {
         this._deletePendingRequest(aPageRecord);
-        return pushRecord;
+        return record;
       }, err => {
         this._deletePendingRequest(aPageRecord);
         throw err;
      });
   },
 
   _register: function(aPageRecord) {
     debug("_register()");
     if (!aPageRecord.scope || aPageRecord.originAttributes === undefined) {
       return Promise.reject({state: 0, error: "NotFoundError"});
     }
 
     return this._checkActivated()
       .then(_ => this._db.getByIdentifiers(aPageRecord))
-      .then(pushRecord => {
-        if (pushRecord === undefined) {
+      .then(record => {
+        if (!record) {
           return this._lookupOrPutPendingRequest(aPageRecord);
         }
-        return pushRecord;
+        if (record.isExpired()) {
+          return record.getLastVisit().then(lastVisit => {
+            if (lastVisit > record.lastPush) {
+              // If the user revisited the site, drop the expired push
+              // registration and re-register.
+              return this._db.delete(record.keyID).then(_ => {
+                return this._lookupOrPutPendingRequest(aPageRecord);
+              });
+            }
+            throw {state: 0, error: "NotFoundError"};
+          });
+        }
+        return record;
       }, error => {
         debug("getByIdentifiers failed");
         throw error;
       });
   },
 
   /**
    * Exceptions thrown in _onRegisterSuccess are caught by the promise obtained
    * from _service.request, causing the promise to be rejected instead.
    */
   _onRegisterSuccess: function(aRecord) {
     debug("_onRegisterSuccess()");
 
     return this._db.put(aRecord)
-      .then(_ => aRecord, error => {
+      .catch(error => {
         // Unable to save. Destroy the subscription in the background.
         this._sendRequest("unregister", aRecord).catch(err => {
           debug("_onRegisterSuccess: Error unregistering stale subscription" +
             err);
         });
         throw error;
       });
   },
@@ -872,45 +922,45 @@ this.PushService = {
 
     let mm = aMessage.target.QueryInterface(Ci.nsIMessageSender);
     let pageRecord = aMessage.data;
 
     let principal = aMessage.principal;
     if (!principal) {
       debug("No principal passed!");
       let message = {
-        requestID: aPageRecord.requestID,
+        requestID: pageRecord.requestID,
         error: "SecurityError"
       };
       mm.sendAsyncMessage("PushService:Register:KO", message);
       return;
     }
 
     pageRecord.originAttributes =
       ChromeUtils.originAttributesToSuffix(principal.originAttributes);
 
     if (!pageRecord.scope || pageRecord.originAttributes === undefined) {
       debug("Incorrect identifier values set! " + JSON.stringify(pageRecord));
       let message = {
-        requestID: aPageRecord.requestID,
+        requestID: pageRecord.requestID,
         error: "SecurityError"
       };
       mm.sendAsyncMessage("PushService:Register:KO", message);
       return;
     }
 
     this[aMessage.name.slice("Push:".length).toLowerCase()](pageRecord, mm);
   },
 
   register: function(aPageRecord, aMessageManager) {
     debug("register(): " + JSON.stringify(aPageRecord));
 
     this._register(aPageRecord)
-      .then(pushRecord => {
-        let message = this._service.prepareRegister(pushRecord);
+      .then(record => {
+        let message = record.toRegister();
         message.requestID = aPageRecord.requestID;
         aMessageManager.sendAsyncMessage("PushService:Register:OK", message);
       }, error => {
         let message = {
           requestID: aPageRecord.requestID,
           error
         };
         aMessageManager.sendAsyncMessage("PushService:Register:KO", message);
@@ -953,17 +1003,17 @@ this.PushService = {
       .then(record => {
         // If the endpoint didn't exist, let's just fail.
         if (record === undefined) {
           throw "NotFoundError";
         }
 
         return Promise.all([
           this._sendRequest("unregister", record),
-          this._db.delete(this._service.getKeyFromRecord(record))
+          this._db.delete(record.keyID),
         ]);
       });
   },
 
   unregister: function(aPageRecord, aMessageManager) {
     debug("unregister() " + JSON.stringify(aPageRecord));
 
     this._unregister(aPageRecord)
@@ -993,21 +1043,29 @@ this.PushService = {
   _registration: function(aPageRecord) {
     debug("_registration()");
     if (!aPageRecord.scope || aPageRecord.originAttributes === undefined) {
       return Promise.reject({state: 0, error: "NotFoundError"});
     }
 
     return this._checkActivated()
       .then(_ => this._db.getByIdentifiers(aPageRecord))
-      .then(pushRecord => {
-        if (!pushRecord) {
+      .then(record => {
+        if (!record) {
           return null;
         }
-        return this._service.prepareRegistration(pushRecord);
+        if (record.isExpired()) {
+          return record.getLastVisit().then(lastVisit => {
+            if (lastVisit > record.lastPush) {
+              return this._db.delete(record.keyID).then(_ => null);
+            }
+            throw {state: 0, error: "NotFoundError"};
+          });
+        }
+        return record.toRegistration();
       });
   },
 
   registration: function(aPageRecord, aMessageManager) {
     debug("registration()");
 
     return this._registration(aPageRecord)
       .then(registration =>
@@ -1015,10 +1073,34 @@ this.PushService = {
           requestID: aPageRecord.requestID,
           registration
         }), error =>
         aMessageManager.sendAsyncMessage("PushService:Registration:KO", {
           requestID: aPageRecord.requestID,
           error
         })
       );
-  }
+  },
+
+  _dropExpiredRegistrations: function() {
+    debug("dropExpiredRegistrations()");
+
+    this._db.getAllExpired().then(records => {
+      return Promise.all(records.map(record => {
+        return record.getLastVisit().then(lastVisit => {
+          if (lastVisit > record.lastPush) {
+            // If the user revisited the site, drop the expired push
+            // registration and notify the associated service worker.
+            return this._db.delete(record.keyID).then(() => {
+              this._notifySubscriptionChangeObservers(record);
+            });
+          }
+        }).catch(error => {
+          debug("dropExpiredRegistrations: Error dropping registration " +
+            record.keyID + ": " + error);
+        });
+      }));
+    }).catch(error => {
+      debug("dropExpiredRegistrations: Error dropping registrations: " +
+        error);
+    });
+  },
 };
--- a/dom/push/PushServiceHttp2.jsm
+++ b/dom/push/PushServiceHttp2.jsm
@@ -6,16 +6,17 @@
 "use strict";
 
 const Cc = Components.classes;
 const Ci = Components.interfaces;
 const Cu = Components.utils;
 const Cr = Components.results;
 
 const {PushDB} = Cu.import("resource://gre/modules/PushDB.jsm");
+const {PushRecord} = Cu.import("resource://gre/modules/PushRecord.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/IndexedDBHelper.jsm");
 Cu.import("resource://gre/modules/Timer.jsm");
 Cu.import("resource://gre/modules/Preferences.jsm");
 Cu.import("resource://gre/modules/Promise.jsm");
 
 this.EXPORTED_SYMBOLS = ["PushServiceHttp2"];
@@ -28,17 +29,17 @@ var gDebuggingEnabled = prefs.get("debug
 
 function debug(s) {
   if (gDebuggingEnabled) {
     dump("-*- PushServiceHttp2.jsm: " + s + "\n");
   }
 }
 
 const kPUSHHTTP2DB_DB_NAME = "pushHttp2";
-const kPUSHHTTP2DB_DB_VERSION = 3; // Change this if the IndexedDB format changes
+const kPUSHHTTP2DB_DB_VERSION = 4; // Change this if the IndexedDB format changes
 const kPUSHHTTP2DB_STORE_NAME = "pushHttp2";
 
 /**
  * A proxy between the PushService and connections listening for incoming push
  * messages. The PushService can silence messages from the connections by
  * setting PushSubscriptionListener._pushService to null. This is required
  * because it can happen that there is an outstanding push message that will
  * be send on OnStopRequest but the PushService may not be interested in these.
@@ -280,26 +281,25 @@ SubscriptionListener.prototype = {
       let uriTry = Services.io.newURI(subscriptionUri, null, null);
     } catch (e) {
       debug("Invalid URI " + subscriptionUri);
       this._subInfo.reject({error: "Return code 201, but URI is bogus. " +
                                    subscriptionUri});
       return;
     }
 
-    var reply = {
+    let reply = new PushRecordHttp2({
       subscriptionUri: subscriptionUri,
       pushEndpoint: linkParserResult.pushEndpoint,
       pushReceiptEndpoint: linkParserResult.pushReceiptEndpoint,
-      pageURL: this._subInfo.record.pageURL,
       scope: this._subInfo.record.scope,
       originAttributes: this._subInfo.record.originAttributes,
-      pushCount: 0,
-      lastPush: 0
-    };
+      quota: this._subInfo.record.maxQuota,
+    });
+
     this._subInfo.resolve(reply);
   },
 };
 
 function retryAfterParser(aRequest) {
   var retryAfter = 0;
   try {
     var retryField = aRequest.getResponseHeader("retry-after");
@@ -374,61 +374,22 @@ function linkParser(linkHeader, serverUR
 this.PushServiceHttp2 = {
   _mainPushService: null,
   _serverURI: null,
 
   // Keep information about all connections, e.g. the channel, listener...
   _conns: {},
   _started: false,
 
-  upgradeSchema: function(aTransaction,
-                          aDb,
-                          aOldVersion,
-                          aNewVersion,
-                          aDbInstance) {
-    debug("upgradeSchemaHttp2()");
-
-    //XXXnsm We haven't shipped Push during this upgrade, so I'm just going to throw old
-    //registrations away without even informing the app.
-    if (aNewVersion != aOldVersion) {
-      try {
-        aDb.deleteObjectStore(aDbInstance._dbStoreName);
-      } catch (e) {
-        if (e.name === "NotFoundError") {
-          debug("No existing object store found");
-        } else {
-          throw e;
-        }
-      }
-    }
-
-    let objectStore = aDb.createObjectStore(aDbInstance._dbStoreName,
-                                            { keyPath: "subscriptionUri" });
-
-    // index to fetch records based on endpoints. used by unregister
-    objectStore.createIndex("pushEndpoint", "pushEndpoint", { unique: true });
-
-    // index to fetch records by identifiers.
-    // In the current security model, the originAttributes distinguish between
-    // different 'apps' on the same origin. Since ServiceWorkers are
-    // same-origin to the scope they are registered for, the attributes and
-    // scope are enough to reconstruct a valid principal.
-    objectStore.createIndex("identifiers", ["scope", "originAttributes"], { unique: true });
-    objectStore.createIndex("originAttributes", "originAttributes", { unique: false });
-  },
-
-  getKeyFromRecord: function(aRecord) {
-    return aRecord.subscriptionUri;
-  },
-
   newPushDB: function() {
     return new PushDB(kPUSHHTTP2DB_DB_NAME,
                       kPUSHHTTP2DB_DB_VERSION,
                       kPUSHHTTP2DB_STORE_NAME,
-                      this.upgradeSchema);
+                      "subscriptionUri",
+                      PushRecordHttp2);
   },
 
   hasmainPushService: function() {
     return this._mainPushService !== null;
   },
 
   checkServerURI: function(serverURL) {
     if (!serverURL) {
@@ -793,47 +754,47 @@ this.PushServiceHttp2 = {
     } else {
       this._retryAfterBackoff(aSubscriptionUri, -1);
     }
   },
 
   _pushChannelOnStop: function(aUri, aAckUri, aMessage) {
     debug("pushChannelOnStop() ");
 
-    let sendNotification = function(aAckUri, aPushRecord, self) {
-      aPushRecord.pushCount = aPushRecord.pushCount + 1;
-      aPushRecord.lastPush = new Date().getTime();
-      self._mainPushService.receivedPushMessage(aPushRecord, aMessage);
-      self._ackMsgRecv(aAckUri);
-    };
-
-    let recoverNoSuchEndpoint = function() {
-      debug("Could not get push endpoint " + aUri + " from DB");
-    };
-
-    this._mainPushService.getByKeyID(aUri)
-      .then(pushRecord => sendNotification(aAckUri, pushRecord, this),
-            recoverNoSuchEndpoint);
+    this._mainPushService.receivedPushMessage(aUri, aMessage, record => {
+      // Always update the stored record.
+      return record;
+    });
+    this._ackMsgRecv(aAckUri);
   },
 
   onAlarmFired: function() {
     // Conditions are arranged in decreasing specificity.
     // i.e. when _waitingForPong is true, other conditions are also true.
     this._startConnectionsWaitingForAlarm();
   },
+};
 
-  prepareRegistration: function(aPushRecord) {
-    return {
-      pushEndpoint: aPushRecord.pushEndpoint,
-      pushReceiptEndpoint: aPushRecord.pushReceiptEndpoint,
-      version: aPushRecord.version,
-      lastPush: aPushRecord.lastPush,
-      pushCount: aPushRecord.pushCount
-    };
+function PushRecordHttp2(record) {
+  PushRecord.call(this, record);
+  this.subscriptionUri = record.subscriptionUri;
+  this.pushReceiptEndpoint = record.pushReceiptEndpoint;
+}
+
+PushRecordHttp2.prototype = Object.create(PushRecord.prototype, {
+  keyID: {
+    get() {
+      return this.subscriptionUri;
+    },
   },
+});
 
-  prepareRegister: function(aPushRecord) {
-    return {
-      pushEndpoint: aPushRecord.pushEndpoint,
-      pushReceiptEndpoint: aPushRecord.pushReceiptEndpoint
-    };
-  }
+PushRecordHttp2.prototype.toRegistration = function() {
+  let registration = PushRecord.prototype.toRegistration.call(this);
+  registration.pushReceiptEndpoint = this.pushReceiptEndpoint;
+  return registration;
 };
+
+PushRecordHttp2.prototype.toRegister = function() {
+  let register = PushRecord.prototype.toRegister.call(this);
+  register.pushReceiptEndpoint = this.pushReceiptEndpoint;
+  return register;
+};
--- a/dom/push/PushServiceWebSocket.jsm
+++ b/dom/push/PushServiceWebSocket.jsm
@@ -6,16 +6,17 @@
 "use strict";
 
 const Cc = Components.classes;
 const Ci = Components.interfaces;
 const Cu = Components.utils;
 const Cr = Components.results;
 
 const {PushDB} = Cu.import("resource://gre/modules/PushDB.jsm");
+const {PushRecord} = Cu.import("resource://gre/modules/PushRecord.jsm");
 Cu.import("resource://gre/modules/Preferences.jsm");
 Cu.import("resource://gre/modules/Timer.jsm");
 Cu.import("resource://gre/modules/Promise.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 
 
 XPCOMUtils.defineLazyServiceGetter(this, "gDNSService",
@@ -27,17 +28,17 @@ XPCOMUtils.defineLazyServiceGetter(this,
                                    "@mozilla.org/power/powermanagerservice;1",
                                    "nsIPowerManagerService");
 #endif
 
 var threadManager = Cc["@mozilla.org/thread-manager;1"]
                       .getService(Ci.nsIThreadManager);
 
 const kPUSHWSDB_DB_NAME = "pushapi";
-const kPUSHWSDB_DB_VERSION = 3; // Change this if the IndexedDB format changes
+const kPUSHWSDB_DB_VERSION = 4; // Change this if the IndexedDB format changes
 const kPUSHWSDB_STORE_NAME = "pushapi";
 
 const kUDP_WAKEUP_WS_STATUS_CODE = 4774;  // WebSocket Close status code sent
                                           // by server to signal that it can
                                           // wake client up using UDP.
 
 const kWS_MAX_WENTDOWN = 2;
 
@@ -120,61 +121,22 @@ const STATE_WAITING_FOR_WS_START = 1;
 const STATE_WAITING_FOR_HELLO = 2;
 // Websocket operational, handshake completed, begin protocol messaging.
 const STATE_READY = 3;
 
 this.PushServiceWebSocket = {
   _mainPushService: null,
   _serverURI: null,
 
-  upgradeSchema: function(aTransaction,
-                          aDb,
-                          aOldVersion,
-                          aNewVersion,
-                          aDbInstance) {
-    debug("upgradeSchemaWS()");
-
-    //XXXnsm We haven't shipped Push during this upgrade, so I'm just going to throw old
-    //registrations away without even informing the app.
-    if (aNewVersion != aOldVersion) {
-      try {
-        aDb.deleteObjectStore(aDbInstance._dbStoreName);
-      } catch (e) {
-        if (e.name === "NotFoundError") {
-          debug("No existing object store found");
-        } else {
-          throw e;
-        }
-      }
-    }
-
-    let objectStore = aDb.createObjectStore(aDbInstance._dbStoreName,
-                                            { keyPath: "channelID" });
-
-    // index to fetch records based on endpoints. used by unregister
-    objectStore.createIndex("pushEndpoint", "pushEndpoint", { unique: true });
-
-    // index to fetch records by identifiers.
-    // In the current security model, the originAttributes distinguish between
-    // different 'apps' on the same origin. Since ServiceWorkers are
-    // same-origin to the scope they are registered for, the attributes and
-    // scope are enough to reconstruct a valid principal.
-    objectStore.createIndex("identifiers", ["scope", "originAttributes"], { unique: true });
-    objectStore.createIndex("originAttributes", "originAttributes", { unique: false });
-  },
-
-  getKeyFromRecord: function(aRecord) {
-    return aRecord.channelID;
-  },
-
   newPushDB: function() {
     return new PushDB(kPUSHWSDB_DB_NAME,
                       kPUSHWSDB_DB_VERSION,
                       kPUSHWSDB_STORE_NAME,
-                      this.upgradeSchema);
+                      "channelID",
+                      PushRecordWebSocket);
   },
 
   disconnect: function() {
     this._shutdownWS();
   },
 
   observe: function(aSubject, aTopic, aData) {
 
@@ -677,20 +639,20 @@ this.PushServiceWebSocket = {
       this._currentState = STATE_WAITING_FOR_WS_START;
     } catch(e) {
       debug("Error opening websocket. asyncOpen failed!");
       this._shutdownWS();
       this._reconnectAfterBackoff();
     }
   },
 
-  connect: function(channelIDs) {
+  connect: function(records) {
     debug("connect");
     // Check to see if we need to do anything.
-    if (channelIDs.length > 0) {
+    if (records.length > 0) {
       this._beginWSSetup();
     }
   },
 
   /**
    * There is only one alarm active at any time. This alarm has 3 intervals
    * corresponding to 3 tasks.
    *
@@ -878,26 +840,24 @@ this.PushServiceWebSocket = {
       }
       catch (e) {
         debug("Invalid pushEndpoint " + reply.pushEndpoint);
         tmp.reject({state: 0, error: "Invalid pushEndpoint " +
                                      reply.pushEndpoint});
         return;
       }
 
-      let record = {
+      let record = new PushRecordWebSocket({
         channelID: reply.channelID,
         pushEndpoint: reply.pushEndpoint,
-        pageURL: tmp.record.pageURL,
         scope: tmp.record.scope,
         originAttributes: tmp.record.originAttributes,
-        pushCount: 0,
-        lastPush: 0,
-        version: null
-      };
+        version: null,
+        quota: tmp.record.maxQuota,
+      });
       dump("PushWebSocket " +  JSON.stringify(record));
       tmp.resolve(record);
     } else {
       tmp.reject(reply);
     }
   },
 
   /**
@@ -1011,21 +971,20 @@ this.PushServiceWebSocket = {
         this._wsSendMessage(data);
       }
     }
   },
 
   _queueRequest(data) {
     if (this._currentState != STATE_READY) {
       if (!this._notifyRequestQueue) {
-        this._enqueue(_ => {
-          return new Promise((resolve, reject) => {
-                               this._notifyRequestQueue = resolve;
-                             });
+        let promise = new Promise((resolve, reject) => {
+          this._notifyRequestQueue = resolve;
         });
+        this._enqueue(_ => promise);
       }
 
     }
 
     this._enqueue(_ => this._send(data));
     if (!this._ws) {
       // This will end up calling notifyRequestQueue().
       this._beginWSSetup();
@@ -1036,46 +995,27 @@ this.PushServiceWebSocket = {
         this._notifyRequestQueue = null;
       }
     }
   },
 
   _receivedUpdate: function(aChannelID, aLatestVersion) {
     debug("Updating: " + aChannelID + " -> " + aLatestVersion);
 
-    let compareRecordVersionAndNotify = function(aPushRecord) {
-      debug("compareRecordVersionAndNotify()");
-      if (!aPushRecord) {
-        debug("No record for channel ID " + aChannelID);
-        return;
+    this._mainPushService.receivedPushMessage(aChannelID, "", record => {
+      if (record.version === null ||
+          record.version < aLatestVersion) {
+        debug("Version changed for " + aChannelID + ": " + aLatestVersion);
+        record.version = aLatestVersion;
+        return record;
       }
-
-      if (aPushRecord.version === null ||
-          aPushRecord.version < aLatestVersion) {
-        debug("Version changed, notifying app and updating DB");
-        aPushRecord.version = aLatestVersion;
-        aPushRecord.pushCount = aPushRecord.pushCount + 1;
-        aPushRecord.lastPush = new Date().getTime();
-        this._mainPushService.receivedPushMessage(aPushRecord,
-                                                  "Short as life is, we make " +
-                                                  "it still shorter by the " +
-                                                  "careless waste of time.");
-      }
-      else {
-        debug("No significant version change: " + aLatestVersion);
-      }
-    };
-
-    let recoverNoSuchChannelID = function(aChannelIDFromServer) {
-      debug("Could not get channelID " + aChannelIDFromServer + " from DB");
-    };
-
-    this._mainPushService.getByKeyID(aChannelID)
-      .then(compareRecordVersionAndNotify.bind(this),
-            err => recoverNoSuchChannelID(err));
+      debug("No significant version change for " + aChannelID + ": " +
+            aLatestVersion);
+      return null;
+    });
   },
 
   // begin Push protocol handshake
   _wsOnStart: function(context) {
     debug("wsOnStart()");
     this._releaseWakeLock();
 
     if (this._currentState != STATE_WAITING_FOR_WS_START) {
@@ -1116,17 +1056,17 @@ this.PushServiceWebSocket = {
 
         data.mobilenetwork = {
           mcc: networkState.mcc,
           mnc: networkState.mnc,
           netid: networkState.netid
         };
       }
 
-      this._mainPushService.getAllKeyIDs()
+      this._mainPushService.getAllUnexpired()
         .then(sendHelloMessage.bind(this),
               sendHelloMessage.bind(this));
     });
   },
 
   /**
    * This statusCode is not the websocket protocol status code, but the TCP
    * connection close status code.
@@ -1294,31 +1234,16 @@ this.PushServiceWebSocket = {
    * If this happens, we reconnect the WebSocket to not miss out on
    * notifications.
    */
   onStopListening: function(aServ, aStatus) {
     debug("UDP Server socket was shutdown. Status: " + aStatus);
     this._udpServer = undefined;
     this._beginWSSetup();
   },
-
-  prepareRegistration: function(aPushRecord) {
-    return {
-      pushEndpoint: aPushRecord.pushEndpoint,
-      version: aPushRecord.version,
-      lastPush: aPushRecord.lastPush,
-      pushCount: aPushRecord.pushCount
-    };
-  },
-
-  prepareRegister: function(aPushRecord) {
-    return {
-      pushEndpoint: aPushRecord.pushEndpoint
-    };
-  }
 };
 
 let PushNetworkInfo = {
   /**
    * Returns information about MCC-MNC and the IP of the current connection.
    */
   getNetworkInformation: function() {
     debug("getNetworkInformation()");
@@ -1434,8 +1359,28 @@ let PushNetworkInfo = {
 
     debug("[_getMobileNetworkId:queryDNSForDomain] Getting mobile network ID");
 
     let netidAddress = "wakeup.mnc" + ("00" + networkInfo.mnc).slice(-3) +
       ".mcc" + ("00" + networkInfo.mcc).slice(-3) + ".3gppnetwork.org";
     queryDNSForDomain(netidAddress, callback);
   }
 };
+
+function PushRecordWebSocket(record) {
+  PushRecord.call(this, record);
+  this.channelID = record.channelID;
+  this.version = record.version;
+}
+
+PushRecordWebSocket.prototype = Object.create(PushRecord.prototype, {
+  keyID: {
+    get() {
+      return this.channelID;
+    },
+  },
+});
+
+PushRecordWebSocket.prototype.toRegistration = function() {
+  let registration = PushRecord.prototype.toRegistration.call(this);
+  registration.version = this.version;
+  return registration;
+};
--- a/dom/push/moz.build
+++ b/dom/push/moz.build
@@ -10,16 +10,17 @@ EXTRA_COMPONENTS += [
 ]
 
 EXTRA_PP_JS_MODULES += [
     'PushServiceWebSocket.jsm',
 ]
 
 EXTRA_JS_MODULES += [
     'PushDB.jsm',
+    'PushRecord.jsm',
     'PushService.jsm',
     'PushServiceHttp2.jsm',
 ]
 
 MOCHITEST_MANIFESTS += [
     'test/mochitest.ini',
 ]
 
--- a/dom/push/test/worker.js
+++ b/dom/push/test/worker.js
@@ -2,17 +2,16 @@
 // http://creativecommons.org/licenses/publicdomain/
 
 this.onpush = handlePush;
 
 function handlePush(event) {
 
   self.clients.matchAll().then(function(result) {
     if (event instanceof PushEvent &&
-      event.data instanceof PushMessageData &&
-      event.data.text().length > 0) {
+      event.data instanceof PushMessageData) {
 
       result[0].postMessage({type: "finished", okay: "yes"});
       return;
     }
     result[0].postMessage({type: "finished", okay: "no"});
   });
 }
--- a/dom/push/test/xpcshell/head.js
+++ b/dom/push/test/xpcshell/head.js
@@ -5,16 +5,17 @@
 
 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
 
 Cu.import('resource://gre/modules/XPCOMUtils.jsm');
 Cu.import('resource://gre/modules/Services.jsm');
 Cu.import('resource://gre/modules/Timer.jsm');
 Cu.import('resource://gre/modules/Promise.jsm');
 Cu.import('resource://gre/modules/Preferences.jsm');
+Cu.import('resource://gre/modules/PlacesUtils.jsm');
 
 const serviceExports = Cu.import('resource://gre/modules/PushService.jsm', {});
 const servicePrefs = new Preferences('dom.push.');
 
 XPCOMUtils.defineLazyServiceGetter(
   this,
   "PushNotificationService",
   "@mozilla.org/push/NotificationService;1",
@@ -57,16 +58,35 @@ function after(times, func) {
   return function afterFunc() {
     if (--times <= 0) {
       return func.apply(this, arguments);
     }
   };
 }
 
 /**
+ * Updates the places database.
+ *
+ * @param {mozIPlaceInfo} place A place record to insert.
+ * @returns {Promise} A promise that fulfills when the database is updated.
+ */
+function addVisit(place) {
+  return new Promise((resolve, reject) => {
+    if (typeof place.uri == 'string') {
+      place.uri = Services.io.newURI(place.uri, null, null);
+    }
+    PlacesUtils.asyncHistory.updatePlaces(place, {
+      handleCompletion: resolve,
+      handleError: reject,
+      handleResult() {},
+    });
+  });
+}
+
+/**
  * Defers one or more callbacks until the next turn of the event loop. Multiple
  * callbacks are executed in order.
  *
  * @param {Function[]} callbacks The callbacks to execute. One callback will be
  *  executed per tick.
  */
 function waterfall(...callbacks) {
   callbacks.reduce((promise, callback) => promise.then(() => {
@@ -194,17 +214,18 @@ function setPrefs(prefs = {}) {
     'adaptive.lastGoodPingInterval.mobile': 3 * 60 * 1000,
     'adaptive.lastGoodPingInterval.wifi': 3 * 60 * 1000,
     'adaptive.gap': 60000,
     'adaptive.upperLimit': 29 * 60 * 1000,
     // Misc. defaults.
     'adaptive.mobile': '',
     'http2.maxRetries': 2,
     'http2.retryInterval': 500,
-    'http2.reset_retry_count_after_ms': 60000
+    'http2.reset_retry_count_after_ms': 60000,
+    maxQuotaPerSubscription: 16,
   }, prefs);
   for (let pref in defaultPrefs) {
     servicePrefs.set(pref, defaultPrefs[pref]);
   }
 }
 
 function compareAscending(a, b) {
   return a > b ? 1 : a < b ? -1 : 0;
--- a/dom/push/test/xpcshell/test_clearAll_successful.js
+++ b/dom/push/test/xpcshell/test_clearAll_successful.js
@@ -15,17 +15,19 @@ function run_test() {
 
 add_task(function* test_unregister_success() {
   let db = PushServiceWebSocket.newPushDB();
   do_register_cleanup(() => {return db.drop().then(_ => db.close());});
   yield db.put({
     channelID,
     pushEndpoint: 'https://example.org/update/unregister-success',
     scope: 'https://example.com/page/unregister-success',
-    version: 1
+    version: 1,
+    originAttributes: '',
+    quota: Infinity,
   });
 
   let unregisterDefer = Promise.defer();
   PushService.init({
     serverURI: "wss://push.example.org/",
     networkInfo: new MockDesktopNetworkInfo(),
     db,
     makeWebSocket(uri) {
--- a/dom/push/test/xpcshell/test_notification_ack.js
+++ b/dom/push/test/xpcshell/test_notification_ack.js
@@ -21,29 +21,32 @@ function run_test() {
 add_task(function* test_notification_ack() {
   let db = PushServiceWebSocket.newPushDB();
   do_register_cleanup(() => {return db.drop().then(_ => db.close());});
   let records = [{
     channelID: '21668e05-6da8-42c9-b8ab-9cc3f4d5630c',
     pushEndpoint: 'https://example.com/update/1',
     scope: 'https://example.org/1',
     originAttributes: '',
-    version: 1
+    version: 1,
+    quota: Infinity,
   }, {
     channelID: '9a5ff87f-47c9-4215-b2b8-0bdd38b4b305',
     pushEndpoint: 'https://example.com/update/2',
     scope: 'https://example.org/2',
     originAttributes: '',
-    version: 2
+    version: 2,
+    quota: Infinity,
   }, {
     channelID: '5477bfda-22db-45d4-9614-fee369630260',
     pushEndpoint: 'https://example.com/update/3',
     scope: 'https://example.org/3',
     originAttributes: '',
-    version: 3
+    version: 3,
+    quota: Infinity,
   }];
   for (let record of records) {
     yield db.put(record);
   }
 
   let notifyPromise = Promise.all([
     promiseObserverNotification('push-notification'),
     promiseObserverNotification('push-notification'),
--- a/dom/push/test/xpcshell/test_notification_duplicate.js
+++ b/dom/push/test/xpcshell/test_notification_duplicate.js
@@ -19,23 +19,25 @@ function run_test() {
 add_task(function* test_notification_duplicate() {
   let db = PushServiceWebSocket.newPushDB();
   do_register_cleanup(() => {return db.drop().then(_ => db.close());});
   let records = [{
     channelID: '8d2d9400-3597-4c5a-8a38-c546b0043bcc',
     pushEndpoint: 'https://example.org/update/1',
     scope: 'https://example.com/1',
     originAttributes: "",
-    version: 2
+    version: 2,
+    quota: Infinity,
   }, {
     channelID: '27d1e393-03ef-4c72-a5e6-9e890dfccad0',
     pushEndpoint: 'https://example.org/update/2',
     scope: 'https://example.com/2',
     originAttributes: "",
-    version: 2
+    version: 2,
+    quota: Infinity,
   }];
   for (let record of records) {
     yield db.put(record);
   }
 
   let notifyPromise = promiseObserverNotification('push-notification');
 
   let acks = 0;
--- a/dom/push/test/xpcshell/test_notification_error.js
+++ b/dom/push/test/xpcshell/test_notification_error.js
@@ -21,29 +21,32 @@ add_task(function* test_notification_err
   do_register_cleanup(() => {return db.drop().then(_ => db.close());});
 
   let originAttributes = '';
   let records = [{
     channelID: 'f04f1e46-9139-4826-b2d1-9411b0821283',
     pushEndpoint: 'https://example.org/update/success-1',
     scope: 'https://example.com/a',
     originAttributes: originAttributes,
-    version: 1
+    version: 1,
+    quota: Infinity,
   }, {
     channelID: '3c3930ba-44de-40dc-a7ca-8a133ec1a866',
     pushEndpoint: 'https://example.org/update/error',
     scope: 'https://example.com/b',
     originAttributes: originAttributes,
-    version: 2
+    version: 2,
+    quota: Infinity,
   }, {
     channelID: 'b63f7bef-0a0d-4236-b41e-086a69dfd316',
     pushEndpoint: 'https://example.org/update/success-2',
     scope: 'https://example.com/c',
     originAttributes: originAttributes,
-    version: 3
+    version: 3,
+    quota: Infinity,
   }];
   for (let record of records) {
     yield db.put(record);
   }
 
   let notifyPromise = Promise.all([
     promiseObserverNotification(
       'push-notification',
--- a/dom/push/test/xpcshell/test_notification_http2.js
+++ b/dom/push/test/xpcshell/test_notification_http2.js
@@ -53,29 +53,32 @@ add_task(function* test_pushNotification
 
   var serverURL = "https://localhost:" + serverPort;
 
   let records = [{
     subscriptionUri: serverURL + '/pushNotifications/subscription1',
     pushEndpoint: serverURL + '/pushEndpoint1',
     pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint1',
     scope: 'https://example.com/page/1',
-    originAttributes: ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })
+    originAttributes: ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
+    quota: Infinity,
   }, {
     subscriptionUri: serverURL + '/pushNotifications/subscription2',
     pushEndpoint: serverURL + '/pushEndpoint2',
     pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint2',
     scope: 'https://example.com/page/2',
-    originAttributes: ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })
+    originAttributes: ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
+    quota: Infinity,
   }, {
     subscriptionUri: serverURL + '/pushNotifications/subscription3',
     pushEndpoint: serverURL + '/pushEndpoint3',
     pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint3',
     scope: 'https://example.com/page/3',
-    originAttributes: ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })
+    originAttributes: ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
+    quota: Infinity,
   }];
 
   for (let record of records) {
     yield db.put(record);
   }
 
   let notifyPromise = Promise.all([
     promiseObserverNotification('push-notification'),
--- a/dom/push/test/xpcshell/test_notification_incomplete.js
+++ b/dom/push/test/xpcshell/test_notification_incomplete.js
@@ -19,44 +19,52 @@ function run_test() {
 
 add_task(function* test_notification_incomplete() {
   let db = PushServiceWebSocket.newPushDB();
   do_register_cleanup(() => {return db.drop().then(_ => db.close());});
   let records = [{
     channelID: '123',
     pushEndpoint: 'https://example.org/update/1',
     scope: 'https://example.com/page/1',
-    version: 1
+    version: 1,
+    originAttributes: '',
+    quota: Infinity,
   }, {
     channelID: '3ad1ed95-d37a-4d88-950f-22cbe2e240d7',
     pushEndpoint: 'https://example.org/update/2',
     scope: 'https://example.com/page/2',
-    version: 1
+    version: 1,
+    originAttributes: '',
+    quota: Infinity,
   }, {
     channelID: 'd239498b-1c85-4486-b99b-205866e82d1f',
     pushEndpoint: 'https://example.org/update/3',
     scope: 'https://example.com/page/3',
-    version: 3
+    version: 3,
+    originAttributes: '',
+    quota: Infinity,
   }, {
     channelID: 'a50de97d-b496-43ce-8b53-05522feb78db',
     pushEndpoint: 'https://example.org/update/4',
     scope: 'https://example.com/page/4',
-    version: 10
+    version: 10,
+    originAttributes: '',
+    quota: Infinity,
   }];
   for (let record of records) {
-    db.put(record);
+    yield db.put(record);
   }
 
   Services.obs.addObserver(function observe(subject, topic, data) {
     ok(false, 'Should not deliver malformed updates');
   }, 'push-notification', false);
 
   let notificationDefer = Promise.defer();
   let notificationDone = after(2, notificationDefer.resolve);
-  let prevHandler = PushService._handleNotificationReply;
+  let prevHandler = PushServiceWebSocket._handleNotificationReply;
   PushServiceWebSocket._handleNotificationReply = function _handleNotificationReply() {
     notificationDone();
     return prevHandler.apply(this, arguments);
   };
   PushService.init({
     serverURI: "wss://push.example.org/",
     networkInfo: new MockDesktopNetworkInfo(),
     db,
--- a/dom/push/test/xpcshell/test_notification_version_string.js
+++ b/dom/push/test/xpcshell/test_notification_version_string.js
@@ -17,17 +17,18 @@ function run_test() {
 add_task(function* test_notification_version_string() {
   let db = PushServiceWebSocket.newPushDB();
   do_register_cleanup(() => {return db.drop().then(_ => db.close());});
   yield db.put({
     channelID: '6ff97d56-d0c0-43bc-8f5b-61b855e1d93b',
     pushEndpoint: 'https://example.org/updates/1',
     scope: 'https://example.com/page/1',
     originAttributes: '',
-    version: 2
+    version: 2,
+    quota: Infinity,
   });
 
   let notifyPromise = promiseObserverNotification('push-notification');
 
   let ackDefer = Promise.defer();
   PushService.init({
     serverURI: "wss://push.example.org/",
     networkInfo: new MockDesktopNetworkInfo(),
@@ -65,9 +66,10 @@ add_task(function* test_notification_ver
   strictEqual(message.version, 4, 'Wrong version');
 
   yield waitForPromise(ackDefer.promise, DEFAULT_TIMEOUT,
     'Timed out waiting for string acknowledgement');
 
   let storeRecord = yield db.getByKeyID(
     '6ff97d56-d0c0-43bc-8f5b-61b855e1d93b');
   strictEqual(storeRecord.version, 4, 'Wrong record version');
+  equal(storeRecord.quota, Infinity, 'Wrong quota');
 });
new file mode 100644
--- /dev/null
+++ b/dom/push/test/xpcshell/test_quota_exceeded.js
@@ -0,0 +1,147 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+Cu.import("resource://gre/modules/Task.jsm");
+
+const userAgentID = '7eb873f9-8d47-4218-804b-fff78dc04e88';
+
+function run_test() {
+  do_get_profile();
+  setPrefs({
+    userAgentID,
+  });
+  run_next_test();
+}
+
+add_task(function* test_expiration_origin_threshold() {
+  let db = PushServiceWebSocket.newPushDB();
+  do_register_cleanup(() => db.drop().then(_ => db.close()));
+
+  yield db.put({
+    channelID: 'eb33fc90-c883-4267-b5cb-613969e8e349',
+    pushEndpoint: 'https://example.org/push/1',
+    scope: 'https://example.com/auctions',
+    pushCount: 0,
+    lastPush: 0,
+    version: null,
+    originAttributes: '',
+    quota: 16,
+  });
+  yield db.put({
+    channelID: '46cc6f6a-c106-4ffa-bb7c-55c60bd50c41',
+    pushEndpoint: 'https://example.org/push/2',
+    scope: 'https://example.com/deals',
+    pushCount: 0,
+    lastPush: 0,
+    version: null,
+    originAttributes: '',
+    quota: 16,
+  });
+
+  // The notification threshold is per-origin, even with multiple service
+  // workers for different scopes.
+  yield addVisit({
+    uri: 'https://example.com/login',
+    title: 'Sign in to see your auctions',
+    visits: [{
+      visitDate: (Date.now() - 7 * 24 * 60 * 60 * 1000) * 1000,
+      transitionType: Ci.nsINavHistoryService.TRANSITION_LINK,
+    }],
+  });
+
+  // We'll always use your most recent visit to an origin.
+  yield addVisit({
+    uri: 'https://example.com/auctions',
+    title: 'Your auctions',
+    visits: [{
+      visitDate: (Date.now() - 2 * 24 * 60 * 60 * 1000) * 1000,
+      transitionType: Ci.nsINavHistoryService.TRANSITION_LINK,
+    }],
+  });
+
+  // ...But we won't count downloads or embeds.
+  yield addVisit({
+    uri: 'https://example.com/invoices/invoice.pdf',
+    title: 'Invoice #123',
+    visits: [{
+      visitDate: (Date.now() - 1 * 24 * 60 * 60 * 1000) * 1000,
+      transitionType: Ci.nsINavHistoryService.TRANSITION_EMBED,
+    }, {
+      visitDate: Date.now() * 1000,
+      transitionType: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD,
+    }],
+  });
+
+  // We expect to receive 6 notifications: 5 on the `auctions` channel,
+  // and 1 on the `deals` channel. They're from the same origin, but
+  // different scopes, so each can send 5 notifications before we remove
+  // their subscription.
+  let updates = 0;
+  let notifyPromise = promiseObserverNotification('push-notification', (subject, data) => {
+    updates++;
+    return updates == 6;
+  });
+  let unregisterDefer = Promise.defer();
+
+  PushService.init({
+    serverURI: 'wss://push.example.org/',
+    networkInfo: new MockDesktopNetworkInfo(),
+    db,
+    makeWebSocket(uri) {
+      return new MockWebSocket(uri, {
+        onHello(request) {
+          deepEqual(request.channelIDs.sort(), [
+            '46cc6f6a-c106-4ffa-bb7c-55c60bd50c41',
+            'eb33fc90-c883-4267-b5cb-613969e8e349',
+          ], 'Wrong active registrations in handshake');
+          this.serverSendMsg(JSON.stringify({
+            messageType: 'hello',
+            status: 200,
+            uaid: userAgentID,
+          }));
+          // We last visited the site 2 days ago, so we can send 5
+          // notifications without throttling. Sending a 6th should
+          // drop the registration.
+          for (let version = 1; version <= 6; version++) {
+            this.serverSendMsg(JSON.stringify({
+              messageType: 'notification',
+              updates: [{
+                channelID: 'eb33fc90-c883-4267-b5cb-613969e8e349',
+                version,
+              }],
+            }));
+          }
+          // But the limits are per-channel, so we can send 5 more
+          // notifications on a different channel.
+          this.serverSendMsg(JSON.stringify({
+            messageType: 'notification',
+            updates: [{
+              channelID: '46cc6f6a-c106-4ffa-bb7c-55c60bd50c41',
+              version: 1,
+            }],
+          }));
+        },
+        onUnregister(request) {
+          equal(request.channelID, 'eb33fc90-c883-4267-b5cb-613969e8e349', 'Unregistered wrong channel ID');
+          unregisterDefer.resolve();
+        },
+        // We expect to receive acks, but don't care about their
+        // contents.
+        onACK(request) {},
+      });
+    },
+  });
+
+  yield waitForPromise(unregisterDefer.promise, DEFAULT_TIMEOUT,
+    'Timed out waiting for unregister request');
+
+  yield waitForPromise(notifyPromise, DEFAULT_TIMEOUT,
+    'Timed out waiting for notifications');
+
+  let expiredRecord = yield db.getByKeyID('eb33fc90-c883-4267-b5cb-613969e8e349');
+  strictEqual(expiredRecord.quota, 0, 'Expired record not updated');
+});
new file mode 100644
--- /dev/null
+++ b/dom/push/test/xpcshell/test_quota_observer.js
@@ -0,0 +1,125 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+const userAgentID = '28cd09e2-7506-42d8-9e50-b02785adc7ef';
+
+function run_test() {
+  do_get_profile();
+  setPrefs({
+    userAgentID,
+  });
+  run_next_test();
+}
+
+add_task(function* test_expiration_history_observer() {
+  let db = PushServiceWebSocket.newPushDB();
+  do_register_cleanup(() => db.drop().then(_ => db.close()));
+
+  // A registration that we'll expire...
+  yield db.put({
+    channelID: '379c0668-8323-44d2-a315-4ee83f1a9ee9',
+    pushEndpoint: 'https://example.org/push/1',
+    scope: 'https://example.com/deals',
+    pushCount: 0,
+    lastPush: 0,
+    version: null,
+    originAttributes: '',
+    quota: 16,
+  });
+
+  // ...And an expired registration that we'll revive later.
+  yield db.put({
+    channelID: 'eb33fc90-c883-4267-b5cb-613969e8e349',
+    pushEndpoint: 'https://example.org/push/2',
+    scope: 'https://example.com/auctions',
+    pushCount: 0,
+    lastPush: 0,
+    version: null,
+    originAttributes: '',
+    quota: 0,
+  });
+
+  yield addVisit({
+    uri: 'https://example.com/infrequent',
+    title: 'Infrequently-visited page',
+    visits: [{
+      visitDate: (Date.now() - 14 * 24 * 60 * 60 * 1000) * 1000,
+      transitionType: Ci.nsINavHistoryService.TRANSITION_LINK,
+    }],
+  });
+
+  let unregisterDefer = Promise.defer();
+
+  PushService.init({
+    serverURI: 'wss://push.example.org/',
+    networkInfo: new MockDesktopNetworkInfo(),
+    db,
+    makeWebSocket(uri) {
+      return new MockWebSocket(uri, {
+        onHello(request) {
+          deepEqual(request.channelIDs, [
+            '379c0668-8323-44d2-a315-4ee83f1a9ee9',
+          ], 'Should not include expired channel IDs');
+          this.serverSendMsg(JSON.stringify({
+            messageType: 'hello',
+            status: 200,
+            uaid: userAgentID,
+          }));
+          this.serverSendMsg(JSON.stringify({
+            messageType: 'notification',
+            updates: [{
+              channelID: '379c0668-8323-44d2-a315-4ee83f1a9ee9',
+              version: 2,
+            }],
+          }));
+        },
+        onUnregister(request) {
+          equal(request.channelID, '379c0668-8323-44d2-a315-4ee83f1a9ee9', 'Dropped wrong channel ID');
+          unregisterDefer.resolve();
+        },
+        onACK(request) {},
+      });
+    }
+  });
+
+  yield waitForPromise(unregisterDefer.promise, DEFAULT_TIMEOUT,
+    'Timed out waiting for unregister request');
+
+  let expiredRecord = yield db.getByKeyID('379c0668-8323-44d2-a315-4ee83f1a9ee9');
+  strictEqual(expiredRecord.quota, 0, 'Expired record not updated');
+
+  let notifiedScopes = [];
+  let subChangePromise = promiseObserverNotification('push-subscription-change', (subject, data) => {
+    notifiedScopes.push(data);
+    return notifiedScopes.length == 2;
+  });
+
+  // Now visit the site...
+  yield addVisit({
+    uri: 'https://example.com/another-page',
+    title: 'Infrequently-visited page',
+    visits: [{
+      visitDate: Date.now() * 1000,
+      transitionType: Ci.nsINavHistoryService.TRANSITION_LINK,
+    }],
+  });
+  Services.obs.notifyObservers(null, 'idle-daily', '');
+
+  // And we should receive notifications for both scopes.
+  yield waitForPromise(subChangePromise, DEFAULT_TIMEOUT,
+    'Timed out waiting for subscription change events');
+  deepEqual(notifiedScopes.sort(), [
+    'https://example.com/auctions',
+    'https://example.com/deals'
+  ], 'Wrong scopes for subscription changes');
+
+  let aRecord = yield db.getByKeyID('379c0668-8323-44d2-a315-4ee83f1a9ee9');
+  ok(!aRecord, 'Should drop expired record');
+
+  let bRecord = yield db.getByKeyID('eb33fc90-c883-4267-b5cb-613969e8e349');
+  ok(!bRecord, 'Should drop evicted record');
+});
--- a/dom/push/test/xpcshell/test_register_5xxCode_http2.js
+++ b/dom/push/test/xpcshell/test_register_5xxCode_http2.js
@@ -80,17 +80,17 @@ add_task(function* test1() {
   PushService.init({
     serverURI: serverURL + "/subscribe5xxCode",
     service: PushServiceHttp2,
     db
   });
 
   let newRecord = yield PushNotificationService.register(
     'https://example.com/retry5xxCode',
-    { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }
+    ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })
   );
 
   var subscriptionUri = serverURL + '/subscription';
   var pushEndpoint = serverURL + '/pushEndpoint';
   var pushReceiptEndpoint = serverURL + '/receiptPushEndpoint';
   equal(newRecord.subscriptionUri, subscriptionUri,
     'Wrong subscription ID in registration record');
   equal(newRecord.pushEndpoint, pushEndpoint,
--- a/dom/push/test/xpcshell/test_register_case.js
+++ b/dom/push/test/xpcshell/test_register_case.js
@@ -43,17 +43,17 @@ add_task(function* test_register_case() 
           }));
         }
       });
     }
   });
 
   let newRecord = yield waitForPromise(
     PushNotificationService.register('https://example.net/case',
-      { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
+      ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })),
     DEFAULT_TIMEOUT,
     'Mixed-case register response timed out'
   );
   equal(newRecord.pushEndpoint, 'https://example.com/update/case',
     'Wrong push endpoint in registration record');
   equal(newRecord.scope, 'https://example.net/case',
     'Wrong scope in registration record');
 
--- a/dom/push/test/xpcshell/test_register_flush.js
+++ b/dom/push/test/xpcshell/test_register_flush.js
@@ -25,17 +25,18 @@ function run_test() {
 add_task(function* test_register_flush() {
   let db = PushServiceWebSocket.newPushDB();
   do_register_cleanup(() => {return db.drop().then(_ => db.close());});
   let record = {
     channelID: '9bcc7efb-86c7-4457-93ea-e24e6eb59b74',
     pushEndpoint: 'https://example.org/update/1',
     scope: 'https://example.com/page/1',
     originAttributes: '',
-    version: 2
+    version: 2,
+    quota: Infinity,
   };
   yield db.put(record);
 
   let notifyPromise = promiseObserverNotification('push-notification');
 
   let ackDefer = Promise.defer();
   let ackDone = after(2, ackDefer.resolve);
   PushService.init({
--- a/dom/push/test/xpcshell/test_register_invalid_channel.js
+++ b/dom/push/test/xpcshell/test_register_invalid_channel.js
@@ -44,17 +44,17 @@ add_task(function* test_register_invalid
           }));
         }
       });
     }
   });
 
   yield rejects(
     PushNotificationService.register('https://example.com/invalid-channel',
-      { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
+      ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })),
     function(error) {
       return error == 'Invalid channel ID';
     },
     'Wrong error for invalid channel ID'
   );
 
   let record = yield db.getByKeyID(channelID);
   ok(!record, 'Should not store records for error responses');
--- a/dom/push/test/xpcshell/test_register_invalid_endpoint.js
+++ b/dom/push/test/xpcshell/test_register_invalid_endpoint.js
@@ -46,17 +46,17 @@ add_task(function* test_register_invalid
         }
       });
     }
   });
 
   yield rejects(
     PushNotificationService.register(
       'https://example.net/page/invalid-endpoint',
-      { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
+      ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })),
     function(error) {
       return error && error.includes('Invalid pushEndpoint');
     },
     'Wrong error for invalid endpoint'
   );
 
   let record = yield db.getByKeyID(channelID);
   ok(!record, 'Should not store records with invalid endpoints');
--- a/dom/push/test/xpcshell/test_register_invalid_json.js
+++ b/dom/push/test/xpcshell/test_register_invalid_json.js
@@ -45,17 +45,17 @@ add_task(function* test_register_invalid
           registers++;
         }
       });
     }
   });
 
   yield rejects(
     PushNotificationService.register('https://example.net/page/invalid-json',
-      { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
+      ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })),
     function(error) {
       return error == 'TimeoutError';
     },
     'Wrong error for invalid JSON response'
   );
 
   yield waitForPromise(helloDefer.promise, DEFAULT_TIMEOUT,
     'Reconnect after invalid JSON response timed out');
--- a/dom/push/test/xpcshell/test_register_no_id.js
+++ b/dom/push/test/xpcshell/test_register_no_id.js
@@ -49,17 +49,17 @@ add_task(function* test_register_no_id()
           }));
         }
       });
     }
   });
 
   yield rejects(
     PushNotificationService.register('https://example.com/incomplete',
-      { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
+      ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })),
     function(error) {
       return error == 'TimeoutError';
     },
     'Wrong error for incomplete register response'
   );
 
   yield waitForPromise(helloDefer.promise, DEFAULT_TIMEOUT,
     'Reconnect after incomplete register response timed out');
--- a/dom/push/test/xpcshell/test_register_request_queue.js
+++ b/dom/push/test/xpcshell/test_register_request_queue.js
@@ -41,21 +41,21 @@ add_task(function* test_register_request
           ok(false, 'Should cancel timed-out requests');
         }
       });
     }
   });
 
   let firstRegister = PushNotificationService.register(
     'https://example.com/page/1',
-    { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }
+    ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })
   );
   let secondRegister = PushNotificationService.register(
     'https://example.com/page/1',
-    { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }
+    ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })
   );
 
   yield waitForPromise(Promise.all([
     rejects(firstRegister, function(error) {
       return error == 'TimeoutError';
     }, 'Should time out the first request'),
     rejects(secondRegister, function(error) {
       return error == 'TimeoutError';
--- a/dom/push/test/xpcshell/test_register_rollback.js
+++ b/dom/push/test/xpcshell/test_register_rollback.js
@@ -70,17 +70,17 @@ add_task(function* test_register_rollbac
         }
       });
     }
   });
 
   // Should return a rejected promise if storage fails.
   yield rejects(
     PushNotificationService.register('https://example.com/storage-error',
-      { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
+      ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })),
     function(error) {
       return error == 'universe has imploded';
     },
     'Wrong error for unregister database failure'
   );
 
   // Should send an out-of-band unregister request.
   yield waitForPromise(unregisterDefer.promise, DEFAULT_TIMEOUT,
--- a/dom/push/test/xpcshell/test_register_success.js
+++ b/dom/push/test/xpcshell/test_register_success.js
@@ -53,25 +53,29 @@ add_task(function* test_register_success
           }));
         }
       });
     }
   });
 
   let newRecord = yield PushNotificationService.register(
     'https://example.org/1',
-    { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }
+    ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })
   );
   equal(newRecord.channelID, channelID,
     'Wrong channel ID in registration record');
   equal(newRecord.pushEndpoint, 'https://example.com/update/1',
     'Wrong push endpoint in registration record');
   equal(newRecord.scope, 'https://example.org/1',
     'Wrong scope in registration record');
+  equal(newRecord.quota, Infinity,
+    'Wrong quota in registration record');
 
   let record = yield db.getByKeyID(channelID);
   equal(record.channelID, channelID,
     'Wrong channel ID in database record');
   equal(record.pushEndpoint, 'https://example.com/update/1',
     'Wrong push endpoint in database record');
   equal(record.scope, 'https://example.org/1',
     'Wrong scope in database record');
+  equal(record.quota, Infinity,
+    'Wrong quota in database record');
 });
--- a/dom/push/test/xpcshell/test_register_timeout.js
+++ b/dom/push/test/xpcshell/test_register_timeout.js
@@ -79,17 +79,17 @@ add_task(function* test_register_timeout
           registers++;
         }
       });
     }
   });
 
   yield rejects(
     PushNotificationService.register('https://example.net/page/timeout',
-      { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
+      ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })),
     function(error) {
       return error == 'TimeoutError';
     },
     'Wrong error for request timeout'
   );
 
   let record = yield db.getByKeyID(channelID);
   ok(!record, 'Should not store records for timed-out responses');
--- a/dom/push/test/xpcshell/test_register_wrong_id.js
+++ b/dom/push/test/xpcshell/test_register_wrong_id.js
@@ -55,17 +55,17 @@ add_task(function* test_register_wrong_i
           }));
         }
       });
     }
   });
 
   yield rejects(
     PushNotificationService.register('https://example.com/mismatched',
-      { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
+      ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })),
     function(error) {
       return error == 'TimeoutError';
     },
     'Wrong error for mismatched register reply'
   );
 
   yield waitForPromise(helloDefer.promise, DEFAULT_TIMEOUT,
     'Reconnect after mismatched register reply timed out');
--- a/dom/push/test/xpcshell/test_register_wrong_type.js
+++ b/dom/push/test/xpcshell/test_register_wrong_type.js
@@ -51,17 +51,17 @@ add_task(function* test_register_wrong_t
       });
     }
   });
 
   let promise =
 
   yield rejects(
     PushNotificationService.register('https://example.com/mistyped',
-      { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
+      ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })),
     function(error) {
       return error == 'TimeoutError';
     },
     'Wrong error for non-string channel ID'
   );
 
   yield waitForPromise(helloDefer.promise, DEFAULT_TIMEOUT,
     'Reconnect after sending non-string channel ID timed out');
--- a/dom/push/test/xpcshell/test_registration_error.js
+++ b/dom/push/test/xpcshell/test_registration_error.js
@@ -27,15 +27,15 @@ add_task(function* test_registrations_er
     }),
     makeWebSocket(uri) {
       return new MockWebSocket(uri);
     }
   });
 
   yield rejects(
     PushNotificationService.registration('https://example.net/1',
-      { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
+      ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })),
     function(error) {
       return error == 'Database error';
     },
     'Wrong message'
   );
 });
--- a/dom/push/test/xpcshell/test_registration_none.js
+++ b/dom/push/test/xpcshell/test_registration_none.js
@@ -20,11 +20,11 @@ add_task(function* test_registration_non
     networkInfo: new MockDesktopNetworkInfo(),
     makeWebSocket(uri) {
       return new MockWebSocket(uri);
     }
   });
 
   let registration = yield PushNotificationService.registration(
     'https://example.net/1',
-    { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false });
+    ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }));
   ok(!registration, 'Should not open a connection without registration');
 });
--- a/dom/push/test/xpcshell/test_registration_success.js
+++ b/dom/push/test/xpcshell/test_registration_success.js
@@ -16,34 +16,38 @@ function run_test() {
 add_task(function* test_registration_success() {
   let db = PushServiceWebSocket.newPushDB();
   do_register_cleanup(() => {return db.drop().then(_ => db.close());});
   let records = [{
     channelID: 'bf001fe0-2684-42f2-bc4d-a3e14b11dd5b',
     pushEndpoint: 'https://example.com/update/same-manifest/1',
     scope: 'https://example.net/a',
     originAttributes: '',
-    version: 5
+    version: 5,
+    quota: Infinity,
   }, {
     channelID: 'f6edfbcd-79d6-49b8-9766-48b9dcfeff0f',
     pushEndpoint: 'https://example.com/update/same-manifest/2',
     scope: 'https://example.net/b',
     originAttributes: ChromeUtils.originAttributesToSuffix({ appId: 42 }),
-    version: 10
+    version: 10,
+    quota: Infinity,
   }, {
     channelID: 'b1cf38c9-6836-4d29-8a30-a3e98d59b728',
     pushEndpoint: 'https://example.org/update/different-manifest',
     scope: 'https://example.org/c',
     originAttributes: ChromeUtils.originAttributesToSuffix({ appId: 42, inBrowser: true }),
-    version: 15
+    version: 15,
+    quota: Infinity,
   }];
   for (let record of records) {
     yield db.put(record);
   }
 
+  let handshakeDefer = Promise.defer();
   PushService.init({
     serverURI: "wss://push.example.org/",
     networkInfo: new MockDesktopNetworkInfo(),
     makeWebSocket(uri) {
       return new MockWebSocket(uri, {
         onHello(request) {
           equal(request.uaid, userAgentID, 'Wrong device ID in handshake');
           deepEqual(request.channelIDs.sort(), [
@@ -51,21 +55,28 @@ add_task(function* test_registration_suc
             'bf001fe0-2684-42f2-bc4d-a3e14b11dd5b',
             'f6edfbcd-79d6-49b8-9766-48b9dcfeff0f',
           ], 'Wrong channel list in handshake');
           this.serverSendMsg(JSON.stringify({
             messageType: 'hello',
             status: 200,
             uaid: userAgentID
           }));
+          handshakeDefer.resolve();
         }
       });
     }
   });
 
+  yield waitForPromise(
+    handshakeDefer.promise,
+    DEFAULT_TIMEOUT,
+    'Timed out waiting for handshake'
+  );
+
   let registration = yield PushNotificationService.registration(
     'https://example.net/a', '');
   equal(
     registration.pushEndpoint,
     'https://example.com/update/same-manifest/1',
     'Wrong push endpoint for scope'
   );
   equal(registration.version, 5, 'Wrong version for scope');
--- a/dom/push/test/xpcshell/test_registration_success_http2.js
+++ b/dom/push/test/xpcshell/test_registration_success_http2.js
@@ -37,29 +37,32 @@ add_task(function* test_pushNotification
 
   var serverURL = "https://localhost:" + serverPort;
 
   let records = [{
     subscriptionUri: serverURL + '/subscriptionA',
     pushEndpoint: serverURL + '/pushEndpointA',
     pushReceiptEndpoint: serverURL + '/pushReceiptEndpointA',
     scope: 'https://example.net/a',
-    originAttributes: ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })
+    originAttributes: ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
+    quota: Infinity,
   }, {
     subscriptionUri: serverURL + '/subscriptionB',
     pushEndpoint: serverURL + '/pushEndpointB',
     pushReceiptEndpoint: serverURL + '/pushReceiptEndpointB',
     scope: 'https://example.net/b',
-    originAttributes: ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })
+    originAttributes: ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
+    quota: Infinity,
   }, {
     subscriptionUri: serverURL + '/subscriptionC',
     pushEndpoint: serverURL + '/pushEndpointC',
     pushReceiptEndpoint: serverURL + '/pushReceiptEndpointC',
     scope: 'https://example.net/c',
-    originAttributes: ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })
+    originAttributes: ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
+    quota: Infinity,
   }];
 
   for (let record of records) {
     yield db.put(record);
   }
 
   PushService.init({
     serverURI: serverURL,
--- a/dom/push/test/xpcshell/test_resubscribe_4xxCode_http2.js
+++ b/dom/push/test/xpcshell/test_resubscribe_4xxCode_http2.js
@@ -67,17 +67,19 @@ add_task(function* test1() {
   do_test_pending();
 
   var serverURL = "http://localhost:" + httpServer.identity.primaryPort;
 
   let records = [{
     subscriptionUri: serverURL + '/subscription4xxCode',
     pushEndpoint: serverURL + '/pushEndpoint',
     pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint',
-    scope: 'https://example.com/page'
+    scope: 'https://example.com/page',
+    originAttributes: '',
+    quota: Infinity,
   }];
 
   for (let record of records) {
     yield db.put(record);
   }
 
   PushService.init({
     serverURI: serverURL + "/subscribe",
--- a/dom/push/test/xpcshell/test_resubscribe_5xxCode_http2.js
+++ b/dom/push/test/xpcshell/test_resubscribe_5xxCode_http2.js
@@ -77,17 +77,19 @@ add_task(function* test1() {
   do_test_pending();
 
   var serverURL = "http://localhost:" + httpServer.identity.primaryPort;
 
   let records = [{
     subscriptionUri: serverURL + '/subscription5xxCode',
     pushEndpoint: serverURL + '/pushEndpoint',
     pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint',
-    scope: 'https://example.com/page'
+    scope: 'https://example.com/page',
+    originAttributes: '',
+    quota: Infinity,
   }];
 
   for (let record of records) {
     yield db.put(record);
   }
 
   PushService.init({
     serverURI: serverURL + "/subscribe",
--- a/dom/push/test/xpcshell/test_resubscribe_listening_for_msg_error_http2.js
+++ b/dom/push/test/xpcshell/test_resubscribe_listening_for_msg_error_http2.js
@@ -62,17 +62,19 @@ add_task(function* test1() {
   do_test_pending();
 
   var serverURL = "http://localhost:" + httpServer.identity.primaryPort;
 
   let records = [{
     subscriptionUri: 'http://localhost/subscriptionNotExist',
     pushEndpoint: serverURL + '/pushEndpoint',
     pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint',
-    scope: 'https://example.com/page'
+    scope: 'https://example.com/page',
+    originAttributes: '',
+    quota: Infinity,
   }];
 
   for (let record of records) {
     yield db.put(record);
   }
 
   PushService.init({
     serverURI: serverURL + "/subscribe",
--- a/dom/push/test/xpcshell/test_unregister_empty_scope.js
+++ b/dom/push/test/xpcshell/test_unregister_empty_scope.js
@@ -25,15 +25,15 @@ add_task(function* test_unregister_empty
           }));
         }
       });
     }
   });
 
   yield rejects(
     PushNotificationService.unregister('',
-      { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
+      ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false })),
     function(error) {
       return error.error == 'NotFoundError';
     },
     'Wrong error for empty endpoint'
   );
 });
--- a/dom/push/test/xpcshell/test_unregister_error.js
+++ b/dom/push/test/xpcshell/test_unregister_error.js
@@ -16,17 +16,18 @@ function run_test() {
 add_task(function* test_unregister_error() {
   let db = PushServiceWebSocket.newPushDB();
   do_register_cleanup(() => {return db.drop().then(_ => db.close());});
   yield db.put({
     channelID: channelID,
     pushEndpoint: 'https://example.org/update/failure',
     scope: 'https://example.net/page/failure',
     originAttributes: '',
-    version: 1
+    version: 1,
+    quota: Infinity,
   });
 
   let unregisterDefer = Promise.defer();
   PushService.init({
     serverURI: "wss://push.example.org/",
     networkInfo: new MockDesktopNetworkInfo(),
     db,
     makeWebSocket(uri) {
--- a/dom/push/test/xpcshell/test_unregister_invalid_json.js
+++ b/dom/push/test/xpcshell/test_unregister_invalid_json.js
@@ -20,23 +20,25 @@ function run_test() {
 add_task(function* test_unregister_invalid_json() {
   let db = PushServiceWebSocket.newPushDB();
   do_register_cleanup(() => {return db.drop().then(_ => db.close());});
   let records = [{
     channelID: '87902e90-c57e-4d18-8354-013f4a556559',
     pushEndpoint: 'https://example.org/update/1',
     scope: 'https://example.edu/page/1',
     originAttributes: '',
-    version: 1
+    version: 1,
+    quota: Infinity,
   }, {
     channelID: '057caa8f-9b99-47ff-891c-adad18ce603e',
     pushEndpoint: 'https://example.com/update/2',
     scope: 'https://example.net/page/1',
     originAttributes: '',
-    version: 1
+    version: 1,
+    quota: Infinity,
   }];
   for (let record of records) {
     yield db.put(record);
   }
 
   let unregisterDefer = Promise.defer();
   let unregisterDone = after(2, unregisterDefer.resolve);
   PushService.init({
--- a/dom/push/test/xpcshell/test_unregister_not_found.js
+++ b/dom/push/test/xpcshell/test_unregister_not_found.js
@@ -25,13 +25,13 @@ add_task(function* test_unregister_not_f
           }));
         }
       });
     }
   });
 
   let promise = PushNotificationService.unregister(
     'https://example.net/nonexistent',
-    { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false });
+    ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }));
   yield rejects(promise, function(error) {
     return error == 'NotFoundError';
   }, 'Wrong error for nonexistent scope');
 });
--- a/dom/push/test/xpcshell/test_unregister_success.js
+++ b/dom/push/test/xpcshell/test_unregister_success.js
@@ -16,17 +16,18 @@ function run_test() {
 add_task(function* test_unregister_success() {
   let db = PushServiceWebSocket.newPushDB();
   do_register_cleanup(() => {return db.drop().then(_ => db.close());});
   yield db.put({
     channelID,
     pushEndpoint: 'https://example.org/update/unregister-success',
     scope: 'https://example.com/page/unregister-success',
     originAttributes: '',
-    version: 1
+    version: 1,
+    quota: Infinity,
   });
 
   let unregisterDefer = Promise.defer();
   PushService.init({
     serverURI: "wss://push.example.org/",
     networkInfo: new MockDesktopNetworkInfo(),
     db,
     makeWebSocket(uri) {
--- a/dom/push/test/xpcshell/test_unregister_success_http2.js
+++ b/dom/push/test/xpcshell/test_unregister_success_http2.js
@@ -50,16 +50,17 @@ add_task(function* test_pushUnsubscripti
   var serverURL = "https://localhost:" + serverPort;
 
   yield db.put({
     subscriptionUri: serverURL + '/subscriptionUnsubscriptionSuccess',
     pushEndpoint: serverURL + '/pushEndpointUnsubscriptionSuccess',
     pushReceiptEndpoint: serverURL + '/receiptPushEndpointUnsubscriptionSuccess',
     scope: 'https://example.com/page/unregister-success',
     originAttributes: ChromeUtils.originAttributesToSuffix({ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inBrowser: false }),
+    quota: Infinity,
   });
 
   PushService.init({
     serverURI: serverURL,
     db
   });
 
   yield PushNotificationService.unregister(
--- a/dom/push/test/xpcshell/test_webapps_cleardata.js
+++ b/dom/push/test/xpcshell/test_webapps_cleardata.js
@@ -21,17 +21,16 @@ function run_test() {
   );
   run_next_test();
 }
 
 add_task(function* test_webapps_cleardata() {
   let db = PushServiceWebSocket.newPushDB();
   do_register_cleanup(() => {return db.drop().then(_ => db.close());});
 
-  PushService._generateID = () => channelID;
   PushService.init({
     serverURI: "wss://push.example.org",
     networkInfo: new MockDesktopNetworkInfo(),
     db,
     makeWebSocket(uri) {
       return new MockWebSocket(uri, {
         onHello(data) {
           equal(data.messageType, 'hello', 'Handshake: wrong message type');
--- a/dom/push/test/xpcshell/xpcshell.ini
+++ b/dom/push/test/xpcshell/xpcshell.ini
@@ -4,16 +4,18 @@ tail =
 # Push notifications and alarms are currently disabled on Android.
 skip-if = toolkit == 'android'
 
 [test_notification_ack.js]
 [test_notification_duplicate.js]
 [test_notification_error.js]
 [test_notification_incomplete.js]
 [test_notification_version_string.js]
+[test_quota_exceeded.js]
+[test_quota_observer.js]
 [test_register_case.js]
 [test_register_flush.js]
 [test_register_invalid_channel.js]
 [test_register_invalid_endpoint.js]
 [test_register_invalid_json.js]
 [test_register_no_id.js]
 [test_register_request_queue.js]
 [test_register_rollback.js]
--- a/modules/libpref/init/all.js
+++ b/modules/libpref/init/all.js
@@ -4404,16 +4404,20 @@ pref("dom.push.enabled", true);
 // Mobile prefs
 pref("dom.push.enabled", false);
 #endif
 
 pref("dom.push.debug", false);
 pref("dom.push.serverURL", "wss://push.services.mozilla.com/");
 pref("dom.push.userAgentID", "");
 
+// The maximum number of notifications that a service worker can receive
+// without user interaction.
+pref("dom.push.maxQuotaPerSubscription", 16);
+
 // Is the network connection allowed to be up?
 // This preference should be used in UX to enable/disable push.
 pref("dom.push.connection.enabled", true);
 
 // Exponential back-off start is 5 seconds like in HTTP/1.1.
 // Maximum back-off is pingInterval.
 pref("dom.push.retryBaseInterval", 5000);