From e5b7c052bda5ffd528af8eae4ff88f9bd9f79a40 Mon Sep 17 00:00:00 2001
From: ansuz <ansuz@transitiontech.ca>
Date: Wed, 18 Sep 2019 15:02:32 +0200
Subject: [PATCH 1/3] export the rpc's 'destroy' method via pinpad

---
 www/common/pinpad.js | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/www/common/pinpad.js b/www/common/pinpad.js
index d63d5bb0d..48c62f3da 100644
--- a/www/common/pinpad.js
+++ b/www/common/pinpad.js
@@ -17,6 +17,8 @@ var factory = function (Util, Rpc) {
 
             var exp = {};
 
+            exp.destroy = rpc.destroy;
+
             // expose the supplied publicKey as an identifier
             exp.publicKey = edPublic;
 

From 84518c07752e859cd232f1bb0accde2a17e0d601 Mon Sep 17 00:00:00 2001
From: ansuz <ansuz@transitiontech.ca>
Date: Wed, 18 Sep 2019 15:03:14 +0200
Subject: [PATCH 2/3] a few more helpers in common-util

---
 www/common/common-util.js | 70 +++++++++++++++++++++++++++++++--------
 1 file changed, 56 insertions(+), 14 deletions(-)

diff --git a/www/common/common-util.js b/www/common/common-util.js
index f607c30f4..4e067b668 100644
--- a/www/common/common-util.js
+++ b/www/common/common-util.js
@@ -21,6 +21,10 @@
         };
     };
 
+    Util.clone = function (o) {
+        return JSON.parse(JSON.stringify(o));
+    };
+
     Util.tryParse = function (s) {
         try { return JSON.parse(s); } catch (e) { return;}
     };
@@ -57,6 +61,44 @@
         };
     };
 
