├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── docs ├── cli.mdx ├── config.mdx ├── deploy.mdx ├── install.mdx ├── nginx.mdx ├── overview.mdx ├── postgres.mdx ├── privateModules │ ├── gettingStarted.mdx │ └── manifest.mdx └── test.mdx ├── registry ├── databaseMigrations │ └── postgres │ │ ├── auth-basic.sql │ │ └── stripe-subscriptions.sql ├── dbConfig │ └── postgres │ │ ├── .env.sample │ │ ├── database.json │ │ └── docker-compose.yaml ├── modules │ ├── authBasic │ │ ├── .env.sample │ │ ├── README.mdx │ │ ├── controllers │ │ │ ├── authBasic.test.ts │ │ │ └── authBasic.ts │ │ ├── middleware │ │ │ └── authBasic │ │ │ │ └── jwt.ts │ │ ├── router │ │ │ └── auth.ts │ │ └── utils │ │ │ ├── errors │ │ │ └── auth.ts │ │ │ └── jwt │ │ │ └── tokenManager.ts │ ├── postmarkEmail │ │ ├── .env.sample │ │ ├── README.mdx │ │ ├── controllers │ │ │ ├── email.test.ts │ │ │ └── email.ts │ │ └── utils │ │ │ └── errors │ │ │ └── email.ts │ ├── shared │ │ └── utils │ │ │ ├── errors │ │ │ ├── HttpError.ts │ │ │ └── common.ts │ │ │ ├── response.test.ts │ │ │ └── response.ts │ ├── stripeSubscriptions │ │ ├── .env.sample │ │ ├── README.mdx │ │ ├── controllers │ │ │ ├── subscription.test.ts │ │ │ ├── subscription.ts │ │ │ ├── subscriptionWebhook.test.ts │ │ │ └── subscriptionWebhook.ts │ │ ├── middleware │ │ │ └── subscriptions │ │ │ │ └── stripeSignature.ts │ │ ├── router │ │ │ └── subscription.ts │ │ └── utils │ │ │ └── errors │ │ │ └── subscriptions.ts │ └── uploadToS3 │ │ ├── .env.sample │ │ ├── README.mdx │ │ ├── controllers │ │ ├── storeFileS3.test.ts │ │ └── storeFileS3.ts │ │ ├── router │ │ └── storeFileS3.ts │ │ └── utils │ │ └── errors │ │ └── storeFileS3.ts ├── package.json ├── pnpm-lock.yaml ├── repositories │ ├── connection.postgres.ts │ ├── refreshToken.interface.ts │ ├── refreshToken.postgres.ts │ ├── refreshToken.template.ts │ ├── subscription.interface.ts │ ├── subscription.postgres.ts │ ├── subscription.template.ts │ ├── user.interface.ts │ ├── user.postgres.ts │ └── user.template.ts ├── schemaValidators │ ├── authBasic.interface.ts │ ├── authBasic.yup.ts │ ├── authBasic.zod.ts │ ├── storeFile.interface.ts │ ├── storeFile.yup.ts │ ├── storeFile.zod.ts │ ├── subscription.interface.ts │ ├── subscription.yup.ts │ └── subscription.zod.ts ├── tsconfig.json └── vitest.config.ts └── templates └── node-ts ├── .dockerignore ├── .gitignore ├── Dockerfile ├── README.md ├── docker-compose.yaml ├── nginx ├── Dockerfile ├── SSL_INSTALATION.md ├── docker-compose.yaml └── nginx.conf ├── package.json ├── src ├── middleware │ └── errors.ts ├── server.ts └── utils │ ├── errors │ ├── HttpError.ts │ └── common.ts │ └── response.ts ├── tsconfig.json ├── tsup.config.ts └── vitest.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # Node.js template lock files 133 | templates/node-ts/pnpm-lock.yaml 134 | templates/node-ts/package-lock.json 135 | templates/node-ts/yarn.lock 136 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official email address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at founders@vratix.com. 63 | All complaints will be reviewed and investigated promptly and fairly. 64 | 65 | All community leaders are obligated to respect the privacy and security of the 66 | reporter of any incident. 67 | 68 | ## Enforcement Guidelines 69 | 70 | Community leaders will follow these Community Impact Guidelines in determining 71 | the consequences for any action they deem in violation of this Code of Conduct: 72 | 73 | ### 1. Correction 74 | 75 | **Community Impact**: Use of inappropriate language or other behavior deemed 76 | unprofessional or unwelcome in the community. 77 | 78 | **Consequence**: A private, written warning from community leaders, providing 79 | clarity around the nature of the violation and an explanation of why the 80 | behavior was inappropriate. A public apology may be requested. 81 | 82 | ### 2. Warning 83 | 84 | **Community Impact**: A violation through a single incident or series of 85 | actions. 86 | 87 | **Consequence**: A warning with consequences for continued behavior. No 88 | interaction with the people involved, including unsolicited interaction with 89 | those enforcing the Code of Conduct, for a specified period of time. This 90 | includes avoiding interactions in community spaces as well as external channels 91 | like social media. Violating these terms may lead to a temporary or permanent 92 | ban. 93 | 94 | ### 3. Temporary Ban 95 | 96 | **Community Impact**: A serious violation of community standards, including 97 | sustained inappropriate behavior. 98 | 99 | **Consequence**: A temporary ban from any sort of interaction or public 100 | communication with the community for a specified period of time. No public or 101 | private interaction with the people involved, including unsolicited interaction 102 | with those enforcing the Code of Conduct, is allowed during this period. 103 | Violating these terms may lead to a permanent ban. 104 | 105 | ### 4. Permanent Ban 106 | 107 | **Community Impact**: Demonstrating a pattern of violation of community 108 | standards, including sustained inappropriate behavior, harassment of an 109 | individual, or aggression toward or disparagement of classes of individuals. 110 | 111 | **Consequence**: A permanent ban from any sort of public interaction within the 112 | community. 113 | 114 | ## Attribution 115 | 116 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 117 | version 2.1, available at 118 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 119 | 120 | Community Impact Guidelines were inspired by 121 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 122 | 123 | For answers to common questions about this code of conduct, see the FAQ at 124 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 125 | [https://www.contributor-covenant.org/translations][translations]. 126 | 127 | [homepage]: https://www.contributor-covenant.org 128 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 129 | [Mozilla CoC]: https://github.com/mozilla/diversity 130 | [FAQ]: https://www.contributor-covenant.org/faq 131 | [translations]: https://www.contributor-covenant.org/translations 132 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Vratix LTD 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vratix API Module Library 2 | [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) 3 | ![GitHub License](https://img.shields.io/github/license/vratix-dev/api-library) 4 | ![NPM Downloads](https://img.shields.io/npm/dm/vratix) 5 | 6 | ## TL;DR 7 | 8 | Use the `init` command to create a new Node.js project: 9 | ```bash 10 | npx vratix init 11 | ``` 12 | --- 13 | - [Overview](#overview) 14 | - [Installation](#installation) 15 | - [Private Modules](./docs/privateModules/gettingStarted.mdx) 16 | - [Community Modules](#modules) 17 | - [Auth Basic](./registry/modules/authBasic/README.mdx) 18 | - [Stripe Subscriptions](./registry/modules/stripeSubscriptions/README.mdx) 19 | - [Emails (Postmark)](./registry/modules/postmarkEmail/README.mdx) 20 | - [S3 File Upload](./registry/modules/upload-to-s3/README.mdx) 21 | - [NGINX Proxy](./docs/nginx.mdx) 22 | - [Configuration](./docs/config.mdx) 23 | - [The CLI](./docs/cli.mdx) 24 | - [License](LICENSE) 25 | 26 | ## Overview 27 | We created this library of reusable API modules to simplify API development because we were wasting too much time setting up basic functionality and researching the latest backend best practices. 28 | We wanted a repository of high-quality API modules we can reuse, copy and paste into our projects and have a working backend in seconds. 29 | 30 | Currently, the modules work for **Express.js**, however, we’re actively working to extend compatibility with other backend languages and popular Node.js frameworks. 31 | We would be more than happy for you to contribute and help us achieve this faster. 32 | 33 | > This isn’t just another package; it’s a source code repository you can copy and use — your code, your way. 34 | The modules are designed to be a solid foundation for any API service, **you should customize them to fit your unique needs**. 35 | 36 | **We recommend using our CLI** to import modules into your codebase. It automates file placement, manages external dependencies, sets up database repositories and migrations, and resolves module imports. 37 | 38 | ## Installation 39 | 40 | You’re free to copy and use any code from this API Module Library — it's designed to be a foundation you can build on. 41 | 42 | To simplify setup and integration, we created a CLI tool that helps you start new projects or integrate our API Modules into existing ones. 43 | The CLI handles imports, configurations, and dependencies automatically, so you can get up and running in minutes. 44 | 45 | ### Start a New Project 46 | 47 | Use the `init` command to create a new Node.js project or configure an existing one. 48 | Add the `-c` flag to specify a custom folder, or the CLI will set up the project in the current directory: 49 | 50 | ```bash 51 | npx vratix init 52 | ``` 53 | 54 | ### Configure Your Project 55 | 56 | The CLI will prompt you with a few questions to configure your project and create `./config/modules.json`: 57 | 58 | ```txt showLineNumbers 59 | Select your package manager: › pnpm 60 | What database are you going to use: › PostgreSQL 61 | Select your schema validator: › zod 62 | Should we set up Docker containers for this service (docker-compose.yaml): › no / yes 63 | Should we configure a web proxy for this project (NGINX): › no / yes 64 | ``` 65 | 66 | ### Choose API Modules 67 | 68 | During setup, select any initial API Modules you’d like to install as part of the project template: 69 | 70 | ```txt showLineNumbers 71 | ☐ Auth (Basic) 72 | ☐ Stripe Subscriptions 73 | ☐ S3 File Upload 74 | ☐ None 75 | ``` 76 | 77 | If you choose "None," you can add modules individually after setup. 78 | 79 | ### Set Folder Overrides 80 | 81 | Customize the paths for main module folders if needed: 82 | 83 | ```txt showLineNumbers 84 | @components -> /src/components 85 | @routes -> /src/routes 86 | @middleware -> /src/middleware 87 | @utils -> /src/utils 88 | ``` 89 | 90 | > **Note**: Any folder overrides will still be located within `/src`. 91 | 92 | ### Ready To Go 93 | 94 | Once setup is complete, to start your service run: 95 | 96 | ```bash 97 | npm run dev:local 98 | ``` 99 | 100 | If you are using docker you can run your service container with: 101 | 102 | ```bash 103 | docker compose up -d --build 104 | ``` 105 | > Check [Deployment](/docs/deploy) for detailed guides. 106 | 107 | #### To add additional modules after the initial setup, use: 108 | 109 | ```bash 110 | npx vratix add 111 | ``` -------------------------------------------------------------------------------- /docs/cli.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: The CLI 3 | description: Guide to using our CLI 4 | --- 5 | 6 | Our CLI offers a quick, hassle-free way to set up a new project and install API modules with a single command. 7 | 8 | While you’re free to copy source code directly from our GitHub repo, the CLI significantly simplifies the setup process by automating file placements, dependency resolutions, and other configurations. 9 | 10 | The "raw" source code of API modules in GitHub may appear complex if copied directly. This setup is intentional to make the CLI work but can be confusing when integrating manually. 11 | 12 | Using the CLI ensures smooth installation by handling: 13 | - Copying files from the module registry 14 | - Installing schema validators and database repositories 15 | - Resolving internal and external dependencies 16 | - Adjusting `import` aliases in each file 17 | 18 | ## `init` 19 | 20 | Use the `init` command to create a new project using our Node.js template or to add a `.config/modules.json` file to an existing project. 21 | 22 | ```bash 23 | npx vratix init 24 | ``` 25 | 26 | This command installs the template, sets up `package.json`, and configures the project based on your choices. For details on this process, refer to the [Installation Guide](/docs/install). 27 | 28 | ### Options 29 | ```txt 30 | Usage: vratix init|i [options] 31 | 32 | Initialize project 33 | 34 | Options: 35 | -c, --cwd The working directory. Defaults to the current directory. 36 | -h, --help Display help for the command 37 | ``` 38 | 39 | ## `add` 40 | 41 | The `add` command allows you to add new API modules to your project. It checks if both `package.json` and `.config/modules.json` exist in the project directory. 42 | If these files are missing, use the [init](#init) command first. 43 | 44 | ```bash 45 | npx vratix add 46 | ``` 47 | 48 | This command installs all relevant files for the specified module, manages dependencies, and adjusts `import` statements as needed. 49 | 50 | ### Options 51 | ```txt 52 | Usage: vratix add [options] [module...] 53 | 54 | Add an API module to your project 55 | 56 | Arguments: 57 | module The name of the module you want to add 58 | 59 | Options: 60 | -c, --cwd The working directory. Defaults to the current directory. 61 | -h, --help Display help for the command 62 | ``` 63 | 64 | ## `module [command]` 65 | 66 | You can use the `module` commands to create and publish new API modules to your private registry. 67 | 68 | ```bash 69 | npx vratix module [command] 70 | ``` 71 | 72 | > **NOTE**: To publish API modules you need to authenticate your CLI session using the [`login`](#login) command 73 | 74 | ### Options 75 | ```txt 76 | Usage: vratix module|m [options] [command] 77 | 78 | Private module commands. Use them to create, validate and publish Private API Modules. 79 | 80 | Options: 81 | -h, --help display help for command 82 | 83 | Commands: 84 | new Create a new API Module, this will create the necessary files and directories for your new module. 85 | publish [options] Publish your API Module to your Vratix registry. 86 | help [command] display help for command 87 | ``` 88 | 89 | ## `login` 90 | 91 | To publish or install Private API Modules you need to authenticate your CLI session. To do this use the `login` command: 92 | 93 | ```bash 94 | npx vratix login 95 | ``` 96 | 97 | This will open a browser window and ask you to verify an 8 character code shown in your terminal. The code will look something like this `6AF2-4CPI`. 98 | You need to have a Vratix account to be able to authenticate your CLI session. [Create a new account](/auth). 99 | 100 | ### Options 101 | ```txt 102 | Usage: vratix login [options] 103 | 104 | Authenticate your CLI session with your Vratix account. 105 | 106 | Options: 107 | -h, --help display help for command 108 | ``` -------------------------------------------------------------------------------- /docs/config.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Config File 3 | description: Project configuration 4 | --- 5 | 6 | When you initialize your new API project using our CLI, you will see a `.config/modules.json` file created. 7 | It contains configurations the CLI uses when adding new modules to your project. See [The CLI](/docs/cli) for more information. 8 | 9 | > **NOTE:** This configuration file is not required if you're copying code from our GitHub repo. 10 | 11 | Most modifications to this file can break the CLI’s functionality, but here are a few things you can safely adjust if needed. 12 | 13 | ## `packageManger` 14 | 15 | Specifies the package manager used to install dependencies. 16 | Change this if you switch to a different package manager after initial setup. 17 | 18 | ```json 19 | { 20 | "packageManger": "npm" | "pnpm" | "yarn", 21 | } 22 | ``` 23 | 24 | ## `docker` 25 | 26 | This property indicates whether Docker containers should be configured when installing new modules. 27 | Set this to `false` if you decide to stop using Docker. 28 | 29 | ```json 30 | { 31 | "docker": true | false 32 | } 33 | ``` 34 | 35 | ## `schemaValidator` 36 | 37 | Defines the schema validator for request payloads. 38 | If you change the validator in your codebase, update this property so the CLI installs the correct dependencies accordingly. 39 | 40 | ```json 41 | { 42 | "schemaValidator": "zod" | "yup" | "none" 43 | } 44 | ``` 45 | 46 | ## `database` 47 | 48 | Set your main database here. If you change databases, update this property so the CLI can install the appropriate DB repositories and schemas. 49 | See **Databases** for supported DB engines. 50 | 51 | ```json 52 | { 53 | "database": "postgres" 54 | } 55 | ``` 56 | 57 | ## `folderOverrides` 58 | 59 | This section allows path overrides for core files in your Node.js project. Update these properties if you change the folder structure after initialization. 60 | 61 | > By default, the CLI considers `./src` as the root directory for all files. 62 | For example, `"controllers": "controllers"` will store controller files in `./src/controllers`. 63 | 64 | ### `folderOverrides.controllers` 65 | 66 | Specifies the folder path for storing controller files, where you’ll implement business logic. 67 | 68 | ```json 69 | { 70 | "folderOverrides" : { 71 | "controllers": "controllers" 72 | } 73 | } 74 | ``` 75 | 76 | ### `folderOverrides.routes` 77 | 78 | Defines the location for endpoint route files. 79 | 80 | ```json 81 | { 82 | "folderOverrides" : { 83 | "routes": "routes" 84 | } 85 | } 86 | ``` 87 | 88 | ### `folderOverrides.middleware` 89 | 90 | Sets the path for middleware files, allowing you to configure custom middleware for Express.js. 91 | 92 | ```json 93 | { 94 | "folderOverrides" : { 95 | "middleware": "middleware" 96 | } 97 | } 98 | ``` 99 | 100 | ### `folderOverrides.utils` 101 | 102 | Specifies where helper and miscellaneous utility scripts are stored. 103 | 104 | ```json 105 | { 106 | "folderOverrides" : { 107 | "utils": "utils" 108 | } 109 | } 110 | ``` 111 | -------------------------------------------------------------------------------- /docs/deploy.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Deployment 3 | description: Deployment Guide 4 | --- 5 | 6 | Deploy your backend service locally or on your cloud service of choice using Docker or directly on your host machine. 7 | 8 | ## With Docker 9 | 10 | If Docker is enabled for your project, a `docker-compose.yaml` file will be available with all necessary container configurations. 11 | Ensure Docker Engine and Docker Compose are installed. 12 | 13 | ### localhost 14 | 15 | **Step 1:** Configure your `.env` file with the necessary environment variables. 16 | 17 | **Step 2:** Run the following command to start the service: 18 | 19 | ```bash 20 | docker compose up -d --build 21 | ``` 22 | 23 | This command performs several actions: 24 | 25 | 1. **Copies your service files** to the `backend` container. 26 | 2. **Builds the project** in the container with `tsup --watch`. 27 | 3. **Runs the service** within the container, monitoring for changes and restarting as needed. Refer to the `docker:serve` command in `package.json`. 28 | 4. **Builds and runs other containers** (e.g., `postgres` or `ingress-proxy`) as specified in `docker-compose.yaml`. 29 | 30 | ### Staging/Production 31 | 32 | > **Note:** Using a prebuilt container image is recommended for cloud deployments. This guide outlines deployment without a prebuilt image. 33 | 34 | If you are using a cloud-provisioned DB resource, you might want to remove any DB containers in staging/production by updating the `include` property in your `docker-compose.yaml`. 35 | 36 | **Step 1:** Set `NODE_ENV=production` in your `.env` file to target the production build stage (see `docker-compose.yaml` on line 7). 37 | 38 | **Step 2:** Clone your project to the host server and install any required dependencies, such as Docker Compose and `pnpm`. 39 | 40 | **Step 3:** Configure your production `.env` with the appropriate variables. 41 | 42 | **Step 4 (Optional):** Configure an NGINX proxy for SSL and to handle incoming requests. Refer to the [NGINX guide](/docs/nginx). 43 | 44 | **Step 5:** Run the following command to build and start the Docker services: 45 | 46 | ```bash 47 | docker compose up -d --build 48 | ``` 49 | 50 | ## Without Docker 51 | 52 | Running the service without Docker simplifies the process, though you may still need a DB container for local development or an NGINX proxy for staging/production. 53 | 54 | ### localhost 55 | 56 | **Step 1:** Configure your `.env` file with required local environment variables. 57 | 58 | **Step 2:** Use the following command to start the service: 59 | 60 | ```bash 61 | npm run dev:local 62 | ``` 63 | 64 | This will: 65 | 1. Build the project in watch mode using `tsup --watch` 66 | 2. Run the service, automatically restarting on file changes. See the `local:serve` command in `package.json` for configuration. 67 | 68 | ### Staging/Production 69 | 70 | **Step 1:** Clone your project to the production server. 71 | 72 | **Step 2:** Configure your production `.env` with the appropriate variables. 73 | 74 | **Step 3 (Optional):** Configure a reverse proxy (e.g., NGINX) to handle SSL and incoming requests. Refer to the [NGINX guide](/docs/nginx) for setup. 75 | 76 | **Step 4:** Run the following commands to build and start the service: 77 | 78 | ```bash 79 | npm run build:prod 80 | npm run prod:serve 81 | ``` 82 | -------------------------------------------------------------------------------- /docs/install.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Installation 3 | description: Set up the CLI 4 | --- 5 | 6 | You’re free to copy and use any code from our Community API Modules — they are designed to be a foundation you can build on. 7 | 8 | To simplify setup and integration, we created a CLI tool to help you start new projects. 9 | It handles imports, configurations, and dependencies automatically, so you can get up and running in seconds. 10 | 11 | ### Start a New Project 12 | 13 | Use the `init` command to create a new Node.js project or configure an existing one. 14 | Add the `-c` flag to specify a custom folder, or the CLI will set up the project in the current directory: 15 | 16 | ```bash 17 | npx vratix init 18 | ``` 19 | 20 | ### Configure Your Project 21 | 22 | The CLI will prompt you with a few questions to configure your project and create `./config/modules.json`: 23 | 24 | ```txt showLineNumbers 25 | Select your package manager: › pnpm 26 | What database are you going to use: › PostgreSQL 27 | Select your schema validator: › zod 28 | Should we set up Docker containers for this service (docker-compose.yaml): › no / yes 29 | Should we configure a web proxy for this project (NGINX): › no / yes 30 | ``` 31 | 32 | ### Choose API Modules 33 | 34 | During setup, select any initial API Modules you’d like to install as part of the project template: 35 | 36 | ```txt showLineNumbers 37 | ☐ Auth (Basic) 38 | ☐ Stripe Subscriptions 39 | ☐ S3 File Upload 40 | ... 41 | ☐ None 42 | ``` 43 | 44 | If you choose "None," you can add modules individually after setup. 45 | 46 | ### Set Folder Overrides 47 | 48 | Customize the paths for main module folders if needed: 49 | 50 | ```txt showLineNumbers 51 | @components -> /src/components 52 | @routes -> /src/routes 53 | @middleware -> /src/middleware 54 | @utils -> /src/utils 55 | ``` 56 | 57 | > **Note**: Any folder overrides will still be located within `/src`. 58 | 59 | ### Ready To Go 60 | 61 | Once setup is complete, to start your service run: 62 | 63 | ```bash 64 | npm run dev:local 65 | ``` 66 | 67 | If you are using docker you can run your service container with: 68 | 69 | ```bash 70 | docker compose up -d --build 71 | ``` 72 | > Check [Deployment](/docs/deploy) for detailed guides. 73 | 74 | #### To add additional modules after the initial setup, use: 75 | 76 | ```bash 77 | npx vratix add 78 | ``` -------------------------------------------------------------------------------- /docs/nginx.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: NGINX Configuration Guide 3 | description: Configure an NGINX web proxy for your backend service with SSL support 4 | --- 5 | 6 | Our Node.js template includes a minimal [NGINX configuration](https://github.com/vratix-dev/api-library/tree/17826bff71a99fc1e74860f3acc9129e27b3dbec/templates/node-ts/nginx) 7 | that works out-of-the-box for most deployments, but you can expand it as needed. 8 | 9 | The default template does not include SSL support. Below are detailed instructions on setting up SSL with Let's Encrypt. 10 | 11 | ## SSL Setup (Optional) 12 | 13 | > SSL certificates are generated on the host and then mounted into the NGINX Docker container. 14 | 15 | ### 1. Install Certbot and Generate SSL Certificates 16 | 17 | 1. **Install Certbot** (Let's Encrypt client) on your host if it’s not already installed: 18 | 19 | ```bash 20 | sudo apt install certbot 21 | ``` 22 | 23 | 2. **Generate SSL certificates** for your domain. Replace `yourdomain.com` with your actual domain name: 24 | 25 | ```bash /yourdomain.com/ 26 | sudo certbot certonly --standalone -d yourdomain.com -d www.yourdomain.com 27 | ``` 28 | 29 | Certificates will be stored in `/etc/letsencrypt/live/yourdomain.com/`. 30 | 31 | ### 2. Update `nginx.conf` for SSL 32 | 33 | Modify your `nginx.conf` to enable SSL: 34 | 35 | 1. **Add an HTTPS server block** in `nginx.conf`, configured as follows: 36 | ```nginx /yourdomain.com/ 37 | server { 38 | listen 443 ssl; 39 | server_name yourdomain.com; 40 | 41 | ssl_certificate /etc/ssl/certs/fullchain.pem; 42 | ssl_certificate_key /etc/ssl/certs/privkey.pem; 43 | 44 | ssl_protocols TLSv1.2 TLSv1.3; 45 | ssl_prefer_server_ciphers on; 46 | 47 | location / { 48 | proxy_pass http://backend:3000; 49 | proxy_set_header Host $host; 50 | proxy_set_header X-Real-IP $remote_addr; 51 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 52 | proxy_set_header X-Forwarded-Proto $scheme; 53 | } 54 | } 55 | ``` 56 | 2. **Add an HTTP to HTTPS redirect block** (optional): 57 | ```nginx /yourdomain.com/ 58 | server { 59 | listen 80; 60 | server_name yourdomain.com; 61 | 62 | return 301 https://$host$request_uri; 63 | } 64 | ``` 65 | 66 | ### 3. Mount Certificates in Docker 67 | 68 | Ensure SSL certificates are available to the NGINX container by mounting them in `docker-compose.yml`. 69 | Replace `yourdomain.com` with your domain name: 70 | 71 | ```yaml /yourdomain.com/ 72 | services: 73 | nginx: 74 | volumes: 75 | - ./etc/letsencrypt/live/yourdomain.com:/etc/ssl/certs:ro 76 | ``` 77 | 78 | ### 4. Automate SSL Renewal 79 | 80 | 1. **Open your crontab** to add an automated renewal job: 81 | 82 | ```bash 83 | sudo crontab -e 84 | ``` 85 | 86 | 2. **Add the renewal command** to run daily at 3 AM (or adjust the schedule as desired): 87 | 88 | ```bash 89 | 0 3 * * * certbot renew --quiet && docker compose -f /path-to-your-project/docker-compose.yml restart nginx 90 | ``` 91 | 92 | This setup will check for certificate renewal daily and restart NGINX if renewal occurs. 93 | -------------------------------------------------------------------------------- /docs/overview.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Overview 3 | description: Easy-to-use, open-source modules that implement common API logic to simplify backend development 4 | --- 5 | 6 | We created this library of reusable API modules to simplify API development because we were wasting too much time setting up basic functionality and researching the latest backend best practices. 7 | We wanted a repository of high-quality API modules we can reuse, copy and paste into our projects and have a working backend in seconds. 8 | 9 | These modules have been reliably built by us, following best practices with minimal assumptions 10 | so you can integrate them into any API project and have full ownership and control of your codebase. 11 | 12 | Currently, the modules work for **Express.js**, however, we’re actively working to extend compatibility with other backend languages and popular Node.js frameworks. 13 | We would be more than happy for you to contribute and help us achieve this faster. 14 | 15 | > This isn’t just another package; it’s a source code repository you can copy and use — your code, your way. 16 | The modules are designed to be a solid foundation for any API service, **you should customize them to fit your unique needs**. 17 | 18 | **We recommend using our CLI** to import modules into your codebase. It automates file placement, manages external dependencies, sets up database repositories and migrations, and resolves module imports. 19 | 20 | Stop reinventing the wheel. Start innovating where it counts. -------------------------------------------------------------------------------- /docs/postgres.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: PostgreSQL 3 | description: Guide for setting up and managing PostgreSQL with our API modules 4 | --- 5 | Our API modules currently support PostgreSQL for handling data storage, migrations, and seamless integration with your backend. 6 | This guide covers configuring PostgreSQL, setting up a local environment, and managing migrations. 7 | 8 | ## Configuration 9 | 10 | To configure PostgreSQL as your database engine, ensure you’ve set up your environment file correctly with the following `.env` variable: 11 | 12 | ```dotenv 13 | PG_CONNECTION_STRING=postgresql://{username}:{password}@{hostname}:5432/postgres 14 | ``` 15 | 16 | If you are running the DB as a Docker container, use `postgres` as the hostname. 17 | 18 | ## Running with Docker 19 | 20 | To spin up a local PostgreSQL database, you can use Docker to avoid manual installation: 21 | 22 | 1. If Docker is enabled in your project, a `/dbConfig/postgres/docker-compose.yaml` file should already be present with a PostgreSQL service setup, 23 | If you haven't created your project using our CLI, you will need to add a Docker Compose file yourself. 24 | 25 | 2. To start the PostgreSQL container, run: 26 | ```bash 27 | docker compose up -d postgres 28 | ``` 29 | 30 | ## Migrations 31 | 32 | Our CLI generates migration files for each module, making it easy to handle your database updates. 33 | 34 | > **NOTE:** All of our modules that have a DB repository implementation come with schema and migration files, which are added to your project when installing them. 35 | 36 | We add a `package.json` file in `/src/db-migrations` to enable `db-migrate` to work with our ESM project. **Do not modify the contents of this file.** 37 | 38 | To run migrations, execute the following command from your project root: 39 | ```bash 40 | npm run migrate:up 41 | ``` 42 | 43 | To create new migration files for your SQL schemas, follow the `db-migrate` [package guide](https://db-migrate.readthedocs.io/en/latest/Getting%20Started/usage/#creating-migrations). 44 | 45 | ## Template Repository 46 | 47 | If you select None as your database during CLI setup, a repository template will be added to your project. 48 | This template provides a foundation for implementing any database of your choice. 49 | The repository pattern abstracts data access logic, making it easy to integrate, replace, or modify for various databases. 50 | -------------------------------------------------------------------------------- /docs/privateModules/gettingStarted.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting Started 3 | description: Create custom API Modules and install them via the Vratix CLI into any Node.js project 4 | --- 5 | 6 | **Private API Modules** allow developers to write, manage, and reuse custom backend API logic. 7 | You can publish private modules via the CLI or by connecting a GitHub repository through the Vratix dashboard. 8 | 9 | ## How Private API Modules Work 10 | 11 | - Each user gets a **private registry** where they can publish and manage their API Modules. 12 | - Modules can be created using the **Vratix CLI** or by linking a **GitHub repository**. 13 | - Every module must include a valid [`manifest.json`](/docs/privateModules/manifest) file. 14 | - Modules can depend on **Community API Modules** (Vratix Open Source API Library) or other private modules from your registry. 15 | - Each module has a **size limit of 5MB**. 16 | 17 | ## Create Your Vratix Account 18 | 19 | To create, publish, and install Private Modules, you need to create a [Vratix account](/auth). 20 | 21 | When you sign up, we will create a **Private Registry** linked to your account, where all your custom API Modules will be stored. 22 | 23 | ## Authenticating Your CLI Session 24 | 25 | Before you can **publish** private modules or **install** them into your API projects, you need to authenticate your CLI session. 26 | 27 | Run the following command: 28 | 29 | ```bash 30 | npx vratix login 31 | ``` 32 | 33 | This will open a browser window where you can authorize the CLI to use your Vratix account. 34 | Once authenticated, you can proceed with publishing and installing modules. 35 | 36 | Your CLI session is **valid for 1 hour** after which you need to authenticate again. 37 | 38 | ## Creating a New Private API Module 39 | 40 | You can create a new API Module in two ways: 41 | 42 | ### Using Our GitHub Template 43 | 44 | We provide a [GitHub template](https://github.com/vratix-dev/api-module-template) to quickly scaffold a new API Module. 45 | Fork the template, customize your module, and publish it using the CLI or by linking the repository to Vratix. 46 | 47 | ### Using the CLI 48 | 49 | Run the following command: 50 | 51 | ```bash 52 | npx vratix module new 53 | ``` 54 | 55 | This command will guide you through creating the basic structure of an API Module, including a `manifest.json` file. 56 | 57 | ### Example `manifest.json` 58 | 59 | ```json 60 | { 61 | "key": "ios-billing-module", 62 | "version": "1.0.0", 63 | "name": "iOS Billing", 64 | "description": "A simple API module that integrates billing for iOS mobile apps", 65 | "typescript": true, 66 | "framework": "express", 67 | "folders": { 68 | "controllers": "controllers", 69 | "routes": "routes", 70 | "middleware": "middleware", 71 | "utils": "utils" 72 | }, 73 | "registryDependencies": ["payments"], 74 | "communityDependencies": ["auth-basic"] 75 | } 76 | ``` 77 | 78 | Learn more about configuring your module’s [`manifest.json`](/docs/privateModules/manifest). 79 | 80 | You can also override the standard folder names, just like when creating a new API service with **Community Modules**. 81 | 82 | ## Publishing a Private API Module 83 | 84 | There are two ways to publish a private module: 85 | 86 | ### Using a GitHub Repository 87 | 88 | You can also publish modules by linking a GitHub repository to your account in the **Vratix Dashboard**. 89 | This ensures seamless source code updates on every push to the `main` branch. 90 | 91 | **Note:** Only repositories with a valid `manifest.json` file can be imported to your private registry. 92 | 93 | ### Using the CLI 94 | 95 | Once your module is ready, publish it with: 96 | 97 | ```bash 98 | npx vratix module publish --private 99 | ``` 100 | 101 | This will upload your module to your **private registry**. 102 | 103 | ## Installing a Private API Module 104 | 105 | Private modules published via the CLI or GitHub can be easily installed into your Node.js projects. 106 | 107 | > ⚠ **Do not modify or move your `manifest.json` file**, as this may cause issues during installation. 108 | 109 | To install a private module, run: 110 | 111 | ```bash 112 | npx vratix add 113 | ``` 114 | 115 | You will first be asked if you want to add any **Community Modules**, then you can select from your **Private Registry**. 116 | 117 | ## Managing Dependencies 118 | 119 | Private modules can depend on: 120 | 121 | - **Community API Modules** (our Open Source API Library) 122 | - **Other Private Modules** from your registry 123 | 124 | Dependencies should be listed in `manifest.json`. 125 | When installing a module, the **Vratix CLI** will automatically resolve and install its dependencies. -------------------------------------------------------------------------------- /docs/privateModules/manifest.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Manifest File 3 | description: API Module configuration 4 | --- 5 | 6 | Each Private API Module needs a `manifest.json` file which defines the module configurations used when installing the module via our CLI. 7 | 8 | The `manifest.json` file is automatically created inside `.config/manifest.json` when you create a new API Module via the CLI. 9 | If you create an API Module from scratch you need to make sure to add a valid manifest.json file so you can later add it to your Vratix registry. 10 | 11 | ## `key` 12 | 13 | This is the unique identifier of every Private Module in your registry. 14 | When using the CLI, the `key` is generated from the module name you select. 15 | 16 | ```json 17 | { 18 | "key": "your-module-key" 19 | } 20 | ``` 21 | 22 | > This is unique across your account. 23 | 24 | ## `name` 25 | 26 | This is the name of your module, it shows on the Vratix dashboard and CLI. 27 | 28 | ```json 29 | { 30 | "name": "Your API Module" 31 | } 32 | ``` 33 | 34 | ## `description` 35 | 36 | Short description of what the modules does. Shows up on the Vratix dashboard and CLI. 37 | 38 | ```json 39 | { 40 | "description": "A simple API module that integrates some repetitive logic" 41 | } 42 | ``` 43 | 44 | ## `typescript` 45 | 46 | Added for forward compatibility, this doesn't change anything. 47 | 48 | ```json 49 | { 50 | "typescript": true | false 51 | } 52 | ``` 53 | 54 | ## `framework` 55 | 56 | Added for forward compatibility, this doesn't change anything. 57 | 58 | ```json 59 | { 60 | "framework": "express" 61 | } 62 | ``` 63 | 64 | ## `folders` 65 | 66 | Just like when creating new Node.js services with the Community Modules, you can modify the default folder names of your API Module. 67 | 68 | > You can have different folder names for your API Module and `folderOverrides` in your API project. 69 | > The CLI will resolve any differences in imports and folder names automatically. 70 | 71 | ### `folders.controllers` 72 | 73 | Specifies the folder path for storing controller files, where you’ll implement business logic. 74 | 75 | ```json 76 | { 77 | "folders": { 78 | "controllers": "controllers" 79 | } 80 | } 81 | ``` 82 | 83 | ### `folders.routes` 84 | 85 | Defines the location for endpoint route files. 86 | 87 | ```json 88 | { 89 | "folders": { 90 | "routes": "routes" 91 | } 92 | } 93 | ``` 94 | 95 | ### `folders.middleware` 96 | 97 | Sets the path for middleware files, allowing you to configure custom middleware for Express.js. 98 | 99 | ```json 100 | { 101 | "folders": { 102 | "middleware": "middleware" 103 | } 104 | } 105 | ``` 106 | 107 | ### `folders.utils` 108 | 109 | Specifies where helper and miscellaneous utility scripts are stored. 110 | 111 | ```json 112 | { 113 | "folders": { 114 | "utils": "utils" 115 | } 116 | } 117 | ``` 118 | 119 | ## `registryDependencies` 120 | 121 | An array of **Private API Module** keys. Use this property to list any dependencies from **your private** Vratix registry. 122 | 123 | The CLI will try to install all private dependencies when adding the API Module to a project. 124 | 125 | ```json 126 | { 127 | "registryDependencies": ["payments", "scheduling-jobs"] 128 | } 129 | ``` 130 | 131 | > Currently you need to add this manually and make sure the dependency modules exist. 132 | 133 | ## `communityDependencies` 134 | 135 | An array of **Community API Module** keys. Use this property to list any dependencies from **the open source** Vratix registry (API Library). 136 | 137 | The CLI will try to install all community dependencies when adding the API Module to a project. 138 | 139 | ```json 140 | { 141 | "communityDependencies": ["auth-basic"] 142 | } 143 | ``` 144 | 145 | > Currently you need to add this manually and make sure the dependency modules exist. 146 | 147 | ## Example `manifest.json` 148 | 149 | ```json 150 | { 151 | "key": "ios-billing-module", 152 | "version": "1.0.0", 153 | "name": "iOS Billing", 154 | "description": "A simple API module that integrates billing for iOS mobile apps", 155 | "typescript": true, 156 | "framework": "express", 157 | "folders": { 158 | "controllers": "controllers", 159 | "routes": "routes", 160 | "middleware": "middleware", 161 | "utils": "utils" 162 | }, 163 | "registryDependencies": ["payments"], 164 | "communityDependencies": ["auth-basic"] 165 | } 166 | ``` 167 | -------------------------------------------------------------------------------- /docs/test.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Testing 3 | description: Guide for running the tests that come with each API Module 4 | --- 5 | 6 | Our API modules come with pre-written tests for controllers and middleware, making it easy to ensure your service functions as expected. 7 | We have included the test coverage for each module in the documentation. 8 | 9 | ## Setup 10 | 11 | Make sure to configure `.env` variables in your `vitest.config.ts` file so the tests can run. You can use mock values. 12 | 13 | ```json 14 | { 15 | "env": { 16 | "JWT_SECRET_KEY": "testSecretKey", 17 | "JWT_ISSUER": "api.tests", 18 | "S3_BUCKET_NAME": "mockBucketName", 19 | "S3_BUCKET_REGION": "us-east-1", 20 | "POSTMARK_SERVER_TOKEN": "xxxx-xxxxx-xxxx-xxxxx-xxxxxx" 21 | } 22 | } 23 | ``` 24 | 25 | ## Running Tests 26 | 27 | To execute tests, run the following command from the root of your project: 28 | 29 | ```bash 30 | npm run test 31 | ``` 32 | 33 | ## Coverage Report 34 | 35 | To generate a test coverage report, use: 36 | 37 | ```bash 38 | npm run coverage 39 | ``` 40 | 41 | This command will create a detailed report, allowing you to identify which parts of your code are covered by tests. 42 | -------------------------------------------------------------------------------- /registry/databaseMigrations/postgres/auth-basic.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE users ( 2 | "id" SERIAL PRIMARY KEY, 3 | "username" VARCHAR(255) UNIQUE NOT NULL, 4 | "password" VARCHAR(255) NOT NULL, 5 | "email" VARCHAR(255) UNIQUE, 6 | "created_at" TIMESTAMP NOT NULL DEFAULT NOW() 7 | ); 8 | 9 | CREATE TABLE refresh_tokens ( 10 | "id" SERIAL PRIMARY KEY, 11 | "token" UUID NOT NULL DEFAULT gen_random_uuid(), 12 | "token_family" UUID NOT NULL DEFAULT gen_random_uuid(), 13 | "user_id" INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, 14 | "active" BOOLEAN DEFAULT true, 15 | "expires_at" TIMESTAMP NOT NULL, 16 | "created_at" TIMESTAMP NOT NULL DEFAULT NOW() 17 | ); 18 | 19 | CREATE INDEX idx_users_username ON users(username); 20 | CREATE INDEX idx_refresh_tokens_token ON refresh_tokens(token); 21 | CREATE INDEX idx_refresh_tokens_token_family ON refresh_tokens(token_family); 22 | -------------------------------------------------------------------------------- /registry/databaseMigrations/postgres/stripe-subscriptions.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE user_subscriptions ( 2 | "id" SERIAL PRIMARY KEY, 3 | "plan" VARCHAR NOT NULL, 4 | "user_id" INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, 5 | "customer_id" VARCHAR, 6 | "subscription_id" VARCHAR NOT NULL, 7 | "is_owner" BOOLEAN NOT NULL DEFAULT TRUE, 8 | "created_at" TIMESTAMP NOT NULL DEFAULT NOW(), 9 | UNIQUE (user_id, subscription_id) 10 | ); 11 | 12 | CREATE INDEX idx_user_subscriptions_plan ON user_subscriptions(plan); 13 | CREATE INDEX idx_user_subscriptions_user_id ON user_subscriptions(user_id); 14 | -------------------------------------------------------------------------------- /registry/dbConfig/postgres/.env.sample: -------------------------------------------------------------------------------- 1 | # PostgreSQL env variables for local development - NOTE: Change when deploying in production! 2 | PG_CONNECTION_STRING=postgresql://postgres:password@localhost:5432/postgres 3 | -------------------------------------------------------------------------------- /registry/dbConfig/postgres/database.json: -------------------------------------------------------------------------------- 1 | { 2 | "postgres": { "ENV": "PG_CONNECTION_STRING" }, 3 | "sql-file": true 4 | } 5 | -------------------------------------------------------------------------------- /registry/dbConfig/postgres/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | container_name: postgres 4 | image: postgres:16.4-alpine3.20 5 | restart: always 6 | environment: 7 | POSTGRES_PASSWORD: password 8 | volumes: 9 | - ./pgdata:/var/lib/postgresql/data 10 | ports: 11 | - 5432:5432 12 | depends_on: 13 | - backend 14 | networks: 15 | - backend-network 16 | -------------------------------------------------------------------------------- /registry/modules/authBasic/.env.sample: -------------------------------------------------------------------------------- 1 | # auth-basic env variables 2 | JWT_SECRET_KEY= 3 | JWT_ISSUER= -------------------------------------------------------------------------------- /registry/modules/authBasic/README.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Auth Basic 3 | description: Simple username and password authentication module 4 | features: 5 | newFeatures: 6 | - Automatic Reuse Detection 7 | available: 8 | - Username and Password Authentication 9 | - JWT Access Tokens 10 | - Refresh Tokens 11 | - Reply Attack Protection 12 | comingSoon: 13 | - HTTP Cookie Sessions 14 | postmanCollection: https://app.getpostman.com/run-collection/39515350-6b29066f-831e-4b43-a6ac-5f84801aa5b0?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D39515350-6b29066f-831e-4b43-a6ac-5f84801aa5b0%26entityType%3Dcollection%26workspaceId%3D36bf5973-695a-44e0-889e-bba83a364391 15 | testCoverage: 89.3 16 | --- 17 | 18 | ## About 19 | 20 | The **Auth Basic Module** provides essential authentication functionality for backend services. 21 | It supports quick setup for user signup and login with username and password, along with session management. 22 | As a core module, it enables secure access to routes and serves as the foundation for many other API modules. 23 | 24 | ## Installation 25 | 26 | To add the Auth Basic Module to your project, run: 27 | 28 | ```bash 29 | npx vratix add auth-basic 30 | ``` 31 | 32 | ## .env 33 | 34 | Add the following environment variables to your `.env` file: 35 | 36 | - **JWT_SECRET_KEY**: Secret key used for signing and verifying JWTs 37 | - **Default**: None (required) 38 | - **Example**: `JWT_SECRET_KEY=your-secret-key` 39 | - **JWT_ISSUER**: Issuer identifier for JWTs to ensure they’re issued by your backend 40 | - **Default**: None (required) 41 | - **Example**: `JWT_ISSUER=com.yourdomain` 42 | 43 | ## Usage 44 | 45 | Import the router from `@/routes/auth.js` in your main entry point file (e.g., `server.ts`): 46 | 47 | ```typescript server.ts 48 | import { router as authRouter } from "@/routes/auth.js"; 49 | 50 | app.use("/api/auth", authRouter); 51 | ``` 52 | 53 | ### Middleware 54 | 55 | This module provides a `protectedRoute` middleware to secure endpoints, requiring a valid JWT access token for access. 56 | 57 | ```typescript showLineNumbers {3} /protectedRoute/2 58 | import { protectedRoute } from "@/middleware/jwt"; 59 | 60 | router.post("/upload/:fileName", protectedRoute, async (req, res, next) => { 61 | ... 62 | }); 63 | ``` 64 | 65 | ### Endpoints 66 | 67 | The Auth Basic Module exposes the following endpoints: 68 | 69 | | Method | Endpoint | Description | 70 | | ------ | ---------------- | ----------------------------------------------------------- | 71 | | POST | `/signup` | Creates a new user account and returns session tokens | 72 | | POST | `/login` | Authenticates the user and returns session tokens | 73 | | POST | `/refresh-token` | Issues a new JWT access token and rotates the refresh token | 74 | 75 | ### Errors 76 | 77 | Below are common errors with solutions for this module: 78 | 79 | | Error Code | Name | Solution | 80 | | ---------- | ----------------------- | ---------------------------------------------------------------- | 81 | | 409 | UsernameNotAvailable | Ensure `username` is unique | 82 | | 409 | InvalidLoginCredentials | Verify the credentials are correct | 83 | | 403 | ForbiddenError | User attempted to access a protected route with an invalid token | 84 | | 500 | JWTEnvVariableMissing | Verify `.env` file configuration | 85 | 86 | ### Examples 87 | 88 | To explore sample requests and responses, download our Postman collection: 89 | 90 | 94 | -------------------------------------------------------------------------------- /registry/modules/authBasic/controllers/authBasic.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; 2 | import argon from "argon2"; 3 | 4 | import { createAuthBasicController } from "@/modules/authBasic/controllers/authBasic.js"; 5 | 6 | import { UserRepository, User } from "@/repositories/user.interface.js"; 7 | import { 8 | RefreshTokenRepository, 9 | RefreshToken, 10 | } from "@/repositories/refreshToken.interface.js"; 11 | import { BasicAuthSchema } from "@/schemaValidators/authBasic.interface.js"; 12 | 13 | import { 14 | usernameNotAvailable, 15 | invalidLoginCredentials, 16 | } from "@/modules/authBasic/utils/errors/auth.js"; 17 | import { forbiddenError } from "@/modules/shared/utils/errors/common.js"; 18 | 19 | vi.mock("argon2"); 20 | 21 | describe("auth-basic API Module tests", () => { 22 | describe("AuthBasic Controller Tests", () => { 23 | let controller: ReturnType; 24 | let userRepositoryMock: Partial; 25 | let rtRepositoryMock: RefreshTokenRepository; 26 | 27 | const mockUser: User = { 28 | userId: 1, 29 | username: "testUser", 30 | password: "hashedPassword", 31 | createdAt: new Date().toISOString(), 32 | }; 33 | 34 | const mockRefreshToken: RefreshToken = { 35 | userId: mockUser.userId, 36 | token: "1234", 37 | tokenFamily: "mockFamilyId", 38 | active: true, 39 | expiresAt: new Date( 40 | new Date().getTime() + 1 * 24 * 60 * 6000 41 | ).toISOString(), 42 | }; 43 | 44 | const mockNewRefreshToken: RefreshToken = { 45 | ...mockRefreshToken, 46 | token: "567", 47 | expiresAt: new Date( 48 | new Date().getTime() + 31 * 24 * 60 * 6000 49 | ).toISOString(), 50 | }; 51 | 52 | beforeEach(() => { 53 | userRepositoryMock = { 54 | getUser: vi.fn(), 55 | createAuthBasicUser: vi.fn(), 56 | }; 57 | 58 | rtRepositoryMock = { 59 | getToken: vi.fn(), 60 | invalidateTokenFamily: vi.fn(), 61 | createToken: vi.fn(), 62 | }; 63 | 64 | // Injecting the mocked repository into the controller 65 | controller = createAuthBasicController( 66 | userRepositoryMock as UserRepository, 67 | rtRepositoryMock 68 | ); 69 | }); 70 | 71 | it("should throw usernameNotAvailable when user exists on signup", async () => { 72 | const signupData: BasicAuthSchema = { 73 | username: mockUser.username, 74 | password: mockUser.password, 75 | }; 76 | 77 | (userRepositoryMock.getUser as Mock).mockResolvedValue(mockUser); 78 | 79 | await expect(controller.signup(signupData)).rejects.toThrowError( 80 | usernameNotAvailable() 81 | ); 82 | expect(userRepositoryMock.getUser).toHaveBeenCalledWith( 83 | signupData.username 84 | ); 85 | }); 86 | 87 | it("should create a new user on signup", async () => { 88 | const signupData: BasicAuthSchema = { 89 | username: "newUser", 90 | password: "123", 91 | }; 92 | 93 | const mockNewUser = { 94 | userId: 123, 95 | ...signupData, 96 | }; 97 | 98 | (userRepositoryMock.getUser as Mock).mockResolvedValue(null); 99 | (argon.hash as Mock).mockResolvedValue("hashedPass"); 100 | (userRepositoryMock.createAuthBasicUser as Mock).mockResolvedValue( 101 | mockNewUser 102 | ); 103 | 104 | const result = await controller.signup(signupData); 105 | 106 | expect(rtRepositoryMock.createToken).toHaveBeenCalledOnce(); 107 | expect(userRepositoryMock.getUser).toHaveBeenCalledWith( 108 | signupData.username 109 | ); 110 | expect(userRepositoryMock.createAuthBasicUser).toHaveBeenCalledWith({ 111 | username: signupData.username, 112 | hashedPass: "hashedPass", 113 | }); 114 | 115 | const { password, ...expectedRes } = mockNewUser; 116 | expect(result.user).toEqual(expectedRes); 117 | }); 118 | 119 | it("should throw invalidLoginCredentials when logging in with wrong username", async () => { 120 | const loginData: BasicAuthSchema = { 121 | username: "newUser", 122 | password: "123", 123 | }; 124 | 125 | (userRepositoryMock.getUser as Mock).mockResolvedValue(null); 126 | 127 | await expect(controller.login(loginData)).rejects.toThrowError( 128 | invalidLoginCredentials() 129 | ); 130 | expect(userRepositoryMock.getUser).toHaveBeenCalledWith( 131 | loginData.username 132 | ); 133 | }); 134 | 135 | it("should throw invalidLoginCredentials when logging in with correct username and wrong password", async () => { 136 | const loginData: BasicAuthSchema = { 137 | username: mockUser.username, 138 | password: "123", 139 | }; 140 | 141 | (userRepositoryMock.getUser as Mock).mockResolvedValue(mockUser); 142 | (argon.verify as Mock).mockResolvedValue(false); 143 | 144 | await expect(controller.login(loginData)).rejects.toThrowError( 145 | invalidLoginCredentials() 146 | ); 147 | expect(userRepositoryMock.getUser).toHaveBeenCalledWith( 148 | loginData.username 149 | ); 150 | expect(argon.verify).toHaveBeenCalledWith( 151 | mockUser.password, 152 | loginData.password 153 | ); 154 | }); 155 | 156 | it("should login with correct credentials", async () => { 157 | const loginData: BasicAuthSchema = { 158 | username: mockUser.username, 159 | password: mockUser.password, 160 | }; 161 | 162 | (userRepositoryMock.getUser as Mock).mockResolvedValue(mockUser); 163 | (argon.verify as Mock).mockResolvedValue(true); 164 | 165 | const result = await controller.login(loginData); 166 | 167 | expect(rtRepositoryMock.createToken).toHaveBeenCalledOnce(); 168 | expect(userRepositoryMock.getUser).toHaveBeenCalledWith( 169 | loginData.username 170 | ); 171 | expect(argon.verify).toHaveBeenCalledWith( 172 | mockUser.password, 173 | loginData.password 174 | ); 175 | 176 | const { password, ...expectedRes } = mockUser; 177 | expect(result.user).toEqual(expectedRes); 178 | }); 179 | 180 | it("should refresh token", async () => { 181 | (rtRepositoryMock.getToken as Mock).mockResolvedValue(mockRefreshToken); 182 | (rtRepositoryMock.createToken as Mock).mockResolvedValue(mockNewRefreshToken); 183 | 184 | const result = await controller.refreshToken({ token: mockRefreshToken.token }); 185 | 186 | expect(rtRepositoryMock.getToken).toHaveBeenCalledWith(mockRefreshToken.token); 187 | expect(result.refreshToken).toEqual(mockNewRefreshToken); 188 | }); 189 | 190 | it("should throw forbiddenError if token not found or expired", async () => { 191 | (rtRepositoryMock.getToken as Mock).mockResolvedValue(null); 192 | 193 | await expect( 194 | controller.refreshToken({ token: mockRefreshToken.token }) 195 | ).rejects.toThrowError(forbiddenError()); 196 | 197 | expect(rtRepositoryMock.getToken).toHaveBeenCalledWith(mockRefreshToken.token); 198 | }); 199 | 200 | it("should throw forbiddenError if token has been used", async () => { 201 | const mockExpiredRefreshToken = { ...mockRefreshToken, active: false }; 202 | 203 | (rtRepositoryMock.getToken as Mock).mockResolvedValue(mockExpiredRefreshToken); 204 | 205 | await expect( 206 | controller.refreshToken({ token: mockRefreshToken.token }) 207 | ).rejects.toThrowError(forbiddenError()); 208 | 209 | expect(rtRepositoryMock.getToken).toHaveBeenCalledWith(mockRefreshToken.token); 210 | }); 211 | }); 212 | }); 213 | -------------------------------------------------------------------------------- /registry/modules/authBasic/controllers/authBasic.ts: -------------------------------------------------------------------------------- 1 | import argon from "argon2"; 2 | 3 | import { 4 | BasicAuthSchema, 5 | RefreshTokenSchema, 6 | } from "@/schemaValidators/authBasic.interface.js"; 7 | 8 | import { accessTokenManager } from "@/modules/authBasic/utils/jwt/tokenManager.js"; 9 | import { 10 | usernameNotAvailable, 11 | invalidLoginCredentials, 12 | } from "@/modules/authBasic/utils/errors/auth.js"; 13 | import { forbiddenError } from "@/modules/shared/utils/errors/common.js"; 14 | 15 | import { User, UserRepository } from "@/repositories/user.interface.js"; 16 | import { 17 | RefreshToken, 18 | RefreshTokenRepository, 19 | } from "@/repositories/refreshToken.interface.js"; 20 | 21 | interface TokensOutput { accessToken: string; refreshToken: RefreshToken }; 22 | interface AuthOutput extends TokensOutput {user: Omit} 23 | 24 | interface AuthBasicController { 25 | login: (props: BasicAuthSchema) => Promise 26 | signup: (props: BasicAuthSchema) => Promise 27 | refreshToken: (props:RefreshTokenSchema) => Promise 28 | } 29 | 30 | export const createAuthBasicController = ( 31 | userRepo: UserRepository, 32 | refreshTokenRepo: RefreshTokenRepository 33 | ): AuthBasicController => { 34 | const generateAccessToken = (userId: number) => { 35 | const signedJWT = accessTokenManager.sign({ userId: userId.toString() }); 36 | 37 | return signedJWT; 38 | }; 39 | 40 | const generateRefreshToken = async (userId: number, tokenFamily?: string) => { 41 | const expAt = new Date(new Date().getTime() + 31 * 24 * 60 * 60000); // Expire in 31 days 42 | const refreshTokenExp = expAt.toISOString(); 43 | 44 | const token = await refreshTokenRepo.createToken({ 45 | userId, 46 | tokenFamily, 47 | expiresAt: refreshTokenExp, 48 | }); 49 | 50 | return token; 51 | }; 52 | 53 | return { 54 | async signup(props) { 55 | const { username, password, email } = props; 56 | 57 | await userRepo.getUser(username).then((res) => { 58 | if (res !== null) throw usernameNotAvailable(); 59 | }); 60 | 61 | // timeCost, parallelism and memoryCost configured according to OWASP recommendations: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html 62 | const hashedPass = await argon.hash(password, { 63 | timeCost: 2, 64 | parallelism: 1, 65 | memoryCost: 19456, // 19 MiB 66 | }); 67 | 68 | const newUser = await userRepo.createAuthBasicUser({ 69 | username, 70 | hashedPass, 71 | email, 72 | }); 73 | 74 | const refreshToken = await generateRefreshToken(newUser.userId); 75 | const accessToken = generateAccessToken(newUser.userId); 76 | 77 | const { password: _, ...userRes } = newUser; 78 | 79 | return { user: userRes, accessToken, refreshToken }; 80 | }, 81 | 82 | async login(props) { 83 | const { username, password } = props; 84 | 85 | const user = await userRepo.getUser(username).then((res) => { 86 | if (res === null) throw invalidLoginCredentials(); 87 | return res; 88 | }); 89 | 90 | const hashedPass = user.password; 91 | const isOk = await argon.verify(hashedPass, password); 92 | 93 | if (isOk) { 94 | const refreshToken = await generateRefreshToken(user.userId); 95 | const accessToken = generateAccessToken(user.userId); 96 | 97 | const { password: _, ...userRes } = user; 98 | 99 | return { user: userRes, accessToken, refreshToken }; 100 | } 101 | 102 | throw invalidLoginCredentials(); 103 | }, 104 | 105 | async refreshToken({ token }: RefreshTokenSchema) { 106 | const tokenData = await refreshTokenRepo.getToken(token); 107 | 108 | if (!tokenData) throw forbiddenError(); 109 | 110 | const { userId, tokenFamily, active } = tokenData; 111 | 112 | if (active) { 113 | // Token is valid and hasn't been used yet 114 | const newRefreshToken = await generateRefreshToken(userId, tokenFamily); 115 | const accessToken = generateAccessToken(userId); 116 | 117 | return { accessToken, refreshToken: newRefreshToken }; 118 | } else { 119 | // Previously refreshed token used, invalidate all tokens in family 120 | refreshTokenRepo.invalidateTokenFamily(tokenFamily); 121 | 122 | throw forbiddenError(); 123 | } 124 | }, 125 | }; 126 | }; 127 | -------------------------------------------------------------------------------- /registry/modules/authBasic/middleware/authBasic/jwt.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from "express"; 2 | 3 | import { createUserRepository } from "@/repositories/user.postgres.js"; 4 | 5 | import { accessTokenManager } from "@/modules/authBasic/utils/jwt/tokenManager.js"; 6 | import { notAuthenticated, invalidAccessToken } from "@/modules/authBasic/utils/errors/auth.js"; 7 | 8 | const userRepository = createUserRepository(); 9 | 10 | /** 11 | * This middleware can be used to protect a route or a single endpoint. 12 | * Add it before any authorization middleware. 13 | * 14 | * It will append the current user object to `req.user` 15 | * 16 | * Extracts JWT token from the `authorization` header with scheme `B/bearer` 17 | */ 18 | export const protectedRoute: RequestHandler = async (req, _, next) => { 19 | const authHeader = req.header("authorization"); 20 | 21 | if (!authHeader) { 22 | return next(notAuthenticated()); 23 | } 24 | 25 | const accessToken = authHeader.replace(new RegExp("\\b[Bb]earer\\s"), ""); 26 | 27 | try { 28 | const { userId } = accessTokenManager.validate(accessToken); 29 | const user = await userRepository.getUserById(parseInt(userId)); 30 | 31 | if (user) { 32 | req.user = user; 33 | next(); 34 | } else { 35 | next(invalidAccessToken()); 36 | } 37 | } catch (err) { 38 | next(invalidAccessToken()); 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /registry/modules/authBasic/router/auth.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | 3 | import { basicAuthValidator } from "@/schemaValidators/authBasic.zod.js"; 4 | 5 | import { createAuthBasicController } from "@/modules/authBasic/controllers/authBasic.js"; 6 | import { createUserRepository } from "@/repositories/user.postgres.js"; 7 | import { createRefreshTokenRepository } from "@/repositories/refreshToken.postgres.js"; 8 | 9 | import { response } from "@/modules/shared/utils/response.js"; 10 | 11 | const router = express.Router(); 12 | 13 | const userRepository = createUserRepository(); 14 | const refreshTokenRepository = createRefreshTokenRepository(); 15 | 16 | const authBasicController = createAuthBasicController( 17 | userRepository, 18 | refreshTokenRepository 19 | ); 20 | 21 | router.post("/signup", async (req, res, next) => { 22 | const payload = req.body; 23 | 24 | await basicAuthValidator() 25 | .validateAuth(payload) 26 | .then(authBasicController.signup) 27 | .then((result) => res.json(response(result))) 28 | .catch(next); 29 | }); 30 | 31 | router.post("/login", async (req, res, next) => { 32 | const payload = req.body; 33 | 34 | await basicAuthValidator() 35 | .validateAuth(payload) 36 | .then(authBasicController.login) 37 | .then((result) => res.json(response(result))) 38 | .catch(next); 39 | }); 40 | 41 | router.post("/refresh-token", async (req, res, next) => { 42 | const payload = req.body; 43 | 44 | await basicAuthValidator() 45 | .validateRefreshToken(payload) 46 | .then(authBasicController.refreshToken) 47 | .then((result) => res.json(response(result))) 48 | .catch(next); 49 | }); 50 | 51 | export { router as authRouter }; 52 | -------------------------------------------------------------------------------- /registry/modules/authBasic/utils/errors/auth.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from "@/modules/shared/utils/errors/HttpError.js"; 2 | 3 | export const jwtEnvVariablesMissing = () => { 4 | return new HttpError( 5 | "JWTEnvVariableMissing", 6 | "JWT env variables missing from your .env file", 7 | 500 8 | ); 9 | }; 10 | 11 | export const invalidAccessToken = () => { 12 | return new HttpError( 13 | "InvalidAccessToken", 14 | "The JWT Access Token is not valid, please log in your account again.", 15 | 400 16 | ); 17 | } 18 | 19 | export const invalidLoginCredentials = () => { 20 | return new HttpError( 21 | "InvalidLoginCredentials", 22 | "Your login credentials are not correct, please try logging in again.", 23 | 409 24 | ); 25 | }; 26 | 27 | export const notAuthenticated = () => { 28 | return new HttpError( 29 | "NotAuthenticated", 30 | "User not authenticated, please log into your account and try again.", 31 | 401 32 | ); 33 | }; 34 | 35 | export const usernameNotAvailable = () => { 36 | return new HttpError( 37 | "UsernameNotAvailable", 38 | "This username is already taken, please choose another one.", 39 | 409 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /registry/modules/authBasic/utils/jwt/tokenManager.ts: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | 3 | import { jwtEnvVariablesMissing } from "@/modules/authBasic/utils/errors/auth.js"; 4 | 5 | /** 6 | * Example reading public/private keys from a "./keys" folder in rootDir 7 | * 8 | * const JWT_PUBLIC_KEY = fs.readFileSync(path.join(process.cwd(), "/keys/jwt_public.key"), { encoding: 'utf8' }); 9 | * const JWT_PRIVATE_KEY = fs.readFileSync(path.join(process.cwd(), "/keys/jwt_private.key"), { encoding: 'utf8' }); 10 | */ 11 | const JWT_ISSUER = process.env.JWT_ISSUER; // This is set as the project name in `package.json` 12 | const JWT_SECRET_KEY = process.env.JWT_SECRET_KEY as string; // Randomly generated when the API module is installed. Min length: 64 characters 13 | 14 | /** 15 | * JWT Token Manager - Define any JWT-related functions here to reuse JWT configuration 16 | * 17 | * @see {algorithm} Use the `RS256` (or equivalent) algorithm with public/private keys if you are building a distributed system or the JWT token will be shared between services 18 | */ 19 | const TokenManager = ( 20 | secretOrPrivateKey: string, 21 | secretOrPublicKey: string, 22 | options: jwt.SignOptions | jwt.VerifyOptions 23 | ) => { 24 | if (!JWT_SECRET_KEY || !JWT_ISSUER) { 25 | throw jwtEnvVariablesMissing(); 26 | } 27 | 28 | const algorithm = "HS256"; 29 | 30 | const sign = (payload: T, signOptions?: jwt.SignOptions) => { 31 | const jwtSignOptions = Object.assign({ algorithm }, signOptions, options); 32 | return jwt.sign(payload, secretOrPrivateKey, jwtSignOptions); 33 | }; 34 | 35 | const validate = (token: string, verifyOptions?: jwt.VerifyOptions) => { 36 | const jwtVerifyOptions = Object.assign( 37 | { algorithms: algorithm }, 38 | verifyOptions, 39 | options 40 | ); 41 | return jwt.verify(token, secretOrPublicKey, jwtVerifyOptions) as T; 42 | }; 43 | 44 | return { validate, sign }; 45 | }; 46 | 47 | type JWTAccessToken = { 48 | userId: string; 49 | } 50 | 51 | export const accessTokenManager = TokenManager( 52 | JWT_SECRET_KEY, 53 | JWT_SECRET_KEY, 54 | { 55 | issuer: JWT_ISSUER, 56 | audience: `${JWT_ISSUER}:client`, 57 | } 58 | ); 59 | -------------------------------------------------------------------------------- /registry/modules/postmarkEmail/.env.sample: -------------------------------------------------------------------------------- 1 | # postmark-email env variables 2 | POSTMARK_SERVER_TOKEN= -------------------------------------------------------------------------------- /registry/modules/postmarkEmail/README.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Emails (Postmark) 3 | description: Simple module to send transaction emails in your backend service 4 | features: 5 | available: 6 | - Transactional Emails 7 | - Bulk Emails 8 | - DMARK Setup Instructions 9 | testCoverage: 100 10 | --- 11 | 12 | ## About 13 | 14 | The **Emails (Postmark) Module** enables you to send transactional and bulk emails using [Postmark](https://postmarkapp.com). 15 | It’s designed for secure, efficient email operations within your backend service. 16 | This module doesn’t expose endpoints by default but can easily be adapted to include them as needed. 17 | 18 | ## Installation 19 | 20 | To add the Emails (Postmark) Module to your project, run: 21 | 22 | ```bash 23 | npx vratix add postmark-email 24 | ``` 25 | 26 | ## .env 27 | 28 | Add the following environment variable to your `.env` file: 29 | 30 | - **POSTMARK_SERVER_TOKEN**: Postmark server token required to authenticate email sending 31 | - **Default**: None (required) 32 | - **Example**: `POSTMARK_SERVER_TOKEN=xxxx-xxxxx-xxxx-xxxxx-xxxxxx` 33 | 34 | ## Dependencies 35 | 36 | The module requires the `auth-basic` API module for user management. It will be automatically installed with this module if not already present. 37 | 38 | ## Usage 39 | 40 | Here is an example of using the `sendEmail` controller. If needed, you can pass a custom `postmark.ServerClient` instance when creating the `emailController`: 41 | 42 | ```typescript 43 | import { createEmailController } from "@/controllers/email.js"; 44 | import { createUserRepository } from "@/repositories/user.js"; 45 | 46 | const userRepository = createUserRepository(); 47 | const emailController = createEmailController(userRepository); 48 | 49 | type ExamplePayload = { 50 | foo: string; 51 | }; 52 | 53 | emailController.sendEmail({ 54 | receiverId: 123, 55 | templateName: "exampleTemplate", 56 | payload: { foo: "bar" }, 57 | }); 58 | ``` 59 | 60 | ### Strongly Typed Payloads 61 | 62 | To ensure strongly typed payloads, you can pass a generic type to `sendEmail` and `sendBulkEmails`: 63 | 64 | ```typescript {4} 65 | type SendEmail = { 66 | receiverId: number; 67 | templateName: string; 68 | payload: T; 69 | }; 70 | ``` 71 | 72 | You can also define payload types in `/src/controllers/email.ts` like this: 73 | 74 | ```typescript 75 | type ExampleTemplatePayload = { 76 | foo: string; 77 | }; 78 | 79 | type EmailPayload = ExampleTemplatePayload | object; 80 | 81 | type SendEmail = { 82 | receiverId: number; 83 | templateName: string; 84 | payload: EmailPayload; 85 | }; 86 | ``` 87 | 88 | ### Sending Customized Bulk Emails 89 | 90 | For bulk emails with customized content for each recipient, modify the types as follows: 91 | 92 | ```typescript 93 | type SendBulkEmails = { 94 | templateName: string; 95 | payload: Omit, "templateName">[]; 96 | }; 97 | ``` 98 | 99 | > **NOTE**: Adjust the controller logic to handle the `payload` array if sending unique data to multiple recipients. 100 | 101 | ### Errors 102 | 103 | Below are common errors with solutions for this module: 104 | 105 | | Error Code | Name | Solution | 106 | | ---------- | -------------- | ------------------------------------------------------------------------------------- | 107 | | 404 | EmailNotFound | Ensure the user has an email associated with their account | 108 | | 500 | SendEmailError | An issue occurred with the Postmark SDK. Verify your Postmark server token is correct | 109 | 110 | ## DMARC Setup Instructions (Optional) 111 | 112 | If you have configured DMARK for your domain you can skip this part. 113 | 114 | To enhance deliverability and security, follow these steps to configure DMARC for your email domain. 115 | DMARC (Domain-based Message Authentication, Reporting, and Conformance) helps protect against email spoofing and ensures that emails sent from your domain are authenticated. [Postman's What is DMARC](https://postmarkapp.com/support/article/892-what-is-dmarc) 116 | 117 | ### 1. Set Up SPF and DKIM Records 118 | 119 | - **SPF (Sender Policy Framework)**: Define the servers authorized to send emails for your domain. 120 | - Add an SPF record to your DNS settings: `v=spf1 include:spf.postmarkapp.com ~all` 121 | - **DKIM (DomainKeys Identified Mail)**: Enable DKIM to sign outgoing messages. 122 | - In Postmark, go to your domain settings to find the DKIM keys and add them to your DNS records. 123 | 124 | ### 2. Configure DMARC Policy 125 | 126 | - Create a DMARC TXT record in your DNS settings. Here’s a basic example: 127 | 128 | ```text 129 | Host: _dmarc.yourdomain.com 130 | Value: "v=DMARC1; p=none; rua=mailto:dmarc-reports@yourdomain.com" 131 | ``` 132 | 133 | - **Tip**: Start with `p=none` to monitor your DMARC results before enforcing stricter policies. 134 | -------------------------------------------------------------------------------- /registry/modules/postmarkEmail/controllers/email.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; 2 | import { ServerClient } from "postmark"; 3 | 4 | import { createEmailController } from "@/modules/postmarkEmail/controllers/email.js"; 5 | import { UserRepository } from "@/repositories/user.interface.js"; 6 | 7 | import { 8 | emailNotFound, 9 | postmarkSendError, 10 | } from "@/modules/postmarkEmail/utils/errors/email.js"; 11 | 12 | vi.mock("postmark", () => { 13 | return { 14 | ServerClient: vi.fn().mockImplementation(() => ({ 15 | sendEmailWithTemplate: vi.fn(), 16 | sendEmailBatchWithTemplates: vi.fn(), 17 | })), 18 | }; 19 | }); 20 | 21 | const pmClient = new ServerClient("xxxx-xxxxx-xxxx-xxxxx-xxxxxx"); 22 | 23 | describe("postmark-email API Module tests", () => { 24 | describe("Email Controller Tests", () => { 25 | let controller: ReturnType; 26 | let userRepositoryMock: Partial; 27 | 28 | type MockPayload = { 29 | name: string; 30 | foo: string; 31 | }; 32 | 33 | const mockUser = { id: 123, email: "mock@mail.com" }; 34 | const mockUsers = [ 35 | { id: 123, email: "mock@mail.com" }, 36 | { id: 567, email: "mock2@mail.com" }, 37 | ]; 38 | const mockTemplateName = "exampleTemplate"; 39 | const mockPayload = { name: "Bob", foo: "bar" }; 40 | 41 | beforeEach(() => { 42 | userRepositoryMock = { getUserById: vi.fn() }; 43 | 44 | // Injecting the mocked repositories into the controller 45 | controller = createEmailController( 46 | userRepositoryMock as UserRepository, 47 | pmClient 48 | ); 49 | }); 50 | 51 | it("should send and email", async () => { 52 | (userRepositoryMock.getUserById as Mock).mockResolvedValue(mockUser); 53 | (pmClient.sendEmailWithTemplate as Mock).mockResolvedValue({}); 54 | 55 | await controller.sendEmail({ 56 | receiverId: mockUser.id, 57 | templateName: mockTemplateName, 58 | payload: mockPayload, 59 | }); 60 | 61 | expect(pmClient.sendEmailWithTemplate).toHaveBeenCalledWith({ 62 | From: "no-reply@sender.com", 63 | To: mockUser.email, 64 | TemplateAlias: mockTemplateName, 65 | TemplateModel: mockPayload, 66 | TrackOpens: true, 67 | }); 68 | }); 69 | 70 | it("should throw emailNotFound during single send if user doesn't have email", async () => { 71 | (userRepositoryMock.getUserById as Mock).mockResolvedValue({ 72 | email: undefined, 73 | }); 74 | 75 | await expect( 76 | controller.sendEmail({ 77 | receiverId: mockUser.id, 78 | templateName: mockTemplateName, 79 | payload: mockPayload, 80 | }) 81 | ).rejects.toThrowError(emailNotFound()); 82 | 83 | expect(pmClient.sendEmailWithTemplate).not.toHaveBeenCalled(); 84 | }); 85 | 86 | it("should throw postmarkSendError during single send if there is a problem with Postmark SDK", async () => { 87 | const mockErr = { message: "mock msg" }; 88 | (userRepositoryMock.getUserById as Mock).mockResolvedValue(mockUser); 89 | (pmClient.sendEmailWithTemplate as Mock).mockRejectedValue(mockErr); 90 | 91 | await expect( 92 | controller.sendEmail({ 93 | receiverId: mockUser.id, 94 | templateName: mockTemplateName, 95 | payload: mockPayload, 96 | }) 97 | ).rejects.toThrowError(postmarkSendError(mockErr.message)); 98 | }); 99 | 100 | it("should send bulk emails", async () => { 101 | const mockReceivers = mockUsers.map((el) => el.id); 102 | 103 | (userRepositoryMock.getUserById as Mock) 104 | .mockResolvedValueOnce(mockUsers[0]) 105 | .mockResolvedValueOnce(mockUsers[1]); 106 | (pmClient.sendEmailBatchWithTemplates as Mock).mockResolvedValue({}); 107 | 108 | await controller.sendBulkEmails({ 109 | receiverIds: mockReceivers, 110 | templateName: mockTemplateName, 111 | payload: mockPayload, 112 | }); 113 | 114 | expect(pmClient.sendEmailBatchWithTemplates).toHaveBeenCalledWith([ 115 | { 116 | From: "no-reply@sender.com", 117 | To: mockUsers[0].email, 118 | TemplateAlias: mockTemplateName, 119 | TemplateModel: mockPayload, 120 | TrackOpens: true, 121 | }, 122 | { 123 | From: "no-reply@sender.com", 124 | To: mockUsers[1].email, 125 | TemplateAlias: mockTemplateName, 126 | TemplateModel: mockPayload, 127 | TrackOpens: true, 128 | }, 129 | ]); 130 | }); 131 | 132 | it("should silently ignore users with no email and send bulk emails", async () => { 133 | const mockNoEmailUser = { id: 987 }; 134 | const mockReceivers = [...mockUsers, mockNoEmailUser].map((el) => el.id); 135 | 136 | (userRepositoryMock.getUserById as Mock) 137 | .mockResolvedValueOnce(mockUsers[0]) 138 | .mockResolvedValueOnce(mockUsers[1]) 139 | .mockResolvedValueOnce(mockNoEmailUser); 140 | (pmClient.sendEmailBatchWithTemplates as Mock).mockResolvedValue({}); 141 | 142 | await controller.sendBulkEmails({ 143 | receiverIds: mockReceivers, 144 | templateName: mockTemplateName, 145 | payload: mockPayload, 146 | }); 147 | 148 | expect(pmClient.sendEmailBatchWithTemplates).toHaveBeenCalledWith([ 149 | { 150 | From: "no-reply@sender.com", 151 | To: mockUsers[0].email, 152 | TemplateAlias: mockTemplateName, 153 | TemplateModel: mockPayload, 154 | TrackOpens: true, 155 | }, 156 | { 157 | From: "no-reply@sender.com", 158 | To: mockUsers[1].email, 159 | TemplateAlias: mockTemplateName, 160 | TemplateModel: mockPayload, 161 | TrackOpens: true, 162 | }, 163 | ]); 164 | }); 165 | 166 | it("should throw postmarkSendError during bulk send if there is a problem with Postmark SDK", async () => { 167 | const mockErr = { message: "mock msg" }; 168 | const mockReceivers = mockUsers.map((el) => el.id); 169 | 170 | (userRepositoryMock.getUserById as Mock) 171 | .mockResolvedValueOnce(mockUsers[0]) 172 | .mockResolvedValueOnce(mockUsers[1]); 173 | (pmClient.sendEmailBatchWithTemplates as Mock).mockRejectedValue(mockErr); 174 | 175 | await expect( 176 | controller.sendBulkEmails({ 177 | receiverIds: mockReceivers, 178 | templateName: mockTemplateName, 179 | payload: mockPayload, 180 | }) 181 | ).rejects.toThrowError(postmarkSendError(mockErr.message)); 182 | }); 183 | }); 184 | }); 185 | -------------------------------------------------------------------------------- /registry/modules/postmarkEmail/controllers/email.ts: -------------------------------------------------------------------------------- 1 | import { ServerClient, TemplatedMessage } from "postmark"; 2 | 3 | import { UserRepository } from "@/repositories/user.interface.js"; 4 | 5 | import { 6 | emailNotFound, 7 | postmarkSendError, 8 | } from "@/modules/postmarkEmail/utils/errors/email.js"; 9 | 10 | const POSTMARK_SERVER_TOKEN = process.env.POSTMARK_SERVER_TOKEN as string; 11 | const pmClient = new ServerClient(POSTMARK_SERVER_TOKEN); 12 | 13 | // NOTE: Replace with your email address 14 | const FROM_EMAIL = "no-reply@sender.com"; 15 | 16 | /** 17 | * If you wish to have different payload for each receiver use this type: 18 | * 19 | * ``` 20 | * type SendBulkEmails = { 21 | * templateName: string; 22 | * payload: Omit, "templateName">[]; 23 | * }; 24 | * ``` 25 | * 26 | * **NOTE**: You will need to modify the controller logic to process the `payload` array correctly 27 | */ 28 | type SendBulkEmails = { 29 | receiverIds: number[]; 30 | templateName: string; 31 | payload: T; 32 | }; 33 | 34 | type SendEmail = { 35 | receiverId: number; 36 | templateName: string; 37 | payload: T; 38 | }; 39 | 40 | interface EmailController { 41 | sendEmail: (props: SendEmail) => void; 42 | sendBulkEmails: (props: SendBulkEmails) => void; 43 | } 44 | 45 | export const createEmailController = ( 46 | userRepository: UserRepository, 47 | pm: ServerClient = pmClient 48 | ): EmailController => { 49 | return { 50 | async sendEmail({ receiverId, templateName, payload }) { 51 | const user = await userRepository.getUserById(receiverId); 52 | 53 | if (user?.email) { 54 | const message: TemplatedMessage = { 55 | From: FROM_EMAIL, 56 | To: user.email, 57 | TemplateAlias: templateName, 58 | TemplateModel: payload, 59 | TrackOpens: true, 60 | }; 61 | 62 | await pm.sendEmailWithTemplate(message).catch((err) => { 63 | throw postmarkSendError(err?.message); 64 | }); 65 | } else { 66 | throw emailNotFound(); 67 | } 68 | }, 69 | 70 | async sendBulkEmails({ receiverIds, templateName, payload }) { 71 | const usersPromises = receiverIds.map((id) => userRepository.getUserById(id)); 72 | const users = await Promise.all(usersPromises); 73 | 74 | // Ignore users who don't have an email so we don't interrupt the bulk send 75 | const usersWithEmail = users.filter((user) => user?.email); 76 | 77 | // Paginate users as postmark supports max 500 emails per batch 78 | const userChunks = []; 79 | for (let i = 0; i < usersWithEmail.length; i += 500) { 80 | const chunk = usersWithEmail.slice(i, i + 500); 81 | userChunks.push(chunk); 82 | } 83 | 84 | const bulkSendPromises = userChunks.map(async (userChunk) => { 85 | const messages: TemplatedMessage[] = userChunk.map((user) => { 86 | return { 87 | From: FROM_EMAIL, 88 | To: user!.email, 89 | TemplateAlias: templateName, 90 | TemplateModel: payload, 91 | TrackOpens: true, 92 | }; 93 | }); 94 | 95 | await pm.sendEmailBatchWithTemplates(messages).catch((err) => { 96 | throw postmarkSendError(err?.message); 97 | }); 98 | }); 99 | 100 | await Promise.all(bulkSendPromises); 101 | }, 102 | }; 103 | }; 104 | -------------------------------------------------------------------------------- /registry/modules/postmarkEmail/utils/errors/email.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from "@/modules/shared/utils/errors/HttpError.js"; 2 | 3 | export const emailNotFound = () => { 4 | return new HttpError( 5 | "EmailNotFound", 6 | "No email was found for this user. Please add an email to the account and try again", 7 | 404 8 | ); 9 | }; 10 | 11 | export const postmarkSendError = (message?: string) => { 12 | return new HttpError( 13 | "SendEmailError", 14 | `There was an internal error sending the email. Error: ${message}`, 15 | 500 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /registry/modules/shared/utils/errors/HttpError.ts: -------------------------------------------------------------------------------- 1 | export class HttpError extends Error { 2 | status: number; 3 | name: string; 4 | 5 | constructor(name: string, message: string, status: number) { 6 | super(message); 7 | this.name = name; 8 | this.status = status; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /registry/modules/shared/utils/errors/common.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from "@/modules/shared/utils/errors/HttpError.js"; 2 | 3 | export const notImplementedError = () => { 4 | return new HttpError("NotImplementedError", "Not Implemented", 501); 5 | }; 6 | 7 | export const badRequestError = () => { 8 | return new HttpError("BadRequestError", "Bad Request", 400); 9 | }; 10 | 11 | export const unauthorizedError = () => { 12 | return new HttpError("UnauthorizedError", "Unauthorized", 401); 13 | }; 14 | 15 | export const forbiddenError = () => { 16 | return new HttpError("ForbiddenError", "Forbidden", 403); 17 | }; 18 | 19 | export const notFoundError = () => { 20 | return new HttpError("NotFoundError", "Resource Not Found", 404); 21 | }; 22 | 23 | export const internalServerError = () => { 24 | return new HttpError("InternalServerError", "Internal Server Error", 500); 25 | }; 26 | 27 | export const tooManyRequestsError = () => { 28 | return new HttpError("TooManyRequestsError", "Too Many Requests", 429); 29 | }; 30 | 31 | export const requestTimeoutError = () => { 32 | return new HttpError("RequestTimeoutError", "Request Timeout", 408); 33 | }; 34 | -------------------------------------------------------------------------------- /registry/modules/shared/utils/response.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from "vitest"; 2 | 3 | import { notFoundError } from "./errors/common.js"; 4 | import { response } from "./response.js"; 5 | 6 | describe("Response Object Tests", () => { 7 | it("should return data object", () => { 8 | const mockData = { 9 | mock: "test", 10 | }; 11 | 12 | const mockResponse = response(mockData); 13 | 14 | expect(mockResponse.data).toBeTruthy(); 15 | expect(mockResponse.error).toBeFalsy(); 16 | expect(mockResponse.data).toEqual(mockData); 17 | }); 18 | 19 | it("should return empty response if data is undefined", () => { 20 | const mockResponse = response(); 21 | 22 | expect(mockResponse.data).toBeFalsy(); 23 | expect(mockResponse.error).toBeFalsy(); 24 | }); 25 | 26 | it("should return error object and no data", () => { 27 | const error = notFoundError(); 28 | const mockResponse = response(undefined, error); 29 | 30 | expect(mockResponse.data).toBeFalsy(); 31 | expect(mockResponse.error).toBeTruthy(); 32 | expect(mockResponse.error).toEqual({ 33 | name: error.name, 34 | message: error.message, 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /registry/modules/shared/utils/response.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from "./errors/HttpError.js"; 2 | 3 | /** 4 | * Standardized response format 5 | * @param {T} data optional parameters to return. 6 | * @param {HttpError | Error} error the success or error message. 7 | */ 8 | export const response = (data?: T, error?: HttpError | Error) => { 9 | if (error !== undefined) { 10 | return { error: { name: error?.name, message: error?.message } }; 11 | } 12 | return { data }; 13 | }; 14 | -------------------------------------------------------------------------------- /registry/modules/stripeSubscriptions/.env.sample: -------------------------------------------------------------------------------- 1 | # stripe-subscriptions env variables 2 | STRIPE_API_KEY= 3 | STRIPE_WH_SECRET= 4 | CHECKOUT_SUCCESS_URL= 5 | CHECKOUT_CANCEL_URL= -------------------------------------------------------------------------------- /registry/modules/stripeSubscriptions/README.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Stripe Subscriptions 3 | description: Making Stripe subscriptions a piece of cake. Add subscription billing to your app within minutes 4 | features: 5 | available: 6 | - Checkout Sessions 7 | - Payment Links 8 | - List Available Subscription Plans 9 | - Upsells at Checkout 10 | - Plan Upgrades/Downgrades with Prorating 11 | - Subscription Cancellation at End of Billing 12 | - Seat-Based Subscriptions 13 | comingSoon: 14 | - Usage-Based Subscriptions 15 | - Free Tiers 16 | postmanCollection: https://app.getpostman.com/run-collection/39515350-cd2bdb24-9802-4392-811b-ff743b769305?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D39515350-cd2bdb24-9802-4392-811b-ff743b769305%26entityType%3Dcollection%26workspaceId%3D36bf5973-695a-44e0-889e-bba83a364391 17 | testCoverage: 98 18 | --- 19 | 20 | ## About 21 | 22 | The Stripe Subscriptions API module provides a full-featured integration for subscription-based billing using Stripe, allowing developers to quickly monetize their apps without complex setup. 23 | It offloads the logic of managing products and prices to Stripe, keeping your database lightweight and minimizing data synchronization issues. 24 | 25 | ## Installation 26 | 27 | To add the Stripe Subscriptions Module to your project, run: 28 | 29 | ```bash 30 | npx vratix add stripe-subscriptions 31 | ``` 32 | 33 | ## .env 34 | 35 | Add the following environment variables to your `.env` file: 36 | 37 | - **STRIPE_API_KEY**: A server-side [Stripe API secret key](https://docs.stripe.com/keys) 38 | - **Default**: None (required) 39 | - **Example**: `STRIPE_API_KEY=sk_test_...` 40 | - **STRIPE_WH_SECRET**: Webhook endpoint secret from the Stripe Developer Dashboard 41 | - **Default**: None (required) 42 | - **Example**: `STRIPE_WH_SECRET=whsec_...` 43 | - **CHECKOUT_SUCCESS_URL**: Redirect URL after successful payment 44 | - **Default**: None (required) 45 | - **Example**: `CHECKOUT_SUCCESS_URL=https://yourdomain.com/payment-success` 46 | - **CHECKOUT_CANCEL_URL**: Redirect URL if payment is canceled 47 | - **Default**: None (required) 48 | - **Example**: `CHECKOUT_CANCEL_URL=https://yourdomain.com` 49 | 50 | ## Dependencies 51 | 52 | The `auth-basic` module is required for user management and authentication and will be installed automatically with this module. 53 | 54 | ## Usage 55 | 56 | To start using the module, import the router in your entry file, such as `server.ts`: 57 | 58 | ```typescript server.ts 59 | import { router as subscriptionRouter } from "@/routes/subscription.js"; 60 | 61 | app.use("/subscription", subscriptionRouter); 62 | ``` 63 | 64 | This module leverages Stripe as the source of truth, storing minimal subscription data on your end: 65 | 66 | - **Customer ID**, **Subscription ID**, and **Plan Key** (`plan`). 67 | 68 | ### Subscription Key (`plan`) 69 | 70 | The `plan` key is composed from the [Stripe price](https://docs.stripe.com/api/prices/object#price_object-lookup_key) `lookup_key` 71 | or the [Stripe product](https://docs.stripe.com/api/products/object#product_object-name) `name` in lowercase, e.g., `team_pro`, 72 | so checking if a user is subscribed can be as simple as `user.plan === 'team_pro'`. 73 | 74 | ### Subscription Object 75 | 76 | The module simplifies Stripe’s API responses, returning only the relevant properties in a structured format: 77 | 78 | ```json 79 | { 80 | "plan": "team_pro", 81 | "name": "Team Pro Plan", 82 | "description": "Pro features for you and your whole team", 83 | "priceId": "price_...", 84 | "interval": "monthly", 85 | "priceType": "per_unit", 86 | "price": { 87 | "currency": "USD", 88 | "amount": 2000 // $20.00 represented as a whole intiger 89 | }, 90 | "features": ["Unlimited API calls", "Team management", "Granular access"] 91 | } 92 | ``` 93 | 94 | ### Seat-Based Subscriptions 95 | 96 | Seats can be configured during Stripe Checkout by adjusting the `quantity` property for the subscription. To disable this behaviour remove the `line_items.adjustable_quantity` property. 97 | 98 | ```typescript {6} 99 | const checkout = await stripe.checkout.sessions.create({ 100 | ...customerData, 101 | line_items: [ 102 | { 103 | adjustable_quantity: { enabled: true }, 104 | quantity: seats || 1, 105 | price: priceId, 106 | }, 107 | ], 108 | subscription_data: { metadata: { userId } }, 109 | saved_payment_method_options: { payment_method_save: "enabled" }, 110 | success_url: CHECKOUT_SUCCESS_URL, 111 | cancel_url: CHECKOUT_CANCEL_URL, 112 | mode: "subscription", 113 | }); 114 | ``` 115 | 116 | You can also manually set the number of seats by adding the `seats` property before creating a Stripe Checkout URL. 117 | 118 | ### Endpoints 119 | 120 | The Stripe Subscriptions Module exposes the following endpoints: 121 | 122 | | Method | Endpoint | Description | 123 | | ------ | ------------------------- | ------------------------------------------------------------------------------ | 124 | | POST | `/` | Lists available subscriptions | 125 | | GET | `/user` | Lists current subscriptions of the authenticated user | 126 | | POST | `/payment/checkout` | Generates a Stripe Checkout URL for user subscriptions | 127 | | POST | `/payment/link` | Generates a reusable Stripe Payment Link for user subscriptions | 128 | | PATCH | `/:subscriptionId` | Upgrades/Downgrades subscription, prorates based on current billing period | 129 | | POST | `/:subscriptionId/seat` | Adds a user to a seat in a seat-based subscription (Owner only) | 130 | | DELETE | `/:subscriptionId/seat` | Removes a user from a seat in a seat-based subscription (Owner only) | 131 | | PATCH | `/:subscriptionId/seat` | Adjusts seat count (Owner only) | 132 | | DELETE | `/:subscriptionId/cancel` | Cancels subscription at end of billing period, no future charges (Owner only) | 133 | | POST | `/:subscriptionId/cancel` | Stops pending cancellation, renews subscription at end of billing (Owner only) | 134 | 135 | ### Errors 136 | 137 | Below are common errors with solutions for this module: 138 | 139 | | Error Code | Name | Solution | 140 | | ---------- | ------------------------------ | -------------------------------------------------------------------------------------- | 141 | | 404 | SubscriptionNotFound | Verify subscription ID and ensure it's an **active** subscription for the user | 142 | | 409 | NoAvailableSeats | Either increase seat count or remove a user to free up a seat | 143 | | 403 | NoEmptySeatsToRemove | Ensure seat count matches or exceeds currently occupied seats | 144 | | 400 | CantRemoveSubOwner | Subscription owner cannot be removed from seats, cancel subscription to stop billing | 145 | | 400 | StripeWebhookEventNotSupported | Confirm correct Stripe webhook events are configured on the Stripe Developer Dashboard | 146 | 147 | ### Examples 148 | 149 | To explore sample requests and responses, download our Postman collection: 150 | 151 | 155 | -------------------------------------------------------------------------------- /registry/modules/stripeSubscriptions/controllers/subscription.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; 2 | import Stripe from "stripe"; 3 | 4 | import { createSubscriptionController } from "@/modules/stripeSubscriptions/controllers/subscription.js"; 5 | 6 | import { UserSubscriptionRepository } from "@/repositories/subscription.interface.js"; 7 | import { UserRepository } from "@/repositories/user.interface.js"; 8 | 9 | import { 10 | cantRemoveSubOwner, 11 | noAvailableSeats, 12 | noEmptySeatsToRemove, 13 | notAuthorizedToModifySubscription, 14 | } from "@/modules/stripeSubscriptions/utils/errors/subscriptions.js"; 15 | 16 | // Mock Stripe instance 17 | vi.mock("stripe", () => { 18 | return { 19 | default: vi.fn().mockImplementation(() => ({ 20 | checkout: { 21 | sessions: { 22 | create: vi.fn(), 23 | }, 24 | }, 25 | paymentLinks: { 26 | create: vi.fn(), 27 | }, 28 | prices: { 29 | list: vi.fn(), 30 | }, 31 | subscriptions: { 32 | update: vi.fn(), 33 | retrieve: vi.fn(), 34 | }, 35 | subscriptionItems: { 36 | update: vi.fn(), 37 | }, 38 | })), 39 | }; 40 | }); 41 | 42 | const stripe = new Stripe(""); 43 | 44 | describe("stripe-subscriptions API Module tests", () => { 45 | describe("Subscription Controller Tests", () => { 46 | let controller: ReturnType; 47 | let subscriptionRepoMock: UserSubscriptionRepository; 48 | 49 | const mockUserId = 123; 50 | const mockUserEmail = "mock@test.com"; 51 | const mockSubId = "sub_123"; 52 | const mockPriceId = "price_123"; 53 | const mockPlanKey = "basic"; 54 | const mockUserSub = { 55 | name: "Basic Plan", 56 | plan: mockPlanKey, 57 | subscriptionId: mockSubId, 58 | isOwner: true, 59 | customerId: "cus_123", 60 | seats: 2, 61 | createdAt: new Date().toISOString(), 62 | }; 63 | const mockSubItem = { 64 | id: mockPriceId, 65 | quantity: 2, 66 | price: { lookup_key: mockPlanKey, product: { name: "Basic Plan" } }, 67 | subscription: mockSubId, 68 | }; 69 | const mockSubscription = { 70 | metadata: { userId: mockUserId }, 71 | items: { data: [mockSubItem] }, 72 | }; 73 | 74 | beforeEach(() => { 75 | subscriptionRepoMock = { 76 | getUserSubscriptions: vi.fn(), 77 | getSubscriptionUsers: vi.fn(), 78 | createUserSubscription: vi.fn(), 79 | removeUserFromSubscription: vi.fn(), 80 | removeUserSubscription: vi.fn(), 81 | }; 82 | 83 | // Injecting the mocked repositories into the controller 84 | controller = createSubscriptionController(stripe, subscriptionRepoMock); 85 | }); 86 | 87 | it("should return available subscriptions with correct structure", async () => { 88 | const mockPrices = [ 89 | { 90 | id: mockPriceId, 91 | lookup_key: "basic", 92 | recurring: { interval: "month" }, 93 | billing_scheme: "per_unit", 94 | currency: "USD", 95 | unit_amount: 1000, 96 | product: { 97 | name: "Basic Plan", 98 | description: "Basic features", 99 | marketing_features: [{ name: "100 API Calls" }], 100 | }, 101 | }, 102 | ]; 103 | 104 | (stripe.prices.list as Mock).mockResolvedValue({ data: mockPrices }); 105 | 106 | const result = await controller.getSubscriptions(); 107 | 108 | const expectedValue = { 109 | plan: mockPrices[0].lookup_key, 110 | name: mockPrices[0].product.name, 111 | description: mockPrices[0].product.description, 112 | priceId: mockPrices[0].id, 113 | interval: mockPrices[0].recurring.interval, 114 | priceType: mockPrices[0].billing_scheme, 115 | price: { 116 | currency: mockPrices[0].currency, 117 | amount: mockPrices[0].unit_amount, 118 | }, 119 | features: ["100 API Calls"], 120 | }; 121 | expect(result).toContainEqual(expectedValue); 122 | }); 123 | 124 | it("should return user subscriptions", async () => { 125 | (subscriptionRepoMock.getUserSubscriptions as Mock).mockResolvedValue([ 126 | mockUserSub, 127 | ]); 128 | (stripe.subscriptions.retrieve as Mock).mockResolvedValue({ 129 | ...mockSubscription, 130 | items: { 131 | data: [ 132 | { 133 | ...mockSubItem, 134 | quantity: mockUserSub.seats, 135 | }, 136 | ], 137 | }, 138 | }); 139 | 140 | const result = await controller.getUserSubs({ userId: mockUserId }); 141 | const { customerId, ...expectedValue } = mockUserSub; 142 | 143 | expect(stripe.subscriptions.retrieve).toHaveBeenCalledWith( 144 | mockUserSub.subscriptionId, 145 | { expand: ["items.data.price.product"] } 146 | ); 147 | expect(result.userSubscriptions).toContainEqual({ 148 | ...expectedValue, 149 | }); 150 | }); 151 | 152 | it("should create a Checkout Session with existing Stripe Customer and return URL", async () => { 153 | const mockUrl = "https://checkout.stripe.com/test-session"; 154 | 155 | (subscriptionRepoMock.getUserSubscriptions as Mock).mockResolvedValue([ 156 | mockUserSub, 157 | ]); 158 | 159 | (stripe.checkout.sessions.create as Mock).mockResolvedValue({ 160 | url: mockUrl, 161 | }); 162 | 163 | const result = await controller.createCheckout({ 164 | userId: mockUserId, 165 | userEmail: mockUserEmail, 166 | priceId: mockPriceId, 167 | seats: 1, 168 | }); 169 | 170 | expect(stripe.checkout.sessions.create).toHaveBeenCalledWith({ 171 | customer: mockUserSub.customerId, 172 | line_items: [ 173 | { 174 | adjustable_quantity: { enabled: true }, 175 | quantity: 1, 176 | price: mockPriceId, 177 | }, 178 | ], 179 | subscription_data: { metadata: { userId: mockUserId } }, 180 | saved_payment_method_options: { payment_method_save: "enabled" }, 181 | mode: "subscription", 182 | tax_id_collection: { enabled: true }, 183 | automatic_tax: { enabled: true }, 184 | }); 185 | 186 | expect(result.url).toBe(mockUrl); 187 | }); 188 | 189 | it("should create a Checkout Session with email and return URL", async () => { 190 | const mockUrl = "https://checkout.stripe.com/test-session"; 191 | const mockUser = { email: mockUserEmail }; 192 | 193 | (subscriptionRepoMock.getUserSubscriptions as Mock).mockResolvedValue( 194 | undefined 195 | ); 196 | 197 | (stripe.checkout.sessions.create as Mock).mockResolvedValue({ 198 | url: mockUrl, 199 | }); 200 | 201 | const result = await controller.createCheckout({ 202 | userId: mockUserId, 203 | userEmail: mockUserEmail, 204 | priceId: mockPriceId, 205 | seats: 1, 206 | }); 207 | 208 | expect(stripe.checkout.sessions.create).toHaveBeenCalledWith({ 209 | customer_email: mockUser.email, 210 | line_items: [ 211 | { 212 | adjustable_quantity: { enabled: true }, 213 | quantity: 1, 214 | price: mockPriceId, 215 | }, 216 | ], 217 | subscription_data: { metadata: { userId: mockUserId } }, 218 | saved_payment_method_options: { payment_method_save: "enabled" }, 219 | mode: "subscription", 220 | tax_id_collection: { enabled: true }, 221 | automatic_tax: { enabled: true }, 222 | }); 223 | 224 | expect(result.url).toBe(mockUrl); 225 | }); 226 | 227 | it("should create a Payment Link", async () => { 228 | const mockUrl = "https://checkout.stripe.com/test-session"; 229 | 230 | (stripe.paymentLinks.create as Mock).mockResolvedValue({ 231 | url: mockUrl, 232 | }); 233 | 234 | const result = await controller.createPaymentLink({ 235 | userId: mockUserId, 236 | userEmail: mockUserEmail, 237 | priceId: mockPriceId, 238 | seats: 1, 239 | }); 240 | 241 | expect(stripe.paymentLinks.create).toHaveBeenCalledWith({ 242 | line_items: [ 243 | { 244 | adjustable_quantity: { enabled: true }, 245 | quantity: 1, 246 | price: mockPriceId, 247 | }, 248 | ], 249 | subscription_data: { metadata: { userId: mockUserId } }, 250 | tax_id_collection: { enabled: true }, 251 | automatic_tax: { enabled: true }, 252 | after_completion: { 253 | type: "redirect", 254 | redirect: {}, 255 | }, 256 | }); 257 | 258 | expect(result.url).toBe(mockUrl); 259 | }); 260 | 261 | it("should add a user to a subscription seat", async () => { 262 | (stripe.subscriptions.retrieve as Mock).mockResolvedValue( 263 | mockSubscription 264 | ); 265 | (subscriptionRepoMock.getSubscriptionUsers as Mock).mockResolvedValue([ 266 | mockUserSub, 267 | ]); 268 | 269 | await controller.addUserToSeat({ 270 | userId: mockUserId, 271 | subscriptionId: mockSubId, 272 | addUserId: 567, 273 | }); 274 | 275 | expect(subscriptionRepoMock.createUserSubscription).toHaveBeenCalledWith({ 276 | userId: 567, 277 | subscriptionId: mockSubId, 278 | plan: mockPlanKey, 279 | isOwner: false, 280 | }); 281 | }); 282 | 283 | it("should throw noAvailableSeats if subscription has only 1 seat", async () => { 284 | const mockSubscription2 = { 285 | ...mockSubscription, 286 | items: { data: [{ ...mockSubscription.items.data[0], quantity: 1 }] }, 287 | }; 288 | 289 | (stripe.subscriptions.retrieve as Mock).mockResolvedValue( 290 | mockSubscription2 291 | ); 292 | 293 | await expect( 294 | controller.addUserToSeat({ 295 | userId: mockUserId, 296 | subscriptionId: mockSubId, 297 | addUserId: 567, 298 | }) 299 | ).rejects.toThrowError(noAvailableSeats()); 300 | }); 301 | 302 | it("should throw noAvailableSeats when all seats are used", async () => { 303 | const mockSubscription2 = { 304 | ...mockSubscription, 305 | items: { data: [{ ...mockSubscription.items.data[0], quantity: 3 }] }, 306 | }; 307 | 308 | (stripe.subscriptions.retrieve as Mock).mockResolvedValue( 309 | mockSubscription2 310 | ); 311 | (subscriptionRepoMock.getSubscriptionUsers as Mock).mockResolvedValue([ 312 | mockUserSub, 313 | mockUserSub, 314 | mockUserSub, 315 | ]); 316 | 317 | await expect( 318 | controller.addUserToSeat({ 319 | userId: mockUserId, 320 | subscriptionId: mockSubId, 321 | addUserId: 567, 322 | }) 323 | ).rejects.toThrowError(noAvailableSeats()); 324 | }); 325 | 326 | it("should update subscription plan", async () => { 327 | const mockNewPlanKey = "premium"; 328 | const mockNewPriceId = "price_567"; 329 | const mockNewUserSub = { ...mockUserSub, plan: mockNewPlanKey }; 330 | const mockNewSubItem = { 331 | id: mockPriceId, 332 | quantity: 2, 333 | price: { 334 | lookup_key: mockNewPlanKey, 335 | product: { name: "Premium Plan" }, 336 | }, 337 | subscription: mockSubId, 338 | }; 339 | 340 | (stripe.subscriptions.retrieve as Mock).mockResolvedValue( 341 | mockSubscription 342 | ); 343 | (stripe.subscriptionItems.update as Mock).mockResolvedValue( 344 | mockNewSubItem 345 | ); 346 | 347 | (subscriptionRepoMock.createUserSubscription as Mock).mockResolvedValue({ 348 | subscriptionId: mockSubId, 349 | createdAt: mockNewUserSub.createdAt, 350 | }); 351 | (subscriptionRepoMock.getSubscriptionUsers as Mock).mockResolvedValue([ 352 | mockUserSub, 353 | ]); 354 | 355 | const result = await controller.updatePlan({ 356 | userId: mockUserId, 357 | subscriptionId: mockSubId, 358 | newPriceId: mockNewPriceId, 359 | }); 360 | 361 | const { customerId, ...expectedValue } = mockNewUserSub; 362 | expect(result).toEqual({ 363 | ...expectedValue, 364 | name: mockNewSubItem.price.product.name, 365 | }); 366 | }); 367 | 368 | it("should throw notAuthorizedToModifySubscription if non-owner tries to update subscription plan", async () => { 369 | (stripe.subscriptions.retrieve as Mock).mockResolvedValue( 370 | mockSubscription 371 | ); 372 | 373 | await expect( 374 | controller.updatePlan({ 375 | userId: 567, 376 | subscriptionId: mockSubId, 377 | newPriceId: "price_567", 378 | }) 379 | ).rejects.toThrowError(notAuthorizedToModifySubscription()); 380 | }); 381 | 382 | it("should increase subscription seats count", async () => { 383 | (stripe.subscriptions.retrieve as Mock).mockResolvedValue( 384 | mockSubscription 385 | ); 386 | 387 | await controller.updateSeats({ 388 | userId: mockUserId, 389 | subscriptionId: mockSubId, 390 | newSeats: 4, 391 | }); 392 | 393 | expect(stripe.subscriptionItems.update).toHaveBeenCalledWith( 394 | mockPriceId, 395 | { quantity: 4 } 396 | ); 397 | }); 398 | 399 | it("should not update subscription seats if new count is the same as existing seats count", async () => { 400 | (stripe.subscriptions.retrieve as Mock).mockResolvedValue( 401 | mockSubscription 402 | ); 403 | 404 | await controller.updateSeats({ 405 | userId: mockUserId, 406 | subscriptionId: mockSubId, 407 | newSeats: 2, 408 | }); 409 | 410 | expect(stripe.subscriptionItems.update).not.toHaveBeenCalled(); 411 | }); 412 | 413 | it("should reduce subscription seats count", async () => { 414 | (stripe.subscriptions.retrieve as Mock).mockResolvedValue( 415 | mockSubscription 416 | ); 417 | (subscriptionRepoMock.getSubscriptionUsers as Mock).mockResolvedValue([ 418 | mockUserSub, 419 | ]); 420 | 421 | await controller.updateSeats({ 422 | userId: mockUserId, 423 | subscriptionId: mockSubId, 424 | newSeats: 1, 425 | }); 426 | 427 | expect(stripe.subscriptionItems.update).toHaveBeenCalledWith( 428 | mockPriceId, 429 | { quantity: 1 } 430 | ); 431 | }); 432 | 433 | it("should throw noEmptySeatsToRemove if all seats are used", async () => { 434 | (stripe.subscriptions.retrieve as Mock).mockResolvedValue( 435 | mockSubscription 436 | ); 437 | (subscriptionRepoMock.getSubscriptionUsers as Mock).mockResolvedValue([ 438 | mockUserSub, 439 | mockUserSub, 440 | ]); 441 | 442 | await expect( 443 | controller.updateSeats({ 444 | userId: mockUserId, 445 | subscriptionId: mockSubId, 446 | newSeats: 1, 447 | }) 448 | ).rejects.toThrowError(noEmptySeatsToRemove()); 449 | 450 | expect(stripe.subscriptionItems.update).not.toHaveBeenCalled(); 451 | }); 452 | 453 | it("should throw notAuthorizedToModifySubscription if non-owner tries to update subscription seats", async () => { 454 | (stripe.subscriptions.retrieve as Mock).mockResolvedValue( 455 | mockSubscription 456 | ); 457 | 458 | await expect( 459 | controller.updateSeats({ 460 | userId: 567, 461 | subscriptionId: mockSubId, 462 | newSeats: 3, 463 | }) 464 | ).rejects.toThrowError(notAuthorizedToModifySubscription()); 465 | }); 466 | 467 | it("should remove user from subscription seat", async () => { 468 | const mockRemoveUserId = 567; 469 | 470 | (subscriptionRepoMock.getUserSubscriptions as Mock).mockResolvedValue([ 471 | mockUserSub, 472 | ]); 473 | 474 | await controller.removeUserFromSeat({ 475 | userId: mockUserId, 476 | subscriptionId: mockSubId, 477 | removeUserId: mockRemoveUserId, 478 | }); 479 | 480 | expect( 481 | subscriptionRepoMock.removeUserFromSubscription 482 | ).toHaveBeenCalledWith(mockRemoveUserId, mockSubId); 483 | }); 484 | 485 | it("should throw cantRemoveSubOwner if tried to remove subscription owner", async () => { 486 | (subscriptionRepoMock.getUserSubscriptions as Mock).mockResolvedValue([ 487 | mockUserSub, 488 | ]); 489 | 490 | await expect( 491 | controller.removeUserFromSeat({ 492 | userId: mockUserId, 493 | subscriptionId: mockSubId, 494 | removeUserId: mockUserId, 495 | }) 496 | ).rejects.toThrowError(cantRemoveSubOwner()); 497 | }); 498 | 499 | it("should cancel the subscription", async () => { 500 | (stripe.subscriptions.retrieve as Mock).mockResolvedValue( 501 | mockSubscription 502 | ); 503 | 504 | await controller.cancelSubscription({ 505 | userId: mockUserId, 506 | subscriptionId: mockSubId, 507 | }); 508 | 509 | expect(stripe.subscriptions.update).toHaveBeenCalledWith(mockSubId, { 510 | cancel_at_period_end: true, 511 | }); 512 | }); 513 | 514 | it("should throw notAuthorizedToModifySubscription if non-owner cancels the subscription", async () => { 515 | (stripe.subscriptions.retrieve as Mock).mockResolvedValue( 516 | mockSubscription 517 | ); 518 | 519 | await expect( 520 | controller.cancelSubscription({ 521 | userId: 567, 522 | subscriptionId: mockSubId, 523 | }) 524 | ).rejects.toThrowError(notAuthorizedToModifySubscription()); 525 | }); 526 | 527 | it("should stop the cancellation of the subscription", async () => { 528 | (stripe.subscriptions.retrieve as Mock).mockResolvedValue( 529 | mockSubscription 530 | ); 531 | 532 | await controller.stopCancellation({ 533 | userId: mockUserId, 534 | subscriptionId: mockSubId, 535 | }); 536 | 537 | expect(stripe.subscriptions.update).toHaveBeenCalledWith(mockSubId, { 538 | cancel_at_period_end: false, 539 | }); 540 | }); 541 | 542 | it("should throw notAuthorizedToModifySubscription if non-owner stops the subscription cancelation", async () => { 543 | (stripe.subscriptions.retrieve as Mock).mockResolvedValue( 544 | mockSubscription 545 | ); 546 | 547 | await expect( 548 | controller.stopCancellation({ 549 | userId: 567, 550 | subscriptionId: mockSubId, 551 | }) 552 | ).rejects.toThrowError(notAuthorizedToModifySubscription()); 553 | }); 554 | }); 555 | }); 556 | -------------------------------------------------------------------------------- /registry/modules/stripeSubscriptions/controllers/subscription.ts: -------------------------------------------------------------------------------- 1 | import Stripe from "stripe"; 2 | 3 | import { 4 | GetUserSubsSchema, 5 | CreateCheckoutSchema, 6 | CreatePaymentLinkSchema, 7 | CancelSubscriptionSchema, 8 | UpdatePlanSchema, 9 | UpdateSubscriptionSeatsSchema, 10 | AddUserToSeatSchema, 11 | RemoveUserFromSeatSchema, 12 | } from "@/schemaValidators/subscription.interface.js"; 13 | 14 | import { 15 | UserSubscription, 16 | UserSubscriptionRepository, 17 | } from "@/repositories/subscription.interface.js"; 18 | 19 | import { 20 | noEmptySeatsToRemove, 21 | noAvailableSeats, 22 | cantRemoveSubOwner, 23 | notAuthorizedToModifySubscription, 24 | } from "@/modules/stripeSubscriptions/utils/errors/subscriptions.js"; 25 | 26 | const CHECKOUT_SUCCESS_URL = process.env.CHECKOUT_SUCCESS_URL as string; 27 | const CHECKOUT_CANCEL_URL = process.env.CHECKOUT_CANCEL_URL as string; 28 | 29 | type SubscriptionPrices = { 30 | currency: string; 31 | amount: number | null; 32 | }; 33 | 34 | export type Subscription = { 35 | plan: string; 36 | priceId: string; 37 | name: string; 38 | description: string | null; 39 | interval: "day" | "week" | "month" | "year"; 40 | price: SubscriptionPrices; 41 | priceType: "per_unit" | "tiered"; 42 | features?: string[]; 43 | }; 44 | 45 | type UserSubsOutput = { 46 | name: string; 47 | plan: string; 48 | subscriptionId: string; 49 | isOwner: boolean; 50 | seats?: number; 51 | createdAt: string; 52 | }; 53 | 54 | interface SubscriptionController { 55 | getSubscriptions: () => Promise; 56 | 57 | getUserSubs: ( 58 | props: GetUserSubsSchema 59 | ) => Promise<{ userSubscriptions: UserSubsOutput[] }>; 60 | 61 | createCheckout: (props: CreateCheckoutSchema) => Promise<{ url: string }>; 62 | 63 | createPaymentLink: (props: CreatePaymentLinkSchema) => Promise<{ url: string }>; 64 | 65 | addUserToSeat: (props: AddUserToSeatSchema) => void; 66 | 67 | updatePlan: (props: UpdatePlanSchema) => Promise; 68 | 69 | updateSeats: (props: UpdateSubscriptionSeatsSchema) => void; 70 | 71 | removeUserFromSeat: (props: RemoveUserFromSeatSchema) => void; 72 | 73 | cancelSubscription: (props: CancelSubscriptionSchema) => void; 74 | 75 | stopCancellation: (props: CancelSubscriptionSchema) => void; 76 | } 77 | 78 | export const createSubscriptionController = ( 79 | stripe: Stripe, 80 | userSubRepository: UserSubscriptionRepository 81 | ): SubscriptionController => { 82 | const generatePlanKey = (priceKey: string | null, productName: string) => { 83 | return priceKey || productName.toLowerCase().replaceAll(" ", "_"); 84 | }; 85 | 86 | return { 87 | async getSubscriptions() { 88 | const stripePrices = await stripe.prices.list({ 89 | active: true, 90 | type: "recurring", 91 | expand: ["data.product"], 92 | }); 93 | 94 | const subscriptions: Subscription[] = stripePrices.data.map((price) => { 95 | const product = price.product as Stripe.Product; // We can assert the type as we expand the product object 96 | const { name, description, marketing_features } = product; 97 | 98 | const planKey = generatePlanKey(price.lookup_key, name); 99 | 100 | const productFeatures = marketing_features 101 | .filter((el) => el.name !== undefined) 102 | .map((el) => el.name!); 103 | 104 | return { 105 | plan: planKey, 106 | name, 107 | description, 108 | priceId: price.id, 109 | interval: price.recurring!.interval, // To create a subscription price needs to have `recurring` property. That is why we can assert with `!` 110 | priceType: price.billing_scheme, 111 | price: { 112 | currency: price.currency, 113 | amount: price.unit_amount, 114 | }, 115 | features: productFeatures, 116 | }; 117 | }); 118 | 119 | return subscriptions; 120 | }, 121 | 122 | async getUserSubs({ userId }) { 123 | const userSubs = await userSubRepository.getUserSubscriptions(userId); 124 | const subscriptionPromises = userSubs.map(async (sub) => { 125 | const subscription = await stripe.subscriptions.retrieve( 126 | sub.subscriptionId, 127 | { expand: ["items.data.price.product"] } 128 | ); 129 | const subscriptionItem = subscription.items.data[0]; 130 | const product = subscriptionItem.price.product as Stripe.Product; 131 | 132 | return { 133 | name: product.name, 134 | plan: sub.plan, 135 | subscriptionId: sub.subscriptionId, 136 | isOwner: sub.isOwner, 137 | seats: subscriptionItem.quantity, 138 | createdAt: sub.createdAt, 139 | }; 140 | }); 141 | 142 | const userSubscriptions: UserSubsOutput[] = await Promise.all( 143 | subscriptionPromises 144 | ); 145 | 146 | return { userSubscriptions }; 147 | }, 148 | 149 | async createCheckout({ userId, userEmail, priceId, seats }) { 150 | const userSubscription = await userSubRepository 151 | .getUserSubscriptions(userId) 152 | .then((res) => res?.at(0)); 153 | 154 | let customer, customerEmail; 155 | if (userSubscription && userSubscription.customerId) { 156 | // Returning paying user, reuse existing Stripe Customer 157 | customer = userSubscription.customerId; 158 | } else { 159 | // First time paying user, create Stripe Customer after Checkout with email 160 | customerEmail = userEmail; 161 | } 162 | 163 | const checkout = await stripe.checkout.sessions.create({ 164 | customer, 165 | customer_email: customerEmail, 166 | line_items: [ 167 | { 168 | adjustable_quantity: { enabled: seats !== undefined }, 169 | quantity: seats || 1, 170 | price: priceId, 171 | }, 172 | ], 173 | subscription_data: { metadata: { userId } }, 174 | saved_payment_method_options: { payment_method_save: "enabled" }, 175 | success_url: CHECKOUT_SUCCESS_URL, 176 | cancel_url: CHECKOUT_CANCEL_URL, 177 | tax_id_collection: { enabled: true }, 178 | automatic_tax: { enabled: true }, 179 | mode: "subscription", 180 | }); 181 | 182 | return { url: checkout.url! }; 183 | }, 184 | 185 | async createPaymentLink({ userId, priceId, seats }) { 186 | const paymentLink = await stripe.paymentLinks.create({ 187 | line_items: [ 188 | { 189 | adjustable_quantity: { enabled: seats !== undefined }, 190 | quantity: seats || 1, 191 | price: priceId, 192 | }, 193 | ], 194 | subscription_data: { metadata: { userId } }, 195 | tax_id_collection: { enabled: true }, 196 | automatic_tax: { enabled: true }, 197 | after_completion: { 198 | type: "redirect", 199 | redirect: { url: CHECKOUT_SUCCESS_URL }, 200 | }, 201 | }); 202 | 203 | return { url: paymentLink.url }; 204 | }, 205 | 206 | async addUserToSeat({ userId, subscriptionId, addUserId }) { 207 | const subscription = await stripe.subscriptions.retrieve(subscriptionId, { 208 | expand: ["items.data.price.product"], 209 | }); 210 | 211 | const subscriptionItem = subscription.items.data[0]; 212 | 213 | if (subscriptionItem.quantity === 1) { 214 | throw noAvailableSeats(); 215 | } 216 | 217 | if (parseInt(subscription.metadata.userId) === userId) { 218 | const subscriptionUsers = await userSubRepository.getSubscriptionUsers( 219 | subscriptionId 220 | ); 221 | 222 | if (subscriptionItem.quantity! > subscriptionUsers.length) { 223 | // There are unallocated seats for this subscription, add user 224 | const price = subscriptionItem.price; 225 | const product = price.product as Stripe.Product; // We can assert the type as we expand the product object 226 | const planKey = generatePlanKey(price.lookup_key, product.name); 227 | 228 | await userSubRepository.createUserSubscription({ 229 | userId: addUserId, 230 | subscriptionId, 231 | plan: planKey, 232 | isOwner: false, 233 | }); 234 | } else { 235 | throw noAvailableSeats(); 236 | } 237 | } else { 238 | throw notAuthorizedToModifySubscription(); 239 | } 240 | }, 241 | 242 | async updatePlan({ userId, subscriptionId, newPriceId }) { 243 | const subscription = await stripe.subscriptions.retrieve(subscriptionId); 244 | 245 | if (parseInt(subscription.metadata.userId) === userId) { 246 | // User is owner of this subscription 247 | const subscriptionItem = subscription.items.data[0]; 248 | 249 | const newSubscriptionItem = await stripe.subscriptionItems.update( 250 | subscriptionItem.id, 251 | { price: newPriceId, expand: ["price.product"] } 252 | ); 253 | 254 | const product = newSubscriptionItem.price.product as Stripe.Product; // We can assert the type as we expand the product object 255 | const newPlanKey = generatePlanKey( 256 | newSubscriptionItem.price.lookup_key, 257 | product.name 258 | ); 259 | 260 | const subscriptionUsers = await userSubRepository.getSubscriptionUsers( 261 | subscriptionId 262 | ); 263 | 264 | const userSubscriptionPromises = subscriptionUsers.map(async (user) => { 265 | // Update the plan for all users with this subscription ID 266 | const userSub = await userSubRepository.createUserSubscription({ 267 | userId: user.userId, 268 | subscriptionId: newSubscriptionItem.subscription, 269 | plan: newPlanKey, 270 | customerId: user.customerId, 271 | }); 272 | 273 | return userSub; 274 | }); 275 | 276 | const updatedSubscriptions: UserSubscription[] = await Promise.all( 277 | userSubscriptionPromises 278 | ); 279 | 280 | return { 281 | name: product.name, 282 | plan: newPlanKey, 283 | isOwner: true, 284 | seats: newSubscriptionItem.quantity, 285 | subscriptionId: updatedSubscriptions[0].subscriptionId, 286 | createdAt: updatedSubscriptions[0].createdAt, 287 | }; 288 | } else { 289 | throw notAuthorizedToModifySubscription(); 290 | } 291 | }, 292 | 293 | async updateSeats({ userId, subscriptionId, newSeats }) { 294 | const subscription = await stripe.subscriptions.retrieve(subscriptionId); 295 | 296 | if (parseInt(subscription.metadata.userId) === userId) { 297 | // User is owner of this subscription 298 | const subscriptionItem = subscription.items.data[0]; 299 | const subscriptionSeats = subscriptionItem.quantity!; 300 | 301 | if (newSeats === subscriptionSeats) return; 302 | else if (newSeats < subscriptionSeats) { 303 | // Removing seats, check if there are available empty seats 304 | const subscriptionUsers = 305 | await userSubRepository.getSubscriptionUsers(subscriptionId); 306 | 307 | if (newSeats < subscriptionUsers.length) { 308 | // No available seats to remove 309 | throw noEmptySeatsToRemove(); 310 | } 311 | } 312 | 313 | // Update seats 314 | await stripe.subscriptionItems.update(subscriptionItem.id, { 315 | quantity: newSeats, 316 | }); 317 | } else { 318 | throw notAuthorizedToModifySubscription(); 319 | } 320 | }, 321 | 322 | async removeUserFromSeat({ userId, subscriptionId, removeUserId }) { 323 | const userSub = await userSubRepository 324 | .getUserSubscriptions(userId) 325 | .then( 326 | (data) => data.filter((el) => el.subscriptionId === subscriptionId)[0] 327 | ); 328 | 329 | if (userSub.isOwner) { 330 | if (userId !== removeUserId) { 331 | await userSubRepository.removeUserFromSubscription( 332 | removeUserId, 333 | subscriptionId 334 | ); 335 | } else { 336 | throw cantRemoveSubOwner(); 337 | } 338 | } else { 339 | throw notAuthorizedToModifySubscription(); 340 | } 341 | }, 342 | 343 | async cancelSubscription({ userId, subscriptionId }) { 344 | const subscription = await stripe.subscriptions.retrieve(subscriptionId); 345 | 346 | if (parseInt(subscription.metadata.userId) === userId) { 347 | await stripe.subscriptions.update(subscriptionId, { 348 | cancel_at_period_end: true, 349 | }); 350 | } else { 351 | throw notAuthorizedToModifySubscription(); 352 | } 353 | }, 354 | 355 | async stopCancellation({ userId, subscriptionId }) { 356 | const subscription = await stripe.subscriptions.retrieve(subscriptionId); 357 | 358 | if (parseInt(subscription.metadata.userId) === userId) { 359 | await stripe.subscriptions.update(subscriptionId, { 360 | cancel_at_period_end: false, 361 | }); 362 | } else { 363 | throw notAuthorizedToModifySubscription(); 364 | } 365 | }, 366 | }; 367 | }; 368 | -------------------------------------------------------------------------------- /registry/modules/stripeSubscriptions/controllers/subscriptionWebhook.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; 2 | import Stripe from "stripe"; 3 | 4 | import { createSubscriptionsWebHookController } from "@/modules/stripeSubscriptions/controllers/subscriptionWebhook.js"; 5 | 6 | import { UserSubscriptionRepository } from "@/repositories/subscription.interface.js"; 7 | 8 | // Mock Stripe instance 9 | vi.mock("stripe", () => { 10 | return { 11 | default: vi.fn().mockImplementation(() => ({ 12 | subscriptions: { 13 | retrieve: vi.fn(), 14 | }, 15 | })), 16 | }; 17 | }); 18 | 19 | const stripe = new Stripe(""); 20 | 21 | describe("stripe-subscriptions API Module tests", () => { 22 | describe("Subscription Webhook Tests", () => { 23 | let controller: ReturnType; 24 | let subscriptionRepoMock: Partial; 25 | 26 | const mockUserId = 123; 27 | const mockSubId = "sub_123"; 28 | const mockPlanKey = "basic"; 29 | const mockSubItem = { 30 | id: "price_123", 31 | quantity: 2, 32 | price: { lookup_key: mockPlanKey, product: { name: "Basic Plan" } }, 33 | subscription: mockSubId, 34 | }; 35 | const mockSubscription = { 36 | metadata: { userId: mockUserId }, 37 | items: { data: [mockSubItem] }, 38 | }; 39 | 40 | beforeEach(() => { 41 | subscriptionRepoMock = { 42 | createUserSubscription: vi.fn(), 43 | removeUserSubscription: vi.fn(), 44 | }; 45 | 46 | // Injecting the mocked repositories into the controller 47 | controller = createSubscriptionsWebHookController( 48 | stripe, 49 | subscriptionRepoMock as UserSubscriptionRepository 50 | ); 51 | }); 52 | 53 | it("should handle checkout.session.completed and create a user subscription", async () => { 54 | const mockEvent = { 55 | type: "checkout.session.completed", 56 | data: { 57 | object: { 58 | mode: "subscription", 59 | subscription: mockSubId, 60 | customer: "cust_123", 61 | }, 62 | }, 63 | }; 64 | 65 | (stripe.subscriptions.retrieve as Mock).mockResolvedValue( 66 | mockSubscription 67 | ); 68 | 69 | await controller.handleWebhook(mockEvent as Stripe.Event); 70 | 71 | expect(subscriptionRepoMock.createUserSubscription).toHaveBeenCalledWith({ 72 | userId: mockUserId, 73 | customerId: "cust_123", 74 | subscriptionId: mockSubId, 75 | plan: mockPlanKey, 76 | }); 77 | }); 78 | 79 | it("should handle customer.subscription.deleted and remove a user subscription", async () => { 80 | const mockEvent = { 81 | type: "customer.subscription.deleted", 82 | data: { object: { id: mockSubId } }, 83 | }; 84 | 85 | await controller.handleWebhook(mockEvent as Stripe.Event); 86 | 87 | expect(subscriptionRepoMock.removeUserSubscription).toHaveBeenCalledWith( 88 | mockSubId 89 | ); 90 | }); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /registry/modules/stripeSubscriptions/controllers/subscriptionWebhook.ts: -------------------------------------------------------------------------------- 1 | import Stripe from "stripe"; 2 | 3 | import { UserSubscriptionRepository } from "@/repositories/subscription.interface.js"; 4 | 5 | interface SubscriptionsWHController { 6 | handleWebhook: (event: Stripe.Event) => void; 7 | } 8 | 9 | export const createSubscriptionsWebHookController = ( 10 | stripe: Stripe, 11 | userSubRepository: UserSubscriptionRepository 12 | ): SubscriptionsWHController => { 13 | return { 14 | async handleWebhook(event) { 15 | switch (event.type) { 16 | case "checkout.session.async_payment_succeeded": 17 | case "checkout.session.completed": { 18 | const { mode, subscription, customer } = event.data.object; 19 | 20 | if (mode === "subscription" && subscription) { 21 | const stripeSub = await stripe.subscriptions.retrieve( 22 | subscription as string, 23 | { expand: ["items.data.price.product"] } 24 | ); 25 | 26 | const userId = parseInt(stripeSub.metadata.userId); 27 | const price = stripeSub.items.data[0].price; 28 | const product = price.product as Stripe.Product; // We can assert the type as we expand the product object 29 | 30 | const planKey = 31 | price.lookup_key || 32 | product.name.toLowerCase().replaceAll(" ", "_"); 33 | 34 | await userSubRepository.createUserSubscription({ 35 | userId, 36 | customerId: customer as string, 37 | subscriptionId: subscription as string, 38 | plan: planKey, 39 | }); 40 | } 41 | 42 | break; 43 | } 44 | case "customer.subscription.deleted": { 45 | const subId = event.data.object.id; 46 | 47 | userSubRepository.removeUserSubscription(subId); 48 | break; 49 | } 50 | default: { 51 | break; 52 | } 53 | } 54 | }, 55 | }; 56 | }; 57 | -------------------------------------------------------------------------------- /registry/modules/stripeSubscriptions/middleware/subscriptions/stripeSignature.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from "express"; 2 | import Stripe from "stripe"; 3 | 4 | import { stripeWebhookValidationError } from "@/modules/stripeSubscriptions/utils/errors/subscriptions.js"; 5 | 6 | const STRIPE_API_KEY = process.env.STRIPE_API_KEY as string; 7 | const STRIPE_WH_SECRET = process.env.STRIPE_WH_SECRET as string; 8 | 9 | const stripe = new Stripe(STRIPE_API_KEY); 10 | 11 | declare global { 12 | namespace Express { 13 | interface Request { 14 | stripeEvent?: Stripe.Event; 15 | } 16 | } 17 | } 18 | 19 | export const validateStripeSignature: RequestHandler = async (req, _, next) => { 20 | const sig = req.header("stripe-signature"); 21 | 22 | if (sig) { 23 | const body = req.rawBody; 24 | const event = await stripe.webhooks.constructEventAsync(body, sig, STRIPE_WH_SECRET); 25 | req.stripeEvent = event; 26 | 27 | next(); 28 | } else { 29 | next( 30 | stripeWebhookValidationError({ 31 | name: "MissingSignature", 32 | message: "Stripe Signature missing", 33 | }) 34 | ); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /registry/modules/stripeSubscriptions/router/subscription.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import Stripe from "stripe"; 3 | 4 | import { subscriptionValidator } from "@/schemaValidators/subscription.zod.js"; 5 | 6 | import { createSubscriptionController } from "@/modules/stripeSubscriptions/controllers/subscription.js"; 7 | import { createSubscriptionsWebHookController } from "@/modules/stripeSubscriptions/controllers/subscriptionWebhook.js"; 8 | 9 | import { createUserSubRepository } from "@/repositories/subscription.postgres.js"; 10 | 11 | import { validateStripeSignature } from "@/modules/stripeSubscriptions/middleware/subscriptions/stripeSignature.js"; 12 | 13 | import { protectedRoute } from "@/modules/authBasic/middleware/authBasic/jwt.js"; 14 | import { response } from "@/modules/shared/utils/response.js"; 15 | 16 | const STRIPE_API_KEY = process.env.STRIPE_API_KEY as string; 17 | 18 | const stripe = new Stripe(STRIPE_API_KEY); 19 | const router = express.Router(); 20 | 21 | const userSubRepository = createUserSubRepository(); 22 | 23 | const subscriptionController = createSubscriptionController( 24 | stripe, 25 | userSubRepository, 26 | ); 27 | const subscriptionWHController = createSubscriptionsWebHookController( 28 | stripe, 29 | userSubRepository 30 | ); 31 | 32 | router.get("/", async (_, res, next) => { 33 | await subscriptionController 34 | .getSubscriptions() 35 | .then((result) => res.json(response(result))) 36 | .catch(next); 37 | }); 38 | 39 | router.get("/user", protectedRoute, async (req, res, next) => { 40 | const user = req.user!; 41 | 42 | await subscriptionValidator() 43 | .validateGetUserSubs({ userId: user.userId }) 44 | .then(subscriptionController.getUserSubs) 45 | .then((result) => res.json(response(result))) 46 | .catch(next); 47 | }); 48 | 49 | router 50 | .route("/:subscriptionId/seat") 51 | .patch(protectedRoute, async (req, res, next) => { 52 | const user = req.user!; 53 | const { subscriptionId } = req.params; 54 | const payload = req.body; 55 | 56 | await subscriptionValidator() 57 | .validateUpdateSeats({ 58 | ...payload, 59 | userId: user.userId, 60 | subscriptionId, 61 | }) 62 | .then(subscriptionController.updateSeats) 63 | .then(() => res.json(response())) 64 | .catch(next); 65 | }) 66 | .post(protectedRoute, async (req, res, next) => { 67 | const user = req.user!; 68 | const { subscriptionId } = req.params; 69 | const payload = req.body; 70 | 71 | await subscriptionValidator() 72 | .validateAddUserToSeat({ 73 | ...payload, 74 | userId: user.userId, 75 | subscriptionId, 76 | }) 77 | .then(subscriptionController.addUserToSeat) 78 | .then(() => res.json(response())) 79 | .catch(next); 80 | }) 81 | .delete(protectedRoute, async (req, res, next) => { 82 | const user = req.user!; 83 | const { subscriptionId } = req.params; 84 | const payload = req.body; 85 | 86 | await subscriptionValidator() 87 | .validateRemoveUserFromSeat({ 88 | ...payload, 89 | userId: user.userId, 90 | subscriptionId, 91 | }) 92 | .then(subscriptionController.removeUserFromSeat) 93 | .then(() => res.json(response())) 94 | .catch(next); 95 | }); 96 | 97 | router 98 | .route("/:subscriptionId/cancel") 99 | .delete(protectedRoute, async (req, res, next) => { 100 | const user = req.user!; 101 | const { subscriptionId } = req.params; 102 | 103 | await subscriptionValidator() 104 | .validateCancelSubscription({ 105 | userId: user.userId, 106 | subscriptionId, 107 | }) 108 | .then(subscriptionController.cancelSubscription) 109 | .then(() => res.json(response())) 110 | .catch(next); 111 | }) 112 | .post(protectedRoute, async (req, res, next) => { 113 | const user = req.user!; 114 | const { subscriptionId } = req.params; 115 | 116 | await subscriptionValidator() 117 | .validateCancelSubscription({ 118 | userId: user.userId, 119 | subscriptionId, 120 | }) 121 | .then(subscriptionController.stopCancellation) 122 | .then(() => res.json(response())) 123 | .catch(next); 124 | }); 125 | 126 | router.post("/payment/checkout", protectedRoute, async (req, res, next) => { 127 | const user = req.user!; 128 | const payload = req.body; 129 | 130 | await subscriptionValidator() 131 | .validateCreateCheckout({ 132 | ...payload, 133 | userId: user.userId, 134 | userEmail: user.email, 135 | }) 136 | .then(subscriptionController.createCheckout) 137 | .then((result) => res.json(response(result))) 138 | .catch(next); 139 | }); 140 | 141 | router.post("/payment/link", protectedRoute, async (req, res, next) => { 142 | const user = req.user!; 143 | const payload = req.body; 144 | 145 | await subscriptionValidator() 146 | .validateCreatePaymentLink({ 147 | ...payload, 148 | userId: user.userId, 149 | userEmail: user.email, 150 | }) 151 | .then(subscriptionController.createPaymentLink) 152 | .then((result) => res.json(response(result))) 153 | .catch(next); 154 | }); 155 | 156 | router.post("/webhook", validateStripeSignature, async (req, res, next) => { 157 | try { 158 | const eventPayload = req.stripeEvent!; 159 | 160 | subscriptionWHController.handleWebhook(eventPayload); 161 | 162 | res.json({ received: true }); 163 | } catch (err) { 164 | next(err); 165 | } 166 | }); 167 | 168 | router.patch("/:subscriptionId", protectedRoute, async (req, res, next) => { 169 | const user = req.user!; 170 | const { subscriptionId } = req.params; 171 | const payload = req.body; 172 | 173 | await subscriptionValidator() 174 | .validateUpdatePlanSub({ 175 | ...payload, 176 | subscriptionId, 177 | userId: user.userId, 178 | }) 179 | .then(subscriptionController.updatePlan) 180 | .then((result) => res.json(response(result))) 181 | .catch(next); 182 | }); 183 | 184 | export { router as subscriptionRouter }; 185 | -------------------------------------------------------------------------------- /registry/modules/stripeSubscriptions/utils/errors/subscriptions.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from "@/modules/shared/utils/errors/HttpError.js"; 2 | 3 | export const subscriptionNotFound = (subscriptionId: string) => { 4 | return new HttpError( 5 | "SubscriptionNotFound", 6 | `This user doesn't have an active subscription that matches this subscription ID: ${subscriptionId}`, 7 | 404 8 | ); 9 | }; 10 | 11 | export const noEmptySeatsToRemove = () => { 12 | return new HttpError( 13 | "NoEmptySeatsToRemove", 14 | "All seats are currently used. Remove users from the subscription before reducing the seat number", 15 | 409 16 | ); 17 | }; 18 | 19 | export const noAvailableSeats = () => { 20 | return new HttpError( 21 | "NoAvailableSeats", 22 | "Can't add this user to the subscription as there are no available seats to assign. Add more seats and try again", 23 | 409 24 | ); 25 | }; 26 | 27 | export const cantRemoveSubOwner = () => { 28 | return new HttpError( 29 | "CantRemoveSubOwner", 30 | "The subscription owner can't be removed. To remove a subscription for the owner you need to cancel the subscription", 31 | 400 32 | ); 33 | }; 34 | 35 | export const stripeWebhookValidationError = (error?: Error) => { 36 | return new HttpError( 37 | "StripeWebhookValidationError", 38 | `Webhook error: ${error?.message}`, 39 | 404 40 | ); 41 | }; 42 | 43 | export const stripeWebhookEventNotSupported = (event: string) => { 44 | return new HttpError( 45 | "StripeWebhookEventNotSupported", 46 | `Webhook event not supported: ${event}`, 47 | 400 48 | ); 49 | }; 50 | 51 | export const notAuthorizedToModifySubscription = () => { 52 | return new HttpError( 53 | "NotAuthorizedToModifySubscription", 54 | `User not authorized to modify this subscription. Only subscription owners can modify subscription details`, 55 | 401 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /registry/modules/uploadToS3/.env.sample: -------------------------------------------------------------------------------- 1 | # upload-to-s3 env variables 2 | S3_BUCKET_NAME= 3 | S3_BUCKET_REGION= -------------------------------------------------------------------------------- /registry/modules/uploadToS3/README.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: S3 File Storage 3 | description: Securely upload any file to an AWS S3 bucket 4 | features: 5 | available: 6 | - Large File Support 7 | - Direct-to-storage File Streaming 8 | postmanCollection: https://app.getpostman.com/run-collection/39515350-90213a2f-1b4c-496f-abe7-0a7d66112a3e?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D39515350-90213a2f-1b4c-496f-abe7-0a7d66112a3e%26entityType%3Dcollection%26workspaceId%3D36bf5973-695a-44e0-889e-bba83a364391 9 | testCoverage: 61.3 10 | --- 11 | 12 | ## About 13 | 14 | The S3 File Storage API module provides a secure and efficient solution for storing files of any size directly in an AWS S3 bucket. 15 | This module requires an existing AWS account and aims to facilitate seamless integration with minimal configuration. 16 | 17 | ## Installation 18 | 19 | To add the S3 File Storage Module to your project, run: 20 | 21 | ```bash 22 | npx vratix add upload-to-s3 23 | ``` 24 | 25 | ## .env 26 | 27 | Add the following environment variables to your `.env` file: 28 | 29 | - **S3_BUCKET_NAME**: The S3 bucket where files will be stored 30 | - **Default**: None (required) 31 | - **Example**: `S3_BUCKET_NAME=my-s3-bucket` 32 | - **S3_BUCKET_REGION**: The AWS Region of your S3 bucket 33 | - **Default**: None (required) 34 | - **Example**: `S3_BUCKET_REGION=us-east-1` 35 | 36 | ## Dependencies 37 | 38 | This module will install the `auth-basic` API module for user authentication and the `protectedRoute` middleware. 39 | 40 | ## Usage 41 | 42 | To use this module, import the router from `@/routes/storeFileS3.js` into your entry point file (e.g., `server.ts`): 43 | 44 | ```typescript server.ts 45 | import { router as storeFileRouter } from "@/routes/storeFileS3.js"; 46 | 47 | app.use("/store-file", storeFileRouter); 48 | ``` 49 | 50 | ### Authentication 51 | 52 | **Note:** Refer to the [Node.js AWS SDK documentation](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/getting-your-credentials.html) for authentication best practices. 53 | The module automatically retrieves AWS credentials from your environment. 54 | 55 | We reccomend you use AWS IAM Identity Center (SSO) for local development. After you complete the AWS SSO configuration you will need to update the SSO profile name in `src/routes/storeFileS3.ts`: 56 | ```typescript /your-sso-profile/ 57 | const s3Client = new S3Client({ 58 | credentials: fromSSO({ profile: "your-sso-profile" }), 59 | }); 60 | ``` 61 | 62 | ### File Uploads 63 | 64 | The module uses `formidable` for parsing and streaming files to S3. The file should be sent with `multipart/form-data` content type. 65 | 66 | > Each file is stored with a unique key in the format `owner:${userId}_name:${fileName}` to associate files with the uploading user. 67 | > Returned file names are simplified to `fileName` only. You can adjust this logic as needed. 68 | > **NOTE:** Make sure to include the file extension when specifying `fileName`. 69 | 70 | Below is an example React component that allows users to upload files directly from a front-end form: 71 | 72 | ```tsx /multipart/ /form-data/ 73 | import React, { useState } from "react"; 74 | import axios from "axios"; 75 | 76 | function FileUpload() { 77 | const [file, setFile] = useState(null); 78 | 79 | const handleSubmit = async (e: React.FormEvent) => { 80 | e.preventDefault(); 81 | if (!file) return; 82 | const formData = new FormData(); 83 | formData.append("file", file); 84 | 85 | await axios.post("/api/store-file/upload/${file.name}", formData, { 86 | headers: { "Content-Type": "multipart/form-data" }, 87 | }); 88 | }; 89 | 90 | return ( 91 |
92 | setFile(e.target.files?.[0] || null)} 95 | /> 96 | 97 |
98 | ); 99 | } 100 | 101 | export default FileUpload; 102 | ``` 103 | 104 | ### Endpoints 105 | 106 | The S3 File Storage Module exposes the following endpoints: 107 | 108 | | Method | Endpoint | Description | 109 | | ------ | ------------------- | ------------------------------------------------------- | 110 | | POST | `/upload/:fileName` | Uploads a file to the designated S3 bucket | 111 | | GET | `/files` | Retrieves a list of uploaded files for the current user | 112 | | DELETE | `/files` | Deletes a specific file associated with the user | 113 | | GET | `/:fileKey` | Downloads a file by name | 114 | | DELETE | `/:fileKey` | Deletes a file by name | 115 | 116 | ### Errors 117 | 118 | Below are common errors with solutions for this module: 119 | 120 | | Error Code | Name | Solution | 121 | | ---------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------- | 122 | | 404 | FileNotFound | The requested file does not exist in the S3 bucket. Verify the file's existence and ensure the correct file key is used | 123 | | 400 | ErrorDownloadingFile | An error occurred while downloading the file from S3. Check AWS credentials and network configuration in your environment | 124 | | 400 | CantGetFiles | An error occurred while retrieving the file list from S3. Verify AWS credentials and check the bucket permissions | 125 | 126 | ### Examples 127 | 128 | To explore sample requests and responses, download our Postman collection: 129 | 130 | 134 | -------------------------------------------------------------------------------- /registry/modules/uploadToS3/controllers/storeFileS3.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach } from "vitest"; 2 | import { sdkStreamMixin } from "@smithy/util-stream"; 3 | import { mockClient } from "aws-sdk-client-mock"; 4 | 5 | import { 6 | DeleteObjectsCommand, 7 | GetObjectCommand, 8 | ListObjectsV2Command, 9 | S3Client, 10 | } from "@aws-sdk/client-s3"; 11 | import { Readable } from "stream"; 12 | 13 | import { createStoreFileS3Controller } from "@/modules/uploadToS3/controllers/storeFileS3.js"; 14 | import { 15 | errorDownloadingS3File, 16 | cantGetS3Files, 17 | } from "@/modules/uploadToS3/utils/errors/storeFileS3.js"; 18 | 19 | const s3Mock = mockClient(S3Client); 20 | 21 | describe("upload-to-s3 API Module Tests", () => { 22 | describe("StoreFileS3 Controller Tests", () => { 23 | const s3Client = new S3Client({}); 24 | let controller: ReturnType; 25 | 26 | const mockUserId = 1; 27 | const mockFileName = "test-file.txt"; 28 | const mockFile = { 29 | originalFilename: mockFileName, 30 | filepath: "/path/to/test-file.txt", 31 | mimetype: "text/plain", 32 | content: Buffer.from([8, 6, 7, 5, 3, 0, 9]), 33 | }; 34 | 35 | beforeEach(() => { 36 | s3Mock.reset(); 37 | 38 | // Injecting the mocked repository into the controller 39 | controller = createStoreFileS3Controller(s3Client); 40 | }); 41 | 42 | it("should download a file from S3", async () => { 43 | const stream = new Readable(); 44 | stream.push(mockFile.content); 45 | stream.push(null); 46 | const sdkStream = sdkStreamMixin(stream); 47 | 48 | s3Mock 49 | .on(GetObjectCommand) 50 | .resolves({ Body: sdkStream, ContentType: mockFile.mimetype }); 51 | 52 | const result = await controller.downloadFile({ 53 | fileName: mockFileName, 54 | userId: mockUserId, 55 | }); 56 | const bodyStr = await result.Body?.transformToString(); 57 | 58 | expect(result.ContentType).toBe(mockFile.mimetype); 59 | expect(bodyStr).toBe(mockFile.content.toString()); 60 | }); 61 | 62 | it("should throw errorDownloadingS3File if downloading fails", async () => { 63 | s3Mock.on(GetObjectCommand).rejects(); 64 | 65 | await expect( 66 | controller.downloadFile({ fileName: mockFileName, userId: mockUserId }) 67 | ).rejects.toThrowError(errorDownloadingS3File(mockFileName)); 68 | }); 69 | 70 | it("should get a list of files in S3", async () => { 71 | const ContinuationToken = "1"; 72 | const NextContinuationToken = "2"; 73 | const mockContent = { 74 | name: mockFileName, 75 | size: 1024, 76 | modified: new Date(), 77 | s3Location: `https://mockBucketName.s3.us-east-1.amazonaws.com/owner:${mockUserId}_name:${mockFileName}`, 78 | }; 79 | 80 | s3Mock.on(ListObjectsV2Command).resolves({ 81 | Contents: [ 82 | { 83 | Key: `owner:${mockUserId}_name:${mockContent.name}`, 84 | Size: mockContent.size, 85 | LastModified: mockContent.modified, 86 | }, 87 | ], 88 | ContinuationToken, 89 | NextContinuationToken, 90 | }); 91 | 92 | const result = await controller.getFileList({ 93 | pageToken: ContinuationToken, 94 | userId: mockUserId, 95 | }); 96 | 97 | expect(result.files).toContainEqual(mockContent); 98 | expect(result.nextToken).toBe(NextContinuationToken); 99 | }); 100 | 101 | it("should throw cantGetS3Files if S3 can't list files in bucket", async () => { 102 | s3Mock.on(ListObjectsV2Command).rejects(); 103 | 104 | await expect( 105 | controller.getFileList({ userId: mockUserId }) 106 | ).rejects.toThrowError(cantGetS3Files()); 107 | }); 108 | 109 | it("should delete a file from S3", async () => { 110 | const filesToDelete = [mockFileName]; 111 | s3Mock.on(DeleteObjectsCommand).resolves({}); 112 | 113 | expect( 114 | controller.deleteFiles({ files: filesToDelete, userId: mockUserId }) 115 | ).toBeTruthy(); 116 | }); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /registry/modules/uploadToS3/controllers/storeFileS3.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "express"; 2 | import formidable from "formidable"; 3 | import { PassThrough } from "stream"; 4 | import { 5 | GetObjectCommandOutput, 6 | GetObjectCommand, 7 | S3Client, 8 | ListObjectsV2Command, 9 | DeleteObjectsCommand, 10 | S3ServiceException, 11 | } from "@aws-sdk/client-s3"; 12 | import { Upload } from "@aws-sdk/lib-storage"; 13 | 14 | import { 15 | GetFileSchema, 16 | ListFilesSchema, 17 | DeleteFilesSchema, 18 | } from "@/schemaValidators/storeFile.interface.js"; 19 | 20 | import { 21 | fileParseError, 22 | s3UploadFailed, 23 | errorDownloadingS3File, 24 | s3FileNotFound, 25 | cantGetS3Files, 26 | } from "@/modules/uploadToS3/utils/errors/storeFileS3.js"; 27 | 28 | const S3_BUCKET_NAME = process.env.S3_BUCKET_NAME; 29 | const S3_BUCKET_REGION = process.env.S3_BUCKET_REGION; 30 | 31 | interface UploadFile extends GetFileSchema { 32 | req: Request; 33 | } 34 | 35 | type UploadedFileLoc = { s3Location: string | undefined }; 36 | interface FileOutput extends UploadedFileLoc { 37 | name: string; 38 | size?: number; 39 | modified?: Date; 40 | }; 41 | type FileListOutput = { files: FileOutput[]; nextToken: string | undefined }; 42 | 43 | interface StoreFileS3Controller { 44 | uploadFile: (props: UploadFile) => Promise; 45 | downloadFile: (props: GetFileSchema) => Promise; 46 | getFileList: (props: ListFilesSchema) => Promise; 47 | deleteFiles: (props: DeleteFilesSchema) => void; 48 | } 49 | 50 | export const createStoreFileS3Controller = ( 51 | s3Client: S3Client 52 | ): StoreFileS3Controller => { 53 | const generateFileName = (userId: number, name: string) => { 54 | return `owner:${userId}_name:${name}`; 55 | }; 56 | 57 | return { 58 | uploadFile({ req, fileName, userId }) { 59 | return new Promise((resolve, reject) => { 60 | const form = formidable({ 61 | allowEmptyFiles: false, 62 | fileWriteStreamHandler: () => { 63 | const passThrough = new PassThrough(); 64 | const uploadParams = { 65 | Bucket: S3_BUCKET_NAME, 66 | Key: generateFileName(userId, fileName), 67 | Body: passThrough, 68 | }; 69 | 70 | new Upload({ client: s3Client, params: uploadParams }) 71 | .done() 72 | .then((result) => resolve({ s3Location: result.Location })) 73 | .catch(() => reject(s3UploadFailed())); 74 | 75 | return passThrough; 76 | }, 77 | }); 78 | 79 | form.parse(req); 80 | 81 | form.on("error", (err) => reject(fileParseError(err?.code))); 82 | }); 83 | }, 84 | 85 | async downloadFile({ fileName, userId }) { 86 | const data = await s3Client 87 | .send( 88 | new GetObjectCommand({ 89 | Bucket: S3_BUCKET_NAME, 90 | Key: generateFileName(userId, fileName), 91 | }) 92 | ) 93 | .catch((err: S3ServiceException) => { 94 | if (err.name === "NoSuchKey") { 95 | throw s3FileNotFound(fileName); 96 | } else { 97 | throw errorDownloadingS3File(fileName); 98 | } 99 | }); 100 | 101 | return data; 102 | }, 103 | 104 | async getFileList({ pageToken, userId }) { 105 | const list = await s3Client 106 | .send( 107 | new ListObjectsV2Command({ 108 | Bucket: S3_BUCKET_NAME, 109 | ContinuationToken: pageToken, 110 | }) 111 | ) 112 | .catch(() => { 113 | throw cantGetS3Files(); 114 | }); 115 | 116 | const fileNames: FileOutput[] = 117 | list.Contents?.filter((file) => 118 | file.Key?.includes(`owner:${userId}`) 119 | ).map((file) => ({ 120 | name: file.Key!.split("name:")[1], 121 | size: file.Size, 122 | modified: file.LastModified, 123 | s3Location: `https://${S3_BUCKET_NAME}.s3.${S3_BUCKET_REGION}.amazonaws.com/${file.Key}`, 124 | 125 | })) || []; 126 | 127 | return { files: fileNames, nextToken: list.NextContinuationToken }; 128 | }, 129 | 130 | async deleteFiles({ files, userId }) { 131 | const deleteObjectsKeys = files.map((name) => ({ 132 | Key: generateFileName(userId, name), 133 | })); 134 | 135 | await s3Client.send( 136 | new DeleteObjectsCommand({ 137 | Bucket: S3_BUCKET_NAME, 138 | Delete: { Objects: deleteObjectsKeys }, 139 | }) 140 | ); 141 | }, 142 | }; 143 | }; 144 | -------------------------------------------------------------------------------- /registry/modules/uploadToS3/router/storeFileS3.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { S3Client } from "@aws-sdk/client-s3"; 3 | import { fromSSO } from "@aws-sdk/credential-provider-sso"; 4 | 5 | import { storeFileValidator } from "@/schemaValidators/storeFile.zod.js"; 6 | 7 | import { createStoreFileS3Controller } from "@/modules/uploadToS3/controllers/storeFileS3.js"; 8 | import { protectedRoute } from "@/modules/authBasic/middleware/authBasic/jwt.js"; 9 | 10 | import { response } from "@/modules/shared/utils/response.js"; 11 | 12 | const s3Client = new S3Client({ 13 | credentials: fromSSO({ profile: "your-sso-profile" }), 14 | }); 15 | 16 | const storeFileController = createStoreFileS3Controller(s3Client); 17 | const router = express.Router(); 18 | 19 | router.post("/upload/:fileName", protectedRoute, async (req, res, next) => { 20 | const { fileName } = req.params; 21 | const { userId } = req.user!; 22 | 23 | await storeFileValidator() 24 | .validateGetFile({ fileName, userId }) 25 | .then((val) => 26 | storeFileController.uploadFile({ req, fileName: val.fileName, userId }) 27 | ) 28 | .then((result) => res.json(response(result))) 29 | .catch(next); 30 | }); 31 | 32 | router 33 | .route("/files") 34 | .get(protectedRoute, async (req, res, next) => { 35 | const pageToken = req.query.pageToken as string; 36 | const { userId } = req.user!; 37 | 38 | await storeFileValidator() 39 | .validateListFiles({ pageToken, userId }) 40 | .then(storeFileController.getFileList) 41 | .then((result) => res.json(response(result))) 42 | .catch(next); 43 | }) 44 | .delete(protectedRoute, async (req, res, next) => { 45 | const payload = req.body; 46 | const { userId } = req.user!; 47 | 48 | await storeFileValidator() 49 | .validateDeleteFiles({ ...payload, userId }) 50 | .then(storeFileController.deleteFiles) 51 | .then((result) => res.json(response(result))) 52 | .catch(next); 53 | }); 54 | 55 | router 56 | .route("/:fileKey") 57 | .get(protectedRoute, async (req, res, next) => { 58 | const fileName = req.params.fileKey; 59 | const { userId } = req.user!; 60 | 61 | await storeFileValidator() 62 | .validateGetFile({ fileName, userId }) 63 | .then(storeFileController.downloadFile) 64 | .then(async (result) => { 65 | res.setHeader( 66 | "Content-Type", 67 | result.ContentType || "application/octet-stream" 68 | ); 69 | res.setHeader( 70 | "Content-Disposition", 71 | `attachment; filename="${fileName}"` 72 | ); 73 | 74 | const body = await result.Body?.transformToByteArray() 75 | res.write(body); 76 | res.end(); 77 | }) 78 | .catch(next); 79 | }) 80 | .delete(protectedRoute, async (req, res, next) => { 81 | const fileName = req.params.fileKey; 82 | const { userId } = req.user!; 83 | 84 | await storeFileValidator() 85 | .validateDeleteFiles({ files: [fileName], userId }) 86 | .then(storeFileController.deleteFiles) 87 | .then((result) => res.json(response(result))) 88 | .catch(next); 89 | }); 90 | 91 | export { router as storeFileS3Router }; 92 | -------------------------------------------------------------------------------- /registry/modules/uploadToS3/utils/errors/storeFileS3.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from "@/modules/shared/utils/errors/HttpError.js"; 2 | 3 | export const fileParseError = (errorCode?: string) => { 4 | return new HttpError( 5 | "FileParseError", 6 | `File couldn't be parsed, please try again. ${ 7 | errorCode && "Error code: " + errorCode 8 | }`, 9 | 400 10 | ); 11 | }; 12 | 13 | export const s3UploadFailed = () => { 14 | return new HttpError( 15 | "FileUploadFailed", 16 | `There was a problem uploading your file`, 17 | 500 18 | ); 19 | }; 20 | 21 | export const errorDownloadingS3File = (fileName: string) => { 22 | return new HttpError( 23 | "ErrorDownloadingFile", 24 | `There was a problem downloading file ${fileName}`, 25 | 400 26 | ); 27 | }; 28 | 29 | export const s3FileNotFound = (fileName: string) => { 30 | return new HttpError( 31 | "FileNotFound", 32 | `File ${fileName} couldn't be found`, 33 | 404 34 | ); 35 | }; 36 | 37 | export const cantGetS3Files = () => { 38 | return new HttpError("CantGetFiles", `Couldn't get your files`, 400); 39 | }; 40 | -------------------------------------------------------------------------------- /registry/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "registry", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "test": "vitest", 6 | "coverage": "vitest run --coverage" 7 | }, 8 | "type": "module", 9 | "dependencies": { 10 | "@aws-sdk/client-s3": "^3.787.0", 11 | "@aws-sdk/credential-provider-sso": "^3.787.0", 12 | "@aws-sdk/lib-storage": "^3.787.0", 13 | "argon2": "^0.41.1", 14 | "express": "^4.21.2", 15 | "formidable": "^3.5.2", 16 | "jsonwebtoken": "^9.0.2", 17 | "pg": "^8.14.1", 18 | "postmark": "^4.0.5", 19 | "stripe": "^18.0.0", 20 | "yup": "^1.6.1", 21 | "zod": "^3.24.2" 22 | }, 23 | "devDependencies": { 24 | "@smithy/util-stream": "^3.3.4", 25 | "@types/express": "^5.0.1", 26 | "@types/formidable": "^3.4.5", 27 | "@types/jsonwebtoken": "^9.0.9", 28 | "@types/pg": "^8.11.12", 29 | "@vitest/coverage-istanbul": "3.0.8", 30 | "aws-sdk-client-mock": "^4.1.0", 31 | "vitest": "^3.1.1" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /registry/repositories/connection.postgres.ts: -------------------------------------------------------------------------------- 1 | import pg from "pg"; 2 | 3 | const PG_CONNECTION_STRING = process.env.PG_CONNECTION_STRING; 4 | 5 | export const createPool = () => { 6 | const connectionString = PG_CONNECTION_STRING; 7 | return new pg.Pool({ connectionString }); 8 | }; 9 | 10 | export const pgPool = createPool() -------------------------------------------------------------------------------- /registry/repositories/refreshToken.interface.ts: -------------------------------------------------------------------------------- 1 | export interface RefreshTokenRepository { 2 | getToken: (token: string) => Promise; 3 | invalidateTokenFamily: (tokenFamily: string) => void; 4 | createToken: (props: CreateRefreshToken) => Promise; 5 | } 6 | 7 | export type RefreshToken = { 8 | userId: number; 9 | token: string; 10 | tokenFamily: string; 11 | active: boolean; 12 | expiresAt: string; 13 | }; 14 | 15 | export type CreateRefreshToken = { 16 | userId: number; 17 | expiresAt: string; 18 | tokenFamily?: string; 19 | }; 20 | -------------------------------------------------------------------------------- /registry/repositories/refreshToken.postgres.ts: -------------------------------------------------------------------------------- 1 | import { QueryConfig } from "pg"; 2 | import { pgPool } from "@/repositories/connection.postgres.js"; 3 | import { RefreshTokenRepository } from "@/repositories/refreshToken.interface.js"; 4 | 5 | export const createRefreshTokenRepository = (): RefreshTokenRepository => { 6 | return { 7 | async getToken(token) { 8 | const query: QueryConfig = { 9 | name: "queryGetRefreshToken", 10 | text: ` 11 | SELECT user_id, token, token_family, active, expires_at 12 | FROM refresh_tokens 13 | WHERE token = $1 14 | AND expires_at > NOW(); 15 | `, 16 | values: [token], 17 | }; 18 | const result = await pgPool.query(query).then((data) => data.rows.at(0)); 19 | 20 | if (result) { 21 | return { 22 | userId: result.user_id, 23 | token: result.token, 24 | active: result.active, 25 | tokenFamily: result.token_family, 26 | expiresAt: result.expires_at, 27 | }; 28 | } else return null; 29 | }, 30 | 31 | async createToken({ tokenFamily, userId, expiresAt }) { 32 | const query: QueryConfig = { 33 | name: "queryCreateRefreshToken", 34 | text: ` 35 | INSERT INTO refresh_tokens (token_family, user_id, expires_at) 36 | VALUES (COALESCE($1, gen_random_uuid()), $2, $3) 37 | RETURNING user_id, token, token_family, active, expires_at; 38 | `, 39 | values: [tokenFamily, userId, expiresAt], 40 | }; 41 | const result = await pgPool.query(query).then((data) => data.rows.at(0)); 42 | 43 | return { 44 | userId: result.user_id, 45 | token: result.token, 46 | active: result.active, 47 | tokenFamily: result.token_family, 48 | expiresAt: result.expire_at, 49 | }; 50 | }, 51 | 52 | async invalidateTokenFamily(tokenFamily) { 53 | const query: QueryConfig = { 54 | name: "queryInvalidateRefreshTokenFamily", 55 | text: ` 56 | DELETE FROM refresh_tokens 57 | WHERE token_family = $1; 58 | `, 59 | values: [tokenFamily], 60 | }; 61 | 62 | await pgPool.query(query); 63 | }, 64 | }; 65 | }; 66 | -------------------------------------------------------------------------------- /registry/repositories/refreshToken.template.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * HOW TO USE THIS TEMPLATE: 3 | * 4 | * Use this Refresh Token Repository template file to integrate any database engine. 5 | * 6 | * Replace the `REPLACE:` comments with your query logic and return the correct types defined in refreshToken.interface.ts 7 | * 8 | * Once you complete your implementation you don't have to do anything else as 9 | * this repository is referenced in the rest of the code base and used to implement the service logic. 10 | */ 11 | 12 | import { RefreshTokenRepository } from "@/repositories/refreshToken.interface.js"; 13 | 14 | import { notImplementedError } from "@/modules/shared/utils/errors/common.js"; 15 | 16 | export const createRefreshTokenRepository = (): RefreshTokenRepository => { 17 | return { 18 | async getToken(token) { 19 | // REPLACE: Implement token retrieval with your DB of choice 20 | throw notImplementedError(); // Remove when implemented 21 | }, 22 | 23 | async createToken(props) { 24 | // REPLACE: Implement token creation with your DB of choice 25 | throw notImplementedError(); // Remove when implemented 26 | }, 27 | 28 | async invalidateTokenFamily(tokenFamily) { 29 | // REPLACE: Implement token family invalidation with your DB of choice 30 | throw notImplementedError(); // Remove when implemented 31 | }, 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /registry/repositories/subscription.interface.ts: -------------------------------------------------------------------------------- 1 | export interface UserSubscriptionRepository { 2 | getUserSubscriptions: (userId: number) => Promise; 3 | getSubscriptionUsers: (subscriptionId: string) => Promise; 4 | createUserSubscription: ( 5 | props: CreateUserSubscription 6 | ) => Promise; 7 | removeUserFromSubscription: (userId: number, subscriptionId: string) => void; 8 | removeUserSubscription: (subscriptionId: string) => void; 9 | } 10 | 11 | type CreateUserSubscription = { 12 | plan: string; 13 | userId: number; 14 | customerId?: string; 15 | subscriptionId: string; 16 | isOwner?: boolean; 17 | }; 18 | 19 | export interface UserSubscription extends CreateUserSubscription { 20 | isOwner: boolean; 21 | createdAt: string; 22 | } 23 | -------------------------------------------------------------------------------- /registry/repositories/subscription.postgres.ts: -------------------------------------------------------------------------------- 1 | import { QueryConfig } from "pg"; 2 | import { pgPool } from "@/repositories/connection.postgres.js"; 3 | import { 4 | UserSubscriptionRepository, 5 | UserSubscription, 6 | } from "@/repositories/subscription.interface.js"; 7 | 8 | export const createUserSubRepository = (): UserSubscriptionRepository => { 9 | return { 10 | async getUserSubscriptions(userId) { 11 | const query: QueryConfig = { 12 | name: "queryGetUserSubscriptions", 13 | text: ` 14 | SELECT plan, user_id, customer_id, subscription_id, is_owner, created_at 15 | FROM user_subscriptions 16 | WHERE user_id = $1; 17 | `, 18 | values: [userId], 19 | }; 20 | const result = await pgPool.query(query).then((data) => data.rows); 21 | 22 | const subscriptions: UserSubscription[] = result.map((el) => ({ 23 | plan: el.plan, 24 | userId: el.user_id, 25 | customerId: el.customer_id, 26 | subscriptionId: el.subscription_id, 27 | isOwner: el.is_owner, 28 | createdAt: el.created_at, 29 | })); 30 | 31 | return subscriptions; 32 | }, 33 | 34 | async getSubscriptionUsers(subscriptionId) { 35 | const query: QueryConfig = { 36 | name: "queryGetSubscriptionUsers", 37 | text: ` 38 | SELECT plan, user_id, customer_id, subscription_id, is_owner, created_at 39 | FROM user_subscriptions 40 | WHERE subscription_id = $1; 41 | `, 42 | values: [subscriptionId], 43 | }; 44 | const result = await pgPool.query(query).then((data) => data.rows); 45 | 46 | const subscriptions: UserSubscription[] = result.map((el) => ({ 47 | plan: el.plan, 48 | userId: el.user_id, 49 | customerId: el.customer_id, 50 | subscriptionId: el.subscription_id, 51 | isOwner: el.is_owner, 52 | createdAt: el.created_at, 53 | })); 54 | 55 | return subscriptions; 56 | }, 57 | 58 | async createUserSubscription(props) { 59 | const { plan, userId, customerId, subscriptionId, isOwner } = props; 60 | const query: QueryConfig = { 61 | name: "queryCreateUserSubscription", 62 | text: ` 63 | INSERT INTO user_subscriptions (plan, user_id, customer_id, subscription_id, is_owner) 64 | VALUES ($1, $2, $3, $4, COALESCE($5, true)) 65 | ON CONFLICT (user_id, subscription_id) 66 | DO UPDATE 67 | SET plan = EXCLUDED.plan, 68 | created_at = DEFAULT 69 | RETURNING plan, user_id, customer_id, subscription_id, is_owner, created_at; 70 | `, 71 | values: [plan, userId, customerId, subscriptionId, isOwner], 72 | }; 73 | 74 | const result = await pgPool.query(query).then((data) => data.rows.at(0)); 75 | 76 | return { 77 | plan: result.plan, 78 | userId: result.user_id, 79 | customerId: result.customer_id, 80 | subscriptionId: result.subscription_id, 81 | isOwner: result.is_owner, 82 | createdAt: result.created_at, 83 | }; 84 | }, 85 | 86 | async removeUserFromSubscription(userId, subscriptionId) { 87 | const query: QueryConfig = { 88 | name: "queryRemoveUserFromSubscription", 89 | text: ` 90 | DELETE FROM user_subscriptions 91 | WHERE user_id = $1 92 | AND subscription_id = $2 93 | AND is_owner = false; 94 | `, 95 | values: [userId, subscriptionId], 96 | }; 97 | 98 | await pgPool.query(query); 99 | }, 100 | 101 | async removeUserSubscription(subscriptionId) { 102 | const query: QueryConfig = { 103 | name: "queryRemoveUserSubscription", 104 | text: ` 105 | DELETE FROM user_subscriptions 106 | WHERE subscription_id = $1; 107 | `, 108 | values: [subscriptionId], 109 | }; 110 | 111 | await pgPool.query(query); 112 | }, 113 | }; 114 | }; 115 | -------------------------------------------------------------------------------- /registry/repositories/subscription.template.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * HOW TO USE THIS TEMPLATE: 3 | * 4 | * Use this User Subscription Repository template file to integrate any database engine. 5 | * 6 | * Replace the `REPLACE:` comments with your query logic and return the correct types defined in subscription.interface.ts 7 | * 8 | * Once you complete your implementation you don't have to do anything else as 9 | * this repository is referenced in the rest of the code base and used to implement the service logic. 10 | */ 11 | 12 | import { UserSubscriptionRepository } from "@/repositories/subscription.interface.js"; 13 | 14 | import { notImplementedError } from "@/modules/shared/utils/errors/common.js"; 15 | 16 | export const createUserSubRepository = (): UserSubscriptionRepository => { 17 | return { 18 | async getUserSubscriptions(userId) { 19 | // REPLACE: Implement subscription retrieval with your DB of choice 20 | throw notImplementedError(); // Remove when implemented 21 | }, 22 | 23 | async getSubscriptionUsers(subscriptionId) { 24 | // REPLACE: Implement subscription users retrieval with your DB of choice 25 | throw notImplementedError(); // Remove when implemented 26 | }, 27 | 28 | async createUserSubscription(props) { 29 | // REPLACE: Implement subscription creation with your DB of choice 30 | throw notImplementedError(); // Remove when implemented 31 | }, 32 | 33 | async removeUserFromSubscription(userId, subscriptionId) { 34 | // REPLACE: Implement remove user from subscription with your DB of choice 35 | throw notImplementedError(); // Remove when implemented 36 | }, 37 | 38 | async removeUserSubscription(subscriptionId) { 39 | // REPLACE: Implement subscription deletion with your DB of choice 40 | throw notImplementedError(); // Remove when implemented 41 | }, 42 | }; 43 | }; 44 | -------------------------------------------------------------------------------- /registry/repositories/user.interface.ts: -------------------------------------------------------------------------------- 1 | export interface UserRepository { 2 | getUser: (username: string) => Promise; 3 | getUserById: (id: number) => Promise; 4 | createAuthBasicUser: (props: AuthBasicSignup) => Promise; 5 | } 6 | 7 | export type User = { 8 | userId: number; 9 | username: string; 10 | email?: string; 11 | password: string; 12 | createdAt: string; 13 | }; 14 | 15 | declare global { 16 | namespace Express { 17 | interface Request { 18 | user?: User; 19 | } 20 | } 21 | } 22 | 23 | export type AuthBasicSignup = { 24 | username: string; 25 | hashedPass: string; 26 | email?: string; 27 | }; 28 | -------------------------------------------------------------------------------- /registry/repositories/user.postgres.ts: -------------------------------------------------------------------------------- 1 | import { QueryConfig } from "pg"; 2 | import { pgPool } from "@/repositories/connection.postgres.js"; 3 | import { UserRepository } from "@/repositories/user.interface.js"; 4 | 5 | export const createUserRepository = (): UserRepository => { 6 | return { 7 | async getUser(username) { 8 | const query: QueryConfig = { 9 | name: "queryGetUserByUsername", 10 | text: ` 11 | SELECT id, email, username, password, created_at 12 | FROM users 13 | WHERE username = $1; 14 | `, 15 | values: [username], 16 | }; 17 | const result = await pgPool.query(query).then((data) => data.rows.at(0)); 18 | 19 | if (result) { 20 | return { 21 | userId: result.id, 22 | username: result.username, 23 | password: result.password, 24 | email: result.email, 25 | createdAt: result.created_at, 26 | }; 27 | } else return null; 28 | }, 29 | 30 | async getUserById(userId) { 31 | const query: QueryConfig = { 32 | name: "queryGetUserById", 33 | text: ` 34 | SELECT id, email, username, password, created_at 35 | FROM users 36 | WHERE id = $1; 37 | `, 38 | values: [userId], 39 | }; 40 | const result = await pgPool.query(query).then((data) => data.rows.at(0)); 41 | 42 | if (result) { 43 | return { 44 | userId: result.id, 45 | username: result.username, 46 | password: result.password, 47 | email: result.email, 48 | createdAt: result.created_at, 49 | }; 50 | } else return null; 51 | }, 52 | 53 | async createAuthBasicUser(data) { 54 | const query: QueryConfig = { 55 | name: "queryCreateAuthBasicUser", 56 | text: ` 57 | INSERT INTO users (username, password, email) 58 | VALUES ($1, $2, $3) 59 | RETURNING id, email, username, password, created_at; 60 | `, 61 | values: [data.username, data.hashedPass, data.email], 62 | }; 63 | const result = await pgPool.query(query).then((data) => data.rows.at(0)); 64 | 65 | return { 66 | userId: result.id, 67 | username: result.username, 68 | password: result.password, 69 | email: result.email, 70 | createdAt: result.created_at, 71 | }; 72 | }, 73 | }; 74 | }; 75 | -------------------------------------------------------------------------------- /registry/repositories/user.template.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * HOW TO USE THIS TEMPLATE: 3 | * 4 | * Use this User Repository template file to integrate any database engine. 5 | * 6 | * Replace the `REPLACE:` comments with your query logic and return the correct types defined in user.interface.ts 7 | * 8 | * Once you complete your implementation you don't have to do anything else as 9 | * this repository is referenced in the rest of the code base and used to implement the service logic. 10 | */ 11 | 12 | import { 13 | UserRepository, 14 | User, 15 | AuthBasicSignup, 16 | } from "@/repositories/user.interface.js"; 17 | 18 | import { notImplementedError } from "@/modules/shared/utils/errors/common.js"; 19 | 20 | export const createUserRepository = (): UserRepository => { 21 | return { 22 | async getUser(username: string): Promise { 23 | // REPLACE: Implement user retrieval with your DB of choice 24 | throw notImplementedError(); // Remove when implemented 25 | }, 26 | 27 | async getUserById(userId: number): Promise { 28 | // REPLACE: Implement user retrieval by ID with your DB of choice 29 | throw notImplementedError(); // Remove when implemented 30 | }, 31 | 32 | async createAuthBasicUser(data: AuthBasicSignup): Promise { 33 | // REPLACE: Implement user creation with your DB of choice 34 | throw notImplementedError(); // Remove when implemented 35 | }, 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /registry/schemaValidators/authBasic.interface.ts: -------------------------------------------------------------------------------- 1 | export type BasicAuthSchema = { 2 | username: string; 3 | password: string; 4 | email?: string; 5 | }; 6 | 7 | export type RefreshTokenSchema = { 8 | token: string; 9 | }; 10 | 11 | export interface BasicAuthValidator { 12 | validateAuth: (payload: BasicAuthSchema) => Promise; 13 | validateRefreshToken: ( 14 | payload: RefreshTokenSchema 15 | ) => Promise; 16 | } 17 | -------------------------------------------------------------------------------- /registry/schemaValidators/authBasic.yup.ts: -------------------------------------------------------------------------------- 1 | import yup from "yup"; 2 | import { BasicAuthValidator } from "./authBasic.interface.js"; 3 | 4 | const basicAuthSchema = yup.object({ 5 | username: yup.string().required(), 6 | password: yup 7 | .string() 8 | .min(12, "Password must be at least 12 characters long") 9 | .matches(/[A-Z]/, "Password must contain at least one uppercase letter") 10 | .matches(/[a-z]/, "Password must contain at least one lowercase letter") 11 | .matches(/\d/, "Password must contain at least one number") 12 | .required(), 13 | email: yup.string().email() 14 | }); 15 | 16 | const refreshTokenSchema = yup.object({ 17 | token: yup.string().uuid().required(), 18 | }); 19 | 20 | export const basicAuthValidator = (): BasicAuthValidator => { 21 | return { 22 | async validateAuth(payload) { 23 | return await basicAuthSchema.noUnknown().strict(true).validate(payload); 24 | }, 25 | 26 | async validateRefreshToken(payload) { 27 | return await refreshTokenSchema 28 | .noUnknown() 29 | .strict(true) 30 | .validate(payload); 31 | }, 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /registry/schemaValidators/authBasic.zod.ts: -------------------------------------------------------------------------------- 1 | import z from "zod"; 2 | import { BasicAuthValidator } from "./authBasic.interface.js"; 3 | 4 | const basicAuthSchema = z.object({ 5 | username: z.string(), 6 | password: z 7 | .string() 8 | .min(12, "Password must be at least 12 characters long") 9 | .regex(/[A-Z]/, "Password must contain at least one uppercase letter") 10 | .regex(/[a-z]/, "Password must contain at least one lowercase letter") 11 | .regex(/\d/, "Password must contain at least one number"), 12 | email: z.string().email().optional(), 13 | }); 14 | 15 | const refreshTokenSchema = z.object({ 16 | token: z.string().uuid(), 17 | }); 18 | 19 | export const basicAuthValidator = (): BasicAuthValidator => { 20 | return { 21 | async validateAuth(payload) { 22 | return await basicAuthSchema.parseAsync(payload); 23 | }, 24 | 25 | async validateRefreshToken(payload) { 26 | return await refreshTokenSchema.parseAsync(payload); 27 | }, 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /registry/schemaValidators/storeFile.interface.ts: -------------------------------------------------------------------------------- 1 | export type GetFileSchema = { 2 | userId: number; 3 | fileName: string; 4 | }; 5 | 6 | export type ListFilesSchema = { 7 | userId: number; 8 | pageToken?: string; 9 | }; 10 | 11 | export type DeleteFilesSchema = { 12 | userId: number; 13 | files: string[]; 14 | }; 15 | 16 | export interface StoreFileValidator { 17 | validateGetFile: (payload: GetFileSchema) => Promise; 18 | 19 | validateListFiles: (payload: ListFilesSchema) => Promise; 20 | 21 | validateDeleteFiles: ( 22 | payload: DeleteFilesSchema 23 | ) => Promise; 24 | } 25 | -------------------------------------------------------------------------------- /registry/schemaValidators/storeFile.yup.ts: -------------------------------------------------------------------------------- 1 | import yup from "yup"; 2 | import { StoreFileValidator } from "./storeFile.interface.js"; 3 | 4 | const fileNameSchema = yup 5 | .string() 6 | .matches(/^[a-zA-Z0-9._-]{1,255}$/, "Invalid file name"); 7 | 8 | const getFileSchema = yup.object({ 9 | userId: yup.number().required(), 10 | fileName: fileNameSchema.required(), 11 | }); 12 | 13 | const listFilesSchema = yup.object({ 14 | userId: yup.number().required(), 15 | pageToken: yup.string(), 16 | }); 17 | 18 | const deleteFilesSchema = yup.object({ 19 | userId: yup.number().required(), 20 | files: yup.array(fileNameSchema.required()).required(), 21 | }); 22 | 23 | export const storeFileValidator = (): StoreFileValidator => { 24 | return { 25 | async validateGetFile(payload) { 26 | return await getFileSchema.noUnknown().strict(true).validate(payload); 27 | }, 28 | 29 | async validateListFiles(payload) { 30 | return await listFilesSchema.noUnknown().strict(true).validate(payload); 31 | }, 32 | 33 | async validateDeleteFiles(payload) { 34 | return await deleteFilesSchema.noUnknown().strict(true).validate(payload); 35 | }, 36 | }; 37 | }; 38 | -------------------------------------------------------------------------------- /registry/schemaValidators/storeFile.zod.ts: -------------------------------------------------------------------------------- 1 | import z from "zod"; 2 | import { StoreFileValidator } from "./storeFile.interface.js"; 3 | 4 | const fileNameSchema = z 5 | .string() 6 | .regex(/^[a-zA-Z0-9._-]{1,255}$/, "Invalid file name"); 7 | 8 | const getFileSchema = z.object({ 9 | userId: z.number(), 10 | fileName: fileNameSchema, 11 | }); 12 | 13 | const listFilesSchema = z.object({ 14 | userId: z.number(), 15 | pageToken: z.string().optional(), 16 | }); 17 | 18 | const deleteFilesSchema = z.object({ 19 | userId: z.number(), 20 | files: z.array(fileNameSchema), 21 | }); 22 | 23 | export const storeFileValidator = (): StoreFileValidator => { 24 | return { 25 | async validateGetFile(payload) { 26 | return await getFileSchema.parseAsync(payload); 27 | }, 28 | 29 | async validateListFiles(payload) { 30 | return await listFilesSchema.parseAsync(payload); 31 | }, 32 | 33 | async validateDeleteFiles(payload) { 34 | return await deleteFilesSchema.parseAsync(payload); 35 | }, 36 | }; 37 | }; 38 | -------------------------------------------------------------------------------- /registry/schemaValidators/subscription.interface.ts: -------------------------------------------------------------------------------- 1 | export type GetUserSubsSchema = { 2 | userId: number; 3 | }; 4 | 5 | export type CreateCheckoutSchema = { 6 | userId: number; 7 | userEmail: string; 8 | priceId: string; 9 | seats?: number; 10 | }; 11 | 12 | export type CreatePaymentLinkSchema = Omit; 13 | 14 | export type GetSubscriptionSchema = { 15 | userId: number; 16 | subscriptionId: string; 17 | }; 18 | 19 | export type UpdatePlanSchema = GetSubscriptionSchema & { 20 | newPriceId: string; 21 | }; 22 | 23 | export type UpdateSubscriptionSeatsSchema = GetSubscriptionSchema & { 24 | newSeats: number; 25 | }; 26 | 27 | export interface AddUserToSeatSchema extends GetSubscriptionSchema { 28 | addUserId: number; 29 | } 30 | export interface RemoveUserFromSeatSchema extends GetSubscriptionSchema { 31 | removeUserId: number; 32 | } 33 | 34 | export type CancelSubscriptionSchema = GetSubscriptionSchema; 35 | 36 | export interface SubscriptionValidator { 37 | validateGetUserSubs: ( 38 | payload: GetUserSubsSchema 39 | ) => Promise; 40 | 41 | validateCreateCheckout: ( 42 | payload: CreateCheckoutSchema 43 | ) => Promise; 44 | 45 | validateCreatePaymentLink: ( 46 | payload: CreatePaymentLinkSchema 47 | ) => Promise; 48 | 49 | validateUpdatePlanSub: ( 50 | payload: UpdatePlanSchema 51 | ) => Promise; 52 | 53 | validateUpdateSeats: ( 54 | payload: UpdateSubscriptionSeatsSchema 55 | ) => Promise; 56 | 57 | validateAddUserToSeat: ( 58 | payload: AddUserToSeatSchema 59 | ) => Promise; 60 | 61 | validateRemoveUserFromSeat: ( 62 | payload: RemoveUserFromSeatSchema 63 | ) => Promise; 64 | 65 | validateCancelSubscription: ( 66 | payload: CancelSubscriptionSchema 67 | ) => Promise; 68 | } 69 | -------------------------------------------------------------------------------- /registry/schemaValidators/subscription.yup.ts: -------------------------------------------------------------------------------- 1 | import yup from "yup"; 2 | import { SubscriptionValidator } from "./subscription.interface.js"; 3 | 4 | const getUserSubsSchema = yup.object({ 5 | userId: yup.number().required(), 6 | }); 7 | 8 | const createCheckoutSchema = getUserSubsSchema.shape({ 9 | userEmail: yup.string().email().required(), 10 | priceId: yup.string().required(), 11 | seats: yup.number().min(1).default(1), 12 | }); 13 | 14 | const createPaymentLinkSchema = createCheckoutSchema.omit(["userEmail"]) 15 | 16 | const updatePlanSchema = getUserSubsSchema.shape({ 17 | subscriptionId: yup.string().required(), 18 | newPriceId: yup.string().required(), 19 | }); 20 | 21 | const updateSeatsSchema = getUserSubsSchema.shape({ 22 | subscriptionId: yup.string().required(), 23 | newSeats: yup.number().min(0).required(), 24 | }); 25 | 26 | const addUserToSeatSchema = getUserSubsSchema.shape({ 27 | subscriptionId: yup.string().required(), 28 | addUserId: yup.number().required() 29 | }); 30 | 31 | const removeUserFromSeatSchema = getUserSubsSchema.shape({ 32 | subscriptionId: yup.string().required(), 33 | removeUserId: yup.number().required() 34 | }); 35 | 36 | const cancelSubscriptionSchema = getUserSubsSchema.shape({ 37 | subscriptionId: yup.string().required(), 38 | }); 39 | 40 | export const subscriptionValidator = (): SubscriptionValidator => { 41 | return { 42 | async validateGetUserSubs(payload) { 43 | return await getUserSubsSchema.noUnknown().strict(true).validate(payload); 44 | }, 45 | 46 | async validateCreateCheckout(payload) { 47 | return await createCheckoutSchema 48 | .noUnknown() 49 | .strict(true) 50 | .validate(payload); 51 | }, 52 | 53 | async validateCreatePaymentLink(payload) { 54 | return await createPaymentLinkSchema 55 | .noUnknown() 56 | .strict(true) 57 | .validate(payload); 58 | }, 59 | 60 | async validateUpdatePlanSub(payload) { 61 | return await updatePlanSchema.noUnknown().strict(true).validate(payload); 62 | }, 63 | 64 | async validateUpdateSeats(payload) { 65 | return await updateSeatsSchema.noUnknown().strict(true).validate(payload); 66 | }, 67 | 68 | async validateAddUserToSeat(payload) { 69 | return await addUserToSeatSchema 70 | .noUnknown() 71 | .strict(true) 72 | .validate(payload); 73 | }, 74 | 75 | async validateRemoveUserFromSeat(payload) { 76 | return await removeUserFromSeatSchema 77 | .noUnknown() 78 | .strict(true) 79 | .validate(payload); 80 | }, 81 | 82 | async validateCancelSubscription(payload) { 83 | return await cancelSubscriptionSchema 84 | .noUnknown() 85 | .strict(true) 86 | .validate(payload); 87 | }, 88 | }; 89 | }; 90 | -------------------------------------------------------------------------------- /registry/schemaValidators/subscription.zod.ts: -------------------------------------------------------------------------------- 1 | import z from "zod"; 2 | import { SubscriptionValidator } from "./subscription.interface.js"; 3 | 4 | const getUserSubsSchema = z.object({ 5 | userId: z.number(), 6 | }); 7 | 8 | const createCheckoutSchema = getUserSubsSchema.extend({ 9 | userEmail: z.string().email(), 10 | priceId: z.string(), 11 | seats: z.number().min(1).default(1).optional(), 12 | }); 13 | 14 | const createPaymentLinkSchema = createCheckoutSchema.omit({ userEmail: true }); 15 | 16 | const updatePlanSchema = getUserSubsSchema.extend({ 17 | subscriptionId: z.string(), 18 | newPriceId: z.string(), 19 | }); 20 | 21 | const updateSeatsSchema = getUserSubsSchema.extend({ 22 | subscriptionId: z.string(), 23 | newSeats: z.number().min(0), 24 | }); 25 | 26 | const addUserToSeatSchema = getUserSubsSchema.extend({ 27 | subscriptionId: z.string(), 28 | addUserId: z.number(), 29 | }); 30 | 31 | const removeUserFromSeatSchema = getUserSubsSchema.extend({ 32 | subscriptionId: z.string(), 33 | removeUserId: z.number(), 34 | }); 35 | 36 | const cancelSubscriptionSchema = getUserSubsSchema.extend({ 37 | subscriptionId: z.string(), 38 | }); 39 | 40 | export const subscriptionValidator = (): SubscriptionValidator => { 41 | return { 42 | async validateGetUserSubs(payload) { 43 | return await getUserSubsSchema.parseAsync(payload); 44 | }, 45 | 46 | async validateCreateCheckout(payload) { 47 | return await createCheckoutSchema.parseAsync(payload); 48 | }, 49 | 50 | async validateCreatePaymentLink(payload) { 51 | return await createPaymentLinkSchema.parseAsync(payload); 52 | }, 53 | 54 | async validateUpdatePlanSub(payload) { 55 | return await updatePlanSchema.parseAsync(payload); 56 | }, 57 | 58 | async validateUpdateSeats(payload) { 59 | return await updateSeatsSchema.parseAsync(payload); 60 | }, 61 | 62 | async validateAddUserToSeat(payload) { 63 | return await addUserToSeatSchema.parseAsync(payload); 64 | }, 65 | 66 | async validateRemoveUserFromSeat(payload) { 67 | return await removeUserFromSeatSchema.parseAsync(payload); 68 | }, 69 | 70 | async validateCancelSubscription(payload) { 71 | return await cancelSubscriptionSchema.parseAsync(payload); 72 | }, 73 | }; 74 | }; 75 | -------------------------------------------------------------------------------- /registry/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "../templates/node-ts/tsconfig.json", 4 | "compilerOptions": { 5 | "noEmit": true, 6 | "incremental": true, 7 | "isolatedModules": true, 8 | "rootDir": ".", 9 | "baseUrl": ".", 10 | "paths": { 11 | "@/*": ["./*"] 12 | } 13 | }, 14 | "include": ["./modules/**/*", "./schemaValidators/**/*", "./repositories/**/*"], 15 | "exclude": ["node_modules"] 16 | } 17 | -------------------------------------------------------------------------------- /registry/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { configDefaults, defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | clearMocks: true, 6 | reporters: "verbose", 7 | environment: "node", 8 | watch: true, 9 | coverage: { 10 | provider: "istanbul", 11 | reportsDirectory: "./coverage", 12 | reporter: ["text", "json", "html"], 13 | exclude: [ 14 | ...configDefaults.coverage.exclude || [], 15 | "repositories/*", 16 | "schemaValidators/*", 17 | "**/router/*", 18 | "**/middleware/*", 19 | "**/errors/*" 20 | ], 21 | }, 22 | env: { 23 | JWT_SECRET_KEY: "testSecretKey", 24 | JWT_ISSUER: "api.library.tests", 25 | S3_BUCKET_NAME: "mockBucketName", 26 | S3_BUCKET_REGION: "us-east-1", 27 | POSTMARK_SERVER_TOKEN: "xxxx-xxxxx-xxxx-xxxxx-xxxxxx" 28 | }, 29 | alias: { 30 | "@/": new URL("./", import.meta.url).pathname, 31 | }, 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /templates/node-ts/.dockerignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules/ 3 | 4 | # Build directories 5 | dist/ 6 | build/ 7 | 8 | # System files 9 | .DS_Store # macOS system files 10 | Thumbs.db # Windows system files 11 | *.swp # Vim swap files 12 | 13 | # Other 14 | README.md -------------------------------------------------------------------------------- /templates/node-ts/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # PostgreSQL DB Local files 120 | pgdata/ 121 | 122 | # TernJS port file 123 | .tern-port 124 | 125 | # Stores VSCode versions used for testing VSCode extensions 126 | .vscode-test 127 | 128 | # yarn v2 129 | .yarn/cache 130 | .yarn/unplugged 131 | .yarn/build-state.yml 132 | .yarn/install-state.gz 133 | .pnp.* 134 | -------------------------------------------------------------------------------- /templates/node-ts/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Base stage for both dev and prod 2 | FROM node:20.17.0-alpine3.20 AS base 3 | WORKDIR /app 4 | COPY package.json pnpm-lock.yaml ./ 5 | RUN npm install -g pnpm 6 | RUN pnpm install 7 | 8 | # Stage 2: Development stage 9 | FROM base AS development 10 | COPY . . 11 | EXPOSE 3000 12 | CMD ["pnpm", "run", "dev:docker"] 13 | 14 | # Stage 3: Build stage (for production) 15 | FROM base AS build 16 | COPY . . 17 | RUN pnpm run build:prod 18 | 19 | # Stage 4: Production stage (only the build output) 20 | FROM node:20.17.0-alpine3.20 AS production 21 | WORKDIR /app 22 | COPY --from=build /app/dist ./dist 23 | COPY --from=build /app/package.json /app/pnpm-lock.yaml ./ 24 | RUN npm install -g pnpm 25 | RUN pnpm install --production 26 | EXPOSE 3000 27 | CMD ["pnpm", "run", "prod:serve"] -------------------------------------------------------------------------------- /templates/node-ts/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vratix-dev/vratix-expressjs-api-modules/80ed464887fe6f1d94f0e1fce5d232377a9a7e1b/templates/node-ts/README.md -------------------------------------------------------------------------------- /templates/node-ts/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | backend: 3 | container_name: backend 4 | restart: always 5 | build: 6 | context: . 7 | target: ${NODE_ENV:-development} # Default to development target 8 | volumes: 9 | - ${VOLUME_MOUNT:-.}:/app # Default to mounting the current directory; PROD CONFIG: We don't want project folder in prod container, set "VOLUME_MOUNT=" to avoid mounting the folder 10 | - /app/node_modules 11 | ports: 12 | - "3000:3000" 13 | networks: 14 | - backend-network 15 | 16 | networks: 17 | backend-network: 18 | -------------------------------------------------------------------------------- /templates/node-ts/nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | # Production Stage 2 | FROM nginx:alpine AS production 3 | 4 | COPY nginx.conf /etc/nginx/nginx.conf 5 | -------------------------------------------------------------------------------- /templates/node-ts/nginx/SSL_INSTALATION.md: -------------------------------------------------------------------------------- 1 | # (Optional) SSL Setup for NGINX Proxy 2 | 3 | > This guide explains how to install SSL certificates for NGINX using Let's Encrypt. SSL certificates are generated on the host and then mounted into the NGINX Docker container. 4 | 5 | ### 1. Install Certbot and Generate SSL Certificates 6 | 7 | 1. **Install Certbot** (Let's Encrypt client) on your host if it’s not already installed: 8 | 9 | ```bash 10 | sudo apt install certbot 11 | ``` 12 | 13 | 2. **Generate SSL certificates** for your domain. Replace `yourdomain.com` with your actual domain name: 14 | 15 | ```bash 16 | sudo certbot certonly --standalone -d yourdomain.com -d www.yourdomain.com 17 | ``` 18 | 19 | Certificates will be stored in `/etc/letsencrypt/live/yourdomain.com/`. 20 | 21 | ### 2. Update `nginx.conf` for SSL 22 | 23 | Modify your `nginx.conf` to enable SSL: 24 | 25 | 1. **Add an HTTPS server block** in `nginx.conf`, configured as follows: 26 | ```nginx 27 | server { 28 | listen 443 ssl; 29 | server_name yourdomain.com; 30 | 31 | ssl_certificate /etc/ssl/certs/fullchain.pem; 32 | ssl_certificate_key /etc/ssl/certs/privkey.pem; 33 | 34 | ssl_protocols TLSv1.2 TLSv1.3; 35 | ssl_prefer_server_ciphers on; 36 | 37 | location / { 38 | proxy_pass http://backend:3000; 39 | proxy_set_header Host $host; 40 | proxy_set_header X-Real-IP $remote_addr; 41 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 42 | proxy_set_header X-Forwarded-Proto $scheme; 43 | } 44 | } 45 | ``` 46 | 2. **Add an HTTP to HTTPS redirect block** (optional): 47 | ```nginx 48 | server { 49 | listen 80; 50 | server_name yourdomain.com; 51 | 52 | return 301 https://$host$request_uri; 53 | } 54 | ``` 55 | 56 | ### 3. Mount Certificates in Docker 57 | 58 | Ensure SSL certificates are available to the NGINX container by mounting them in `docker-compose.yml`. 59 | Replace `yourdomain.com` with your domain name: 60 | 61 | ```yaml 62 | services: 63 | nginx: 64 | volumes: 65 | - ./etc/letsencrypt/live/yourdomain.com:/etc/ssl/certs:ro 66 | ``` 67 | 68 | ### 4. Automate SSL Renewal 69 | 70 | 1. **Open your crontab** to add an automated renewal job: 71 | 72 | ```bash 73 | sudo crontab -e 74 | ``` 75 | 76 | 2. **Add the renewal command** to run daily at 3 AM (or adjust the schedule as desired): 77 | 78 | ```bash 79 | 0 3 * * * certbot renew --quiet && docker compose -f /path-to-your-project/docker-compose.yml restart nginx 80 | ``` 81 | 82 | This setup will check for certificate renewal daily and restart NGINX if renewal occurs. 83 | -------------------------------------------------------------------------------- /templates/node-ts/nginx/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | nginx: 3 | container_name: ingress-proxy 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | target: ${NODE_ENV} 8 | ports: 9 | - "80:80" 10 | volumes: 11 | - ./nginx.conf:/etc/nginx/nginx.conf 12 | depends_on: 13 | - backend 14 | networks: 15 | - ingress-network 16 | 17 | networks: 18 | ingress-network: 19 | -------------------------------------------------------------------------------- /templates/node-ts/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | worker_processes auto; 3 | 4 | events { 5 | worker_connections 1024; 6 | } 7 | 8 | http { 9 | tcp_nopush on; # send headers in one piece - better than sending them one by one 10 | tcp_nodelay on; # don't buffer data sent - good for small data bursts in real time 11 | keepalive_timeout 75; # server will close connection after this time 12 | types_hash_max_size 2048; 13 | 14 | include /etc/nginx/mime.types; 15 | default_type application/octet-stream; 16 | 17 | # Logging settings 18 | access_log /var/log/nginx/access.log; 19 | error_log /var/log/nginx/error.log; 20 | 21 | # Gzip compression to improve performance 22 | gzip on; 23 | gzip_types text/plain text/xml text/css application/xml application/json application/javascript image/png image/jpeg image/svg+xml; 24 | 25 | server { 26 | listen 80; # Port where NGINX will listen for HTTP requests 27 | server_name _; # Catch-all server name 28 | 29 | location /api { 30 | # Proxy requests to the Node.js app running on port 3000 in the container 31 | proxy_pass http://backend:3000/api; 32 | 33 | proxy_set_header Host $host; 34 | proxy_set_header X-Real-IP $remote_addr; 35 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 36 | proxy_set_header X-Forwarded-Proto $scheme; 37 | 38 | proxy_redirect off; 39 | } 40 | 41 | # Handle static assets if needed (optional) 42 | location /static/ { 43 | alias /usr/share/nginx/html/static/; 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /templates/node-ts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-template", 3 | "license": "MIT", 4 | "version": "1.0.0", 5 | "private": true, 6 | "main": "src/server.ts", 7 | "type": "module", 8 | "scripts": { 9 | "test": "vitest", 10 | "coverage": "vitest run --coverage", 11 | "dev:local": "tsup --watch --onSuccess 'pnpm run local:serve'", 12 | "dev:docker": "tsup --watch --onSuccess 'pnpm run docker:serve'", 13 | "local:serve": "pkill -f 'node dist/server.js'; node dist/server.js &", 14 | "docker:serve": "if [ -f /tmp/server.pid ]; then kill -9 $(cat /tmp/server.pid); fi; node dist/server.js & echo $! > /tmp/server.pid", 15 | "clean": "rimraf ./dist", 16 | "prebuild:prod": "pnpm run clean", 17 | "build:prod": "NODE_ENV=production tsup", 18 | "prod:serve": "node dist/server.js" 19 | }, 20 | "dependencies": { 21 | "cors": "^2.8.5", 22 | "dotenv": "^16.4.5", 23 | "express": "^4.21.0", 24 | "helmet": "^8.0.0" 25 | }, 26 | "devDependencies": { 27 | "@types/cors": "^2.8.17", 28 | "@types/express": "^5.0.0", 29 | "@types/node": "^22.7.4", 30 | "@vitest/coverage-istanbul": "^3.0.8", 31 | "ts-node": "^10.9.2", 32 | "tsup": "^8.3.0", 33 | "typescript": "^5.6.2", 34 | "vitest": "^3.0.8" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /templates/node-ts/src/middleware/errors.ts: -------------------------------------------------------------------------------- 1 | import { response } from "@/utils/response.js"; 2 | import { notImplementedError } from "@/utils/errors/common.js"; 3 | import { HttpError } from "@/utils/errors/HttpError.js"; 4 | import { NextFunction, Request, RequestHandler, Response } from "express"; 5 | 6 | // Global Error Handler Middleware 7 | export const globalErrorHandler = ( 8 | err: HttpError | Error, 9 | _req: Request, 10 | res: Response, 11 | _next: NextFunction 12 | ) => { 13 | if (process.env.NODE_ENV !== "production") { 14 | console.error(`Timestamp: ${new Date().toISOString()}`); 15 | console.error("Error:", err); 16 | } 17 | 18 | const responseStatus = err instanceof HttpError ? err.status : 500; 19 | res.status(responseStatus).send(response(undefined, err)); 20 | }; 21 | 22 | // Middleware for handling requests that don't match any available router 23 | export const endpointNotImplemented: RequestHandler = (_req, _res, next) => { 24 | next(notImplementedError()); 25 | }; 26 | -------------------------------------------------------------------------------- /templates/node-ts/src/server.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import dotenv from "dotenv"; 3 | import helmet from "helmet"; 4 | import cors from "cors"; 5 | 6 | import { 7 | endpointNotImplemented, 8 | globalErrorHandler, 9 | } from "@/middleware/errors.js"; 10 | 11 | dotenv.config(); 12 | 13 | const app = express(); 14 | const PORT = process.env.PORT || 3000; 15 | 16 | declare module "http" { 17 | interface IncomingMessage { 18 | rawBody: string; 19 | } 20 | } 21 | 22 | app.use(cors()); 23 | app.use( 24 | express.json({ 25 | verify: (req, _, buf) => { 26 | // Provide access to the request raw body 27 | req.rawBody = buf.toString(); 28 | }, 29 | }) 30 | ); 31 | app.use(express.urlencoded({ extended: false })); 32 | 33 | /*------------- Security Config -------------*/ 34 | app.use(helmet()); 35 | 36 | /*------------- Endpoints -------------*/ 37 | 38 | /** 39 | * Example endpoint definition: 40 | * 41 | * app.use("/api/user", userRouter); 42 | */ 43 | 44 | /*------------- Error middleware -------------*/ 45 | app.use(endpointNotImplemented); 46 | app.use(globalErrorHandler); 47 | 48 | app.listen(PORT, () => console.log(`Service listening on port ${PORT}...`)); 49 | -------------------------------------------------------------------------------- /templates/node-ts/src/utils/errors/HttpError.ts: -------------------------------------------------------------------------------- 1 | export class HttpError extends Error { 2 | status: number; 3 | name: string; 4 | 5 | constructor(name: string, message: string, status: number) { 6 | super(message); 7 | this.name = name; 8 | this.status = status; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /templates/node-ts/src/utils/errors/common.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from "@/utils/errors/HttpError.js"; 2 | 3 | export const notImplementedError = () => { 4 | return new HttpError("NotImplementedError", "Not Implemented", 501); 5 | }; 6 | 7 | export const badRequestError = () => { 8 | return new HttpError("BadRequestError", "Bad Request", 400); 9 | }; 10 | 11 | export const unauthorizedError = () => { 12 | return new HttpError("UnauthorizedError", "Unauthorized", 401); 13 | }; 14 | 15 | export const forbiddenError = () => { 16 | return new HttpError("ForbiddenError", "Forbidden", 403); 17 | }; 18 | 19 | export const notFoundError = () => { 20 | return new HttpError("NotFoundError", "Resource Not Found", 404); 21 | }; 22 | 23 | export const internalServerError = () => { 24 | return new HttpError("InternalServerError", "Internal Server Error", 500); 25 | }; 26 | 27 | export const tooManyRequestsError = () => { 28 | return new HttpError("TooManyRequestsError", "Too Many Requests", 429); 29 | }; 30 | 31 | export const requestTimeoutError = () => { 32 | return new HttpError("RequestTimeoutError", "Request Timeout", 408); 33 | }; 34 | -------------------------------------------------------------------------------- /templates/node-ts/src/utils/response.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from "./errors/HttpError.js"; 2 | 3 | /** 4 | * Standardized response format 5 | * @param {T} data optional parameters to return. 6 | * @param {HttpError | Error} error the success or error message. 7 | */ 8 | export const response = (data?: T, error?: HttpError | Error) => { 9 | if (error !== undefined) { 10 | const errMessage = error.name === "ZodError" ? JSON.parse(error.message) : error.message; 11 | 12 | return { error: { name: error?.name, message: errMessage } }; 13 | } 14 | return { data }; 15 | }; 16 | -------------------------------------------------------------------------------- /templates/node-ts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | /* Visit https://aka.ms/tsconfig to read more about this file */ 5 | /* Language and Environment */ 6 | "target": "ES2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 7 | "lib": ["ES2022"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 8 | 9 | /* Modules */ 10 | "module": "NodeNext", /* Specify what module code is generated. */ 11 | "moduleResolution": "NodeNext", /* Specify how TypeScript looks up a file from a given module specifier. */ 12 | "rootDir": "./src", /* Specify the root folder within your source files. */ 13 | "baseUrl": ".", /* Specify the base directory to resolve non-relative module names. */ 14 | "paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */ 15 | "@/*": ["./src/*"] 16 | }, 17 | "resolveJsonModule": true, /* Enable importing .json files. */ 18 | "jsx": "preserve", 19 | 20 | /* JavaScript Support */ 21 | "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 22 | "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 23 | 24 | /* Emit */ 25 | "outDir": "dist", /* Specify an output folder for all emitted files. */ 26 | "removeComments": true, /* Disable emitting comments. */ 27 | 28 | /* Interop Constraints */ 29 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 30 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 31 | 32 | /* Type Checking */ 33 | "strict": true, /* Enable all strict type-checking options. */ 34 | "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 35 | "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 36 | 37 | /* Completeness */ 38 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 39 | }, 40 | "include": ["src/**/*"], 41 | "exclude": ["node_modules", "dist"] 42 | } 43 | -------------------------------------------------------------------------------- /templates/node-ts/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | import dotenv from "dotenv"; 3 | 4 | export default defineConfig({ 5 | clean: true, 6 | format: ["esm"], 7 | entry: ["src/server.ts"], 8 | minify: true, 9 | target: "es2022", 10 | outDir: "dist", 11 | splitting: false, 12 | sourcemap: true, 13 | env: dotenv.config().parsed, 14 | }); 15 | -------------------------------------------------------------------------------- /templates/node-ts/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { configDefaults, defineConfig } from "vitest/config"; 2 | import path from "path"; 3 | 4 | export default defineConfig({ 5 | test: { 6 | clearMocks: true, 7 | reporters: "verbose", 8 | environment: "node", 9 | watch: true, 10 | coverage: { 11 | provider: "istanbul", 12 | reportsDirectory: "./coverage", 13 | reporter: ["text", "json", "html"], 14 | exclude: [ 15 | ...configDefaults.coverage.exclude || [], 16 | "**/repositories/*", 17 | "**/schemas/*", 18 | "**/routes/*", 19 | "**/middleware/*", 20 | "**/errors/*" 21 | ], 22 | }, 23 | env: {}, 24 | alias: [ 25 | { 26 | find: new RegExp('^@/(.*)$'), 27 | replacement: path.resolve(__dirname, './src/$1'), 28 | } 29 | ], 30 | }, 31 | }); 32 | --------------------------------------------------------------------------------