diff --git a/lib/compliance-external.js b/lib/compliance-external.js new file mode 100644 index 00000000..466b77e9 --- /dev/null +++ b/lib/compliance-external.js @@ -0,0 +1,80 @@ +const _ = require('lodash/fp') + +const logger = require('./logger') +const configManager = require('./new-config-manager') +const ph = require('./plugin-helper') + +const getPlugin = (settings, pluginCode) => { + const account = settings.accounts[pluginCode] + const plugin = ph.load(ph.COMPLIANCE, pluginCode) + + return ({ plugin, account }) +} + +const getStatus = (settings, service, customerId) => { + try { + const { plugin, account } = getPlugin(settings, service) + + return plugin.getApplicantStatus(account, customerId) + .then((status) => ({ + service, + status + })) + .catch((error) => { + if (error.response.status !== 404) logger.error(`Error getting applicant for service ${service}:`, error.message) + return { + service: service, + status: null, + } + }) + } catch (error) { + logger.error(`Error loading plugin for service ${service}:`, error) + return Promise.resolve({ + service: service, + status: null, + }) + } + +} + +const getStatusMap = (settings, customerExternalCompliance) => { + const triggers = configManager.getTriggers(settings.config) + const services = _.flow( + _.map('externalService'), + _.compact, + _.uniq + )(triggers) + + const applicantPromises = _.map(service => { + return getStatus(settings, service, customerExternalCompliance) + })(services) + + return Promise.all(applicantPromises) + .then((applicantResults) => { + return _.reduce((map, result) => { + if (result.status) map[result.service] = result.status + return map + }, {})(applicantResults) + }) +} + +const createApplicant = (settings, externalService, customerId) => { + const account = settings.accounts[externalService] + const { plugin } = getPlugin(settings, externalService) + + return plugin.createApplicant(account, customerId, account.applicantLevel) +} + +const createLink = (settings, externalService, customerId) => { + const account = settings.accounts[externalService] + const { plugin } = getPlugin(settings, externalService) + + return plugin.createLink(account, customerId, account.applicantLevel) +} + +module.exports = { + getStatusMap, + getStatus, + createApplicant, + createLink +} diff --git a/lib/customers.js b/lib/customers.js index 62ef15a3..cb7c588f 100644 --- a/lib/customers.js +++ b/lib/customers.js @@ -17,6 +17,9 @@ const NUM_RESULTS = 1000 const sms = require('./sms') const settingsLoader = require('./new-settings-loader') const logger = require('./logger') +const externalCompliance = require('./compliance-external') + +const { APPROVED, RETRY } = require('./plugins/compliance/consts') const TX_PASSTHROUGH_ERROR_CODES = ['operatorCancel', 'scoreThresholdReached', 'walletScoringError'] @@ -243,7 +246,7 @@ function deleteEditedData (id, data) { 'id_card_data', 'id_card_photo', 'us_ssn', - 'subcriber_info', + 'subscriber_info', 'name' ] const filteredData = _.pick(defaults, _.mapKeys(_.snakeCase, data)) @@ -322,6 +325,7 @@ function getById (id) { return db.oneOrNone(sql, [id]) .then(assignCustomerData) .then(getCustomInfoRequestsData) + .then(getExternalComplianceMachine) .then(camelize) } @@ -342,7 +346,11 @@ function camelize (customer) { function camelizeDeep (customer) { return _.flow( camelize, - it => ({ ...it, notes: (it.notes ?? []).map(camelize) }) + it => ({ + ...it, + notes: (it.notes ?? []).map(camelize), + externalCompliance: (it.externalCompliance ?? []).map(camelize) + }) )(customer) } @@ -587,6 +595,7 @@ function getCustomerById (id) { return db.oneOrNone(sql, [passableErrorCodes, id]) .then(assignCustomerData) .then(getCustomInfoRequestsData) + .then(getExternalCompliance) .then(camelizeDeep) .then(formatSubscriberInfo) } @@ -927,6 +936,95 @@ function updateLastAuthAttempt (customerId) { return db.none(sql, [customerId]) } +function getExternalComplianceMachine (customer) { + return settingsLoader.loadLatest() + .then(settings => externalCompliance.getStatusMap(settings, customer.id)) + .then(statusMap => { + return updateExternalComplianceByMap(customer.id, statusMap) + .then(() => customer.externalCompliance = statusMap) + .then(() => customer) + }) +} + +function updateExternalCompliance(customerId, service, status) { + const sql = ` + UPDATE customer_external_compliance SET last_known_status = $1, last_updated = now() + WHERE customer_id=$2 AND service=$3 + ` + return db.none(sql, [status, customerId, service]) +} + +function updateExternalComplianceByMap(customerId, serviceMap) { + const sql = ` + UPDATE customer_external_compliance SET last_known_status = $1, last_updated = now() + WHERE customer_id=$2 AND service=$3 + ` + const pairs = _.toPairs(serviceMap) + const promises = _.map(([service, status]) => db.none(sql, [status.answer, customerId, service]))(pairs) + return Promise.all(promises) +} + +function getExternalCompliance(customer) { + const sql = `SELECT external_id, service, last_known_status, last_updated + FROM customer_external_compliance where customer_id=$1` + return db.manyOrNone(sql, [customer.id]) + .then(compliance => { + customer.externalCompliance = compliance + }) + .then(() => customer) +} + +function getOpenExternalCompliance() { + const sql = `SELECT customer_id, service, last_known_status FROM customer_external_compliance where last_known_status in ('PENDING', 'RETRY') or last_known_status is null` + return db.manyOrNone(sql) +} + +function notifyRetryExternalCompliance(settings, customerId, service) { + const sql = 'SELECT phone FROM customers WHERE id=$1' + const promises = [db.one(sql, [customerId]), externalCompliance.createLink(settings, service, customerId)] + + return Promise.all(promises) + .then(([toNumber, link]) => { + const body = `Your external compliance verification has failed. Please try again. Link for retry: ${link}` + + return sms.sendMessage(settings, { toNumber, body }) + }) +} + +function notifyApprovedExternalCompliance(settings, customerId) { + const sql = 'SELECT phone FROM customers WHERE id=$1' + return db.one(sql, [customerId]) + .then((toNumber) => { + const body = 'Your external compliance verification has been approved.' + + return sms.sendMessage(settings, { toNumber, body }) + }) +} + +function checkExternalCompliance(settings) { + return getOpenExternalCompliance() + .then(externals => { + console.log(externals) + const promises = _.map(external => { + return externalCompliance.getStatus(settings, external.service, external.customer_id) + .then(status => { + console.log('status', status, external.customer_id, external.service) + if (status.status.answer === RETRY) notifyRetryExternalCompliance(settings, external.customer_id, status.service) + if (status.status.answer === APPROVED) notifyApprovedExternalCompliance(settings, external.customer_id) + + return updateExternalCompliance(external.customer_id, external.service, status.status.answer) + }) + }, externals) + return Promise.all(promises) + }) +} + +function addExternalCompliance(customerId, service, id) { + const sql = `INSERT INTO customer_external_compliance (customer_id, external_id, service) VALUES ($1, $2, $3)` + return db.none(sql, [customerId, id, service]) +} + + module.exports = { add, addWithEmail, @@ -950,5 +1048,7 @@ module.exports = { updateTxCustomerPhoto, enableTestCustomer, disableTestCustomer, - updateLastAuthAttempt + updateLastAuthAttempt, + addExternalCompliance, + checkExternalCompliance } diff --git a/lib/graphql/types.js b/lib/graphql/types.js index 7977e522..26622c37 100644 --- a/lib/graphql/types.js +++ b/lib/graphql/types.js @@ -109,6 +109,7 @@ type Trigger { thresholdDays: Int customInfoRequestId: String customInfoRequest: CustomInfoRequest + externalService: String } type TermsDetails { diff --git a/lib/new-admin/config/accounts.js b/lib/new-admin/config/accounts.js index e5e5231e..ae60a507 100644 --- a/lib/new-admin/config/accounts.js +++ b/lib/new-admin/config/accounts.js @@ -15,6 +15,7 @@ const ID_VERIFIER = 'idVerifier' const EMAIL = 'email' const ZERO_CONF = 'zeroConf' const WALLET_SCORING = 'wallet_scoring' +const COMPLIANCE = 'compliance' const ALL_ACCOUNTS = [ { code: 'bitfinex', display: 'Bitfinex', class: TICKER, cryptos: bitfinex.CRYPTO }, @@ -61,7 +62,9 @@ const ALL_ACCOUNTS = [ { code: 'blockcypher', display: 'Blockcypher', class: ZERO_CONF, cryptos: [BTC] }, { code: 'mock-zero-conf', display: 'Mock 0-conf', class: ZERO_CONF, cryptos: ALL_CRYPTOS, dev: true }, { code: 'scorechain', display: 'Scorechain', class: WALLET_SCORING, cryptos: [BTC, ETH, LTC, BCH, DASH, USDT, USDT_TRON, TRX] }, - { code: 'mock-scoring', display: 'Mock scoring', class: WALLET_SCORING, cryptos: ALL_CRYPTOS, dev: true } + { code: 'mock-scoring', display: 'Mock scoring', class: WALLET_SCORING, cryptos: ALL_CRYPTOS, dev: true }, + { code: 'sumsub', display: 'Sumsub', class: COMPLIANCE }, + { code: 'mock-compliance', display: 'Mock Compliance', class: COMPLIANCE, dev: true }, ] const devMode = require('minimist')(process.argv.slice(2)).dev diff --git a/lib/new-admin/graphql/types/customer.type.js b/lib/new-admin/graphql/types/customer.type.js index d238fda6..fe140032 100644 --- a/lib/new-admin/graphql/types/customer.type.js +++ b/lib/new-admin/graphql/types/customer.type.js @@ -40,6 +40,7 @@ const typeDef = gql` customInfoRequests: [CustomRequestData] notes: [CustomerNote] isTestCustomer: Boolean + externalCompliance: [JSONObject] } input CustomerInput { diff --git a/lib/new-settings-loader.js b/lib/new-settings-loader.js index a87c25cd..302c35df 100644 --- a/lib/new-settings-loader.js +++ b/lib/new-settings-loader.js @@ -29,7 +29,9 @@ const SECRET_FIELDS = [ 'inforu.apiKey', 'galoy.walletId', 'galoy.apiSecret', - 'bitfinex.secret' + 'bitfinex.secret', + 'sumsub.apiToken', + 'sumsub.privateKey' ] /* diff --git a/lib/plugin-helper.js b/lib/plugin-helper.js index 6af93c74..de189979 100644 --- a/lib/plugin-helper.js +++ b/lib/plugin-helper.js @@ -11,7 +11,8 @@ const pluginCodes = { LAYER2: 'layer2', SMS: 'sms', EMAIL: 'email', - ZERO_CONF: 'zero-conf' + ZERO_CONF: 'zero-conf', + COMPLIANCE: 'compliance' } module.exports = _.assign({load}, pluginCodes) diff --git a/lib/plugins/compliance/consts.js b/lib/plugins/compliance/consts.js new file mode 100644 index 00000000..c6291189 --- /dev/null +++ b/lib/plugins/compliance/consts.js @@ -0,0 +1,6 @@ +module.exports = { + PENDING: 'PENDING', + RETRY: 'RETRY', + APPROVED: 'APPROVED', + REJECTED: 'REJECTED' +} \ No newline at end of file diff --git a/lib/plugins/compliance/mock-compliance/mock-compliance.js b/lib/plugins/compliance/mock-compliance/mock-compliance.js new file mode 100644 index 00000000..954b8072 --- /dev/null +++ b/lib/plugins/compliance/mock-compliance/mock-compliance.js @@ -0,0 +1,31 @@ +const uuid = require('uuid') + +const {APPROVED} = require('../consts') + +const CODE = 'mock-compliance' + +const createLink = (settings, userId, level) => { + return `this is a mock external link, ${userId}, ${level}` +} + +const getApplicantStatus = (account, userId) => { + return Promise.resolve({ + service: CODE, + status: { + level: account.applicantLevel, answer: APPROVED + } + }) +} + +const createApplicant = () => { + return Promise.resolve({ + id: uuid.v4() + }) +} + +module.exports = { + CODE, + createApplicant, + getApplicantStatus, + createLink +} diff --git a/lib/plugins/compliance/sumsub/request.js b/lib/plugins/compliance/sumsub/request.js new file mode 100644 index 00000000..f102f996 --- /dev/null +++ b/lib/plugins/compliance/sumsub/request.js @@ -0,0 +1,34 @@ +const axios = require('axios') +const crypto = require('crypto') +const _ = require('lodash/fp') +const FormData = require('form-data') + +const axiosConfig = { + baseURL: 'https://api.sumsub.com' +} + +const getSigBuilder = (apiToken, secretKey) => config => { + const timestamp = Math.floor(Date.now() / 1000) + const signature = crypto.createHmac('sha256', secretKey) + + signature.update(`${timestamp}${_.toUpper(config.method)}${config.url}`) + if (config.data instanceof FormData) { + signature.update(config.data.getBuffer()) + } else if (config.data) { + signature.update(JSON.stringify(config.data)) + } + + config.headers['X-App-Token'] = apiToken + config.headers['X-App-Access-Sig'] = signature.digest('hex') + config.headers['X-App-Access-Ts'] = timestamp + + return config +} + +const request = ((account, config) => { + const instance = axios.create(axiosConfig) + instance.interceptors.request.use(getSigBuilder(account.apiToken, account.secretKey), Promise.reject) + return instance(config) +}) + +module.exports = request diff --git a/lib/plugins/compliance/sumsub/sumsub.api.js b/lib/plugins/compliance/sumsub/sumsub.api.js new file mode 100644 index 00000000..a7d7557a --- /dev/null +++ b/lib/plugins/compliance/sumsub/sumsub.api.js @@ -0,0 +1,98 @@ +const request = require('./request') + +const createApplicant = (account, userId, level) => { + if (!userId || !level) { + return Promise.reject(`Missing required fields: userId: ${userId}, level: ${level}`) + } + + const config = { + method: 'POST', + url: `/resources/applicants?levelName=${level}`, + data: { + externalUserId: userId, + sourceKey: 'lamassu' + }, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + } + + return request(account, config) +} + +const createLink = (account, userId, level) => { + if (!userId || !level) { + return Promise.reject(`Missing required fields: userId: ${userId}, level: ${level}`) + } + + const config = { + method: 'POST', + url: `/resources/sdkIntegrations/levels/${level}/websdkLink?ttlInSecs=${600}&externalUserId=${userId}`, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + } + + return request(account, config) +} + +const getApplicantByExternalId = (account, id) => { + if (!id) { + return Promise.reject('Missing required fields: id') + } + + const config = { + method: 'GET', + url: `/resources/applicants/-;externalUserId=${id}/one`, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + } + + return request(account, config) +} + +const getApplicantStatus = (account, id) => { + if (!id) { + return Promise.reject(`Missing required fields: id`) + } + + const config = { + method: 'GET', + url: `/resources/applicants/${id}/status`, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + } + + return request(account, config) +} + +const getApplicantById = (account, id) => { + if (!id) { + return Promise.reject(`Missing required fields: id`) + } + + const config = { + method: 'GET', + url: `/resources/applicants/${id}/one`, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + } + + return request(account, config) +} + +module.exports = { + createLink, + createApplicant, + getApplicantByExternalId, + getApplicantById, + getApplicantStatus +} diff --git a/lib/plugins/compliance/sumsub/sumsub.js b/lib/plugins/compliance/sumsub/sumsub.js new file mode 100644 index 00000000..80e2d2ea --- /dev/null +++ b/lib/plugins/compliance/sumsub/sumsub.js @@ -0,0 +1,52 @@ +const _ = require('lodash/fp') + +const sumsubApi = require('./sumsub.api') +const { PENDING, RETRY, APPROVED, REJECTED } = require('../consts') + +const CODE = 'sumsub' + +const getApplicantByExternalId = (account, userId) => { + return sumsubApi.getApplicantByExternalId(account, userId) + .then(r => r.data) +} + +const createApplicant = (account, userId, level) => { + return sumsubApi.createApplicant(account, userId, level) + .then(r => r.data) + .catch(err => { + if (err.response.status === 409) return getApplicantByExternalId(account, userId) + throw err + }) +} + +const createLink = (account, userId, level) => { + return sumsubApi.createLink(account, userId, level) + .then(r => r.data.url) +} + +const getApplicantStatus = (account, userId) => { + return sumsubApi.getApplicantByExternalId(account, userId) + .then(r => { + const levelName = _.get('data.review.levelName', r) + const reviewStatus = _.get('data.review.reviewStatus', r) + const reviewAnswer = _.get('data.review.reviewResult.reviewAnswer', r) + const reviewRejectType = _.get('data.review.reviewResult.reviewRejectType', r) + + // if last review was from a different level, return the current level and RETRY + if (levelName !== account.applicantLevel) return { level: account.applicantLevel, answer: RETRY } + + let answer = PENDING + if (reviewAnswer === 'GREEN' && reviewStatus === 'completed') answer = APPROVED + if (reviewAnswer === 'RED' && reviewRejectType === 'RETRY') answer = RETRY + if (reviewAnswer === 'RED' && reviewRejectType === 'FINAL') answer = REJECTED + + return { level: levelName, answer } + }) +} + +module.exports = { + CODE, + createApplicant, + getApplicantStatus, + createLink +} \ No newline at end of file diff --git a/lib/poller.js b/lib/poller.js index ca002612..3c33c93b 100644 --- a/lib/poller.js +++ b/lib/poller.js @@ -6,6 +6,7 @@ const T = require('./time') const logger = require('./logger') const cashOutTx = require('./cash-out/cash-out-tx') const cashInTx = require('./cash-in/cash-in-tx') +const customers = require('./customers') const sanctionsUpdater = require('./ofac/update') const sanctions = require('./ofac/index') const coinAtmRadar = require('./coinatmradar/coinatmradar') @@ -31,6 +32,7 @@ const RADAR_UPDATE_INTERVAL = 5 * T.minutes const PRUNE_MACHINES_HEARTBEAT = 1 * T.day const TRANSACTION_BATCH_LIFECYCLE = 20 * T.minutes const TICKER_RATES_INTERVAL = 59 * T.seconds +const EXTERNAL_COMPLIANCE_INTERVAL = 1 * T.minutes const CHECK_NOTIFICATION_INTERVAL = 20 * T.seconds const PENDING_INTERVAL = 10 * T.seconds @@ -127,6 +129,10 @@ function updateCoinAtmRadar () { .then(rates => coinAtmRadar.update(rates, settings())) } +// function checkExternalCompliance (settings) { +// return customers.checkExternalCompliance(settings) +// } + function initializeEachSchema (schemas = ['public']) { // for each schema set "thread variables" and do polling return _.forEach(schema => { @@ -190,6 +196,7 @@ function doPolling (schema) { pi().sweepHd() notifier.checkNotification(pi()) updateCoinAtmRadar() + // checkExternalCompliance(settings()) addToQueue(pi().getRawRates, TICKER_RATES_INTERVAL, schema, QUEUE.FAST) addToQueue(pi().executeTrades, TRADE_INTERVAL, schema, QUEUE.FAST) @@ -206,6 +213,7 @@ function doPolling (schema) { addToQueue(updateAndLoadSanctions, SANCTIONS_UPDATE_INTERVAL, schema, QUEUE.SLOW) addToQueue(updateCoinAtmRadar, RADAR_UPDATE_INTERVAL, schema, QUEUE.SLOW) addToQueue(pi().pruneMachinesHeartbeat, PRUNE_MACHINES_HEARTBEAT, schema, QUEUE.SLOW, settings) + // addToQueue(checkExternalCompliance, EXTERNAL_COMPLIANCE_INTERVAL, schema, QUEUE.SLOW, settings) } function setup (schemasToAdd = [], schemasToRemove = []) { diff --git a/lib/routes/customerRoutes.js b/lib/routes/customerRoutes.js index d0e11431..0ebb4128 100644 --- a/lib/routes/customerRoutes.js +++ b/lib/routes/customerRoutes.js @@ -25,6 +25,7 @@ const plugins = require('../plugins') const Tx = require('../tx') const loyalty = require('../loyalty') const logger = require('../logger') +const externalCompliance = require('../compliance-external') function updateCustomerCustomInfoRequest (customerId, patch) { const promise = _.isNil(patch.data) ? @@ -234,6 +235,28 @@ function sendSmsReceipt (req, res, next) { }) } +function getExternalComplianceLink (req, res, next) { + const customerId = req.query.customer + const triggerId = req.query.trigger + const isRetry = req.query.isRetry + if (_.isNil(customerId) || _.isNil(triggerId)) return next(httpError('Not Found', 404)) + + const settings = req.settings + const triggers = configManager.getTriggers(settings.config) + const trigger = _.find(it => it.id === triggerId)(triggers) + const externalService = trigger.externalService + + if (isRetry) { + return externalCompliance.createLink(settings, externalService, customerId) + .then(url => respond(req, res, { url })) + } + + return externalCompliance.createApplicant(settings, externalService, customerId) + .then(applicant => customers.addExternalCompliance(customerId, externalService, applicant.id)) + .then(() => externalCompliance.createLink(settings, externalService, customerId)) + .then(url => respond(req, res, { url })) +} + function addOrUpdateCustomer (customerData, config, isEmailAuth) { const triggers = configManager.getTriggers(config) const maxDaysThreshold = complianceTriggers.maxDaysThreshold(triggers) @@ -311,6 +334,7 @@ router.patch('/:id/suspend', triggerSuspend) router.patch('/:id/photos/idcarddata', updateIdCardData) router.patch('/:id/:txId/photos/customerphoto', updateTxCustomerPhoto) router.post('/:id/smsreceipt', sendSmsReceipt) +router.get('/external', getExternalComplianceLink) router.post('/phone_code', getOrAddCustomerPhone) router.post('/email_code', getOrAddCustomerEmail) diff --git a/migrations/1718464437502-integrate-sumsub.js b/migrations/1718464437502-integrate-sumsub.js new file mode 100644 index 00000000..e47348c3 --- /dev/null +++ b/migrations/1718464437502-integrate-sumsub.js @@ -0,0 +1,21 @@ +const db = require('./db') + +exports.up = function (next) { + let sql = [ + `CREATE TYPE EXTERNAL_COMPLIANCE_STATUS AS ENUM('PENDING', 'APPROVED', 'REJECTED', 'RETRY')`, + `CREATE TABLE CUSTOMER_EXTERNAL_COMPLIANCE ( + customer_id UUID NOT NULL REFERENCES customers(id), + service TEXT NOT NULL, + external_id TEXT NOT NULL, + last_known_status EXTERNAL_COMPLIANCE_STATUS, + last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (customer_id, service) + )` + ] + + db.multi(sql, next) +} + +exports.down = function (next) { + next() +} diff --git a/new-lamassu-admin/src/components/buttons/DeleteButton.js b/new-lamassu-admin/src/components/buttons/DeleteButton.js new file mode 100644 index 00000000..c1c31082 --- /dev/null +++ b/new-lamassu-admin/src/components/buttons/DeleteButton.js @@ -0,0 +1,53 @@ +import { makeStyles } from '@material-ui/core/styles' +import classnames from 'classnames' +import React, { memo } from 'react' + +import typographyStyles from 'src/components/typography/styles' +import { ReactComponent as DeleteIcon } from 'src/styling/icons/button/cancel/zodiac.svg' +import { zircon, zircon2, comet, fontColor, white } from 'src/styling/variables' + +const { p } = typographyStyles + +const styles = { + button: { + extend: p, + border: 'none', + backgroundColor: zircon, + cursor: 'pointer', + outline: 0, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + width: 167, + height: 48, + color: fontColor, + '&:hover': { + backgroundColor: zircon2 + }, + '&:active': { + backgroundColor: comet, + color: white, + '& svg g *': { + stroke: white + } + }, + '& svg': { + marginRight: 8 + } + } +} + +const useStyles = makeStyles(styles) + +const SimpleButton = memo(({ className, children, ...props }) => { + const classes = useStyles() + + return ( + + ) +}) + +export default SimpleButton diff --git a/new-lamassu-admin/src/components/buttons/index.js b/new-lamassu-admin/src/components/buttons/index.js index 42530ee3..e0e34f7d 100644 --- a/new-lamassu-admin/src/components/buttons/index.js +++ b/new-lamassu-admin/src/components/buttons/index.js @@ -1,6 +1,7 @@ import ActionButton from './ActionButton' import AddButton from './AddButton' import Button from './Button' +import DeleteButton from './DeleteButton' import FeatureButton from './FeatureButton' import IDButton from './IDButton' import IconButton from './IconButton' @@ -19,5 +20,6 @@ export { IDButton, AddButton, SupportLinkButton, - SubpageButton + SubpageButton, + DeleteButton } diff --git a/new-lamassu-admin/src/pages/Customers/CustomerData.js b/new-lamassu-admin/src/pages/Customers/CustomerData.js index a2aa9a81..049c0227 100644 --- a/new-lamassu-admin/src/pages/Customers/CustomerData.js +++ b/new-lamassu-admin/src/pages/Customers/CustomerData.js @@ -64,7 +64,7 @@ const Photo = ({ show, src }) => { const CustomerData = ({ locale, - customer, + customer = {}, updateCustomer, replacePhoto, editCustomer, @@ -399,6 +399,33 @@ const CustomerData = ({ }) }, R.keys(smsData) ?? []) + const externalCompliance = R.map(it => ({ + fields: [ + { + name: 'externalId', + label: 'Third Party ID', + editable: false + }, + { + name: 'lastKnownStatus', + label: 'Last Known Status', + editable: false + }, + { + name: 'lastUpdated', + label: 'Last Updated', + editable: false + } + ], + titleIcon: , + title: `External Info [${it.service}]`, + initialValues: it ?? { + externalId: '', + lastKnownStatus: '', + lastUpdated: '' + } + }))(customer.externalCompliance ?? []) + const editableCard = ( { title, @@ -440,6 +467,24 @@ const CustomerData = ({ ) } + const nonEditableCard = ( + { title, state, titleIcon, fields, hasImage, initialValues, children }, + idx + ) => { + return ( + + ) + } + const visibleCards = getVisibleCards(cards) return ( @@ -514,6 +559,25 @@ const CustomerData = ({ )} + {!R.isEmpty(externalCompliance) && ( +
+ + External compliance information + + + + {externalCompliance.map((elem, idx) => { + return isEven(idx) ? nonEditableCard(elem, idx) : null + })} + + + {externalCompliance.map((elem, idx) => { + return !isEven(idx) ? nonEditableCard(elem, idx) : null + })} + + +
+ )} {retrieveAdditionalDataDialog} diff --git a/new-lamassu-admin/src/pages/Customers/CustomerProfile.js b/new-lamassu-admin/src/pages/Customers/CustomerProfile.js index 5e95d4fb..cc8c54bf 100644 --- a/new-lamassu-admin/src/pages/Customers/CustomerProfile.js +++ b/new-lamassu-admin/src/pages/Customers/CustomerProfile.js @@ -82,6 +82,7 @@ const GET_CUSTOMER = gql` isTestCustomer subscriberInfo phoneOverride + externalCompliance customFields { id label @@ -153,6 +154,7 @@ const SET_CUSTOMER = gql` lastTxClass subscriberInfo phoneOverride + externalCompliance } } ` diff --git a/new-lamassu-admin/src/pages/Customers/components/EditableCard.js b/new-lamassu-admin/src/pages/Customers/components/EditableCard.js index 287fc52a..1dc71165 100644 --- a/new-lamassu-admin/src/pages/Customers/components/EditableCard.js +++ b/new-lamassu-admin/src/pages/Customers/components/EditableCard.js @@ -8,7 +8,6 @@ import { useState, React } from 'react' import ErrorMessage from 'src/components/ErrorMessage' import PromptWhenDirty from 'src/components/PromptWhenDirty' import { MainStatus } from 'src/components/Status' -// import { HoverableTooltip } from 'src/components/Tooltip' import { ActionButton } from 'src/components/buttons' import { Label1, P, H3 } from 'src/components/typography' import { diff --git a/new-lamassu-admin/src/pages/Dashboard/Footer/Footer.js b/new-lamassu-admin/src/pages/Dashboard/Footer/Footer.js index 5ff9bdd3..eefaab13 100644 --- a/new-lamassu-admin/src/pages/Dashboard/Footer/Footer.js +++ b/new-lamassu-admin/src/pages/Dashboard/Footer/Footer.js @@ -1,4 +1,3 @@ -/* eslint-disable no-unused-vars */ import { useQuery } from '@apollo/react-hooks' import { makeStyles } from '@material-ui/core' import Grid from '@material-ui/core/Grid' @@ -6,7 +5,7 @@ import BigNumber from 'bignumber.js' import classnames from 'classnames' import gql from 'graphql-tag' import * as R from 'ramda' -import React, { useState } from 'react' +import React from 'react' import { Label2 } from 'src/components/typography' import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg' @@ -38,7 +37,7 @@ const Footer = () => { const withCommissions = R.path(['cryptoRates', 'withCommissions'])(data) ?? {} const classes = useStyles() const config = R.path(['config'])(data) ?? {} - const canExpand = R.keys(withCommissions).length > 4 + // const canExpand = R.keys(withCommissions).length > 4 const wallets = fromNamespace('wallets')(config) const cryptoCurrencies = R.path(['cryptoCurrencies'])(data) ?? [] diff --git a/new-lamassu-admin/src/pages/Services/schemas/index.js b/new-lamassu-admin/src/pages/Services/schemas/index.js index b7b90c23..f1a1b53a 100644 --- a/new-lamassu-admin/src/pages/Services/schemas/index.js +++ b/new-lamassu-admin/src/pages/Services/schemas/index.js @@ -12,6 +12,7 @@ import itbit from './itbit' import kraken from './kraken' import mailgun from './mailgun' import scorechain from './scorechain' +import sumsub from './sumsub' import telnyx from './telnyx' import trongrid from './trongrid' import twilio from './twilio' @@ -35,5 +36,6 @@ export default { [scorechain.code]: scorechain, [trongrid.code]: trongrid, [binance.code]: binance, - [bitfinex.code]: bitfinex + [bitfinex.code]: bitfinex, + [sumsub.code]: sumsub } diff --git a/new-lamassu-admin/src/pages/Services/schemas/sumsub.js b/new-lamassu-admin/src/pages/Services/schemas/sumsub.js new file mode 100644 index 00000000..ee156a9e --- /dev/null +++ b/new-lamassu-admin/src/pages/Services/schemas/sumsub.js @@ -0,0 +1,44 @@ +import * as Yup from 'yup' + +import { SecretInput, TextInput } from 'src/components/inputs/formik' + +import { secretTest } from './helper' + +const schema = { + code: 'sumsub', + name: 'Sumsub', + title: 'Sumsub (Compliance)', + elements: [ + { + code: 'apiToken', + display: 'API Token', + component: SecretInput + }, + { + code: 'secretKey', + display: 'Secret Key', + component: SecretInput + }, + { + code: 'applicantLevel', + display: 'Applicant Level', + component: TextInput, + face: true + } + ], + getValidationSchema: account => { + return Yup.object().shape({ + apiToken: Yup.string('The API token must be a string') + .max(100, 'The API token is too long') + .test(secretTest(account?.apiToken, 'API token')), + secretKey: Yup.string('The secret key must be a string') + .max(100, 'The secret key is too long') + .test(secretTest(account?.secretKey, 'secret key')), + applicantLevel: Yup.string('The applicant level must be a string') + .max(100, 'The applicant level is too long') + .required('The applicant level is required') + }) + } +} + +export default schema diff --git a/new-lamassu-admin/src/pages/Triggers/TriggerView.js b/new-lamassu-admin/src/pages/Triggers/TriggerView.js index 6957fbfc..5333184a 100644 --- a/new-lamassu-admin/src/pages/Triggers/TriggerView.js +++ b/new-lamassu-admin/src/pages/Triggers/TriggerView.js @@ -28,8 +28,9 @@ const TriggerView = ({ config, toggleWizard, addNewTriger, - customInfoRequests, - emailAuth + emailAuth, + complianceServices, + customInfoRequests }) => { const currency = R.path(['fiatCurrency'])( fromNamespace(namespaces.LOCALE)(config) @@ -78,7 +79,9 @@ const TriggerView = ({ save={add} onClose={() => toggleWizard(true)} customInfoRequests={customInfoRequests} + complianceServices={complianceServices} emailAuth={emailAuth} + triggers={triggers} /> )} {R.isEmpty(triggers) && ( diff --git a/new-lamassu-admin/src/pages/Triggers/Triggers.js b/new-lamassu-admin/src/pages/Triggers/Triggers.js index 9560cda4..2292de5a 100644 --- a/new-lamassu-admin/src/pages/Triggers/Triggers.js +++ b/new-lamassu-admin/src/pages/Triggers/Triggers.js @@ -42,6 +42,12 @@ const GET_CONFIG = gql` query getData { config accounts + accountsConfig { + code + display + class + cryptos + } } ` @@ -75,6 +81,9 @@ const Triggers = () => { const emailAuth = data?.config?.triggersConfig_customerAuthentication === 'EMAIL' + const complianceServices = R.filter(R.propEq('class', 'compliance'))( + data?.accountsConfig || [] + ) const triggers = fromServer(data?.config?.triggers ?? []) const complianceConfig = data?.config && fromNamespace('compliance')(data.config) @@ -135,7 +144,7 @@ const Triggers = () => { return ( <> { config={data?.config ?? {}} toggleWizard={toggleWizard('newTrigger')} addNewTriger={addNewTriger} - customInfoRequests={enabledCustomInfoRequests} emailAuth={emailAuth} + complianceServices={complianceServices} + customInfoRequests={enabledCustomInfoRequests} /> )} {!loading && subMenu === 'advancedSettings' && ( diff --git a/new-lamassu-admin/src/pages/Triggers/Wizard.js b/new-lamassu-admin/src/pages/Triggers/Wizard.js index 0e699f7b..8b978410 100644 --- a/new-lamassu-admin/src/pages/Triggers/Wizard.js +++ b/new-lamassu-admin/src/pages/Triggers/Wizard.js @@ -48,14 +48,27 @@ const styles = { const useStyles = makeStyles(styles) -const getStep = (step, currency, customInfoRequests, emailAuth) => { +const getStep = ( + { step, config }, + currency, + customInfoRequests, + complianceServices, + emailAuth, + triggers +) => { switch (step) { // case 1: // return txDirection case 1: return type(currency) case 2: - return requirements(customInfoRequests, emailAuth) + return requirements( + config, + triggers, + customInfoRequests, + complianceServices, + emailAuth + ) default: return Fragment } @@ -166,6 +179,8 @@ const getRequirementText = (config, classes) => { return <>blocked case 'custom': return <>asked to fulfill a custom requirement + case 'external': + return <>redirected to an external verification process default: return orUnderline(null, classes) } @@ -210,7 +225,9 @@ const Wizard = ({ error, currency, customInfoRequests, - emailAuth + complianceServices, + emailAuth, + triggers }) => { const classes = useStyles() @@ -220,7 +237,14 @@ const Wizard = ({ }) const isLastStep = step === LAST_STEP - const stepOptions = getStep(step, currency, customInfoRequests, emailAuth) + const stepOptions = getStep( + { step, config }, + currency, + customInfoRequests, + complianceServices, + emailAuth, + triggers + ) const onContinue = async it => { const newConfig = R.merge(config, stepOptions.schema.cast(it)) diff --git a/new-lamassu-admin/src/pages/Triggers/helper.js b/new-lamassu-admin/src/pages/Triggers/helper.js index c91ea450..e25a51eb 100644 --- a/new-lamassu-admin/src/pages/Triggers/helper.js +++ b/new-lamassu-admin/src/pages/Triggers/helper.js @@ -9,6 +9,7 @@ import { NumberInput, RadioGroup, Dropdown } from 'src/components/inputs/formik' import { H4, Label2, Label1, Info1, Info2 } from 'src/components/typography' import { errorColor } from 'src/styling/variables' import { transformNumber } from 'src/utils/number' +import { onlyFirstToUpper } from 'src/utils/string' // import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg' // import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-out.svg' @@ -82,6 +83,14 @@ const useStyles = makeStyles({ dropdownField: { marginTop: 16, minWidth: 155 + }, + externalFields: { + '& > *': { + marginRight: 15 + }, + '& > *:last-child': { + marginRight: 0 + } } }) @@ -488,6 +497,13 @@ const requirementSchema = Yup.object() otherwise: Yup.string() .nullable() .transform(() => '') + }), + externalService: Yup.string().when('requirement', { + is: value => value === 'external', + then: Yup.string(), + otherwise: Yup.string() + .nullable() + .transform(() => '') }) }).required() }) @@ -502,6 +518,10 @@ const requirementSchema = Yup.object() return requirement.requirement === type ? !R.isNil(requirement.customInfoRequestId) : true + case 'external': + return requirement.requirement === type + ? !R.isNil(requirement.externalService) + : true default: return true } @@ -518,6 +538,12 @@ const requirementSchema = Yup.object() path: 'requirement', message: 'You must select an item' }) + + if (requirement && !requirementValidator(requirement, 'external')) + return context.createError({ + path: 'requirement', + message: 'You must select an item' + }) }) const requirementOptions = [ @@ -530,7 +556,8 @@ const requirementOptions = [ { display: 'US SSN', code: 'usSsn' }, // { display: 'Super user', code: 'superuser' }, { display: 'Suspend', code: 'suspend' }, - { display: 'Block', code: 'block' } + { display: 'Block', code: 'block' }, + { display: 'External Verification', code: 'external' } ] const hasRequirementError = (errors, touched, values) => @@ -545,7 +572,18 @@ const hasCustomRequirementError = (errors, touched, values) => (!values.requirement?.customInfoRequestId || !R.isNil(values.requirement?.customInfoRequestId)) -const Requirement = ({ customInfoRequests, emailAuth }) => { +const hasExternalRequirementError = (errors, touched, values) => + !!errors.requirement && + !!touched.requirement?.externalService && + !values.requirement?.externalService + +const Requirement = ({ + config = {}, + triggers, + emailAuth, + complianceServices, + customInfoRequests = [] +}) => { const classes = useStyles() const { touched, @@ -557,27 +595,55 @@ const Requirement = ({ customInfoRequests, emailAuth }) => { const isSuspend = values?.requirement?.requirement === 'suspend' const isCustom = values?.requirement?.requirement === 'custom' + const isExternal = values?.requirement?.requirement === 'external' + + const customRequirementsInUse = R.reduce( + (acc, value) => { + if (value.requirement.requirement === 'custom') + acc.push({ + triggerType: value.triggerType, + id: value.requirement.customInfoRequestId + }) + return acc + }, + [], + triggers + ) + + const availableCustomRequirements = R.filter( + it => + !R.includes( + { triggerType: config.triggerType, id: it.id }, + customRequirementsInUse + ), + customInfoRequests + ) + const makeCustomReqOptions = () => - customInfoRequests.map(it => ({ + availableCustomRequirements.map(it => ({ value: it.id, display: it.customRequest.name })) - const enableCustomRequirement = customInfoRequests?.length > 0 + const enableCustomRequirement = !R.isEmpty(availableCustomRequirements) + const customInfoOption = { display: 'Custom information requirement', code: 'custom' } + const itemToRemove = emailAuth ? 'sms' : 'email' const reqOptions = requirementOptions.filter(it => it.code !== itemToRemove) - const options = enableCustomRequirement - ? [...reqOptions, customInfoOption] - : [...reqOptions] + const options = R.clone(reqOptions) + + enableCustomRequirement && options.push(customInfoOption) + const titleClass = { [classes.error]: (!!errors.requirement && !isSuspend && !isCustom) || (isSuspend && hasRequirementError(errors, touched, values)) || - (isCustom && hasCustomRequirementError(errors, touched, values)) + (isCustom && hasCustomRequirementError(errors, touched, values)) || + (isExternal && hasExternalRequirementError(errors, touched, values)) } return ( @@ -620,22 +686,50 @@ const Requirement = ({ customInfoRequests, emailAuth }) => { /> )} + {isExternal && ( +
+ ({ + value: it.code, + display: it.display + }))} + /> +
+ )} ) } -const requirements = (customInfoRequests, emailAuth) => ({ +const requirements = ( + config, + triggers, + customInfoRequests, + complianceServices, + emailAuth +) => ({ schema: requirementSchema, options: requirementOptions, Component: Requirement, - props: { customInfoRequests, emailAuth }, + props: { + config, + triggers, + customInfoRequests, + emailAuth, + complianceServices + }, hasRequirementError: hasRequirementError, hasCustomRequirementError: hasCustomRequirementError, + hasExternalRequirementError: hasExternalRequirementError, initialValues: { requirement: { requirement: '', suspensionDays: '', - customInfoRequestId: '' + customInfoRequestId: '', + externalService: '' } } }) @@ -665,7 +759,7 @@ const customReqIdMatches = customReqId => it => { return it.id === customReqId } -const RequirementInput = ({ customInfoRequests }) => { +const RequirementInput = ({ customInfoRequests = [] }) => { const { values } = useFormikContext() const classes = useStyles() @@ -700,7 +794,8 @@ const RequirementView = ({ requirement, suspensionDays, customInfoRequestId, - customInfoRequests + externalService, + customInfoRequests = [] }) => { const classes = useStyles() const display = @@ -708,6 +803,8 @@ const RequirementView = ({ ? R.path(['customRequest', 'name'])( R.find(customReqIdMatches(customInfoRequestId))(customInfoRequests) ) ?? '' + : requirement === 'external' + ? `External Verification (${onlyFirstToUpper(externalService)})` : getView(requirementOptions, 'display')(requirement) const isSuspend = requirement === 'suspend' return ( @@ -840,7 +937,7 @@ const getElements = (currency, classes, customInfoRequests) => [ { name: 'requirement', size: 'sm', - width: 230, + width: 260, bypassField: true, input: () => , view: it => ( @@ -850,7 +947,7 @@ const getElements = (currency, classes, customInfoRequests) => [ { name: 'threshold', size: 'sm', - width: 284, + width: 254, textAlign: 'right', input: () => , view: (it, config) => @@ -877,7 +974,7 @@ const sortBy = [ ) ] -const fromServer = (triggers, customInfoRequests) => { +const fromServer = triggers => { return R.map( ({ requirement, @@ -885,12 +982,14 @@ const fromServer = (triggers, customInfoRequests) => { threshold, thresholdDays, customInfoRequestId, + externalService, ...rest }) => ({ requirement: { requirement, suspensionDays, - customInfoRequestId + customInfoRequestId, + externalService }, threshold: { threshold, @@ -908,6 +1007,7 @@ const toServer = triggers => threshold: threshold.threshold, thresholdDays: threshold.thresholdDays, customInfoRequestId: requirement.customInfoRequestId, + externalService: requirement.externalService, ...rest }))(triggers) diff --git a/package-lock.json b/package-lock.json index 6a64b6c0..95e48023 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1342,7 +1342,7 @@ "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==" }, "bitcoinjs-message": { - "version": "npm:@bitgo-forks/bitcoinjs-message@1.0.0-master.2", + "version": "npm:bitcoinjs-message@1.0.0-master.2", "resolved": "https://registry.npmjs.org/@bitgo-forks/bitcoinjs-message/-/bitcoinjs-message-1.0.0-master.2.tgz", "integrity": "sha512-XSDGM3rA75vcDxeKqHPexika/TgWUFWdfKTv1lV8TZTb5XFHHD6ARckLdMOBiCf29eZSzbJQvF/OIWqNqMl/2A==", "requires": { @@ -4714,36 +4714,6 @@ "wif": "^2.0.1" } }, - "bitcoinjs-message": { - "version": "npm:@bitgo-forks/bitcoinjs-message@1.0.0-master.2", - "resolved": "https://registry.npmjs.org/@bitgo-forks/bitcoinjs-message/-/bitcoinjs-message-1.0.0-master.2.tgz", - "integrity": "sha512-XSDGM3rA75vcDxeKqHPexika/TgWUFWdfKTv1lV8TZTb5XFHHD6ARckLdMOBiCf29eZSzbJQvF/OIWqNqMl/2A==", - "requires": { - "bech32": "^1.1.3", - "bs58check": "^2.1.2", - "buffer-equals": "^1.0.3", - "create-hash": "^1.1.2", - "secp256k1": "5.0.0", - "varuint-bitcoin": "^1.0.1" - }, - "dependencies": { - "bech32": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz", - "integrity": "sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==" - }, - "secp256k1": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-5.0.0.tgz", - "integrity": "sha512-TKWX8xvoGHrxVdqbYeZM9w+izTF4b9z3NhSaDkdn81btvuh+ivbIMGT/zQvDtTFWhRlThpoz6LEYTr7n8A5GcA==", - "requires": { - "elliptic": "^6.5.4", - "node-addon-api": "^5.0.0", - "node-gyp-build": "^4.2.0" - } - } - } - }, "bitcore-lib": { "version": "8.25.47", "resolved": "https://registry.npmjs.org/bitcore-lib/-/bitcore-lib-8.25.47.tgz", @@ -8644,12 +8614,12 @@ "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==" }, "form-data": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", - "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", "requires": { "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", + "combined-stream": "^1.0.8", "mime-types": "^2.1.12" } }, @@ -12203,6 +12173,16 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==" }, + "form-data": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + }, "get-uri": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-2.0.4.tgz", @@ -16423,6 +16403,16 @@ "readable-stream": "^2.3.5" }, "dependencies": { + "form-data": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + }, "readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", diff --git a/package.json b/package.json index 9d3d2ece..fbc13130 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "ethereumjs-wallet": "^0.6.3", "express": "4.17.1", "express-session": "^1.17.1", + "form-data": "^4.0.0", "futoin-hkdf": "^1.0.2", "got": "^7.1.0", "graphql": "^15.5.0",