diff --git a/collections_.py b/collections_.py
index 16a574dc4c82787d44dca99cb1d431f52ad23739..856a8e105613d232a377474c61098c910a9018a1 100644
--- a/collections_.py
+++ b/collections_.py
@@ -1,13 +1,10 @@
 # @author:  Gunnar Schaefer
 
-import re
-import json
-import webapp2
-import bson.json_util
-
 import logging
 log = logging.getLogger('nimsapi')
 
+import bson.json_util
+
 import nimsapiutil
 
 
@@ -48,7 +45,9 @@ class Collections(nimsapiutil.NIMSRequestHandler):
 
     def count(self):
         """Return the number of Collections."""
-        self.response.write(json.dumps(self.app.db.collections.count()))
+        if self.request.method == 'OPTIONS':
+            return self.options()
+        self.response.write(self.app.db.collections.count())
 
     def post(self):
         """Create a new Collection."""
@@ -63,8 +62,7 @@ class Collections(nimsapiutil.NIMSRequestHandler):
         """Return the list of Collections."""
         query = {'permissions.uid': self.uid} if not self.user_is_superuser else None
         projection = {'curator': 1, 'name': 1, 'notes': 1, 'permissions': {'$elemMatch': {'uid': self.uid}}}
-        collections = list(self.app.db.collections.find(query, projection))
-        self.response.write(json.dumps(collections, default=bson.json_util.default))
+        return list(self.app.db.collections.find(query, projection))
 
     def put(self):
         """Update many Collections."""
@@ -114,8 +112,7 @@ class Collection(nimsapiutil.NIMSRequestHandler):
     def get(self, cid):
         """Return one Collection, conditionally with details."""
         cid = bson.ObjectId(cid)
-        collection = self.get_collection(cid)
-        self.response.write(json.dumps(collection, default=bson.json_util.default))
+        return self.get_collection(cid)
 
     def put(self, cid):
         """Update an existing Collection."""
@@ -171,7 +168,9 @@ class Sessions(nimsapiutil.NIMSRequestHandler):
 
     def count(self):
         """Return the number of Sessions."""
-        self.response.write(json.dumps(self.app.db.sessions.count()))
+        if self.request.method == 'OPTIONS':
+            return self.options()
+        self.response.write(self.app.db.sessions.count())
 
     def post(self):
         """Create a new Session"""
@@ -190,7 +189,7 @@ class Sessions(nimsapiutil.NIMSRequestHandler):
         sessions = list(self.app.db.sessions.find(query, projection))
         for sess in sessions:
             sess['site'] = self.app.config['site_id']
-        self.response.write(json.dumps(sessions, default=bson.json_util.default))
+        return sessions
 
     def put(self):
         """Update many Sessions."""
@@ -230,7 +229,9 @@ class Epochs(nimsapiutil.NIMSRequestHandler):
 
     def count(self):
         """Return the number of Epochs."""
-        self.response.write(json.dumps(self.app.db.epochs.count()))
+        if self.request.method == 'OPTIONS':
+            return self.options()
+        self.response.write(self.app.db.epochs.count())
 
     def post(self):
         """Create a new Epoch."""
@@ -247,9 +248,7 @@ class Epochs(nimsapiutil.NIMSRequestHandler):
         elif sid != '':
             self.abort(400, sid + ' is not a valid ObjectId')
         projection = ['name', 'description', 'datatype', 'notes']
-        print query
-        epochs = list(self.app.db.epochs.find(query, projection))
-        self.response.write(json.dumps(epochs, default=bson.json_util.default))
+        return list(self.app.db.epochs.find(query, projection))
 
     def put(self):
         """Update many Epochs."""
diff --git a/experiments.py b/experiments.py
index 18752fd5a0be175c20cc7850f9b7059dd1930730..dab607b27ead38b0b035ca04c4b4f9c633e84042 100644
--- a/experiments.py
+++ b/experiments.py
@@ -1,12 +1,10 @@
 # @author:  Gunnar Schaefer
 
-import json
-import webapp2
-import bson.json_util
-
 import logging
 log = logging.getLogger('nimsapi')
 
+import bson.json_util
+
 import nimsdata
 import nimsapiutil
 
@@ -51,7 +49,9 @@ class Experiments(nimsapiutil.NIMSRequestHandler):
 
     def count(self):
         """Return the number of Experiments."""
-        self.response.write(json.dumps(self.app.db.experiments.count()))
+        if self.request.method == 'OPTIONS':
+            return self.options()
+        self.response.write(self.app.db.experiments.count())
 
     def post(self):
         """Create a new Experiment."""
@@ -64,7 +64,7 @@ class Experiments(nimsapiutil.NIMSRequestHandler):
         experiments = list(self.app.db.experiments.find(query, projection))
         for exp in experiments:
             exp['site'] = self.app.config['site_id']
-        self.response.write(json.dumps(experiments, default=bson.json_util.default))
+        return experiments
 
     def put(self):
         """Update many Experiments."""
@@ -117,8 +117,7 @@ class Experiment(nimsapiutil.NIMSRequestHandler):
     def get(self, xid):
         """Return one Experiment, conditionally with details."""
         xid = bson.ObjectId(xid)
-        experiment = self.get_experiment(xid)
-        self.response.write(json.dumps(experiment, default=bson.json_util.default))
+        return self.get_experiment(xid)
 
     def put(self, xid):
         """Update an existing Experiment."""
@@ -168,7 +167,9 @@ class Sessions(nimsapiutil.NIMSRequestHandler):
 
     def count(self):
         """Return the number of Sessions."""
-        self.response.write(json.dumps(self.app.db.sessions.count()))
+        if self.request.method == 'OPTIONS':
+            return self.options()
+        self.response.write(self.app.db.sessions.count())
 
     def post(self):
         """Create a new Session"""
@@ -180,8 +181,7 @@ class Sessions(nimsapiutil.NIMSRequestHandler):
         self.get_experiment(xid) # ensure permissions
         query = {'experiment': xid}
         projection = ['name', 'subject', 'notes']
-        sessions = list(self.app.db.sessions.find(query, projection))
-        self.response.write(json.dumps(sessions, default=bson.json_util.default))
+        return list(self.app.db.sessions.find(query, projection))
 
     def put(self):
         """Update many Sessions."""
@@ -222,16 +222,17 @@ class Session(nimsapiutil.NIMSRequestHandler):
     }
 
     def schema(self, *args, **kwargs):
+        if self.request.method == 'OPTIONS':
+            return self.options()
         import copy
         json_schema = copy.deepcopy(self.json_schema)
         json_schema['properties'].update(nimsdata.NIMSData.session_properties)
-        self.response.write(json.dumps(json_schema, default=bson.json_util.default))
+        return json_schema
 
     def get(self, sid):
         """Return one Session, conditionally with details."""
         sid = bson.ObjectId(sid)
-        session = self.get_session(sid)
-        self.response.write(json.dumps(session, default=bson.json_util.default))
+        return self.get_session(sid)
 
     def put(self, sid):
         """Update an existing Session."""
@@ -247,15 +248,6 @@ class Session(nimsapiutil.NIMSRequestHandler):
         """Delete a Session."""
         self.abort(501)
 
-    def move(self, sid):
-        """
-        Move a Session to another Experiment.
-
-        Usage:
-            /nimsapi/sessions/123/move?dest=456
-        """
-        self.response.write('session %s move, %s\n' % (sid, self.request.params))
-
 
 class Epochs(nimsapiutil.NIMSRequestHandler):
 
@@ -290,7 +282,9 @@ class Epochs(nimsapiutil.NIMSRequestHandler):
 
     def count(self):
         """Return the number of Epochs."""
-        self.response.write(json.dumps(self.app.db.epochs.count()))
+        if self.request.method == 'OPTIONS':
+            return self.options()
+        self.response.write(self.app.db.epochs.count())
 
     def post(self):
         """Create a new Epoch."""
