Adding basic invitation operations sans accept

Change-Id: I3ffdc0a460f47f02af299cdc7ac14c4a3f1c5bcc
diff --git a/package.json b/package.json
index 65b6895..4166d93 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
     "es6-promisify": "^2.0.0",
     "es6-shim": "^0.33.0",
     "global": "^4.3.0",
+    "htmlencode": "^0.0.4",
     "jquery": "^2.1.4",
     "lodash": "^3.10.1",
     "query-string": "^2.4.0",
diff --git a/src/components/destination-marker.js b/src/components/destination-marker.js
index af4d6ec..f496c3e 100644
--- a/src/components/destination-marker.js
+++ b/src/components/destination-marker.js
@@ -47,7 +47,7 @@
         color: color,
         listeners: []
       }));
-      if (update) {
+      if (update !== false) {
         this.updateIcon();
         this.updateTitle();
       }
diff --git a/src/components/map-widget.js b/src/components/map-widget.js
index 68632b0..40b643e 100644
--- a/src/components/map-widget.js
+++ b/src/components/map-widget.js
@@ -136,7 +136,7 @@
           var place = new Place(result);
 
           var marker = self.getOrCreateMarker(place, SEARCH_CLIENT,
-            DestinationMarker.color.RED);
+            DestinationMarker.color.RED, null, false);
           self.searchMarkers.push(marker);
 
           marker.onClick.add(marker.restrictListenerToClient(function() {
@@ -196,7 +196,7 @@
       this.destMeta.delete(destination);
     },
 
-    getOrCreateMarker: function(place, client, color, mergePredicate) {
+    getOrCreateMarker: function(place, client, color, mergePredicate, update) {
       var self = this;
 
       var key = place.toKey();
@@ -204,7 +204,7 @@
       var marker = this.markers[key];
       if (marker) {
         if (!mergePredicate || mergePredicate(marker)) {
-          marker.pushClient(client, color);
+          marker.pushClient(client, color, update);
         } else {
           marker = null;
         }
diff --git a/src/components/message.js b/src/components/message.js
index e68ce89..3cb1918 100644
--- a/src/components/message.js
+++ b/src/components/message.js
@@ -53,7 +53,13 @@
       this.$text.text(text);
     },
 
+    setHtml: function(html) {
+      this.$text.html(html);
+    },
+
     setTimestamp: function(timestamp) {
+      this.timestamp = timestamp;
+
       var fmt;
       if (timestamp === null || timestamp === undefined) {
         fmt = '';
@@ -68,6 +74,10 @@
       }
     },
 
+    getTimestamp: function() {
+      return this.timestamp;
+    },
+
     setSender: function(sender) {
       this.$sender.text(sender);
       if (sender) {
@@ -77,32 +87,42 @@
       }
     },
 
-    set: function(message) {
-      if (!message) {
-        this.onLowerPriority();
-        return;
-      }
-
-      if (typeof message === 'string') {
-        message = Message.info(message);
-      }
-
+    setPromise: function(promise) {
       var self = this;
-
-      this.setType(message.type);
-      this.setSender(message.sender);
-      this.setTimestamp(message.timestamp);
-      this.setText(message.text);
-
-      if (message.promise) {
-        message.promise.then(function(message) {
-          self.set(message);
+      if (promise) {
+        promise.then(function(obj) {
+          self.set(obj);
         }, function(err) {
           self.set(Message.error(err));
         });
       } else {
         this.onLowerPriority();
       }
+    },
+
+    set: function(obj) {
+      if (!obj) {
+        this.onLowerPriority();
+        return;
+      }
+
+      if (typeof obj === 'string') {
+        obj = {
+          type: Message.INFO,
+          text: obj
+        };
+      }
+
+      this.setType(obj.type);
+      this.setSender(obj.sender);
+      this.setTimestamp(obj.timestamp);
+      if (obj.text) {
+        this.setText(obj.text);
+      } else {
+        this.setHtml(obj.html);
+      }
+
+      this.setPromise(obj.promise);
     }
   },
 
diff --git a/src/components/messages.js b/src/components/messages.js
index 3a25184..65d3c87 100644
--- a/src/components/messages.js
+++ b/src/components/messages.js
@@ -91,7 +91,7 @@
       this.$content.focus();
     },
 
-    push: function(messageData) {
+    push: function(message) {
       var self = this;
       $.each(arguments, function() {
         self.pushOne(this);
@@ -133,22 +133,25 @@
       }
     },
 
-    pushOne: function(messageData) {
+    pushOne: function(message) {
       var self = this;
 
-      var messageObject = new Message(messageData);
-      this.$messages.append(messageObject.$);
+      if ($.isPlainObject(message)) {
+        message = new Message(message);
+      }
 
-      var isOld = messageData.timestamp !== undefined &&
-        messageData.timestamp !== null &&
-        Date.now() - messageData.timestamp >= Messages.OLD;
+      this.$messages.append(message.$);
+
+      var timestamp = message.getTimestamp();
+      var isOld = timestamp !== undefined && timestamp !== null &&
+        Date.now() - timestamp >= Messages.OLD;
 
       if (this.isOpen()) {
         this.$messages.scrollTop(this.$messages.prop('scrollHeight'));
       }
 
       if (isOld) {
-        messageObject.$.addClass('history');
+        message.$.addClass('history');
       } else {
         if (!this.isOpen()) {
           /*
@@ -167,23 +170,23 @@
            * It would be best to use CSS animations, but at this time that would
            * mean sacrificing either auto-height or flow-affecting sliding.
            */
-          messageObject.$
+          message.$
             .addClass('animating')
             .hide()
             .slideDown(this.SLIDE_DOWN);
         }
 
-        messageObject.onLowerPriority.add(function() {
-          messageObject.$.addClass('history');
+        message.onLowerPriority.add(function() {
+          message.$.addClass('history');
 
           if (self.isClosed()) {
-            messageObject.$
+            message.$
               .addClass('animating')
               .show()
               .delay(Messages.TTL)
               .animate({ opacity: 0 }, Messages.FADE)
               .slideUp(Messages.SLIDE_UP, function() {
-                messageObject.$
+                message.$
                   .removeClass('animating')
                   .attr('style', null);
               });
diff --git a/src/group-manager.js b/src/group-manager.js
index 4b43e01..60925ac 100644
--- a/src/group-manager.js
+++ b/src/group-manager.js
@@ -5,6 +5,7 @@
 var vanadium = require('vanadium');
 
 var defineClass = require('./util/define-class');
+var naming = require('./naming');
 
 var GroupManager = defineClass({
   publics: {
@@ -14,35 +15,75 @@
       return this.prereq.then(function() {
         var sg = self.syncbaseWrapper.syncGroup(self.sgAdmin, name);
 
-        var spec = sg.buildSpec(
-          prefixes,
-          [self.name('sgmt', name)]
-        );
+        var mgmt = vanadium.naming.join(self.mountNames.app, 'sgmt', name);
+        var spec = sg.buildSpec(prefixes, [mgmt]);
 
         /* TODO(rosswang): Right now, duplicate SyncBase creates on
          * different SyncBase instances results in siloed SyncGroups.
          * Revisit this logic once it merges properly. */
-        return sg.joinOrCreate(spec);
+        return sg.joinOrCreate(spec).then(function() {
+          // TODO(rosswang): this is a hack to make the syncgroup joinable
+          return self.vanadiumWrapper.setPermissions(mgmt, new Map([
+            ['Admin', {in: ['...']}],
+            ['Read', {in: ['...']}],
+            ['Resolve', {in: ['...']}]
+          ]));
+        });
       });
+    },
+
+    joinSyncGroup: function(owner, name) {
+      var sg = this.syncbaseWrapper.syncGroup(
+        vanadium.naming.join(naming.appMount(owner), 'sgadmin'), name);
+      return sg.join();
     }
   },
 
-  init: function(vanadiumWrapper, syncbaseWrapper, mountNames) {
+  privates: {
+    advertise: function() {
+      var self = this;
+
+      var basicPerms = new Map([
+        ['Admin', {in: [this.identity.account]}],
+        ['Read', {in: ['...']}],
+        ['Resolve', {in: ['...']}]
+      ]);
+
+      return Promise.all([
+        /* TODO(rosswang): this is a very short term hack just because user
+         * mount names on ns.dev.v.io don't yet default to Resolve in [...].
+         */
+        this.vanadiumWrapper.setPermissions(this.mountNames.user, basicPerms),
+        this.vanadiumWrapper.setPermissions(this.mountNames.app, basicPerms),
+        this.prereq.then(function() {
+          // TODO(rosswang): This seems wrong too.
+          return self.vanadiumWrapper.setPermissions(self.sgAdmin, basicPerms);
+        })
+      ]);
+    }
+  },
+
+  constants: [ 'sgAdmin', 'syncbaseWrapper' ],
+
+  events: {
+    onError: 'memory'
+  },
+
+  init: function(identity, vanadiumWrapper, syncbaseWrapper, mountNames) {
+    this.identity = identity;
+    this.vanadiumWrapper = vanadiumWrapper;
     this.syncbaseWrapper = syncbaseWrapper;
+    this.mountNames = mountNames;
 
-    this.name = function() {
-      var parts = [mountNames.app];
-      Array.prototype.push.apply(parts, arguments);
-      return vanadium.naming.join(parts);
-    };
-
-    this.sgAdmin = this.name('sgadmin');
+    this.sgAdmin = vanadium.naming.join(mountNames.app, 'sgadmin');
 
     /* TODO(rosswang): Once Vanadium supports global sync-group admin
      * creation, remove this. For now, use the first local SyncBase
      * instance to administrate. */
     this.prereq = vanadiumWrapper.mount(this.sgAdmin, syncbaseWrapper.mountName,
       vanadiumWrapper.multiMount.FAIL);
+
+    this.advertise().catch(this.onError);
   }
 });
 
diff --git a/src/identity.js b/src/identity.js
index d9211cf..b13e25e 100644
--- a/src/identity.js
+++ b/src/identity.js
@@ -7,7 +7,10 @@
 module.exports = Identity;
 
 function Identity(accountName) {
-  this.username = extractUsername(accountName);
+  var account = processAccountName(accountName);
+
+  this.account = account.name;
+  this.username = account.username;
   this.deviceType = 'desktop';
   this.deviceId = uuid.v4();
 
@@ -19,15 +22,22 @@
   return uuid.v4();
 }
 
-function extractUsername(accountName) {
+var ACCOUNT_REGEX = /(dev\.v\.io\/u\/([^\/]+)).*/;
+
+function processAccountName(accountName) {
   if (!accountName || accountName === 'unknown') {
-    return autoUsername();
+    return {
+      name: '...',
+      username: autoUsername()
+    };
   }
 
-  var parts = accountName.split('/');
-  if (parts[0] !== 'dev.v.io' || parts[1] !== 'u') {
-    return accountName;
-  }
-
-  return parts[2];
+  var match = ACCOUNT_REGEX.exec(accountName);
+  return match? {
+    name: match[1],
+    username: match[2]
+  } : {
+    name: accountName,
+    username: accountName
+  };
 }
diff --git a/src/invitation-manager.js b/src/invitation-manager.js
index 83488a9..9b0d878 100644
--- a/src/invitation-manager.js
+++ b/src/invitation-manager.js
@@ -2,50 +2,154 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+var $ = require('./util/jquery');
 var defineClass = require('./util/define-class');
 
+// TODO(rosswang): generalize this
+var ESC = {
+  '_': '_',
+  '.': 'd',
+  '@': 'a'
+};
+
+var INV = {};
+$.each(ESC, function(k, v) {
+  INV[v] = k;
+});
+
+function escapeUsername(str) {
+  return str.replace(/_|\.|@/g, function(m) {
+    return '_' + ESC[m];
+  });
+}
+
+function unescapeUsername(str) {
+  return str.replace(/_(.)/g, function(m, p1) {
+    return INV[p1];
+  });
+}
+
+function invitationKey(owner, recipient) {
+  return ['invitations', escapeUsername(owner), escapeUsername(recipient)];
+}
+
 var InvitationManager = defineClass({
   publics: {
+    accept: function(owner) {
+    },
+
+    decline: function(owner) {
+      return Promise.all([
+        this.syncbasePromise,
+        this.prereqs
+      ]).then(function(args) {
+        var syncbase = args[0];
+        var username = args[1].identity.username;
+
+        return syncbase.delete(invitationKey(owner, username));
+      });
+    },
+
+    getActiveInvite: function() {
+      return this.activeInvite;
+    },
+
     invite: function(username) {
+      var self = this;
+
+      return this.groupManagerPromise.then(function(gm) {
+        return gm.joinSyncGroup(username, 'invitations').then(function() {
+          return self.prereqs;
+        }).then(function(prereqs) {
+          var owner = self.activeInvite || prereqs.identity.username;
+
+          return gm.syncbaseWrapper.put(invitationKey(owner, username),
+            prereqs.identity.username);
+        });
+      });
+    },
+
+    getUsername: function() {
+      return this.username;
     }
   },
 
   privates: {
-    /**
-     * TODO(rosswang): this is a very short term hack just because user mount
-     * names on ns.dev.v.io don't yet default to Resolve in [...].
-     */
-    advertise: function() {
-      return this.prereqs.then(function(prereqs) {
-        var mountNames = prereqs.mountNames;
-        var vanadiumWrapper = prereqs.vanadiumWrapper;
-        return vanadiumWrapper.getPermissions(mountNames.user)
-          .then(function(results) {
-            var perms = results[0];
-            perms.set('Resolve', {in: ['...']});
-            return vanadiumWrapper.setPermissions(mountNames.user, perms);
+    processUpdates: function(data) {
+      var self = this;
+
+      if (data.invitations) {
+        $.each(data.invitations, function(owner, record) {
+          var ownerInvites = self.invitations[owner];
+          if (!ownerInvites) {
+            ownerInvites = self.invitations[owner] = {};
+          }
+
+          $.each(record, function(recipient, sender) {
+            if (ownerInvites[recipient]) {
+              delete ownerInvites[recipient];
+            } else {
+              self.onInvite(unescapeUsername(owner),
+                unescapeUsername(recipient), sender);
+            }
           });
-      });
+        });
+      }
+
+      if (this.invitations) {
+        $.each(this.invitations, function(owner, record) {
+          $.each(record, function(recipient, sender) {
+            self.onDismiss(unescapeUsername(owner),
+              unescapeUsername(recipient), sender);
+          });
+        });
+      }
+
+      this.invitations = data.invitations || {};
     }
   },
 
   events: {
+    /**
+     * @param owner the user who owns the trip.
+     * @param recipient the user invited to the trip.
+     * @param sender the user who sent the invitation.
+     */
+    onInvite: '',
+
+    /**
+     * @param owner the user who owns the trip.
+     * @param recipient the user invited to the trip.
+     * @param sender the user who sent the invitation.
+     */
+    onDismiss: '',
+
     onError: 'memory'
   },
 
   /**
-   * @param prereqs promise of { mountNames, vanadiumWrapper }
+   * @param prereqs promise of { identity, mountNames, vanadiumWrapper }
    */
   init: function(prereqs, groupManagerPromise) {
-    this.prereqs = prereqs;
+    var self = this;
 
+    this.prereqs = prereqs;
+    this.syncbasePromise = groupManagerPromise.then(function(gm) {
+      gm.syncbaseWrapper.onUpdate.add(self.processUpdates);
+      return gm.syncbaseWrapper;
+    });
     this.groupManagerPromise = groupManagerPromise;
 
-    this.createInvitationsSg = groupManagerPromise.then(function(gm) {
-      return gm.createSyncGroup('invitations', ['invitations']);
+    this.invitations = {};
+
+    prereqs.then(function(args) {
+      self.username = args.identity.username;
     });
 
-    this.advertise().catch(this.onError);
+    groupManagerPromise.then(function(gm) {
+      gm.createSyncGroup('invitations', ['invitations'])
+        .catch(self.onError);
+    });
   }
 });
 
diff --git a/src/naming.js b/src/naming.js
new file mode 100644
index 0000000..cc371c6
--- /dev/null
+++ b/src/naming.js
@@ -0,0 +1,34 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+var vanadium = require('vanadium');
+
+var ROOT = '/ns.dev.v.io:8101';
+
+function userMount(username) {
+  return vanadium.naming.join(ROOT, 'users', username);
+}
+
+function appMount(username) {
+  return vanadium.naming.join(userMount(username), 'travel');
+}
+
+function deviceMount(username, deviceName) {
+  return vanadium.naming.join(appMount(username), deviceName);
+}
+
+function mountNames(id) {
+  return {
+    user: userMount(id.username),
+    app: appMount(id.username),
+    device: deviceMount(id.username, id.deviceName)
+  };
+}
+
+module.exports = {
+  userMount: userMount,
+  appMount: appMount,
+  deviceMount: deviceMount,
+  mountNames: mountNames
+};
diff --git a/src/static/index.css b/src/static/index.css
index db4d8ef..a0e7517 100644
--- a/src/static/index.css
+++ b/src/static/index.css
@@ -151,6 +151,12 @@
   margin-top: 3px;
 }
 
+.messages.headlines a {
+  background-color: rgba(255, 255, 255, .8);
+  border-radius: 2px;
+  padding: 1px 3px;
+}
+
 .messages.headlines li.history {
   display: none;
 }
diff --git a/src/strings.js b/src/strings.js
index d5d0362..110e555 100644
--- a/src/strings.js
+++ b/src/strings.js
@@ -2,6 +2,24 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+var htmlEncode = require('htmlencode').htmlEncode;
+
+function text(plainText) {
+  return htmlEncode(plainText);
+}
+
+function link(name, linkText) {
+  return '<a name="' + name + '" href="#">' + text(linkText) + '</a>';
+}
+
+function pre(preText) {
+  return '<pre>' + preText + '</pre>';
+}
+
+function ownerOfTrip(sender, owner) {
+  return sender === owner? 'a' : owner + '\'s';
+}
+
 function getStrings(locale) {
   return {
     'Add destination': 'Add destination',
@@ -29,9 +47,36 @@
     label: function(label, details) {
       return label + ': ' + details;
     },
+    invitationAccepted: function(sender, owner) {
+      return 'Accepted invite from ' + sender + ' to join ' +
+        ownerOfTrip(sender, owner) + ' trip.';
+    },
+    invitationDeclined: function(sender, owner) {
+      return 'Declined invite from ' + sender + ' to join ' +
+        ownerOfTrip(sender, owner) + ' trip.';
+    },
+    invitationReceived: function(sender, owner) {
+      return text(sender + ' has invited you to join ' +
+        ownerOfTrip(sender, owner) + ' trip. ') +
+        link('accept', 'Accept') + text(' / ') + link('decline', 'Decline');
+    },
+    invitationSent: function(recipient, sender) {
+      return sender?
+        sender + ' invited ' + recipient + ' to join the trip.' :
+        'Invited ' + recipient + ' to join the trip.';
+    },
     'Not connected': 'Not connected',
+    notReachable: function(username) {
+      return username + ' is not reachable or is not a Travel Planner user.';
+    },
     'Origin': 'Origin',
     'Search': 'Search',
+    sendingInvite: function(username) {
+      return 'Inviting ' + username + ' to join the trip...';
+    },
+    status: function(status) {
+      return text('Status: ') + pre(status);
+    },
     'Timeline': 'Timeline',
     'Travel Planner': 'Travel Planner',
     'Unknown error': 'Unknown error'
diff --git a/src/travel.js b/src/travel.js
index c3ff2df..9707d59 100644
--- a/src/travel.js
+++ b/src/travel.js
@@ -2,8 +2,8 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+var Deferred = require('vanadium/src/lib/deferred');
 var raf = require('raf');
-var vanadium = require('vanadium');
 
 var $ = require('./util/jquery');
 var defineClass = require('./util/define-class');
@@ -21,8 +21,9 @@
 
 var vanadiumWrapperDefault = require('./vanadium-wrapper');
 
-var strings = require('./strings').currentLocale;
 var describeDestination = require('./describe-destination');
+var naming = require('./naming');
+var strings = require('./strings').currentLocale;
 
 function bindControlToDestination(control, destination) {
   function updateOrdinal() {
@@ -71,21 +72,6 @@
   control.setPlaceholder(describeDestination.descriptionOpenEnded(destination));
 }
 
-function makeMountNames(id) {
-  var parts = ['/ns.dev.v.io:8101', 'users', id.username];
-  var names = {
-    user: vanadium.naming.join(parts)
-  };
-
-  parts.push('travel');
-  names.app = vanadium.naming.join(parts);
-
-  parts.push(id.deviceName);
-  names.device = vanadium.naming.join(parts);
-
-  return names;
-}
-
 var CMD_REGEX = /\/(\S*)(?:\s+(.*))?/;
 
 var Travel = defineClass({
@@ -95,9 +81,11 @@
     },
 
     info: function (info, promise) {
-      var messageData = Message.info(info);
-      messageData.promise = promise;
-      this.messages.push(messageData);
+      this.messages.push(new Message({
+        type: Message.INFO,
+        text: info,
+        promise: promise
+      }));
     }
   },
 
@@ -291,6 +279,37 @@
       }
     },
 
+    handleInvite: function(owner, recipient, sender) {
+      var self = this;
+      var invitationManager = this.sync.invitationManager;
+      var me = invitationManager.getUsername();
+
+      if (recipient === me) {
+        var async = new Deferred();
+
+        var message = new Message({
+          type: Message.INFO,
+          html: strings.invitationReceived(sender, owner),
+          promise: async.promise
+        });
+
+        message.$.find('a[name=accept]').click(function() {
+          invitationManager.accept(owner).then(function() {
+            return strings.invitationAccepted(sender, owner);
+          }).then(async.resolve, async.reject);
+          return false;
+        });
+        message.$.find('a[name=decline]').click(function() {
+          invitationManager.decline(owner).then(function() {
+            return strings.invitationDeclined(sender, owner);
+          }).then(async.resolve, async.reject);
+          return false;
+        });
+
+        self.messages.push(message);
+      }
+    },
+
     handleUserMessage: function(message, raw) {
       var match = CMD_REGEX.exec(raw);
       if (match) {
@@ -355,10 +374,11 @@
         wrapper.onCrash.add(error);
 
         var identity = new Identity(wrapper.getAccountName());
-        var mountNames = makeMountNames(identity);
+        var mountNames = naming.mountNames(identity);
         messages.setUsername(identity.username);
 
         return {
+          identity: identity,
           mountNames: mountNames,
           vanadiumWrapper: wrapper
         };
@@ -394,6 +414,8 @@
       self.messages.push.apply(self.messages, messages);
     });
 
+    sync.invitationManager.onInvite.add(this.handleInvite);
+
     messages.onMessage.add(this.handleUserMessage);
 
     timeline.onAddClick.add(this.handleTimelineDestinationAdd);
@@ -478,7 +500,30 @@
     this.commands = {
       invite: {
         op: function(username) {
-          this.sync.invitationManager.invite(username);
+          this.info(strings.sendingInvite(username),
+            this.sync.invitationManager.invite(username)
+              .then(function() {
+                var me = self.sync.invitationManager.getUsername();
+                self.sync.message({
+                  type: Message.INFO,
+                  text: strings.invitationSent(username, me)
+                });
+              }, function(err) {
+                if (err.id === 'v.io/v23/verror.NoServers') {
+                  throw strings.notReachable(username);
+                } else {
+                  throw err;
+                }
+              }));
+        }
+      },
+
+      status: {
+        op: function() {
+          this.messages.push(new Message({
+            type: Message.INFO,
+            html: strings.status(JSON.stringify(this.sync.status, null, 2))
+          }));
         }
       }
     };
diff --git a/src/travelsync.js b/src/travelsync.js
index 36f73e1..16be682 100644
--- a/src/travelsync.js
+++ b/src/travelsync.js
@@ -384,50 +384,70 @@
       this.processDestinations(data.destinations);
     },
 
-    start: function(args) {
+    serve: function(args) {
       var self = this;
-
-      var vanadiumWrapper = args.vanadiumWrapper;
       var mountNames = args.mountNames;
+      var vanadiumWrapper = args.vanadiumWrapper;
+
+      this.status.rpc = 'starting';
+      return vanadiumWrapper.server(
+          vanadium.naming.join(mountNames.device, 'rpc'), this.server)
+        .then(function(server) {
+          self.status.rpc = 'ready';
+          return server;
+        }, function(err) {
+          self.status.rpc = 'failed';
+          throw err;
+        });
+    },
+
+    connectSyncbase: function(args) {
+      var self = this;
+      var vanadiumWrapper = args.vanadiumWrapper;
 
       var sbName = queryString.parse(location.search).syncbase || 4000;
       if ($.isNumeric(sbName)) {
         sbName = '/localhost:' + sbName;
       }
 
-      var startSyncbase = vanadiumWrapper
+      this.status.syncbase = 'starting';
+      return vanadiumWrapper
         .syncbase(sbName)
         .then(function(syncbase) {
           syncbase.onError.add(self.onError);
           syncbase.onUpdate.add(self.processUpdates);
+          self.status.syncbase = 'ready';
           return syncbase;
+        }, function(err) {
+          self.status.syncbase = 'failed';
+          throw err;
         });
+    },
 
-      var gmp = startSyncbase.then(function(syncbase) {
-        return new GroupManager(vanadiumWrapper, syncbase, mountNames);
-      });
+    createGroupManager: function(args, syncbase) {
+      var gm = new GroupManager(args.identity, args.vanadiumWrapper, syncbase,
+        args.mountNames);
+      gm.onError.add(this.onError);
 
-      var createPrimarySyncGroup = gmp.then(function(gm) {
-          return gm.createSyncGroup('trip', ['']);
-      });
+      return gm;
+    },
 
-      return Promise.all([
-        vanadiumWrapper.server(
-          vanadium.naming.join(mountNames.device, 'rpc'), this.server),
-        startSyncbase,
-        gmp,
-        createPrimarySyncGroup
-      ]).then(function(values) {
-        return {
-          server: values[0],
-          syncbase: values[1],
-          groupManager: values[2]
-        };
-      });
+    createPrimarySyncGroup: function(groupManager) {
+      var self = this;
+
+      this.status.tripSyncGroup = 'creating';
+      return groupManager.createSyncGroup('trip', [''])
+        .then(function(sg) {
+          self.status.tripSyncGroup = 'created';
+          return sg;
+        }, function(err) {
+          self.status.tripSyncGroup = 'failed';
+          throw err;
+        });
     }
   },
 
-  constants: [ 'startup', 'invitationManager' ],
+  constants: [ 'invitationManager', 'startup', 'status' ],
   events: {
     /**
      * @param newSize
@@ -451,7 +471,8 @@
   },
 
   /**
-   * @param prereqs a promise that produces { mountNames, vanadiumWrapper }.
+   * @param prereqs a promise that produces { identity, mountNames,
+   *  vanadiumWrapper }.
    * @mapsDependencies an object with the following keys:
    *  maps
    *  placesService
@@ -464,16 +485,33 @@
     this.tripStatus = {};
     this.messages = {};
     this.destRecords = [];
+    this.status = {};
 
     this.server = new vdlTravel.TravelSync();
-    this.startup = prereqs.then(this.start);
+    var startRpc = prereqs.then(this.serve);
+    var startSyncbase = prereqs.then(this.connectSyncbase);
+    var startGroupManager = Promise
+      .all([prereqs, startSyncbase])
+      .then(function(args) {
+        return self.createGroupManager(args[0], args[1]);
+      });
+    var createPrimarySyncGroup = startGroupManager
+      .then(this.createPrimarySyncGroup);
 
-    this.invitationManager = new InvitationManager(
-      prereqs,
-      this.startup.then(function(services) {
-        return services.groupManager;
-      }));
+    this.startup = Promise.all([
+        startRpc,
+        startSyncbase,
+        startGroupManager,
+        createPrimarySyncGroup
+      ]).then(function(values) {
+        return {
+          server: values[0],
+          syncbase: values[1],
+          groupManager: values[2]
+        };
+      });
 
+    this.invitationManager = new InvitationManager(prereqs, startGroupManager);
     this.invitationManager.onError.add(this.onError);
 
     this.handleDestinationPlaceChange = function() {
diff --git a/test/identity.js b/test/identity.js
index 98c2988..42d7d2e 100644
--- a/test/identity.js
+++ b/test/identity.js
@@ -6,23 +6,24 @@
 
 var Identity = require('../src/identity');
 
-function verifyAutoAccountName(t, n) {
-  t.assert(n.length > 1, 'auto-generated username is nontrivial');
+function verifyAutoAccount(t, i) {
+  t.equals(i.account, '...', 'unknown account defaults to open');
+  t.assert(i.username.length > 1, 'auto-generated username is nontrivial');
 }
 
 test('auto-generated username from unknown', function(t) {
-  var a = new Identity('unknown').username,
-      b = new Identity('unknown').username;
-  verifyAutoAccountName(t, a);
-  verifyAutoAccountName(t, b);
-  t.notEqual(b, a, 'auto-generated username is unique');
+  var a = new Identity('unknown'),
+      b = new Identity('unknown');
+  verifyAutoAccount(t, a);
+  verifyAutoAccount(t, b);
+  t.notEqual(b.username, a.username, 'auto-generated username is unique');
   t.end();
 });
 
 function testAutoExtract(t, r) {
-  var n = new Identity(r).username;
-  verifyAutoAccountName(t, n);
-  t.not(n, r);
+  var i = new Identity(r);
+  verifyAutoAccount(t, i);
+  t.not(i.username, r);
   t.end();
 }
 
@@ -44,6 +45,8 @@
 
 test('init', function(t) {
   var i = new Identity(testAccountName);
+  t.equals(i.account, 'dev.v.io/u/joeuser@google.com',
+    'should generalize a dev.v.io account name');
   t.equals(i.username, 'joeuser@google.com',
     'should extract a username from a dev.v.io account name');
   var expectedPrefix = 'joeuser@google.com/desktop_';