├── .npmrc ├── .lintstagedrc.json ├── .npmignore ├── .husky ├── pre-commit └── commit-msg ├── src ├── utils │ ├── string.js │ ├── timeout-error.js │ ├── errors │ │ ├── forbidden-error.ts │ │ ├── bad-request-error.ts │ │ ├── unprocessable-error.ts │ │ └── authentication │ │ │ ├── authorization-error.js │ │ │ ├── two-factor-authentication-required-error.js │ │ │ ├── secret-not-found-error.js │ │ │ └── inconsistent-secret-rendering-error.js │ ├── join-url.js │ ├── data.js │ ├── integrations.js │ ├── get-ip-from-request.js │ ├── error.js │ ├── error-messages.js │ ├── schema.js │ ├── json.js │ ├── widgets.js │ └── services-builder.js ├── routes │ ├── forest.js │ ├── healthcheck.js │ └── scopes.js ├── context │ ├── service-builder.js │ ├── build-values.js │ ├── build-utils.js │ ├── build-external.js │ └── build-services.js ├── integrations │ ├── close.io │ │ ├── services │ │ │ ├── closeio-lead-getter.js │ │ │ ├── closeio-lead-email-getter.js │ │ │ ├── closeio-lead-emails-getter.js │ │ │ ├── closeio-lead-creator.js │ │ │ └── closeio-customer-lead-getter.js │ │ ├── serializers │ │ │ ├── closeio-lead-emails.js │ │ │ └── closeio-leads.js │ │ └── setup.js │ ├── stripe │ │ ├── services │ │ │ ├── payment-refunder.js │ │ │ ├── payment-getter.js │ │ │ ├── invoice-getter.js │ │ │ ├── subscription-getter.js │ │ │ ├── source-getter.js │ │ │ ├── invoices-getter.js │ │ │ ├── sources-getter.js │ │ │ ├── payments-getter.js │ │ │ └── subscriptions-getter.js │ │ └── serializers │ │ │ ├── cards.js │ │ │ ├── bank-accounts.js │ │ │ ├── payments.js │ │ │ ├── invoices.js │ │ │ └── subscriptions.js │ ├── intercom │ │ ├── services │ │ │ ├── conversation-getter.js │ │ │ ├── integration-informations-getter.js │ │ │ ├── contact-getter.js │ │ │ └── attributes-getter.js │ │ ├── serializers │ │ │ ├── intercom-conversation.js │ │ │ ├── intercom-attributes.js │ │ │ └── intercom-conversations.js │ │ ├── routes.js │ │ └── setup.js │ ├── layer │ │ ├── services │ │ │ ├── conversation-getter.js │ │ │ ├── messages-getter.js │ │ │ └── conversations-getter.js │ │ ├── serializers │ │ │ ├── messages.js │ │ │ └── conversations.js │ │ └── setup.js │ ├── mixpanel │ │ ├── serializers │ │ │ └── mixpanel-events.js │ │ ├── routes.js │ │ ├── services │ │ │ └── mixpanel-events-getter.js │ │ └── setup.js │ └── index.js ├── services │ ├── exposed │ │ ├── records-remover.js │ │ ├── record-getter.js │ │ ├── record-remover.js │ │ ├── record-creator.js │ │ ├── record-updater.js │ │ ├── records-exporter.js │ │ ├── records-counter.ts │ │ ├── abstract-records-service.js │ │ ├── record-serializer.js │ │ └── error-handler.js │ ├── authorization │ │ ├── errors │ │ │ ├── custom-action-trigger-forbidden-error.ts │ │ │ ├── invalid-action-condition-error.ts │ │ │ ├── unsupported-conditional-error.ts │ │ │ ├── approval-not-allowed-error.ts │ │ │ └── custom-action-requires-approval-error.ts │ │ └── types.ts │ ├── path.js │ ├── integration-informations-getter.js │ ├── apimap-fields-formater.js │ ├── logger.js │ ├── scope-manager.js │ ├── project-directory-finder.js │ ├── ip-whitelist.js │ ├── models-manager.js │ ├── forest-server-requester.js │ ├── oidc-configuration-retriever.js │ ├── auth.js │ ├── apimap-sender.js │ ├── oidc-client-manager.js │ ├── token.js │ ├── smart-action-hook-service.js │ ├── csv-exporter.js │ ├── authorization-finder.js │ └── authentication.js ├── deserializers │ ├── smart-action-hook.js │ ├── params-fields.js │ ├── ip-whitelist.js │ ├── query.js │ └── resource.js ├── serializers │ └── stat.js ├── middlewares │ ├── count.js │ └── ip-whitelist.js ├── config │ └── jwt.ts └── generators │ └── schemas.js ├── .babelrc ├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ └── build.yml ├── SECURITY.md ├── test ├── deserializers │ ├── params-fields.test.js │ ├── ip-whitelist.test.js │ └── resource.test.js ├── helpers │ ├── request.js │ └── create-server.js ├── utils │ ├── data.test.js │ ├── timeout-error.test.js │ ├── integrations.test.js │ ├── string.test.js │ ├── widgets.test.js │ ├── get-ip-from-request.test.js │ ├── join-url.test.js │ └── json.test.js ├── routes │ ├── healthcheck.test.js │ └── scopes.test.js ├── services │ ├── ip-whitelist.test.js │ ├── path.unit.test.js │ ├── apimap-fields-formater.test.js │ ├── csv-exporter.test.js │ ├── error.test.js │ ├── oidc-configuration-retriever.test.js │ └── token.test.js ├── middlewares │ └── deactivate-count.unit.test.js ├── context │ └── index.test.js └── fixtures │ ├── users-schema.js │ └── cars-schema.js ├── .gitignore ├── commitlint.config.js ├── README.md ├── .releaserc.js └── .eslintrc.js /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.[jt]s": ["eslint --cache --quiet --fix"] 3 | } 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | .forestadmin-schema.json 3 | .github 4 | .yarn-error.log 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit "$1" 5 | -------------------------------------------------------------------------------- /src/utils/string.js: -------------------------------------------------------------------------------- 1 | const Inflector = require('inflected'); 2 | 3 | exports.parameterize = (value) => (value ? Inflector.parameterize(value.trim()) : ''); 4 | -------------------------------------------------------------------------------- /src/utils/timeout-error.js: -------------------------------------------------------------------------------- 1 | const timeoutError = (url, error) => { 2 | if (error.timeout) { 3 | return `The request to Forest Admin server has timed out while trying to reach ${url} at ${new Date().toISOString()}`; 4 | } 5 | return null; 6 | }; 7 | 8 | module.exports = timeoutError; 9 | -------------------------------------------------------------------------------- /src/routes/forest.js: -------------------------------------------------------------------------------- 1 | const path = require('../services/path'); 2 | 3 | module.exports = function Forest(app, options) { 4 | this.perform = () => { 5 | app.get(path.generate('', options), (request, response) => { 6 | response.status(204).send(); 7 | }); 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /src/utils/errors/forbidden-error.ts: -------------------------------------------------------------------------------- 1 | export default class ForbiddenError extends Error { 2 | public readonly status: number; 3 | 4 | constructor(message?: string) { 5 | super(message || 'Forbidden'); 6 | this.name = this.constructor.name; 7 | this.status = 403; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-typescript"], 3 | "plugins": [ 4 | "@babel/plugin-transform-runtime", 5 | "@babel/plugin-proposal-class-properties", 6 | "@babel/plugin-transform-classes", 7 | "@babel/plugin-proposal-optional-chaining" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/errors/bad-request-error.ts: -------------------------------------------------------------------------------- 1 | export default class BadRequestError extends Error { 2 | public readonly status: number; 3 | 4 | constructor(message?: string) { 5 | super(message || 'Bad Request'); 6 | this.name = this.constructor.name; 7 | this.status = 400; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/errors/unprocessable-error.ts: -------------------------------------------------------------------------------- 1 | export default class UnprocessableError extends Error { 2 | public readonly status: number; 3 | 4 | constructor(message?: string) { 5 | super(message || 'Unprocessable'); 6 | this.name = this.constructor.name; 7 | this.status = 422; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/context/service-builder.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | module.exports = (plan) => plan 3 | .addPackage('externals', require('./build-external')) 4 | .addPackage('values', require('./build-values')) 5 | .addPackage('utils', require('./build-utils')) 6 | .addPackage('services', require('./build-services').default); 7 | -------------------------------------------------------------------------------- /src/routes/healthcheck.js: -------------------------------------------------------------------------------- 1 | const cors = require('cors'); 2 | const path = require('../services/path'); 3 | 4 | module.exports = function HealthCheck(app, opts) { 5 | this.perform = () => { 6 | app.get(path.generate('healthcheck', opts), cors(), (request, response) => { 7 | response.status(200).send(); 8 | }); 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /src/integrations/close.io/services/closeio-lead-getter.js: -------------------------------------------------------------------------------- 1 | function CloseioLeadGetter(Implementation, params, opts) { 2 | const Closeio = opts.integrations.closeio.closeio; 3 | const closeio = new Closeio(opts.integrations.closeio.apiKey); 4 | 5 | this.perform = () => closeio._get(`/lead/${params.leadId}`); 6 | } 7 | 8 | module.exports = CloseioLeadGetter; 9 | -------------------------------------------------------------------------------- /src/utils/errors/authentication/authorization-error.js: -------------------------------------------------------------------------------- 1 | class AuthorizationError extends Error { 2 | constructor(status, message) { 3 | super(message || 'Error while authorizing the user on Forest Admin'); 4 | 5 | this.name = 'AuthorizationError'; 6 | this.status = status || 500; 7 | } 8 | } 9 | 10 | module.exports = AuthorizationError; 11 | -------------------------------------------------------------------------------- /src/services/exposed/records-remover.js: -------------------------------------------------------------------------------- 1 | const AbstractRecordService = require('./abstract-records-service'); 2 | 3 | class RecordsRemover extends AbstractRecordService { 4 | remove(ids) { 5 | return new this.Implementation.ResourcesRemover(this.model, this.params, ids, this.user) 6 | .perform(); 7 | } 8 | } 9 | 10 | module.exports = RecordsRemover; 11 | -------------------------------------------------------------------------------- /src/integrations/close.io/services/closeio-lead-email-getter.js: -------------------------------------------------------------------------------- 1 | function CloseioLeadGetter(Implementation, params, opts) { 2 | const Closeio = opts.integrations.closeio.closeio; 3 | const closeio = new Closeio(opts.integrations.closeio.apiKey); 4 | 5 | this.perform = () => closeio._get(`/activity/email/${params.emailId}`); 6 | } 7 | 8 | module.exports = CloseioLeadGetter; 9 | -------------------------------------------------------------------------------- /src/utils/errors/authentication/two-factor-authentication-required-error.js: -------------------------------------------------------------------------------- 1 | class TwoFactorAuthenticationRequiredError extends Error { 2 | constructor() { 3 | super('Two factor authentication required'); 4 | 5 | this.name = 'TwoFactorAuthenticationRequiredError'; 6 | this.status = 403; 7 | } 8 | } 9 | 10 | module.exports = TwoFactorAuthenticationRequiredError; 11 | -------------------------------------------------------------------------------- /src/utils/join-url.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {string} baseUrl 3 | * @param {...string} parts 4 | * @returns string 5 | */ 6 | function joinUrl(baseUrl, ...parts) { 7 | return [ 8 | baseUrl, 9 | ...parts, 10 | ].filter(Boolean) 11 | .map((part) => part 12 | .replace(/^\//, '') 13 | .replace(/\/$/, '')) 14 | .join('/'); 15 | } 16 | 17 | module.exports = joinUrl; 18 | -------------------------------------------------------------------------------- /src/services/authorization/errors/custom-action-trigger-forbidden-error.ts: -------------------------------------------------------------------------------- 1 | import ForbiddenError from '../../../utils/errors/forbidden-error'; 2 | 3 | export default class CustomActionTriggerForbiddenError extends ForbiddenError { 4 | constructor() { 5 | super("You don't have permission to trigger this action."); 6 | 7 | this.name = 'CustomActionTriggerForbiddenError'; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/services/exposed/record-getter.js: -------------------------------------------------------------------------------- 1 | const AbstractRecordService = require('./abstract-records-service'); 2 | 3 | class RecordGetter extends AbstractRecordService { 4 | get(recordId) { 5 | return new this.Implementation.ResourceGetter( 6 | this.model, 7 | { ...this.params, recordId }, 8 | this.user, 9 | ) 10 | .perform(); 11 | } 12 | } 13 | 14 | module.exports = RecordGetter; 15 | -------------------------------------------------------------------------------- /src/services/exposed/record-remover.js: -------------------------------------------------------------------------------- 1 | const AbstractRecordService = require('./abstract-records-service'); 2 | 3 | class RecordRemover extends AbstractRecordService { 4 | remove(recordId) { 5 | return new this.Implementation.ResourceRemover( 6 | this.model, 7 | { ...this.params, recordId }, 8 | this.user, 9 | ) 10 | .perform(); 11 | } 12 | } 13 | 14 | module.exports = RecordRemover; 15 | -------------------------------------------------------------------------------- /src/deserializers/smart-action-hook.js: -------------------------------------------------------------------------------- 1 | class SmartActionHookDeserializer { 2 | // eslint-disable-next-line class-methods-use-this 3 | deserialize(requestBody) { 4 | const { 5 | fields, 6 | changed_field: changedField, 7 | } = requestBody.data.attributes; 8 | 9 | return { 10 | fields, 11 | changedField, 12 | }; 13 | } 14 | } 15 | 16 | module.exports = SmartActionHookDeserializer; 17 | -------------------------------------------------------------------------------- /src/serializers/stat.js: -------------------------------------------------------------------------------- 1 | const JSONAPISerializer = require('jsonapi-serializer').Serializer; 2 | const uuidV1 = require('uuid/v1'); 3 | 4 | function StatSerializer(stat) { 5 | stat.id = uuidV1(); 6 | 7 | this.perform = () => 8 | new JSONAPISerializer('stats', stat, { 9 | attributes: ['value', 'objective'], 10 | keyForAttribute: (key) => key, 11 | }); 12 | } 13 | 14 | module.exports = StatSerializer; 15 | -------------------------------------------------------------------------------- /src/deserializers/params-fields.js: -------------------------------------------------------------------------------- 1 | function ParamsFieldsDeserializer(paramsFields) { 2 | this.perform = () => { 3 | if (paramsFields) { 4 | return Object.keys(paramsFields).reduce((fields, modelName) => { 5 | fields[modelName] = paramsFields[modelName].split(','); 6 | return fields; 7 | }, {}); 8 | } 9 | return null; 10 | }; 11 | } 12 | 13 | module.exports = ParamsFieldsDeserializer; 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Expected behavior 2 | 3 | TODO: Please describe here the behavior you are expecting. 4 | 5 | ## Actual behavior 6 | 7 | TODO: What is the current behavior? 8 | 9 | ## Failure Logs 10 | 11 | TODO: Please include any relevant log snippets, if necessary. 12 | 13 | ## Context 14 | 15 | TODO: Please provide any relevant information about your setup. 16 | 17 | * Package Version: 18 | * Express Version: 19 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Definition of Done 2 | 3 | ### General 4 | 5 | - [ ] Write an explicit title for the Pull Request, following [Conventional Commits specification](https://www.conventionalcommits.org) 6 | - [ ] Test manually the implemented changes 7 | - [ ] Validate the code quality (indentation, syntax, style, simplicity, readability) 8 | 9 | ### Security 10 | 11 | - [ ] Consider the security impact of the changes made 12 | -------------------------------------------------------------------------------- /src/services/authorization/types.ts: -------------------------------------------------------------------------------- 1 | export type User = { 2 | id: number; 3 | renderingId: number; 4 | email: string; 5 | tags: Record; 6 | }; 7 | 8 | export type GenericPlainTreeBranch = { aggregator: string; conditions: Array }; 9 | export type GenericPlainTreeLeaf = { field: string; operator: string; value?: unknown }; 10 | export type GenericPlainTree = GenericPlainTreeBranch | GenericPlainTreeLeaf; 11 | -------------------------------------------------------------------------------- /src/utils/errors/authentication/secret-not-found-error.js: -------------------------------------------------------------------------------- 1 | class SecretNotFoundError extends Error { 2 | constructor() { 3 | super('Cannot retrieve the project you\'re trying to unlock. ' 4 | + 'Please check that you\'re using the right environment secret regarding your project and environment.'); 5 | 6 | this.name = 'SecretNotFoundError'; 7 | this.status = 500; 8 | } 9 | } 10 | 11 | module.exports = SecretNotFoundError; 12 | -------------------------------------------------------------------------------- /src/services/authorization/errors/invalid-action-condition-error.ts: -------------------------------------------------------------------------------- 1 | import UnprocessableError from '../../../utils/errors/unprocessable-error'; 2 | 3 | export default class InvalidActionConditionError extends UnprocessableError { 4 | constructor() { 5 | super( 6 | 'The conditions to trigger this action cannot be verified. Please contact an administrator.', 7 | ); 8 | 9 | this.name = 'InvalidActionConditionError'; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | To report a security vulnerability, please use the [Forest Admin security email](mailto:security@forestadmin.com). 6 | 7 | Our technical team will consider your request carefully. 8 | 9 | If the vulnerability report is accepted, Forest Admin will: 10 | - work on a fix of the current version with the highest priority, 11 | - let you know as soon as a new patched version is published. 12 | -------------------------------------------------------------------------------- /src/utils/errors/authentication/inconsistent-secret-rendering-error.js: -------------------------------------------------------------------------------- 1 | const errorMessages = require('../../error-messages'); 2 | 3 | class InconsistentSecretAndRenderingError extends Error { 4 | constructor() { 5 | super(errorMessages.SERVER_TRANSACTION.SECRET_AND_RENDERINGID_INCONSISTENT); 6 | 7 | this.name = 'InconsistentSecretAndRenderingError'; 8 | this.status = 500; 9 | } 10 | } 11 | 12 | module.exports = InconsistentSecretAndRenderingError; 13 | -------------------------------------------------------------------------------- /src/integrations/close.io/services/closeio-lead-emails-getter.js: -------------------------------------------------------------------------------- 1 | function CloseioLeadEmailsGetter(Implementation, params, opts) { 2 | const Closeio = opts.integrations.closeio.closeio; 3 | const closeio = new Closeio(opts.integrations.closeio.apiKey); 4 | 5 | this.perform = () => 6 | closeio._get(`/activity/email/?lead_id=${params.leadId}`) 7 | .then((results) => [results.data.length, results.data]); 8 | } 9 | 10 | module.exports = CloseioLeadEmailsGetter; 11 | -------------------------------------------------------------------------------- /src/utils/data.js: -------------------------------------------------------------------------------- 1 | exports.find = (data, path) => { 2 | if (data) { 3 | if (path) { 4 | const keys = path.split('.'); 5 | let value = data; 6 | // eslint-disable-next-line consistent-return 7 | keys.forEach((key) => { 8 | if (key in value && value[key]) { 9 | value = value[key]; 10 | } else { 11 | value = null; 12 | } 13 | }); 14 | return value; 15 | } 16 | } 17 | return data; 18 | }; 19 | -------------------------------------------------------------------------------- /src/services/authorization/errors/unsupported-conditional-error.ts: -------------------------------------------------------------------------------- 1 | import UnprocessableError from '../../../utils/errors/unprocessable-error'; 2 | 3 | export default class UnsupportedConditionalsError extends UnprocessableError { 4 | constructor() { 5 | super( 6 | 'The roles conditions (conditional smart actions) are not supported with Smart Collection. Please contact an administrator.', 7 | ); 8 | 9 | this.name = 'UnsupportedConditionalsFeatureError'; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/integrations.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | 3 | exports.pushIntoApimap = (apimap, collection) => { 4 | const existingCollection = _.remove(apimap, (currentCollection) => 5 | currentCollection.name === collection.name); 6 | 7 | if (existingCollection.length) { 8 | if (!collection.actions) { 9 | collection.actions = []; 10 | } 11 | collection.actions = collection.actions.concat(existingCollection[0].actions); 12 | } 13 | 14 | apimap.push(collection); 15 | }; 16 | -------------------------------------------------------------------------------- /src/services/authorization/errors/approval-not-allowed-error.ts: -------------------------------------------------------------------------------- 1 | import ForbiddenError from '../../../utils/errors/forbidden-error'; 2 | 3 | export default class ApprovalNotAllowedError extends ForbiddenError { 4 | data: { roleIdsAllowedToApprove: number[] }; 5 | 6 | constructor(roleIdsAllowedToApprove: number[]) { 7 | super("You don't have permission to approve this action."); 8 | 9 | this.name = 'ApprovalNotAllowedError'; 10 | this.data = { 11 | roleIdsAllowedToApprove, 12 | }; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/integrations/close.io/serializers/closeio-lead-emails.js: -------------------------------------------------------------------------------- 1 | const JSONAPISerializer = require('jsonapi-serializer').Serializer; 2 | 3 | function serializeCloseioLeadEmails(attributes, collectionName, meta) { 4 | const type = `${collectionName}_closeio_emails`; 5 | 6 | return new JSONAPISerializer(type, attributes, { 7 | attributes: ['id', 'status', 'sender', 'subject', 'body_text'], 8 | keyForAttribute(key) { return key; }, 9 | meta, 10 | }); 11 | } 12 | 13 | module.exports = serializeCloseioLeadEmails; 14 | -------------------------------------------------------------------------------- /src/services/authorization/errors/custom-action-requires-approval-error.ts: -------------------------------------------------------------------------------- 1 | import ForbiddenError from '../../../utils/errors/forbidden-error'; 2 | 3 | export default class CustomActionRequiresApprovalError extends ForbiddenError { 4 | data: { roleIdsAllowedToApprove: number[] }; 5 | 6 | constructor(roleIdsAllowedToApprove: number[]) { 7 | super('This action requires to be approved.'); 8 | 9 | this.name = 'CustomActionRequiresApprovalError'; 10 | this.data = { 11 | roleIdsAllowedToApprove, 12 | }; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/middlewares/count.js: -------------------------------------------------------------------------------- 1 | const deactivateCountMiddleware = (request, response) => { 2 | const { path } = request; 3 | const splittedPath = path.split('/'); 4 | 5 | if (splittedPath[splittedPath.length - 1] === 'count') { 6 | response.status(200).send({ 7 | meta: { 8 | count: 'deactivated', 9 | }, 10 | }); 11 | } else { 12 | response.status(400).send({ 13 | error: 'The deactiveCount middleware can only be used in count routes.', 14 | }); 15 | } 16 | }; 17 | 18 | module.exports = deactivateCountMiddleware; 19 | -------------------------------------------------------------------------------- /test/deserializers/params-fields.test.js: -------------------------------------------------------------------------------- 1 | const ParamsFieldsDeserializer = require('../../src/deserializers/params-fields'); 2 | 3 | describe('deserializers > params-fields', () => { 4 | it('should split commas separated strings into arrays', () => { 5 | const paramsFields = { model: 'field1,field2,field3' }; 6 | const expectedResult = { model: ['field1', 'field2', 'field3'] }; 7 | const actualResult = (new ParamsFieldsDeserializer(paramsFields)).perform(); 8 | 9 | expect(actualResult).toStrictEqual(expectedResult); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/integrations/close.io/services/closeio-lead-creator.js: -------------------------------------------------------------------------------- 1 | function CloseioLeadCreator(Implementation, params, opts) { 2 | const Closeio = opts.integrations.closeio.closeio; 3 | const closeio = new Closeio(opts.integrations.closeio.apiKey); 4 | 5 | this.perform = () => { 6 | const attrs = params.data.attributes.values; 7 | return closeio.lead.create({ 8 | name: attrs['Company/Organization Name'], 9 | contacts: [{ 10 | name: attrs['Contact Name'], 11 | }], 12 | }); 13 | }; 14 | } 15 | 16 | module.exports = CloseioLeadCreator; 17 | -------------------------------------------------------------------------------- /src/deserializers/ip-whitelist.js: -------------------------------------------------------------------------------- 1 | const P = require('bluebird'); 2 | 3 | function IpWhitelistDeserializer(data) { 4 | this.perform = () => 5 | P.try(() => ({ 6 | useIpWhitelist: data.attributes.use_ip_whitelist, 7 | rules: data.attributes.rules.map((rule) => { 8 | const { ip_minimum: ipMinimum, ip_maximum: ipMaximum, ...rest } = rule; 9 | if (ipMinimum) rest.ipMinimum = ipMinimum; 10 | if (ipMaximum) rest.ipMaximum = ipMaximum; 11 | return rest; 12 | }), 13 | })); 14 | } 15 | 16 | module.exports = IpWhitelistDeserializer; 17 | -------------------------------------------------------------------------------- /src/context/build-values.js: -------------------------------------------------------------------------------- 1 | module.exports = (context) => 2 | context 3 | .addInstance('env', () => ({ 4 | ...process.env, 5 | FOREST_URL: process.env.FOREST_URL || 'https://api.forestadmin.com', 6 | JWT_ALGORITHM: process.env.JWT_ALGORITHM || 'HS256', 7 | NODE_ENV: ['dev', 'development'].includes(process.env.NODE_ENV) 8 | ? 'development' 9 | : 'production', 10 | APPLICATION_URL: process.env.APPLICATION_URL || `http://localhost:${process.env.APPLICATION_PORT || 3310}`, 11 | })) 12 | .addValue('forestUrl', ({ env }) => env.FOREST_URL); 13 | -------------------------------------------------------------------------------- /test/helpers/request.js: -------------------------------------------------------------------------------- 1 | const { inject } = require('@forestadmin/context'); 2 | const request = require('supertest'); 3 | const nock = require('nock'); 4 | 5 | function init() { 6 | const { forestUrl } = inject(); 7 | 8 | nock(forestUrl) 9 | .persist() 10 | .get('/liana/v1/ip-whitelist-rules') 11 | .reply(200, { 12 | data: { 13 | type: 'ip-whitelist-rules', 14 | id: '1', 15 | attributes: { 16 | use_ip_whitelist: false, 17 | rules: [], 18 | }, 19 | }, 20 | }); 21 | } 22 | 23 | module.exports = { init, request }; 24 | -------------------------------------------------------------------------------- /src/context/build-utils.js: -------------------------------------------------------------------------------- 1 | const errorMessages = require('../utils/error-messages'); 2 | const errorUtils = require('../utils/error'); 3 | const stringUtils = require('../utils/string'); 4 | const joinUrl = require('../utils/join-url'); 5 | const { setFieldWidget } = require('../utils/widgets'); 6 | 7 | module.exports = (context) => 8 | context.addInstance('errorMessages', () => errorMessages) 9 | .addInstance('stringUtils', () => stringUtils) 10 | .addInstance('errorUtils', () => errorUtils) 11 | .addInstance('setFieldWidget', () => setFieldWidget) 12 | .addInstance('joinUrl', () => joinUrl); 13 | -------------------------------------------------------------------------------- /src/integrations/stripe/services/payment-refunder.js: -------------------------------------------------------------------------------- 1 | const P = require('bluebird'); 2 | 3 | function PaymentRefunder(params, opts) { 4 | const stripe = opts.integrations.stripe.stripe(opts.integrations.stripe.apiKey); 5 | 6 | function refund(chargeId) { 7 | return new P((resolve, reject) => { 8 | stripe.refunds.create({ 9 | charge: chargeId, 10 | }, (err) => { 11 | if (err) { return reject(err); } 12 | return resolve(); 13 | }); 14 | }); 15 | } 16 | 17 | this.perform = () => P.map(params.data.attributes.ids, (id) => refund(id)); 18 | } 19 | 20 | module.exports = PaymentRefunder; 21 | -------------------------------------------------------------------------------- /src/services/exposed/record-creator.js: -------------------------------------------------------------------------------- 1 | const AbstractRecordService = require('./abstract-records-service'); 2 | const ResourceDeserializer = require('../../deserializers/resource'); 3 | 4 | class RecordCreator extends AbstractRecordService { 5 | create(record) { 6 | return new this.Implementation.ResourceCreator(this.model, this.params, record, this.user) 7 | .perform(); 8 | } 9 | 10 | deserialize(body) { 11 | return new ResourceDeserializer(this.Implementation, this.model, body, true, { 12 | omitNullAttributes: true, 13 | }) 14 | .perform(); 15 | } 16 | } 17 | 18 | module.exports = RecordCreator; 19 | -------------------------------------------------------------------------------- /src/services/exposed/record-updater.js: -------------------------------------------------------------------------------- 1 | const AbstractRecordService = require('./abstract-records-service'); 2 | const ResourceDeserializer = require('../../deserializers/resource'); 3 | 4 | class RecordUpdater extends AbstractRecordService { 5 | update(record, recordId) { 6 | return new this.Implementation.ResourceUpdater( 7 | this.model, 8 | { ...this.params, recordId }, 9 | record, 10 | this.user, 11 | ) 12 | .perform(); 13 | } 14 | 15 | deserialize(body) { 16 | return new ResourceDeserializer(this.Implementation, this.model, body, false) 17 | .perform(); 18 | } 19 | } 20 | 21 | module.exports = RecordUpdater; 22 | -------------------------------------------------------------------------------- /src/services/exposed/records-exporter.js: -------------------------------------------------------------------------------- 1 | const AbstractRecordService = require('./abstract-records-service'); 2 | const CSVExporter = require('../csv-exporter'); 3 | 4 | class RecordsExporter extends AbstractRecordService { 5 | streamExport(response) { 6 | const recordsExporter = new this.Implementation.ResourcesExporter( 7 | this.model, 8 | this.lianaOptions, 9 | this.params, 10 | null, 11 | this.user, 12 | ); 13 | const modelName = this.Implementation.getModelName(this.model); 14 | return new CSVExporter(this.params, response, modelName, recordsExporter) 15 | .perform(); 16 | } 17 | } 18 | 19 | module.exports = RecordsExporter; 20 | -------------------------------------------------------------------------------- /src/context/build-external.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const moment = require('moment'); 3 | const VError = require('verror'); 4 | const superagentRequest = require('superagent'); 5 | const path = require('path'); 6 | const openIdClient = require('openid-client'); 7 | const jsonwebtoken = require('jsonwebtoken'); 8 | 9 | module.exports = (context) => 10 | context.addInstance('superagentRequest', () => superagentRequest) 11 | .addInstance('fs', () => fs) 12 | .addInstance('path', path) 13 | .addInstance('openIdClient', () => openIdClient) 14 | .addInstance('jsonwebtoken', () => jsonwebtoken) 15 | .addInstance('moment', () => moment) 16 | .addInstance('VError', () => VError); 17 | -------------------------------------------------------------------------------- /src/integrations/intercom/services/conversation-getter.js: -------------------------------------------------------------------------------- 1 | const logger = require('../../../services/logger'); 2 | 3 | function ConversationGetter(Implementation, params, opts) { 4 | const Intercom = opts.integrations.intercom.intercom; 5 | const intercom = new Intercom.Client(opts.integrations.intercom.credentials).usePromises(); 6 | 7 | this.perform = () => 8 | intercom.conversations 9 | .find({ id: params.conversationId }) 10 | .then((response) => response.body) 11 | .catch((error) => { 12 | logger.error('Cannot retrieve the Intercom conversation for the following reason: ', error); 13 | return null; 14 | }); 15 | } 16 | 17 | module.exports = ConversationGetter; 18 | -------------------------------------------------------------------------------- /src/services/path.js: -------------------------------------------------------------------------------- 1 | exports.generate = (path, options) => { 2 | const pathPrefix = options.expressParentApp ? '/' : '/forest/'; 3 | return pathPrefix + path; 4 | }; 5 | 6 | exports.generateForInit = (path, options) => { 7 | if (options.expressParentApp) return `/${path}`; 8 | 9 | const pathPrefix = '/forest'; 10 | return [`${pathPrefix}`, `${pathPrefix}/${path}`]; 11 | }; 12 | 13 | exports.generateForSmartActionCustomEndpoint = (path, options) => { 14 | if (options.expressParentApp) { 15 | return path.replace(/^\/?forest\//, '/'); 16 | } 17 | 18 | // NOTICE: Automatically fix missing / character at the beginning at the endpoint declaration. 19 | return path[0] === '/' ? path : `/${path}`; 20 | }; 21 | -------------------------------------------------------------------------------- /src/config/jwt.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | import { Params } from 'express-jwt'; 3 | import { Algorithm } from 'jsonwebtoken'; 4 | 5 | /* eslint-disable import/prefer-default-export */ 6 | const ALGORITHM_DEFAULT = process.env.JWT_ALGORITHM || 'HS256'; 7 | const CONFIGURATION_DEFAULT = { 8 | algorithms: [ALGORITHM_DEFAULT] as Algorithm[], 9 | credentialsRequired: false, 10 | }; 11 | 12 | export type JWTConfiguration = { 13 | secret: string, 14 | getToken: (request: Request)=> string | Promise | undefined, 15 | }; 16 | 17 | export function getJWTConfiguration(configuration: JWTConfiguration): Params { 18 | return { 19 | ...CONFIGURATION_DEFAULT, 20 | ...configuration, 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /test/utils/data.test.js: -------------------------------------------------------------------------------- 1 | const { find } = require('../../src/utils/data'); 2 | 3 | describe('utils › data › find', () => { 4 | it('should find values in data thanks to path', () => { 5 | const data = { foo: { bar: 'baz' } }; 6 | expect(find(data, 'foo')).toStrictEqual({ bar: 'baz' }); 7 | expect(find(data, 'foo.bar')).toBe('baz'); 8 | }); 9 | 10 | it('should return null if path does not match data', () => { 11 | const data = { foo: 'bar' }; 12 | expect(find(data, 'wrong-path')).toBeNull(); 13 | }); 14 | 15 | it('should return a falsy data if data is falsy', () => { 16 | expect(find(null, 'valid.path')).toBeNull(); 17 | expect(find(undefined, 'valid.path')).toBeUndefined(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/deserializers/query.js: -------------------------------------------------------------------------------- 1 | function QueryDeserializer(attributes) { 2 | this.perform = () => { 3 | const { 4 | all_records: allRecords, 5 | all_records_ids_excluded: allRecordsIdsExcluded, 6 | all_records_subset_query: allRecordsSubsetQuery, 7 | parent_association_name: parentAssociationName, 8 | parent_collection_name: parentCollectionName, 9 | parent_collection_id: parentCollectionId, 10 | ...rest 11 | } = attributes; 12 | return { 13 | ...rest, 14 | allRecords, 15 | allRecordsIdsExcluded, 16 | allRecordsSubsetQuery, 17 | parentAssociationName, 18 | parentCollectionName, 19 | parentCollectionId, 20 | }; 21 | }; 22 | } 23 | 24 | module.exports = QueryDeserializer; 25 | -------------------------------------------------------------------------------- /src/services/integration-informations-getter.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const { inject } = require('@forestadmin/context'); 3 | 4 | function IntegrationInformationsGetter(modelName, Implementation, integration) { 5 | const { modelsManager } = inject(); 6 | this.perform = () => { 7 | const models = modelsManager.getModels(); 8 | let value = null; 9 | 10 | _.each(integration.mapping, (mappingValue) => { 11 | const collectionName = mappingValue.split('.')[0]; 12 | if (models[collectionName] && Implementation 13 | .getModelName(models[collectionName]) === modelName) { 14 | value = mappingValue; 15 | } 16 | }); 17 | 18 | return value; 19 | }; 20 | } 21 | 22 | module.exports = IntegrationInformationsGetter; 23 | -------------------------------------------------------------------------------- /src/utils/get-ip-from-request.js: -------------------------------------------------------------------------------- 1 | const ipAddr = require('ipaddr.js'); 2 | const ipRegex = require('ip-regex'); 3 | 4 | function getIpFromRequest(request) { 5 | /** @type {string} */ 6 | const forwardedAddresses = request.headers['x-forwarded-for']; 7 | const parsedIps = forwardedAddresses?.match(ipRegex()); 8 | 9 | if (parsedIps?.length) { 10 | // If the ip chain contains multiple IPs, the last public IP from the chain is the only 11 | // one we can trust and it corresponds to real IP that contacted our own reverse proxy 12 | return parsedIps 13 | .reverse() 14 | .map((address) => address.trim()) 15 | .find((address) => ipAddr.parse(address).range() !== 'private'); 16 | } 17 | 18 | return request.connection.remoteAddress; 19 | } 20 | 21 | module.exports = getIpFromRequest; 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | # Build 30 | dist 31 | .eslintcache 32 | 33 | # Editor 34 | .vscode 35 | .idea 36 | 37 | # Tests 38 | .forestadmin-schema.json 39 | 40 | # Lock 41 | package-lock.json 42 | -------------------------------------------------------------------------------- /src/integrations/intercom/services/integration-informations-getter.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const { inject } = require('@forestadmin/context'); 3 | 4 | function IntegrationInformationsGetter(modelName, Implementation, integration) { 5 | const { modelsManager } = inject(); 6 | this.perform = () => { 7 | const models = modelsManager.getModels(); 8 | let value = null; 9 | 10 | _.each( 11 | integration.mapping, 12 | (mappingValue) => { 13 | const collectionName = mappingValue.split('.')[0]; 14 | if (models[collectionName] && Implementation 15 | .getModelName(models[collectionName]) === modelName) { 16 | value = mappingValue; 17 | } 18 | }, 19 | ); 20 | 21 | return value; 22 | }; 23 | } 24 | 25 | module.exports = IntegrationInformationsGetter; 26 | -------------------------------------------------------------------------------- /src/services/exposed/records-counter.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-argument */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-call */ 3 | /* eslint-disable @typescript-eslint/no-unsafe-return */ 4 | /* eslint-disable @typescript-eslint/no-unsafe-member-access */ 5 | import AbstractRecordService from './abstract-records-service'; 6 | 7 | export default class RecordsCounter extends AbstractRecordService { 8 | public async count(): Promise { 9 | const resourcesGetterImplementation = new this.Implementation.ResourcesGetter( 10 | this.model, 11 | this.lianaOptions, 12 | this.params, 13 | this.user, 14 | ) as { count: (params: { excludesScope: boolean }) => Promise }; 15 | return resourcesGetterImplementation.count({ excludesScope: this.excludesScope }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/integrations/close.io/services/closeio-customer-lead-getter.js: -------------------------------------------------------------------------------- 1 | function CloseioCustomerLeadGetter(Implementation, params, opts, integrationInfo) { 2 | const Closeio = opts.integrations.closeio.closeio; 3 | const closeio = new Closeio(opts.integrations.closeio.apiKey); 4 | 5 | this.perform = () => 6 | Implementation.Closeio.getCustomer( 7 | integrationInfo.collection, 8 | params.recordId, 9 | ) 10 | .then((customer) => { 11 | if (!customer) { return { data: [] }; } 12 | 13 | const query = `name:"${customer[integrationInfo.field] 14 | }" or email:"${customer[integrationInfo.field]}"`; 15 | 16 | return closeio._get(`/lead?query=${encodeURIComponent(query)}`); 17 | }) 18 | .then((response) => response.data[0]); 19 | } 20 | 21 | module.exports = CloseioCustomerLeadGetter; 22 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | // NOTICE: When a github "squash and merge" is performed, github add the PR link in the commit 2 | // message using the format ` (#)`. Github provide the target branch of the build, 3 | // so authorizing 4+5 = 9 characters more on main for the max header length should work 4 | // until we reach PR #99999. 5 | 6 | let maxLineLength = 100; 7 | 8 | const prExtrasChars = 9; 9 | 10 | const isPushEvent = process.env.GITHUB_EVENT_NAME === 'push'; 11 | 12 | if (isPushEvent) { 13 | maxLineLength += prExtrasChars; 14 | } 15 | 16 | module.exports = { 17 | extends: ['@commitlint/config-conventional'], 18 | rules: { 19 | 'header-max-length': [1, 'always', maxLineLength], 20 | 'body-max-line-length': [1, 'always', maxLineLength], 21 | 'footer-max-line-length': [1, 'always', maxLineLength], 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /src/integrations/layer/services/conversation-getter.js: -------------------------------------------------------------------------------- 1 | const P = require('bluebird'); 2 | const request = require('superagent'); 3 | 4 | function ConversationGetter(Implementation, params, opts) { 5 | function getConversation() { 6 | return new P(((resolve, reject) => request 7 | .get(`https://api.layer.com/apps/${opts.integrations.layer.appId 8 | }/conversations/${params.conversationId}`) 9 | .set('Accept', 'application/vnd.layer+json; version=2.0') 10 | .set('Content-type', 'application/json') 11 | .set('Authorization', `Bearer ${opts.integrations.layer.serverApiToken}`) 12 | .end((error, data) => { 13 | if (error) { return reject(error); } 14 | return resolve(data.body); 15 | }))); 16 | } 17 | 18 | this.perform = () => getConversation(); 19 | } 20 | 21 | module.exports = ConversationGetter; 22 | -------------------------------------------------------------------------------- /src/integrations/layer/services/messages-getter.js: -------------------------------------------------------------------------------- 1 | const P = require('bluebird'); 2 | const request = require('superagent'); 3 | 4 | function MessagesGetter(Implementation, params, opts) { 5 | function getMessages() { 6 | return new P(((resolve, reject) => request 7 | .get(`https://api.layer.com/apps/${opts.integrations.layer.appId 8 | }/conversations/${params.conversationId}/messages`) 9 | .set('Accept', 'application/vnd.layer+json; version=2.0') 10 | .set('Content-type', 'application/json') 11 | .set('Authorization', `Bearer ${ 12 | opts.integrations.layer.serverApiToken}`) 13 | .end((error, data) => { 14 | if (error) { return reject(error); } 15 | return resolve([data.body.length, data.body]); 16 | }))); 17 | } 18 | 19 | this.perform = () => getMessages(); 20 | } 21 | 22 | module.exports = MessagesGetter; 23 | -------------------------------------------------------------------------------- /src/routes/scopes.js: -------------------------------------------------------------------------------- 1 | const path = require('../services/path'); 2 | const auth = require('../services/auth'); 3 | 4 | function initScopeRoutes(app, { configStore, scopeManager, logger }) { 5 | app.post( 6 | path.generate('scope-cache-invalidation', configStore.lianaOptions), 7 | auth.ensureAuthenticated, 8 | (request, response) => { 9 | try { 10 | const { renderingId } = request.body; 11 | if (!renderingId) { 12 | logger.error('missing renderingId'); 13 | return response.status(400).send(); 14 | } 15 | 16 | scopeManager.invalidateScopeCache(renderingId); 17 | return response.status(200).send(); 18 | } catch (error) { 19 | logger.error('Error during scope cache invalidation: ', error); 20 | return response.status(500).send(); 21 | } 22 | }, 23 | ); 24 | } 25 | 26 | module.exports = initScopeRoutes; 27 | -------------------------------------------------------------------------------- /test/routes/healthcheck.test.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | const createServer = require('../helpers/create-server'); 3 | 4 | const ACCESS_CONTROL_ALLOW_ORIGIN = 'access-control-allow-origin'; 5 | const envSecret = Array(65).join('0'); 6 | const authSecret = Array(65).join('1'); 7 | 8 | describe('routes > healthcheck', () => { 9 | describe('#GET /forest/healthcheck', () => { 10 | it('should return 200', async () => { 11 | expect.assertions(2); 12 | const app = await createServer(envSecret, authSecret); 13 | await new Promise((done) => { 14 | request(app) 15 | .get('/forest/healthcheck') 16 | .end((error, response) => { 17 | expect(response.status).toBe(200); 18 | expect(response.res.headers[ACCESS_CONTROL_ALLOW_ORIGIN]).toBe('*'); 19 | done(); 20 | }); 21 | }); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/services/apimap-fields-formater.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | 3 | class ApimapFieldsFormater { 4 | constructor({ logger }) { 5 | this.logger = logger; 6 | } 7 | 8 | formatFieldsByCollectionName(fields, collectionName) { 9 | const fieldsValid = _.filter(fields, (field) => { 10 | if (_.isUndefined(field.field) || _.isNull(field.field)) { 11 | this.logger.warn(`Bad Smart Field declaration in "${collectionName}" collection: missing "field" attribute.`); 12 | return false; 13 | } 14 | return true; 15 | }); 16 | 17 | return _.map(fieldsValid, (field) => { 18 | field.isVirtual = true; 19 | field.isFilterable = field.isFilterable || false; 20 | field.isSortable = field.isSortable || false; 21 | field.isReadOnly = !field.set; 22 | 23 | return field; 24 | }); 25 | } 26 | } 27 | 28 | module.exports = ApimapFieldsFormater; 29 | -------------------------------------------------------------------------------- /test/utils/timeout-error.test.js: -------------------------------------------------------------------------------- 1 | const timeoutError = require('../../src/utils/timeout-error'); 2 | 3 | describe('utils > timeoutError', () => { 4 | describe('if the error is a timeout', () => { 5 | it('should format an error message', () => { 6 | const mockDate = new Date(1466424490000); 7 | const spy = jest.spyOn(global, 'Date').mockImplementation(() => mockDate); 8 | expect(timeoutError('http://example.test/path/to/ressource?id=123', { timeout: true })).toBe('The request to Forest Admin server has timed out while trying to reach http://example.test/path/to/ressource?id=123 at 2016-06-20T12:08:10.000Z'); 9 | spy.mockRestore(); 10 | }); 11 | }); 12 | describe('if the error is not a timeout', () => { 13 | it('should return null', () => { 14 | expect(timeoutError('http://example.test/path/to/ressource?id=123', { timeout: false })).toBeNull(); 15 | }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/integrations/close.io/serializers/closeio-leads.js: -------------------------------------------------------------------------------- 1 | const JSONAPISerializer = require('jsonapi-serializer').Serializer; 2 | 3 | function serializeCloseioLeads(attributes, collectionName, meta) { 4 | const type = `${collectionName}_closeio_leads`; 5 | 6 | return new JSONAPISerializer(type, attributes, { 7 | attributes: ['url', 'created_by_name', 'display_name', 'status_label', 8 | 'date_created', 'date_updated', 'description', 'emails'], 9 | emails: { 10 | ref: 'id', 11 | included: false, 12 | ignoreRelationshipData: true, 13 | nullIfMissing: true, 14 | relationshipLinks: { 15 | related(dataSet) { 16 | return { 17 | href: `/forest/${collectionName}_closeio_leads/${ 18 | dataSet.id}/emails`, 19 | }; 20 | }, 21 | }, 22 | }, 23 | keyForAttribute(key) { return key; }, 24 | meta, 25 | }); 26 | } 27 | 28 | module.exports = serializeCloseioLeads; 29 | -------------------------------------------------------------------------------- /src/middlewares/ip-whitelist.js: -------------------------------------------------------------------------------- 1 | const httpError = require('http-errors'); 2 | const ipWhitelistService = require('../services/ip-whitelist'); 3 | const getIpFromRequest = require('../utils/get-ip-from-request'); 4 | 5 | function retrieveWhitelist(environmentSecret, ip, next) { 6 | return ipWhitelistService 7 | .retrieve(environmentSecret) 8 | .then(() => (ipWhitelistService.isIpValid(ip) ? next() : next(httpError(403, `IP address rejected (${ip})`)))) 9 | .catch(() => next(httpError(403, 'IP whitelist not retrieved'))); 10 | } 11 | 12 | function createIpAuthorizer(environmentSecret) { 13 | return function ipAuthorizer(request, response, next) { 14 | const ip = getIpFromRequest(request); 15 | 16 | if (!ipWhitelistService.isIpWhitelistRetrieved() || !ipWhitelistService.isIpValid(ip)) { 17 | return retrieveWhitelist(environmentSecret, ip, next); 18 | } 19 | 20 | return next(); 21 | }; 22 | } 23 | 24 | module.exports = createIpAuthorizer; 25 | -------------------------------------------------------------------------------- /src/integrations/layer/serializers/messages.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const JSONAPISerializer = require('jsonapi-serializer').Serializer; 3 | 4 | function serializeMessages(messages, collectionName, meta) { 5 | function mapMessage(message) { 6 | // jshint camelcase: false 7 | message.id = message.id.replace('layer:///messages/', ''); 8 | message.sentAt = message.sent_at; 9 | message.content = message.parts[0].body; 10 | message.mimeType = message.parts[0].mime_type; 11 | message.sender = message.sender.display_name; 12 | 13 | return message; 14 | } 15 | 16 | let data = null; 17 | if (_.isArray(messages)) { 18 | data = messages.map((message) => mapMessage(message)); 19 | } else { 20 | data = mapMessage(messages); 21 | } 22 | 23 | const type = `${collectionName}_layer_messages`; 24 | 25 | return new JSONAPISerializer(type, data, { 26 | attributes: ['sender', 'sentAt', 'content', 'mimeType'], 27 | keyForAttribute(key) { return key; }, 28 | meta, 29 | }); 30 | } 31 | 32 | module.exports = serializeMessages; 33 | -------------------------------------------------------------------------------- /src/utils/error.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | 3 | class Unauthorized extends Error { 4 | constructor(message) { 5 | super(message); 6 | this.name = 'Unauthorized'; 7 | this.status = 401; 8 | this.message = message; 9 | } 10 | } 11 | 12 | class UnprocessableEntity extends Error { 13 | constructor(message) { 14 | super(message); 15 | this.name = 'UnprocessableEntity'; 16 | this.status = 422; 17 | this.message = message; 18 | } 19 | } 20 | 21 | class InvalidFiltersFormat extends Error { 22 | constructor(message) { 23 | super(message); 24 | this.name = 'InvalidFiltersFormat'; 25 | this.message = message || 'The filters format is not a valid JSON string.'; 26 | this.status = 422; 27 | } 28 | } 29 | 30 | class NoMatchingOperatorError extends Error { 31 | constructor(message) { 32 | super(message); 33 | this.name = 'NoMatchingOperatorError'; 34 | this.message = message || 'The given operator is not handled.'; 35 | this.status = 422; 36 | } 37 | } 38 | 39 | module.exports = { 40 | Unauthorized, 41 | UnprocessableEntity, 42 | InvalidFiltersFormat, 43 | NoMatchingOperatorError, 44 | }; 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Forest Express Lianas dependency 2 | 3 | [![npm package](https://badge.fury.io/js/forest-express.svg)](https://badge.fury.io/js/forest-express) 4 | [![CI status](https://github.com/ForestAdmin/forest-express/workflows/Build,%20Test%20and%20Deploy/badge.svg?branch=main)](https://github.com/ForestAdmin/forest-express/actions) 5 | [![Test Coverage](https://api.codeclimate.com/v1/badges/760886d5e81ea4095e90/test_coverage)](https://codeclimate.com/github/ForestAdmin/forest-express/test_coverage) 6 | [![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) 7 | 8 | ## Build 9 | 10 | To transpile from `src` to `build` using babel: 11 | 12 | `yarn build` 13 | 14 | To do it at every js file change in the `src` folder: 15 | 16 | `yarn build:watch` 17 | 18 | ## Lint 19 | 20 | `yarn lint` 21 | 22 | ## Test 23 | 24 | `yarn test` 25 | 26 | ## Community 27 | 28 | 👇 Join our Developers community for support and more 29 | 30 | [![Discourse developers community](https://img.shields.io/discourse/posts?label=discourse&server=https%3A%2F%2Fcommunity.forestadmin.com)](https://community.forestadmin.com) 31 | -------------------------------------------------------------------------------- /test/services/ip-whitelist.test.js: -------------------------------------------------------------------------------- 1 | const { 2 | retrieve, 3 | isIpValid, 4 | isIpWhitelistRetrieved, 5 | } = require('../../src/services/ip-whitelist'); 6 | const forestServerRequester = require('../../src/services/forest-server-requester'); 7 | 8 | jest.mock('../../src/services/forest-server-requester'); 9 | 10 | describe('utils › services', () => { 11 | forestServerRequester.perform.mockResolvedValue({ 12 | data: { 13 | attributes: { 14 | use_ip_whitelist: true, 15 | rules: [ 16 | { 17 | type: 1, 18 | ip_minimum: '1.0.0.0', 19 | ip_maximum: '1.2.0.0', 20 | }, 21 | ], 22 | }, 23 | }, 24 | }); 25 | it('should consider valid IP as valid', async () => { 26 | await retrieve(); 27 | expect(isIpWhitelistRetrieved()).toBe(true); 28 | expect(isIpValid('1.0.0.0')).toBe(true); 29 | expect(isIpValid('1.0.1.0')).toBe(true); 30 | expect(isIpValid('1.2.0.0')).toBe(true); 31 | }); 32 | it('should consider invalid IP as invalid', async () => { 33 | await retrieve(); 34 | expect(isIpWhitelistRetrieved()).toBe(true); 35 | expect(isIpValid('1.3.0.0')).toBe(false); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: "@babel/core" 10 | versions: 11 | - 7.12.10 12 | - 7.12.13 13 | - 7.12.16 14 | - 7.12.17 15 | - 7.13.1 16 | - 7.13.10 17 | - 7.13.13 18 | - 7.13.14 19 | - 7.13.15 20 | - 7.13.8 21 | - dependency-name: simple-git 22 | versions: 23 | - 2.31.0 24 | - 2.32.0 25 | - 2.34.2 26 | - 2.35.0 27 | - 2.35.1 28 | - 2.35.2 29 | - 2.36.0 30 | - 2.36.1 31 | - 2.36.2 32 | - 2.37.0 33 | - dependency-name: ini 34 | versions: 35 | - 1.3.8 36 | - dependency-name: npm-user-validate 37 | versions: 38 | - 1.0.1 39 | - dependency-name: y18n 40 | versions: 41 | - 3.2.2 42 | - dependency-name: nock 43 | versions: 44 | - 13.0.10 45 | - 13.0.6 46 | - 13.0.7 47 | - 13.0.8 48 | - 13.0.9 49 | - dependency-name: csv-stringify 50 | versions: 51 | - 5.6.1 52 | - dependency-name: lodash 53 | versions: 54 | - 4.17.20 55 | - dependency-name: moment-timezone 56 | versions: 57 | - 0.5.32 58 | -------------------------------------------------------------------------------- /src/services/exposed/abstract-records-service.js: -------------------------------------------------------------------------------- 1 | const { inject } = require('@forestadmin/context'); 2 | const ResourceSerializer = require('../../serializers/resource'); 3 | 4 | class AbstractRecordService { 5 | constructor(model, user, params, { configStore, modelsManager } = inject()) { 6 | if (!params.timezone) throw new Error('Missing timezone in parameters'); 7 | 8 | this.model = model; 9 | this.user = user; 10 | this.params = params; 11 | this.configStore = configStore; 12 | this.modelsManager = modelsManager; 13 | this.excludesScope = false; 14 | } 15 | 16 | get Implementation() { 17 | return this.configStore.Implementation; 18 | } 19 | 20 | get lianaOptions() { 21 | return this.configStore.lianaOptions; 22 | } 23 | 24 | get integrator() { 25 | return this.configStore.integrator; 26 | } 27 | 28 | serialize(records, meta = null) { 29 | return new ResourceSerializer( 30 | this.Implementation, 31 | this.model, 32 | records, 33 | this.integrator, 34 | meta, 35 | this.fieldsSearched, 36 | this.searchValue, 37 | this.fieldsPerModel, 38 | ).perform(); 39 | } 40 | } 41 | 42 | module.exports = AbstractRecordService; 43 | -------------------------------------------------------------------------------- /test/deserializers/ip-whitelist.test.js: -------------------------------------------------------------------------------- 1 | const IpWhitelistDeserializer = require('../../src/deserializers/ip-whitelist'); 2 | 3 | describe('deserializers > ip-whitelist', () => { 4 | it('should transform IP properties and preserve everything else', async () => { 5 | const attributes = { 6 | use_ip_whitelist: true, 7 | rules: [ 8 | { 9 | ip_minimum: '1.0.0.0', 10 | ip_maximum: '1.2.0.0', 11 | }, 12 | { 13 | foo: 'bar', 14 | }, 15 | { 16 | foo: 'bar', 17 | ip_minimum: '2.0.0.0', 18 | ip_maximum: '2.2.0.0', 19 | }, 20 | ], 21 | }; 22 | 23 | const expected = { 24 | useIpWhitelist: true, 25 | rules: [ 26 | { 27 | ipMinimum: '1.0.0.0', 28 | ipMaximum: '1.2.0.0', 29 | }, 30 | { 31 | foo: 'bar', 32 | }, 33 | { 34 | foo: 'bar', 35 | ipMinimum: '2.0.0.0', 36 | ipMaximum: '2.2.0.0', 37 | }, 38 | ], 39 | }; 40 | 41 | const deserialiazedData = await (new IpWhitelistDeserializer({ attributes })).perform(); 42 | expect(deserialiazedData).toStrictEqual(expected); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /test/utils/integrations.test.js: -------------------------------------------------------------------------------- 1 | const { pushIntoApimap } = require('../../src/utils/integrations'); 2 | 3 | // NOTICE: The `pushIntoApimap` function mutates the original `apimap` and reorders the list. 4 | // This behavior is a bit unexpected and should be fixed in a near future. 5 | describe('utils › integrations › pushIntoApimap', () => { 6 | it('should append new collections', () => { 7 | const apimap = [{ name: 'users' }]; 8 | const collection = { name: 'projects' }; 9 | 10 | const expected = [{ name: 'users' }, { name: 'projects' }]; 11 | pushIntoApimap(apimap, collection); 12 | expect(apimap).toStrictEqual(expected); 13 | }); 14 | 15 | it('should merge existing collections and actions', () => { 16 | const apimap = [ 17 | { name: 'users', actions: ['send-invoice'] }, 18 | { name: 'projects', actions: ['mark-as-live'] }, 19 | ]; 20 | const collection = { name: 'users', actions: ['my-new-action'] }; 21 | 22 | const expected = [ 23 | { name: 'projects', actions: ['mark-as-live'] }, 24 | { name: 'users', actions: ['my-new-action', 'send-invoice'] }, 25 | ]; 26 | pushIntoApimap(apimap, collection); 27 | expect(apimap).toStrictEqual(expected); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /test/utils/string.test.js: -------------------------------------------------------------------------------- 1 | const StringUtil = require('../../src/utils/string'); 2 | 3 | describe('utils > string', () => { 4 | describe('parameterize function', () => { 5 | it('should exist', () => { 6 | expect(StringUtil.parameterize).toBeDefined(); 7 | }); 8 | 9 | describe('with a null string argument', () => { 10 | it('should return an empty string', () => { 11 | expect(StringUtil.parameterize(null)).toBe(''); 12 | }); 13 | }); 14 | 15 | describe('with a string with spaces', () => { 16 | it('should replace the spaces by dashes (-)', () => { 17 | expect(StringUtil.parameterize('add transaction')).toBe('add-transaction'); 18 | }); 19 | 20 | it('should lowercase the characters', () => { 21 | expect(StringUtil.parameterize('Add Transaction')).toBe('add-transaction'); 22 | }); 23 | 24 | it('should trim extra spaces', () => { 25 | expect(StringUtil.parameterize(' add transaction ')).toBe('add-transaction'); 26 | }); 27 | 28 | it('should replace accented characters with their ascii equivalents', () => { 29 | expect(StringUtil.parameterize('add trénsàction')).toBe('add-trensaction'); 30 | }); 31 | }); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/integrations/stripe/serializers/cards.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const JSONAPISerializer = require('jsonapi-serializer').Serializer; 3 | const Schemas = require('../../../generators/schemas'); 4 | 5 | function serializeCards(cards, collectionName, meta) { 6 | function getCustomerAttributes() { 7 | if (!cards.length) { return []; } 8 | 9 | const schema = Schemas.schemas[collectionName]; 10 | if (!schema) { return []; } 11 | return _.map(schema.fields, 'field'); 12 | } 13 | 14 | const customerAttributes = getCustomerAttributes(); 15 | 16 | const type = `${collectionName}_stripe_cards`; 17 | 18 | return new JSONAPISerializer(type, cards, { 19 | attributes: ['last4', 'brand', 'funding', 'exp_month', 'exp_year', 20 | 'country', 'name', 'address_line1', 'address_line2', 'address_city', 21 | 'address_state', 'address_zip', 'address_country', 'cvc_check', 22 | 'customer'], 23 | customer: { 24 | ref: 'id', 25 | attributes: customerAttributes, 26 | }, 27 | keyForAttribute(key) { return key; }, 28 | typeForAttribute(attr) { 29 | if (attr === 'customer') { return collectionName; } 30 | return attr; 31 | }, 32 | meta, 33 | }); 34 | } 35 | 36 | module.exports = serializeCards; 37 | -------------------------------------------------------------------------------- /test/utils/widgets.test.js: -------------------------------------------------------------------------------- 1 | const { setFieldWidget } = require('../../src/utils/widgets'); 2 | 3 | describe('utils › widgets', () => { 4 | describe('setFieldWidget', () => { 5 | it('should remove widget property', () => { 6 | const field = { widget: 'hop' }; 7 | setFieldWidget(field); 8 | expect(field.widget).toBeUndefined(); 9 | }); 10 | 11 | it('should set widgetEdit correctly for legacy widgets', () => { 12 | const field = { widget: 'price' }; 13 | const expected = { widgetEdit: { name: 'price editor', parameters: {} } }; 14 | setFieldWidget(field); 15 | expect(field).toStrictEqual(expected); 16 | }); 17 | 18 | it('should set widgetEdit correctly for currrent widgets', () => { 19 | const field = { widget: 'boolean editor' }; 20 | const expected = { widgetEdit: { name: 'boolean editor', parameters: {} } }; 21 | setFieldWidget(field); 22 | expect(field).toStrictEqual(expected); 23 | }); 24 | 25 | it('should set widgetEdit to null when widget is unknown', () => { 26 | const field = { widget: 'WRONG VALUE' }; 27 | const expected = { widgetEdit: null }; 28 | setFieldWidget(field); 29 | expect(field).toStrictEqual(expected); 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/integrations/stripe/services/payment-getter.js: -------------------------------------------------------------------------------- 1 | const P = require('bluebird'); 2 | 3 | function PaymentsGetter(Implementation, params, opts, integrationInfo) { 4 | const stripe = opts.integrations.stripe.stripe(opts.integrations.stripe.apiKey); 5 | let collectionModel = null; 6 | 7 | function getCharge(paymentId) { 8 | return new P((resolve, reject) => { 9 | stripe.charges.retrieve(paymentId, (error, charge) => { 10 | if (error) { return reject(error); } 11 | return resolve(charge); 12 | }); 13 | }); 14 | } 15 | 16 | this.perform = () => { 17 | collectionModel = integrationInfo.collection; 18 | const { 19 | field: collectionFieldName, 20 | embeddedPath, 21 | } = integrationInfo; 22 | const fieldName = embeddedPath ? `${collectionFieldName}.${embeddedPath}` : collectionFieldName; 23 | 24 | return getCharge(params.paymentId) 25 | .then((payment) => 26 | Implementation.Stripe.getCustomerByUserField( 27 | collectionModel, 28 | fieldName, 29 | payment.customer, 30 | ) 31 | .then((customer) => { 32 | payment.customer = customer; 33 | return payment; 34 | })); 35 | }; 36 | } 37 | 38 | module.exports = PaymentsGetter; 39 | -------------------------------------------------------------------------------- /src/integrations/stripe/services/invoice-getter.js: -------------------------------------------------------------------------------- 1 | const P = require('bluebird'); 2 | 3 | function InvoicesGetter(Implementation, params, opts, integrationInfo) { 4 | const stripe = opts.integrations.stripe.stripe(opts.integrations.stripe.apiKey); 5 | let collectionModel = null; 6 | 7 | function getInvoice(invoiceId) { 8 | return new P((resolve, reject) => { 9 | stripe.invoices.retrieve(invoiceId, (error, invoice) => { 10 | if (error) { return reject(error); } 11 | return resolve(invoice); 12 | }); 13 | }); 14 | } 15 | 16 | this.perform = () => { 17 | collectionModel = integrationInfo.collection; 18 | const { 19 | field: collectionFieldName, 20 | embeddedPath, 21 | } = integrationInfo; 22 | const fieldName = embeddedPath ? `${collectionFieldName}.${embeddedPath}` : collectionFieldName; 23 | 24 | return getInvoice(params.invoiceId) 25 | .then((invoice) => 26 | Implementation.Stripe.getCustomerByUserField( 27 | collectionModel, 28 | fieldName, 29 | invoice.customer, 30 | ) 31 | .then((customer) => { 32 | invoice.customer = customer; 33 | return invoice; 34 | })); 35 | }; 36 | } 37 | 38 | module.exports = InvoicesGetter; 39 | -------------------------------------------------------------------------------- /src/services/logger.js: -------------------------------------------------------------------------------- 1 | const winston = require('winston'); 2 | 3 | const CONFIG = { 4 | levels: { 5 | error: 0, 6 | debug: 1, 7 | warn: 2, 8 | data: 3, 9 | info: 4, 10 | verbose: 5, 11 | silly: 6, 12 | }, 13 | colors: { 14 | error: 'red', 15 | debug: 'blue', 16 | warn: 'yellow', 17 | data: 'grey', 18 | info: 'green', 19 | verbose: 'cyan', 20 | silly: 'magenta', 21 | }, 22 | }; 23 | 24 | const TITLE = '[forest] 🌳🌳🌳 '; 25 | 26 | winston.addColors(CONFIG.colors); 27 | 28 | module.exports = winston.createLogger({ 29 | transports: [ 30 | new winston.transports.Console({ 31 | format: winston.format.combine( 32 | winston.format.metadata({ fillExcept: ['level', 'message'] }), 33 | winston.format.printf((info) => { 34 | let message = TITLE + info.message; 35 | 36 | if (info.metadata && Object.keys(info.metadata).length) { 37 | message += `\n${JSON.stringify(info.metadata, null, 2)}`; 38 | } 39 | 40 | if (info.stack) { 41 | message += `\n${info.stack}`; 42 | } 43 | 44 | return message; 45 | }), 46 | winston.format.colorize({ all: true }), 47 | ), 48 | }), 49 | ], 50 | levels: CONFIG.levels, 51 | }); 52 | -------------------------------------------------------------------------------- /src/integrations/stripe/services/subscription-getter.js: -------------------------------------------------------------------------------- 1 | const P = require('bluebird'); 2 | 3 | function SubscriptionGetter(Implementation, params, opts, integrationInfo) { 4 | const stripe = opts.integrations.stripe.stripe(opts.integrations.stripe.apiKey); 5 | 6 | function getSubscription(subscriptionId) { 7 | return new P((resolve, reject) => { 8 | stripe.subscriptions.retrieve(subscriptionId, (error, subscription) => { 9 | if (error) { return reject(error); } 10 | return resolve(subscription); 11 | }); 12 | }); 13 | } 14 | 15 | this.perform = () => { 16 | const { 17 | collection: collectionModel, 18 | field: collectionFieldName, 19 | embeddedPath, 20 | } = integrationInfo; 21 | const fieldName = embeddedPath ? `${collectionFieldName}.${embeddedPath}` : collectionFieldName; 22 | 23 | return getSubscription(params.subscriptionId) 24 | .then((subscription) => 25 | Implementation.Stripe.getCustomerByUserField( 26 | collectionModel, 27 | fieldName, 28 | subscription.customer, 29 | ) 30 | .then((customer) => { 31 | subscription.customer = customer; 32 | return subscription; 33 | })); 34 | }; 35 | } 36 | 37 | module.exports = SubscriptionGetter; 38 | -------------------------------------------------------------------------------- /src/integrations/stripe/serializers/bank-accounts.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const JSONAPISerializer = require('jsonapi-serializer').Serializer; 3 | const Schemas = require('../../../generators/schemas'); 4 | 5 | function serializeBankAccounts(bankAccounts, collectionName, meta) { 6 | function getCustomerAttributes() { 7 | if (!bankAccounts.length) { return []; } 8 | 9 | const schema = Schemas.schemas[collectionName]; 10 | if (!schema) { return []; } 11 | return _.map(schema.fields, 'field'); 12 | } 13 | 14 | const customerAttributes = getCustomerAttributes(); 15 | 16 | const type = `${collectionName}_stripe_bank_accounts`; 17 | 18 | return new JSONAPISerializer(type, bankAccounts, { 19 | attributes: ['account', 'account_holder_name', 'account_holder_type', 20 | 'bank_name', 'country', 'currency', 'default_for_currency', 'fingerprint', 21 | 'last4', 'rooting_number', 'status', 'customer'], 22 | customer: { 23 | ref: Schemas.schemas[collectionName].idField, 24 | attributes: customerAttributes, 25 | }, 26 | keyForAttribute(key) { return key; }, 27 | typeForAttribute(attr) { 28 | if (attr === 'customer') { return collectionName; } 29 | return attr; 30 | }, 31 | meta, 32 | }); 33 | } 34 | 35 | module.exports = serializeBankAccounts; 36 | -------------------------------------------------------------------------------- /test/deserializers/resource.test.js: -------------------------------------------------------------------------------- 1 | const ResourceDeserializer = require('../../src/deserializers/resource'); 2 | const Schemas = require('../../src/generators/schemas'); 3 | const usersSchema = require('../fixtures/users-schema'); 4 | 5 | const Implementation = { 6 | getModelName: (model) => model.name, 7 | }; 8 | 9 | describe('deserializers > resource', () => { 10 | Schemas.schemas = { users: usersSchema }; 11 | 12 | function getDeserializer(attributes) { 13 | return new ResourceDeserializer( 14 | Implementation, 15 | { name: 'users' }, 16 | { data: { attributes } }, 17 | false, 18 | ); 19 | } 20 | it('should return resource properties from request', async () => { 21 | await expect(getDeserializer({ id: '1' }).perform()).resolves.toStrictEqual({ id: '1' }); 22 | await expect(getDeserializer({ name: 'jane' }).perform()).resolves.toStrictEqual({ name: 'jane', id: undefined }); 23 | await expect(getDeserializer({ id: '1', name: 'john' }).perform()).resolves.toStrictEqual({ id: '1', name: 'john' }); 24 | }); 25 | 26 | it('should compute smart field setters and mutate entity', async () => { 27 | await expect(getDeserializer({ id: '1', name: 'jane', smart: 'doe' }).perform()).resolves 28 | .toStrictEqual({ id: '1', name: 'jane - doe', smart: 'doe' }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/integrations/intercom/serializers/intercom-conversation.js: -------------------------------------------------------------------------------- 1 | const JSONAPISerializer = require('jsonapi-serializer').Serializer; 2 | 3 | function serializeIntercomConversation(conversation, collectionName) { 4 | if (conversation.conversation_message) { 5 | // NOTICE: Intercom API old version 6 | conversation.subject = conversation.conversation_message.subject; 7 | conversation.body = [conversation.conversation_message.body, conversation.link]; 8 | } else { 9 | // NOTICE: Intercom API v2 10 | conversation.subject = conversation.source.subject; 11 | // NOTICE: Add all (except the first one) messages of the conversation. 12 | conversation.body = conversation.conversation_parts.conversation_parts 13 | .map((part) => part.body); 14 | // NOTICE: Add the first message of the conversation. 15 | conversation.body.unshift(conversation.source.body); 16 | } 17 | 18 | if (conversation.assignee) { 19 | conversation.assignee = conversation.assignee.email; 20 | } 21 | 22 | const type = `${collectionName}_intercom_conversations`; 23 | 24 | return new JSONAPISerializer(type, conversation, { 25 | attributes: ['created_at', 'updated_at', 'open', 'read', 'subject', 'body', 'assignee'], 26 | keyForAttribute(key) { return key; }, 27 | }); 28 | } 29 | 30 | module.exports = serializeIntercomConversation; 31 | -------------------------------------------------------------------------------- /src/integrations/intercom/serializers/intercom-attributes.js: -------------------------------------------------------------------------------- 1 | const JSONAPISerializer = require('jsonapi-serializer').Serializer; 2 | 3 | function serializeIntercomAttributes(attributes, collectionName, meta) { 4 | const type = `${collectionName}_intercom_attributes`; 5 | 6 | const unixTimestampToDateOrNull = (unixTimestamp) => 7 | unixTimestamp && new Date(unixTimestamp * 1000); 8 | 9 | // Attributes keys ending with `_at` are unix timestamp 10 | // thus they need to be converted to js Date 11 | Object.entries(attributes).forEach(([attributeKey, attributeValue]) => { 12 | if (attributeKey.endsWith('_at')) { 13 | attributes[attributeKey] = unixTimestampToDateOrNull(attributeValue); 14 | } 15 | }); 16 | 17 | return new JSONAPISerializer(type, attributes, { 18 | attributes: [ 19 | 'email', 20 | 'name', 21 | 'role', 22 | 'companies', 23 | 'tags', 24 | 'platform', 25 | 'browser', 26 | 'city', 27 | 'country', 28 | 'signed_up_at', 29 | 'last_request_at', 30 | 'last_seen_at', 31 | 'last_replied_at', 32 | 'last_contacted_at', 33 | 'last_email_opened_at', 34 | 'last_email_clicked_at', 35 | 'created_at', 36 | 'updated_at', 37 | ], 38 | keyForAttribute(key) { return key; }, 39 | meta, 40 | }); 41 | } 42 | 43 | module.exports = serializeIntercomAttributes; 44 | -------------------------------------------------------------------------------- /src/utils/error-messages.js: -------------------------------------------------------------------------------- 1 | exports.CONFIGURATION = { 2 | AUTH_SECRET_MISSING: 'Your Forest authSecret seems to be missing. Can you check that you properly set a Forest authSecret in the Forest initializer?', 3 | }; 4 | 5 | exports.SERVER_TRANSACTION = { 6 | SECRET_AND_RENDERINGID_INCONSISTENT: 'Cannot retrieve the project you\'re trying to unlock. The envSecret and renderingId seems to be missing or inconsistent.', 7 | SERVER_DOWN: 'Cannot retrieve the data from the Forest server. Forest API seems to be down right now.', 8 | SECRET_NOT_FOUND: 'Cannot retrieve the data from the Forest server. Can you check that you properly copied the Forest envSecret in the Liana initializer?', 9 | UNEXPECTED: 'Cannot retrieve the data from the Forest server. An error occured in Forest API.', 10 | INVALID_STATE_MISSING: 'Invalid response from the authentication server: the state parameter is missing', 11 | INVALID_STATE_FORMAT: 'Invalid response from the authentication server: the state parameter is not at the right format', 12 | INVALID_STATE_RENDERING_ID: 'Invalid response from the authentication server: the state does not contain a renderingId', 13 | MISSING_RENDERING_ID: 'Authentication request must contain a renderingId', 14 | INVALID_RENDERING_ID: 'The parameter renderingId is not valid', 15 | names: { 16 | TWO_FACTOR_AUTHENTICATION_REQUIRED: 'TwoFactorAuthenticationRequiredForbiddenError', 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /src/integrations/intercom/serializers/intercom-conversations.js: -------------------------------------------------------------------------------- 1 | const JSONAPISerializer = require('jsonapi-serializer').Serializer; 2 | 3 | function serializeIntercomConversations(conversations, collectionName, meta) { 4 | conversations = conversations.map((conversation) => { 5 | if (conversation.conversation_message) { 6 | // NOTICE: Intercom API old version 7 | conversation.subject = conversation.conversation_message.subject; 8 | conversation.body = [conversation.conversation_message.body, conversation.link]; 9 | } else { 10 | // NOTICE: Intercom API v2 11 | conversation.subject = conversation.source.subject; 12 | // NOTICE: The Intercom API does not sent all the conversation in a "list" request, only the 13 | // first message. So we add suspension points "...". 14 | conversation.body = [conversation.source.body, '...']; 15 | } 16 | 17 | if (conversation.assignee) { 18 | conversation.assignee = conversation.assignee.email; 19 | } 20 | 21 | return conversation; 22 | }); 23 | 24 | const type = `${collectionName}_intercom_conversations`; 25 | 26 | return new JSONAPISerializer(type, conversations, { 27 | attributes: ['created_at', 'updated_at', 'open', 'read', 'subject', 'body', 'assignee'], 28 | keyForAttribute(key) { return key; }, 29 | meta, 30 | }); 31 | } 32 | 33 | module.exports = serializeIntercomConversations; 34 | -------------------------------------------------------------------------------- /test/services/path.unit.test.js: -------------------------------------------------------------------------------- 1 | const path = require('../../src/services/path'); 2 | 3 | describe('services > path', () => { 4 | describe('generate', () => { 5 | it('should preprend /forest', async () => { 6 | const arg = 'dummy_path_value'; 7 | const options = {}; 8 | const generatedPath = path.generate(arg, options); 9 | 10 | expect(generatedPath).toBe(`/forest/${arg}`); 11 | }); 12 | 13 | it('should not prepend /forest with expressParentApp option', async () => { 14 | const arg = 'dummy_path_value'; 15 | const options = { expressParentApp: true }; 16 | const generatedPath = path.generate(arg, options); 17 | 18 | expect(generatedPath).toBe(`/${arg}`); 19 | }); 20 | }); 21 | 22 | describe('generateForInit', () => { 23 | it('should allow /forest and subpaths', async () => { 24 | const arg = 'dummy_path_value'; 25 | const options = {}; 26 | const generatedPath = path.generateForInit(arg, options); 27 | 28 | expect(generatedPath).toStrictEqual(['/forest', `/forest/${arg}`]); 29 | }); 30 | 31 | it('should not include /forest with expressParentApp option', async () => { 32 | const arg = 'dummy_path_value'; 33 | const options = { expressParentApp: true }; 34 | const generatedPath = path.generateForInit(arg, options); 35 | 36 | expect(generatedPath).toBe(`/${arg}`); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/services/exposed/record-serializer.js: -------------------------------------------------------------------------------- 1 | const { inject } = require('@forestadmin/context'); 2 | const ResourceSerializer = require('../../serializers/resource'); 3 | const ParamsFieldsDeserializer = require('../../deserializers/params-fields'); 4 | 5 | class RecordSerializer { 6 | constructor(model, user, query, { configStore } = inject()) { 7 | // user and query parameters are kept for retro-compatibility for v8. 8 | // Should be dropped when releasing the next major. 9 | if (!model) { 10 | throw new Error('RecordSerializer initialization error: missing first argument "model"'); 11 | } 12 | if (!(model instanceof Object)) { 13 | throw new Error('RecordSerializer initialization error: "model" argument should be an object (ex: `{ name: "myModel" }`)'); 14 | } 15 | if (!model.modelName) { 16 | model.modelName = model.name; 17 | } 18 | 19 | this.model = model; 20 | this.configStore = configStore; 21 | this.query = query; 22 | } 23 | 24 | serialize(records, meta = null) { 25 | return new ResourceSerializer( 26 | this.configStore.Implementation, 27 | this.model, 28 | records, 29 | this.configStore.integrator, 30 | meta, 31 | null, 32 | null, 33 | this.query?.fields ? new ParamsFieldsDeserializer(this.query.fields).perform() : null, 34 | ).perform(); 35 | } 36 | } 37 | 38 | module.exports = RecordSerializer; 39 | -------------------------------------------------------------------------------- /src/integrations/layer/services/conversations-getter.js: -------------------------------------------------------------------------------- 1 | const P = require('bluebird'); 2 | const request = require('superagent'); 3 | 4 | function ConversationsGetter(Implementation, params, opts, integrationInfo) { 5 | let collectionModel = null; 6 | 7 | function getConversations(user) { 8 | return new P(((resolve, reject) => { 9 | if (!user) { return resolve([0, []]); } 10 | 11 | return request 12 | .get(`https://api.layer.com/apps/${opts.integrations.layer.appId 13 | }/users/${user}/conversations`) 14 | .set('Accept', 'application/vnd.layer+json; version=2.0') 15 | .set('Content-type', 'application/json') 16 | .set('Authorization', `Bearer ${ 17 | opts.integrations.layer.serverApiToken}`) 18 | .end((error, data) => { 19 | if (error) { return reject(error); } 20 | return resolve([data.body.length, data.body]); 21 | }); 22 | })); 23 | } 24 | 25 | this.perform = () => { 26 | const collectionFieldName = integrationInfo.field; 27 | collectionModel = integrationInfo.collection; 28 | 29 | return Implementation.Layer.getUser( 30 | collectionModel, 31 | collectionFieldName, 32 | params.recordId, 33 | ) 34 | .then((user) => getConversations(user[collectionFieldName]) 35 | .spread((count, conversations) => [count, conversations]) 36 | .catch(() => [0, []])); 37 | }; 38 | } 39 | 40 | module.exports = ConversationsGetter; 41 | -------------------------------------------------------------------------------- /test/helpers/create-server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const bodyParser = require('body-parser'); 3 | const { expressjwt: jwt } = require('express-jwt'); 4 | const { inject } = require('@forestadmin/context'); 5 | 6 | const forestExpress = require('../../src'); 7 | const { getJWTConfiguration } = require('../../src/config/jwt'); 8 | const request = require('./request'); 9 | 10 | let app; 11 | 12 | module.exports = async function createServer(envSecret, authSecret) { 13 | if (app) { 14 | return app; 15 | } 16 | 17 | app = express(); 18 | 19 | app.use(bodyParser.json()); 20 | app.use(bodyParser.urlencoded({ extended: false })); 21 | 22 | app.use(jwt(getJWTConfiguration({ secret: 'jwt-secret' }))); 23 | 24 | const implementation = { 25 | opts: { 26 | envSecret, 27 | authSecret, 28 | connections: { 29 | db1: { 30 | models: {}, 31 | }, 32 | }, 33 | }, 34 | }; 35 | 36 | implementation.getModelName = () => {}; 37 | implementation.getLianaName = () => {}; 38 | implementation.getLianaVersion = () => {}; 39 | implementation.getOrmVersion = () => {}; 40 | implementation.getDatabaseType = () => {}; 41 | 42 | // We don't want to subscribe Server Events in tests 43 | jest.spyOn(inject().forestAdminClient, 'subscribeToServerEvents').mockResolvedValue(); 44 | 45 | const forestApp = await forestExpress.init(implementation); 46 | app.use(forestApp); 47 | request.init(); 48 | 49 | return app; 50 | }; 51 | -------------------------------------------------------------------------------- /src/integrations/stripe/services/source-getter.js: -------------------------------------------------------------------------------- 1 | const P = require('bluebird'); 2 | 3 | function SourceGetter(Implementation, params, opts, integrationInfo) { 4 | const stripe = opts.integrations.stripe.stripe(opts.integrations.stripe.apiKey); 5 | let collectionModel = null; 6 | 7 | function getSource(customerId, objectId) { 8 | return new P((resolve, reject) => { 9 | stripe.customers.retrieveSource(customerId, objectId, (error, source) => { 10 | if (error) { return reject(error); } 11 | return resolve(source); 12 | }); 13 | }); 14 | } 15 | 16 | this.perform = () => { 17 | collectionModel = integrationInfo.collection; 18 | const { 19 | field: collectionFieldName, 20 | embeddedPath, 21 | } = integrationInfo; 22 | const fieldName = embeddedPath ? `${collectionFieldName}.${embeddedPath}` : collectionFieldName; 23 | 24 | return Implementation.Stripe.getCustomer( 25 | collectionModel, 26 | collectionFieldName, 27 | params.recordId, 28 | ) 29 | .then((customer) => 30 | getSource(customer[collectionFieldName], params.objectId) 31 | .then((source) => 32 | Implementation.Stripe.getCustomerByUserField( 33 | collectionModel, 34 | fieldName, 35 | source.customer, 36 | ) 37 | .then((customerFound) => { 38 | source.customer = customerFound; 39 | return source; 40 | }))); 41 | }; 42 | } 43 | 44 | module.exports = SourceGetter; 45 | -------------------------------------------------------------------------------- /src/services/exposed/error-handler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates an error handler, that will return errors as JSON responses 3 | * 4 | * @param {{logger?: {error: (message: string, context?: any) => void }}?} options 5 | * @returns {import("express").ErrorRequestHandler} 6 | */ 7 | function errorHandler({ logger } = {}) { 8 | /** 9 | * @param {any} error 10 | * @param {import('express').Request} request 11 | * @param {import('express').Response} response 12 | * @param {() => void} next 13 | */ 14 | return function handleError(error, request, response, next) { 15 | if (error) { 16 | const responseError = { 17 | status: error.status || 500, 18 | detail: error.message, 19 | name: error.name, 20 | ...(error.data ? { data: error.data } : {}), 21 | }; 22 | 23 | // NOTICE: Send the first error if any. 24 | if (error.errors && error.errors[0]) { 25 | if (error.errors[0].message) { 26 | responseError.detail = error.errors[0].message; 27 | } 28 | if (error.errors[0].name) { 29 | responseError.name = error.errors[0].name; 30 | } 31 | } 32 | 33 | if (!error.status && logger) { 34 | // NOTICE: Unexpected errors should log an error in the console. 35 | logger.error('Unexpected error: ', error); 36 | } 37 | 38 | response.status(error.status || 500).send({ 39 | errors: [responseError], 40 | }); 41 | } else { 42 | next(); 43 | } 44 | }; 45 | } 46 | 47 | module.exports = errorHandler; 48 | -------------------------------------------------------------------------------- /src/integrations/stripe/serializers/payments.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const JSONAPISerializer = require('jsonapi-serializer').Serializer; 3 | const Schemas = require('../../../generators/schemas'); 4 | 5 | function serializePayments(payments, collectionName, meta) { 6 | function getCustomerAttributes() { 7 | if (!payments.length) { return []; } 8 | 9 | const schema = Schemas.schemas[collectionName]; 10 | if (!schema) { return []; } 11 | return _.map(schema.fields, 'field'); 12 | } 13 | 14 | function format(payment) { 15 | if (payment.created) { 16 | payment.created = new Date(payment.created * 1000); 17 | } 18 | 19 | if (payment.amount) { payment.amount /= 100; } 20 | 21 | return payment; 22 | } 23 | 24 | const customerAttributes = getCustomerAttributes(); 25 | 26 | if (payments.length) { 27 | payments = payments.map(format); 28 | } else { 29 | payments = format(payments); 30 | } 31 | 32 | const type = `${collectionName}_stripe_payments`; 33 | 34 | return new JSONAPISerializer(type, payments, { 35 | attributes: ['created', 'status', 'amount', 'currency', 'refunded', 36 | 'customer', 'description'], 37 | customer: { 38 | ref: Schemas.schemas[collectionName].idField, 39 | attributes: customerAttributes, 40 | }, 41 | keyForAttribute(key) { return key; }, 42 | typeForAttribute(attr) { 43 | if (attr === 'customer') { return collectionName; } 44 | return attr; 45 | }, 46 | meta, 47 | }); 48 | } 49 | 50 | module.exports = serializePayments; 51 | -------------------------------------------------------------------------------- /src/services/scope-manager.js: -------------------------------------------------------------------------------- 1 | const { inject } = require('@forestadmin/context'); 2 | 3 | class ScopeManager { 4 | constructor({ 5 | forestAdminClient, 6 | } = inject()) { 7 | /** @private @readonly @type {import('@forestadmin/forestadmin-client').ForestAdminClient} */ 8 | this.forestAdminClient = forestAdminClient; 9 | } 10 | 11 | async appendScopeForUser(existingFilter, user, collectionName) { 12 | const scopeFilter = await this.getScopeForUser(user, collectionName, true); 13 | const filters = [existingFilter, scopeFilter].filter(Boolean); 14 | 15 | switch (filters.length) { 16 | case 0: 17 | return undefined; 18 | case 1: 19 | return filters[0]; 20 | default: 21 | return `{"aggregator":"and","conditions":[${existingFilter},${scopeFilter}]}`; 22 | } 23 | } 24 | 25 | async getScopeForUser(user, collectionName, asString = false) { 26 | if (!user) throw new Error('Missing required user'); 27 | if (!user.renderingId) throw new Error('Missing required renderingId'); 28 | if (!collectionName) throw new Error('Missing required collectionName'); 29 | 30 | const scopeFilters = await this.forestAdminClient.getScope({ 31 | renderingId: user.renderingId, 32 | userId: user.id, 33 | collectionName, 34 | }); 35 | 36 | return asString && !!scopeFilters ? JSON.stringify(scopeFilters) : scopeFilters; 37 | } 38 | 39 | invalidateScopeCache(renderingId) { 40 | this.forestAdminClient.markScopesAsUpdated(renderingId); 41 | } 42 | } 43 | 44 | module.exports = ScopeManager; 45 | -------------------------------------------------------------------------------- /test/middlewares/deactivate-count.unit.test.js: -------------------------------------------------------------------------------- 1 | const deactivateCount = require('../../src/middlewares/count'); 2 | 3 | describe('middlewares > deactivateCount', () => { 4 | describe('when on a count route', () => { 5 | it('should return the right metadata', async () => { 6 | const reqMock = { path: 'collection1/count' }; 7 | const statusMock = { send: () => { } }; 8 | const respMock = { status: () => statusMock }; 9 | 10 | const sendResult = jest.spyOn(statusMock, 'send'); 11 | const statusResult = jest.spyOn(respMock, 'status').mockImplementation(() => statusMock); 12 | 13 | deactivateCount(reqMock, respMock); 14 | 15 | expect(statusResult).toHaveBeenCalledWith(200); 16 | expect(sendResult).toHaveBeenCalledWith({ 17 | meta: { 18 | count: 'deactivated', 19 | }, 20 | }); 21 | }); 22 | }); 23 | 24 | describe('when not on a count route', () => { 25 | it('should return an error', async () => { 26 | const reqMock = { path: 'collection1/:recordId' }; 27 | const statusMock = { send: () => { } }; 28 | const respMock = { status: () => statusMock }; 29 | 30 | const sendResult = jest.spyOn(statusMock, 'send'); 31 | const statusResult = jest.spyOn(respMock, 'status').mockImplementation(() => statusMock); 32 | 33 | deactivateCount(reqMock, respMock); 34 | 35 | expect(statusResult).toHaveBeenCalledWith(400); 36 | expect(sendResult).toHaveBeenCalledWith({ 37 | error: 'The deactiveCount middleware can only be used in count routes.', 38 | }); 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/integrations/mixpanel/serializers/mixpanel-events.js: -------------------------------------------------------------------------------- 1 | const moment = require('moment'); 2 | const uuidV1 = require('uuid/v1'); 3 | const JSONAPISerializer = require('jsonapi-serializer').Serializer; 4 | 5 | function serializeMixpanelEvents(events, collectionName, meta, options) { 6 | events = events.map((event) => { 7 | const MAP_PROPERTIES = { 8 | $city: 'city', 9 | $region: 'region', 10 | $timezone: 'timezone', 11 | $os: 'os', 12 | $os_version: 'osVersion', 13 | mp_country_code: 'country', 14 | time: 'date', 15 | }; 16 | 17 | Object.keys(event.properties).forEach((propertyName) => { 18 | if (MAP_PROPERTIES[propertyName]) { 19 | event[MAP_PROPERTIES[propertyName]] = event.properties[propertyName]; 20 | } else { 21 | event[propertyName] = event.properties[propertyName]; 22 | } 23 | 24 | delete event.properties[propertyName]; 25 | }); 26 | 27 | event.id = uuidV1(); 28 | event.date = moment(parseInt(event.date, 10) * 1000).format(''); 29 | 30 | return event; 31 | }); 32 | 33 | const type = `${collectionName}_mixpanel_events`; 34 | const attributes = ['id', 'event', 'date', 'city', 'region', 'country', 'os', 'osVersion', 35 | 'browser']; 36 | 37 | if (options.integrations.mixpanel.customProperties) { 38 | // eslint-disable-next-line prefer-spread 39 | attributes.push.apply(attributes, options.integrations.mixpanel.customProperties); 40 | } 41 | 42 | return new JSONAPISerializer(type, events, { 43 | attributes, 44 | keyForAttribute(key) { return key; }, 45 | meta, 46 | }); 47 | } 48 | 49 | module.exports = serializeMixpanelEvents; 50 | -------------------------------------------------------------------------------- /src/generators/schemas.js: -------------------------------------------------------------------------------- 1 | const P = require('bluebird'); 2 | 3 | function isArray(object) { 4 | return object && Array.isArray(object); 5 | } 6 | 7 | module.exports = { 8 | schemas: {}, 9 | 10 | _concat(left, right) { 11 | const leftArray = left || []; 12 | const rightArray = right || []; 13 | return leftArray.concat(rightArray); 14 | }, 15 | 16 | perform(implementation, integrator, models, opts) { 17 | return P.each(models, (model) => 18 | implementation.SchemaAdapter(model, opts) 19 | .then((schema) => { 20 | integrator.defineFields(model, schema); 21 | integrator.defineSegments(model, schema); 22 | schema.isSearchable = true; 23 | return schema; 24 | }) 25 | .then((schema) => { 26 | const modelName = implementation.getModelName(model); 27 | 28 | if (this.schemas[modelName]) { 29 | const currentSchema = this.schemas[modelName]; 30 | 31 | schema.fields = this._concat(schema.fields, currentSchema.fields); 32 | schema.actions = this._concat(schema.actions, currentSchema.actions); 33 | schema.segments = this._concat(schema.segments, currentSchema.segments); 34 | 35 | // Set this value only if searchFields property as been declared somewhere. 36 | if (isArray(schema.searchFields) || isArray(currentSchema.searchFields)) { 37 | schema.searchFields = this._concat( 38 | schema.searchFields, 39 | currentSchema.searchFields, 40 | ); 41 | } 42 | } 43 | 44 | this.schemas[modelName] = schema; 45 | })); 46 | }, 47 | }; 48 | -------------------------------------------------------------------------------- /test/context/index.test.js: -------------------------------------------------------------------------------- 1 | const { makeLogger } = require('../../src/context/build-services'); 2 | 3 | describe('context > build-services', () => { 4 | describe('makeLogger', () => { 5 | it('should create logger with default value (log level Info)', async () => { 6 | const env = {}; 7 | const logger = { 8 | info: jest.fn(), 9 | }; 10 | 11 | const internalLogger = makeLogger({ env, logger }); 12 | 13 | internalLogger('Info', 'Message'); 14 | 15 | expect(logger.info).toHaveBeenCalledOnce(); 16 | expect(logger.info).toHaveBeenCalledWith('Message'); 17 | }); 18 | 19 | describe('when FOREST_LOGGER_LEVEL is equal or higher than the actual log level', () => { 20 | it('should log the message', async () => { 21 | const env = { FOREST_LOGGER_LEVEL: 'Debug' }; 22 | const logger = { 23 | debug: jest.fn(), 24 | }; 25 | 26 | const internalLogger = makeLogger({ env, logger }); 27 | 28 | internalLogger('Debug', 'Message'); 29 | 30 | expect(logger.debug).toHaveBeenCalledOnce(); 31 | expect(logger.debug).toHaveBeenCalledWith('Message'); 32 | }); 33 | }); 34 | 35 | describe('when FOREST_LOGGER_LEVEL is lower than the actual log level', () => { 36 | it('should not log anything', async () => { 37 | const env = { FOREST_LOGGER_LEVEL: 'Error' }; 38 | const logger = { 39 | info: jest.fn(), 40 | }; 41 | 42 | const internalLogger = makeLogger({ env, logger }); 43 | 44 | internalLogger('Info', 'Message'); 45 | 46 | expect(logger.info).not.toHaveBeenCalled(); 47 | }); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/integrations/index.js: -------------------------------------------------------------------------------- 1 | const CloseIo = require('./close.io'); 2 | const Intercom = require('./intercom'); 3 | const Layer = require('./layer'); 4 | const MixPanel = require('./mixpanel'); 5 | const Stripe = require('./stripe'); 6 | 7 | function IntegrationChecker(opts, Implementation) { 8 | const modules = [ 9 | new CloseIo(opts, Implementation), 10 | new Intercom(opts, Implementation), 11 | new Layer(opts, Implementation), 12 | new MixPanel(opts, Implementation), 13 | new Stripe(opts, Implementation), 14 | ]; 15 | 16 | this.defineRoutes = (app, model) => { 17 | modules.forEach((module) => { 18 | if (module.defineRoutes) { 19 | module.defineRoutes(app, model); 20 | } 21 | }); 22 | }; 23 | 24 | this.defineCollections = (collections) => { 25 | modules.forEach((module) => { 26 | if (module.defineCollections) { 27 | module.defineCollections(collections); 28 | } 29 | }); 30 | }; 31 | 32 | this.defineSegments = (model, schema) => { 33 | modules.forEach((module) => { 34 | if (module.defineSegments) { 35 | module.defineSegments(model, schema); 36 | } 37 | }); 38 | }; 39 | 40 | this.defineFields = (model, schema) => { 41 | modules.forEach((module) => { 42 | if (module.defineFields) { 43 | module.defineFields(model, schema); 44 | } 45 | }); 46 | }; 47 | 48 | this.defineSerializationOption = (model, schema, dest, field) => { 49 | modules.forEach((module) => { 50 | if (module.defineSerializationOption) { 51 | module.defineSerializationOption(model, schema, dest, field); 52 | } 53 | }); 54 | }; 55 | } 56 | 57 | module.exports = IntegrationChecker; 58 | -------------------------------------------------------------------------------- /src/services/project-directory-finder.js: -------------------------------------------------------------------------------- 1 | class ProjectDirectoryFinder { 2 | constructor({ path }) { 3 | this.path = path; 4 | this.dirname = __dirname; 5 | 6 | // NOTICE: Order does matter as packages install via yarn 2 Plug n Play mode also 7 | // include node_modules in path. 8 | this.PATHS_ROOT_PACKAGES = [ 9 | // Yarn 2 Plug n Play mode 10 | this.path.join('.yarn', 'cache'), 11 | this.path.join('.yarn', 'unplugged'), 12 | // Usual Yarn / NPM 13 | 'node_modules', 14 | ]; 15 | } 16 | 17 | ensureAbsolutePath(subPathsToProject) { 18 | // NOTICE: on POSIX system, empty path created by previous split is skipped by path.join. 19 | if (!this.path.isAbsolute(this.path.join(...subPathsToProject))) { 20 | return this.path.join(this.path.sep, ...subPathsToProject); 21 | } 22 | return this.path.join(...subPathsToProject); 23 | } 24 | 25 | getAbsolutePath() { 26 | for (let index = 0; index <= this.PATHS_ROOT_PACKAGES.length; index += 1) { 27 | const rootPackagesPath = this.PATHS_ROOT_PACKAGES[index]; 28 | // NOTICE: forest-express has not been sym linked. 29 | if (this.dirname.includes(rootPackagesPath)) { 30 | const indexRootPath = this.dirname.indexOf(rootPackagesPath); 31 | const pathProjectRoot = this.dirname.substr(0, indexRootPath); 32 | const subPathsToProject = pathProjectRoot.split(this.path.sep); 33 | return this.ensureAbsolutePath(subPathsToProject); 34 | } 35 | } 36 | 37 | // NOTICE: forest-express is sym linked, assuming the process is running on project directory. 38 | return process.cwd(); 39 | } 40 | } 41 | 42 | module.exports = ProjectDirectoryFinder; 43 | -------------------------------------------------------------------------------- /test/utils/get-ip-from-request.test.js: -------------------------------------------------------------------------------- 1 | const getIpFromRequest = require('../../src/utils/get-ip-from-request'); 2 | 3 | describe('utils > getIpFromRequest', () => { 4 | describe('with x-forwarded-for header', () => { 5 | const request = { 6 | headers: { 7 | 'x-forwarded-for': '34.235.48.51, 10.0.10.117', 8 | }, 9 | connection: { 10 | remoteAddress: 'should not be used', 11 | }, 12 | }; 13 | 14 | it('should return the first public ip', () => { 15 | expect(getIpFromRequest(request)).toBe('34.235.48.51'); 16 | }); 17 | 18 | it('should not fail with the port in the ip', () => { 19 | request.headers['x-forwarded-for'] = '10.0.10.117, 34.235.48.51:53465'; 20 | 21 | expect(getIpFromRequest(request)).toBe('34.235.48.51'); 22 | }); 23 | 24 | it('should fallback to remote address if the IP in the header is invalid', () => { 25 | request.headers['x-forwarded-for'] = '10'; 26 | request.connection.remoteAddress = '1.2.3.4'; 27 | 28 | expect(getIpFromRequest(request)).toBe('1.2.3.4'); 29 | }); 30 | 31 | describe('with a loopback in the header', () => { 32 | it('should return the loopback', () => { 33 | request.headers['x-forwarded-for'] = '10.0.10.117, 127.0.0.1'; 34 | 35 | expect(getIpFromRequest(request)).toBe('127.0.0.1'); 36 | }); 37 | }); 38 | }); 39 | 40 | describe('without x-forwarded-for header', () => { 41 | const request = { 42 | headers: {}, 43 | connection: { 44 | remoteAddress: '34.235.48.51', 45 | }, 46 | }; 47 | 48 | it('should return the remoteAddress ip', () => { 49 | expect(getIpFromRequest(request)).toBe('34.235.48.51'); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/integrations/intercom/services/contact-getter.js: -------------------------------------------------------------------------------- 1 | const { inject } = require('@forestadmin/context'); 2 | const logger = require('../../../services/logger'); 3 | 4 | function getContactQueryParameter(targetFieldValue) { 5 | const targetField = targetFieldValue.includes('@') ? 'email' : 'external_id'; 6 | 7 | return { 8 | query: { 9 | field: targetField, 10 | operator: '=', 11 | value: targetFieldValue, 12 | }, 13 | }; 14 | } 15 | 16 | class ContactGetter { 17 | static async getContact(intercomClient, Implementation, mappingValue, recordId) { 18 | const { modelsManager } = inject(); 19 | // NOTICE: `modelFieldName` is expected to be a sequelize/mongoose field 20 | // (ie. camelCase formatted) 21 | const [modelName, modelFieldName] = mappingValue.split('.'); 22 | const model = modelsManager.getModels()[modelName]; 23 | const customer = await Implementation.Intercom.getCustomer(model, recordId); 24 | const targetFieldValue = customer[modelFieldName]; 25 | 26 | if (!targetFieldValue) { 27 | // NOTICE: `undefined` means the field does not exist. 28 | if (targetFieldValue === undefined) { 29 | logger.error(`Intercom Integration Error: No field "${modelFieldName}" on model "${modelName}"`); 30 | } 31 | // TODO: An info log should be shown here: no "mapping value" and so no intercom info 32 | // can be retrieved in this context 33 | return null; 34 | } 35 | 36 | const queryParameter = getContactQueryParameter(targetFieldValue); 37 | 38 | // TODO: Replace this by a proper call to intercom.contacts.search() when available 39 | return intercomClient.post('/contacts/search', queryParameter); 40 | } 41 | } 42 | 43 | module.exports = ContactGetter; 44 | -------------------------------------------------------------------------------- /src/services/ip-whitelist.js: -------------------------------------------------------------------------------- 1 | const P = require('bluebird'); 2 | const _ = require('lodash'); 3 | const VError = require('verror'); 4 | const ipUtil = require('forest-ip-utils'); 5 | const logger = require('./logger'); 6 | const errorMessages = require('../utils/error-messages'); 7 | const forestServerRequester = require('./forest-server-requester'); 8 | const IpWhitelistDeserializer = require('../deserializers/ip-whitelist'); 9 | 10 | let ipWhitelistRules = null; 11 | let useIpWhitelist = true; 12 | 13 | function retrieve(environmentSecret) { 14 | return forestServerRequester 15 | .perform('/liana/v1/ip-whitelist-rules', environmentSecret) 16 | .then((responseBody) => { 17 | if (responseBody.data) { 18 | return new IpWhitelistDeserializer(responseBody.data).perform(); 19 | } 20 | return P.reject(new Error(`IP Whitelist: ${errorMessages.SERVER_TRANSACTION.UNEXPECTED}`)); 21 | }) 22 | .then((ipWhitelistData) => { 23 | useIpWhitelist = ipWhitelistData.useIpWhitelist; 24 | ipWhitelistRules = ipWhitelistData.rules; 25 | }) 26 | .catch((error) => { 27 | logger.error(`An error occured while retrieving your IP whitelist. Your Forest envSecret may be missing or unknown. Can you check that you properly set your Forest envSecret in the Forest initializer? ${error.message}`); 28 | return P.reject(new VError('IP Whitelist error', error)); 29 | }); 30 | } 31 | 32 | function isIpWhitelistRetrieved() { 33 | return !useIpWhitelist || ipWhitelistRules !== null; 34 | } 35 | 36 | function isIpValid(ip) { 37 | if (useIpWhitelist) { 38 | return _.some(ipWhitelistRules, (rule) => ipUtil.isIpMatchesRule(ip, rule)); 39 | } 40 | 41 | return true; 42 | } 43 | 44 | module.exports = { 45 | retrieve, 46 | isIpValid, 47 | isIpWhitelistRetrieved, 48 | }; 49 | -------------------------------------------------------------------------------- /src/utils/schema.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const { inject } = require('@forestadmin/context'); 3 | 4 | exports.getBelongsToAssociations = (schema) => 5 | _.filter( 6 | schema.fields, 7 | (field) => field.reference && !_.isArray(field.type) && !field.isVirtual && !field.integration, 8 | ); 9 | 10 | exports.getHasManyAssociations = (schema) => 11 | _.filter( 12 | schema.fields, 13 | (field) => _.isArray(field.type) && !field.isVirtual && !field.integration, 14 | ); 15 | 16 | function getField(schema, fieldName) { 17 | const [fieldNameToSearch] = fieldName.split(':'); 18 | 19 | return schema.fields.find((field) => field.field === fieldNameToSearch); 20 | } 21 | exports.getField = getField; 22 | 23 | exports.getSmartField = (schema, fieldName) => { 24 | const field = getField(schema, fieldName); 25 | if (!field) return null; 26 | 27 | // If the field is not virtual but the field requested is something like "myField:nestedField" 28 | // then we want to retrieve nestedField to check if nestedField isVirtual 29 | if (!field.isVirtual && fieldName.includes(':') && field.reference) { 30 | const [referencedModel] = field.reference.split('.'); 31 | const { schemasGenerator } = inject(); 32 | const referenceSchema = schemasGenerator.schemas[referencedModel]; 33 | return exports.getSmartField(referenceSchema, fieldName.substring(fieldName.indexOf(':') + 1)); 34 | } 35 | 36 | return field.isVirtual ? field : null; 37 | }; 38 | 39 | exports.isSmartField = (schema, fieldName) => { 40 | const fieldFound = exports.getSmartField(schema, fieldName); 41 | return !!fieldFound && !!fieldFound.isVirtual; 42 | }; 43 | 44 | exports.getFieldType = (schema, fieldName) => { 45 | const fieldFound = getField(schema, fieldName); 46 | return fieldFound && fieldFound.type; 47 | }; 48 | -------------------------------------------------------------------------------- /src/integrations/mixpanel/routes.js: -------------------------------------------------------------------------------- 1 | const { inject } = require('@forestadmin/context'); 2 | const _ = require('lodash'); 3 | const IntegrationInformationsGetter = require('../../services/integration-informations-getter'); 4 | const MixpanelEventsGetter = require('./services/mixpanel-events-getter'); 5 | const serializeMixpanelEvents = require('./serializers/mixpanel-events'); 6 | const auth = require('../../services/auth'); 7 | const path = require('../../services/path'); 8 | 9 | module.exports = function Routes(app, model, Implementation, options) { 10 | const { modelsManager } = inject(); 11 | const modelName = Implementation.getModelName(model); 12 | let integrationInfo; 13 | 14 | if (options.integrations && options.integrations.mixpanel) { 15 | integrationInfo = new IntegrationInformationsGetter( 16 | modelName, 17 | Implementation, 18 | options.integrations.mixpanel, 19 | ).perform(); 20 | } 21 | 22 | if (integrationInfo) { 23 | const integrationValues = integrationInfo.split('.'); 24 | integrationInfo = { 25 | collection: modelsManager.getModels()[integrationValues[0]], 26 | field: integrationValues[1], 27 | }; 28 | } 29 | 30 | this.mixpanelEvents = (request, response, next) => { 31 | new MixpanelEventsGetter( 32 | Implementation, 33 | _.extend(request.query, request.params), 34 | options, 35 | integrationInfo, 36 | ) 37 | .perform() 38 | .then((events) => serializeMixpanelEvents(events, modelName, { }, options)) 39 | .then((events) => { response.send(events); }) 40 | .catch(next); 41 | }; 42 | 43 | this.perform = () => { 44 | app.get( 45 | path.generate(`${modelName}/:recordId/relationships/mixpanel_last_events`, options), 46 | auth.ensureAuthenticated, 47 | this.mixpanelEvents, 48 | ); 49 | }; 50 | }; 51 | -------------------------------------------------------------------------------- /src/services/models-manager.js: -------------------------------------------------------------------------------- 1 | module.exports = class ModelsManager { 2 | constructor({ configStore }) { 3 | this.configStore = configStore; 4 | this.models = null; 5 | } 6 | 7 | _flatConnectionsModels() { 8 | const { connections } = this.configStore.lianaOptions; 9 | 10 | // Should transform connections object to an array containing all models 11 | // connections => { db1: { models: { model1: {} } }, db2: { models: { model2: {} } } } 12 | // return [ model1, model2 ] 13 | return Object.values(connections) 14 | .reduce((models, connection) => models.concat(Object.values(connection.models)), []); 15 | } 16 | 17 | _filterModels(models, condition) { 18 | const { getModelName } = this.configStore.Implementation; 19 | 20 | return models.filter((model) => condition(getModelName(model))); 21 | } 22 | 23 | _computeModels(models) { 24 | const { getModelName } = this.configStore.Implementation; 25 | 26 | return models.reduce((computedModels, model) => { 27 | computedModels[getModelName(model)] = model; 28 | return computedModels; 29 | }, {}); 30 | } 31 | 32 | _generateModelList() { 33 | const { includedModels, excludedModels } = this.configStore.lianaOptions; 34 | let models = this._flatConnectionsModels(); 35 | const useInclude = includedModels && includedModels.length; 36 | const useExclude = excludedModels && excludedModels.length; 37 | 38 | if (useInclude) { 39 | models = this._filterModels(models, (modelName) => includedModels.includes(modelName)); 40 | } else if (useExclude) { 41 | models = this._filterModels(models, (modelName) => !excludedModels.includes(modelName)); 42 | } 43 | 44 | this.models = this._computeModels(models); 45 | } 46 | 47 | getModels() { 48 | if (!this.models) this._generateModelList(); 49 | return this.models; 50 | } 51 | 52 | getModelByName(name) { 53 | return this.getModels()[name]; 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /src/integrations/stripe/serializers/invoices.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const JSONAPISerializer = require('jsonapi-serializer').Serializer; 3 | const Schemas = require('../../../generators/schemas'); 4 | 5 | function serializeInvoices(invoices, collectionName, meta) { 6 | function getCustomerAttributes() { 7 | if (!invoices.length) { return []; } 8 | 9 | const schema = Schemas.schemas[collectionName]; 10 | if (!schema) { return []; } 11 | return _.map(schema.fields, 'field'); 12 | } 13 | 14 | function format(invoice) { 15 | // jshint camelcase: false 16 | invoice.date = new Date(invoice.date * 1000); 17 | 18 | if (invoice.period_start) { 19 | invoice.period_start = new Date(invoice.period_start * 1000); 20 | } 21 | 22 | if (invoice.period_end) { 23 | invoice.period_end = new Date(invoice.period_end * 1000); 24 | } 25 | 26 | if (invoice.subtotal) { invoice.subtotal /= 100; } 27 | if (invoice.total) { invoice.total /= 100; } 28 | 29 | return invoice; 30 | } 31 | 32 | const customerAttributes = getCustomerAttributes(); 33 | 34 | if (invoices.length) { 35 | invoices = invoices.map(format); 36 | } else { 37 | invoices = format(invoices); 38 | } 39 | 40 | const type = `${collectionName}_stripe_invoices`; 41 | 42 | return new JSONAPISerializer(type, invoices, { 43 | attributes: ['amount_due', 'attempt_count', 'attempted', 'closed', 44 | 'currency', 'date', 'forgiven', 'paid', 'period_start', 'period_end', 45 | 'subtotal', 'total', 'application_fee', 'tax', 'tax_percent', 46 | 'customer'], 47 | customer: { 48 | ref: Schemas.schemas[collectionName].idField, 49 | attributes: customerAttributes, 50 | }, 51 | keyForAttribute: (key) => key, 52 | typeForAttribute: (attr) => { 53 | if (attr === 'customer') { return collectionName; } 54 | return attr; 55 | }, 56 | meta, 57 | }); 58 | } 59 | 60 | module.exports = serializeInvoices; 61 | -------------------------------------------------------------------------------- /src/services/forest-server-requester.js: -------------------------------------------------------------------------------- 1 | const { inject } = require('@forestadmin/context'); 2 | const P = require('bluebird'); 3 | const superagent = require('superagent'); 4 | const VError = require('verror'); 5 | const timeoutError = require('../utils/timeout-error'); 6 | const errorMessages = require('../utils/error-messages'); 7 | 8 | function perform(route, environmentSecret, queryParameters, headers) { 9 | const { forestUrl } = inject(); 10 | 11 | const url = forestUrl + route; 12 | // eslint-disable-next-line sonarjs/cognitive-complexity 13 | return new P((resolve, reject) => { 14 | const request = superagent 15 | .get(url).timeout(process.env.FOREST_REQUEST_TIMEOUT || 30_000); 16 | 17 | if (environmentSecret) { 18 | request.set('forest-secret-key', environmentSecret); 19 | } 20 | 21 | if (headers) { 22 | request.set(headers); 23 | } 24 | 25 | if (queryParameters) { 26 | request.query(queryParameters); 27 | } 28 | 29 | request.end((error, result) => { 30 | if (result) { 31 | if (result.status === 200 && result.body) { 32 | return resolve(result.body); 33 | } 34 | if (result.status === 0) { 35 | return reject(new Error(errorMessages.SERVER_TRANSACTION.SERVER_DOWN)); 36 | } 37 | if (result.status === 404) { 38 | return reject(new Error(errorMessages.SERVER_TRANSACTION.SECRET_NOT_FOUND)); 39 | } 40 | if (result.status === 422) { 41 | return reject(new Error(errorMessages 42 | .SERVER_TRANSACTION.SECRET_AND_RENDERINGID_INCONSISTENT)); 43 | } 44 | } 45 | 46 | if (error) { 47 | const message = timeoutError(url, error) || 'Forest server request error'; 48 | return reject(new VError(error, message)); 49 | } 50 | 51 | return reject(new Error(errorMessages.SERVER_TRANSACTION.UNEXPECTED, error)); 52 | }); 53 | }); 54 | } 55 | 56 | module.exports = { 57 | perform, 58 | }; 59 | -------------------------------------------------------------------------------- /src/services/oidc-configuration-retriever.js: -------------------------------------------------------------------------------- 1 | const DEFAULT_EXPIRATION_IN_SECONDS = 30 * 60; 2 | 3 | class OidcConfigurationRetrieverService { 4 | /** @type {import('./forest-server-requester')} */ 5 | forestServerRequester; 6 | 7 | /** @type {import('../context/init').Env} */ 8 | env; 9 | 10 | /** @type {Promise<{configuration: import('openid-client').IssuerMetadata, expiration: Date}>} */ 11 | cachedWellKnownConfiguration; 12 | 13 | /** 14 | * @param {import("../context/init").Context} context 15 | */ 16 | constructor(context) { 17 | this.forestServerRequester = context.forestServerRequester; 18 | this.env = context.env; 19 | } 20 | 21 | /** 22 | * @private 23 | * @returns {Promise} 24 | */ 25 | async _fetchConfiguration() { 26 | return this.forestServerRequester.perform('/oidc/.well-known/openid-configuration'); 27 | } 28 | 29 | /** 30 | * @returns {Promise} 31 | */ 32 | async retrieve() { 33 | const now = new Date(); 34 | 35 | if (this.cachedWellKnownConfiguration 36 | && (await this.cachedWellKnownConfiguration).expiration < now) { 37 | this.clearCache(); 38 | } 39 | 40 | if (!this.cachedWellKnownConfiguration) { 41 | this.cachedWellKnownConfiguration = this._fetchConfiguration() 42 | .then((configuration) => { 43 | const expirationDuration = this.env.FOREST_OIDC_CONFIG_EXPIRATION_IN_SECONDS 44 | || DEFAULT_EXPIRATION_IN_SECONDS; 45 | const expiration = new Date(Date.now() + expirationDuration); 46 | return { configuration, expiration }; 47 | }) 48 | .catch((error) => { 49 | this.cachedWellKnownConfiguration = null; 50 | throw error; 51 | }); 52 | } 53 | 54 | return (await this.cachedWellKnownConfiguration).configuration; 55 | } 56 | 57 | clearCache() { 58 | this.cachedWellKnownConfiguration = null; 59 | } 60 | } 61 | 62 | module.exports = OidcConfigurationRetrieverService; 63 | -------------------------------------------------------------------------------- /test/services/apimap-fields-formater.test.js: -------------------------------------------------------------------------------- 1 | const ApimapFieldsFormater = require('../../src/services/apimap-fields-formater'); 2 | 3 | const apimapFieldsFormater = new ApimapFieldsFormater({ 4 | logger: { 5 | warn: jest.fn(), 6 | }, 7 | }); 8 | 9 | describe('services > apimap-fields-formater', () => { 10 | describe('formatFieldsByCollectionName', () => { 11 | it('should filter fields without declared "field" attribute', () => { 12 | const fieldsFormated = apimapFieldsFormater.formatFieldsByCollectionName( 13 | [{ 14 | field: 'email', 15 | type: 'String', 16 | }, { 17 | field: 'signInCount', 18 | type: 'Number', 19 | isFilterable: true, 20 | }], 21 | 'Users', 22 | ); 23 | 24 | expect(fieldsFormated).toHaveLength(2); 25 | expect(fieldsFormated).toStrictEqual([{ 26 | field: 'email', 27 | type: 'String', 28 | isVirtual: true, 29 | isFilterable: false, 30 | isSortable: false, 31 | isReadOnly: true, 32 | }, { 33 | field: 'signInCount', 34 | type: 'Number', 35 | isVirtual: true, 36 | isFilterable: true, 37 | isSortable: false, 38 | isReadOnly: true, 39 | }]); 40 | }); 41 | 42 | describe('when one of the given fields does not have a field value', () => { 43 | const fieldsFormated = apimapFieldsFormater.formatFieldsByCollectionName( 44 | [{ 45 | type: 'String', 46 | }], 47 | 'Users', 48 | ); 49 | 50 | it('should log a warning message', () => { 51 | expect(apimapFieldsFormater.logger.warn).toHaveBeenCalledTimes(1); 52 | expect(apimapFieldsFormater.logger.warn).toHaveBeenCalledWith('Bad Smart Field declaration in "Users" collection: missing "field" attribute.'); 53 | }); 54 | 55 | it('should not format the field', () => { 56 | expect(fieldsFormated).toHaveLength(0); 57 | expect(fieldsFormated).toStrictEqual([]); 58 | }); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/integrations/mixpanel/services/mixpanel-events-getter.js: -------------------------------------------------------------------------------- 1 | const moment = require('moment'); 2 | const logger = require('../../../services/logger'); 3 | 4 | function MixpanelEventsGetter(Implementation, params, options, integrationInfo) { 5 | const MixpanelExport = options.integrations.mixpanel.mixpanel; 6 | const panel = new MixpanelExport({ 7 | api_key: options.integrations.mixpanel.apiKey, 8 | api_secret: options.integrations.mixpanel.apiSecret, 9 | }); 10 | 11 | this.perform = () => { 12 | const collectionFieldName = integrationInfo.field; 13 | const collectionModel = integrationInfo.collection; 14 | 15 | return Implementation.Mixpanel.getUser(collectionModel, params.recordId) 16 | .then((user) => { 17 | const script = `function main() { 18 | return People().filter(function (user) { 19 | return user.properties.$email == '${user[collectionFieldName]}'; 20 | }); 21 | }`; 22 | 23 | // NOTICE: The mixpanel's API doesn't allow to retrieve events that are 24 | // more than 60 days old. 25 | const fromDate = moment().subtract(60, 'days'); 26 | const toDate = moment(); 27 | 28 | return panel 29 | .get('jql', { 30 | script, 31 | }) 32 | .then((result) => { 33 | if (!result || !result[0]) { return { results: { events: [] } }; } 34 | 35 | return panel 36 | .get('stream/query', { 37 | from_date: fromDate.format('YYYY-MM-DD'), 38 | to_date: toDate.format('YYYY-MM-DD'), 39 | distinct_ids: [result[0].distinct_id], 40 | limit: 100, 41 | }); 42 | }) 43 | .then((result) => { 44 | if (result.error) { 45 | logger.error('Cannot retrieve mixpanel events: ', result.error); 46 | return []; 47 | } 48 | 49 | return result.results.events.reverse(); 50 | }); 51 | }); 52 | }; 53 | } 54 | 55 | module.exports = MixpanelEventsGetter; 56 | -------------------------------------------------------------------------------- /src/services/auth.js: -------------------------------------------------------------------------------- 1 | const { compose } = require('compose-middleware'); 2 | const error = require('../utils/error'); 3 | const logger = require('./logger'); 4 | const createIpAuthorizer = require('../middlewares/ip-whitelist'); 5 | 6 | const ERROR_MESSAGE = 'Forest cannot authenticate the user for this request.'; 7 | const ERROR_MESSAGE_TOKEN_OLD = 'Your token format is invalid, please login again.'; 8 | 9 | let ipAuthorizer; 10 | 11 | function initAuth(options) { 12 | ipAuthorizer = createIpAuthorizer(options.envSecret); 13 | } 14 | 15 | function ensureAuthenticated(request, response, next) { 16 | if (!request.user) { 17 | return next(new error.Unauthorized(ERROR_MESSAGE)); 18 | } 19 | 20 | // NOTICE: Automatically logout users trying to access the API with a token having an 21 | // old data format. 22 | if (request.user.type) { 23 | return next(new error.Unauthorized(ERROR_MESSAGE_TOKEN_OLD)); 24 | } 25 | 26 | return next(); 27 | } 28 | 29 | function authenticate(request, response, next, authenticator) { 30 | if (request.user) { 31 | // NOTICE: User already authentified by the liana authentication middleware. 32 | return next(); 33 | } 34 | 35 | if (!authenticator) { 36 | logger.error('The Liana has not been initialized to enable the authentication.'); 37 | return next(new error.Unauthorized(ERROR_MESSAGE)); 38 | } 39 | 40 | return authenticator(request, response, (hasError) => { 41 | if (hasError) { 42 | logger.debug(hasError); 43 | return next(new error.Unauthorized(ERROR_MESSAGE)); 44 | } 45 | 46 | return ensureAuthenticated(request, response, next); 47 | }); 48 | } 49 | 50 | exports.allowedUsers = []; 51 | exports.ensureAuthenticated = compose([ 52 | ensureAuthenticated, 53 | (request, response, next) => { 54 | if (!ipAuthorizer) { 55 | return logger.error('"ensureAuthenticated" middleware must be called after "liana.init" function.'); 56 | } 57 | 58 | return ipAuthorizer(request, response, next); 59 | }, 60 | ]); 61 | exports.authenticate = authenticate; 62 | exports.initAuth = initAuth; 63 | -------------------------------------------------------------------------------- /src/services/apimap-sender.js: -------------------------------------------------------------------------------- 1 | const timeoutError = require('../utils/timeout-error'); 2 | 3 | class ApimapSender { 4 | constructor({ forestUrl, logger, superagentRequest }) { 5 | this.forestUrl = forestUrl; 6 | this.superagentRequest = superagentRequest; 7 | this.logger = logger; 8 | } 9 | 10 | handleResult(result) { 11 | if (!result) return; 12 | 13 | if ([200, 202, 204].includes(result.status)) { 14 | if (result.body && result.body.warning) { 15 | this.logger.warn(result.body.warning); 16 | } 17 | } else if (result.status === 0) { 18 | this.logger.warn('Cannot send the apimap to Forest. Are you online?'); 19 | } else if (result.status === 404) { 20 | this.logger.error('Cannot find the project related to the envSecret you configured. Can you check on Forest that you copied it properly in the Forest initialization?'); 21 | } else if (result.status === 503) { 22 | this.logger.warn('Forest is in maintenance for a few minutes. We are upgrading your experience in the forest. We just need a few more minutes to get it right.'); 23 | } else { 24 | this.logger.error('An error occured with the apimap sent to Forest. Please contact support@forestadmin.com for further investigations.'); 25 | } 26 | } 27 | 28 | _send(envSecret, data, path) { 29 | const url = `${this.forestUrl}/${path}`; 30 | return this.superagentRequest 31 | .post(url) 32 | .set('forest-secret-key', envSecret) 33 | .send(data) 34 | .catch((error) => { 35 | const message = timeoutError(url, error); 36 | if (message) { 37 | throw new Error(message); 38 | } else { 39 | throw error; 40 | } 41 | }) 42 | .then((result) => { 43 | this.handleResult(result); 44 | return result; 45 | }); 46 | } 47 | 48 | send(envSecret, apimap) { 49 | return this._send(envSecret, apimap, 'forest/apimaps'); 50 | } 51 | 52 | checkHash(envSecret, schemaFileHash) { 53 | return this._send(envSecret, { schemaFileHash }, 'forest/apimaps/hashcheck'); 54 | } 55 | } 56 | 57 | module.exports = ApimapSender; 58 | -------------------------------------------------------------------------------- /src/utils/json.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | 3 | const prettyPrint = (json, indentation = '') => { 4 | let result = ''; 5 | 6 | if (_.isArray(json)) { 7 | result += '['; 8 | const isSmall = json.length < 3; 9 | const isPrimaryValue = json.length && !_.isArray(json[0]) && !_.isObject(json[0]); 10 | 11 | _.each(json, (item, index) => { 12 | if (index === 0 && isPrimaryValue && !isSmall) { 13 | result += `\n${indentation} `; 14 | } else if (index > 0 && isPrimaryValue && !isSmall) { 15 | result += `,\n${indentation} `; 16 | } else if (index > 0) { 17 | result += ', '; 18 | } 19 | 20 | result += prettyPrint(item, isPrimaryValue ? `${indentation} ` : indentation); 21 | }); 22 | 23 | if (isPrimaryValue && !isSmall) { 24 | result += `\n${indentation}`; 25 | } 26 | result += ']'; 27 | } else if (_.isObject(json)) { 28 | result += '{\n'; 29 | 30 | let isFirst = true; 31 | Object.keys(json).forEach((key) => { 32 | const value = json[key]; 33 | if (!isFirst) { 34 | result += ',\n'; 35 | } else { 36 | isFirst = false; 37 | } 38 | 39 | result += `${indentation} "${key}": `; 40 | result += prettyPrint(value, `${indentation} `); 41 | }); 42 | 43 | result += `\n${indentation}}`; 44 | } else if (_.isNil(json)) { 45 | result += 'null'; 46 | } else if (_.isString(json)) { 47 | // NOTICE: Escape invalid characters (see: https://www.json.org/json-en.html). 48 | // Also, JSON spec precise that '/' doesn't need to be escaped. 49 | // (see: https://stackoverflow.com/questions/1580647/json-why-are-forward-slashes-escaped) 50 | const escapedJsonString = json 51 | .replace(/[\\]/g, '\\\\') 52 | .replace(/["]/g, '\\"') 53 | .replace(/[\b]/g, '\\b') 54 | .replace(/[\f]/g, '\\f') 55 | .replace(/[\n]/g, '\\n') 56 | .replace(/[\r]/g, '\\r') 57 | .replace(/[\t]/g, '\\t'); 58 | result += `"${escapedJsonString}"`; 59 | } else { 60 | result += `${json}`; 61 | } 62 | 63 | return result; 64 | }; 65 | 66 | exports.prettyPrint = prettyPrint; 67 | -------------------------------------------------------------------------------- /src/integrations/intercom/services/attributes-getter.js: -------------------------------------------------------------------------------- 1 | const logger = require('../../../services/logger'); 2 | const ContactGetter = require('./contact-getter'); 3 | 4 | function AttributesGetter(Implementation, params, opts, mappingValue) { 5 | const Intercom = opts.integrations.intercom.intercom; 6 | const intercom = new Intercom.Client(opts.integrations.intercom.credentials); 7 | 8 | this.perform = async () => { 9 | try { 10 | const contactQueryResponse = await ContactGetter 11 | .getContact(intercom, Implementation, mappingValue, params.recordId); 12 | 13 | // NOTICE: No contact found with the given `recordId` or `mappingValue` 14 | if ( 15 | !contactQueryResponse 16 | || !contactQueryResponse.body 17 | || !contactQueryResponse.body.data 18 | || !contactQueryResponse.body.data[0] 19 | ) { 20 | logger.error('Cannot access to Intercom attributes: No intercom contact matches the given key'); 21 | return null; 22 | } 23 | 24 | const contact = contactQueryResponse.body.data[0]; 25 | 26 | contact.city = contact.location.city; 27 | contact.country = contact.location.country; 28 | 29 | // NOTICE: This slows down the request 30 | const tags = await intercom.get(`/contacts/${contact.id}/tags`); 31 | contact.tags = tags.body.data.map((tag) => tag.name); 32 | 33 | // NOTICE: This slows down the request 34 | const companies = await intercom.get(`/contacts/${contact.id}/companies`); 35 | contact.companies = companies.body.data.map((company) => company.name); 36 | 37 | // NOTICE: As of v2.0, intercom API does not retrieve the segments a contact is part of 38 | // You can look for a contact with a given segment id but it isn't retrieved with it 39 | return contact; 40 | } catch (error) { 41 | if (error.statusCode) { 42 | logger.error('Cannot retrieve Intercom attributes for the following reason: ', error); 43 | } else { 44 | logger.error('Internal error while retrieving Intercom attributes: ', error); 45 | } 46 | return null; 47 | } 48 | }; 49 | } 50 | 51 | module.exports = AttributesGetter; 52 | -------------------------------------------------------------------------------- /src/utils/widgets.js: -------------------------------------------------------------------------------- 1 | // This file is a copy/paste of `utils/widgets.js` and a function from 2 | // `services/environment-configurator.js` in forestadmin-server. 3 | // The purpose is to transform the legacy `widget` from smart actions 4 | // into `widgetEdit`, since it is required when using smart actions' hooks 5 | // to avoid loosing widgets. 6 | 7 | // This list contains every edit widget available. 8 | const widgetEditList = [ 9 | 'address editor', 10 | 'belongsto typeahead', 11 | 'belongsto dropdown', 12 | 'boolean editor', 13 | 'checkboxes', 14 | 'color editor', 15 | 'date editor', 16 | 'dropdown', 17 | 'embedded document editor', 18 | 'file picker', 19 | 'json code editor', 20 | 'input array', 21 | 'multiple select', 22 | 'number input', 23 | 'point editor', 24 | 'price editor', 25 | 'radio button', 26 | 'rich text', 27 | 'text area editor', 28 | 'text editor', 29 | 'time input', 30 | ]; 31 | 32 | // This list convert old V1 widgets' names that have been changed. 33 | // It creates a mapping between V1 widget name and its corresponding V2 edit widget name. 34 | // This is only used to migrate edit widgets from smart actions using legacy V1 system. 35 | const V1ToV2EditWidgetsMapping = { 36 | address: 'address editor', 37 | 'belongsto select': 'belongsto dropdown', 38 | 'color picker': 'color editor', 39 | 'date picker': 'date editor', 40 | price: 'price editor', 41 | 'JSON editor': 'json code editor', 42 | 'rich text editor': 'rich text', 43 | 'text area': 'text area editor', 44 | 'text input': 'text editor', 45 | }; 46 | 47 | /** 48 | * Convert V1 widgets from smart actions to V2 widgets. 49 | * V1 widgets still need to be supported as their usage in smart action is legacy. 50 | * 51 | * @param {*} field A smart action field 52 | */ 53 | function setFieldWidget(field) { 54 | if (field.widget) { 55 | if (V1ToV2EditWidgetsMapping[field.widget]) { 56 | field.widgetEdit = { name: V1ToV2EditWidgetsMapping[field.widget], parameters: { } }; 57 | } else if (widgetEditList.includes(field.widget)) { 58 | field.widgetEdit = { name: field.widget, parameters: { } }; 59 | } else { 60 | field.widgetEdit = null; 61 | } 62 | delete field.widget; 63 | } 64 | } 65 | 66 | module.exports = { 67 | setFieldWidget, 68 | }; 69 | -------------------------------------------------------------------------------- /test/services/csv-exporter.test.js: -------------------------------------------------------------------------------- 1 | const { init, inject } = require('@forestadmin/context'); 2 | const CSVExporter = require('../../src/services/csv-exporter'); 3 | const Schemas = require('../../src/generators/schemas'); 4 | 5 | init((context) => context.addInstance('configStore', () => ({ 6 | Implementation: { 7 | Flattener: undefined, 8 | }, 9 | }))); 10 | 11 | describe('services > csv-exporter', () => { 12 | const initialiseContext = () => { 13 | Schemas.schemas.cars = { 14 | fields: [{ 15 | field: '_id', 16 | }, { 17 | field: 'name', 18 | }], 19 | }; 20 | }; 21 | 22 | describe('handling records to export', () => { 23 | const fakeResponse = { 24 | setHeader: () => {}, 25 | write: () => {}, 26 | end: () => {}, 27 | }; 28 | const exportParams = { 29 | filename: 'cars', 30 | fields: { 31 | cars: '_id,name', 32 | }, 33 | header: '_id,name', 34 | }; 35 | const mockRecordsExporter = { 36 | perform: (exporter) => exporter([{}]), 37 | }; 38 | 39 | describe('when implementation supports flatten fields feature', () => { 40 | it('should flatten records', async () => { 41 | initialiseContext(); 42 | const { configStore } = inject(); 43 | configStore.Implementation = { 44 | Flattener: { 45 | flattenRecordsForExport: jest.fn().mockReturnValue([{}]), 46 | }, 47 | }; 48 | 49 | const csvExporter = new CSVExporter( 50 | exportParams, 51 | fakeResponse, 52 | 'cars', 53 | mockRecordsExporter, 54 | ); 55 | 56 | await csvExporter.perform(); 57 | 58 | expect(configStore.Implementation.Flattener.flattenRecordsForExport) 59 | .toHaveBeenNthCalledWith(1, 'cars', [{}]); 60 | }); 61 | }); 62 | 63 | describe('when implementation does not support flatten fields feature', () => { 64 | it('should not flatten records', async () => { 65 | initialiseContext(); 66 | 67 | const csvExporter = new CSVExporter( 68 | exportParams, 69 | fakeResponse, 70 | 'cars', 71 | mockRecordsExporter, 72 | ); 73 | 74 | await expect(csvExporter.perform()).toResolve(); 75 | }); 76 | }); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/services/oidc-client-manager.js: -------------------------------------------------------------------------------- 1 | class OidcClientManagerService { 2 | /** 3 | * @private @readonly 4 | * @type {Map>} 5 | */ 6 | cache = new Map(); 7 | 8 | /** @private @readonly */ 9 | oidcConfigurationRetrieverService; 10 | 11 | /** @private @readonly */ 12 | openIdClient; 13 | 14 | /** @private @readonly */ 15 | env; 16 | 17 | /** @private @readonly */ 18 | logger; 19 | 20 | /** 21 | * @param {import('../context/init').Context} dependencies 22 | */ 23 | constructor({ 24 | oidcConfigurationRetrieverService, openIdClient, env, logger, configStore, 25 | }) { 26 | this.oidcConfigurationRetrieverService = oidcConfigurationRetrieverService; 27 | this.openIdClient = openIdClient; 28 | this.env = env; 29 | this.logger = logger; 30 | this.configStore = configStore; 31 | } 32 | 33 | /** 34 | * @param {string} callbackUrl 35 | * @returns {Promise} 36 | */ 37 | async getClientForCallbackUrl(callbackUrl) { 38 | if (!this.cache.has(callbackUrl)) { 39 | const configuration = await this.oidcConfigurationRetrieverService.retrieve(); 40 | const issuer = new this.openIdClient.Issuer(configuration); 41 | const clientId = this.configStore.lianaOptions.clientId 42 | || this.env.FOREST_CLIENT_ID 43 | || undefined; 44 | const envSecret = this.configStore.lianaOptions.envSecret 45 | || this.env.FOREST_ENV_SECRET; 46 | 47 | const registration = { 48 | client_id: clientId, 49 | redirect_uris: [callbackUrl], 50 | token_endpoint_auth_method: 'none', 51 | }; 52 | 53 | const registrationPromise = clientId 54 | ? new issuer.Client(registration) 55 | : issuer.Client.register(registration, { initialAccessToken: envSecret }).catch((error) => { 56 | this.logger.error('Unable to register the client', { 57 | configuration, 58 | registration, 59 | error, 60 | }); 61 | this.cache.delete(callbackUrl); 62 | throw error; 63 | }); 64 | 65 | this.cache.set(callbackUrl, registrationPromise); 66 | } 67 | 68 | return this.cache.get(callbackUrl); 69 | } 70 | 71 | clearCache() { 72 | this.cache.clear(); 73 | } 74 | } 75 | 76 | module.exports = OidcClientManagerService; 77 | -------------------------------------------------------------------------------- /.releaserc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | branches: ['main', '+([0-9])?(.{+([0-9]),x}).x', {name: 'beta', prerelease: true}], 3 | plugins: [ 4 | [ 5 | '@semantic-release/commit-analyzer', { 6 | preset: 'angular', 7 | releaseRules: [ 8 | // Example: `type(scope): subject [force release]` 9 | { subject: '*\\[force release\\]*', release: 'patch' }, 10 | ], 11 | }, 12 | ], 13 | '@semantic-release/release-notes-generator', 14 | '@semantic-release/changelog', 15 | '@semantic-release/npm', 16 | '@semantic-release/git', 17 | '@semantic-release/github', 18 | [ 19 | 'semantic-release-slack-bot', 20 | { 21 | markdownReleaseNotes: true, 22 | notifyOnSuccess: true, 23 | notifyOnFail: false, 24 | onSuccessTemplate: { 25 | text: "📦 $package_name@$npm_package_version has been released!", 26 | blocks: [{ 27 | type: 'section', 28 | text: { 29 | type: 'mrkdwn', 30 | text: '*New `$package_name` package released!*' 31 | } 32 | }, { 33 | type: 'context', 34 | elements: [{ 35 | type: 'mrkdwn', 36 | text: "📦 *Version:* <$repo_url/releases/tag/v$npm_package_version|$npm_package_version>" 37 | }] 38 | }, { 39 | type: 'divider', 40 | }], 41 | attachments: [{ 42 | blocks: [{ 43 | type: 'section', 44 | text: { 45 | type: 'mrkdwn', 46 | text: '*Changes* of version $release_notes', 47 | }, 48 | }], 49 | }], 50 | }, 51 | packageName: 'forest-express', 52 | } 53 | ], 54 | [ 55 | "semantic-release-npm-deprecate-old-versions", { 56 | "rules": [ 57 | { 58 | "rule": "supportLatest", 59 | "options": { 60 | "numberOfMajorReleases": 3, 61 | "numberOfMinorReleases": "all", 62 | "numberOfPatchReleases": "all" 63 | } 64 | }, 65 | { 66 | "rule": "supportPreReleaseIfNotReleased", 67 | "options": { 68 | "numberOfPreReleases": 1, 69 | } 70 | }, 71 | "deprecateAll" 72 | ] 73 | } 74 | ] 75 | ], 76 | } 77 | -------------------------------------------------------------------------------- /src/integrations/layer/serializers/conversations.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const JSONAPISerializer = require('jsonapi-serializer').Serializer; 3 | 4 | function serializeConversations(conversations, collectionName, meta) { 5 | function mapConversation(conversation) { 6 | // jshint camelcase: false 7 | conversation.id = conversation.id.replace('layer:///conversations/', ''); 8 | conversation.createdAt = conversation.created_at; 9 | 10 | if (conversation.last_message) { 11 | conversation.lastMessage = { 12 | id: conversation.last_message.id.replace('layer:///messages/', ''), 13 | sentAt: conversation.last_message.sent_at, 14 | content: conversation.last_message.parts[0].body, 15 | mimeType: conversation.last_message.parts[0].mime_type, 16 | sender: conversation.last_message.sender.display_name, 17 | }; 18 | } 19 | 20 | if (_.isArray(conversation.participants) && conversation.participants.length) { 21 | conversation.title = conversation.participants.map((participant) => participant.display_name).join(', '); 22 | } 23 | 24 | return conversation; 25 | } 26 | 27 | const type = `${collectionName}_layer_conversations`; 28 | let data = null; 29 | 30 | if (_.isArray(conversations)) { 31 | data = conversations.map((conversation) => mapConversation(conversation)); 32 | } else { 33 | data = mapConversation(conversations); 34 | } 35 | 36 | return new JSONAPISerializer(type, data, { 37 | attributes: ['title', 'createdAt', 'messages', 'lastMessage'], 38 | messages: { 39 | ref: 'id', 40 | ignoreRelationshipData: true, 41 | included: false, 42 | nullIfMissing: true, 43 | relationshipLinks: { 44 | related(dataSet) { 45 | return { 46 | href: `/forest/${collectionName}_layer_conversations/${dataSet.id 47 | }/relationships/messages`, 48 | }; 49 | }, 50 | }, 51 | }, 52 | lastMessage: { 53 | ref: 'id', 54 | attributes: ['sender', 'sentAt', 'content', 'mimeType'], 55 | }, 56 | keyForAttribute(key) { return key; }, 57 | typeForAttribute(attribute) { 58 | if (attribute === 'lastMessage') { 59 | return `${collectionName}_layer_messages`; 60 | } 61 | 62 | return undefined; 63 | }, 64 | meta, 65 | }); 66 | } 67 | 68 | module.exports = serializeConversations; 69 | -------------------------------------------------------------------------------- /src/utils/services-builder.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const AuthenticationService = require('../services/authentication'); 3 | const logger = require('../services/logger'); 4 | const pathService = require('../services/path'); 5 | const errorMessages = require('./error-messages'); 6 | 7 | /** 8 | * @typedef {{ 9 | * errorMessages: import('./error-messages'); 10 | * }} Utils 11 | * @typedef {{ 12 | * logger: import('../services/logger'); 13 | * pathService: import('../services/path'); 14 | * authenticationService: import('../services/authentication'); 15 | * }} Services 16 | * @typedef {Utils & Services} Injections 17 | */ 18 | 19 | /** 20 | * @template TInjections 21 | * @template TInstance 22 | * @param {TInjections} injections 23 | * @param {function(new:TInstance, TInjections)} Dependency 24 | * @returns { TInjections & {[name]: TInstance}} 25 | */ 26 | function addFromClass(injections, Dependency) { 27 | return { 28 | ...injections, 29 | 30 | [_.lowerFirst(Dependency.getClassName())]: new Dependency(injections), 31 | }; 32 | } 33 | 34 | /** 35 | * @template TInjections 36 | * @template TInstance 37 | * @param {TInjections} injections 38 | * @param {string} name 39 | * @param {TInstance} instance 40 | * @returns { TInjections & {[name]: TInstance}} 41 | */ 42 | function addInstance(injections, name, instance) { 43 | return { 44 | ...injections, 45 | [name]: instance, 46 | }; 47 | } 48 | 49 | /** 50 | * @function 51 | * @template T 52 | * @param {T} injections 53 | * @returns {T & Utils} 54 | */ 55 | function buildUtils(injections) { 56 | /** @type {*} */ 57 | let result = injections; 58 | 59 | result = addInstance(result, 'errorMessages', errorMessages); 60 | 61 | return result; 62 | } 63 | /** 64 | * @template T 65 | * @param {T & Utils} injections 66 | * @returns {T & Utils & Services} 67 | */ 68 | function buildServices(injections) { 69 | /** @type {*} */ 70 | let result = injections; 71 | 72 | result = addInstance(result, 'logger', logger); 73 | result = addInstance(result, 'pathService', pathService); 74 | result = addFromClass(result, AuthenticationService); 75 | 76 | return result; 77 | } 78 | 79 | /** 80 | * @returns { Injections } 81 | */ 82 | function init() { 83 | return [ 84 | buildUtils, 85 | buildServices, 86 | ].reduce((injections, builder) => builder(injections), {}); 87 | } 88 | 89 | module.exports = init; 90 | -------------------------------------------------------------------------------- /src/services/token.js: -------------------------------------------------------------------------------- 1 | const EXPIRATION_IN_HOURS = 1; 2 | const PAST_DATE = new Date(0); 3 | const FOREST_SESSION_TOKEN = 'forest_session_token'; 4 | const REGEX_COOKIE_SESSION_TOKEN = new RegExp(`${FOREST_SESSION_TOKEN}=([^;]*)`); 5 | 6 | class TokenService { 7 | /** @private @readonly @type {import('jsonwebtoken')} */ 8 | jsonwebtoken; 9 | 10 | /** 11 | * @param {import("../context/init").Context} context 12 | */ 13 | constructor(context) { 14 | this.jsonwebtoken = context.jsonwebtoken; 15 | } 16 | 17 | /** @returns {number} */ 18 | // eslint-disable-next-line class-methods-use-this 19 | get expirationInHours() { 20 | return EXPIRATION_IN_HOURS; 21 | } 22 | 23 | /** @returns {number} */ 24 | get expirationInSeconds() { 25 | return this.expirationInHours * 3600; 26 | } 27 | 28 | /** @returns {string} */ 29 | // eslint-disable-next-line class-methods-use-this 30 | get forestCookieName() { 31 | return FOREST_SESSION_TOKEN; 32 | } 33 | 34 | /** 35 | * @param {{ 36 | * id: number; 37 | * email: string; 38 | * first_name: string; 39 | * last_name: string; 40 | * teams: number[]; 41 | * }} user 42 | * @param {string|number} renderingId 43 | * @param {{ 44 | * authSecret: string 45 | * }} options 46 | * @returns string 47 | */ 48 | createToken(user, renderingId, { authSecret }) { 49 | return this.jsonwebtoken.sign({ 50 | id: user.id, 51 | email: user.email, 52 | firstName: user.first_name, 53 | lastName: user.last_name, 54 | team: user.teams[0], 55 | role: user.role, 56 | tags: user.tags, 57 | permissionLevel: user.permission_level, 58 | renderingId, 59 | }, authSecret, { 60 | expiresIn: `${this.expirationInHours} hours`, 61 | }); 62 | } 63 | 64 | // eslint-disable-next-line class-methods-use-this 65 | deleteToken() { 66 | return { 67 | expires: PAST_DATE, 68 | httpOnly: true, 69 | secure: true, 70 | sameSite: 'none', 71 | }; 72 | } 73 | 74 | /** 75 | * @param {string} cookies 76 | */ 77 | // eslint-disable-next-line class-methods-use-this 78 | extractForestSessionToken(cookies) { 79 | const forestSession = cookies.match(REGEX_COOKIE_SESSION_TOKEN); 80 | if (forestSession && forestSession[1]) { 81 | return forestSession[1]; 82 | } 83 | return null; 84 | } 85 | } 86 | 87 | module.exports = TokenService; 88 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const BASE_EXTENDS = [ 2 | 'airbnb-base', 3 | 'airbnb-typescript/base', 4 | 'plugin:jest/all', 5 | 'plugin:sonarjs/recommended', 6 | ]; 7 | 8 | module.exports = { 9 | root: true, 10 | extends: BASE_EXTENDS, 11 | plugins: [ 12 | 'sonarjs', 13 | ], 14 | env: { 15 | node: true, 16 | }, 17 | ignorePatterns: [ 18 | 'dist/**', 19 | '.eslintrc.js', 20 | 'jest.config.js' 21 | ], 22 | rules: { 23 | '@typescript-eslint/naming-convention': 'off', 24 | 'implicit-arrow-linebreak': 0, 25 | 'import/no-extraneous-dependencies': [ 26 | 'error', 27 | { 28 | devDependencies: [ 29 | '.eslint-bin/*.js', 30 | 'test/**/*.js', 31 | ], 32 | }, 33 | ], 34 | 'import/extensions': ['error', 'ignorePackages', { 35 | js: 'never', 36 | ts: 'never', 37 | }], 38 | 'jest/max-expects': 'off', 39 | 'jest/max-nested-describe': 'off', 40 | 'jest/require-hook': 'off', 41 | "jest/prefer-expect-assertions": [ 42 | "error", 43 | { "onlyFunctionsWithExpectInLoop": true, "onlyFunctionsWithExpectInCallback": true } 44 | ], 45 | 'no-param-reassign': 0, 46 | 'no-underscore-dangle': 0, 47 | 'sonarjs/cognitive-complexity': 1, 48 | 'sonarjs/no-collapsible-if': 0, 49 | 'sonarjs/no-duplicate-string': 0, 50 | 'sonarjs/no-extra-arguments': 0, 51 | 'sonarjs/no-identical-expressions': 0, 52 | 'sonarjs/no-identical-functions': 0, 53 | 'sonarjs/no-same-line-conditional': 0, 54 | }, 55 | parser: '@typescript-eslint/parser', 56 | parserOptions: { 57 | tsconfigRootDir: __dirname, 58 | project: ['./tsconfig.json'], 59 | }, 60 | settings: { 61 | 'import/resolver': { 62 | node: { 63 | extensions: ['.js', '.json', '.ts'], 64 | }, 65 | }, 66 | 'import/extensions': [ 67 | '.js', 68 | '.ts', 69 | ], 70 | }, 71 | overrides: [ 72 | { 73 | files: ['*.ts'], 74 | extends: [ 75 | ...BASE_EXTENDS, 76 | 'plugin:@typescript-eslint/recommended', 77 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 78 | ], 79 | plugins: [ 80 | 'sonarjs', 81 | '@typescript-eslint', 82 | ], 83 | rules: { 84 | 'import/extensions': ['error', 'ignorePackages', { 85 | js: 'never', 86 | ts: 'never', 87 | }], 88 | }, 89 | }, 90 | 91 | ], 92 | }; 93 | -------------------------------------------------------------------------------- /test/routes/scopes.test.js: -------------------------------------------------------------------------------- 1 | const { inject } = require('@forestadmin/context'); 2 | const request = require('supertest'); 3 | const createServer = require('../helpers/create-server'); 4 | const auth = require('../../src/services/auth'); 5 | 6 | const envSecret = Array(65).join('0'); 7 | const authSecret = Array(65).join('1'); 8 | 9 | describe('routes > scopes', () => { 10 | describe('#POST /forest/scope-cache-invalidation', () => { 11 | describe('when missing renderingId', () => { 12 | it('should return a 400', async () => { 13 | expect.assertions(2); 14 | 15 | jest.spyOn(auth, 'ensureAuthenticated').mockImplementation( 16 | (req, res, next) => next(), 17 | ); 18 | 19 | const app = await createServer(envSecret, authSecret); 20 | const { scopeManager } = inject(); 21 | const spyOnInvalidateScopeCache = jest.spyOn(scopeManager, 'invalidateScopeCache'); 22 | await new Promise((done) => { 23 | request(app) 24 | .post('/forest/scope-cache-invalidation') 25 | .end((error, response) => { 26 | expect(response.status).toBe(400); 27 | expect(spyOnInvalidateScopeCache).not.toHaveBeenCalled(); 28 | done(); 29 | }); 30 | }); 31 | 32 | jest.clearAllMocks(); 33 | }); 34 | }); 35 | 36 | describe('when providing a valid renderingId', () => { 37 | it('should return a 200', async () => { 38 | expect.assertions(3); 39 | 40 | jest.spyOn(auth, 'ensureAuthenticated').mockImplementation( 41 | (req, res, next) => next(), 42 | ); 43 | 44 | const app = await createServer(envSecret, authSecret); 45 | const { scopeManager } = inject(); 46 | const spyOnInvalidateScopeCache = jest.spyOn(scopeManager, 'invalidateScopeCache'); 47 | const renderingId = 34; 48 | await new Promise((done) => { 49 | request(app) 50 | .post('/forest/scope-cache-invalidation') 51 | .send({ renderingId }) 52 | .end((error, response) => { 53 | expect(response.status).toBe(200); 54 | expect(spyOnInvalidateScopeCache).toHaveBeenCalledTimes(1); 55 | expect(spyOnInvalidateScopeCache).toHaveBeenCalledWith(renderingId); 56 | done(); 57 | }); 58 | }); 59 | 60 | jest.clearAllMocks(); 61 | }); 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /test/services/error.test.js: -------------------------------------------------------------------------------- 1 | const { 2 | Unauthorized, 3 | UnprocessableEntity, 4 | InvalidFiltersFormat, 5 | NoMatchingOperatorError, 6 | } = require('../../src/utils/error'); 7 | 8 | describe('services > error', () => { 9 | describe('unauthorized', () => { 10 | it('should create an error object with name "Unauthorized" and status 401', () => { 11 | const error = new Unauthorized('Invalid token'); 12 | expect(error.name).toBe('Unauthorized'); 13 | expect(error.status).toBe(401); 14 | }); 15 | 16 | it('should create display a custom error message', () => { 17 | const throwError = () => { throw new Unauthorized('Invalid token'); }; 18 | expect(() => throwError()).toThrow('Invalid token'); 19 | }); 20 | }); 21 | 22 | describe('unprocessableEntity', () => { 23 | it('should create an error object with name "UnprocessableEntity" and status 422', () => { 24 | const error = new UnprocessableEntity('Invalid user email'); 25 | expect(error.name).toBe('UnprocessableEntity'); 26 | expect(error.status).toBe(422); 27 | }); 28 | 29 | it('should create display a custom error message', () => { 30 | const throwError = () => { throw new UnprocessableEntity('Invalid user email'); }; 31 | expect(() => throwError()).toThrow('Invalid user email'); 32 | }); 33 | }); 34 | 35 | describe('invalidFiltersFormat', () => { 36 | it('should create an error object with name "InvalidFiltersFormat" and status 422', () => { 37 | const error = new InvalidFiltersFormat(); 38 | expect(error.name).toBe('InvalidFiltersFormat'); 39 | expect(error.status).toBe(422); 40 | }); 41 | 42 | it('should create display a default message', () => { 43 | const throwError = () => { throw new InvalidFiltersFormat(); }; 44 | expect(() => throwError()).toThrow('The filters format is not a valid JSON string.'); 45 | }); 46 | }); 47 | 48 | describe('noMatchingOperatorError', () => { 49 | it('should create an error object with name "NoMatchingOperatorError" and status 422', () => { 50 | const error = new NoMatchingOperatorError(); 51 | expect(error.name).toBe('NoMatchingOperatorError'); 52 | expect(error.status).toBe(422); 53 | }); 54 | 55 | it('should create display a default message', () => { 56 | const throwError = () => { throw new NoMatchingOperatorError(); }; 57 | expect(() => throwError()).toThrow('The given operator is not handled.'); 58 | }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /test/services/oidc-configuration-retriever.test.js: -------------------------------------------------------------------------------- 1 | const OidcConfigurationRetrieverService = require('../../src/services/oidc-configuration-retriever'); 2 | 3 | describe('oidcConfigurationRetrieverService', () => { 4 | function setup({ expiration } = {}) { 5 | const forestServerRequester = { 6 | perform: jest.fn(), 7 | }; 8 | const context = { 9 | env: { 10 | FOREST_OIDC_CONFIG_EXPIRATION_IN_SECONDS: expiration, 11 | }, 12 | forestServerRequester, 13 | }; 14 | const oidcConfigurationRetrieverService = new OidcConfigurationRetrieverService(context); 15 | return { oidcConfigurationRetrieverService, forestServerRequester }; 16 | } 17 | it('should retrieve the configuration from the server', async () => { 18 | const { oidcConfigurationRetrieverService, forestServerRequester } = setup(); 19 | 20 | const configuration = { issuer: 'forest-admin' }; 21 | forestServerRequester.perform.mockResolvedValue(configuration); 22 | 23 | const result = await oidcConfigurationRetrieverService.retrieve(); 24 | 25 | expect(result).toBe(configuration); 26 | expect(forestServerRequester.perform).toHaveBeenCalledWith('/oidc/.well-known/openid-configuration'); 27 | }); 28 | 29 | describe('when called twice', () => { 30 | it('should not retrieve the configuration from the server if the cache is still valid', async () => { 31 | const { 32 | oidcConfigurationRetrieverService, 33 | forestServerRequester, 34 | } = setup({ expiration: 1000 }); 35 | 36 | const configuration = { issuer: 'forest-admin' }; 37 | forestServerRequester.perform.mockResolvedValue(configuration); 38 | 39 | await oidcConfigurationRetrieverService.retrieve(); 40 | const result = await oidcConfigurationRetrieverService.retrieve(); 41 | 42 | expect(result).toBe(configuration); 43 | expect(forestServerRequester.perform).toHaveBeenCalledTimes(1); 44 | }); 45 | 46 | it('should retrieve the configuration from the server if the cache expired', async () => { 47 | const { 48 | oidcConfigurationRetrieverService, 49 | forestServerRequester, 50 | } = setup({ expiration: 1 }); 51 | 52 | const configuration = { issuer: 'forest-admin' }; 53 | forestServerRequester.perform.mockResolvedValue(configuration); 54 | 55 | await oidcConfigurationRetrieverService.retrieve(); 56 | await new Promise((resolve) => { setTimeout(resolve, 10); }); 57 | const result = await oidcConfigurationRetrieverService.retrieve(); 58 | 59 | expect(result).toBe(configuration); 60 | expect(forestServerRequester.perform).toHaveBeenCalledTimes(2); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/integrations/stripe/serializers/subscriptions.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const JSONAPISerializer = require('jsonapi-serializer').Serializer; 3 | const Schemas = require('../../../generators/schemas'); 4 | 5 | // jshint camelcase: false 6 | function serializeSubscriptions(subscriptions, collectionName, meta) { 7 | function getCustomerAttributes() { 8 | if (!subscriptions.length) { return []; } 9 | 10 | const schema = Schemas.schemas[collectionName]; 11 | if (!schema) { return []; } 12 | return _.map(schema.fields, 'field'); 13 | } 14 | 15 | function format(subscription) { 16 | // jshint camelcase: false 17 | if (subscription.canceled_at) { 18 | subscription.canceled_at = new Date(subscription.canceled_at * 1000); 19 | } 20 | 21 | if (subscription.created) { 22 | subscription.created = new Date(subscription.created * 1000); 23 | } 24 | 25 | if (subscription.current_period_end) { 26 | subscription.current_period_end = new Date(subscription.current_period_end * 1000); 27 | } 28 | 29 | if (subscription.current_period_start) { 30 | subscription.current_period_start = new Date(subscription.current_period_start * 1000); 31 | } 32 | 33 | if (subscription.ended_at) { 34 | subscription.ended_at = new Date(subscription.ended_at * 1000); 35 | } 36 | 37 | if (subscription.start) { 38 | subscription.start = new Date(subscription.start * 1000); 39 | } 40 | 41 | if (subscription.trial_end) { 42 | subscription.trial_end = new Date(subscription.trial_end * 1000); 43 | } 44 | 45 | if (subscription.trial_start) { 46 | subscription.trial_start = new Date(subscription.trial_start * 1000); 47 | } 48 | 49 | return subscription; 50 | } 51 | 52 | const customerAttributes = getCustomerAttributes(); 53 | 54 | if (subscriptions.length) { 55 | subscriptions = subscriptions.map(format); 56 | } else { 57 | subscriptions = format(subscriptions); 58 | } 59 | 60 | const type = `${collectionName}_stripe_subscriptions`; 61 | 62 | return new JSONAPISerializer(type, subscriptions, { 63 | attributes: ['cancel_at_period_end', 'canceled_at', 'created', 64 | 'current_period_end', 'current_period_start', 'ended_at', 'livemode', 65 | 'quantity', 'start', 'status', 'tax_percent', 'trial_end', 'trial_start', 66 | 'customer'], 67 | customer: { 68 | ref: Schemas.schemas[collectionName].idField, 69 | attributes: customerAttributes, 70 | }, 71 | keyForAttribute(key) { return key; }, 72 | typeForAttribute(attr) { 73 | if (attr === 'customer') { return collectionName; } 74 | return attr; 75 | }, 76 | meta, 77 | }); 78 | } 79 | 80 | module.exports = serializeSubscriptions; 81 | -------------------------------------------------------------------------------- /test/fixtures/users-schema.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'users', 3 | idField: 'id', 4 | primaryKeys: ['id'], 5 | isCompositePrimary: false, 6 | fields: [ 7 | { 8 | field: 'smart', 9 | type: 'Json', 10 | get: () => ({ foo: 'bar' }), 11 | set: (user, value) => { 12 | user.name = `${user.name} - ${value}`; 13 | return user; 14 | }, 15 | isVirtual: true, 16 | isFilterable: false, 17 | isSortable: false, 18 | isReadOnly: true, 19 | defaultValue: null, 20 | isRequired: false, 21 | description: null, 22 | reference: null, 23 | inverseOf: null, 24 | relationships: null, 25 | enums: null, 26 | validations: [], 27 | integration: null, 28 | }, 29 | { 30 | field: 'hasAddress', 31 | type: 'Boolean', 32 | get: () => true, 33 | isVirtual: true, 34 | isFilterable: false, 35 | isSortable: false, 36 | isReadOnly: true, 37 | defaultValue: false, 38 | isRequired: false, 39 | description: null, 40 | reference: null, 41 | inverseOf: null, 42 | relationships: null, 43 | enums: null, 44 | validations: [], 45 | integration: null, 46 | }, 47 | { 48 | field: 'id', 49 | type: 'String', 50 | columnName: 'id', 51 | primaryKey: true, 52 | isRequired: true, 53 | validations: [Array], 54 | defaultValue: null, 55 | isReadOnly: false, 56 | isSortable: true, 57 | isFilterable: true, 58 | isVirtual: false, 59 | description: null, 60 | reference: null, 61 | inverseOf: null, 62 | relationships: null, 63 | enums: null, 64 | integration: null, 65 | }, 66 | { 67 | field: 'name', 68 | type: 'String', 69 | columnName: 'name', 70 | defaultValue: null, 71 | isRequired: false, 72 | isReadOnly: false, 73 | isSortable: true, 74 | isFilterable: true, 75 | isVirtual: false, 76 | description: null, 77 | reference: null, 78 | inverseOf: null, 79 | relationships: null, 80 | enums: null, 81 | validations: [], 82 | integration: null, 83 | }, 84 | ], 85 | isSearchable: true, 86 | actions: [{ 87 | name: 'Test me', 88 | type: 'single', 89 | endpoint: '/forest/actions/test-me', 90 | httpMethod: 'POST', 91 | fields: [], 92 | redirect: null, 93 | baseUrl: null, 94 | download: false, 95 | }], 96 | segments: [], 97 | onlyForRelationships: false, 98 | isVirtual: false, 99 | isReadOnly: false, 100 | paginationType: 'page', 101 | icon: null, 102 | nameOld: 'users', 103 | integration: null, 104 | }; 105 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build, Test and Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - beta 8 | - 8.x.x 9 | pull_request: 10 | 11 | jobs: 12 | lint: 13 | name: Linting 14 | runs-on: ubuntu-latest 15 | if: "!contains(github.event.head_commit.message, '[skip ci]')" 16 | steps: 17 | - name: Cancel previous running workflows 18 | uses: fkirc/skip-duplicate-actions@master 19 | - uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | - uses: actions/setup-node@v4 23 | with: 24 | node-version: 16.14.0 25 | - uses: actions/cache@v4 26 | with: 27 | path: '**/node_modules' 28 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 29 | - name: install dependencies 30 | run: yarn install --frozen-lockfile --non-interactive --production=false 31 | - name: Lint commit message 32 | uses: wagoid/commitlint-github-action@v2 33 | - name: lint Javascript 34 | run: yarn lint 35 | 36 | test: 37 | name: Test 38 | runs-on: ubuntu-latest 39 | needs: [lint] 40 | steps: 41 | - uses: actions/checkout@v4 42 | - uses: actions/setup-node@v4 43 | - name: Cache node_modules 44 | uses: actions/cache@v4 45 | with: 46 | path: '**/node_modules' 47 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 48 | - name: Run tests 49 | run: yarn test 50 | 51 | deploy: 52 | name: Release package 53 | runs-on: ubuntu-latest 54 | needs: [test] 55 | if: github.event_name == 'push' && (github.ref == 'refs/heads/8.x.x' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta') 56 | steps: 57 | - uses: actions/checkout@v4 58 | with: 59 | persist-credentials: false # GITHUB_TOKEN must not be set for the semantic release 60 | - uses: actions/setup-node@v4 61 | with: 62 | node-version: 12.22.12 63 | - uses: actions/cache@v4 64 | with: 65 | path: '**/node_modules' 66 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 67 | - name: Build package 68 | run: yarn build 69 | - name: Semantic Release 70 | uses: cycjimmy/semantic-release-action@v2 71 | id: semantic 72 | with: 73 | semantic_version: 17.3.0 74 | env: 75 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 76 | GIT_AUTHOR_EMAIL: ${{ secrets.GIT_AUTHOR_EMAIL }} 77 | GIT_AUTHOR_NAME: ${{ secrets.GIT_AUTHOR_NAME }} 78 | GIT_COMMITTER_EMAIL: ${{ secrets.GIT_COMMITTER_EMAIL }} 79 | GIT_COMMITTER_NAME: ${{ secrets.GIT_COMMITTER_NAME }} 80 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 81 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} 82 | -------------------------------------------------------------------------------- /src/integrations/close.io/setup.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const { pushIntoApimap } = require('../../utils/integrations'); 3 | 4 | const INTEGRATION_NAME = 'close.io'; 5 | 6 | exports.createCollections = (Implementation, apimap, collectionAndFieldName) => { 7 | const collectionName = collectionAndFieldName.split('.')[0]; 8 | const collectionDisplayName = _.capitalize(collectionName); 9 | 10 | // jshint camelcase: false 11 | pushIntoApimap(apimap, { 12 | name: `${collectionName}_closeio_leads`, 13 | displayName: `${collectionDisplayName} Leads`, 14 | icon: 'closeio', 15 | integration: INTEGRATION_NAME, 16 | isVirtual: true, 17 | isReadOnly: true, 18 | onlyForRelationships: true, 19 | fields: [ 20 | { 21 | field: 'url', type: 'String', isFilterable: false, widget: 'link', 22 | }, 23 | { field: 'display_name', type: 'String', isFilterable: false }, 24 | { field: 'status_label', type: 'String' }, 25 | { field: 'created_by_name', type: 'String', isFilterable: false }, 26 | { field: 'date_updated', type: 'Date', isFilterable: false }, 27 | { field: 'date_created', type: 'Date', isFilterable: false }, 28 | { field: 'description', type: 'String', isFilterable: false }, 29 | { 30 | field: 'emails', 31 | type: ['String'], 32 | reference: `${collectionName 33 | }_closeio_emails.id`, 34 | }, 35 | ], 36 | }); 37 | 38 | pushIntoApimap(apimap, { 39 | name: `${collectionName}_closeio_emails`, 40 | displayName: `${collectionDisplayName} Conversations`, 41 | icon: 'closeio', 42 | integration: INTEGRATION_NAME, 43 | isVirtual: true, 44 | isReadOnly: true, 45 | onlyForRelationships: true, 46 | fields: [ 47 | { field: 'status', type: 'String', isFilterable: false }, 48 | { field: 'sender', type: 'String', isFilterable: false }, 49 | { field: 'subject', type: 'String', isFilterable: false }, 50 | { field: 'body_text', type: 'String', isFilterable: false }, 51 | ], 52 | }); 53 | }; 54 | 55 | exports.createFields = (implementation, model, schema) => { 56 | schema.fields.push({ 57 | field: 'lead', 58 | type: 'String', 59 | reference: `${implementation.getModelName(model)}_closeio_leads.id`, 60 | isFilterable: false, 61 | integration: INTEGRATION_NAME, 62 | isVirtual: true, 63 | }); 64 | 65 | if (!schema.actions) { schema.actions = []; } 66 | schema.actions.push({ 67 | id: `${implementation.getModelName(model)}.Create Close.io lead`, 68 | name: 'Create Close.io lead', 69 | endpoint: `/forest/${implementation.getModelName(model)}_closeio_leads`, 70 | fields: [{ 71 | field: 'Company/Organization Name', 72 | type: 'String', 73 | }, { 74 | field: 'Contact Name', 75 | type: 'String', 76 | }], 77 | }); 78 | }; 79 | -------------------------------------------------------------------------------- /src/services/smart-action-hook-service.js: -------------------------------------------------------------------------------- 1 | class SmartActionHookService { 2 | constructor({ setFieldWidget, smartActionFieldValidator, smartActionFormLayoutService }) { 3 | this.setFieldWidget = setFieldWidget; 4 | this.smartActionFieldValidator = smartActionFieldValidator; 5 | this.smartActionFormLayoutService = smartActionFormLayoutService; 6 | } 7 | 8 | /** 9 | * Transform fields from an array to an object to ease usage in hook, 10 | * adds null value, prepare widgets. 11 | * 12 | * @param {*} fields A smart action field 13 | */ 14 | getFieldsForUser(fields) { 15 | return fields.map((field) => { 16 | // Update widget from legacy to current format. 17 | this.setFieldWidget(field); 18 | if (field.value === undefined) field.value = null; 19 | return field; 20 | }); 21 | } 22 | 23 | /** 24 | * Get the response from user-defined hook. 25 | * 26 | * @param {Function} hook the callback hook of the smart action. 27 | * @param {Array} fields the array of fields. 28 | * @param {Object} record the current record that has to be passed to load hook. 29 | */ 30 | async getResponse(action, hook, fields, request, changedField = null) { 31 | const fieldsForUser = this.getFieldsForUser(fields); 32 | 33 | if (typeof hook !== 'function') throw new Error('hook must be a function'); 34 | 35 | // Call the user-defined load hook. 36 | const hookResult = await hook({ request, fields: fieldsForUser, changedField }); 37 | 38 | if (!(hookResult && Array.isArray(hookResult))) { 39 | throw new Error('hook must return an array'); 40 | } 41 | 42 | const { fields: fieldHookResult, layout } = this.smartActionFormLayoutService 43 | .extractFieldsAndLayout(hookResult); 44 | 45 | const validFields = fieldHookResult.map((field) => { 46 | this.smartActionFieldValidator.validateField(field, action.name); 47 | this.smartActionFieldValidator 48 | .validateFieldChangeHook(field, action.name, action.hooks?.change); 49 | 50 | if (field.value === undefined) field.value = null; 51 | 52 | // Reset `value` when not present in `enums` (which means `enums` has changed). 53 | if (Array.isArray(field.enums)) { 54 | // `Value` can be an array if the type of fields is `[x]` 55 | if (Array.isArray(field.type) 56 | && Array.isArray(field.value) 57 | && field.value.some((value) => !field.enums.includes(value))) { 58 | return { ...field, value: null }; 59 | } 60 | 61 | // `Value` can be any other value 62 | if (!Array.isArray(field.type) && !field.enums.includes(field.value)) { 63 | return { ...field, value: null }; 64 | } 65 | } 66 | return field; 67 | }); 68 | return { fields: validFields, layout }; 69 | } 70 | } 71 | 72 | module.exports = SmartActionHookService; 73 | -------------------------------------------------------------------------------- /src/services/csv-exporter.js: -------------------------------------------------------------------------------- 1 | const moment = require('moment'); 2 | const stringify = require('csv-stringify/lib/sync'); 3 | const { inject } = require('@forestadmin/context'); 4 | const ParamsFieldsDeserializer = require('../deserializers/params-fields'); 5 | const SmartFieldsValuesInjector = require('./smart-fields-values-injector'); 6 | 7 | // NOTICE: Prevent bad date formatting into timestamps. 8 | const CSV_OPTIONS = { 9 | cast: { 10 | date: (value) => moment(value).format(), 11 | }, 12 | }; 13 | 14 | function CSVExporter(params, response, modelName, recordsExporter) { 15 | const { configStore } = inject(); 16 | 17 | function getValueForAttribute(record, attribute) { 18 | let value; 19 | if (params.fields[attribute]) { 20 | if (record[attribute]) { 21 | if (params.fields[attribute] && record[attribute][params.fields[attribute]]) { 22 | value = record[attribute][params.fields[attribute]]; 23 | } else { 24 | // eslint-disable-next-line 25 | value = record[attribute].id || record[attribute]._id; 26 | } 27 | } 28 | } else { 29 | value = record[attribute]; 30 | } 31 | 32 | return value || ''; 33 | } 34 | 35 | // eslint-disable-next-line sonarjs/cognitive-complexity 36 | this.perform = async () => { 37 | const filename = `${params.filename}.csv`; 38 | response.setHeader('Content-Type', 'text/csv; charset=utf-8'); 39 | response.setHeader('Content-disposition', `attachment; filename=${filename}`); 40 | response.setHeader('Last-Modified', moment()); 41 | 42 | // NOTICE: From nginx doc: Setting this to "no" will allow unbuffered 43 | // responses suitable for Comet and HTTP streaming applications. 44 | response.setHeader('X-Accel-Buffering', 'no'); 45 | response.setHeader('Cache-Control', 'no-cache'); 46 | 47 | const CSVHeader = `${params.header}\n`; 48 | const CSVAttributes = params.fields[modelName].split(','); 49 | response.write(CSVHeader); 50 | 51 | const fieldsPerModel = new ParamsFieldsDeserializer(params.fields).perform(); 52 | 53 | await recordsExporter 54 | .perform(async (records) => { 55 | await Promise.all( 56 | // eslint-disable-next-line max-len 57 | records.map((record) => new SmartFieldsValuesInjector(record, modelName, fieldsPerModel).perform()), 58 | ); 59 | 60 | if (configStore.Implementation.Flattener) { 61 | records = configStore.Implementation.Flattener 62 | .flattenRecordsForExport(modelName, records); 63 | } 64 | 65 | records.forEach((record) => { 66 | // eslint-disable-next-line max-len 67 | response.write(stringify([CSVAttributes.map((attribute) => getValueForAttribute(record, attribute, params))], CSV_OPTIONS)); 68 | }); 69 | }); 70 | response.end(); 71 | }; 72 | } 73 | 74 | module.exports = CSVExporter; 75 | -------------------------------------------------------------------------------- /src/integrations/layer/setup.js: -------------------------------------------------------------------------------- 1 | const { inject } = require('@forestadmin/context'); 2 | const _ = require('lodash'); 3 | const { pushIntoApimap } = require('../../utils/integrations'); 4 | 5 | const INTEGRATION_NAME = 'layer'; 6 | 7 | exports.createCollections = (Implementation, apimap, collectionAndFieldName) => { 8 | const { modelsManager } = inject(); 9 | // jshint camelcase: false 10 | const collectionName = collectionAndFieldName.split('.')[0]; 11 | const model = modelsManager.getModels()[collectionName]; 12 | const referenceName = `${Implementation.getModelName(model)}.id`; 13 | const collectionDisplayName = _.capitalize(collectionName); 14 | 15 | pushIntoApimap(apimap, { 16 | name: `${Implementation.getModelName(model)}_layer_conversations`, 17 | displayName: `${collectionDisplayName} Conversations`, 18 | icon: 'layer', 19 | integration: INTEGRATION_NAME, 20 | isVirtual: true, 21 | isReadOnly: true, 22 | onlyForRelationships: true, 23 | paginationType: 'cursor', 24 | fields: [ 25 | { field: 'id', type: 'String', isFilterable: false }, 26 | { field: 'title', type: 'String', isFilterable: false }, 27 | { field: 'createdAt', type: 'Date', isFilterable: false }, 28 | { 29 | field: 'lastMessage', 30 | type: 'String', 31 | reference: `${Implementation.getModelName(model)}_layer_messages`, 32 | isFilterable: false, 33 | }, 34 | { 35 | field: 'messages', 36 | type: ['String'], 37 | reference: `${Implementation.getModelName(model)}_layer_messages`, 38 | isFilterable: false, 39 | }, 40 | { 41 | field: 'participants', 42 | type: ['String'], 43 | reference: referenceName, 44 | isFilterable: false, 45 | }, 46 | ], 47 | }); 48 | 49 | pushIntoApimap(apimap, { 50 | name: `${Implementation.getModelName(model)}_layer_messages`, 51 | displayName: `${collectionDisplayName} Messages`, 52 | icon: 'layer', 53 | integration: INTEGRATION_NAME, 54 | onlyForRelationships: true, 55 | isVirtual: true, 56 | isReadOnly: true, 57 | paginationType: 'cursor', 58 | fields: [ 59 | { field: 'id', type: 'String', isFilterable: false }, 60 | { field: 'sender', type: 'String', isFilterable: false }, 61 | { field: 'sentAt', type: 'Date', isFilterable: false }, 62 | { field: 'content', type: 'String', isFilterable: false }, 63 | { field: 'mimeType', type: 'String', isFilterable: false }, 64 | ], 65 | actions: [], 66 | }); 67 | }; 68 | 69 | exports.createFields = (Implementation, model, schemaFields) => { 70 | schemaFields.push({ 71 | field: 'layer_conversations', 72 | displayName: 'Conversations', 73 | type: ['String'], 74 | reference: `${Implementation.getModelName(model)}_layer_conversations.id`, 75 | column: null, 76 | isFilterable: false, 77 | integration: INTEGRATION_NAME, 78 | }); 79 | }; 80 | -------------------------------------------------------------------------------- /src/services/authorization-finder.js: -------------------------------------------------------------------------------- 1 | const InconsistentSecretAndRenderingError = require('../utils/errors/authentication/inconsistent-secret-rendering-error'); 2 | const SecretNotFoundError = require('../utils/errors/authentication/secret-not-found-error'); 3 | const TwoFactorAuthenticationRequiredError = require('../utils/errors/authentication/two-factor-authentication-required-error'); 4 | const AuthorizationError = require('../utils/errors/authentication/authorization-error'); 5 | 6 | class AuthorizationFinder { 7 | /** @private @readonly @type {import('./forest-server-requester')} */ 8 | forestServerRequester; 9 | 10 | /** @private @readonly @type {import('./logger')} */ 11 | logger; 12 | 13 | /** @private @readonly @type {import('../utils/error-messages')} */ 14 | errorMessages; 15 | 16 | /** 17 | * @param {import('../context/init').Context} context 18 | */ 19 | constructor(context) { 20 | this.forestServerRequester = context.forestServerRequester; 21 | this.logger = context.logger; 22 | this.errorMessages = context.errorMessages; 23 | } 24 | 25 | /** 26 | * @private 27 | * @param {Error} error 28 | * @returns {Error} 29 | */ 30 | _generateAuthenticationError(error) { 31 | switch (error.message) { 32 | case this.errorMessages.SERVER_TRANSACTION.SECRET_AND_RENDERINGID_INCONSISTENT: 33 | return new InconsistentSecretAndRenderingError(); 34 | case this.errorMessages.SERVER_TRANSACTION.SECRET_NOT_FOUND: 35 | return new SecretNotFoundError(); 36 | default: 37 | } 38 | 39 | // eslint-disable-next-line camelcase 40 | const serverErrors = error?.jse_cause?.response?.body?.errors; 41 | const serverError = serverErrors && serverErrors[0]; 42 | 43 | if (serverError?.name === this.errorMessages 44 | .SERVER_TRANSACTION 45 | .names 46 | .TWO_FACTOR_AUTHENTICATION_REQUIRED) { 47 | return new TwoFactorAuthenticationRequiredError(); 48 | } 49 | 50 | if (serverError?.status) { 51 | throw new AuthorizationError( 52 | serverError.status, 53 | serverError.detail, 54 | ); 55 | } 56 | 57 | return new Error(); 58 | } 59 | 60 | /** 61 | * @param {number|string} renderingId 62 | * @param {string} environmentSecret 63 | * @param {string} forestToken 64 | */ 65 | async authenticate( 66 | renderingId, 67 | environmentSecret, 68 | forestToken, 69 | ) { 70 | const headers = { 'forest-token': forestToken }; 71 | 72 | const url = `/liana/v2/renderings/${renderingId}/authorization`; 73 | 74 | try { 75 | const result = await this.forestServerRequester 76 | .perform(url, environmentSecret, null, headers); 77 | 78 | const user = result.data.attributes; 79 | user.id = result.data.id; 80 | return user; 81 | } catch (error) { 82 | // eslint-disable-next-line camelcase 83 | this.logger.error('Authorization error: ', error?.jse_cause?.response.body || error); 84 | throw this._generateAuthenticationError(error); 85 | } 86 | } 87 | } 88 | 89 | module.exports = AuthorizationFinder; 90 | -------------------------------------------------------------------------------- /test/utils/join-url.test.js: -------------------------------------------------------------------------------- 1 | const joinUrl = require('../../src/utils/join-url'); 2 | 3 | describe('utils > join-url', () => { 4 | describe('when the base url only contains a domain name', () => { 5 | const baseUrl = 'http://localhost:3000'; 6 | 7 | it('should correctly append the path if it does not start with a /', () => { 8 | expect(joinUrl(baseUrl, 'forest/authentication')).toBe(`${baseUrl}/forest/authentication`); 9 | }); 10 | 11 | it('should correctly append the path if it starts with a /', () => { 12 | expect(joinUrl(baseUrl, '/forest/authentication')).toBe(`${baseUrl}/forest/authentication`); 13 | }); 14 | 15 | it('should correctly append each parts when not starting with a /', () => { 16 | expect(joinUrl(baseUrl, 'forest', 'authentication')).toBe(`${baseUrl}/forest/authentication`); 17 | }); 18 | 19 | it('should correctly append each parts when starting with a /', () => { 20 | expect(joinUrl(baseUrl, '/forest', '/authentication')).toBe(`${baseUrl}/forest/authentication`); 21 | }); 22 | 23 | it('should correctly append each parts when ending with a /', () => { 24 | expect(joinUrl(baseUrl, '/forest/', '/authentication')).toBe(`${baseUrl}/forest/authentication`); 25 | }); 26 | 27 | it('should ignore empty parts', () => { 28 | expect(joinUrl(baseUrl, '', 'forest', 'authentication')).toBe(`${baseUrl}/forest/authentication`); 29 | }); 30 | }); 31 | 32 | describe('when the base url only contains a domain name ending with /', () => { 33 | const baseUrl = 'http://localhost:3000/'; 34 | 35 | it('should correctly append the path if it does not start with a /', () => { 36 | expect(joinUrl(baseUrl, 'forest/authentication')).toBe(`${baseUrl}forest/authentication`); 37 | }); 38 | 39 | it('should correctly append the path if it starts with a /', () => { 40 | expect(joinUrl(baseUrl, '/forest/authentication')).toBe(`${baseUrl}forest/authentication`); 41 | }); 42 | }); 43 | 44 | describe('when the base url contains a domain name and a path not ending with /', () => { 45 | const baseUrl = 'http://localhost:3000/prefix'; 46 | 47 | it('should correctly append the path if it does not start with a /', () => { 48 | expect(joinUrl(baseUrl, 'forest/authentication')).toBe(`${baseUrl}/forest/authentication`); 49 | }); 50 | 51 | it('should correctly append the path if it starts with a /', () => { 52 | expect(joinUrl(baseUrl, '/forest/authentication')).toBe(`${baseUrl}/forest/authentication`); 53 | }); 54 | }); 55 | 56 | describe('when the base url contains a domain name and a path ending with /', () => { 57 | const baseUrl = 'http://localhost:3000/prefix/'; 58 | 59 | it('should correctly append the path if it does not start with a /', () => { 60 | expect(joinUrl(baseUrl, 'forest/authentication')).toBe(`${baseUrl}forest/authentication`); 61 | }); 62 | 63 | it('should correctly append the path if it starts with a /', () => { 64 | expect(joinUrl(baseUrl, '/forest/authentication')).toBe(`${baseUrl}forest/authentication`); 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/integrations/intercom/routes.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const IntegrationInformationsGetter = require('./services/integration-informations-getter'); 3 | const AttributesGetter = require('./services/attributes-getter'); 4 | const serializeAttributes = require('./serializers/intercom-attributes'); 5 | const ConversationsGetter = require('./services/conversations-getter'); 6 | const serializeConversations = require('./serializers/intercom-conversations'); 7 | const ConversationGetter = require('./services/conversation-getter'); 8 | const serializeConversation = require('./serializers/intercom-conversation'); 9 | const path = require('../../services/path'); 10 | const auth = require('../../services/auth'); 11 | 12 | module.exports = function Routes(app, model, Implementation, options) { 13 | const modelName = Implementation.getModelName(model); 14 | let integrationInfo; 15 | 16 | if (options.integrations && options.integrations.intercom) { 17 | integrationInfo = new IntegrationInformationsGetter( 18 | modelName, 19 | Implementation, 20 | options.integrations.intercom, 21 | ).perform(); 22 | } 23 | 24 | this.getAttributes = (request, response, next) => { 25 | new AttributesGetter( 26 | Implementation, 27 | _.extend(request.query, request.params), 28 | options, 29 | integrationInfo, 30 | ) 31 | .perform() 32 | .then((attributes) => serializeAttributes(attributes, modelName)) 33 | .then((attributes) => { 34 | response.send(attributes); 35 | }) 36 | .catch(next); 37 | }; 38 | 39 | this.listConversations = (request, response, next) => { 40 | new ConversationsGetter( 41 | Implementation, 42 | _.extend(request.query, request.params), 43 | options, 44 | integrationInfo, 45 | ) 46 | .perform() 47 | .spread((count, conversations) => serializeConversations( 48 | conversations, 49 | modelName, 50 | { count }, 51 | )) 52 | .then((conversations) => { 53 | response.send(conversations); 54 | }) 55 | .catch(next); 56 | }; 57 | 58 | this.getConversation = (request, response, next) => { 59 | new ConversationGetter(Implementation, _.extend(request.query, request.params), options) 60 | .perform() 61 | .then((conversation) => serializeConversation(conversation, modelName)) 62 | .then((conversation) => { 63 | response.send(conversation); 64 | }) 65 | .catch(next); 66 | }; 67 | 68 | this.perform = () => { 69 | if (integrationInfo) { 70 | app.get( 71 | path.generate(`${modelName}/:recordId/intercom_attributes`, options), 72 | auth.ensureAuthenticated, 73 | this.getAttributes, 74 | ); 75 | app.get( 76 | path.generate(`${modelName}/:recordId/intercom_conversations`, options), 77 | auth.ensureAuthenticated, 78 | this.listConversations, 79 | ); 80 | app.get( 81 | path.generate(`${modelName}_intercom_conversations/:conversationId`, options), 82 | auth.ensureAuthenticated, 83 | this.getConversation, 84 | ); 85 | } 86 | }; 87 | }; 88 | -------------------------------------------------------------------------------- /src/services/authentication.js: -------------------------------------------------------------------------------- 1 | class AuthenticationService { 2 | /** @private @readonly @type {import('./authorization-finder')} */ 3 | authorizationFinder; 4 | 5 | /** @private @readonly @type {import('./token')} */ 6 | tokenService; 7 | 8 | /** @private @readonly @type {import('./oidc-client-manager')} */ 9 | oidcClientManagerService; 10 | 11 | /** @private @readonly @type {import('../utils/error-messages')} */ 12 | errorMessages; 13 | 14 | /** 15 | * @param {import("../context/init").Context} context 16 | */ 17 | constructor({ 18 | authorizationFinder, tokenService, 19 | errorMessages, oidcClientManagerService, 20 | }) { 21 | this.authorizationFinder = authorizationFinder; 22 | this.tokenService = tokenService; 23 | this.oidcClientManagerService = oidcClientManagerService; 24 | this.errorMessages = errorMessages; 25 | } 26 | 27 | /** 28 | * @private 29 | * @param {string} state 30 | * @returns {{renderingId: string}} 31 | */ 32 | _parseState(state) { 33 | if (!state) { 34 | throw new Error(this.errorMessages.SERVER_TRANSACTION.INVALID_STATE_MISSING); 35 | } 36 | 37 | /** @type {string} */ 38 | let renderingId; 39 | 40 | try { 41 | const parsedState = JSON.parse(state); 42 | renderingId = parsedState.renderingId; 43 | } catch (e) { 44 | throw new Error(this.errorMessages.SERVER_TRANSACTION.INVALID_STATE_FORMAT); 45 | } 46 | 47 | if (!renderingId) { 48 | throw new Error(this.errorMessages.SERVER_TRANSACTION.INVALID_STATE_RENDERING_ID); 49 | } 50 | 51 | return { renderingId }; 52 | } 53 | 54 | /** 55 | * Step 1 of the authentication 56 | * @param {string} redirectUrl 57 | * @param {{renderingId: string|number}} state 58 | * @returns {Promise<{ 59 | * authorizationUrl: string; 60 | * }>} 61 | */ 62 | async startAuthentication(redirectUrl, state) { 63 | const client = await this.oidcClientManagerService.getClientForCallbackUrl(redirectUrl); 64 | 65 | const authorizationUrl = client.authorizationUrl({ 66 | scope: 'openid email profile', 67 | state: JSON.stringify(state), 68 | }); 69 | 70 | return { authorizationUrl }; 71 | } 72 | 73 | /** 74 | * @param {string} redirectUrl 75 | * @param {import('openid-client').CallbackParamsType} params 76 | * @param {{ envSecret: string, authSecret: string }} options 77 | */ 78 | async verifyCodeAndGenerateToken(redirectUrl, params, options) { 79 | const client = await this.oidcClientManagerService.getClientForCallbackUrl(redirectUrl); 80 | 81 | const { renderingId } = this._parseState(params.state); 82 | 83 | const tokenSet = await client.callback( 84 | redirectUrl, 85 | params, 86 | { state: params.state }, 87 | ); 88 | 89 | const user = await this.authorizationFinder.authenticate( 90 | renderingId, 91 | options.envSecret, 92 | tokenSet.access_token, 93 | ); 94 | 95 | return this.tokenService.createToken(user, renderingId, options); 96 | } 97 | } 98 | 99 | module.exports = AuthenticationService; 100 | -------------------------------------------------------------------------------- /src/integrations/stripe/services/invoices-getter.js: -------------------------------------------------------------------------------- 1 | const P = require('bluebird'); 2 | const logger = require('../../../services/logger'); 3 | const dataUtil = require('../../../utils/data'); 4 | 5 | function InvoicesGetter(Implementation, params, opts, integrationInfo) { 6 | const stripe = opts.integrations.stripe.stripe(opts.integrations.stripe.apiKey); 7 | let collectionModel = null; 8 | 9 | function hasPagination() { 10 | return params.page; 11 | } 12 | 13 | function getLimit() { 14 | if (hasPagination()) { 15 | return params.page.size || 10; 16 | } 17 | return 10; 18 | } 19 | 20 | function getStartingAfter() { 21 | if (hasPagination() && params.starting_after) { 22 | return params.starting_after; 23 | } 24 | return undefined; 25 | } 26 | 27 | function getEndingBefore() { 28 | if (hasPagination() && params.ending_before) { 29 | return params.ending_before; 30 | } 31 | return undefined; 32 | } 33 | 34 | function getInvoices(query) { 35 | return new P((resolve, reject) => { 36 | stripe.invoices.list(query, (err, invoices) => { 37 | if (err) { return reject(err); } 38 | return resolve([invoices.total_count, invoices.data]); 39 | }); 40 | }); 41 | } 42 | 43 | this.perform = () => { 44 | collectionModel = integrationInfo.collection; 45 | const { 46 | field: collectionFieldName, 47 | embeddedPath, 48 | } = integrationInfo; 49 | const fieldName = embeddedPath ? `${collectionFieldName}.${embeddedPath}` : collectionFieldName; 50 | 51 | return Implementation.Stripe.getCustomer(collectionModel, collectionFieldName, params.recordId) 52 | .then((customer) => { 53 | const query = { 54 | limit: getLimit(), 55 | starting_after: getStartingAfter(), 56 | ending_before: getEndingBefore(), 57 | 'include[]': 'total_count', 58 | }; 59 | 60 | if (customer && !!customer[collectionFieldName]) { 61 | query.customer = dataUtil.find(customer[collectionFieldName], embeddedPath); 62 | } 63 | 64 | if (customer && !query.customer) { return P.resolve([0, []]); } 65 | 66 | return getInvoices(query) 67 | .spread((count, invoices) => 68 | P 69 | .map(invoices, (invoice) => { 70 | if (customer) { 71 | invoice.customer = customer; 72 | } else { 73 | return Implementation.Stripe.getCustomerByUserField( 74 | collectionModel, 75 | fieldName, 76 | invoice.customer, 77 | ) 78 | .then((customerFound) => { 79 | invoice.customer = customerFound; 80 | return invoice; 81 | }); 82 | } 83 | return invoice; 84 | }) 85 | .then((invoicesData) => [count, invoicesData])) 86 | .catch((error) => { 87 | logger.warn('Stripe invoices retrieval issue: ', error); 88 | return P.resolve([0, []]); 89 | }); 90 | }, () => P.resolve([0, []])); 91 | }; 92 | } 93 | 94 | module.exports = InvoicesGetter; 95 | -------------------------------------------------------------------------------- /test/fixtures/cars-schema.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | name: 'cars', 3 | nameOld: 'cars', 4 | icon: null, 5 | integration: null, 6 | isReadOnly: false, 7 | isSearchable: true, 8 | isVirtual: false, 9 | onlyForRelationships: false, 10 | paginationType: 'page', 11 | fields: [{ 12 | field: '_id', 13 | type: 'String', 14 | defaultValue: null, 15 | enums: null, 16 | integration: null, 17 | isFilterable: true, 18 | isPrimaryKey: true, 19 | isReadOnly: false, 20 | isRequired: false, 21 | isSortable: true, 22 | isVirtual: false, 23 | reference: null, 24 | inverseOf: null, 25 | validations: [], 26 | }, 27 | { 28 | field: 'coordinate', 29 | type: 'Point', 30 | columnName: 'coordinate', 31 | isRequired: true, 32 | validations: [ 33 | { 34 | type: 'is present', 35 | message: null, 36 | value: null, 37 | }, 38 | ], 39 | defaultValue: null, 40 | isPrimaryKey: false, 41 | isReadOnly: false, 42 | isSortable: true, 43 | isFilterable: true, 44 | isVirtual: false, 45 | description: null, 46 | reference: null, 47 | inverseOf: null, 48 | relationships: null, 49 | enums: null, 50 | integration: null, 51 | }, 52 | { 53 | field: 'engine@@@horsePower', 54 | type: 'String', 55 | defaultValue: null, 56 | enums: null, 57 | integration: null, 58 | isFilterable: true, 59 | isPrimaryKey: false, 60 | isReadOnly: false, 61 | isRequired: false, 62 | isSortable: true, 63 | isVirtual: false, 64 | reference: null, 65 | inverseOf: null, 66 | validations: [], 67 | }, { 68 | field: 'engine@@@identification@@@manufacturer', 69 | type: 'String', 70 | defaultValue: null, 71 | enums: null, 72 | integration: null, 73 | isFilterable: true, 74 | isPrimaryKey: false, 75 | isReadOnly: false, 76 | isRequired: false, 77 | isSortable: true, 78 | isVirtual: false, 79 | reference: null, 80 | inverseOf: null, 81 | validations: [], 82 | }, { 83 | field: 'attributes', 84 | type: { 85 | fields: [{ 86 | field: 'option', 87 | type: 'String', 88 | }, { 89 | field: 'color', 90 | type: 'String', 91 | }], 92 | }, 93 | defaultValue: null, 94 | enums: null, 95 | integration: null, 96 | isFilterable: true, 97 | isPrimaryKey: false, 98 | isReadOnly: false, 99 | isRequired: false, 100 | isSortable: true, 101 | isVirtual: false, 102 | reference: null, 103 | inverseOf: null, 104 | validations: [], 105 | }, { 106 | field: 'name', 107 | type: 'String', 108 | defaultValue: null, 109 | enums: null, 110 | integration: null, 111 | isFilterable: true, 112 | isPrimaryKey: false, 113 | isReadOnly: false, 114 | isRequired: true, 115 | isSortable: true, 116 | isVirtual: false, 117 | reference: null, 118 | inverseOf: null, 119 | validations: [{ 120 | message: null, 121 | type: 'is present', 122 | value: null, 123 | }], 124 | }], 125 | segments: [], 126 | actions: [], 127 | }; 128 | -------------------------------------------------------------------------------- /src/integrations/stripe/services/sources-getter.js: -------------------------------------------------------------------------------- 1 | const P = require('bluebird'); 2 | const logger = require('../../../services/logger'); 3 | const dataUtil = require('../../../utils/data'); 4 | 5 | function SourcesGetter(Implementation, params, opts, integrationInfo) { 6 | const stripe = opts.integrations.stripe.stripe(opts.integrations.stripe.apiKey); 7 | 8 | function hasPagination() { 9 | return params.page; 10 | } 11 | 12 | function getLimit() { 13 | if (hasPagination()) { 14 | return params.page.size || 10; 15 | } 16 | return 10; 17 | } 18 | 19 | function getStartingAfter() { 20 | if (hasPagination() && params.starting_after) { 21 | return params.starting_after; 22 | } 23 | return undefined; 24 | } 25 | 26 | function getEndingBefore() { 27 | if (hasPagination() && params.ending_before) { 28 | return params.ending_before; 29 | } 30 | return undefined; 31 | } 32 | 33 | function getSources(customerId, query) { 34 | return new P((resolve, reject) => { 35 | stripe.customers.listSources(customerId, query, (error, sources) => { 36 | if (error) { return reject(error); } 37 | return resolve([sources.total_count, sources.data]); 38 | }); 39 | }); 40 | } 41 | 42 | this.perform = () => { 43 | const { 44 | collection: collectionModel, 45 | field: collectionFieldName, 46 | embeddedPath, 47 | } = integrationInfo; 48 | const fieldName = embeddedPath ? `${collectionFieldName}.${embeddedPath}` : collectionFieldName; 49 | 50 | return Implementation.Stripe.getCustomer(collectionModel, collectionFieldName, params.recordId) 51 | .then((customer) => { 52 | const query = { 53 | limit: getLimit(), 54 | starting_after: getStartingAfter(), 55 | ending_before: getEndingBefore(), 56 | 'include[]': 'total_count', 57 | object: params.object, 58 | }; 59 | 60 | let customerId; 61 | if (customer[collectionFieldName]) { 62 | customerId = dataUtil.find(customer[collectionFieldName], embeddedPath); 63 | } 64 | 65 | if (customer && !customerId) { return P.resolve([0, []]); } 66 | 67 | return getSources(customerId, query) 68 | .spread((count, sources) => 69 | P 70 | .map(sources, (source) => { 71 | if (customer) { 72 | source.customer = customer; 73 | } else { 74 | return Implementation.Stripe.getCustomerByUserField( 75 | collectionModel, 76 | fieldName, 77 | source.customer, 78 | ) 79 | .then((customerFound) => { 80 | source.customer = customerFound; 81 | return source; 82 | }); 83 | } 84 | return source; 85 | }) 86 | .then((sourcesData) => [count, sourcesData])) 87 | .catch((error) => { 88 | logger.warn('Stripe sources retrieval issue: ', error); 89 | return P.resolve([0, []]); 90 | }); 91 | }, () => P.resolve([0, []])); 92 | }; 93 | } 94 | 95 | module.exports = SourcesGetter; 96 | -------------------------------------------------------------------------------- /src/integrations/stripe/services/payments-getter.js: -------------------------------------------------------------------------------- 1 | const P = require('bluebird'); 2 | const logger = require('../../../services/logger'); 3 | const dataUtil = require('../../../utils/data'); 4 | 5 | function PaymentsGetter(Implementation, params, opts, integrationInfo) { 6 | const stripe = opts.integrations.stripe.stripe(opts.integrations.stripe.apiKey); 7 | let collectionModel = null; 8 | 9 | function hasPagination() { 10 | return params.page; 11 | } 12 | 13 | function getLimit() { 14 | if (hasPagination()) { 15 | return params.page.size || 10; 16 | } 17 | return 10; 18 | } 19 | 20 | function getStartingAfter() { 21 | if (hasPagination() && params.starting_after) { 22 | return params.starting_after; 23 | } 24 | return undefined; 25 | } 26 | 27 | function getEndingBefore() { 28 | if (hasPagination() && params.ending_before) { 29 | return params.ending_before; 30 | } 31 | return undefined; 32 | } 33 | 34 | function getCharges(query) { 35 | return new P((resolve, reject) => { 36 | stripe.charges.list(query, (err, charges) => { 37 | if (err) { return reject(err); } 38 | return resolve([charges.total_count, charges.data]); 39 | }); 40 | }); 41 | } 42 | 43 | this.perform = () => { 44 | collectionModel = integrationInfo.collection; 45 | const { 46 | field: collectionFieldName, 47 | embeddedPath, 48 | } = integrationInfo; 49 | const fieldName = embeddedPath ? `${collectionFieldName}.${embeddedPath}` : collectionFieldName; 50 | 51 | return Implementation.Stripe.getCustomer(collectionModel, collectionFieldName, params.recordId) 52 | .then((customer) => { 53 | const query = { 54 | limit: getLimit(), 55 | starting_after: getStartingAfter(), 56 | ending_before: getEndingBefore(), 57 | source: { object: 'card' }, 58 | 'include[]': 'total_count', 59 | }; 60 | 61 | if (customer && !!customer[collectionFieldName]) { 62 | query.customer = dataUtil.find(customer[collectionFieldName], embeddedPath); 63 | } 64 | 65 | if (customer && !query.customer) { return P.resolve([0, []]); } 66 | 67 | return getCharges(query) 68 | .spread((count, payments) => 69 | P 70 | .map(payments, (payment) => { 71 | if (customer) { 72 | payment.customer = customer; 73 | } else { 74 | return Implementation.Stripe.getCustomerByUserField( 75 | collectionModel, 76 | fieldName, 77 | payment.customer, 78 | ) 79 | .then((customerFound) => { 80 | payment.customer = customerFound; 81 | return payment; 82 | }); 83 | } 84 | return payment; 85 | }) 86 | .then((paymentsData) => [count, paymentsData])) 87 | .catch((error) => { 88 | logger.warn('Stripe payments retrieval issue: ', error); 89 | return P.resolve([0, []]); 90 | }); 91 | }, () => P.resolve([0, []])); 92 | }; 93 | } 94 | 95 | module.exports = PaymentsGetter; 96 | -------------------------------------------------------------------------------- /src/deserializers/resource.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const P = require('bluebird'); 3 | const logger = require('../services/logger'); 4 | const Schemas = require('../generators/schemas'); 5 | 6 | function ResourceDeserializer(Implementation, model, params, withRelationships, opts) { 7 | if (!opts) { opts = {}; } 8 | const schema = Schemas.schemas[Implementation.getModelName(model)]; 9 | 10 | function extractAttributes() { 11 | let { attributes } = params.data; 12 | if (params.data.attributes) { 13 | attributes[schema.idField] = params.data.attributes[schema.idField] 14 | || params.data.id; 15 | } 16 | 17 | // NOTICE: Look for some Smart Field setters and apply them if any. 18 | const smartFields = _.filter(schema.fields, (field) => { 19 | if (field.isVirtual && field.set && field.reference) { 20 | logger.warn(`The "${field.field}" Smart Relationship cannot be updated implementing a "set" function.`); 21 | } 22 | return field.isVirtual && field.set && !field.reference; 23 | }); 24 | 25 | _.each(schema.fields, (field) => { 26 | if (field.type === 'Point' && params.data.attributes[field.field]) { 27 | const coordinates = params.data.attributes[field.field].split(','); 28 | params.data.attributes[field.field] = { 29 | type: 'Point', 30 | coordinates, 31 | }; 32 | } 33 | }); 34 | 35 | return P 36 | .each(smartFields, (field) => { 37 | // WARNING: The Smart Fields setters may override other changes. 38 | if (field.field in attributes) { 39 | return field.set(attributes, attributes[field.field]); 40 | } 41 | return null; 42 | }) 43 | .then(() => { 44 | if (opts.omitNullAttributes) { 45 | attributes = _.pickBy(attributes, (value) => !_.isNull(value)); 46 | } 47 | 48 | return attributes || {}; 49 | }); 50 | } 51 | 52 | function extractRelationships() { 53 | return new P((resolve) => { 54 | const relationships = {}; 55 | 56 | _.each(schema.fields, (field) => { 57 | if (field.reference && params.data.relationships 58 | && params.data.relationships[field.field]) { 59 | if (params.data.relationships[field.field].data === null) { 60 | // Remove the relationships 61 | relationships[field.field] = null; 62 | } else if (params.data.relationships[field.field].data) { 63 | // Set the relationship 64 | if (_.isArray(params.data.relationships[field.field].data)) { 65 | relationships[field.field] = params.data.relationships[field.field] 66 | .data.map((d) => d.id); 67 | } else { 68 | relationships[field.field] = params.data.relationships[field.field] 69 | .data.id; 70 | } 71 | } // Else ignore the relationship 72 | } 73 | }); 74 | 75 | resolve(relationships); 76 | }); 77 | } 78 | 79 | this.perform = () => { 80 | if (withRelationships) { 81 | return P.all([extractAttributes(), extractRelationships()]) 82 | .spread((attributes, relationships) => _.extend(attributes, relationships)); 83 | } 84 | return extractAttributes(); 85 | }; 86 | } 87 | 88 | module.exports = ResourceDeserializer; 89 | -------------------------------------------------------------------------------- /src/integrations/stripe/services/subscriptions-getter.js: -------------------------------------------------------------------------------- 1 | const P = require('bluebird'); 2 | const logger = require('../../../services/logger'); 3 | const dataUtil = require('../../../utils/data'); 4 | 5 | function SubscriptionsGetter(Implementation, params, opts, integrationInfo) { 6 | const stripe = opts.integrations.stripe.stripe(opts.integrations.stripe.apiKey); 7 | let collectionModel = null; 8 | 9 | function hasPagination() { 10 | return params.page; 11 | } 12 | 13 | function getLimit() { 14 | if (hasPagination()) { 15 | return params.page.size || 10; 16 | } 17 | return 10; 18 | } 19 | 20 | function getStartingAfter() { 21 | if (hasPagination() && params.starting_after) { 22 | return params.starting_after; 23 | } 24 | return undefined; 25 | } 26 | 27 | function getEndingBefore() { 28 | if (hasPagination() && params.ending_before) { 29 | return params.ending_before; 30 | } 31 | return undefined; 32 | } 33 | 34 | function getSubscriptions(query) { 35 | return new P((resolve, reject) => { 36 | stripe.subscriptions.list(query, (err, subcriptions) => { 37 | if (err) { return reject(err); } 38 | return resolve([subcriptions.total_count, subcriptions.data]); 39 | }); 40 | }); 41 | } 42 | 43 | this.perform = () => { 44 | collectionModel = integrationInfo.collection; 45 | const { 46 | field: collectionFieldName, 47 | embeddedPath, 48 | } = integrationInfo; 49 | const fieldName = embeddedPath ? `${collectionFieldName}.${embeddedPath}` : collectionFieldName; 50 | 51 | return Implementation.Stripe.getCustomer(collectionModel, collectionFieldName, params.recordId) 52 | .then((customer) => { 53 | const query = { 54 | limit: getLimit(), 55 | starting_after: getStartingAfter(), 56 | ending_before: getEndingBefore(), 57 | 'include[]': 'total_count', 58 | }; 59 | 60 | if (customer && !!customer[collectionFieldName]) { 61 | query.customer = dataUtil.find(customer[collectionFieldName], embeddedPath); 62 | } 63 | 64 | if (customer && !query.customer) { return P.resolve([0, []]); } 65 | 66 | return getSubscriptions(query) 67 | .spread((count, subscriptions) => 68 | P 69 | .map(subscriptions, (subscription) => { 70 | if (customer) { 71 | subscription.customer = customer; 72 | } else { 73 | return Implementation.Stripe.getCustomerByUserField( 74 | collectionModel, 75 | fieldName, 76 | subscription.customer, 77 | ) 78 | .then((customerFound) => { 79 | subscription.customer = customerFound; 80 | return subscription; 81 | }); 82 | } 83 | return subscription; 84 | }) 85 | .then((subscriptionsData) => [count, subscriptionsData])) 86 | .catch((error) => { 87 | logger.warn('Stripe subscriptions retrieval issue: ', error); 88 | return P.resolve([0, []]); 89 | }); 90 | }, () => P.resolve([0, []])); 91 | }; 92 | } 93 | 94 | module.exports = SubscriptionsGetter; 95 | -------------------------------------------------------------------------------- /test/services/token.test.js: -------------------------------------------------------------------------------- 1 | const TokenService = require('../../src/services/token'); 2 | 3 | describe('token service', () => { 4 | function setup() { 5 | const jsonwebtoken = { 6 | sign: jest.fn(), 7 | }; 8 | 9 | /** @type {*} */ 10 | const context = { 11 | jsonwebtoken, 12 | }; 13 | 14 | const request = { 15 | headers: { 16 | cookie: 'forest_session_token=my_value_token', 17 | }, 18 | }; 19 | 20 | const response = { 21 | cookie: jest.fn(), 22 | }; 23 | 24 | const tokenService = new TokenService(context); 25 | 26 | const cookiesWithForestSessionToken = 'ajs_anonymous_id=%221d8dca9c-df5a-4c05-9bf7-0100df608603%22; _uc_referrer=direct; _ga=GA1.2.426771952.1606302064; _gid=GA1.2.263801298.1606302064; forest_session_token=myForestToken'; 27 | 28 | const cookiesWithoutForestSessionToken = 'ajs_anonymous_id=%221d8dca9c-df5a-4c05-9bf7-0100df608603%22; _uc_referrer=direct; _ga=GA1.2.426771952.1606302064; _gid=GA1.2.263801298.1606302064'; 29 | 30 | return { 31 | tokenService, 32 | jsonwebtoken, 33 | request, 34 | response, 35 | cookiesWithForestSessionToken, 36 | cookiesWithoutForestSessionToken, 37 | }; 38 | } 39 | 40 | it("should sign a token with user's data", () => { 41 | const { jsonwebtoken, tokenService } = setup(); 42 | 43 | jsonwebtoken.sign.mockReturnValue('THE TOKEN'); 44 | 45 | const user = { 46 | id: 666, 47 | email: 'alice@forestadmin.com', 48 | first_name: 'Alice', 49 | last_name: 'Doe', 50 | teams: [1, 2, 4], 51 | role: 'Test', 52 | tags: [{ key: 'city', value: 'Paris' }], 53 | }; 54 | 55 | const result = tokenService.createToken( 56 | user, 57 | 42, 58 | { authSecret: 'THIS IS SECRET' }, 59 | ); 60 | 61 | expect(result).toBe('THE TOKEN'); 62 | expect(jsonwebtoken.sign).toHaveBeenCalledWith( 63 | { 64 | id: 666, 65 | email: 'alice@forestadmin.com', 66 | firstName: 'Alice', 67 | lastName: 'Doe', 68 | team: 1, 69 | role: 'Test', 70 | renderingId: 42, 71 | tags: [{ key: 'city', value: 'Paris' }], 72 | }, 73 | 'THIS IS SECRET', 74 | { expiresIn: '1 hours' }, 75 | ); 76 | }); 77 | 78 | it('should update the expiration date of a token in the past', () => { 79 | const { tokenService } = setup(); 80 | const result = tokenService.deleteToken(); 81 | 82 | expect(result).toStrictEqual({ 83 | expires: new Date(0), 84 | httpOnly: true, 85 | secure: true, 86 | sameSite: 'none', 87 | }); 88 | }); 89 | 90 | it('should return null when there is no forest session cookie', () => { 91 | const { tokenService, cookiesWithoutForestSessionToken } = setup(); 92 | const result = tokenService.extractForestSessionToken(cookiesWithoutForestSessionToken); 93 | 94 | expect(result).toBeNull(); 95 | }); 96 | 97 | it('should return the forest session cookie', () => { 98 | const { tokenService, cookiesWithForestSessionToken } = setup(); 99 | const result = tokenService.extractForestSessionToken(cookiesWithForestSessionToken); 100 | 101 | expect(result).toBe('myForestToken'); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /test/utils/json.test.js: -------------------------------------------------------------------------------- 1 | const { prettyPrint } = require('../../src/utils/json'); 2 | 3 | describe('utils > json', () => { 4 | it('should prettyPrint simple primitive types', () => { 5 | expect(prettyPrint(1)).toBe('1'); 6 | expect(prettyPrint('a simple string')).toBe('"a simple string"'); 7 | expect(prettyPrint(true)).toBe('true'); 8 | 9 | expect(prettyPrint(null)).toBe('null'); 10 | expect(prettyPrint(undefined)).toBe('null'); 11 | }); 12 | 13 | it('should prettyPrint more complex string', () => { 14 | expect(prettyPrint('.*foo$')).toBe('".*foo$"'); 15 | expect(prettyPrint('http://somekindofurl.mydomain.com')) 16 | .toBe('"http://somekindofurl.mydomain.com"'); 17 | expect(prettyPrint('This text \r\n contains \r\n escaped char')) 18 | .toBe('"This text \\r\\n contains \\r\\n escaped char"'); 19 | expect(prettyPrint('\t \r \n \f \b " /')) 20 | .toBe('"\\t \\r \\n \\f \\b \\" /"'); 21 | }); 22 | 23 | it('should prettyPrint a simple array', () => { 24 | expect(prettyPrint(['a', 'b', 'c'])).toBe('[\n "a",\n "b",\n "c"\n]'); 25 | }); 26 | 27 | it('should prettyPrint a simple object', () => { 28 | expect(prettyPrint({ name: 'John' })).toBe('{\n "name": "John"\n}'); 29 | }); 30 | 31 | it('should prettyPrint a regexp string', () => { 32 | const regExp = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/g.toString(); 33 | const prettyPrintedRegExp = prettyPrint(regExp); 34 | // NOTICE: On regex, `prettyPrint` & `JSON.stringify` should produce the same result. 35 | expect(prettyPrintedRegExp).toStrictEqual(JSON.stringify(regExp)); 36 | expect(() => JSON.parse(prettyPrintedRegExp)).not.toThrow(); 37 | expect(JSON.parse(prettyPrintedRegExp)).toStrictEqual(regExp); 38 | }); 39 | 40 | it('should prettyPrint a simple array of objects', () => { 41 | const prettyPrintedObjectArray = prettyPrint([{ name: 'John' }, { name: 'Sylvia' }]); 42 | 43 | expect(prettyPrintedObjectArray) 44 | .toBe('[{\n "name": "John"\n}, {\n "name": "Sylvia"\n}]'); 45 | }); 46 | 47 | it('should prettyPrint a complex object', () => { 48 | const obj = { 49 | id: 1, 50 | fullname: 'John smith', 51 | profile: 'https://mysocialnetwork.mydomain.com/johnsmith', 52 | isAvailable: false, 53 | extras: { 54 | languages: ['js', 'C', 'C++'], 55 | caution: '\nw00t//', 56 | }, 57 | }; 58 | const prettyPrintedObject = prettyPrint(obj); 59 | 60 | expect(prettyPrintedObject) 61 | .toStrictEqual(expect.stringContaining('"id": 1,')); 62 | expect(prettyPrintedObject) 63 | .toStrictEqual(expect.stringContaining('"fullname": "John smith",')); 64 | expect(prettyPrintedObject) 65 | .toStrictEqual( 66 | expect.stringContaining('"profile": "https://mysocialnetwork.mydomain.com/johnsmith",'), 67 | ); 68 | expect(prettyPrintedObject) 69 | .toStrictEqual(expect.stringContaining('"isAvailable": false,')); 70 | expect(prettyPrintedObject) 71 | .toStrictEqual(expect.stringContaining(' "languages": [\n "js",\n "C",\n "C++"\n ],')); 72 | expect(prettyPrintedObject) 73 | .toStrictEqual(expect.stringContaining(' "caution": "\\nw00t//"')); 74 | 75 | expect(JSON.parse(prettyPrintedObject)).toStrictEqual(obj); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /src/integrations/mixpanel/setup.js: -------------------------------------------------------------------------------- 1 | const { inject } = require('@forestadmin/context'); 2 | const _ = require('lodash'); 3 | const { pushIntoApimap } = require('../../utils/integrations'); 4 | 5 | const INTEGRATION_NAME = 'mixpanel'; 6 | 7 | exports.createCollections = (Implementation, apimap, collectionAndFieldName, options) => { 8 | const { modelsManager } = inject(); 9 | const model = modelsManager.getModels()[collectionAndFieldName.split('.')[0]]; 10 | const modelName = Implementation.getModelName(model); 11 | const collectionDisplayName = _.capitalize(modelName); 12 | 13 | const fields = [{ 14 | field: 'id', 15 | type: 'String', 16 | isVirtual: true, 17 | isFilterable: false, 18 | isSortable: false, 19 | }, { 20 | field: 'event', 21 | type: 'String', 22 | isVirtual: true, 23 | isFilterable: false, 24 | isSortable: false, 25 | }, { 26 | field: 'date', 27 | type: 'Date', 28 | isVirtual: true, 29 | isFilterable: false, 30 | isSortable: false, 31 | }, { 32 | field: 'city', 33 | type: 'String', 34 | isVirtual: true, 35 | isFilterable: false, 36 | isSortable: false, 37 | }, { 38 | field: 'region', 39 | type: 'String', 40 | isVirtual: true, 41 | isFilterable: false, 42 | isSortable: false, 43 | }, { 44 | field: 'country', 45 | type: 'String', 46 | isVirtual: true, 47 | isFilterable: false, 48 | isSortable: false, 49 | }, { 50 | field: 'timezone', 51 | type: 'String', 52 | isVirtual: true, 53 | isFilterable: false, 54 | isSortable: false, 55 | }, { 56 | field: 'os', 57 | type: 'String', 58 | isVirtual: true, 59 | isFilterable: false, 60 | isSortable: false, 61 | }, { 62 | field: 'osVersion', 63 | type: 'String', 64 | isVirtual: true, 65 | isFilterable: false, 66 | isSortable: false, 67 | }, { 68 | field: 'browser', 69 | type: 'String', 70 | isVirtual: true, 71 | isFilterable: false, 72 | isSortable: false, 73 | }, { 74 | field: 'browserVersion', 75 | type: 'String', 76 | isVirtual: true, 77 | isFilterable: false, 78 | isSortable: false, 79 | }]; 80 | 81 | if (options.integrations.mixpanel.customProperties) { 82 | // eslint-disable-next-line prefer-spread 83 | fields.push.apply( 84 | fields, 85 | options.integrations.mixpanel.customProperties.map((propertyName) => ({ 86 | field: propertyName, 87 | type: 'String', 88 | isVirtual: true, 89 | isFilterable: false, 90 | isSortable: false, 91 | })), 92 | ); 93 | } 94 | 95 | pushIntoApimap(apimap, { 96 | name: `${modelName}_mixpanel_events`, 97 | displayName: `${collectionDisplayName} Events`, 98 | icon: 'mixpanel', 99 | isVirtual: true, 100 | integration: INTEGRATION_NAME, 101 | isReadOnly: true, 102 | onlyForRelationships: true, 103 | paginationType: 'cursor', 104 | fields, 105 | }); 106 | }; 107 | 108 | exports.createFields = (implementation, model, schemaFields) => { 109 | schemaFields.push({ 110 | field: 'mixpanel_last_events', 111 | displayName: 'Last events', 112 | type: ['String'], 113 | reference: `${implementation.getModelName(model)}_mixpanel_events.id`, 114 | column: null, 115 | integration: INTEGRATION_NAME, 116 | }); 117 | }; 118 | -------------------------------------------------------------------------------- /src/context/build-services.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | const createForestAdminClient = require('@forestadmin/forestadmin-client').default; 3 | 4 | const loggerLevels = { 5 | Error: 0, 6 | Warn: 1, 7 | Info: 2, 8 | Debug: 3, 9 | }; 10 | 11 | function makeLogger({ env, logger }) { 12 | return (level, ...args) => { 13 | const loggerLevel = env.FOREST_LOGGER_LEVEL ?? 'Info'; 14 | 15 | if (loggerLevels[level] <= loggerLevels[loggerLevel]) { 16 | logger[level.toLowerCase()](...args); 17 | } 18 | }; 19 | } 20 | 21 | module.exports.makeLogger = makeLogger; 22 | 23 | module.exports.default = (context) => 24 | context 25 | .addInstance('logger', () => require('../services/logger')) 26 | .addUsingFunction('forestAdminClient', ({ env, forestUrl, logger }) => createForestAdminClient({ 27 | envSecret: env.FOREST_ENV_SECRET, 28 | forestServerUrl: forestUrl, 29 | logger: makeLogger({ env, logger }), 30 | instantCacheRefresh: false, 31 | })) 32 | .addInstance('chartHandler', ({ forestAdminClient }) => forestAdminClient.chartHandler) 33 | .addUsingClass('authorizationService', () => require('../services/authorization/authorization').default) 34 | .addUsingClass('actionAuthorizationService', () => require('../services/authorization/action-authorization').default) 35 | .addInstance('pathService', () => require('../services/path')) 36 | .addInstance('errorHandler', () => require('../services/exposed/error-handler')) 37 | .addInstance('ipWhitelist', () => require('../services/ip-whitelist')) 38 | .addInstance('forestServerRequester', () => require('../services/forest-server-requester')) 39 | .addInstance('schemasGenerator', () => require('../generators/schemas')) 40 | .addInstance('baseFilterParser', () => require('../services/base-filters-parser')) 41 | .addUsingClass('projectDirectoryFinder', () => require('../services/project-directory-finder')) 42 | .addUsingClass('configStore', () => require('../services/config-store')) 43 | .addUsingClass('apimapFieldsFormater', () => require('../services/apimap-fields-formater')) 44 | .addUsingClass('authorizationFinder', () => require('../services/authorization-finder')) 45 | .addUsingClass('apimapSorter', () => require('../services/apimap-sorter')) 46 | .addUsingClass('apimapSender', () => require('../services/apimap-sender')) 47 | .addUsingClass('schemaFileUpdater', () => require('../services/schema-file-updater')) 48 | .addUsingClass('scopeManager', () => require('../services/scope-manager')) 49 | .addUsingClass('modelsManager', () => require('../services/models-manager')) 50 | .addUsingClass('tokenService', () => require('../services/token')) 51 | .addUsingClass('oidcConfigurationRetrieverService', () => require('../services/oidc-configuration-retriever')) 52 | .addUsingClass('oidcClientManagerService', () => require('../services/oidc-client-manager')) 53 | .addUsingClass('authenticationService', () => require('../services/authentication')) 54 | .addUsingClass('smartActionFieldValidator', () => require('../services/smart-action-field-validator')) 55 | .addUsingClass('smartActionFormLayoutService', () => require('../services/smart-action-form-layout-service')) 56 | .addUsingClass('smartActionHookService', () => require('../services/smart-action-hook-service')) 57 | .addUsingClass('smartActionHookDeserializer', () => require('../deserializers/smart-action-hook')); 58 | -------------------------------------------------------------------------------- /src/integrations/intercom/setup.js: -------------------------------------------------------------------------------- 1 | const { inject } = require('@forestadmin/context'); 2 | const _ = require('lodash'); 3 | const { pushIntoApimap } = require('../../utils/integrations'); 4 | 5 | const INTEGRATION_NAME = 'intercom'; 6 | 7 | exports.createCollections = (Implementation, apimap, collectionName) => { 8 | const { modelsManager } = inject(); 9 | const collectionDisplayName = _.capitalize(collectionName); 10 | const model = modelsManager.getModels()[collectionName]; 11 | // jshint camelcase: false 12 | pushIntoApimap(apimap, { 13 | name: `${Implementation.getModelName(model)}_intercom_conversations`, 14 | displayName: `${collectionDisplayName} Conversations`, 15 | icon: 'intercom', 16 | integration: INTEGRATION_NAME, 17 | onlyForRelationships: true, 18 | isVirtual: true, 19 | isReadOnly: true, 20 | fields: [ 21 | { field: 'subject', type: 'String' }, 22 | { field: 'body', type: ['String'] }, 23 | { field: 'open', type: 'Boolean' }, 24 | { field: 'read', type: 'Boolean' }, 25 | { field: 'assignee', type: 'String' }, 26 | ], 27 | }); 28 | 29 | pushIntoApimap(apimap, { 30 | name: `${Implementation.getModelName(model)}_intercom_attributes`, 31 | displayName: `${collectionDisplayName} Attributes`, 32 | icon: 'intercom', 33 | integration: INTEGRATION_NAME, 34 | onlyForRelationships: true, 35 | isVirtual: true, 36 | isReadOnly: true, 37 | fields: [ 38 | { field: 'email', type: 'String', isFilterable: false }, 39 | { field: 'name', type: 'String', isFilterable: false }, 40 | { field: 'role', type: 'String', isFilterable: false }, 41 | { field: 'companies', type: ['String'], isFilterable: false }, 42 | { field: 'tags', type: ['String'], isFilterable: false }, 43 | { field: 'platform', type: 'String', isFilterable: false }, 44 | { field: 'browser', type: 'String', isFilterable: false }, 45 | { field: 'city', type: 'String', isFilterable: false }, 46 | { field: 'country', type: 'String', isFilterable: false }, 47 | { field: 'signed_up_at', type: 'Date', isFilterable: false }, 48 | { field: 'last_request_at', type: 'Date', isFilterable: false }, 49 | { field: 'last_seen_at', type: 'Date', isFilterable: false }, 50 | { field: 'last_replied_at', type: 'Date', isFilterable: false }, 51 | { field: 'last_contacted_at', type: 'Date', isFilterable: false }, 52 | { field: 'last_email_opened_at', type: 'Date', isFilterable: false }, 53 | { field: 'last_email_clicked_at', type: 'Date', isFilterable: false }, 54 | { field: 'created_at', type: 'Date', isFilterable: false }, 55 | { field: 'updated_at', type: 'Date', isFilterable: false }, 56 | ], 57 | }); 58 | }; 59 | 60 | exports.createFields = (implementation, model, schemaFields) => { 61 | schemaFields.push({ 62 | field: 'intercom_conversations', 63 | type: ['String'], 64 | reference: `${implementation.getModelName(model) 65 | }_intercom_conversations.id`, 66 | column: null, 67 | isFilterable: false, 68 | integration: INTEGRATION_NAME, 69 | isVirtual: true, 70 | }); 71 | 72 | schemaFields.push({ 73 | field: 'intercom_attributes', 74 | type: 'String', 75 | reference: `${implementation.getModelName(model) 76 | }_intercom_attributes.id`, 77 | column: null, 78 | isFilterable: false, 79 | integration: INTEGRATION_NAME, 80 | isVirtual: true, 81 | }); 82 | }; 83 | --------------------------------------------------------------------------------