SyncGroup permisions and lazy trip SGs

Change-Id: I8b31c18a88c29b465462dd413e5ad598476b7466
diff --git a/mocks/syncbase-wrapper.js b/mocks/syncbase-wrapper.js
index a8368a1..a3dfd6c 100644
--- a/mocks/syncbase-wrapper.js
+++ b/mocks/syncbase-wrapper.js
@@ -4,6 +4,8 @@
 
 require('es6-shim');
 
+var verror = require('vanadium').verror;
+
 var $ = require('../src/util/jquery');
 var defineClass = require('../src/util/define-class');
 
@@ -185,30 +187,56 @@
 
     syncGroup: function(sgAdmin, name) {
       var repo = this.repo;
+      var sgp;
 
-      var sgp = {
+      function sgFactory(spec) {
+        return function() {
+          var sg = new Set();
+          sg.prefixes = spec;
+          return sg;
+        };
+      }
+
+      function errNoExist() {
+        return new verror.NoExistError(null, 'SyncGroup does not exist.');
+      }
+
+      function getSg() {
+        return syncgroups[sgAdmin + '$' + name];
+      }
+
+      function joinSg(factory) {
+        var sgKey = sgAdmin + '$' + name;
+        var sg = syncgroups[sgKey];
+        if (!sg) {
+          sg = syncgroups[sgKey] = factory();
+        }
+        sg.add(repo);
+
+        return Promise.resolve(sgp);
+      }
+
+      sgp = {
         buildSpec: function(prefixes) {
           return prefixes;
         },
 
+        changeSpec: function(){
+          return getSg()? Promise.resolve() : Promise.reject(errNoExist());
+        },
+
         join: function() {
-          var sgKey = sgAdmin + '$' + name;
-          var sg = syncgroups[sgKey];
-          sg.add(repo);
-          return Promise.resolve(sgp);
+          return joinSg(function() {
+            throw errNoExist();
+          });
+        },
+
+        createOrJoin: function(spec) {
+          return joinSg(sgFactory(spec));
         },
 
         joinOrCreate: function(spec) {
-          var sgKey = sgAdmin + '$' + name;
-          var sg = syncgroups[sgKey];
-          if (!sg) {
-            sg = syncgroups[sgKey] = new Set();
-          }
-
-          sg.prefixes = spec;
-          sg.add(repo);
-
-          return Promise.resolve(sgp);
+          return joinSg(sgFactory(spec));
         }
       };
 
diff --git a/src/identity.js b/src/identity.js
index b13e25e..1c9c832 100644
--- a/src/identity.js
+++ b/src/identity.js
@@ -18,6 +18,10 @@
   this.entityName = this.username + '/' + this.deviceName;
 }
 
+Identity.blessingForUsername = function(username) {
+  return 'dev.v.io/u/' + username;
+};
+
 function autoUsername() {
   return uuid.v4();
 }
diff --git a/src/invitation-manager.js b/src/invitation-manager.js
index 86399db..f6e4cb7 100644
--- a/src/invitation-manager.js
+++ b/src/invitation-manager.js
@@ -4,6 +4,8 @@
 
 require('es6-shim');
 
+var verror = require('vanadium').verror;
+
 var $ = require('./util/jquery');
 var defineClass = require('./util/define-class');
 var debug = require('./debug');
@@ -41,16 +43,23 @@
   ];
 }
 
