/*!
* Ledger Agent module API.
*
* Copyright (c) 2017-2018 Digital Bazaar, Inc. All rights reserved.
*/
'use strict';
const _ = require('lodash');
const assert = require('assert-plus');
const async = require('async');
const bedrock = require('bedrock');
const brPermission = require('bedrock-permission');
const brLedgerNode = require('bedrock-ledger-node');
const {config} = bedrock;
const database = require('bedrock-mongodb');
const uuid = require('uuid/v4');
const {BedrockError} = bedrock.util;
const LedgerAgent = require('./LedgerAgent');
require('bedrock-permission');
// module permissions
const PERMISSIONS = bedrock.config.permission.permissions;
// module API
const api = {};
module.exports = api;
bedrock.events.on('bedrock-mongodb.ready', callback => async.auto({
openCollections: callback =>
database.openCollections(['ledgerAgent'], callback),
createIndexes: ['openCollections', (results, callback) =>
database.createIndexes([{
collection: 'ledgerAgent',
fields: {id: 1},
options: {unique: true, background: false}
}, {
collection: 'ledgerAgent',
fields: {ledgerNode: 1},
options: {unique: false, background: false}
}, {
collection: 'ledgerAgent',
fields: {'meta.owner': 1},
options: {unique: false, background: false}
}], callback)
]
}, err => callback(err)));
/**
* Create a new ledger agent given a set of options. If a ledgerNodeId is
* provided, a new ledger agent will be created to connect to an existing
* ledger. If a config block is specified in the options and genesis
* is set to true, a new ledger and corresponding ledger node will be created,
* ignoring any specified ledgerNodeId.
*
* actor - the actor performing the action.
* ledgerNodeId - the ID for the ledger node to connect to.
* options - a set of options used when creating the agent.
* * ledgerConfiguration - the configuration for the ledger.
* * genesis - if true, create an entirely new genesis ledger
* (default: false).
* * owner (required) - the owner of the ledger node and agent.
* * storage - the storage subsystem for the ledger (default: 'mongodb').
* * public - if true, the agent should be accessible by anyone,
* false if only the owner should have access (default: false).
* callback(err, ledger) - the callback to call when finished.
* * err - An Error if an error occurred, null otherwise
* * ledgerAgent - the ledger agent associated with the agent.
*/
api.add = (actor, ledgerNodeId, options, callback) => {
const createOptions = _.defaultsDeep(options, {
storage: 'mongodb',
public: false
});
// owner must be specified
if(!options.owner) {
return callback(new BedrockError(
'Ledger agent owner not specified.',
'BadRequest',
{httpStatusCode: 404, public: true}
));
}
// add a new ledger node if one was specified
if(createOptions.ledgerConfiguration) {
return _addNewLedgerNode(actor, createOptions, callback);
}
_addNewLedgerAgent(actor, ledgerNodeId, createOptions, callback);
};
/**
* Gets a ledger agent given an agentId and a set of options.
*
* actor - the actor performing the action.
* agentId - the URI of the agent.
* options - a set of options used when creating the agent.
* * public (optional) - true if the ledger agent should be public,
* false otherwise.
* callback(err, ledgerAgent) - the callback to call when finished.
* * err - An Error if an error occurred, null otherwise
* * ledgerAgent - A ledger agent that can be used to instruct the ledger
* node to perform certain actions.
*/
api.get = (actor, agentId, options, callback) => {
if(typeof options === 'function') {
callback = options;
options = {};
}
const query = {
id: database.hash(agentId),
'meta.deleted': {
$exists: false
}
};
if(options.public !== undefined) {
options['meta.public'] = options.public;
}
async.auto({
find: callback => database.collections.ledgerAgent.findOne(
query, {}, callback),
checkPermission: ['find', (results, callback) => {
const record = results.find;
if(!record) {
return callback(new BedrockError(
'Ledger agent not found.',
'NotFoundError',
{httpStatusCode: 404, ledgerAgentId: agentId, public: true}
));
}
if(record.meta.public !== true) {
// check permissions if the ledger agent isn't public
return brPermission.checkPermission(
actor, PERMISSIONS.LEDGER_AGENT_ACCESS,
{resource: record.meta, translate: 'owner'}, callback);
}
callback();
}],
getLedgerNode: ['checkPermission', (results, callback) => brLedgerNode.get(
(results.find.meta.public !== true) ? actor : null,
results.find.ledgerAgent.ledgerNode, options, callback)
],
createLedgerAgent: ['getLedgerNode', (results, callback) => {
const record = results.find;
const laOptions = {
description: record.ledgerAgent.description,
id: record.ledgerAgent.id,
name: record.ledgerAgent.name,
node: results.getLedgerNode,
owner: record.meta.owner,
public: record.meta.public || false,
plugins: record.ledgerAgent.plugins,
};
callback(null, new LedgerAgent(laOptions));
}]
}, (err, results) => {
callback(err, results.createLedgerAgent);
});
};
/**
* Remove an existing ledger agent given an agentId and a set of options.
*
* actor - the actor performing the action.
* agentId - the URI of the agent.
* options - a set of options used when removing the agent.
* callback(err) - the callback to call when finished.
* * err - An Error if an error occurred, null otherwise
*/
api.remove = (actor, agentId, options, callback) => {
// owner must be specified
if(!options.owner) {
return callback(new BedrockError(
'Ledger agent owner not specified.',
'DataError',
{httpStatusCode: 400, public: true}
));
}
async.auto({
find: callback => database.collections.ledgerAgent.findOne({
id: database.hash(agentId)
}, callback),
checkPermission: ['find', (results, callback) => {
if(!results.find) {
return callback(new BedrockError(
'Ledger agent not found.',
'NotFoundError',
{httpStatusCode: 404, ledger: agentId, public: true}
));
}
const record = results.find;
brPermission.checkPermission(
actor, PERMISSIONS.LEDGER_AGENT_REMOVE, {
resource: record.meta,
translate: 'owner'
}, callback);
}],
update: ['checkPermission', (results, callback) =>
database.collections.ledgerAgent.update({
id: database.hash(agentId)
}, {
$set: {
'meta.deleted': Date.now()
}
}, database.writeOptions, callback)
]
}, err => callback(err));
};
/**
* Gets an iterator that will iterate over all ledger agents in the system.
* The iterator will return a ledger agent which can be used to operate on
* the corresponding ledger node.
*
* actor - the actor performing the action.
* options - a set of options to use when retrieving the list.
* * owner (optional) - filter results by this owner.
* * public (optional) - false to filter out public ledger agents.
* callback(err, iterator) - the callback to call when finished.
* * err - An Error if an error occurred, null otherwise
* * iterator - An iterator that returns a list of ledger agents.
*/
api.getAgentIterator = function(actor, options, callback) {
async.auto({
find: callback => {
// find all non-deleted ledger agents
const query = {
'meta.deleted': {
$exists: false
},
};
if(options.owner) {
query['meta.owner'] = options.owner;
}
if(options.public) {
query['meta.public'] = options.public;
}
const projection = {
'ledgerAgent.id': 1
};
database.collections.ledgerAgent.find(query, projection, callback);
},
hasNext: ['find', (results, callback) => {
// check to see if there are any ledger agents
results.find.hasNext().then(hasNext => callback(null, hasNext), callback);
}]
}, (err, results) => {
if(err) {
return callback(err);
}
// create a ledger agent iterator
const iterator = {
done: !results.hasNext
};
iterator.next = () => {
if(iterator.done) {
return {done: true};
}
const cursor = results.find;
const promise = cursor.next().then(record => {
// ensure iterator will have something to iterate over next
return cursor.hasNext().then(hasNext => {
iterator.done = !hasNext;
return new Promise((resolve, reject) => {
const getOptions = {};
if(options.owner) {
getOptions.owner = options.owner;
}
if(options.public) {
getOptions.public = options.public;
}
api.get(
actor, record.ledgerAgent.id, getOptions, (err, ledgerAgent) =>
err ? reject(err) : resolve(ledgerAgent));
});
});
}).catch(err => {
iterator.done = true;
throw err;
});
return {value: promise, done: iterator.done};
};
iterator[Symbol.iterator] = () => {
return iterator;
};
callback(null, iterator);
});
};
/**
* Adds a new ledger node and then adds a ledger agent for that ledger node.
*/
function _addNewLedgerNode(actor, options, callback) {
async.auto({
// check both create and access permission
checkPermission: callback => {
async.auto({
checkCreate: callback => brPermission.checkPermission(
actor, PERMISSIONS.LEDGER_AGENT_CREATE, {
resource: options.owner
}, callback),
checkAccess: callback => brPermission.checkPermission(
actor, PERMISSIONS.LEDGER_AGENT_ACCESS, {
resource: options.owner
}, callback)
}, err => {
callback(err);
});
},
createLedgerNode: ['checkPermission', (results, callback) => {
brLedgerNode.add(actor, options, callback);
}]
}, (err, results) => {
if(err) {
return callback(err);
}
const ledgerNodeId = results.createLedgerNode.id;
_addNewLedgerAgent(actor, ledgerNodeId, options, callback);
});
}
/**
* Adds a new ledger agent given a ledger node identifier.
*/
function _addNewLedgerAgent(actor, ledgerNodeId, options, callback) {
const laUuid = uuid();
const ledgerAgent = {
id: 'urn:uuid:' + laUuid,
ledgerNode: ledgerNodeId,
public: options.public || false,
};
if(options.name) {
ledgerAgent.name = options.name;
}
if(options.description) {
ledgerAgent.description = options.description;
}
assert.optionalArrayOfString(options.plugins, 'options.plugins');
ledgerAgent.plugins = options.plugins || [];
const record = {
id: database.hash(ledgerAgent.id),
ledgerNode: database.hash(ledgerAgent.ledgerNode),
ledgerAgent,
meta: {
owner: options.owner,
public: options.public
}
};
async.auto({
// check both create and access permission
checkPermission: callback => {
async.auto({
checkCreate: callback => brPermission.checkPermission(
actor, PERMISSIONS.LEDGER_AGENT_CREATE, {
resource: record.meta.owner
}, callback),
checkAccess: callback => brPermission.checkPermission(
actor, PERMISSIONS.LEDGER_AGENT_ACCESS, {
resource: record.meta.owner
}, callback)
}, err => {
callback(err);
});
},
// validate all the specified plugins
plugins: callback => {
let pluginName;
try {
for(pluginName of ledgerAgent.plugins) {
const p = brLedgerNode.use(pluginName);
if(p.type !== 'ledgerAgentPlugin') {
throw new Error('The plugin `type` must be `ledgerAgentPlugin`.');
}
if(!p.api.serviceType) {
throw new Error('`serviceType` must be defined.');
}
if(!p.api.router) {
throw new Error('`router` must be defined.');
}
if(!p.api.router.stack.some(s => s.route.path === '/')) {
throw new Error('A root route `/` must be defined.');
}
}
} catch(e) {
return callback(new BedrockError(
'Invalid ledger agent plugin.', 'SyntaxError',
{httpStatusCode: 400, public: true, pluginName}, e
));
}
callback();
},
getLedgerNode: ['checkPermission', 'plugins', (results, callback) =>
brLedgerNode.get(actor, ledgerNodeId, options, callback)],
insert: ['getLedgerNode', (results, callback) => {
database.collections.ledgerAgent.insert(
record, database.writeOptions, (err, result) => {
if(err && database.isDuplicateError(err)) {
return callback(new BedrockError(
'Duplicate ledger agent.', 'DuplicateError', {
public: true,
httpStatusCode: 409
}));
}
callback(err, result);
});
}],
createLedgerAgent: ['insert', (results, callback) => {
const laOptions = bedrock.util.clone(ledgerAgent);
laOptions.node = results.getLedgerNode;
const la = new LedgerAgent(laOptions);
callback(null, la);
}]
}, (err, results) =>
err ? callback(err) : callback(null, results.createLedgerAgent)
);
}