Starting multi-user syncing

Adding commands to the chat box
Adding InvitationManager with basic operations (TODO: accept)

Change-Id: I1463957c0cfb45fd32b0f69c53b4f51780cd8427
diff --git a/Makefile b/Makefile
index 7bcaa39..da440ef 100644
--- a/Makefile
+++ b/Makefile
@@ -68,7 +68,7 @@
 
 .PHONY: creds
 creds:
-	@principal seekblessings --v23.credentials tmp/creds
+	@principal seekblessings --v23.credentials tmp/creds/$(creds)
 
 .PHONY: syncbase
 syncbase: bin
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 a68d411..2f7211d 100644
--- a/src/components/destination-marker.js
+++ b/src/components/destination-marker.js
@@ -41,12 +41,28 @@
       this.onClear();
     },
 
-    pushClient: function(client, color) {
+    pushClient: function(client, color, update) {
       this.clients.push($.extend({}, this.topClient(), {
         client: client,
         color: color,
         listeners: []
       }));
+      if (update !== false) { //undefined => true
+        this.updateIcon();
+        this.updateTitle();
+      }
+    },
+
+    /**
+     * Flip the top two clients, to deprioritize a low-priority client that was
+     * just pushed.
+     */
+    deprioritizeClient: function() {
+      if (this.clients.length > 1) {
+        var demoted = this.topClient();
+        this.clients.splice(--this.clients.length - 1, 0, demoted);
+      }
+
       this.updateIcon();
       this.updateTitle();
     },
diff --git a/src/components/destination-search.js b/src/components/destination-search.js
index dc3b989..2dffcc9 100644
--- a/src/components/destination-search.js
+++ b/src/components/destination-search.js
@@ -125,6 +125,7 @@
       if (e.which === 13) {
         this.onSubmit(this.getValue());
       }
+      e.stopPropagation();
     }
   },
 
diff --git a/src/components/map-widget.js b/src/components/map-widget.js
index 0143586..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() {
@@ -145,6 +145,8 @@
               dest.setPlace(place);
             }
           }));
+
+          marker.deprioritizeClient();
         });
       }
     }
@@ -194,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();
@@ -202,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..c2857c4 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);
     }
   },
 