+function tripSgName(tripId) {
+  return 'trip-' + tripId;
+}
+
 var InvitationManager = defineClass({
   publics: {
     invite: function(recipient, owner, tripId) {
       var self = this;
 
-      return this.groupManagerPromise.then(function(gm) {
-        return gm.joinSyncGroup(recipient, 'invitations').then(function() {
-          return gm.syncbaseWrapper.put(invitationKey(recipient, owner, tripId),
-            self.username);
-        });
+      return this.sgmPromise.then(function(sgm) {
+        return Promise.all([
+            self.addTripCollaborator(owner, tripId, recipient),
+            sgm.joinSyncGroup(recipient, 'invitations')
+          ]).then(function() {
+            return sgm.syncbaseWrapper.put(
+              invitationKey(recipient, owner, tripId), self.username);
+          });
       });
     },
 
@@ -62,6 +71,17 @@
   privates: {
     invitation: defineClass.innerClass({
       publics: {
+        accept: function() {
+          return this.outer.joinTripSyncGroup(this.owner, this.tripId)
+            .then(this.delete);
+        },
+
+        decline: function() {
+          return this.delete();
+        }
+      },
+
+      privates: {
         delete: function() {
           var self = this;
 
@@ -87,12 +107,61 @@
       }
     }),
 
+    createTripSyncGroup: function(tripId, initialCollaborators) {
+      return this.sgmPromise.then(function(sgm) {
+        return sgm.createSyncGroup(tripSgName(tripId), [['trips', tripId]],
+          [sgm.identity.username].concat(initialCollaborators));
+      });
+    },
+
+    joinTripSyncGroup: function(owner, tripId) {
+      return this.sgmPromise.then(function(sgm) {
+        return sgm.joinSyncGroup(owner, tripSgName(tripId));
+      });
+    },
+
+    addTripCollaborator: function(owner, tripId, collaborator) {
+      var self = this;
+
+      return this.sgmPromise.then(function(sgm) {
+        return sgm.addCollaborator(owner, tripSgName(tripId), collaborator)
+          .catch(function(err) {
+            if (err instanceof verror.NoExistError &&
+                owner === self.username) {
+              return self.createTripSyncGroup(tripId, collaborator);
+            } else {
+              throw err;
+            }
+          });
+      });
+    },
+
+    manageTripSyncGroups: function(trips) {
+      var self = this;
+
+      //TODO(rosswang): maybe make this more intelligent, and handle ejection
+      if (trips) {
+        $.each(trips, function(tripId, trip) {
+          if (trip.owner) {
+            self.joinTripSyncGroup(trip.owner, tripId)
+              .catch(function(err) {
+                if (!(err instanceof verror.NoExistError)) {
+                  throw err;
+                }
+              }).catch(self.onError);
+          }
+        });
+      }
+    },
+
     processUpdates: function(data) {
       var self = this;
 
-      var toMe;
-      if (data.invitations &&
-          (toMe = data.invitations[escapeUsername(this.username)])) {
+      this.manageTripSyncGroups(data.trips);
+
+      var toMe = data.invitations &&
+        data.invitations[escapeUsername(this.username)];
+      if (toMe) {
         $.each(toMe, function(owner, ownerRecords) {
           var ownerInvites = self.invitations[owner];
           if (!ownerInvites) {
@@ -150,25 +219,21 @@
     onError: 'memory'
   },
 
-  init: function(usernamePromise, groupManagerPromise) {
+  init: function(sgmPromise) {
     var self = this;
 
-    this.syncbasePromise = groupManagerPromise.then(function(gm) {
-      gm.syncbaseWrapper.onUpdate.add(self.processUpdates);
-      return gm.syncbaseWrapper;
+    this.syncbasePromise = sgmPromise.then(function(sgm) {
+      self.username = sgm.identity.username;
+      sgm.syncbaseWrapper.onUpdate.add(self.processUpdates);
+      return sgm.syncbaseWrapper;
     });
-    this.groupManagerPromise = groupManagerPromise;
+    this.sgmPromise = sgmPromise;
 
     this.invitations = {};
 
-    usernamePromise.then(function(username) {
-      //this will have been set prior to groupManagerPromise completing
-      self.username = username;
-    });
-
-    groupManagerPromise.then(function(gm) {
-      gm.createSyncGroup('invitations',
-          [['invitations', escapeUsername(self.username)]])
+    sgmPromise.then(function(sgm) {
+      sgm.createSyncGroup('invitations',
+          [['invitations', escapeUsername(self.username)]], ['...'])
         .catch(self.onError);
     });
   }
diff --git a/src/sync-util/trip-manager.js b/src/sync-util/trip-manager.js
index 91ed6f8..3549b86 100644
--- a/src/sync-util/trip-manager.js
+++ b/src/sync-util/trip-manager.js
@@ -21,17 +21,6 @@
   },
 
   publics: {
-    createTripSyncGroup: function(groupManager, tripId) {
-      return groupManager.createSyncGroup('trip-' + tripId,
-        [['trips', tripId]]);
-    },
-
-    joinTripSyncGroup: function(owner, tripId) {
-      return this.startSyncgroupManager.then(function(gm) {
-        return gm.joinSyncGroup(owner, 'trip-' + tripId);
-      });
-    },
-
     /**
      * Sets the active trip to the given trip ID after it is available.
      */
@@ -95,8 +84,6 @@
     processTrips: function(userTripMetadata, trips) {
       var self = this;
 
-      this.manageTripSyncGroups(trips);
-
       var trip;
 
       if (this.awaitedTripId) {
@@ -149,17 +136,10 @@
         } else {
           var tripId = this.activeTripId = uuid.v4();
           debug.log('Creating new trip ' + tripId);
-          trip = {}; //don't initialize owner until the syncgroup is ready
-          this.startSyncgroupManager.then(function(gm) {
-            return Promise.all([
-                self.createTripSyncGroup(gm, tripId),
-                self.usernamePromise
-              ]).then(function(args) {
-                return gm.syncbaseWrapper.put(['trips', tripId, 'owner'],
-                  args[1]).then(function() {
-                    return args[0];
-                  });
-              })
+          trip = {};
+          this.startSyncgroupManager.then(function(sgm) {
+            return sgm.syncbaseWrapper.put(['trips', tripId, 'owner'],
+                sgm.identity.username)
               .catch(self.onError);
           });
         }
@@ -203,29 +183,12 @@
 
     isNascent: function(trip) {
       return this.getTripLength(trip) <= 1;
-    },
-
-    manageTripSyncGroups: function(trips) {
-      var self = this;
-
-      //TODO(rosswang): maybe make this more intelligent, and handle ejection
-      if (trips) {
-        $.each(trips, function(tripId, trip) {
-          /* Join is idempotent, but repeatedly joining might be causing major,
-           * fatal sluggishness. TODO(rosswang): if this is not the case, maybe
-           * go ahead and poll. */
-          if (!self.joinedTrips.has(tripId) && trip.owner) {
-            self.joinedTrips.add(tripId);
-            self.joinTripSyncGroup(trip.owner, tripId).catch(self.onError);
-          }
-        });
-      }
     }
   },
 
   init: function(usernamePromise, deferredSyncbaseWrapper,
       startSyncgroupManager) {
-    this.usernamePromise =  usernamePromise;
+    this.usernamePromise = usernamePromise;
     this.sbw = deferredSyncbaseWrapper;
     this.startSyncgroupManager = startSyncgroupManager;
     this.joinedTrips = new Set();
diff --git a/src/syncgroup-manager.js b/src/syncgroup-manager.js
index e471537..f0562fd 100644
--- a/src/syncgroup-manager.js
+++ b/src/syncgroup-manager.js
@@ -7,21 +7,23 @@
 var vanadium = require('vanadium');
 
 var defineClass = require('./util/define-class');
+var Identity = require('./identity');
 var naming = require('./naming');
 
 var SyncgroupManager = defineClass({
   publics: {
-    createSyncGroup: function(name, prefixes) {
+    createSyncGroup: function(name, prefixes, initialCollaborators) {
       var self = this;
 
-      var sg = self.syncbaseWrapper.syncGroup(self.sgAdmin, name);
+      var sg = this.syncbaseWrapper.syncGroup(self.sgAdmin, name);
 
-      var mgmt = vanadium.naming.join(self.mountNames.app, 'sgmt');
-      var spec = sg.buildSpec(prefixes, [mgmt]);
+      var mgmt = vanadium.naming.join(this.mountNames.app, 'sgmt');
+      var spec = sg.buildSpec(prefixes, [mgmt], this.identity.account,
+        initialCollaborators.map(function(username) {
+          return username === '...'?
+            username : Identity.blessingForUsername(username);
+        }));
 
-      /* 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([
@@ -39,13 +41,26 @@
     },
 
     joinSyncGroup: function(owner, name) {
-      var sg = this.syncbaseWrapper.syncGroup(
-        vanadium.naming.join(naming.appMount(owner), 'sgadmin'), name);
-      return sg.join();
+      return this.getForeignSyncGroup(owner, name).join();
+    },
+
+    addCollaborator: function(owner, sgName, username) {
+      var blessing = Identity.blessingForUsername(username);
+      return this.getForeignSyncGroup(owner, sgName)
+        .changeSpec(function(spec) {
+          ['Read', 'Write', 'Resolve'].forEach(function(perm) {
+            spec.perms.get(perm).in.push(blessing);
+          });
+        });
     }
   },
 
   privates: {
+    getForeignSyncGroup: function(owner, name) {
+      return this.syncbaseWrapper.syncGroup(
+        vanadium.naming.join(naming.appMount(owner), 'sgadmin'), name);
+    },
+
     advertise: function() {
       var basicPerms = new Map([
         ['Admin', {in: [this.identity.account]}],
@@ -63,7 +78,7 @@
     }
   },
 
-  constants: [ 'sgAdmin', 'syncbaseWrapper' ],
+  constants: [ 'identity', 'sgAdmin', 'syncbaseWrapper' ],
 
   events: {
     onError: 'memory'
diff --git a/src/travel.js b/src/travel.js
index 5ce1c2e..54929c7 100644
--- a/src/travel.js
+++ b/src/travel.js
@@ -338,16 +338,14 @@
       message.setHtml(strings.invitationReceived(sender, owner));
       message.setPromise(new Promise(function(resolve, reject) {
         message.$.find('a[name=accept]').click(function() {
-          self.sync.joinTripSyncGroup(owner, tripId)
-            .then(invitation.delete)
-            .then(function() {
-              self.sync.watchForTrip(tripId);
-              return strings.invitationAccepted(sender, owner);
-            }).then(resolve, reject);
+          invitation.accept().then(function() {
+            self.sync.watchForTrip(tripId);
+            return strings.invitationAccepted(sender, owner);
+          }).then(resolve, reject);
           return false;
         });
         message.$.find('a[name=decline]').click(function() {
-          invitation.delete().then(function() {
+          invitation.decline().then(function() {
             return strings.invitationDeclined(sender, owner);
           }).then(resolve, reject);
           return false;
diff --git a/src/travelsync.js b/src/travelsync.js
index 9e0d635..3c00064 100644
--- a/src/travelsync.js
+++ b/src/travelsync.js
@@ -113,16 +113,17 @@
       return gm;
     },
 
-    createPrimarySyncGroup: function(groupManager) {
+    createPrimarySyncGroup: function(syncgroupManager) {
       var self = this;
 
       this.status.userSyncGroup = 'creating';
-      return groupManager.createSyncGroup('user', [[]])
+      return syncgroupManager.createSyncGroup('user', [[]],
+        [syncgroupManager.identity.username])
         .then(function(sg) {
           self.status.userSyncGroup = 'created';
           return sg;
         }, function(err) {
-          self.status.usersSyncGroup = 'failed';
+          self.status.userSyncGroup = 'failed';
           throw err;
         });
     }
@@ -208,8 +209,7 @@
         };
       });
 
-    this.invitationManager = new InvitationManager(usernamePromise,
-      this.startSyncgroupManager);
+    this.invitationManager = new InvitationManager(this.startSyncgroupManager);
     this.invitationManager.onError.add(this.onError);
   }
 });
diff --git a/src/vanadium-wrapper/index.js b/src/vanadium-wrapper/index.js
index 06962dd..45b4c0d 100644
--- a/src/vanadium-wrapper/index.js
+++ b/src/vanadium-wrapper/index.js
@@ -57,7 +57,8 @@
                 return mount(true);
               }
             }, function(err) {
-              if (err.id === 'v.io/v23/naming.nameDoesntExist') {
+              // TODO(rosswang): does this work?
+              if (err instanceof vanadiumDefault.naming.ErrNoSuchName) {
                 return mount(true);
               } else {
                 throw err;
diff --git a/src/vanadium-wrapper/syncbase-wrapper.js b/src/vanadium-wrapper/syncbase-wrapper.js
index af70d9d..3c36bd3 100644
--- a/src/vanadium-wrapper/syncbase-wrapper.js
+++ b/src/vanadium-wrapper/syncbase-wrapper.js
@@ -7,6 +7,7 @@
 var promisify = require('es6-promisify');
 var syncbase = require('syncbase');
 var vanadium = require('vanadium');
+var verror = vanadium.verror;
 
 var defineClass = require('../util/define-class');
 
@@ -17,12 +18,10 @@
  */
 function setUp(context, app, db) {
   function nonfatals(err) {
-    switch (err.id) {
-      case 'v.io/v23/verror.Exist':
-        console.info(err.msg);
-        return;
-      default:
-        throw err;
+    if (err instanceof verror.ExistError) {
+      console.info(err.msg);
+    } else {
+      throw err;
     }
   }
 
@@ -92,7 +91,7 @@
 
   publics: {
     /**
-     * @param seq a function executing the batch operations, receiving as its
+     * @param fn a function executing the batch operations, receiving as its
      *  `this` context and first parameter the batch operation methods
      *  (put, delete), each of which returns a promise. The callback must return
      *  the overarching promise.
@@ -205,12 +204,20 @@
       });
 
       var join = promisify(function(cb) {
-        debug.log('Syncbase: join syncgroup ' + name);
         sg.join(self.context, SG_MEMBER_INFO, chainable(cb));
       });
 
-      var setSpec = promisify(function(spec, cb) {
-          sg.setSpec(self.context, spec, '', chainable(cb));
+      var getSpec = promisify(function(cb) {
+        sg.getSpec(self.context, function(err, spec, version) {
+          cb(err, {
+            spec: spec,
+            version: version
+          });
+        });
+      });
+
+      var setSpec = promisify(function(spec, version, cb) {
+        sg.setSpec(self.context, spec, version, chainable(cb));
       });
 
       /* Be explicit about arg lists because promisify is sensitive to extra
@@ -218,14 +225,14 @@
        * they're made by promisify, wrap them in a fn that actually takes 0
        * args. */
       sgp = {
-        buildSpec: function(prefixes, mountTables) {
+        buildSpec: function(prefixes, mountTables, admin, initialPermissions) {
           return new syncbase.nosql.SyncGroupSpec({
             perms: new Map([
-              ['Admin', {in: ['...']}],
-              ['Read', {in: ['...']}],
-              ['Write', {in: ['...']}],
-              ['Resolve', {in: ['...']}],
-              ['Debug', {in: ['...']}]
+              ['Admin', {in: [admin]}],
+              ['Read', {in: initialPermissions}],
+              ['Write', {in: initialPermissions}],
+              ['Resolve', {in: initialPermissions}],
+              ['Debug', {in: [admin]}]
             ]),
             prefixes: prefixes.map(function(p) { return 't:' + joinKey(p); }),
             mountTables: mountTables
@@ -235,17 +242,28 @@
         create: function(spec) { return create(spec); },
         destroy: function() { return destroy(); },
         join: function() { return join(); },
-        setSpec: function(spec) { return setSpec(spec); },
+        getSpec: function() { return getSpec(); },
+        setSpec: function(spec, version) { return setSpec(spec, version); },
+        changeSpec: function(fn) {
+          return sgp.getSpec().then(function(versionedSpec) {
+            var spec = versionedSpec.spec;
+            return sgp.setSpec(fn(spec) || spec, versionedSpec.version)
+              .catch(function(err) {
+                if (err instanceof verror.VersionError) {
+                  return sgp.changeSpec(fn);
+                } else {
+                  throw err;
+                }
+              });
+          });
+        },
 
         createOrJoin: function(spec) {
           return sgp.create(spec)
             .catch(function(err) {
-              if (err.id === 'v.io/v23/verror.Exist') {
+              if (err instanceof verror.ExistError) {
                 debug.log('Syncbase: syncgroup ' + name + ' already exists.');
-                return sgp.join()
-                  .then(function() {
-                    return sgp.setSpec(spec);
-                  });
+                return sgp.join();
               } else {
                 throw err;
               }
@@ -254,10 +272,8 @@
 
         joinOrCreate: function(spec) {
           return sgp.join()
-            .then(function() {
-              return sgp.setSpec(spec);
-            }, function(err) {
-              if (err.id === 'v.io/v23/verror.NoExist') {
+            .catch(function(err) {
+              if (err instanceof verror.NoExistError) {
                 debug.log('Syncbase: syncgroup ' + name + ' does not exist.');
                 return sgp.createOrJoin(spec);
               } else {
@@ -350,7 +366,7 @@
             }
           }).on('error', reject);
         }).catch(function(err) {
-          if (err.id === 'v.io/v23/verror.Internal') {
+          if (err instanceof verror.InternalError) {
             console.error(err);
           } else {
             throw err;