@@ -302,8 +296,7 @@ class Epochs(nimsapiutil.NIMSRequestHandler):
         self.get_session(sid) # ensure permissions
         query = {'session': sid}
         projection = ['name', 'description', 'datatype', 'notes']
-        epochs = list(self.app.db.epochs.find(query, projection))
-        self.response.write(json.dumps(epochs, default=bson.json_util.default))
+        return list(self.app.db.epochs.find(query, projection))
 
     def put(self):
         """Update many Epochs."""
@@ -340,16 +333,17 @@ class Epoch(nimsapiutil.NIMSRequestHandler):
     }
 
     def schema(self, *args, **kwargs):
+        if self.request.method == 'OPTIONS':
+            return self.options()
         import copy
         json_schema = copy.deepcopy(self.json_schema)
         json_schema['properties'].update(nimsdata.nimsdicom.NIMSDicom.epoch_properties)
-        self.response.write(json.dumps(json_schema, default=bson.json_util.default))
+        return json_schema
 
     def get(self, eid):
         """Return one Epoch, conditionally with details."""
         eid = bson.ObjectId(eid)
-        epoch = self.get_epoch(eid)
-        self.response.write(json.dumps(epoch, default=bson.json_util.default))
+        return self.get_epoch(eid)
 
     def put(self, eid):
         """Update an existing Epoch."""
diff --git a/internimsclient.py b/internimsclient.py
index e8ff3e4fe1c1977181d2b0d5bfda488d79d6724d..a576fe691419446e4b758e37b3a232a45bb94b91 100755
--- a/internimsclient.py
+++ b/internimsclient.py
@@ -2,6 +2,12 @@
 #
 # @author:  Gunnar Schaefer, Kevin S. Hahn
 
+import logging
+import logging.config
+log = logging.getLogger('internims')
+logging.getLogger('requests').setLevel(logging.WARNING)
+
+import re
 import json
 import base64
 import datetime
@@ -10,15 +16,10 @@ import Crypto.Hash.SHA
 import Crypto.PublicKey.RSA
 import Crypto.Signature.PKCS1_v1_5
 
-import logging
-import logging.config
-log = logging.getLogger('internims')
-logging.getLogger('requests').setLevel(logging.WARNING)
-
 
 def update(db, api_uri, site_id, privkey, internims_url):
     """sends is-alive signal to internims central."""
-    db.remotes.ensure_index('UTC', expireAfterSeconds=120)
+    db.remotes.ensure_index('timestamp', expireAfterSeconds=120)
 
     exp_userlist = [e['permissions'] for e in db.experiments.find(None, {'_id': False, 'permissions.uid': True})]
     col_userlist = [c['permissions'] for c in db.collections.find(None, {'_id': False, 'permissions.uid': True})]
@@ -29,25 +30,27 @@ def update(db, api_uri, site_id, privkey, internims_url):
     signature = Crypto.Signature.PKCS1_v1_5.new(privkey).sign(h)
     headers = {'Authorization': base64.b64encode(signature)}
 
-    r = requests.post(url=internims_url, data=payload, headers=headers, verify=True)
+    r = requests.post(internims_url, data=payload, headers=headers)
     if r.status_code == 200:
         response = (json.loads(r.content))
         # update remotes entries
         for site in response['sites']:
-            site['UTC'] = datetime.datetime.strptime(site['timestamp'], '%Y-%m-%dT%H:%M:%S.%f')
-            db.remotes.find_and_modify({'_id': site['_id']}, update=site, upsert=True)
-            log.debug('upserting remote: ' + site['_id'])
-
-        # update, add remotes to users
-        new_remotes = response['users']
-        log.debug('users w/ remotes: ' + str(new_remotes))
-        for user in response['users']:
-            db.users.update({'uid': user}, {'$set': {'remotes': new_remotes.get(user, [])}})
-
-        # cannot use new_remotes.viewkeys(). leads to 'bson.errors.InvalidDocument: Cannot encode object: dict_keys([])'
-        db.users.update({'remotes': {'$exists':True}, 'uid': {'$nin': new_remotes.keys()}}, {'$unset': {'remotes': ''}}, multi=True)
+            site['timestamp'] = datetime.datetime.strptime(site['timestamp'], '%Y-%m-%dT%H:%M:%S.%fZ')
+            db.remotes.update({'_id': site['_id']}, site, upsert=True)
+        log.debug('updating remotes: ' + ', '.join((r['_id'] for r in response['sites'])))
+
+        # delete remotes from users, who no longer have remotes
+        db.users.update({'remotes': {'$exists':True}, '_id': {'$nin': response['users'].keys()}}, {'$unset': {'remotes': ''}}, multi=True)
+
+        # add remotes to users
+        log.debug('users w/ remotes: ' + ', '.join(response['users']))
+        for uid, remotes in response['users'].iteritems():
+            db.users.update({'_id': uid}, {'$set': {'remotes': remotes}})
     else:
-        log.warning((r.status_code, r.reason))
+        # r.reason contains generic description for the specific error code
+        # need the part of the error response body that contains the detailed explanation
+        reason = re.search('<br /><br />\n(.*)\n\n\n </body>\n</html>', r.content)
+        log.warning((r.status_code, reason.group(1)))
 
 
 if __name__ == '__main__':
diff --git a/nimsapi.py b/nimsapi.py
index fe4a708f5dacb09ef3c4d5490d6be6adcbb985e8..a3ec983c842cf31bc7f022696a8668ac025b0090 100755
--- a/nimsapi.py
+++ b/nimsapi.py
@@ -2,6 +2,10 @@
 #
 # @author:  Gunnar Schaefer, Kevin S. Hahn
 
+import logging
+import logging.config
+log = logging.getLogger('nimsapi')
+
 import os
 import re
 import json
@@ -15,15 +19,23 @@ import bson.json_util
 import webapp2_extras.routes
 import Crypto.PublicKey.RSA
 
-import logging
-import logging.config
-log = logging.getLogger('nimsapi')
-
+import users
 import experiments
 import nimsapiutil
 import collections_
 import tempdir as tempfile
 
+def hrsize(size):
+    if size < 1000:
+        return '%d%s' % (size, 'B')
+    for suffix in 'KMGTPEZY':
+        size /= 1024.
+        if size < 10.:
+            return '%.1f%s' % (size, suffix)
+        if size < 1000.:
+            return '%.0f%s' % (size, suffix)
+    return '%.0f%s' % (size, 'Y')
+
 
 class NIMSAPI(nimsapiutil.NIMSRequestHandler):
 
