Merge "veyron/tools/build: Print more detailed error information"
diff --git a/examples/mdb/Makefile b/examples/mdb/Makefile
index 88a62b3..d453a6a 100644
--- a/examples/mdb/Makefile
+++ b/examples/mdb/Makefile
@@ -1,7 +1,10 @@
 build:
-	${VEYRON_ROOT}/veyron/scripts/build/go install {veyron,veyron2}/...
+	${VEYRON_ROOT}/veyron/scripts/build/go install veyron/examples/mdb/... veyron/services/mounttable/mounttabled veyron/services/store/stored veyron/tools/identity
 
 run: build
 	./run.sh
 
-.PHONY: build run
+test:
+	./test.sh
+
+.PHONY: build run test
diff --git a/examples/mdb/run.sh b/examples/mdb/run.sh
index a678718..7ead48a 100755
--- a/examples/mdb/run.sh
+++ b/examples/mdb/run.sh
@@ -9,8 +9,8 @@
 trap onexit INT TERM EXIT
 
 onexit() {
-  exec 2> /dev/null
-  kill $(jobs -pr)
+  exec 2>/dev/null
+  kill $(jobs -p)
   rm -rf "${ID_FILE}"
 }
 
diff --git a/examples/mdb/test.sh b/examples/mdb/test.sh
new file mode 100755
index 0000000..656560f
--- /dev/null
+++ b/examples/mdb/test.sh
@@ -0,0 +1,52 @@
+#!/bin/bash
+
+# Tests the mdb example.
+#
+# Builds binaries, starts up services, waits a few seconds, then checks that the
+# store browser responds with valid data.
+
+set -e
+set -u
+
+readonly THIS_SCRIPT="$0"
+readonly WORK_DIR=$(mktemp -d)
+
+trap onexit INT TERM EXIT
+
+onexit() {
+  exec 2>/dev/null
+  kill $(jobs -p)
+  rm -rf "${WORK_DIR}"
+}
+
+fail() {
+  [[ $# -gt 0 ]] && echo "${THIS_SCRIPT} $*"
+  echo FAIL
+  exit 1
+}
+
+pass() {
+  echo PASS
+  exit 0
+}
+
+main() {
+  make build || fail "line ${LINENO}: failed to build"
+  ./run.sh >/dev/null 2>&1 &
+
+  sleep 5  # Wait for services to warm up.
+
+  URL="http://localhost:5000"
+  FILE="${WORK_DIR}/index.html"
+
+  curl 2>/dev/null "${URL}" -o "${FILE}" || fail "line ${LINENO}: failed to fetch ${URL}"
+
+  if grep -q moviesbox "${FILE}"; then
+    pass
+  else
+    cat ${FILE}
+    fail "line ${LINENO}: fetched page does not meet expectations"
+  fi
+}
+
+main "$@"
diff --git a/examples/pipetobrowser/Makefile b/examples/pipetobrowser/Makefile
index e80138b..0953b87 100644
--- a/examples/pipetobrowser/Makefile
+++ b/examples/pipetobrowser/Makefile
@@ -1,7 +1,6 @@
 PATH:=$(VEYRON_ROOT)/environment/cout/node/bin:$(PATH)
 PATH:=node_modules/.bin:../node_modules/.bin:$(PATH)
 
-VEYRON_JS_API=$(VEYRON_ROOT)/veyron/javascript/api
 VEYRON_BUILD_SCRIPT=$(VEYRON_ROOT)/veyron/scripts/build/go
 
 # All JS files except build.js and third party
@@ -24,11 +23,8 @@
 	export
 
 # Build and copies Veyron from local source
-browser/third-party/veyron: $(VEYRON_JS_API)
-	mkdir -p browser/third-party
-	(cd $(VEYRON_JS_API) && ./vgrunt build)
-	mkdir -p browser/third-party/veyron
-	cp -rf $(VEYRON_JS_API)/dist/*.* browser/third-party/veyron
+browser/third-party/veyron: node_modules
+	cp -rf $</veyron/dist/ $@
 
 # Install JSPM and Bower packages as listed in browser/package.json from JSPM and browser/bower.json from bower
 browser/third-party: browser/package.json browser/bower.json
diff --git a/examples/pipetobrowser/package.json b/examples/pipetobrowser/package.json
index add9381..c38e890 100644
--- a/examples/pipetobrowser/package.json
+++ b/examples/pipetobrowser/package.json
@@ -2,10 +2,13 @@
   "name": "pipe-to-browser",
   "version": "0.0.1",
   "description": "P2B allows one to pipe anything from shell console to the browser. Data being piped to the browser then is displayed in a graphical and formatted way by a 'viewer' Viewers are pluggable pieces of code that know how to handle and display a stream of data.",
+  "dependencies": {
+    "veyron": "git+ssh://git@github.com:veyron/veyron.js.git"
+  },
   "devDependencies": {
     "jspm": "~0.6.7",
     "vulcanize": "~0.3.0",
     "serve": "~1.4.0",
     "bower": "~1.3.8"
   }
-}
\ No newline at end of file
+}
diff --git a/examples/todos/.gitignore b/examples/todos/.gitignore
index d5c005f..25e502e 100644
--- a/examples/todos/.gitignore
+++ b/examples/todos/.gitignore
@@ -1,4 +1,5 @@
 node_modules
 npm-debug.log
 todos_appd/node_modules
-todos_appd/public/js/veyron.*
+todos_appd/public/bundle.*
+todos_appd/third_party/veyron.*
diff --git a/examples/todos/Makefile b/examples/todos/Makefile
index 86f2607..76f0c00 100644
--- a/examples/todos/Makefile
+++ b/examples/todos/Makefile
@@ -1,26 +1,31 @@
-VEYRON_JS_API := ${VEYRON_ROOT}/veyron/javascript/api
+# TODO(sadovsky): Eliminate separate {build,run,watch}app rules once everything
+# is wired together.
 
 export PATH := node_modules/.bin:${PATH}
 
-build: buildgo buildnode buildbrowser
+VEYRON_JS_API := ${VEYRON_ROOT}/veyron/javascript/api
+BUNDLE_JS := todos_appd/public/bundle.js
+
+node_modules:
+	npm install
+	(cd todos_appd && npm install)
 
 buildgo:
 	${VEYRON_ROOT}/veyron/scripts/build/go install {veyron,veyron2}/...
 
-buildnode:
-	npm install
+buildapp: node_modules
+	browserify -d todos_appd/browser/*.js -p [minifyify --map bundle.js.map --output ${BUNDLE_JS}.map] -o ${BUNDLE_JS}
 
-buildbrowser:
-	(cd ${VEYRON_JS_API} && ./vgrunt build) && \
-	mkdir -p todos_appd/public/js && \
-	cp -rf ${VEYRON_JS_API}/dist/veyron.* todos_appd/public/js
+build: buildgo buildapp
 
 run: build
 	./run.sh
 
-# TODO(sadovsky): Merge into other rules once everything's wired up.
-runapp:
-	(cd todos_appd && npm install && npm start)
+runapp: buildapp
+	(cd todos_appd && npm start)
+
+watchapp:
+	watch -n 1 make buildapp
 
 gofmt:
 	gofmt -w .
@@ -28,10 +33,10 @@
 clean:
 	rm -rf node_modules
 	rm -rf todos_appd/node_modules
-	rm -rf todos_appd/public/js/veyron.*
+	rm -rf todos_appd/public/bundle.*
+	rm -rf todos_appd/third_party/veyron.*
 
-lint:
-	npm install --dev
-	jshint todos_appd/server.js todos_appd/public/js/*.js --exclude "**/veyron.*"
+lint: node_modules
+	jshint todos_appd/server.js todos_appd/browser/*.js
 
-.PHONY: build buildgo buildnode buildbrowser run runapp gofmt clean lint
+.PHONY: buildgo buildapp build run runapp watchapp gofmt clean lint
diff --git a/examples/todos/package.json b/examples/todos/package.json
index b289006..431c8ce 100644
--- a/examples/todos/package.json
+++ b/examples/todos/package.json
@@ -1,7 +1,12 @@
 {
   "name": "todos",
   "version": "0.0.1",
+  "dependencies": {
+    "veyron": "git+ssh://git@github.com:veyron/veyron.js.git"
+  },
   "devDependencies": {
-    "jshint": "^2.5.2"
+    "browserify": "^5.9.1",
+    "jshint": "^2.5.2",
+    "minifyify": "^4.0.3"
   }
 }
diff --git a/examples/todos/todos_appd/browser/collection.js b/examples/todos/todos_appd/browser/collection.js
new file mode 100644
index 0000000..589465a
--- /dev/null
+++ b/examples/todos/todos_appd/browser/collection.js
@@ -0,0 +1,136 @@
+// TODO: Use minimongo?
+
+'use strict';
+
+module.exports = Collection;
+
+var CHANGE = 'change';
+
+function BaseEvent(type) {
+  this.type = type;
+}
+
+function ChangeEvent() {
+  BaseEvent.call(this, CHANGE);
+}
+
+function Collection(name) {
+  this.name_ = name;
+  this.vals_ = [];
+
+  this.listeners_ = {};
+  this.listeners_[CHANGE] = [];
+}
+
+Collection.prototype = {
+  find: function(q, opts) {
+    var that = this;
+    q = this.normalize_(q);
+    var res = _.filter(this.vals_, function(v) {
+      return that.matches_(v, q);
+    });
+    if (opts.sort) {
+      // TODO: Eliminate simplifying assumptions.
+      var keys = _.keys(opts.sort);
+      console.assert(keys.length === 1);
+      var key = keys[0];
+      console.assert(opts.sort[key] === 1);
+      res = res.sort(function(a, b) {
+        // TODO: Verify and enhance comparator.
+        return a[key] > b[key];
+      });
+    }
+    return _.cloneDeep(res);
+  },
+  findOne: function(q, opts) {
+    var all = this.find(q, opts);
+    if (all.length > 0) {
+      return all[0];
+    }
+    return null;
+  },
+  insert: function(v) {
+    console.assert(!_.has(v, '_id'));
+    v = _.assign({}, v, {_id: this.vals_.length});
+    this.vals_.push(v);
+    this.dispatchEvent_(new ChangeEvent());
+    return v._id;
+  },
+  remove: function(q) {
+    var that = this;
+    q = this.normalize_(q);
+    this.vals_ = _.filter(this.vals_, function(v) {
+      return !that.matches_(v, q);
+    });
+    this.dispatchEvent_(new ChangeEvent());
+  },
+  update: function(q, opts) {
+    var that = this;
+    q = this.normalize_(q);
+    var vals = _.filter(this.vals_, function(v) {
+      return that.matches_(v, q);
+    });
+
+    // TODO: Eliminate simplifying assumptions.
+    var keys = _.keys(opts);
+    console.assert(keys.length === 1);
+    var key = keys[0];
+    console.assert(_.contains(['$addToSet', '$pull', '$set'], key));
+    var opt = opts[key];
+    var fields = _.keys(opt);
+    console.assert(keys.length === 1);
+    var field = fields[0];
+
+    _.each(vals, function(val) {
+      switch (key) {
+        case '$addToSet':
+        val[field] = _.union(val[field], [opt[field]]);
+        break;
+        case '$pull':
+        val[field] = _.without(val[field], opt[field]);
+        break;
+        case '$set':
+        val[field] = opt[field];
+        break;
+      }
+    });
+
+    this.dispatchEvent_(new ChangeEvent());
+  },
+  addEventListener: function(type, handler) {
+    this.listeners_[type].push(handler);
+  },
+  removeEventListener: function(type, handler) {
+    this.listeners_[type] = _.without(this.listeners_[type], handler);
+  },
+  on: function(type, handler) {
+    this.addEventListener(type, handler);
+  },
+  normalize_: function(q) {
+    if (_.isObject(q)) {
+      return q;
+    }
+    return {_id: q};
+  },
+  matches_: function(v, q) {
+    var keys = _.keys(q);
+    for (var i = 0; i < keys.length; i++) {
+      var key = keys[i];
+      if (_.isArray(v[key]) && !_.isArray(q[key])) {
+        if (!_.contains(v[key], q[key])) {
+          return false;
+        }
+      } else {
+        if (q[key] !== v[key]) {
+          return false;
+        }
+      }
+    }
+    return true;
+  },
+  dispatchEvent_: function(e) {
+    _.each(this.listeners_[e.type], function(handler) {
+      handler(e);
+    });
+  }
+};
diff --git a/examples/todos/todos_appd/browser/defaults.js b/examples/todos/todos_appd/browser/defaults.js
new file mode 100644
index 0000000..1455103
--- /dev/null
+++ b/examples/todos/todos_appd/browser/defaults.js
@@ -0,0 +1,61 @@
+'use strict';
+
+var Collection = require('./collection');
+
+var lists = new Collection('lists');
+var todos = new Collection('todos');
+
+// Copied from meteor/todos/server/bootstrap.js.
+var data = [
+  {name: 'Meteor Principles',
+   contents: [
+     ['Data on the Wire', 'Simplicity', 'Better UX', 'Fun'],
+     ['One Language', 'Simplicity', 'Fun'],
+     ['Database Everywhere', 'Simplicity'],
+     ['Latency Compensation', 'Better UX'],
+     ['Full Stack Reactivity', 'Better UX', 'Fun'],
+     ['Embrace the Ecosystem', 'Fun'],
+     ['Simplicity Equals Productivity', 'Simplicity', 'Fun']
+   ]
+  },
+  {name: 'Languages',
+   contents: [
+     ['Lisp', 'GC'],
+     ['C', 'Linked'],
+     ['C++', 'Objects', 'Linked'],
+     ['Python', 'GC', 'Objects'],
+     ['Ruby', 'GC', 'Objects'],
+     ['JavaScript', 'GC', 'Objects'],
+     ['Scala', 'GC', 'Objects'],
+     ['Erlang', 'GC'],
+     ['6502 Assembly', 'Linked']
+   ]
+  },
+  {name: 'Favorite Scientists',
+   contents: [
+     ['Ada Lovelace', 'Computer Science'],
+     ['Grace Hopper', 'Computer Science'],
+     ['Marie Curie', 'Physics', 'Chemistry'],
+     ['Carl Friedrich Gauss', 'Math', 'Physics'],
+     ['Nikola Tesla', 'Physics'],
+     ['Claude Shannon', 'Math', 'Computer Science']
+   ]
+  }
+];
+
+var timestamp = (new Date()).getTime();
+for (var i = 0; i < data.length; i++) {
+  var listId = lists.insert({name: data[i].name});
+  for (var j = 0; j < data[i].contents.length; j++) {
+    var info = data[i].contents[j];
+    todos.insert({listId: listId,
+                  text: info[0],
+                  done: false,
+                  timestamp: timestamp,
+                  tags: info.slice(1)});
+    timestamp += 1;  // ensure unique timestamp
+  }
+}
+
+exports.lists = lists;
+exports.todos = todos;
diff --git a/examples/todos/todos_appd/browser/dispatcher.js b/examples/todos/todos_appd/browser/dispatcher.js
new file mode 100644
index 0000000..f2e3d10
--- /dev/null
+++ b/examples/todos/todos_appd/browser/dispatcher.js
@@ -0,0 +1,43 @@
+// Note, this is a mix of React Actions, Dispatcher, and Stores.
+
+'use strict';
+
+module.exports = Dispatcher;
+
+function Dispatcher(lists, todos) {
+  this.lists_ = lists;
+  this.todos_ = todos;
+}
+
+Dispatcher.prototype = {
+  addList: function(name) {
+    return this.lists_.insert({name: name});
+  },
+  editListName: function(listId, name) {
+    this.lists_.update(listId, {$set: {name: name}});
+  },
+  addTodo: function(listId, text, tags) {
+    return this.todos_.insert({
+      listId: listId,
+      text: text,
+      done: false,
+      timestamp: (new Date()).getTime(),
+      tags: tags
+    });
+  },
+  removeTodo: function(todoId) {
+    this.todos_.remove(todoId);
+  },
+  editTodoText: function(todoId, text) {
+    this.todos_.update(todoId, {$set: {text: text}});
+  },
+  markTodoDone: function(todoId, done) {
+    this.todos_.update(todoId, {$set: {done: done}});
+  },
+  addTag: function(todoId, tag) {
+    this.todos_.update(todoId, {$addToSet: {tags: tag}});
+  },
+  removeTag: function(todoId, tag) {
+    this.todos_.update(todoId, {$pull: {tags: tag}});
+  }
+};
diff --git a/examples/todos/todos_appd/browser/index.js b/examples/todos/todos_appd/browser/index.js
new file mode 100644
index 0000000..75123e8
--- /dev/null
+++ b/examples/todos/todos_appd/browser/index.js
@@ -0,0 +1,448 @@
+'use strict';
+
+var Dispatcher = require('./dispatcher');
+
+////////////////////////////////////////
+// Global state
+
+var defaults = require('./defaults');
+var cLists = defaults.lists;
+var cTodos = defaults.todos;
+
+var d = new Dispatcher(cLists, cTodos);
+
+////////////////////////////////////////
+// Helpers
+
+function activateInput(input) {
+  input.focus();
+  input.select();
+}
+
+function okCancelEvents(callbacks) {
+  var ok = callbacks.ok || function() {};
+  var cancel = callbacks.cancel || function() {};
+  function done(ev) {
+    var value = ev.target.value;
+    if (value) {
+      ok(value, ev);
+    } else {
+      cancel(ev);
+    }
+  }
+  return {
+    onKeyDown: function(ev) {
+      if (ev.which === 27) {  // esc
+        cancel(ev);
+      }
+    },
+    onKeyUp: function(ev) {
+      if (ev.which === 13) {  // enter
+        done(ev);
+      }
+    },
+    onBlur: function(ev) {
+      done(ev);
+    }
+  };
+}
+
+////////////////////////////////////////
+// Components
+
+var TagFilter = React.createClass({
+  displayName: 'TagFilter',
+  render: function() {
+    var that = this;
+    var tagFilter = this.props.tagFilter;
+    var tagInfos = [], totalCount = 0;
+    _.each(this.props.todos, function(todo) {
+      _.each(todo.tags, function(tag) {
+        var tagInfo = _.find(tagInfos, function(x) {
+          return x.tag === tag;
+        });
+        if (!tagInfo) {
+          tagInfos.push({tag: tag, count: 1, selected: tagFilter === tag});
+        } else {
+          tagInfo.count++;
+        }
+      });
+      totalCount++;
+    });
+    tagInfos = _.sortBy(tagInfos, function(x) { return x.tag; });
+    // Note, the 'All items' tag handling is fairly convoluted in Meteor.
+    tagInfos.unshift({
+      tag: null,
+      count: totalCount,
+      selected: tagFilter === null
+    });
+
+    var children = [];
+    _.each(tagInfos, function(tagInfo) {
+      var count = React.DOM.span(
+          {className: 'count'}, '(' + tagInfo.count + ')');
+      children.push(React.DOM.div({
+        className: 'tag' + (tagInfo.selected ? ' selected' : ''),
+        onMouseDown: function() {
+          var newTagFilter = tagFilter === tagInfo.tag ? null : tagInfo.tag;
+          that.props.setTagFilter(newTagFilter);
+        }
+      }, tagInfo.tag === null ? 'All items' : tagInfo.tag, ' ', count));
+    });
+    return React.DOM.div(
+        {id: 'tag-filter', className: 'tag-list'},
+        React.DOM.div({className: 'label'}, 'Show:'),
+        children);
+  }
+});
+
+var Tags = React.createClass({
+  displayName: 'Tags',
+  getInitialState: function() {
+    return {
+      addingTag: false
+    };
+  },
+  componentDidUpdate: function() {
+    if (this.state.addingTag) {
+      activateInput(this.getDOMNode().querySelector('#edittag-input'));
+    }
+  },
+  render: function() {
+    var that = this;
+    var children = [];
+    _.each(this.props.tags, function(tag) {
+      // Note, we must specify the "key" prop so that React doesn't reuse the
+      // opacity=0 node after a tag is removed.
+      children.push(React.DOM.div(
+          {className: 'tag removable_tag', key: tag},
+          React.DOM.div({className: 'name'}, tag),
+          React.DOM.div({
+            className: 'remove',
+            onClick: function(ev) {
+              ev.target.parentNode.style.opacity = 0;
+              // Wait for CSS animation to finish.
+              window.setTimeout(function() {
+                d.removeTag(that.props.todoId, tag);
+              }, 300);
+            }
+          })));
+    });
+    if (this.state.addingTag) {
+      children.push(React.DOM.div(
+          {className: 'tag edittag'},
+          React.DOM.input(_.assign({
+            type: 'text',
+            id: 'edittag-input',
+            defaultValue: ''
+          }, okCancelEvents({
+            ok: function(value) {
+              d.addTag(that.props.todoId, value);
+              that.setState({addingTag: false});
+            },
+            cancel: function() {
+              that.setState({addingTag: false});
+            }
+          })))));
+    } else {
+      children.push(React.DOM.div({
+        className: 'tag addtag',
+        onClick: function() {
+          that.setState({addingTag: true});
+        }
+      }, '+tag'));
+    }
+    return React.DOM.div({className: 'item-tags'}, children);
+  }
+});
+
+var Todo = React.createClass({
+  displayName: 'Todo',
+  getInitialState: function() {
+    return {
+      editingText: false
+    };
+  },
+  componentDidUpdate: function() {
+    if (this.state.editingText) {
+      activateInput(this.getDOMNode().querySelector('#todo-input'));
+    }
+  },
+  render: function() {
+    var that = this;
+    var todo = this.props.todo, children = [];
+    if (this.state.editingText) {
+      children.push(React.DOM.div(
+          {className: 'edit'},
+          React.DOM.input(_.assign({
+            id: 'todo-input',
+            type: 'text',
+            defaultValue: todo.text
+          }, okCancelEvents({
+            ok: function(value) {
+              d.editTodoText(todo._id, value);
+              that.setState({editingText: false});
+            },
+            cancel: function() {
+              that.setState({editingText: false});
+            }
+          })))));
+    } else {
+      children.push(React.DOM.div({
+        className: 'destroy',
+        onClick: function() {
+          d.removeTodo(todo._id);
+        }
+      }));
+      children.push(React.DOM.div(
+          {className: 'display'},
+          React.DOM.input({
+            className: 'check',
+            name: 'markdone',
+            type: 'checkbox',
+            checked: todo.done,
+            onClick: function() {
+              d.markTodoDone(!todo.done);
+            }
+          }),
+          React.DOM.div({
+            className: 'todo-text',
+            onDoubleClick: function() {
+              that.setState({editingText: true});
+            }
+          }, todo.text)));
+    }
+    children.push(new Tags({todoId: todo._id, tags: todo.tags}));
+    return React.DOM.li({
+      className: 'todo' + (todo.done ? ' done' : '')
+    }, children);
+  }
+});
+
+var Todos = React.createClass({
+  displayName: 'Todos',
+  render: function() {
+    var that = this;
+    if (this.props.listId === null) {
+      return null;
+    }
+    var children = [];
+    if (this.props.todos === null) {
+      children.push('Loading...');
+    } else {
+      var tagFilter = this.props.tagFilter, items = [];
+      _.each(this.props.todos, function(todo) {
+        if (tagFilter === null || _.contains(todo.tags, tagFilter)) {
+          items.push(new Todo({todo: todo}));
+        }
+      });
+      children.push(React.DOM.div(
+          {id: 'new-todo-box'},
+          React.DOM.input(_.assign({
+            type: 'text',
+            id: 'new-todo',
+            placeholder: 'New item'
+          }, okCancelEvents({
+            ok: function(value, ev) {
+              var tags = tagFilter ? [tagFilter] : [];
+              d.addTodo(that.props.listId, value, tags);
+              ev.target.value = '';
+            }
+          })))));
+      children.push(React.DOM.ul({id: 'item-list'}, items));
+    }
+    return React.DOM.div({id: 'items-view'}, children);
+  }
+});
+
+var List = React.createClass({
+  displayName: 'List',
+  getInitialState: function() {
+    return {
+      editingName: false
+    };
+  },
+  componentDidUpdate: function() {
+    if (this.state.editingName) {
+      activateInput(this.getDOMNode().querySelector('#list-name-input'));
+    }
+  },
+  render: function() {
+    var that = this;
+    var list = this.props.list, child;
+    // http://facebook.github.io/react/docs/forms.html#controlled-components
+    if (this.state.editingName) {
+      child = React.DOM.div(
+          {className: 'edit'},
+          React.DOM.input(_.assign({
+            className: 'list-name-input',
+            id: 'list-name-input',
+            type: 'text',
+            defaultValue: list.name
+          }, okCancelEvents({
+            ok: function(value) {
+              d.editListName(list._id, value);
+              that.setState({editingName: false});
+            },
+            cancel: function() {
+              that.setState({editingName: false});
+            }
+          }))));
+    } else {
+      child = React.DOM.div(
+          {className: 'display'},
+          React.DOM.a({
+            className: 'list-name' + (list.name ? '' : ' empty'),
+            href: '/lists/' + list._id
+          }, list.name));
+    }
+    return React.DOM.div({
+      className: 'list' + (list.selected ? ' selected' : ''),
+      onMouseDown: function() {
+        that.props.setListId(list._id);
+      },
+      onClick: function(ev) {
+        ev.preventDefault();  // prevent page refresh
+      },
+      onDoubleClick: function() {
+        that.setState({editingName: true});
+      }
+    }, child);
+  }
+});
+
+var Lists = React.createClass({
+  displayName: 'Lists',
+  render: function() {
+    var that = this;
+    var children = [React.DOM.h3({}, 'Todo Lists')];
+    if (this.props.lists === null) {
+      children.push(React.DOM.div({id: 'lists'}, 'Loading...'));
+    } else {
+      var lists = [];
+      _.each(this.props.lists, function(list) {
+        list.selected = that.props.listId === list._id;
+        lists.push(new List({
+          list: list,
+          setListId: that.props.setListId
+        }));
+      });
+      children.push(React.DOM.div({id: 'lists'}, lists));
+      children.push(React.DOM.div(
+          {id: 'createList'},
+          React.DOM.input(_.assign({
+            type: 'text',
+            id: 'new-list',
+            placeholder: 'New list'
+          }, okCancelEvents({
+            ok: function(value, ev) {
+              var id = d.addList(value);
+              that.props.setListId(id);
+              ev.target.value = '';
+            }
+          })))));
+    }
+    return React.DOM.div({}, children);
+  }
+});
+
+var Page = React.createClass({
+  displayName: 'Page',
+  getInitialState: function() {
+    return {
+      lists: null,  // all lists
+      todos: null,  // all todos for current listId
+      listId: this.props.initialListId,  // current list
+      tagFilter: null  // current tag
+    };
+  },
+  fetchLists: function() {
+    return cLists.find({}, {sort: {name: 1}});
+  },
+  fetchTodos: function(listId) {
+    if (listId === null) {
+      return null;
+    }
+    return cTodos.find({listId: listId}, {sort: {timestamp: 1}});
+  },
+  updateURL: function() {
+    var router = this.props.router, listId = this.state.listId;
+    router.navigate(listId === null ? '' : '/lists/' + String(listId));
+  },
+  componentDidMount: function() {
+    var that = this;
+    var lists = this.fetchLists();
+    var listId = this.state.listId;
+    if (listId === null && lists.length > 0) {
+      listId = lists[0]._id;
+    }
+    this.setState({
+      lists: lists,
+      todos: this.fetchTodos(listId),
+      listId: listId
+    });
+    this.updateURL();
+
+    cLists.on('change', function() {
+      that.setState({lists: that.fetchLists()});
+    });
+    cTodos.on('change', function() {
+      that.setState({todos: that.fetchTodos(that.state.listId)});
+    });
+  },
+  componentDidUpdate: function() {
+    this.updateURL();
+  },
+  render: function() {
+    var that = this;
+    return React.DOM.div({}, [
+      React.DOM.div({id: 'top-tag-filter'}, new TagFilter({
+        todos: this.state.todos,
+        tagFilter: this.state.tagFilter,
+        setTagFilter: function(tagFilter) {
+          that.setState({tagFilter: tagFilter});
+        }
+      })),
+      React.DOM.div({id: 'main-pane'}, new Todos({
+        todos: this.state.todos,
+        listId: this.state.listId,
+        tagFilter: this.state.tagFilter
+      })),
+      React.DOM.div({id: 'side-pane'}, new Lists({
+        lists: this.state.lists,
+        listId: this.state.listId,
+        setListId: function(listId) {
+          if (listId !== that.state.listId) {
+            that.setState({
+              todos: that.fetchTodos(listId),
+              listId: listId
+            });
+          }
+        }
+      }))
+    ]);
+  }
+});
+
+////////////////////////////////////////
+// UI initialization
+
+var Router = Backbone.Router.extend({
+  routes: {
+    '': 'main',
+    'lists/:listId': 'main'
+  }
+});
+var router = new Router();
+
+var page;
+router.on('route:main', function(listId) {
+  console.assert(!page);
+  if (listId !== null) {
+    listId = parseInt(listId, 10);
+  }
+  page = new Page({router: router, initialListId: listId});
+  React.renderComponent(page, document.getElementById('c'));
+});
+
+Backbone.history.start({pushState: true});
diff --git a/examples/todos/todos_appd/index.html b/examples/todos/todos_appd/index.html
index 4871e63..0edab81 100644
--- a/examples/todos/todos_appd/index.html
+++ b/examples/todos/todos_appd/index.html
@@ -3,20 +3,16 @@
   <head>
     <meta charset="utf-8">
     <meta name="viewport" content="width=device-width, initial-scale=1">
-    <link rel="stylesheet" href="/public/css/index.css">
+    <link rel="stylesheet" href="/public/index.css">
     <title>Todos</title>
   </head>
   <body>
     <div id="c"></div>
-    <script src="/public/third_party/jquery-2.1.1.min.js"></script>
-    <script src="/public/third_party/lodash.min.js"></script>
-    <script src="/public/third_party/backbone-min.js"></script>
+    <script src="/third_party/jquery-2.1.1.min.js"></script>
+    <script src="/third_party/lodash.min.js"></script>
+    <script src="/third_party/backbone-min.js"></script>
     <!--<script src="public/third_party/react-0.11.1.min.js"></script>-->
-    <script src="/public/third_party/react-0.11.1.js"></script>
-    <script src="/public/js/collection.js"></script>
-    <script src="/public/js/bootstrap.js"></script>
-    <script src="/public/js/dispatcher.js"></script>
-    <script src="/public/js/index.js"></script>
-    <script>app.init();</script>
+    <script src="/third_party/react-0.11.1.js"></script>
+    <script src="/public/bundle.js"></script>
   </body>
 </html>
diff --git a/examples/todos/todos_appd/misc/readme.txt b/examples/todos/todos_appd/misc/readme.txt
deleted file mode 100644
index 942c2c7..0000000
--- a/examples/todos/todos_appd/misc/readme.txt
+++ /dev/null
@@ -1,6 +0,0 @@
-make lint
-make run
-
-cd ~/dev/meteor/todos
-meteor reset
-meteor run
diff --git a/examples/todos/todos_appd/misc/todo.txt b/examples/todos/todos_appd/misc/todo.txt
deleted file mode 100644
index 4650a84..0000000
--- a/examples/todos/todos_appd/misc/todo.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-- Browserify, use require() syntax
-- Use Veyron store and sync
-- Write alternative, Mercury-based UI
diff --git a/examples/todos/todos_appd/package.json b/examples/todos/todos_appd/package.json
index 4365b1b..4832279 100644
--- a/examples/todos/todos_appd/package.json
+++ b/examples/todos/todos_appd/package.json
@@ -1,10 +1,7 @@
 {
-  "name": "todos",
+  "name": "todos_appd",
   "version": "0.0.1",
   "dependencies": {
     "express": "^4.6.1"
-  },
-  "devDependencies": {
-    "browserify": "^5.0.8"
   }
 }
diff --git a/examples/todos/todos_appd/public/static/close_16.png b/examples/todos/todos_appd/public/close_16.png
similarity index 100%
rename from examples/todos/todos_appd/public/static/close_16.png
rename to examples/todos/todos_appd/public/close_16.png
Binary files differ
diff --git a/examples/todos/todos_appd/public/static/destroy.png b/examples/todos/todos_appd/public/destroy.png
similarity index 100%
rename from examples/todos/todos_appd/public/static/destroy.png
rename to examples/todos/todos_appd/public/destroy.png
Binary files differ
diff --git a/examples/todos/todos_appd/public/css/index.css b/examples/todos/todos_appd/public/index.css
similarity index 96%
rename from examples/todos/todos_appd/public/css/index.css
rename to examples/todos/todos_appd/public/index.css
index 0f75a81..6b21348 100644
--- a/examples/todos/todos_appd/public/css/index.css
+++ b/examples/todos/todos_appd/public/index.css
@@ -202,7 +202,7 @@
 }
 
 #item-list .todo:hover .destroy {
-  background: url("/public/static/destroy.png") no-repeat 0 0;
+  background: url("/public/destroy.png") no-repeat 0 0;
 }
 
 #item-list .todo .destroy:hover {
@@ -229,7 +229,7 @@
   right: 4px;
   bottom: 0;
   width: 16px;
-  background: url("/public/static/close_16.png") no-repeat 0 center;
+  background: url("/public/close_16.png") no-repeat 0 center;
 }
 
 #item-list .todo .item-tags .tag .remove:hover {
diff --git a/examples/todos/todos_appd/public/js/bootstrap.js b/examples/todos/todos_appd/public/js/bootstrap.js
deleted file mode 100644
index 597fa20..0000000
--- a/examples/todos/todos_appd/public/js/bootstrap.js
+++ /dev/null
@@ -1,63 +0,0 @@
-var app = app || {};
-
-(function() {
-  'use strict';
-
-  var Lists = new app.Collection('lists');
-  var Todos = new app.Collection('todos');
-
-  app.Lists = Lists;
-  app.Todos = Todos;
-
-  // Copied from meteor/todos/server/bootstrap.js.
-  var data = [
-    {name: 'Meteor Principles',
-     contents: [
-       ['Data on the Wire', 'Simplicity', 'Better UX', 'Fun'],
-       ['One Language', 'Simplicity', 'Fun'],
-       ['Database Everywhere', 'Simplicity'],
-       ['Latency Compensation', 'Better UX'],
-       ['Full Stack Reactivity', 'Better UX', 'Fun'],
-       ['Embrace the Ecosystem', 'Fun'],
-       ['Simplicity Equals Productivity', 'Simplicity', 'Fun']
-     ]
-    },
-    {name: 'Languages',
-     contents: [
-       ['Lisp', 'GC'],
-       ['C', 'Linked'],
-       ['C++', 'Objects', 'Linked'],
-       ['Python', 'GC', 'Objects'],
-       ['Ruby', 'GC', 'Objects'],
-       ['JavaScript', 'GC', 'Objects'],
-       ['Scala', 'GC', 'Objects'],
-       ['Erlang', 'GC'],
-       ['6502 Assembly', 'Linked']
-     ]
-    },
-    {name: 'Favorite Scientists',
-     contents: [
-       ['Ada Lovelace', 'Computer Science'],
-       ['Grace Hopper', 'Computer Science'],
-       ['Marie Curie', 'Physics', 'Chemistry'],
-       ['Carl Friedrich Gauss', 'Math', 'Physics'],
-       ['Nikola Tesla', 'Physics'],
-       ['Claude Shannon', 'Math', 'Computer Science']
-     ]
-    }
-  ];
-
-  var timestamp = (new Date()).getTime();
-  for (var i = 0; i < data.length; i++) {
-    var listId = Lists.insert({name: data[i].name});
-    for (var j = 0; j < data[i].contents.length; j++) {
-      var info = data[i].contents[j];
-      Todos.insert({listId: listId,
-                    text: info[0],
-                    done: false,
-                    timestamp: timestamp,
-                    tags: info.slice(1)});
-      timestamp += 1;  // ensure unique timestamp
-    }
-  }
-}());
diff --git a/examples/todos/todos_appd/public/js/collection.js b/examples/todos/todos_appd/public/js/collection.js
deleted file mode 100644
index 8e6443d..0000000
--- a/examples/todos/todos_appd/public/js/collection.js
+++ /dev/null
@@ -1,138 +0,0 @@
-// TODO: Use minimongo?
-
-var app = app || {};
-
-(function() {
-  'use strict';
-
-  var CHANGE = 'change';
-
-  var BaseEvent = function(type) {
-    this.type = type;
-  };
-
-  var ChangeEvent = function() {
-    BaseEvent.bind(this)(CHANGE);
-  };
-
-  app.Collection = function(name) {
-    this.name_ = name;
-    this.vals_ = [];
-
-    this.listeners_ = {};
-    this.listeners_[CHANGE] = [];
-  };
-
-  app.Collection.prototype = {
-    find: function(q, opts) {
-      var that = this;
-      q = this.normalize_(q);
-      var res = _.filter(this.vals_, function(v) {
-        return that.matches_(v, q);
-      });
-      if (opts.sort) {
-        // TODO: Eliminate simplifying assumptions.
-        var keys = _.keys(opts.sort);
-        console.assert(keys.length === 1);
-        var key = keys[0];
-        console.assert(opts.sort[key] === 1);
-        res = res.sort(function(a, b) {
-          // TODO: Verify and enhance comparator.
-          return a[key] > b[key];
-        });
-      }
-      return _.cloneDeep(res);
-    },
-    findOne: function(q, opts) {
-      var all = this.find(q, opts);
-      if (all.length > 0) {
-        return all[0];
-      }
-      return null;
-    },
-    insert: function(v) {
-      console.assert(!_.has(v, '_id'));
-      v = _.assign({}, v, {_id: this.vals_.length});
-      this.vals_.push(v);
-      this.dispatchEvent_(new ChangeEvent());
-      return v._id;
-    },
-    remove: function(q) {
-      var that = this;
-      q = this.normalize_(q);
-      this.vals_ = _.filter(this.vals_, function(v) {
-        return !that.matches_(v, q);
-      });
-      this.dispatchEvent_(new ChangeEvent());
-    },
-    update: function(q, opts) {
-      var that = this;
-      q = this.normalize_(q);
-      var vals = _.filter(this.vals_, function(v) {
-        return that.matches_(v, q);
-      });
-
-      // TODO: Eliminate simplifying assumptions.
-      var keys = _.keys(opts);
-      console.assert(keys.length === 1);
-      var key = keys[0];
-      console.assert(_.contains(['$addToSet', '$pull', '$set'], key));
-      var opt = opts[key];
-      var fields = _.keys(opt);
-      console.assert(keys.length === 1);
-      var field = fields[0];
-
-      _.each(vals, function(val) {
-        switch (key) {
-        case '$addToSet':
-          val[field] = _.union(val[field], [opt[field]]);
-          break;
-        case '$pull':
-          val[field] = _.without(val[field], opt[field]);
-          break;
-        case '$set':
-          val[field] = opt[field];
-          break;
-        }
-      });
-
-      this.dispatchEvent_(new ChangeEvent());
-    },
-    addEventListener: function(type, handler) {
-      this.listeners_[type].push(handler);
-    },
-    removeEventListener: function(type, handler) {
-      this.listeners_[type] = _.without(this.listeners_[type], handler);
-    },
-    onChange: function(handler) {
-      this.addEventListener(CHANGE, handler);
-    },
-    normalize_: function(q) {
-      if (_.isObject(q)) {
-        return q;
-      }
-      return {_id: q};
-    },
-    matches_: function(v, q) {
-      var keys = _.keys(q);
-      for (var i = 0; i < keys.length; i++) {
-        var key = keys[i];
-        if (_.isArray(v[key]) && !_.isArray(q[key])) {
-          if (!_.contains(v[key], q[key])) {
-            return false;
-          }
-        } else {
-          if (q[key] !== v[key]) {
-            return false;
-          }
-        }
-      }
-      return true;
-    },
-    dispatchEvent_: function(e) {
-      _.each(this.listeners_[e.type], function(handler) {
-        handler(e);
-      });
-    }
-  };
-}());
diff --git a/examples/todos/todos_appd/public/js/dispatcher.js b/examples/todos/todos_appd/public/js/dispatcher.js
deleted file mode 100644
index addb42d..0000000
--- a/examples/todos/todos_appd/public/js/dispatcher.js
+++ /dev/null
@@ -1,43 +0,0 @@
-// Note, this is a mix of React Actions, Dispatcher, and Stores.
-
-var app = app || {};
-
-(function() {
-  'use strict';
-
-  app.Dispatcher = function() {
-  };
-
-  app.Dispatcher.prototype = {
-    addList: function(name) {
-      return app.Lists.insert({name: name});
-    },
-    editListName: function(listId, name) {
-      app.Lists.update(listId, {$set: {name: name}});
-    },
-    addTodo: function(listId, text, tags) {
-      return app.Todos.insert({
-        listId: listId,
-        text: text,
-        done: false,
-        timestamp: (new Date()).getTime(),
-        tags: tags
-      });
-    },
-    removeTodo: function(todoId) {
-      app.Todos.remove(todoId);
-    },
-    editTodoText: function(todoId, text) {
-      app.Todos.update(todoId, {$set: {text: text}});
-    },
-    markTodoDone: function(todoId, done) {
-      app.Todos.update(todoId, {$set: {done: done}});
-    },
-    addTag: function(todoId, tag) {
-      app.Todos.update(todoId, {$addToSet: {tags: tag}});
-    },
-    removeTag: function(todoId, tag) {
-      app.Todos.update(todoId, {$pull: {tags: tag}});
-    }
-  };
-}());
diff --git a/examples/todos/todos_appd/public/js/index.js b/examples/todos/todos_appd/public/js/index.js
deleted file mode 100644
index d966443..0000000
--- a/examples/todos/todos_appd/public/js/index.js
+++ /dev/null
@@ -1,445 +0,0 @@
-var app = app || {};
-
-(function() {
-  'use strict';
-
-  ////////////////////////////////////////
-  // Helpers
-
-  var d = new app.Dispatcher();
-
-  var activateInput = function(input) {
-    input.focus();
-    input.select();
-  };
-
-  var okCancelEvents = function(callbacks) {
-    var ok = callbacks.ok || function() {};
-    var cancel = callbacks.cancel || function() {};
-    var done = function(ev) {
-      var value = ev.target.value;
-      if (value) {
-        ok(value, ev);
-      } else {
-        cancel(ev);
-      }
-    };
-    return {
-      onKeyDown: function(ev) {
-        if (ev.which === 27) {  // esc
-          cancel(ev);
-        }
-      },
-      onKeyUp: function(ev) {
-        if (ev.which === 13) {  // enter
-          done(ev);
-        }
-      },
-      onBlur: function(ev) {
-        done(ev);
-      }
-    };
-  };
-
-  ////////////////////////////////////////
-  // Components
-
-  var TagFilter = React.createClass({
-    displayName: 'TagFilter',
-    render: function() {
-      var that = this;
-      var tagFilter = this.props.tagFilter;
-      var tagInfos = [], totalCount = 0;
-      _.each(this.props.todos, function(todo) {
-        _.each(todo.tags, function(tag) {
-          var tagInfo = _.find(tagInfos, function(x) {
-            return x.tag === tag;
-          });
-          if (!tagInfo) {
-            tagInfos.push({tag: tag, count: 1, selected: tagFilter === tag});
-          } else {
-            tagInfo.count++;
-          }
-        });
-        totalCount++;
-      });
-      tagInfos = _.sortBy(tagInfos, function(x) { return x.tag; });
-      // Note, the 'All items' tag handling is fairly convoluted in Meteor.
-      tagInfos.unshift({
-        tag: null,
-        count: totalCount,
-        selected: tagFilter === null
-      });
-
-      var children = [];
-      _.each(tagInfos, function(tagInfo) {
-        var count = React.DOM.span(
-          {className: 'count'}, '(' + tagInfo.count + ')');
-        children.push(React.DOM.div({
-          className: 'tag' + (tagInfo.selected ? ' selected' : ''),
-          onMouseDown: function() {
-            var newTagFilter = tagFilter === tagInfo.tag ? null : tagInfo.tag;
-            that.props.setTagFilter(newTagFilter);
-          }
-        }, tagInfo.tag === null ? 'All items' : tagInfo.tag, ' ', count));
-      });
-      return React.DOM.div(
-        {id: 'tag-filter', className: 'tag-list'},
-        React.DOM.div({className: 'label'}, 'Show:'),
-        children);
-    }
-  });
-
-  var Tags = React.createClass({
-    displayName: 'Tags',
-    getInitialState: function() {
-      return {
-        addingTag: false
-      };
-    },
-    componentDidUpdate: function() {
-      if (this.state.addingTag) {
-        activateInput(this.getDOMNode().querySelector('#edittag-input'));
-      }
-    },
-    render: function() {
-      var that = this;
-      var children = [];
-      _.each(this.props.tags, function(tag) {
-        // Note, we must specify the "key" prop so that React doesn't reuse the
-        // opacity=0 node after a tag is removed.
-        children.push(React.DOM.div(
-          {className: 'tag removable_tag', key: tag},
-          React.DOM.div({className: 'name'}, tag),
-          React.DOM.div({
-            className: 'remove',
-            onClick: function(ev) {
-              ev.target.parentNode.style.opacity = 0;
-              // Wait for CSS animation to finish.
-              window.setTimeout(function() {
-                d.removeTag(that.props.todoId, tag);
-              }, 300);
-            }
-          })));
-      });
-      if (this.state.addingTag) {
-        children.push(React.DOM.div(
-          {className: 'tag edittag'},
-          React.DOM.input(_.assign({
-            type: 'text',
-            id: 'edittag-input',
-            defaultValue: ''
-          }, okCancelEvents({
-            ok: function(value) {
-              d.addTag(that.props.todoId, value);
-              that.setState({addingTag: false});
-            },
-            cancel: function() {
-              that.setState({addingTag: false});
-            }
-          })))));
-      } else {
-        children.push(React.DOM.div({
-          className: 'tag addtag',
-          onClick: function() {
-            that.setState({addingTag: true});
-          }
-        }, '+tag'));
-      }
-      return React.DOM.div({className: 'item-tags'}, children);
-    }
-  });
-
-  var Todo = React.createClass({
-    displayName: 'Todo',
-    getInitialState: function() {
-      return {
-        editingText: false
-      };
-    },
-    componentDidUpdate: function() {
-      if (this.state.editingText) {
-        activateInput(this.getDOMNode().querySelector('#todo-input'));
-      }
-    },
-    render: function() {
-      var that = this;
-      var todo = this.props.todo, children = [];
-      if (this.state.editingText) {
-        children.push(React.DOM.div(
-          {className: 'edit'},
-          React.DOM.input(_.assign({
-            id: 'todo-input',
-            type: 'text',
-            defaultValue: todo.text
-          }, okCancelEvents({
-            ok: function(value) {
-              d.editTodoText(todo._id, value);
-              that.setState({editingText: false});
-            },
-            cancel: function() {
-              that.setState({editingText: false});
-            }
-          })))));
-      } else {
-        children.push(React.DOM.div({
-          className: 'destroy',
-          onClick: function() {
-            d.removeTodo(todo._id);
-          }
-        }));
-        children.push(React.DOM.div(
-          {className: 'display'},
-          React.DOM.input({
-            className: 'check',
-            name: 'markdone',
-            type: 'checkbox',
-            checked: todo.done,
-            onClick: function() {
-              d.markTodoDone(!todo.done);
-            }
-          }),
-          React.DOM.div({
-            className: 'todo-text',
-            onDoubleClick: function() {
-              that.setState({editingText: true});
-            }
-          }, todo.text)));
-      }
-      children.push(new Tags({todoId: todo._id, tags: todo.tags}));
-      return React.DOM.li({
-        className: 'todo' + (todo.done ? ' done' : '')
-      }, children);
-    }
-  });
-
-  var Todos = React.createClass({
-    displayName: 'Todos',
-    render: function() {
-      var that = this;
-      if (this.props.listId === null) {
-        return null;
-      }
-      var children = [];
-      if (this.props.todos === null) {
-        children.push('Loading...');
-      } else {
-        var tagFilter = this.props.tagFilter, items = [];
-        _.each(this.props.todos, function(todo) {
-          if (tagFilter === null || _.contains(todo.tags, tagFilter)) {
-            items.push(new Todo({todo: todo}));
-          }
-        });
-        children.push(React.DOM.div(
-          {id: 'new-todo-box'},
-          React.DOM.input(_.assign({
-            type: 'text',
-            id: 'new-todo',
-            placeholder: 'New item'
-          }, okCancelEvents({
-            ok: function(value, ev) {
-              var tags = tagFilter ? [tagFilter] : [];
-              d.addTodo(that.props.listId, value, tags);
-              ev.target.value = '';
-            }
-          })))));
-        children.push(React.DOM.ul({id: 'item-list'}, items));
-      }
-      return React.DOM.div({id: 'items-view'}, children);
-    }
-  });
-
-  var List = React.createClass({
-    displayName: 'List',
-    getInitialState: function() {
-      return {
-        editingName: false
-      };
-    },
-    componentDidUpdate: function() {
-      if (this.state.editingName) {
-        activateInput(this.getDOMNode().querySelector('#list-name-input'));
-      }
-    },
-    render: function() {
-      var that = this;
-      var list = this.props.list, child;
-      // http://facebook.github.io/react/docs/forms.html#controlled-components
-      if (this.state.editingName) {
-        child = React.DOM.div(
-          {className: 'edit'},
-          React.DOM.input(_.assign({
-            className: 'list-name-input',
-            id: 'list-name-input',
-            type: 'text',
-            defaultValue: list.name
-          }, okCancelEvents({
-            ok: function(value) {
-              d.editListName(list._id, value);
-              that.setState({editingName: false});
-            },
-            cancel: function() {
-              that.setState({editingName: false});
-            }
-          }))));
-      } else {
-        child = React.DOM.div(
-          {className: 'display'},
-          React.DOM.a({
-            className: 'list-name' + (list.name ? '' : ' empty'),
-            href: '/' + list._id
-          }, list.name));
-      }
-      return React.DOM.div({
-        className: 'list' + (list.selected ? ' selected' : ''),
-        onMouseDown: function() {
-          that.props.setListId(list._id);
-        },
-        onClick: function(ev) {
-          ev.preventDefault();  // prevent page refresh
-        },
-        onDoubleClick: function() {
-          that.setState({editingName: true});
-        }
-      }, child);
-    }
-  });
-
-  var Lists = React.createClass({
-    displayName: 'Lists',
-    render: function() {
-      var that = this;
-      var children = [React.DOM.h3({}, 'Todo Lists')];
-      if (this.props.lists === null) {
-        children.push(React.DOM.div({id: 'lists'}, 'Loading...'));
-      } else {
-        var lists = [];
-        _.each(this.props.lists, function(list) {
-          list.selected = that.props.listId === list._id;
-          lists.push(new List({
-            list: list,
-            setListId: that.props.setListId
-          }));
-        });
-        children.push(React.DOM.div({id: 'lists'}, lists));
-        children.push(React.DOM.div(
-          {id: 'createList'},
-          React.DOM.input(_.assign({
-            type: 'text',
-            id: 'new-list',
-            placeholder: 'New list'
-          }, okCancelEvents({
-            ok: function(value, ev) {
-              var id = d.addList(value);
-              that.props.setListId(id);
-              ev.target.value = '';
-            }
-          })))));
-      }
-      return React.DOM.div({}, children);
-    }
-  });
-
-  var Page = React.createClass({
-    displayName: 'Page',
-    getInitialState: function() {
-      return {
-        lists: null,  // all lists
-        todos: null,  // all todos for current listId
-        listId: this.props.initialListId,  // current list
-        tagFilter: null  // current tag
-      };
-    },
-    fetchLists: function() {
-      return app.Lists.find({}, {sort: {name: 1}});
-    },
-    fetchTodos: function(listId) {
-      if (listId === null) {
-        return null;
-      }
-      return app.Todos.find({listId: listId}, {sort: {timestamp: 1}});
-    },
-    updateURL: function() {
-      var router = this.props.router, listId = this.state.listId;
-      router.navigate(listId === null ? '' : String(listId));
-    },
-    componentDidMount: function() {
-      var that = this;
-      var lists = this.fetchLists();
-      var listId = this.state.listId;
-      if (listId === null && lists.length > 0) {
-        listId = lists[0]._id;
-      }
-      this.setState({
-        lists: lists,
-        todos: this.fetchTodos(listId),
-        listId: listId
-      });
-      this.updateURL();
-
-      app.Lists.onChange(function() {
-        that.setState({lists: that.fetchLists()});
-      });
-      app.Todos.onChange(function() {
-        that.setState({todos: that.fetchTodos(that.state.listId)});
-      });
-    },
-    componentDidUpdate: function() {
-      this.updateURL();
-    },
-    render: function() {
-      var that = this;
-      return React.DOM.div({}, [
-        React.DOM.div({id: 'top-tag-filter'}, new TagFilter({
-          todos: this.state.todos,
-          tagFilter: this.state.tagFilter,
-          setTagFilter: function(tagFilter) {
-            that.setState({tagFilter: tagFilter});
-          }
-        })),
-        React.DOM.div({id: 'main-pane'}, new Todos({
-          todos: this.state.todos,
-          listId: this.state.listId,
-          tagFilter: this.state.tagFilter
-        })),
-        React.DOM.div({id: 'side-pane'}, new Lists({
-          lists: this.state.lists,
-          listId: this.state.listId,
-          setListId: function(listId) {
-            if (listId !== that.state.listId) {
-              that.setState({
-                todos: that.fetchTodos(listId),
-                listId: listId
-              });
-            }
-          }
-        }))
-      ]);
-    }
-  });
-
-  ////////////////////////////////////////
-  // Initialization
-
-  app.init = function() {
-    var Router = Backbone.Router.extend({
-      routes: {
-        '': 'main',
-        ':listId': 'main'
-      }
-    });
-    var router = new Router();
-
-    var page;
-    router.on('route:main', function(listId) {
-      console.assert(!page);
-      if (listId !== null) {
-        listId = Number(listId);
-      }
-      page = new Page({router: router, initialListId: listId});
-      React.renderComponent(page, document.getElementById('c'));
-    });
-
-    Backbone.history.start({pushState: true});
-  };
-}());
diff --git a/examples/todos/todos_appd/server.js b/examples/todos/todos_appd/server.js
index f1416dd..9abd8c4 100644
--- a/examples/todos/todos_appd/server.js
+++ b/examples/todos/todos_appd/server.js
@@ -10,10 +10,15 @@
 }
 
 app.use('/public', express.static(pathTo('public')));
+app.use('/third_party', express.static(pathTo('third_party')));
 
-app.get('*', function(req, res) {
+var routes = ['/', '/lists/*'];
+var handler = function(req, res) {
   res.sendfile('index.html');
-});
+};
+for (var i = 0; i < routes.length; i++) {
+  app.get(routes[i], handler);
+}
 
 var server = app.listen(4000, function() {
   console.log('Serving http://localhost:%d', server.address().port);
diff --git a/examples/todos/todos_appd/public/third_party/backbone-min.js b/examples/todos/todos_appd/third_party/backbone-min.js
similarity index 100%
rename from examples/todos/todos_appd/public/third_party/backbone-min.js
rename to examples/todos/todos_appd/third_party/backbone-min.js
diff --git a/examples/todos/todos_appd/public/third_party/jquery-2.1.1.min.js b/examples/todos/todos_appd/third_party/jquery-2.1.1.min.js
similarity index 100%
rename from examples/todos/todos_appd/public/third_party/jquery-2.1.1.min.js
rename to examples/todos/todos_appd/third_party/jquery-2.1.1.min.js
diff --git a/examples/todos/todos_appd/public/third_party/lodash.min.js b/examples/todos/todos_appd/third_party/lodash.min.js
similarity index 100%
rename from examples/todos/todos_appd/public/third_party/lodash.min.js
rename to examples/todos/todos_appd/third_party/lodash.min.js
diff --git a/examples/todos/todos_appd/public/third_party/react-0.11.1.js b/examples/todos/todos_appd/third_party/react-0.11.1.js
similarity index 100%
rename from examples/todos/todos_appd/public/third_party/react-0.11.1.js
rename to examples/todos/todos_appd/third_party/react-0.11.1.js
diff --git a/examples/todos/todos_appd/public/third_party/react-0.11.1.min.js b/examples/todos/todos_appd/third_party/react-0.11.1.min.js
similarity index 100%
rename from examples/todos/todos_appd/public/third_party/react-0.11.1.min.js
rename to examples/todos/todos_appd/third_party/react-0.11.1.min.js
diff --git a/services/wsprd/wspr.go b/services/wsprd/wspr.go
index 4a01ea0..76efed0 100644
--- a/services/wsprd/wspr.go
+++ b/services/wsprd/wspr.go
@@ -5,14 +5,18 @@
 
 	"veyron/lib/signals"
 	"veyron/services/wsprd/wspr"
+	"veyron2/rt"
 )
 
 func main() {
-	port := flag.Int("port", 8124, "Port to listen on")
-	veyronProxy := flag.String("vproxy", "", "The endpoint for the veyron proxy to publish on. This must be set")
+	port := flag.Int("port", 8124, "Port to listen on.")
+	veyronProxy := flag.String("vproxy", "", "The endpoint for the veyron proxy to publish on. This must be set.")
+	identd := flag.String("identd", "", "The endpoint for the identd server.  This must be set.")
 	flag.Parse()
 
-	proxy := wspr.NewWSPR(*port, *veyronProxy)
+	rt.Init()
+
+	proxy := wspr.NewWSPR(*port, *veyronProxy, *identd)
 	defer proxy.Shutdown()
 	go func() {
 		proxy.Run()
diff --git a/services/wsprd/wspr/pipe.go b/services/wsprd/wspr/pipe.go
index 8c6dae9..c6f981c 100644
--- a/services/wsprd/wspr/pipe.go
+++ b/services/wsprd/wspr/pipe.go
@@ -2,7 +2,6 @@
 
 import (
 	"bytes"
-	"encoding/base64"
 	"encoding/json"
 	"fmt"
 	"io"
@@ -15,7 +14,6 @@
 	"veyron/services/wsprd/app"
 	"veyron/services/wsprd/lib"
 	"veyron2"
-	"veyron2/security"
 	"veyron2/verror"
 	"veyron2/vlog"
 	"veyron2/vom"
@@ -262,37 +260,3 @@
 	}
 	p.cleanup()
 }
-
-func decodeIdentity(logger vlog.Logger, msg string) security.PrivateID {
-	if len(msg) == 0 {
-		return nil
-	}
-	// PrivateIds are sent as base64-encoded-vom-encoded identity.PrivateID.
-	// Pure JSON or pure VOM could not have been used.
-	// - JSON cannot be used because identity.PrivateID contains an
-	//   ecdsa.PrivateKey (which encoding/json cannot decode).
-	// - Regular VOM cannot be used because it only has a binary,
-	//   Go-specific implementation at this time.
-	// The "portable" encoding is base64-encoded VOM (see
-	// veyron/daemon/cmd/identity/responder/responder.go).
-	// When toddw@ has the text-based VOM encoding going, that can probably
-	// be used instead.
-	var id security.PrivateID
-	if err := vom.NewDecoder(base64.NewDecoder(base64.URLEncoding, strings.NewReader(msg))).Decode(&id); err != nil {
-		logger.Error("Could not decode identity:", err)
-		return nil
-	}
-	return id
-}
-
-func encodeIdentity(logger vlog.Logger, identity security.PrivateID) string {
-	var vomEncoded bytes.Buffer
-	if err := vom.NewEncoder(&vomEncoded).Encode(identity); err != nil {
-		logger.Error("Could not encode identity: %v", err)
-	}
-	var base64Encoded bytes.Buffer
-	encoder := base64.NewEncoder(base64.URLEncoding, &base64Encoded)
-	encoder.Write(vomEncoded.Bytes())
-	encoder.Close()
-	return base64Encoded.String()
-}
diff --git a/services/wsprd/wspr/pipe_test.go b/services/wsprd/wspr/pipe_test.go
deleted file mode 100644
index 8a271c6..0000000
--- a/services/wsprd/wspr/pipe_test.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package wspr
-
-import (
-	"testing"
-	"veyron/services/wsprd/lib"
-	"veyron2"
-	"veyron2/rt"
-	"veyron2/security"
-)
-
-var r veyron2.Runtime
-
-func init() {
-	r = rt.Init()
-}
-
-type testWriter struct{}
-
-func (*testWriter) Send(lib.ResponseType, interface{}) error { return nil }
-func (*testWriter) Error(error)                              {}
-
-func TestEncodeDecodeIdentity(t *testing.T) {
-	identity := security.FakePrivateID("/fake/private/id")
-	resultIdentity := decodeIdentity(r.Logger(), encodeIdentity(r.Logger(), identity))
-	if identity != resultIdentity {
-		t.Errorf("expected decodeIdentity(encodeIdentity(identity)) to be %v, got %v", identity, resultIdentity)
-	}
-}
diff --git a/services/wsprd/wspr/wspr.go b/services/wsprd/wspr/wspr.go
index 06c1ffb..6d238fe 100644
--- a/services/wsprd/wspr/wspr.go
+++ b/services/wsprd/wspr/wspr.go
@@ -17,6 +17,7 @@
 import (
 	"bytes"
 	"crypto/tls"
+	"encoding/json"
 	"fmt"
 	"io"
 	"log"
@@ -25,9 +26,11 @@
 	"sync"
 	"time"
 
+	veyron_identity "veyron/services/identity"
 	"veyron/services/wsprd/identity"
 	"veyron2"
 	"veyron2/rt"
+	"veyron2/security"
 	"veyron2/vlog"
 )
 
@@ -41,31 +44,20 @@
 }
 
 type WSPR struct {
-	mu            sync.Mutex
-	tlsCert       *tls.Certificate
-	rt            veyron2.Runtime
-	logger        vlog.Logger
-	port          int
-	veyronProxyEP string
-	idManager     *identity.IDManager
-	pipes         map[*http.Request]*pipe
+	mu             sync.Mutex
+	tlsCert        *tls.Certificate
+	rt             veyron2.Runtime
+	logger         vlog.Logger
+	port           int
+	identdEP       string
+	veyronProxyEP  string
+	idManager      *identity.IDManager
+	blesserService veyron_identity.OAuthBlesser
+	pipes          map[*http.Request]*pipe
 }
 
 var logger vlog.Logger
 
-func (ctx WSPR) handleDebug(w http.ResponseWriter, r *http.Request) {
-	w.Header().Set("Content-Type", "text/html")
-	w.Write([]byte(`<html>
-<head>
-<title>/debug</title>
-</head>
-<body>
-<ul>
-<li><a href="/debug/pprof">/debug/pprof</a></li>
-</li></ul></body></html>
-`))
-}
-
 func readFromRequest(r *http.Request) (*bytes.Buffer, error) {
 	var buf bytes.Buffer
 	if readBytes, err := io.Copy(&buf, r.Body); err != nil {
@@ -82,19 +74,23 @@
 
 // Starts the proxy and listens for requests. This method is blocking.
 func (ctx WSPR) Run() {
-	http.HandleFunc("/debug", ctx.handleDebug)
-	http.Handle("/favicon.ico", http.NotFoundHandler())
-	http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
-		ctx.logger.VI(0).Info("Creating a new websocket")
-		p := newPipe(w, r, &ctx, nil)
+	// Bind to the OAuth Blesser service
+	blesserService, err := veyron_identity.BindOAuthBlesser(ctx.identdEP)
+	if err != nil {
+		log.Fatalf("Failed to bind to identity service at %v: %v", ctx.identdEP, err)
+	}
+	ctx.blesserService = blesserService
 
-		if p == nil {
-			return
-		}
-		ctx.mu.Lock()
-		defer ctx.mu.Unlock()
-		ctx.pipes[r] = p
-	})
+	// HTTP routes
+	http.HandleFunc("/debug", ctx.handleDebug)
+	http.HandleFunc("/create-account", ctx.handleCreateAccount)
+	http.HandleFunc("/assoc-account", ctx.handleAssocAccount)
+	http.HandleFunc("/ws", ctx.handleWS)
+	// Everything else is a 404.
+	// Note: the pattern "/" matches all paths not matched by other
+	// registered patterns, not just the URL with Path == "/".'
+	// (http://golang.org/pkg/net/http/#ServeMux)
+	http.Handle("/", http.NotFoundHandler())
 	ctx.logger.VI(1).Infof("Listening on port %d.", ctx.port)
 	httpErr := http.ListenAndServe(fmt.Sprintf("127.0.0.1:%d", ctx.port), nil)
 	if httpErr != nil {
@@ -113,10 +109,13 @@
 }
 
 // Creates a new WebSocket Proxy object.
-func NewWSPR(port int, veyronProxyEP string, opts ...veyron2.ROpt) *WSPR {
+func NewWSPR(port int, veyronProxyEP, identdEP string, opts ...veyron2.ROpt) *WSPR {
 	if veyronProxyEP == "" {
 		log.Fatalf("a veyron proxy must be set")
 	}
+	if identdEP == "" {
+		log.Fatalf("an identd server must be set")
+	}
 
 	newrt, err := rt.New(opts...)
 	if err != nil {
@@ -131,8 +130,149 @@
 
 	return &WSPR{port: port,
 		veyronProxyEP: veyronProxyEP,
+		identdEP:      identdEP,
 		rt:            newrt,
 		logger:        newrt.Logger(),
 		idManager:     idManager,
 	}
 }
+
+// HTTP Handlers
+
+func (ctx WSPR) handleDebug(w http.ResponseWriter, r *http.Request) {
+	if r.Method != "GET" {
+		w.WriteHeader(http.StatusMethodNotAllowed)
+		fmt.Fprintf(w, "")
+		return
+	}
+	w.Header().Set("Content-Type", "text/html")
+	w.Write([]byte(`<html>
+<head>
+<title>/debug</title>
+</head>
+<body>
+<ul>
+<li><a href="/debug/pprof">/debug/pprof</a></li>
+</li></ul></body></html>
+`))
+}
+
+func (ctx WSPR) handleWS(w http.ResponseWriter, r *http.Request) {
+	if r.Method != "GET" {
+		http.Error(w, "Method not allowed.", http.StatusMethodNotAllowed)
+		return
+	}
+	ctx.logger.VI(0).Info("Creating a new websocket")
+	p := newPipe(w, r, &ctx, nil)
+
+	if p == nil {
+		return
+	}
+	ctx.mu.Lock()
+	defer ctx.mu.Unlock()
+	ctx.pipes[r] = p
+}
+
+// Structs for marshalling input/output to create-account route.
+type createAccountInput struct {
+	AccessToken string `json:access_token`
+}
+
+type createAccountOutput struct {
+	Names []string `json:names`
+}
+
+// Handler for creating an account in the identity manager.
+// A valid OAuth2 access token must be supplied in the request body. That
+// access token is exchanged for a blessing from the identd server.  A new
+// privateID is then derived from WSPR's privateID and the blessing. That
+// privateID is stored in the identity manager. The name of the new privateID
+// is returned to the client.
+func (ctx WSPR) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
+	if r.Method != "POST" {
+		http.Error(w, "Method not allowed.", http.StatusMethodNotAllowed)
+		return
+	}
+
+	// Parse request body.
+	var data createAccountInput
+	if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+		msg := fmt.Sprintf("Error parsing body: %v", err)
+		ctx.logger.Error(msg)
+		http.Error(w, msg, http.StatusBadRequest)
+	}
+
+	// Get a blessing for the access token from identity server.
+	blessingAny, err := ctx.blesserService.BlessUsingAccessToken(ctx.rt.NewContext(), data.AccessToken)
+	if err != nil {
+		msg := fmt.Sprintf("Error getting blessing for access token: %v", err)
+		ctx.logger.Error(msg)
+		http.Error(w, msg, http.StatusBadRequest)
+		return
+	}
+	blessing := blessingAny.(security.PublicID)
+
+	// Derive a new identity from the runtime's identity and the blessing.
+	identity, err := ctx.rt.Identity().Derive(blessing)
+	if err != nil {
+		msg := fmt.Sprintf("Error deriving identity: %v", err)
+		ctx.logger.Error(msg)
+		http.Error(w, msg, http.StatusBadRequest)
+		return
+	}
+
+	for _, name := range blessing.Names() {
+		// Store identity in identity manager.
+		if err := ctx.idManager.AddAccount(name, identity); err != nil {
+			msg := fmt.Sprintf("Error storing identity: %v", err)
+			ctx.logger.Error(msg)
+			http.Error(w, msg, http.StatusBadRequest)
+			return
+		}
+	}
+
+	// Return the names to the client.
+	out := createAccountOutput{
+		Names: blessing.Names(),
+	}
+	outJson, err := json.Marshal(out)
+	if err != nil {
+		msg := fmt.Sprintf("Error mashalling names: %v", err)
+		ctx.logger.Error(msg)
+		http.Error(w, msg, http.StatusInternalServerError)
+		return
+	}
+
+	// Success.
+	fmt.Fprintf(w, string(outJson))
+}
+
+// Struct for marshalling input to assoc-account route.
+type assocAccountInput struct {
+	Name   string `json:name`
+	Origin string `json:origin`
+}
+
+// Handler for associating an existing privateID with an origin.
+func (ctx WSPR) handleAssocAccount(w http.ResponseWriter, r *http.Request) {
+	if r.Method != "POST" {
+		http.Error(w, "Method not allowed.", http.StatusMethodNotAllowed)
+		return
+	}
+
+	// Parse request body.
+	var data assocAccountInput
+	if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+		http.Error(w, fmt.Sprintf("Error parsing body: %v", err), http.StatusBadRequest)
+	}
+
+	// Store the origin.
+	// TODO(nlacasse, bjornick): determine what the caveats should be.
+	if err := ctx.idManager.AddOrigin(data.Origin, data.Name, nil); err != nil {
+		http.Error(w, fmt.Sprintf("Error associating account: %v", err), http.StatusBadRequest)
+		return
+	}
+
+	// Success.
+	fmt.Fprintf(w, "")
+}
diff --git a/services/wsprd/wspr/wspr_test.go b/services/wsprd/wspr/wspr_test.go
new file mode 100644
index 0000000..2677a54
--- /dev/null
+++ b/services/wsprd/wspr/wspr_test.go
@@ -0,0 +1,227 @@
+package wspr
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"veyron2/context"
+	"veyron2/ipc"
+	"veyron2/security"
+	"veyron2/vdl/vdlutil"
+)
+
+// BEGIN MOCK BLESSER SERVICE
+// TODO(nlacasse): Is there a better way to mock this?!
+type mockBlesserService struct {
+	id    security.PrivateID
+	count int
+}
+
+func newMockBlesserService(id security.PrivateID) *mockBlesserService {
+	return &mockBlesserService{
+		id:    id,
+		count: 0,
+	}
+}
+
+func (m *mockBlesserService) BlessUsingAccessToken(c context.T, accessToken string, co ...ipc.CallOpt) (vdlutil.Any, error) {
+	m.count = m.count + 1
+	name := fmt.Sprintf("mock-blessing-%v", m.count)
+	return m.id.Bless(m.id.PublicID(), name, 5*time.Minute, nil)
+}
+
+// This is never used.  Only needed for mock.
+func (m *mockBlesserService) BlessUsingAuthorizationCode(c context.T, authCode, redirect string, co ...ipc.CallOpt) (vdlutil.Any, error) {
+	return m.id.PublicID(), nil
+}
+
+// This is never used.  Only needed for mock.
+func (m *mockBlesserService) GetMethodTags(c context.T, s string, co ...ipc.CallOpt) ([]interface{}, error) {
+	return nil, nil
+}
+
+// This is never used.  Only needed for mock.
+func (m *mockBlesserService) Signature(c context.T, co ...ipc.CallOpt) (ipc.ServiceSignature, error) {
+	return ipc.ServiceSignature{}, nil
+}
+
+// This is never used.  Only needed for mock.
+func (m *mockBlesserService) UnresolveStep(c context.T, co ...ipc.CallOpt) ([]string, error) {
+	return []string{}, nil
+}
+
+// END MOCK BLESSER SERVICE
+
+func setup(t *testing.T) (*WSPR, func()) {
+	wspr := NewWSPR(0, "/mock/proxy", "/mock/identd")
+	providerId := wspr.rt.Identity()
+
+	wspr.blesserService = newMockBlesserService(providerId)
+	return wspr, func() {
+		wspr.Shutdown()
+	}
+}
+
+func TestHandleCreateAccount(t *testing.T) {
+	wspr, teardown := setup(t)
+	defer teardown()
+
+	method := "POST"
+	path := "/create-account"
+
+	// Add one account
+	data1 := createAccountInput{
+		AccessToken: "mock-access-token-1",
+	}
+	data1Json, err := json.Marshal(data1)
+	if err != nil {
+		t.Fatalf("json.Marshal(%v) failed: %v", data1, err)
+	}
+
+	data1JsonReader := bytes.NewReader(data1Json)
+	req, err := http.NewRequest(method, path, (data1JsonReader))
+	if err != nil {
+		t.Fatalf("http.NewRequest(%v, %v, %v,) failed: %v", method, path, data1JsonReader, err)
+	}
+
+	resp1 := httptest.NewRecorder()
+	wspr.handleCreateAccount(resp1, req)
+	if resp1.Code != 200 {
+		t.Fatalf("Expected handleCreateAccount to return 200 OK, instead got %v", resp1)
+	}
+
+	// Verify that idManager has the new account
+	topLevelName := wspr.rt.Identity().PublicID().Names()[0]
+	expectedAccountName := topLevelName + "/mock-blessing-1"
+	gotAccounts := wspr.idManager.AccountsMatching(security.PrincipalPattern(expectedAccountName))
+	if len(gotAccounts) != 1 {
+		t.Fatalf("Expected to have 1 account with name %v, but got %v: %v", expectedAccountName, len(gotAccounts), gotAccounts)
+	}
+
+	// Add another account
+	data2 := createAccountInput{
+		AccessToken: "mock-access-token-2",
+	}
+	data2Json, err := json.Marshal(data2)
+	if err != nil {
+		t.Fatalf("json.Marshal(%v) failed: %v", data2, err)
+	}
+	data2JsonReader := bytes.NewReader(data2Json)
+	req, err = http.NewRequest(method, path, data2JsonReader)
+	if err != nil {
+		t.Fatalf("http.NewRequest(%v, %v, %v,) failed: %v", method, path, data2JsonReader, err)
+	}
+
+	resp2 := httptest.NewRecorder()
+	wspr.handleCreateAccount(resp2, req)
+	if resp2.Code != 200 {
+		t.Fatalf("Expected handleCreateAccount to return 200 OK, instead got %v", resp2)
+	}
+
+	// Verify that idManager has both accounts
+	gotAccounts = wspr.idManager.AccountsMatching(security.PrincipalPattern(topLevelName + "/*"))
+	if len(gotAccounts) != 2 {
+		t.Fatalf("Expected to have 2 accounts, but got %v: %v", len(gotAccounts), gotAccounts)
+	}
+}
+
+func TestHandleAssocAccount(t *testing.T) {
+	wspr, teardown := setup(t)
+	defer teardown()
+
+	// First create an accounts.
+	accountName := "mock-account"
+	identityName := "mock-id"
+	privateID, err := wspr.rt.NewIdentity(identityName)
+	if err != nil {
+		t.Fatalf("wspr.rt.NewIdentity(%v) failed: %v", identityName, err)
+	}
+	if err := wspr.idManager.AddAccount(accountName, privateID); err != nil {
+		t.Fatalf("wspr.idManager.AddAccount(%v, %v) failed; %v", accountName, privateID, err)
+	}
+
+	// Associate with that account
+	method := "POST"
+	path := "/assoc-account"
+
+	origin := "https://my.webapp.com:443"
+	data := assocAccountInput{
+		Name:   accountName,
+		Origin: origin,
+	}
+
+	dataJson, err := json.Marshal(data)
+	if err != nil {
+		t.Fatalf("json.Marshal(%v) failed: %v", data, err)
+	}
+
+	dataJsonReader := bytes.NewReader(dataJson)
+	req, err := http.NewRequest(method, path, (dataJsonReader))
+	if err != nil {
+		t.Fatalf("http.NewRequest(%v, %v, %v,) failed: %v", method, path, dataJsonReader, err)
+	}
+
+	resp := httptest.NewRecorder()
+	wspr.handleAssocAccount(resp, req)
+	if resp.Code != 200 {
+		t.Fatalf("Expected handleAssocAccount to return 200 OK, instead got %v", resp)
+	}
+
+	// Verify that idManager has the correct identity for the origin
+	gotID, err := wspr.idManager.Identity(origin)
+	if err != nil {
+		t.Fatalf("wspr.idManager.Identity(%v) failed: %v", origin, err)
+	}
+
+	if gotID == nil {
+		t.Fatalf("Expected wspr.idManager.Identity(%v) to return an valid identity, but got %v", origin, gotID)
+	}
+}
+
+func TestHandleAssocAccountWithMissingAccount(t *testing.T) {
+	wspr, teardown := setup(t)
+	defer teardown()
+
+	method := "POST"
+	path := "/assoc-account"
+
+	accountName := "mock-account"
+	origin := "https://my.webapp.com:443"
+	data := assocAccountInput{
+		Name:   accountName,
+		Origin: origin,
+	}
+
+	dataJson, err := json.Marshal(data)
+	if err != nil {
+		t.Fatalf("json.Marshal(%v) failed: %v", data, err)
+	}
+
+	dataJsonReader := bytes.NewReader(dataJson)
+	req, err := http.NewRequest(method, path, (dataJsonReader))
+	if err != nil {
+		t.Fatalf("http.NewRequest(%v, %v, %v,) failed: %v", method, path, dataJsonReader, err)
+	}
+
+	// Verify that the request fails with 400 Bad Request error
+	resp := httptest.NewRecorder()
+	wspr.handleAssocAccount(resp, req)
+	if resp.Code != 400 {
+		t.Fatalf("Expected handleAssocAccount to return 400 error, but got %v", resp)
+	}
+
+	// Verify that idManager has no identities for the origin
+	gotID, err := wspr.idManager.Identity(origin)
+	if err == nil {
+		t.Fatalf("Expected wspr.idManager.Identity(%v) to fail, but got: %v", origin, gotID)
+	}
+
+	if gotID != nil {
+		t.Fatalf("Expected wspr.idManager.Identity(%v) not to return an identity, but got %v", origin, gotID)
+	}
+}