├── libs └── release-it │ ├── .npmignore │ ├── .prettierrc │ ├── package.json │ ├── NOTICE │ ├── CHANGELOG.md │ ├── .release-it.js │ └── LICENSE ├── packages ├── core │ ├── README.md │ ├── .npmignore │ ├── .prettierignore │ ├── .prettierrc │ ├── tsconfig.build.json │ ├── .lintstagedrc │ ├── src │ │ ├── app.module.ts │ │ ├── app.controller.ts │ │ └── main.ts │ ├── nest-cli.json │ ├── NOTICE │ ├── tsconfig.json │ ├── .eslintrc.js │ ├── test │ │ └── app.e2e.test.ts │ ├── package.json │ └── LICENSE └── api-gateway │ ├── .npmignore │ ├── README.md │ ├── .release-it.js │ ├── .taprc │ ├── .prettierignore │ ├── .prettierrc │ ├── .lintstagedrc │ ├── src │ ├── auth │ │ ├── dto │ │ │ ├── verify-otp.dto.ts │ │ │ ├── forgot-password.dto.ts │ │ │ ├── verify-2fa.dto.ts │ │ │ ├── confirm-email.dto.ts │ │ │ ├── login.dto.ts │ │ │ ├── reset-password.dto.ts │ │ │ └── register.dto.ts │ │ ├── guards │ │ │ ├── jwt.guard.ts │ │ │ ├── jwt-2fa.guard.ts │ │ │ ├── jwt-refresh.guard.ts │ │ │ └── api-key.guard.ts │ │ ├── interfaces │ │ │ └── index.ts │ │ ├── entities │ │ │ └── session.entity.ts │ │ ├── strategies │ │ │ ├── jwt-2fa.strategy.ts │ │ │ ├── jwt-strategy.ts │ │ │ └── jwt-refresh.strategy.ts │ │ ├── session.service.ts │ │ ├── auth.module.ts │ │ ├── transactions │ │ │ └── reset-password.transaction.ts │ │ ├── token.service.ts │ │ └── auth.controller.ts │ ├── api-keys │ │ ├── dto │ │ │ ├── update-api-key.dto.ts │ │ │ └── create-api-key.dto.ts │ │ ├── api-keys.module.ts │ │ ├── api-keys.controller.ts │ │ ├── entities │ │ │ └── api-key.entity.ts │ │ └── api-keys.service.ts │ ├── common │ │ ├── modules │ │ │ ├── mailer │ │ │ │ ├── templates │ │ │ │ │ ├── email-confirmation.hbs │ │ │ │ │ └── email-reset-password.hbs │ │ │ │ ├── mailer.module.ts │ │ │ │ └── mailer.service.ts │ │ │ └── cipher │ │ │ │ ├── cipher.module.ts │ │ │ │ └── cipher.service.ts │ │ ├── constants.ts │ │ ├── decorators │ │ │ ├── current-user.decorator.ts │ │ │ └── is-not-empty-string.decorator.ts │ │ ├── pipes │ │ │ ├── sanitize-trim.pipe.ts │ │ │ └── abstract-validation.pipe.ts │ │ ├── dto │ │ │ └── index.ts │ │ └── utils.ts │ ├── users │ │ ├── users.module.ts │ │ ├── entities │ │ │ └── user.entity.ts │ │ └── users.service.ts │ ├── activities │ │ ├── activities.module.ts │ │ ├── entities │ │ │ └── activity.entity.ts │ │ └── activities.service.ts │ ├── recovery-tokens │ │ ├── recovery-tokens.module.ts │ │ ├── entities │ │ │ └── recovery-token.entity.ts │ │ └── recovery-tokens.service.ts │ ├── base │ │ ├── base.entity.ts │ │ ├── interfaces │ │ │ ├── base-controller.interface.ts │ │ │ └── base-service.interface.ts │ │ ├── base.transaction.ts │ │ ├── base.service.ts │ │ └── base.controller.ts │ ├── events │ │ └── index.ts │ ├── app.roles.ts │ ├── database │ │ └── database.module.ts │ ├── profiles │ │ └── entities │ │ │ └── profile.entity.ts │ ├── app.controller.ts │ ├── main.ts │ ├── config.ts │ └── app.module.ts │ ├── tsconfig.build.json │ ├── nest-cli.json │ ├── test │ ├── common │ │ └── utils.test.ts │ ├── before-all-tests.js │ ├── config.test.ts │ ├── helper.ts │ ├── http-client.ts │ ├── app.e2e.test.ts │ ├── api-keys │ │ └── api-keys.e2e.test.ts │ └── base │ │ └── base-service.test.ts │ ├── NOTICE │ ├── tsconfig.json │ ├── scripts │ └── start-db.sh │ ├── .eslintrc.js │ ├── typings │ └── common │ │ └── index.d.ts │ ├── .env.template │ ├── package.json │ ├── CHANGELOG.md │ └── LICENSE ├── pnpm-workspace.yaml ├── .husky └── pre-commit ├── tsconfig.json ├── .npmrc ├── docker-compose.yml ├── tsconfig-base.json ├── turbo.json ├── codecov.yml ├── renovate.json ├── NOTICE ├── scripts ├── get-package-from-tag.js └── copy-license.sh ├── .gitignore ├── package.json ├── .github └── workflows │ ├── publish-releases.yml │ └── ci.yml ├── README.md └── LICENSE /libs/release-it/.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !NOTICE -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 | ## @bitify/core 2 | -------------------------------------------------------------------------------- /packages/core/.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !dist/** 3 | !NOTICE -------------------------------------------------------------------------------- /packages/api-gateway/.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !dist/** 3 | !NOTICE -------------------------------------------------------------------------------- /packages/api-gateway/README.md: -------------------------------------------------------------------------------- 1 | ## @bitify/api-gateway 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "libs/**" 3 | - "packages/**" 4 | -------------------------------------------------------------------------------- /packages/api-gateway/.release-it.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@bitify/release-it'); 2 | -------------------------------------------------------------------------------- /packages/api-gateway/.taprc: -------------------------------------------------------------------------------- 1 | timeout: 300 2 | coverage: false 3 | check-coverage: false -------------------------------------------------------------------------------- /packages/core/.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts 2 | .nyc_output 3 | coverage 4 | dist -------------------------------------------------------------------------------- /packages/api-gateway/.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts 2 | .nyc_output 3 | coverage 4 | dist -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "include": ["**/*", ".*.js"] 4 | } -------------------------------------------------------------------------------- /libs/release-it/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "endOfLine": "auto" 5 | } 6 | -------------------------------------------------------------------------------- /packages/core/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "endOfLine": "auto" 5 | } 6 | -------------------------------------------------------------------------------- /packages/api-gateway/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "endOfLine": "auto" 5 | } 6 | -------------------------------------------------------------------------------- /packages/core/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/core/.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.{js,ts}": ["prettier --list-different", "eslint"], 3 | "*.md": "prettier --list-different" 4 | } 5 | -------------------------------------------------------------------------------- /packages/api-gateway/.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.{js,ts}": ["prettier --list-different", "eslint"], 3 | "*.md": "prettier --list-different" 4 | } 5 | -------------------------------------------------------------------------------- /packages/api-gateway/src/auth/dto/verify-otp.dto.ts: -------------------------------------------------------------------------------- 1 | import { OTPDto } from '../../common/dto'; 2 | 3 | export class VerifyOTPDto extends OTPDto {} 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | package-lock=true 3 | auto-install-peers=true 4 | strict-peer-dependencies=false 5 | child-concurrency=1 6 | resolution-mode=highest -------------------------------------------------------------------------------- /packages/api-gateway/src/auth/dto/forgot-password.dto.ts: -------------------------------------------------------------------------------- 1 | import { EmailDto } from '../../common/dto'; 2 | 3 | export class ForgotPasswordDto extends EmailDto {} 4 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | services: 3 | postgresql: 4 | ports: 5 | - "127.0.0.1:5432:5432" 6 | image: "postgres:16-alpine" 7 | environment: 8 | - POSTGRES_PASSWORD=postgres 9 | -------------------------------------------------------------------------------- /packages/api-gateway/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "coverage"], 4 | "compilerOptions": { 5 | "removeComments": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/core/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | 4 | @Module({ 5 | controllers: [AppController], 6 | }) 7 | export class AppModule {} 8 | -------------------------------------------------------------------------------- /packages/api-gateway/src/auth/guards/jwt.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class JwtGuard extends AuthGuard('jwt') {} 6 | -------------------------------------------------------------------------------- /packages/core/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/api-gateway/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/api-gateway/src/auth/guards/jwt-2fa.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class Jwt2FAGuard extends AuthGuard('jwt-2fa') {} 6 | -------------------------------------------------------------------------------- /packages/api-gateway/src/auth/guards/jwt-refresh.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class JwtRefreshGuard extends AuthGuard('jwt-refresh') {} 6 | -------------------------------------------------------------------------------- /packages/core/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | 3 | @Controller() 4 | export class AppController { 5 | @Get('ping') 6 | ping(): Record { 7 | return {}; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig-base.json: -------------------------------------------------------------------------------- 1 | { 2 | // when ts-node release this https://github.com/TypeStrong/ts-node/issues/2000 3 | // change with ["@tsconfig/strictest/tsconfig.json", "@tsconfig/node-lts/tsconfig.json"] 4 | "extends": "@tsconfig/strictest/tsconfig.json", 5 | } -------------------------------------------------------------------------------- /packages/api-gateway/src/api-keys/dto/update-api-key.dto.ts: -------------------------------------------------------------------------------- 1 | import { PickType } from '@nestjs/swagger'; 2 | import { CreateApiKeyDto } from './create-api-key.dto'; 3 | 4 | export class UpdateApiKeyDto extends PickType(CreateApiKeyDto, [ 5 | 'spot', 6 | 'userIps', 7 | 'wallet', 8 | ] as const) {} 9 | -------------------------------------------------------------------------------- /packages/api-gateway/src/auth/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export interface I2FAResponse { 2 | twoFactorToken: string; 3 | } 4 | 5 | export interface ILoginResponse { 6 | accessToken: string; 7 | refreshToken: string; 8 | } 9 | 10 | export interface IEnable2FAResponse { 11 | secret: string; 12 | qrcode: string; 13 | } 14 | -------------------------------------------------------------------------------- /packages/api-gateway/src/common/modules/mailer/templates/email-confirmation.hbs: -------------------------------------------------------------------------------- 1 |

Welcome to {{appName}}!

2 |

Your email authentication code is {{code}} - Valid for 8 minutes.

3 |

For account security purposes, please do not share this authentication code 4 | with anyone.

5 |

Regards,

6 |

{{appName}} Team