@@ -38,12 +50,12 @@ class NIMSAPI(nimsapiutil.NIMSRequestHandler):
         resources = """
             Resource                                            | Description
             :---------------------------------------------------|:-----------------------
-            nimsapi/download                                    | download
+            nimsapi/login                                       | user login
+            [(nimsapi/remotes)]                                 | list of remote instances
             nimsapi/upload                                      | upload
-            nimsapi/remotes                                     | list of remote instances
-            [(nimsapi/log)]                                     | list of uwsgi log messages
+            nimsapi/download                                    | download
+            [(nimsapi/log)]                                     | log messages
             [(nimsapi/users)]                                   | list of users
-            nimsapi/users/current                               | details for currently logged in user
             [(nimsapi/users/count)]                             | count of users
             [(nimsapi/users/listschema)]                        | schema for user list
             [(nimsapi/users/schema)]                            | schema for single user
@@ -100,7 +112,22 @@ class NIMSAPI(nimsapiutil.NIMSRequestHandler):
         self.response.write('</body>\n')
         self.response.write('</html>\n')
 
+    def login(self):
+        """Return details for the current User."""
+        if self.request.method == 'OPTIONS':
+            return self.options()
+        log.debug(self.uid + ' has logged in')
+        return self.app.db.users.find_and_modify({'_id': self.uid}, {'$inc': {'logins': 1}}, fields=['firstname', 'lastname', 'superuser'])
+
+    def remotes(self):
+        """Return the list of all remote sites."""
+        if self.request.method == 'OPTIONS':
+            return self.options()
+        return [r['_id'] for r in self.app.db.remotes.find()]
+
     def upload(self):
+        if self.request.method == 'OPTIONS':
+            return self.options()
         # TODO add security: either authenticated user or machine-to-machine CRAM
         if 'Content-MD5' not in self.request.headers:
             self.abort(400, 'Request must contain a valid "Content-MD5" header.')
@@ -109,7 +136,6 @@ class NIMSAPI(nimsapiutil.NIMSRequestHandler):
         with tempfile.TemporaryDirectory(prefix='.tmp', dir=stage_path) as tempdir_path:
             hash_ = hashlib.md5()
             upload_filepath = os.path.join(tempdir_path, filename)
-            log.info('upload from ' + self.request.user_agent + ': ' + os.path.basename(upload_filepath))
             with open(upload_filepath, 'wb') as upload_file:
                 for chunk in iter(lambda: self.request.body_file.read(2**20), ''):
                     hash_.update(chunk)
@@ -118,9 +144,12 @@ class NIMSAPI(nimsapiutil.NIMSRequestHandler):
                 self.abort(400, 'Content-MD5 mismatch.')
             if not tarfile.is_tarfile(upload_filepath) and not zipfile.is_zipfile(upload_filepath):
                 self.abort(415)
+            log.info('upload from %s: %s [%s]' % (self.request.user_agent, os.path.basename(upload_filepath), hrsize(self.request.content_length)))
             os.rename(upload_filepath, os.path.join(stage_path, str(uuid.uuid1()) + '_' + filename)) # add UUID to prevent clobbering files
 
     def download(self):
+        if self.request.method == 'OPTIONS':
+            return self.options()
         paths = []
         symlinks = []
         for js_id in self.request.get('id', allow_multiple=True):
@@ -129,326 +158,89 @@ class NIMSAPI(nimsapiutil.NIMSRequestHandler):
             paths += _idpaths
             symlinks += _idsymlinks
 
-    def remotes(self):
-        """Return the list of remotes where user has membership"""
-        remotes = [remote['_id'] for remote in list(self.app.db.remotes.find({}, []))]
-        self.response.write(json.dumps(remotes, default=bson.json_util.default))
-
     def log(self):
-        """Return logs"""
-        # TODO: don't hardcode log path.
-        logfile = '/var/log/uwsgi/app/nims.log'
+        """Return logs."""
+        if self.request.method == 'OPTIONS':
+            return self.options()
         try:
-            logs = open(logfile).readlines()
+            logs = open(app.config['log_path']).readlines()
         except IOError as e:
-            log.debug(e)
             if 'Permission denied' in e:
-                # specify body format to print details separate from comment
                 body_template = '${explanation}<br /><br />${detail}<br /><br />${comment}'
                 comment = 'To fix permissions, run the following command: chmod o+r ' + logfile
                 self.abort(500, detail=str(e), comment=comment, body_template=body_template)
             else:
-                # file does not exist
-                self.abort(500, e)
-        else:
-            logs.reverse()
-            numlines = self.request.get('n', None)
-
-            trimmed = []
-            for line in logs:
-                match = re.search('^[\d\s:-]{17}[\s]+nimsapi:[.]*', line)
-                if match:
-                    trimmed.append(line)
-
-            if not numlines: numlines = len(trimmed)
-
-            self.response.headers['Content-Type'] = 'application/json'
-            self.response.write(json.dumps(trimmed[:int(numlines)]))
-
-
-class Users(nimsapiutil.NIMSRequestHandler):
-
-    """/nimsapi/users """
-
-    json_schema = {
-        '$schema': 'http://json-schema.org/draft-04/schema#',
-        'title': 'User List',
-        'type': 'array',
-        'items': {
-            'title': 'User',
-            'type': 'object',
-            'properties': {
-                '_id': {
-                    'title': 'Database ID',
-                    'type': 'string',
-                },
-                'firstname': {
-                    'title': 'First Name',
-                    'type': 'string',
-                    'default': '',
-                },
-                'lastname': {
-                    'title': 'Last Name',
-                    'type': 'string',
-                    'default': '',
-                },
-                'email': {
-                    'title': 'Email',
-                    'type': 'string',
-                    'format': 'email',
-                    'default': '',
-                },
-                'email_hash': {
-                    'type': 'string',
-                    'default': '',
-                },
-            }
-        }
-    }
-
-    def count(self):
-        """Return the number of Users."""
-        self.response.write('%d users\n' % self.app.db.users.count())
-
-    def current(self):
-        """Return the current User."""
-        # FIXME: trim this down to not use the self.user object, and only send relevant info
-        self.response.write(json.dumps(self.user, default=bson.json_util.default))
-
-    def post(self):
-        """Create a new User"""
-        self.response.write('users post\n')
-
-    def get(self):
-        """Return the list of Users."""
-        projection = ['firstname', 'lastname', 'email_hash']
-        users = list(self.app.db.users.find({}, projection))
-        self.response.write(json.dumps(users, default=bson.json_util.default))
-
-    def put(self):
-        """Update many Users."""
-        self.response.write('users put\n')
-
-
-class User(nimsapiutil.NIMSRequestHandler):
-
-    """/nimsapi/users/<uid> """
-
-    json_schema = {
-        '$schema': 'http://json-schema.org/draft-04/schema#',
-        'title': 'User',
-        'type': 'object',
-        'properties': {
-            '_id': {
-                'title': 'Database ID',
-                'type': 'string',
-            },
-            'firstname': {
-                'title': 'First Name',
-                'type': 'string',
-                'default': '',
-            },
-            'lastname': {
-                'title': 'Last Name',
-                'type': 'string',
-                'default': '',
-            },
-            'email': {
-                'title': 'Email',
-                'type': 'string',
-                'format': 'email',
-                'default': '',
-            },
-            'email_hash': {
-                'type': 'string',
-                'default': '',
-            },
-            'superuser': {
-                'title': 'Superuser',
-                'type': 'boolean',
-            },
-        },
-        'required': ['_id'],
-    }
-
-    def get(self, uid):
-        """Return User details."""
-        user = self.app.db.users.find_one({'uid': uid})
-        self.response.write(json.dumps(user, default=bson.json_util.default))
-
-    def put(self, uid):
-        """Update an existing User."""
-        user = self.app.db.users.find_one({'user_info': uid})
-        if not user:
-            self.abort(404)
-        if uid == self.uid or self.user_is_superuser: # users can only update their own info
-            updates = {'$set': {}, '$unset': {}}
-            for k, v in self.request.params.iteritems():
-                if k != 'superuser' and k in []:#user_fields:
-                    updates['$set'][k] = v # FIXME: do appropriate type conversion
-                elif k == 'superuser' and uid == self.uid and self.user_is_superuser is not None: # toggle superuser for requesting user
-                    updates['$set'][k] = v.lower() in ('1', 'true')
-                elif k == 'superuser' and uid != self.uid and self.user_is_superuser:             # enable/disable superuser for other user
-                    if v.lower() in ('1', 'true') and user.get('superuser') is None:
-                        updates['$set'][k] = False # superuser is tri-state: False indicates granted, but disabled, superuser privileges
-                    elif v.lower() not in ('1', 'true'):
-                        updates['$unset'][k] = ''
-            self.app.db.users.update({'user_info': uid}, updates)
-        else:
-            self.abort(403)
-
-    def delete(self, uid):
-        """Delete an User."""
-        self.response.write('user %s delete, %s\n' % (uid, self.request.params))
-
-
-class Groups(nimsapiutil.NIMSRequestHandler):
-
-    """/nimsapi/groups """
-
-    json_schema = {
-        '$schema': 'http://json-schema.org/draft-04/schema#',
-        'title': 'Group List',
-        'type': 'array',
-        'items': {
-            'title': 'Group',
-            'type': 'object',
-            'properties': {
-                '_id': {
-                    'title': 'Database ID',
-                    'type': 'string',
-                },
-            }
-        }
-    }
-
-    def count(self):
-        """Return the number of Groups."""
-        self.response.write('%d groups\n' % self.app.db.groups.count())
-
-    def post(self):
-        """Create a new Group"""
-        self.response.write('groups post\n')
-
-    def get(self):
-        """Return the list of Groups."""
-        projection = ['_id']
-        groups = list(self.app.db.groups.find({}, projection))
-        self.response.write(json.dumps(groups, default=bson.json_util.default))
-
-    def put(self):
-        """Update many Groups."""
-        self.response.write('groups put\n')
-
-
-class Group(nimsapiutil.NIMSRequestHandler):
-
-    """/nimsapi/groups/<gid>"""
-
-    json_schema = {
-        '$schema': 'http://json-schema.org/draft-04/schema#',
-        'title': 'Group',
-        'type': 'object',
-        'properties': {
-            '_id': {
-                'title': 'Database ID',
-                'type': 'string',
-            },
-            'name': {
-                'title': 'Name',
-                'type': 'string',
-                'maxLength': 32,
-            },
-            'pis': {
-                'title': 'PIs',
-                'type': 'array',
-                'default': [],
-                'items': {
-                    'type': 'string',
-                },
-                'uniqueItems': True,
-            },
-            'admins': {
-                'title': 'Admins',
-                'type': 'array',
-                'default': [],
-                'items': {
-                    'type': 'string',
-                },
-                'uniqueItems': True,
-            },
-            'memebers': {
-                'title': 'Members',
-                'type': 'array',
-                'default': [],
-                'items': {
-                    'type': 'string',
-                },
-                'uniqueItems': True,
-            },
-        },
-        'required': ['_id'],
-    }
-
-    def get(self, gid):
-        """Return Group details."""
-        group = self.app.db.groups.find_one({'_id': gid})
-        self.response.write(json.dumps(group, default=bson.json_util.default))
-
-    def put(self, gid):
-        """Update an existing Group."""
-        self.response.write('group %s put, %s\n' % (gid, self.request.params))
-
-    def delete(self, gid):
-        """Delete an Group."""
+                self.abort(500, e) # file does not exist
+        try:
+            n = int(self.request.get('n', 10000))
+        except:
+            self.abort(400, 'n must be an integer')
+        return [line.strip() for line in reversed(logs) if re.match('[-:0-9 ]{18} +nimsapi:(?!.*[/a-z]*/log )', line)][:n]
 
 
 routes = [
-    webapp2.Route(r'/nimsapi',                                      NIMSAPI),
+    webapp2.Route(r'/nimsapi',                                          NIMSAPI),
     webapp2_extras.routes.PathPrefixRoute(r'/nimsapi', [
-        webapp2.Route(r'/download',                                 NIMSAPI, handler_method='download', methods=['GET']),
-        webapp2.Route(r'/upload',                                   NIMSAPI, handler_method='upload', methods=['PUT']),
-        webapp2.Route(r'/remotes',                                  NIMSAPI, handler_method='remotes', methods=['GET']),
-        webapp2.Route(r'/log',                                      NIMSAPI, handler_method='log', methods=['GET']),
-        webapp2.Route(r'/users',                                    Users),
-        webapp2.Route(r'/users/current',                            Users, handler_method='current', methods=['GET']),
-        webapp2.Route(r'/users/count',                              Users, handler_method='count', methods=['GET']),
-        webapp2.Route(r'/users/listschema',                         Users, handler_method='schema', methods=['GET']),
-        webapp2.Route(r'/users/schema',                             User, handler_method='schema', methods=['GET']),
-        webapp2.Route(r'/users/<uid>',                              User),
-        webapp2.Route(r'/groups',                                   Groups),
-        webapp2.Route(r'/groups/count',                             Groups, handler_method='count', methods=['GET']),
-        webapp2.Route(r'/groups/listschema',                        Groups, handler_method='schema', methods=['GET']),
-        webapp2.Route(r'/groups/schema',                            Group, handler_method='schema', methods=['GET']),
-        webapp2.Route(r'/groups/<gid>',                             Group),
-        webapp2.Route(r'/experiments',                              experiments.Experiments),
-        webapp2.Route(r'/experiments/count',                        experiments.Experiments, handler_method='count', methods=['GET']),
-        webapp2.Route(r'/experiments/listschema',                   experiments.Experiments, handler_method='schema', methods=['GET']),
-        webapp2.Route(r'/experiments/schema',                       experiments.Experiment, handler_method='schema', methods=['GET']),
-        webapp2.Route(r'/experiments/<xid:[0-9a-f]{24}>',           experiments.Experiment),
-        webapp2.Route(r'/experiments/<xid:[0-9a-f]{24}>/sessions',  experiments.Sessions),
-        webapp2.Route(r'/sessions/count',                           experiments.Sessions, handler_method='count', methods=['GET']),
-        webapp2.Route(r'/sessions/listschema',                      experiments.Sessions, handler_method='schema', methods=['GET']),
-        webapp2.Route(r'/sessions/schema',                          experiments.Session, handler_method='schema', methods=['GET']),
-        webapp2.Route(r'/sessions/<sid:[0-9a-f]{24}>',              experiments.Session),
-        webapp2.Route(r'/sessions/<sid:[0-9a-f]{24}>/move',         experiments.Session, handler_method='move'),
-        webapp2.Route(r'/sessions/<sid:[0-9a-f]{24}>/epochs',       experiments.Epochs),
-        webapp2.Route(r'/epochs/count',                             experiments.Epochs, handler_method='count', methods=['GET']),
-        webapp2.Route(r'/epochs/listschema',                        experiments.Epochs, handler_method='schema', methods=['GET']),
-        webapp2.Route(r'/epochs/schema',                            experiments.Epoch, handler_method='schema', methods=['GET']),
-        webapp2.Route(r'/epochs/<eid:[0-9a-f]{24}>',                experiments.Epoch),
-        webapp2.Route(r'/collections',                              collections_.Collections),
-        webapp2.Route(r'/collections/count',                        collections_.Collections, handler_method='count', methods=['GET']),
-        webapp2.Route(r'/collections/listschema',                   collections_.Collections, handler_method='schema', methods=['GET']),
-        webapp2.Route(r'/collections/schema',                       collections_.Collection, handler_method='schema', methods=['GET']),
-        webapp2.Route(r'/collections/<cid:[0-9a-f]{24}>',           collections_.Collection),
-        webapp2.Route(r'/collections/<cid:[0-9a-f]{24}>/sessions',  collections_.Sessions),
-        webapp2.Route(r'/collections/<cid:[0-9a-f]{24}>/epochs',    collections_.Epochs),
+        webapp2.Route(r'/login',                                        NIMSAPI, handler_method='login', methods=['OPTIONS', 'GET', 'POST']),
+        webapp2.Route(r'/remotes',                                      NIMSAPI, handler_method='remotes', methods=['OPTIONS', 'GET']),
+        webapp2.Route(r'/upload',                                       NIMSAPI, handler_method='upload', methods=['OPTIONS', 'PUT']),
+        webapp2.Route(r'/download',                                     NIMSAPI, handler_method='download', methods=['OPTIONS', 'GET']),
+        webapp2.Route(r'/log',                                          NIMSAPI, handler_method='log', methods=['OPTIONS', 'GET']),
+    ]),
+    webapp2.Route(r'/nimsapi/users',                                    users.Users),
+    webapp2_extras.routes.PathPrefixRoute(r'/nimsapi/users', [
+        webapp2.Route(r'/count',                                        users.Users, handler_method='count', methods=['OPTIONS', 'GET']),
+        webapp2.Route(r'/listschema',                                   users.Users, handler_method='schema', methods=['OPTIONS', 'GET']),
+        webapp2.Route(r'/schema',                                       users.User, handler_method='schema', methods=['OPTIONS', 'GET']),
+        webapp2.Route(r'/<uid>',                                        users.User),
+    ]),
+    webapp2.Route(r'/nimsapi/groups',                                   users.Groups),
+    webapp2_extras.routes.PathPrefixRoute(r'/nimsapi/groups', [
+        webapp2.Route(r'/count',                                        users.Groups, handler_method='count', methods=['OPTIONS', 'GET']),
+        webapp2.Route(r'/listschema',                                   users.Groups, handler_method='schema', methods=['OPTIONS', 'GET']),
+        webapp2.Route(r'/schema',                                       users.Group, handler_method='schema', methods=['OPTIONS', 'GET']),
+        webapp2.Route(r'/<gid>',                                        users.Group),
+    ]),
+    webapp2.Route(r'/nimsapi/experiments',                              experiments.Experiments),
+    webapp2_extras.routes.PathPrefixRoute(r'/nimsapi/experiments', [
+        webapp2.Route(r'/count',                                        experiments.Experiments, handler_method='count', methods=['OPTIONS', 'GET']),
+        webapp2.Route(r'/listschema',                                   experiments.Experiments, handler_method='schema', methods=['OPTIONS', 'GET']),
+        webapp2.Route(r'/schema',                                       experiments.Experiment, handler_method='schema', methods=['OPTIONS', 'GET']),
+        webapp2.Route(r'/<xid:[0-9a-f]{24}>',                           experiments.Experiment),
+        webapp2.Route(r'/<xid:[0-9a-f]{24}>/sessions',                  experiments.Sessions),
+    ]),
+    webapp2.Route(r'/nimsapi/collections',                              collections_.Collections),
+    webapp2_extras.routes.PathPrefixRoute(r'/nimsapi/collections', [
+        webapp2.Route(r'/count',                                        collections_.Collections, handler_method='count', methods=['OPTIONS', 'GET']),
+        webapp2.Route(r'/listschema',                                   collections_.Collections, handler_method='schema', methods=['OPTIONS', 'GET']),
+        webapp2.Route(r'/schema',                                       collections_.Collection, handler_method='schema', methods=['OPTIONS', 'GET']),
+        webapp2.Route(r'/<cid:[0-9a-f]{24}>',                           collections_.Collection),
+        webapp2.Route(r'/<cid:[0-9a-f]{24}>/sessions',                  collections_.Sessions),
+        webapp2.Route(r'/<cid:[0-9a-f]{24}>/epochs',                    collections_.Epochs),
+    ]),
+    webapp2_extras.routes.PathPrefixRoute(r'/nimsapi/sessions', [
+        webapp2.Route(r'/count',                                        experiments.Sessions, handler_method='count', methods=['OPTIONS', 'GET']),
+        webapp2.Route(r'/listschema',                                   experiments.Sessions, handler_method='schema', methods=['OPTIONS', 'GET']),
+        webapp2.Route(r'/schema',                                       experiments.Session, handler_method='schema', methods=['OPTIONS', 'GET']),
+        webapp2.Route(r'/<sid:[0-9a-f]{24}>',                           experiments.Session),
+        webapp2.Route(r'/<sid:[0-9a-f]{24}>/epochs',                    experiments.Epochs),
+    ]),
+    webapp2_extras.routes.PathPrefixRoute(r'/nimsapi/epochs', [
+        webapp2.Route(r'/count',                                        experiments.Epochs, handler_method='count', methods=['OPTIONS', 'GET']),
+        webapp2.Route(r'/listschema',                                   experiments.Epochs, handler_method='schema', methods=['OPTIONS', 'GET']),
+        webapp2.Route(r'/schema',                                       experiments.Epoch, handler_method='schema', methods=['OPTIONS', 'GET']),
+        webapp2.Route(r'/<eid:[0-9a-f]{24}>',                           experiments.Epoch),
     ]),
 ]
 
+def dispatcher(router, request, response):
+    rv = router.default_dispatcher(request, response)
+    if rv is not None:
+        return response.write(json.dumps(rv, default=bson.json_util.default))
+
 app = webapp2.WSGIApplication(routes, debug=True)
-app.config = dict(stage_path='', site_id=None, ssl_key=None, insecure=False)
+app.router.set_dispatcher(dispatcher)
+app.config = dict(stage_path='', site_id='local', ssl_key=None, insecure=False, log_path='')
 
 
 if __name__ == '__main__':
@@ -462,6 +254,7 @@ if __name__ == '__main__':
     arg_parser.add_argument('config_file', help='path to config file')
     arg_parser.add_argument('--db_uri', help='NIMS DB URI')
     arg_parser.add_argument('--stage_path', help='path to staging area')
+    arg_parser.add_argument('--log_path', help='path to API log file')
     arg_parser.add_argument('--ssl_key', help='path to private SSL key file')
     arg_parser.add_argument('--site_id', help='InterNIMS site ID')
     arg_parser.add_argument('--oauth2_id_endpoint', help='OAuth2 provider ID endpoint')
@@ -470,6 +263,7 @@ if __name__ == '__main__':
     config = ConfigParser.ConfigParser({'here': os.path.dirname(os.path.abspath(args.config_file))})
     config.read(args.config_file)
     logging.config.fileConfig(args.config_file, disable_existing_loggers=False)
+    logging.getLogger('paste.httpserver').setLevel(logging.INFO) # silence paste logging
 
     if args.ssl_key:
         try:
@@ -483,8 +277,9 @@ if __name__ == '__main__':
     else:
         log.warning('private SSL key not specified, internims functionality disabled')
 
-    app.config['site_id'] = args.site_id or 'local'
+    app.config['site_id'] = args.site_id or app.config['site_id']
     app.config['stage_path'] = args.stage_path or config.get('nims', 'stage_path')
+    app.config['log_path'] = args.log_path or app.config['log_path']
     app.config['oauth2_id_endpoint'] = args.oauth2_id_endpoint or config.get('oauth2', 'id_endpoint')
     app.config['insecure'] = config.getboolean('nims', 'insecure')
 
diff --git a/nimsapi.wsgi b/nimsapi.wsgi
index 994240d52d6a8c444d27d8389d4e63573e56a6f7..8dcbc569e72d8eac159537354e85c25553482b21 100644
--- a/nimsapi.wsgi
+++ b/nimsapi.wsgi
@@ -48,6 +48,7 @@ else:
 site_id = config.get('nims', 'site_id')
 application = nimsapi.app
 application.config['stage_path'] = config.get('nims', 'stage_path')
+application.config['log_path'] = config.get('nims', 'log_path')
 application.config['site_id'] = site_id
 application.config['ssl_key']  = privkey
 application.config['oauth2_id_endpoint'] = config.get('oauth2', 'id_endpoint')
diff --git a/nimsapiutil.py b/nimsapiutil.py
index c063a0d3ac6c3416a5067df52ef1fa5baf16be4f..b3cf4fa5391a2d8f560ffb52358cb0d86b5fba5b 100644
--- a/nimsapiutil.py
+++ b/nimsapiutil.py
@@ -1,5 +1,9 @@
 # @author:  Gunnar Schaefer, Kevin S. Hahn
 
+import logging
+log = logging.getLogger('nimsapi')
+logging.getLogger('requests').setLevel(logging.WARNING)                  # silence Requests library logging
+
 import json
 import base64
 import webapp2
@@ -10,10 +14,6 @@ import Crypto.Hash.SHA
 import Crypto.PublicKey.RSA
 import Crypto.Signature.PKCS1_v1_5
 
-import logging
-log = logging.getLogger('nimsapi')
-logging.getLogger('requests').setLevel(logging.WARNING)                  # silence Requests library logging
-
 INTEGER_ROLES = {
         'anon-read':  0,
         'read-only':  1,
@@ -57,174 +57,175 @@ class NIMSRequestHandler(webapp2.RequestHandler):
 
     def __init__(self, request=None, response=None):
         self.initialize(request, response)
-        self.uid = '@public'  # @public is default user
+        self.target_id = self.request.get('iid', None)
         self.access_token = self.request.headers.get('Authorization', None)
-        log.debug('accesstoken: ' + str(self.access_token))
+
+        # CORS header
+        if 'Origin' in self.request.headers and self.request.headers['Origin'].startswith('https://'):
+            self.response.headers['Access-Control-Allow-Origin'] = self.request.headers['Origin']
 
         if self.access_token and self.app.config['oauth2_id_endpoint']:
-            r = requests.request(method='GET', url = self.app.config['oauth2_id_endpoint'], headers={'Authorization': 'Bearer ' + self.access_token})
+            r = requests.get(self.app.config['oauth2_id_endpoint'], headers={'Authorization': 'Bearer ' + self.access_token})
             if r.status_code == 200:
-                oauth_user = json.loads(r.content)
-                self.uid = oauth_user['email']
-                log.debug('oauth user: ' + oauth_user['email'])
+                self.uid = json.loads(r.content)['email']
             else:
-                #TODO: add handlers for bad tokens.
-                log.debug('ERR: ' + str(r.status_code) + ' bad token')
+                # TODO: add handlers for bad tokens
+                # inform app of expired token, app will try to get new token, or ask user to log in again
+                log.debug('ERR: ' + str(r.status_code) + r.reason + ' bad token')
         elif self.app.config['insecure'] and 'X-Requested-With' not in self.request.headers and self.request.get('user', None):
             self.uid = self.request.get('user')
+        else:
+            self.uid = '@public'
+            self.user_is_superuser = False
+
+        if self.uid != '@public':
+            user = self.app.db.users.find_one({'_id': self.uid}, ['superuser'])
+            if user:
+                self.user_is_superuser = user.get('superuser', None)
+            else:
+                self.abort(403, 'user ' + self.uid + ' does not exist')
 
-        self.user = self.app.db.users.find_one({'uid': self.uid})
-        self.user_is_superuser = self.user.get('superuser', None) if self.user else False
+        if self.target_id not in [None, self.app.config['site_id']]:
+            self.rtype = 'to_remote'
 
-        # p2p request
-        self.target_id = self.request.get('iid', None)
-        self.p2p_user = self.request.headers.get('X-From', None)
-        self.site_id = self.app.config['site_id']
-        self.ssl_key = self.app.config['ssl_key']
-        log.debug('X-From: ' + str(self.p2p_user))
+            if not self.app.config['site_id']:
+                self.abort(500, 'api site_id is not configured')
+            if not self.app.config['ssl_key']:
+                self.abort(500, 'api ssl_key is not configured')
 
-        # CORS bare minimum
-        self.response.headers.add('Access-Control-Allow-Origin', self.request.headers.get('origin', '*'))
+            target = self.app.db.remotes.find_one({'_id': self.target_id}, {'_id': False, 'api_uri': True})
+            if not target:
+                self.abort(402, 'remote host ' + self.target_id + ' is not an authorized remote')
 
-        if not self.request.path.endswith('/nimsapi/log'):
-            log.info(self.request.method + ' ' + self.request.path + ' ' + str(self.request.params.mixed()))
+            # adjust headers
+            self.headers = self.request.headers
+            self.headers['User-Agent'] = 'NIMS Instance ' + self.app.config['site_id']
+            self.headers['X-From'] = (self.uid + '#' + self.app.config['site_id']) if self.uid != '@public' else self.uid
+            self.headers['Content-Length'] = len(self.request.body)
+            self.headers['Date'] = str(datetime.datetime.now().strftime('%a, %d %b %Y %H:%M:%S'))   # Nonce for msg
+            del self.headers['Host']
+            if self.headers.get('Authorization'): del self.headers['Authorization']
+
+            # adjust params
+            self.params = self.request.params.mixed()
+            if self.params.get('user'): del self.params['user']
+            del self.params['iid']
+
+            # assemble msg, hash, and signature
+            msg = self.request.method + self.request.path + str(self.params) + self.request.body + self.headers.get('Date')
+            signature = Crypto.Signature.PKCS1_v1_5.new(self.app.config['ssl_key']).sign(Crypto.Hash.SHA.new(msg))
+            self.headers['X-Signature'] = base64.b64encode(signature)
+
+            # prepare delegated request URI
+            self.target_uri = target['api_uri'] + self.request.path.split('/nimsapi')[1]
+
+        elif self.request.user_agent.startswith('NIMS Instance'):
+            self.rtype = 'from_remote'
+
+            self.uid = self.request.headers.get('X-From')
+            self.user_is_superuser = False
+
+            remote_instance = self.request.user_agent.replace('NIMS Instance', '').strip()
+            requester = self.app.db.remotes.find_one({'_id': remote_instance})
+            if not requester:
+                self.abort(402, remote_instance + ' is not authorized')
+
+            # assemble msg, hash, and verify received signature
+            signature = base64.b64decode(self.request.headers.get('X-Signature'))
+            msg = self.request.method + self.request.path + str(self.request.params.mixed()) + self.request.body + self.request.headers.get('Date')
+            verifier = Crypto.Signature.PKCS1_v1_5.new(Crypto.PublicKey.RSA.importKey(requester['pubkey']))
+            if not verifier.verify(Crypto.Hash.SHA.new(msg), signature):
+                self.abort(402, 'remote message/signature is not authentic')
+        else:
+            self.rtype = 'local'
 
     def dispatch(self):
         """dispatching and request forwarding"""
-        # dispatch to local instance
-        if self.target_id in [None, self.site_id]:
-            # request originates from remote instance
-            if self.request.user_agent.startswith('NIMS Instance'):
-                # is the requester an authorized remote site
-                requester = self.request.user_agent.replace('NIMS Instance', '').strip()
-                target = self.app.db.remotes.find_one({'_id':requester})
-                if not target:
-                    log.debug('remote host ' + requester + ' not in auth list. DENIED')
-                    self.abort(403, requester + ' is not authorized')
-                log.debug('request from ' + self.request.user_agent + ', interNIMS p2p initiated')
-                # verify signature
-                self.signature = base64.b64decode(self.request.headers.get('X-Signature'))
-
-                # assemble msg to be hased
-                msg = self.request.method + self.request.path + str(dict(self.request.params)) + self.request.body + self.request.headers.get('Date')
-                key = Crypto.PublicKey.RSA.importKey(target['pubkey'])
-                h = Crypto.Hash.SHA.new(msg)
-                verifier = Crypto.Signature.PKCS1_v1_5.new(key)
-                if verifier.verify(h, self.signature):
-                    super(NIMSRequestHandler, self).dispatch()
-                else:
-                    log.debug('message/signature is not authentic')
-                    self.abort(403, 'authentication failed')
-            # request originates from self
-            else:
-                    super(NIMSRequestHandler, self).dispatch()
-
-        # dispatch to remote instance
-        elif self.ssl_key is not None and self.site_id is not None:
-            log.debug('dispatching to remote ' + self.target_id)
-            # is target registered?
-            target = self.app.db.remotes.find_one({'_id': self.target_id}, {'_id':False, 'api_uri':True})
-            if not target:
-                log.debug('remote host ' + self.target_id + ' not in auth list. DENIED')
-                self.abort(403, self.target_id + 'is not authorized')
-
-            # adjust headers
-            headers = self.request.headers
-            headers['User-Agent'] = 'NIMS Instance ' + self.site_id
-            headers['X-From'] = self.uid
-            headers['Content-Length'] = len(self.request.body)
-            del headers['Host']                                                 # delete old host destination
-            try:
-                del headers['Authorization']                                    # delete access_token
-            except KeyError:
-                pass                                                            # not all requests will have access_token
-
-            # assemble msg to be hashed
-            nonce = str(datetime.datetime.now().strftime('%a, %d %b %Y %H:%M:%S'))
-            headers['Date'] = nonce
-            msg = self.request.method + self.request.path + str(dict(self.request.params)) + self.request.body + nonce
-            # create a signature
-            h = Crypto.Hash.SHA.new(msg)
-            signature = Crypto.Signature.PKCS1_v1_5.new(self.ssl_key).sign(h)
-            headers['X-Signature'] = base64.b64encode(signature)
-
-            # construct outgoing request
-            target_api = 'https://' + target['api_uri'] + self.request.path.split('/nimsapi')[1]
-            r = requests.request(method=self.request.method, data=self.request.body, url=target_api, params=self.request.params, headers=headers, verify=False)
-
-            # return response content
-            # TODO: think about: are the headers even useful?
+        log.debug(self.rtype + ' ' + self.uid + ' ' + self.request.method + ' ' + self.request.path + ' ' + str(self.request.params.mixed()))
+        if self.rtype in ['local', 'from_remote']:
+            return super(NIMSRequestHandler, self).dispatch()
+        else:
+            if self.request.method == 'OPTIONS':
+                return self.options()
+            r = requests.request(self.request.method, self.target_uri, params=self.params, data=self.request.body, headers=self.headers, verify=False)
+            if r.status_code != 200:
+                self.abort(r.status_code, 'internims p2p err: ' + r.reason)
             self.response.write(r.content)
 
-        elif self.ssl_key is None or self.site_id is None:
-            log.debug('ssl key or site id undefined, cannot dispatch to remote')
+    def abort(self, code, *args, **kwargs):
+        log.warning(str(code) + ' ' + '; '.join(args))
+        if 'Access-Control-Allow-Origin' in self.response.headers:
+            headers = kwargs.setdefault('headers', {})
+            headers['Access-Control-Allow-Origin'] = self.response.headers['Access-Control-Allow-Origin']
+        webapp2.abort(code, *args, **kwargs)
+
+    def options(self, *args, **kwargs):
+        self.response.headers['Access-Control-Allow-Methods'] = 'GET, HEAD, POST, PUT, DELETE, OPTIONS'
+        self.response.headers['Access-Control-Allow-Headers'] = 'Authorization'
+        self.response.headers['Access-Control-Max-Age'] = '151200'
 
-    def schema(self, *args, **kwargs):
-        self.response.write(json.dumps(self.json_schema, default=bson.json_util.default))
+    def schema(self):
+        if self.request.method == 'OPTIONS':
+            return self.options()
+        return self.json_schema
 
-    def get_collection(self, cid, min_role='anon-read'):
+    def get_collection(self, cid, min_role=None):
         collection = self.app.db.collections.find_one({'_id': cid})
         if not collection:
-            self.abort(404)
+            self.abort(404, 'no such Collection')
         if not self.user_is_superuser:
-            for perm in collection['permissions']:
-                if perm['uid'] == self.uid:
-                    break
-            else:
-                self.abort(403, self.uid + ' does not have permission to this Collection')
-            if INTEGER_ROLES[perm['role']] < INTEGER_ROLES[min_role]:
-                self.abort(403, self.uid + ' does not have at least ' + min_role + ' on this Collection')
-            if perm['role'] != 'admin': # if not admin, mask all other permissions
-                    collection['permissions'] = [{'uid': self.uid, 'role': perm['role']}]
+            coll = self.app.db.collections.find_one({'_id': cid, 'permissions.uid': self.uid}, ['permissions.$'])
+            if not coll:
+                self.abort(403, self.uid + ' does not have permissions on this Collection')
+            if min_role and INTEGER_ROLES[coll['permissions'][0]['role']] < INTEGER_ROLES[min_role]:
+                self.abort(403, self.uid + ' does not have at least ' + min_role + ' permissions on this Collection')
+            if coll['permissions'][0]['role'] != 'admin': # if not admin, mask permissions of other users
+                collection['permissions'] = coll['permissions']
         return collection
 
-    def get_experiment(self, xid, min_role='anon-read'):
+    def get_experiment(self, xid, min_role=None):
         experiment = self.app.db.experiments.find_one({'_id': xid})
         if not experiment:
-            self.abort(404)
+            self.abort(404, 'no such Experiment')
         if not self.user_is_superuser:
-            for perm in experiment['permissions']:
-                if perm['uid'] == self.uid:
-                    break
-            else:
-                self.abort(403, self.uid + ' does not have permission to this Experiment')
-            if INTEGER_ROLES[perm['role']] < INTEGER_ROLES[min_role]:
-                self.abort(403, self.uid + ' does not have at least ' + min_role + ' on this Experiment')
-            if perm['role'] != 'admin': # if not admin, mask all other permissions
-                    experiment['permissions'] = [{'uid': self.uid, 'role': perm['role']}]
+            exp = self.app.db.experiments.find_one({'_id': xid, 'permissions.uid': self.uid}, ['permissions.$'])
+            if not exp:
+                self.abort(403, self.uid + ' does not have permissions on this Experiment')
+            if min_role and INTEGER_ROLES[exp['permissions'][0]['role']] < INTEGER_ROLES[min_role]:
+                self.abort(403, self.uid + ' does not have at least ' + min_role + ' permissions on this Experiment')
+            if exp['permissions'][0]['role'] != 'admin': # if not admin, mask permissions of other users
+                experiment['permissions'] = exp['permissions']
         return experiment
 
-    def get_session(self, sid, min_role='anon-read'):
-        #FIXME: implement min_role logic
+    def get_session(self, sid, min_role=None):
         session = self.app.db.sessions.find_one({'_id': sid})
         if not session:
-            self.abort(404)
-        experiment = self.app.db.experiments.find_one({'_id': session['experiment']})
-        if not experiment:
-            self.abort(500)
+            self.abort(404, 'no such Session')
         if not self.user_is_superuser:
-            for perm in experiment['permissions']:
-                if perm['uid'] == self.uid:
-                    break
-            else:
-                self.abort(403, 'user does not have permission to this Session')
+            experiment = self.app.db.experiments.find_one({'_id': session['experiment'], 'permissions.uid': self.uid}, ['permissions.$'])
+            if not experiment:
+                if not self.app.db.experiments.find_one({'_id': session['experiment']}, []):
+                    self.abort(500)
+                else:
+                    self.abort(403, self.uid + ' does not have permissions to this Session')
+            if min_role and INTEGER_ROLES[experiment['permissions'][0]['role']] < INTEGER_ROLES[min_role]:
+                self.abort(403, self.uid + ' does not have at least ' + min_role + ' permissions on this Session')
         return session
 
-    def get_epoch(self, eid, min_role='anon-read'):
-        #FIXME: implement min_role logic
+    def get_epoch(self, eid, min_role=None):
         epoch = self.app.db.epochs.find_one({'_id': eid})
         if not epoch:
-            self.abort(404)
-        session = self.app.db.sessions.find_one({'_id': epoch['session']})
-        if not session:
-            self.abort(500)
-        experiment = self.app.db.experiments.find_one({'_id': session['experiment']})
-        if not experiment:
-            self.abort(500)
+            self.abort(404, 'no such Epoch')
         if not self.user_is_superuser:
-            for perm in experiment['permissions']:
-                if perm['uid'] == self.uid:
-                    break
-            else:
-                self.abort(403, 'user does not have permission to this Epoch')
+            session = self.app.db.sessions.find_one({'_id': epoch['session']}, ['experiment'])
+            if not session:
+                self.abort(500)
+            experiment = self.app.db.experiments.find_one({'_id': session['experiment'], 'permissions.uid': self.uid}, ['permissions.$'])
+            if not experiment:
+                if not self.app.db.experiments.find_one({'_id': session['experiment']}, []):
+                    self.abort(500)
+                else:
+                    self.abort(403, self.uid + ' does not have permissions on this Epoch')
+            if min_role and INTEGER_ROLES[experiment['permissions'][0]['role']] < INTEGER_ROLES[min_role]:
+                self.abort(403, self.uid + ' does not have at least ' + min_role + ' permissions on this Epoch')
         return epoch
diff --git a/users.py b/users.py
new file mode 100644
index 0000000000000000000000000000000000000000..932bb0ba6620079b92c2ab6f46f81d0dad65f443
--- /dev/null
+++ b/users.py
@@ -0,0 +1,240 @@
+# @author:  Gunnar Schaefer
+
+import logging
+log = logging.getLogger('nimsapi')
+
+import bson.json_util
+
+import nimsapiutil
+
+
+class Users(nimsapiutil.NIMSRequestHandler):
+
+    """/nimsapi/users """
+
+    json_schema = {
+        '$schema': 'http://json-schema.org/draft-04/schema#',
+        'title': 'User List',
+        'type': 'array',
+        'items': {
+            'title': 'User',
+            'type': 'object',
+            'properties': {
+                '_id': {
+                    'title': 'Database ID',
+                    'type': 'string',
+                },
+                'firstname': {
+                    'title': 'First Name',
+                    'type': 'string',
+                    'default': '',
+                },
+                'lastname': {
+                    'title': 'Last Name',
+                    'type': 'string',
+                    'default': '',
+                },
+                'email': {
+                    'title': 'Email',
+                    'type': 'string',
+                    'format': 'email',
+                    'default': '',
+                },
+                'email_hash': {
+                    'type': 'string',
+                    'default': '',
+                },
+            }
+        }
+    }
+
+    def count(self):
+        """Return the number of Users."""
+        if self.request.method == 'OPTIONS':
+            return self.options()
+        self.response.write(self.app.db.users.count())
+
+    def post(self):
+        """Create a new User"""
+        self.response.write('users post\n')
+
+    def get(self):
+        """Return the list of Users."""
+        return list(self.app.db.users.find({}, ['firstname', 'lastname', 'email_hash']))
+
+    def put(self):
+        """Update many Users."""
+        self.response.write('users put\n')
+
+
+class User(nimsapiutil.NIMSRequestHandler):
+
+    """/nimsapi/users/<uid> """
+
+    json_schema = {
+        '$schema': 'http://json-schema.org/draft-04/schema#',
+        'title': 'User',
+        'type': 'object',
+        'properties': {
+            '_id': {
+                'title': 'Database ID',
+                'type': 'string',
+            },
+            'firstname': {
+                'title': 'First Name',
+                'type': 'string',
+                'default': '',
+            },
+            'lastname': {
+                'title': 'Last Name',
+                'type': 'string',
+                'default': '',
+            },
+            'email': {
+                'title': 'Email',
+                'type': 'string',
+                'format': 'email',
+                'default': '',
+            },
+            'email_hash': {
+                'type': 'string',
+                'default': '',
+            },
+            'superuser': {
+                'title': 'Superuser',
+                'type': 'boolean',
+            },
+        },
+        'required': ['_id'],
+    }
+
+    def get(self, uid):
+        """Return User details."""
+        projection = []
+        if self.request.get('remotes') in ('1', 'true'):
+            projection += ['remotes']
+        if self.request.get('status') in ('1', 'true'):
+            projection += ['status']
+        user = self.app.db.users.find_one({'_id': uid}, projection or None)
+        if not user:
+            self.abort(404, 'no such User')
+        return user
+
+    def put(self, uid):
+        """Update an existing User."""
+        user = self.app.db.users.find_one({'_id': uid})
+        if not user:
+            self.abort(404)
+        if uid == self.uid or self.user_is_superuser: # users can only update their own info
+            updates = {'$set': {}, '$unset': {}}
+            for k, v in self.request.params.iteritems():
+                if k != 'superuser' and k in []:#user_fields:
+                    updates['$set'][k] = v # FIXME: do appropriate type conversion
+                elif k == 'superuser' and uid == self.uid and self.user_is_superuser is not None: # toggle superuser for requesting user
+                    updates['$set'][k] = v.lower() in ('1', 'true')
+                elif k == 'superuser' and uid != self.uid and self.user_is_superuser:             # enable/disable superuser for other user
+                    if v.lower() in ('1', 'true') and user.get('superuser') is None:
+                        updates['$set'][k] = False # superuser is tri-state: False indicates granted, but disabled, superuser privileges
+                    elif v.lower() not in ('1', 'true'):
+                        updates['$unset'][k] = ''
+            self.app.db.users.update({'_id': uid}, updates)
+        else:
+            self.abort(403)
+
+    def delete(self, uid):
+        """Delete an User."""
+        self.response.write('user %s delete, %s\n' % (uid, self.request.params))
+
+
+class Groups(nimsapiutil.NIMSRequestHandler):
+
+    """/nimsapi/groups """
+
+    json_schema = {
+        '$schema': 'http://json-schema.org/draft-04/schema#',
+        'title': 'Group List',
+        'type': 'array',
+        'items': {
+            'title': 'Group',
+            'type': 'object',
+            'properties': {
+                '_id': {
+                    'title': 'Database ID',
+                    'type': 'string',
+                },
+            }
+        }
+    }
+
+    def count(self):
+        """Return the number of Groups."""
+        if self.request.method == 'OPTIONS':
+            return self.options()
+        self.response.write(self.app.db.groups.count())
+
+    def post(self):
+        """Create a new Group"""
+        self.response.write('groups post\n')
+
+    def get(self):
+        """Return the list of Groups."""
+        return list(self.app.db.groups.find({}, []))
+
+    def put(self):
+        """Update many Groups."""
+        self.response.write('groups put\n')
+
+
+class Group(nimsapiutil.NIMSRequestHandler):
+
+    """/nimsapi/groups/<gid>"""
+
+    json_schema = {
+        '$schema': 'http://json-schema.org/draft-04/schema#',
+        'title': 'Group',
+        'type': 'object',
+        'properties': {
+            '_id': {
+                'title': 'Database ID',
+                'type': 'string',
+            },
+            'name': {
+                'title': 'Name',
+                'type': 'string',
+                'maxLength': 32,
+            },
+            'roles': {
+                'title': 'Roles',
+                'type': 'array',
+                'default': [],
+                'items': {
+                    'type': 'object',
+                    'properties': {
+                        'uid': {
+                            'type': 'string',
+                        },
+                        'role': {
+                            'type': 'string',
+                            'enum': [k for k, v in sorted(nimsapiutil.INTEGER_ROLES.iteritems(), key=lambda (k, v): v)],
+                        },
+                    },
+                },
+                'uniqueItems': True,
+            },
+        },
+        'required': ['_id'],
+    }
+
+    def get(self, gid):
+        """Return Group details."""
+        group = self.app.db.groups.find_one({'_id': gid})
+        if not group:
+            self.abort(404, 'no such Group')
+        return group
+
+    def put(self, gid):
+        """Update an existing Group."""
+        self.response.write('group %s put, %s\n' % (gid, self.request.params))
+
+    def delete(self, gid):
+        """Delete an Group."""