@@ -117,9 +137,11 @@
   init: function(initial) {
     this.$ = $('<li>')
       .append(
-        this.$label = $('<span>').addClass('label').append(
-          this.$sender = $('<span>').addClass('username'),
-          this.$timestamp = $('<span>').addClass('timestamp')),
+        this.$label = $('<span>')
+          .addClass('label no-sender no-timestamp')
+          .append(
+            this.$sender = $('<span>').addClass('username'),
+            this.$timestamp = $('<span>').addClass('timestamp')),
         this.$text = $('<span>').addClass('text'));
     if (initial) {
       this.set(initial);
diff --git a/src/components/messages.js b/src/components/messages.js
index 12b1087..c79a5fa 100644
--- a/src/components/messages.js
+++ b/src/components/messages.js
@@ -7,6 +7,9 @@
 
 var Message = require('./message');
 
+var VK_ENTER = 13;
+var VK_ESCAPE = 27;
+
 var Messages = defineClass({
   statics: {
     SLIDE_DOWN: 150,
@@ -60,7 +63,7 @@
     open: function() {
       var $messages = this.$messages;
 
-      if (!this.isOpen()) {
+      if (this.isClosed()) {
         this.$.find('.animating')
           .stop(true)
           .removeClass('animating')
@@ -83,12 +86,15 @@
               }
             });
         }
-
-        this.$content.focus();
       }
+      this.focus();
     },
 
-    push: function(messageData) {
+    focus: function() {
+      this.$content.focus();
+    },
+
+    push: function(message) {
       var self = this;
       $.each(arguments, function() {
         self.pushOne(this);
@@ -113,63 +119,77 @@
 
   privates: {
     inputKey: function(e) {
-      if (e.which === 13) {
-        var message = Message.info(this.$content.prop('value'));
-        message.sender = this.username;
-        this.$content.prop('value', '');
-        this.onMessage(message);
+      switch (e.which) {
+        case VK_ENTER: {
+          var raw = this.$content.prop('value');
+          if (raw) {
+            var message = Message.info(raw);
+            message.sender = this.username;
+            this.$content.prop('value', '');
+            this.onMessage(message, raw);
+          }
+          break;
+        }
+        case VK_ESCAPE: {
+          this.close();
+        }
       }
     },
 
-    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'));
-      } else if (isOld) {
-        messageObject.$.addClass('history');
-      } else {
-        /*
-         * Implementation notes: slideDown won't work properly (won't be able to
-         * calculate goal height) unless the element is in the DOM tree prior
-         * to the call, so we hide first, attach, and then animate. slideDown
-         * implicitly shows the element. Furthermore, it won't run unless the
-         * element starts hidden.
-         *
-         * Similarly, we use animate rather than fadeIn because fadeIn
-         * implicitly hides the element upon completion, resulting in an abrupt
-         * void in the element flow. Instead, we want to keep the element taking
-         * up space while invisible until we've collapsed the height via
-         * slideUp.
-         *
-         * It would be best to use CSS animations, but at this time that would
-         * mean sacrificing either auto-height or flow-affecting sliding.
-         */
-        messageObject.$
-          .addClass('animating')
-          .hide()
-          .slideDown(this.SLIDE_DOWN);
       }
 
-      if (!isOld) {
-        messageObject.onLowerPriority.add(function() {
-          messageObject.$.addClass('history');
+      if (isOld) {
+        message.$.addClass('history');
+      } else {
+        if (!this.isOpen()) {
+          /*
+           * Implementation notes: slideDown won't work properly (won't be able
+           * to calculate goal height) unless the element is in the DOM tree
+           * prior to the call, so we hide first, attach, and then animate.
+           * slideDown implicitly shows the element. Furthermore, it won't run
+           * unless the element starts hidden.
+           *
+           * Similarly, we use animate rather than fadeIn because fadeIn
+           * implicitly hides the element upon completion, resulting in an
+           * abrupt void in the element flow. Instead, we want to keep the
+           * element taking up space while invisible until we've collapsed the
+           * height via slideUp.
+           *
+           * It would be best to use CSS animations, but at this time that would
+           * mean sacrificing either auto-height or flow-affecting sliding.
+           */
+          message.$
+            .addClass('animating')
+            .hide()
+            .slideDown(this.SLIDE_DOWN);
+        }
+
+        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);
               });
@@ -180,6 +200,10 @@
   },
 
   constants: ['$'],
+  /**
+   * @param message the message object that should be sent.
+   * @param raw the raw text input.
+   */
   events: [ 'onMessage' ],
 
   init: function() {
@@ -196,7 +220,7 @@
               $('<div>').append(
                 this.$content = $('<input>')
                   .attr('type', 'text')
-                  .keypress(this.inputKey)));
+                  .keydown(this.inputKey)));
 
     this.$ = $('<div>')
       .addClass('messages headlines')
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/ifc/ops.vdl b/src/ifc/ops.vdl
index 16e442a..5cab7f3 100644
--- a/src/ifc/ops.vdl
+++ b/src/ifc/ops.vdl
@@ -10,7 +10,7 @@
   Collaborate = "C"
 )
 
-// Stub multicast RPCs to mock SyncBase storage.
+// Stub multicast RPCs to mock Syncbase storage.
 // TODO: allow multiple trips (e.g. multiple planned trips).
 type TravelSync interface {
   // Gets the current trip.
diff --git a/src/invitation-manager.js b/src/invitation-manager.js
new file mode 100644
index 0000000..e1a26a4
--- /dev/null
+++ b/src/invitation-manager.js
@@ -0,0 +1,158 @@
+// 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.
+
+require('es6-shim');
+
+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: {
+    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 { identity, mountNames, vanadiumWrapper }
+   */
+  init: function(prereqs, groupManagerPromise) {
+    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.invitations = {};
+
+    prereqs.then(function(args) {
+      self.username = args.identity.username;
+    });
+
+    groupManagerPromise.then(function(gm) {
+      gm.createSyncGroup('invitations', ['invitations'])
+        .catch(self.onError);
+    });
+  }
+});
+
+module.exports = InvitationManager;
\ No newline at end of file
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 1a94323..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,8 +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/syncgroup-manager.js b/src/syncgroup-manager.js
new file mode 100644
index 0000000..97b8040
--- /dev/null
+++ b/src/syncgroup-manager.js
@@ -0,0 +1,92 @@
+// 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.
+
+require('es6-shim');
+
+var vanadium = require('vanadium');
+
+var defineClass = require('./util/define-class');
+var naming = require('./naming');
+
+var SyncgroupManager = defineClass({
+  publics: {
+    createSyncGroup: function(name, prefixes) {
+      var self = this;
+
+      return this.prereq.then(function() {
+        var sg = self.syncbaseWrapper.syncGroup(self.sgAdmin, 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).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();
+    }
+  },
+
+  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.sgAdmin = vanadium.naming.join(mountNames.app, 'sgadmin');
+
+    /* TODO(rosswang): Once Vanadium supports global SyncGroup 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);
+  }
+});
+
+module.exports = SyncgroupManager;
\ No newline at end of file
diff --git a/src/travel.js b/src/travel.js
index 5b1c30c..9aae46d 100644
--- a/src/travel.js
+++ b/src/travel.js
@@ -2,8 +2,9 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+require('es6-shim');
+
 var raf = require('raf');
-var vanadium = require('vanadium');
 
 var $ = require('./util/jquery');
 var defineClass = require('./util/define-class');
@@ -21,8 +22,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,18 +73,7 @@
   control.setPlaceholder(describeDestination.descriptionOpenEnded(destination));
 }
 
-function makeMountNames(id) {
-  // TODO: first-class app-wide rather than siloed by account
-  var parts = ['/ns.dev.v.io:8101', 'users', id.username, 'travel'];
-  var names = {
-    user: vanadium.naming.join(parts)
-  };
-
-  parts.push(id.deviceName);
-  names.device = vanadium.naming.join(parts);
-
-  return names;
-}
+var CMD_REGEX = /\/(\S*)(?:\s+(.*))?/;
 
 var Travel = defineClass({
   publics: {
@@ -91,9 +82,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
+      }));
     }
   },
 
@@ -277,6 +270,53 @@
       raf(this.trimUnusedDestinations);
     },
 
+    runCommand: function(command, rest) {
+      var handler = this.commands[command];
+      if (handler) {
+        var args = handler.parseArgs? handler.parseArgs(rest) : [rest];
+        handler.op.apply(this, args);
+      } else {
+        this.error('Unrecognized command ' + command);
+      }
+    },
+
+    handleInvite: function(owner, recipient, sender) {
+      var self = this;
+      var invitationManager = this.sync.invitationManager;
+      var me = invitationManager.getUsername();
+
+      if (recipient === me) {
+        var message = new Message();
+        message.setType(Message.INFO);
+        message.setHtml(strings.invitationReceived(sender, owner));
+        message.setPromise(new Promise(function(resolve, reject) {
+          message.$.find('a[name=accept]').click(function() {
+            invitationManager.accept(owner).then(function() {
+              return strings.invitationAccepted(sender, owner);
+            }).then(resolve, reject);
+            return false;
+          });
+          message.$.find('a[name=decline]').click(function() {
+            invitationManager.decline(owner).then(function() {
+              return strings.invitationDeclined(sender, owner);
+            }).then(resolve, reject);
+            return false;
+          });
+        }));
+
+        self.messages.push(message);
+      }
+    },
+
+    handleUserMessage: function(message, raw) {
+      var match = CMD_REGEX.exec(raw);
+      if (match) {
+        this.runCommand(match[1], match[2]);
+      } else {
+        this.sync.message(message);
+      }
+    },
+
     trimUnusedDestinations: function() {
       for (var lastControl = this.timeline.get(-1);
           !lastControl.getPlace() && !lastControl.isSelected() &&
@@ -332,11 +372,12 @@
         wrapper.onCrash.add(error);
 
         var identity = new Identity(wrapper.getAccountName());
-        identity.mountNames = makeMountNames(identity);
+        var mountNames = naming.mountNames(identity);
         messages.setUsername(identity.username);
 
         return {
           identity: identity,
+          mountNames: mountNames,
           vanadiumWrapper: wrapper
         };
       });
@@ -371,9 +412,9 @@
       self.messages.push.apply(self.messages, messages);
     });
 
-    messages.onMessage.add(function(message) {
-      sync.message(message);
-    });
+    sync.invitationManager.onInvite.add(this.handleInvite);
+
+    messages.onMessage.add(this.handleUserMessage);
 
     timeline.onAddClick.add(this.handleTimelineDestinationAdd);
 
@@ -447,6 +488,43 @@
 
     destinations.add();
     miniDestinationSearch.focus();
+
+    $domRoot.keypress(function() {
+      messages.open();
+      /* Somehow emergent behavior types the key just hit without any further
+       * code from us. Praise be to the code gods; pray for cross-browser. */
+    });
+
+    this.commands = {
+      invite: {
+        op: function(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 baf55e7..25d5f3a 100644
--- a/src/travelsync.js
+++ b/src/travelsync.js
@@ -13,6 +13,8 @@
 var defineClass = require('./util/define-class');
 
 var debug = require('./debug');
+var SyncgroupManager = require('./syncgroup-manager');
+var InvitationManager = require('./invitation-manager');
 var Place = require('./place');
 
 var vdlTravel = require('../ifc');
@@ -236,7 +238,7 @@
       });
     },
 
-    /* A note on these operations: SyncBase client operations occur
+    /* A note on these operations: Syncbase client operations occur
      * asynchronously, in response to events that can rapidly change state. As
      * such, each write operation must first check to ensure the record it's
      * updating for is still valid (has a defined id).
@@ -382,62 +384,70 @@
       this.processDestinations(data.destinations);
     },
 
-    start: function(args) {
+    serve: function(args) {
       var self = this;
-
+      var mountNames = args.mountNames;
       var vanadiumWrapper = args.vanadiumWrapper;
-      var identity = args.identity;
+
+      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);
-
-          /* TODO(rosswang): Once Vanadium supports global sync-group admin
-           * creation, remove this. For now, use the first local SyncBase
-           * instance to administrate. */
-          var sgAdmin = vanadium.naming.join(
-            identity.mountNames.user, 'sgadmin');
-          return vanadiumWrapper.mount(sgAdmin, sbName,
-              vanadiumWrapper.multiMount.FAIL)
-            .then(function() {
-              var sg = syncbase.syncGroup(sgAdmin, 'trip');
-
-              var spec = sg.buildSpec(
-                [''],
-                [vanadium.naming.join(identity.mountNames.user, 'sgmt')]
-              );
-
-              /* 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);
-            })
-            .then(function() {
-              return syncbase;
-            });
+          self.status.syncbase = 'ready';
+          return syncbase;
+        }, function(err) {
+          self.status.syncbase = 'failed';
+          throw err;
         });
+    },
 
-      return Promise.all([
-        vanadiumWrapper.server(
-          vanadium.naming.join(identity.mountNames.device, 'rpc'), this.server),
-        startSyncbase
-      ]).then(function(values) {
-        return {
-          server: values[0],
-          syncbase: values[1]
-        };
-      });
+    createSyncgroupManager: function(args, syncbase) {
+      var gm = new SyncgroupManager(args.identity, args.vanadiumWrapper,
+        syncbase, args.mountNames);
+      gm.onError.add(this.onError);
+
+      return gm;
+    },
+
+    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' ],
+  constants: [ 'invitationManager', 'startup', 'status' ],
   events: {
     /**
      * @param newSize
@@ -461,12 +471,13 @@
   },
 
   /**
-   * @param promise a promise that produces { mountName, vanadiumWrapper }.
+   * @param prereqs a promise that produces { identity, mountNames,
+   *  vanadiumWrapper }.
    * @mapsDependencies an object with the following keys:
    *  maps
    *  placesService
    */
-  init: function(promise, mapsDependencies) {
+  init: function(prereqs, mapsDependencies) {
     var self = this;
 
     this.mapsDeps = mapsDependencies;
@@ -474,9 +485,35 @@
     this.tripStatus = {};
     this.messages = {};
     this.destRecords = [];
+    this.status = {};
 
     this.server = new vdlTravel.TravelSync();
-    this.startup = promise.then(this.start);
+    var startRpc = prereqs.then(this.serve);
+    var startSyncbase = prereqs.then(this.connectSyncbase);
+    var startSyncgroupManager = Promise
+      .all([prereqs, startSyncbase])
+      .then(function(args) {
+        return self.createSyncgroupManager(args[0], args[1]);
+      });
+    var createPrimarySyncGroup = startSyncgroupManager
+      .then(this.createPrimarySyncGroup);
+
+    this.startup = Promise.all([
+        startRpc,
+        startSyncbase,
+        startSyncgroupManager,
+        createPrimarySyncGroup
+      ]).then(function(values) {
+        return {
+          server: values[0],
+          syncbase: values[1],
+          groupManager: values[2]
+        };
+      });
+
+    this.invitationManager = new InvitationManager(prereqs,
+      startSyncgroupManager);
+    this.invitationManager.onError.add(this.onError);
 
     this.handleDestinationPlaceChange = function() {
       self.updateDestinationPlace(this);
diff --git a/src/vanadium-wrapper/index.js b/src/vanadium-wrapper/index.js
index ba2a3a0..e136879 100644
--- a/src/vanadium-wrapper/index.js
+++ b/src/vanadium-wrapper/index.js
@@ -77,6 +77,16 @@
       return refreshName();
     },
 
+    getPermissions: function(name) {
+      return this.runtime.namespace().getPermissions(
+        this.runtime.getContext(), name);
+    },
+
+    setPermissions: function(name, perms) {
+      return this.runtime.namespace().setPermissions(
+        this.runtime.getContext(), name, perms);
+    },
+
     /**
      * @param endpoint Vanadium name
      * @returns a promise resolving to a client or rejecting with an error.
diff --git a/src/vanadium-wrapper/syncbase-wrapper.js b/src/vanadium-wrapper/syncbase-wrapper.js
index 0c2c3e7..974720b 100644
--- a/src/vanadium-wrapper/syncbase-wrapper.js
+++ b/src/vanadium-wrapper/syncbase-wrapper.js
@@ -85,7 +85,7 @@
       var db = app.noSqlDatabase('db');
 
       return setUp(context, app, db).then(function() {
-        return new SyncbaseWrapper(context, db);
+        return new SyncbaseWrapper(context, db, mountName);
       });
     }
   },
@@ -341,16 +341,21 @@
     }
   },
 
+  constants: [ 'mountName' ],
+
   events: {
     onError: 'memory',
     onUpdate: 'memory'
   },
 
-  init: function(context, db) {
+  init: function(context, db, mountName) {
+    // TODO(rosswang): mountName probably won't be necessary after SyncGroup
+    // admin instances are hosted (see group-manager).
     var self = this;
     this.context = context;
     this.db = db;
     this.t = db.table('t');
+    this.mountName = mountName;
 
     this.writes = new Set();
 
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_';
diff --git a/tools/start_services.sh b/tools/start_services.sh
index 1247ac5..b8e74a5 100644
--- a/tools/start_services.sh
+++ b/tools/start_services.sh
@@ -25,14 +25,17 @@
   fi
 }
 main() {
+  PATH=${PATH}:${V23_ROOT}/release/go/bin
   local -r TMP=tmp
+  local -r CREDS=./tmp/creds/${creds-}
   local -r PORT=${port-4000}
   local -r MOUNTTABLED_ADDR=":$((PORT+1))"
   local -r SYNCBASED_ADDR=":$((PORT))"
+  local -r BLESSINGS=`principal dump --v23.credentials=${CREDS} -s=true`
   mkdir -p $TMP
-  ${V23_ROOT}/release/go/bin/mounttabled \
+  mounttabled \
     --v23.tcp.address=${MOUNTTABLED_ADDR} \
-    --v23.credentials=${TMP}/creds &
+    --v23.credentials=${CREDS} &
   ./bin/syncbased \
     --v=5 \
     --alsologtostderr=false \
@@ -40,8 +43,8 @@
     --name=syncbase \
     --v23.namespace.root=/${MOUNTTABLED_ADDR} \
     --v23.tcp.address=${SYNCBASED_ADDR} \
-    --v23.credentials=${TMP}/creds \
-    --v23.permissions.literal='{"Admin":{"In":["..."]},"Write":{"In":["..."]},"Read":{"In":["..."]},"Resolve":{"In":["..."]},"Debug":{"In":["..."]}}'
+    --v23.credentials=${CREDS} \
+    --v23.permissions.literal="{\"Admin\":{\"In\":[\"${BLESSINGS}\"]},\"Write\":{\"In\":[\"${BLESSINGS}\"]},\"Read\":{\"In\":[\"${BLESSINGS}\"]},\"Resolve\":{\"In\":[\"${BLESSINGS}\"]},\"Debug\":{\"In\":[\"...\"]}}"
   tail -f /dev/null  # wait forever
 }
 main "$@"