-------------------------------------------------------------------------------- /packages/api-gateway/src/common/constants.ts: -------------------------------------------------------------------------------- 1 | export enum Collections { 2 | ACTIVITIES = 'activities', 3 | APIKEYS = 'apikeys', 4 | RECOVERY_TOKENS = 'recovery_tokens', 5 | PROFILES = 'profiles', 6 | USERS = 'users', 7 | } 8 | 9 | export enum UserState { 10 | ACTIVE = 1, 11 | PENDING = 0, 12 | BANNED = -1, 13 | } 14 | -------------------------------------------------------------------------------- /packages/api-gateway/src/auth/entities/session.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | 3 | @Entity() 4 | export class Session { 5 | @PrimaryGeneratedColumn('uuid') 6 | id!: string; 7 | 8 | @Column({ type: 'uuid' }) 9 | userId!: string; 10 | 11 | @Column() 12 | expires!: Date; 13 | } 14 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.org/schema.json", 3 | "pipeline": { 4 | "start": { 5 | "cache": false 6 | }, 7 | "release": { 8 | "dependsOn": ["^release"], 9 | "outputMode": "new-only" 10 | }, 11 | "format": {}, 12 | "format:fix": {}, 13 | "lint": {}, 14 | "lint:fix": {} 15 | } 16 | } -------------------------------------------------------------------------------- /packages/api-gateway/src/common/modules/cipher/cipher.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { CipherService } from './cipher.service'; 4 | 5 | @Module({ 6 | imports: [ConfigModule], 7 | providers: [CipherService], 8 | exports: [CipherService], 9 | }) 10 | export class CipherModule {} 11 | -------------------------------------------------------------------------------- /packages/api-gateway/test/common/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'tap'; 2 | import { trim } from '../../src/common/utils'; 3 | 4 | test('trim', ({ equal, same, end }) => { 5 | equal(trim(' foobar '), 'foobar'); 6 | same(trim([' foobar ']), ['foobar']); 7 | same(trim({ foo: ' bar ' }), { foo: 'bar' }); 8 | same(trim({ foo: ' bar ' }, 'foo'), { foo: ' bar ' }); 9 | end(); 10 | }); 11 | -------------------------------------------------------------------------------- /packages/api-gateway/src/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { UsersService } from './users.service'; 4 | import { User } from './entities/user.entity'; 5 | 6 | @Module({ 7 | imports: [TypeOrmModule.forFeature([User])], 8 | providers: [UsersService], 9 | exports: [UsersService], 10 | }) 11 | export class UsersModule {} 12 | -------------------------------------------------------------------------------- /packages/core/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { 4 | FastifyAdapter, 5 | NestFastifyApplication, 6 | } from '@nestjs/platform-fastify'; 7 | 8 | async function bootstrap() { 9 | const app = await NestFactory.create( 10 | AppModule, 11 | new FastifyAdapter(), 12 | ); 13 | await app.listen(3000); 14 | } 15 | bootstrap(); 16 | -------------------------------------------------------------------------------- /packages/api-gateway/src/auth/dto/verify-2fa.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNotEmptyString } from '../../common/decorators/is-not-empty-string.decorator'; 3 | import { OTPDto } from '../../common/dto'; 4 | 5 | export class Verify2FADto extends OTPDto { 6 | @ApiProperty({ 7 | description: 'OTP secret provided after enabling 2FA', 8 | }) 9 | @IsNotEmptyString() 10 | readonly secret!: string; 11 | } 12 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: true 3 | coverage: 4 | precision: 2 5 | round: down 6 | range: "60...80" 7 | status: 8 | project: true 9 | patch: true 10 | changes: false 11 | parsers: 12 | gcov: 13 | branch_detection: 14 | conditional: true 15 | loop: true 16 | method: false 17 | macro: false 18 | comment: 19 | layout: "reach,diff,flags,tree" 20 | behavior: default 21 | require_changes: false 22 | -------------------------------------------------------------------------------- /packages/api-gateway/src/activities/activities.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ActivitiesService } from './activities.service'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { Activity } from './entities/activity.entity'; 5 | 6 | @Module({ 7 | imports: [TypeOrmModule.forFeature([Activity])], 8 | providers: [ActivitiesService], 9 | exports: [ActivitiesService], 10 | }) 11 | export class ActivitiesModule {} 12 | -------------------------------------------------------------------------------- /packages/api-gateway/src/common/decorators/current-user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | import { User } from '../../users/entities/user.entity'; 3 | 4 | export const CurrentUser = createParamDecorator( 5 | (prop: string, ctx: ExecutionContext): User => { 6 | const request = ctx.switchToHttp().getRequest(); 7 | const { user } = request; 8 | return prop ? user?.[prop] : user; 9 | }, 10 | ); 11 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:base"], 4 | "rangeStrategy": "update-lockfile", 5 | "ignoreDeps": ["pnpm"], 6 | "prHourlyLimit": 10, 7 | "packageRules": [ 8 | { 9 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 10 | "automerge": true 11 | } 12 | ], 13 | "lockFileMaintenance": { 14 | "enabled": true, 15 | "automerge": true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/api-gateway/src/common/pipes/sanitize-trim.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, PipeTransform, ArgumentMetadata } from '@nestjs/common'; 2 | import { trim } from '../utils'; 3 | 4 | @Injectable() 5 | export class SanitizeTrimPipe implements PipeTransform { 6 | transform(values: any, metadata: ArgumentMetadata) { 7 | const { type } = metadata; 8 | if (type === 'body') { 9 | return trim(values, 'password'); 10 | } 11 | return values; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/api-gateway/src/auth/dto/confirm-email.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNotEmptyString } from '../../common/decorators/is-not-empty-string.decorator'; 3 | import { EmailDto } from '../../common/dto'; 4 | 5 | export class ResendConfirmEmailDto extends EmailDto {} 6 | 7 | export class ConfirmEmailDto extends ResendConfirmEmailDto { 8 | @ApiProperty({ description: 'User code', example: '123456' }) 9 | @IsNotEmptyString() 10 | readonly code!: string; 11 | } 12 | -------------------------------------------------------------------------------- /packages/api-gateway/src/common/modules/mailer/templates/email-reset-password.hbs: -------------------------------------------------------------------------------- 1 |

Forgot you {{appName}} password?

2 |

Looks like you'd like to reset your password for the 3 | {{appName}} 4 | account. Don't worry, you can make a new one by clicking the link below - 5 | Valid for 8 minutes.

6 |

{{url}}

7 |

If you didn't ask us to reset your password, just ignore this email. Your 8 | account is safe with us.

9 |

Regards,

10 |

{{appName}} Team

-------------------------------------------------------------------------------- /packages/api-gateway/src/recovery-tokens/recovery-tokens.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { RecoveryTokensService } from './recovery-tokens.service'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { RecoveryToken } from './entities/recovery-token.entity'; 5 | 6 | @Module({ 7 | imports: [TypeOrmModule.forFeature([RecoveryToken])], 8 | providers: [RecoveryTokensService], 9 | exports: [RecoveryTokensService], 10 | }) 11 | export class RecoveryTokensModule {} 12 | -------------------------------------------------------------------------------- /libs/release-it/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bitify/release-it", 3 | "version": "0.0.3", 4 | "license": "Apache-2.0", 5 | "main": ".release-it.js", 6 | "publishConfig": { 7 | "access": "public" 8 | }, 9 | "scripts": { 10 | "format": "prettier --list-different .", 11 | "format:fix": "prettier --write .", 12 | "release": "release-it --ci" 13 | }, 14 | "devDependencies": { 15 | "@release-it/conventional-changelog": "^7.0.2", 16 | "prettier": "^3.0.2", 17 | "release-it": "^16.1.5" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/api-gateway/src/recovery-tokens/entities/recovery-token.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, Index } from 'typeorm'; 2 | import { BaseEntity } from '../../base/base.entity'; 3 | import { Collections } from '../../common/constants'; 4 | 5 | @Entity({ name: Collections.RECOVERY_TOKENS }) 6 | @Index(`index_${Collections.RECOVERY_TOKENS}_on_userId`, ['userId']) 7 | export class RecoveryToken extends BaseEntity { 8 | @Column({ type: 'uuid' }) 9 | userId!: string; 10 | 11 | @Column() 12 | token!: string; 13 | 14 | @Column({ type: 'timestamptz' }) 15 | expiresAt!: Date; 16 | } 17 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Andrea Fassina 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /packages/api-gateway/src/base/base.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CreateDateColumn, 3 | DeleteDateColumn, 4 | PrimaryGeneratedColumn, 5 | UpdateDateColumn, 6 | } from 'typeorm'; 7 | 8 | export abstract class BaseEntity { 9 | @PrimaryGeneratedColumn('uuid') 10 | id!: string; 11 | 12 | @CreateDateColumn({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) 13 | createdAt!: Date; 14 | 15 | @UpdateDateColumn({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) 16 | updatedAt!: Date; 17 | 18 | @DeleteDateColumn({ type: 'timestamptz', nullable: true }) 19 | deletedAt!: Date | null; 20 | } 21 | -------------------------------------------------------------------------------- /libs/release-it/NOTICE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Andrea Fassina 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /packages/core/NOTICE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Andrea Fassina 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /packages/api-gateway/NOTICE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Andrea Fassina 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /packages/api-gateway/src/api-keys/api-keys.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { ApiKey } from './entities/api-key.entity'; 4 | import { ApiKeysService } from './api-keys.service'; 5 | import { ApiKeysController } from './api-keys.controller'; 6 | import { CipherModule } from '../common/modules/cipher/cipher.module'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([ApiKey]), CipherModule], 10 | controllers: [ApiKeysController], 11 | providers: [ApiKeysService], 12 | exports: [ApiKeysService], 13 | }) 14 | export class ApiKeysModule {} 15 | -------------------------------------------------------------------------------- /packages/api-gateway/src/auth/dto/login.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNotEmptyString } from '../../common/decorators/is-not-empty-string.decorator'; 3 | import { EmailDto } from '../../common/dto'; 4 | import { IsOptional } from 'class-validator'; 5 | 6 | export class LoginDto extends EmailDto { 7 | @ApiProperty({ description: 'User password', example: 'mR3U5c91Xs' }) 8 | @IsNotEmptyString() 9 | readonly password!: string; 10 | 11 | @ApiProperty({ description: 'Google reCAPTCHA Site Key' }) 12 | @IsNotEmptyString() 13 | @IsOptional() 14 | readonly recaptchaToken?: string; 15 | } 16 | -------------------------------------------------------------------------------- /libs/release-it/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.0.3](https://github.com/fasenderos/bitify/compare/@bitify/release-it-v0.0.2...@bitify/release-it-v0.0.3) (2023-09-23) 4 | 5 | ## [0.0.2](https://github.com/fasenderos/bitify/compare/@bitify/release-it-v0.0.1...@bitify/release-it-v0.0.2) (2023-09-23) 6 | 7 | ## 0.0.1 (2023-09-23) 8 | 9 | ### Chore 10 | 11 | - add release action + auto changelog ([aa6a17b](https://github.com/fasenderos/bitify/commit/aa6a17b98a66173f917f972651d024d68decf87c)) 12 | 13 | ### Documentation 14 | 15 | - **api-gateway:** remove changelog ([d7ba8ec](https://github.com/fasenderos/bitify/commit/d7ba8ec64cb8b4883b774956a98711696d57e3e0)) 16 | -------------------------------------------------------------------------------- /packages/api-gateway/src/events/index.ts: -------------------------------------------------------------------------------- 1 | export const EmailConfirmation = 'email.confirmation.token'; 2 | export interface EmailConfirmationDto { 3 | email: string; 4 | code: string; 5 | } 6 | 7 | export const EmailResetPassword = 'email.forgot.password'; 8 | export interface EmailResetPasswordDto { 9 | token: string; 10 | email: string; 11 | } 12 | 13 | export const ActivityRecord = 'system.activity.record'; 14 | export interface ActivityRecordDto { 15 | userId: string; 16 | userIP: string; 17 | userAgent: string; 18 | action: 'signup' | 'password.reset'; 19 | result: 'succeed' | 'failes'; 20 | topic: 'account' | 'password'; 21 | data?: string; 22 | } 23 | -------------------------------------------------------------------------------- /packages/api-gateway/src/auth/dto/reset-password.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { PasswordStrengthDto } from '../../common/dto'; 3 | import { IsNotEmptyString } from '../../common/decorators/is-not-empty-string.decorator'; 4 | import { IsEmail } from 'class-validator'; 5 | 6 | export class ResetPasswordDto extends PasswordStrengthDto { 7 | @ApiProperty({ 8 | description: 'Recovery token provided for resetting password', 9 | }) 10 | @IsNotEmptyString() 11 | readonly token!: string; 12 | 13 | @ApiProperty({ description: 'User email', example: 'email@mysite.com' }) 14 | @IsEmail() 15 | @IsNotEmptyString() 16 | readonly email!: string; 17 | } 18 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig-base.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "declaration": true, 6 | "removeComments": true, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "allowSyntheticDefaultImports": true, 10 | "target": "ES2021", 11 | "sourceMap": true, 12 | "outDir": "./dist", 13 | "baseUrl": "./", 14 | "incremental": true, 15 | "skipLibCheck": true, 16 | "strictNullChecks": true, 17 | "noImplicitAny": true, 18 | "strictBindCallApply": true, 19 | "resolveJsonModule": true 20 | }, 21 | "typeRoots": ["./node_modules/@types", "./typings"] 22 | } 23 | -------------------------------------------------------------------------------- /packages/api-gateway/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig-base.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "declaration": true, 6 | "removeComments": false, 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "allowSyntheticDefaultImports": true, 10 | "target": "ES2021", 11 | "sourceMap": true, 12 | "outDir": "./dist", 13 | "baseUrl": "./", 14 | "incremental": true, 15 | "skipLibCheck": true, 16 | "strictNullChecks": true, 17 | "noImplicitAny": true, 18 | "strictBindCallApply": true, 19 | "resolveJsonModule": true 20 | }, 21 | "typeRoots": ["./node_modules/@types", "./typings"] 22 | } 23 | -------------------------------------------------------------------------------- /scripts/get-package-from-tag.js: -------------------------------------------------------------------------------- 1 | const { exec } = require("child_process"); 2 | 3 | // Get the package's path from a github tag name 4 | // Github tag have the format @bitify/app-name-v0.0.1 5 | // e.g. node ./scripts/get-pacakge-from-tag.js @bitify/app-name-v0.0.1 6 | try { 7 | const tagName = process.argv[2]; 8 | const packageName = /^(.+?)(-v[\d].[\d].[\d])/.exec(tagName)[1]; 9 | 10 | exec("pnpm m ls --json --depth=-1", (_, packages) => { 11 | // log the path so it can be captured from the bash script 12 | console.log(JSON.parse(packages).find((x) => x.name === packageName).path); 13 | }); 14 | } catch (error) { 15 | // something wrong, log an empty string 16 | console.log(""); 17 | } 18 | -------------------------------------------------------------------------------- /packages/api-gateway/scripts/start-db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | SERVER="bitify_database"; 5 | PW="bitify_password"; 6 | DB="bitify_database"; 7 | 8 | echo "stop & remove old docker [$SERVER] and starting new fresh instance of [$SERVER]" 9 | (docker kill $SERVER || :) && \ 10 | (docker rm $SERVER || :) && \ 11 | docker run --name $SERVER -e POSTGRES_PASSWORD=$PW \ 12 | -e PGPASSWORD=$PW \ 13 | -p 5432:5432 \ 14 | -d postgres 15 | 16 | # wait for pg to start 17 | echo "sleep wait for pg-server [$SERVER] to start"; 18 | SLEEP 3; 19 | 20 | # create the db 21 | echo "CREATE DATABASE $DB ENCODING 'UTF-8';" | docker exec -i $SERVER psql -U postgres 22 | echo "\l" | docker exec -i $SERVER psql -U postgres -------------------------------------------------------------------------------- /packages/api-gateway/src/base/interfaces/base-controller.interface.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial } from 'typeorm'; 2 | import { BaseEntity } from '../base.entity'; 3 | import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; 4 | import { User } from '../../users/entities/user.entity'; 5 | 6 | export interface IBaseController< 7 | T extends BaseEntity & { userId?: string }, 8 | C extends DeepPartial, 9 | U extends QueryDeepPartialEntity, 10 | > { 11 | create(data: C, user: User): Promise; 12 | findById(id: string, user: User): Promise; 13 | findAll(user: User): Promise; 14 | updateById(id: string, dto: U, user: User): Promise; 15 | deleteById(id: string, user: User): Promise; 16 | } 17 | -------------------------------------------------------------------------------- /packages/api-gateway/src/activities/entities/activity.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, Index } from 'typeorm'; 2 | import { BaseEntity } from '../../base/base.entity'; 3 | import { Collections } from '../../common/constants'; 4 | 5 | @Entity({ name: Collections.ACTIVITIES }) 6 | @Index(`index_${Collections.ACTIVITIES}_on_userId`, ['userId']) 7 | export class Activity extends BaseEntity { 8 | @Column({ type: 'uuid' }) 9 | userId!: string; 10 | 11 | @Column() 12 | userIP!: string; 13 | 14 | @Column() 15 | userAgent!: string; 16 | 17 | @Column() 18 | topic!: string; 19 | 20 | @Column() 21 | action!: string; 22 | 23 | @Column() 24 | result!: string; 25 | 26 | @Column({ type: 'text', nullable: true }) 27 | data?: string | null; 28 | } 29 | -------------------------------------------------------------------------------- /packages/api-gateway/src/recovery-tokens/recovery-tokens.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { BaseService } from '../base/base.service'; 3 | import { RecoveryToken } from './entities/recovery-token.entity'; 4 | import { DeepPartial, Repository } from 'typeorm'; 5 | import { InjectRepository } from '@nestjs/typeorm'; 6 | import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; 7 | 8 | @Injectable() 9 | export class RecoveryTokensService extends BaseService< 10 | RecoveryToken, 11 | DeepPartial, 12 | QueryDeepPartialEntity 13 | > { 14 | constructor( 15 | @InjectRepository(RecoveryToken) 16 | repo: Repository, 17 | ) { 18 | super(repo); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/api-gateway/src/app.roles.ts: -------------------------------------------------------------------------------- 1 | import { RolesBuilder } from 'nest-access-control'; 2 | import { Collections } from './common/constants'; 3 | 4 | const { APIKEYS } = Collections; 5 | 6 | export enum UserRole { 7 | // SUPER ADMIN has an access to the whole system without any limits 8 | SUPERADMIN = 'superadmin', 9 | // ADMIN has nearly full access except managing permissions 10 | ADMIN = 'admin', 11 | SUPPORT = 'support', 12 | MEMBER = 'member', 13 | } 14 | 15 | export enum ApiKeyAbility { 16 | READ = 'read', 17 | READ_WRITE = 'read-write', 18 | } 19 | 20 | export const roles: RolesBuilder = new RolesBuilder(); 21 | 22 | roles 23 | // ******* MEMBER ACL ******* // 24 | .grant(UserRole.MEMBER) 25 | .createOwn([APIKEYS]) 26 | .readOwn([APIKEYS]) 27 | .updateOwn([APIKEYS]) 28 | .deleteOwn([APIKEYS]); 29 | -------------------------------------------------------------------------------- /scripts/copy-license.sh: -------------------------------------------------------------------------------- 1 | 2 | PACKAGES=`ls -d packages/*` 3 | LIBS=`ls -d libs/*` 4 | 5 | for i in $PACKAGES; do 6 | echo "copying license to $i" 7 | cp LICENSE $i 8 | cp NOTICE $i 9 | echo "set license in package.json for $i" 10 | cd $i 11 | node -e 'const fs = require("fs"); const pkg = JSON.parse(fs.readFileSync("package.json")); pkg.license = "Apache-2.0"; fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\r\n");' 12 | cd ../.. 13 | done 14 | 15 | for i in $LIBS; do 16 | echo "copying license to $i" 17 | cp LICENSE $i 18 | cp NOTICE $i 19 | echo "set license in package.json for $i" 20 | cd $i 21 | node -e 'const fs = require("fs"); const pkg = JSON.parse(fs.readFileSync("package.json")); pkg.license = "Apache-2.0"; fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\r\n" );' 22 | cd ../.. 23 | done -------------------------------------------------------------------------------- /packages/api-gateway/src/common/pipes/abstract-validation.pipe.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentMetadata, 3 | Injectable, 4 | Type, 5 | ValidationPipe, 6 | ValidationPipeOptions, 7 | } from '@nestjs/common'; 8 | 9 | @Injectable() 10 | export class AbstractValidationPipe extends ValidationPipe { 11 | constructor( 12 | options: ValidationPipeOptions, 13 | private readonly targetTypes: { 14 | body?: Type; 15 | query?: Type; 16 | param?: Type; 17 | custom?: Type; 18 | }, 19 | ) { 20 | super(options); 21 | } 22 | 23 | override async transform(value: any, metadata: ArgumentMetadata) { 24 | const targetType = this.targetTypes[metadata.type]; 25 | if (!targetType) { 26 | return super.transform(value, metadata); 27 | } 28 | return super.transform(value, { ...metadata, metatype: targetType }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/core/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: [ 19 | '.eslintrc.js', 20 | 'dist', 21 | 'coverage', 22 | '.nyc_output', 23 | 'scripts', 24 | ], 25 | rules: { 26 | '@typescript-eslint/interface-name-prefix': 'off', 27 | '@typescript-eslint/explicit-function-return-type': 'off', 28 | '@typescript-eslint/explicit-module-boundary-types': 'off', 29 | '@typescript-eslint/no-explicit-any': 'off', 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /packages/api-gateway/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: [ 19 | '.eslintrc.js', 20 | 'dist', 21 | 'coverage', 22 | '.nyc_output', 23 | 'scripts', 24 | ], 25 | rules: { 26 | '@typescript-eslint/interface-name-prefix': 'off', 27 | '@typescript-eslint/explicit-function-return-type': 'off', 28 | '@typescript-eslint/explicit-module-boundary-types': 'off', 29 | '@typescript-eslint/no-explicit-any': 'off', 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /packages/api-gateway/src/api-keys/api-keys.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | import { ApiTags } from '@nestjs/swagger'; 3 | import { Collections } from '../common/constants'; 4 | import { ControllerFactory } from '../base/base.controller'; 5 | import { ApiKey } from './entities/api-key.entity'; 6 | import { ApiKeysService } from './api-keys.service'; 7 | import { CreateApiKeyDto } from './dto/create-api-key.dto'; 8 | import { UpdateApiKeyDto } from './dto/update-api-key.dto'; 9 | 10 | const BaseController = ControllerFactory< 11 | ApiKey, 12 | CreateApiKeyDto, 13 | UpdateApiKeyDto 14 | >(CreateApiKeyDto, UpdateApiKeyDto, Collections.APIKEYS); 15 | 16 | @ApiTags(Collections.APIKEYS) 17 | @Controller(Collections.APIKEYS) 18 | export class ApiKeysController extends BaseController { 19 | constructor(service: ApiKeysService) { 20 | super(service); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/api-gateway/src/common/decorators/is-not-empty-string.decorator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ValidationOptions, 3 | registerDecorator, 4 | isNotEmpty, 5 | isString, 6 | ValidationArguments, 7 | } from 'class-validator'; 8 | 9 | // Check the value is a non empty string 10 | export function IsNotEmptyString(validationOptions?: ValidationOptions) { 11 | return (object: any, propertyName: string) => { 12 | registerDecorator({ 13 | name: 'isNotEmptyString', 14 | target: object.constructor, 15 | propertyName, 16 | options: validationOptions ?? {}, 17 | validator: { 18 | validate: (value: any): boolean => 19 | isString(value) && isNotEmpty(value.trim()), 20 | defaultMessage: (validationArguments?: ValidationArguments): string => 21 | `${validationArguments?.property} must be a non empty string`, 22 | }, 23 | }); 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /packages/api-gateway/typings/common/index.d.ts: -------------------------------------------------------------------------------- 1 | export type EmptyObject = Record; 2 | 3 | export interface AppConfig { 4 | app: { 5 | name: string; 6 | version: string; 7 | }; 8 | auth: { 9 | exp2FAToken: string; 10 | expAccessToken: string; 11 | expRefreshToken: string; 12 | expVerifyMail: string; 13 | expResetPassword: string; 14 | recaptchaSecret: string; 15 | secret2FAToken: string; 16 | secretAccessToken: string; 17 | secretRefreshToken: string; 18 | }; 19 | db: { 20 | host: string; 21 | port: number; 22 | username: string; 23 | password: string; 24 | database: string; 25 | }; 26 | email: { 27 | transport: string; 28 | from: string; 29 | }; 30 | encryption: { 31 | secret: string; 32 | }; 33 | frontend: { 34 | baseUrl: string; 35 | }; 36 | server: { 37 | address: string; 38 | port: number; 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /packages/api-gateway/src/activities/activities.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Activity } from './entities/activity.entity'; 3 | import { Repository } from 'typeorm'; 4 | import { InjectRepository } from '@nestjs/typeorm'; 5 | import { OnEvent } from '@nestjs/event-emitter'; 6 | import { ActivityRecord, ActivityRecordDto } from '../events'; 7 | 8 | @Injectable() 9 | export class ActivitiesService { 10 | constructor( 11 | @InjectRepository(Activity) 12 | private readonly repo: Repository, 13 | ) {} 14 | 15 | @OnEvent(ActivityRecord) 16 | async activityRecord({ 17 | userId, 18 | userIP, 19 | userAgent, 20 | action, 21 | result, 22 | topic, 23 | data, 24 | }: ActivityRecordDto) { 25 | await this.repo.insert({ 26 | userId, 27 | userIP, 28 | userAgent, 29 | action, 30 | result, 31 | topic, 32 | ...(data ? /* istanbul ignore next */ { data } : {}), 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/core/test/app.e2e.test.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { test, beforeEach, afterEach } from 'tap'; 3 | import { 4 | FastifyAdapter, 5 | NestFastifyApplication, 6 | } from '@nestjs/platform-fastify'; 7 | import { AppModule } from '../src/app.module'; 8 | 9 | let app: NestFastifyApplication; 10 | 11 | beforeEach(async () => { 12 | const moduleFixture: TestingModule = await Test.createTestingModule({ 13 | imports: [AppModule], 14 | }).compile(); 15 | app = moduleFixture.createNestApplication( 16 | new FastifyAdapter(), 17 | ); 18 | await app.init(); 19 | await app.getHttpAdapter().getInstance().ready(); 20 | }); 21 | 22 | afterEach(async () => { 23 | await app.close(); 24 | }); 25 | 26 | test('/ping should return "pong"', async ({ equal, same }) => { 27 | const { statusCode, payload } = await app.inject({ 28 | method: 'GET', 29 | url: '/ping', 30 | }); 31 | equal(statusCode, 200); 32 | same(JSON.parse(payload), {}); 33 | }); 34 | -------------------------------------------------------------------------------- /packages/api-gateway/src/database/database.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { AppConfig } from '../../typings/common'; 5 | import path from 'path'; 6 | 7 | @Module({ 8 | imports: [ 9 | TypeOrmModule.forRootAsync({ 10 | imports: [ConfigModule], 11 | useFactory: (configService: ConfigService) => { 12 | const { host, port, username, password, database } = configService.get< 13 | AppConfig['db'] 14 | >('db') as AppConfig['db']; 15 | return { 16 | type: 'postgres', 17 | host, 18 | port, 19 | username, 20 | password, 21 | database, 22 | synchronize: process.env['NODE_ENV'] !== 'production', 23 | entities: [path.join(__dirname, '/../**/*.entity{.ts,.js}')], 24 | }; 25 | }, 26 | inject: [ConfigService], 27 | }), 28 | ], 29 | }) 30 | export class DBModule {} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDEs 2 | .idea 3 | .vscode 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage/ 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Compiled Javascript files 33 | dist/ 34 | 35 | # Dependency lock file 36 | package-lock.json 37 | 38 | # Dependency directories 39 | node_modules/ 40 | 41 | # TypeScript cache 42 | *.tsbuildinfo 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # dotenv environment variables file 57 | .env* 58 | !.env.template 59 | 60 | # Turbo 61 | .turbo 62 | -------------------------------------------------------------------------------- /packages/api-gateway/.env.template: -------------------------------------------------------------------------------- 1 | ### SERVER 2 | SERVER_ADDRESS="127.0.0.1" 3 | SERVER_PORT="3001" 4 | 5 | ### JWT 6 | JWT_SECRET_ACCESS_TOKEN="change-me-jwt-secret" 7 | JWT_SECRET_REFRESH_TOKEN="change-me-jwt-refresh-secret" 8 | JWT_SECRET_2FA_TOKEN="change-me-jwt-2fa-secret" 9 | 10 | ### ENCRYPTION Must be a 32 chars length 11 | ENCRYPTION_KEY="change-me-encryption-secret-32-c" 12 | 13 | ### DATABASE 14 | POSTGRES_HOST="127.0.0.1" 15 | POSTGRES_PORT="5432" 16 | POSTGRES_USERNAME="postgres" 17 | POSTGRES_PASSWORD="mysecretpassword" 18 | POSTGRES_DATABASE="my_database" 19 | 20 | ### EMAIL 21 | EMAIL_TRANSPORT="smtps://username:password@smtp.example.com" 22 | EMAIL_FROM="no-reply " 23 | 24 | ### FRONTEND 25 | FRONTEND_BASE_URL="http://127.0.0.1:3000" 26 | 27 | ### GOOGLE RECAPTCHA 28 | # Leave it empty to disable reCAPTCHA on login/registration 29 | # for testing we use this key https://developers.google.com/recaptcha/docs/faq#id-like-to-run-automated-tests-with-recaptcha.-what-should-i-do 30 | RECAPTCHA_PRIVATE_KEY="6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe" 31 | -------------------------------------------------------------------------------- /packages/api-gateway/src/profiles/entities/profile.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, Index } from 'typeorm'; 2 | import { BaseEntity } from '../../base/base.entity'; 3 | import { Collections } from '../../common/constants'; 4 | 5 | @Entity({ name: Collections.PROFILES }) 6 | @Index(`index_${Collections.PROFILES}_on_userId`, ['userId']) 7 | export class Profile extends BaseEntity { 8 | @Column({ type: 'uuid' }) 9 | userId!: string; 10 | 11 | @Column('varchar', { nullable: true }) 12 | firstName!: string | null; 13 | 14 | @Column('varchar', { nullable: true }) 15 | lastName!: string | null; 16 | 17 | @Column('varchar', { nullable: true }) 18 | dob!: Date | null; 19 | 20 | @Column('varchar', { nullable: true }) 21 | address!: string | null; 22 | 23 | @Column('varchar', { nullable: true }) 24 | postcode!: string | null; 25 | 26 | @Column('varchar', { nullable: true }) 27 | city!: string | null; 28 | 29 | @Column('varchar', { nullable: true }) 30 | country!: string | null; 31 | 32 | @Column('jsonb', { nullable: true }) 33 | metadata!: string | null; 34 | } 35 | -------------------------------------------------------------------------------- /packages/api-gateway/src/base/interfaces/base-service.interface.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DeepPartial, 3 | DeleteResult, 4 | FindManyOptions, 5 | FindOptionsWhere, 6 | UpdateResult, 7 | } from 'typeorm'; 8 | import { BaseEntity } from '../base.entity'; 9 | import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; 10 | 11 | export interface IBaseService< 12 | T extends BaseEntity & { userId?: string }, 13 | C extends DeepPartial, 14 | U extends QueryDeepPartialEntity, 15 | > { 16 | createEntity(data: C, userId: string): T; 17 | save(data: T): Promise; 18 | find(options?: FindManyOptions): Promise; 19 | findOne(filter: FindOptionsWhere, unselected?: boolean): Promise; 20 | findById(id: string, unselected?: boolean): Promise; 21 | update(filter: FindOptionsWhere, data: U): Promise; 22 | updateById(id: string, data: U, userId?: string): Promise; 23 | delete(filter: FindOptionsWhere, soft?: boolean): Promise; 24 | deleteById( 25 | id: string, 26 | userId?: string, 27 | soft?: boolean, 28 | ): Promise; 29 | } 30 | -------------------------------------------------------------------------------- /packages/api-gateway/src/auth/dto/register.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsOptional, IsStrongPassword, MaxLength } from 'class-validator'; 3 | import { IsNotEmptyString } from '../../common/decorators/is-not-empty-string.decorator'; 4 | import { EmailDto } from '../../common/dto'; 5 | 6 | const passwordDescription = 7 | 'Password has to be between 8-30 characters, and contains at least one uppercase letter, one lowercase letter and a number'; 8 | 9 | export class RegisterDto extends EmailDto { 10 | @ApiProperty({ 11 | description: `User ${passwordDescription}`, 12 | example: 'mR3U5c91Xs', 13 | }) 14 | @IsStrongPassword( 15 | { 16 | minLength: 8, 17 | minLowercase: 1, 18 | minNumbers: 1, 19 | minUppercase: 1, 20 | minSymbols: 0, 21 | }, 22 | { 23 | message: passwordDescription, 24 | }, 25 | ) 26 | @MaxLength(30, { 27 | message: passwordDescription, 28 | }) 29 | @IsNotEmptyString() 30 | readonly password!: string; 31 | 32 | @ApiProperty({ description: 'Google reCAPTCHA Site Key' }) 33 | @IsNotEmptyString() 34 | @IsOptional() 35 | readonly recaptchaToken?: string; 36 | } 37 | -------------------------------------------------------------------------------- /packages/api-gateway/src/auth/strategies/jwt-2fa.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Strategy, ExtractJwt } from 'passport-jwt'; 4 | import { ConfigService } from '@nestjs/config'; 5 | import { User } from '../../users/entities/user.entity'; 6 | import { IJwtPayload } from '../token.service'; 7 | import { UsersService } from '../../users/users.service'; 8 | 9 | @Injectable() 10 | export class Jwt2FAStrategy extends PassportStrategy(Strategy, 'jwt-2fa') { 11 | constructor( 12 | readonly config: ConfigService, 13 | private readonly user: UsersService, 14 | ) { 15 | super({ 16 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 17 | ignoreExpiration: false, 18 | secretOrKey: config.get('auth.secret2FAToken'), 19 | }); 20 | } 21 | 22 | async validate(payload: IJwtPayload): Promise { 23 | // Get user from DB and add the result on req.user 24 | const user = await this.user.findById(payload.sub); 25 | if (user === null) throw new UnauthorizedException(); 26 | this.user.validateUserAuth(user); 27 | return user; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/api-gateway/src/common/modules/mailer/mailer.module.ts: -------------------------------------------------------------------------------- 1 | import { MailerModule } from '@nestjs-modules/mailer'; 2 | import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter'; 3 | import { Module } from '@nestjs/common'; 4 | import { MailService } from './mailer.service'; 5 | import { join } from 'path'; 6 | import { ConfigModule, ConfigService } from '@nestjs/config'; 7 | 8 | @Module({ 9 | imports: [ 10 | ConfigModule, 11 | MailerModule.forRootAsync({ 12 | imports: [ConfigModule], 13 | useFactory: async (config: ConfigService) => { 14 | const transport = config.get('email.transport'); 15 | return { 16 | transport: transport ?? { jsonTransport: true }, 17 | defaults: { 18 | from: config.get('email.from'), 19 | }, 20 | template: { 21 | dir: join(__dirname, 'templates'), 22 | adapter: new HandlebarsAdapter(), 23 | options: { 24 | strict: true, 25 | }, 26 | }, 27 | }; 28 | }, 29 | inject: [ConfigService], 30 | }), 31 | ], 32 | providers: [MailService], 33 | }) 34 | export class MailModule {} 35 | -------------------------------------------------------------------------------- /packages/api-gateway/src/api-keys/entities/api-key.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, Index } from 'typeorm'; 2 | import { BaseEntity } from '../../base/base.entity'; 3 | import { Collections } from '../../common/constants'; 4 | import { ApiKeyAbility } from '../../app.roles'; 5 | 6 | // Currently only HMAC, in the future we may also support RSA 7 | export enum ApiKeyType { 8 | HMAC = 'HMAC', 9 | } 10 | 11 | @Entity({ name: Collections.APIKEYS }) 12 | @Index(`index_${Collections.APIKEYS}_on_userId`, ['userId']) 13 | export class ApiKey extends BaseEntity { 14 | @Column({ type: 'uuid' }) 15 | userId!: string; 16 | 17 | @Column({ unique: true }) 18 | public!: string; 19 | 20 | @Column({ select: false }) 21 | secret!: string; 22 | 23 | @Column() 24 | notes!: string; 25 | 26 | @Column({ type: 'enum', enum: ApiKeyType }) 27 | type!: ApiKeyType; 28 | 29 | @Column('simple-array', { nullable: true }) 30 | userIps!: string[] | null; 31 | 32 | @Column({ type: 'enum', enum: ApiKeyAbility, nullable: true }) 33 | spot!: ApiKeyAbility | null; 34 | 35 | @Column({ type: 'enum', enum: ApiKeyAbility, nullable: true }) 36 | wallet!: ApiKeyAbility | null; 37 | 38 | @Column({ type: 'timestamptz' }) 39 | expiresAt!: Date; 40 | } 41 | -------------------------------------------------------------------------------- /packages/api-gateway/test/before-all-tests.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const { Client } = require('pg'); 3 | // eslint-disable-next-line @typescript-eslint/no-var-requires 4 | require('dotenv').config(); 5 | 6 | const clearDatabase = async () => { 7 | const client = new Client({ 8 | user: process.env['POSTGRES_USERNAME'] ?? 'postgres', 9 | host: process.env['POSTGRES_HOST'] ?? '127.0.0.1', 10 | database: process.env['POSTGRES_DATABASE'] ?? 'postgres', 11 | password: process.env['POSTGRES_PASSWORD'] ?? 'postgres', 12 | port: parseInt(process.env['POSTGRES_PORT'] ?? '5432'), 13 | }); 14 | await client.connect(); 15 | const allTables = `select table_schema||'.'||table_name as table_fullname 16 | from information_schema."tables" 17 | where table_type = 'BASE TABLE' 18 | and table_schema not in ('pg_catalog', 'information_schema');`; 19 | const results = await client.query(allTables); 20 | const tables = results.rows.map((res) => res.table_fullname.split('.')[1]); 21 | for await (const table of tables) { 22 | await client.query(`TRUNCATE ${table} RESTART IDENTITY CASCADE;`); 23 | } 24 | }; 25 | 26 | const run = async () => { 27 | await clearDatabase(); 28 | }; 29 | 30 | run().then(() => process.exit()); 31 | -------------------------------------------------------------------------------- /packages/api-gateway/src/common/dto/index.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsEmail, IsStrongPassword, MaxLength } from 'class-validator'; 3 | import { IsNotEmptyString } from '../decorators/is-not-empty-string.decorator'; 4 | 5 | export class EmailDto { 6 | @ApiProperty({ description: 'User email', example: 'email@mysite.com' }) 7 | @IsEmail() 8 | @IsNotEmptyString() 9 | readonly email!: string; 10 | } 11 | 12 | export class OTPDto { 13 | @ApiProperty({ 14 | description: '6-digit OTP code', 15 | example: '123456', 16 | }) 17 | @IsNotEmptyString() 18 | readonly otp!: string; 19 | } 20 | 21 | const passwordDescription = 22 | 'Password has to be between 8-30 characters, and contains at least one uppercase letter, one lowercase letter and a number'; 23 | 24 | export class PasswordStrengthDto { 25 | @ApiProperty({ 26 | description: `User ${passwordDescription}`, 27 | example: 'mR3U5c91Xs', 28 | }) 29 | @IsStrongPassword( 30 | { 31 | minLength: 8, 32 | minLowercase: 1, 33 | minNumbers: 1, 34 | minUppercase: 1, 35 | minSymbols: 0, 36 | }, 37 | { 38 | message: passwordDescription, 39 | }, 40 | ) 41 | @MaxLength(30, { 42 | message: passwordDescription, 43 | }) 44 | @IsNotEmptyString() 45 | readonly password!: string; 46 | } 47 | -------------------------------------------------------------------------------- /packages/api-gateway/src/api-keys/dto/create-api-key.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 2 | import { IsArray, IsEnum, IsIP, IsOptional, ValidateIf } from 'class-validator'; 3 | import { ApiKeyAbility } from '../../app.roles'; 4 | import { IsNotEmptyString } from '../../common/decorators/is-not-empty-string.decorator'; 5 | 6 | export class CreateApiKeyDto { 7 | @ApiProperty({ description: 'Name for the API key' }) 8 | @IsNotEmptyString() 9 | readonly notes!: string; 10 | 11 | @ApiPropertyOptional({ description: 'List of trusted IPs' }) 12 | @IsArray() 13 | @IsIP(undefined, { each: true }) 14 | @IsOptional() 15 | readonly userIps?: string[]; 16 | 17 | @ApiPropertyOptional({ 18 | enum: ApiKeyAbility, 19 | description: 'Read: list spot orders; Write: submit and cancel spot orders', 20 | example: [ApiKeyAbility.READ, ApiKeyAbility.READ_WRITE], 21 | }) 22 | @IsEnum(ApiKeyAbility) 23 | @ValidateIf((o) => !o.wallet || o.spot) 24 | readonly spot?: ApiKeyAbility; 25 | 26 | @ApiPropertyOptional({ 27 | enum: ApiKeyAbility, 28 | description: 29 | 'Read: wallet status, deposit and withdraw history; Write: submit withdraw requests', 30 | example: [ApiKeyAbility.READ, ApiKeyAbility.READ_WRITE], 31 | }) 32 | @IsEnum(ApiKeyAbility) 33 | @ValidateIf((o) => !o.spot || o.wallet) 34 | readonly wallet?: ApiKeyAbility; 35 | } 36 | -------------------------------------------------------------------------------- /packages/api-gateway/src/auth/session.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | import { Session } from './entities/session.entity'; 5 | import { ConfigService } from '@nestjs/config'; 6 | import timestring from 'timestring'; 7 | 8 | @Injectable() 9 | export class SessionService { 10 | EXP_MS_REFRESH: number; 11 | 12 | constructor( 13 | @InjectRepository(Session) 14 | private readonly session: Repository, 15 | private readonly configService: ConfigService, 16 | ) { 17 | const expRefreshToken = this.configService.get( 18 | 'auth.expRefreshToken', 19 | ) as string; 20 | this.EXP_MS_REFRESH = timestring(expRefreshToken, 'ms'); 21 | } 22 | 23 | async createSession(userId: string, now: number): Promise { 24 | const expires = new Date(now + this.EXP_MS_REFRESH); 25 | const session = await this.session.insert({ userId, expires }); 26 | return session.raw[0]; 27 | } 28 | 29 | async refreshSession(sessionId: string, now: number): Promise { 30 | const expires = new Date(now + this.EXP_MS_REFRESH); 31 | await this.session.update({ id: sessionId }, { expires }); 32 | } 33 | 34 | public findById(sessionId: string) { 35 | return this.session.findOneBy({ id: sessionId }); 36 | } 37 | 38 | public async deleteById(id: string): Promise { 39 | await this.session.delete(id); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/api-gateway/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { JwtModule } from '@nestjs/jwt'; 3 | import { PassportModule } from '@nestjs/passport'; 4 | import { AuthController } from './auth.controller'; 5 | import { UsersModule } from '../users/users.module'; 6 | import { SessionService } from './session.service'; 7 | import { TokenService } from './token.service'; 8 | import { AuthService } from './auth.service'; 9 | import { Session } from './entities/session.entity'; 10 | import { TypeOrmModule } from '@nestjs/typeorm'; 11 | import { JwtStrategy } from './strategies/jwt-strategy'; 12 | import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy'; 13 | import { Jwt2FAStrategy } from './strategies/jwt-2fa.strategy'; 14 | import { CipherModule } from '../common/modules/cipher/cipher.module'; 15 | import { RecoveryTokensModule } from '../recovery-tokens/recovery-tokens.module'; 16 | import { ResetPasswordTransaction } from './transactions/reset-password.transaction'; 17 | 18 | @Module({ 19 | imports: [ 20 | CipherModule, 21 | JwtModule.register({}), 22 | PassportModule, 23 | RecoveryTokensModule, 24 | TypeOrmModule.forFeature([Session]), 25 | UsersModule, 26 | ], 27 | providers: [ 28 | AuthService, 29 | Jwt2FAStrategy, 30 | JwtRefreshStrategy, 31 | JwtStrategy, 32 | ResetPasswordTransaction, 33 | SessionService, 34 | TokenService, 35 | ], 36 | controllers: [AuthController], 37 | }) 38 | export class AuthModule {} 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bitify-root", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "Ultra-fast Open Source Trading Platform for NodeJS", 6 | "license": "Apache-2.0", 7 | "scripts": { 8 | "ci:publish": "pnpm publish -r", 9 | "cleanall": "rm -rf pnpm-lock.yaml node_modules */**/node_modules && pnpm workspace -- clean", 10 | "clean": "pnpm workspace -- clean", 11 | "format": "turbo --concurrency=1 format", 12 | "format:fix": "turbo --concurrency=1 format:fix", 13 | "lint": "turbo --concurrency=1 lint", 14 | "lint:fix": "turbo --concurrency=1 lint:fix", 15 | "prepare": "husky install", 16 | "release": "turbo release --concurrency=1", 17 | "test": "pnpm workspace -- test", 18 | "test:dev": "pnpm workspace -- test:dev", 19 | "start": "pnpm workspace -- start", 20 | "start:dev": "pnpm workspace -- start:dev", 21 | "workspace": "pnpm -r --workspace-concurrency=1" 22 | }, 23 | "packageManager": "pnpm@8.6.10", 24 | "engines": { 25 | "node": ">=18" 26 | }, 27 | "devDependencies": { 28 | "@tsconfig/node-lts": "^18.12.3", 29 | "@tsconfig/strictest": "^2.0.1", 30 | "husky": "^8.0.0", 31 | "lint-staged": "^14.0.1", 32 | "turbo": "^1.10.14" 33 | }, 34 | "keywords": [ 35 | "trading", 36 | "cryptocurrency", 37 | "exchange", 38 | "trading-platform", 39 | "matching-engine", 40 | "bitify", 41 | "blockchain", 42 | "crypto-exchange-software", 43 | "cryptocurrency-exchange-software" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /packages/api-gateway/src/common/modules/cipher/cipher.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; 4 | 5 | @Injectable() 6 | export class CipherService { 7 | private secret: string; 8 | // TODO make them configurable 9 | private separator: string = '::'; 10 | private ivLength: number = 16; 11 | private algorithm: string = 'aes-256-ctr'; 12 | 13 | constructor(private readonly config: ConfigService) { 14 | this.secret = this.config.get('encryption.secret') as string; 15 | } 16 | 17 | encrypt(text: string) { 18 | const iv = randomBytes(this.ivLength); 19 | const cipher = createCipheriv(this.algorithm, Buffer.from(this.secret), iv); 20 | let encrypted = cipher.update(text); 21 | encrypted = Buffer.concat([encrypted, cipher.final()]); 22 | return iv.toString('hex') + this.separator + encrypted.toString('hex'); 23 | } 24 | 25 | decrypt(text: string) { 26 | const textParts = text.split(this.separator); 27 | const iv = Buffer.from(textParts.shift() as string, 'hex'); 28 | const encryptedText = Buffer.from(textParts.join(this.separator), 'hex'); 29 | const decipher = createDecipheriv( 30 | this.algorithm, 31 | Buffer.from(this.secret), 32 | iv, 33 | ); 34 | let decrypted = decipher.update(encryptedText); 35 | decrypted = Buffer.concat([decrypted, decipher.final()]); 36 | return decrypted.toString(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/api-gateway/src/auth/strategies/jwt-strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Strategy, ExtractJwt } from 'passport-jwt'; 4 | import { ConfigService } from '@nestjs/config'; 5 | import { User } from '../../users/entities/user.entity'; 6 | import { IJwtPayload } from '../token.service'; 7 | import { UsersService } from '../../users/users.service'; 8 | import { SessionService } from '../session.service'; 9 | 10 | @Injectable() 11 | export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { 12 | constructor( 13 | readonly config: ConfigService, 14 | private readonly session: SessionService, 15 | private readonly user: UsersService, 16 | ) { 17 | super({ 18 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 19 | ignoreExpiration: false, 20 | secretOrKey: config.get('auth.secretAccessToken'), 21 | }); 22 | } 23 | 24 | async validate(payload: IJwtPayload): Promise { 25 | // Check that the session exist. On logout the session is destroyed but the token may still be valid 26 | const session = await this.session.findById(payload.jti); 27 | if (session === null || session.expires.getTime() < Date.now()) 28 | throw new UnauthorizedException(); 29 | 30 | // Get user from DB and add the result on req.user 31 | const user = await this.user.findById(payload.sub); 32 | if (user === null) throw new UnauthorizedException(); 33 | this.user.validateUserAuth(user); 34 | return user; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/api-gateway/src/common/modules/mailer/mailer.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { OnEvent } from '@nestjs/event-emitter'; 3 | import { 4 | EmailConfirmation, 5 | EmailConfirmationDto, 6 | EmailResetPassword, 7 | EmailResetPasswordDto, 8 | } from '../../../events'; 9 | import { MailerService } from '@nestjs-modules/mailer'; 10 | import { ConfigService } from '@nestjs/config'; 11 | 12 | @Injectable() 13 | export class MailService { 14 | appName: string; 15 | frontendBaseUrl: string; 16 | constructor( 17 | private readonly mail: MailerService, 18 | private readonly config: ConfigService, 19 | ) { 20 | this.appName = this.config.get('app.name') as string; 21 | this.frontendBaseUrl = this.config.get( 22 | 'frontend.baseUrl', 23 | ) as string; 24 | } 25 | @OnEvent(EmailConfirmation) 26 | async emailConfirmation({ email, code }: EmailConfirmationDto) { 27 | await this.mail.sendMail({ 28 | to: email, 29 | subject: `Welcome to ${this.appName}! Confirm your Email`, 30 | template: 'email-confirmation', 31 | context: { code, appName: this.appName }, 32 | }); 33 | } 34 | 35 | @OnEvent(EmailResetPassword) 36 | async emailResetPassword({ email, token }: EmailResetPasswordDto) { 37 | const resetUrl = `${this.frontendBaseUrl}/auth/reset-password?token=${token}`; 38 | await this.mail.sendMail({ 39 | to: email, 40 | subject: `Reset your ${this.appName} password`, 41 | template: 'email-reset-password', 42 | context: { url: resetUrl, appName: this.appName }, 43 | }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/api-gateway/src/auth/strategies/jwt-refresh.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Strategy, ExtractJwt } from 'passport-jwt'; 4 | import { ConfigService } from '@nestjs/config'; 5 | import { User } from '../../users/entities/user.entity'; 6 | import { IJwtPayload } from '../token.service'; 7 | import { UsersService } from '../../users/users.service'; 8 | import { SessionService } from '../session.service'; 9 | 10 | @Injectable() 11 | export class JwtRefreshStrategy extends PassportStrategy( 12 | Strategy, 13 | 'jwt-refresh', 14 | ) { 15 | constructor( 16 | readonly config: ConfigService, 17 | private readonly session: SessionService, 18 | private readonly user: UsersService, 19 | ) { 20 | super({ 21 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 22 | ignoreExpiration: false, 23 | secretOrKey: config.get('auth.secretRefreshToken'), 24 | }); 25 | } 26 | 27 | async validate(payload: IJwtPayload): Promise { 28 | // Check that the session exist. On logout the session is destroyed but the token may still be valid 29 | const session = await this.session.findById(payload.jti); 30 | if (session === null || session.expires.getTime() < Date.now()) 31 | throw new UnauthorizedException(); 32 | 33 | // Get user from DB and add the result on req.user 34 | const user = await this.user.findById(payload.sub); 35 | if (user === null) throw new UnauthorizedException(); 36 | this.user.validateUserAuth(user); 37 | return user; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/api-gateway/src/users/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, Index } from 'typeorm'; 2 | import { Collections, UserState } from '../../common/constants'; 3 | import { UserRole } from '../../app.roles'; 4 | import { BaseEntity } from '../../base/base.entity'; 5 | 6 | @Entity({ name: Collections.USERS }) 7 | export class User extends BaseEntity { 8 | @Index('index_users_on_email', { unique: true }) 9 | @Column() 10 | email!: string; 11 | 12 | @Column({ select: false }) 13 | passwordHash!: string; 14 | 15 | /** 16 | * superadmin = has an access to the whole system without any limits 17 | * admin = has nearly full access except managing permissions 18 | * support 19 | * member 20 | */ 21 | @Column({ default: UserRole.MEMBER }) 22 | roles!: string; 23 | 24 | /** 25 | * Level 0 is default account level 26 | * Level 1 will apply after email verification 27 | * Level 2 will apply after phone verification 28 | * Level 3 will apply after identity & document verification 29 | */ 30 | @Column({ default: 0 }) 31 | level!: number; 32 | 33 | /** active (1), pending (0), banned (-1) */ 34 | @Column({ default: UserState.PENDING }) 35 | state!: number; 36 | 37 | @Column('uuid', { nullable: true }) 38 | referralId!: string | null; 39 | 40 | @Column({ default: false }) 41 | otp!: boolean; 42 | 43 | @Column('varchar', { nullable: true, select: false }) 44 | otpSecret!: string | null; 45 | 46 | @Column('varchar', { nullable: true, select: false }) 47 | verifyCode!: string | null; 48 | 49 | @Column('timestamptz', { nullable: true, select: false }) 50 | verifyExpire!: Date | null; 51 | } 52 | -------------------------------------------------------------------------------- /packages/api-gateway/src/common/utils.ts: -------------------------------------------------------------------------------- 1 | import { isPlainObject } from 'lodash'; 2 | import sanitizeHtml from 'sanitize-html'; 3 | import { getRandomValues } from 'crypto'; 4 | 5 | export const sanitize = (value: string): string => { 6 | return sanitizeHtml(value, { 7 | allowedTags: [], 8 | allowedAttributes: {}, 9 | }); 10 | }; 11 | 12 | export const trim = (value: any, exclude?: string): any => { 13 | if (typeof value === 'string') { 14 | return sanitize(value).trim(); 15 | } 16 | if (Array.isArray(value)) { 17 | value.forEach((element, index) => { 18 | value[index] = trim(element); 19 | }); 20 | return value; 21 | } 22 | if (isPlainObject(value)) { 23 | Object.keys(value).forEach((key) => { 24 | if (key !== exclude) value[key] = trim(value[key]); 25 | }); 26 | return value; 27 | } 28 | return value; 29 | }; 30 | 31 | export const createRandomString = (length = 32): string => { 32 | let result = ''; 33 | const lowercase = 'abcdefghijklmnopqrstuvwxyz'; 34 | const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; 35 | const numbers = '0123456789'; 36 | const alphanumeric = numbers + lowercase + uppercase; 37 | const charactersLength = alphanumeric.length; 38 | 39 | // Create an array of 32-bit unsigned integers 40 | const randomValues = new Uint32Array(length); 41 | // Generate random values 42 | getRandomValues(randomValues); 43 | randomValues.forEach((value) => { 44 | result += alphanumeric.charAt(value % charactersLength); 45 | }); 46 | return result; 47 | }; 48 | 49 | export const isExpired = (date: Date, expireInMs: number): boolean => { 50 | return Date.now() - date.getTime() > expireInMs; 51 | }; 52 | -------------------------------------------------------------------------------- /packages/api-gateway/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | HttpCode, 5 | HttpStatus, 6 | Post, 7 | UseGuards, 8 | } from '@nestjs/common'; 9 | import { EmptyObject } from '../typings/common'; 10 | import { 11 | HealthCheck, 12 | HealthCheckResult, 13 | HealthCheckService, 14 | TypeOrmHealthIndicator, 15 | } from '@nestjs/terminus'; 16 | import { ApiOkResponse, ApiOperation } from '@nestjs/swagger'; 17 | import { ApiKeyGuard } from './auth/guards/api-key.guard'; 18 | 19 | @Controller() 20 | export class AppController { 21 | constructor( 22 | private readonly health: HealthCheckService, 23 | private readonly db: TypeOrmHealthIndicator, 24 | ) {} 25 | 26 | @ApiOperation({ description: 'Test Connectivity' }) 27 | @ApiOkResponse({ description: 'Return a ping frame {}.' }) 28 | @Get('ping') 29 | ping(): EmptyObject { 30 | return {}; 31 | } 32 | 33 | @ApiOperation({ description: 'Check server time' }) 34 | @ApiOkResponse({ 35 | description: 'Return object with the shape { serverTime: number }', 36 | }) 37 | @Get('time') 38 | time(): { serverTime: number } { 39 | return { serverTime: Date.now() }; 40 | } 41 | 42 | @ApiOperation({ description: 'Check server status' }) 43 | @Get('health') 44 | @HealthCheck() 45 | async healtCheck(): Promise { 46 | return await this.health.check([ 47 | async () => await this.db.pingCheck('typeorm'), 48 | ]); 49 | } 50 | 51 | @Get('api/test-get') 52 | @HttpCode(HttpStatus.OK) 53 | @UseGuards(ApiKeyGuard) 54 | testGetApiKeyAuth() { 55 | return 'OK'; 56 | } 57 | 58 | @Post('api/test-post') 59 | @HttpCode(HttpStatus.OK) 60 | @UseGuards(ApiKeyGuard) 61 | testPostApiKeyAuth() { 62 | return 'OK'; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /libs/release-it/.release-it.js: -------------------------------------------------------------------------------- 1 | const version = '${version}'; 2 | const packageName = process.env.npm_package_name; 3 | const scope = packageName.split('/')[1]; 4 | 5 | module.exports = { 6 | git: { 7 | push: true, 8 | tagName: `${packageName}-v${version}`, 9 | tagAnnotation: `Release ${packageName} v${version}`, 10 | pushRepo: 'git@github.com:fasenderos/bitify.git', 11 | commitsPath: '.', 12 | commitMessage: `build(${scope}): released version v${version} [no ci]`, 13 | requireCommits: true, 14 | requireCommitsFail: false, 15 | requireCleanWorkingDir: true, 16 | }, 17 | npm: { 18 | publish: true, 19 | versionArgs: ['--workspaces false'], 20 | }, 21 | github: { 22 | release: true, 23 | releaseName: `${packageName}-v${version}`, 24 | commitArgs: ['-S'], 25 | tagArgs: ['-s'], 26 | }, 27 | plugins: { 28 | '@release-it/conventional-changelog': { 29 | path: '.', 30 | header: '# Changelog', 31 | infile: 'CHANGELOG.md', 32 | preset: { 33 | name: 'conventionalcommits', 34 | types: [ 35 | { type: 'feat', section: 'Features' }, 36 | { type: 'fix', section: 'Bug Fixes' }, 37 | { type: 'chore', section: 'Chore' }, 38 | { type: 'docs', section: 'Documentation' }, 39 | { type: 'refactor', section: 'Refactoring' }, 40 | { type: 'perf', section: 'Performance Improvement' }, 41 | { type: 'test', section: 'Test' }, 42 | { type: 'style', hidden: true }, 43 | ], 44 | }, 45 | gitRawCommitsOpts: { 46 | path: '.', 47 | }, 48 | }, 49 | }, 50 | hooks: { 51 | // Format the generated changelog befor commit to avoid problem with lint-staged 52 | 'before:git:release': 'npm run format:fix && git add .', 53 | }, 54 | }; 55 | -------------------------------------------------------------------------------- /packages/api-gateway/src/base/base.transaction.ts: -------------------------------------------------------------------------------- 1 | import { DataSource, EntityManager, QueryRunner } from 'typeorm'; 2 | import { Injectable } from '@nestjs/common'; 3 | 4 | @Injectable() 5 | export abstract class BaseTransaction { 6 | protected constructor(private readonly connection: DataSource) {} 7 | 8 | // this function will contain all of the operations that you need to perform 9 | // and has to be implemented in all transaction classes 10 | protected abstract execute( 11 | data: TransactionInput, 12 | manager: EntityManager, 13 | ): Promise; 14 | 15 | private async createRunner(): Promise { 16 | return this.connection.createQueryRunner(); 17 | } 18 | 19 | // this is the main function that runs the transaction 20 | async run(data: TransactionInput): Promise { 21 | // since everything in Nest.js is a singleton we should create a separate 22 | // QueryRunner instance for each call 23 | const queryRunner = await this.createRunner(); 24 | await queryRunner.connect(); 25 | await queryRunner.startTransaction(); 26 | 27 | try { 28 | const result = await this.execute(data, queryRunner.manager); 29 | await queryRunner.commitTransaction(); 30 | return result; 31 | } catch (error) { 32 | await queryRunner.rollbackTransaction(); 33 | throw new Error('Transaction failed'); 34 | } finally { 35 | await queryRunner.release(); 36 | } 37 | } 38 | 39 | // this is a function that allows us to use other "transaction" classes 40 | // inside of any other "main" transaction, i.e. without creating a new DB transaction 41 | async runWithinTransaction( 42 | data: TransactionInput, 43 | manager: EntityManager, 44 | ): Promise { 45 | return this.execute(data, manager); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/api-gateway/test/config.test.ts: -------------------------------------------------------------------------------- 1 | import Config from '../src/config'; 2 | import { AppConfig } from '../typings/common'; 3 | import { name, version } from '../package.json'; 4 | import t from 'tap'; 5 | 6 | t.test('test config', ({ same, end, equal }) => { 7 | // Without required params should throw err 8 | try { 9 | Config(); 10 | } catch (err: any) { 11 | equal(err.message, 'Config missing env.RECAPTCHA_PRIVATE_KEY'); 12 | } 13 | 14 | // Mock required params 15 | process.env['RECAPTCHA_PRIVATE_KEY'] = 'somevalue'; 16 | process.env['EMAIL_FROM'] = 'somevalue'; 17 | process.env['FRONTEND_BASE_URL'] = 'somevalue'; 18 | 19 | const config: AppConfig = Config(); 20 | same(config, { 21 | app: { name, version }, 22 | auth: { 23 | exp2FAToken: '2m', 24 | expAccessToken: '15m', 25 | expRefreshToken: '7d', 26 | expVerifyMail: '8m', 27 | expResetPassword: '8m', 28 | recaptchaSecret: 'somevalue', 29 | secret2FAToken: 'CHANGE-2FA-TOKEN', 30 | secretAccessToken: 'CHANGE-ACCESS-TOKEN', 31 | secretRefreshToken: 'CHANGE-REFRESH-TOKEN', 32 | }, 33 | db: { 34 | host: '127.0.0.1', 35 | port: 5432, 36 | username: 'postgres', 37 | password: 'postgres', 38 | database: 'postgres', 39 | }, 40 | email: { transport: undefined, from: 'somevalue' }, 41 | encryption: { secret: 'CHANGE-ENCRYPTION-KEY' }, 42 | frontend: { baseUrl: 'somevalue' }, 43 | server: { address: '127.0.0.1', port: 3001 }, 44 | }); 45 | end(); 46 | }); 47 | 48 | // t.test( 49 | // 'config should throw if missing required value default value', 50 | // ({ end, error }) => { 51 | // process.env['RECAPTCHA_PRIVATE_KEY'] = 'somevalue'; 52 | // process.env['EMAIL_TRANSPORT'] = 'somevalue'; 53 | // process.env['EMAIL_FROM'] = 'somevalue'; 54 | 55 | // end(); 56 | // }, 57 | // ); 58 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bitify/core", 3 | "version": "0.0.0", 4 | "description": "", 5 | "scripts": { 6 | "build": "pnpm clean && nest build", 7 | "clean": "rm -rf coverage dist .nyc_output", 8 | "format": "prettier --list-different .", 9 | "format:fix": "prettier --write .", 10 | "start": "nest start", 11 | "start:dev": "nest start --watch", 12 | "start:debug": "nest start --debug --watch", 13 | "start:prod": "node dist/main", 14 | "lint": "eslint", 15 | "lint:fix": "eslint --fix", 16 | "test": "tap --ts", 17 | "test:dev": "tap --ts --watch" 18 | }, 19 | "author": "Andrea Fassina ", 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/fasenderos/bitify.git" 23 | }, 24 | "license": "Apache-2.0", 25 | "bugs": { 26 | "url": "https://github.com/fasenderos/bitify/issues" 27 | }, 28 | "homepage": "https://github.com/fasenderos/bitify#readme", 29 | "dependencies": { 30 | "@nestjs/common": "^10.0.0", 31 | "@nestjs/core": "^10.0.0", 32 | "@nestjs/platform-fastify": "^10.0.0", 33 | "reflect-metadata": "^0.1.13", 34 | "rxjs": "^7.8.1" 35 | }, 36 | "devDependencies": { 37 | "@nestjs/cli": "^10.0.0", 38 | "@nestjs/schematics": "^10.0.0", 39 | "@nestjs/testing": "^10.0.0", 40 | "@types/node": "^20.3.1", 41 | "@types/tap": "^15.0.8", 42 | "@typescript-eslint/eslint-plugin": "^6.4.1", 43 | "@typescript-eslint/parser": "^6.4.1", 44 | "eslint": "^8.47.0", 45 | "eslint-config-prettier": "^9.0.0", 46 | "eslint-plugin-prettier": "^5.0.0", 47 | "prettier": "^3.0.2", 48 | "source-map-support": "^0.5.21", 49 | "tap": "^16.3.7", 50 | "ts-loader": "^9.4.3", 51 | "ts-node": "^10.9.1", 52 | "tsconfig-paths": "^4.2.0", 53 | "typescript": "^5.1.3" 54 | }, 55 | "tap": { 56 | "coverage": true, 57 | "check-coverage": false, 58 | "coverage-report": "lcov" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /packages/api-gateway/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { 3 | FastifyAdapter, 4 | NestFastifyApplication, 5 | } from '@nestjs/platform-fastify'; 6 | import { AppModule } from './app.module'; 7 | import { ConfigService } from '@nestjs/config'; 8 | import { Logger } from 'nestjs-pino'; 9 | import { AppConfig } from '../typings/common'; 10 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 11 | import { ValidationPipe } from '@nestjs/common'; 12 | 13 | async function bootstrap(): Promise { 14 | const app = await NestFactory.create( 15 | AppModule, 16 | new FastifyAdapter({ logger: false }), 17 | ); 18 | 19 | const pinoLogger = app.get(Logger); 20 | app.useLogger(pinoLogger); 21 | 22 | // Starts listening for shutdown hooks 23 | app.enableShutdownHooks(); 24 | 25 | // DTO Validation Global configuration 26 | app.useGlobalPipes( 27 | new ValidationPipe({ 28 | forbidNonWhitelisted: true, 29 | stopAtFirstError: true, 30 | whitelist: true, 31 | }), 32 | ); 33 | 34 | // Get configService 35 | const configService: ConfigService = app.get(ConfigService); 36 | const { port, address } = configService.get( 37 | 'server', 38 | ) as AppConfig['server']; 39 | const { name, version } = configService.get( 40 | 'app', 41 | ) as AppConfig['app']; 42 | 43 | const config = new DocumentBuilder() 44 | .setTitle(`${name} documentations`) 45 | .setDescription(`The ${name} API description`) 46 | .setVersion(version) 47 | .addBearerAuth() 48 | .build(); 49 | const document = SwaggerModule.createDocument(app, config); 50 | SwaggerModule.setup('docs', app, document); 51 | 52 | await app.listen(port, address); 53 | pinoLogger.log(`${name}@v${version} is running on: ${await app.getUrl()}`); 54 | } 55 | bootstrap().catch((e: Error) => { 56 | console.error(`Error starting server\n${e.message}\n${e.stack ?? ''}`); 57 | throw e; 58 | }); 59 | -------------------------------------------------------------------------------- /packages/api-gateway/src/auth/transactions/reset-password.transaction.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnprocessableEntityException } from '@nestjs/common'; 2 | import { DataSource, EntityManager } from 'typeorm'; 3 | import { BaseTransaction } from '../../base/base.transaction'; 4 | import { RecoveryToken } from '../../recovery-tokens/entities/recovery-token.entity'; 5 | import { hash } from 'bcrypt'; 6 | import { User } from '../../users/entities/user.entity'; 7 | 8 | interface ResetPasswordTransactionInput { 9 | userId: string; 10 | password: string; 11 | hashedToken: string; 12 | } 13 | 14 | @Injectable() 15 | export class ResetPasswordTransaction extends BaseTransaction< 16 | ResetPasswordTransactionInput, 17 | void 18 | > { 19 | constructor(connection: DataSource) { 20 | super(connection); 21 | } 22 | 23 | // Run reset password in transaction to prevent race conditions. 24 | protected async execute( 25 | data: ResetPasswordTransactionInput, 26 | manager: EntityManager, 27 | ): Promise { 28 | const { userId, password, hashedToken } = data; 29 | // Get all user recovery tokens from DB 30 | const allUsertokens = await manager.find(RecoveryToken, { 31 | where: { userId }, 32 | }); 33 | const token = allUsertokens.find((x) => x.token === hashedToken); 34 | if (!token) 35 | // The token was redeemed by another transaction 36 | throw new UnprocessableEntityException('Invalid password reset token'); 37 | 38 | // Delete all tokens belonging to this user to prevent duplicate use 39 | await manager.delete(RecoveryToken, { userId }); 40 | // Check if the current token has expired or not 41 | if (token.expiresAt.getTime() < Date.now()) 42 | throw new UnprocessableEntityException( 43 | 'Your password reset oken has expired. Please try again', 44 | ); 45 | 46 | // All checks have been completed. We can change the user’s password 47 | const passwordHash = await hash(password, 10); 48 | await manager.update(User, { id: userId }, { passwordHash }); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/publish-releases.yml: -------------------------------------------------------------------------------- 1 | name: Release & Publish 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | package: 7 | description: "The package name to be released (e.g @bitify/api-gateway). If empty every packages will be released" 8 | type: string 9 | 10 | jobs: 11 | setup-node_modules: 12 | runs-on: ubuntu-latest 13 | timeout-minutes: 15 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: pnpm/action-setup@v2.4.0 17 | - uses: actions/setup-node@v3 18 | with: 19 | node-version: 20 20 | cache: "pnpm" 21 | - uses: "nick-fields/retry@v2.8.3" 22 | with: 23 | max_attempts: 10 24 | timeout_minutes: 15 25 | retry_on: error 26 | command: pnpm fetch --ignore-scripts 27 | 28 | publish: 29 | needs: setup-node_modules 30 | runs-on: ubuntu-latest 31 | environment: main 32 | permissions: 33 | contents: write 34 | timeout-minutes: 15 35 | steps: 36 | - uses: actions/checkout@v4 37 | with: 38 | fetch-depth: 0 39 | - uses: pnpm/action-setup@v2.4.0 40 | - uses: actions/setup-node@v3 41 | with: 42 | node-version: "18" 43 | registry-url: "https://registry.npmjs.org" 44 | cache: "pnpm" 45 | - uses: webfactory/ssh-agent@v0.8.0 46 | with: 47 | ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} 48 | - name: git config 49 | run: | 50 | git config user.name "fasenderos" 51 | git config user.email "fasenderos@gmail.com" 52 | - name: Install dependencies 53 | uses: nick-fields/retry@v2.8.3 54 | with: 55 | max_attempts: 10 56 | timeout_minutes: 15 57 | retry_on: error 58 | command: pnpm install --frozen-lockfile 59 | - name: Release packages 60 | run: | 61 | if [ "${{ github.event.inputs.package }}" != "" ]; 62 | then 63 | PATH="$(node ./scripts/get-package-from-tag.js '${{ github.ref_name }}')" 64 | if [! -z "$PATH" ]; then cd $PATH; fi 65 | fi 66 | pnpm release 67 | env: 68 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 69 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 70 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 71 | -------------------------------------------------------------------------------- /packages/api-gateway/src/config.ts: -------------------------------------------------------------------------------- 1 | import { name, version } from '../package.json'; 2 | import { AppConfig } from '../typings/common'; 3 | 4 | function ensureValues( 5 | key: string, 6 | defaultValue?: string, 7 | throwOnMissing = true, 8 | ): string { 9 | const value = process.env[key]; 10 | if (value === undefined) { 11 | if (defaultValue) { 12 | console.error( 13 | `Config missing env.${key} - the default value '${defaultValue}' will be used`, 14 | ); 15 | return defaultValue; 16 | } 17 | if (throwOnMissing) throw new Error(`Config missing env.${key}`); 18 | } 19 | return value as string; 20 | } 21 | 22 | export enum TokenTypes { 23 | ACCESS = 'access', 24 | VERIFY_EMAIL = 'verify_email', 25 | RESET_PASSWORD = 'reset_password', 26 | } 27 | 28 | export default (): AppConfig => { 29 | return { 30 | app: { 31 | name, 32 | version, 33 | }, 34 | auth: { 35 | exp2FAToken: '2m', 36 | expAccessToken: '15m', 37 | expRefreshToken: '7d', 38 | expVerifyMail: '8m', 39 | expResetPassword: '8m', 40 | recaptchaSecret: ensureValues('RECAPTCHA_PRIVATE_KEY', undefined), 41 | secret2FAToken: ensureValues('JWT_SECRET_2FA_TOKEN', 'CHANGE-2FA-TOKEN'), 42 | secretAccessToken: ensureValues( 43 | 'JWT_SECRET_ACCESS_TOKEN', 44 | 'CHANGE-ACCESS-TOKEN', 45 | ), 46 | secretRefreshToken: ensureValues( 47 | 'JWT_SECRET_REFRESH_TOKEN', 48 | 'CHANGE-REFRESH-TOKEN', 49 | ), 50 | }, 51 | db: { 52 | host: ensureValues('POSTGRES_HOST', '127.0.0.1'), 53 | port: parseInt(ensureValues('POSTGRES_PORT', '5432'), 10), 54 | username: ensureValues('POSTGRES_USERNAME', 'postgres'), 55 | password: ensureValues('POSTGRES_PASSWORD', 'postgres'), 56 | database: ensureValues('POSTGRES_DATABASE', 'postgres'), 57 | }, 58 | email: { 59 | transport: ensureValues('EMAIL_TRANSPORT', undefined, false), 60 | from: ensureValues('EMAIL_FROM', undefined), 61 | }, 62 | encryption: { 63 | secret: ensureValues('ENCRYPTION_KEY', 'CHANGE-ENCRYPTION-KEY'), 64 | }, 65 | frontend: { 66 | baseUrl: ensureValues('FRONTEND_BASE_URL', undefined, true), 67 | }, 68 | server: { 69 | address: ensureValues('SERVER_ADDRESS', '127.0.0.1'), 70 | port: parseInt(ensureValues('SERVER_PORT', '3001'), 10), 71 | }, 72 | }; 73 | }; 74 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Lint, Test & Upload Coverage 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'packages/**' 9 | pull_request: 10 | paths: 11 | - 'packages/**' 12 | 13 | # This allows a subsequently queued workflow run to interrupt previous runs 14 | concurrency: 15 | group: "${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}" 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | setup-node_modules: 20 | runs-on: ubuntu-latest 21 | timeout-minutes: 15 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: pnpm/action-setup@v2.4.0 25 | - uses: actions/setup-node@v3 26 | with: 27 | node-version: 20 28 | cache: "pnpm" 29 | - uses: "nick-fields/retry@v2.8.3" 30 | with: 31 | max_attempts: 10 32 | timeout_minutes: 15 33 | retry_on: error 34 | command: pnpm fetch --ignore-scripts 35 | 36 | ci-api-gateway: 37 | needs: setup-node_modules 38 | runs-on: ${{ matrix.os }} 39 | timeout-minutes: 15 40 | strategy: 41 | matrix: 42 | node-version: [18, 20] 43 | os: [ubuntu-latest] 44 | steps: 45 | - uses: actions/checkout@v4 46 | - uses: pnpm/action-setup@v2.4.0 47 | - uses: actions/setup-node@v3 48 | with: 49 | node-version: ${{ matrix.node-version }} 50 | cache: "pnpm" 51 | - name: Start postgresql containers for testing 52 | run: docker-compose up -d postgresql 53 | # For testing we use this recaptcha key https://developers.google.com/recaptcha/docs/faq#id-like-to-run-automated-tests-with-recaptcha.-what-should-i-do 54 | - name: Create env file 55 | run: | 56 | cd packages/api-gateway && touch .env 57 | echo RECAPTCHA_PRIVATE_KEY="6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe" >> .env 58 | echo EMAIL_FROM="no-reply " >> .env 59 | echo FRONTEND_BASE_URL="http://127.0.0.1:3000" >> .env 60 | echo ENCRYPTION_KEY="change-me-encryption-secret-32-c" >> .env 61 | cat .env 62 | - name: Install dependencies 63 | uses: nick-fields/retry@v2.8.3 64 | with: 65 | max_attempts: 10 66 | timeout_minutes: 15 67 | retry_on: error 68 | command: pnpm install --frozen-lockfile 69 | - name: Run linter 70 | run: cd packages/api-gateway && pnpm lint 71 | - name: Run test suite 72 | run: cd packages/api-gateway && pnpm test:ci 73 | - name: Upload coverage reports to Codecov 74 | if: ${{ matrix.node-version == 20 }} 75 | uses: codecov/codecov-action@v3 76 | with: 77 | token: ${{ secrets.CODECOV_TOKEN }} 78 | files: ./packages/api-gateway/coverage/lcov.info 79 | fail_ci_if_error: true 80 | -------------------------------------------------------------------------------- /packages/api-gateway/src/api-keys/api-keys.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { BaseService } from '../base/base.service'; 3 | import { ApiKey, ApiKeyType } from './entities/api-key.entity'; 4 | import { Repository, UpdateResult } from 'typeorm'; 5 | import { InjectRepository } from '@nestjs/typeorm'; 6 | import { CreateApiKeyDto } from './dto/create-api-key.dto'; 7 | import { UpdateApiKeyDto } from './dto/update-api-key.dto'; 8 | import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; 9 | import { createRandomString } from '../common/utils'; 10 | import { CipherService } from '../common/modules/cipher/cipher.service'; 11 | 12 | @Injectable() 13 | export class ApiKeysService extends BaseService< 14 | ApiKey, 15 | CreateApiKeyDto, 16 | UpdateApiKeyDto 17 | > { 18 | constructor( 19 | @InjectRepository(ApiKey) 20 | repo: Repository, 21 | private readonly cipher: CipherService, 22 | ) { 23 | super(repo); 24 | } 25 | 26 | override async save(apikey: ApiKey) { 27 | // Create Public and Private Api Keys 28 | const { publicKey, privateKey } = await this.generateAPIKeys(); 29 | 30 | // Set api key expiration 31 | this.setExpiration(apikey); 32 | 33 | // Save in DB crypted secret and public key 34 | apikey.secret = this.cipher.encrypt(privateKey); 35 | apikey.public = publicKey; 36 | 37 | // Currently only HMAC, in the future we may also support RSA 38 | apikey.type = ApiKeyType.HMAC; 39 | await this.repo.save(apikey); 40 | 41 | // Return the real public and secret keys to the user. 42 | // The secret key will never be shown again 43 | apikey.secret = privateKey; 44 | apikey.public = publicKey; 45 | return apikey; 46 | } 47 | 48 | override updateById( 49 | id: string, 50 | data: UpdateApiKeyDto, 51 | userId?: string, 52 | ): Promise { 53 | const update: QueryDeepPartialEntity = { ...data }; 54 | // If userIps is `null`, means that user have 55 | // removed the IPs, so we have to update the apikey expiration 56 | if (data.userIps === null) this.setExpiration(update); 57 | return this.repo.update( 58 | { 59 | id, 60 | ...(userId ? { userId: userId } : /* istanbul ignore next */ {}), 61 | }, 62 | update, 63 | ); 64 | } 65 | 66 | async generateAPIKeys() { 67 | const publicKey = createRandomString(18); 68 | const privateKey = createRandomString(36); 69 | return { publicKey, privateKey }; 70 | } 71 | 72 | setExpiration(apiKey: QueryDeepPartialEntity) { 73 | // Without IP apiKey expires in 90 days 74 | const expiration = 75 | (apiKey.userIps ?? []).length > 0 76 | ? new Date(0) // '1970-01-01T00:00:00.000Z' 77 | : new Date(Date.now() + 7_776_000_000); // 60 * 60 * 24 * 90 * 1000 = 7_776_000_000 78 | apiKey.expiresAt = expiration; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /packages/api-gateway/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, RequestMethod } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | import { ConfigModule } from '@nestjs/config'; 4 | import config from './config'; 5 | import { LoggerModule } from 'nestjs-pino'; 6 | import { IncomingMessage } from 'http'; 7 | import { UsersModule } from './users/users.module'; 8 | import { TerminusModule } from '@nestjs/terminus'; 9 | import { DBModule } from './database/database.module'; 10 | import { AuthModule } from './auth/auth.module'; 11 | import { EventEmitterModule } from '@nestjs/event-emitter'; 12 | import { ActivitiesModule } from './activities/activities.module'; 13 | import { AccessControlModule } from 'nest-access-control'; 14 | import { roles } from './app.roles'; 15 | import { ApiKeysModule } from './api-keys/api-keys.module'; 16 | import { CipherModule } from './common/modules/cipher/cipher.module'; 17 | import { ServerResponse } from 'http'; 18 | 19 | @Module({ 20 | imports: [ 21 | ConfigModule.forRoot({ 22 | isGlobal: true, 23 | load: [config], 24 | cache: true, 25 | }), 26 | EventEmitterModule.forRoot(), 27 | DBModule, 28 | LoggerModule.forRoot({ 29 | pinoHttp: { 30 | // We want to use pino-pretty only if there is a human watching this, 31 | // otherwise we log as newline-delimited JSON. 32 | .../* istanbul ignore next */ (process.stdout.isTTY 33 | ? { 34 | transport: { target: 'pino-pretty' }, 35 | level: 'debug', 36 | } 37 | : { 38 | level: 'info', 39 | }), 40 | // Define a custom logger level 41 | customLogLevel: function (_: IncomingMessage, res: ServerResponse) { 42 | if (res.statusCode == null || res.statusCode < 300) { 43 | // Disable logs for response without error 44 | return 'silent'; 45 | } else if (res.statusCode >= 300 && res.statusCode < 500) { 46 | return 'warn'; 47 | } 48 | // res.statusCode >= 500 || err 49 | /* istanbul ignore next - right now the only error 500 is auth/login which is excluded from logging */ 50 | return 'error'; 51 | }, 52 | // Define additional custom request properties 53 | customProps: () => ({ appName: 'API' }), 54 | redact: { 55 | paths: ['req.headers.authorization'], 56 | remove: true, 57 | }, 58 | }, 59 | exclude: [ 60 | // https://github.com/pillarjs/path-to-regexp#zero-or-more 61 | { method: RequestMethod.OPTIONS, path: ':all*' }, 62 | { method: RequestMethod.HEAD, path: ':all*' }, 63 | { method: RequestMethod.ALL, path: 'auth/:all+' }, 64 | ], 65 | }), 66 | AccessControlModule.forRoles(roles), 67 | ActivitiesModule, 68 | ApiKeysModule, 69 | AuthModule, 70 | CipherModule, 71 | UsersModule, 72 | TerminusModule, 73 | ], 74 | controllers: [AppController], 75 | }) 76 | export class AppModule {} 77 | -------------------------------------------------------------------------------- /packages/api-gateway/test/helper.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FastifyAdapter, 3 | NestFastifyApplication, 4 | } from '@nestjs/platform-fastify'; 5 | import { DataSource } from 'typeorm'; 6 | import { UserRole } from '../src/app.roles'; 7 | import { createRandomString } from '../src/common/utils'; 8 | import { User } from '../src/users/entities/user.entity'; 9 | import { AuthService } from '../src/auth/auth.service'; 10 | import { UserState } from '../src/common/constants'; 11 | import { UsersService } from '../src/users/users.service'; 12 | import { Test, TestingModule } from '@nestjs/testing'; 13 | import { AppModule } from '../src/app.module'; 14 | 15 | export const buildServer = async (): Promise => { 16 | const moduleFixture: TestingModule = await Test.createTestingModule({ 17 | imports: [AppModule], 18 | }).compile(); 19 | const app = moduleFixture.createNestApplication( 20 | new FastifyAdapter(), 21 | ); 22 | await app.init(); 23 | await app.getHttpAdapter().getInstance().ready(); 24 | return app; 25 | }; 26 | 27 | export const clearDatabase = async () => { 28 | const app = await buildServer(); 29 | const dataSource = app.get(DataSource); 30 | const entities = dataSource.entityMetadatas; 31 | 32 | for await (const entity of entities) { 33 | const repository = dataSource.getRepository(entity.name); 34 | await repository.query( 35 | `TRUNCATE ${entity.tableName} RESTART IDENTITY CASCADE;`, 36 | ); 37 | } 38 | }; 39 | 40 | /** 41 | * Create new active user with the specified role 42 | * @param {NestFastifyApplication} app 43 | * @param {UserRole} role 44 | * @returns The created user 45 | */ 46 | export async function createUser( 47 | app: NestFastifyApplication, 48 | role = UserRole.MEMBER, 49 | ): Promise<{ user: User; password: string }> { 50 | const email = `${createRandomString()}@somesite.com`; 51 | const password = 'Test1234'; 52 | const ip = '123.123.123.123'; 53 | const ua = 54 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36'; 55 | const authService = app.get(AuthService); 56 | const userService = app.get(UsersService); 57 | await authService.register(email, password, ip, ua, 'somerecaptchatoken'); 58 | const user = (await userService.findByEmail(email)) as unknown as User; 59 | await userService.updateById(user.id, { 60 | state: UserState.ACTIVE, 61 | level: 1, 62 | verifyCode: null, 63 | verifyExpire: null, 64 | roles: role, 65 | }); 66 | return { 67 | user: (await userService.findByEmail(email)) as unknown as User, 68 | password, 69 | }; 70 | } 71 | 72 | export async function removeUser(id: string, app: NestFastifyApplication) { 73 | const service = app.get(UsersService); 74 | // @ts-expect-error user is private, don't want to make a getter only for this test utils 75 | return service.user.delete(id); 76 | } 77 | 78 | export async function removeResource( 79 | id: string, 80 | app: NestFastifyApplication, 81 | resourceService: any, 82 | ) { 83 | const service = app.get(resourceService); 84 | return service.deleteById(id, false); 85 | } 86 | -------------------------------------------------------------------------------- /packages/api-gateway/src/auth/token.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { JwtService } from '@nestjs/jwt'; 3 | import { TokenTypes } from '../config'; 4 | import { ConfigService } from '@nestjs/config'; 5 | 6 | export interface IJwt2FAToken { 7 | iss: string; 8 | sub: string; 9 | type: string; 10 | iat: number; 11 | } 12 | export interface IJwtPayload extends IJwt2FAToken { 13 | jti: string; 14 | } 15 | 16 | @Injectable() 17 | export class TokenService { 18 | APP_NAME: string; 19 | ACCESS_SECRET: string; 20 | REFRESH_SECRET: string; 21 | TWO_FACTOR_SECRET: string; 22 | EXP_ACCESS: string; 23 | EXP_REFRESH: string; 24 | EXP_TWO_FACTOR: string; 25 | 26 | constructor( 27 | private readonly jwt: JwtService, 28 | private readonly config: ConfigService, 29 | ) { 30 | this.APP_NAME = this.config.get('app.name') as string; 31 | this.ACCESS_SECRET = this.config.get( 32 | 'auth.secretAccessToken', 33 | ) as string; 34 | this.REFRESH_SECRET = this.config.get( 35 | 'auth.secretRefreshToken', 36 | ) as string; 37 | this.TWO_FACTOR_SECRET = this.config.get( 38 | 'auth.secret2FAToken', 39 | ) as string; 40 | this.EXP_ACCESS = this.config.get('auth.expAccessToken') as string; 41 | this.EXP_REFRESH = this.config.get( 42 | 'auth.expRefreshToken', 43 | ) as string; 44 | this.EXP_TWO_FACTOR = this.config.get('auth.exp2FAToken') as string; 45 | } 46 | 47 | async generateAccessToken( 48 | userId: string, 49 | sessionId: string, 50 | now: number, 51 | ): Promise { 52 | const payload: IJwtPayload = { 53 | iss: this.APP_NAME, 54 | sub: userId, 55 | jti: sessionId, 56 | type: TokenTypes.ACCESS, 57 | iat: now, 58 | }; 59 | const accessToken = await this.jwt.signAsync(payload, { 60 | secret: this.ACCESS_SECRET, 61 | expiresIn: this.EXP_ACCESS, 62 | }); 63 | return accessToken; 64 | } 65 | 66 | async generateRefreshToken( 67 | userId: string, 68 | sessionId: string, 69 | now: number, 70 | ): Promise { 71 | const payload: IJwtPayload = { 72 | iss: this.APP_NAME, 73 | sub: userId, 74 | jti: sessionId, 75 | type: TokenTypes.ACCESS, 76 | iat: now, 77 | }; 78 | const accessToken = await this.jwt.signAsync(payload, { 79 | secret: this.REFRESH_SECRET, 80 | expiresIn: this.EXP_REFRESH, 81 | }); 82 | return accessToken; 83 | } 84 | 85 | async generate2FAToken(userId: string): Promise { 86 | const payload: IJwt2FAToken = { 87 | iss: this.APP_NAME, 88 | sub: userId, 89 | type: TokenTypes.ACCESS, 90 | iat: Date.now(), 91 | }; 92 | const twoFactorToken = await this.jwt.signAsync(payload, { 93 | secret: this.TWO_FACTOR_SECRET, 94 | expiresIn: this.EXP_TWO_FACTOR, 95 | }); 96 | return twoFactorToken; 97 | } 98 | 99 | decode(token: string): IJwtPayload { 100 | return this.jwt.decode(token.replace('Bearer ', ''), { 101 | json: true, 102 | }) as IJwtPayload; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /packages/api-gateway/test/http-client.ts: -------------------------------------------------------------------------------- 1 | import { NestFastifyApplication } from '@nestjs/platform-fastify'; 2 | import { createHmac } from 'crypto'; 3 | 4 | export class HttpClient { 5 | app: NestFastifyApplication; 6 | constructor(app: NestFastifyApplication) { 7 | this.app = app; 8 | } 9 | 10 | async get(path: string, auth?: string, headers?: any) { 11 | const response = await this.app.inject({ 12 | method: 'GET', 13 | path, 14 | ...this.getHeaders(auth, headers), 15 | }); 16 | return this.responseHandler(response); 17 | } 18 | 19 | async post(path: string, payload?: any, auth?: string, headers?: any) { 20 | const response = await this.app.inject({ 21 | method: 'POST', 22 | path, 23 | ...this.getHeaders(auth, headers), 24 | ...(payload ? { payload } : {}), 25 | }); 26 | return this.responseHandler(response); 27 | } 28 | 29 | async put(path: string, payload?: any, auth?: string, headers?: any) { 30 | const response = await this.app.inject({ 31 | method: 'PUT', 32 | path, 33 | ...this.getHeaders(auth, headers), 34 | ...(payload ? { payload } : {}), 35 | }); 36 | return this.responseHandler(response); 37 | } 38 | 39 | async patch(path: string, payload?: any, auth?: string, headers?: any) { 40 | const response = await this.app.inject({ 41 | method: 'PATCH', 42 | path, 43 | ...this.getHeaders(auth, headers), 44 | ...(payload ? { payload } : {}), 45 | }); 46 | return this.responseHandler(response); 47 | } 48 | 49 | async del(path: string, auth?: string, headers?: any) { 50 | const response = await this.app.inject({ 51 | method: 'DELETE', 52 | path, 53 | ...this.getHeaders(auth, headers), 54 | }); 55 | return this.responseHandler(response); 56 | } 57 | 58 | async login(email: string, password: string, otp?: string, auth?: string) { 59 | const endpoint = otp ? 'otp' : 'login'; 60 | const body = otp 61 | ? { code: otp } 62 | : { 63 | email, 64 | password, 65 | recaptchaToken: 'somerecaptchatoken', 66 | }; 67 | return this.post(`/auth/${endpoint}`, body, auth); 68 | } 69 | 70 | getSignature( 71 | secretKey: string, 72 | publicKey: string, 73 | data: string, 74 | timestamp = Date.now(), 75 | recvWindow = 5000, 76 | ) { 77 | return createHmac('sha256', secretKey) 78 | .update(timestamp + publicKey + recvWindow + data) 79 | .digest('hex'); 80 | } 81 | 82 | private getHeaders(auth?: string, headers?: any) { 83 | return auth 84 | ? { 85 | headers: { 86 | ...(headers ? headers : {}), 87 | authorization: `Bearer ${auth}`, 88 | }, 89 | } 90 | : { 91 | ...(headers ? { headers } : {}), 92 | }; 93 | } 94 | 95 | private responseHandler(response: any) { 96 | const { statusCode } = response; 97 | let body = response.body; 98 | try { 99 | body = JSON.parse(body); 100 | } catch (error) {} 101 | return { 102 | statusCode: statusCode, 103 | body, 104 | }; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Package License 3 | Codecov 4 | Built with TypeScript 5 |

