├── .dockerignore ├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.md │ ├── config.yml │ └── feature-request.md ├── dependabot.yml ├── pull_request_template.md └── workflows │ └── dependabot-auto-approve.yml ├── .gitignore ├── .gitlab-ci.yml ├── .mocharc.js ├── .prettierignore ├── .prettierrc.js ├── .vscode └── launch.json ├── @types └── knex-stringcase │ └── index.d.ts ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── RELEASE.md ├── docker-compose.yml ├── img └── paystring-logo-color.png ├── lefthook.yml ├── nyc.config.js ├── package-lock.json ├── package.json ├── scripts ├── version │ ├── bump.sh │ ├── compare.sh │ └── version.sh └── wait-for-postgres.sh ├── src ├── app.ts ├── config.ts ├── data-access │ ├── payIds.ts │ ├── reports.ts │ └── users.ts ├── db │ ├── extensions │ │ └── 01_pgcrypto.sql │ ├── functions │ │ └── 01_set_updated_at.sql │ ├── knex.ts │ ├── migrations │ │ ├── 01_drop_organization_from_account.sql │ │ ├── 02_change_pay_id_format_constraint.sql │ │ ├── 03_pay_id_lowercase_constraint.sql │ │ ├── 04_address_info_uppercase_constraints.sql │ │ ├── 05_increase_payment_network_character_limit.sql │ │ ├── 06_alter_payid_regex.sql │ │ ├── 07_add_identity_key.sql │ │ └── 08_add_identity_key_signature.sql │ ├── schema │ │ ├── 01_account.sql │ │ └── 02_address.sql │ ├── seed │ │ ├── payid_regex_examples.sql │ │ └── seeded_values_for_testing.sql │ ├── syncDatabaseSchema.ts │ └── triggers │ │ ├── 01_account_before_update.sql │ │ └── 02_address_before_update.sql ├── discoveryLinks.json ├── hooks │ └── memo.ts ├── html │ ├── favicon.ico │ └── index.html ├── index.ts ├── middlewares │ ├── adminApiHeaders.ts │ ├── checkPublicApiVersionHeaders.ts │ ├── constructJrd.ts │ ├── errorHandler.ts │ ├── initializeMetrics.ts │ ├── payIds.ts │ ├── sendSuccess.ts │ └── users.ts ├── routes │ ├── adminApiRouter.ts │ ├── index.ts │ ├── metricsRouter.ts │ └── publicApiRouter.ts ├── services │ ├── basePayId.ts │ ├── headers.ts │ ├── metrics.ts │ ├── urls.ts │ └── users.ts ├── types │ ├── database.ts │ ├── headers.ts │ └── protocol.ts └── utils │ ├── errors │ ├── contentTypeError.ts │ ├── databaseError.ts │ ├── handleHttpError.ts │ ├── index.ts │ ├── lookupError.ts │ ├── parseError.ts │ └── payIdError.ts │ └── logger.ts ├── test ├── global.test.ts ├── helpers │ └── helpers.ts ├── integration │ ├── data-access │ │ ├── databaseErrors.test.ts │ │ ├── payIdRegex.test.ts │ │ ├── payIds.test.ts │ │ └── reports.test.ts │ ├── e2e │ │ ├── admin-api │ │ │ ├── deleteUsers.test.ts │ │ │ ├── getUsers.test.ts │ │ │ ├── healthCheck.test.ts │ │ │ ├── metrics.test.ts │ │ │ ├── optionsUsersPayId.test.ts │ │ │ ├── patchUsersPayId.test.ts │ │ │ ├── postUsers.test.ts │ │ │ ├── privateApiVersionHeader.test.ts │ │ │ └── putUsers.test.ts │ │ └── public-api │ │ │ ├── basePayId.test.ts │ │ │ ├── basePayIdContentNegotiation.test.ts │ │ │ ├── cacheControl.test.ts │ │ │ ├── discovery.test.ts │ │ │ ├── healthCheck.test.ts │ │ │ ├── payloads.ts │ │ │ ├── testDiscoveryLinks.json │ │ │ ├── verifiablePayId.test.ts │ │ │ ├── verifiablePayIdContentNegotiation.test.ts │ │ │ └── versionHeader.test.ts │ └── sql │ │ └── payidRegex.test.ts └── unit │ ├── constructUrl.test.ts │ ├── formatPaymentInfoBasePayId.test.ts │ ├── formatPaymentInfoVerifiablePayId.test.ts │ ├── getAddressDetailsType.test.ts │ ├── getPreferredAddressInfo.test.ts │ ├── parseAcceptHeader.test.ts │ ├── parseAcceptHeaders.test.ts │ └── urlToPayId.test.ts ├── tsconfig.eslint.json └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | # In .dockerignore 2 | # Exclude everything, the image build will be faster. 3 | # 4 | * 5 | 6 | # Now un-exclude the required files/directory to run PayID 7 | # 8 | !@types 9 | !package.json 10 | !package-lock.json 11 | !scripts 12 | !src 13 | !test 14 | !tsconfig.eslint.json 15 | !tsconfig.json 16 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Do not lint generated files. 2 | src/generated 3 | build 4 | dist 5 | 6 | # Don't ever lint node_modules 7 | node_modules 8 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | 4 | parser: '@typescript-eslint/parser', // Make ESLint compatible with TypeScript 5 | parserOptions: { 6 | // Enable linting rules with type information from our tsconfig 7 | tsconfigRootDir: __dirname, 8 | project: ['./tsconfig.eslint.json'], 9 | 10 | sourceType: 'module', // Allow the use of imports / ES modules 11 | 12 | ecmaFeatures: { 13 | impliedStrict: true, // Enable global strict mode 14 | }, 15 | }, 16 | 17 | // Specify global variables that are predefined 18 | env: { 19 | node: true, // Enable node global variables & Node.js scoping 20 | es2020: true, // Add all ECMAScript 2020 globals and automatically set the ecmaVersion parser option to ES2020 21 | }, 22 | 23 | plugins: ['import', 'node'], 24 | 25 | extends: ['@xpring-eng/eslint-config-mocha', '@xpring-eng/eslint-config-base/loose'], 26 | 27 | rules: { 28 | // We explicitly use `process.exit()` because all other errors should really be handled. 29 | 'no-process-exit': 'off', 30 | 'node/no-process-exit': 'off', 31 | 32 | // TODO:(hbergren) These are all rules we should remove eventually 33 | 'import/max-dependencies': ['warn', { max: 9 }], 34 | 'max-lines-per-function': ['warn', { max: 86 }], 35 | 'max-statements': ['warn', { max: 26 }], 36 | complexity: ['warn', { max: 12 }], 37 | }, 38 | 39 | // TODO:(hbergren) Should be able tor remove these overrides when we remove the above rules 40 | overrides: [ 41 | { 42 | files: ['test/**/*.ts'], 43 | rules: { 44 | // Warn when modules have too many dependencies (code smell) 45 | // Increased the max for test files and test helper files, since tests usually need to import more things 46 | 'import/max-dependencies': ['warn', { max: 8 }], 47 | 48 | // describe blocks count as a function in Mocha tests, and can be insanely long 49 | 'max-lines-per-function': 'off', 50 | 51 | // TODO:(hbergren) We should probably cut this override eventually 52 | 'max-lines': ['warn', {max: 500}], 53 | }, 54 | }, 55 | ], 56 | } 57 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behavior to automatically normalize line endings. 2 | # This will treat all files as text files, 3 | # and convert to OS's line ending on checkout and back to LF on commit automatically. 4 | * text=auto 5 | 6 | # Behavior for images and documents, which are treated as binary and shouldn't be normalized. 7 | *.jpg binary 8 | *.jpeg binary 9 | *.png binary 10 | *.gif binary 11 | *.ico binary 12 | *.pdf binary 13 | 14 | # Force shell scripts to always use LF line endings. 15 | # Necessary because we run our shell scripts in Docker UNIX containers. 16 | *.sh text eol=lf 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: File a bug report to help us improve! 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | 12 | ## Expected Behavior 13 | 14 | 15 | 16 | ## Actual Behavior 17 | 18 | 19 | 20 | ## Context 21 | 22 | 23 | 24 | 25 | ## Potential Solution 26 | 27 | 28 | 29 | ## Steps to Reproduce 30 | 31 | 1. Step one 32 | 2. Step two 33 | 3. Step three 34 | 35 | ## Environment 36 | 37 | - Node version: 38 | - NPM version: 39 | - Operating System and version: 40 | - PayID server version: 41 | - PayID Version header (if applicable): 42 | 43 | ### Screenshots 44 | 45 | If applicable, add screenshots to help explain your problem. 46 | 47 | ## Bonus 48 | 49 | **Are you willing to submit a pull request to fix this bug?** 50 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: PayID Protocol Change 4 | url: https://github.com/payid-org/rfcs/tree/master 5 | about: Please submit an RFC here. 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | ## Detailed Description 12 | 13 | 14 | 15 | ## Context 16 | 17 | 18 | 19 | 20 | ## Possible Implementation 21 | 22 | 23 | 24 | ### Alternatives Considered 25 | 26 | 27 | 28 | ## Additional Context 29 | 30 | 31 | 32 | ## Bonus 33 | 34 | **Are you willing to submit a pull request to implement this change?** 35 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Dependabot configuration 2 | # https://help.github.com/en/github/administering-a-repository/configuration-options-for-dependency-updates 3 | version: 2 4 | updates: 5 | # GitHub Actions 6 | - package-ecosystem: 'github-actions' 7 | directory: '/' 8 | schedule: 9 | interval: 'weekly' 10 | 11 | # NPM Dependencies 12 | - package-ecosystem: 'npm' 13 | directory: '/' 14 | schedule: 15 | interval: 'weekly' 16 | versioning-strategy: increase 17 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## High Level Overview of Change 2 | 3 | 8 | 9 | ### Context of Change 10 | 11 | 19 | 20 | ### Type of Change 21 | 22 | 25 | 26 | - [ ] Bug fix (non-breaking change which fixes an issue) 27 | - [ ] New feature (non-breaking change which adds functionality) 28 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 29 | - [ ] Refactor (non-breaking change that only restructures code) 30 | - [ ] Tests (You added tests for code that already exists, or your new feature included in this PR) 31 | - [ ] Documentation Updates 32 | - [ ] Release 33 | 34 | ## Before / After 35 | 36 | 40 | 41 | ## Test Plan 42 | 43 | 46 | 47 | 51 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-approve.yml: -------------------------------------------------------------------------------- 1 | name: Auto-Approve Dependabot PRs 2 | on: 3 | pull_request: 4 | branches: [ master ] 5 | 6 | # Runs a job to auto-approve PRs made by dependabot so 7 | # they can be auto-merged by dependabot 8 | jobs: 9 | dependabot-auto-approve: 10 | runs-on: ubuntu-latest 11 | 12 | # Run the auto-approve script twice (needs 2 reviewers) 13 | steps: 14 | - uses: hmarr/auto-approve-action@v2.0.0 15 | if: github.actor == 'dependabot[bot]' || github.actor == 'dependabot-preview[bot]' 16 | with: 17 | github-token: "${{ secrets.GITHUB_TOKEN }}" 18 | - uses: hmarr/auto-approve-action@v2.0.0 19 | if: github.actor == 'dependabot[bot]' || github.actor == 'dependabot-preview[bot]' 20 | with: 21 | github-token: "${{ secrets.GITHUB_TOKEN }}" 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/osx,node 3 | # Edit at https://www.gitignore.io/?templates=osx,node 4 | 5 | ### Node ### 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | *.lcov 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Anything in the build directory 43 | build/ 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # TypeScript v1 declaration files 50 | typings/ 51 | 52 | # TypeScript cache 53 | *.tsbuildinfo 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Optional REPL history 62 | .node_repl_history 63 | 64 | # Output of 'npm pack' 65 | *.tgz 66 | 67 | # Yarn Integrity file 68 | .yarn-integrity 69 | 70 | # dotenv environment variables file 71 | .env 72 | .env.test 73 | 74 | # parcel-bundler cache (https://parceljs.org/) 75 | .cache 76 | 77 | # next.js build output 78 | .next 79 | 80 | # nuxt.js build output 81 | .nuxt 82 | 83 | # rollup.js default build output 84 | dist/ 85 | 86 | # Uncomment the public line if your project uses Gatsby 87 | # https://nextjs.org/blog/next-9-1#public-directory-support 88 | # https://create-react-app.dev/docs/using-the-public-folder/#docsNav 89 | # public 90 | 91 | # Storybook build outputs 92 | .out 93 | .storybook-out 94 | 95 | # vuepress build output 96 | .vuepress/dist 97 | 98 | # Serverless directories 99 | .serverless/ 100 | 101 | # FuseBox cache 102 | .fusebox/ 103 | 104 | # DynamoDB Local files 105 | .dynamodb/ 106 | 107 | # Temporary folders 108 | tmp/ 109 | temp/ 110 | 111 | ### OSX ### 112 | # General 113 | .DS_Store 114 | .AppleDouble 115 | .LSOverride 116 | 117 | # Icon must end with two \r 118 | Icon 119 | 120 | # Thumbnails 121 | ._* 122 | 123 | # Files that might appear in the root of a volume 124 | .DocumentRevisions-V100 125 | .fseventsd 126 | .Spotlight-V100 127 | .TemporaryItems 128 | .Trashes 129 | .VolumeIcon.icns 130 | .com.apple.timemachine.donotpresent 131 | 132 | # Directories potentially created on remote AFP share 133 | .AppleDB 134 | .AppleDesktop 135 | Network Trash Folder 136 | Temporary Items 137 | .apdisk 138 | 139 | # IntelliJ nonsense 140 | /.idea 141 | 142 | # Lefthook 143 | /.lefthook-local 144 | lefthook-local.yml 145 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - test 3 | 4 | variables: 5 | POSTGRES_USER: postgres 6 | POSTGRES_PASSWORD: password 7 | POSTGRES_DB: database_development 8 | POSTGRES_HOST_AUTH_METHOD: trust 9 | DB_HOSTNAME: postgres 10 | 11 | services: 12 | - postgres:12 13 | 14 | lint: 15 | stage: test 16 | image: 17 | name: node:12 18 | before_script: 19 | - rm -rf .npm 20 | - npm i --cache .npm --prefer-offline --no-audit --progress=false 21 | script: 22 | - npm run lintNoFix 23 | 24 | code coverage: 25 | stage: test 26 | image: 27 | name: node:12 28 | before_script: 29 | - rm -rf .npm 30 | - npm i --cache .npm --no-audit --progress=false --prefer-offline -g nyc codecov 31 | - npm i --cache .npm --no-audit --progress=false --prefer-offline 32 | script: 33 | - npm run build 34 | - nyc npm test 35 | - mkdir coverage 36 | - nyc report --reporter=text-lcov > coverage/coverage.json 37 | - codecov 38 | 39 | link checker: 40 | stage: test 41 | image: 42 | name: golang:1.14-alpine 43 | before_script: 44 | - apk add git 45 | - export GO111MODULE=on 46 | - go get -u github.com/raviqqe/liche 47 | script: 48 | - liche -r ${CI_PROJECT_DIR} 49 | -------------------------------------------------------------------------------- /.mocharc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | require: [ 5 | 'ts-node/register', 6 | 'source-map-support/register', 7 | ], 8 | extension: [ 9 | 'ts' 10 | ], 11 | 12 | // Do not look for mocha opts file 13 | opts: false, 14 | 15 | // Warn if test exceed 75ms duration 16 | slow: 75, 17 | 18 | // Fail if tests exceed 10000ms 19 | timeout: 10000, 20 | 21 | // Check for global variable leaks 22 | 'check-leaks': true, 23 | } 24 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Do not lint generated files. 2 | src/generated 3 | build 4 | dist 5 | .nyc_output 6 | 7 | # Don't ever lint node_modules and NPM stuff 8 | .npm 9 | node_modules 10 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tabWidth: 2, 3 | printWidth: 80, 4 | singleQuote: true, 5 | semi: false, 6 | trailingComma: 'all', 7 | arrowParens: 'always', 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch PayID Server", 11 | "skipFiles": ["/**", "node_modules/**/*.js"], 12 | "program": "${workspaceFolder}/build/src/index.js", 13 | "preLaunchTask": "tsc: build - tsconfig.json", 14 | "outFiles": ["${workspaceFolder}/build/**/*.js"] 15 | }, 16 | { 17 | "type": "node", 18 | "request": "launch", 19 | "name": "Mocha All", 20 | "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", 21 | "skipFiles": ["/**", "node_modules/**/*.js"], 22 | "args": [ 23 | "--timeout", 24 | "999999", 25 | "--colors", 26 | "--config", 27 | "${workspaceFolder}/.mocharc.js", 28 | "${workspaceFolder}/test/**/*.test.ts" 29 | ], 30 | "console": "integratedTerminal", 31 | "internalConsoleOptions": "neverOpen" 32 | }, 33 | { 34 | "type": "node", 35 | "request": "launch", 36 | "name": "Mocha Current File", 37 | "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", 38 | "skipFiles": ["/**", "node_modules/**/*.js"], 39 | "args": [ 40 | "--timeout", 41 | "999999", 42 | "--colors", 43 | "--config", 44 | "${workspaceFolder}/.mocharc.js", 45 | "${file}" 46 | ], 47 | "console": "integratedTerminal", 48 | "internalConsoleOptions": "neverOpen" 49 | } 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /@types/knex-stringcase/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'knex-stringcase' 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to PayID 2 | 3 | :tada: First off, thanks for taking the time to contribute! :tada: 4 | 5 | The following is a set of guidelines for contributing to PayID and associated packages hosted in the [PayID Organization](https://github.com/payid-org) 6 | 7 | - [Code of Conduct](#code-of-conduct) 8 | - [Issue Reporting Guidelines](#issue-reporting-guidelines) 9 | - [Reporting Vulnerabilities](#reporting-vulnerabilities) 10 | - [Pull Request Guidelines](#pull-request-guidelines) 11 | - [Adding New Features](#adding-new-features) 12 | - [Fixing Bugs](#fixing-bugs) 13 | - [Versioning](#versioning) 14 | - [Development Setup](#development-setup) 15 | - [Commonly Used NPM Scripts](#commonly-used-npm-scripts) 16 | 17 | ## Code of Conduct 18 | 19 | This project and all participants are governed by the [PayID Code of Conduct](https://github.com/payid-org/.github/blob/master/CODE_OF_CONDUCT.md). 20 | 21 | ## Issue Reporting Guidelines 22 | 23 | If you've found a bug or want to request a new feature, please [create a new issue](https://github.com/payid-org/payid/issues/new) or a [pull request](https://github.com/payid-org/payid/compare) on GitHub. 24 | 25 | Please include as much detail as possible to help us properly address your issue. If we need to triage issues and constantly ask people for more detail, that's time taken away from actually fixing issues. Help us be as efficient as possible by including a lot of detail in your issues. 26 | 27 | ### Reporting Vulnerabilities 28 | 29 | If you have found a vulnerability, please read our [Vulnerability Disclosure Policy](https://github.com/payid-org/.github/blob/master/SECURITY.md) to learn how to responsibly report the vulnerability. 30 | 31 | ## Pull Request Guidelines 32 | 33 | - We believe in small PRs. An ideal PR is <= 250 lines of code, so it can be easily reviewed. 34 | - Not all functionality can fit in a 250 LoC PR. For those cases, we use [Stacked PRs](https://unhashable.com/stacked-pull-requests-keeping-github-diffs-small/). This lets each standalone slice of functionality be easily reviewed, while clearly showing the relationship between features. Writing small PRs in a stack also lets you get approval for important architecture/design decisions before writing 2000 lines of code of functionality based on your initial assumptions. 35 | - All PRs should be made against the `master` branch, unless stacking. 36 | - It's ok to have multiple small commits as you work on the PR - Github will automatically squash your commits before merging. 37 | 38 | ### Adding New Features 39 | 40 | - Add accompanying test cases. 41 | - Provide a convincing reason to add this feature. Ideally, you should open a suggestion issue first and have it approved before beginning work. 42 | 43 | ### Fixing Bugs 44 | 45 | - Provide a detailed description of the bug in the PR. 46 | - Add one or more regression tests. 47 | 48 | ## Versioning 49 | 50 | 51 | 52 | The PayID reference implementation itself follows [Semantic Versioning](https://semver.org/), and this is the version that is referenced by [Github Releases](https://github.com/payid-org/payid/releases) and in the [package.json](./package.json) file. 53 | 54 | The PayID Protocol itself is versioned in a `{major}.{minor}` version, and this will be referenced in the CHANGELOG and Github Releases where appropriate. In the codebase, the supported protocol versions live in [config.ts](./src/config.ts). 55 | 56 | The RESTful CRUD API for interacting with PayIDs on the server is versioned as well, using a `YYYY-MM-DD` version, as many other RESTful APIs use for their version headers. This version will be referenced in the CHANGELOG and Github Releases where appropriate as well, and also lives in [config.ts](./src/config.ts). 57 | 58 | ## Development Setup 59 | 60 | You will need [NodeJS](https://nodejs.org/en/) v12 or higher, and [npm](https://www.npmjs.com/get-npm). 61 | 62 | You will also need a local Postgres database running. The specifics can be set as environment variables read in [config.ts](./src/config.ts). 63 | 64 | After cloning the repo, run: 65 | 66 | ```sh 67 | $ npm install # To install dependencies of the project 68 | ``` 69 | 70 | ### Commonly Used NPM Scripts 71 | 72 | ```sh 73 | # Boot up the Node.js / Express PayID server locally 74 | $ npm run start 75 | 76 | # Run the full test suite 77 | $ npm run test 78 | 79 | # Lint the code, auto-fixing any auto-fixable problems 80 | $ npm run lint 81 | 82 | # Boot up a development database 83 | # (A Postgres database in a Docker container configured to run with PayID) 84 | $ npm run devDbUp 85 | 86 | # Boot up a full development environment 87 | # (A database & PayID server in 2 separate Docker containers) 88 | $ npm run devEnvUp 89 | 90 | # Bring down the development environment 91 | $ npm run devDown 92 | ``` 93 | 94 | There are other scripts available in the `scripts` section of the [package.json](./package.json) file. 95 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12-alpine 2 | 3 | LABEL maintainer="Florent Uzio " 4 | 5 | WORKDIR /opt 6 | 7 | RUN mkdir payid 8 | 9 | WORKDIR /opt/payid 10 | 11 | COPY . . 12 | 13 | # create a group and user 14 | RUN addgroup -S appgroup && adduser -S appuser -G appgroup 15 | 16 | # postgresql-client is needed if/when we run "wait-for-postgres.sh" (in ./scripts) to make sure Postgres is ready to execute SQL scripts. 17 | RUN apk --no-cache add postgresql-client~=12 &&\ 18 | npm cache clean --force &&\ 19 | npm install &&\ 20 | npm run build 21 | 22 | EXPOSE 8080 8081 23 | 24 | # run all future commands as this user 25 | USER appuser 26 | 27 | CMD ["node", "/opt/payid/build/src/index.js"] 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [PayString](https://www.paystring.org/) 2 | 3 | [![License: Apache 2](https://img.shields.io/badge/license-Apache%202-brightgreen)](https://github.com/PayString/paystring/blob/master/LICENSE) 4 | [![Public API Version: 1.1](https://img.shields.io/badge/Public%20API%20Version-1.1-blue)](https://github.com/PayString/paystring/blob/master/src/config.ts#L1) 5 | [![Admin API Version: 2020-08-28](https://img.shields.io/badge/Admin%20API%20Version-2020--08--25-blue)](https://github.com/PayString/paystring/blob/master/src/config.ts#L2) 6 | 7 | Welcome to PayString, the universal payment identifier. 8 | 9 | _This project is not associated with PayID operated by NPP Australia Ltd. People in Australia are prohibited from using this project. See below for more details._ 10 | 11 | This is the reference implementation server for [PayString](https://docs.paystring.org/getting-started), serving the [PayString API](https://api.paystring.org/?version=latest). It uses TypeScript, a Node.js HTTP server, and a PostgreSQL database. 12 | 13 | By default, the server hosts the Public API, which conforms to the PayString Protocol, on port 8080. The server also hosts a second RESTful API on port 8081 for CRUD (Create/Read/Update/Delete) operations to manage PayStrings and associated addresses. 14 | 15 | To experiment with PayString, you can start a local server by running `npm run devEnvUp`, which uses our local [`docker-compose.yml`](./docker-compose.yml) file, which implicitly starts both a database and a PayString server inside Docker containers. To work on the PayString server source code itself, you can start a PostgreSQL database to develop against by running `npm run devDbUp`, which starts a database in a Docker container, and a local PayString server. 16 | 17 | To clean up the associated Docker containers after you create a local server or database container, run `npm run devDown`. 18 | 19 | ## Further Reading 20 | 21 | - [PayString Developer Documentation](https://docs.paystring.org/getting-started) 22 | - [PayString API Documentation](https://api.paystring.org/?version=latest) 23 | - [Core PayString Protocol RFCs](https://github.com/PayString/rfcs) 24 | 25 | ## Legal 26 | 27 | By using, reproducing, or distributing this code, you agree to the terms and conditions for use (including the Limitation of Liability) in the [Apache License 2.0](https://github.com/PayString/paystring/blob/master/LICENSE). If you do not agree, you may not use, reproduce, or distribute the code. **This code is not authorised for download in Australia. Any persons located in Australia are expressly prohibited from downloading, using, reproducing or distributing the code.** This code is not owned by, or associated with, NPP Australia Limited, and has no sponsorship, affiliation or other connection with the “Pay ID” service operated by NPP Australia Limited in Australia. 28 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # PayID Server Release Process 2 | 3 | ## Cutting a Release 4 | 5 | The full process for cutting a release is as follows: 6 | 7 | 8 | 9 | 10 | 11 | 1. Checkout a new branch: 12 | `git checkout -b v1.4-release` 13 | 14 | 2. Run the bump script locally on that branch (the working directory must be clean): 15 | `npm run bump minor` 16 | 17 | 3. Commit the changes, push up the branch, and open a PR: 18 | `git commit package.json package-lock.json` 19 | `git push --set-upstream origin HEAD` 20 | `hub pull-request` 21 | 22 | 4. Once the PR is merged, checkout the `master` branch: 23 | `git checkout master` 24 | 25 | 5. Make a new Git tag that matches the new NPM version (make sure it is associated with the right commit SHA): 26 | `git tag -a v1.4 f34dcd3` 27 | 28 | 6. Push up the tag from `master`: 29 | `git push origin v1.4` 30 | 31 | 7. Cut a release draft: 32 | `npm run release` 33 | 34 | 8. Fill in the release details and publish it in GitHub. 35 | 36 | ## NPM Scripts Reference 37 | 38 | ### bump 39 | 40 | To compare Git tag & NPM version, and bump the NPM version, run: 41 | 42 | `npm run bump ` ( where `` = `major`, `minor`, or `patch` ) 43 | 44 | ### release 45 | 46 | To create a release draft from the command line, run: 47 | 48 | `npm run release` ( you may need to setup `hub` locally ) 49 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # docker-compose.yml 2 | # Usage: 3 | # export PAYID_DOCKER_IMAGE_VERSION=$(node -p "require('./package.json').version") 4 | # docker-compose up -d --build 5 | version: '3' 6 | services: 7 | payid_server: 8 | # Defines the container name when using "docker-compose up". Warning: does not work with "docker-compose run" (Docker issue) 9 | container_name: 'payid-server' 10 | # Defines the image name and tag. 11 | image: payid:${PAYID_DOCKER_IMAGE_VERSION} 12 | # As we define the build context, the image is not pulled from Dockerhub. It is built locally based on our Dockerfile. 13 | build: 14 | context: . 15 | # Port mapping, external_port:internal_port 16 | ports: 17 | - '8080:8080' 18 | - '8081:8081' 19 | # The payid container can't run alone. A Postgres container will always be created thanks to "depends_on" 20 | depends_on: 21 | - 'db' 22 | environment: 23 | # Used in wait-for-postgres.sh and by payid. The value must be the same as the POSTGRES_PASSWORD value in the db service. 24 | - DB_PASSWORD=password 25 | # Uncomment DB_NAME if you change POSTGRES_DB in the "db" service below 26 | #- DB_NAME=database_development 27 | # DB_HOSTNAME is used by wait-for-postgres.sh and payid to point at the right host (default is 127.0.0.1 which would fail) 28 | - DB_HOSTNAME=db 29 | # uncomment DB_USERNAME if POSTGRES_USER in the db service is uncommented and different than "postgres". DB_USERNAME and POSTGRES_USER must have the same values 30 | #- DB_USERNAME=postgres 31 | # "command" overrides CMD in the Dockerfile to use wait-for-postgres.sh and make sure Postgres is ready to execute SQL scripts 32 | # If we don't do this check, we will end up seeing error messages like: 33 | # psql: error: could not connect to server: could not connect to server: Connection refused 34 | command: 35 | [ 36 | '/opt/payid/scripts/wait-for-postgres.sh', 37 | 'db', 38 | 'node', 39 | '/opt/payid/build/src/index.js', 40 | ] 41 | db: 42 | container_name: payid-database 43 | # Image pulled from Dockerhub 44 | image: postgres:12-alpine 45 | # external_port:internal_port 46 | ports: 47 | - '${DB_PORT:-5432}:5432' 48 | environment: 49 | # POSTGRES_PASSWORD is mandatory. If POSTGRES_PASSWORD != password, update DB_PASSWORD in the payid service 50 | - POSTGRES_PASSWORD=password 51 | # If POSTGRES_DB != database_development, change DB_NAME in the payid service above 52 | - POSTGRES_DB=database_development 53 | # Uncomment if you want to change the Postgres user (in that case, change DB_USERNAME in the payid service). Default value: postgres 54 | #- POSTGRES_USER=postgres 55 | -------------------------------------------------------------------------------- /img/paystring-logo-color.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PayString/paystring/5fb44206891c005ab7114ecf937e7ced7cc1162b/img/paystring-logo-color.png -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | # Refer to following link for examples and full documentation: 2 | # https://github.com/Arkweid/lefthook/blob/master/docs/full_guide.md 3 | # 4 | pre-push: 5 | parallel: true 6 | commands: 7 | # Disable audits 8 | # packages-audit: 9 | # tags: security 10 | # run: npm audit 11 | eslint: 12 | tags: style 13 | run: npm run lintNoFix 14 | test: 15 | tags: test 16 | run: npm run test 17 | tsc: 18 | tags: typescript 19 | run: tsc --noEmit --incremental false 20 | 21 | pre-commit: 22 | parallel: true 23 | commands: 24 | eslint: 25 | glob: "*.ts" 26 | run: npx eslint {staged_files} 27 | -------------------------------------------------------------------------------- /nyc.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = { 4 | extension: ['.ts', '.tsx'], 5 | include: ['src/**/*.ts'], 6 | 7 | // Instrument all files, not just ones touched by test suite 8 | all: true, 9 | 10 | // Check if coverage is within thresholds. 11 | // TODO: Enable coverage requirements? 12 | // 'check-coverage': true, 13 | // branches: 80, 14 | // lines: 80, 15 | // functions: 80, 16 | // statements: 80, 17 | 18 | // Controls color highlighting for tests. 19 | // >= 95% is green, 80-95 is yellow, and < 80 is red. 20 | watermarks: { 21 | lines: [80, 95], 22 | functions: [80, 95], 23 | branches: [80, 95], 24 | statements: [80, 95], 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "payid", 3 | "version": "1.4.0", 4 | "description": "The PayID Server reference implementation", 5 | "keywords": [], 6 | "homepage": "https://github.com/payid-org/payid#readme", 7 | "bugs": { 8 | "url": "https://github.com/payid-org/payid/issues" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/payid-org/payid.git" 13 | }, 14 | "license": "Apache-2.0", 15 | "author": "", 16 | "main": "./build/src/index.js", 17 | "scripts": { 18 | "build": "run-script-os", 19 | "build:default": "rm -rf build && tsc --project . && npm run copySQL && npm run copyHTML && npm run copyJSON && npm run copyPackageJSON", 20 | "build:win32": "del /q /f build && tsc --project . && npm run copySQL && npm run copyHTML && npm run copyJSON && npm run copyPackageJSON", 21 | "buildWatch": "tsc --watch --project .", 22 | "copyHTML": "copyfiles -u 1 src/html/* build/src", 23 | "copySQL": "copyfiles -u 1 **/*.sql build/src", 24 | "copyJSON": "copyfiles -u 1 src/**/*.json build/src", 25 | "copyPackageJSON": "copyfiles package.json build", 26 | "lint": "eslint . --ext .ts --fix --max-warnings 0 && prettier --write '**/*.{md,json}'", 27 | "lintNoFix": "eslint . --ext .ts --max-warnings 0 && prettier --check '**/*.{md,json}'", 28 | "lintWatch": "chokidar src/**/*.ts -c \"npm run lint\" --initial --verbose", 29 | "prestart": "npm run build", 30 | "start": "node build/src/index.js", 31 | "startWatch": "tsc-watch --onSuccess 'npm run start'", 32 | "test": "nyc mocha 'test/**/*.test.ts'", 33 | "testWatch": "nyc mocha --watch 'test/**/*.test.ts'", 34 | "devEnvUp": "run-script-os", 35 | "devEnvUp:default": "PAYID_DOCKER_IMAGE_VERSION=$npm_package_version docker-compose up -d", 36 | "devEnvUp:win32": "set PAYID_DOCKER_IMAGE_VERSION=%npm_package_version%&&docker-compose up -d", 37 | "devDbUp": "PAYID_DOCKER_IMAGE_VERSION=$npm_package_version docker-compose up -d db", 38 | "devDown": "PAYID_DOCKER_IMAGE_VERSION=$npm_package_version docker-compose down", 39 | "bump": "./scripts/version/bump.sh $npm_package_version $1", 40 | "release": "hub release create -d -m $npm_package_version $npm_package_version" 41 | }, 42 | "dependencies": { 43 | "@hapi/boom": "^9.1.0", 44 | "@payid-org/server-metrics": "^1.1.0", 45 | "@xpring-eng/http-status": "^1.0.0", 46 | "@xpring-eng/logger": "^1.0.0", 47 | "express": "^4.17.1", 48 | "knex": "^0.21.5", 49 | "knex-stringcase": "^1.4.5", 50 | "pg": "8.2.1", 51 | "prom-client": "^12.0.0", 52 | "semver": "^7.3.2", 53 | "typescript": "^3.9.5" 54 | }, 55 | "devDependencies": { 56 | "@arkweid/lefthook": "^0.7.2", 57 | "@fintechstudios/eslint-plugin-chai-as-promised": "^3.0.2", 58 | "@types/chai": "^4.2.12", 59 | "@types/chai-as-promised": "^7.1.2", 60 | "@types/express": "^4.17.6", 61 | "@types/mocha": "^7.0.2", 62 | "@types/node": "^14.0.26", 63 | "@types/pg": "^7.14.4", 64 | "@types/semver": "^7.3.1", 65 | "@types/supertest": "^2.0.10", 66 | "@typescript-eslint/eslint-plugin": "^3.7.0", 67 | "@typescript-eslint/parser": "^3.8.0", 68 | "@xpring-eng/eslint-config-base": "^0.11.0", 69 | "@xpring-eng/eslint-config-mocha": "^1.0.0", 70 | "chai": "^4.2.0", 71 | "chai-as-promised": "^7.1.1", 72 | "chokidar": "^3.4.2", 73 | "chokidar-cli": "^2.1.0", 74 | "copyfiles": "^2.3.0", 75 | "eslint": "^7.6.0", 76 | "eslint-plugin-array-func": "^3.1.6", 77 | "eslint-plugin-eslint-comments": "^3.2.0", 78 | "eslint-plugin-import": "^2.22.0", 79 | "eslint-plugin-jsdoc": "^30.2.4", 80 | "eslint-plugin-mocha": "^7.0.1", 81 | "eslint-plugin-node": "^11.1.0", 82 | "eslint-plugin-prettier": "^3.1.4", 83 | "eslint-plugin-tsdoc": "^0.2.5", 84 | "mocha": "^8.1.1", 85 | "nyc": "^15.1.0", 86 | "prettier": "^2.0.5", 87 | "run-script-os": "^1.1.1", 88 | "supertest": "^4.0.2", 89 | "ts-node": "^8.10.2", 90 | "tsc-watch": "^4.2.9" 91 | }, 92 | "engines": { 93 | "node": ">=12.0.0", 94 | "npm": ">=6", 95 | "yarn": "please use npm" 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /scripts/version/bump.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source ./scripts/version/version.sh 4 | 5 | bump $1 $2 6 | -------------------------------------------------------------------------------- /scripts/version/compare.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source ./scripts/version/version.sh 4 | 5 | compare_versions $1 6 | -------------------------------------------------------------------------------- /scripts/version/version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Bumps the NPM version. 4 | # 5 | # $1 - The current NPM version. 6 | # $2 - The type of version bump (major, minor, patch). 7 | function bump() { 8 | declare -r current_version=$1 9 | declare -r bump_increment=$2 10 | 11 | if [[ $bump_increment != "major" && \ 12 | $bump_increment != "minor" && \ 13 | $bump_increment != "patch" 14 | ]]; then 15 | error "Invalid bump increment. Please specify 'major', 'minor', or 'patch'." 16 | else 17 | declare -r versions_match=$(compare_versions current_version) 18 | 19 | # Bump only if Git & NPM versions are equal 20 | if [[ versions_match ]]; then 21 | declare -r new_version=$(npm --no-git-tag-version version $bump_increment) 22 | 23 | echo "NPM Version: "${new_version:1} 24 | else 25 | error "Version Mismatch: 26 | 27 | NPM Version: "$npm_version" 28 | Git Tag Version: "${git_tag_version:1}" 29 | " 30 | fi 31 | fi 32 | } 33 | 34 | # Compares the NPM version and Git tag version 35 | # in the repo. 36 | # 37 | # $1 - The NPM version string. 38 | # 39 | # returns - A boolean indicating if the versions match. 40 | function compare_versions { 41 | declare -r npm_version=$1 42 | declare -r git_tag_version=$(git describe --tags | cut -f 1 -d '-') 43 | 44 | # Drop the 'v' from the Git tag for the comparison. 45 | if [[ $npm_version != ${git_tag_version:1} ]]; then 46 | false 47 | else 48 | true 49 | fi 50 | } 51 | 52 | # Prints an error string to stderr, and exits the program with a 1 code. 53 | # 54 | # $1 - The error string to print. 55 | function error { 56 | echo "ERROR - $1" 1>&2 57 | exit 1 58 | } 59 | -------------------------------------------------------------------------------- /scripts/wait-for-postgres.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # wait-for-postgres.sh 3 | # https://docs.docker.com/compose/startup-order/ 4 | 5 | # Wrapper script to perform a more application-specific health check. 6 | # Here we want to wait until Postgres is ready to accept the PayID SQL scripts. 7 | 8 | # Exit immediately if a command exits with a non-zero status. 9 | set -e 10 | 11 | # In docker-compose.yml, the command value is: 12 | # command: 13 | # [ 14 | # '/opt/payid/docker/wait-for-postgres.sh', 15 | # 'db', => $1 16 | # 'node', => $2 17 | # '/opt/payid/build/src/index.js', => $3 18 | # ] 19 | # So we will have: 20 | # /opt/payid/docker/wait-for-postgres.sh $1 $2 $3 21 | # The database hostname ("db" service) is in position 1 22 | DB_HOSTNAME="$1" 23 | 24 | # shift will move to the left the parameters, so $1 is removed, $2 becomes $1 and $3 becomes $2 25 | # https://www.youtube.com/watch?v=48j0kxOFKZE 26 | shift 27 | 28 | # $* concatenates the remaining parameters, it is equivalent to "$1 $2", which is now (after the shift): node /opt/payid/build/src/index.js 29 | CMD=$* 30 | 31 | # Check that the Postgres is ready to execute SQL scripts, if not we wait 1 second and try again 32 | # \q means we want to quit psql 33 | until PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOSTNAME" -U "postgres" -c '\q'; do 34 | >&2 echo "Postgres is unavailable - sleeping" 35 | sleep 1 36 | done 37 | 38 | >&2 echo "Postgres is up - executing command" 39 | 40 | # The eval built-in will feed the command to the shell, which will effectively execute the command. Needed as we use $* above. 41 | eval exec "$CMD" -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { Server } from 'http' 2 | 3 | import { checkMetricsConfiguration } from '@payid-org/server-metrics' 4 | import * as express from 'express' 5 | 6 | import config from './config' 7 | import syncDatabaseSchema from './db/syncDatabaseSchema' 8 | import sendSuccess from './middlewares/sendSuccess' 9 | import { metricsRouter, adminApiRouter, publicApiRouter } from './routes' 10 | import metrics from './services/metrics' 11 | import logger from './utils/logger' 12 | 13 | /** 14 | * The PayID application. Runs two Express servers on different ports. 15 | * 16 | * One server responds to PayID Protocol requests (the public API), 17 | * while the other server exposes CRUD commands for PayIDs stored in the database (the Admin API). 18 | */ 19 | export default class App { 20 | // Exposed for testing purposes 21 | public readonly publicApiExpress: express.Application 22 | public readonly adminApiExpress: express.Application 23 | 24 | private publicApiServer?: Server 25 | private adminApiServer?: Server 26 | 27 | public constructor() { 28 | this.publicApiExpress = express() 29 | this.adminApiExpress = express() 30 | } 31 | 32 | /** 33 | * Initializes the PayID server by: 34 | * - Ensuring the database has all tables/columns necessary 35 | * - Boot up the Public API server 36 | * - Boot up the Admin API server 37 | * - Scheduling various operations around metrics. 38 | * 39 | * @param initConfig - The application configuration to initialize the app with. 40 | * Defaults to whatever is in config.ts. 41 | */ 42 | public async init(initConfig = config): Promise { 43 | // Execute DDL statements not yet defined on the current database 44 | await syncDatabaseSchema(initConfig.database) 45 | 46 | this.publicApiServer = this.launchPublicApi(initConfig.app) 47 | this.adminApiServer = this.launchAdminApi(initConfig.app) 48 | 49 | // Check if our metrics configuration is valid. 50 | checkMetricsConfiguration(initConfig.metrics) 51 | 52 | // Explicitly log that we are pushing metrics if we're pushing metrics. 53 | if (initConfig.metrics.pushMetrics) { 54 | logger.info(`Pushing metrics is enabled. 55 | 56 | Metrics only capture the total number of PayIDs grouped by (paymentNetwork, environment), 57 | and the (paymentNetwork, environment) tuple of public requests to the PayID server. 58 | No identifying information is captured. 59 | 60 | If you would like to opt out of pushing metrics, set the environment variable PUSH_PAYID_METRICS to "false". 61 | `) 62 | } 63 | } 64 | 65 | /** 66 | * Shuts down the PayID server, and cleans up the recurring metric operations. 67 | */ 68 | public close(): void { 69 | this.publicApiServer?.close() 70 | this.adminApiServer?.close() 71 | 72 | metrics.stopMetrics() 73 | } 74 | 75 | /** 76 | * Boots up the public API to respond to PayID Protocol requests. 77 | * 78 | * @param appConfig - The application configuration to boot up the Express server with. 79 | * 80 | * @returns An HTTP server listening on the public API port. 81 | */ 82 | private launchPublicApi(appConfig: typeof config.app): Server { 83 | this.publicApiExpress.use('/', publicApiRouter) 84 | 85 | return this.publicApiExpress.listen(appConfig.publicApiPort, () => 86 | logger.info(`Public API listening on ${appConfig.publicApiPort}`), 87 | ) 88 | } 89 | 90 | /** 91 | * Boots up the Admin API to respond to CRUD commands hitting REST endpoints. 92 | * 93 | * @param appConfig - The application configuration to boot up the Express server with. 94 | * 95 | * @returns An HTTP server listening on the Admin API port. 96 | */ 97 | private launchAdminApi(appConfig: typeof config.app): Server { 98 | this.adminApiExpress.use('/users', adminApiRouter) 99 | this.adminApiExpress.use('/metrics', metricsRouter) 100 | this.adminApiExpress.use('/status/health', sendSuccess) 101 | 102 | return this.adminApiExpress.listen(appConfig.adminApiPort, () => 103 | logger.info(`Admin API listening on ${appConfig.adminApiPort}`), 104 | ) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { version as packageVersion } from '../package.json' 2 | 3 | export const payIdServerVersions: readonly string[] = ['1.0', '1.1'] 4 | export const adminApiVersions: readonly string[] = ['2020-05-28', '2020-08-25'] 5 | 6 | /** 7 | * Application configuration. 8 | * 9 | * NOTE: The defaults are developer defaults. This configuration is NOT a valid 10 | * production configuration. Please refer to example.production.env for 11 | * reference. 12 | */ 13 | const config = { 14 | database: { 15 | connection: { 16 | host: process.env.DB_HOSTNAME ?? '127.0.0.1', 17 | port: Number(process.env.DB_PORT ?? 5432), 18 | user: process.env.DB_USERNAME ?? 'postgres', 19 | password: process.env.DB_PASSWORD ?? 'password', 20 | database: process.env.DB_NAME ?? 'database_development', 21 | }, 22 | options: { 23 | seedDatabase: process.env.DB_SEED === 'true', 24 | }, 25 | }, 26 | app: { 27 | publicApiPort: Number(process.env.PUBLIC_API_PORT) || 8080, 28 | // TODO: (When we make a breaking Admin API change, cut PRIVATE_API_PORT) 29 | adminApiPort: 30 | Number(process.env.PRIVATE_API_PORT ?? process.env.ADMIN_API_PORT) || 31 | 8081, 32 | payIdVersion: payIdServerVersions[payIdServerVersions.length - 1], 33 | adminApiVersion: adminApiVersions[adminApiVersions.length - 1], 34 | logLevel: process.env.LOG_LEVEL ?? 'INFO', 35 | }, 36 | metrics: { 37 | /** 38 | * Whether or not to report PayID server metrics. Defaults to true. 39 | * To opt out, you can set the PUSH_PAYID_METRICS to any value that isn't "true". 40 | */ 41 | pushMetrics: process.env.PUSH_PAYID_METRICS 42 | ? process.env.PUSH_PAYID_METRICS === 'true' 43 | : true, 44 | /** 45 | * Domain name that operates this PayID server. 46 | * 47 | * Used to identify who is pushing the metrics in cases where multiple PayID servers are pushing to a shared metrics server. 48 | * Required for pushing metrics. 49 | * 50 | * This will be dynamically set by incoming requests if the ENV var is unset. 51 | */ 52 | domain: process.env.PAYID_DOMAIN ?? 'missing_domain', 53 | 54 | /** URL to a Prometheus push gateway, defaulting to the Xpring Prometheus server. */ 55 | gatewayUrl: 56 | process.env.PUSH_GATEWAY_URL ?? 'https://push00.mon.payid.tech/', 57 | 58 | /** How frequently (in seconds) to push metrics to the Prometheus push gateway. */ 59 | pushIntervalInSeconds: Number(process.env.PUSH_METRICS_INTERVAL) || 15, 60 | 61 | /** How frequently (in seconds) to refresh the PayID Count report data from the database. */ 62 | payIdCountRefreshIntervalInSeconds: 63 | Number(process.env.PAYID_COUNT_REFRESH_INTERVAL) || 60, 64 | 65 | payIdProtocolVersion: payIdServerVersions[payIdServerVersions.length - 1], 66 | 67 | serverAgent: `@payid-org/payid:${packageVersion}`, 68 | }, 69 | } 70 | 71 | export default config 72 | -------------------------------------------------------------------------------- /src/data-access/payIds.ts: -------------------------------------------------------------------------------- 1 | import knex from '../db/knex' 2 | import { Address, AddressInformation } from '../types/database' 3 | 4 | /** 5 | * Retrieve all of the address information associated with a given PayID from the database. 6 | * 7 | * @param payId - The PayID used to retrieve address information. 8 | * @returns All of the address information associated with a given PayID. 9 | */ 10 | export async function getAllAddressInfoFromDatabase( 11 | payId: string, 12 | ): Promise { 13 | const addressInformation = await knex 14 | .select('address.paymentNetwork', 'address.environment', 'address.details') 15 | .from
('address') 16 | .innerJoin('account', 'address.accountId', 'account.id') 17 | .where('account.payId', payId) 18 | .whereNull('address.identityKeySignature') 19 | 20 | return addressInformation 21 | } 22 | 23 | /** 24 | * Retrieve all verified address data associated with a given PayID. 25 | * 26 | * @param payId -- The PayID used to retrieve verified address information. 27 | * @returns All of the verified addresses associated with the given PayID. 28 | */ 29 | export async function getAllVerifiedAddressInfoFromDatabase( 30 | payId: string, 31 | ): Promise { 32 | const addressInformation = await knex 33 | .select( 34 | 'address.paymentNetwork', 35 | 'address.environment', 36 | 'address.details', 37 | 'address.identityKeySignature', 38 | ) 39 | .from
('address') 40 | .innerJoin('account', 'address.accountId', 'account.id') 41 | .where('account.payId', payId) 42 | .whereNotNull('address.identityKeySignature') 43 | 44 | return addressInformation 45 | } 46 | 47 | /** 48 | * Retrieves the identity key for a specific PayID. 49 | * 50 | * @param payId - The PayID that we are requesting an identityKey for. 51 | * @returns The identity key for that PayID if it exists. 52 | */ 53 | export async function getIdentityKeyFromDatabase( 54 | payId: string, 55 | ): Promise { 56 | const identityKey = await knex 57 | .select('account.identityKey') 58 | .from('account') 59 | .where('account.payId', payId) 60 | 61 | return identityKey[0].identityKey 62 | } 63 | -------------------------------------------------------------------------------- /src/data-access/reports.ts: -------------------------------------------------------------------------------- 1 | import { AddressCount } from '@payid-org/server-metrics' 2 | 3 | import knex from '../db/knex' 4 | 5 | /** 6 | * Retrieve count of addresses, grouped by payment network and environment. 7 | * 8 | * @returns A list with the number of addresses that have a given (paymentNetwork, environment) tuple, 9 | * ordered by (paymentNetwork, environment). 10 | */ 11 | export async function getAddressCounts(): Promise { 12 | const addressCounts: AddressCount[] = await knex 13 | .select('address.paymentNetwork', 'address.environment') 14 | .count('* as count') 15 | .from('address') 16 | .groupBy('address.paymentNetwork', 'address.environment') 17 | .orderBy(['address.paymentNetwork', 'address.environment']) 18 | 19 | return addressCounts.map((addressCount) => ({ 20 | paymentNetwork: addressCount.paymentNetwork, 21 | environment: addressCount.environment, 22 | count: Number(addressCount.count), 23 | })) 24 | } 25 | 26 | /** 27 | * Retrieve the count of PayIDs in the database. 28 | * 29 | * @returns The count of PayIDs that exist for this PayID server. 30 | */ 31 | export async function getPayIdCount(): Promise { 32 | const payIdCount: number = await knex 33 | .count('* as count') 34 | .from('account') 35 | .then((record) => { 36 | return Number(record[0].count) 37 | }) 38 | 39 | return payIdCount 40 | } 41 | -------------------------------------------------------------------------------- /src/db/extensions/01_pgcrypto.sql: -------------------------------------------------------------------------------- 1 | -- We use this to generate random UUIDs from inside Postgres. 2 | -- It also adds hashing and encryption functions. 3 | -- https://www.postgresql.org/docs/current/pgcrypto.html 4 | 5 | CREATE EXTENSION IF NOT EXISTS "pgcrypto"; 6 | -------------------------------------------------------------------------------- /src/db/functions/01_set_updated_at.sql: -------------------------------------------------------------------------------- 1 | -- Assuming the table has an `updated_at` column, 2 | -- we can use this function to write an update trigger 3 | -- that automatically updates the `updated_at` column. 4 | 5 | CREATE OR REPLACE FUNCTION set_updated_at() 6 | RETURNS TRIGGER AS $$ 7 | BEGIN 8 | NEW.updated_at = CURRENT_TIMESTAMP; 9 | RETURN NEW; 10 | END; 11 | $$ LANGUAGE plpgsql; 12 | 13 | 14 | COMMENT ON FUNCTION set_updated_at IS 'Used in triggers. Updates the `updated_at` field of the table.'; 15 | -------------------------------------------------------------------------------- /src/db/knex.ts: -------------------------------------------------------------------------------- 1 | import * as knexInit from 'knex' 2 | import * as knexStringcase from 'knex-stringcase' 3 | 4 | import config from '../config' 5 | import { handleDatabaseError } from '../utils/errors' 6 | 7 | const knexConfig = { 8 | client: 'pg', 9 | connection: config.database.connection, 10 | pool: { 11 | /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types -- 12 | * Knex doesn't have great types for the afterCreate parameters 13 | */ 14 | /** 15 | * A function automatically called by Knex after we initialize our database connection pool. 16 | * Ensures the database timezone is set to UTC and queries only timeout after 3 seconds. 17 | * 18 | * @param conn - A Knex database connection. 19 | * @param done - A callback to handle the asynchronous nature of database queries. 20 | */ 21 | afterCreate(conn: any, done: Function): void { 22 | conn.query('SET timezone="UTC";', async (err?: Error) => { 23 | if (err) { 24 | return done(err, conn) 25 | } 26 | 27 | conn.query('SET statement_timeout TO 3000;', async (error: Error) => { 28 | // if err is not falsy, connection is discarded from pool 29 | done(error, conn) 30 | }) 31 | 32 | return undefined 33 | }) 34 | }, 35 | /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types */ 36 | }, 37 | } 38 | 39 | // Convert between camelCase in the Node app to snake_case in the DB 40 | const knex = knexInit(knexStringcase(knexConfig)) 41 | 42 | // Handle all database errors by listening on the callback 43 | knex.on('query-error', (error) => { 44 | handleDatabaseError(error) 45 | }) 46 | 47 | export default knex 48 | -------------------------------------------------------------------------------- /src/db/migrations/01_drop_organization_from_account.sql: -------------------------------------------------------------------------------- 1 | -- Remove the organization column from account. 2 | -- 3 | -- This column was going to be used for a "hosted PayID" idea, where multiple organizations 4 | -- would outsource their PayID server implementation to a hosted provider. 5 | -- However, we currently don't do any authentication for the Admin API, 6 | -- which means that our reference implementation could not be a hosted solution anyways. 7 | ALTER TABLE account 8 | DROP COLUMN IF EXISTS organization; 9 | -------------------------------------------------------------------------------- /src/db/migrations/02_change_pay_id_format_constraint.sql: -------------------------------------------------------------------------------- 1 | -- Update the regex validating PayIDs to guarantee a lowercase user 2 | ALTER TABLE account DROP CONSTRAINT IF EXISTS pay_id_valid_url; 3 | -- Need to drop this as well because our tests re-execute this file 4 | -- and will error if it tries to create it when it already exists 5 | ALTER TABLE account DROP CONSTRAINT IF EXISTS valid_pay_id; 6 | ALTER TABLE account ADD CONSTRAINT valid_pay_id CHECK(pay_id ~ '^([a-z0-9\-\_\.]+)(?:\$)[^\s/$.?#].+\.[^\s]+$'); 7 | -------------------------------------------------------------------------------- /src/db/migrations/03_pay_id_lowercase_constraint.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | -- Add a constraint to assert that PayIDs are always stored all lowercase. 4 | -- This is fine because PayIDs should be case-insensitive. 5 | ALTER TABLE account 6 | DROP CONSTRAINT IF EXISTS pay_id_lowercase; 7 | 8 | ALTER TABLE account 9 | ADD CONSTRAINT pay_id_lowercase CHECK(lower(pay_id) = pay_id); 10 | 11 | END TRANSACTION; 12 | -------------------------------------------------------------------------------- /src/db/migrations/04_address_info_uppercase_constraints.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | -- Add constraints to assert that (payment_network, environment) are always stored all uppercase. 4 | -- This is fine because both of those should be case-insensitive. 5 | ALTER TABLE address 6 | DROP CONSTRAINT IF EXISTS payment_network_uppercase; 7 | 8 | ALTER TABLE address 9 | DROP CONSTRAINT IF EXISTS environment_uppercase; 10 | 11 | 12 | 13 | ALTER TABLE address 14 | ADD CONSTRAINT payment_network_uppercase CHECK (upper(payment_network) = payment_network); 15 | 16 | ALTER TABLE address 17 | ADD CONSTRAINT environment_uppercase CHECK(upper(environment) = environment); 18 | 19 | END TRANSACTION; 20 | -------------------------------------------------------------------------------- /src/db/migrations/05_increase_payment_network_character_limit.sql: -------------------------------------------------------------------------------- 1 | -- Originally this was only 5, extending to be more inclusive 2 | -- of networks with longer names like interledger 3 | ALTER TABLE address 4 | ALTER COLUMN payment_network type varchar(20); 5 | -------------------------------------------------------------------------------- /src/db/migrations/06_alter_payid_regex.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | -- Alters the regex constraint to check for valid PayIDs. 4 | -- This regex is much stricter and more accurate, based on email regexes. 5 | ALTER TABLE account 6 | DROP CONSTRAINT IF EXISTS valid_pay_id; 7 | 8 | ALTER TABLE account 9 | ADD CONSTRAINT valid_pay_id CHECK(pay_id ~ '^[a-z0-9!#@%&*+/=?^_`{|}~-]+(?:\.[a-z0-9!#@%&*+/=?^_`{|}~-]+)*\$(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z-]*[a-z0-9])?|(?:[0-9]{1,3}\.){3}[0-9]{1,3})$'); 10 | 11 | END TRANSACTION; 12 | -------------------------------------------------------------------------------- /src/db/migrations/07_add_identity_key.sql: -------------------------------------------------------------------------------- 1 | -- Add identity key column to account 2 | -- 3 | -- This column is used to store user generated identity keys so that clients 4 | -- querying addresses can verify the individual user confirmed ownership 5 | -- of an address via a signature. 6 | ALTER TABLE account 7 | ADD COLUMN IF NOT EXISTS identity_key varchar; 8 | -------------------------------------------------------------------------------- /src/db/migrations/08_add_identity_key_signature.sql: -------------------------------------------------------------------------------- 1 | -- Add identity key signature field to account table 2 | -- 3 | -- This column will be used to store user signatures confirming 4 | -- ownership of an address. 5 | ALTER TABLE address 6 | ADD COLUMN IF NOT EXISTS identity_key_signature varchar; 7 | -------------------------------------------------------------------------------- /src/db/schema/01_account.sql: -------------------------------------------------------------------------------- 1 | -- We use a UUID id column because the Travel Rule requires exchanging an account number, 2 | -- and we wouldn't want to expose a monotonically increasing integer. 3 | 4 | CREATE TABLE IF NOT EXISTS account ( 5 | id uuid PRIMARY KEY DEFAULT(gen_random_uuid()), 6 | pay_id varchar(200) UNIQUE NOT NULL, 7 | identity_key varchar, 8 | 9 | -- AUDIT COLUMNS 10 | created_at timestamp with time zone NOT NULL DEFAULT(CURRENT_TIMESTAMP), 11 | updated_at timestamp with time zone NOT NULL DEFAULT(CURRENT_TIMESTAMP), 12 | 13 | -- CONSTRAINTS 14 | CONSTRAINT pay_id_length_nonzero CHECK(length(pay_id) > 0), 15 | CONSTRAINT pay_id_lowercase CHECK(lower(pay_id) = pay_id), 16 | 17 | -- Regex discussion: https://github.com/payid-org/payid/issues/345 18 | CONSTRAINT valid_pay_id CHECK(pay_id ~ '^[a-z0-9!#@%&*+/=?^_`{|}~-]+(?:\.[a-z0-9!#@%&*+/=?^_`{|}~-]+)*\$(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z-]*[a-z0-9])?|(?:[0-9]{1,3}\.){3}[0-9]{1,3})$') 19 | ); 20 | -------------------------------------------------------------------------------- /src/db/schema/02_address.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS address ( 2 | id serial PRIMARY KEY, 3 | account_id uuid REFERENCES account(id) ON DELETE CASCADE NOT NULL, 4 | 5 | payment_network varchar(20) NOT NULL, 6 | environment varchar(20), 7 | 8 | details jsonb NOT NULL, 9 | 10 | identity_key_signature varchar, 11 | 12 | -- AUDIT COLUMNS 13 | created_at timestamp with time zone NOT NULL DEFAULT(CURRENT_TIMESTAMP), 14 | updated_at timestamp with time zone NOT NULL DEFAULT(CURRENT_TIMESTAMP), 15 | 16 | -- CONSTRAINTS 17 | CONSTRAINT payment_network_length_nonzero CHECK(length(payment_network) > 0), 18 | CONSTRAINT environment_length_nonzero CHECK(length(environment) > 0), 19 | 20 | CONSTRAINT payment_network_uppercase CHECK (upper(payment_network) = payment_network), 21 | CONSTRAINT environment_uppercase CHECK(upper(environment) = environment), 22 | 23 | -- Note that the ordering here matters, since a SELECT with a WHERE clause on just `payment_network` will use the associated index, 24 | -- as will one for `(payment_network, environment)`, but a SELECT with a WHERE clause on just `environment` WILL NOT use the index. 25 | CONSTRAINT one_address_per_account_payment_network_environment_tuple UNIQUE (payment_network, environment, account_id) 26 | ); 27 | 28 | -- INDEXES 29 | -- 30 | -- Postgres only makes indexes on PRIMARY KEYs and UNIQUE constraints by default, so we need to create indexes on FOREIGN KEYs ourselves. 31 | CREATE INDEX IF NOT EXISTS address_account_id_idx ON address(account_id); 32 | -------------------------------------------------------------------------------- /src/db/seed/payid_regex_examples.sql: -------------------------------------------------------------------------------- 1 | -- construct a table of valid and invalid PayIDs to test implementations against 2 | drop table if exists payid_examples; 3 | create table payid_examples (pay_id varchar(250) primary key, is_valid bool); 4 | 5 | -- valid payids 6 | insert into payid_examples values ('1$1.1.1.1', true); 7 | insert into payid_examples values ('payid$example.com', true); 8 | insert into payid_examples values ('firstname.lastname$example.com', true); 9 | insert into payid_examples values ('payid$subexample.example.com', true); 10 | insert into payid_examples values ('firstname+lastname$example.com', true); 11 | insert into payid_examples values ('payid$123.123.123.123', true); 12 | insert into payid_examples values ('1234567890$example.com', true); 13 | insert into payid_examples values ('payid$example-one.com', true); 14 | insert into payid_examples values ('_______$example.com', true); 15 | insert into payid_examples values ('payid$example.name', true); 16 | insert into payid_examples values ('payid$example.co.jp', true); 17 | insert into payid_examples values ('firstname-lastname$example.com', true); 18 | insert into payid_examples values ('payid@example.com$example.com', true); 19 | insert into payid_examples values ('firstname.lastname@example.com$example.com', true); 20 | insert into payid_examples values ('payid@subexample.example.com$example.com', true); 21 | insert into payid_examples values ('firstname+lastname@example.com$example.com', true); 22 | insert into payid_examples values ('payid@123.123.123.123$example.com', true); 23 | insert into payid_examples values ('1234567890@example.com$example.com', true); 24 | insert into payid_examples values ('payid@example-one.com$example.com', true); 25 | insert into payid_examples values ('_______@example.com$example.com', true); 26 | insert into payid_examples values ('payid@example.name$example.com', true); 27 | insert into payid_examples values ('payid@example.co.jp$example.com', true); 28 | insert into payid_examples values ('firstname-lastname@example.com$example.com', true); 29 | 30 | -- invalid payids 31 | insert into payid_examples values ('payid@[123.123.123.123]$example.com', false); 32 | insert into payid_examples values ('payid$[123.123.123.123]', false); 33 | insert into payid_examples values ('"payid"$example.com', false); 34 | insert into payid_examples values ('#$%^%#$$#$$#.com', false); 35 | insert into payid_examples values ('$example.com', false); 36 | insert into payid_examples values ('Joe Smith ', false); 37 | insert into payid_examples values ('payid.example.com', false); 38 | insert into payid_examples values ('payid$example$example.com', false); 39 | insert into payid_examples values ('.payid$example.com', false); 40 | insert into payid_examples values ('payid.$example.com', false); 41 | insert into payid_examples values ('payid..payid$example.com', false); 42 | insert into payid_examples values ('あいうえお$example.com', false); 43 | insert into payid_examples values ('payid$example.com (Joe Smith)', false); 44 | insert into payid_examples values ('payid$example', false); 45 | insert into payid_examples values ('payid$-example.com', false); 46 | insert into payid_examples values ('payid$111.222.333.44444', false); 47 | insert into payid_examples values ('payid$example..com', false); 48 | -- really invalid payids 49 | insert into payid_examples values ('payid$example.com?garbage', false); 50 | insert into payid_examples values ('payid$example.com/garbage', false); 51 | insert into payid_examples values ('payid$example.com#garbage', false); 52 | insert into payid_examples values ('payid$example.com&garbage', false); 53 | insert into payid_examples values ('payid$example.com:garbage', false); 54 | insert into payid_examples values ('payid$example.com\\garbage', false); 55 | insert into payid_examples values ('payid$example.com/garbage?more=garbage&even=more#garbage', false); 56 | insert into payid_examples values ('payid$example.com;', false); 57 | insert into payid_examples values (E'payid$example.com\'', false); 58 | insert into payid_examples values ('payid$example.com"', false); 59 | insert into payid_examples values ('p$ayid$example.com?garbage', false); 60 | -------------------------------------------------------------------------------- /src/db/seed/seeded_values_for_testing.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | TRUNCATE account, address; 4 | 5 | /* Unverified Accounts */ 6 | INSERT INTO account(id, pay_id) VALUES 7 | ('232370e9-045e-4269-96ec-5a79091d65ff', 'alice$127.0.0.1'), 8 | ('223ece9c-2a15-48e1-9df6-d9ac77c5db90', 'bob$127.0.0.1'), 9 | ('ec06236e-d134-4a7b-b69e-0606fb54b67b', 'alice$xpring.money'), 10 | ('69b0d20a-cdef-4bb9-adf9-2109979a12af', 'bob$xpring.money'), 11 | ('b253bed2-79ce-45d0-bbdd-96867aa85fd5', 'zebra$xpring.money'), 12 | ('8a75f884-ab16-40c4-a82a-aca454dad6b2', 'empty$xpring.money'); 13 | 14 | /* Verified Accounts */ 15 | INSERT INTO account(id, pay_id, identity_key) VALUES 16 | ('27944333-faf6-41e8-90c3-1ec9001f0830', 'emptyverified$127.0.0.1', ''), 17 | ('9a75f884-ab16-40c4-a82a-aca454dad6b2', 'verifiabledemo$127.0.0.1', 'eyJuYW1lIjoiaWRlbnRpdHlLZXkiLCJhbGciOiJFUzI1NksiLCJ0eXAiOiJKT1NFK0pTT04iLCJiNjQiOmZhbHNlLCJjcml0IjpbImI2NCIsIm5hbWUiXSwiandrIjp7ImNydiI6InNlY3AyNTZrMSIsIngiOiI2S0dtcEF6WUhWUm9qVmU5UEpfWTVyZHltQ21kTy1xaVRHem1Edl9waUlvIiwieSI6ImhxS3Vnc1g3Vjk3eFRNLThCMTBONUQxcW44MUZWMjItM1p0TURXaXZfSnciLCJrdHkiOiJFQyIsImtpZCI6Im4zNlhTc0M1TjRnNUtCVzRBWXJ5d1ZtRE1kUWNEV1BJX0RfNUR1UlNhNDAifX0'), 18 | ('2955cce9-c350-4b60-9726-c415072961ed', 'verified$127.0.0.1', 'bGV0IG1lIHNlZSB0aGVtIGNvcmdpcyBOT1cgb3IgcGF5IHRoZSBwcmljZQ=='), 19 | ('67d9ad5f-5cd8-4a0c-b642-70e63354e647', 'postmalone$127.0.0.1', 'aGkgbXkgbmFtZSBpcyBhdXN0aW4gYW5kIEkgYW0gdGVzdGluZyB0aGluZ3M='), 20 | ('35192b90-9b88-4137-85c9-3d1d3d92cf2c', 'johnwick$127.0.0.1', 'aGV0IG1lIHNlZSB0aGVtIGNvcmdpcyBOT1cgb3IgcGF5IHRoZSBwcmljZQ=='), 21 | ('772d5315-988a-4509-be15-3b535c870555', 'donaldduck$127.0.0.1', 'aGVyIGVpIGFtIGxvb2tpbmcgb3V0IHRoZSB3aW5kb3cgYW5kIGEgbWYgY3JhbmUgYXBwZWFycw=='), 22 | ('bc041012-7385-4904-8bc9-57219c0bf290', 'nextversion$127.0.0.1', 'd2VpcmQgYWwgeWFrbm9jaWYgc2hvdWxkIHJ1biBmb3IgcHJlc2lkZW50ZQ=='); 23 | 24 | /* Unverified Addresses */ 25 | INSERT INTO address(account_id, payment_network, environment, details) VALUES 26 | ('232370e9-045e-4269-96ec-5a79091d65ff', 'XRPL', 'MAINNET', '{"address": "rw2ciyaNshpHe7bCHo4bRWq6pqqynnWKQg", "tag": "67298042"}'), 27 | ('232370e9-045e-4269-96ec-5a79091d65ff', 'XRPL', 'TESTNET', '{"address": "rDk7FQvkQxQQNGTtfM2Fr66s7Nm3k87vdS"}'), 28 | ('232370e9-045e-4269-96ec-5a79091d65ff', 'BTC', 'TESTNET', '{"address": "mxNEbRXokcdJtT6sbukr1CTGVx8Tkxk3DB"}'), 29 | ('232370e9-045e-4269-96ec-5a79091d65ff', 'ACH', NULL, '{"accountNumber": "000123456789", "routingNumber": "123456789"}'), 30 | ('223ece9c-2a15-48e1-9df6-d9ac77c5db90', 'XRPL', 'TESTNET', '{"address": "rDk7FQvkQxQQNGTtfM2Fr66s7Nm3k87vdS"}'), 31 | ('ec06236e-d134-4a7b-b69e-0606fb54b67b', 'XRPL', 'TESTNET', '{"address": "rDk7FQvkQxQQNGTtfM2Fr66s7Nm3k87vdS"}'), 32 | ('69b0d20a-cdef-4bb9-adf9-2109979a12af', 'XRPL', 'TESTNET', '{"address": "rDk7FQvkQxQQNGTtfM2Fr66s7Nm3k87vdS"}'), 33 | ('35192b90-9b88-4137-85c9-3d1d3d92cf2c', 'BTC', 'MAINNET', '{"address": "2NGZrVvZG92qGYqzTLjCAewvPZ7JE8S8VxE"}'), 34 | ('b253bed2-79ce-45d0-bbdd-96867aa85fd5', 'INTERLEDGER', 'TESTNET', '{"address": "$xpring.money/zebra"}'), 35 | ('bc041012-7385-4904-8bc9-57219c0bf290', 'BTC', 'TESTNET', '{"address": "mnBgkgCvqC3JeB5akfjAFik8qSG74r39dHJ"}'); 36 | 37 | /* Verified Addresses */ 38 | INSERT INTO address(account_id, payment_network, environment, details, identity_key_signature) VALUES 39 | ('9a75f884-ab16-40c4-a82a-aca454dad6b2', 'XRPL', 'TESTNET', '{"address": "rDk7FQvkQxQQNGTtfM2Fr66s7Nm3k87vdS"}', 'rsoKeVLzwg2PpGRK0S10fpmh5WNtttF9dyJgSv3USEr4aN3bUBzp5ImRQo8wlh3E00GtZ2cse-lhoQ4zJKj0Jw'), 40 | ('67d9ad5f-5cd8-4a0c-b642-70e63354e647', 'BTC', 'TESTNET', '{"address": "2NGZrVvZG92qGYqzTLjCAewvPZ7JE8S8VxE"}', 'TG9vayBhdCBtZSEgd29vIEknbSB0ZXN0aW5nIHRoaW5ncyBhbmQgdGhpcyBpcyBhIHNpZ25hdHVyZQ=='), 41 | ('35192b90-9b88-4137-85c9-3d1d3d92cf2c', 'BTC', 'TESTNET', '{"address": "2NGZrVvZG92qGYqzTLjCAewvPZ7JE8S8VxE"}', 'TG9vayBhdCBtZSEgd29vIEknbSB0ZXN0aW5nIHRoaW5ncyBhbmQgdGhpcyBpcyBhIHNpZ25hdHVyZQ=='), 42 | ('35192b90-9b88-4137-85c9-3d1d3d92cf2c', 'XRPL', 'TESTNET', '{"address": "rDk7FQvkQxQQNGTtfM2Fr66s7Nm3k87vdS"}', 'TG9vayBhdCBtZSEgd29vIEknbSB0ZXN0aW5nIHRoaW5ncyBhbmQgdGhpcyBpcyBhIHNpZ25hdHVyZQ=='), 43 | ('35192b90-9b88-4137-85c9-3d1d3d92cf2c', 'XRPL', 'MAINNET', '{"address": "rDk7FQvkQxQQNGTtfM2Fr66s7Nm3k87vdS"}', 'TG9vayBhdCBtZSEgd29vIEknbSB0ZXN0aW5nIHRoaW5ncyBhbmQgdGhpcyBpcyBhIHNpZ25hdHVyZQ=='), 44 | ('35192b90-9b88-4137-85c9-3d1d3d92cf2c', 'ACH', NULL, '{"accountNumber": "000123456789", "routingNumber": "123456789"}', 'TG9vayBhdCBtZSEgd29vIEknbSB0ZXN0aW5nIHRoaW5ncyBhbmQgdGhpcyBpcyBhIHNpZ25hdHVyZQ=='), 45 | ('772d5315-988a-4509-be15-3b535c870555', 'XRPL', 'MAINNET', '{"address": "rU5KBPzSyPycRVW1HdgCKjYpU6W9PKQdE8"}', 'YW5kIGFsbCBvZiBhIHN1ZGRlbiBpdCBzdGFydHMgcnVubmluZyBhdCBtZSBzbyBJIGZsaXAhIGNvbGEK'), 46 | ('bc041012-7385-4904-8bc9-57219c0bf290', 'XRPL', 'MAINNET', '{"address": "rM19Xw44JvpC6fL2ioAZRuH6mpuwxcPqsu"}', 'YnV0IHdoYXQgaWYgaXQgd3MgdGhlIHBpZ2VvbnMgYWxsIGFsb25nIGluIHRoZSBjdWJwYXJzZHM='); 47 | 48 | END TRANSACTION; 49 | -------------------------------------------------------------------------------- /src/db/syncDatabaseSchema.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-await-in-loop -- 2 | * We need to await in a loop because we _want_ to block each operation until the previous one completes. 3 | * Executing the SQL DDL statements and running migrations relies on a specific order of operations. 4 | */ 5 | 6 | import * as fs from 'fs' 7 | import * as path from 'path' 8 | 9 | import { Client } from 'pg' 10 | 11 | import config from '../config' 12 | import logger from '../utils/logger' 13 | 14 | /** 15 | * Syncs the database schema with our database. 16 | * Depending on the config provided, it may seed the database with test values. 17 | * 18 | * @param databaseConfig - Contains the database connection configuration, and some options for controlling behavior. 19 | */ 20 | export default async function syncDatabaseSchema( 21 | databaseConfig: typeof config.database, 22 | ): Promise { 23 | // Define the list of directories holding '*.sql' files, in the order we want to execute them 24 | const sqlDirectories = [ 25 | 'extensions', 26 | 'schema', 27 | 'functions', 28 | 'triggers', 29 | 'migrations', 30 | ] 31 | 32 | // Run the seed script if we are seeding our database 33 | if (databaseConfig.options.seedDatabase) { 34 | sqlDirectories.push('seed') 35 | } 36 | 37 | // Loop through directories holding SQL files and execute them against the database 38 | for (const directory of sqlDirectories) { 39 | const files = await fs.promises.readdir(path.join(__dirname, directory)) 40 | 41 | // Note that this loops through the files in alphabetical order 42 | for (const file of files) { 43 | await executeSqlFile( 44 | path.join(__dirname, directory, file), 45 | databaseConfig, 46 | ) 47 | } 48 | } 49 | } 50 | 51 | /** 52 | * Run the SQL file containing DDL or DML on the database. 53 | * 54 | * @param file - A SQL file that we would like to execute against our database. 55 | * @param databaseConfig - A database config object that holds connection information. 56 | */ 57 | async function executeSqlFile( 58 | file: string, 59 | databaseConfig: typeof config.database, 60 | ): Promise { 61 | const sql = await fs.promises.readFile(file, 'utf8') 62 | const client = new Client(databaseConfig.connection) 63 | 64 | try { 65 | // Connect to the database 66 | await client.connect() 67 | 68 | // Execute SQL query 69 | logger.debug(`Executing query:\n${sql}`) 70 | await client.query(sql) 71 | 72 | // Close the database connection 73 | await client.end() 74 | } catch (err) { 75 | logger.fatal( 76 | '\nerror running query', 77 | file, 78 | err.message, 79 | '\n\nCheck that Postgres is running and that there is no port conflict\n', 80 | ) 81 | 82 | // If we can't execute our SQL, our app is in an indeterminate state, so kill it. 83 | process.exit(1) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/db/triggers/01_account_before_update.sql: -------------------------------------------------------------------------------- 1 | -- Wrap the DROP/CREATE in a TRANSACTION, 2 | -- because Postgres doesn't support CREATE TRIGGER IF NOT EXISTS syntax. 3 | -- This way it is always safe to execute this file. 4 | BEGIN TRANSACTION; 5 | 6 | DROP TRIGGER IF EXISTS account_before_update ON account; 7 | 8 | CREATE TRIGGER account_before_update 9 | BEFORE UPDATE ON account 10 | FOR EACH ROW 11 | EXECUTE FUNCTION set_updated_at(); 12 | 13 | 14 | COMMENT ON TRIGGER account_before_update ON account IS 'Update the `updated_at` field for every row updated.'; 15 | 16 | END TRANSACTION; 17 | -------------------------------------------------------------------------------- /src/db/triggers/02_address_before_update.sql: -------------------------------------------------------------------------------- 1 | -- Wrap the DROP/CREATE in a TRANSACTION, 2 | -- because Postgres doesn't support CREATE TRIGGER IF NOT EXISTS syntax. 3 | -- This way it is always safe to execute this file. 4 | BEGIN TRANSACTION; 5 | 6 | DROP TRIGGER IF EXISTS address_before_update ON address; 7 | 8 | CREATE TRIGGER address_before_update 9 | BEFORE UPDATE ON address 10 | FOR EACH ROW 11 | EXECUTE FUNCTION set_updated_at(); 12 | 13 | 14 | COMMENT ON TRIGGER address_before_update ON address IS 'Update the `updated_at` field for every row updated.'; 15 | 16 | END TRANSACTION; 17 | -------------------------------------------------------------------------------- /src/discoveryLinks.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "rel": "https://payid.org/ns/payid-easy-checkout-uri/1.0", 4 | "href": "https://dev.wallet.xpring.io/wallet/xrp/testnet/payto" 5 | } 6 | ] 7 | -------------------------------------------------------------------------------- /src/hooks/memo.ts: -------------------------------------------------------------------------------- 1 | import { PaymentInformation } from '../types/protocol' 2 | 3 | /** 4 | * This function is expected to be overwritten by companies deploying 5 | * PayID servers. It is expected that this function would query other 6 | * internal systems to attach metadata to a transaction. 7 | * 8 | * @param _paymentInformation - A PaymentInformation object to potentially be used in generating the memo. 9 | * @returns A string to be attached as memo. 10 | */ 11 | export default function createMemo( 12 | _paymentInformation: PaymentInformation, 13 | ): string { 14 | return '' 15 | } 16 | -------------------------------------------------------------------------------- /src/html/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PayString/paystring/5fb44206891c005ab7114ecf937e7ced7cc1162b/src/html/favicon.ico -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import App from './app' 2 | import config from './config' 3 | import logger from './utils/logger' 4 | 5 | function run(): void { 6 | if (require.main === module) { 7 | const app: App = new App() 8 | app.init(config).catch((err) => { 9 | logger.fatal(err) 10 | process.exit(1) 11 | }) 12 | } 13 | } 14 | 15 | run() 16 | -------------------------------------------------------------------------------- /src/middlewares/adminApiHeaders.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | 3 | import config, { adminApiVersions } from '../config' 4 | import { ParseError, ParseErrorType, ContentTypeError } from '../utils/errors' 5 | 6 | /** 7 | * A middleware asserting that all Admin API HTTP requests have an appropriate PayID-API-Version header. 8 | * 9 | * It also sets version headers on all Admin API HTTP responses for informational purposes. 10 | * 11 | * @param req - An Express Request object. 12 | * @param res - An Express Response object. 13 | * @param next - An Express next() function. 14 | * 15 | * @throws A ParseError if the PayID-API-Version header is missing, malformed, or unsupported. 16 | */ 17 | export function checkRequestAdminApiVersionHeaders( 18 | req: Request, 19 | res: Response, 20 | next: NextFunction, 21 | ): void { 22 | // Add our Server-Version headers to all successful responses. 23 | // This should be the most recent version of the PayID protocol / PayID Admin API this server knows how to handle. 24 | // We add it early so even errors will respond with Server-Version headers. 25 | res.header('PayID-Server-Version', config.app.payIdVersion) 26 | // TODO:(hbergren) Rename this to PayID-Admin-Server-Version 27 | res.header('PayID-API-Server-Version', config.app.adminApiVersion) 28 | 29 | // TODO:(hbergren) Rename this to PayID-Admin-API-Version 30 | const payIdApiVersionHeader = req.header('PayID-API-Version') 31 | 32 | // Checks if the PayID-API-Version header exists 33 | if (!payIdApiVersionHeader) { 34 | throw new ParseError( 35 | "A PayID-API-Version header is required in the request, of the form 'PayID-API-Version: YYYY-MM-DD'.", 36 | ParseErrorType.MissingPayIdApiVersionHeader, 37 | ) 38 | } 39 | 40 | const dateRegex = /^(?\d{4})-(?\d{2})-(?\d{2})$/u 41 | const regexResult = dateRegex.exec(payIdApiVersionHeader) 42 | if (!regexResult) { 43 | throw new ParseError( 44 | "A PayID-API-Version header must be in the form 'PayID-API-Version: YYYY-MM-DD'.", 45 | ParseErrorType.InvalidPayIdApiVersionHeader, 46 | ) 47 | } 48 | 49 | // Because they are ISO8601 date strings, we can just do a string comparison 50 | if (payIdApiVersionHeader < adminApiVersions[0]) { 51 | throw new ParseError( 52 | `The PayID-API-Version ${payIdApiVersionHeader} is not supported, please try upgrading your request to at least 'PayID-API-Version: ${adminApiVersions[0]}'`, 53 | ParseErrorType.UnsupportedPayIdApiVersionHeader, 54 | ) 55 | } 56 | 57 | next() 58 | } 59 | 60 | /** 61 | * A middleware asserting that Admin requests have an appropriate Content-Type header. 62 | * 63 | * @param req - An Express Request object. 64 | * @param _res - An Express Response object. 65 | * @param next - An Express next() function. 66 | * @throws A ParseError if the Content-Type header is missing, malformed, or unsupported. 67 | */ 68 | export function checkRequestContentType( 69 | req: Request, 70 | _res: Response, 71 | next: NextFunction, 72 | ): void { 73 | type MimeType = 'application/json' | 'application/merge-patch+json' 74 | // The default media type required is 'application/json' for POST and PUT requests 75 | let mediaType: MimeType = 'application/json' 76 | 77 | if (req.method === 'PATCH') { 78 | /** 79 | * The required Content-Type header for the PATCH endpoints is 'application/merge-patch+json'. 80 | * 81 | * The merge patch format is primarily intended for use with the HTTP PATCH method 82 | * as a means of describing a set of modifications to a target resource’s content. 83 | * Application/merge-patch+json is a Type Specific Variation of the "application/merge-patch" Media Type that uses a 84 | * JSON data structure to describe the changes to be made to a target resource. 85 | */ 86 | mediaType = 'application/merge-patch+json' 87 | } 88 | 89 | // POST, PUT and PATCH requests need a valid Content-Type header 90 | if ( 91 | req.header('Content-Type') !== mediaType && 92 | ['POST', 'PUT', 'PATCH'].includes(req.method) 93 | ) { 94 | throw new ContentTypeError(mediaType) 95 | } 96 | 97 | next() 98 | } 99 | 100 | /** 101 | * A middleware putting an Accept-Patch header in the response. 102 | * 103 | * @param _req - An Express Request object. 104 | * @param res - An Express Response object. 105 | * @param next - An Express next() function. 106 | */ 107 | export function addAcceptPatchResponseHeader( 108 | _req: Request, 109 | res: Response, 110 | next: NextFunction, 111 | ): void { 112 | /** 113 | * Add this header to the response. 114 | * Accept-Patch in response to any method means that PATCH is allowed on the resource identified by the Request-URI. 115 | * The Accept-Patch response HTTP header advertises which media-type the server is able to understand for a PATCH request. 116 | */ 117 | res.header('Accept-Patch', 'application/merge-patch+json') 118 | 119 | next() 120 | } 121 | -------------------------------------------------------------------------------- /src/middlewares/checkPublicApiVersionHeaders.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import * as semver from 'semver' 3 | 4 | import config, { payIdServerVersions } from '../config' 5 | import { ParseError, ParseErrorType } from '../utils/errors' 6 | 7 | /** 8 | * A middleware asserting that all public API HTTP requests have an appropriate PayID-Version header. 9 | * 10 | * It also sets a PayID-Server-Version header on all public API responses to allow for PayID Protocol version negotiation. 11 | * 12 | * @param req - An Express Request object. 13 | * @param res - An Express Response object. 14 | * @param next - An Express next() function. 15 | * 16 | * @throws A ParseError if the PayID-Version header is missing, malformed, or unsupported. 17 | */ 18 | export default function checkPublicApiVersionHeaders( 19 | req: Request, 20 | res: Response, 21 | next: NextFunction, 22 | ): void { 23 | // Add our PayID-Server-Version header to all successful responses. 24 | // This should be the most recent version of the PayID protocol this server knows how to handle. 25 | // We add it early so even errors will respond with the `PayID-Server-Version` header. 26 | res.header('PayID-Server-Version', config.app.payIdVersion) 27 | 28 | const payIdVersionHeader = req.header('PayID-Version') 29 | 30 | // Checks if the PayID-Version header exists 31 | if (!payIdVersionHeader) { 32 | throw new ParseError( 33 | "A PayID-Version header is required in the request, of the form 'PayID-Version: {major}.{minor}'.", 34 | ParseErrorType.MissingPayIdVersionHeader, 35 | ) 36 | } 37 | 38 | // Regex only includes major and minor because we ignore patch. 39 | const semverRegex = /^\d+\.\d+$/u 40 | const regexResult = semverRegex.exec(payIdVersionHeader) 41 | if (!regexResult) { 42 | throw new ParseError( 43 | "A PayID-Version header must be in the form 'PayID-Version: {major}.{minor}'.", 44 | ParseErrorType.InvalidPayIdVersionHeader, 45 | ) 46 | } 47 | 48 | // Because payIdServerVersion is a constant from config, 49 | // and we have the regex check on the payIdRequestVersion, 50 | // both of these semver.coerce() calls are guaranteed to succeed. 51 | // But we need to cast them because TypeScript doesn't realize that. 52 | const payIdRequestVersion = semver.coerce(payIdVersionHeader) as semver.SemVer 53 | const payIdServerVersion = semver.coerce( 54 | config.app.payIdVersion, 55 | ) as semver.SemVer 56 | 57 | if (semver.gt(payIdRequestVersion, payIdServerVersion)) { 58 | throw new ParseError( 59 | `The PayID-Version ${payIdVersionHeader} is not supported, please try downgrading your request to PayID-Version ${config.app.payIdVersion}`, 60 | ParseErrorType.UnsupportedPayIdVersionHeader, 61 | ) 62 | } 63 | 64 | if (!payIdServerVersions.includes(payIdVersionHeader)) { 65 | throw new ParseError( 66 | `The PayID Version ${payIdVersionHeader} is not supported, try something in the range ${ 67 | payIdServerVersions[0] 68 | } - ${payIdServerVersions[payIdServerVersions.length - 1]}`, 69 | ParseErrorType.UnsupportedPayIdVersionHeader, 70 | ) 71 | } 72 | 73 | // Add our PayID-Version header to all responses. 74 | // Eventually, we'll need to be able to upgrade/downgrade responses. 75 | res.header('PayID-Version', payIdVersionHeader) 76 | 77 | // TODO:(hbergren) This probably should not live here. 78 | // We probably want a separate setHeaders() function that does the setting, 79 | // and this and the PayID-Version header can live there. 80 | // 81 | // The response may not be stored in any cache. 82 | // Although other directives may be set, 83 | // this alone is the only directive you need in preventing cached responses on modern browsers 84 | res.header('Cache-Control', 'no-store') 85 | 86 | next() 87 | } 88 | -------------------------------------------------------------------------------- /src/middlewares/constructJrd.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express' 2 | 3 | import * as discoveryLinks from '../discoveryLinks.json' 4 | import { ParseError, ParseErrorType } from '../utils/errors' 5 | 6 | /** 7 | * Constructs a PayID Discovery JRD from a PayID. 8 | * 9 | * @param req - Contains a PayID as a query parameter. 10 | * @param res - Stores the JRD to be returned to the client. 11 | * @param next - Passes req/res to next middleware. 12 | * @returns A Promise resolving to nothing. 13 | * @throws ParseError if the PayID is missing from the request parameters. 14 | */ 15 | export default function constructJrd( 16 | req: Request, 17 | res: Response, 18 | next: NextFunction, 19 | ): void { 20 | const payId = req.query.resource 21 | 22 | // Query parameters could be a string or a ParsedQs, or an array of either. 23 | // PayID Discovery only allows for one 'resource' query parameter, so we 24 | // check for that here. 25 | if (!payId || Array.isArray(payId) || typeof payId !== 'string') { 26 | throw new ParseError( 27 | 'A PayID must be provided in the `resource` request parameter.', 28 | ParseErrorType.MissingPayId, 29 | ) 30 | } 31 | 32 | res.locals.response = { 33 | subject: payId, 34 | links: discoveryLinks, 35 | } 36 | 37 | return next() 38 | } 39 | -------------------------------------------------------------------------------- /src/middlewares/errorHandler.ts: -------------------------------------------------------------------------------- 1 | import HttpStatus from '@xpring-eng/http-status' 2 | import { Request, Response, NextFunction, RequestHandler } from 'express' 3 | 4 | import metrics from '../services/metrics' 5 | import { 6 | PayIDError, 7 | handleHttpError, 8 | ParseErrorType, 9 | LookupErrorType, 10 | LookupError, 11 | } from '../utils/errors' 12 | 13 | /** 14 | * An error handling middleware responsible for catching unhandled errors, 15 | * and sending out an appropriate HTTP error response. 16 | * 17 | * @param err - An uncaught error to be handled by our error handler. 18 | * @param _req - An Express Request object (unused). 19 | * @param res - An Express Response object. 20 | * @param next - An Express next() function. Used for delegating to the default error handler. 21 | * 22 | * @returns Nothing. 23 | */ 24 | export default function errorHandler( 25 | err: Error | PayIDError, 26 | _req: Request, 27 | res: Response, 28 | next: NextFunction, 29 | ): void { 30 | // https://expressjs.com/en/guide/error-handling.html 31 | // If you call next() with an error after you have started writing the response, 32 | // (for example, if you encounter an error while streaming the response to the client), 33 | // the Express default error handler closes the connection and fails the request. 34 | // 35 | // So, when you add a custom error handler, 36 | // you must delegate to the default Express error handler when the headers have already been sent to the client. 37 | if (res.headersSent) { 38 | return next(err) 39 | } 40 | 41 | let status = HttpStatus.InternalServerError 42 | if (err instanceof PayIDError) { 43 | status = err.httpStatusCode 44 | 45 | // Collect metrics on public API requests with bad Accept headers 46 | if (err.kind === ParseErrorType.InvalidMediaType) { 47 | metrics.recordPayIdLookupBadAcceptHeader() 48 | } 49 | 50 | // Collect metrics on public API requests to a PayID that does not exist 51 | if ( 52 | err.kind === LookupErrorType.MissingPayId && 53 | err instanceof LookupError && 54 | err.headers 55 | ) { 56 | err.headers.forEach((acceptType) => 57 | metrics.recordPayIdLookupResult( 58 | false, 59 | acceptType.paymentNetwork, 60 | acceptType.environment, 61 | ), 62 | ) 63 | } 64 | } 65 | 66 | return handleHttpError(status, err.message, res, err) 67 | } 68 | 69 | /** 70 | * A function used to wrap asynchronous Express middlewares. 71 | * It catches async errors so Express can pass them to an error handling middleware. 72 | * 73 | * @param handler - An Express middleware function. 74 | * 75 | * @returns An Express middleware capable of catching asynchronous errors. 76 | */ 77 | export function wrapAsync(handler: RequestHandler): RequestHandler { 78 | return async ( 79 | req: Request, 80 | res: Response, 81 | next: NextFunction, 82 | ): Promise => Promise.resolve(handler(req, res, next)).catch(next) 83 | } 84 | -------------------------------------------------------------------------------- /src/middlewares/initializeMetrics.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | 3 | import config from '../config' 4 | import metrics from '../services/metrics' 5 | 6 | /** 7 | * An Express middleware that schedules metrics generation. 8 | * 9 | * It also looks at the request hostname property to dynamically set 10 | * the domain to associate the metrics it pushes with (if push metrics are enabled). 11 | * 12 | * @param req - An Express request object. We get the hostname off of this for pushing metrics. 13 | * @param _res - An Express response object (unused). 14 | * @param next - An Express next function. 15 | */ 16 | export default function initializeMetrics( 17 | req: Request, 18 | _res: Response, 19 | next: NextFunction, 20 | ): void { 21 | // Start metrics on the first public API request. 22 | // This will _always_ happen at initialization unless the PAYID_DOMAIN env var is set. 23 | if ( 24 | config.metrics.domain === 'missing_domain' || 25 | !metrics.areMetricsRunning() 26 | ) { 27 | config.metrics.domain = req.hostname 28 | 29 | metrics.scheduleRecurringMetricsPush() 30 | metrics.scheduleRecurringMetricsGeneration() 31 | } 32 | 33 | next() 34 | } 35 | -------------------------------------------------------------------------------- /src/middlewares/payIds.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | 3 | import { 4 | getAllAddressInfoFromDatabase, 5 | getAllVerifiedAddressInfoFromDatabase, 6 | getIdentityKeyFromDatabase, 7 | } from '../data-access/payIds' 8 | import createMemo from '../hooks/memo' 9 | import { 10 | formatPaymentInfo, 11 | getPreferredAddressHeaderPair, 12 | } from '../services/basePayId' 13 | import { parseAcceptHeaders } from '../services/headers' 14 | import metrics from '../services/metrics' 15 | import { urlToPayId, constructUrl } from '../services/urls' 16 | import { LookupError, LookupErrorType } from '../utils/errors' 17 | 18 | /** 19 | * Resolves inbound requests to a PayID to their respective ledger addresses or other payment information. 20 | * 21 | * @param req - Contains PayID and payment network header. 22 | * @param res - Stores payment information to be returned to the client. 23 | * @param next - Passes req/res to next middleware. 24 | * 25 | * @returns A Promise resolving to nothing. 26 | * 27 | * @throws A LookupError if we could not find payment information for the given PayID. 28 | */ 29 | // eslint-disable-next-line max-lines-per-function -- For this middleware, this limit is too restrictive. 30 | export default async function getPaymentInfo( 31 | req: Request, 32 | res: Response, 33 | next: NextFunction, 34 | ): Promise { 35 | // NOTE: If you plan to expose your PayID with a port number, you 36 | // should include req.port as a fourth parameter. 37 | const payIdUrl = constructUrl(req.protocol, req.hostname, req.url) 38 | 39 | // Parses the constructed URL to confirm it can be converted into a valid PayID 40 | const payId = urlToPayId(payIdUrl) 41 | 42 | // Parses any accept headers to make sure they use valid PayID syntax 43 | // - This overload (req.accepts()) isn't mentioned in the express documentation, 44 | // but if there are no args provided, an array of types sorted by preference 45 | // is returned 46 | // https://github.com/jshttp/accepts/blob/master/index.js#L96 47 | const parsedAcceptHeaders = parseAcceptHeaders(req.accepts()) 48 | 49 | // Get all addresses from DB 50 | // TODO(aking): Refactor this into a single knex query 51 | const [ 52 | allAddressInfo, 53 | allVerifiedAddressInfo, 54 | identityKey, 55 | ] = await Promise.all([ 56 | getAllAddressInfoFromDatabase(payId), 57 | getAllVerifiedAddressInfoFromDatabase(payId), 58 | getIdentityKeyFromDatabase(payId).catch((_err) => { 59 | // This error is only emitted if the PayID is not found 60 | // If the PayID is found, but it has no identity key, it returns null instead 61 | // We can thus use this query to trigger 404s for missing PayIDs 62 | // --- 63 | // Respond with a 404 if we can't find the requested PayID 64 | throw new LookupError( 65 | `PayID ${payId} could not be found.`, 66 | LookupErrorType.MissingPayId, 67 | parsedAcceptHeaders, 68 | ) 69 | }), 70 | ]) 71 | 72 | // Content-negotiation to get preferred payment information 73 | const [ 74 | preferredHeader, 75 | preferredAddresses, 76 | verifiedPreferredAddresses, 77 | ] = getPreferredAddressHeaderPair( 78 | allAddressInfo, 79 | allVerifiedAddressInfo, 80 | parsedAcceptHeaders, 81 | ) 82 | 83 | // Respond with a 404 if we can't find the requested payment information 84 | if (!preferredHeader) { 85 | // Record metrics for 404s 86 | throw new LookupError( 87 | `Payment information for ${payId} could not be found.`, 88 | LookupErrorType.MissingAddress, 89 | parsedAcceptHeaders, 90 | ) 91 | } 92 | 93 | // Wrap addresses into PaymentInformation object (this is the response in Base PayID) 94 | // * NOTE: To append a memo, MUST set a memo in createMemo() 95 | const formattedPaymentInfo = formatPaymentInfo( 96 | preferredAddresses, 97 | verifiedPreferredAddresses, 98 | identityKey, 99 | res.get('PayID-Version'), 100 | payId, 101 | createMemo, 102 | ) 103 | 104 | // Set the content-type to the media type corresponding to the returned address 105 | res.set('Content-Type', preferredHeader.mediaType) 106 | 107 | // Store response information (or information to be used in other middlewares) 108 | // TODO:(hbergren), come up with a less hacky way to pipe around data than global state. 109 | res.locals.payId = payId 110 | res.locals.paymentInformation = formattedPaymentInfo 111 | res.locals.response = formattedPaymentInfo 112 | 113 | metrics.recordPayIdLookupResult( 114 | true, 115 | preferredHeader.paymentNetwork, 116 | preferredHeader.environment, 117 | ) 118 | return next() 119 | } 120 | -------------------------------------------------------------------------------- /src/middlewares/sendSuccess.ts: -------------------------------------------------------------------------------- 1 | import HttpStatus from '@xpring-eng/http-status' 2 | import { Request, Response } from 'express' 3 | 4 | import logger from '../utils/logger' 5 | 6 | /** 7 | * Sends an HTTP response with the appropriate HTTP status and JSON-formatted payload (if any). 8 | * 9 | * It also sets the Location header on responses for 201 - Created responses. 10 | * 11 | * @param req - An Express Request object. 12 | * @param res - An Express Response object. 13 | */ 14 | export default function sendSuccess(req: Request, res: Response): void { 15 | const status = Number(res.locals?.status) || HttpStatus.OK 16 | 17 | // Set a location header when our status is 201 - Created 18 | if (status === HttpStatus.Created) { 19 | // The first part of the destructured array will be "", because the string starts with "/" 20 | // And for PUT commands, the path could potentially hold more after `userPath`. 21 | const [, userPath] = req.originalUrl.split('/') 22 | 23 | const locationHeader = ['', userPath, res.locals.payId].join('/') 24 | 25 | res.location(locationHeader) 26 | } 27 | 28 | // Debug-level log all successful requests 29 | // Do not log health checks 30 | if (req.originalUrl !== '/status/health') { 31 | logger.debug( 32 | status, 33 | ((): string => { 34 | if (res.get('PayID-API-Server-Version')) { 35 | return '- Admin API:' 36 | } 37 | return '- Public API:' 38 | })(), 39 | `${req.method} ${req.originalUrl}`, 40 | ) 41 | } 42 | 43 | if (res.locals.response) { 44 | res.status(status).json(res.locals.response) 45 | } else { 46 | res.sendStatus(status) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/routes/adminApiRouter.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express' 2 | 3 | import { 4 | checkRequestAdminApiVersionHeaders, 5 | checkRequestContentType, 6 | addAcceptPatchResponseHeader, 7 | } from '../middlewares/adminApiHeaders' 8 | import errorHandler, { wrapAsync } from '../middlewares/errorHandler' 9 | import sendSuccess from '../middlewares/sendSuccess' 10 | import { 11 | getUser, 12 | postUser, 13 | putUser, 14 | deleteUser, 15 | patchUserPayId, 16 | } from '../middlewares/users' 17 | 18 | const adminApiRouter = express.Router() 19 | 20 | /** 21 | * Routes for the PayID Admin API. 22 | */ 23 | adminApiRouter 24 | // All /:payId requests should have an Accept-Patch response header with the PATCH mime type 25 | .use('/:payId', addAcceptPatchResponseHeader) 26 | 27 | // All [POST, PUT, PATCH] requests should have an appropriate Content-Type header, 28 | // AND all Admin API requests should have an appropriate PayID-API-Version header. 29 | .use('/*', checkRequestContentType, checkRequestAdminApiVersionHeaders) 30 | 31 | // Get user route 32 | .get('/:payId', wrapAsync(getUser), sendSuccess) 33 | 34 | // Create user route 35 | .post('/', express.json(), wrapAsync(postUser), sendSuccess) 36 | 37 | // Replace user route 38 | .put('/:payId', express.json(), wrapAsync(putUser), sendSuccess) 39 | 40 | // Delete user route 41 | .delete('/:payId', wrapAsync(deleteUser), sendSuccess) 42 | 43 | // Patch user's PayID route 44 | .patch( 45 | '/:payId', 46 | express.json({ type: 'application/merge-patch+json' }), 47 | wrapAsync(patchUserPayId), 48 | sendSuccess, 49 | ) 50 | 51 | // Error handling middleware (needs to be defined last) 52 | .use(errorHandler) 53 | 54 | export default adminApiRouter 55 | -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import adminApiRouter from './adminApiRouter' 2 | import metricsRouter from './metricsRouter' 3 | import publicApiRouter from './publicApiRouter' 4 | 5 | export { metricsRouter, adminApiRouter, publicApiRouter } 6 | -------------------------------------------------------------------------------- /src/routes/metricsRouter.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express' 2 | import { NextFunction, Request, Response } from 'express' 3 | 4 | import errorHandler from '../middlewares/errorHandler' 5 | import metrics from '../services/metrics' 6 | 7 | const metricsRouter = express.Router() 8 | 9 | /** 10 | * Routes for the metrics report generated by Prometheus. 11 | */ 12 | metricsRouter 13 | .get('/', (_req: Request, res: Response, next: NextFunction): void => { 14 | res.set('Content-Type', 'text/plain') 15 | res.send(metrics.getMetrics()) 16 | return next() 17 | }) 18 | 19 | // Error handling middleware needs to be defined last 20 | .use(errorHandler) 21 | 22 | export default metricsRouter 23 | -------------------------------------------------------------------------------- /src/routes/publicApiRouter.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path' 2 | 3 | import * as express from 'express' 4 | 5 | import checkPublicApiVersionHeaders from '../middlewares/checkPublicApiVersionHeaders' 6 | import constructJrd from '../middlewares/constructJrd' 7 | import errorHandler, { wrapAsync } from '../middlewares/errorHandler' 8 | import initializeMetrics from '../middlewares/initializeMetrics' 9 | import getPaymentInfo from '../middlewares/payIds' 10 | import sendSuccess from '../middlewares/sendSuccess' 11 | 12 | const publicApiRouter = express.Router() 13 | 14 | /** 15 | * Routes for the PayID Public API. 16 | */ 17 | publicApiRouter 18 | // Allow the PayID Protocol to basically ignore CORS 19 | .use((_req, res, next) => { 20 | res.header('Access-Control-Allow-Origin', '*') 21 | res.header('Access-Control-Allow-Methods', 'GET, OPTIONS') 22 | res.header('Access-Control-Allow-Headers', 'PayID-Version') 23 | res.header( 24 | 'Access-Control-Expose-Headers', 25 | 'PayID-Version, PayID-Server-Version', 26 | ) 27 | next() 28 | }) 29 | 30 | // Welcome page route 31 | .get('/', (_req: express.Request, res: express.Response) => { 32 | res.sendFile(path.join(__dirname, '../html/index.html')) 33 | }) 34 | 35 | // Favicon route 36 | .get('/favicon.ico', (_req: express.Request, res: express.Response) => { 37 | res.sendFile(path.join(__dirname, '../html/favicon.ico')) 38 | }) 39 | 40 | // Health route 41 | .get('/status/health', sendSuccess) 42 | 43 | // PayID Discovery route 44 | .get('/.well-known/webfinger', constructJrd, sendSuccess) 45 | 46 | // Base PayID route 47 | .get( 48 | '/*', 49 | checkPublicApiVersionHeaders, 50 | initializeMetrics, 51 | wrapAsync(getPaymentInfo), 52 | sendSuccess, 53 | ) 54 | 55 | // Error handling middleware (needs to be defined last) 56 | .use(errorHandler) 57 | 58 | export default publicApiRouter 59 | -------------------------------------------------------------------------------- /src/services/basePayId.ts: -------------------------------------------------------------------------------- 1 | import { AddressInformation } from '../types/database' 2 | import { ParsedAcceptHeader } from '../types/headers' 3 | import { AddressDetailsType, PaymentInformation } from '../types/protocol' 4 | 5 | /** 6 | * Format AddressInformation into a PaymentInformation object. 7 | * To be returned in PaymentSetupDetails, or as the response in 8 | * a Base PayID flow. 9 | * 10 | * @param addresses - Array of address information associated with a PayID. 11 | * @param verifiedAddresses - Array of address information associated with a PayID. 12 | * @param identityKey - A base64 encoded identity key for verifiable PayID. 13 | * @param version - The PayID protocol response version. 14 | * @param payId - Optionally include a PayId. 15 | * @param memoFn - A function, taking an optional PaymentInformation object, 16 | * that returns a string to be used as the memo. 17 | * @returns The formatted PaymentInformation object. 18 | */ 19 | // eslint-disable-next-line max-params -- We want 6 parameters here. I think this makes more sense that destructuring. 20 | export function formatPaymentInfo( 21 | addresses: readonly AddressInformation[], 22 | verifiedAddresses: readonly AddressInformation[], 23 | identityKey: string | null, 24 | version: string, 25 | payId: string, 26 | memoFn?: (paymentInformation: PaymentInformation) => string, 27 | ): PaymentInformation { 28 | const paymentInformation: PaymentInformation = { 29 | payId, 30 | version, 31 | addresses: addresses.map((address) => { 32 | return { 33 | paymentNetwork: address.paymentNetwork, 34 | ...(address.environment && { environment: address.environment }), 35 | addressDetailsType: getAddressDetailsType(address, version), 36 | addressDetails: address.details, 37 | } 38 | }), 39 | verifiedAddresses: verifiedAddresses.map((address) => { 40 | return { 41 | signatures: [ 42 | { 43 | name: 'identityKey', 44 | protected: identityKey ?? '', 45 | signature: address.identityKeySignature ?? '', 46 | }, 47 | ], 48 | payload: JSON.stringify({ 49 | payId, 50 | // Call the address a "payIdAddress" so we don't step on the JWT "address" 51 | // field if we ever change our minds 52 | payIdAddress: { 53 | paymentNetwork: address.paymentNetwork, 54 | ...(address.environment && { environment: address.environment }), 55 | addressDetailsType: getAddressDetailsType(address, version), 56 | addressDetails: address.details, 57 | }, 58 | }), 59 | } 60 | }), 61 | } 62 | 63 | return { 64 | ...paymentInformation, 65 | ...(memoFn?.(paymentInformation) && { memo: memoFn(paymentInformation) }), 66 | } 67 | } 68 | 69 | /** 70 | * Gets the best payment information associated with a PayID given a set of sorted 71 | * Accept types and a list of payment information. 72 | * 73 | * @param allAddresses - The array of AddressInformation objects to look through. 74 | * @param allVerifiedAddresses - The array of verified AddressInformation objects to look through. 75 | * @param sortedParsedAcceptHeaders - An array of ParsedAcceptHeader objects, sorted by preference. 76 | * 77 | * @returns A tuple containing the AcceptMediaType (or undefined) and its associated AddressInformation 78 | * if one exists. 79 | */ 80 | export function getPreferredAddressHeaderPair( 81 | allAddresses: readonly AddressInformation[], 82 | allVerifiedAddresses: readonly AddressInformation[], 83 | sortedParsedAcceptHeaders: readonly ParsedAcceptHeader[], 84 | ): [ 85 | ParsedAcceptHeader | undefined, 86 | readonly AddressInformation[], 87 | readonly AddressInformation[], 88 | ] { 89 | if (allAddresses.length === 0 && allVerifiedAddresses.length === 0) { 90 | return [undefined, [], []] 91 | } 92 | 93 | // Find the optimal payment information from a sorted list 94 | for (const acceptHeader of sortedParsedAcceptHeaders) { 95 | // Return all addresses for application/payid+json 96 | if (acceptHeader.paymentNetwork === 'PAYID') { 97 | return [acceptHeader, allAddresses, allVerifiedAddresses] 98 | } 99 | 100 | // Otherwise, try to fetch the address for the respective media type 101 | // foundAddress -> what we have in our database 102 | // acceptHeader -> what the client sent over 103 | const foundAddress = allAddresses.find( 104 | (address) => 105 | address.paymentNetwork === acceptHeader.paymentNetwork && 106 | // If no environment is found in our database, it returns null 107 | // If the client doesn't send over an environment, it is undefined 108 | // Below we convert null to undefined to do the comparison 109 | (address.environment ?? undefined) === acceptHeader.environment, 110 | ) 111 | const foundVerifiedAddress = allVerifiedAddresses.find( 112 | (address) => 113 | address.paymentNetwork === acceptHeader.paymentNetwork && 114 | (address.environment ?? undefined) === acceptHeader.environment, 115 | ) 116 | 117 | // Return the address + the media type to respond with 118 | // If either a unverified or verified address is found, we return 119 | if (foundAddress || foundVerifiedAddress) { 120 | return [ 121 | acceptHeader, 122 | foundAddress ? [foundAddress] : [], 123 | foundVerifiedAddress ? [foundVerifiedAddress] : [], 124 | ] 125 | } 126 | } 127 | 128 | return [undefined, [], []] 129 | } 130 | 131 | // HELPERS 132 | 133 | /** 134 | * Gets the associated AddressDetailsType for an address. 135 | * 136 | * @param address - The address information associated with a PayID. 137 | * @param version - The PayID protocol version. 138 | * @returns The AddressDetailsType for the address. 139 | */ 140 | export function getAddressDetailsType( 141 | address: AddressInformation, 142 | version: string, 143 | ): AddressDetailsType { 144 | if (address.paymentNetwork === 'ACH') { 145 | if (version === '1.0') { 146 | return AddressDetailsType.AchAddress 147 | } 148 | return AddressDetailsType.FiatAddress 149 | } 150 | return AddressDetailsType.CryptoAddress 151 | } 152 | -------------------------------------------------------------------------------- /src/services/headers.ts: -------------------------------------------------------------------------------- 1 | import { ParsedAcceptHeader } from '../types/headers' 2 | import { ParseError, ParseErrorType } from '../utils/errors' 3 | 4 | const badAcceptHeaderErrorMessage = `Must have an Accept header of the form "application/{payment_network}(-{environment})+json". 5 | Examples: 6 | - 'Accept: application/xrpl-mainnet+json' 7 | - 'Accept: application/btc-testnet+json' 8 | - 'Accept: application/ach+json' 9 | - 'Accept: application/payid+json' 10 | ` 11 | 12 | /** 13 | * Parses a list of accept headers to confirm they adhere to the PayID accept header syntax. 14 | * 15 | * @param acceptHeaders - A list of accept headers. 16 | * 17 | * @returns A parsed list of accept headers. 18 | * 19 | * @throws A custom ParseError when the Accept Header is missing. 20 | */ 21 | // TODO(dino): Generate this error code from a list of supported media types 22 | // TODO(dino): Move the metrics capturing to the error handling middleware 23 | export function parseAcceptHeaders( 24 | acceptHeaders: string[], 25 | ): readonly ParsedAcceptHeader[] { 26 | // MUST include at least 1 accept header 27 | if (!acceptHeaders.length) { 28 | throw new ParseError( 29 | `Missing Accept Header. ${badAcceptHeaderErrorMessage}`, 30 | ParseErrorType.InvalidMediaType, 31 | ) 32 | } 33 | 34 | // Accept types MUST be the proper format 35 | const parsedAcceptHeaders = acceptHeaders.map((type) => 36 | parseAcceptHeader(type), 37 | ) 38 | return parsedAcceptHeaders 39 | } 40 | 41 | // HELPERS 42 | 43 | /** 44 | * Parses an accept header for valid syntax. 45 | * 46 | * @param acceptHeader - A string representation of an accept header to validate. 47 | * 48 | * @returns A parsed accept header. 49 | * 50 | * @throws A custom ParseError when the Accept Header is invalid. 51 | */ 52 | export function parseAcceptHeader(acceptHeader: string): ParsedAcceptHeader { 53 | const ACCEPT_HEADER_REGEX = /^(?:application\/)(?\w+)-?(?\w+)?(?:\+json)$/u 54 | const lowerCaseMediaType = acceptHeader.toLowerCase() 55 | const regexResult = ACCEPT_HEADER_REGEX.exec(lowerCaseMediaType) 56 | if (!regexResult || !regexResult.groups) { 57 | throw new ParseError( 58 | `Invalid Accept Header. ${badAcceptHeaderErrorMessage}`, 59 | ParseErrorType.InvalidMediaType, 60 | ) 61 | } 62 | 63 | return { 64 | mediaType: lowerCaseMediaType, 65 | // Optionally returns the environment (only if it exists) 66 | ...(regexResult.groups.environment && { 67 | environment: regexResult.groups.environment.toUpperCase(), 68 | }), 69 | paymentNetwork: regexResult.groups.paymentNetwork.toUpperCase(), 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/services/metrics.ts: -------------------------------------------------------------------------------- 1 | import { Metrics } from '@payid-org/server-metrics' 2 | 3 | import configuration from '../config' 4 | import { getAddressCounts, getPayIdCount } from '../data-access/reports' 5 | 6 | const metrics = new Metrics( 7 | configuration.metrics, 8 | getAddressCounts, 9 | getPayIdCount, 10 | ) 11 | 12 | export default metrics 13 | -------------------------------------------------------------------------------- /src/services/urls.ts: -------------------------------------------------------------------------------- 1 | import { ParseError, ParseErrorType } from '../utils/errors' 2 | 3 | /** 4 | * Gets the full URL from request components. To be used to create the PayID. 5 | * 6 | * @param protocol - The URL protocol (http(s)). 7 | * @param hostname - Used to create the host in the PayID (user$host). 8 | * @param path - Used to create the "user" in the PayID (user$host). 9 | * @param port - Used in the PayID if included (optional). 10 | * 11 | * @returns A constructed URL. 12 | */ 13 | export function constructUrl( 14 | protocol: string, 15 | hostname: string, 16 | path: string, 17 | port?: string, 18 | ): string { 19 | if (port) { 20 | return `${protocol}://${hostname}:${port}${path}` 21 | } 22 | return `${protocol}://${hostname}${path}` 23 | } 24 | 25 | /** 26 | * Converts a PayID from `https://...` URL representation to `user$...` representation. 27 | * 28 | * @param url - The url string to convert to a PayId. 29 | * 30 | * @returns A PayID in the $ format. 31 | */ 32 | export function urlToPayId(url: string): string { 33 | // Parse the URL and get back a valid PayID URL 34 | const payIdUrl = parsePayIdUrl(url) 35 | 36 | // Get the user from the pathname 37 | const user = payIdUrl.pathname.slice(1) 38 | 39 | // use .host instead of .hostname to return the port if applicable 40 | return `${user.toLowerCase()}$${payIdUrl.host}` 41 | } 42 | 43 | // HELPER FUNCTIONS 44 | 45 | /** 46 | * Validate if the input is ASCII based text. 47 | * 48 | * Shamelessly taken from: https://stackoverflow.com/questions/14313183/javascript-regex-how-do-i-check-if-the-string-is-ascii-only. 49 | * 50 | * @param input - The string to verify. 51 | * @returns A boolean indicating whether or not the string is ASCII. 52 | */ 53 | function isASCII(input: string): boolean { 54 | // eslint-disable-next-line no-control-regex -- Regex for checking if ASCII uses control characters 55 | return /^[\x00-\x7F]*$/u.test(input) 56 | } 57 | 58 | /** 59 | * Parse the URL to see if it can be converted to a PayID. 60 | * 61 | * @param url - The URL string to be converted to a PayID URL. 62 | * 63 | * @returns A URL object. 64 | * 65 | * @throws A custom ParseError when the PayID URL is invalid. 66 | */ 67 | function parsePayIdUrl(url: string): URL { 68 | // Make sure it's not something wild like an FTP request 69 | if (!url.startsWith('http://') && !url.startsWith('https://')) { 70 | throw new ParseError( 71 | 'Invalid PayID URL protocol. PayID URLs must be HTTP/HTTPS.', 72 | ParseErrorType.InvalidPayId, 73 | ) 74 | } 75 | 76 | if (!isASCII(url)) { 77 | throw new ParseError( 78 | 'Invalid PayID characters. PayIDs must be ASCII.', 79 | ParseErrorType.InvalidPayId, 80 | ) 81 | } 82 | 83 | // Verify it's a valid URL 84 | const parsedUrl = new URL(url) 85 | 86 | // Disallow namespace paths 87 | // Valid: domain.com/user 88 | // Invalid: domain.com/payid/user 89 | if ((parsedUrl.pathname.match(/\//gu) || []).length > 1) { 90 | throw new ParseError( 91 | 'Too many paths. The only paths allowed in a PayID are to specify the user.', 92 | ParseErrorType.InvalidPayId, 93 | ) 94 | } 95 | 96 | return parsedUrl 97 | } 98 | -------------------------------------------------------------------------------- /src/services/users.ts: -------------------------------------------------------------------------------- 1 | import { adminApiVersions } from '../config' 2 | import { AddressInformation } from '../types/database' 3 | import { 4 | Address, 5 | VerifiedAddress, 6 | VerifiedAddressSignature, 7 | } from '../types/protocol' 8 | import { ParseError, ParseErrorType } from '../utils/errors' 9 | 10 | /** 11 | * Parse all addresses depending on the Admin API version and format them properly 12 | * so they can be consumed by the database. 13 | * 14 | * @param maybeAddresses - An array of addresses ( in either the old or new format ) or undefined. 15 | * @param maybeVerifiedAddresses - An array of verified addresses ( in either the old or new format ) or undefined. 16 | * @param maybeIdentityKey - An identity key or undefined ( included with verified addresses ). 17 | * @param requestVersion - The request version to determine how to parse the addresses. 18 | * 19 | * @returns A tuple of all the formatted addresses & the identity key. 20 | */ 21 | export default function parseAllAddresses( 22 | maybeAddresses: Address[] | AddressInformation[] | undefined, 23 | maybeVerifiedAddresses: VerifiedAddress[] | AddressInformation[] | undefined, 24 | maybeIdentityKey: string | undefined, 25 | requestVersion: string, 26 | ): [AddressInformation[], string | undefined] { 27 | const addresses = maybeAddresses ?? [] 28 | const verifiedAddresses = maybeVerifiedAddresses ?? [] 29 | let allAddresses: AddressInformation[] = [] 30 | 31 | // If using "old" API format, we don't need to do any translation 32 | if (requestVersion < adminApiVersions[1]) { 33 | allAddresses = allAddresses.concat( 34 | addresses as AddressInformation[], 35 | verifiedAddresses as AddressInformation[], 36 | ) 37 | } 38 | // If using Public API format, we need to translate the payload so 39 | // the data-access functions can consume them 40 | else if (requestVersion >= adminApiVersions[1]) { 41 | const formattedAddresses = (addresses as Address[]).map( 42 | (address: Address) => { 43 | return { 44 | paymentNetwork: address.paymentNetwork, 45 | ...(address.environment && { environment: address.environment }), 46 | details: address.addressDetails, 47 | } 48 | }, 49 | ) 50 | const formattedVerifiedAddressesAndKey = parseVerifiedAddresses( 51 | verifiedAddresses as VerifiedAddress[], 52 | ) 53 | allAddresses = allAddresses.concat( 54 | formattedAddresses, 55 | formattedVerifiedAddressesAndKey[0], 56 | ) 57 | return [allAddresses, formattedVerifiedAddressesAndKey[1]] 58 | } 59 | 60 | return [allAddresses, maybeIdentityKey] 61 | } 62 | 63 | // HELPERS 64 | 65 | /** 66 | * Parse all verified addresses to confirm they use a single identity key & 67 | * return parsed output that can be inserted into the database. 68 | * 69 | * @param verifiedAddresses - Array of verified addresses that adheres the the Public API format. 70 | * 71 | * @returns Array of address inforation to be consumed by insertUser. 72 | */ 73 | function parseVerifiedAddresses( 74 | verifiedAddresses: VerifiedAddress[], 75 | ): [AddressInformation[], string | undefined] { 76 | const identityKeyLabel = 'identityKey' 77 | const formattedAddresses: AddressInformation[] = [] 78 | let identityKey: string | undefined 79 | 80 | verifiedAddresses.forEach((verifiedAddress: VerifiedAddress) => { 81 | let identityKeySignature: string | undefined 82 | let identityKeyCount = 0 83 | 84 | verifiedAddress.signatures.forEach( 85 | (signaturePayload: VerifiedAddressSignature) => { 86 | let decodedKey: { name: string } 87 | try { 88 | decodedKey = JSON.parse( 89 | Buffer.from(signaturePayload.protected, 'base64').toString(), 90 | ) 91 | } catch (_err) { 92 | throw new ParseError( 93 | 'Invalid JSON for protected payload (identity key).', 94 | ParseErrorType.InvalidIdentityKey, 95 | ) 96 | } 97 | 98 | // Get the first identity key & signature 99 | if (!identityKey && decodedKey.name === identityKeyLabel) { 100 | identityKey = signaturePayload.protected 101 | identityKeyCount += 1 102 | identityKeySignature = signaturePayload.signature 103 | } else { 104 | // Increment the count of identity keys per address 105 | // And grab the signature for each address 106 | if (decodedKey.name === identityKeyLabel) { 107 | identityKeyCount += 1 108 | identityKeySignature = signaturePayload.signature 109 | } 110 | 111 | // Identity key must match across all addresses 112 | if ( 113 | identityKey !== signaturePayload.protected && 114 | decodedKey.name === identityKeyLabel 115 | ) { 116 | throw new ParseError( 117 | 'More than one identity key detected. Only one identity key per PayID can be used.', 118 | ParseErrorType.MultipleIdentityKeys, 119 | ) 120 | } 121 | 122 | // Each address must have only one identity key / signature pair 123 | if (identityKeyCount > 1) { 124 | throw new ParseError( 125 | 'More than one identity key detected. Only one identity key per address can be used.', 126 | ParseErrorType.MultipleIdentityKeys, 127 | ) 128 | } 129 | } 130 | }, 131 | ) 132 | // Transform to format consumable by insert user 133 | // And add to all addresses 134 | const jwsPayload = JSON.parse(verifiedAddress.payload) 135 | const databaseAddressPayload = { 136 | paymentNetwork: jwsPayload.payIdAddress.paymentNetwork, 137 | environment: jwsPayload.payIdAddress.environment, 138 | details: { 139 | address: jwsPayload.payIdAddress.addressDetails.address, 140 | }, 141 | identityKeySignature, 142 | } 143 | formattedAddresses.push(databaseAddressPayload) 144 | }) 145 | 146 | return [formattedAddresses, identityKey] 147 | } 148 | -------------------------------------------------------------------------------- /src/types/database.ts: -------------------------------------------------------------------------------- 1 | import { CryptoAddressDetails, FiatAddressDetails } from './protocol' 2 | 3 | /** 4 | * Model of the Account table schema for the database. 5 | */ 6 | export interface Account { 7 | readonly id: string 8 | readonly payId: string 9 | readonly identityKey?: string 10 | 11 | readonly createdAt: Date 12 | readonly updatedAt: Date 13 | } 14 | 15 | /** 16 | * Model of the Address table schema for the database. 17 | */ 18 | export interface Address { 19 | readonly id: number 20 | readonly accountId: string 21 | 22 | readonly paymentNetwork: string 23 | readonly environment?: string | null 24 | readonly details: CryptoAddressDetails | FiatAddressDetails 25 | 26 | readonly identityKeySignature?: string 27 | 28 | readonly createdAt: Date 29 | readonly updatedAt: Date 30 | } 31 | 32 | /** 33 | * The information retrieved from or inserted into the database for a given address. 34 | */ 35 | export type AddressInformation = Pick< 36 | Address, 37 | 'paymentNetwork' | 'environment' | 'details' | 'identityKeySignature' 38 | > 39 | -------------------------------------------------------------------------------- /src/types/headers.ts: -------------------------------------------------------------------------------- 1 | /** A parsed HTTP Accept header object. */ 2 | export interface ParsedAcceptHeader { 3 | /** A raw Accept header media type. */ 4 | readonly mediaType: string 5 | 6 | /** The payment network requested in the media type. */ 7 | readonly paymentNetwork: string 8 | 9 | /** 10 | * The environment requested in the media type. 11 | * 12 | * Optional, as some headers (like application/ach+json) don't have an environment. 13 | */ 14 | readonly environment?: string 15 | } 16 | -------------------------------------------------------------------------------- /src/types/protocol.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-inline-comments -- It is useful to have inline comments for interfaces. */ 2 | /** 3 | * Type of payment address in PaymentInformation. 4 | */ 5 | export enum AddressDetailsType { 6 | CryptoAddress = 'CryptoAddressDetails', 7 | FiatAddress = 'FiatAddressDetails', // Replaces AchAddressDetails 8 | AchAddress = 'AchAddressDetails', // Maintain compatibility for 1.0 9 | } 10 | 11 | /** 12 | * Matching schema for AddressDetailsType.CryptoAddress. 13 | */ 14 | export interface CryptoAddressDetails { 15 | readonly address: string 16 | readonly tag?: string 17 | } 18 | 19 | /** 20 | * Matching schema for AddressDetailsType.FiatAddress. 21 | */ 22 | export interface FiatAddressDetails { 23 | readonly accountNumber: string 24 | readonly routingNumber?: string 25 | } 26 | 27 | /** 28 | * The payment information response payload of a PayID Protocol (Public API) request. 29 | */ 30 | export interface PaymentInformation { 31 | readonly payId?: string 32 | readonly version?: string 33 | readonly addresses: Address[] 34 | readonly verifiedAddresses: VerifiedAddress[] 35 | readonly memo?: string 36 | } 37 | 38 | /** 39 | * Address information included inside of a PaymentInformation object. 40 | */ 41 | export interface Address { 42 | readonly paymentNetwork: string 43 | readonly environment?: string 44 | readonly addressDetailsType: AddressDetailsType 45 | readonly addressDetails: CryptoAddressDetails | FiatAddressDetails 46 | } 47 | 48 | /** 49 | * Object containing address information alongside signatures. 50 | */ 51 | export interface VerifiedAddress { 52 | readonly payload: string 53 | readonly signatures: readonly VerifiedAddressSignature[] 54 | } 55 | 56 | /** 57 | * JWS object for verification. 58 | */ 59 | export interface VerifiedAddressSignature { 60 | name?: string 61 | protected: string 62 | signature: string 63 | } 64 | -------------------------------------------------------------------------------- /src/utils/errors/contentTypeError.ts: -------------------------------------------------------------------------------- 1 | import HttpStatus from '@xpring-eng/http-status' 2 | 3 | import PayIDError from './payIdError' 4 | 5 | /** 6 | * A custom error type to organize logic around 415 - Unsupported Media Type errors. 7 | * 8 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/415|MDN Unsupported Media Type (415)}. 9 | */ 10 | export default class ContentTypeError extends PayIDError { 11 | public readonly kind: string 12 | 13 | /** 14 | * The constructor for new ContentTypeError. 15 | * 16 | * @param mediaType - The Media Type (also called Content Type) required by the request. 17 | */ 18 | public constructor( 19 | mediaType: 'application/json' | 'application/merge-patch+json', 20 | ) { 21 | // All content type errors are the result of a 415 Unsupported Media Type error 22 | const message = `A 'Content-Type' header is required for this request: 'Content-Type: ${mediaType}'.` 23 | super(message, HttpStatus.UnsupportedMediaType) 24 | this.kind = 'UnsupportedMediaTypeHeader' 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/errors/databaseError.ts: -------------------------------------------------------------------------------- 1 | import HttpStatus from '@xpring-eng/http-status' 2 | 3 | import logger from '../logger' 4 | 5 | import PayIDError from './payIdError' 6 | 7 | /** 8 | * An enum containing the different kinds of DatabaseErrors. 9 | */ 10 | enum DatabaseErrorType { 11 | InvalidPayId = 'InvalidPayId', 12 | EmptyStringViolation = 'EmptyStringViolation', 13 | StringCaseViolation = 'StringCaseViolation', 14 | UniqueConstraintViolation = 'UniqueConstraintViolation', 15 | NotNullViolation = 'NotNullViolation', 16 | Unknown = 'Unknown', 17 | } 18 | 19 | /** 20 | * A enum for the different error messages associated with different kinds of DatabaseErrors. 21 | * 22 | * Exported for testing purposes. 23 | */ 24 | export enum DatabaseErrorMessage { 25 | InvalidPayId = 'The PayID provided was in an invalid format', 26 | 27 | EmptyStringPayId = 'The PayID was an empty string, which is invalid', 28 | EmptyStringPaymentNetwork = "The 'payment_network' of an address was an empty string, which is invalid", 29 | EmptyStringEnvironment = "The 'environment' of an address was an empty string, which is invalid", 30 | 31 | StringCasePayId = 'The PayID provided had uppercase characters, but must be all lowercase', 32 | StringCasePaymentNetwork = "The 'payment_network' provided had lowercase characters, but must be all uppercase", 33 | StringCaseEnvironment = "The 'environment' provided had lowercase characters, but must be all uppercase", 34 | 35 | UniqueConstraintPayId = 'There already exists a user with the provided PayID', 36 | UniqueConstraintAddress = 'More than one address for the same (payment_network, environment) tuple was provided', 37 | 38 | NotNull = 'NULL was given for a required value.', 39 | Unknown = 'An unknown error occurred.', 40 | } 41 | 42 | /** 43 | * A custom error class for problems encountered with running a database query. 44 | * 45 | * For example, A DatabaseError[NotNullViolation] is raised if we try updating/inserting 46 | * a non-nullable column with a NULL value. 47 | * 48 | * Exported for testing purposes. 49 | */ 50 | export default class DatabaseError extends PayIDError { 51 | public readonly kind: DatabaseErrorType 52 | 53 | /** 54 | * The constructor for new DatabaseErrors. 55 | * 56 | * @param message - The error message. 57 | * @param kind - The kind of DatabaseError. 58 | * @param status - An HTTP response code. 59 | */ 60 | public constructor( 61 | message: string, 62 | kind: DatabaseErrorType, 63 | status: HttpStatus, 64 | ) { 65 | super(message, status) 66 | 67 | this.kind = kind 68 | } 69 | } 70 | 71 | /* eslint-disable max-lines-per-function -- 72 | * TODO:(hbergren), it might be worth refactoring this into smaller helper functions, 73 | * to make this easier to reason about. 74 | */ 75 | /** 76 | * Map a raw error raised by Postgres/Knex into a custom DatabaseError. 77 | * 78 | * @param error - A raw SQL error raised by Postgres/Knex. 79 | * 80 | * @throws A custom DatabaseError which wraps the raw error from the database. 81 | */ 82 | export function handleDatabaseError(error: Error): never { 83 | logger.debug(error) 84 | 85 | // InvalidPayId Errors 86 | if (error.message.includes('valid_pay_id')) { 87 | throw new DatabaseError( 88 | DatabaseErrorMessage.InvalidPayId, 89 | DatabaseErrorType.InvalidPayId, 90 | HttpStatus.BadRequest, 91 | ) 92 | } 93 | 94 | // EmptyStringViolation Errors 95 | if (error.message.includes('pay_id_length_nonzero')) { 96 | throw new DatabaseError( 97 | DatabaseErrorMessage.EmptyStringPayId, 98 | DatabaseErrorType.EmptyStringViolation, 99 | HttpStatus.BadRequest, 100 | ) 101 | } 102 | 103 | if (error.message.includes('payment_network_length_nonzero')) { 104 | throw new DatabaseError( 105 | DatabaseErrorMessage.EmptyStringPaymentNetwork, 106 | DatabaseErrorType.EmptyStringViolation, 107 | HttpStatus.BadRequest, 108 | ) 109 | } 110 | 111 | if (error.message.includes('environment_length_nonzero')) { 112 | throw new DatabaseError( 113 | DatabaseErrorMessage.EmptyStringEnvironment, 114 | DatabaseErrorType.EmptyStringViolation, 115 | HttpStatus.BadRequest, 116 | ) 117 | } 118 | 119 | // StringCaseViolation Errors 120 | if (error.message.includes('pay_id_lowercase')) { 121 | throw new DatabaseError( 122 | DatabaseErrorMessage.StringCasePayId, 123 | DatabaseErrorType.StringCaseViolation, 124 | HttpStatus.InternalServerError, 125 | ) 126 | } 127 | 128 | if (error.message.includes('payment_network_uppercase')) { 129 | throw new DatabaseError( 130 | DatabaseErrorMessage.StringCasePaymentNetwork, 131 | DatabaseErrorType.StringCaseViolation, 132 | HttpStatus.InternalServerError, 133 | ) 134 | } 135 | 136 | if (error.message.includes('environment_uppercase')) { 137 | throw new DatabaseError( 138 | DatabaseErrorMessage.StringCaseEnvironment, 139 | DatabaseErrorType.StringCaseViolation, 140 | HttpStatus.InternalServerError, 141 | ) 142 | } 143 | 144 | // UniqueConstraintViolation Errors 145 | if (error.message.includes('account_pay_id_key')) { 146 | throw new DatabaseError( 147 | DatabaseErrorMessage.UniqueConstraintPayId, 148 | DatabaseErrorType.UniqueConstraintViolation, 149 | HttpStatus.Conflict, 150 | ) 151 | } 152 | 153 | if ( 154 | error.message.includes( 155 | 'one_address_per_account_payment_network_environment_tuple', 156 | ) 157 | ) { 158 | throw new DatabaseError( 159 | DatabaseErrorMessage.UniqueConstraintAddress, 160 | DatabaseErrorType.UniqueConstraintViolation, 161 | HttpStatus.Conflict, 162 | ) 163 | } 164 | 165 | // General errors 166 | if (error.message.includes('violates not-null constraint')) { 167 | throw new DatabaseError( 168 | DatabaseErrorMessage.NotNull, 169 | DatabaseErrorType.NotNullViolation, 170 | HttpStatus.InternalServerError, 171 | ) 172 | } 173 | 174 | throw new DatabaseError( 175 | DatabaseErrorMessage.Unknown, 176 | DatabaseErrorType.Unknown, 177 | HttpStatus.InternalServerError, 178 | ) 179 | 180 | // TODO:(hbergren) Knex does not yet handle connection errors: 181 | // https://github.com/knex/knex/issues/3113 182 | } 183 | /* eslint-enable max-lines-per-function */ 184 | -------------------------------------------------------------------------------- /src/utils/errors/handleHttpError.ts: -------------------------------------------------------------------------------- 1 | import * as Boom from '@hapi/boom' 2 | import HttpStatus from '@xpring-eng/http-status' 3 | import { Response } from 'express' 4 | 5 | import logger from '../logger' 6 | 7 | /** 8 | * A helper function for logging errors and sending the HTTP error response. 9 | * 10 | * @param errorCode - An HTTP error code. 11 | * @param msg - The error message. 12 | * @param res - An Express Response object, for sending the HTTP response. 13 | * @param err - The associated error object (optional). 14 | * 15 | * TODO:(hbergren) Kill this function and put this logic in our errorHandler. 16 | */ 17 | export default function handleHttpError( 18 | errorCode: number, 19 | msg: string, 20 | res: Response, 21 | err?: Error, 22 | ): void { 23 | // Logging for our debugging purposes 24 | if (errorCode >= HttpStatus.InternalServerError) { 25 | logger.error(errorCode, ':', err?.toString() ?? msg) 26 | } else { 27 | logger.warn(errorCode, ':', err?.toString() ?? msg) 28 | } 29 | 30 | // Error code matching 31 | let error: Boom.Payload 32 | switch (errorCode) { 33 | case HttpStatus.BadRequest: 34 | error = Boom.badRequest(msg).output.payload 35 | break 36 | 37 | case HttpStatus.NotFound: 38 | error = Boom.notFound(msg).output.payload 39 | break 40 | 41 | case HttpStatus.Conflict: 42 | error = Boom.conflict(msg).output.payload 43 | break 44 | 45 | case HttpStatus.UnsupportedMediaType: 46 | error = Boom.unsupportedMediaType(msg).output.payload 47 | break 48 | 49 | default: 50 | // This is a 500 internal server error 51 | error = Boom.badImplementation(msg).output.payload 52 | } 53 | 54 | res.status(errorCode).json(error) 55 | } 56 | -------------------------------------------------------------------------------- /src/utils/errors/index.ts: -------------------------------------------------------------------------------- 1 | import ContentTypeError from './contentTypeError' 2 | import DatabaseError, { 3 | DatabaseErrorMessage, 4 | handleDatabaseError, 5 | } from './databaseError' 6 | import handleHttpError from './handleHttpError' 7 | import LookupError, { LookupErrorType } from './lookupError' 8 | import ParseError, { ParseErrorType } from './parseError' 9 | import PayIDError from './payIdError' 10 | 11 | export { 12 | DatabaseError, 13 | DatabaseErrorMessage, 14 | handleDatabaseError, 15 | handleHttpError, 16 | PayIDError, 17 | ParseError, 18 | ParseErrorType, 19 | LookupError, 20 | LookupErrorType, 21 | ContentTypeError, 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/errors/lookupError.ts: -------------------------------------------------------------------------------- 1 | import HttpStatus from '@xpring-eng/http-status' 2 | 3 | import { ParsedAcceptHeader } from '../../types/headers' 4 | 5 | import PayIDError from './payIdError' 6 | 7 | export enum LookupErrorType { 8 | MissingPayId = 'MissingPayId', 9 | MissingAddress = 'MissingAddress', 10 | // TODO: Remove Unknown after MissingPayId/MissingAddress are implemented 11 | Unknown = 'Unknown', 12 | } 13 | 14 | /** 15 | * A custom error class to organize logic around errors related to a 404 - Not Found. 16 | */ 17 | export default class LookupError extends PayIDError { 18 | public readonly kind: LookupErrorType 19 | public readonly headers?: readonly ParsedAcceptHeader[] 20 | 21 | /** 22 | * The constructor for new LookupErrors. 23 | * 24 | * @param message - An error message. 25 | * @param kind - The kind of LookupError for this instance. 26 | * @param headers - The headers used for the PayID lookup. 27 | */ 28 | public constructor( 29 | message: string, 30 | kind: LookupErrorType, 31 | headers?: readonly ParsedAcceptHeader[], 32 | ) { 33 | // All lookup errors should return a 404 - Not Found 34 | super(message, HttpStatus.NotFound) 35 | this.kind = kind 36 | this.headers = headers 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/errors/parseError.ts: -------------------------------------------------------------------------------- 1 | import HttpStatus from '@xpring-eng/http-status' 2 | 3 | import PayIDError from './payIdError' 4 | 5 | /** 6 | * An enum containing the different kinds of ParseErrors. 7 | */ 8 | export enum ParseErrorType { 9 | InvalidMediaType = 'InvalidMediaType', 10 | 11 | // PayID Stuff 12 | MissingPayId = 'MissingPayId', 13 | InvalidPayId = 'InvalidPayId', 14 | 15 | // Verifiable PayID stuff 16 | MultipleIdentityKeys = 'MultipleIdentityKeys', 17 | InvalidIdentityKey = 'InvalidIdentityKey', 18 | 19 | // These are the Public API version header errors for the PayID Protocol. 20 | MissingPayIdVersionHeader = 'MissingPayIdVersionHeader', 21 | InvalidPayIdVersionHeader = 'InvalidPayIdVersionHeader', 22 | UnsupportedPayIdVersionHeader = 'UnsupportedPayIdVersionHeader', 23 | 24 | // These are the Admin API version header errors, for the CRUD PayID API service. 25 | MissingPayIdApiVersionHeader = 'MissingPayIdApiVersionHeader', 26 | InvalidPayIdApiVersionHeader = 'InvalidPayIdApiVersionHeader', 27 | UnsupportedPayIdApiVersionHeader = 'UnsupportedPayIdApiVersionHeader', 28 | 29 | // Verifiable PayID Stuff 30 | IncompatibleRequestMethod = 'IncompatibleRequestMethod', 31 | } 32 | 33 | /** 34 | * A custom error type to organize logic around 400 - Bad Request errors. 35 | */ 36 | export default class ParseError extends PayIDError { 37 | public readonly kind: ParseErrorType 38 | 39 | /** 40 | * The constructor for new ParseErrors. 41 | * 42 | * @param message - An error message. 43 | * @param kind - The kind of ParseError for this error instance. 44 | */ 45 | public constructor(message: string, kind: ParseErrorType) { 46 | // All parsing errors are the result of a bad request 47 | super(message, HttpStatus.BadRequest) 48 | this.kind = kind 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/errors/payIdError.ts: -------------------------------------------------------------------------------- 1 | import HttpStatus from '@xpring-eng/http-status' 2 | 3 | /** 4 | * Custom Errors for PayID. 5 | */ 6 | export default abstract class PayIDError extends Error { 7 | public readonly httpStatusCode: HttpStatus 8 | public abstract readonly kind: string 9 | 10 | /** 11 | * Create a new PayIDError instance. 12 | * 13 | * @param message - The error message. 14 | * @param status - An HttpStatus code associated with this error. 15 | */ 16 | public constructor(message: string, status: HttpStatus) { 17 | super(message) 18 | 19 | // All our custom errors will extend PayIDError 20 | // So use the name of the class extending PayIDError 21 | this.name = this.constructor.name 22 | this.httpStatusCode = status 23 | } 24 | 25 | /** 26 | * A custom toString method, 27 | * so our custom Errors include their `kind` when converted to a string. 28 | * 29 | * @returns A string representation of the PayIDError. 30 | */ 31 | public toString(): string { 32 | return `${this.name}[${this.kind}]: ${this.message}` 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import createLogger from '@xpring-eng/logger' 2 | 3 | import config from '../config' 4 | 5 | const logger = createLogger(config.app.logLevel) 6 | 7 | export default logger 8 | -------------------------------------------------------------------------------- /test/global.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable mocha/no-top-level-hooks -- 2 | * This is the file specifically for top-level hooks to run before/after ALL tests. 3 | */ 4 | import 'mocha' 5 | import knex from '../src/db/knex' 6 | import logger from '../src/utils/logger' 7 | 8 | // We can use a before block outside any describe block to execute code before any test runs. 9 | // Here, we disable logging for all tests. 10 | before(function () { 11 | logger.level = 'FATAL' 12 | }) 13 | 14 | // Close DB connections after all tests are run 15 | after(async function () { 16 | await knex.destroy() 17 | }) 18 | -------------------------------------------------------------------------------- /test/helpers/helpers.ts: -------------------------------------------------------------------------------- 1 | import * as v8 from 'v8' 2 | 3 | import App from '../../src/app' 4 | import config from '../../src/config' 5 | import knex from '../../src/db/knex' 6 | import syncDatabaseSchema from '../../src/db/syncDatabaseSchema' 7 | 8 | /** 9 | * Deep clones an object *properly*. 10 | * 11 | * @param obj - The object to be deep cloned. 12 | * 13 | * @returns A deeply cloned object. 14 | */ 15 | function structuredClone(obj: T): T { 16 | return v8.deserialize(v8.serialize(obj)) 17 | } 18 | 19 | /** 20 | * Boots up the Express application for testing purposes. 21 | * The first time this is run it will initialize the database connection pool. 22 | * 23 | * @returns The Express application. 24 | */ 25 | export async function appSetup(): Promise { 26 | const app = new App() 27 | 28 | // Deep cloning the configuration so we don't mutate the global shared configuration 29 | const testConfig = structuredClone(config) 30 | testConfig.database.options.seedDatabase = true 31 | 32 | await app.init(testConfig) 33 | 34 | return app 35 | } 36 | 37 | /** 38 | * Shuts down the Express application, so there are not running processes when testing ends. 39 | * 40 | * @param app - The Express app. 41 | */ 42 | export function appCleanup(app: App): void { 43 | app.close() 44 | } 45 | 46 | export async function seedDatabase(): Promise { 47 | // Deep cloning the configuration so we don't mutate the global shared configuration 48 | const testConfig = structuredClone(config) 49 | testConfig.database.options.seedDatabase = true 50 | 51 | await syncDatabaseSchema(testConfig.database) 52 | } 53 | 54 | /** 55 | * Gets the definition of a database constraint. 56 | * 57 | * @param constraintName - The name of the constraint. 58 | * @param tableName - The name of the table associated with the constraint. 59 | * 60 | * @returns The constraint definition from the database. 61 | */ 62 | export async function getDatabaseConstraintDefinition( 63 | constraintName: string, 64 | tableName: string, 65 | ): Promise { 66 | return knex 67 | .raw( 68 | ` 69 | -- Select the constraint definition in the relevant table. 70 | -- We fetch the relevant constraint, get the constraint definition. 71 | -- 72 | SELECT pg_get_constraintdef(con.oid) as constraint_def 73 | FROM pg_constraint con 74 | INNER JOIN pg_class rel ON rel.oid = con.conrelid 75 | WHERE con.conname = ? 76 | AND rel.relname = ?; 77 | `, 78 | [constraintName, tableName], 79 | ) 80 | .then(async (result) => result.rows[0].constraint_def) 81 | } 82 | -------------------------------------------------------------------------------- /test/integration/data-access/databaseErrors.test.ts: -------------------------------------------------------------------------------- 1 | import 'mocha' 2 | import * as chai from 'chai' 3 | import * as chaiAsPromised from 'chai-as-promised' 4 | 5 | import { insertUser } from '../../../src/data-access/users' 6 | import { DatabaseError, DatabaseErrorMessage } from '../../../src/utils/errors' 7 | import { seedDatabase } from '../../helpers/helpers' 8 | 9 | chai.use(chaiAsPromised) 10 | const { assert } = chai 11 | 12 | describe('Data Access - Database Errors', function (): void { 13 | before(async function () { 14 | await seedDatabase() 15 | }) 16 | 17 | const exampleAddresses = [ 18 | { 19 | paymentNetwork: 'ABC', 20 | environment: 'XYZ', 21 | details: { 22 | address: 'abc.xyz', 23 | tag: '123', 24 | }, 25 | }, 26 | ] 27 | 28 | // Account table errors (PayID) 29 | 30 | it('Raises an error when attempting to insert a user with an null PayID', async function () { 31 | // GIVEN a PayID and associated addresses to insert 32 | const payId = null 33 | 34 | // WHEN we insert the user into the database 35 | // @ts-expect-error -- In production we verify that the PayID is not null, but we want to test the DatabaseError 36 | const insertedAddresses = insertUser(payId, exampleAddresses) 37 | 38 | // THEN we get a DatabaseError with our expected error message 39 | return assert.isRejected( 40 | insertedAddresses, 41 | DatabaseError, 42 | DatabaseErrorMessage.NotNull, 43 | ) 44 | }) 45 | 46 | it('Raises an error when attempting to insert a user with an empty PayID', async function () { 47 | // GIVEN a PayID and associated addresses to insert 48 | const payId = '' 49 | 50 | // WHEN we insert the user into the database 51 | const insertedAddresses = insertUser(payId, exampleAddresses) 52 | 53 | // THEN we get a DatabaseError with our expected error message 54 | return assert.isRejected( 55 | insertedAddresses, 56 | DatabaseError, 57 | DatabaseErrorMessage.EmptyStringPayId, 58 | ) 59 | }) 60 | 61 | it('Raises an error when attempting to insert a user with an uppercase PayID', async function () { 62 | // GIVEN a PayID and associated addresses to insert 63 | const payId = 'ALICE$example.com' 64 | 65 | // WHEN we insert the user into the database 66 | const insertedAddresses = insertUser(payId, exampleAddresses) 67 | 68 | // THEN we get a DatabaseError with our expected error message 69 | return assert.isRejected( 70 | insertedAddresses, 71 | DatabaseError, 72 | DatabaseErrorMessage.StringCasePayId, 73 | ) 74 | }) 75 | 76 | it('Raises an error when attempting to insert a user with a PayID already in use', async function () { 77 | // GIVEN a PayID and associated addresses to insert 78 | const payId = 'alice$xpring.money' 79 | 80 | // WHEN we insert the user into the database 81 | const insertedAddresses = insertUser(payId, exampleAddresses) 82 | 83 | // THEN we get a DatabaseError with our expected error message 84 | return assert.isRejected( 85 | insertedAddresses, 86 | DatabaseError, 87 | DatabaseErrorMessage.UniqueConstraintPayId, 88 | ) 89 | }) 90 | 91 | // Address table errors 92 | 93 | it('Raises an error when attempting to insert an address with a NULL details payload', async function () { 94 | // GIVEN a PayID and associated addresses to insert 95 | const payId = 'alice$example.com' 96 | const addresses = [ 97 | { 98 | paymentNetwork: 'XRPL', 99 | environment: 'TESTNET', 100 | }, 101 | ] 102 | 103 | // WHEN we insert the user into the database 104 | // @ts-expect-error -- We are testing the DatabaseError, so need to ignore the typing information 105 | const insertedAddresses = insertUser(payId, addresses) 106 | 107 | // THEN we get a DatabaseError with our expected error message 108 | return assert.isRejected( 109 | insertedAddresses, 110 | DatabaseError, 111 | DatabaseErrorMessage.NotNull, 112 | ) 113 | }) 114 | 115 | it('Raises an error when attempting to insert an address with an empty paymentNetwork', async function () { 116 | // GIVEN a PayID and associated addresses to insert 117 | const payId = 'alice$example.com' 118 | const addresses = [ 119 | { 120 | paymentNetwork: '', 121 | environment: 'TESTNET', 122 | details: { 123 | address: 'abc', 124 | }, 125 | }, 126 | ] 127 | 128 | // WHEN we insert the user into the database 129 | const insertedAddresses = insertUser(payId, addresses) 130 | 131 | // THEN we get a DatabaseError with our expected error message 132 | return assert.isRejected( 133 | insertedAddresses, 134 | DatabaseError, 135 | DatabaseErrorMessage.EmptyStringPaymentNetwork, 136 | ) 137 | }) 138 | 139 | it('Raises an error when attempting to insert an address with an empty environment', async function () { 140 | // GIVEN a PayID and associated addresses to insert 141 | const payId = 'alice$example.com' 142 | const addresses = [ 143 | { 144 | paymentNetwork: 'XRPL', 145 | environment: '', 146 | details: { 147 | address: 'abc', 148 | }, 149 | }, 150 | ] 151 | 152 | // WHEN we insert the user into the database 153 | const insertedAddresses = insertUser(payId, addresses) 154 | 155 | // THEN we get a DatabaseError with our expected error message 156 | return assert.isRejected( 157 | insertedAddresses, 158 | DatabaseError, 159 | DatabaseErrorMessage.EmptyStringEnvironment, 160 | ) 161 | }) 162 | 163 | it('Raises an error when attempting to insert multiple addresses for the same (paymentNetwork, environment)', async function () { 164 | // GIVEN a PayID and associated addresses to insert 165 | const payId = 'alice$example.com' 166 | const addresses = [ 167 | { 168 | paymentNetwork: 'XRPL', 169 | environment: 'TESTNET', 170 | details: { 171 | address: 'abc', 172 | }, 173 | }, 174 | { 175 | paymentNetwork: 'XRPL', 176 | environment: 'TESTNET', 177 | details: { 178 | address: 'xyz', 179 | }, 180 | }, 181 | ] 182 | 183 | // WHEN we insert the user into the database 184 | const insertedAddresses = insertUser(payId, addresses) 185 | 186 | // THEN we get a DatabaseError with our expected error message 187 | return assert.isRejected( 188 | insertedAddresses, 189 | DatabaseError, 190 | DatabaseErrorMessage.UniqueConstraintAddress, 191 | ) 192 | }) 193 | }) 194 | -------------------------------------------------------------------------------- /test/integration/data-access/payIdRegex.test.ts: -------------------------------------------------------------------------------- 1 | import 'mocha' 2 | import * as chai from 'chai' 3 | import * as chaiAsPromised from 'chai-as-promised' 4 | 5 | import { getAllAddressInfoFromDatabase } from '../../../src/data-access/payIds' 6 | import { insertUser } from '../../../src/data-access/users' 7 | 8 | chai.use(chaiAsPromised) 9 | const { assert } = chai 10 | 11 | describe('Data Access - PayID Regex - insertUser()', function (): void { 12 | const addresses = [ 13 | { 14 | paymentNetwork: 'XRPL', 15 | environment: 'MAINNET', 16 | details: { 17 | address: 'XV5sbjUmgPpvXv4ixFWZ5ptAYZ6PD28Sq49uo34VyjnmK5H', 18 | }, 19 | }, 20 | ] 21 | 22 | // specific regex we are testing: 23 | // constraint = valid_pay_id 24 | // location = src/db/migrations/02_change_pay_id_format_constraint.sql 25 | 26 | it('Accepts PayID with a lowercase letter for user', async function () { 27 | // GIVEN an acceptable PayID with a lowercase letter for the user 28 | const payId = 'a$wallet.com' 29 | 30 | // WHEN we attempt to insert that PayID into our DB 31 | await insertUser(payId, addresses) 32 | 33 | // THEN we expect the user to have been successfully inserted 34 | const resp = await getAllAddressInfoFromDatabase(payId) 35 | assert.deepEqual(resp, addresses) 36 | }) 37 | 38 | it('Rejects PayID with a uppercase letter for user', async function () { 39 | // GIVEN an unacceptable PayID with an uppercase letter for the user 40 | const payId = 'A$wallet.com' 41 | 42 | // WHEN we attempt to insert that PayID into our DB 43 | const insertion = insertUser(payId, addresses) 44 | 45 | // THEN we expect insert to throw an error 46 | // NOTE: We need to return the assertion here because we are using chai-as-promised 47 | return assert.isRejected(insertion) 48 | }) 49 | 50 | it('Accepts PayID with a number for user', async function () { 51 | // GIVEN an acceptable PayID with a number for the user 52 | const payId = '1$wallet.com' 53 | 54 | // WHEN we attempt to insert that PayID into our DB 55 | await insertUser(payId, addresses) 56 | 57 | // THEN we expect the user to have been successfully inserted 58 | const resp = await getAllAddressInfoFromDatabase(payId) 59 | assert.deepEqual(resp, addresses) 60 | }) 61 | 62 | it('Accepts PayID with a user containing a period', async function () { 63 | // GIVEN an acceptable PayID with a user containing a period 64 | const payId = 'first.last$wallet.com' 65 | 66 | // WHEN we attempt to insert that PayID into our DB 67 | await insertUser(payId, addresses) 68 | 69 | // THEN we expect the user to have been successfully inserted 70 | const resp = await getAllAddressInfoFromDatabase(payId) 71 | assert.deepEqual(resp, addresses) 72 | }) 73 | 74 | it('Accepts PayID with an _ for user', async function () { 75 | // GIVEN an acceptable PayID with an _ for the user 76 | const payId = '_$wallet.com' 77 | 78 | // WHEN we attempt to insert that PayID into our DB 79 | await insertUser(payId, addresses) 80 | 81 | // THEN we expect the user to have been successfully inserted 82 | const resp = await getAllAddressInfoFromDatabase(payId) 83 | assert.deepEqual(resp, addresses) 84 | }) 85 | 86 | it('Accepts PayID with a hyphen for user', async function () { 87 | // GIVEN an acceptable PayID with a hypen for the user 88 | const payId = '-$wallet.com' 89 | 90 | // WHEN we attempt to insert that PayID into our DB 91 | await insertUser(payId, addresses) 92 | 93 | // THEN we expect the user to have been successfully inserted 94 | const resp = await getAllAddressInfoFromDatabase(payId) 95 | assert.deepEqual(resp, addresses) 96 | }) 97 | 98 | it('Accepts PayID with normal host', async function () { 99 | // GIVEN an acceptable PayID with a normal host 100 | const payId = 'user$wallet.com' 101 | 102 | // WHEN we attempt to insert that PayID into our DB 103 | await insertUser(payId, addresses) 104 | 105 | // THEN we expect the user to have been successfully inserted 106 | const resp = await getAllAddressInfoFromDatabase(payId) 107 | assert.deepEqual(resp, addresses) 108 | }) 109 | 110 | it('Accepts PayID with a subdomain', async function () { 111 | // GIVEN an acceptable PayID a subdomain 112 | const payId = 'user$subdomain.wallet.com' 113 | 114 | // WHEN we attempt to insert that PayID into our DB 115 | await insertUser(payId, addresses) 116 | 117 | // THEN we expect the user to have been successfully inserted 118 | const resp = await getAllAddressInfoFromDatabase(payId) 119 | assert.deepEqual(resp, addresses) 120 | }) 121 | }) 122 | -------------------------------------------------------------------------------- /test/integration/data-access/payIds.test.ts: -------------------------------------------------------------------------------- 1 | import 'mocha' 2 | import { assert } from 'chai' 3 | 4 | import { getAllAddressInfoFromDatabase } from '../../../src/data-access/payIds' 5 | import { seedDatabase } from '../../helpers/helpers' 6 | 7 | describe('Data Access - getAllAddressInfoFromDatabase()', function (): void { 8 | before(async function () { 9 | await seedDatabase() 10 | }) 11 | 12 | it('Gets address information for a known PayID (1 address)', async function () { 13 | // GIVEN a PayID known to exist in the database 14 | // WHEN we attempt to retrieve address information for that tuple 15 | const addressInfo = await getAllAddressInfoFromDatabase( 16 | 'alice$xpring.money', 17 | ) 18 | 19 | // THEN we get our seeded value back 20 | const expectedaddressInfo = [ 21 | { 22 | paymentNetwork: 'XRPL', 23 | environment: 'TESTNET', 24 | details: { 25 | address: 'rDk7FQvkQxQQNGTtfM2Fr66s7Nm3k87vdS', 26 | }, 27 | }, 28 | ] 29 | assert.deepEqual(addressInfo, expectedaddressInfo) 30 | }) 31 | 32 | it('Gets address information for a known PayID (multiple addresses)', async function () { 33 | // GIVEN a PayID known to exist in the database 34 | // WHEN we attempt to retrieve address information for that tuple 35 | const addressInfo = await getAllAddressInfoFromDatabase('alice$127.0.0.1') 36 | 37 | // THEN we get our seeded values back 38 | const expectedaddressInfo = [ 39 | { 40 | paymentNetwork: 'XRPL', 41 | environment: 'MAINNET', 42 | details: { 43 | address: 'rw2ciyaNshpHe7bCHo4bRWq6pqqynnWKQg', 44 | tag: '67298042', 45 | }, 46 | }, 47 | { 48 | paymentNetwork: 'XRPL', 49 | environment: 'TESTNET', 50 | details: { 51 | address: 'rDk7FQvkQxQQNGTtfM2Fr66s7Nm3k87vdS', 52 | }, 53 | }, 54 | { 55 | paymentNetwork: 'BTC', 56 | environment: 'TESTNET', 57 | details: { 58 | address: 'mxNEbRXokcdJtT6sbukr1CTGVx8Tkxk3DB', 59 | }, 60 | }, 61 | { 62 | paymentNetwork: 'ACH', 63 | environment: null, 64 | details: { 65 | accountNumber: '000123456789', 66 | routingNumber: '123456789', 67 | }, 68 | }, 69 | ] 70 | assert.deepEqual(addressInfo, expectedaddressInfo) 71 | }) 72 | 73 | it('Returns empty array for an unknown PayID', async function () { 74 | // GIVEN a PayID known to not exist in the database 75 | // WHEN we attempt to retrieve address information for that tuple 76 | const addressInfo = await getAllAddressInfoFromDatabase('johndoe$xpring.io') 77 | 78 | // THEN we get back an empty array 79 | assert.deepStrictEqual(addressInfo, []) 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /test/integration/data-access/reports.test.ts: -------------------------------------------------------------------------------- 1 | import 'mocha' 2 | import { assert } from 'chai' 3 | 4 | import { 5 | getAddressCounts, 6 | getPayIdCount, 7 | } from '../../../src/data-access/reports' 8 | import { seedDatabase } from '../../helpers/helpers' 9 | 10 | describe('Data Access - getPayIdCounts()', function (): void { 11 | before(async function () { 12 | await seedDatabase() 13 | }) 14 | 15 | it('getAddressCounts - Returns a result per by unique network and environment', async function () { 16 | const results = await getAddressCounts() 17 | const expected = [ 18 | { 19 | paymentNetwork: 'ACH', 20 | environment: null, 21 | count: 2, 22 | }, 23 | { 24 | paymentNetwork: 'BTC', 25 | environment: 'MAINNET', 26 | count: 1, 27 | }, 28 | { 29 | paymentNetwork: 'BTC', 30 | environment: 'TESTNET', 31 | count: 4, 32 | }, 33 | { 34 | paymentNetwork: 'INTERLEDGER', 35 | environment: 'TESTNET', 36 | count: 1, 37 | }, 38 | { 39 | paymentNetwork: 'XRPL', 40 | environment: 'MAINNET', 41 | count: 4, 42 | }, 43 | { 44 | paymentNetwork: 'XRPL', 45 | environment: 'TESTNET', 46 | count: 6, 47 | }, 48 | ] 49 | assert.deepEqual(results, expected) 50 | }) 51 | 52 | it('getPayIdCount - Returns a count of PayIDs', async function () { 53 | const payIdCount = await getPayIdCount() 54 | const expectedPayIdCount = 13 55 | 56 | assert.strictEqual(payIdCount, expectedPayIdCount) 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /test/integration/e2e/admin-api/deleteUsers.test.ts: -------------------------------------------------------------------------------- 1 | import HttpStatus from '@xpring-eng/http-status' 2 | import * as request from 'supertest' 3 | import 'mocha' 4 | 5 | import App from '../../../../src/app' 6 | import { appSetup, appCleanup } from '../../../helpers/helpers' 7 | 8 | let app: App 9 | const payIdApiVersion = '2020-05-28' 10 | 11 | const acceptPatch = 'application/merge-patch+json' 12 | 13 | describe('E2E - adminApiRouter - DELETE /users', function (): void { 14 | before(async function () { 15 | app = await appSetup() 16 | }) 17 | 18 | it('Returns a 204 and no payload when deleting an account', function (done): void { 19 | // GIVEN a PayID known to resolve to an account on the PayID service 20 | const payId = 'alice$xpring.money' 21 | const missingPayIdError = { 22 | error: 'Not Found', 23 | message: `No information could be found for the PayID ${payId}.`, 24 | statusCode: 404, 25 | } 26 | 27 | // WHEN we make a DELETE request to /users/ with the PayID to delete 28 | request(app.adminApiExpress) 29 | .delete(`/users/${payId}`) 30 | .set('PayID-API-Version', payIdApiVersion) 31 | // THEN we expect to have an Accept-Patch header in the response 32 | .expect('Accept-Patch', acceptPatch) 33 | // THEN we expect back a 204-No Content, indicating successful deletion 34 | .expect(HttpStatus.NoContent) 35 | .then((_res) => { 36 | // AND subsequent GET requests to that PayID now return a 404 37 | request(app.adminApiExpress) 38 | .get(`/users/${payId}`) 39 | .set('PayID-API-Version', payIdApiVersion) 40 | .expect(HttpStatus.NotFound, missingPayIdError, done) 41 | }) 42 | .catch((err) => { 43 | done(err) 44 | }) 45 | }) 46 | 47 | it('Returns a 204 and no payload when deleting an account given an uppercase PayID', function (done): void { 48 | // GIVEN a PayID known to resolve to an account on the PayID service 49 | const payId = 'BOB$XPRING.MONEY' 50 | const missingPayIdError = { 51 | error: 'Not Found', 52 | message: `No information could be found for the PayID ${payId.toLowerCase()}.`, 53 | statusCode: 404, 54 | } 55 | 56 | // WHEN we make a DELETE request to /users/ with the PayID to delete 57 | request(app.adminApiExpress) 58 | .delete(`/users/${payId}`) 59 | .set('PayID-API-Version', payIdApiVersion) 60 | // THEN we expect to have an Accept-Patch header in the response 61 | .expect('Accept-Patch', acceptPatch) 62 | // THEN we expect back a 204-No Content, indicating successful deletion 63 | .expect(HttpStatus.NoContent) 64 | .then((_res) => { 65 | // AND subsequent GET requests to that PayID now return a 404 66 | request(app.adminApiExpress) 67 | .get(`/users/${payId.toLowerCase()}`) 68 | .set('PayID-API-Version', payIdApiVersion) 69 | .expect(HttpStatus.NotFound, missingPayIdError, done) 70 | }) 71 | .catch((err) => { 72 | done(err) 73 | }) 74 | }) 75 | 76 | it('Returns a 204 when attempting to delete an account that does not exist', function (done): void { 77 | // GIVEN a PayID known to not exist on the PayID service 78 | const payId = 'johndoe$xpring.money' 79 | 80 | // WHEN we make a DELETE request to /users/ with the PayID to delete 81 | request(app.adminApiExpress) 82 | .delete(`/users/${payId}`) 83 | .set('PayID-API-Version', payIdApiVersion) 84 | // THEN we expect to have an Accept-Patch header in the response 85 | .expect('Accept-Patch', acceptPatch) 86 | // THEN we expect back a 204 - No Content 87 | .expect(HttpStatus.NoContent, done) 88 | }) 89 | 90 | after(function () { 91 | appCleanup(app) 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /test/integration/e2e/admin-api/healthCheck.test.ts: -------------------------------------------------------------------------------- 1 | import 'mocha' 2 | 3 | import HttpStatus from '@xpring-eng/http-status' 4 | import * as request from 'supertest' 5 | 6 | import App from '../../../../src/app' 7 | import { appSetup, appCleanup } from '../../../helpers/helpers' 8 | 9 | let app: App 10 | 11 | describe('E2E - adminApiRouter - Health Check', function (): void { 12 | before(async function () { 13 | app = await appSetup() 14 | }) 15 | 16 | it('Returns a 200 - OK for a GET /status/health', function (done): void { 17 | request(app.adminApiExpress) 18 | .get('/status/health') 19 | .expect(HttpStatus.OK, 'OK', done) 20 | }) 21 | 22 | after(function () { 23 | appCleanup(app) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /test/integration/e2e/admin-api/optionsUsersPayId.test.ts: -------------------------------------------------------------------------------- 1 | import 'mocha' 2 | 3 | import HttpStatus from '@xpring-eng/http-status' 4 | import * as request from 'supertest' 5 | 6 | import App from '../../../../src/app' 7 | import { appSetup, appCleanup } from '../../../helpers/helpers' 8 | 9 | let app: App 10 | const payIdApiVersion = '2020-05-28' 11 | 12 | const contentType = 'application/merge-patch+json' 13 | 14 | describe('E2E - adminApiRouter - OPTIONS /users/:payId', function (): void { 15 | before(async function () { 16 | app = await appSetup() 17 | }) 18 | 19 | it('Returns a 200 with PATCH in the Allow header', function (done): void { 20 | // GIVEN a PayID known to resolve to an account on the PayID service 21 | const payId = 'alice$xpring.money' 22 | 23 | // WHEN we make an OPTIONS request to /users/:payId 24 | request(app.adminApiExpress) 25 | .options(`/users/${payId}`) 26 | .set('PayID-API-Version', payIdApiVersion) 27 | // THEN we expect the Allow header in the response 28 | .expect('Allow', /PATCH/u) 29 | // AND we expect back a 200-OK 30 | .expect(HttpStatus.OK, done) 31 | }) 32 | 33 | it('Returns a 200 with the appropriate Accept-Patch response header', function (done): void { 34 | // GIVEN a PayID known to resolve to an account on the PayID service 35 | const payId = 'alice$xpring.money' 36 | 37 | // WHEN we make an OPTIONS request to /users/:payId 38 | request(app.adminApiExpress) 39 | .options(`/users/${payId}`) 40 | .set('PayID-API-Version', payIdApiVersion) 41 | // THEN we expect the Accept-Patch header in the response 42 | .expect('Accept-Patch', contentType) 43 | // AND we expect back a 200-OK 44 | .expect(HttpStatus.OK, done) 45 | }) 46 | 47 | it('Returns a 400 - the PayID-API-Version header is missing', function (done): void { 48 | // GIVEN a PayID known to resolve to an account on the PayID service 49 | const payId = 'alice$xpring.money' 50 | // AND our expected error response 51 | const expectedErrorResponse = { 52 | error: 'Bad Request', 53 | message: 54 | "A PayID-API-Version header is required in the request, of the form 'PayID-API-Version: YYYY-MM-DD'.", 55 | statusCode: 400, 56 | } 57 | 58 | // WHEN we make an OPTIONS request to /users/:payId 59 | request(app.adminApiExpress) 60 | .options(`/users/${payId}`) 61 | // WITHOUT the 'PayID-API-Version' header 62 | .expect('Content-Type', /json/u) 63 | // THEN we expect the Accept-Patch header in the response 64 | .expect('Accept-Patch', contentType) 65 | // AND we expect back a 400 - Bad Request 66 | .expect(HttpStatus.BadRequest, expectedErrorResponse, done) 67 | }) 68 | 69 | after(function () { 70 | appCleanup(app) 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /test/integration/e2e/admin-api/privateApiVersionHeader.test.ts: -------------------------------------------------------------------------------- 1 | import 'mocha' 2 | 3 | import HttpStatus from '@xpring-eng/http-status' 4 | import * as request from 'supertest' 5 | 6 | import App from '../../../../src/app' 7 | import config, { adminApiVersions } from '../../../../src/config' 8 | import { appSetup, appCleanup } from '../../../helpers/helpers' 9 | 10 | let app: App 11 | 12 | describe('E2E - adminApiRouter - Version Headers', function (): void { 13 | // Boot up Express application and initialize DB connection pool 14 | before(async function () { 15 | app = await appSetup() 16 | }) 17 | 18 | it('Returns a 400 - Bad Request when we omit a PayID-API-Version header', function (done): void { 19 | // GIVEN a PayID known to resolve to an account on the PayID service 20 | const payId = 'alice$xpring.money' 21 | const expectedErrorResponse = { 22 | statusCode: 400, 23 | error: 'Bad Request', 24 | message: 25 | "A PayID-API-Version header is required in the request, of the form 'PayID-API-Version: YYYY-MM-DD'.", 26 | } 27 | 28 | // WHEN we make a GET request to /users/ with that PayID as our user 29 | request(app.adminApiExpress) 30 | .get(`/users/${payId}`) 31 | .expect('PayID-API-Server-Version', config.app.adminApiVersion) 32 | .expect('Content-Type', /json/u) 33 | // THEN We expect back a 400 - Bad Request 34 | .expect(HttpStatus.BadRequest, expectedErrorResponse, done) 35 | }) 36 | 37 | it('Returns a 400 - Bad Request when we provide a malformed PayID-API-Version header', function (done): void { 38 | // GIVEN a PayID known to resolve to an account on the PayID service 39 | const payId = 'alice$xpring.money' 40 | const payIdApiVersion = 'a-b-c' 41 | const expectedErrorResponse = { 42 | statusCode: 400, 43 | error: 'Bad Request', 44 | message: 45 | "A PayID-API-Version header must be in the form 'PayID-API-Version: YYYY-MM-DD'.", 46 | } 47 | 48 | // WHEN we make a GET request to /users/ with that PayID as our user 49 | request(app.adminApiExpress) 50 | .get(`/users/${payId}`) 51 | .set('PayID-API-Version', payIdApiVersion) 52 | .expect('PayID-API-Server-Version', config.app.adminApiVersion) 53 | .expect('Content-Type', /json/u) 54 | // THEN We expect back a 400 - Bad Request 55 | .expect(HttpStatus.BadRequest, expectedErrorResponse, done) 56 | }) 57 | 58 | it('Returns a 400 - Bad Request when we provide an unsupported PayID-API-Version header (less than first Version cut)', function (done): void { 59 | // GIVEN a PayID known to resolve to an account on the PayID service 60 | const payId = 'alice$xpring.money' 61 | const payIdApiVersion = '2020-05-27' 62 | const expectedErrorResponse = { 63 | statusCode: 400, 64 | error: 'Bad Request', 65 | message: `The PayID-API-Version ${payIdApiVersion} is not supported, please try upgrading your request to at least 'PayID-API-Version: ${adminApiVersions[0]}'`, 66 | } 67 | 68 | // WHEN we make a GET request to /users/ with that PayID as our user 69 | request(app.adminApiExpress) 70 | .get(`/users/${payId}`) 71 | .set('PayID-API-Version', payIdApiVersion) 72 | .expect('PayID-API-Server-Version', config.app.adminApiVersion) 73 | .expect('Content-Type', /json/u) 74 | // THEN We expect back a 400 - Bad Request 75 | .expect(HttpStatus.BadRequest, expectedErrorResponse, done) 76 | }) 77 | 78 | it('Returns a 200 - OK when we provide a valid PayID-Version header', function (done): void { 79 | // GIVEN a PayID known to resolve to an account on the PayID service 80 | const payId = 'alice$xpring.money' 81 | 82 | // WHEN we make a GET request to /users/ with that PayID as our user 83 | request(app.adminApiExpress) 84 | .get(`/users/${payId}`) 85 | .set('PayID-API-Version', config.app.adminApiVersion) 86 | .expect('PayID-API-Server-Version', config.app.adminApiVersion) 87 | .expect('Content-Type', /json/u) 88 | // THEN We expect back a 200 - OK, with the account information 89 | .expect(HttpStatus.OK, done) 90 | }) 91 | 92 | // Shut down Express application and close DB connections 93 | after(function () { 94 | appCleanup(app) 95 | }) 96 | }) 97 | -------------------------------------------------------------------------------- /test/integration/e2e/public-api/basePayIdContentNegotiation.test.ts: -------------------------------------------------------------------------------- 1 | import 'mocha' 2 | 3 | import HttpStatus from '@xpring-eng/http-status' 4 | import { assert } from 'chai' 5 | import * as request from 'supertest' 6 | 7 | import App from '../../../../src/app' 8 | import { appSetup, appCleanup } from '../../../helpers/helpers' 9 | 10 | import { 11 | XRPL_TESTNET_ADDRESS, 12 | XRPL_MAINNET_ADDRESS, 13 | XRPL_TESTNET_ACCEPT_HEADER, 14 | XRPL_MAINNET_ACCEPT_HEADER, 15 | } from './payloads' 16 | 17 | let app: App 18 | 19 | const USER = '/alice' 20 | const PAYID = `${USER.slice(1)}$127.0.0.1` 21 | const VERSION = '1.1' 22 | 23 | const XRPL_EXPECTED_TESTNET_RESPONSE = { 24 | addresses: [XRPL_TESTNET_ADDRESS], 25 | verifiedAddresses: [], 26 | payId: PAYID, 27 | version: VERSION, 28 | } 29 | const XRPL_EXPECTED_MAINNET_RESPONSE = { 30 | addresses: [XRPL_MAINNET_ADDRESS], 31 | verifiedAddresses: [], 32 | payId: PAYID, 33 | version: VERSION, 34 | } 35 | 36 | describe('E2E - publicAPIRouter - Content Negotiation', function (): void { 37 | // Boot up Express application and initialize DB connection pool 38 | before(async function () { 39 | app = await appSetup() 40 | }) 41 | 42 | it('Returns the first address for multiple types with no q', function (done): void { 43 | // GIVEN a payment pointer known to have an associated xrpl-testnet and xrpl-mainnet address 44 | const acceptHeader = `${XRPL_TESTNET_ACCEPT_HEADER}, ${XRPL_MAINNET_ACCEPT_HEADER}` 45 | 46 | // WHEN we make a GET request to the public endpoint to retrieve payment info with an Accept header specifying 47 | // both testnet and mainnet, with no q for either 48 | request(app.publicApiExpress) 49 | .get(USER) 50 | .set('PayID-Version', '1.1') 51 | .set('Accept', acceptHeader) 52 | // THEN we get back an xrpl testnet header as our Content-Type 53 | .expect((res) => { 54 | assert.strictEqual( 55 | res.get('Content-Type').split('; ')[0], 56 | XRPL_TESTNET_ACCEPT_HEADER, 57 | ) 58 | }) 59 | // AND we get back the xrpl-testnet account information associated with that payment pointer for xrpl-testnet 60 | .expect(HttpStatus.OK, XRPL_EXPECTED_TESTNET_RESPONSE, done) 61 | }) 62 | 63 | it('Returns the preferred available address where the higher q is at the beginning', function (done): void { 64 | // GIVEN a payment pointer known to have an associated xrpl-testnet address and xrpl-mainnet address 65 | const acceptHeader = `${XRPL_TESTNET_ACCEPT_HEADER}; q=1.1, ${XRPL_MAINNET_ACCEPT_HEADER}; q=0.5` 66 | 67 | // WHEN we make a GET request to the public endpoint to retrieve payment info with an Accept header specifying testnet 68 | // and mainnet, with testnet having a higher q-value 69 | request(app.publicApiExpress) 70 | .get(USER) 71 | .set('PayID-Version', '1.1') 72 | .set('Accept', acceptHeader) 73 | // THEN we get back an xrpl testnet header as the Content-Type 74 | .expect((res) => { 75 | assert.strictEqual( 76 | res.get('Content-Type').split('; ')[0], 77 | XRPL_TESTNET_ACCEPT_HEADER, 78 | ) 79 | }) 80 | // AND we get back the xrpl testnet account information associated with that payment pointer for xrpl testnet 81 | .expect(HttpStatus.OK, XRPL_EXPECTED_TESTNET_RESPONSE, done) 82 | }) 83 | 84 | it('Returns the preferred available address where the higher q is at the end', function (done): void { 85 | // GIVEN a payment pointer known to have an associated xrpl-testnet address and an xrpl-mainnet address 86 | const acceptHeader = `${XRPL_TESTNET_ACCEPT_HEADER}; q=0.5, ${XRPL_MAINNET_ACCEPT_HEADER}; q=1.1` 87 | 88 | // WHEN we make a GET request to the public endpoint to retrieve payment info with an Accept header specifying 89 | // xrpl-testnet and xrpl-mainnet, with a higher q for xrpl-mainnet 90 | request(app.publicApiExpress) 91 | .get(USER) 92 | .set('PayID-Version', '1.1') 93 | .set('Accept', acceptHeader) 94 | // THEN we get back a xrpl-mainnet accept header as the Content-Type 95 | .expect((res) => { 96 | assert.strictEqual( 97 | res.get('Content-Type').split('; ')[0], 98 | XRPL_MAINNET_ACCEPT_HEADER, 99 | ) 100 | }) 101 | // AND we get back the xrpl-mainnet account information associated with that payment pointer for xrpl-mainnet. 102 | .expect(HttpStatus.OK, XRPL_EXPECTED_MAINNET_RESPONSE, done) 103 | }) 104 | 105 | it('Returns a valid address when the most preferred type does not exist', function (done): void { 106 | // GIVEN a payment pointer known to have an associated xrpl-testnet address and mainnet address 107 | const nonExistentAcceptType = 'application/fakenetwork-fakenet+json' 108 | const acceptHeader = `${nonExistentAcceptType}; q=1.1, ${XRPL_TESTNET_ACCEPT_HEADER}; q=0.5, ${XRPL_MAINNET_ACCEPT_HEADER}; q=0.9` 109 | 110 | // WHEN we make a GET request to the public endpoint to retrieve payment info with an Accept header specifying 111 | // a non-existent network+environment most preferred, followed by xrpl-mainnet and xrpl-testnet 112 | request(app.publicApiExpress) 113 | .get(USER) 114 | .set('PayID-Version', '1.1') 115 | .set('Accept', acceptHeader) 116 | // THEN we get back a xrpl-mainnet accept header as the Content-Type 117 | .expect((res) => { 118 | assert.strictEqual( 119 | res.get('Content-Type').split('; ')[0], 120 | XRPL_MAINNET_ACCEPT_HEADER, 121 | ) 122 | }) 123 | // AND we get back the xrpl-mainnet account information associated with that payment pointer for xrpl mainnet. 124 | .expect(HttpStatus.OK, XRPL_EXPECTED_MAINNET_RESPONSE, done) 125 | }) 126 | 127 | // Shut down Express application and close DB connections 128 | after(function () { 129 | appCleanup(app) 130 | }) 131 | }) 132 | -------------------------------------------------------------------------------- /test/integration/e2e/public-api/cacheControl.test.ts: -------------------------------------------------------------------------------- 1 | import 'mocha' 2 | 3 | import HttpStatus from '@xpring-eng/http-status' 4 | import * as request from 'supertest' 5 | 6 | import App from '../../../../src/app' 7 | import config from '../../../../src/config' 8 | import { appSetup, appCleanup } from '../../../helpers/helpers' 9 | 10 | let app: App 11 | const payIdServerVersion = config.app.payIdVersion 12 | 13 | describe('E2E - publicAPIRouter - Cache Control', function (): void { 14 | // Boot up Express application and initialize DB connection pool 15 | before(async function () { 16 | app = await appSetup() 17 | }) 18 | 19 | it('Returns a "no-store" Cache-Control header', function (done): void { 20 | // GIVEN a PayID known to have an associated xrpl-mainnet address 21 | const payId = '/alice' 22 | const acceptHeader = 'application/xrpl-mainnet+json' 23 | const payIdVersion = payIdServerVersion 24 | 25 | // WHEN we make a GET request specifying a supported PayID-Version header 26 | request(app.publicApiExpress) 27 | .get(payId) 28 | .set('PayID-Version', payIdVersion) 29 | .set('Accept', acceptHeader) 30 | // THEN we expect to get back the latest PayID version as the server version 31 | .expect('PayID-Server-Version', payIdServerVersion) 32 | // AND we expect to get back the payload using the PayID-Version we specified in the request 33 | .expect('PayID-Version', payIdVersion) 34 | // AND we expect a "no-store" Cache-Control response header 35 | .expect('Cache-Control', 'no-store') 36 | // AND we expect a 200 - OK 37 | .expect(HttpStatus.OK, done) 38 | }) 39 | 40 | // Shut down Express application and close DB connections 41 | after(function () { 42 | appCleanup(app) 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /test/integration/e2e/public-api/discovery.test.ts: -------------------------------------------------------------------------------- 1 | import HttpStatus from '@xpring-eng/http-status' 2 | import * as request from 'supertest' 3 | 4 | import App from '../../../../src/app' 5 | import { appCleanup, appSetup } from '../../../helpers/helpers' 6 | 7 | import * as discoveryLinks from './testDiscoveryLinks.json' 8 | 9 | let app: App 10 | const discoveryPath = '/.well-known/webfinger' 11 | 12 | describe('E2E - publicAPIRouter - PayID Discovery', function (): void { 13 | // Boot up Express application and initialize DB connection pool 14 | before(async function () { 15 | app = await appSetup() 16 | }) 17 | 18 | it('Discovery query returns JRD', function (done): void { 19 | // GIVEN a PayID 20 | const payId = 'alice$wallet.com' 21 | const expectedResponse = { 22 | subject: payId, 23 | links: discoveryLinks, 24 | } 25 | 26 | // WHEN we make a GET request to the PayID Discovery endpoint 27 | request(app.publicApiExpress) 28 | .get(`${discoveryPath}?resource=${payId}`) 29 | // THEN we get back a JRD with subject = the PayID and all links from the discoveryLinks.json file 30 | .expect(HttpStatus.OK, expectedResponse, done) 31 | }) 32 | 33 | it('Discovery query with no PayID in request parameter returns 400', function (done): void { 34 | // GIVEN no PayID for a PayID Discovery request 35 | const expectedErrorResponse = { 36 | statusCode: 400, 37 | error: 'Bad Request', 38 | message: 'A PayID must be provided in the `resource` request parameter.', 39 | } 40 | 41 | // WHEN we make a GET request to the PayID Discovery endpoint with no `resource` request parameter 42 | request(app.publicApiExpress) 43 | .get(discoveryPath) 44 | // THEN we get back a 400 with the expected error message 45 | .expect(HttpStatus.BadRequest, expectedErrorResponse, done) 46 | }) 47 | 48 | after(function () { 49 | appCleanup(app) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /test/integration/e2e/public-api/healthCheck.test.ts: -------------------------------------------------------------------------------- 1 | import 'mocha' 2 | 3 | import HttpStatus from '@xpring-eng/http-status' 4 | import * as request from 'supertest' 5 | 6 | import App from '../../../../src/app' 7 | import { appSetup, appCleanup } from '../../../helpers/helpers' 8 | 9 | let app: App 10 | 11 | describe('E2E - publicAPIRouter - Health Check', function (): void { 12 | before(async function () { 13 | app = await appSetup() 14 | }) 15 | 16 | it('Returns a 200 - OK for a GET /status/health', function (done): void { 17 | request(app.publicApiExpress) 18 | .get('/status/health') 19 | .expect(HttpStatus.OK, 'OK', done) 20 | }) 21 | 22 | after(function () { 23 | appCleanup(app) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /test/integration/e2e/public-api/payloads.ts: -------------------------------------------------------------------------------- 1 | import { Address, AddressDetailsType } from '../../../../src/types/protocol' 2 | 3 | export const XRPL_TESTNET_ACCEPT_HEADER = 'application/xrpl-testnet+json' 4 | export const XRPL_MAINNET_ACCEPT_HEADER = 'application/xrpl-mainnet+json' 5 | 6 | export const XRPL_TESTNET_ADDRESS: Address = { 7 | paymentNetwork: 'XRPL', 8 | environment: 'TESTNET', 9 | addressDetailsType: AddressDetailsType.CryptoAddress, 10 | addressDetails: { 11 | address: 'rDk7FQvkQxQQNGTtfM2Fr66s7Nm3k87vdS', 12 | }, 13 | } 14 | 15 | export const XRPL_MAINNET_ADDRESS: Address = { 16 | paymentNetwork: 'XRPL', 17 | environment: 'MAINNET', 18 | addressDetailsType: AddressDetailsType.CryptoAddress, 19 | addressDetails: { 20 | address: 'rw2ciyaNshpHe7bCHo4bRWq6pqqynnWKQg', 21 | tag: '67298042', 22 | }, 23 | } 24 | 25 | export const SIGNATURE = { 26 | name: 'identityKey', 27 | protected: 'aGV0IG1lIHNlZSB0aGVtIGNvcmdpcyBOT1cgb3IgcGF5IHRoZSBwcmljZQ==', 28 | signature: 29 | 'TG9vayBhdCBtZSEgd29vIEknbSB0ZXN0aW5nIHRoaW5ncyBhbmQgdGhpcyBpcyBhIHNpZ25hdHVyZQ==', 30 | } 31 | -------------------------------------------------------------------------------- /test/integration/e2e/public-api/testDiscoveryLinks.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "rel": "https://payid.org/ns/payid-easy-checkout-uri/1.0", 4 | "href": "https://dev.wallet.xpring.io/wallet/xrp/testnet/payto" 5 | } 6 | ] 7 | -------------------------------------------------------------------------------- /test/integration/e2e/public-api/verifiablePayIdContentNegotiation.test.ts: -------------------------------------------------------------------------------- 1 | import 'mocha' 2 | 3 | import HttpStatus from '@xpring-eng/http-status' 4 | import { assert } from 'chai' 5 | import * as request from 'supertest' 6 | 7 | import App from '../../../../src/app' 8 | import { appSetup, appCleanup } from '../../../helpers/helpers' 9 | 10 | import { 11 | XRPL_TESTNET_ADDRESS, 12 | XRPL_TESTNET_ACCEPT_HEADER, 13 | XRPL_MAINNET_ACCEPT_HEADER, 14 | SIGNATURE, 15 | } from './payloads' 16 | 17 | let app: App 18 | 19 | const USER = '/johnwick' 20 | const PAYID = `${USER.slice(1)}$127.0.0.1` 21 | const VERSION = '1.1' 22 | const XRPL_EXPECTED_TESTNET_RESPONSE = { 23 | addresses: [], 24 | verifiedAddresses: [ 25 | { 26 | signatures: [SIGNATURE], 27 | payload: JSON.stringify({ 28 | payId: PAYID, 29 | payIdAddress: XRPL_TESTNET_ADDRESS, 30 | }), 31 | }, 32 | ], 33 | payId: PAYID, 34 | version: VERSION, 35 | } 36 | 37 | describe('E2E - publicAPIRouter - Verifiable PayId Content Negotiation', function (): void { 38 | // Boot up Express application and initialize DB connection pool 39 | before(async function () { 40 | app = await appSetup() 41 | }) 42 | 43 | it('Returns the first address for multiple types with no q', function (done): void { 44 | // GIVEN a payment pointer known to have an associated xrpl-testnet and xrpl-mainnet address 45 | const acceptHeader = `${XRPL_TESTNET_ACCEPT_HEADER}, ${XRPL_MAINNET_ACCEPT_HEADER}` 46 | 47 | // WHEN we make a GET request to the public endpoint to retrieve payment info with an Accept header specifying 48 | // both testnet and mainnet, with no q for either 49 | request(app.publicApiExpress) 50 | .get(USER) 51 | .set('PayID-Version', VERSION) 52 | .set('Accept', acceptHeader) 53 | // THEN we get back an xrpl testnet header as our Content-Type 54 | .expect((res) => { 55 | assert.strictEqual( 56 | res.get('Content-Type').split('; ')[0], 57 | XRPL_TESTNET_ACCEPT_HEADER, 58 | ) 59 | }) 60 | // AND we get back the xrpl-testnet account information associated with that payment pointer for xrpl-testnet 61 | .expect(HttpStatus.OK, XRPL_EXPECTED_TESTNET_RESPONSE, done) 62 | }) 63 | 64 | it('Returns the preferred available address where the higher q is at the beginning', function (done): void { 65 | // GIVEN a payment pointer known to have an associated xrpl-testnet address and xrpl-mainnet address 66 | const acceptHeader = `${XRPL_TESTNET_ACCEPT_HEADER}; q=1.1, ${XRPL_MAINNET_ACCEPT_HEADER}; q=0.5` 67 | 68 | // WHEN we make a GET request to the public endpoint to retrieve payment info with an Accept header specifying testnet 69 | // and mainnet, with testnet having a higher q-value 70 | request(app.publicApiExpress) 71 | .get(USER) 72 | .set('PayID-Version', VERSION) 73 | .set('Accept', acceptHeader) 74 | // THEN we get back an xrpl testnet header as the Content-Type 75 | .expect((res) => { 76 | assert.strictEqual( 77 | res.get('Content-Type').split('; ')[0], 78 | XRPL_TESTNET_ACCEPT_HEADER, 79 | ) 80 | }) 81 | // AND we get back the xrpl testnet account information associated with that payment pointer for xrpl testnet 82 | .expect(HttpStatus.OK, XRPL_EXPECTED_TESTNET_RESPONSE, done) 83 | }) 84 | 85 | it('Returns the preferred available address where the higher q is at the end', function (done): void { 86 | // GIVEN a payment pointer known to have an associated xrpl-testnet address and an xrpl-mainnet address 87 | const acceptHeader = `${XRPL_MAINNET_ACCEPT_HEADER}; q=0.5, ${XRPL_TESTNET_ACCEPT_HEADER}; q=1.1` 88 | 89 | // WHEN we make a GET request to the public endpoint to retrieve payment info with an Accept header specifying 90 | // xrpl-testnet and xrpl-mainnet, with a higher q for xrpl-testnet 91 | request(app.publicApiExpress) 92 | .get(USER) 93 | .set('PayID-Version', VERSION) 94 | .set('Accept', acceptHeader) 95 | // THEN we get back a xrpl-testnet accept header as the Content-Type 96 | .expect((res) => { 97 | assert.strictEqual( 98 | res.get('Content-Type').split('; ')[0], 99 | XRPL_TESTNET_ACCEPT_HEADER, 100 | ) 101 | }) 102 | // AND we get back the xrpl-mainnet account information associated with that payment pointer for xrpl-mainnet. 103 | .expect(HttpStatus.OK, XRPL_EXPECTED_TESTNET_RESPONSE, done) 104 | }) 105 | 106 | it('Returns a valid address when the most preferred type does not exist', function (done): void { 107 | // GIVEN a payment pointer known to have an associated xrpl-testnet address and mainnet address 108 | const nonExistentAcceptType = 'application/fakenetwork-fakenet+json' 109 | const acceptHeader = `${nonExistentAcceptType}; q=1.1, ${XRPL_MAINNET_ACCEPT_HEADER}; q=0.5, ${XRPL_TESTNET_ACCEPT_HEADER}; q=0.9` 110 | 111 | // WHEN we make a GET request to the public endpoint to retrieve payment info with an Accept header specifying 112 | // a non-existent network+environment most preferred, followed by xrpl-mainnet and xrpl-testnet 113 | request(app.publicApiExpress) 114 | .get(USER) 115 | .set('PayID-Version', VERSION) 116 | .set('Accept', acceptHeader) 117 | // THEN we get back a xrpl-testnet accept header as the Content-Type 118 | .expect((res) => { 119 | assert.strictEqual( 120 | res.get('Content-Type').split('; ')[0], 121 | XRPL_TESTNET_ACCEPT_HEADER, 122 | ) 123 | }) 124 | // AND we get back the xrpl-mainnet account information associated with that payment pointer for xrpl mainnet. 125 | .expect(HttpStatus.OK, XRPL_EXPECTED_TESTNET_RESPONSE, done) 126 | }) 127 | 128 | // Shut down Express application and close DB connections 129 | after(function () { 130 | appCleanup(app) 131 | }) 132 | }) 133 | -------------------------------------------------------------------------------- /test/integration/e2e/public-api/versionHeader.test.ts: -------------------------------------------------------------------------------- 1 | import 'mocha' 2 | 3 | import HttpStatus from '@xpring-eng/http-status' 4 | import * as request from 'supertest' 5 | 6 | import App from '../../../../src/app' 7 | import config, { payIdServerVersions } from '../../../../src/config' 8 | import { appSetup, appCleanup } from '../../../helpers/helpers' 9 | 10 | let app: App 11 | const earliestPayIdServerVersion = payIdServerVersions[0] 12 | const payIdServerVersion = config.app.payIdVersion 13 | 14 | describe('E2E - publicAPIRouter - Version Headers', function (): void { 15 | // Boot up Express application and initialize DB connection pool 16 | before(async function () { 17 | app = await appSetup() 18 | }) 19 | 20 | it('Returns a 400 - Bad Request when we omit a PayID-Version header', function (done): void { 21 | // GIVEN a PayID known to have an associated xrpl-mainnet address 22 | const payId = '/alice' 23 | const acceptHeader = 'application/xrpl-mainnet+json' 24 | const expectedErrorResponse = { 25 | statusCode: 400, 26 | error: 'Bad Request', 27 | message: 28 | "A PayID-Version header is required in the request, of the form 'PayID-Version: {major}.{minor}'.", 29 | } 30 | 31 | // WHEN we make a GET request without specifying a PayID-Version header 32 | request(app.publicApiExpress) 33 | .get(payId) 34 | .set('Accept', acceptHeader) 35 | // THEN we expect to get back the latest PayID version as the server version 36 | .expect('PayID-Server-Version', payIdServerVersion) 37 | // AND we expect a 400 - Bad Request 38 | .expect(HttpStatus.BadRequest, expectedErrorResponse, done) 39 | }) 40 | 41 | it('Returns a 400 - Bad Request when we provide a malformed PayID-Version header', function (done): void { 42 | // GIVEN a PayID known to have an associated xrpl-mainnet address 43 | const payId = '/alice' 44 | const acceptHeader = 'application/xrpl-mainnet+json' 45 | const expectedErrorResponse = { 46 | statusCode: 400, 47 | error: 'Bad Request', 48 | message: 49 | "A PayID-Version header must be in the form 'PayID-Version: {major}.{minor}'.", 50 | } 51 | 52 | // WHEN we make a GET request specifying a malformed PayID-Version header 53 | request(app.publicApiExpress) 54 | .get(payId) 55 | .set('PayID-Version', 'abc') 56 | .set('Accept', acceptHeader) 57 | // THEN we expect to get back the latest PayID version as the server version 58 | .expect('PayID-Server-Version', payIdServerVersion) 59 | // AND we expect a 400 - Bad Request 60 | .expect(HttpStatus.BadRequest, expectedErrorResponse, done) 61 | }) 62 | 63 | it('Returns a 400 - Bad Request when we provide an unsupported PayID-Version header (greater than PayID server)', function (done): void { 64 | // GIVEN a PayID known to have an associated xrpl-mainnet address 65 | const payId = '/alice' 66 | const acceptHeader = 'application/xrpl-mainnet+json' 67 | const payIdVersion = '100.0' 68 | const expectedErrorResponse = { 69 | statusCode: 400, 70 | error: 'Bad Request', 71 | message: `The PayID-Version ${payIdVersion} is not supported, please try downgrading your request to PayID-Version ${payIdServerVersion}`, 72 | } 73 | 74 | // WHEN we make a GET request specifying an unsupported PayID-Version header 75 | request(app.publicApiExpress) 76 | .get(payId) 77 | .set('PayID-Version', payIdVersion) 78 | .set('Accept', acceptHeader) 79 | // THEN we expect to get back the latest PayID version as the server version 80 | .expect('PayID-Server-Version', payIdServerVersion) 81 | // AND we expect a 400 - Bad Request 82 | .expect(HttpStatus.BadRequest, expectedErrorResponse, done) 83 | }) 84 | 85 | it('Returns a 400 - Bad Request when we provide an unknown PayID-Version header', function (done): void { 86 | // GIVEN a PayID known to have an associated xrpl-mainnet address 87 | const payId = '/alice' 88 | const acceptHeader = 'application/xrpl-mainnet+json' 89 | const payIdVersion = '0.1' 90 | const expectedErrorResponse = { 91 | statusCode: 400, 92 | error: 'Bad Request', 93 | message: `The PayID Version ${payIdVersion} is not supported, try something in the range ${earliestPayIdServerVersion} - ${payIdServerVersion}`, 94 | } 95 | 96 | // WHEN we make a GET request specifying an unsupported PayID-Version header 97 | request(app.publicApiExpress) 98 | .get(payId) 99 | .set('PayID-Version', payIdVersion) 100 | .set('Accept', acceptHeader) 101 | // THEN we expect to get back the latest PayID version as the server version 102 | .expect('PayID-Server-Version', payIdServerVersion) 103 | // AND we expect a 400 - Bad Request 104 | .expect(HttpStatus.BadRequest, expectedErrorResponse, done) 105 | }) 106 | 107 | it('Returns a 200 - OK when we provide a valid PayID-Version header', function (done): void { 108 | // GIVEN a PayID known to have an associated xrpl-mainnet address 109 | const payId = '/alice' 110 | const acceptHeader = 'application/xrpl-mainnet+json' 111 | const payIdVersion = payIdServerVersion 112 | 113 | // WHEN we make a GET request specifying a supported PayID-Version header 114 | request(app.publicApiExpress) 115 | .get(payId) 116 | .set('PayID-Version', payIdVersion) 117 | .set('Accept', acceptHeader) 118 | // THEN we expect to get back the latest PayID version as the server version 119 | .expect('PayID-Server-Version', payIdServerVersion) 120 | // AND we expect to get back the payload using the PayID-Version we specified in the request 121 | .expect('PayID-Version', payIdVersion) 122 | // AND we expect a 200 - OK 123 | .expect(HttpStatus.OK, done) 124 | }) 125 | 126 | // Shut down Express application and close DB connections 127 | after(function () { 128 | appCleanup(app) 129 | }) 130 | }) 131 | -------------------------------------------------------------------------------- /test/integration/sql/payidRegex.test.ts: -------------------------------------------------------------------------------- 1 | import 'mocha' 2 | import { assert } from 'chai' 3 | 4 | import knex from '../../../src/db/knex' 5 | import { 6 | seedDatabase, 7 | getDatabaseConstraintDefinition, 8 | } from '../../helpers/helpers' 9 | 10 | describe('Database Schema - PayID Regex Example Table', function (): void { 11 | let payIdRegex: string 12 | 13 | before(async function () { 14 | await seedDatabase() 15 | 16 | const validPayIdConstraint = await getDatabaseConstraintDefinition( 17 | 'valid_pay_id', 18 | 'account', 19 | ) 20 | 21 | // Extract regex from constraint definition 22 | const regexExtractor = /'(?.*?)'/u 23 | const match = regexExtractor.exec(validPayIdConstraint) 24 | payIdRegex = match?.groups?.payIdRegex ?? '' 25 | 26 | if (payIdRegex === '') { 27 | throw new Error('Expected payIdRegex to be defined.') 28 | } 29 | }) 30 | 31 | it('Contains the expected number of valid PayIDs', async function () { 32 | // GIVEN an expected number of PayIDs that pass the PayID regex 33 | const EXPECTED_VALID_PAYID_COUNT = 23 34 | 35 | // WHEN we fetch the number of PayIDs that pass the PayID regex 36 | const validPayIdCount = await knex 37 | .count('*') 38 | .from('payid_examples') 39 | .where('pay_id', '~*', payIdRegex) 40 | .then(async (result) => Number(result[0].count)) 41 | 42 | // AND the number of PayIDs with 'is_valid = true' the database table 43 | // (This is just a sanity check on the seeded values) 44 | const isValidCount = await knex 45 | .count('*') 46 | .from('payid_examples') 47 | .where('is_valid', true) 48 | .then(async (result) => Number(result[0].count)) 49 | 50 | // THEN we expect to get our expected number of valid PayIDs 51 | assert.strictEqual(validPayIdCount, EXPECTED_VALID_PAYID_COUNT) 52 | assert.strictEqual(isValidCount, EXPECTED_VALID_PAYID_COUNT) 53 | }) 54 | 55 | it('Contains the expected number of invalid PayIDs', async function () { 56 | // GIVEN an expected number of PayIDs that fail the PayID regex 57 | const EXPECTED_INVALID_PAYID_COUNT = 28 58 | 59 | // WHEN we fetch the number of PayIDs that fail the PayID regex 60 | const invalidPayIdCount = await knex 61 | .count('*') 62 | .from('payid_examples') 63 | .where('pay_id', '!~*', payIdRegex) 64 | .then(async (result) => Number(result[0].count)) 65 | 66 | // AND the number of PayIDs with 'is_valid = false' in the database table 67 | const isInvalidCount = await knex 68 | .count('*') 69 | .from('payid_examples') 70 | .where('is_valid', false) 71 | .then(async (result) => Number(result[0].count)) 72 | 73 | // // THEN we expect to get our expected number of valid PayIDs 74 | assert.strictEqual(invalidPayIdCount, EXPECTED_INVALID_PAYID_COUNT) 75 | assert.strictEqual(isInvalidCount, EXPECTED_INVALID_PAYID_COUNT) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /test/unit/constructUrl.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | 3 | import 'mocha' 4 | 5 | import { constructUrl } from '../../src/services/urls' 6 | 7 | describe('Parsing - URLs - constructUrl()', function (): void { 8 | it('returns a url from components', function (): void { 9 | // GIVEN a set of URL components 10 | const protocol = 'https' 11 | const hostname = 'example.com' 12 | const path = '/alice' 13 | const expectedUrl = 'https://example.com/alice' 14 | 15 | // WHEN we attempt converting them to a URL 16 | const actualUrl = constructUrl(protocol, hostname, path) 17 | 18 | // THEN we get our expected URL 19 | assert.strictEqual(actualUrl, expectedUrl) 20 | }) 21 | 22 | it('returns a url with a port from components', function (): void { 23 | // GIVEN a set of URL components w/ a port 24 | const protocol = 'https' 25 | const hostname = 'example.com' 26 | const path = '/alice' 27 | const port = '8080' 28 | const expectedUrl = 'https://example.com:8080/alice' 29 | 30 | // WHEN we attempt converting them to a URL 31 | const actualUrl = constructUrl(protocol, hostname, path, port) 32 | 33 | // THEN we get our expected URL w/ a port 34 | assert.strictEqual(actualUrl, expectedUrl) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /test/unit/formatPaymentInfoBasePayId.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | 3 | import { formatPaymentInfo } from '../../src/services/basePayId' 4 | import { AddressDetailsType } from '../../src/types/protocol' 5 | 6 | const version1dot1 = '1.1' 7 | 8 | describe('Base PayID - formatPaymentInfo()', function (): void { 9 | it('Returns CryptoAddressDetails & FiatAddressDetails for addressDetailsTypes when formatting array with multiple AddressInformation', function () { 10 | // GIVEN an array of AddressInformation with an ACH entry 11 | const payId = 'alice$example.com' 12 | const addressInfo = [ 13 | { 14 | paymentNetwork: 'XRP', 15 | environment: 'TESTNET', 16 | details: { 17 | address: 'rDk7FQvkQxQQNGTtfM2Fr66s7Nm3k87vdS', 18 | }, 19 | }, 20 | { 21 | paymentNetwork: 'ACH', 22 | environment: null, 23 | details: { 24 | accountNumber: '000123456789', 25 | routingNumber: '123456789', 26 | }, 27 | }, 28 | ] 29 | const expectedPaymentInfo = { 30 | payId: 'alice$example.com', 31 | version: '1.1', 32 | addresses: [ 33 | { 34 | paymentNetwork: 'XRP', 35 | environment: 'TESTNET', 36 | addressDetailsType: AddressDetailsType.CryptoAddress, 37 | addressDetails: { 38 | address: 'rDk7FQvkQxQQNGTtfM2Fr66s7Nm3k87vdS', 39 | }, 40 | }, 41 | { 42 | paymentNetwork: 'ACH', 43 | addressDetailsType: AddressDetailsType.FiatAddress, 44 | addressDetails: { 45 | accountNumber: '000123456789', 46 | routingNumber: '123456789', 47 | }, 48 | }, 49 | ], 50 | verifiedAddresses: [], 51 | } 52 | 53 | // WHEN we format it 54 | const paymentInfo = formatPaymentInfo( 55 | addressInfo, 56 | [], 57 | '', 58 | version1dot1, 59 | payId, 60 | ) 61 | 62 | // THEN we get back a PaymentInformation object with the appropriate address details 63 | assert.deepStrictEqual(paymentInfo, expectedPaymentInfo) 64 | }) 65 | 66 | it('Does not return a environment field when it is not included in the address information', function (): void { 67 | // GIVEN an array of AddressInformation with an ACH entry (no environment) 68 | const payId = 'alice$example.com' 69 | const version = '1.1' 70 | const addressInfo = [ 71 | { 72 | paymentNetwork: 'ACH', 73 | environment: null, 74 | details: { 75 | accountNumber: '000123456789', 76 | routingNumber: '123456789', 77 | }, 78 | }, 79 | ] 80 | 81 | const expectedPaymentInfo = { 82 | addresses: [ 83 | { 84 | paymentNetwork: 'ACH', 85 | addressDetailsType: AddressDetailsType.FiatAddress, 86 | addressDetails: { 87 | accountNumber: '000123456789', 88 | routingNumber: '123456789', 89 | }, 90 | }, 91 | ], 92 | verifiedAddresses: [], 93 | payId, 94 | version, 95 | } 96 | 97 | // WHEN we format it 98 | const paymentInfo = formatPaymentInfo( 99 | addressInfo, 100 | [], 101 | '', 102 | version1dot1, 103 | payId, 104 | ) 105 | 106 | // THEN we get back a PaymentInformation object with no environment 107 | assert.deepStrictEqual(paymentInfo, expectedPaymentInfo) 108 | }) 109 | 110 | it('Returns a memo field when using a memo function that returns a truthy string', function (): void { 111 | // GIVEN an array of AddressInformation with an XRP entry 112 | const payId = 'alice$example.com' 113 | const addressInfo = [ 114 | { 115 | paymentNetwork: 'XRP', 116 | environment: 'TESTNET', 117 | details: { 118 | address: 'rDk7FQvkQxQQNGTtfM2Fr66s7Nm3k87vdS', 119 | }, 120 | }, 121 | ] 122 | 123 | const expectedPaymentInfo = { 124 | addresses: [ 125 | { 126 | paymentNetwork: 'XRP', 127 | environment: 'TESTNET', 128 | addressDetailsType: AddressDetailsType.CryptoAddress, 129 | addressDetails: { 130 | address: 'rDk7FQvkQxQQNGTtfM2Fr66s7Nm3k87vdS', 131 | }, 132 | }, 133 | ], 134 | verifiedAddresses: [], 135 | payId: 'alice$example.com', 136 | memo: 'memo', 137 | version: '1.1', 138 | } 139 | 140 | // AND GIVEN a createMemo() that returns a truthy value 141 | const memoFn = (): string => 'memo' 142 | 143 | // WHEN we format the address information 144 | const paymentInfo = formatPaymentInfo( 145 | addressInfo, 146 | [], 147 | '', 148 | version1dot1, 149 | payId, 150 | memoFn, 151 | ) 152 | 153 | // THEN we get back a PaymentInformation object with a memo 154 | assert.deepStrictEqual(paymentInfo, expectedPaymentInfo) 155 | }) 156 | }) 157 | -------------------------------------------------------------------------------- /test/unit/formatPaymentInfoVerifiablePayId.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | 3 | import { formatPaymentInfo } from '../../src/services/basePayId' 4 | import { AddressInformation } from '../../src/types/database' 5 | import { 6 | PaymentInformation, 7 | AddressDetailsType, 8 | } from '../../src/types/protocol' 9 | 10 | describe('Verifiable PayID - formatPaymentInfo()', function (): void { 11 | it('Returns properly formatted array for Verifiable PayID', function () { 12 | // GIVEN an array of AddressInformation with an ACH entry 13 | const version = '1.1' 14 | const payId = 'alice$example.com' 15 | const verifiedAddressInfo: AddressInformation[] = [ 16 | { 17 | paymentNetwork: 'XRP', 18 | environment: 'TESTNET', 19 | details: { 20 | address: 'rDk7FQvkQxQQNGTtfM2Fr66s7Nm3k87vdS', 21 | }, 22 | identityKeySignature: 'xrpSignature', 23 | }, 24 | { 25 | paymentNetwork: 'ACH', 26 | environment: null, 27 | details: { 28 | accountNumber: '000123456789', 29 | routingNumber: '123456789', 30 | }, 31 | identityKeySignature: 'achSignature', 32 | }, 33 | ] 34 | const expectedPaymentInfo: PaymentInformation = { 35 | addresses: [], 36 | verifiedAddresses: [ 37 | { 38 | payload: JSON.stringify({ 39 | payId: 'alice$example.com', 40 | payIdAddress: { 41 | paymentNetwork: 'XRP', 42 | environment: 'TESTNET', 43 | addressDetailsType: AddressDetailsType.CryptoAddress, 44 | addressDetails: { 45 | address: 'rDk7FQvkQxQQNGTtfM2Fr66s7Nm3k87vdS', 46 | }, 47 | }, 48 | }), 49 | signatures: [ 50 | { 51 | name: 'identityKey', 52 | protected: 'anIdentityKey', 53 | signature: 'xrpSignature', 54 | }, 55 | ], 56 | }, 57 | { 58 | payload: JSON.stringify({ 59 | payId: 'alice$example.com', 60 | payIdAddress: { 61 | paymentNetwork: 'ACH', 62 | addressDetailsType: AddressDetailsType.FiatAddress, 63 | addressDetails: { 64 | accountNumber: '000123456789', 65 | routingNumber: '123456789', 66 | }, 67 | }, 68 | }), 69 | signatures: [ 70 | { 71 | name: 'identityKey', 72 | protected: 'anIdentityKey', 73 | signature: 'achSignature', 74 | }, 75 | ], 76 | }, 77 | ], 78 | payId: 'alice$example.com', 79 | version: '1.1', 80 | } 81 | 82 | // WHEN we format it 83 | const paymentInfo = formatPaymentInfo( 84 | [], 85 | verifiedAddressInfo, 86 | 'anIdentityKey', 87 | version, 88 | payId, 89 | ) 90 | 91 | // THEN we get back a PaymentInformation object with the appropriate address details 92 | assert.deepStrictEqual(paymentInfo, expectedPaymentInfo) 93 | }) 94 | }) 95 | -------------------------------------------------------------------------------- /test/unit/getAddressDetailsType.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | 3 | import { getAddressDetailsType } from '../../src/services/basePayId' 4 | import { AddressDetailsType } from '../../src/types/protocol' 5 | 6 | const version1dot0 = '1.0' 7 | const version1dot1 = '1.1' 8 | 9 | describe('Base PayID - getAddressDetailsType()', function (): void { 10 | it('Returns FiatAddressDetails for addressDetailsType when formatting ACH AddressInformation', function () { 11 | // GIVEN an array of AddressInformation with a single ACH (empty environment) entry 12 | const addressInfo = { 13 | paymentNetwork: 'ACH', 14 | environment: null, 15 | details: { 16 | accountNumber: '000123456789', 17 | routingNumber: '123456789', 18 | }, 19 | } 20 | 21 | // WHEN we get the address details type 22 | const addressDetailsType = getAddressDetailsType(addressInfo, version1dot1) 23 | 24 | // THEN we get back an AddressDetailsType of FiatAddress 25 | assert.deepStrictEqual(addressDetailsType, AddressDetailsType.FiatAddress) 26 | }) 27 | 28 | it('If using version 1.0, returns AchAddressDetails for addressDetailsType when formatting ACH AddressInformation', function () { 29 | // GIVEN an array of AddressInformation with a single ACH (empty environment) entry 30 | const addressInfo = { 31 | paymentNetwork: 'ACH', 32 | environment: null, 33 | details: { 34 | accountNumber: '000123456789', 35 | routingNumber: '123456789', 36 | }, 37 | } 38 | 39 | // WHEN we get the address details type 40 | const addressDetailsType = getAddressDetailsType(addressInfo, version1dot0) 41 | 42 | // THEN we get back an AddressDetailsType of FiatAddress 43 | assert.deepStrictEqual(addressDetailsType, AddressDetailsType.AchAddress) 44 | }) 45 | 46 | it('Returns CryptoAddressDetails for addressDetailsType when formatting XRP AddressInformation', function () { 47 | // GIVEN an array of AddressInformation with a single XRP entry 48 | const addressInfo = { 49 | paymentNetwork: 'XRP', 50 | environment: 'TESTNET', 51 | details: { 52 | address: 'rDk7FQvkQxQQNGTtfM2Fr66s7Nm3k87vdS', 53 | }, 54 | } 55 | 56 | // WHEN we get the address details type 57 | const addressDetailsType = getAddressDetailsType(addressInfo, version1dot1) 58 | 59 | // THEN we get back an AddressDetailsType of CryptoAddress 60 | assert.deepStrictEqual(addressDetailsType, AddressDetailsType.CryptoAddress) 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /test/unit/getPreferredAddressInfo.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | 3 | import { getPreferredAddressHeaderPair } from '../../src/services/basePayId' 4 | import { AddressInformation } from '../../src/types/database' 5 | import { ParsedAcceptHeader } from '../../src/types/headers' 6 | 7 | describe('Base PayID - getPreferredAddressInfo()', function (): void { 8 | let addressInfo: AddressInformation[] 9 | let verifiedAddressInfo: AddressInformation[] 10 | 11 | beforeEach(function () { 12 | addressInfo = [ 13 | { 14 | paymentNetwork: 'XRPL', 15 | environment: 'TESTNET', 16 | details: { 17 | address: 'rDk7FQvkQxQQNGTtfM2Fr66s7Nm3k87vdS', 18 | }, 19 | }, 20 | { 21 | paymentNetwork: 'ACH', 22 | environment: null, 23 | details: { 24 | accountNumber: '000123456789', 25 | routingNumber: '123456789', 26 | }, 27 | }, 28 | ] 29 | verifiedAddressInfo = [ 30 | { 31 | paymentNetwork: 'XRPL', 32 | environment: 'TESTNET', 33 | details: { 34 | address: 'rDk7FQvkQxQQNGTtfM2Fr66s7Nm3k87vdS', 35 | }, 36 | }, 37 | { 38 | paymentNetwork: 'ETH', 39 | environment: 'KOVAN', 40 | details: { 41 | address: '0x43F14dFF256E8e44b839AE00BE8E0e02fA7D18Db', 42 | }, 43 | }, 44 | ] 45 | }) 46 | 47 | it('Returns all addresses & payid media type if payment network is PAYID', function () { 48 | // GIVEN an array of addresses and array of AcceptMediaTypes 49 | const acceptMediaTypes: ParsedAcceptHeader[] = [ 50 | { 51 | mediaType: 'application/payid+json', 52 | paymentNetwork: 'PAYID', 53 | }, 54 | ] 55 | const expectedAddressInfo: [ 56 | ParsedAcceptHeader, 57 | AddressInformation[], 58 | AddressInformation[], 59 | ] = [ 60 | { 61 | mediaType: 'application/payid+json', 62 | paymentNetwork: 'PAYID', 63 | }, 64 | addressInfo, 65 | verifiedAddressInfo, 66 | ] 67 | 68 | // WHEN we try get get the preferred addresses for PAYID payment network 69 | const preferredAddressInfo = getPreferredAddressHeaderPair( 70 | addressInfo, 71 | verifiedAddressInfo, 72 | acceptMediaTypes, 73 | ) 74 | 75 | // THEN we return all the addresses we have 76 | assert.deepStrictEqual(preferredAddressInfo, expectedAddressInfo) 77 | }) 78 | 79 | it('Returns the first order preferred address when found', function () { 80 | // GIVEN an array of addresses and array of AcceptMediaTypes 81 | const acceptMediaTypes: ParsedAcceptHeader[] = [ 82 | { 83 | mediaType: 'application/xrpl-testnet+json', 84 | environment: 'TESTNET', 85 | paymentNetwork: 'XRPL', 86 | }, 87 | ] 88 | const expectedAddressInfo: [ 89 | ParsedAcceptHeader, 90 | AddressInformation[], 91 | AddressInformation[], 92 | ] = [ 93 | { 94 | mediaType: 'application/xrpl-testnet+json', 95 | environment: 'TESTNET', 96 | paymentNetwork: 'XRPL', 97 | }, 98 | [addressInfo[0]], 99 | [verifiedAddressInfo[0]], 100 | ] 101 | 102 | // WHEN we try get get the preferred addresses for XRP payment network 103 | const preferredAddressInfo = getPreferredAddressHeaderPair( 104 | addressInfo, 105 | verifiedAddressInfo, 106 | acceptMediaTypes, 107 | ) 108 | 109 | // THEN we get back the XRP addresses 110 | assert.deepStrictEqual(preferredAddressInfo, expectedAddressInfo) 111 | }) 112 | 113 | it('Returns the second order preferred address (unverified) when the first is not found', function () { 114 | // GIVEN an array of addresses and array of AcceptMediaTypes 115 | const acceptMediaTypes: ParsedAcceptHeader[] = [ 116 | { 117 | mediaType: 'application/xrpl-mainnet+json', 118 | environment: 'MAINNET', 119 | paymentNetwork: 'XRPL', 120 | }, 121 | { 122 | mediaType: 'application/ach+json', 123 | paymentNetwork: 'ACH', 124 | }, 125 | ] 126 | const expectedAddressInfo: [ 127 | ParsedAcceptHeader, 128 | AddressInformation[], 129 | AddressInformation[], 130 | ] = [ 131 | { 132 | mediaType: 'application/ach+json', 133 | paymentNetwork: 'ACH', 134 | }, 135 | [addressInfo[1]], 136 | [], 137 | ] 138 | 139 | // WHEN we try get get the preferred addresses for XRP, ACH payment network 140 | const preferredAddressInfo = getPreferredAddressHeaderPair( 141 | addressInfo, 142 | verifiedAddressInfo, 143 | acceptMediaTypes, 144 | ) 145 | 146 | // THEN we get back the ACH addresses (because XRP was not found) 147 | assert.deepStrictEqual(preferredAddressInfo, expectedAddressInfo) 148 | }) 149 | 150 | it('Returns the second order preferred address (verified) when the first is not found', function () { 151 | // GIVEN an array of addresses and array of AcceptMediaTypes 152 | const acceptMediaTypes: ParsedAcceptHeader[] = [ 153 | { 154 | mediaType: 'application/xrpl-mainnet+json', 155 | environment: 'MAINNET', 156 | paymentNetwork: 'XRPL', 157 | }, 158 | { 159 | mediaType: 'application/eth-kovan+json', 160 | environment: 'KOVAN', 161 | paymentNetwork: 'ETH', 162 | }, 163 | ] 164 | const expectedAddressInfo: [ 165 | ParsedAcceptHeader, 166 | AddressInformation[], 167 | AddressInformation[], 168 | ] = [ 169 | { 170 | mediaType: 'application/eth-kovan+json', 171 | environment: 'KOVAN', 172 | paymentNetwork: 'ETH', 173 | }, 174 | [], 175 | [verifiedAddressInfo[1]], 176 | ] 177 | 178 | // WHEN we try get get the preferred addresses for XRP, ACH payment network 179 | const preferredAddressInfo = getPreferredAddressHeaderPair( 180 | addressInfo, 181 | verifiedAddressInfo, 182 | acceptMediaTypes, 183 | ) 184 | 185 | // THEN we get back the ACH addresses (because XRP was not found) 186 | assert.deepStrictEqual(preferredAddressInfo, expectedAddressInfo) 187 | }) 188 | 189 | it('Returns undefined if no preferred address found', function () { 190 | // GIVEN an array of addresses and array of AcceptMediaTypes 191 | const acceptMediaTypes: ParsedAcceptHeader[] = [ 192 | { 193 | mediaType: 'application/xrpl-mainnet+json', 194 | environment: 'MAINNET', 195 | paymentNetwork: 'XRPL', 196 | }, 197 | ] 198 | 199 | // WHEN we try get get the preferred addresses for XRP network on mainnet 200 | const preferredAddressInfo = getPreferredAddressHeaderPair( 201 | addressInfo, 202 | verifiedAddressInfo, 203 | acceptMediaTypes, 204 | ) 205 | 206 | // THEN we get back undefined, because XRP network on mainnet was not found 207 | assert.deepStrictEqual(preferredAddressInfo, [undefined, [], []]) 208 | }) 209 | }) 210 | -------------------------------------------------------------------------------- /test/unit/parseAcceptHeader.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | 3 | import 'mocha' 4 | import { parseAcceptHeader } from '../../src/services/headers' 5 | import { ParsedAcceptHeader } from '../../src/types/headers' 6 | import { ParseError } from '../../src/utils/errors' 7 | 8 | describe('Parsing - Headers - parseAcceptHeader()', function (): void { 9 | it('Parses a string with a valid media type', function () { 10 | // GIVEN a string with a valid Accept type 11 | const validAcceptMediaType = 'application/xrpl-testnet+json' 12 | const expectedParsedAcceptHeader: ParsedAcceptHeader = { 13 | mediaType: validAcceptMediaType, 14 | paymentNetwork: 'XRPL', 15 | environment: 'TESTNET', 16 | } 17 | 18 | // WHEN we attempt to parse it 19 | const parsedAcceptHeader = parseAcceptHeader(validAcceptMediaType) 20 | 21 | // THEN we successfully parsed the parts 22 | assert.deepStrictEqual(parsedAcceptHeader, expectedParsedAcceptHeader) 23 | }) 24 | 25 | it('Parses a string with a valid media type without an environment', function () { 26 | // GIVEN a string with a valid Accept type 27 | const validAcceptMediaType = 'application/ach+json' 28 | const expectedParsedAcceptHeader: ParsedAcceptHeader = { 29 | mediaType: validAcceptMediaType, 30 | paymentNetwork: 'ACH', 31 | } 32 | 33 | // WHEN we attempt to parse it 34 | const parsedAcceptHeader = parseAcceptHeader(validAcceptMediaType) 35 | 36 | // THEN we successfully parsed the parts 37 | assert.deepStrictEqual(parsedAcceptHeader, expectedParsedAcceptHeader) 38 | }) 39 | 40 | it('Throws an error when parsing a string with an invalid media type', function () { 41 | // GIVEN a string with an invalid Accept type 42 | const invalidAcceptMediaType = 'invalid-type' 43 | 44 | // WHEN we attempt to parse it 45 | const invalidMediaTypeParse = (): ParsedAcceptHeader => 46 | parseAcceptHeader(invalidAcceptMediaType) 47 | 48 | // THEN we throw a ParseError 49 | assert.throws( 50 | invalidMediaTypeParse, 51 | ParseError, 52 | `Invalid Accept Header. Must have an Accept header of the form "application/{payment_network}(-{environment})+json". 53 | Examples: 54 | - 'Accept: application/xrpl-mainnet+json' 55 | - 'Accept: application/btc-testnet+json' 56 | - 'Accept: application/ach+json' 57 | - 'Accept: application/payid+json' 58 | `, 59 | ) 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /test/unit/parseAcceptHeaders.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | 3 | import 'mocha' 4 | import { parseAcceptHeaders } from '../../src/services/headers' 5 | import { ParsedAcceptHeader } from '../../src/types/headers' 6 | import { ParseError } from '../../src/utils/errors' 7 | 8 | describe('Parsing - Headers - parseAcceptHeaders()', function (): void { 9 | it('Parses a list with a valid media type strings', function () { 10 | // GIVEN a string with a valid Accept type 11 | const validAcceptMediaType1 = 'application/xrpl-testnet+json' 12 | const validAcceptMediaType2 = 'application/xrpl-mainnet+json' 13 | const expectedParsedAcceptHeader1: ParsedAcceptHeader = { 14 | mediaType: validAcceptMediaType1, 15 | paymentNetwork: 'XRPL', 16 | environment: 'TESTNET', 17 | } 18 | const expectedParsedAcceptHeader2: ParsedAcceptHeader = { 19 | mediaType: validAcceptMediaType2, 20 | paymentNetwork: 'XRPL', 21 | environment: 'MAINNET', 22 | } 23 | 24 | // WHEN we attempt to parse it 25 | const parsedAcceptHeaders = parseAcceptHeaders([ 26 | validAcceptMediaType1, 27 | validAcceptMediaType2, 28 | ]) 29 | 30 | // THEN we successfully parsed the parts 31 | assert.deepStrictEqual(parsedAcceptHeaders[0], expectedParsedAcceptHeader1) 32 | assert.deepStrictEqual(parsedAcceptHeaders[1], expectedParsedAcceptHeader2) 33 | }) 34 | 35 | it('Throws an error on an empty list of media types', function () { 36 | const expectedError = `Missing Accept Header. Must have an Accept header of the form "application/{payment_network}(-{environment})+json". 37 | Examples: 38 | - 'Accept: application/xrpl-mainnet+json' 39 | - 'Accept: application/btc-testnet+json' 40 | - 'Accept: application/ach+json' 41 | - 'Accept: application/payid+json' 42 | ` 43 | // GIVEN an empty list 44 | // WHEN we attempt to parse it 45 | const invalidMediaTypeParse = (): ParsedAcceptHeader => 46 | parseAcceptHeaders([])[0] 47 | 48 | // THEN we throw a ParseError 49 | assert.throws(invalidMediaTypeParse, ParseError, expectedError) 50 | }) 51 | 52 | it('Throws an error if the list contains an invalid media type', function () { 53 | // GIVEN a string with an invalid Accept type 54 | const invalidAcceptMediaType = 'invalid-type' 55 | const validAcceptMediaType = 'application/xrpl-testnet+json' 56 | const expectedError = `Invalid Accept Header. Must have an Accept header of the form "application/{payment_network}(-{environment})+json". 57 | Examples: 58 | - 'Accept: application/xrpl-mainnet+json' 59 | - 'Accept: application/btc-testnet+json' 60 | - 'Accept: application/ach+json' 61 | - 'Accept: application/payid+json' 62 | ` 63 | 64 | // WHEN we attempt to parse it 65 | const invalidMediaTypeParse = (): ParsedAcceptHeader => 66 | parseAcceptHeaders([invalidAcceptMediaType, validAcceptMediaType])[0] 67 | 68 | // THEN we throw a ParseError 69 | assert.throws(invalidMediaTypeParse, ParseError, expectedError) 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /test/unit/urlToPayId.test.ts: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai' 2 | 3 | import 'mocha' 4 | 5 | import { urlToPayId } from '../../src/services/urls' 6 | 7 | describe('Parsing - URLs - urlToPayId()', function (): void { 8 | it('throws an error on inputs that are not HTTP/HTTPS', function (): void { 9 | // GIVEN a badly formed input 10 | const url = 'ftp://example.com/alice' 11 | const expectedErrorMessage = 12 | 'Invalid PayID URL protocol. PayID URLs must be HTTP/HTTPS.' 13 | 14 | // WHEN we attempt converting it to a PayID 15 | const badConversion = (): string => urlToPayId(url) 16 | 17 | // THEN we get our expected error 18 | assert.throws(badConversion, expectedErrorMessage) 19 | }) 20 | 21 | it('Handles an HTTPS PayID URL', function (): void { 22 | // GIVEN an http URL 23 | const url = 'https://example.com/alice' 24 | const expectedPayId = 'alice$example.com' 25 | 26 | // WHEN we attempt converting it to a PayID 27 | const actualPayId = urlToPayId(url) 28 | 29 | // THEN we get our expected error 30 | assert.strictEqual(actualPayId, expectedPayId) 31 | }) 32 | 33 | it('Handles an HTTP PayID URL', function (): void { 34 | // GIVEN an http URL 35 | const url = 'http://example.com/alice' 36 | const expectedPayId = 'alice$example.com' 37 | 38 | // WHEN we attempt converting it to a PayID 39 | const actualPayId = urlToPayId(url) 40 | 41 | // THEN we get our expected PayId 42 | assert.strictEqual(actualPayId, expectedPayId) 43 | }) 44 | 45 | it('throws an error on inputs that are not ASCII', function (): void { 46 | // GIVEN a badly formed PayID URL (non-ASCII) 47 | // Note that this is a real TLD that exists 48 | const url = 'https://hansbergren.example.संगठन' 49 | const expectedErrorMessage = 50 | 'Invalid PayID characters. PayIDs must be ASCII.' 51 | 52 | // WHEN we attempt converting it to a PayID 53 | const badConversion = (): string => urlToPayId(url) 54 | 55 | // THEN we get our expected error 56 | assert.throws(badConversion, expectedErrorMessage) 57 | }) 58 | 59 | it('throws an error on an invalid URL', function (): void { 60 | // GIVEN an invalid PayID URL (multi-step path) 61 | const url = 'https://example.com/badPath/alice' 62 | const expectedErrorMessage = 63 | 'Too many paths. The only paths allowed in a PayID are to specify the user.' 64 | 65 | // WHEN we attempt converting it to a PayID 66 | const badConversion = (): string => urlToPayId(url) 67 | 68 | // THEN we get our expected error & error message 69 | assert.throws(badConversion, expectedErrorMessage) 70 | }) 71 | 72 | it('handles a PayID URL with a subdomain', function (): void { 73 | // GIVEN a PayID URL with a subdomain 74 | const url = 'https://payid.example.com/alice' 75 | const expectedPayId = 'alice$payid.example.com' 76 | 77 | // WHEN we attempt converting it to a PayID 78 | const actualPayId = urlToPayId(url) 79 | 80 | // THEN we get our expected PayID 81 | assert.strictEqual(actualPayId, expectedPayId) 82 | }) 83 | 84 | it('handles a PayID URL with capital letters', function (): void { 85 | // GIVEN a PayID URL with capitals 86 | const url = 'https://example.com/ALICE' 87 | const expectedPayId = 'alice$example.com' 88 | 89 | // WHEN we attempt converting it to a PayID 90 | const actualPayId = urlToPayId(url) 91 | 92 | // THEN we get our expected PayID 93 | assert.strictEqual(actualPayId, expectedPayId) 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/**/*", "typings/**/*", "test/**/*"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "includes": ["src/**/*.ts"], 3 | 4 | "compileOnSave": true, 5 | "compilerOptions": { 6 | // Basic Options 7 | "project": ".", 8 | "outDir": "./build", 9 | "target": "es2019", 10 | "module": "commonjs", 11 | "moduleResolution": "node", 12 | "incremental": true, 13 | 14 | // Add support for custom typings 15 | "baseUrl": "./", 16 | "paths": { 17 | "*": ["@types/*"] 18 | }, 19 | 20 | // Source Maps & Declaration Files 21 | "declaration": true, 22 | "declarationMap": true, 23 | "sourceMap": true, 24 | 25 | // Strict Mode 26 | "strict": true, 27 | 28 | // Checks not in "strict" mode 29 | "noUnusedLocals": true, 30 | "noUnusedParameters": true, 31 | "noImplicitReturns": true, 32 | "noFallthroughCasesInSwitch": true, 33 | "forceConsistentCasingInFileNames": true, 34 | 35 | // Enables loading static JSON modules 36 | "resolveJsonModule": true 37 | } 38 | } 39 | --------------------------------------------------------------------------------