+    Util.response = function () {
+        var pending = {};
+        var timeouts = {};
+
+        var clear = function (id) {
+            clearTimeout(timeouts[id]);
+            delete timeouts[id];
+            delete pending[id];
+        };
+
+        var expect = function (id, fn, ms) {
+            if (typeof(id) !== 'string') { throw new Error("EXPECTED_STRING"); }
+            if (typeof(fn) !== 'function') { throw new Error("EXPECTED_CALLBACK"); }
+            pending[id] = fn;
+            if (typeof(ms) === 'number' && ms) {
+                timeouts[id] = setTimeout(function () {
+                    if (typeof(pending[id]) === 'function') { pending[id]('TIMEOUT'); }
+                    clear(id);
+                }, ms);
+            }
+        };
+
+        var handle = function (id, args) {
+            var fn = pending[id];
+            if (typeof(fn) !== 'function') { throw new Error("MISSING_CALLBACK"); }
+            pending[id].apply(null, Array.isArray(args)? args : [args]);
+            clear(id);
+        };
+
+        return {
+            expected: function (id) {
+                return Boolean(pending[id]);
+            },
+            expect: expect,
+            handle: handle,
+        };
+    };
+
     Util.find = function (map, path) {
         var l = path.length;
         for (var i = 0; i < l; i++) {
@@ -98,21 +140,21 @@
         return hexArray.join("");
     };
 
-    Util.uint8ArrayToHex = function (a) {
-        // call slice so Uint8Arrays work as expected
-        return Array.prototype.slice.call(a).map(function (e) {
-            var n = Number(e & 0xff).toString(16);
-            if (n === 'NaN') {
-                throw new Error('invalid input resulted in NaN');
-            }
+    Util.uint8ArrayToHex = function (bytes) {
+        var hexString = '';
+        for (var i = 0; i < bytes.length; i++) {
+            if (bytes[i] < 16) { hexString += '0'; }
+            hexString += bytes[i].toString(16);
+        }
+        return hexString;
+    };
 
-            switch (n.length) {
-                case 0: return '00'; // just being careful, shouldn't happen
-                case 1: return '0' + n;
-                case 2: return n;
-                default: throw new Error('unexpected value');
-            }
-        }).join('');
+    Util.hexToUint8Array = function (hexString) {
+        var bytes = new Uint8Array(Math.ceil(hexString.length / 2));
+        for (var i = 0; i < bytes.length; i++) {
+            bytes[i] = parseInt(hexString.substr(i * 2, 2), 16);
+        }
+        return bytes;
     };
 
     // given an array of Uint8Arrays, return a new Array with all their values

From 073543fe3d7169d8212f76e2893808b0b4cd5275 Mon Sep 17 00:00:00 2001
From: ansuz <ansuz@transitiontech.ca>
Date: Wed, 18 Sep 2019 15:04:08 +0200
Subject: [PATCH 3/3] more WIP for roster

---
 scripts/tests/test-rpc.js  | 136 ++++++++++------
 www/common/outer/roster.js | 310 +++++++++++++++++++++++++------------
 2 files changed, 305 insertions(+), 141 deletions(-)

diff --git a/scripts/tests/test-rpc.js b/scripts/tests/test-rpc.js
index 92e911569..dc69f0402 100644
--- a/scripts/tests/test-rpc.js
+++ b/scripts/tests/test-rpc.js
@@ -10,6 +10,7 @@ var Rpc = require("../../www/common/rpc");
 var Hash = require("../../www/common/common-hash");
 var CpNetflux = require("../../www/bower_components/chainpad-netflux");
 var Roster = require("./roster");
+var Util = require("../../lib/common-util");
 
 var createMailbox = function (config, cb) {
     var webchannel;
@@ -68,6 +69,10 @@ var createUser = function (config, cb) {
                 return void cb(err);
             }
             user = client;
+            user.destroy = Util.mkEvent(true);
+            user.destroy.reg(function () {
+                user.network.disconnect();
+            });
         }));
     }).nThen(function (w) {
         // make all the parameters you'll need
@@ -87,6 +92,9 @@ var createUser = function (config, cb) {
                 return void console.error('ANON_RPC_CONNECT_ERR');
             }
             user.anonRpc = rpc;
+            user.destroy.reg(function () {
+                user.anonRpc.destroy();
+            });
         }));
 
         Pinpad.create(network, user.edKeys, w(function (err, rpc) {
@@ -97,6 +105,9 @@ var createUser = function (config, cb) {
                 return console.log('RPC_CONNECT_ERR');
             }
             user.rpc = rpc;
+            user.destroy.reg(function () {
+                user.rpc.destroy();
+            });
         }));
 
         Pinpad.create(network, config.teamEdKeys, w(function (err, rpc) {
@@ -106,6 +117,9 @@ var createUser = function (config, cb) {
                 return console.log('RPC_CONNECT_ERR');
             }
             user.team_rpc = rpc;
+            user.destroy.reg(function () {
+                user.team_rpc.destroy();
+            });
         }));
     }).nThen(function (w) {
         user.rpc.reset([], w(function (err, hash) {
@@ -169,7 +183,7 @@ var createUser = function (config, cb) {
                 return void cb(err);
             }
 
-            console.log('PIN_RESPONSE', hash);
+            //console.log('PIN_RESPONSE', hash);
 
             if (hash[0] === EMPTY_ARRAY_HASH) { throw new Error("PIN_DIDNT_WORK"); }
             user.latestPinHash = hash;
@@ -201,7 +215,7 @@ var createUser = function (config, cb) {
             }
 
             if (hash[0] !== EMPTY_ARRAY_HASH) {
-                console.log('UNPIN_RESPONSE', hash);
+                //console.log('UNPIN_RESPONSE', hash);
                 throw new Error("UNPIN_DIDNT_WORK");
             }
             user.latestPinHash = hash[0];
@@ -215,15 +229,14 @@ var createUser = function (config, cb) {
             }
         }));
     }).nThen(function () {
-        user.cleanup = function (cb) {
-            // TODO remove your mailbox
 
+        user.cleanup = function (cb) {
+            //console.log("Destroying user");
+            // TODO remove your mailbox
+            user.destroy.fire();
             cb = cb;
         };
 
-
-
-
         cb(void 0, user);
     });
 };