6 | 7 | > WARNING: This software is not ready yet, please don't use in production. There are a [lot of things to do](#to-do) and is under active development. APIs and table schemas are subject to change without notice. Technical support is unavailable at this time. 8 | 9 |

Bitify Trading Platform

10 |

:star: Star me on GitHub — it motivates me a lot!

11 | 12 | ## Table of Contents 13 | 14 | - [About](#about) 15 | - [To-Do](#to-do) 16 | - [Contributing](#contributing) 17 | - [Donation](#donation) 18 | - [License](#license) 19 | 20 | # About 21 | 22 | Bitify is an open-source trading platform for building a Blockchain/FinTech cryptocurrency exchange. 23 | 24 | ## To-Do 25 | 26 | If you want to speed up the Bitify release, please contribute to this project by implementing some of the following features: 27 | 28 | ### Must need 29 | 30 | - [ ] Test, Test and Test everything 31 | - [ ] Documentation 32 | 33 | ### Authentication Server 34 | 35 | - [x] User registration and login 36 | - [x] Email verification 37 | - [x] Forgot and reset password 38 | - [x] Two Factor Authentication 39 | - [ ] OTP Recovery 40 | - [x] Captcha 41 | - [ ] Role-Base or Attribute Base ACL 42 | - [x] API Keys with permissions and IP restriction 43 | - [ ] [KYC Verification](https://en.wikipedia.org/wiki/Know_your_customer) 44 | 45 | ### Repository 46 | 47 | - [x] Run test/lint on PR 48 | - [x] Update dependencies with dependabot or similar 49 | - [x] Code checker like CodeQL or similar 50 | - [x] Code coverage like Codecov 51 | - [x] Publish release on NPM and Github 52 | - [x] Auto changelog (each package with its own) 53 | 54 | ### Macro Area 55 | 56 | - [ ] Wallet manager 57 | - [ ] Manage Order book 58 | - [ ] Integration with payment gateway 59 | - [ ] Webscoket API 60 | - [ ] Logging 61 | - [ ] Monitoring 62 | - [ ] Rate limit 63 | - [ ] Trading interface (frontend) 64 | - [ ] Admin Panel (frontend) 65 | 66 | More to come... 67 | 68 | ## Contributing 69 | 70 | I would greatly appreciate any contributions to make this project better. Please make sure to follow the below guidelines before getting your hands dirty. 71 | 72 | 1. Fork the repository 73 | 2. Create your branch (git checkout -b my-branch) 74 | 3. Commit any changes to your branch 75 | 4. Push your changes to your remote branch 76 | 5. Open a pull request 77 | 78 | Bug fixes and features should always come with tests and documentation where needed. 79 | 80 | ## Donation 81 | 82 | If this project help you reduce time to develop, you can give me a cup of coffee 🍵 :) 83 | 84 | - USDT (TRC20): `TXArNxsq2Ee8Jvsk45PudVio52Joiq1yEe` 85 | - BTC: `1GYDVSAQNgG7MFhV5bk15XJy3qoE4NFenp` 86 | - BTC (BEP20): `0xf673ee099be8129ec05e2f549d96ebea24ac5d97` 87 | - ETH (ERC20): `0xf673ee099be8129ec05e2f549d96ebea24ac5d97` 88 | - BNB (BEP20): `0xf673ee099be8129ec05e2f549d96ebea24ac5d97` 89 | 90 | ## License 91 | 92 | Bitify is licensed under the [Apache 2.0](/LICENSE.md) license. 93 | -------------------------------------------------------------------------------- /packages/api-gateway/src/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | ConflictException, 4 | Injectable, 5 | InternalServerErrorException, 6 | UnauthorizedException, 7 | } from '@nestjs/common'; 8 | import { InjectRepository } from '@nestjs/typeorm'; 9 | import { DatabaseError } from 'pg'; 10 | import { compare, hash } from 'bcrypt'; 11 | import { User } from './entities/user.entity'; 12 | import { 13 | FindOptionsWhere, 14 | InsertResult, 15 | QueryFailedError, 16 | Repository, 17 | UpdateResult, 18 | } from 'typeorm'; 19 | import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; 20 | import { UserState } from '../common/constants'; 21 | 22 | interface IUserCreate { 23 | email: string; 24 | password: string; 25 | verifyCode: string; 26 | verifyExpire: Date; 27 | } 28 | 29 | @Injectable() 30 | export class UsersService { 31 | constructor( 32 | @InjectRepository(User) 33 | private readonly user: Repository, 34 | ) {} 35 | 36 | public findById(userId: string) { 37 | return this.user.findOneBy({ id: userId }); 38 | } 39 | 40 | public findByEmail(email: string) { 41 | return this.user.findOneBy({ email }); 42 | } 43 | 44 | public async createUser({ 45 | email, 46 | password, 47 | verifyCode, 48 | verifyExpire, 49 | }: IUserCreate): Promise { 50 | const passwordHash = await hash(password, 10); 51 | try { 52 | return await this.user.insert({ 53 | email, 54 | passwordHash, 55 | verifyCode, 56 | verifyExpire, 57 | }); 58 | } catch (error: any) { 59 | /* istanbul ignore next */ 60 | if (error instanceof QueryFailedError) { 61 | const err = error.driverError as DatabaseError; 62 | if (err.code === '23505') { 63 | throw new ConflictException('Email already registered'); 64 | } 65 | } 66 | /* istanbul ignore next */ 67 | throw new BadRequestException(); 68 | } 69 | } 70 | 71 | async validateUserPassword(email: string, password: string): Promise { 72 | const user = await this.getUserWithUnselected({ email }); 73 | 74 | if (user === null) 75 | throw new UnauthorizedException( 76 | 'You have entered an invalid email or password', 77 | ); 78 | 79 | this.validateUserAuth(user); 80 | 81 | const match = await compare(password, user.passwordHash); 82 | if (!match) 83 | throw new UnauthorizedException( 84 | 'You have entered an invalid email or password', 85 | ); 86 | return user; 87 | } 88 | 89 | validateUserAuth(user: User) { 90 | switch (user.state) { 91 | case UserState.ACTIVE: 92 | break; 93 | case UserState.PENDING: 94 | throw new UnauthorizedException('Your account is not active'); 95 | case UserState.BANNED: 96 | throw new UnauthorizedException( 97 | 'Sorry, your account is banned. Contact us for more information.', 98 | ); 99 | default: 100 | throw new InternalServerErrorException(); 101 | } 102 | } 103 | 104 | getUserWithUnselected(where: FindOptionsWhere) { 105 | return this.user.findOne({ 106 | select: this.getAllTableColumns(), 107 | where, 108 | }); 109 | } 110 | 111 | async updateById( 112 | id: string, 113 | data: QueryDeepPartialEntity, 114 | ): Promise { 115 | return this.user.update(id, data); 116 | } 117 | 118 | private getAllTableColumns(): (keyof User)[] { 119 | return this.user.metadata.columns.map( 120 | (col) => col.propertyName, 121 | ) as (keyof User)[]; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /packages/api-gateway/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bitify/api-gateway", 3 | "version": "0.1.0", 4 | "description": "", 5 | "publishConfig": { 6 | "access": "public" 7 | }, 8 | "scripts": { 9 | "build": "pnpm clean && nest build", 10 | "clean": "rm -rf coverage dist .nyc_output", 11 | "format": "prettier --list-different .", 12 | "format:fix": "prettier --write .", 13 | "start": "nest start", 14 | "start:dev": "nest start --watch", 15 | "start:dev:db": "bash ./scripts/start-db.sh", 16 | "start:debug": "nest start --debug --watch", 17 | "start:prod": "node dist/src/main", 18 | "lint": "eslint", 19 | "lint:fix": "eslint --fix", 20 | "prepublish": "pnpm build", 21 | "pretest": "node ./test/before-all-tests.js", 22 | "release": "release-it --ci", 23 | "test": "pnpm pretest && tap --ts", 24 | "test:ci": "pnpm pretest && tap --ts --coverage && tap --coverage-report=lcov", 25 | "test:cov": "pnpm pretest && tap --ts --coverage", 26 | "test:dev": "pnpm pretest && tap --ts --watch --coverage" 27 | }, 28 | "author": "Andrea Fassina ", 29 | "repository": { 30 | "type": "git", 31 | "url": "git+https://github.com/fasenderos/bitify.git" 32 | }, 33 | "license": "Apache-2.0", 34 | "bugs": { 35 | "url": "https://github.com/fasenderos/bitify/issues" 36 | }, 37 | "homepage": "https://github.com/fasenderos/bitify#readme", 38 | "dependencies": { 39 | "@fastify/static": "^6.10.2", 40 | "@nestjs-modules/mailer": "^1.9.1", 41 | "@nestjs/common": "^10.0.0", 42 | "@nestjs/config": "^3.0.0", 43 | "@nestjs/core": "^10.0.0", 44 | "@nestjs/event-emitter": "^2.0.2", 45 | "@nestjs/jwt": "^10.1.0", 46 | "@nestjs/passport": "^10.0.0", 47 | "@nestjs/platform-fastify": "^10.0.0", 48 | "@nestjs/swagger": "^7.1.8", 49 | "@nestjs/terminus": "^10.0.1", 50 | "@nestjs/typeorm": "^10.0.0", 51 | "@release-it/conventional-changelog": "^7.0.2", 52 | "bcrypt": "^5.1.1", 53 | "class-transformer": "^0.5.1", 54 | "class-validator": "^0.14.0", 55 | "cryptr": "^6.2.0", 56 | "fastify": "^4.23.2", 57 | "handlebars": "^4.7.8", 58 | "lodash": "^4.17.21", 59 | "nest-access-control": "^3.0.0", 60 | "nestjs-pino": "^3.3.0", 61 | "nestjs-real-ip": "^3.0.1", 62 | "otplib": "^12.0.1", 63 | "passport-jwt": "^4.0.1", 64 | "pg": "^8.11.1", 65 | "pino-http": "^8.3.3", 66 | "pino-pretty": "^10.2.0", 67 | "qrcode": "^1.5.3", 68 | "reflect-metadata": "^0.1.13", 69 | "release-it": "^16.1.5", 70 | "rxjs": "^7.8.1", 71 | "sanitize-html": "^2.11.0", 72 | "timestring": "^7.0.0", 73 | "typeorm": "^0.3.17", 74 | "undici": "^5.22.1" 75 | }, 76 | "devDependencies": { 77 | "@bitify/release-it": "*", 78 | "@nestjs/cli": "^10.0.0", 79 | "@nestjs/schematics": "^10.0.0", 80 | "@nestjs/testing": "^10.0.0", 81 | "@types/bcrypt": "^5.0.0", 82 | "@types/lodash": "^4.14.197", 83 | "@types/node": "^20.5.7", 84 | "@types/passport-jwt": "^3.0.9", 85 | "@types/pg": "^8.10.2", 86 | "@types/qrcode": "^1.5.1", 87 | "@types/sanitize-html": "^2.9.0", 88 | "@types/tap": "^15.0.8", 89 | "@types/timestring": "^6.0.2", 90 | "@typescript-eslint/eslint-plugin": "^6.4.1", 91 | "@typescript-eslint/parser": "^6.4.1", 92 | "dotenv": "^16.3.1", 93 | "eslint": "^8.47.0", 94 | "eslint-config-prettier": "^9.0.0", 95 | "eslint-plugin-prettier": "^5.0.0", 96 | "prettier": "^3.0.2", 97 | "source-map-support": "^0.5.21", 98 | "tap": "^16.3.7", 99 | "ts-loader": "^9.4.3", 100 | "ts-node": "^10.9.1", 101 | "tsconfig-paths": "^4.2.0", 102 | "typescript": "^5.1.3" 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /packages/api-gateway/src/auth/guards/api-key.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | CanActivate, 4 | ExecutionContext, 5 | Injectable, 6 | UnauthorizedException, 7 | UnprocessableEntityException, 8 | } from '@nestjs/common'; 9 | import { FastifyRequest } from 'fastify'; 10 | import { ApiKeysService } from '../../api-keys/api-keys.service'; 11 | import { CipherService } from '../../common/modules/cipher/cipher.service'; 12 | import { createHmac } from 'crypto'; 13 | 14 | enum ApiKeyHeaders { 15 | X_BTF_SIGN = 'x-btf-sign', 16 | X_BTF_API_KEY = 'x-btf-api-key', 17 | X_BTF_TIMESTAMP = 'x-btf-timestamp', 18 | X_BTF_RECV_WINDOW = 'x-btf-recv-window', 19 | } 20 | 21 | const { X_BTF_SIGN, X_BTF_API_KEY, X_BTF_TIMESTAMP, X_BTF_RECV_WINDOW } = 22 | ApiKeyHeaders; 23 | 24 | interface IApiKeyHeaders { 25 | [X_BTF_SIGN]: string; 26 | [X_BTF_API_KEY]: string; 27 | [X_BTF_TIMESTAMP]: number; 28 | [X_BTF_RECV_WINDOW]: number; 29 | } 30 | 31 | @Injectable() 32 | export class ApiKeyGuard implements CanActivate { 33 | constructor( 34 | private readonly apikey: ApiKeysService, 35 | private readonly cipher: CipherService, 36 | ) {} 37 | 38 | async canActivate(context: ExecutionContext): Promise { 39 | try { 40 | const request = context.switchToHttp().getRequest(); 41 | const headers = this.extractHeadersFromRequest(request); 42 | // The timestamp headers must adheres to the following rule 43 | // serverTime - recvWindow <= timestamp < serverTime + 1000 44 | const now = Date.now(); 45 | const timestamp = headers[X_BTF_TIMESTAMP]; 46 | const recvWindow = headers[X_BTF_RECV_WINDOW]; 47 | 48 | // Check that the timestamp is in the current recvWindow 49 | if (!(now - recvWindow <= timestamp && timestamp < now + 1000)) 50 | throw new UnprocessableEntityException( 51 | 'Timestamp for the request is outside of the recvWindow', 52 | ); 53 | 54 | const publicKey = headers[X_BTF_API_KEY]; 55 | const apikey = await this.apikey.findOne( 56 | { 57 | public: publicKey, 58 | }, 59 | true, 60 | ); 61 | if (!apikey) throw new UnauthorizedException(); 62 | 63 | // Check if api key is expired 64 | const expiresAt = new Date(apikey.expiresAt).getTime(); 65 | if (expiresAt > 0 && expiresAt < now) 66 | throw new UnprocessableEntityException('Your api key is expired'); 67 | 68 | // Now we have to check the HMAC signature 69 | const signature = headers[X_BTF_SIGN]; 70 | const secretKey = this.cipher.decrypt(apikey.secret); 71 | const data = this.extractDataFromRequest(request); 72 | const verifySign = createHmac('sha256', secretKey) 73 | .update(timestamp + publicKey + recvWindow + data) 74 | .digest('hex'); 75 | if (signature !== verifySign) 76 | throw new UnauthorizedException('HMAC Verification failed'); 77 | 78 | return true; 79 | } catch (error: any) { 80 | if ( 81 | error instanceof UnauthorizedException || 82 | error instanceof UnprocessableEntityException 83 | ) { 84 | throw error; 85 | } 86 | /* istanbul ignore next */ 87 | throw new BadRequestException(); 88 | } 89 | } 90 | 91 | private extractDataFromRequest(request: FastifyRequest): string { 92 | let data: string = ''; 93 | if (request.method === 'POST' && request.body) { 94 | data = JSON.stringify(request.body); 95 | } else if (request.method === 'GET' && request.query) { 96 | data = this.serializeParams(request.query); 97 | } else { 98 | /* istanbul ignore next */ 99 | throw new UnprocessableEntityException( 100 | 'Valid method for api-key are POST and GET', 101 | ); 102 | } 103 | return data; 104 | } 105 | 106 | private extractHeadersFromRequest(request: FastifyRequest): IApiKeyHeaders { 107 | if ( 108 | !request?.headers?.[X_BTF_SIGN] || 109 | !request?.headers?.[X_BTF_API_KEY] || 110 | !request?.headers?.[X_BTF_TIMESTAMP] 111 | ) 112 | throw new UnauthorizedException('Missing api key headers'); 113 | 114 | const headers: IApiKeyHeaders = { 115 | [X_BTF_SIGN]: request.headers[X_BTF_SIGN] as string, 116 | [X_BTF_API_KEY]: request.headers[X_BTF_API_KEY] as string, 117 | [X_BTF_TIMESTAMP]: parseInt(request.headers[X_BTF_TIMESTAMP] as string), 118 | [X_BTF_RECV_WINDOW]: request.headers[X_BTF_RECV_WINDOW] 119 | ? parseInt(request.headers[X_BTF_RECV_WINDOW] as string) 120 | : 5000, 121 | }; 122 | return headers; 123 | } 124 | 125 | private serializeParams(params: any = {}): string { 126 | const properties = Object.keys(params); 127 | return properties 128 | .map((key) => { 129 | // Every param value must be encoded 130 | const value = encodeURIComponent(params[key]); 131 | // Strict validation, if there are empty params throw error 132 | if (value?.length === 0) { 133 | throw new UnprocessableEntityException( 134 | 'Failed to sign API request due to undefined parameter', 135 | ); 136 | } 137 | return `${key}=${value}`; 138 | }) 139 | .join('&'); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /packages/api-gateway/src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Body, 4 | ValidationPipe, 5 | Post, 6 | Headers, 7 | Get, 8 | UseGuards, 9 | UsePipes, 10 | HttpCode, 11 | } from '@nestjs/common'; 12 | import { RealIP } from 'nestjs-real-ip'; 13 | import { RegisterDto } from './dto/register.dto'; 14 | import { LoginDto } from './dto/login.dto'; 15 | import { AuthService } from './auth.service'; 16 | import { JwtGuard } from './guards/jwt.guard'; 17 | import { JwtRefreshGuard } from './guards/jwt-refresh.guard'; 18 | import { CurrentUser } from '../common/decorators/current-user.decorator'; 19 | import { User } from '../users/entities/user.entity'; 20 | import { Verify2FADto } from './dto/verify-2fa.dto'; 21 | import { Jwt2FAGuard } from './guards/jwt-2fa.guard'; 22 | import { VerifyOTPDto } from './dto/verify-otp.dto'; 23 | import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 24 | import { SanitizeTrimPipe } from '../common/pipes/sanitize-trim.pipe'; 25 | import { I2FAResponse, IEnable2FAResponse, ILoginResponse } from './interfaces'; 26 | import { 27 | ConfirmEmailDto, 28 | ResendConfirmEmailDto, 29 | } from './dto/confirm-email.dto'; 30 | import { ForgotPasswordDto } from './dto/forgot-password.dto'; 31 | import { ResetPasswordDto } from './dto/reset-password.dto'; 32 | 33 | @ApiTags('auth') 34 | @Controller('auth') 35 | export class AuthController { 36 | constructor(private readonly auth: AuthService) {} 37 | 38 | @Post('register') 39 | @UsePipes(new SanitizeTrimPipe()) 40 | register( 41 | @Body(ValidationPipe) dto: RegisterDto, 42 | @RealIP() userIP: string, 43 | @Headers('user-agent') userAgent: string, 44 | ): Promise { 45 | const { email, password, recaptchaToken } = dto; 46 | return this.auth.register( 47 | email, 48 | password, 49 | userIP, 50 | userAgent, 51 | recaptchaToken, 52 | ); 53 | } 54 | 55 | @Post('login') 56 | @UsePipes(new SanitizeTrimPipe()) 57 | @HttpCode(200) 58 | login( 59 | @Body(ValidationPipe) dto: LoginDto, 60 | @RealIP() userIP: string, 61 | ): Promise { 62 | const { email, password, recaptchaToken } = dto; 63 | return this.auth.login(email, password, userIP, recaptchaToken); 64 | } 65 | 66 | @ApiBearerAuth() 67 | @UseGuards(JwtGuard) 68 | @Get('logout') 69 | logout(@Headers('Authorization') auth: string): Promise { 70 | return this.auth.logout(auth); 71 | } 72 | 73 | @ApiBearerAuth() 74 | @UseGuards(JwtRefreshGuard) 75 | @Get('refresh-token') 76 | refreshToken( 77 | @Headers('Authorization') auth: string, 78 | ): Promise { 79 | return this.auth.refreshToken(auth); 80 | } 81 | 82 | // Controller to verify OTP 83 | @ApiBearerAuth() 84 | @UseGuards(Jwt2FAGuard) 85 | @Post('otp') 86 | @UsePipes(new SanitizeTrimPipe()) 87 | @HttpCode(200) 88 | async verifyOTP( 89 | @CurrentUser('id') userId: string, 90 | @Body(ValidationPipe) dto: VerifyOTPDto, 91 | ): Promise { 92 | await this.auth.verifyOTP(userId, dto.otp); 93 | return this.auth.finalizeLogin(userId); 94 | } 95 | 96 | @Post('forgot-password') 97 | @HttpCode(200) 98 | forgotPassword(@Body(ValidationPipe) dto: ForgotPasswordDto): Promise { 99 | return this.auth.forgotPassword(dto.email); 100 | } 101 | 102 | @Post('reset-password') 103 | @HttpCode(200) 104 | resetPassword( 105 | @Body(ValidationPipe) dto: ResetPasswordDto, 106 | @RealIP() userIP: string, 107 | @Headers('user-agent') userAgent: string, 108 | ): Promise { 109 | const { password, token, email } = dto; 110 | return this.auth.resetPassword(password, token, email, userIP, userAgent); 111 | } 112 | 113 | @Post('confirm-email') 114 | @HttpCode(200) 115 | confirmEmail(@Body(ValidationPipe) dto: ConfirmEmailDto) { 116 | const { email, code } = dto; 117 | return this.auth.confirmEmail(email, code); 118 | } 119 | 120 | @Post('resend-confirm-email') 121 | @HttpCode(200) 122 | public async resendConfirmEmail( 123 | @Body(ValidationPipe) dto: ResendConfirmEmailDto, 124 | ): Promise { 125 | return await this.auth.resendConfirmEmail(dto.email); 126 | } 127 | 128 | // Controller that init the request to enable 2FA 129 | @ApiBearerAuth() 130 | @UseGuards(JwtGuard) 131 | @Get('enable2fa') 132 | enable2FA(@CurrentUser() user: User): Promise { 133 | return this.auth.enable2FA(user); 134 | } 135 | 136 | // Controller to verify the first time 2FA and activate 2FA for user 137 | @ApiBearerAuth() 138 | @UseGuards(JwtGuard) 139 | @Post('verify2fa') 140 | @UsePipes(new SanitizeTrimPipe()) 141 | @HttpCode(200) 142 | verify2FA( 143 | @CurrentUser('id') userId: string, 144 | @Body(ValidationPipe) dto: Verify2FADto, 145 | ): Promise { 146 | return this.auth.verify2FA(userId, dto); 147 | } 148 | 149 | // Controller to disable 2FA 150 | @ApiBearerAuth() 151 | @UseGuards(JwtGuard) 152 | @Post('disable2fa') 153 | @UsePipes(new SanitizeTrimPipe()) 154 | @HttpCode(200) 155 | async disable2FA( 156 | @CurrentUser('id') userId: string, 157 | @Body(ValidationPipe) dto: VerifyOTPDto, 158 | ): Promise { 159 | await this.auth.verifyOTP(userId, dto.otp); 160 | return this.auth.disable2FA(userId); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /packages/api-gateway/src/base/base.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DeepPartial, 3 | DeleteResult, 4 | FindManyOptions, 5 | FindOptionsWhere, 6 | Repository, 7 | UpdateResult, 8 | } from 'typeorm'; 9 | import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; 10 | import { BaseEntity } from './base.entity'; 11 | import { IBaseService } from './interfaces/base-service.interface'; 12 | import { UnprocessableEntityException } from '@nestjs/common'; 13 | 14 | export abstract class BaseService< 15 | Entity extends BaseEntity, 16 | CreateDTO extends DeepPartial, 17 | UpdateDTO extends QueryDeepPartialEntity, 18 | > implements IBaseService 19 | { 20 | constructor(readonly repo: Repository) {} 21 | 22 | /** 23 | * Instantiating the entity. 24 | * @param {CreateDTO} data The entity to be created 25 | * @param {string} userId The userId of the user owner of the resource 26 | * @returns The entity 27 | */ 28 | createEntity(data: CreateDTO, userId: string): Entity { 29 | // Instantiating the entity before saving so hooks run 30 | return this.repo.create({ ...data, userId: userId }); 31 | } 32 | 33 | /** 34 | * Saves a given entity in the database. 35 | * @param {Entity} data The entity to be created 36 | * @returns The created resource 37 | */ 38 | save(data: Entity): Promise { 39 | return this.repo.save(data); 40 | } 41 | 42 | /** 43 | * Finds entities that match given find options. 44 | * @param {FindManyOptions} options The matching conditions for finding 45 | * @returns The entities that match the conditions. 46 | */ 47 | find(options?: FindManyOptions): Promise { 48 | return this.repo.find(options); 49 | } 50 | 51 | /** 52 | * Finds first entity that matches given where condition. 53 | * If entity was not found in the database - returns null. 54 | * @param {FindOptionsWhere} filter The matching conditions for finding 55 | * @param {boolean} unselected Get all columns even those with select: false 56 | * @returns The entity that match the conditions or null. 57 | */ 58 | findOne( 59 | filter: FindOptionsWhere, 60 | unselected = false, 61 | ): Promise { 62 | if (unselected === true) { 63 | return this.repo.findOne({ 64 | select: this.getAllTableColumns(), 65 | where: filter, 66 | }); 67 | } 68 | return this.repo.findOneBy(filter); 69 | } 70 | 71 | /** 72 | * Find entity by ID. If entity was not found in the database - returns null. 73 | * @param {string} id The ID of the entity 74 | * @param {boolean} unselected Get all columns even those with select: false 75 | * @returns The entity that match the conditions or null. 76 | */ 77 | findById(id: string, unselected = false): Promise { 78 | return this.findOne({ id } as FindOptionsWhere, unselected); 79 | } 80 | 81 | /** 82 | * Updates entity by a given conditions. 83 | * Does not check if entity exist in the database. 84 | * @param {FindOptionsWhere} filter The matching conditions for updating 85 | * @param {UpdateDTO} data The payload to update the entity 86 | */ 87 | update( 88 | filter: FindOptionsWhere, 89 | data: UpdateDTO, 90 | ): Promise { 91 | // @ts-expect-error Dto should not have userId, but we check anyway at runtime 92 | if (data.userId) 93 | throw new UnprocessableEntityException('Ownership can not be changed'); 94 | 95 | return this.repo.update(filter, data); 96 | } 97 | 98 | /** 99 | * Updates entity partially by ID. 100 | * Does not check if entity exist in the database. 101 | * @param {string} id The ID of the entity to update 102 | * @param {UpdateDTO} data The payload to update the entity 103 | * @param {string} userId The userId of the user owner of the resource 104 | */ 105 | updateById( 106 | id: string, 107 | data: UpdateDTO, 108 | userId?: string, 109 | ): Promise { 110 | // @ts-expect-error Dto should not have userId, but we check anyway at runtime 111 | if (data.userId) 112 | throw new UnprocessableEntityException('Ownership can not be changed'); 113 | 114 | return this.repo.update( 115 | { 116 | id, 117 | ...(userId ? { userId: userId } : {}), 118 | } as FindOptionsWhere, 119 | data, 120 | ); 121 | } 122 | 123 | /** 124 | * By default entities are soft deleted, which means an update of the deletedAt column. 125 | * The record still exists in the database but will not be retireved by any find/update query. 126 | * When `soft` is false the entity is truly deleted 127 | * @param {FindOptionsWhere} filter The matching conditions for updating 128 | * @param {boolean} soft When true a soft delete is performed otherwise a real delete. 129 | */ 130 | delete(filter: FindOptionsWhere, soft = true): Promise { 131 | return this.repo[soft ? 'softDelete' : 'delete'](filter); 132 | } 133 | 134 | /** 135 | * By default entities are soft deleted, which means an update of the deletedAt column. 136 | * The record still exists in the database, but will not be retireved by any find/update query. 137 | * When `soft` is false the entity is truly deleted 138 | * @param {string} id The ID of the entity to update 139 | * @param {string} userId The userId of the user owner of the resource 140 | * @param {boolean} soft When true a soft delete is performed otherwise a real delete. 141 | */ 142 | deleteById(id: string, userId?: string, soft = true): Promise { 143 | return this.repo[soft ? 'softDelete' : 'delete']({ 144 | id, 145 | ...(userId ? { userId: userId } : {}), 146 | } as FindOptionsWhere); 147 | } 148 | 149 | private getAllTableColumns(): (keyof Entity)[] { 150 | return this.repo.metadata.columns.map( 151 | (col) => col.propertyName, 152 | ) as (keyof Entity)[]; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /packages/api-gateway/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.1.0 (2023-09-23) 4 | 5 | ### Features 6 | 7 | - ad api keys ([d747397](https://github.com/fasenderos/bitify/commit/d74739716e1c411115e3743548d75085893eadab)) 8 | - add base controller ([3e53f05](https://github.com/fasenderos/bitify/commit/3e53f05c90ee97f9eb9af8bacd91122d191a5dd1)) 9 | - add decorator for checkin non empty string ([c5af484](https://github.com/fasenderos/bitify/commit/c5af4849317ee7923e7afcb8bb234196d07a557f)) 10 | - add endpoint for otp and disable 2fa ([60d0871](https://github.com/fasenderos/bitify/commit/60d0871403c87f05e3e7cf1c75dd942c63833232)) 11 | - add jwt auth guard + logout endpoint ([646df2b](https://github.com/fasenderos/bitify/commit/646df2b6d1084607ca514e03b66b5fd596fe9a39)) 12 | - add more base method and some comments ([9f8946f](https://github.com/fasenderos/bitify/commit/9f8946fbcb7569cb5b1cf452f44a1ae37bac9845)) 13 | - add reCAPTCHA ([2931e13](https://github.com/fasenderos/bitify/commit/2931e13866ba2b561578666132e210ac8b7888c4)) 14 | - add refresh token ([51332f6](https://github.com/fasenderos/bitify/commit/51332f69f8f54749a4f943794bf0aa7c8b798c6a)) 15 | - add reset password ([bc8f767](https://github.com/fasenderos/bitify/commit/bc8f767eed48ea7c49cbe56ea3f47531f88f4a18)) 16 | - add sanitize pipe to base controller ([6cc899f](https://github.com/fasenderos/bitify/commit/6cc899f0882f939f978ba3439cd74072b33ab738)) 17 | - add user profile ([e87b8af](https://github.com/fasenderos/bitify/commit/e87b8afaea5ce2b77d63fab029178bffdb966778)) 18 | - api key HMAC guard + test ([2b4bed2](https://github.com/fasenderos/bitify/commit/2b4bed20b0c85fa07aab14b7825dfbaf9cdb6671)) 19 | - cipher module to encrypt/decrypt things ([fa64a5f](https://github.com/fasenderos/bitify/commit/fa64a5f1b36943fdfc4686564312473e5ad1d1d5)) 20 | - email confirmation + otp backup codes ([38e730b](https://github.com/fasenderos/bitify/commit/38e730b9a09b57788548e669b9ceb38cb2d3adcf)) 21 | - enpoints to enable and verify 2FA ([808a62b](https://github.com/fasenderos/bitify/commit/808a62b3e1151f73dd8c7d7a3abdd4f4412dc86c)) 22 | - improve auth user verification on login ([54cde5b](https://github.com/fasenderos/bitify/commit/54cde5b7cea1c1d1379fea71a81de2114db55a8f)) 23 | - improve base service and add test ([f8c3228](https://github.com/fasenderos/bitify/commit/f8c3228c563630c44064705b21292838b731645f)) 24 | - init activity tracker ([7e5d0af](https://github.com/fasenderos/bitify/commit/7e5d0af606de08d30df02f5bab7665f199197b10)) 25 | - init base RBAC ([e492e86](https://github.com/fasenderos/bitify/commit/e492e869f3eca1d36aef26bae91c6d8aa0fb5e6f)) 26 | - new GetUser decoretor to extract user from request ([47384ca](https://github.com/fasenderos/bitify/commit/47384cae82a054ea8d6290882d8e2546dd9343da)) 27 | - new pipe to trim body ([ada1308](https://github.com/fasenderos/bitify/commit/ada13084137e0d2bf74c20eac3d77853b9699a4b)) 28 | - re-send confirm email ([756cbee](https://github.com/fasenderos/bitify/commit/756cbee0053557069c008ef2f26704d51a68557b)) 29 | 30 | ### Bug Fixes 31 | 32 | - ensure encryption key is 32 chars length ([af3a086](https://github.com/fasenderos/bitify/commit/af3a0864435411763d7203b2f87d7b711e5c8f93)) 33 | - recory token logic and test ([59be82d](https://github.com/fasenderos/bitify/commit/59be82d4da68abe9fca04b6be6172f5853c09e97)) 34 | - remove backup otp codes ([aad0f24](https://github.com/fasenderos/bitify/commit/aad0f246597a4273f587173ac6e9b744be257647)) 35 | - response logger and add re captcha test ([e0022a9](https://github.com/fasenderos/bitify/commit/e0022a9c4f17693c6218a844e20927a8e4d89b7b)) 36 | 37 | ### Chore 38 | 39 | - add notes and HMAC type on apikey ([ac56fb9](https://github.com/fasenderos/bitify/commit/ac56fb9619d7c664060c60fba356eee1e4e8157c)) 40 | - add release action + auto changelog ([aa6a17b](https://github.com/fasenderos/bitify/commit/aa6a17b98a66173f917f972651d024d68decf87c)) 41 | - add userId to base entity ([10015a4](https://github.com/fasenderos/bitify/commit/10015a4a4839d6b0e575d4b6f2a8c1a14522d34e)) 42 | - **api-gateway:** build on prepublish command ([8db0d2c](https://github.com/fasenderos/bitify/commit/8db0d2c227f10585a27e394a5b3619496d948e80)) 43 | - config eslint and prettier ([60faba1](https://github.com/fasenderos/bitify/commit/60faba1164948f3c73f31a814e58a77d4bd4c826)) 44 | - remove changest ([1a3bfab](https://github.com/fasenderos/bitify/commit/1a3bfab2eaedabb3a2ecbebde5aaeb31c4c45767)) 45 | - script for coping license across the monorepo ([438ede0](https://github.com/fasenderos/bitify/commit/438ede09d132685f5a411e0a81dba6e5386a37a3)) 46 | - split lint and lint:fix command ([64dad18](https://github.com/fasenderos/bitify/commit/64dad18216ed59c65290aef0193f4d1d10407869)) 47 | 48 | ### Documentation 49 | 50 | - add licenses ([5b6b837](https://github.com/fasenderos/bitify/commit/5b6b837cec3df6bea6558c830753a6df0836411d)) 51 | - **api-gateway:** add changelog ([6ac569d](https://github.com/fasenderos/bitify/commit/6ac569dbb6e15f1deb5b5617416a32d4e44f21ed)) 52 | - **api-gateway:** init empty changelog ([1ddea49](https://github.com/fasenderos/bitify/commit/1ddea49ebb2096de5517c65978082d8bb8e25380)) 53 | - **api-gateway:** remove changelog ([d7ba8ec](https://github.com/fasenderos/bitify/commit/d7ba8ec64cb8b4883b774956a98711696d57e3e0)) 54 | - init api documentation with swagger ([db08987](https://github.com/fasenderos/bitify/commit/db08987dce3f874bd7a90f06c2f2ddca5ec30598)) 55 | - update readme ([17b57ae](https://github.com/fasenderos/bitify/commit/17b57ae7a9876423f290fb14920eb2a631fc3a80)) 56 | 57 | ### Refactoring 58 | 59 | - rename signup/in to register/login to avoid confusion ([77ad14f](https://github.com/fasenderos/bitify/commit/77ad14ff7ee67deac4bc6701139c80fde298bbaa)) 60 | - replace custom cipher with cryptr ([4f029a2](https://github.com/fasenderos/bitify/commit/4f029a24b48139623765d7de37800523a8d07004)) 61 | 62 | ### Performance Improvement 63 | 64 | - add index on userId on every entity ([40af058](https://github.com/fasenderos/bitify/commit/40af058db549f633bd3efb46aa31c185235b7dd5)) 65 | 66 | ### Test 67 | 68 | - add api keys test ([2861c10](https://github.com/fasenderos/bitify/commit/2861c1048ac054e45581956f10de70c8e0980b56)) 69 | - add auth test ([06c677f](https://github.com/fasenderos/bitify/commit/06c677f0df32424f22683f74c2028517ec119cf5)) 70 | - add codecov integration ([3a55e31](https://github.com/fasenderos/bitify/commit/3a55e316b126a1038957a9167fa39efceb5c9ec9)) 71 | - add coverage report to PR ([b009d4a](https://github.com/fasenderos/bitify/commit/b009d4a615aac70f209e03442165300bd8fdbdcf)) 72 | - add missing email from env to cicd ([d0d2f03](https://github.com/fasenderos/bitify/commit/d0d2f032fda8fbed3086c6c362d4306d76b13013)) 73 | - add trim test ([9d6d8f1](https://github.com/fasenderos/bitify/commit/9d6d8f11752001cfbfda0d53bf1cf3785084fa8f)) 74 | - apikey find, update and delete ([7e5bc07](https://github.com/fasenderos/bitify/commit/7e5bc07e7e14433ab8735382dfcd4c291fa6d5b6)) 75 | - change coverage format to lcov ([23b929d](https://github.com/fasenderos/bitify/commit/23b929d77982be8981999cc9e0e689428e9b6b2b)) 76 | - clear DB on every test ([be24eda](https://github.com/fasenderos/bitify/commit/be24eda4bbe74f2271fdc99c9f10aa1b28999b0b)) 77 | - fix before-all-tests.js for cicd ([252306d](https://github.com/fasenderos/bitify/commit/252306d47ecbb2f0ecf38c4e00e9e44ba9bb6ce7)) 78 | - fix config test ([2408979](https://github.com/fasenderos/bitify/commit/240897939fa25a7601c032eade238809470a76be)) 79 | - run tap on node 20 ([eeb802a](https://github.com/fasenderos/bitify/commit/eeb802aa3f11e105e4a25597efe646f49073f31e)) 80 | - test login for user banned/pending ([a5b571e](https://github.com/fasenderos/bitify/commit/a5b571e41c2251ebe750637579a978bfc02ea056)) 81 | - try to fix codecov coverage ([fc7ebf3](https://github.com/fasenderos/bitify/commit/fc7ebf3bf1a4462ee9e1a25001d62ebb3c39cf12)) 82 | -------------------------------------------------------------------------------- /packages/api-gateway/src/base/base.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Delete, 4 | Get, 5 | HttpCode, 6 | HttpStatus, 7 | NotFoundException, 8 | Param, 9 | ParseUUIDPipe, 10 | Patch, 11 | Post, 12 | Type, 13 | UseGuards, 14 | UsePipes, 15 | } from '@nestjs/common'; 16 | import { AbstractValidationPipe } from '../common/pipes/abstract-validation.pipe'; 17 | import { IBaseController } from './interfaces/base-controller.interface'; 18 | import { IBaseService } from './interfaces/base-service.interface'; 19 | import { BaseEntity } from './base.entity'; 20 | import { CurrentUser } from '../common/decorators/current-user.decorator'; 21 | import { JwtGuard } from '../auth/guards/jwt.guard'; 22 | import { UserRole } from '../app.roles'; 23 | import { DeepPartial, FindManyOptions, FindOptionsWhere } from 'typeorm'; 24 | import { Collections } from '../common/constants'; 25 | import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; 26 | import { ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; 27 | import { User } from '../users/entities/user.entity'; 28 | import { ACGuard, UseRoles } from 'nest-access-control'; 29 | import { SanitizeTrimPipe } from '../common/pipes/sanitize-trim.pipe'; 30 | 31 | /** 32 | * Every controller in the application can use the ControllerFactory in order 33 | * to avoid code duplication. Magically you have all CRUD methods to create, read, 34 | * updated and delete an entity protected by the Role Base ACL and the api documentaion. 35 | */ 36 | export function ControllerFactory< 37 | Entity extends BaseEntity & { userId?: string }, 38 | CreateDTO extends DeepPartial, 39 | UpdateDTO extends QueryDeepPartialEntity, 40 | >( 41 | createDto: Type, 42 | updateDto: Type, 43 | resource: Collections, 44 | ): Type> { 45 | const createPipe = new AbstractValidationPipe( 46 | { 47 | forbidNonWhitelisted: true, 48 | forbidUnknownValues: true, 49 | whitelist: true, 50 | transform: true, 51 | stopAtFirstError: true, 52 | }, 53 | { body: createDto }, 54 | ); 55 | const updatePipe = new AbstractValidationPipe( 56 | { 57 | forbidNonWhitelisted: true, 58 | forbidUnknownValues: true, 59 | whitelist: true, 60 | transform: true, 61 | stopAtFirstError: true, 62 | }, 63 | { body: updateDto }, 64 | ); 65 | 66 | class BaseController< 67 | Entity extends BaseEntity & { userId?: string }, 68 | CreateDTO extends DeepPartial, 69 | UpdateDTO extends QueryDeepPartialEntity, 70 | > implements IBaseController 71 | { 72 | protected readonly service: IBaseService; 73 | constructor(service: IBaseService) { 74 | this.service = service; 75 | } 76 | 77 | /** 78 | * Create a given entity 79 | * @param {CreateDTO} dto The entity to be created 80 | * @param {User} user The user that is making the request 81 | * @returns The created resource 82 | */ 83 | @Post() 84 | @HttpCode(HttpStatus.CREATED) 85 | @UseGuards(JwtGuard, ACGuard) 86 | @UseRoles({ resource, action: 'create', possession: 'own' }) 87 | @UsePipes(createPipe) 88 | @UsePipes(SanitizeTrimPipe) 89 | @ApiBearerAuth() 90 | @ApiResponse({ 91 | status: HttpStatus.CREATED, 92 | description: 'The record has been successfully created.', 93 | }) 94 | @ApiResponse({ 95 | status: HttpStatus.UNAUTHORIZED, 96 | description: 'Unauthorized - No credentials or invalid credentials', 97 | }) 98 | @ApiResponse({ 99 | status: HttpStatus.FORBIDDEN, 100 | description: 'Forbidden - Not enough privileges to create the resource', 101 | }) 102 | async create( 103 | @Body() dto: CreateDTO, 104 | @CurrentUser() user: User, 105 | ): Promise { 106 | // Instantiating the entity 107 | const entity = this.service.createEntity(dto, user.id); 108 | // Create owned resource 109 | return this.service.save(entity); 110 | } 111 | 112 | /** 113 | * Find entity by ID. If entity was not found in the database - returns null. 114 | * @param {string} id The ID of the entity 115 | * @param {User} user The user that is making the request 116 | * @returns The entity that match the conditions or null. 117 | */ 118 | @Get(':id') 119 | @HttpCode(HttpStatus.OK) 120 | @UseGuards(JwtGuard, ACGuard) 121 | @UseRoles({ resource, action: 'read', possession: 'own' }) 122 | @ApiBearerAuth() 123 | @ApiResponse({ 124 | status: HttpStatus.OK, 125 | description: 'Return the record or null.', 126 | }) 127 | @ApiResponse({ 128 | status: HttpStatus.UNAUTHORIZED, 129 | description: 'Unauthorized - No credentials or invalid credentials', 130 | }) 131 | @ApiResponse({ 132 | status: HttpStatus.FORBIDDEN, 133 | description: 'Forbidden - Not enough privileges to read the resource', 134 | }) 135 | @ApiResponse({ 136 | status: HttpStatus.NOT_FOUND, 137 | description: 'Not found - The record not exist', 138 | }) 139 | async findById( 140 | @Param('id', ParseUUIDPipe) id: string, 141 | @CurrentUser() user: User, 142 | ): Promise { 143 | // Admin can view any resource 144 | if (user.roles.includes(UserRole.ADMIN)) { 145 | return this.service.findById(id); 146 | } 147 | // Member can view owned resource only 148 | const entity = await this.service.findOne({ 149 | id, 150 | userId: user.id, 151 | } as unknown as FindOptionsWhere); 152 | if (!entity) throw new NotFoundException(); 153 | 154 | return entity; 155 | } 156 | 157 | /** 158 | * Find all entities. 159 | * @param {User} user The user that is making the request 160 | * @returns All the entities 161 | */ 162 | @Get() 163 | @HttpCode(HttpStatus.OK) 164 | @UseGuards(JwtGuard, ACGuard) 165 | @UseRoles({ resource, action: 'read', possession: 'own' }) 166 | @ApiBearerAuth() 167 | @ApiResponse({ 168 | status: HttpStatus.OK, 169 | description: 'Return an array of records.', 170 | }) 171 | @ApiResponse({ 172 | status: HttpStatus.UNAUTHORIZED, 173 | description: 'Unauthorized - No credentials or invalid credentials', 174 | }) 175 | @ApiResponse({ 176 | status: HttpStatus.FORBIDDEN, 177 | description: 'Forbidden - Not enough privileges to read the resources', 178 | }) 179 | findAll(@CurrentUser() user: User): Promise { 180 | // Admin can view any resources 181 | if (user.roles.includes(UserRole.ADMIN)) { 182 | return this.service.find(); 183 | } 184 | // Member can view owned resources only 185 | return this.service.find({ 186 | where: { userId: user.id }, 187 | } as unknown as FindManyOptions); 188 | } 189 | 190 | /** 191 | * Updates entity by a given conditions. 192 | * @param {string} id The ID of the entity 193 | * @param {UpdateDTO} dto The payload to update the entity 194 | * @param {User} user The user that is making the request 195 | * @returns NO_CONTENT 196 | */ 197 | @Patch(':id') 198 | @HttpCode(HttpStatus.NO_CONTENT) 199 | @UseGuards(JwtGuard, ACGuard) 200 | @UseRoles({ resource, action: 'update', possession: 'own' }) 201 | @UsePipes(updatePipe) 202 | @UsePipes(SanitizeTrimPipe) 203 | @ApiBearerAuth() 204 | @ApiResponse({ 205 | status: HttpStatus.NO_CONTENT, 206 | description: 'The record has been successfully updated.', 207 | }) 208 | @ApiResponse({ 209 | status: HttpStatus.UNAUTHORIZED, 210 | description: 'Unauthorized - No credentials or invalid credentials', 211 | }) 212 | @ApiResponse({ 213 | status: HttpStatus.FORBIDDEN, 214 | description: 'Forbidden - Not enough privileges to update the resource', 215 | }) 216 | @ApiResponse({ 217 | status: HttpStatus.NOT_FOUND, 218 | description: 'Not found - The record not exist', 219 | }) 220 | async updateById( 221 | @Param('id', ParseUUIDPipe) id: string, 222 | @Body() dto: UpdateDTO, 223 | @CurrentUser() user: User, 224 | ): Promise { 225 | // Update owned resource 226 | const result = await this.service.updateById(id, dto, user.id); 227 | if (result.affected === 0) throw new NotFoundException(); 228 | } 229 | 230 | /** 231 | * Soft delete entity by ID, which means an update of the deletedAt column. 232 | * The record still exists in the database, but will not be retireved by any find/update query. 233 | * @param {string} id The ID of the entity 234 | * @param {User} user The user that is making the request 235 | * @returns NO_CONTENT 236 | */ 237 | @Delete(':id') 238 | @HttpCode(HttpStatus.NO_CONTENT) 239 | @UseGuards(JwtGuard, ACGuard) 240 | @UseRoles({ resource, action: 'delete', possession: 'own' }) 241 | @ApiBearerAuth() 242 | @ApiResponse({ 243 | status: HttpStatus.NO_CONTENT, 244 | description: 'The record has been successfully deleted.', 245 | }) 246 | @ApiResponse({ 247 | status: HttpStatus.UNAUTHORIZED, 248 | description: 'Unauthorized - No credentials or invalid credentials', 249 | }) 250 | @ApiResponse({ 251 | status: HttpStatus.FORBIDDEN, 252 | description: 'Forbidden - Not enough privileges to delete the resource', 253 | }) 254 | @ApiResponse({ 255 | status: HttpStatus.NOT_FOUND, 256 | description: 'Not found - The record not exist', 257 | }) 258 | async deleteById( 259 | @Param('id', ParseUUIDPipe) id: string, 260 | @CurrentUser() user: User, 261 | ): Promise { 262 | // Delete owned resource 263 | const result = await this.service.deleteById(id, user.id); 264 | if (result.affected === 0) throw new NotFoundException(); 265 | } 266 | } 267 | 268 | return BaseController; 269 | } 270 | -------------------------------------------------------------------------------- /packages/api-gateway/test/app.e2e.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'tap'; 2 | import { buildServer, createUser } from './helper'; 3 | import { HttpClient } from './http-client'; 4 | import { HttpStatus } from '@nestjs/common'; 5 | import { ApiKeysService } from '../src/api-keys/api-keys.service'; 6 | 7 | test('/ping should return "pong"', async ({ equal, same, teardown }) => { 8 | const app = await buildServer(); 9 | teardown(async () => await app.close()); 10 | 11 | const { statusCode, payload } = await app.inject({ 12 | method: 'GET', 13 | url: '/ping', 14 | }); 15 | equal(statusCode, HttpStatus.OK); 16 | same(JSON.parse(payload), {}); 17 | }); 18 | 19 | test('/time should return server time', async ({ equal, teardown }) => { 20 | const app = await buildServer(); 21 | teardown(async () => await app.close()); 22 | 23 | const { statusCode, payload } = await app.inject({ 24 | method: 'GET', 25 | url: '/time', 26 | }); 27 | const data = JSON.parse(payload); 28 | equal(statusCode, HttpStatus.OK); 29 | equal(typeof data.serverTime, 'number'); 30 | }); 31 | 32 | test('/health should return server status', async ({ 33 | equal, 34 | same, 35 | teardown, 36 | }) => { 37 | const app = await buildServer(); 38 | teardown(async () => await app.close()); 39 | 40 | const { statusCode, payload } = await app.inject({ 41 | method: 'GET', 42 | url: '/health', 43 | }); 44 | const data = JSON.parse(payload); 45 | equal(statusCode, HttpStatus.OK); 46 | equal(data.status, 'ok'); 47 | same(data.error, {}); 48 | }); 49 | 50 | test('test api-key authentication', async ({ equal, teardown }) => { 51 | const app = await buildServer(); 52 | teardown(async () => await app.close()); 53 | const http = new HttpClient(app); 54 | 55 | const { user, password } = await createUser(app); 56 | const login = await http.login(user.email, password); 57 | const auth = login.body.accessToken; 58 | 59 | const mockBody = { 60 | notes: 'My Api Key', 61 | spot: 'read', 62 | wallet: 'read-write', 63 | }; 64 | const response = await http.post('/apikeys', mockBody, auth); 65 | const { id, public: publicKey, secret: secretKey } = response.body; 66 | const timestamp = Date.now(); 67 | const recvWindow = 5000; 68 | const params = 'foo=bar&baz=qux'; 69 | const url = '/api/test-get?' + params; 70 | 71 | // Test valid signature for GET method with params 72 | { 73 | const signature = http.getSignature( 74 | secretKey, 75 | publicKey, 76 | params, 77 | timestamp, 78 | ); 79 | const response = await http.get(url, undefined, { 80 | 'X-BTF-SIGN': signature, 81 | 'X-BTF-API-KEY': publicKey, 82 | 'X-BTF-TIMESTAMP': timestamp, 83 | 'X-BTF-RECV-WINDOW': recvWindow, 84 | }); 85 | equal(response.statusCode, HttpStatus.OK); 86 | } 87 | 88 | // Test valid signature for GET method without params 89 | { 90 | const signature = http.getSignature(secretKey, publicKey, '', timestamp); 91 | const response = await http.get('/api/test-get', undefined, { 92 | 'X-BTF-SIGN': signature, 93 | 'X-BTF-API-KEY': publicKey, 94 | 'X-BTF-TIMESTAMP': timestamp, 95 | 'X-BTF-RECV-WINDOW': recvWindow, 96 | }); 97 | equal(response.statusCode, HttpStatus.OK); 98 | } 99 | 100 | // Test valid signature for GET method with an empty param 101 | { 102 | const param = 'foo=bar&baz'; 103 | const signature = http.getSignature(secretKey, publicKey, param, timestamp); 104 | const response = await http.get(`/api/test-get?${param}`, undefined, { 105 | 'X-BTF-SIGN': signature, 106 | 'X-BTF-API-KEY': publicKey, 107 | 'X-BTF-TIMESTAMP': timestamp, 108 | 'X-BTF-RECV-WINDOW': recvWindow, 109 | }); 110 | equal(response.statusCode, HttpStatus.UNPROCESSABLE_ENTITY); 111 | } 112 | 113 | // Test get method without required headers 114 | { 115 | const signature = http.getSignature(secretKey, publicKey, params); 116 | { 117 | // No headers at all 118 | const response = await http.get(url); 119 | equal(response.statusCode, HttpStatus.UNAUTHORIZED); 120 | } 121 | 122 | { 123 | // Missing signature 124 | const response = await http.get(url, undefined, { 125 | 'X-BTF-API-KEY': publicKey, 126 | 'X-BTF-TIMESTAMP': timestamp, 127 | }); 128 | equal(response.statusCode, HttpStatus.UNAUTHORIZED); 129 | } 130 | { 131 | // Missing apikey 132 | const response = await http.get(url, undefined, { 133 | 'X-BTF-SIGN': signature, 134 | 'X-BTF-TIMESTAMP': timestamp, 135 | }); 136 | equal(response.statusCode, HttpStatus.UNAUTHORIZED); 137 | } 138 | { 139 | // Missing timestamp 140 | const response = await http.get(url, undefined, { 141 | 'X-BTF-SIGN': signature, 142 | 'X-BTF-API-KEY': publicKey, 143 | }); 144 | equal(response.statusCode, HttpStatus.UNAUTHORIZED); 145 | } 146 | } 147 | 148 | const body = '{"foo":"bar","baz":"qux"}'; 149 | // Test valid signature for POST method with body 150 | { 151 | const signature = http.getSignature(secretKey, publicKey, body, timestamp); 152 | const response = await http.post( 153 | '/api/test-post', 154 | JSON.parse(body), 155 | undefined, 156 | { 157 | 'X-BTF-SIGN': signature, 158 | 'X-BTF-API-KEY': publicKey, 159 | 'X-BTF-TIMESTAMP': timestamp, 160 | 'X-BTF-RECV-WINDOW': recvWindow, 161 | }, 162 | ); 163 | equal(response.statusCode, HttpStatus.OK); 164 | } 165 | // Test not valid signature 166 | { 167 | const signature = http.getSignature(secretKey, publicKey, body, timestamp); 168 | const response = await http.post( 169 | '/api/test-post', 170 | JSON.parse(body), 171 | undefined, 172 | { 173 | 'X-BTF-SIGN': signature + 'somebrokenstring', 174 | 'X-BTF-API-KEY': publicKey, 175 | 'X-BTF-TIMESTAMP': timestamp, 176 | 'X-BTF-RECV-WINDOW': recvWindow, 177 | }, 178 | ); 179 | equal(response.statusCode, HttpStatus.UNAUTHORIZED); 180 | } 181 | 182 | // The timestamp headers must adheres to the following rule 183 | // serverTime - recvWindow <= timestamp < serverTime + 1000 184 | // Test POST requests 185 | { 186 | { 187 | // Test for serverTime - recvWindow <= timestamp 188 | const timestamp = Date.now() - 5100; 189 | const signature = http.getSignature( 190 | secretKey, 191 | publicKey, 192 | body, 193 | timestamp, 194 | ); 195 | const response = await http.post( 196 | '/api/test-post', 197 | JSON.parse(body), 198 | undefined, 199 | { 200 | 'X-BTF-SIGN': signature, 201 | 'X-BTF-API-KEY': publicKey, 202 | 'X-BTF-TIMESTAMP': timestamp, 203 | 'X-BTF-RECV-WINDOW': recvWindow, 204 | }, 205 | ); 206 | equal(response.statusCode, HttpStatus.UNPROCESSABLE_ENTITY); 207 | equal( 208 | response.body.message, 209 | 'Timestamp for the request is outside of the recvWindow', 210 | ); 211 | } 212 | { 213 | // Test for timestamp < serverTime + 1000 214 | const timestamp = Date.now() + 1100; 215 | const signature = http.getSignature( 216 | secretKey, 217 | publicKey, 218 | body, 219 | timestamp, 220 | ); 221 | const response = await http.post( 222 | '/api/test-post', 223 | JSON.parse(body), 224 | undefined, 225 | { 226 | 'X-BTF-SIGN': signature, 227 | 'X-BTF-API-KEY': publicKey, 228 | 'X-BTF-TIMESTAMP': timestamp, 229 | 'X-BTF-RECV-WINDOW': recvWindow, 230 | }, 231 | ); 232 | equal(response.statusCode, HttpStatus.UNPROCESSABLE_ENTITY); 233 | equal( 234 | response.body.message, 235 | 'Timestamp for the request is outside of the recvWindow', 236 | ); 237 | } 238 | } 239 | 240 | // Test GET requests 241 | { 242 | { 243 | // Test for serverTime - recvWindow <= timestamp 244 | const timestamp = Date.now() - 5100; 245 | const signature = http.getSignature( 246 | secretKey, 247 | publicKey, 248 | params, 249 | timestamp, 250 | ); 251 | const response = await http.get(url, undefined, { 252 | 'X-BTF-SIGN': signature, 253 | 'X-BTF-API-KEY': publicKey, 254 | 'X-BTF-TIMESTAMP': timestamp, 255 | 'X-BTF-RECV-WINDOW': recvWindow, 256 | }); 257 | equal(response.statusCode, HttpStatus.UNPROCESSABLE_ENTITY); 258 | equal( 259 | response.body.message, 260 | 'Timestamp for the request is outside of the recvWindow', 261 | ); 262 | } 263 | { 264 | // Test for timestamp < serverTime + 1000 265 | const timestamp = Date.now() + 1100; 266 | const signature = http.getSignature( 267 | secretKey, 268 | publicKey, 269 | params, 270 | timestamp, 271 | ); 272 | const response = await http.get(url, undefined, { 273 | 'X-BTF-SIGN': signature, 274 | 'X-BTF-API-KEY': publicKey, 275 | 'X-BTF-TIMESTAMP': timestamp, 276 | 'X-BTF-RECV-WINDOW': recvWindow, 277 | }); 278 | equal(response.statusCode, HttpStatus.UNPROCESSABLE_ENTITY); 279 | equal( 280 | response.body.message, 281 | 'Timestamp for the request is outside of the recvWindow', 282 | ); 283 | } 284 | } 285 | 286 | // Check for api key expired 287 | { 288 | const apikeyService = app.get(ApiKeysService); 289 | // Set key as expired 1 minute ago 290 | await apikeyService.updateById( 291 | id, 292 | { 293 | // @ts-expect-error expiresAt can not be updated by api 294 | expiresAt: new Date(Date.now() - 60 * 1000), 295 | }, 296 | user.id, 297 | ); 298 | const signature = http.getSignature( 299 | secretKey, 300 | publicKey, 301 | params, 302 | timestamp, 303 | ); 304 | const response = await http.get(url, undefined, { 305 | 'X-BTF-SIGN': signature, 306 | 'X-BTF-API-KEY': publicKey, 307 | 'X-BTF-TIMESTAMP': timestamp, 308 | 'X-BTF-RECV-WINDOW': recvWindow, 309 | }); 310 | equal(response.statusCode, HttpStatus.UNPROCESSABLE_ENTITY); 311 | equal(response.body.message, 'Your api key is expired'); 312 | } 313 | }); 314 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /packages/core/LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /libs/release-it/LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /packages/api-gateway/LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /packages/api-gateway/test/api-keys/api-keys.e2e.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'tap'; 2 | import { buildServer, createUser } from '../helper'; 3 | import { HttpStatus } from '@nestjs/common'; 4 | import { ApiKey } from '../../src/api-keys/entities/api-key.entity'; 5 | import { HttpClient } from '../http-client'; 6 | 7 | test('shoul create api key', async ({ equal, teardown }) => { 8 | const app = await buildServer(); 9 | teardown(async () => await app.close()); 10 | const http = new HttpClient(app); 11 | 12 | const { user, password } = await createUser(app); 13 | const login = await http.login(user.email, password); 14 | const auth = login.body.accessToken; 15 | 16 | const mockBody = { 17 | notes: 'My Api Key', 18 | userIps: ['123.123.123.123'], 19 | spot: 'read', 20 | wallet: 'read-write', 21 | }; 22 | // Test no auth 23 | const noAuth = await http.post('/apikeys', mockBody); 24 | equal(noAuth.statusCode, HttpStatus.UNAUTHORIZED); 25 | 26 | // Test with wrong body. One of spot or wallet is required 27 | const wrongBody = await http.post( 28 | '/apikeys', 29 | { 30 | notes: mockBody.notes, 31 | userIps: mockBody.userIps, 32 | }, 33 | auth, 34 | ); 35 | equal(wrongBody.statusCode, HttpStatus.BAD_REQUEST); 36 | 37 | // Test with right body 38 | let response = await http.post('/apikeys', mockBody, auth); 39 | const first = response.body as ApiKey; 40 | let statusCode = response.statusCode; 41 | equal(statusCode, HttpStatus.CREATED); 42 | equal(first.public.length > 0, true); 43 | equal(first.secret.length > 0, true); 44 | equal(first.notes, mockBody.notes); 45 | equal(new Date(first.expiresAt).getTime(), 0); // UserIP are set 46 | 47 | response = await http.post( 48 | '/apikeys', 49 | { 50 | notes: mockBody.notes, 51 | spot: mockBody.spot, 52 | }, 53 | auth, 54 | ); 55 | const second = response.body as ApiKey; 56 | statusCode = response.statusCode; 57 | equal(statusCode, HttpStatus.CREATED); 58 | equal(second.public.length > 0, true); 59 | equal(second.secret.length > 0, true); 60 | equal(second.notes, mockBody.notes); 61 | // When there is no ip, api key expires in 90 days 62 | equal(new Date(second.expiresAt).getTime() > Date.now(), true); 63 | }); 64 | 65 | test('shoul find owned api keys', async ({ equal, teardown }) => { 66 | const app = await buildServer(); 67 | teardown(async () => await app.close()); 68 | const http = new HttpClient(app); 69 | 70 | const { user: user1, password: password1 } = await createUser(app); 71 | const { user: user2, password: password2 } = await createUser(app); 72 | 73 | const login1 = await http.login(user1.email, password1); 74 | const login2 = await http.login(user2.email, password2); 75 | const auth1 = login1.body.accessToken; 76 | const auth2 = login2.body.accessToken; 77 | const mockBody = { 78 | notes: 'My Api Key', 79 | userIps: ['123.123.123.123'], 80 | spot: 'read', 81 | wallet: 'read-write', 82 | }; 83 | 84 | const apiKeyUser1 = await http.post('/apikeys', mockBody, auth1); 85 | const apiKeyUser2 = await http.post('/apikeys', mockBody, auth2); 86 | 87 | // User should be able to get only owned apikey by id 88 | { 89 | // User 1 90 | const res = await http.get(`/apikeys/${apiKeyUser1.body.id}`, auth1); 91 | equal(res.statusCode, HttpStatus.OK); 92 | equal(res.body.id, apiKeyUser1.body.id); 93 | 94 | // Should be unauthorized if no auth is provided 95 | { 96 | const res = await http.get(`/apikeys/${apiKeyUser1.body.id}`); 97 | equal(res.statusCode, HttpStatus.UNAUTHORIZED); 98 | } 99 | // Should be unauthorized if wrong auth is provided 100 | { 101 | const res = await http.get( 102 | `/apikeys/${apiKeyUser1.body.id}`, 103 | 'somewrongaccesstoken', 104 | ); 105 | equal(res.statusCode, HttpStatus.UNAUTHORIZED); 106 | } 107 | } 108 | { 109 | // User 2 110 | const res = await http.get(`/apikeys/${apiKeyUser2.body.id}`, auth2); 111 | equal(res.statusCode, HttpStatus.OK); 112 | equal(res.body.id, apiKeyUser2.body.id); 113 | } 114 | 115 | // Should not able to get api key of other user by id 116 | { 117 | // User 1 118 | const res = await http.get(`/apikeys/${apiKeyUser2.body.id}`, auth1); 119 | equal(res.statusCode, HttpStatus.NOT_FOUND); 120 | } 121 | { 122 | // User 2 123 | const res = await http.get(`/apikeys/${apiKeyUser1.body.id}`, auth2); 124 | equal(res.statusCode, HttpStatus.NOT_FOUND); 125 | } 126 | 127 | // Should be able to find all owned apikeys 128 | { 129 | // User 1 130 | const res = await http.get(`/apikeys`, auth1); 131 | equal(res.statusCode, HttpStatus.OK); 132 | equal(Array.isArray(res.body), true); 133 | equal(res.body.length, 1); 134 | equal(res.body[0].id, apiKeyUser1.body.id); 135 | // Should be unauthorized if no auth is provided 136 | { 137 | const res = await http.get('/apikeys'); 138 | equal(res.statusCode, HttpStatus.UNAUTHORIZED); 139 | } 140 | // Should be unauthorized if wrong auth is provided 141 | { 142 | const res = await http.get('/apikeys', 'somewrongaccesstoken'); 143 | equal(res.statusCode, HttpStatus.UNAUTHORIZED); 144 | } 145 | } 146 | 147 | { 148 | // User 2 149 | const res = await http.get(`/apikeys`, auth2); 150 | equal(res.statusCode, HttpStatus.OK); 151 | equal(Array.isArray(res.body), true); 152 | equal(res.body.length, 1); 153 | equal(res.body[0].id, apiKeyUser2.body.id); 154 | } 155 | }); 156 | 157 | test('shoul update owned api keys', async ({ equal, teardown }) => { 158 | const app = await buildServer(); 159 | teardown(async () => await app.close()); 160 | const http = new HttpClient(app); 161 | 162 | const { user: user1, password: password1 } = await createUser(app); 163 | const { user: user2, password: password2 } = await createUser(app); 164 | 165 | const login1 = await http.login(user1.email, password1); 166 | const login2 = await http.login(user2.email, password2); 167 | const auth1 = login1.body.accessToken; 168 | const auth2 = login2.body.accessToken; 169 | const mockBody = { 170 | notes: 'My Api Key', 171 | userIps: ['123.123.123.123'], 172 | spot: 'read', 173 | wallet: 'read-write', 174 | }; 175 | 176 | const apiKeyUser1 = await http.post('/apikeys', mockBody, auth1); 177 | const apiKeyUser2 = await http.post('/apikeys', mockBody, auth2); 178 | 179 | const mockUpdate = { 180 | spot: 'read-write', 181 | userIps: null, 182 | }; 183 | 184 | // User should be able to update only owned apikey by id 185 | { 186 | // User 1 187 | // Before the update expiresAt should be 0 188 | equal(new Date(apiKeyUser1.body.expiresAt).getTime(), 0); 189 | // After the update expiresAt should be a date 190 | const res = await http.patch( 191 | `/apikeys/${apiKeyUser1.body.id}`, 192 | mockUpdate, 193 | auth1, 194 | ); 195 | equal(res.statusCode, HttpStatus.NO_CONTENT); 196 | const updated = await http.get(`/apikeys/${apiKeyUser1.body.id}`, auth1); 197 | equal(updated.body.spot, mockUpdate.spot); 198 | equal(updated.body.userIps, null); 199 | equal(new Date(updated.body.expiresAt).getTime() > Date.now(), true); 200 | } 201 | { 202 | // User 2 203 | // Before the update expiresAt should be 0 204 | equal(new Date(apiKeyUser2.body.expiresAt).getTime(), 0); 205 | // After the update expiresAt should be a date 206 | const res = await http.patch( 207 | `/apikeys/${apiKeyUser2.body.id}`, 208 | mockUpdate, 209 | auth2, 210 | ); 211 | equal(res.statusCode, HttpStatus.NO_CONTENT); 212 | const updated = await http.get(`/apikeys/${apiKeyUser2.body.id}`, auth2); 213 | equal(updated.body.spot, mockUpdate.spot); 214 | equal(updated.body.userIps, null); 215 | equal(new Date(updated.body.expiresAt).getTime() > Date.now(), true); 216 | } 217 | 218 | // Should not able to update api key of other user by id 219 | { 220 | // User 1 221 | const res = await http.patch( 222 | `/apikeys/${apiKeyUser2.body.id}`, 223 | mockUpdate, 224 | auth1, 225 | ); 226 | equal(res.statusCode, HttpStatus.NOT_FOUND); 227 | } 228 | { 229 | // User 2 230 | const res = await http.patch( 231 | `/apikeys/${apiKeyUser1.body.id}`, 232 | mockUpdate, 233 | auth2, 234 | ); 235 | equal(res.statusCode, HttpStatus.NOT_FOUND); 236 | } 237 | }); 238 | 239 | test('shoul delete owned api keys', async ({ equal, teardown }) => { 240 | const app = await buildServer(); 241 | teardown(async () => await app.close()); 242 | const http = new HttpClient(app); 243 | 244 | const { user: user1, password: password1 } = await createUser(app); 245 | const { user: user2, password: password2 } = await createUser(app); 246 | 247 | const login1 = await http.login(user1.email, password1); 248 | const login2 = await http.login(user2.email, password2); 249 | const auth1 = login1.body.accessToken; 250 | const auth2 = login2.body.accessToken; 251 | const mockBody = { 252 | notes: 'My Api Key', 253 | userIps: ['123.123.123.123'], 254 | spot: 'read', 255 | wallet: 'read-write', 256 | }; 257 | 258 | const apiKeyUser1 = await http.post('/apikeys', mockBody, auth1); 259 | const apiKeyUser2 = await http.post('/apikeys', mockBody, auth2); 260 | 261 | // Should not able to delete api key of other user by id 262 | { 263 | // User 1 264 | const res = await http.del(`/apikeys/${apiKeyUser2.body.id}`, auth1); 265 | equal(res.statusCode, HttpStatus.NOT_FOUND); 266 | } 267 | { 268 | // User 2 269 | const res = await http.del(`/apikeys/${apiKeyUser1.body.id}`, auth2); 270 | equal(res.statusCode, HttpStatus.NOT_FOUND); 271 | } 272 | 273 | // User should be able to delete only owned apikey by id 274 | { 275 | // User 1 276 | const res = await http.del(`/apikeys/${apiKeyUser1.body.id}`, auth1); 277 | equal(res.statusCode, HttpStatus.NO_CONTENT); 278 | const deleted = await http.get(`/apikeys/${apiKeyUser1.body.id}`, auth1); 279 | equal(deleted.statusCode, HttpStatus.NOT_FOUND); 280 | } 281 | { 282 | // User 2 283 | const res = await http.del(`/apikeys/${apiKeyUser2.body.id}`, auth2); 284 | equal(res.statusCode, HttpStatus.NO_CONTENT); 285 | const deleted = await http.get(`/apikeys/${apiKeyUser2.body.id}`, auth2); 286 | equal(deleted.statusCode, HttpStatus.NOT_FOUND); 287 | } 288 | }); 289 | -------------------------------------------------------------------------------- /packages/api-gateway/test/base/base-service.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'tap'; 2 | import { buildServer, createUser } from '../helper'; 3 | import { UnprocessableEntityException } from '@nestjs/common'; 4 | import { RecoveryTokensService } from '../../src/recovery-tokens/recovery-tokens.service'; 5 | import { RecoveryToken } from '../../src/recovery-tokens/entities/recovery-token.entity'; 6 | import { randomUUID } from 'crypto'; 7 | 8 | // For testing the base service we use the recovery-token service 9 | // which simply extend the base service without any modification 10 | // TODO does make sense use a fake entity for that? in case 11 | test('base service creatEntity() and save() method', async ({ 12 | equal, 13 | teardown, 14 | }) => { 15 | const app = await buildServer(); 16 | teardown(async () => await app.close()); 17 | 18 | const service = app.get(RecoveryTokensService); 19 | const { user } = await createUser(app); 20 | 21 | const mockBody = { 22 | token: 'somerecoverytoken', 23 | expiresAt: new Date(), 24 | }; 25 | 26 | // Test createEntity() method with right params 27 | const tokenEntity = service.createEntity(mockBody, user.id); 28 | equal(tokenEntity instanceof RecoveryToken, true); 29 | 30 | // test save() method with right param 31 | const token = await service.save(tokenEntity); 32 | equal(token instanceof RecoveryToken, true); 33 | }); 34 | 35 | test('base service find() method', async ({ equal, teardown }) => { 36 | const app = await buildServer(); 37 | teardown(async () => await app.close()); 38 | 39 | const service = app.get(RecoveryTokensService); 40 | const { user } = await createUser(app); 41 | 42 | const mockBody = { 43 | token: 'somerecoverytoken', 44 | expiresAt: new Date(), 45 | }; 46 | const tokenEntity = service.createEntity(mockBody, user.id); 47 | const token = await service.save(tokenEntity); 48 | 49 | // Test find() method without params 50 | { 51 | const res = await service.find(); 52 | equal(Array.isArray(res), true); 53 | res.forEach((token) => { 54 | equal(token instanceof RecoveryToken, true); 55 | }); 56 | } 57 | 58 | // Test find() method with not valid filter 59 | { 60 | const res = await service.find({ where: { id: randomUUID() } }); 61 | equal(Array.isArray(res), true); 62 | equal(res.length, 0); 63 | } 64 | 65 | // Test find() method with valid filter 66 | { 67 | const res = await service.find({ where: { id: token.id } }); 68 | equal(Array.isArray(res), true); 69 | equal(res.length, 1); 70 | } 71 | }); 72 | 73 | test('base service findOne() method', async ({ equal, teardown }) => { 74 | const app = await buildServer(); 75 | teardown(async () => await app.close()); 76 | 77 | const service = app.get(RecoveryTokensService); 78 | const { user } = await createUser(app); 79 | 80 | const mockBody = { 81 | token: 'somerecoverytoken', 82 | expiresAt: new Date(), 83 | }; 84 | const tokenEntity = service.createEntity(mockBody, user.id); 85 | const token = await service.save(tokenEntity); 86 | 87 | // Test findOne() method invalid ID 88 | { 89 | const res = await service.findOne({ id: randomUUID() }); 90 | equal(res, null); 91 | } 92 | 93 | // Test findOne() method with valid ID 94 | { 95 | const res = await service.findOne({ id: token.id }); 96 | equal(res instanceof RecoveryToken, true); 97 | equal(res?.id, token.id); 98 | } 99 | }); 100 | 101 | test('base service findById() method', async ({ equal, teardown }) => { 102 | const app = await buildServer(); 103 | teardown(async () => await app.close()); 104 | 105 | const service = app.get(RecoveryTokensService); 106 | const { user } = await createUser(app); 107 | 108 | const mockBody = { 109 | token: 'somerecoverytoken', 110 | expiresAt: new Date(), 111 | }; 112 | const tokenEntity = service.createEntity(mockBody, user.id); 113 | const token = await service.save(tokenEntity); 114 | 115 | // Test findById() method invalid ID 116 | { 117 | const res = await service.findById(randomUUID()); 118 | equal(res, null); 119 | } 120 | 121 | // Test findById() method with valid ID 122 | { 123 | const res = await service.findById(token.id); 124 | equal(res instanceof RecoveryToken, true); 125 | equal(res?.id, token.id); 126 | } 127 | }); 128 | 129 | test('base service update() method', async ({ equal, teardown }) => { 130 | const app = await buildServer(); 131 | teardown(async () => await app.close()); 132 | 133 | const service = app.get(RecoveryTokensService); 134 | const { user } = await createUser(app); 135 | 136 | const mockBody = { 137 | token: 'somerecoverytoken', 138 | expiresAt: new Date(), 139 | }; 140 | const tokenEntity = service.createEntity(mockBody, user.id); 141 | const token = await service.save(tokenEntity); 142 | 143 | // Test update() method with right params 144 | { 145 | await service.update({ id: token.id }, { token: 'newtokenUpdate' }); 146 | const response = await service.findById(token.id); 147 | equal(response?.token, 'newtokenUpdate'); 148 | } 149 | { 150 | // An update can not pass userId 151 | try { 152 | await service.update( 153 | { id: token.id }, 154 | { token: 'newtokenWithUseId', userId: user.id }, 155 | ); 156 | } catch (error) { 157 | equal(error instanceof UnprocessableEntityException, true); 158 | } 159 | } 160 | }); 161 | 162 | test('base service updateById() method', async ({ equal, teardown }) => { 163 | const app = await buildServer(); 164 | teardown(async () => await app.close()); 165 | 166 | const service = app.get(RecoveryTokensService); 167 | const { user } = await createUser(app); 168 | 169 | const mockBody = { 170 | token: 'somerecoverytoken', 171 | expiresAt: new Date(), 172 | }; 173 | const tokenEntity = service.createEntity(mockBody, user.id); 174 | const token = await service.save(tokenEntity); 175 | 176 | // Test updatedById() method with right params 177 | { 178 | await service.updateById(token.id, { 179 | token: 'newtokenupdateById', 180 | }); 181 | const response = await service.findById(token.id); 182 | equal(response?.token, 'newtokenupdateById'); 183 | } 184 | // Test updatedById() method with right params and the userId 185 | { 186 | await service.updateById( 187 | token.id, 188 | { 189 | token: 'newtokenupdateByIdUserId', 190 | }, 191 | user.id, 192 | ); 193 | const response = await service.findById(token.id); 194 | equal(response?.token, 'newtokenupdateByIdUserId'); 195 | } 196 | { 197 | // An updatedById can not pass userId 198 | try { 199 | await service.updateById(token.id, { 200 | token: 'newtokenWithUserId', 201 | userId: user.id, 202 | }); 203 | } catch (error) { 204 | equal(error instanceof UnprocessableEntityException, true); 205 | } 206 | } 207 | }); 208 | 209 | test('base service delete() method', async ({ equal, teardown }) => { 210 | const app = await buildServer(); 211 | teardown(async () => await app.close()); 212 | 213 | const service = app.get(RecoveryTokensService); 214 | const { user } = await createUser(app); 215 | 216 | const mockBody = { 217 | token: 'somerecoverytoken', 218 | expiresAt: new Date(), 219 | }; 220 | const tokenEntity = service.createEntity(mockBody, user.id); 221 | const token = await service.save(tokenEntity); 222 | 223 | // Test delete() method with right params 224 | { 225 | await service.delete({ id: token.id }); 226 | const response = await service.findById(token.id); 227 | equal(response, null); 228 | { 229 | // restore soft deleted token 230 | await service.repo.update({ id: token.id }, { deletedAt: null }); 231 | // token should now exist 232 | const response = await service.findById(token.id); 233 | equal(response instanceof RecoveryToken, true); 234 | } 235 | } 236 | 237 | // Test delete() method with soft = false 238 | { 239 | await service.delete({ id: token.id }, false); 240 | const response = await service.findById(token.id); 241 | equal(response, null); 242 | { 243 | // try to restore deleted token 244 | await service.repo.update({ id: token.id }, { deletedAt: null }); 245 | // token should not exist 246 | const response = await service.findById(token.id); 247 | equal(response, null); 248 | } 249 | } 250 | }); 251 | 252 | test('base service deleteById() method', async ({ equal, teardown }) => { 253 | const app = await buildServer(); 254 | teardown(async () => await app.close()); 255 | 256 | const service = app.get(RecoveryTokensService); 257 | const { user } = await createUser(app); 258 | 259 | const mockBody = { 260 | token: 'somerecoverytoken', 261 | expiresAt: new Date(), 262 | }; 263 | const tokenEntity = service.createEntity(mockBody, user.id); 264 | const token = await service.save(tokenEntity); 265 | 266 | // Test deleteById() method without passing userId 267 | { 268 | await service.deleteById(token.id); 269 | const response = await service.findById(token.id); 270 | equal(response, null); 271 | { 272 | // restore soft deleted token 273 | await service.repo.update({ id: token.id }, { deletedAt: null }); 274 | // token should now exist 275 | const response = await service.findById(token.id); 276 | equal(response instanceof RecoveryToken, true); 277 | } 278 | } 279 | 280 | // Test deleteById() method with userId 281 | { 282 | await service.deleteById(token.id, user.id); 283 | const response = await service.findById(token.id); 284 | equal(response, null); 285 | { 286 | // restore soft deleted token 287 | await service.repo.update({ id: token.id }, { deletedAt: null }); 288 | // token should now exist 289 | const response = await service.findById(token.id); 290 | equal(response instanceof RecoveryToken, true); 291 | } 292 | } 293 | 294 | // Test deleteById() method with soft = false 295 | { 296 | await service.deleteById(token.id, user.id, false); 297 | const response = await service.findById(token.id); 298 | equal(response, null); 299 | { 300 | // try to restore deleted token 301 | await service.repo.update({ id: token.id }, { deletedAt: null }); 302 | // token should not exist 303 | const response = await service.findById(token.id); 304 | equal(response, null); 305 | } 306 | } 307 | }); 308 | --------------------------------------------------------------------------------