Forked from ipfs-swap-service

This commit is contained in:
Chris Troutner
2021-11-24 15:05:10 -08:00
commit d34d2849cb
167 changed files with 59369 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
# http://editorconfig.org
# A special property that should be specified at the top of the file outside of
# any sections. Set to true to stop .editor config file search on current file
root = true
[*]
# Indentation style
# Possible values - tab, space
indent_style = space
# Indentation size in single-spaced characters
# Possible values - an integer, tab
indent_size = 2
# Line ending file format
# Possible values - lf, crlf, cr
end_of_line = lf
# File character encoding
# Possible values - latin1, utf-8, utf-16be, utf-16le
charset = utf-8
# Denotes whether to trim whitespace at the end of lines
# Possible values - true, false
trim_trailing_whitespace = true
# Denotes whether file should end with a newline
# Possible values - true, false
insert_final_newline = true
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "standard",
"env": {
"node": true,
"mocha": true
},
"parserOptions": {
"ecmaVersion": 8
}
}
+72
View File
@@ -0,0 +1,72 @@
# Created by https://www.gitignore.io/api/node,sublimetext
### Node ###
# Logs
#logs
logs/*.json
*.log
npm-debug.log*
# Runtime data
pids
*.pid
*.seed
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directory
node_modules
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
### SublimeText ###
# cache files for sublime text
*.tmlanguage.cache
*.tmPreferences.cache
*.stTheme.cache
# workspace files are user-specific
*.sublime-workspace
# project files should be checked into the repository, unless a significant
# proportion of contributors will probably not be using SublimeText
*.sublime-project
# sftp configuration file
sftp-config.json
#Documentation
docs
.nyc_output
coverage
database/
system-user-*.json
orbitdb
ipfsdata
.ipfsdata
ipfs-service-provider.sh
run-dev.sh
wallet.json
!README.md
+8
View File
@@ -0,0 +1,8 @@
The MIT License (MIT)
Copyright (c) 2021 Permissionless Software Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+33
View File
@@ -0,0 +1,33 @@
# avax-dex
[![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg)](http://standardjs.com) [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release)
This is a prototype web service that monitors the [P2WDB](https://github.com/Permissionless-Software-Foundation/ipfs-p2wdb-service) for trading signals, to trade AVAX and Avalanche Native Tokens (ANTs) on the AVAX X-Chain. It's inspired by the [SWaP Prototype](https://github.com/vinarmani/swap-protocol/blob/master/swap-protocol-spec.md).
## Development Notes
**Warning**: This repository is under active development. Things will be constantly changing and breaking.
### Road Map:
- Phase 1: MVP (single user)
- Move tokens or AVAX for sale to temporary address.
- Accept input from user:
- Submit Signal message to P2WDB for new buy/sell order.
- Check UTXO status before submitting Payment message.
- React to webhook when new Offers come in.
- Add and track new Offers in MongoDB.
- Process Payment signals:
- Checking transaction for compatibility
- Broadcast/close trades managed by this app.
- Phase 2: Build single-user web app
- _Make_ buy or sell orders.
- Browse Offers on the market.
- _Take_ buy or sell orders.
- Phase 3: Multi-user
- Configure REST API as an IPFS Service that can offer services to multiple users.
- Update web app to display and select available SWaP services over IPFS.
## License
[MIT](./LICENSE.md)
+4
View File
@@ -0,0 +1,4 @@
{
"sampleUrl": null
}
+133
View File
@@ -0,0 +1,133 @@
/*
This Koa server has two interfaces:
- REST API over HTTP
- JSON RPC over IPFS
The architecture of the code follows the Clean Architecture pattern:
https://troutsblog.com/blog/clean-architecture
*/
// npm libraries
const Koa = require('koa')
const bodyParser = require('koa-bodyparser')
const convert = require('koa-convert')
const logger = require('koa-logger')
const mongoose = require('mongoose')
const session = require('koa-generic-session')
const passport = require('koa-passport')
const mount = require('koa-mount')
const serve = require('koa-static')
const cors = require('kcors')
// Local libraries
const config = require('../config') // this first.
const AdminLib = require('../src/adapters/admin')
// const adminLib = new AdminLib()
const WebHookLib = require('../src/adapters/webhook')
const webhookLib = new WebHookLib()
// const JSONRPC = require('../src/rpc')
// const rpc = new JSONRPC()
const errorMiddleware = require('../src/controllers/rest-api/middleware/error')
// const { wlogger } = require('../src/adapters/wlogger')
class Server {
constructor () {
this.adminLib = new AdminLib()
}
async startServer () {
try {
// Create a Koa instance.
const app = new Koa()
app.keys = [config.session]
// Connect to the Mongo Database.
mongoose.Promise = global.Promise
mongoose.set('useCreateIndex', true) // Stop deprecation warning.
console.log(
`Connecting to MongoDB with this connection string: ${config.database}`
)
await mongoose.connect(config.database, {
useUnifiedTopology: true,
useNewUrlParser: true
})
console.log(`Starting environment: ${config.env}`)
console.log(`Debug level: ${config.debugLevel}`)
// MIDDLEWARE START
app.use(convert(logger()))
app.use(bodyParser())
app.use(session())
app.use(errorMiddleware())
// Used to generate the docs.
app.use(mount('/', serve(`${process.cwd()}/docs`)))
// Mount the page for displaying logs.
app.use(mount('/logs', serve(`${process.cwd()}/config/logs`)))
// User Authentication
require('../config/passport')
app.use(passport.initialize())
app.use(passport.session())
// Attach REST API and JSON RPC controllers to the app.
const Controllers = require('../src/controllers')
const controllers = new Controllers()
await controllers.attachRESTControllers(app)
app.controllers = controllers
// Enable CORS for testing
// THIS IS A SECURITY RISK. COMMENT OUT FOR PRODUCTION
app.use(cors({ origin: '*' }))
// MIDDLEWARE END
// Create webhook
try {
try {
// Delete an old webhook if it exists.
await webhookLib.deleteWebhook('http://localhost:5700/order')
} catch (err) {
/* exit quietly */
// console.log('err deleting webhook: ', err)
}
await webhookLib.createWebhook('http://localhost:5700/order')
console.log('Webhook created')
} catch (error) {
console.log('Webhook cant be created')
}
// startServer()
await app.listen(config.port)
console.log(`Server started on ${config.port}`)
// Create the system admin user.
const success = await this.adminLib.createSystemUser()
if (success) console.log('System admin user created.')
// Attach the other IPFS controllers
await controllers.attachControllers(app)
// ipfs-coord has a memory leak. This app shuts down after 4 hours. It
// expects to be run by Docker or pm2, which can automatically restart
// the app.
setTimeout(function () {
process.exit(0)
}, 60000 * 60 * 2) // 2 hours
return app
} catch (err) {
console.error('Could not start server. Error: ', err)
}
}
}
module.exports = Server
+87
View File
@@ -0,0 +1,87 @@
/*
This file is used to store unsecure, application-specific data common to all
environments.
*/
/* eslint no-unneeded-ternary:0 */
// Get the version from the package.json file.
const pkgInfo = require('../../package.json')
const version = pkgInfo.version
const ipfsCoordName = process.env.COORD_NAME
? process.env.COORD_NAME
: 'ipfs-torlist-service-generic'
module.exports = {
// Configure TCP port.
port: process.env.PORT || 5002,
// Password for HTML UI that displays logs.
logPass: 'test',
// Email server settings if nodemailer email notifications are used.
emailServer: process.env.EMAILSERVER
? process.env.EMAILSERVER
: 'mail.someserver.com',
emailUser: process.env.EMAILUSER
? process.env.EMAILUSER
: 'noreply@someserver.com',
emailPassword: process.env.EMAILPASS
? process.env.EMAILPASS
: 'emailpassword',
// FullStack.cash account information, used for automatic JWT handling.
getJwtAtStartup: process.env.GET_JWT_AT_STARTUP ? true : false,
authServer: process.env.AUTHSERVER
? process.env.AUTHSERVER
: 'https://auth.fullstack.cash',
apiServer: process.env.APISERVER
? process.env.APISERVER
: 'https://api.fullstack.cash/v5/',
fullstackLogin: process.env.FULLSTACKLOGIN
? process.env.FULLSTACKLOGIN
: 'demo@demo.com',
fullstackPassword: process.env.FULLSTACKPASS
? process.env.FULLSTACKPASS
: 'demo',
// IPFS settings.
isCircuitRelay: process.env.ENABLE_CIRCUIT_RELAY ? true : false,
// SSL domain used for websocket connection via browsers.
crDomain: process.env.CR_DOMAIN ? process.env.CR_DOMAIN : '',
// Information passed to other IPFS peers about this node.
apiInfo: 'https://ipfs-service-provider.fullstack.cash/',
// JSON-LD and Schema.org schema with info about this app.
announceJsonLd: {
'@context': 'https://schema.org/',
'@type': 'WebAPI',
name: ipfsCoordName,
version,
protocol: 'generic-service',
description:
'This is a generic IPFS Serivice Provider that uses JSON RPC over IPFS to communicate with it. This instance has not been customized. Source code: https://github.com/Permissionless-Software-Foundation/ipfs-service-provider',
documentation: 'https://ipfs-service-provider.fullstack.cash/',
provider: {
'@type': 'Organization',
name: 'Permissionless Software Foundation',
url: 'https://PSFoundation.cash'
}
},
// P2WDB webhook endpoint
webhookService: process.env.WEBHOOKSERVICE
? process.env.WEBHOOKSERVICE
: 'http://localhost:5001/webhook', // P2WDB.
// IPFS Ports
ipfsTcpPort: process.env.IPFS_TCP_PORT ? process.env.IPFS_TCP_PORT : 4001,
ipfsWsPort: process.env.IPFS_WS_PORT ? process.env.IPFS_WS_PORT : 4003,
// BCH Mnemonic for generating encryption keys and payment address
mnemonic: process.env.MNEMONIC ? process.env.MNEMONIC : '',
debugLevel: process.env.DEBUG_LEVEL ? parseInt(process.env.DEBUG_LEVEL) : 1
}
+12
View File
@@ -0,0 +1,12 @@
/*
These are the environment settings for the DEVELOPMENT environment.
This is the environment run by default with `npm start` if KOA_ENV is not
specified.
*/
module.exports = {
session: 'secret-boilerplate-token',
token: 'secret-jwt-token',
database: 'mongodb://localhost:27017/swap-service-dev',
env: 'dev'
}
+18
View File
@@ -0,0 +1,18 @@
/*
These are the environment settings for the PRODUCTION environment.
This is the environment run with `npm start` if KOA_ENV=production.
This is the environment run inside the Docker container.
It is assumed the MonogDB Docker container is accessed by port 5555
so as not to conflict with the default host port of 27017 for MongoDB.
*/
module.exports = {
session: 'secret-boilerplate-token',
token: 'secret-jwt-token',
// database: 'mongodb://172.17.0.1:5555/ipfs-service-prod',
database: process.env.DBURL
? process.env.DBURL
: 'mongodb://172.17.0.1:5555/swap-service-prod',
env: 'prod'
}
+12
View File
@@ -0,0 +1,12 @@
/*
These are the environment settings for the TEST environment.
This is the environment run with `npm start` if KOA_ENV=test.
This is the environment run by the test suite.
*/
module.exports = {
session: 'secret-boilerplate-token',
token: 'secret-jwt-token',
database: 'mongodb://localhost:27017/swap-service-test',
env: 'test'
}
+6
View File
@@ -0,0 +1,6 @@
const common = require("./env/common");
const env = process.env.AVAX_DEX_ENV || "development";
const config = require(`./env/${env}`);
module.exports = Object.assign({}, common, config);
+149
View File
@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>Logs</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<link href="/vendor/bootstrap.min.css" rel="stylesheet" media="screen" />
<link href="/vendor/prettify.css" rel="stylesheet" media="screen" />
<link href="/css/style.css" rel="stylesheet" media="screen, print" />
<!-- <link href="img/favicon.ico" rel="icon" type="image/x-icon"> -->
<script src="/vendor/polyfill.js"></script>
</head>
<body>
<div class="container">
<div class="row">
<br />
<div class="col-sm-4"></div>
<div class="col-sm-4">
<p id="outMsg"><p>
</div>
<div class="col-sm-4"></div>
</div>
<!-- Password for accessing logs -->
<div class="row loginForm">
<form class="form-horizontal">
<div class="form-group">
<label for="inputLogPass" class="col-sm-2 control-label"
>Password</label
>
<div class="col-sm-10">
<input
type="password"
class="form-control"
id="inputLogPass"
placeholder=""
/>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button
type="button"
class="btn btn-default"
onclick="viewLogs()"
>
View Logs
</button>
</div>
</div>
</form>
</div>
<div class="row logTable" style="visibility: hidden;">
<div class="table-responsive">
<table class="table">
<tr>
<th>Time</th>
<th>Level</th>
<th>Message</th>
</tr>
<tr class="tableTemplate">
<td></td>
<td></td>
<td></td>
</tr>
</table>
</div>
</div>
</div>
<script src="/vendor/jquery.min.js"></script>
<script src="/vendor/bootstrap.min.js"></script>
<script>
async function viewLogs() {
try {
const pass = $('#inputLogPass').val()
// if (pass === 'test') {
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
password: pass
})
}
const data = await fetch(`/logapi`, options)
// console.log(`data.status: `, data.status)
if (data.status > 399) {
$('#outMsg').text('Could not communicate with log server.')
throw new Error(`Could not get log data`)
}
const data2 = await data.json()
// console.log(`data2: ${JSON.stringify(data2, null, 2)}`)
if (!data2.success) {
$('#outMsg').text('Incorrect password')
throw new Error(`Incorrect password`)
} else {
$('#outMsg').text('')
}
$('.loginForm').css('visibility', 'hidden')
$('.logTable').css('visibility', 'visible')
const logData = data2.data
// console.log(`logData: ${JSON.stringify(logData, null, 2)}`)
// Clone the template row.
const template = $('.tableTemplate')
// debugger
// Loop through the array of log data.
for (let i = 0; i < logData.length; i++) {
const thisRow = template.clone()
const cols = thisRow.find('td')
const time = new Date(logData[i].timestamp)
// debugger
cols.first().text(time.toLocaleString())
cols.next().text(logData[i].level)
cols.next().next().text(logData[i].message)
// debugger
$('.table').append(thisRow)
}
// } else {
// console.log(`password fail`)
// }
} catch (err) {
console.error(`Error in viewLogs: `, err)
}
}
</script>
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
const passport = require('koa-passport')
const User = require('../src/adapters/localdb/models/users')
const Strategy = require('passport-local')
passport.serializeUser((user, done) => {
done(null, user.id)
})
passport.deserializeUser(async (id, done) => {
try {
const user = await User.findById(id, '-password')
done(null, user)
} catch (err) {
done(err)
}
})
passport.use(
'local',
new Strategy(
{
usernameField: 'email',
passwordField: 'password'
},
passportCallback
)
)
async function passportCallback (email, password, done) {
try {
const user = await User.findOne({ email })
if (!user) {
return done(null, false)
}
try {
const isMatch = await user.validatePassword(password)
if (!isMatch) {
return done(null, false)
}
done(null, user)
} catch (err) {
done(err)
}
} catch (err) {
return done(err)
}
}
// For testing
module.exports = { passport, passportCallback }
+64
View File
@@ -0,0 +1,64 @@
# Developer Documentation
This is living documentation that will be updated, edited, and changed over time, using the same version control as the rest of the code. The purpose of this documentation is to capture and explain how this ipfs-swap-service interacts with the [P2WDB](https://github.com/Permissionless-Software-Foundation/ipfs-p2wdb-service), to create a permissionless, censorship-resistant database for storing trading orders. A web client will be built in the future that will interact with the REST API of this app.
- [Specification](./specification.md)
# Overview
There are three major pieces of software behind the ipfs-swap-service concept. They work together to form a censorship-resistant application for exchanging transaction data for building trades.
![ipfs-swap-service major subcomponents](./diagrams/software-interaction.png)
- _Client_ could be a web browser, or a command-line client like [psf-bch-wallet](https://github.com/Permissionless-Software-Foundation/psf-bch-wallet) or [psf-avax-wallet](https://github.com/Permissionless-Software-Foundation/psf-avax-wallet).
- [ipfs-swap-service](https://github.com/christroutner/ipfs-swap-service) is the back end REST API that maintains a local database of information that the client reads from.
- [P2WDB](https://github.com/Permissionless-Software-Foundation/ipfs-p2wdb-service) is the pay-to-write global database with a REST API for interfacing with the other two pieces of software.
The arrows in the image represent the information flow between the three pieces of software:
- The _Client_ displays information about orders. It _reads_ this information from ipfs-swap-service.
- The _Client_ is also a wallet. It can generate the needed transactions to _write_ information to the P2WDB.
- `ipfs-swap-service` imports data from the global P2WDB database into its local database, using a [webhook](https://en.wikipedia.org/wiki/Webhook) (dashed line). It can also custody funds by creating an _Offer_ and submitting the data to the P2WDB to generate an _Order_ (solid line).
This architecture keeps the global database highly censorship resistant, while allowing local installations to maintain tight control over the user experience. The goal is to have many redundant copies of `ipfs-swap-service` on the network, and to empower individual traders to run their own, private copy.
# Back End
This section provides additional information on `ipfs-swap-service` and P2WDB back end software.
## P2WDB
The heart of the censorship resistance is the pay-to-write database ([P2WDB](https://github.com/Permissionless-Software-Foundation/ipfs-p2wdb-service)). This is an [OrbitDB](https://orbitdb.org/) peer-to-peer (p2p) database. The write-access rules have been customized to allow anyone to write to the database, so long as they prove that a sufficient quantity of [PSF tokens](https://psfoundation.cash) have been burned, to pay for the write.
Because OrbitDB is a p2p database, no one party holds the 'official' copy of the database. Instead, like a blockchain, the database is replicated among several peers, and they coordinate updates to the database using consensus rules. Peers are free to leave or enter the network. Each peer independently verifies the database entries have sufficient proof-of-burn.
## `ipfs-swap-service`
The [ipfs-swap-service](https://github.com/christroutner/ipfs-swap-service) replicates a copy of the global P2WDB, but has the ability to apply localized filters to the data before passing it on to the _Client_, to be displayed.
`ipfs-swap-service` is based on this [ipfs-service-provider boilerplate](https://github.com/Permissionless-Software-Foundation/ipfs-service-provider). It's a production-ready template for a web server, providing interfaces via REST API over HTTP, as well as JSON RPC over IPFS. It includes many features for building a web app. This includes user management and authentication, REST API and JSON RPC scaffolding, API documentation, Docker container generation, and extensive test coverage. It's intended to be customized for the needs of the website administrator.
- [Specification](./specification.md)
# Workflows
This section describes the protocols for the database interactions between the three main software components.
These are just a brief, high-level overview. Review the [Specifications](./specification.md) for more details.
## Writing to the Global Database
Adding data to the global P2WDB is a result of the interaction between the _Client_ and the P2WDB. Ideally, `ipfs-swap-service` is not involved. The [p2wdb npm library](https://www.npmjs.com/package/p2wdb) can be leveraged for easy reading and writing to the P2WDB.
During development, `ipfs-swap-service` is being used to submit Offers to the P2WDB and custody funds. When the project reaches maturity, these functions may be removed, and they should be handled by the Client, so that legal issues around custody of funds are not a problem.
Writing data follows these steps:
- A user submits data to the POST `/offer` REST API endpoint. This will move the funds a segregated UTXO and submit the data to the P2WDB to convert the Offer to an Order. Offers are tracked by the local instance of `ipfs-swap-service`, but Orders are tracked by all instances of `ipfs-swap-service`.
- The P2WDB REST API will then evaluate the data and attempt to update the p2p database using the TXID.
- Each peer on the network will independently validate the new database entry.
- `ipfs-swap-service` will receive a webhook call to its POST `/order` endpoint. This event will trigger the import of the new data into the apps local Mongo database, and generate a new Order model.
## Reading from the Local Database
The Client reads data from the local database stored by `ipfs-swap-service`, and does not read the global database directly. This gives `ipfs-swap-service` the opportunity to filter and modify the data locally for a more controlled user experience.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+107
View File
@@ -0,0 +1,107 @@
# ipfs-swap-service Specification
This document contains a high-level, human-readable specification for the four major architectural areas of the ipfs-swap-service:
- Entities
- Use Cases
- Controllers (inputs)
- Adapters (outputs)
This reflects the [Clean Architecture](https://troutsblog.com/blog/clean-architecture) design pattern.
## Entities
Entities make up the core business concepts. If these entities change, they fundamentally change the entire app.
### Order
An order is created from data passed to the app by the P2WDB webhook.
It is destroyed when the UTXO described in the Signal has been detected as spent.
Order entities have the following properties:
- _tokenId_ - The unique ID that identifies the class of token being offered for sale.
- _buyOrSell_ - A string with a value `buy` or `sell` indicating which type of offer this is.
- _rateInSats_ - The rate in terms of tokens-per-currency-unit.
- For Bitcoin, the min currency is sats.
- For AVAX, the min currency is nano-Avax.
- for eCash, the min currency is bits.
- _minSatsToExchange_ - The minimum order size accepted.
- _signature_ - A message signed by the address which created the order.
- _sigMsg_ - The clear-text message used to generate the signature.
- _utxoTxid_ - The TXID of the UTXO used in the order.
- _utxoVout_ - The vout of the UTXO used in the order.
- _numTokens_ - The maximum number of tokens offered for sale.
- _timestamp_ - The ISO time when the order was created.
- _localTimestamp_ - The localized time when the order was created.
- _p2wdbTxid_ - The TXID proof-of-burn used to add the order to the P2WDB.
- _p2wdbHash_ - The hash used to identify the order entry in the P2WDB.
- _lokadId_ - Not used. Provided for future functionality.
- _messageType_ - Not used. Provided for future functionality.
- _messageClass_ - Not used. Provided for future functionality.
### Offer
An Offer Entity is nearly the same as an Order. But while an Order is generated
by a webhook from P2WDB, the Offer Entity is created internally. It is used
to track an Order generated by this application.
The Offer tracks the [HD index address](https://github.com/bitcoinbook/bitcoinbook/blob/develop/ch05.asciidoc#hd-wallets-bip-32bip-44) used to hold tokens or BCH for sale. This is the part of the app concerned with the custody of the funds. It creates a segregated [UTXO](https://github.com/bitcoinbook/bitcoinbook/blob/develop/ch06.asciidoc#transaction-outputs-and-inputs) to hold the offered asset. The Offer is automatically destroyed if the UTXO is accidentally spent, which is why it needs to be segregated from other wallet UTXOs.
Offer entities have the following properties:
- _offerIpfsId_ - The IPFS ID of the instance of `ipfs-swap-service` that is managing the offer.
- _offerBchAddr_ - The BCH address controlling the offer.
- _offerPubKey_ - The public key used to generate the BCH address, used for encryption.
- _tokenId_ - The unique ID that identifies the class of token being offered for sale.
- _buyOrSell_ - A string with a value `buy` or `sell` indicating which type of offer this is.
- _rateInSats_ - The rate in terms of tokens-per-currency-unit.
- For Bitcoin, the min currency is sats.
- For AVAX, the min currency is nano-Avax.
- for eCash, the min currency is bits.
- _minSatsToExchange_ - The minimum order size accepted.
- _signature_ - A message signed by the address which created the order.
- _sigMsg_ - The clear-text message used to generate the signature.
- _utxoTxid_ - The TXID of the UTXO used in the order.
- _utxoVout_ - The vout of the UTXO used in the order.
- _numTokens_ - The maximum number of tokens offered for sale.
- _timestamp_ - The ISO time when the order was created.
- _localTimestamp_ - The localized time when the order was created.
- _p2wdbTxid_ - The TXID proof-of-burn used to add the order to the P2WDB.
- _p2wdbHash_ - The hash used to identify the order entry in the P2WDB.
- _lokadId_ - Not used. Provided for future functionality.
- _messageType_ - Not used. Provided for future functionality.
- _messageClass_ - Not used. Provided for future functionality.
## Use Cases
Use cases are verbs or actions that is done _to_ an Entity or _between_ Entities.
### Order
- **`createOrder()`** - This method is triggered by a webhook from the P2WDB. It will take the data provided by the P2WDB and create a new Order entity in the local database.
### Offer
- **`ensureFunds()`** - Ensure that the wallet has enough BCH and tokens to complete the requested trade.
- **`moveTokens()`** - Move the tokens indicated in the offer to a temporary holding address. This will generate the UTXO used in the webhook message. This function moves the funds and returns the UTXO information.
- **`createOffer()`** - A macro command that leverages `ensureFunds()` and `moveTokens()`, to create a new Offer and submit it to the P2WDB.
## Controllers
Controllers are inputs to the system. When a controller is activated, it causes the system to react in some way.
### Orders
- **POST /order** - This POST REST API endpoint will be triggered by a webhook generated by the P2WDB. This will notify the `ipfs-swap-service` that a new entry has been added to the P2WDB that matches the `appId` of `swap-<chain>`, where `<chain>` has a value of `avax`, `bch`, or `ecash`. It's a new entry that should be evaluated for inclusion in the `ipfs-swap-service` local database.
### Offers
- **POST /offer** - This POST REST API endpoint can be triggered by the Client or a simple curl call. It passes in the data needed for `ipfs-swap-service` to generate and track a new Offer, then submit the data to the P2WDB to generate an Order that is tracked by all other instances of `ipfs-swap-service`.
## Adapters
Adapters are output libraries so that the business logic doesn't need to know any specific information about the I/O. They are essentially the output of the application.
- **localdb** - An adapter for the local database (MongoDB).
- **ipfs** - An adapter for IPFS and the ipfs-coord library. It allows the app to use the JSON-RPC over IPFS.
+15
View File
@@ -0,0 +1,15 @@
# Examples
Below are a series of JSON RPC calls that can be manually entered at chat.fullstack.cash to interact with the JSON RPC of this IPFS Service Provider.
- `{"jsonrpc":"2.0","id":"555","method":"users","params":{ "endpoint": "createUser", "email": "test555@test.com", "name": "testy tester", "password": "password"}}`<br />
- `{"jsonrpc":"2.0","id":"556","method":"auth","params":{ "endpoint": "authUser", "login": "test555@test.com", "password": "password"}}`<br />
- `{"jsonrpc":"2.0","id":"123","method":"users","params":{ "endpoint": "getAllUsers", "apiToken": "<JWT>"}}`<br />
- `{"jsonrpc":"2.0","id":"123","method":"users","params":{ "endpoint": "updateUser", "apiToken": "<JWT>", "userId": "<_id>", "name": "test999"}}`<br />
- `{"jsonrpc":"2.0","id":"123","method":"users","params":{ "endpoint": "getUser", "apiToken": "<JWT>", "userId": "<_id>"}}`<br />
- `{"jsonrpc":"2.0","id":"123","method":"users","params":{ "endpoint": "deleteUser", "userId": "<_id>", "apiToken": "<JWT>"}}`
+4
View File
@@ -0,0 +1,4 @@
const Server = require('./bin/server.js')
const server = new Server()
server.startServer()
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
curl -fsSL https://www.mongodb.org/static/pgp/server-4.4.asc | sudo apt-key add -
echo "deb [ arch=amd64,arm64 ] https://repo.mongodb.org/apt/ubuntu bionic/mongodb-org/4.4 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-4.4.list
sudo apt-get update
sudo apt-get install -y mongodb-org
sudo service mongod start
sudo systemctl enable mongod
+1
View File
@@ -0,0 +1 @@
This directory will hold the Winston daily logs. Any files saved to this directory will be ignored by Git.
+43797
View File
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
{
"name": "avax-dex",
"version": "1.0.0",
"description": "A DEX for trading tokens on the AVAX X-Chain",
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "npm run test:all",
"test:all": "export AVAX_DEX=test && nyc --reporter=text mocha --exit --timeout 15000 --recursive test/unit test/e2e/automated/",
"test:unit": "export AVAX_DEX=test && mocha --exit --timeout 15000 --recursive test/unit/",
"test:e2e:auto": "export AVAX_DEX=test && mocha --exit --timeout 15000 test/e2e/automated/",
"test:temp": "export AVAX_DEX=test && mocha --exit --timeout 15000 -g '#rate-limit' test/unit/json-rpc/",
"lint": "standard --env mocha --fix",
"docs": "./node_modules/.bin/apidoc -i src/ -o docs",
"coverage": "nyc report --reporter=text-lcov | coveralls",
"coverage:report": "export AVAX_DEX=test && nyc --reporter=html mocha --exit --timeout 15000 --recursive test/unit/ test/e2e/automated/"
},
"author": "Chris Troutner <chris.troutner@gmail.com>",
"contributors": [
"Gary Nadir"
],
"license": "MIT",
"apidoc": {
"title": "avax-dex",
"url": "localhost:5000"
},
"repository": "Permissionless-Software-Foundation/avax-dex",
"dependencies": {
"@chris.troutner/ipfs": "2.0.2",
"@psf/bch-js": "4.20.26",
"axios": "^0.21.4",
"bch-message-lib": "1.13.9",
"bcryptjs": "2.4.3",
"glob": "7.1.6",
"ipfs-coord": "6.7.4",
"jsonrpc-lite": "2.2.0",
"jsonwebtoken": "8.5.1",
"jwt-bch-lib": "1.3.0",
"kcors": "2.2.2",
"koa": "2.13.1",
"koa-bodyparser": "4.3.0",
"koa-convert": "2.0.0",
"koa-generic-session": "2.1.1",
"koa-logger": "3.2.1",
"koa-mount": "4.0.0",
"koa-passport": "4.1.3",
"koa-router": "10.0.0",
"koa-static": "5.0.0",
"koa2-ratelimit": "0.9.0",
"line-reader": "0.4.0",
"minimal-slp-wallet": "3.4.7",
"mongoose": "5.13.13",
"node-fetch": "npm:@achingbrain/node-fetch@2.6.7",
"nodemailer": "6.4.17",
"passport-local": "1.0.0",
"public-ip": "^4.0.4",
"winston": "3.3.3",
"winston-daily-rotate-file": "4.5.0"
},
"devDependencies": {
"apidoc": "0.26.0",
"chai": "4.3.0",
"coveralls": "2.11.4",
"eslint": "7.19.0",
"eslint-config-prettier": "7.2.0",
"eslint-config-standard": "16.0.2",
"eslint-plugin-node": "11.1.0",
"eslint-plugin-prettier": "3.3.1",
"eslint-plugin-standard": "4.0.0",
"husky": "4.3.8",
"mocha": "8.4.0",
"nyc": "15.1.0",
"semantic-release": "17.4.4",
"sinon": "9.2.4",
"standard": "16.0.4",
"uuid": "8.3.2"
},
"release": {
"publish": [
{
"path": "@semantic-release/npm",
"npmPublish": false
}
]
},
"husky": {
"hooks": {
"pre-commit": "npm run lint"
}
},
"standard": {
"ignore": [
"/test/unit/mocks/**/*.js"
]
}
}
+75
View File
@@ -0,0 +1,75 @@
# Create a Dockerized API server
#
#IMAGE BUILD COMMANDS
# ct-base-ubuntu = ubuntu 18.04 + nodejs v10 LTS
#FROM christroutner/ct-base-ubuntu
FROM ubuntu:20.04
MAINTAINER Chris Troutner <chris.troutner@gmail.com>
#Update the OS and install any OS packages needed.
RUN apt-get update
RUN apt-get install -y sudo git curl nano gnupg wget
#Install Node and NPM
RUN curl -sL https://deb.nodesource.com/setup_14.x -o nodesource_setup.sh
RUN bash nodesource_setup.sh
RUN apt-get install -y nodejs build-essential
#Create the user 'safeuser' and add them to the sudo group.
#RUN useradd -ms /bin/bash safeuser
#RUN adduser safeuser sudo
#Set password to 'password' change value below if you want a different password
#RUN echo safeuser:password | chpasswd
#Set the working directory to be the home directory
WORKDIR /home/safeuser
#Setup NPM for non-root global install
#RUN mkdir /home/safeuser/.npm-global
#RUN chown -R safeuser .npm-global
#RUN echo "export PATH=~/.npm-global/bin:$PATH" >> /home/safeuser/.profile
#RUN runuser -l safeuser -c "npm config set prefix '~/.npm-global'"
# Update to the latest version of npm.
# Working with npm@7.21.1
RUN npm install -g npm@7.23.0
# npm mirror to prevent direct dependency on npm.
RUN npm set registry http://94.130.170.209:4873/
# Switch to user account.
#USER safeuser
# Prep 'sudo' commands.
#RUN echo 'abcd8765' | sudo -S pwd
# Clone the rest.bitcoin.com repository
WORKDIR /home/safeuser
RUN git clone https://github.com/Permissionless-Software-Foundation/ipfs-service-provider
# Switch to the desired branch. `master` is usually stable,
# and `stage` has the most up-to-date changes.
WORKDIR /home/safeuser/ipfs-service-provider
# For development: switch to unstable branch
RUN git checkout ct-unstable
# Install dependencies
#RUN mkdir .ipfsdata
#RUN npm install -g @mapbox/node-pre-gyp
RUN npm install
# Generate the API docs
RUN npm run docs
VOLUME /home/safeuser/keys
# Expose the port the API will be served on.
EXPOSE 5001
# Start the application.
COPY start-production.sh start-production.sh
CMD ["./start-production.sh"]
#CMD ["npm", "start"]
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# Remove all untagged docker images.
docker rmi $(docker images | grep "^<none>" | awk '{print $3}')
+33
View File
@@ -0,0 +1,33 @@
# Start the service with the command 'docker-compose up -d'
version: '2'
services:
mongo-ipfs-service:
image: mongo
container_name: mongo-ipfs-service
ports:
- '5555:27017' # <host port>:<container port>
volumes:
- ../data/database:/data/db
command: mongod --logpath=/dev/null # -- quiet
restart: always
ipfs-service:
build: .
container_name: ipfs-service
logging:
driver: 'json-file'
options:
max-size: '10m'
max-file: '10'
mem_limit: 500mb
links:
- mongo-ipfs-service
ports:
- '5001:5001' # <host port>:<container port>
- '5268:5268' # IPFS TCP
- '5269:5269' # IPFS WS
volumes:
- ../data/ipfsdata:/home/safeuser/ipfs-service-provider/.ipfsdata
restart: always
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
# BEGIN: Optional configuration settings
# This mnemonic is used to set up persistent public key for e2ee
# Replace this with your own 12-word mnemonic.
# You can get one at https://wallet.fullstack.cash.
export MNEMONIC="olive two muscle bottom coral ancient wait legend bronze useful process session"
# The human readable name this IPFS node identifies as.
export COORD_NAME=ipfs-service-provider-generic
# Allow this node to function as a circuit relay. It must not be behind a firewall.
#export ENABLE_CIRCUIT_RELAY=true
# For browsers to use your circuit realy, you must set up a domain, SSL certificate,
# and you must forward that subdomain to the IPFS_WS_PORT.
#export CR_DOMAIN=subdomain.yourdomain.com
# Debug level. 0 = minimal info. 2 = max info.
export DEBUG_LEVEL=1
# END: Optional configuration settings
# Production database connection string.
export DBURL=mongodb://172.17.0.1:5555/ipfs-service-prod
# Configure IPFS ports
export IPFS_TCP_PORT=5268
export IPFS_WS_PORT=5269
# Configure REST API port
export PORT=5001
export SVC_ENV=production
npm start
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
# This script is an example for running a generic ipfs-service-provider instance.
# Ports
export PORT=5001 # REST API port
export IPFS_TCP_PORT=5268
export IPFS_WS_PORT=5269
# The human-readible name that is used when displaying data about this node.
export COORD_NAME=ipfs-service-provider-generic
# This is used for end-to-end encryption (e2ee).
export MNEMONIC="churn aisle shield silver ladder swear hunt slim pen demand spoil veteran"
# 0 = less verbose. 3 = most verbose
export DEBUG_LEVEL=1
# MongoDB connection string.
#export DBURL=mongodb://localhost:27017/bch-service-dev
npm start
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# This script is an example for running a ipfs-service-provider as a Circuit Relay.
# Circuit Relays are help other nodes on the network communicate. They are
# critical for reliable functioning of the network, and for circumventing
# censorship.
# Ports
export PORT=5001 # REST API port
export IPFS_TCP_PORT=5268
export IPFS_WS_PORT=5269
# The human-readible name that is used when displaying data about this node.
export COORD_NAME=ipfs-service-provider-generic
# This is used for end-to-end encryption (e2ee).
export MNEMONIC="churn aisle shield silver ladder swear hunt slim pen demand spoil veteran"
# 0 = less verbose. 3 = most verbose
export DEBUG_LEVEL=1
# MongoDB connection string.
#export DBURL=mongodb://localhost:27017/bch-service-dev
# Comment to disable circuit relay functionality. Or set to 1 to enable.
export ENABLE_CIRCUIT_RELAY=1
# For browsers to use your circuit realy, you must set up a domain, SSL certificate,
# and you must forward that subdomain to the IPFS_WS_PORT.
#export CR_DOMAIN=subdomain.yourdomain.com
npm start
+172
View File
@@ -0,0 +1,172 @@
/*
A library for working with the system admin user. This is an auto-generated
account with 'admin' privledges, for interacting with private APIs.
The admin account is regenerated every time the server is started. This improves
security by not having stale passwords for the account. The login information
and JWT token for the admin account is written to a JSON file, for easy
retrieval by other apps running on the server that may need admin privledges
to access private APIs.
This library is really more of an Adapter to the internal systems default
admin user. It's not really a central Entity, which is why this library lives
in the Adapter directory.
*/
'use strict'
const axios = require('axios').default
const mongoose = require('mongoose')
const User = require('../adapters/localdb/models/users')
const config = require('../../config')
const JsonFiles = require('../adapters/json-files')
const jsonFiles = new JsonFiles()
const JSON_FILE = `system-user-${config.env}.json`
const JSON_PATH = `${__dirname.toString()}/../../config/${JSON_FILE}`
const LOCALHOST = `http://localhost:${config.port}`
const context = {}
let _this
class Admin {
constructor () {
this.axios = axios
this.User = User
this.config = config
this.jsonFiles = jsonFiles
this.context = context
_this = this
}
// Create the first user in the system. A 'admin' level system user that is
// used by the Listing Manager and test scripts, in order access private API
// functions.
async createSystemUser () {
// Create the system user.
try {
context.password = _this._randomString(20)
const options = {
method: 'POST',
url: `${LOCALHOST}/users`,
data: {
user: {
email: 'system@system.com',
password: context.password,
name: 'admin'
}
}
}
const result = await _this.axios.request(options)
// console.log('admin.data: ', result.data)
context.email = result.data.user.email
context.id = result.data.user._id
context.token = result.data.token
// Get the mongoDB entry
const user = await _this.User.findById(context.id)
// Change the user type to admin
user.type = 'admin'
// console.log(`user created: ${JSON.stringify(user, null, 2)}`)
// Save the user model.
await user.save()
// console.log(`admin user created: ${JSON.stringify(result.body, null, 2)}`)
// console.log(`with password: ${context.password}`)
// Write out the system user information to a JSON file that external
// applications like the Task Manager and the test scripts can access.
await jsonFiles.writeJSON(context, JSON_PATH)
return context
} catch (err) {
// Handle existing system user.
if (err.response.status === 422) {
try {
// Delete the existing user
await _this.deleteExistingSystemUser()
// Call this function again.
return _this.createSystemUser()
} catch (err2) {
console.error(
'Error in admin.js/createSystemUser() while trying generate new system user.'
)
// process.end(1)
throw err2
}
} else {
console.log('Error in admin.js/createSystemUser: ')
// process.end(1)
throw err
}
}
}
async deleteExistingSystemUser () {
try {
mongoose.Promise = global.Promise
mongoose.set('useCreateIndex', true) // Stop deprecation warning.
await mongoose.connect(config.database, {
useNewUrlParser: true,
useUnifiedTopology: true
})
await _this.User.deleteOne({ email: 'system@system.com' })
} catch (err) {
console.log('Error in admin.js/deleteExistingSystemUser()')
throw err
}
}
async loginAdmin () {
// console.log(`loginAdmin() running.`)
let existingUser
try {
// Read the exising file
existingUser = await _this.jsonFiles.readJSON(JSON_PATH)
// console.log(`existingUser: ${JSON.stringify(existingUser, null, 2)}`)
// Log in as the user.
const options = {
method: 'POST',
url: `${LOCALHOST}/auth`,
headers: {
Accept: 'application/json'
},
data: {
email: 'system@system.com',
password: existingUser.password
}
}
const result = await _this.axios.request(options)
// console.log(`result1: ${JSON.stringify(result, null, 2)}`)
return result
} catch (err) {
console.error('Error in admin.js/loginAdmin().')
// console.error(`existingUser: ${JSON.stringify(existingUser, null, 2)}`)
throw err
}
}
_randomString (length) {
let text = ''
const possible =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
for (let i = 0; i < length; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length))
}
return text
}
}
module.exports = Admin
+80
View File
@@ -0,0 +1,80 @@
/*
This library contains methods for working with the BCHN and BCHA blockchains.
*/
// Public npm libraries
const BCHJS = require('@psf/bch-js')
const MsgLib = require('bch-message-lib')
class Bch {
constructor () {
// Encapsulate dependencies
this.bchjs = new BCHJS()
this.PSF_TOKEN_ID =
'38e97c5d7d3585a2cbf3f9580c82ca33985f9cb0845d4dcce220cb709f9538b0'
this.msgLib = new MsgLib({ bchjs: this.bchjs })
}
// Verify that the entry was signed by a specific BCH address.
_verifySignature (verifyObj) {
try {
// Expand the input object.
const { offerBchAddr, signature, sigMsg } = verifyObj
// Convert to BCH address.
// const scrubbedAddr = this.bchjs.SLP.Address.toCashAddress(slpAddress)
const isValid = this.bchjs.BitcoinCash.verifyMessage(
offerBchAddr,
signature,
sigMsg
)
return isValid
} catch (err) {
console.error('Error in bch.js/_verifySignature()')
throw err
}
}
// Gets the total psf token balance
async getPSFTokenBalance (slpAddress) {
try {
if (!slpAddress || typeof slpAddress !== 'string') {
throw new Error('slpAddress must be a string')
}
let psfBalance = 0
const balances = await this.bchjs.SLP.Utils.balancesForAddress(
slpAddress
)
// Sums all the balances of all tokens
// that match the psf token ID
for (let i = 0; i < balances.length; i++) {
if (balances[i].tokenId === this.PSF_TOKEN_ID) {
psfBalance += balances[i].balance
}
}
return psfBalance
} catch (err) {
console.error('Error in bch.js/getPSFTokenBalance()')
throw err
}
}
async getMerit (slpAddr) {
try {
if (!slpAddr || typeof slpAddr !== 'string') {
throw new Error('slpAddr must be a string')
}
const merit = await this.msgLib.merit.agMerit(slpAddr, this.PSF_TOKEN_ID)
return merit
} catch (error) {
console.error('error in bch.js/getMerit()')
throw error
}
}
}
module.exports = Bch
+63
View File
@@ -0,0 +1,63 @@
/*
Business logic for the /contact endpoint.
*/
/* eslint-disable no-useless-escape */
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
const config = require('../../config')
const NodeMailer = require('../adapters/nodemailer')
const nodemailer = new NodeMailer()
const { wlogger } = require('../adapters/wlogger')
let _this
class ContactLib {
constructor () {
_this = this
_this.config = config
_this.nodemailer = nodemailer
}
async sendEmail (emailObj) {
try {
// Validate input
if (!emailObj.email || typeof emailObj.email !== 'string') {
throw new Error("Property 'email' must be a string!")
}
if (!emailObj.formMessage || typeof emailObj.formMessage !== 'string') {
throw new Error("Property 'formMessage' must be a string!")
}
// If an email list exists, the email will be sended to that list
// otherwhise will be sended by default to the variable "_this.config.emailUser"
let _to = [_this.config.emailUser]
// Email list is optional
if (emailObj.emailList) {
if (
!Array.isArray(emailObj.emailList) ||
!emailObj.emailList.length > 0
) {
throw new Error("Property 'emailList' must be a array of emails!")
} else {
_to = emailObj.emailList
}
}
console.log(`Trying send message to : ${_to}`)
emailObj.subject = 'Someone wants contact with you.'
emailObj.to = _to
const result = await _this.nodemailer.sendEmail(emailObj)
return result
} catch (err) {
wlogger.error('Error in lib/contact.js/sendEmail()')
throw err
}
}
}
module.exports = ContactLib
+107
View File
@@ -0,0 +1,107 @@
/*
A library of utility functions for working with FullStack.cash JWT tokens.
Feel free to copy this library into your own app, as well as the unit tests
for this file.
*/
const JwtLib = require('jwt-bch-lib')
const BCHJS = require('@psf/bch-js')
class FullStackJWT {
constructor (localConfig = {}) {
// Input Validation
this.authServer = localConfig.authServer
if (!this.authServer || typeof this.authServer !== 'string') {
throw new Error(
'Must pass a url for the AUTH server when instantiating FullStackJWT class.'
)
}
this.apiServer = localConfig.apiServer
if (!this.apiServer || typeof this.apiServer !== 'string') {
throw new Error(
'Must pass a url for the API server when instantiating FullStackJWT class.'
)
}
this.login = localConfig.fullstackLogin
if (!this.login || typeof this.login !== 'string') {
throw new Error(
'Must pass a FullStack.cash login (email) instantiating FullStackJWT class.'
)
}
this.password = localConfig.fullstackPassword
if (!this.password || typeof this.password !== 'string') {
throw new Error(
'Must pass a FullStack.cash account password when instantiating FullStackJWT class.'
)
}
// Encapsulate dependencies
this.jwtLib = new JwtLib({
// Overwrite default values with the values in the config file.
server: this.authServer,
login: this.login,
password: this.password
})
// State
this.apiToken = '' // Default value.
this.bchjs = {}
}
// Get's a JWT token from FullStack.cash.
async getJWT () {
try {
// Skip connecting FullStack.cash auth server to the network if this is an E2E test.
if (process.env.TEST_TYPE === 'e2e') {
this.apiToken = 'faketoken'
return this.apiToken
}
// Log into the auth server.
await this.jwtLib.register()
this.apiToken = this.jwtLib.userData.apiToken
if (!this.apiToken) {
throw new Error('This account does not have a JWT')
}
console.log(`Retrieved JWT token: ${this.apiToken}\n`)
// Ensure the JWT token is valid to use.
const isValid = await this.jwtLib.validateApiToken()
// Get a new token with the same API level, if the existing token is not
// valid (probably expired).
if (!isValid.isValid) {
this.apiToken = await this.jwtLib.getApiToken(
this.jwtLib.userData.apiLevel
)
console.log(
`The JWT token was not valid. Retrieved new JWT token: ${this.apiToken}\n`
)
} else {
console.log('JWT token is valid.\n')
}
return this.apiToken
} catch (err) {
console.error(
`Error trying to log into ${this.server} and retrieve JWT token.`
)
throw err
}
}
// Create an instance of bchjs with the validated JWT token. Returns this
// instance of bch-js.
instanceBchjs () {
this.bchjs = new BCHJS({
restURL: this.apiServer,
apiToken: this.apiToken
})
return this.bchjs
}
}
module.exports = FullStackJWT
+90
View File
@@ -0,0 +1,90 @@
/*
This is a top-level library that encapsulates all the additional Adapters.
The concept of Adapters comes from Clean Architecture:
https://troutsblog.com/blog/clean-architecture
*/
// Public NPM libraries
const BCHJS = require('@psf/bch-js')
// Load individual adapter libraries.
const IPFSAdapter = require('./ipfs')
const LocalDB = require('./localdb')
const LogsAPI = require('./logapi')
const Passport = require('./passport')
const Nodemailer = require('./nodemailer')
// const { wlogger } = require('./wlogger')
const JSONFiles = require('./json-files')
const FullStackJWT = require('./fullstack-jwt')
const BCHAdapter = require('./bch')
const WalletAdapter = require('./wallet')
const P2wdbAdapter = require('./p2wdb')
//
// // Instantiate adapter libraries.
// const ipfs = new IPFSAdapter()
// const localdb = new LocalDB()
// const logapi = new LogsAPI()
// const passport = new Passport()
// const nodemailer = new Nodemailer()
// const jsonFiles = new JSONFiles()
// const bchjs = new BCHJSAdapter()
//
// module.exports = {
// ipfs,
// localdb,
// logapi,
// passport,
// nodemailer,
// wlogger,
// jsonFiles,
// bchjs
const config = require('../../config')
class Adapters {
constructor (localConfig = {}) {
// Encapsulate dependencies
this.ipfs = new IPFSAdapter()
this.localdb = new LocalDB()
this.logapi = new LogsAPI()
this.passport = new Passport()
this.nodemailer = new Nodemailer()
this.jsonFiles = new JSONFiles()
this.bchjs = new BCHJS()
this.bch = new BCHAdapter()
this.config = config
this.wallet = new WalletAdapter()
this.p2wdb = new P2wdbAdapter()
// Get a valid JWT API key and instance bch-js.
this.fullStackJwt = new FullStackJWT(config)
}
async start () {
try {
if (this.config.getJwtAtStartup) {
// Get a JWT token and instantiate bch-js with it. Then pass that instance
// to all the rest of the apps controllers and adapters.
await this.fullStackJwt.getJWT()
// Instantiate bch-js with the JWT token, and overwrite the placeholder for bch-js.
this.bchjs = await this.fullStackJwt.instanceBchjs()
}
// Start the IPFS node.
await this.ipfs.start()
// Open the wallet file
const walletData = await this.wallet.openWallet()
// console.log('walletData: ', walletData)
// Instance the wallet.
await this.wallet.instanceWallet(walletData, this.bchjs)
} catch (err) {
console.error('Error in adapters/index.js/start()')
throw err
}
}
}
module.exports = Adapters
+54
View File
@@ -0,0 +1,54 @@
/*
top-level IPFS library that combines the individual IPFS-based libraries.
*/
const IpfsAdapter = require('./ipfs')
const IpfsCoordAdapter = require('./ipfs-coord')
class IPFS {
constructor (localConfig = {}) {
// Encapsulate dependencies
this.ipfsAdapter = new IpfsAdapter()
this.IpfsCoordAdapter = IpfsCoordAdapter
this.process = process
this.ipfsCoordAdapter = {} // placeholder
// Properties of this class instance.
this.isReady = false
}
// Provides a global start() function that triggers the start() function in
// the underlying libraries.
async start () {
try {
// Start IPFS
await this.ipfsAdapter.start()
console.log('IPFS is ready.')
// this.ipfs is a Promise that will resolve into an instance of an IPFS node.
this.ipfs = this.ipfsAdapter.ipfs
// Start ipfs-coord
this.ipfsCoordAdapter = new this.IpfsCoordAdapter({
ipfs: this.ipfs
})
await this.ipfsCoordAdapter.start()
console.log('ipfs-coord is ready.')
return true
} catch (err) {
console.error('Error in adapters/ipfs/index.js/start()')
// If error is due to a lock file issue. Kill the process, so that
// Docker or pm2 has a chance to restart the service.
if (err.message.includes('Lock already being held')) {
this.process.exit(1)
}
throw err
}
}
}
module.exports = IPFS
+95
View File
@@ -0,0 +1,95 @@
/*
Clean Architecture Adapter for ipfs-coord.
This library deals with ipfs-coord library so that the apps business logic
doesn't need to have any specific knowledge of the library.
*/
// Global npm libraries
const IpfsCoord = require('ipfs-coord')
const BCHJS = require('@psf/bch-js')
const publicIp = require('public-ip')
// Local libraries
const config = require('../../../config')
// const JSONRPC = require('../../controllers/json-rpc/')
let _this
class IpfsCoordAdapter {
constructor (localConfig = {}) {
// Dependency injection.
this.ipfs = localConfig.ipfs
if (!this.ipfs) {
throw new Error(
'Instance of IPFS must be passed when instantiating ipfs-coord.'
)
}
// Encapsulate dependencies
this.IpfsCoord = IpfsCoord
this.ipfsCoord = {}
this.bchjs = new BCHJS()
this.config = config
this.publicIp = publicIp
// Properties of this class instance.
this.isReady = false
_this = this
}
async start () {
const circuitRelayInfo = {}
// If configured as a Circuit Relay, get the public IP addresses for this node.
if (this.config.isCircuitRelay) {
try {
const ip4 = await this.publicIp.v4()
// const ip6 = await publicIp.v6()
circuitRelayInfo.ip4 = ip4
circuitRelayInfo.tcpPort = this.config.ipfsTcpPort
// Domain used by browser-based secure websocket connections.
circuitRelayInfo.crDomain = this.config.crDomain
} catch (err) {
/* exit quietly */
}
}
this.ipfsCoord = new this.IpfsCoord({
ipfs: this.ipfs,
type: 'node.js',
// type: 'browser',
bchjs: this.bchjs,
privateLog: console.log, // Default to console.log
isCircuitRelay: this.config.isCircuitRelay,
circuitRelayInfo,
apiInfo: this.config.apiInfo,
announceJsonLd: this.config.announceJsonLd,
debugLevel: this.config.debugLevel
})
// Wait for the ipfs-coord library to signal that it is ready.
await this.ipfsCoord.start()
// Signal that this adapter is ready.
this.isReady = true
return this.isReady
}
// Expects router to be a function, which handles the input data from the
// pubsub channel. It's expected to be capable of routing JSON RPC commands.
attachRPCRouter (router) {
try {
_this.ipfsCoord.privateLog = router
_this.ipfsCoord.adapters.orbit.privateLog = router
} catch (err) {
console.error('Error in attachRPCRouter()')
throw err
}
}
}
module.exports = IpfsCoordAdapter
+85
View File
@@ -0,0 +1,85 @@
/*
Clean Architecture Adapter for IPFS.
This library deals with IPFS so that the apps business logic doesn't need
to have any specific knowledge of the js-ipfs library.
TODO: Add the external IP address to the list of multiaddrs advertised by
this node. See this GitHub Issue for details:
https://github.com/Permissionless-Software-Foundation/ipfs-service-provider/issues/38
*/
// Global npm libraries
// const IPFS = require('ipfs')
const IPFS = require('@chris.troutner/ipfs')
// Local libraries
const config = require('../../../config')
class IpfsAdapter {
constructor (localConfig) {
// Encapsulate dependencies
this.IPFS = IPFS
// Properties of this class instance.
this.isReady = false
this.config = config
}
// Start an IPFS node.
async start () {
try {
// Ipfs Options
const ipfsOptions = {
repo: './.ipfsdata/ipfs',
start: true,
config: {
relay: {
enabled: true, // enable circuit relay dialer and listener
hop: {
enabled: config.isCircuitRelay // enable circuit relay HOP (make this node a relay)
}
},
pubsub: true, // enable pubsub
Swarm: {
ConnMgr: {
HighWater: 30,
LowWater: 10
}
},
Addresses: {
Swarm: [
`/ip4/0.0.0.0/tcp/${this.config.ipfsTcpPort}`,
`/ip4/0.0.0.0/tcp/${this.config.ipfsWsPort}/ws`
]
}
}
}
// Create a new IPFS node.
this.ipfs = await this.IPFS.create(ipfsOptions)
// Set the 'server' profile so the node does not scan private networks.
await this.ipfs.config.profiles.apply('server')
// Debugging: Display IPFS config settings.
// const configSettings = await this.ipfs.config.getAll()
// console.log(`configSettings: ${JSON.stringify(configSettings, null, 2)}`)
// Signal that this adapter is ready.
this.isReady = true
return this.ipfs
} catch (err) {
console.error('Error in ipfs.js/start()')
throw err
}
}
async stop () {
await this.ipfs.stop()
return true
}
}
module.exports = IpfsAdapter
+78
View File
@@ -0,0 +1,78 @@
/*
A utility file for reading and writing JSON files.
*/
'use strict'
const fs = require('fs')
let _this
class JsonFiles {
constructor () {
this.fs = fs
_this = this
}
// Writes out a JSON file of any object passed to the function.
// This is used for testing.
writeJSON (obj, fileName) {
return new Promise(function (resolve, reject) {
try {
if (!obj) {
throw new Error('obj property is required')
}
if (!fileName || typeof fileName !== 'string') {
throw new Error('fileName property must be a string')
}
const fileStr = JSON.stringify(obj, null, 2)
_this.fs.writeFile(fileName, fileStr, function (err) {
if (err) {
console.error('Error while trying to write file: ')
throw err
} else {
// console.log(`${fileName} written successfully!`)
return resolve()
}
})
} catch (err) {
console.error(
'Error trying to write out object in util.js/_writeJSON().'
)
return reject(err)
}
})
}
readJSON (fileName) {
return new Promise(function (resolve, reject) {
try {
if (!fileName || typeof fileName !== 'string') {
throw new Error('fileName property must be a string')
}
_this.fs.readFile(fileName, (err, data) => {
if (err) {
if (err.code === 'ENOENT') {
console.log('.json file not found!')
} else {
console.log(`err: ${JSON.stringify(err, null, 2)}`)
}
// throw err
return reject(err)
}
const obj = JSON.parse(data)
return resolve(obj)
})
} catch (err) {
console.error('Error trying to read JSON file in util.js/_readJSON().')
return reject(err)
}
})
}
}
module.exports = JsonFiles
+19
View File
@@ -0,0 +1,19 @@
/*
This library encapsulates code concerned with MongoDB and Mongoose models.
*/
// Load Mongoose models.
const Users = require('./models/users')
const Entries = require('./models/entry')
const Order = require('./models/order')
class LocalDB {
constructor () {
// Encapsulate dependencies
this.Users = Users
this.Entry = Entries
this.Order = Order
}
}
module.exports = LocalDB
+13
View File
@@ -0,0 +1,13 @@
const mongoose = require('mongoose')
const Entry = new mongoose.Schema({
entry: { type: String },
description: { type: String },
slpAddress: { type: String },
signature: { type: String },
category: { type: String },
balance: { type: Number },
merit: { type: Number }
})
module.exports = mongoose.model('entry', Entry)
+24
View File
@@ -0,0 +1,24 @@
const mongoose = require('mongoose')
const Offer = new mongoose.Schema({
// SWaP Protocol Properties
lokadId: { type: String },
messageType: { type: Number },
messageClass: { type: Number },
tokenId: { type: String },
buyOrSell: { type: String },
rateInSats: { type: String },
minSatsToExchange: { type: String },
signature: { type: String },
sigMsg: { type: String },
utxoTxid: { type: String },
utxoVout: { type: Number },
numTokens: { type: Number },
//
offerIpfsId: { type: String },
offerBchAddr: { type: String },
offerPubKey: { type: String }
})
module.exports = mongoose.model('offer', Offer)
+23
View File
@@ -0,0 +1,23 @@
const mongoose = require('mongoose')
const Order = new mongoose.Schema({
// SWaP Protocol Properties
lokadId: { type: String },
messageType: { type: Number },
messageClass: { type: Number },
tokenId: { type: String },
buyOrSell: { type: String },
rateInSats: { type: String },
minSatsToExchange: { type: String },
signature: { type: String },
sigMsg: { type: String },
utxoTxid: { type: String },
utxoVout: { type: Number },
numTokens: { type: Number },
timestamp: { type: String },
localTimestamp: { type: String },
p2wdbTxid: { type: String },
p2wdbHash: { type: String }
})
module.exports = mongoose.model('order', Order)
+54
View File
@@ -0,0 +1,54 @@
const mongoose = require('mongoose')
const bcrypt = require('bcryptjs')
const config = require('../../../../config')
const jwt = require('jsonwebtoken')
const User = new mongoose.Schema({
type: { type: String, default: 'user' },
name: { type: String },
username: { type: String },
password: { type: String, required: true },
email: {
type: String,
required: true,
unique: true
}
})
// Before saving, convert the password to a hash.
User.pre('save', async function preSave (next) {
const user = this
if (!user.isModified('password')) {
return next()
}
const salt = await bcrypt.genSalt(10)
const hash = await bcrypt.hash(user.password, salt)
user.password = hash
next(null)
})
// Validate the password by comparing to the saved hash.
User.methods.validatePassword = async function validatePassword (password) {
const user = this
const isMatch = await bcrypt.compare(password, user.password)
return isMatch
}
// Generate a JWT token.
User.methods.generateToken = function generateToken () {
const user = this
const token = jwt.sign({ id: user.id }, config.token)
// console.log(`config.token: ${config.token}`)
// console.log(`generated token: ${token}`)
return token
}
// export default mongoose.model('user', User)
module.exports = mongoose.model('user', User)
+42
View File
@@ -0,0 +1,42 @@
/*
Adapter for the Order database model. Provides pagination when retrieving
orders from the datatabase.
*/
const Order = require('./models/order')
// let _this
class OrderPagination {
constructor (localConfig = {}) {
// Encapsulate dependencies
this.Order = Order
// _this = this
}
// Read all entries in the P2WDB.
async readAll (page = 0) {
try {
const ENTRIES_PER_PAGE = 20
// Pull data from MongoDB.
// Get all entries in the database.
const data = await this.Order.find({})
// Sort entries so newest entries show first.
.sort('-timestamp')
// Skip to the start of the selected page.
.skip(page * ENTRIES_PER_PAGE)
// Only return 20 results.
.limit(ENTRIES_PER_PAGE)
// console.log('data: ', data)
return data
} catch (err) {
console.error('Error in order-pagination.js/readAll()')
throw err
}
}
}
module.exports = OrderPagination
+170
View File
@@ -0,0 +1,170 @@
const lineReader = require('line-reader')
const fs = require('fs')
const config = require('../../config')
let _this
class LogsApi {
constructor () {
_this = this
_this.fs = fs
_this.lineReader = lineReader
_this.config = config
}
async getLogs (password) {
try {
// console.log('entering getLogs()')
_this.password = password
// console.log(`password: ${password}`)
// Password matches the password set in the config file.
if (password === _this.config.logPass) {
// Generate the full path and file name for the current log file.
const fullPath = _this.generateFileName()
// console.log(`fullPath: ${JSON.stringify(fullPath, null, 2)}`)
// Throw an error if the file does not exist.
if (!_this.fs.existsSync(fullPath)) {
return {
success: false,
data: 'file does not exist'
}
} else {
// Read in the data from the log file.
const data = await _this.readLines(fullPath)
// console.log(`data: ${JSON.stringify(data, null, 2)}`)
// Filter the logs before passing them to the front end.
const filteredData = _this.filterLogs(data)
return {
success: true,
data: filteredData
}
}
// Password does not match password in config file.
} else {
return {
success: false
}
}
} catch (err) {
console.error('Error in lib/logapi.js/getLogs()')
throw err
}
}
// Sorts the log data by their timestamp. Returns the LIMIT or less elements.
filterLogs (data, LIMIT = 100) {
try {
if (!Array.isArray(data)) {
throw new Error('Data must be array')
}
// console.log(`data: ${JSON.stringify(data, null, 2)}`)
// const LIMIT = 100 // Max number of entries to return.
// Sort the elements by date.
data.sort(function (a, b) {
let dateA = new Date(a.timestamp)
dateA = dateA.getTime()
let dateB = new Date(b.timestamp)
dateB = dateB.getTime()
return dateB - dateA
})
// Limit the number of elements.
if (data.length > LIMIT) {
return data.slice(0, LIMIT)
}
// else
return data
} catch (err) {
console.error('Error in lib/logapi.js/filterLogs()')
throw err
}
}
generateFileName () {
try {
const now = new Date()
let thisDate = now.getDate()
thisDate = ('0' + thisDate).slice(-2)
let thisMonth = now.getMonth() + 1
thisMonth = ('0' + thisMonth).slice(-2)
// console.log(`thisMonth: ${thisMonth}`)
const thisYear = now.getFullYear()
const filename = `koa-${
_this.config.env
}-${thisYear}-${thisMonth}-${thisDate}.log`
// console.log(`filename: ${filename}`)
const logDir = `${__dirname.toString()}/../../logs/`
const fullPath = `${logDir}${filename}`
// console.log(`fullPath: ${fullPath}`)
return fullPath
} catch (err) {
console.error('Error in lib/logapi.js/generateFileName()')
throw err
}
}
// Promise based read-file
/* readFile (path, opts = 'utf8') {
return new Promise((resolve, reject) => {
_this.fs.readFile(path, opts, (err, data) => {
if (err) reject(err)
else resolve(data)
})
})
} */
// Returns an array with each element containing a line of the file.
readLines (filename) {
return new Promise((resolve, reject) => {
try {
if (!filename || typeof filename !== 'string') {
throw new Error('filename must be a string')
}
// Throw an error if the file does not exist.
if (!_this.fs.existsSync(filename)) {
throw new Error('file does not exist')
}
const data = []
// let i = 0
_this.lineReader.eachLine(filename, function (line, last) {
try {
data.push(JSON.parse(line))
// Uncomment to display the raw data in each line of the winston log file.
// console.log(`line ${i}: ${line}`)
// i++
if (last) return resolve(data)
} catch (err) {
// console.log('err: ', err)
if (last) return resolve(data)
}
})
} catch (err) {
console.log('Error in lib/logapi.js/readLines()')
return reject(err)
}
})
}
}
module.exports = LogsApi
+153
View File
@@ -0,0 +1,153 @@
/*
A library for controlling the sending of email.
*/
'use strict'
const nodemailer = require('nodemailer')
const config = require('../../config')
const { wlogger } = require('./wlogger')
let _this
class NodeMailer {
constructor () {
this.nodemailer = nodemailer
this.config = config
_this = this
_this.transporter = _this.createTransporter()
}
// Define an email server 'transport' for nodemailer
createTransporter () {
const transporter = _this.nodemailer.createTransport({
host: _this.config.emailServer,
port: 587,
secure: false, // true for 465, false for other ports
auth: {
user: _this.config.emailUser, // generated ethereal user
pass: _this.config.emailPassword // generated ethereal password
}
})
return transporter
}
// Handles the sending of data via email.
async sendEmail (data) {
try {
// Validate input
if (!data.email || typeof data.email !== 'string') {
throw new Error("Property 'email' must be a string!")
}
if (!data.to || !Array.isArray(data.to)) {
throw new Error("Property 'to' must be a array!")
}
await _this.validateEmailArray(data.to)
if (!data.formMessage || typeof data.formMessage !== 'string') {
throw new Error("Property 'formMessage' must be a string!")
}
if (!data.subject || typeof data.subject !== 'string') {
throw new Error("Property 'subject' must be a string!")
}
// Use the provided html or use a default html generated from the input data
const html = data.htmlData || _this.getHtmlFromObject(data)
const sendObj = {
// from: `${data.email}`, // sender address
from: data.email,
to: data.to, // list of receivers
// subject: `Pearson ${subject}`, // Subject line
subject: data.subject,
// html: '<b>This is a test email</b>' // html body
html
}
// send mail with defined transport object
const info = await _this.transporter.sendMail(sendObj)
console.log('Message sent: %s', info.messageId)
return info
} catch (err) {
wlogger.error('Error in lib/nodemailer.js/sendEmail()')
throw err
}
}
async validateEmailArray (emailList) {
try {
if (!emailList || !Array.isArray(emailList)) {
throw new Error("Property 'emailList' must be a array!")
}
// Email list can't be empty
if (!emailList.length > 0) {
throw new Error("Property 'emailList' cant be empty!")
}
return true
} catch (err) {
wlogger.error('Error in lib/nodemailer.js/validateEmailArray()')
throw err
}
}
// get the email html from object
getHtmlFromObject (objectData) {
try {
if (!objectData || typeof objectData !== 'object') {
throw new Error("Property 'objectData' must be a object!")
}
if (!objectData.subject) {
throw new Error("Property 'subject' must be a string!")
}
if (!objectData.formMessage) {
throw new Error("Property 'formMessage' must be a string!")
}
const obj = {}
Object.assign(obj, objectData)
// neccesary data
const msg = obj.formMessage.replace(/(\r\n|\n|\r)/g, '<br />')
const now = new Date()
const subject = obj.subject
// Delete unneccesary data if it exist
delete obj.to
delete obj.subject
delete obj.from
delete obj.emailList
delete obj.formMessage
const bodyJson = obj
bodyJson.message = msg
// Html body
let htmlBody = ''
// maps the object and converts it into html format
Object.keys(bodyJson).forEach(function (key) {
htmlBody += `${key}: ${bodyJson[key]}<br/>`
})
const defaultHtmlData = `<h3>${subject}:</h3>
<p>
time: ${now.toLocaleString()}<br/>
${htmlBody}
</p>`
return defaultHtmlData
} catch (error) {
wlogger.error('Error in lib/nodemailer.js/getHtmlFromObject()')
throw error
}
}
}
module.exports = NodeMailer
+51
View File
@@ -0,0 +1,51 @@
/*
Adapter library for interacting with the P2WDB
*/
// Public npm libraries.
const axios = require('axios')
// Global constants
const P2WDB_SERVER = 'http://localhost:5001/entry/write'
// const P2WDB_SERVER = 'https://p2wdb.fullstack.cash/entry/write'
class P2wdbAdapter {
constructor (localConfig = {}) {
// Encapsulate dependencies
this.axios = axios
}
async write (inputObj) {
try {
const { txid, signature, message, appId, data } = inputObj
// TODO: Input validation
const now = new Date()
const dataObj = {
appId,
data,
timestamp: now.toISOString(),
localTimeStamp: now.toLocaleString()
}
const bodyData = {
txid,
message,
signature,
data: JSON.stringify(dataObj)
}
const result = await this.axios.post(P2WDB_SERVER, bodyData)
// console.log(`Response from API: ${JSON.stringify(result.data, null, 2)}`)
return result.data.hash
} catch (err) {
console.error('Error in p2wdb.js/write()')
throw err
}
}
}
module.exports = P2wdbAdapter
+35
View File
@@ -0,0 +1,35 @@
/*
koa-passport is an authorization library used for different authentication schemes.
*/
const passport = require('koa-passport')
let _this
class Passport {
constructor () {
_this = this
this.passport = passport
}
async authUser (ctx) {
return new Promise((resolve, reject) => {
try {
if (!ctx) throw new Error('ctx is required')
_this.passport.authenticate('local', (err, user) => {
try {
if (err) throw err
resolve(user)
} catch (err) {
return reject(err)
}
})(ctx, null)
} catch (err) {
return reject(err)
}
})
}
}
module.exports = Passport
+231
View File
@@ -0,0 +1,231 @@
/*
Adapter library for working with a wallet.
*/
// Public npm libraries
const BchWallet = require('minimal-slp-wallet/index')
// Local libraries
const JsonFiles = require('./json-files')
const WALLET_FILE = `${__dirname.toString()}/../../wallet.json`
const PROOF_OF_BURN_QTY = 0.01
const P2WDB_TOKEN_ID =
'38e97c5d7d3585a2cbf3f9580c82ca33985f9cb0845d4dcce220cb709f9538b0'
class WalletAdapter {
constructor (localConfig = {}) {
// Encapsulate dependencies
this.jsonFiles = new JsonFiles()
this.WALLET_FILE = WALLET_FILE
this.BchWallet = BchWallet
}
// Open the wallet file, or create one if the file doesn't exist.
async openWallet () {
try {
let walletData
// Try to open the wallet.json file.
try {
// console.log('this.WALLET_FILE: ', this.WALLET_FILE)
walletData = await this.jsonFiles.readJSON(this.WALLET_FILE)
} catch (err) {
// Create a new wallet file if one does not already exist.
// console.log('caught: ', err)
console.warn('Wallet file not found. Creating new wallet.json file.')
// Create a new wallet.
// No-Update flag creates wallet without making any network calls.
const walletInstance = new this.BchWallet(undefined, { noUpdate: true })
// Wait for wallet to initialize.
await walletInstance.walletInfoPromise
walletData = walletInstance.walletInfo
// Add the nextAddress property
walletData.nextAddress = 1
// Write the wallet data to the JSON file.
await this.jsonFiles.writeJSON(walletData, this.WALLET_FILE)
}
// console.log('walletData: ', walletData)
return walletData
} catch (err) {
console.error('Error in openWallet()')
throw err
}
}
// Increments the 'nextAddress' property in the wallet file. This property
// indicates the HD index that should be used to generate a key pair for
// storing funds for Offers.
// This function opens the wallet file, increments the nextAddress property,
// then saves the change to the wallet file.
async incrementNextAddress () {
try {
const walletData = await this.openWallet()
// console.log('original walletdata: ', walletData)
walletData.nextAddress++
// console.log('walletData finish: ', walletData)
await this.jsonFiles.writeJSON(walletData, this.WALLET_FILE)
// Update the working instance of the wallet.
this.bchWallet.walletInfo.nextAddress++
// console.log('this.bchWallet.walletInfo: ', this.bchWallet.walletInfo)
return walletData.nextAddress
} catch (err) {
console.error('Error in incrementNextAddress()')
throw err
}
}
// This method returns an object that contains a private key WIF, public address,
// and the index of the HD wallet that the key pair was generated from.
// TODO: Allow input integer. If input is used, use that as the index. If no
// input is provided, then call incrementNextAddress().
async getKeyPair () {
try {
const hdIndex = await this.incrementNextAddress()
const mnemonic = this.bchWallet.walletInfo.mnemonic
// root seed buffer
const rootSeed = await this.bchWallet.bchjs.Mnemonic.toSeed(mnemonic)
const masterHDNode = this.bchWallet.bchjs.HDNode.fromSeed(rootSeed)
// HDNode of BIP44 account
// const account = this.bchWallet.bchjs.HDNode.derivePath(masterHDNode, "m/44'/245'/0'")
const childNode = masterHDNode.derivePath(`m/44'/245'/0'/0/${hdIndex}`)
const cashAddress = this.bchWallet.bchjs.HDNode.toCashAddress(childNode)
console.log('cashAddress: ', cashAddress)
const wif = this.bchWallet.bchjs.HDNode.toWIF(childNode)
const outObj = {
cashAddress,
wif,
hdIndex
}
return outObj
} catch (err) {
console.error('Error in getKeyPair()')
throw err
}
}
// Create an instance of minimal-slp-wallet. Use data in the wallet.json file,
// and pass the bch-js information to the minimal-slp-wallet library.
async instanceWallet (walletData, bchjs) {
try {
// TODO: Throw error if bch-js is not passed in.
// TODO: throw error if wallet data is not passed in.
const advancedConfig = {
restURL: bchjs.restURL,
apiToken: bchjs.apiToken
}
// Instantiate minimal-slp-wallet.
this.bchWallet = new this.BchWallet(walletData.mnemonic, advancedConfig)
// Wait for wallet to initialize.
await this.bchWallet.walletInfoPromise
return true
} catch (err) {
console.error('Error in instanceWallet()')
throw err
}
}
// Generate a cryptographic signature, required to write to the P2WDB.
async generateSignature (message) {
try {
// TODO: Add input validation for message.
const privKey = this.bchWallet.walletInfo.privateKey
// console.log('privKey: ', privKey)
// console.log('flags.data: ', flags.data)
const signature = this.bchWallet.bchjs.BitcoinCash.signMessageWithPrivKey(
privKey,
message
)
return signature
} catch (err) {
console.error('Error in generateSignature()')
throw err
}
}
// Burn enough PSF to generate a valide proof-of-burn for writing to the P2WDB.
async burnPsf () {
try {
// TODO: Throw error if this.bchWallet has not been instantiated.
// console.log('walletData: ', walletData)
// console.log(
// `walletData.utxos.utxoStore.slpUtxos: ${JSON.stringify(
// walletData.utxos.utxoStore.slpUtxos,
// null,
// 2,
// )}`,
// )
// Get token UTXOs held by the wallet.
const tokenUtxos = this.bchWallet.utxos.utxoStore.slpUtxos.type1.tokens
// Find a token UTXO that contains PSF with a quantity higher than needed
// to generate a proof-of-burn.
let tokenUtxo = {}
for (let i = 0; i < tokenUtxos.length; i++) {
const thisUtxo = tokenUtxos[i]
// If token ID matches.
if (thisUtxo.tokenId === P2WDB_TOKEN_ID) {
if (parseFloat(thisUtxo.tokenQty) >= PROOF_OF_BURN_QTY) {
tokenUtxo = thisUtxo
break
}
}
}
if (tokenUtxo.tokenId !== P2WDB_TOKEN_ID) {
throw new Error(
`Token UTXO of with ID of ${P2WDB_TOKEN_ID} and quantity greater than ${PROOF_OF_BURN_QTY} could not be found in wallet.`
)
}
const result = await this.bchWallet.burnTokens(
PROOF_OF_BURN_QTY,
P2WDB_TOKEN_ID
)
// console.log('walletData.burnTokens() result: ', result)
return result
// return {
// success: true,
// txid: 'fakeTxid',
// }
} catch (err) {
console.error('Error in burnPsf(): ', err)
throw err
}
}
}
module.exports = WalletAdapter
+66
View File
@@ -0,0 +1,66 @@
/*
The (Clean Architecture) Adapter for manageing a webhook connection to P2WDB.
*/
const config = require('../../config')
const axios = require('axios')
let _this
const APPID = 'swapTest555'
class WebHook {
constructor () {
_this = this
_this.config = config
_this.axios = axios
}
// REST petition to create a webhook in p2wdb-service
async createWebhook (url) {
try {
if (!url || typeof url !== 'string') {
throw new Error('url must be a string')
}
const endpoint = _this.config.webhookService
const obj = {
appId: APPID,
url
}
const result = await axios.post(endpoint, obj)
return result.data
} catch (err) {
console.log('Error in adapters/webhook/createWebHook()')
throw err
}
}
// REST petition to delete a webhook in p2wdb-service
async deleteWebhook (url) {
try {
if (!url || typeof url !== 'string') {
throw new Error('url must be a string')
}
const endpoint = _this.config.webhookService
const obj = {
appId: APPID,
url
}
const result = await axios.delete(endpoint, { data: obj })
return result.data
} catch (err) {
console.log('Error in adapters/webhook/deleteWebHook()')
throw err
}
}
}
module.exports = WebHook
+72
View File
@@ -0,0 +1,72 @@
/*
Instantiates and configures the Winston logging library. This utitlity library
can be called by other parts of the application to conveniently tap into the
logging library.
*/
'use strict'
const winston = require('winston')
require('winston-daily-rotate-file')
const config = require('../../config')
class Wlogger {
constructor (localConfig = {}) {
this.config = config
// Configure daily-rotation transport.
this.transport = new winston.transports.DailyRotateFile({
filename: `${__dirname.toString()}/../../logs/koa-${
this.config.env
}-%DATE%.log`,
datePattern: 'YYYY-MM-DD',
zippedArchive: false,
maxSize: '1m', // 1 megabyte
maxFiles: '5d', // 5 days
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
)
})
this.transport.on('rotate', this.notifyRotation)
// This controls what goes into the log FILES
this.wlogger = winston.createLogger({
level: 'verbose',
format: winston.format.json(),
transports: [
//
// - Write to all logs with level `info` and below to `combined.log`
// - Write all logs error (and below) to `error.log`.
//
// new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
// new winston.transports.File({ filename: 'logs/combined.log' })
this.transport
]
})
}
notifyRotation (oldFilename, newFilename) {
this.wlogger.info('Rotating log files')
}
outputToConsole () {
this.wlogger.add(
new winston.transports.Console({
format: winston.format.simple(),
level: 'info'
})
)
}
}
const logger = new Wlogger()
// Allow the logger to write to the console.
logger.outputToConsole()
const wlogger = logger.wlogger
module.exports = { wlogger, Wlogger }
+64
View File
@@ -0,0 +1,64 @@
/*
This is a top-level library that encapsulates all the additional Controllers.
The concept of Controllers comes from Clean Architecture:
https://troutsblog.com/blog/clean-architecture
*/
// Public npm libraries.
// Load the Clean Architecture Adapters library
const Adapters = require('../adapters')
// Load the JSON RPC Controller.
const JSONRPC = require('./json-rpc')
// Load the Clean Architecture Use Case libraries.
const UseCases = require('../use-cases')
// const useCases = new UseCases({ adapters })
// Load the REST API Controllers.
const RESTControllers = require('./rest-api')
class Controllers {
constructor (localConfig = {}) {
this.adapters = new Adapters()
this.useCases = new UseCases({ adapters: this.adapters })
}
async attachControllers (app) {
// Wait for any startup processes to complete for the Adapters libraries.
await this.adapters.start()
// Attach the REST controllers to the Koa app.
// this.attachRESTControllers(app)
this.attachRPCControllers()
}
// Top-level function for this library.
// Start the various Controllers and attach them to the app.
attachRESTControllers (app) {
const restControllers = new RESTControllers({
adapters: this.adapters,
useCases: this.useCases
})
// Attach the REST API Controllers associated with the boilerplate code to the Koa app.
restControllers.attachRESTControllers(app)
}
// Add the JSON RPC router to the ipfs-coord adapter.
attachRPCControllers () {
const jsonRpcController = new JSONRPC({
adapters: this.adapters,
useCases: this.useCases
})
// Attach the input of the JSON RPC router to the output of ipfs-coord.
this.adapters.ipfs.ipfsCoordAdapter.attachRPCRouter(
jsonRpcController.router
)
}
}
module.exports = Controllers
+46
View File
@@ -0,0 +1,46 @@
/*
This is the JSON RPC router for the users API
*/
// Public npm libraries
const jsonrpc = require('jsonrpc-lite')
// Local libraries
const config = require('../../../../config')
class AboutRPC {
constructor (localConfig) {
// Encapsulate dependencies
this.jsonrpc = jsonrpc
}
/**
* @api {JSON} /about About IPFS Node
* @apiPermission public
* @apiName About
* @apiGroup JSON About
*
* @apiExample Example usage:
* {"jsonrpc":"2.0","id":"555","method":"about"}
*
* @apiDescription
* This endpoint can be customized so that users can retrieve information about
* your IPFS node and Service Provider application. This is a great place to
* put a website URL, an IPFS hash, an other basic information.
*/
// This is the top-level router for this library.
// This is a bit different than other router libraries, because there is
// only one response, which is a string about this node.
async aboutRouter (rpcData) {
return {
success: true,
status: 200,
// message: aboutStr,
message: JSON.stringify(config.announceJsonLd),
endpoint: 'about'
}
}
}
module.exports = AboutRPC
+176
View File
@@ -0,0 +1,176 @@
/*
This is the JSON RPC router for the users API
*/
// Public npm libraries
const jsonrpc = require('jsonrpc-lite')
// Local libraries
// const AuthLib = require('../../lib/auth')
// const UserLib = require('../../../use-cases/user')
const { wlogger } = require('../../../adapters/wlogger')
const RateLimit = require('../rate-limit')
class AuthRPC {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating Auth JSON RPC Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating Auth JSON RPC Controller.'
)
}
// Encapsulate dependencies
// this.authLib = new AuthLib()
this.jsonrpc = jsonrpc
this.userLib = this.useCases.user
this.rateLimit = new RateLimit()
}
// Top-level router for this library. All other methods in this class are for
// a specific endpoint. This method routes incoming calls to one of those
// methods.
async authRouter (rpcData) {
let endpoint = 'unknown'
try {
// console.log('authRouter rpcData: ', rpcData)
endpoint = rpcData.payload.params.endpoint
// Route the call based on the requested endpoint.
switch (endpoint) {
case 'authUser':
await this.rateLimit.limiter(rpcData.from)
return await this.authUser(rpcData)
}
} catch (err) {
console.error('Error in AuthRPC/authRouter()')
// throw err
return {
success: false,
status: 500,
message: err.message,
endpoint
}
}
}
/**
* @api {JSON} /auth Get JWT Token
* @apiPermission public
* @apiName AuthUser
* @apiGroup JSON Auth
*
* @apiExample Example usage:
* {"jsonrpc":"2.0","id":"556","method":"auth","params":{ "endpoint": "authUser", "login": "test555@test.com", "password": "password"}}
*
* @apiParam {String} login Email(required).
* @apiParam {String} password Password(required).
* @apiParam {string} endpoint (required)
*
* @apiSuccess {Object} users User object
* @apiSuccess {ObjectId} users._id User id
* @apiSuccess {String} user.type User type (admin or user)
* @apiSuccess {String} users.name User name
* @apiSuccess {String} users.email User email
* @apiSuccess {String} token JWT.
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "jsonrpc": "2.0",
* "id": "556",
* "result": {
* "method": "auth",
* "reciever": "Qmc2uJhg7yrqaNaoTJRDkzrAyVe82e9JMFQcxrBUjbdXyC",
* "value": {
* "endpoint": "authUser",
* "userId": "607de52d426f3d3148b3a467",
* "userType": "user",
* "userName": "testy tester",
* "userEmail": "test555@test.com",
* "apiToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjYwN2RlNTJkNDI2ZjNkMzE0OGIzYTQ2NyIsImlhdCI6MTYxODg2NTcwM30.acGe5ZiBAAcbOcPQDIhvc3z0KjnuYZd1Y5pJJJC9mJQ",
* "status": 200,
* "success": true,
* "message": ""
* }
* }
*}
*
* @apiError UnprocessableEntity Missing required parameters
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 422 Unprocessable Entity
* {
* "jsonrpc": "2.0",
* "id": "556",
* "result": {
* "method": "auth",
* "reciever": "Qmc2uJhg7yrqaNaoTJRDkzrAyVe82e9JMFQcxrBUjbdXyC",
* "value": {
* "success": false,
* "status": 422,
* "message": "User not found",
* "endpoint": "authUser"
* }
* }
* }
*/
async authUser (rpcData) {
try {
// console.log('authUser rpcData: ', rpcData)
if (!rpcData.payload.params.login) {
throw new Error('login must be specified')
}
if (!rpcData.payload.params.password) {
throw new Error('password must be specified')
}
const login = rpcData.payload.params.login
const password = rpcData.payload.params.password
const user = await this.userLib.authUser(login, password)
// console.log('user: ', user)
const token = user.generateToken()
const response = {
endpoint: 'authUser',
userId: user._id,
userType: user.type,
userName: user.name,
userEmail: user.email,
apiToken: token,
status: 200,
success: true,
message: ''
}
return response
} catch (err) {
// console.error('Error in authUser()')
wlogger.error('Error in authUser(): ', err)
// throw err
// Return an error response
return {
success: false,
status: 422,
message: err.message,
endpoint: 'authUser'
}
}
}
}
module.exports = AuthRPC
+193
View File
@@ -0,0 +1,193 @@
/*
This is the parent class library for the RPC controller.
*/
// Public npm libraries
const jsonrpc = require('jsonrpc-lite')
// Local support libraries
const { wlogger } = require('../../adapters/wlogger')
const UserController = require('./users')
const AuthController = require('./auth')
const AboutController = require('./about')
let _this
class JSONRPC {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating JSON RPC Controllers.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating JSON RPC Controllers.'
)
}
// Encapsulate dependencies
this.ipfsCoord = this.adapters.ipfs.ipfsCoordAdapter.ipfsCoord
this.jsonrpc = jsonrpc
this.userController = new UserController(localConfig)
this.authController = new AuthController(localConfig)
this.aboutController = new AboutController()
// Cache to store IDs of processed JSON RPC commands. Used to prevent
// duplicate processing.
this.msgCache = []
this.MSG_CACHE_SIZE = 30
_this = this
}
// This method takes a raw string of data from IPFS, parses it, and determins
// which controller to route the instruction to.
async router (str, from) {
try {
// console.log('router str: ', str)
console.log('JSON RPC router recieved data from: ', from)
// Exit quietly if 'from' is not specified.
if (!from || typeof from !== 'string') {
wlogger.info(
'Warning: Can not send JSON RPC response. Can not determine which peer this message came from.'
)
return false
}
// Attempt to parse the incoming data as a JSON RPC string.
const parsedData = _this.jsonrpc.parse(str)
// wlogger.debug(`parsedData: ${JSON.stringify(parsedData, null, 2)}`)
// Exit quietly if the incoming string is an invalid JSON RPC string.
if (parsedData.type === 'invalid') {
wlogger.info('Rejecting invalid JSON RPC command.')
return false
}
// Check for duplicate entries with same 'id' value.
const alreadyProcessed = _this._checkIfAlreadyProcessed(parsedData)
if (alreadyProcessed) {
return false
} else {
// console.log(`parsedData: ${JSON.stringify(parsedData, null, 2)}`)
// This node will regularly ping known circuit relays with an /about
// JSON RPC call. These will be handled by ipfs-coord, but will percolate
// up to this library. Ignore these messages.
if (
parsedData.type.includes('success') &&
parsedData.payload.method === undefined
) {
return false
}
// Log the incoming JSON RPC command.
wlogger.info(
`JSON RPC received from ${from}, ID: ${parsedData.payload.id}, type: ${parsedData.type}, method: ${parsedData.payload.method}`
)
}
// Added the property "from" to the parsedData object;
// necessary for calculating rate limits (based on the IPFS ID).
parsedData.from = from
// Default return string
let retObj = _this.defaultResponse()
// Route the command to the appropriate route handler.
switch (parsedData.payload.method) {
case 'users':
retObj = await _this.userController.userRouter(parsedData)
break
case 'auth':
retObj = await _this.authController.authRouter(parsedData)
break
case 'about':
retObj = await _this.aboutController.aboutRouter(parsedData)
}
// console.log('retObj: ', retObj)
// Convert the returned object into a JSON RPC response string.
const retJson = _this.jsonrpc.success(parsedData.payload.id, {
method: parsedData.payload.method,
reciever: from,
value: retObj
})
const retStr = JSON.stringify(retJson, null, 2)
// console.log('retStr: ', retStr)
// Encrypt and publish the response to the originators private OrbitDB,
// if ipfs-coord has been initialized and the peers ID is registered.
// console.log('responding to JSON RPC command')
const thisNode = _this.ipfsCoord.thisNode
// console.log('thisNode: ', thisNode)
try {
await _this.ipfsCoord.useCases.peer.sendPrivateMessage(
from,
retStr,
thisNode
)
} catch (err) {
console.log('sendPrivateMessage() err: ', err)
}
// Return the response and originator. Useful for testing.
return { from, retStr }
} catch (err) {
// console.error('Error in rpc router(): ', err)
wlogger.error('Error in rpc router(): ', err)
// Do not throw error. This is a top-level function.
}
}
// Checks the ID of the JSON RPC call, to see if the message has already been
// processed. Returns true if the ID exists in the cache of processed messages.
// If the ID is new, the function adds it to the cache and return false.
_checkIfAlreadyProcessed (data) {
try {
// console.log('data: ', data)
const id = data.payload.id
// Check if the hash is in the array of already processed message.
const alreadyProcessed = this.msgCache.includes(id)
// Update the msgCache if this is a new message.
if (!alreadyProcessed) {
// Add the hash to the array.
this.msgCache.push(id)
// If the array is at its max size, then remove the oldest element.
if (this.msgCache.length > this.MSG_CACHE_SIZE) {
this.msgCache.shift()
}
}
return alreadyProcessed
} catch (err) {
console.error('Error in _checkIfAlreadyProcessed: ', err)
return true
}
}
// The default JSON RPC response if the incoming command could not be routed.
defaultResponse () {
const errorObj = {
success: false,
status: 422,
message: 'Input does not match routing rules.'
}
return errorObj
}
}
module.exports = JSONRPC
+83
View File
@@ -0,0 +1,83 @@
/*
Rate limit
*/
/* eslint no-useless-catch: 0 */
// Local libraries
const RateLimitLib = require('koa2-ratelimit').RateLimit
class RateLimit {
constructor (options) {
// Encapsulate dependencies
this.RateLimitLib = RateLimitLib
// Set default rate limit options.
this.defaultOptions = {
interval: { min: 1 },
max: 60,
onLimitReached: this.onLimitReached
}
// ctx obj
this.context = {
state: {
user: ''
},
request: {
ip: ''
},
user: '',
set: () => {}
}
// console.log(
// `this.defaultOptions: ${JSON.stringify(this.defaultOptions, null, 2)}`
// )
// console.log(`options: ${JSON.stringify(options, null, 2)}`)
// Set rate limit settings. Default values are overwritten if user passes
// in an options object.
this.rateLimitOptions = Object.assign({}, this.defaultOptions, options)
// console.log(
// `this.rateLimitOptions: ${JSON.stringify(this.rateLimitOptions, null, 2)}`
// )
this.rateLimit = this.RateLimitLib.middleware(this.rateLimitOptions)
}
// This function is called when the user hits their rate limits.
onLimitReached () {
try {
const error = new Error() // Establish provided options as the default options.
error.message = 'Too many requests, please try again later.'
error.status = 429
throw error
} catch (error) {
// console.log("Error in onLimitReached()", error)
throw error
}
}
// This is the middleware function called by the router.
async limiter (from) {
try {
if (!from || typeof from !== 'string') {
throw new Error('from must be a string')
}
// Set context.limiter
// This overrides the default koa behavior and adapts the rate limiter
// to work with the JSON RPC over IPFS.
this.context.state.user = from
this.context.request.ip = from
this.context.user = from
await this.rateLimit(this.context, () => {})
return true
} catch (error) {
console.error('Error in rate-limit.js/limiter()')
throw error
}
}
}
module.exports = RateLimit
+536
View File
@@ -0,0 +1,536 @@
/*
This is the JSON RPC router for the users API
*/
// Public npm libraries
const jsonrpc = require('jsonrpc-lite')
// Local libraries
// const UserLib = require('../../../use-cases/user')
const Validators = require('../validators')
const RateLimit = require('../rate-limit')
class UserRPC {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating User JSON RPC Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating User JSON RPC Controller.'
)
}
// Encapsulate dependencies
this.userLib = this.useCases.user
this.jsonrpc = jsonrpc
this.validators = new Validators(localConfig)
this.rateLimit = new RateLimit()
}
// Top-level router for this library. All other methods in this class are for
// a specific endpoint. This method routes incoming calls to one of those
// methods.
async userRouter (rpcData) {
let endpoint = 'unknown'
try {
// console.log('userRouter rpcData: ', rpcData)
endpoint = rpcData.payload.params.endpoint
let user
// Route the call based on the value of the method property.
switch (endpoint) {
case 'createUser':
await this.rateLimit.limiter(rpcData.from)
return await this.createUser(rpcData)
case 'getAllUsers':
await this.validators.ensureUser(rpcData)
await this.rateLimit.limiter(rpcData.from)
return await this.getAll(rpcData)
case 'getUser':
user = await this.validators.ensureUser(rpcData)
await this.rateLimit.limiter(rpcData.from)
return await this.getUser(rpcData, user)
case 'updateUser':
user = await this.validators.ensureTargetUserOrAdmin(rpcData)
await this.rateLimit.limiter(rpcData.from)
return await this.updateUser(rpcData, user)
case 'deleteUser':
user = await this.validators.ensureTargetUserOrAdmin(rpcData)
await this.rateLimit.limiter(rpcData.from)
return await this.deleteUser(rpcData, user)
}
} catch (err) {
console.error('Error in UsersRPC/rpcRouter()')
// throw err
return {
success: false,
status: err.status || 500,
message: err.message,
endpoint
}
}
}
/**
* @api {JSON} /users Create a new user
* @apiPermission public
* @apiName CreateUser
* @apiGroup JSON Users
*
* @apiExample Example usage:
* {"jsonrpc":"2.0","id":"555","method":"users","params":{ "endpoint": "createUser", "email": "test555@test.com", "name": "testy tester", "password": "password"}}
*
* @apiParam {String} email Email(required).
* @apiParam {String} password Password(required).
* @apiParam {String} name name or handle(optional).
* @apiParam {string} endpoint (required)
* @apiSuccess {Object} users User object
* @apiSuccess {ObjectId} users._id User id
* @apiSuccess {String} user.type User type (admin or user)
* @apiSuccess {String} users.name User name
* @apiSuccess {String} users.username User username
* @apiSuccess {String} users.email User email
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "jsonrpc": "2.0",
* "id": "555",
* "result": {
* "method": "users",
* "reciever": "Qmc2uJhg7yrqaNaoTJRDkzrAyVe82e9JMFQcxrBUjbdXyC",
* "value": {
* "userData": {
* "type": "user",
* "_id": "607dd3e6426f3d3148b3a466",
* "email": "test555@test.com",
* "name": "testy tester",
* "__v": 0
* },
* "token": "eyJhbGciOiJIUzI1NiIs1nR5cCI6IkpXVTJ9.eyJpZCI6IjYwN2RkM2U2NDI2ZjNkMzE0OGIzYTQ2NiIsImlhdCI6MTYxODg1ODk4Mn0.in4vzxDqqyCd7LpuhG3xlXeBqrJ5bp9GJPwhaoVzldI",
* "endpoint": "createUser",
* "success": true,
* "status": 200,
* "message": ""
* }
* }
*}
*
* @apiError UnprocessableEntity Missing required parameters
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 422 Unprocessable Entity
* {
* "jsonrpc": "2.0",
* "id": "123",
* "result": {
* "method": "users",
* "reciever": "Qmc2uJhg7yrqaNaoTJRDkzrAyVe82e9JMFQcxrBUjbdXyC",
* "value": {
* "success": false,
* "status": 422,
* "message": "Unprocessable Entity",
* "endpoint": "getUser"
* }
* }
*}
*/
async createUser (rpcData) {
try {
// console.log('createUser rpcData: ', rpcData)
const retObj = await this.userLib.createUser(rpcData.payload.params)
// Add generic JSON RPC properties that every entry gets.
retObj.endpoint = 'createUser'
retObj.success = true
retObj.status = 200
retObj.message = ''
return retObj
} catch (err) {
// console.error('Error in createUser()')
// throw err
// Return an error response
return {
success: false,
status: 422,
message: err.message,
endpoint: 'createUser'
}
}
}
/**
* @api {JSON} /users Get all users
* @apiPermission public
* @apiName GetAllUsers
* @apiGroup JSON Users
*
* @apiExample Example usage:
* {"jsonrpc":"2.0","id":"555","method":"users","params":{ "endpoint": "getAllUsers", "apiToken": "<JWT>"}}
*
* @apiParam {String} apiToken (required)
* @apiParam {string} endpoint (required)
*
* @apiSuccess {Object[]} users Array of user objects
* @apiSuccess {ObjectId} users._id User id
* @apiSuccess {String} user.type User type (admin or user)
* @apiSuccess {String} users.name User name
* @apiSuccess {String} users.username User username
* @apiSuccess {String} users.email User email
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* result": {
* "method": "users",
* "reciever": "Qmc2uJhg7yrqaNaoTJRDkzrAyVe82e9JMFQcxrBUjbdXyC",
* "value": {
* "users": [
* {
* "type": "user",
* "_id": "6070bc6da931e73d4d9e108d",
* "email": "test678@test.com",
* "name": "testy tester",
* "__v": 0
* }
* ],
* "endpoint": "getAllUsers",
* "success": true,
* "status": 200,
* "message": ""
* }
* }
*
* @apiError UnprocessableEntity Missing required parameters
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 422 Unprocessable Entity
* {
* "jsonrpc": "2.0",
* "id": "123",
* "result": {
* "method": "users",
* "reciever": "Qmc2uJhg7yrqaNaoTJRDkzrAyVe82e9JMFQcxrBUjbdXyC",
* "value": {
* "success": false,
* "status": 422,
* "message": "",
* "endpoint": "getAllUsers"
* }
* }
*/
// Get all Users.
async getAll () {
try {
const users = await this.userLib.getAllUsers()
return {
users,
endpoint: 'getAllUsers',
success: true,
status: 200,
message: ''
}
} catch (err) {
// console.error('Error in getAll()')
// throw err
// Return an error response
return {
success: false,
status: 422,
message: err.message,
endpoint: 'getAllUsers'
}
}
}
/**
* @api {JSON} /users Get a user
* @apiPermission public
* @apiName GetAUser
* @apiGroup JSON Users
*
* @apiExample Example usage:
* {"jsonrpc":"2.0","id":"123","method":"users","params":{ "endpoint": "getUser", "apiToken": "<JWT>", "userId": "<_id>"}}
*
* @apiParam {String} apiToken (required)
* @apiParam {String} userId (required)
* @apiParam {string} endpoint (required)
*
* @apiSuccess {Object[]} users Array of user objects
* @apiSuccess {ObjectId} users._id User id
* @apiSuccess {String} user.type User type (admin or user)
* @apiSuccess {String} users.name User name
* @apiSuccess {String} users.username User username
* @apiSuccess {String} users.email User email
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* result": {
* "jsonrpc": "2.0",
* "id": "123",
* "result": {
* "method": "users",
* "reciever": "Qmc2uJhg7yrqaNaoTJRDkzrAyVe82e9JMFQcxrBUjbdXyC",
* "value": {
* "user": {
* "type": "user",
* "_id": "607dd3e6426f3d3148b3a466",
* "email": "test555@test.com",
* "name": "testy tester",
* "__v": 0
* },
* "endpoint": "getUser",
* "success": true,
* "status": 200,
* "message": ""
* }
*}
*
*
* @apiError UnprocessableEntity Missing required parameters
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 422 Unprocessable Entity
* {
* "jsonrpc": "2.0",
* "id": "123",
* "result": {
* "method": "users",
* "reciever": "Qmc2uJhg7yrqaNaoTJRDkzrAyVe82e9JMFQcxrBUjbdXyC",
* "value": {
* "success": false,
* "status": 422,
* "message": "Unprocessable Entity",
* "endpoint": "getUser"
* }
* }
*
*/
// Get a specific user.
async getUser (rpcData, userModel) {
try {
// console.log('getUser rpcData: ', rpcData)
// Throw error if rpcData does not include 'userId' property for target user.
const userId = rpcData.payload.params.userId
const user = await this.userLib.getUser({ id: userId })
return {
user,
endpoint: 'getUser',
success: true,
status: 200,
message: ''
}
} catch (err) {
// console.error('Error in getUser()')
// throw err
// Return an error response
return {
success: false,
status: 422,
message: err.message,
endpoint: 'getUser'
}
}
}
/**
* @api {JSON} /users Update a user
* @apiPermission public
* @apiName UpdateAUser
* @apiGroup JSON Users
*
* @apiExample Example usage:
* {"jsonrpc":"2.0","id":"123","method":"users","params":{ "endpoint": "updateUser", "apiToken": "<JWT>", "userId": "<_id>", "name": "test999"}}
*
* @apiParam {String} apiToken (required)
* @apiParam {String} userId (required)
* @apiParam {string} endpoint (required)
* @apiParam {String} email Email(Optional).
* @apiParam {String} password Password(Optional).
* @apiParam {String} name name or handle(Optional).
*
*
* @apiSuccess {Object} users User object
* @apiSuccess {ObjectId} users._id User id
* @apiSuccess {String} user.type User type (admin or user)
* @apiSuccess {String} users.name Updated name
* @apiSuccess {String} users.username Updated username
* @apiSuccess {String} users.email Updated email
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* result": {
* "jsonrpc": "2.0",
* "id": "123",
* "result": {
* "method": "users",
* "reciever": "Qmc2uJhg7yrqaNaoTJRDkzrAyVe82e9JMFQcxrBUjbdXyC",
* "value": {
* "user": {
* "type": "user",
* "_id": "607dd3e6426f3d3148b3a466",
* "email": "test555@test.com",
* "name": "test001",
* "__v": 0
* },
* "endpoint": "updateUser",
* "success": true,
* "status": 200,
* "message": ""
* }
* }
*}
*
* @apiError UnprocessableEntity Missing required parameters
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 422 Unprocessable Entity
* {
* "jsonrpc": "2.0",
* "id": "123",
* "result": {
* "method": "users",
* "reciever": "Qmc2uJhg7yrqaNaoTJRDkzrAyVe82e9JMFQcxrBUjbdXyC",
* "value": {
* "success": false,
* "status": 422,
* "message": "Unprocessable Entity.",
* "endpoint": "updateUser"
* }
* }
* }
*
*/
async updateUser (rpcData, userModel) {
try {
// console.log('updateUser rpcData: ', rpcData)
const newData = rpcData.payload.params
const user = await this.userLib.updateUser(userModel, newData)
return {
user,
endpoint: 'updateUser',
success: true,
status: 200,
message: ''
}
} catch (err) {
// console.log('updateUser err: ', err)
// Return an error response
return {
success: false,
status: 422,
message: err.message,
endpoint: 'updateUser'
}
}
}
/**
* @api {JSON} /users Delete a user
* @apiPermission public
* @apiName DeleteAUser
* @apiGroup JSON Users
*
*
* @apiExample Example usage:
* {"jsonrpc":"2.0","id":"123","method":"users","params":{ "endpoint": "deleteUser", "userId": "<_id>", "apiToken": "<JWT>"}}
*
* @apiParam {String} apiToken (required)
* @apiParam {String} userId (required)
* @apiParam {string} endpoint (required)
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* result": {
* "jsonrpc": "2.0",
* "id": "123",
* "result": {
* "method": "users",
* "reciever": "Qmc2uJhg7yrqaNaoTJRDkzrAyVe82e9JMFQcxrBUjbdXyC",
* "value": {
* "endpoint": "deleteUser",
* "success": true,
* "status": 200,
* "message": ""
* }
* }
*}
*
* @apiError UnprocessableEntity Missing required parameters
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 422 Unprocessable Entity
* {
* "jsonrpc": "2.0",
* "id": "123",
* "result": {
* "method": "users",
* "reciever": "Qmc2uJhg7yrqaNaoTJRDkzrAyVe82e9JMFQcxrBUjbdXyC",
* "value": {
* "success": false,
* "status": 422,
* "message": "Unprocessable Entity",
* "endpoint": "deleteUser"
* }
* }
*}
*
*
*/
async deleteUser (rpcData, userModel) {
try {
// console.log('deleteUser rpcData: ', rpcData)
await this.userLib.deleteUser(userModel)
const retObj = {
endpoint: 'deleteUser',
success: true,
status: 200,
message: ''
}
return retObj
} catch (err) {
// console.error('Error in deleteUser()')
// throw err
// Return an error response
return {
success: false,
status: 422,
message: err.message,
endpoint: 'deleteUser'
}
}
}
// TODO create deleteUser()
}
module.exports = UserRPC
+96
View File
@@ -0,0 +1,96 @@
/*
Validators for the JSON RPC
*/
/* eslint no-useless-catch: 0 */
// Public npm libraries
const jwt = require('jsonwebtoken')
// Local libraries
const config = require('../../../config')
// const UserModel = require('../../adapters/localdb/models/users')
class Validators {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating JSON RPC Validators library.'
)
}
// Encapsulate dependencies
this.config = config
this.jwt = jwt
this.UserModel = this.adapters.localdb.Users
}
// Returns if user passes a valid JWT token that resolves to a valid user.
// Otherwise it throws an error.
async ensureUser (rpcData) {
try {
// console.log('rpcData: ', rpcData)
const apiToken = rpcData.payload.params.apiToken
if (!apiToken) throw new Error('apiToken JWT required as a parameter')
const decoded = this.jwt.verify(apiToken, this.config.token)
const user = await this.UserModel.findById(decoded.id, '-password')
if (!user) throw new Error('User not found!')
return user
} catch (err) {
// console.error('Error in ensureUser()')
throw err
}
}
// This middleware ensures that the :id used in the API endpoint matches the
// the ID used in the JWT, or failing that, the ID used in the JWT matches
// an Admin user. This prevents situations like users updating other users
// profiles or non-admins deleting users.
async ensureTargetUserOrAdmin (rpcData) {
try {
// console.log('rpcData: ', rpcData)
// Ensure the JWT is passed in.
const apiToken = rpcData.payload.params.apiToken
if (!apiToken) throw new Error('apiToken JWT required as a parameter')
// Ensure a target user ID is provided.
const targetUserId = rpcData.payload.params.userId
if (!targetUserId) throw new Error('userId must be specified')
// Decode the JWT token.
const decoded = this.jwt.verify(apiToken, this.config.token)
// Get the user described by the JWT token.
const user = await this.UserModel.findById(decoded.id, '-password')
if (!user) throw new Error('User not found!')
// If this current user is an admin, then quietly exit.
if (user.type === 'admin') return true
// Throw an error if the JWT token does not match the targeted user.
if (user._id.toString() !== targetUserId) {
throw new Error('User is neither admin nor target user.')
}
// Get the user model for the targeted User
const targetedUser = await this.UserModel.findById(
targetUserId,
'-password'
)
// Return the user model.
return targetedUser
} catch (error) {
// console.error('Error in ensureUser()')
throw error
}
}
}
module.exports = Validators
@@ -0,0 +1,99 @@
const Passport = require('../../../adapters/passport')
const passport = new Passport()
let _this
class AuthRESTController {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating Auth REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating Auth REST Controller.'
)
}
_this = this
this.passport = passport
}
/**
* @apiDefine TokenError
* @apiError Unauthorized Invalid JWT token
*
* @apiErrorExample {json} Unauthorized-Error:
* HTTP/1.1 401 Unauthorized
* {
* "status": 401,
* "error": "Unauthorized"
* }
*/
/**
* @api {post} /auth Authenticate user
* @apiName AuthUser
* @apiGroup Auth
*
* @apiParam {String} username User username.
* @apiParam {String} password User password.
*
* @apiExample Example usage:
* curl -H "Content-Type: application/json" -X POST -d '{ "username": "johndoe@gmail.com", "password": "foo" }' localhost:5000/auth
*
* @apiSuccess {Object} user User object
* @apiSuccess {ObjectId} user._id User id
* @apiSuccess {String} user.name User name
* @apiSuccess {String} user.username User username
* @apiSuccess {String} token Encoded JWT
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "user": {
* "_id": "56bd1da600a526986cf65c80"
* "username": "johndoe"
* },
* "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ"
* }
*
* @apiError Unauthorized Incorrect credentials
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 401 Unauthorized
* {
* "status": 401,
* "error": "Unauthorized"
* }
*/
async authUser (ctx, next) {
try {
// Retrieve the user from the database after they've proven the correct
// password.
const user = await _this.passport.authUser(ctx, next)
if (!user) {
ctx.throw(401)
}
const token = user.generateToken()
const response = user.toJSON()
delete response.password
ctx.body = {
token,
user: response
}
} catch (err) {
ctx.throw(401)
}
}
}
module.exports = AuthRESTController
+51
View File
@@ -0,0 +1,51 @@
/*
REST API library for auth route.
*/
// Public npm libraries.
const Router = require('koa-router')
// Local libraries.
const AuthRESTController = require('./controller')
class AuthRouter {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating PostEntry REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating PostEntry REST Controller.'
)
}
// Encapsulate dependencies.
this.authRESTController = new AuthRESTController(localConfig)
// Instantiate the router and set the base route.
const baseUrl = '/auth'
this.router = new Router({ prefix: baseUrl })
}
attach (app) {
if (!app) {
throw new Error(
'Must pass app object when attached REST API controllers.'
)
}
// Define the routes and attach the controller.
this.router.post('/', this.authRESTController.authUser)
// Attach the Controller routes to the Koa app.
app.use(this.router.routes())
app.use(this.router.allowedMethods())
}
}
module.exports = AuthRouter
@@ -0,0 +1,77 @@
/*
Controller for the /contact REST API endpoints.
*/
/* eslint-disable no-useless-escape */
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
const ContactLib = require('../../../adapters/contact')
const contactLib = new ContactLib()
let _this
class ContactController {
constructor () {
_this = this
_this.contactLib = contactLib
}
/**
* @api {post} /contact/email Send Email
* @apiName SendMail
* @apiGroup Contact
*
* @apiExample Example usage:
* curl -H "Content-Type: application/json" -X POST -d '{ "obj": { "email": "email@format.com", "formMessage": "a message" } }' localhost:5001/contact/email
*
* @apiParam {Object} obj object (required)
* @apiParam {String} obj.email Sender Email.
* @apiParam {String} obj.formMessage Message.
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
*
* success:true
*
* }
*
* @apiError UnprocessableEntity Missing required parameters
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 422 Unprocessable Entity
* {
* "status": 422,
* "error": "Unprocessable Entity"
* }
*/
async email (ctx) {
try {
const data = ctx.request.body
const emailObj = data.obj
await _this.contactLib.sendEmail(emailObj)
ctx.body = {
success: true
}
} catch (err) {
_this.handleError(ctx, err)
}
}
// DRY error handler
handleError (ctx, err) {
// If an HTTP status is specified by the buisiness logic, use that.
if (err.status) {
if (err.message) {
ctx.throw(err.status, err.message)
} else {
ctx.throw(err.status)
}
} else {
// By default use a 422 error if the HTTP status is not specified.
ctx.throw(422, err.message)
}
}
}
module.exports = ContactController
+56
View File
@@ -0,0 +1,56 @@
/*
REST API library for /contact route.
*/
// Public npm libraries.
const Router = require('koa-router')
// Local libraries.
const ContactRESTControllerLib = require('./controller')
class ContactRouter {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating Contact REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating Contact REST Controller.'
)
}
const dependencies = {
adapters: this.adapters,
useCases: this.useCases
}
// Encapsulate dependencies.
this.contactRESTController = new ContactRESTControllerLib(dependencies)
// Instantiate the router and set the base route.
const baseUrl = '/contact'
this.router = new Router({ prefix: baseUrl })
}
attach (app) {
if (!app) {
throw new Error(
'Must pass app object when attaching REST API controllers.'
)
}
// Define the routes and attach the controller.
this.router.post('/email', this.contactRESTController.email)
// Attach the Controller routes to the Koa app.
app.use(this.router.routes())
app.use(this.router.allowedMethods())
}
}
module.exports = ContactRouter
@@ -0,0 +1,67 @@
/*
REST API Controller library for the /entry route
*/
// const { wlogger } = require('../../../adapters/wlogger')
let _this
class EntryRESTControllerLib {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating /entry REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating /entry REST Controller.'
)
}
// Encapsulate dependencies
this.EntryModel = this.adapters.localdb.Entry
// this.userUseCases = this.useCases.user
_this = this
}
// No api-doc documentation because this wont be a public endpoint
async createEntry (ctx) {
try {
console.log('body: ', ctx.request.body)
const entryObj = ctx.request.body.entry
const entry = await _this.useCases.entry.createEntry(entryObj)
ctx.body = { entry }
} catch (err) {
// console.log(`err.message: ${err.message}`)
// console.log('err: ', err)
// ctx.throw(422, err.message)
_this.handleError(ctx, err)
}
}
// DRY error handler
handleError (ctx, err) {
console.log('err', err.message)
// If an HTTP status is specified by the buisiness logic, use that.
if (err.status) {
if (err.message) {
ctx.throw(err.status, err.message)
} else {
ctx.throw(err.status)
}
} else {
// By default use a 422 error if the HTTP status is not specified.
ctx.throw(422, err.message)
}
}
}
module.exports = EntryRESTControllerLib
+60
View File
@@ -0,0 +1,60 @@
/*
REST API library for /entry route.
*/
// Public npm libraries.
const Router = require('koa-router')
// Local libraries.
const EntryRESTControllerLib = require('./controller')
let _this
class UserRouter {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating /entry REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating /entry REST Controller.'
)
}
const dependencies = {
adapters: this.adapters,
useCases: this.useCases
}
// Encapsulate dependencies.
this.entryRESTController = new EntryRESTControllerLib(dependencies)
// Instantiate the router and set the base route.
const baseUrl = '/entry'
this.router = new Router({ prefix: baseUrl })
_this = this
}
attach (app) {
if (!app) {
throw new Error(
'Must pass app object when attaching REST API controllers.'
)
}
// Define the routes and attach the controller.
this.router.post('/', _this.entryRESTController.createEntry)
// Attach the Controller routes to the Koa app.
app.use(_this.router.routes())
app.use(_this.router.allowedMethods())
}
}
module.exports = UserRouter
+71
View File
@@ -0,0 +1,71 @@
/*
This index file for the Clean Architecture Controllers loads dependencies,
creates instances, and attaches the controller to REST API endpoints for
Koa.
*/
// Public npm libraries.
// Load the REST API Controllers.
const AuthRESTController = require('./auth')
const UserRouter = require('./users')
const ContactRESTController = require('./contact')
const LogsRESTController = require('./logs')
const EntryRouter = require('./entry')
const OfferRouter = require('./offer')
const OrderRouter = require('./order')
class RESTControllers {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating REST Controller libraries.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating REST Controller libraries.'
)
}
// console.log('Controllers localConfig: ', localConfig)
}
attachRESTControllers (app) {
const dependencies = {
adapters: this.adapters,
useCases: this.useCases
}
// Attach the REST API Controllers associated with the /auth route
const authRESTController = new AuthRESTController(dependencies)
authRESTController.attach(app)
// Attach the REST API Controllers associated with the /user route
const userRouter = new UserRouter(dependencies)
userRouter.attach(app)
// Attach the REST API Controllers associated with the /contact route
const contactRESTController = new ContactRESTController(dependencies)
contactRESTController.attach(app)
// Attach the REST API Controllers associated with the /logs route
const logsRESTController = new LogsRESTController(dependencies)
logsRESTController.attach(app)
// Attach the REST API Controllers associated with the /entry route
const entryRouter = new EntryRouter(dependencies)
entryRouter.attach(app)
const offerRouter = new OfferRouter(dependencies)
offerRouter.attach(app)
const orderRouter = new OrderRouter(dependencies)
orderRouter.attach(app)
}
}
module.exports = RESTControllers
@@ -0,0 +1,65 @@
const LogsApiLib = require('../../../adapters/logapi')
const logsApiLib = new LogsApiLib()
let _this
class LogsApi {
constructor () {
_this = this
_this.logsApiLib = logsApiLib
}
/**
* @api {post} /logapi Parse and return the log files.
* @apiPermission public
* @apiName LogApi
* @apiGroup Logs
*
* @apiExample Example usage:
* curl -H "Content-Type: application/json" -X POST -d '{ "password": "secretpasas" }' localhost:5000/logapi
*
* @apiParam {String} password Password (required)
*
* @apiSuccess {Array} users User object
* @apiSuccess {ObjectId} users._id User id
* @apiSuccess {String} user.type User type (admin or user)
* @apiSuccess {String} users.name User name
* @apiSuccess {String} users.username User username
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "user": {
* "_id": "56bd1da600a526986cf65c80"
* "name": "John Doe"
* "username": "johndoe"
* }
* }
*
* @apiError UnprocessableEntity Missing required parameters
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 422 Unprocessable Entity
* {
* "status": 422,
* "error": "Unprocessable Entity"
* }
*/
async getLogs (ctx) {
try {
// console.log('entering getLogs()')
// Get the user-provided password.
const password = ctx.request.body.password
const result = await _this.logsApiLib.getLogs(password)
ctx.body = result
} catch (err) {
if (err && err.message) {
ctx.throw(422, err.message)
} else {
ctx.throw(500, 'Unhandled error')
}
}
}
}
module.exports = LogsApi
+55
View File
@@ -0,0 +1,55 @@
/*
REST API library for /logs route.
*/
// Public npm libraries.
const Router = require('koa-router')
// Local libraries.
const LogsRESTControllerLib = require('./controller')
class LogsRouter {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating Logs REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating Logs REST Controller.'
)
}
const dependencies = {
adapters: this.adapters,
useCases: this.useCases
}
this.logsRESTController = new LogsRESTControllerLib(dependencies)
// Instantiate the router and set the base route.
const baseUrl = '/logs'
this.router = new Router({ prefix: baseUrl })
}
attach (app) {
if (!app) {
throw new Error(
'Must pass app object when attaching REST API controllers.'
)
}
// Define the routes and attach the controller.
this.router.post('/', this.logsRESTController.getLogs)
// Attach the Controller routes to the Koa app.
app.use(this.router.routes())
app.use(this.router.allowedMethods())
}
}
module.exports = LogsRouter
@@ -0,0 +1,11 @@
module.exports = function errorMiddleware () {
return async (ctx, next) => {
try {
await next()
} catch (err) {
ctx.status = err.status || 500
ctx.body = err.message
ctx.app.emit('error', err, ctx)
}
}
}
@@ -0,0 +1,169 @@
/*
REST API validator middleware.
*/
const User = require('../../../adapters/localdb/models/users')
const config = require('../../../../config')
const jwt = require('jsonwebtoken')
const { wlogger } = require('../../../adapters/wlogger')
let _this
class Validators {
constructor () {
this.User = User
this.jwt = jwt
this.config = config
_this = this
}
async ensureUser (ctx, next) {
try {
// console.log(`getToken: ${typeof (getToken)}`)
const token = _this.getToken(ctx)
if (!token) {
// console.log(`Err: Token not provided.`)
ctx.throw(401)
}
let decoded = null
try {
// console.log(`token: ${JSON.stringify(token, null, 2)}`)
// console.log(`config: ${JSON.stringify(config, null, 2)}`)
decoded = _this.jwt.verify(token, config.token)
} catch (err) {
// console.log(`Err: Token could not be decoded: ${err}`)
ctx.throw(401)
}
ctx.state.user = await _this.User.findById(decoded.id, '-password')
if (!ctx.state.user) {
// console.log(`Err: Could not find user.`)
ctx.throw(401)
}
// return next()
return true
} catch (error) {
ctx.throw(401)
}
}
// This funciton is almost identical to ensureUser, except at the end, it verifies
// that the 'type' associated with the user equals 'admin'.
async ensureAdmin (ctx, next) {
try {
// console.log(`getToken: ${typeof (getToken)}`)
const token = _this.getToken(ctx)
if (!token) {
// console.log(`Err: Token not provided.`)
ctx.throw(401)
}
let decoded = null
try {
// console.log(`token: ${JSON.stringify(token, null, 2)}`)
// console.log(`config: ${JSON.stringify(config, null, 2)}`)
decoded = _this.jwt.verify(token, config.token)
} catch (err) {
// console.log(`Err: Token could not be decoded: ${err}`)
ctx.throw(401)
}
ctx.state.user = await _this.User.findById(decoded.id, '-password')
if (!ctx.state.user) {
// console.log(`Err: Could not find user.`)
ctx.throw(401)
}
if (ctx.state.user.type !== 'admin') {
ctx.throw(401, 'not admin')
}
// return next()
return true
} catch (error) {
ctx.throw(401, error.message)
}
}
// This middleware ensures that the :id used in the API endpoint matches the
// the ID used in the JWT, or failing that, the ID used in the JWT matches
// an Admin user. This prevents situations like users updating other users
// profiles or non-admins deleting users.
async ensureTargetUserOrAdmin (ctx, next) {
try {
// console.log(`getToken: ${typeof (getToken)}`)
const token = _this.getToken(ctx)
if (!token) {
// console.log(`Err: Token not provided.`)
ctx.throw(401)
}
// The user ID targeted in this API call.
const targetId = ctx.params.id
// console.log(`targetId: ${JSON.stringify(targetId, null, 2)}`)
let decoded = null
try {
// console.log(`token: ${JSON.stringify(token, null, 2)}`)
// console.log(`config: ${JSON.stringify(config, null, 2)}`)
decoded = _this.jwt.verify(token, config.token)
} catch (err) {
// console.log(`Err: Token could not be decoded: ${err}`)
ctx.throw(401)
}
ctx.state.user = await _this.User.findById(decoded.id, '-password')
if (!ctx.state.user) {
// console.log(`Err: Could not find user.`)
ctx.throw(401)
}
// console.log('ctx.state.user: ', ctx.state.user)
// console.log(`ctx.state.user: ${JSON.stringify(ctx.state.user, null, 2)}`)
// Ensure the calling user and the target user are the same.
if (ctx.state.user._id.toString() !== targetId.toString()) {
wlogger.verbose(
`Calling user and target user do not match! Calling user: ${ctx.state.user._id}, Target user: ${targetId}`
)
// If they don't match, then the calling user better be an admin.
if (ctx.state.user.type !== 'admin') {
ctx.throw(401, 'not admin')
} else {
wlogger.verbose("It's ok. The user is an admin.")
}
}
// return next()
return true
} catch (error) {
ctx.throw(401, error.message)
}
}
getToken (ctx) {
const header = ctx.request.header.authorization
if (!header) {
return null
}
const parts = header.split(' ')
if (parts.length !== 2) {
return null
}
const scheme = parts[0]
const token = parts[1]
if (/^Bearer$/i.test(scheme)) {
return token
}
return null
}
}
module.exports = Validators
@@ -0,0 +1,67 @@
/*
REST API Controller library for the /offer route
*/
// const { wlogger } = require('../../../adapters/wlogger')
let _this
class OfferRESTControllerLib {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating /offer REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating /offer REST Controller.'
)
}
// Encapsulate dependencies
this.OfferModel = this.adapters.localdb.Offer
// this.userUseCases = this.useCases.user
_this = this
}
// No api-doc documentation because this wont be a public endpoint
async createOffer (ctx) {
try {
// console.log('body: ', ctx.request.body)
const offerObj = ctx.request.body.offer
const hash = await _this.useCases.offer.createOffer(offerObj)
ctx.body = { hash }
} catch (err) {
// console.log(`err.message: ${err.message}`)
// console.log('err: ', err)
// ctx.throw(422, err.message)
_this.handleError(ctx, err)
}
}
// DRY error handler
handleError (ctx, err) {
console.log('err', err.message)
// If an HTTP status is specified by the buisiness logic, use that.
if (err.status) {
if (err.message) {
ctx.throw(err.status, err.message)
} else {
ctx.throw(err.status)
}
} else {
// By default use a 422 error if the HTTP status is not specified.
ctx.throw(422, err.message)
}
}
}
module.exports = OfferRESTControllerLib
+60
View File
@@ -0,0 +1,60 @@
/*
REST API library for /offer route.
*/
// Public npm libraries.
const Router = require('koa-router')
// Local libraries.
const OfferRESTControllerLib = require('./controller')
let _this
class OfferRouter {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating /offer REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating /offer REST Controller.'
)
}
const dependencies = {
adapters: this.adapters,
useCases: this.useCases
}
// Encapsulate dependencies.
this.offerRESTController = new OfferRESTControllerLib(dependencies)
// Instantiate the router and set the base route.
const baseUrl = '/offer'
this.router = new Router({ prefix: baseUrl })
_this = this
}
attach (app) {
if (!app) {
throw new Error(
'Must pass app object when attached REST API controllers.'
)
}
// Define the routes and attach the controller.
this.router.post('/', _this.offerRESTController.createOffer)
// Attach the Controller routes to the Koa app.
app.use(_this.router.routes())
app.use(_this.router.allowedMethods())
}
}
module.exports = OfferRouter
@@ -0,0 +1,69 @@
/*
REST API Controller library for the /offer route
*/
// const { wlogger } = require('../../../adapters/wlogger')
let _this
class OrderRESTControllerLib {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating /order REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating /order REST Controller.'
)
}
// Encapsulate dependencies
this.OrderModel = this.adapters.localdb.Order
// this.userUseCases = this.useCases.user
_this = this
}
// No api-doc documentation because this wont be a public endpoint
async createOrder (ctx) {
try {
console.log('body: ', ctx.request.body)
const orderObj = ctx.request.body
await _this.useCases.order.createOrder(orderObj)
ctx.body = {
success: true
}
} catch (err) {
// console.log(`err.message: ${err.message}`)
// console.log('err: ', err)
// ctx.throw(422, err.message)
_this.handleError(ctx, err)
}
}
// DRY error handler
handleError (ctx, err) {
console.log('err', err.message)
// If an HTTP status is specified by the buisiness logic, use that.
if (err.status) {
if (err.message) {
ctx.throw(err.status, err.message)
} else {
ctx.throw(err.status)
}
} else {
// By default use a 422 error if the HTTP status is not specified.
ctx.throw(422, err.message)
}
}
}
module.exports = OrderRESTControllerLib
+60
View File
@@ -0,0 +1,60 @@
/*
REST API library for /offer route.
*/
// Public npm libraries.
const Router = require('koa-router')
// Local libraries.
const OrderRESTControllerLib = require('./controller')
let _this
class OrderRouter {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating /order REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating /order REST Controller.'
)
}
const dependencies = {
adapters: this.adapters,
useCases: this.useCases
}
// Encapsulate dependencies.
this.orderRESTController = new OrderRESTControllerLib(dependencies)
// Instantiate the router and set the base route.
const baseUrl = '/order'
this.router = new Router({ prefix: baseUrl })
_this = this
}
attach (app) {
if (!app) {
throw new Error(
'Must pass app object when attached REST API controllers.'
)
}
// Define the routes and attach the controller.
this.router.post('/', _this.orderRESTController.createOrder)
// Attach the Controller routes to the Koa app.
app.use(_this.router.routes())
app.use(_this.router.allowedMethods())
}
}
module.exports = OrderRouter
@@ -0,0 +1,284 @@
/*
REST API Controller library for the /user route
*/
const { wlogger } = require('../../../adapters/wlogger')
let _this
class UserRESTControllerLib {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating /users REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating /users REST Controller.'
)
}
// Encapsulate dependencies
this.UserModel = this.adapters.localdb.Users
// this.userUseCases = this.useCases.user
_this = this
}
/**
* @api {post} /users Create a new user
* @apiPermission user
* @apiName CreateUser
* @apiGroup REST Users
*
* @apiExample Example usage:
* curl -H "Content-Type: application/json" -X POST -d '{ "user": { "email": "email@format.com", "name": "my name", "password": "secretpasas" } }' localhost:5001/users
*
* @apiParam {Object} user User object (required)
* @apiParam {String} user.email Email
* @apiParam {String} user.password Password
* @apiParam {String} user.name name or handle
*
* @apiSuccess {Object} users User object
* @apiSuccess {ObjectId} users._id User id
* @apiSuccess {String} user.type User type (admin or user)
* @apiSuccess {String} users.name User name
* @apiSuccess {String} users.username User username
* @apiSuccess {String} users.email User email
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "user": {
* "_id": "56bd1da600a526986cf65c80"
* "name": "John Doe"
* "email": "email@format.com",
* "password": "somestrongpassword"
* }
* }
*
* @apiError UnprocessableEntity Missing required parameters
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 422 Unprocessable Entity
* {
* "status": 422,
* "error": "Unprocessable Entity"
* }
*/
async createUser (ctx) {
try {
const userObj = ctx.request.body.user
const { userData, token } = await _this.useCases.user.createUser(userObj)
// console.log('userData: ', userData)
// console.log('token: ', token)
ctx.body = {
user: userData,
token
}
} catch (err) {
// console.log(`err.message: ${err.message}`)
// console.log('err: ', err)
// ctx.throw(422, err.message)
_this.handleError(ctx, err)
}
}
/**
* @api {get} /users Get all users
* @apiPermission user
* @apiName GetUsers
* @apiGroup REST Users
*
* @apiExample Example usage:
* curl -H "Content-Type: application/json" -X GET localhost:5000/users
*
* @apiSuccess {Object[]} users Array of user objects
* @apiSuccess {ObjectId} users._id User id
* @apiSuccess {String} user.type User type (admin or user)
* @apiSuccess {String} users.name User name
* @apiSuccess {String} users.username User username
* @apiSuccess {String} users.email User email
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "users": [{
* "_id": "56bd1da600a526986cf65c80"
* "name": "John Doe"
* "email": "email@format.com"
* }]
* }
*
* @apiUse TokenError
*/
async getUsers (ctx) {
try {
const users = await _this.useCases.user.getAllUsers()
ctx.body = { users }
} catch (err) {
wlogger.error('Error in users/controller.js/getUsers(): '.err)
ctx.throw(422, err.message)
}
}
/**
* @api {get} /users/:id Get user by id
* @apiPermission user
* @apiName GetUser
* @apiGroup REST Users
*
* @apiExample Example usage:
* curl -H "Content-Type: application/json" -X GET localhost:5000/users/56bd1da600a526986cf65c80
*
* @apiSuccess {Object} users User object
* @apiSuccess {ObjectId} users._id User id
* @apiSuccess {String} user.type User type (admin or user)
* @apiSuccess {String} users.name User name
* @apiSuccess {String} users.username User username
* @apiSuccess {String} users.email User email
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "user": {
* "_id": "56bd1da600a526986cf65c80"
* "name": "John Doe"
* "email": "email@format.com"
* }
* }
*
* @apiUse TokenError
*/
async getUser (ctx, next) {
try {
const user = await _this.useCases.user.getUser(ctx.params)
ctx.body = {
user
}
} catch (err) {
_this.handleError(ctx, err)
}
if (next) {
return next()
}
}
/**
* @api {put} /users/:id Update a user
* @apiPermission user
* @apiName UpdateUser
* @apiGroup REST Users
*
* @apiExample Example usage:
* curl -H "Content-Type: application/json" -X PUT -d '{ "user": { "name": "Cool new Name" } }' localhost:5000/users/56bd1da600a526986cf65c80
*
* @apiParam {Object} user User object (required)
* @apiParam {String} user.name Name.
* @apiParam {String} user.email Email.
* @apiParam {String} user.password Password. (optional)
*
* @apiSuccess {Object} users User object
* @apiSuccess {ObjectId} users._id User id
* @apiSuccess {String} user.type User type (admin or user)
* @apiSuccess {String} users.name Updated name
* @apiSuccess {String} users.username Updated username
* @apiSuccess {String} users.email Updated email
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "user": {
* "_id": "56bd1da600a526986cf65c80"
* "name": "Cool new name"
* "email": "email@format.com"
* }
* }
*
* @apiError UnprocessableEntity Missing required parameters
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 422 Unprocessable Entity
* {
* "status": 422,
* "error": "Unprocessable Entity"
* }
*
* @apiUse TokenError
*/
async updateUser (ctx) {
try {
const existingUser = ctx.body.user
const newData = ctx.request.body.user
const user = await _this.useCases.user.updateUser(existingUser, newData)
ctx.body = {
user
}
} catch (err) {
ctx.throw(422, err.message)
}
}
/**
* @api {delete} /users/:id Delete a user
* @apiPermission user
* @apiName DeleteUser
* @apiGroup REST Users
*
* @apiExample Example usage:
* curl -H "Content-Type: application/json" -X DELETE localhost:5000/users/56bd1da600a526986cf65c80
*
* @apiSuccess {StatusCode} 200
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "success": true
* }
*
* @apiUse TokenError
*/
async deleteUser (ctx) {
try {
const user = ctx.body.user
// await user.remove()
await _this.useCases.user.deleteUser(user)
ctx.status = 200
ctx.body = {
success: true
}
} catch (err) {
ctx.throw(422, err.message)
}
}
// DRY error handler
handleError (ctx, err) {
// If an HTTP status is specified by the buisiness logic, use that.
if (err.status) {
if (err.message) {
ctx.throw(err.status, err.message)
} else {
ctx.throw(err.status)
}
} else {
// By default use a 422 error if the HTTP status is not specified.
ctx.throw(422, err.message)
}
}
}
module.exports = UserRESTControllerLib
+88
View File
@@ -0,0 +1,88 @@
/*
REST API library for /user route.
*/
// Public npm libraries.
const Router = require('koa-router')
// Local libraries.
const UserRESTControllerLib = require('./controller')
const Validators = require('../middleware/validators')
let _this
class UserRouter {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating PostEntry REST Controller.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating PostEntry REST Controller.'
)
}
const dependencies = {
adapters: this.adapters,
useCases: this.useCases
}
// Encapsulate dependencies.
this.userRESTController = new UserRESTControllerLib(dependencies)
this.validators = new Validators()
// Instantiate the router and set the base route.
const baseUrl = '/users'
this.router = new Router({ prefix: baseUrl })
_this = this
}
attach (app) {
if (!app) {
throw new Error(
'Must pass app object when attaching REST API controllers.'
)
}
// Define the routes and attach the controller.
this.router.post('/', this.userRESTController.createUser)
this.router.get('/', this.getAll)
this.router.get('/:id', this.getById)
this.router.put('/:id', this.updateUser)
this.router.delete('/:id', this.deleteUser)
// Attach the Controller routes to the Koa app.
app.use(this.router.routes())
app.use(this.router.allowedMethods())
}
async getAll (ctx, next) {
await _this.validators.ensureUser(ctx, next)
await _this.userRESTController.getUsers(ctx, next)
}
async getById (ctx, next) {
await _this.validators.ensureUser(ctx, next)
await _this.userRESTController.getUser(ctx, next)
}
async updateUser (ctx, next) {
await _this.validators.ensureTargetUserOrAdmin(ctx, next)
await _this.userRESTController.getUser(ctx, next)
await _this.userRESTController.updateUser(ctx, next)
}
async deleteUser (ctx, next) {
await _this.validators.ensureTargetUserOrAdmin(ctx, next)
await _this.userRESTController.getUser(ctx, next)
await _this.userRESTController.deleteUser(ctx, next)
}
}
module.exports = UserRouter
+41
View File
@@ -0,0 +1,41 @@
/*
Entry Entity
*/
class Entry {
validate ({
entry,
description,
slpAddress,
signature,
category
} = {}) {
// Input Validation
if (!entry || typeof entry !== 'string') {
throw new Error("Property 'entry' must be a string!")
}
if (!description || typeof description !== 'string') {
throw new Error("Property 'description' must be a string!")
}
if (!slpAddress || typeof slpAddress !== 'string') {
throw new Error("Property 'slpAddress' must be a string!")
}
if (!signature || typeof signature !== 'string') {
throw new Error("Property 'signature' must be a string!")
}
if (!category || typeof category !== 'string') {
throw new Error("Property 'category' must be a string!")
}
const entryData = {
entry,
description,
slpAddress,
signature,
category
}
return entryData
}
}
module.exports = Entry
+58
View File
@@ -0,0 +1,58 @@
/*
Offer Entity
An Offer Entity is nearly the same as an Order. But while an Order is generated
by a webhook from P2WDB, the Offer Entity is created internally. It is used
to track an Order generated by this application.
The Offer tracks the hdIndex address used to hold tokens or BCH for sale.
*/
class Offer {
validate (data) {
const {
messageType,
messageClass,
tokenId,
buyOrSell,
rateInSats,
minSatsToExchange,
numTokens
} = data
// Input Validation
if (!messageType || typeof messageType !== 'number') {
throw new Error("Property 'messageType' must be an integer number.")
}
if (!messageClass || typeof messageClass !== 'number') {
throw new Error("Property 'messageClass' must be an integer number.")
}
if (!tokenId || typeof tokenId !== 'string') {
throw new Error("Property 'tokenId' must be a string.")
}
if (!buyOrSell || typeof buyOrSell !== 'string') {
throw new Error("Property 'buyOrSell' must be a string.")
}
if (!rateInSats || typeof rateInSats !== 'number') {
throw new Error("Property 'rateInSats' must be an integer number.")
}
if (!minSatsToExchange || typeof minSatsToExchange !== 'number') {
throw new Error("Property 'minSatsToExchange' must be an integer number.")
}
if (!numTokens || typeof numTokens !== 'number') {
throw new Error("Property 'numTokens' must be a number.")
}
const offerData = {
messageType,
messageClass,
tokenId,
buyOrSell,
rateInSats,
minSatsToExchange,
numTokens
}
return offerData
}
}
module.exports = Offer
+24
View File
@@ -0,0 +1,24 @@
/*
Order Entity
An order is created when a new Signal is detected via the P2WDB webhook.
It's destroyed when the UTXO described in the Signal has been detected as spent.
{
lokadId: 'SWP', // Placeholder for now, use for backwards compatibility
messageType: 1, // SLP Atomic Swap
messageClass: 1,
tokenId: '38e97c5d7d3585a2cbf3f9580c82ca33985f9cb0845d4dcce220cb709f9538b0',
buyOrSell: 'sell',
rateInSats: 7972, // Price per token in sats
minSatsToExchange: 8100, // Minum size of UTXO to use, e.g. 1 token + miner fees
// Signature from the address holding the tokens, provides 'proof of reserves'
signature: 'H2Sq0UPh0jgs1Zt3JERHtbzfPGXJk9DgJ0FVxVa6iUqiIh6XcvEUFBbvYIuODQs3hYSCkkjcuzbvzNEiv69kFKg=',
sigMsg: 'test',
address: 'bitcoincash:qphjncqpnv444jq8acqk4dkm3296c50xhqggeatvn8',
// UTXO for sale
utxoTxid: 'b9457808be70c39a9cc6c5857cbef856b35fdc91a59debfe06acfc45b11955e3',
utxoVout: 2
}
*/
+76
View File
@@ -0,0 +1,76 @@
/*
Order Entity
An order is created when a new Signal is detected via the P2WDB webhook.
It's destroyed when the UTXO described in the Signal has been detected as spent.
*/
class OrderEntity {
validate (orderData = {}) {
// Throw an error if input object does not have a data property
if (!orderData.data) {
throw new Error(
'Input to order.validate() must be an object with a data property.'
)
}
const {
messageType,
messageClass,
tokenId,
buyOrSell,
rateInSats,
minSatsToExchange,
numTokens,
utxoTxid,
utxoVout
} = orderData.data
// Input Validation
if (!messageType || typeof messageType !== 'number') {
throw new Error("Property 'messageType' must be an integer number.")
}
if (!messageClass || typeof messageClass !== 'number') {
throw new Error("Property 'messageClass' must be an integer number.")
}
if (!tokenId || typeof tokenId !== 'string') {
throw new Error("Property 'tokenId' must be a string.")
}
if (!buyOrSell || typeof buyOrSell !== 'string') {
throw new Error("Property 'buyOrSell' must be a string.")
}
if (!rateInSats || typeof rateInSats !== 'number') {
throw new Error("Property 'rateInSats' must be an integer number.")
}
if (!minSatsToExchange || typeof minSatsToExchange !== 'number') {
throw new Error("Property 'minSatsToExchange' must be an integer number.")
}
if (!numTokens || typeof numTokens !== 'number') {
throw new Error("Property 'numTokens' must be a number.")
}
if (!utxoTxid || typeof utxoTxid !== 'string') {
throw new Error("Property 'utxoTxid' must be a string.")
}
if (typeof utxoVout !== 'number') {
throw new Error("Property 'utxoVout' must be an integer number.")
}
const validatedOrderData = {
messageType,
messageClass,
tokenId,
buyOrSell,
rateInSats,
minSatsToExchange,
numTokens,
utxoTxid,
utxoVout,
timestamp: orderData.timestamp,
localTimestamp: orderData.localTimeStamp,
txid: orderData.txid,
p2wdbHash: orderData.hash
}
return validatedOrderData
}
}
module.exports = OrderEntity
+24
View File
@@ -0,0 +1,24 @@
/*
User Entity
*/
class User {
validate ({ name, email, password } = {}) {
// Input Validation
if (!email || typeof email !== 'string') {
throw new Error("Property 'email' must be a string!")
}
if (!password || typeof password !== 'string') {
throw new Error("Property 'password' must be a string!")
}
if (!name || typeof name !== 'string') {
throw new Error("Property 'name' must be a string!")
}
const userData = { name, email, password }
return userData
}
}
module.exports = User
+66
View File
@@ -0,0 +1,66 @@
const { wlogger } = require('../adapters/wlogger')
const EntryEntiy = require('../entities/entry')
class EntryLib {
constructor (localConfig = {}) {
// console.log('User localConfig: ', localConfig)
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of adapters must be passed in when instantiating User Use Cases library.'
)
}
// Encapsulate dependencies
this.EntryEntity = new EntryEntiy()
this.EntryModel = this.adapters.localdb.Entry
this.bch = this.adapters.bch
}
// Create a new entry model and add it to the Mongo database.
async createEntry (entryObj) {
try {
// Input Validation
const entryEntity = this.EntryEntity.validate(entryObj)
// Verify that the entry was signed by a specific BCH address.
const isValidSignature = this.bch._verifySignature(entryEntity)
if (!isValidSignature) {
throw new Error('Invalid signature')
}
// Verify psf tokens balance
const psfBalance = await this.bch.getPSFTokenBalance(
entryEntity.slpAddress
)
if (psfBalance < 10) {
throw new Error('Insufficient psf balance')
}
const merit = await this.bch.getMerit(entryEntity.slpAddress)
const updatedEntry = {
entry: entryEntity.entry.trim(),
slpAddress: entryEntity.slpAddress.trim(),
description: entryEntity.description.trim(),
signature: entryEntity.signature.trim(),
category: entryEntity.category.trim(),
balance: psfBalance,
merit
}
const entryModel = new this.EntryModel(updatedEntry)
await entryModel.save()
return entryModel
} catch (err) {
// console.log("Error in use-cases/entry.js/createEntry()", err.message)
wlogger.error('Error in use-cases/entry.js/createEntry()')
throw err
}
}
}
module.exports = EntryLib
+29
View File
@@ -0,0 +1,29 @@
/*
This is a top-level library that encapsulates all the additional Use Cases.
The concept of Use Cases comes from Clean Architecture:
https://troutsblog.com/blog/clean-architecture
*/
const UserUseCases = require('./user')
const EntryUseCases = require('./entry')
const OfferUseCases = require('./offer')
const OrderUseCases = require('./order')
class UseCases {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of adapters must be passed in when instantiating Use Cases library.'
)
}
// console.log('use-cases/index.js localConfig: ', localConfig)
this.user = new UserUseCases(localConfig)
this.entry = new EntryUseCases(localConfig)
this.offer = new OfferUseCases(localConfig)
this.order = new OrderUseCases(localConfig)
}
}
module.exports = UseCases
+148
View File
@@ -0,0 +1,148 @@
const { wlogger } = require('../adapters/wlogger')
const OfferEntity = require('../entities/offer')
class OfferLib {
constructor (localConfig = {}) {
// console.log('User localConfig: ', localConfig)
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of adapters must be passed in when instantiating Offer Use Cases library.'
)
}
// Encapsulate dependencies
this.offerEntity = new OfferEntity()
this.OfferModel = this.adapters.localdb.Offer
this.bch = this.adapters.bch
}
// Create a new offer model and add it to the Mongo database.
async createOffer (entryObj) {
try {
// console.log('createOffer(entryObj): ', entryObj)
// Input Validation
const offerEntity = this.offerEntity.validate(entryObj)
// console.log('offerEntity: ', offerEntity)
// Ensure sufficient tokens exist to create the offer.
await this.ensureFunds(offerEntity)
// Move the tokens to holding address.
const utxoInfo = await this.moveTokens(offerEntity)
console.log('utxoInfo: ', utxoInfo)
// Update the UTXO store for the wallet.
await this.adapters.wallet.bchWallet.bchjs.Util.sleep(3000)
await this.adapters.wallet.bchWallet.getUtxos()
// Update the offer with the new UTXO information.
offerEntity.utxoTxid = utxoInfo.txid
offerEntity.utxoVout = utxoInfo.vout
// Burn PSF token to pay for P2WDB write.
const txid = await this.adapters.wallet.burnPsf()
console.log('burn txid: ', txid)
console.log(`https://simpleledger.info/tx/${txid}`)
// generate signature.
const now = new Date()
const message = now.toISOString()
const signature = await this.adapters.wallet.generateSignature(message)
// console.log('signature: ', signature)
const p2wdbObj = {
txid,
signature,
message,
appId: 'swapTest555',
data: offerEntity
}
// Add offer to P2WDB.
const hash = await this.adapters.p2wdb.write(p2wdbObj)
// console.log('hash: ', hash)
return hash
} catch (err) {
// console.log("Error in use-cases/entry.js/createEntry()", err.message)
wlogger.error('Error in use-cases/entry.js/createOffer())')
throw err
}
}
// Move the tokens indicated in the offer to a temporary holding address.
// This will generate the UTXO used in the Signal message. This function
// moves the funds and returns the UTXO information.
async moveTokens (offerEntity) {
try {
const keyPair = await this.adapters.wallet.getKeyPair()
console.log('keyPair: ', keyPair)
const receiver = {
address: keyPair.cashAddress,
tokenId: offerEntity.tokenId,
qty: offerEntity.numTokens
}
const txid = await this.adapters.wallet.bchWallet.sendTokens(receiver, 3)
const utxoInfo = {
txid,
vout: 0
}
return utxoInfo
} catch (err) {
console.error('Error in moveTokens(): ', err)
throw err
}
}
// Ensure that the wallet has enough BCH and tokens to complete the requested
// trade.
async ensureFunds (offerEntity) {
try {
// console.log('this.adapters.wallet: ', this.adapters.wallet.bchWallet)
// Get UTXOs.
const utxos = this.adapters.wallet.bchWallet.utxos.utxoStore
// console.log(`utxos: ${JSON.stringify(utxos, null, 2)}`)
if (offerEntity.buyOrSell.includes('sell')) {
// Sell Offer
// Get token UTXOs that match the token in the offer.
const tokenUtxos = utxos.slpUtxos.type1.tokens.filter(
(x) => x.tokenId === offerEntity.tokenId
)
// console.log('tokenUtxos: ', tokenUtxos)
// Get the total amount of tokens in the wallet that match the token
// in the offer.
let totalTokenBalance = 0
tokenUtxos.map((x) => (totalTokenBalance += parseFloat(x.tokenQty)))
// console.log('totalTokenBalance: ', totalTokenBalance)
// If there are fewer tokens in the wallet than what's in the offer,
// throw an error.
if (totalTokenBalance <= offerEntity.numTokens) {
throw new Error(
'App wallet does not have enough tokens to satisfy the SELL offer.'
)
}
} else {
// Buy Offer
}
return true
} catch (err) {
console.error('Error in ensureFunds()')
throw err
}
}
}
module.exports = OfferLib
+61
View File
@@ -0,0 +1,61 @@
/*
Use Case library for Orders.
Orders are created by a webhook trigger from the P2WDB. Orders are a result of
new data in P2WDB. They differ from Offers, which are generated by a local
user.
An Order is created to match a local Offer, but it's created indirectly, as
a response to the webhook from the P2WDB. In this way, Orders generated from
local Offers are no different than Orders generated by other peers.
*/
const OrderEntity = require('../../entities/order')
class OrderUseCases {
constructor (localConfig = {}) {
// console.log('User localConfig: ', localConfig)
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of adapters must be passed in when instantiating Order Use Cases library.'
)
}
this.orderEntity = new OrderEntity()
this.OrderModel = this.adapters.localdb.Order
}
// This method is called by the POST /order REST API controller, which is
// triggered by a P2WDB webhook.
async createOrder (orderObj) {
try {
console.log('Use Case createOrder(orderObj): ', orderObj)
// console.log('this.adapters.bchjs: ', this.adapters.bchjs)
// Verify that UTXO in order is unspent. If it is spent, then ignore the
// order.
const txid = orderObj.data.utxoTxid
const vout = orderObj.data.utxoVout
const utxoStatus = await this.adapters.bchjs.Blockchain.getTxOut(
txid,
vout
)
console.log('utxoStatus: ', utxoStatus)
if (utxoStatus === null) return false
const orderEntity = this.orderEntity.validate(orderObj)
console.log('orderEntity: ', orderEntity)
// Add order to the local database.
const orderModel = new this.OrderModel(orderEntity)
await orderModel.save()
return true
} catch (err) {
console.error('Error in createOrder()')
throw err
}
}
}
module.exports = OrderUseCases
+106
View File
@@ -0,0 +1,106 @@
/*
This library leverages the p-retry and p-queue libraries, to create a
validation queue with automatic retry.
New nodes syncing will attempt to rapidly validate a lot of entries.
A promise-based queue allows this to happen while respecting rate-limits
of the blockchain service provider.
pay-to-write-access-controller.js depends on this library.
*/
const PQueue = require('p-queue').default
const pRetry = require('p-retry')
let _this
class RetryQueue {
constructor (localConfig = {}) {
if (!localConfig.bchjs) {
throw new Error(
'Must pass instance of bch-js when instantiating RetryQueue Class.'
)
}
this.bchjs = localConfig.bchjs
// Encapsulate dependencies
this.validationQueue = new PQueue({ concurrency: 1 })
this.pRetry = pRetry
this.attempts = 5
_this = this
}
// Add an async function to the queue, and execute it with the input object.
async addToQueue (funcHandle, inputObj) {
try {
console.log('addToQueue inputObj: ', inputObj)
if (!funcHandle) {
throw new Error('function handler is required')
}
if (!inputObj) {
throw new Error('input object is required')
}
const returnVal = await _this.validationQueue.add(() =>
_this.retryWrapper(funcHandle, inputObj)
)
return returnVal
} catch (err) {
console.error('Error in addToQueue(): ', err)
throw err
}
}
// Wrap the p-retry library.
// This function returns a promise that will resolve to the output of the
// function 'funcHandle'.
async retryWrapper (funcHandle, inputObj) {
try {
console.log('retryWrapper inputObj: ', inputObj)
if (!funcHandle) {
throw new Error('function handler is required')
}
if (!inputObj) {
throw new Error('input object is required')
}
console.log('Entering retryWrapper()')
// Add artificial delay to prevent 429 errors.
await this.bchjs.Util.sleep(2000)
return this.pRetry(
async () => {
return await funcHandle(inputObj)
},
{
onFailedAttempt: _this.handleValidationError,
retries: this.attempts // Retry 5 times
}
)
} catch (err) {
console.error('Error in retryWrapper: ', err)
throw err
}
}
// Notifies the user that an error occured and that a retry will be attempted.
// It tracks the number of retries until it fails.
async handleValidationError (error) {
try {
const errorMsg = `Attempt ${error.attemptNumber} to validate entry. There are ${error.retriesLeft} retries left. Waiting before trying again.`
console.log(errorMsg)
const SLEEP_TIME = 30000
console.log(`Waiting ${SLEEP_TIME} milliseconds before trying again.\n`)
await _this.bchjs.Util.sleep(SLEEP_TIME) // 30 sec
} catch (err) {
console.error('Error in handleValidationError(): ', err)
throw err
}
}
}
module.exports = RetryQueue
+184
View File
@@ -0,0 +1,184 @@
/*
This library contains business-logic for dealing with users. Most of these
functions are called by the /user REST API endpoints.
*/
const UserEntity = require('../entities/user')
const { wlogger } = require('../adapters/wlogger')
class UserLib {
constructor (localConfig = {}) {
// console.log('User localConfig: ', localConfig)
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of adapters must be passed in when instantiating User Use Cases library.'
)
}
// Encapsulate dependencies
this.UserEntity = new UserEntity()
this.UserModel = this.adapters.localdb.Users
}
// Create a new user model and add it to the Mongo database.
async createUser (userObj) {
try {
// Input Validation
const userEntity = this.UserEntity.validate(userObj)
const user = new this.UserModel(userEntity)
// Enforce default value of 'user'
user.type = 'user'
// console.log('user: ', user)
// Save the new user model to the database.
await user.save()
// Generate a JWT token for the user.
const token = user.generateToken()
// Convert the database model to a JSON object.
const userData = user.toJSON()
// console.log('userData: ', userData)
// Delete the password property.
delete userData.password
return { userData, token }
} catch (err) {
// console.log('createUser() error: ', err)
wlogger.error('Error in lib/users.js/createUser()')
throw err
}
}
// Returns an array of all user models in the Mongo database.
async getAllUsers () {
try {
// Get all user models. Delete the password property from each model.
const users = await this.UserModel.find({}, '-password')
return users
} catch (err) {
wlogger.error('Error in lib/users.js/getAllUsers()')
throw err
}
}
// Get the model for a specific user.
async getUser (params) {
try {
const { id } = params
const user = await this.UserModel.findById(id, '-password')
// Throw a 404 error if the user isn't found.
if (!user) {
const err = new Error('User not found')
err.status = 404
throw err
}
return user
} catch (err) {
// console.log('Error in getUser: ', err)
if (err.status === 404) throw err
// Return 422 for any other error
err.status = 422
err.message = 'Unprocessable Entity'
throw err
}
}
async updateUser (existingUser, newData) {
try {
// console.log('existingUser: ', existingUser)
// console.log('newData: ', newData)
// Input Validation
// Optional inputs, but they must be strings if included.
if (newData.email && typeof newData.email !== 'string') {
throw new Error("Property 'email' must be a string!")
}
if (newData.name && typeof newData.name !== 'string') {
throw new Error("Property 'name' must be a string!")
}
if (newData.password && typeof newData.password !== 'string') {
throw new Error("Property 'password' must be a string!")
}
// Save a copy of the original user type.
const userType = existingUser.type
// console.log('userType: ', userType)
// If user 'type' property is sent by the client
if (newData.type) {
if (typeof newData.type !== 'string') {
throw new Error("Property 'type' must be a string!")
}
// Unless the calling user is an admin, they can not change the user type.
if (userType !== 'admin') {
throw new Error("Property 'type' can only be changed by Admin user")
}
}
// Overwrite any existing data with the new data.
Object.assign(existingUser, newData)
// Save the changes to the database.
await existingUser.save()
// Delete the password property.
delete existingUser.password
return existingUser
} catch (err) {
wlogger.error('Error in lib/users.js/updateUser()')
throw err
}
}
async deleteUser (user) {
try {
await user.remove()
} catch (err) {
wlogger.error('Error in lib/users.js/deleteUser()')
throw err
}
}
// Used to authenticate a user. If the login and password salt match a user in
// the database, then it returns the user model. The Koa REST API uses the
// Passport library for this functionality. This function is used to
// authenticate users who login via the JSON RPC.
async authUser (login, passwd) {
try {
// console.log('login: ', login)
// console.log('passwd: ', passwd)
const user = await this.UserModel.findOne({ email: login })
if (!user) {
throw new Error('User not found')
}
const isMatch = await user.validatePassword(passwd)
if (!isMatch) {
throw new Error('Login credential do not match')
}
return user
} catch (err) {
// console.error('Error in users.js/authUser()')
console.log('')
throw err
}
}
}
module.exports = UserLib
+7
View File
@@ -0,0 +1,7 @@
# Automated End-to-end Tests
This contains the original boilerplate tests, which are end-to-end tests. These tests are fully automated and test the system directly by making REST API calls with axios.
These tests function exactly the same as a normal user would, by making real REST API calls to the software. As a result, they are fine for testing internal system components like authorization and user handling. However, they are inappropriate for testing sophisticated endpoints that involve complex operations. For example, interacting with a blockchain, pinging other network systems, or writing data to a secondary database.
There is some redundancy between these tests and the unit tests. The focus is on *how* the tests are executed. The unit tests call the libraries directly (internally). These e2e tests use the REST API (externally).
+124
View File
@@ -0,0 +1,124 @@
/*
End-to-end tests for /auth endpoints.
This test sets up the environment for other e2e tests.
*/
// Public npm libraries
const assert = require('chai').assert
const axios = require('axios').default
// Local support libraries
const config = require('../../../config')
const Server = require('../../../bin/server')
const testUtils = require('../../utils/test-utils')
const AdminLib = require('../../../src/adapters/admin')
const adminLib = new AdminLib()
// const request = supertest.agent(app.listen())
const context = {}
const LOCALHOST = `http://localhost:${config.port}`
describe('Auth', () => {
before(async () => {
const app = new Server()
// This should be the first instruction. It starts the REST API server.
await app.startServer()
// Stop the IPFS node for the rest of the e2e tests.
// await app.controllers.adapters.ipfs.stop()
// Delete all previous users in the database.
await testUtils.deleteAllUsers()
// Create a new admin user.
await adminLib.createSystemUser()
const userObj = {
email: 'test@test.com',
password: 'pass',
name: 'test'
}
const testUser = await testUtils.createUser(userObj)
// console.log('TestUser: ', testUser)
context.user = testUser.user
context.token = testUser.token
})
describe('POST /auth', () => {
it('should throw 401 if credentials are incorrect', async () => {
try {
const options = {
method: 'post',
url: `${LOCALHOST}/auth`,
data: {
email: 'test@test.com',
password: 'wrongpassword'
}
}
const result = await axios(options)
// console.log(`result: ${JSON.stringify(result, null, 2)}`)
console.log(
`result stringified: ${JSON.stringify(result.data, null, 2)}`
)
assert(false, 'Unexpected result')
} catch (err) {
assert(err.response.status === 401, 'Error code 401 expected.')
}
})
it('should throw 401 if email is wrong format', async () => {
try {
const options = {
method: 'post',
url: `${LOCALHOST}/auth`,
data: {
email: 'wrongEmail',
password: 'wrongpassword'
}
}
await axios(options)
assert(false, 'Unexpected result')
} catch (err) {
assert(err.response.status === 401, 'Error code 401 expected.')
}
})
it('should auth user', async () => {
try {
const options = {
method: 'post',
url: `${LOCALHOST}/auth`,
data: {
email: 'test@test.com',
password: 'pass'
}
}
const result = await axios(options)
// console.log(`result: ${JSON.stringify(result.data, null, 2)}`)
assert(result.status === 200, 'Status Code 200 expected.')
assert(
result.data.user.email === 'test@test.com',
'Email of test expected'
)
assert(
result.data.user.password === undefined,
'Password expected to be omited'
)
} catch (err) {
console.log(
'Error authenticating test user: ' + JSON.stringify(err, null, 2)
)
throw err
}
})
})
})
+791
View File
@@ -0,0 +1,791 @@
const testUtils = require('../../utils/test-utils')
const assert = require('chai').assert
const config = require('../../../config')
const axios = require('axios').default
const sinon = require('sinon')
const util = require('util')
util.inspect.defaultOptions = { depth: 1 }
const LOCALHOST = `http://localhost:${config.port}`
const context = {}
const UserController = require('../../../src/controllers/rest-api/users/controller')
const Adapters = require('../../../src/adapters')
const adapters = new Adapters()
const UseCases = require('../../../src/use-cases/')
let uut
let sandbox
// const mockContext = require('../../unit/mocks/ctx-mock').context
describe('Users', () => {
before(async () => {
// console.log(`config: ${JSON.stringify(config, null, 2)}`)
// Create a second test user.
const userObj = {
email: 'test2@test.com',
password: 'pass2',
name: 'test2'
}
const testUser = await testUtils.createUser(userObj)
// console.log(`testUser2: ${JSON.stringify(testUser, null, 2)}`)
context.user2 = testUser.user
context.token2 = testUser.token
context.id2 = testUser.user._id
// Get the JWT used to log in as the admin 'system' user.
const adminJWT = await testUtils.getAdminJWT()
// console.log(`adminJWT: ${admi nJWT}`)
context.adminJWT = adminJWT
// const admin = await testUtils.loginAdminUser()
// context.adminJWT = admin.token
// const admin = await adminLib.loginAdmin()
// console.log(`admin: ${JSON.stringify(admin, null, 2)}`)
})
beforeEach(() => {
const useCases = new UseCases({ adapters })
uut = new UserController({ adapters, useCases })
sandbox = sinon.createSandbox()
})
afterEach(() => sandbox.restore())
describe('POST /users - Create User', () => {
it('should reject signup when data is incomplete', async () => {
try {
const options = {
method: 'POST',
url: `${LOCALHOST}/users`,
data: {
email: 'test2@test.com'
}
}
await axios(options)
/* console.log(
`result stringified: ${JSON.stringify(result.data, null, 2)}`
) */
assert(false, 'Unexpected result')
} catch (err) {
assert(err.response.status === 422, 'Error code 422 expected.')
}
})
it('should reject signup if no email property is provided', async () => {
try {
const options = {
method: 'POST',
url: `${LOCALHOST}/users`,
data: {
user: {
password: 'pass2'
}
}
}
await axios(options)
assert(false, 'Unexpected result')
} catch (err) {
// console.log('err', err)
assert.equal(err.response.status, 422)
assert.include(err.response.data, "Property 'email' must be a string")
}
})
it('should reject signup if no password property is provided', async () => {
try {
const options = {
method: 'POST',
url: `${LOCALHOST}/users`,
data: {
user: {
email: 'test2@test.com'
}
}
}
await axios(options)
assert(false, 'Unexpected result')
} catch (err) {
assert.equal(err.response.status, 422)
assert.include(
err.response.data,
"Property 'password' must be a string"
)
}
})
it('should reject if name property property is not string', async () => {
try {
const options = {
method: 'POST',
url: `${LOCALHOST}/users`,
data: {
user: {
email: 'test322@test.com',
password: 'supersecretpassword',
name: 1234
}
}
}
await axios(options)
assert.fail('Unexpected result')
} catch (err) {
// console.log(err)
assert.equal(err.response.status, 422)
assert.include(err.response.data, "Property 'name' must be a string")
}
})
it("should signup of type 'user' by default", async () => {
const options = {
method: 'post',
url: `${LOCALHOST}/users`,
data: {
user: {
email: 'test3@test.com',
password: 'supersecretpassword',
name: 'test3'
}
}
}
const result = await axios(options)
// console.log(`result: ${JSON.stringify(result, null, 2)}`)
context.user = result.data.user
context.token = result.data.token
assert(result.status === 200, 'Status Code 200 expected.')
assert(
result.data.user.email === 'test3@test.com',
'Email of test expected'
)
assert(
result.data.user.password === undefined,
'Password expected to be omited'
)
assert.property(result.data, 'token', 'Token property exists.')
assert.equal(result.data.user.type, 'user')
})
})
describe('GET /users', () => {
it('should not fetch users if the authorization header is missing', async () => {
try {
const options = {
method: 'GET',
url: `${LOCALHOST}/users`,
headers: {
Accept: 'application/json'
}
}
await axios(options)
assert.equal(true, false, 'Unexpected behavior')
} catch (err) {
assert.equal(err.response.status, 401)
}
})
it('should not fetch users if the authorization header is missing the scheme', async () => {
try {
const options = {
method: 'GET',
url: `${LOCALHOST}/users`,
headers: {
Accept: 'application/json',
Authorization: '1'
}
}
await axios(options)
assert.equal(true, false, 'Unexpected behavior')
} catch (err) {
assert.equal(err.response.status, 401)
}
})
it('should not fetch users if the authorization header has invalid scheme', async () => {
const { token } = context
try {
const options = {
method: 'GET',
url: `${LOCALHOST}/users`,
headers: {
Accept: 'application/json',
Authorization: `Unknown ${token}`
}
}
await axios(options)
assert.equal(true, false, 'Unexpected behavior')
} catch (err) {
assert.equal(err.response.status, 401)
}
})
it('should not fetch users if token is invalid', async () => {
try {
const options = {
method: 'GET',
url: `${LOCALHOST}/users`,
headers: {
Accept: 'application/json',
Authorization: 'Bearer 1'
}
}
await axios(options)
assert.equal(true, false, 'Unexpected behavior')
} catch (err) {
assert.equal(err.response.status, 401)
}
})
it('should fetch all users', async () => {
const { token } = context
const options = {
method: 'GET',
url: `${LOCALHOST}/users`,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
}
}
const result = await axios(options)
const users = result.data.users
// console.log(`users: ${util.inspect(users)}`)
assert.hasAnyKeys(users[0], ['type', '_id', 'email'])
assert.isNumber(users.length)
})
it('should return a 422 http status if biz-logic throws an error', async () => {
try {
const { token } = context
// Force an error
sandbox
.stub(uut.useCases.user, 'getAllUsers')
.rejects(new Error('test error'))
const options = {
method: 'GET',
url: `${LOCALHOST}/users`,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
}
}
await axios(options)
assert.fail('Unexpected code path!')
} catch (err) {
// console.log(err)
assert.equal(err.response.status, 422)
assert.equal(err.response.data, 'test error')
}
})
})
describe('GET /users/:id', () => {
it('should not fetch user if token is invalid', async () => {
try {
const options = {
method: 'GET',
url: `${LOCALHOST}/users/1`,
headers: {
Accept: 'application/json',
Authorization: 'Bearer 1'
}
}
await axios(options)
assert.equal(true, false, 'Unexpected behavior')
} catch (err) {
assert.equal(err.response.status, 401)
}
})
it("should throw 404 if user doesn't exist", async () => {
const { token } = context
try {
const options = {
method: 'GET',
url: `${LOCALHOST}/users/5fa4bd7ee1828f5f4d8ed004`,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
}
}
await axios(options)
assert.equal(true, false, 'Unexpected behavior')
} catch (err) {
assert.equal(err.response.status, 404)
}
})
it('should throw 422 for invalid input', async () => {
const { token } = context
try {
const options = {
method: 'GET',
url: `${LOCALHOST}/users/1`,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
}
}
await axios(options)
assert.equal(true, false, 'Unexpected behavior')
} catch (err) {
assert.equal(err.response.status, 422)
}
})
it('should fetch own user', async () => {
const _id = context.user._id
const token = context.token
const options = {
method: 'GET',
url: `${LOCALHOST}/users/${_id}`,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
}
}
const result = await axios(options)
const user = result.data.user
// console.log(`user: ${util.inspect(user)}`)
assert.property(user, 'type')
assert.property(user, 'email')
assert.property(user, '_id')
assert.equal(user._id, _id)
assert.notProperty(
user,
'password',
'Password property should not be returned'
)
})
})
describe('PUT /users/:id', () => {
it('should not update user if token is invalid', async () => {
try {
const options = {
method: 'PUT',
url: `${LOCALHOST}/users/1`,
headers: {
Accept: 'application/json',
Authorization: 'Bearer 1'
}
}
await axios(options)
assert.equal(true, false, 'Unexpected behavior')
} catch (err) {
assert.equal(err.response.status, 401)
}
})
it('should throw 401 if non-admin updating other user', async () => {
const { token } = context
try {
const options = {
method: 'PUT',
url: `${LOCALHOST}/users/1`,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
}
}
await axios(options)
assert.equal(true, false, 'Unexpected behavior')
} catch (err) {
assert.equal(err.response.status, 401)
}
})
it('should not be able to update user type', async () => {
try {
const options = {
method: 'PUT',
url: `${LOCALHOST}/users/${context.user._id.toString()}`,
headers: {
Authorization: `Bearer ${context.token}`
},
data: {
user: {
email: 'test@test.com',
password: 'password',
name: 'new name',
type: 'test'
}
}
}
await axios(options)
// console.log(`Users: ${JSON.stringify(result.data, null, 2)}`)
// assert(result.status === 200, 'Status Code 200 expected.')
// assert(result.data.user.type === 'user', 'Type should be unchanged.')
assert.equal(true, false, 'Unexpected behavior')
} catch (err) {
assert.equal(err.response.status, 422)
assert.include(
err.response.data,
"Property 'type' can only be changed by Admin user"
)
}
})
it('should not be able to update other user when not admin', async () => {
try {
const options = {
method: 'PUT',
url: `${LOCALHOST}/users/${context.user2._id.toString()}`,
headers: {
Authorization: `Bearer ${context.token}`
},
data: {
user: {
name: 'This should not work'
}
}
}
await axios(options)
// console.log(`result: ${JSON.stringify(result.data, null, 2)}`)
assert(false, 'Unexpected result')
} catch (err) {
assert.equal(err.response.status, 401)
}
})
it('should not be able to update if name property is wrong', async () => {
try {
const _id = context.user._id
const token = context.token
const options = {
method: 'PUT',
url: `${LOCALHOST}/users/${_id}`,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
},
data: {
user: {
email: 'testToUpdate@test.com',
name: {},
password: 'password'
}
}
}
await axios(options)
} catch (error) {
assert.equal(error.response.status, 422)
assert.include(error.response.data, "Property 'name' must be a string!")
}
})
it('should not be able to update if password property is not string', async () => {
const { token } = context
const _id = context.user._id
try {
const options = {
method: 'PUT',
url: `${LOCALHOST}/users/${_id}`,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
},
data: {
user: {
password: 1234,
email: 'test@test.com',
name: 'test'
}
}
}
await axios(options)
assert.equal(true, false, 'Unexpected behavior')
} catch (err) {
assert.equal(err.response.status, 422)
assert.include(
err.response.data,
"Property 'password' must be a string!"
)
}
})
it('should not be able to update if email is not string', async () => {
const { token } = context
const _id = context.user._id
try {
const options = {
method: 'PUT',
url: `${LOCALHOST}/users/${_id}`,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
},
data: {
user: {
email: 1234
}
}
}
await axios(options)
assert.equal(true, false, 'Unexpected behavior')
} catch (err) {
assert.equal(err.response.status, 422)
assert.include(err.response.data, "Property 'email' must be a string!")
}
})
it('should not be able to update type property if is not string', async () => {
try {
const _id = context.user._id
const token = context.token
const options = {
method: 'PUT',
url: `${LOCALHOST}/users/${_id}`,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
},
data: {
user: {
type: 1,
email: 'test@test.com',
name: 'test',
password: 'password'
}
}
}
await axios(options)
} catch (err) {
assert.equal(err.response.status, 422)
assert.include(err.response.data, "Property 'type' must be a string!")
}
})
it('should be able to update other user when admin', async () => {
const adminJWT = context.adminJWT
const options = {
method: 'PUT',
url: `${LOCALHOST}/users/${context.user2._id.toString()}`,
headers: {
Authorization: `Bearer ${adminJWT}`
},
data: {
user: {
name: 'This should work',
email: 'test4@test.com',
password: 'password'
}
}
}
const result = await axios(options)
// console.log(`result stringified: ${JSON.stringify(result, null, 2)}`)
const userName = result.data.user.name
assert.equal(userName, 'This should work')
})
it('should update user with minimum inputs', async () => {
const _id = context.user._id
const token = context.token
const options = {
method: 'PUT',
url: `${LOCALHOST}/users/${_id}`,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
},
data: {
user: { email: 'testToUpdate@test.com' }
}
}
const result = await axios(options)
const user = result.data.user
// console.log(`user: ${util.inspect(user)}`)
assert.property(user, 'type')
assert.property(user, 'email')
assert.property(user, '_id')
assert.equal(user._id, _id)
assert.notProperty(
user,
'password',
'Password property should not be returned'
)
assert.equal(user.email, 'testToUpdate@test.com')
})
it('should update user with all inputs', async () => {
const _id = context.user._id
const token = context.token
const options = {
method: 'PUT',
url: `${LOCALHOST}/users/${_id}`,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
},
data: {
user: {
email: 'testToUpdate@test.com',
name: 'my name',
username: 'myUsername'
}
}
}
const result = await axios(options)
const user = result.data.user
// console.log(`user: ${util.inspect(user)}`)
assert.property(user, 'type')
assert.property(user, 'email')
assert.property(user, 'name')
assert.property(user, '_id')
assert.equal(user._id, _id)
assert.notProperty(
user,
'password',
'Password property should not be returned'
)
assert.equal(user.name, 'my name')
assert.equal(user.email, 'testToUpdate@test.com')
assert.equal(user.username, 'myUsername')
})
})
describe('DELETE /users/:id', () => {
it('should not delete user if token is invalid', async () => {
try {
const options = {
method: 'DELETE',
url: `${LOCALHOST}/users/1`,
headers: {
Accept: 'application/json',
Authorization: 'Bearer 1'
}
}
await axios(options)
assert.equal(true, false, 'Unexpected behavior')
} catch (err) {
assert.equal(err.response.status, 401)
}
})
it('should throw 401 if deleting invalid user', async () => {
const { token } = context
try {
const options = {
method: 'DELETE',
url: `${LOCALHOST}/users/1`,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
}
}
await axios(options)
assert.equal(true, false, 'Unexpected behavior')
} catch (err) {
assert.equal(err.response.status, 401)
}
})
it('should not be able to delete other users unless admin', async () => {
try {
const options = {
method: 'DELETE',
url: `${LOCALHOST}/users/${context.user2._id.toString()}`,
headers: {
Authorization: `Bearer ${context.token}`
}
}
await axios(options)
} catch (err) {
assert.equal(err.response.status, 401)
}
})
it('should delete own user', async () => {
const _id = context.user._id
const token = context.token
const options = {
method: 'DELETE',
url: `${LOCALHOST}/users/${_id}`,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`
}
}
const result = await axios(options)
// console.log(`result: ${util.inspect(result.data.success)}`)
assert.equal(result.data.success, true)
})
it('should be able to delete other users when admin', async () => {
const id = context.id2
const adminJWT = context.adminJWT
const options = {
method: 'DELETE',
url: `${LOCALHOST}/users/${id}`,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${adminJWT}`
}
}
const result = await axios(options)
// console.log(`result: ${util.inspect(result.data)}`)
assert.equal(result.data.success, true)
})
})
})
+178
View File
@@ -0,0 +1,178 @@
const config = require('../../../config')
const axios = require('axios').default
const assert = require('chai').assert
const sinon = require('sinon')
// Mock data
// const mockData = require('./mocks/contact-mocks')
const LOCALHOST = `http://localhost:${config.port}`
const mockContext = require('../../unit/mocks/ctx-mock').context
const ContactController = require('../../../src/controllers/rest-api/contact/controller')
let uut
let sandbox
describe('Contact', () => {
beforeEach(() => {
uut = new ContactController()
sandbox = sinon.createSandbox()
})
afterEach(() => sandbox.restore())
describe('POST /contact/email', () => {
it('should throw error if email property is not provided', async () => {
try {
const options = {
method: 'POST',
url: `${LOCALHOST}/contact/email`,
data: {
obj: {
formMessage: 'message'
}
}
}
await axios(options)
// console.log(`result: ${JSON.stringify(result, null, 2)}`)
// console.log(`result stringified: ${JSON.stringify(result, null, 2)}`)
assert(false, 'Unexpected result')
} catch (err) {
assert.equal(err.response.status, 422)
assert.include(err.response.data, "Property 'email' must be a string!")
}
})
it('should throw error if formMessage property is not provided', async () => {
try {
const options = {
method: 'POST',
url: `${LOCALHOST}/contact/email`,
data: {
obj: {
email: 'email@email.com'
}
}
}
await axios(options)
// console.log(`result: ${JSON.stringify(result, null, 2)}`)
// console.log(`result stringified: ${JSON.stringify(result, null, 2)}`)
assert(false, 'Unexpected result')
} catch (err) {
assert.equal(err.response.status, 422)
assert.include(
err.response.data,
"Property 'formMessage' must be a string!"
)
}
})
it('should throw error if email list provided is not a array', async () => {
try {
const options = {
method: 'POST',
url: `${LOCALHOST}/contact/email`,
data: {
obj: {
email: 'email@email.com',
formMessage: 'test message',
emailList: 1
}
}
}
await axios(options)
// console.log(`result: ${JSON.stringify(result, null, 2)}`)
// console.log(`result stringified: ${JSON.stringify(result, null, 2)}`)
assert(false, 'Unexpected result')
} catch (err) {
assert.equal(err.response.status, 422)
assert.include(
err.response.data,
"Property 'emailList' must be a array of emails!"
)
}
})
it('should throw error if email list provided is a empty array', async () => {
try {
const options = {
method: 'POST',
url: `${LOCALHOST}/contact/email`,
data: {
obj: {
email: 'email@email.com',
formMessage: 'test message',
emailList: []
}
}
}
await axios(options)
// console.log(`result: ${JSON.stringify(result, null, 2)}`)
// console.log(`result stringified: ${JSON.stringify(result, null, 2)}`)
assert(false, 'Unexpected result')
} catch (err) {
assert.equal(err.response.status, 422)
assert.include(
err.response.data,
"Property 'emailList' must be a array of emails!"
)
}
})
it('should send email with minimun input', async () => {
try {
// Mock live network calls.
sandbox.stub(uut.contactLib, 'sendEmail').resolves(true)
// Mock the context object.
const ctx = mockContext()
ctx.request = {
body: {
obj: {
email: 'email@email.com',
formMessage: 'test message'
}
}
}
await uut.email(ctx)
} catch (err) {
assert(false, 'Unexpected result')
}
})
it('should send email with all inputs', async () => {
try {
// Mock live network calls.
sandbox.stub(uut.contactLib, 'sendEmail').resolves(true)
// Mock the context object.
const ctx = mockContext()
ctx.request = {
body: {
obj: {
email: 'email@email.com',
formMessage: 'test message',
emailList: ['email@email.com']
}
}
}
await uut.email(ctx)
} catch (err) {
assert(false, 'Unexpected result')
}
})
})
})
+132
View File
@@ -0,0 +1,132 @@
const config = require('../../../config')
const assert = require('chai').assert
const axios = require('axios').default
const sinon = require('sinon')
const util = require('util')
util.inspect.defaultOptions = { depth: 1 }
const LOCALHOST = `http://localhost:${config.port}`
const LogsController = require('../../../src/controllers/rest-api/logs/controller')
const mockContext = require('../../unit/mocks/ctx-mock').context
let sandbox
let uut
describe('LogsApi', () => {
beforeEach(() => {
uut = new LogsController()
sandbox = sinon.createSandbox()
})
afterEach(() => sandbox.restore())
describe('POST /logs', () => {
it('should return false if password is not provided', async () => {
try {
const options = {
method: 'post',
url: `${LOCALHOST}/logs`,
data: {}
}
const result = await axios(options)
assert.isFalse(result.data.success)
} catch (err) {
assert(false, 'Unexpected result')
}
})
it('should return log', async () => {
try {
const options = {
method: 'post',
url: `${LOCALHOST}/logs`,
data: {
password: 'test'
}
}
const result = await axios(options)
assert.isTrue(result.data.success)
assert.isArray(result.data.data)
assert.property(result.data.data[0], 'message')
assert.property(result.data.data[0], 'level')
assert.property(result.data.data[0], 'timestamp')
} catch (err) {
assert(false, 'Unexpected result')
}
})
it('should return false if files are not found!', async () => {
try {
sandbox.stub(uut.logsApiLib, 'getLogs').resolves({
success: false,
data: 'file does not exist'
})
const ctx = mockContext()
ctx.request = {
body: {
password: 'test'
}
}
await uut.getLogs(ctx)
assert.isFalse(ctx.body.success)
assert.include(ctx.body.data, 'file does not exist')
} catch (err) {
assert.fail('Unexpected result')
}
})
it('should catch and handle errors', async () => {
try {
// Force an error
sandbox
.stub(uut.logsApiLib.fs, 'existsSync')
.throws(new Error('test error'))
// Mock the context object.
const ctx = mockContext()
ctx.request = {
body: {
password: 'test'
}
}
await uut.getLogs(ctx)
assert.fail('Unexpected result')
} catch (err) {
assert.include(err.message, 'test error')
}
})
it('should throw unhandled error', async () => {
try {
// Force an error
sandbox.stub(uut.logsApiLib.fs, 'existsSync').throws(new Error())
// Mock the context object.
const ctx = mockContext()
ctx.request = {
body: {
password: 'test'
}
}
await uut.getLogs(ctx)
assert.fail('Unexpected result')
} catch (err) {
assert.include(err.message, 'Unhandled error')
}
})
})
})
@@ -0,0 +1,305 @@
const assert = require('chai').assert
const testUtils = require('../../utils/test-utils')
const Validators = require('../../../src/controllers/rest-api/middleware/validators')
const sinon = require('sinon')
const mockContext = require('../../unit/mocks/ctx-mock').context
const util = require('util')
util.inspect.defaultOptions = { depth: 1 }
const context = {}
let sandbox
let uut
describe('Validators', () => {
before(async () => {
// console.log(`config: ${JSON.stringify(config, null, 2)}`)
// Create a second test user.
const userObj = {
email: 'testvalidator@test.com',
password: 'pass2',
name: 'testvalidator'
}
const testUser = await testUtils.createUser(userObj)
// console.log(`testUser2: ${JSON.stringify(testUser, null, 2)}`)
context.user = testUser.user
context.token = testUser.token
context.id = testUser.user._id
// Get the JWT used to log in as the admin 'system' user.
const adminJWT = await testUtils.getAdminJWT()
// console.log(`adminJWT: ${adminJWT}`)
context.adminJWT = adminJWT
// const admin = await testUtils.loginAdminUser()
// context.adminJWT = admin.token
// const admin = await adminLib.loginAdmin()
// console.log(`admin: ${JSON.stringify(admin, null, 2)}`)
})
beforeEach(() => {
uut = new Validators()
sandbox = sinon.createSandbox()
})
afterEach(() => sandbox.restore())
describe('ensureUser()', () => {
it('should throw 401 if user cant be found', async () => {
try {
// Force an error
sandbox.stub(uut.User, 'findById').resolves(false)
// Mock the context object.
const ctx = mockContext()
ctx.request = {
header: {
authorization: `Bearer ${context.token}`
}
}
await uut.ensureUser(ctx)
assert(false, 'Unexpected result')
} catch (err) {
assert.equal(err.status, 401)
assert.include(err.message, 'Unauthorized')
}
})
it('should throw 401 if token not found', async () => {
try {
// Mock the context object.
const ctx = mockContext()
await uut.ensureUser(ctx)
assert(false, 'Unexpected result')
} catch (err) {
assert.equal(err.status, 401)
assert.include(err.message, 'Unauthorized')
}
})
it('should throw 401 if token is invalid', async () => {
try {
// Mock the context object.
const ctx = mockContext()
ctx.request = {
header: {
authorization: 'Bearer 1'
}
}
await uut.ensureUser(ctx)
assert(false, 'Unexpected result')
} catch (err) {
assert.equal(err.status, 401)
assert.include(err.message, 'Unauthorized')
}
})
it('should return true if user is admin', async () => {
// Mock the context object.
const ctx = mockContext()
ctx.params = { id: context.id }
ctx.request = {
header: {
authorization: `Bearer ${context.adminJWT}`
}
}
const result = await uut.ensureUser(ctx)
assert.equal(result, true)
})
})
describe('ensureAdmin()', () => {
it('should throw 401 if token not found', async () => {
try {
// Mock the context object.
const ctx = mockContext()
await uut.ensureAdmin(ctx)
assert(false, 'Unexpected result')
} catch (err) {
assert.equal(err.status, 401)
assert.include(err.message, 'Unauthorized')
}
})
it('should throw 401 if token is invalid', async () => {
try {
// Mock the context object.
const ctx = mockContext()
ctx.request = {
header: {
authorization: 'Bearer 1'
}
}
await uut.ensureAdmin(ctx)
assert(false, 'Unexpected result')
} catch (err) {
assert.equal(err.status, 401)
assert.include(err.message, 'Unauthorized')
}
})
it('should throw 401 if user cant be found', async () => {
try {
// Force an error
sandbox.stub(uut.User, 'findById').resolves(false)
// Mock the context object.
const ctx = mockContext()
ctx.request = {
header: {
authorization: `Bearer ${context.token}`
}
}
await uut.ensureAdmin(ctx)
assert(false, 'Unexpected result')
} catch (err) {
assert.equal(err.status, 401)
assert.include(err.message, 'Unauthorized')
}
})
it('should throw 401 if user is not admin type', async () => {
try {
// Mock the context object.
const ctx = mockContext()
ctx.request = {
header: {
authorization: `Bearer ${context.token}`
}
}
await uut.ensureAdmin(ctx)
assert(false, 'Unexpected result')
} catch (err) {
assert.equal(err.status, 401)
assert.include(err.message, 'not admin')
}
})
it('should return true if user is admin', async () => {
// Mock the context object.
const ctx = mockContext()
ctx.request = {
header: {
authorization: `Bearer ${context.adminJWT}`
}
}
const result = await uut.ensureAdmin(ctx)
assert.equal(result, true)
})
})
describe('ensureTargetUserOrAdmin()', () => {
it('should throw 401 if token not found', async () => {
try {
// Mock the context object.
const ctx = mockContext()
ctx.params = { id: context.id }
await uut.ensureTargetUserOrAdmin(ctx)
assert(false, 'Unexpected result')
} catch (err) {
assert.equal(err.status, 401)
assert.include(err.message, 'Unauthorized')
}
})
it('should throw 401 if token is invalid', async () => {
try {
// Mock the context object.
const ctx = mockContext()
ctx.params = { id: context.id }
ctx.request = {
header: {
authorization: 'Bearer 1'
}
}
await uut.ensureTargetUserOrAdmin(ctx)
assert(false, 'Unexpected result')
} catch (err) {
assert.equal(err.status, 401)
assert.include(err.message, 'Unauthorized')
}
})
it('should throw 401 if user cant be found', async () => {
try {
// Force an error
sandbox.stub(uut.User, 'findById').resolves(false)
// Mock the context object.
const ctx = mockContext()
ctx.params = { id: context.id }
ctx.request = {
header: {
authorization: `Bearer ${context.token}`
}
}
await uut.ensureTargetUserOrAdmin(ctx)
assert(false, 'Unexpected result')
} catch (err) {
assert.equal(err.status, 401)
assert.include(err.message, 'Unauthorized')
}
})
it('should throw 401 if user is not admin type', async () => {
try {
// Mock the context object.
const ctx = mockContext()
ctx.params = { id: 'Target Id' }
ctx.request = {
header: {
authorization: `Bearer ${context.token}`
}
}
await uut.ensureTargetUserOrAdmin(ctx)
assert(false, 'Unexpected result')
} catch (err) {
assert.equal(err.status, 401)
assert.include(err.message, 'not admin')
}
})
it('should return true if user is admin', async () => {
// Mock the context object.
const ctx = mockContext()
ctx.params = { id: context.id }
ctx.request = {
header: {
authorization: `Bearer ${context.adminJWT}`
}
}
const result = await uut.ensureTargetUserOrAdmin(ctx)
assert.equal(result, true)
})
})
})
+118
View File
@@ -0,0 +1,118 @@
const assert = require('chai').assert
const Admin = require('../../../src/adapters/admin')
const sinon = require('sinon')
const util = require('util')
util.inspect.defaultOptions = { depth: 1 }
let sandbox
let uut
describe('Admin', () => {
beforeEach(() => {
uut = new Admin()
sandbox = sinon.createSandbox()
})
afterEach(() => sandbox.restore())
describe('loginAdmin()', () => {
it('should logind admin', async () => {
try {
const error = new Error('test error')
error.response = {
status: 422
}
// sandbox.stub(uut.axios, 'request').onFirstCall().throws(error)
const result = await uut.loginAdmin()
const user = result.data.user
assert.property(user, '_id')
assert.property(user, 'email')
assert.property(user, 'type')
assert.isString(user._id)
assert.isString(user.email)
assert.isString(user.type)
assert.equal(user.type, 'admin')
} catch (err) {
assert(false, 'Unexpected result')
}
})
it('should handle axios error', async () => {
try {
// Returns an erroneous password to force
// an auth error
sandbox.stub(uut.jsonFiles, 'readJSON').resolves({ password: 'wrong' })
await uut.loginAdmin()
assert(false, 'Unexpected result')
} catch (err) {
assert.equal(err.response.status, 401)
assert.include(err.response.data, 'Unauthorized')
}
})
})
describe('createSystemUser()', () => {
it('should create admin', async () => {
try {
const result = await uut.createSystemUser()
assert.property(result, 'email')
assert.property(result, 'password')
assert.property(result, 'id')
assert.property(result, 'token')
} catch (err) {
assert(false, 'Unexpected result')
}
})
it('should handle axios error', async () => {
try {
const error1 = new Error('test error')
error1.response = {
status: 422
}
const error2 = new Error('test error')
error1.response = {
status: 500
}
// The loginAdmin() function in some use cases is recursive
// after handling the 422 error, it gets called again
sandbox
.stub(uut.axios, 'request')
.onFirstCall()
.throws(error1)
.onSecondCall()
.throws(error2)
await uut.createSystemUser()
assert(false, 'Unexpected result')
} catch (err) {
assert.include(err.message, 'test error')
}
})
it('should handle errors when remove user', async () => {
try {
const error1 = new Error('test error')
error1.response = {
status: 422
}
sandbox.stub(uut.axios, 'request').throws(error1)
sandbox.stub(uut.User, 'deleteOne').throws(new Error('test error'))
await uut.createSystemUser()
assert(false, 'Unexpected result')
} catch (err) {
assert.include(err.message, 'test error')
}
})
})
})
+64
View File
@@ -0,0 +1,64 @@
/*
Automated E2E tests for the /offer endpoint.
*/
// const config = require('../../../config')
// const assert = require('chai').assert
// const axios = require('axios').default
const sinon = require('sinon')
// const LOCALHOST = `http://localhost:${config.port}`
// const OfferController = require('../../../src/controllers/rest-api/offer/controller')
// const mockContext = require('../../unit/mocks/ctx-mock').context
let sandbox
// let uut
describe('OfferApi', () => {
beforeEach(() => {
// uut = new OfferController()
sandbox = sinon.createSandbox()
})
afterEach(() => sandbox.restore())
describe('POST /offer', () => {
// it('should pass data to the handlers', async () => {
// try {
// const mockOffer = {
// lokadId: 'SWP',
// messageType: 1,
// messageClass: 1,
// tokenId: 'token-id',
// buyOrSell: 'sell',
// rateInSats: 1000,
// minSatsToExchange: 1250,
// signature:
// 'H4cRPaAtyNyzG+4Qz+Tp2O7TtFZ7QRsWKKxG71dUZG5xfX0EWRMrBmqM6rH7jToOAT2s9Dm759HMxwP/WTMPzyA=',
// sigMsg: 'test',
// utxoTxid: 'txid-goes-here',
// utxoVout: 1,
// offerIpfsId: 'Qmblah',
// offerBchAddr:
// 'bitcoincash:qzxj37k35z6whp4mj4hrdw2vprx4klcydc6ete5xft',
// offerPubKey: 'pubkeyInHex'
// }
//
// const options = {
// method: 'post',
// url: `${LOCALHOST}/offer`,
// data: { offer: mockOffer }
// }
//
// const result = await axios(options)
// console.log('result.data: ', result.data)
//
// // assert.isFalse(result.data.success)
// } catch (err) {
// assert(false, 'Unexpected result')
// }
// })
})
})
+37
View File
@@ -0,0 +1,37 @@
/*
A manual e2e test for creating an swap offer.
Ensure the REST API is up an running before running this test.
*/
const axios = require('axios')
const LOCALHOST = 'http://localhost:5700'
async function start () {
try {
const mockOffer = {
lokadId: 'SWP',
messageType: 1,
messageClass: 1,
tokenId:
'38e97c5d7d3585a2cbf3f9580c82ca33985f9cb0845d4dcce220cb709f9538b0',
buyOrSell: 'sell',
rateInSats: 1000,
minSatsToExchange: 10,
numTokens: 0.02
}
const options = {
method: 'post',
url: `${LOCALHOST}/offer`,
data: { offer: mockOffer }
}
const result = await axios(options)
console.log('result.data: ', result.data)
} catch (err) {
console.log(err)
}
}
start()
+8
View File
@@ -0,0 +1,8 @@
# Unit Tests
Unit tests are defined as testing the smallest possible unit of a function. They also do not make any live network calls.
Unit tests are broken up by directory:
- [biz-logic](./biz-logic) tests the business logic libraries.
- [rest-api](./rest-api) tests the REST API specific handling of the router.
- json-rpc (coming soon) tests the JSON-RPC routing using ipfs-coord library.
+153
View File
@@ -0,0 +1,153 @@
const assert = require('chai').assert
const BCHJS = require('../../../src/adapters/bch')
const sinon = require('sinon')
const util = require('util')
util.inspect.defaultOptions = { depth: 1 }
const mockData = require('../mocks/bchjs-mock')
let sandbox
let uut
describe('bch', () => {
beforeEach(() => {
uut = new BCHJS()
sandbox = sinon.createSandbox()
})
afterEach(() => sandbox.restore())
describe('#_verifySignature', () => {
it('should return true for valid signature', () => {
const offerBchAddr =
'bitcoincash:qphjncqpnv444jq8acqk4dkm3296c50xhqggeatvn8'
const signature =
'Hz8bi9CsHaYkk5SGtHLU0aaxFspEXz7IdBNn6xV8ejE6OCuIRoZuVE9QJsGSlJ3Rt0ez2LWD0e292NZ84rRwnfk='
const sigMsg = 'example.com'
const verifyObj = { offerBchAddr, signature, sigMsg }
const result = uut._verifySignature(verifyObj)
assert.equal(result, true)
})
it('should return false for invalid signature', () => {
const offerBchAddr =
'bitcoincash:qphjncqpnv444jq8acqk4dkm3296c50xhqggeatvn8'
const signature =
'Hz8bi9CsHaYkk5SGtHLU0aaxFspEXz7IdBNn6xV8ejE6OCuIRoZuVE9QJsGSlJ3Rt0ez2LWD0e292NZ84rRwnfd='
const sigMsg = 'example.com'
const verifyObj = { offerBchAddr, signature, sigMsg }
const result = uut._verifySignature(verifyObj)
assert.equal(result, false)
})
it('should catch and throw errors', () => {
try {
// Force an error
sandbox
.stub(uut.bchjs.BitcoinCash, 'verifyMessage')
.throws(new Error('test error'))
const offerBchAddr =
'bitcoincash:qphjncqpnv444jq8acqk4dkm3296c50xhqggeatvn8'
const signature =
'Hz8bi9CsHaYkk5SGtHLU0aaxFspEXz7IdBNn6xV8ejE6OCuIRoZuVE9QJsGSlJ3Rt0ez2LWD0e292NZ84rRwnfk='
const sigMsg = 'example.com'
const verifyObj = { offerBchAddr, signature, sigMsg }
uut._verifySignature(verifyObj)
assert.fail('Unexpected result')
} catch (err) {
assert.include(err.message, 'test error')
}
})
})
describe('#getPSFTokenBalance', () => {
it('should throw error if slpAddress is not provided', async () => {
try {
await uut.getPSFTokenBalance()
assert.fail('Unexpected result')
} catch (err) {
assert.include(err.message, 'slpAddress must be a string')
}
})
it('should return psf tokens balance', async () => {
// Mock live network calls.
sandbox
.stub(uut.bchjs.SLP.Utils, 'balancesForAddress')
.resolves(mockData.psfBalances)
const slpAddress =
'simpleledger:qp49th03gvjn58d6fxzaga6u09w4z56smyuk43lzkd'
const result = await uut.getPSFTokenBalance(slpAddress)
assert.isNumber(result)
assert.equal(result, 2)
})
it('should return 0 if the slp address does not has Psf tokens', async () => {
// Mock live network calls.
sandbox
.stub(uut.bchjs.SLP.Utils, 'balancesForAddress')
.resolves(mockData.noPsfBalances)
const slpAddress =
'simpleledger:qp49th03gvjn58d6fxzaga6u09w4z56smyuk43lzkd'
const result = await uut.getPSFTokenBalance(slpAddress)
assert.isNumber(result)
assert.equal(result, 0)
})
it('should return 0 for empty balances', async () => {
// Mock live network calls.
sandbox.stub(uut.bchjs.SLP.Utils, 'balancesForAddress').resolves([])
const slpAddress =
'simpleledger:qp49th03gvjn58d6fxzaga6u09w4z56smyuk43lzkd'
const result = await uut.getPSFTokenBalance(slpAddress)
assert.isNumber(result)
assert.equal(result, 0)
})
})
describe('#getPSFTokenBalance', () => {
it('should throw error if slpAddr is not provided', async () => {
try {
await uut.getMerit()
assert.fail('Unexpected result')
} catch (err) {
assert.include(err.message, 'slpAddr must be a string')
}
})
it('should throw error if slpAddr provided is invalid type', async () => {
try {
await uut.getMerit(1)
assert.fail('Unexpected result')
} catch (err) {
assert.include(err.message, 'slpAddr must be a string')
}
})
it('should return the merit ', async () => {
try {
// Mock live network calls.
sandbox.stub(uut.msgLib.merit, 'agMerit').resolves(100)
const slpAddr =
'simpleledger:qqgnksc6zr4nzxrye69fq625wu2myxey6uh9kzjy96'
const merit = await uut.getMerit(slpAddr)
assert.isNumber(merit)
} catch (err) {
assert.fail('Unexpected result')
}
})
})
})

Some files were not shown because too many files have changed in this diff Show More