@@ -233,8 +246,7 @@ var alice, bob, oscar;
 var sharedConfig = {
     teamEdKeys: makeEdKeys(),
     teamCurveKeys: makeCurveKeys(),
-    rosterChannel: Hash.createChannelId(),
-    //rosterHash: makeRosterHash(),
+    rosterSeed: Crypto.Team.createSeed(),
 };
 
 nThen(function  (w) {
@@ -249,7 +261,6 @@ nThen(function  (w) {
     }));
 }).nThen(function (w) {
     // TODO oscar creates the team roster
-    //Roster = Roster;
 
     // user edPublic (for ownership)
     // user curve keys (for encryption and authentication)
@@ -261,19 +272,11 @@ nThen(function  (w) {
     // network
     // owners:
 
-/*
-    var team = {
-        curve: sharedConfig.teamCurveKeys,
-        ed: sharedConfig.teamEdKeys,
-    };
-*/
-
-    var rosterSeed = Crypto.Team.createSeed();
-    var rosterKeys = Crypto.Team.deriveMemberKeys(rosterSeed, oscar.curveKeys);
+    var rosterKeys = Crypto.Team.deriveMemberKeys(sharedConfig.rosterSeed, oscar.curveKeys);
 
     Roster.create({
         network: oscar.network,
-        channel: rosterKeys.channel, //sharedConfig.rosterChannel,
+        channel: rosterKeys.channel,
         owners: [
             oscar.edKeys.edPublic
         ],
@@ -286,32 +289,37 @@ nThen(function  (w) {
             return void console.trace(err);
         }
         oscar.roster = roster;
+        oscar.destroy.reg(function () {
+            roster.stop();
+        });
     }));
 }).nThen(function (w) {
     var roster = oscar.roster;
 
-    roster.on('change', function () {
-        setTimeout(function () {
+    oscar.lastKnownHash = -1;
 
-            console.log("\nCHANGE");
-            console.log("roster.getState()", roster.getState());
-            console.log();
-        });
+    roster.on('change', function () {
+        oscar.currentRoster = roster.getState();
+        //console.log("new state = %s\n", JSON.stringify(oscar.currentRoster));
+    }).on('checkpoint', function (hash) {
+        oscar.lastKnownHash = hash;
     });
 
-    var state = roster.getState();
-    console.log("CURRENT ROSTER STATE:", state);
+    //var state = roster.getState();
+    //console.log("CURRENT ROSTER STATE:", state);
 
     roster.init({
-        name: oscar.name,
+        displayName: oscar.name,
+
         //profile: '',
         // mailbox: '',
         //title: '',
     }, w(function (err) {
         if (err) { return void console.error(err); }
+        //console.log("INITIALIZED");
     }));
 }).nThen(function (w) {
-    console.log("ALICE && BOB");
+    //console.log("ALICE && BOB");
     createUser(sharedConfig, w(function (err, _alice) {
         if (err) {
             w.abort();
@@ -319,7 +327,7 @@ nThen(function  (w) {
         }
         alice = _alice;
         alice.name = 'alice';
-        console.log("Initialized Alice");
+        //console.log("Initialized Alice");
     }));
     createUser(sharedConfig, w(function (err, _bob) {
         if (err) {
@@ -328,29 +336,63 @@ nThen(function  (w) {
         }
         bob = _bob;
         bob.name = 'bob';
-        console.log("Initialized Bob");
+        //console.log("Initialized Bob");
     }));
-}).nThen(function () {
+}).nThen(function (w) {
+    setTimeout(w(), 500);
+
+}).nThen(function (w) {
+    // Alice loads the roster...
+    var rosterKeys = Crypto.Team.deriveMemberKeys(sharedConfig.rosterSeed, alice.curveKeys);
+
+    Roster.create({
+        network: alice.network,
+        channel: rosterKeys.channel,
+        //owners: [], // Alice doesn't know who the owners might be...
+        keys: rosterKeys,
+        anon_rpc: alice.anonRpc,
+        lastKnownHash: void 0, // alice should fetch everything from the beginning of time...
+    }, w(function (err, roster) {
+        if (err) {
+            w.abort();
+            return void console.error(err);
+        }
+        alice.roster = roster;
+        alice.destroy.reg(function () {
+            roster.stop();
+        });
+
+        if (JSON.stringify(alice.roster.getState()) !== JSON.stringify(oscar.roster.getState())) {
+            console.error("Alice and Oscar have different roster states!");
+            throw new Error();
+        } else {
+            console.log("Alice and Oscar have the same roster state");
+        }
+    }));
+}).nThen(function (w) {
     // TODO oscar adds alice and bob to the team as members
     var roster = oscar.roster;
 
     var data = {};
     data[alice.curveKeys.curvePublic] = {
-        name: alice.name,
-        role: 'MEMBER',
+        displayName: alice.name,
+        // role: 'MEMBER', // MEMBER is implicit
+        notifications: '',
     };
     data[bob.curveKeys.curvePublic] = {
-        name: bob.name,
-        role: 'MEMBER',
+        displayName: bob.name,
+        //role: 'MEMBER',
+        notifications: '',
     };
 
-    roster.add(data, function (err) {
-        if (err) {
-            return void console.error(err);
-        }
-        console.log("SENT ADD COMMAND");
-    });
+    roster.add(data, w(function (err) {
+        if (err) { return void console.error(err); }
+        //console.log("SENT ADD COMMAND");
+    }));
 }).nThen(function () {
+    
+
+
     // TODO alice and bob describe themselves...
 
 }).nThen(function () {
@@ -368,7 +410,6 @@ nThen(function  (w) {
             text: "CAMEMBERT",
         }
     }), bob.curveKeys.curvePublic);
-
     alice.anonRpc.send('WRITE_PRIVATE_MESSAGE', [bob.mailboxChannel, message], w(function (err, response) {
         if (err) {
             return void console.error(err);
@@ -410,8 +451,11 @@ nThen(function  (w) {
 
     });
 }).nThen(function () {
-    alice.shutdown();
-    bob.shutdown();
+    //alice.shutdown();
+    //bob.shutdown();
+    alice.cleanup();
+    bob.cleanup();
+    oscar.cleanup();
 });
 
 
diff --git a/www/common/outer/roster.js b/www/common/outer/roster.js
index 5000f10ce..6d4ffd3b1 100644
--- a/www/common/outer/roster.js
+++ b/www/common/outer/roster.js
@@ -22,6 +22,10 @@ var factory = function (Util, Hash, CPNetflux, Sortify, nThen, Crypto) {
         }
     */
 
+    var isMap = function (obj) {
+        return Boolean(obj && typeof(obj) === 'object' && !Array.isArray(obj));
+    };
+
     var canCheckpoint = function (author, state) {
         // if you're here then you've received a checkpoint message
         // that you don't necessarily trust.
@@ -90,6 +94,11 @@ var factory = function (Util, Hash, CPNetflux, Sortify, nThen, Crypto) {
         return false;
     };
 
+    var canUpdateMetadata = function (author, state) {
+        var authorRole = Util.find(state, [author, 'role']);
+        return Boolean(authorRole && ['OWNER', 'ADMIN'].indexOf(authorRole) !== -1);
+    };
+
     var shouldCheckpoint = function (state) {
         // 
 
@@ -123,30 +132,29 @@ var factory = function (Util, Hash, CPNetflux, Sortify, nThen, Crypto) {
             throw new Error("CANNOT_ADD_TO_UNITIALIZED_ROSTER");
         }
 
-        // XXX reject if not all of these are present
-            // displayName
-            // notifications (channel)
-        // XXX if no role is passed, assume MEMBER
-
-        var changed = false;
+        // iterate over everything and make sure it is valid, throw if not
         Object.keys(args).forEach(function (curve) {
             // FIXME only allow valid curve keys, anything else is pollution
-            if (curve.length !== 44) {
+            if (!isValidId(curve)) {
                 console.log(curve, curve.length);
                 throw new Error("INVALID_CURVE_KEY");
             }
+            // reject commands where the members are not proper objects
+            if (!isMap(args[curve])) { throw new Error("INVALID_CONTENT"); }
+            if (roster.state[curve]) { throw new Error("ALREADY_PRESENT"); }
 
             var data = args[curve];
+            // if no role was provided, assume MEMBER
+            if (typeof(data.role) !== 'string') { data.role = 'MEMBER'; }
 
+            if (typeof(data.displayName) !== 'string') { throw new Error("DISPLAYNAME_REQUIRED"); }
+            if (typeof(data.notifications) !== 'string') { throw new Error("NOTIFICATIONS_REQUIRED"); }
+        });
 
-            // ignore anything that isn't a proper object
-            if (!data || typeof(data) !== 'object' || Array.isArray(data)) {
-                return;
-            }
-
-            // ignore instructions to ADD someone who is already in the roster
-            if (roster.state[curve]) { return; }
-
+        var changed = false;
+        // then iterate again and apply it
+        Object.keys(args).forEach(function (curve) {
+            var data = args[curve];
             if (!canAddRole(author, data.role, roster.state)) { return; }
 
             // this will result in a change
@@ -166,15 +174,12 @@ var factory = function (Util, Hash, CPNetflux, Sortify, nThen, Crypto) {
 
         var changed = false;
         args.forEach(function (curve) {
-            if (isValidId(curve)) { throw new Error("INVALID_CURVE_KEY"); }
+            if (!isValidId(curve)) { throw new Error("INVALID_CURVE_KEY"); }
 
             // don't try to remove something that isn't there
             if (!roster.state[curve]) { return; }
-
             var role = roster.state[curve].role;
-
             if (!canRemoveRole(author, role, roster.state)) { return; }
-
             changed = true;
             delete roster.state[curve];
         });
@@ -187,42 +192,47 @@ var factory = function (Util, Hash, CPNetflux, Sortify, nThen, Crypto) {
         }
 
         if (typeof(roster.state) === 'undefined') {
-            throw new Error("CANNOT_DESCRIBE_MEMBERS_OF_UNITIALIZED_ROSTER");
+            throw new Error("NOT_READY");
         }
 
-        var changed = false;
+        // iterate over all the data and make sure it is valid, throw otherwise
         Object.keys(args).forEach(function (curve) {
-            if (!isValidId(curve)) { return; }
-            if (!roster.state[curve]) { return; }
+            if (!isValidId(curve)) {  throw new Error("INVALID_ID"); }
+            if (!roster.state[curve]) { throw new Error("NOT_PRESENT"); }
 
-            if (!canDescribeTarget(author, curve, roster.state)) { return; }
+            if (!canDescribeTarget(author, curve, roster.state)) { throw new Error("INSUFFICIENT_PERMISSIONS"); }
+
+            var data = args[curve];
+            if (!isMap(data)) { throw new Error("INVALID_ARGUMENTS"); }
+
+            var current = Util.clone(roster.state[curve]);
+
+            // DESCRIBE commands must initialize a displayName if it isn't already present
+            if (typeof(current.displayName) !== 'string' && typeof(data.displayName) !== 'string') { throw new Error('DISPLAYNAME_REQUIRED'); }
+
+            // DESCRIBE commands must initialize a mailbox channel if it isn't already present
+            if (typeof(current.notifications) !== 'string' && typeof(data.displayName) !== 'string') { throw new Error('NOTIFICATIONS_REQUIRED'); }
+        });
+
+        var changed = false;
+        // then do a second pass and apply it if there were changes
+        Object.keys(args).forEach(function (curve) {
+            var current = Util.clone(roster.state[curve]);
 
             var data = args[curve];
-            if (!data || typeof(data) !== 'object' || Array.isArray(data)) { return; }
 
-            var current = roster.state[curve];
             Object.keys(data).forEach(function (key) {
                 if (current[key] === data[key]) { return; }
-                changed = true;
                 current[key] = data[key];
             });
-        });
-        return changed;
 
-
-        /*
-            args: {
-                userkey: {
-                    field: newValue
-                },
+            if (Sortify(current) !== Sortify(roster.state[curve])) {
+                changed = true;
+                roster.state[curve] = current;
             }
-        */
+        });
 
-        // owners can update information about any team member
-        // admins can update information about members
-        // members can update information about themselves
-        // non-members cannot update anything
-        //roster = roster;
+        return changed;
     };
 
     // XXX what about concurrent checkpoints? Let's solve for race conditions...
@@ -255,14 +265,29 @@ var factory = function (Util, Hash, CPNetflux, Sortify, nThen, Crypto) {
         return true;
     };
 
-    // describe the team {name, description}, (only admin/owner)
-    commands.TOPIC = function (/* args, author, roster */) {
-        
-    };
+    // only admin/owner can change group metadata
+    commands.METADATA = function (args, author, roster) {
+        if (!isMap(args)) { throw new Error("INVALID_ARGS"); }
 
-    // add a link to an avatar (only owner/admin can do this)
-    commands.AVATAR = function (/* args, author, roster */) {
+        if (!canUpdateMetadata(author, roster.state)) { throw new Error("INSUFFICIENT_PERMISSIONS"); }
 
+        // validate inputs
+        Object.keys(args).forEach(function (k) {
+            // can't set metadata to anything other than strings
+            // use empty string to unset a value if you must
+            if (typeof(args[k]) !== 'string') { throw new Error("INVALID_ARGUMENTS"); }
+        });
+
+        var changed = false;
+        // {topic, name, avatar} are all strings...
+        Object.keys(args).forEach(function (k) {
+            // ignore things that won't cause changes
+            if (args[k] === roster.metadata[k]) { return; }
+
+            changed = true;
+            roster.metadata[k] = args[k];
+        });
+        return changed;
     };
 
     var handleCommand = function (content, author, roster) {
@@ -276,12 +301,8 @@ var factory = function (Util, Hash, CPNetflux, Sortify, nThen, Crypto) {
         return commands[command](content[1], author, roster);
     };
 
-    var clone = function (o) {
-        return JSON.parse(JSON.stringify(o));
-    };
-
     var simulate = function (content, author, roster) {
-        return handleCommand(content, author, clone(roster));
+        return handleCommand(content, author, Util.clone(roster));
     };
 
     var getMessageId = function (msgString) {
@@ -298,8 +319,7 @@ var factory = function (Util, Hash, CPNetflux, Sortify, nThen, Crypto) {
         if (!config.anon_rpc) { return void cb("EXPECTED_ANON_RPC"); }
 
 
-
-
+        var response = Util.response();
 
         var anon_rpc = config.anon_rpc;
 
@@ -308,7 +328,16 @@ var factory = function (Util, Hash, CPNetflux, Sortify, nThen, Crypto) {
         var me = keys.myCurvePublic;
         var channel = config.channel;
 
-        var ref = {};
+        var ref = {
+            // topic, name, and avatar are all guaranteed to be strings, though they might be empty
+            metadata: {
+                topic: '',
+                name: '',
+                avatar: '',
+            },
+            internal: {},
+        };
+
         var roster = {};
 
         var events = {
@@ -319,25 +348,46 @@ var factory = function (Util, Hash, CPNetflux, Sortify, nThen, Crypto) {
         roster.on = function (key, handler) {
             if (typeof(events[key]) !== 'object') { throw new Error("unsupported event"); }
             events[key].reg(handler);
+            return roster;
         };
 
         roster.off = function (key, handler) {
             if (typeof(events[key]) !== 'object') { throw new Error("unsupported event"); }
             events[key].unreg(handler);
+            return roster;
+        };
+
+        roster.once = function (key, handler) {
+            if (typeof(events[key]) !== 'object') { throw new Error("unsupported event"); }
+            var f = function () {
+                handler.apply(null, Array.prototype.slice.call(arguments));
+                events[key].unreg(f);
+            };
+            events[key].reg(f);
+            return roster;
         };
 
         roster.getState = function () {
-            return ref.state;
+            if (!isMap(ref.state)) { return; }
+
+            // XXX return parent element instead of .state ?
+            return Util.clone(ref.state);
         };
 
-        // XXX you must be able to 'leave' a roster session
+        var webChannel;
+
         roster.stop = function () {
-            // shut down the chainpad-netflux session and...
-            // cpNf.leave();
+            if (webChannel && typeof(webChannel.leave) === 'function') {
+                webChannel.leave();
+            } else {
+                console.log("FAILED TO LEAVE");
+            }
         };
 
         var ready = false;
-        var onReady = function (/* info */) {
+        var onReady = function (info) {
+            //console.log("READY");
+            webChannel = info;
             ready = true;
             cb(void 0, roster);
         };
@@ -359,50 +409,60 @@ var factory = function (Util, Hash, CPNetflux, Sortify, nThen, Crypto) {
             console.log("ROSTER CONNECTED");
         };
 
-        // XXX reuse code from RPC ?
-        var pending = {};
-        //var timeouts = {};
+        var isReady = function () {
+             return Boolean(ready && me);
+        };
 
         var onMessage = function (msg, user, vKey, isCp , hash, author) {
-            //console.log("onMessage");
-            //console.log(typeof(msg), msg);
             var parsed = Util.tryParse(msg);
 
             if (!parsed) { return void console.error("could not parse"); }
 
             var changed;
+            var error;
             try {
                 changed = handleCommand(parsed, author, ref);
             } catch (err) {
-                console.error(err);
+                error = err;
             }
 
             var id = getMessageId(hash);
-            if (typeof(pending[id]) === 'function') {
-                // it was your message, execute a callback
-                if (!changed) {
-                    pending[id]("NO_CHANGE");
-                } else {
-                    pending[id](void 0, clone(roster.state));
+
+            if (response.expected(id)) {
+                if (error) { return void response.handle(id, [error]); }
+                try {
+                    if (!changed) {
+                        response.handle(id, ['NO_CHANGE']);
+                    } else {
+                        response.handle(id, [void 0, roster.getState()]);
+                    }
+                } catch (err) {
+                    console.log('CAUGHT', err);
                 }
-            } else {
-                // it was not your message, or it timed out...
-                // execute change ?
-                console.log("HASH", hash);
             }
-            if (changed) { events.change.fire(); }
+            /*
+            else {
+                if (isReady()) {
+                    console.log("unexpected message [%s]", hash, msg);
+                    console.log("received by %s", me);
+                }
+                // it was not your message, or it timed out...
+            }*/
 
-            return void console.log(msg);
+            // if a checkpoint was successfully applied, emit an event
+            if (parsed[0] === 'CHECKPOINT' && changed) {
+                events.checkpoint.fire(hash);
+            } else if (changed) {
+                events.change.fire();
+            }
         };
 
-        var isReady = function () {
-             return Boolean(ready && me);
-        };
 
         var metadata, crypto;
-        var send = function (msg, _cb) {
-            var cb = Util.once(Util.mkAsync(_cb));
-            if (!isReady()) { return void cb("NOT_READY"); }
+        var send = function (msg, cb) {
+            if (!isReady()) {
+                return void cb("NOT_READY");
+            }
 
             var changed = false;
             try {
@@ -411,23 +471,30 @@ var factory = function (Util, Hash, CPNetflux, Sortify, nThen, Crypto) {
             } catch (err) {
                 return void cb(err);
             }
-            if (!changed) { return void cb("NO_CHANGE"); }
+            if (!changed) {
+                return void cb("NO_CHANGE");
+            }
 
             var ciphertext = crypto.encrypt(Sortify(msg));
 
             var id = getMessageId(ciphertext);
 
+            //console.log("Sending with id [%s]", id, msg);
+            //console.log();
+
+            response.expect(id, cb, 3000);
             anon_rpc.send('WRITE_PRIVATE_MESSAGE', [
                 channel,
                 ciphertext
             ], function (err) {
-                if (err) { return void cb(err); }
-                pending[id] = cb;
+                if (err) { return response.handle(id, [err]); }
             });
         };
 
-        roster.init = function (_data, cb) {
-            var data = clone(_data);
+        roster.init = function (_data, _cb) {
+            var cb = Util.once(Util.mkAsync(_cb));
+            if (ref.state) { return void cb("ALREADY_INITIALIZED"); }
+            var data = Util.clone(_data);
             data.role = 'OWNER';
             var state = {};
             state[me] = data;
@@ -435,22 +502,75 @@ var factory = function (Util, Hash, CPNetflux, Sortify, nThen, Crypto) {
         };
 
         // commands
-        roster.checkpoint = function () {
+        roster.checkpoint = function (_cb) {
+            var cb = Util.once(Util.mkAsync(_cb));
+            var state = ref.state;
+            if (!state) { return cb("UNINITIALIZED"); }
             send([ 'CHECKPOINT', ref.state], cb);
         };
 
-        roster.add = function (data, cb) {
+        roster.add = function (data, _cb) {
+            var cb = Util.once(Util.mkAsync(_cb));
+            var state = ref.state;
+            if (!state) { return cb("UNINITIALIZED"); }
+            if (!isMap(data)) { return void cb("INVALID_ARGUMENTS"); }
+
+            // don't add members that are already present
+            // use DESCRIBE to amend
+            Object.keys(data).forEach(function (curve) {
+                if (!isValidId(curve) || isMap(state[curve])) { return delete data[curve]; }
+            });
+
             send([ 'ADD', data ], cb);
         };
 
-        roster.remove = function (data, cb) {
-            send([ 'REMOVE', data ], cb);
+        roster.remove = function (data, _cb) {
+            var cb = Util.once(Util.mkAsync(_cb));
+            var state = ref.state;
+            if (!state) { return cb("UNINITIALIZED"); }
+
+            if (!Array.isArray(data)) { return void cb("INVALID_ARGUMENTS"); }
+
+            var toRemove = [];
+            var current = Object.keys(state);
+            data.forEach(function (curve) {
+                // don't try to remove elements which are not in the current state
+                if (current.indexOf(curve) === -1) { return; }
+                toRemove.push(curve);
+            });
+
+            send([ 'RM', toRemove ], cb);
         };
 
-        roster.describe = function (data, cb) {
+        roster.describe = function (data, _cb) {
+            var cb = Util.once(Util.mkAsync(_cb));
+            var state = ref.state;
+            if (!state) { return cb("UNINITIALIZED"); }
+            if (!isMap(data)) { return void cb("INVALID_ARGUMENTS"); }
+
+            Object.keys(data).forEach(function (curve) {
+                var member = data[curve];
+                if (!isMap(member)) { delete data[curve]; }
+                // don't send fields that won't result in a change
+                Object.keys(member).forEach(function (k) {
+                    if (member[k] === state[curve][k]) { delete member[k]; }
+                });
+            });
+
             send(['DESCRIBE', data], cb);
         };
 
+        roster.metadata = function (data, _cb) {
+            var cb = Util.once(Util.mkAsync(_cb));
+            var metadata = ref.metadata;
+            if (!isMap(data)) { return void cb("INVALID_ARGUMENTS"); }
+
+            Object.keys(data).forEach(function (k) {
+                if (data[k] === metadata[k]) { delete data[k]; }
+            });
+            send(['METADATA', data], cb);
+        };
+
         nThen(function (w) {
             // get metadata so we know the owners and validateKey
             anon_rpc.send('GET_METADATA', channel, function (err, data) {
@@ -458,7 +578,7 @@ var factory = function (Util, Hash, CPNetflux, Sortify, nThen, Crypto) {
                     w.abort();
                     return void console.error(err);
                 }
-                metadata = ref.metadata = (data && data[0]) || undefined;
+                metadata = ref.internal.metadata = (data && data[0]) || undefined;
                 console.log("TEAM_METADATA", metadata);
             });
         }).nThen(function (w) {