diff --git a/.deploy/deployment.yaml b/.deploy/deployment.yaml index d3fa018c..2c005201 100644 --- a/.deploy/deployment.yaml +++ b/.deploy/deployment.yaml @@ -27,9 +27,12 @@ spec: failureThreshold: 3 resources: - limits: + requests: memory: "2Gi" - cpu: "1000m" + cpu: "4000m" + limits: + memory: "4Gi" + cpu: "8000m" ports: - containerPort: __PORT__ env: @@ -59,8 +62,12 @@ spec: value: __JWT_SECRET__ - name: GLOBAL_TXN_CONTROLLER_QUEUE value: __GLOBAL_TXN_CONTROLLER_QUEUE__ + - name: Tx_Query_API + value: __Tx_Query_API__ - name: RABBIT_MQ_URI value: __RABBIT_MQ_URI__ + - name: NODE_ENV + value: __NODE_ENV__ - name: WHITELISTED_CORS value: "['https://entity.hypersign.id','https://api.entity.hypersign.id','https://api.entity-test.hypersign.id','https://wallet-prajna.hypersign.id']" volumeMounts: @@ -87,6 +94,7 @@ spec: - port: __PORT__ targetPort: __PORT__ protocol: TCP + # --- # apiVersion: apps/v1 # kind: Deployment @@ -132,6 +140,3 @@ spec: # - port: 8080 # targetPort: 8080 # protocol: TCP - - - diff --git a/.deploy/hpa2.yaml b/.deploy/hpa2.yaml new file mode 100644 index 00000000..9ac07855 --- /dev/null +++ b/.deploy/hpa2.yaml @@ -0,0 +1,25 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: entity-api-test + namespace: hypermine-development +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: entity-api-test + minReplicas: 1 + maxReplicas: 5 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 70 diff --git a/.deploy/ingress.yaml b/.deploy/ingress.yaml index da56d048..8c52efdf 100644 --- a/.deploy/ingress.yaml +++ b/.deploy/ingress.yaml @@ -14,57 +14,42 @@ metadata: spec: tls: - - secretName: entity-ssl - hosts: - - "api.entity.hypersign.id" - - "*.api.entity.hypersign.id" - + - secretName: entity-ssl + hosts: + - "api.entity.hypersign.id" + - "*.api.entity.hypersign.id" rules: - - host: "api.entity.hypersign.id" - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: entity-api-service - port: - number: 3001 - - path: /ssi/ - pathType: Prefix - backend: - service: - name: entity-api-service - port: - number: 3001 - - path: /api/ - pathType: Prefix - backend: - service: - name: entity-api-service - port: - number: 3001 - - host: "*.api.entity.hypersign.id" - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: entity-api-service - port: - number: 3001 - - path: /ssi/ - pathType: Prefix - backend: - service: - name: entity-api-service - port: - number: 3001 - - path: /api/ - pathType: Prefix - backend: - service: - name: entity-api-service - port: - number: 3001 + - host: "api.entity.hypersign.id" + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: entity-api-service + port: + number: 3001 + - path: /api/ + pathType: Prefix + backend: + service: + name: entity-api-service + port: + number: 3001 + - host: "*.api.entity.hypersign.id" + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: entity-api-service + port: + number: 3001 + - path: /api/ + pathType: Prefix + backend: + service: + name: entity-api-service + port: + number: 3001 diff --git a/.deploy/vpa.yaml b/.deploy/vpa.yaml new file mode 100644 index 00000000..8d9e14f2 --- /dev/null +++ b/.deploy/vpa.yaml @@ -0,0 +1,21 @@ +apiVersion: autoscaling.k8s.io/v1 +kind: VerticalPodAutoscaler +metadata: + name: entity-api-vpa + namespace: hypermine-development +spec: + targetRef: + apiVersion: apps/v1 + kind: Deployment + name: entity-api + updatePolicy: + updateMode: "Auto" # Options: "Off", "Initial", "Auto" + resourcePolicy: + containerPolicies: + - containerName: "*" + minAllowed: + cpu: "2000m" + memory: "2Gi" + maxAllowed: + cpu: "8000m" + memory: "4Gi" diff --git a/.github/workflows/CI-CD.yaml b/.github/workflows/CI-CD.yaml index b0a987c0..988bbc17 100644 --- a/.github/workflows/CI-CD.yaml +++ b/.github/workflows/CI-CD.yaml @@ -5,117 +5,120 @@ on: # branches: # - "master" tags: - - "[0-9]+.[0-9]+.[0-9]+" - - "[0-9]+.[0-9]+.[0-9]-rc.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]-rc.[0-9]+" jobs: Build: runs-on: ubuntu-22.04 environment: production steps: - - name: code checkout - uses: actions/checkout@v3 - - name: Set Latest Tag - run: echo "LATEST_RELEASE_TAG=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV + - name: code checkout + uses: actions/checkout@v3 + - name: Set Latest Tag + run: echo "LATEST_RELEASE_TAG=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV - - id: "auth" - uses: "google-github-actions/auth@v1" - with: - credentials_json: "${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}" + - id: "auth" + uses: "google-github-actions/auth@v1" + with: + credentials_json: "${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}" - - name: install gcloud cli tools - uses: google-github-actions/setup-gcloud@v1 - with: - project_id: ${{secrets.GOOGLE_PROJECT_ID}} - service_account_key: ${{secrets.GOOGLE_APPLICATION_CREDENTIALS}} - install_components: "gke-gcloud-auth-plugin" - export_default_credentials: true - - name: "Use gcloud CLI" - run: "gcloud info" + - name: install gcloud cli tools + uses: google-github-actions/setup-gcloud@v1 + with: + project_id: ${{secrets.GOOGLE_PROJECT_ID}} + service_account_key: ${{secrets.GOOGLE_APPLICATION_CREDENTIALS}} + install_components: "gke-gcloud-auth-plugin" + export_default_credentials: true + - name: "Use gcloud CLI" + run: "gcloud info" - - name: "Docker Auth" - env: - GOOGLE_ARTIFACT_URL: ${{secrets.GOOGLE_ARTIFACT_URL}} + - name: "Docker Auth" + env: + GOOGLE_ARTIFACT_URL: ${{secrets.GOOGLE_ARTIFACT_URL}} - run: gcloud auth configure-docker $GOOGLE_ARTIFACT_URL + run: gcloud auth configure-docker $GOOGLE_ARTIFACT_URL - - name: "Docker Build and Push" - env: - GOOGLE_PROJECT_ID: ${{secrets.GOOGLE_PROJECT_ID}} - GOOGLE_ARTIFACT_URL: ${{secrets.GOOGLE_ARTIFACT_URL}} - GOOGLE_ARTIFACT_REPO: ${{secrets.GOOGLE_ARTIFACT_REPO}} - run: - docker build -t $GOOGLE_ARTIFACT_URL/$GOOGLE_PROJECT_ID/$GOOGLE_ARTIFACT_REPO/enity-api-service:${{ env.LATEST_RELEASE_TAG }} . + - name: "Docker Build and Push" + env: + GOOGLE_PROJECT_ID: ${{secrets.GOOGLE_PROJECT_ID}} + GOOGLE_ARTIFACT_URL: ${{secrets.GOOGLE_ARTIFACT_URL}} + GOOGLE_ARTIFACT_REPO: ${{secrets.GOOGLE_ARTIFACT_REPO}} + run: docker build -t $GOOGLE_ARTIFACT_URL/$GOOGLE_PROJECT_ID/$GOOGLE_ARTIFACT_REPO/enity-api-service:${{ env.LATEST_RELEASE_TAG }} . - docker push $GOOGLE_ARTIFACT_URL/$GOOGLE_PROJECT_ID/$GOOGLE_ARTIFACT_REPO/enity-api-service:${{ env.LATEST_RELEASE_TAG }} - - name: "Docker Build and Push" - env: - GOOGLE_PROJECT_ID: ${{secrets.GOOGLE_PROJECT_ID}} - GOOGLE_ARTIFACT_URL: ${{secrets.GOOGLE_ARTIFACT_URL}} - GOOGLE_ARTIFACT_REPO: ${{secrets.GOOGLE_ARTIFACT_REPO}} - run: - docker build -t $GOOGLE_ARTIFACT_URL/$GOOGLE_PROJECT_ID/$GOOGLE_ARTIFACT_REPO/nginx-entity:latest ./nginx/ + docker push $GOOGLE_ARTIFACT_URL/$GOOGLE_PROJECT_ID/$GOOGLE_ARTIFACT_REPO/enity-api-service:${{ env.LATEST_RELEASE_TAG }} + # - name: "Docker Build and Push" + # env: + # GOOGLE_PROJECT_ID: ${{secrets.GOOGLE_PROJECT_ID}} + # GOOGLE_ARTIFACT_URL: ${{secrets.GOOGLE_ARTIFACT_URL}} + # GOOGLE_ARTIFACT_REPO: ${{secrets.GOOGLE_ARTIFACT_REPO}} + # run: + # docker build -t $GOOGLE_ARTIFACT_URL/$GOOGLE_PROJECT_ID/$GOOGLE_ARTIFACT_REPO/nginx-entity:latest ./nginx/ - docker push $GOOGLE_ARTIFACT_URL/$GOOGLE_PROJECT_ID/$GOOGLE_ARTIFACT_REPO/nginx-entity:latest + # docker push $GOOGLE_ARTIFACT_URL/$GOOGLE_PROJECT_ID/$GOOGLE_ARTIFACT_REPO/nginx-entity:latest Deploy: - needs: [Build] + needs: [ Build ] runs-on: ubuntu-latest environment: production steps: - - name: code checkout - uses: actions/checkout@v3 - - name: Set Latest Tag - run: echo "LATEST_RELEASE_TAG=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV + - name: code checkout + uses: actions/checkout@v3 + - name: Set Latest Tag + run: echo "LATEST_RELEASE_TAG=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV - - id: "auth" - uses: "google-github-actions/auth@v1" - with: - credentials_json: "${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}" - - name: install gcloud cli tools - uses: google-github-actions/setup-gcloud@v1 - with: - project_id: ${{secrets.GOOGLE_PROJECT_ID}} - service_account_key: ${{secrets.GOOGLE_APPLICATION_CREDENTIALS}} - install_components: "gke-gcloud-auth-plugin" - export_default_credentials: true - - name: "Configure kubectl" - run: gcloud container clusters get-credentials hypermine-gke --region=asia-south1 - - name: Replace tags - run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__LATEST_RELEASE_TAG__#${{ env.LATEST_RELEASE_TAG }}#" {} \; - - name: "Replace secrets" - run: find .deploy/deployment.yaml -type f -exec sed -i ''s/__PORT__/${{ secrets.PORT }}/g'' {} \; - - name: "Replace secrets" - run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__BASE_DB_PATH__#${{ secrets.BASE_DB_PATH }}#" {} \; - - name: "Replace secrets" - run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__DB_CONFIG__#${{ secrets.DB_CONFIG }}#" {} \; - - name: "Replace secrets" - run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__HID_NETWORK_RPC__#${{ secrets.HID_NETWORK_RPC }}#" {} \; - - name: "Replace secrets" - run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__HID_NETWORK_API__#${{ secrets.HID_NETWORK_API }}#" {} \; - - name: "Replace secrets" - run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__HID_NETWORK_NAMESPACE__#${{ secrets.HID_NETWORK_NAMESPACE }}#" {} \; - - name: "Replace secrets" - run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__EDV_BASE_URL__#${{ secrets.EDV_BASE_URL }}#" {} \; - - name: "Replace secrets" - run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__EDV_CONFIG_DIR__#${{ secrets.EDV_CONFIG_DIR }}#" {} \; - - name: "Replace secrets" - run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__EDV_DID_FILE_PATH__#${{ secrets.EDV_DID_FILE_PATH }}#" {} \; - - name: "Replace secrets" - run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__EDV_KEY_FILE_PATH__#${{ secrets.EDV_KEY_FILE_PATH }}#" {} \; - - name: "Replace secrets" - run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__GLOBAL_TXN_CONTROLLER_QUEUE__#${{ secrets.GLOBAL_TXN_CONTROLLER_QUEUE }}#" {} \; - - name: "Replace secrets" - run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__RABBIT_MQ_URI__#${{ secrets.RABBIT_MQ_URI }}#" {} \; - - name: "Replace secrets" - run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__MNEMONIC__#${{ secrets.MNEMONIC }}#" {} \; - - name: "Replace secrets" - run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__JWT_SECRET__#${{ secrets.JWT_SECRET }}#" {} \; - - name: "Replace secrets" - run: find .deploy/deployment.yaml -type f -exec sed -i ''s/__GOOGLE_ARTIFACT_URL__/${{ secrets.GOOGLE_ARTIFACT_URL }}/g'' {} \; - - name: "Replace secrets" - run: find .deploy/deployment.yaml -type f -exec sed -i ''s/__GOOGLE_ARTIFACT_REPO__/${{ secrets.GOOGLE_ARTIFACT_REPO }}/g'' {} \; - - name: "Replace secrets" - run: find .deploy/deployment.yaml -type f -exec sed -i ''s/__GOOGLE_PROJECT_ID__/${{ secrets.GOOGLE_PROJECT_ID }}/g'' {} \; - - name: "Deploy to GKE" - run: kubectl apply -f .deploy/deployment.yaml + - id: "auth" + uses: "google-github-actions/auth@v1" + with: + credentials_json: "${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}" + - name: install gcloud cli tools + uses: google-github-actions/setup-gcloud@v1 + with: + project_id: ${{secrets.GOOGLE_PROJECT_ID}} + service_account_key: ${{secrets.GOOGLE_APPLICATION_CREDENTIALS}} + install_components: "gke-gcloud-auth-plugin" + export_default_credentials: true + - name: "Configure kubectl" + run: gcloud container clusters get-credentials hypermine-gke-manual --region=asia-south1 + - name: Replace tags + run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__LATEST_RELEASE_TAG__#${{ env.LATEST_RELEASE_TAG }}#" {} \; + - name: "Replace secrets" + run: find .deploy/deployment.yaml -type f -exec sed -i ''s/__PORT__/${{ secrets.PORT }}/g'' {} \; + - name: "Replace secrets" + run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__BASE_DB_PATH__#${{ secrets.BASE_DB_PATH }}#" {} \; + - name: "Replace secrets" + run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__DB_CONFIG__#${{ secrets.DB_CONFIG }}#" {} \; + - name: "Replace secrets" + run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__HID_NETWORK_RPC__#${{ secrets.HID_NETWORK_RPC }}#" {} \; + - name: "Replace secrets" + run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__HID_NETWORK_API__#${{ secrets.HID_NETWORK_API }}#" {} \; + - name: "Replace secrets" + run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__HID_NETWORK_NAMESPACE__#${{ secrets.HID_NETWORK_NAMESPACE }}#" {} \; + - name: "Replace secrets" + run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__EDV_BASE_URL__#${{ secrets.EDV_BASE_URL }}#" {} \; + - name: "Replace secrets" + run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__EDV_CONFIG_DIR__#${{ secrets.EDV_CONFIG_DIR }}#" {} \; + - name: "Replace secrets" + run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__EDV_DID_FILE_PATH__#${{ secrets.EDV_DID_FILE_PATH }}#" {} \; + - name: "Replace secrets" + run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__EDV_KEY_FILE_PATH__#${{ secrets.EDV_KEY_FILE_PATH }}#" {} \; + - name: "Replace secrets" + run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__GLOBAL_TXN_CONTROLLER_QUEUE__#${{ secrets.GLOBAL_TXN_CONTROLLER_QUEUE }}#" {} \; + - name: "Replace secrets" + run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__RABBIT_MQ_URI__#${{ secrets.RABBIT_MQ_URI }}#" {} \; + - name: "Replace secrets" + run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__MNEMONIC__#${{ secrets.MNEMONIC }}#" {} \; + - name: "Replace secrets" + run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__Tx_Query_API__#${{ secrets.Tx_Query_API }}#" {} \; + - name: "Replace secrets" + run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__JWT_SECRET__#${{ secrets.JWT_SECRET }}#" {} \; + - name: "Replace secrets" + run: find .deploy/deployment.yaml -type f -exec sed -i ''s/__GOOGLE_ARTIFACT_URL__/${{ secrets.GOOGLE_ARTIFACT_URL }}/g'' {} \; + - name: "Replace secrets" + run: find .deploy/deployment.yaml -type f -exec sed -i ''s/__GOOGLE_ARTIFACT_REPO__/${{ secrets.GOOGLE_ARTIFACT_REPO }}/g'' {} \; + - name: "Replace secrets" + run: find .deploy/deployment.yaml -type f -exec sed -i -e "s#__NODE_ENV__#${{ secrets.NODE_ENV }}#" {} \; + - name: "Replace secrets" + run: find .deploy/deployment.yaml -type f -exec sed -i ''s/__GOOGLE_PROJECT_ID__/${{ secrets.GOOGLE_PROJECT_ID }}/g'' {} \; + - name: "Deploy to GKE" + run: kubectl apply -f .deploy/deployment.yaml diff --git a/Dockerfile b/Dockerfile index 329d9d82..a9c8c237 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,4 @@ FROM node:18 -RUN npm install -g npm@latest WORKDIR /usr/src/app COPY ./package.json . RUN npx patch-package -y @@ -11,5 +10,5 @@ ENV NODE_OPTIONS="--openssl-legacy-provider --max-old-space-size=4096" RUN npm install COPY . . RUN npm run build -CMD ["npm","run","start"] +CMD ["npm","run","start:prod"] diff --git a/docker-compose.yml b/docker-compose.yml index 34db08fa..38aca776 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,7 @@ services: SUPER_ADMIN_USERNAME: "root" SUPER_ADMIN_PASSWORD: "root" SESSION_SECRET_KEY: "43bf9ba55e72565d" + NODE_ENV: "production" depends_on: - "issuer-database" - "edv.entity.id" diff --git a/env.sample b/env.sample index 21f12f84..bf37cc54 100644 --- a/env.sample +++ b/env.sample @@ -31,4 +31,6 @@ VAULT_PREFIX='hs:developer-dashboard:' # Prefix which gets attached to every new tenant subdomain TENANT_SUBDOMAIN_PREFIX='ent-' SERVICE_SUFFIX="SSI_API" +NODE_ENV= 'production' +// development diff --git a/package.json b/package.json index 1778a7d8..e212fe04 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "start": "nest start", "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", + "start:prod": "UV_THREADPOOL_SIZE='7' node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest", "test:newman": "ts-node ./test/promptValue.ts && newman run studio-api.postman_collection.json -e environment.json", @@ -31,6 +31,7 @@ "@nestjs/config": "^2.2.0", "@nestjs/core": "^9.0.0", "@nestjs/jwt": "^10.0.1", + "node-cache": "^5.1.2", "@nestjs/mongoose": "^9.2.1", "@nestjs/passport": "^9.0.0", "@nestjs/platform-express": "^9.0.0", @@ -43,9 +44,10 @@ "express-session": "^1.17.3", "fs": "^0.0.1-security", "hid-hd-wallet": "git+https://github.com/hypersign-protocol/hid-hd-wallet.git#main", - "hs-ssi-sdk": "github:hypersign-protocol/hid-ssi-js-sdk#testcase/bjj", + "hs-ssi-sdk": "github:hypersign-protocol/hid-ssi-js-sdk#multiThreading", "hypersign-edv-client": "github:hypersign-protocol/hypersign-edv-client#deleteByDocumentId", "idb-keyval": "^6.2.1", + "ioredis": "^5.8.2", "mongoose": "^6.8.3", "passport": "^0.6.0", "passport-jwt": "^4.0.1", diff --git a/src/app.module.ts b/src/app.module.ts index bb649722..acdcf9b3 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { EdvModule } from './edv/edv.module'; import { AllExceptionsFilter } from './utils/utils'; @@ -8,6 +8,11 @@ import { SchemaModule } from './schema/schema.module'; import { CredentialModule } from './credential/credential.module'; import { PresentationModule } from './presentation/presentation.module'; import { TxSendModuleModule } from './tx-send-module/tx-send-module.module'; +import { StatusModule } from './status/status.module'; +import { CreditManagerModule } from './credit-manager/credit-manager.module'; +import { LogModule } from './log/log.module'; +import { AppLoggerMiddleware } from './utils/interceptor/http-interceptor'; +import { UsageModule } from './usage/usage.module'; @Module({ imports: [ ConfigModule.forRoot({ @@ -20,6 +25,10 @@ import { TxSendModuleModule } from './tx-send-module/tx-send-module.module'; CredentialModule, PresentationModule, TxSendModuleModule, + StatusModule, + CreditManagerModule, + LogModule, + UsageModule, ], controllers: [], providers: [{ provide: APP_FILTER, useClass: AllExceptionsFilter }], diff --git a/src/credential/controllers/credential.controller.ts b/src/credential/controllers/credential.controller.ts index 8f26a893..c862032a 100644 --- a/src/credential/controllers/credential.controller.ts +++ b/src/credential/controllers/credential.controller.ts @@ -49,13 +49,18 @@ import { Credential } from '../schemas/credntial.schema'; import { GetCredentialList } from '../dto/fetch-credential.dto'; import { RegisterCredentialStatusDto } from '../dto/register-credential.dto'; import { TxnHash } from 'src/did/dto/create-did.dto'; +import { ReduceCreditGuard } from 'src/credit-manager/gaurd/reduce-credit.gaurd'; +import { AccessGuard } from 'src/utils/guards/access.gaurd'; +import { Access } from 'src/utils/customDecorator/access.decorator'; +import { ACCESS_TYPES } from 'src/credit-manager/utils'; @ApiBearerAuth('Authorization') -@UseGuards(AuthGuard('jwt')) +@UseGuards(AuthGuard('jwt'), ReduceCreditGuard, AccessGuard) @Controller('credential') @ApiTags('Credential') export class CredentialController { constructor(private readonly credentialService: CredentialService) {} @UsePipes(new ValidationPipe({ transform: true })) + @Access(ACCESS_TYPES.READ_CREDENTIAL) @Get() @ApiOkResponse({ description: 'List of credentials', @@ -87,6 +92,11 @@ export class CredentialController { description: 'Fetch limited list of data', required: false, }) + @ApiQuery({ + name: 'issuerDid', + description: 'Filter by Issuer DID', + required: false, + }) @UseInterceptors(CredentialResponseInterceptor) findAll( @Headers('Authorization') authorization: string, @@ -96,7 +106,7 @@ export class CredentialController { Logger.log('CredentialController:: findAll() method: starts....'); return this.credentialService.findAll(req.user, pageOption); } - + @Access(ACCESS_TYPES.READ_CREDENTIAL) @Get(':credentialId') @ApiOkResponse({ description: 'Resolved credential detail', @@ -138,6 +148,7 @@ export class CredentialController { } @UsePipes(new ValidationPipe({ transform: true })) + @Access(ACCESS_TYPES.WRITE_CREDENTIAL) @Post('/issue') @ApiCreatedResponse({ description: 'Credential Created', @@ -173,6 +184,7 @@ export class CredentialController { } @UsePipes(ValidationPipe) @HttpCode(200) + @Access(ACCESS_TYPES.VERIFY_CREDENTIAL) @Post('/verify') @ApiOkResponse({ description: 'verification result of credential', @@ -209,7 +221,7 @@ export class CredentialController { req.user, ); } - + @Access(ACCESS_TYPES.WRITE_CREDENTIAL) @UsePipes(ValidationPipe) @Post('status/register') @ApiOkResponse({ diff --git a/src/credential/credential.module.ts b/src/credential/credential.module.ts index 3f2641b3..f34cf4a5 100644 --- a/src/credential/credential.module.ts +++ b/src/credential/credential.module.ts @@ -17,17 +17,35 @@ import { TrimMiddleware } from 'src/utils/middleware/trim.middleware'; import { credentialProviders } from './providers/credential.provider'; import { databaseProviders } from '../mongoose/tenant-mongoose-connections'; import { TxSendModuleModule } from 'src/tx-send-module/tx-send-module.module'; +import { StatusModule } from 'src/status/status.module'; +import { StatusService } from 'src/status/status.service'; +import { TxnStatusRepository } from 'src/status/repository/status.repository'; +import { statusProviders } from 'src/status/providers/registration-status.provider'; +import { CreditManagerModule } from 'src/credit-manager/credit-manager.module'; +import { AppLoggerMiddleware } from 'src/utils/interceptor/http-interceptor'; +import { LogModule } from 'src/log/log.module'; @Module({ - imports: [EdvModule, HidWalletModule, DidModule, TxSendModuleModule], + imports: [ + EdvModule, + HidWalletModule, + DidModule, + TxSendModuleModule, + StatusModule, + CreditManagerModule, + LogModule, + ], controllers: [CredentialController], providers: [ CredentialService, CredentialSSIService, HidWalletService, + StatusService, CredentialRepository, + TxnStatusRepository, ...databaseProviders, ...credentialProviders, + ...statusProviders, ], }) export class CredentialModule implements NestModule { @@ -40,5 +58,6 @@ export class CredentialModule implements NestModule { { path: 'credential/:credentialId', method: RequestMethod.GET }, ) .forRoutes(CredentialController); + consumer.apply(AppLoggerMiddleware).forRoutes(CredentialController); } } diff --git a/src/credential/dto/create-credential.dto.ts b/src/credential/dto/create-credential.dto.ts index 5e0daa3d..382674cd 100644 --- a/src/credential/dto/create-credential.dto.ts +++ b/src/credential/dto/create-credential.dto.ts @@ -10,6 +10,7 @@ import { ValidateIf, ArrayNotEmpty, IsEnum, + IsObject, } from 'class-validator'; import { Type } from 'class-transformer'; import { ValidateVerificationMethodId } from 'src/utils/customDecorator/vmId.decorator'; @@ -18,6 +19,56 @@ import { IsSchemaId } from 'src/utils/customDecorator/schemaId.deceorator'; import { IsVcId } from 'src/utils/customDecorator/vc.decorator'; import { subjectDID } from 'src/utils/customDecorator/SubjectDid.decorator'; +export class ResolveCredentialMetadata { + @ApiProperty({ + name: 'credentialId', + description: 'credentialId of credential', + example: 'vc:hid:testnet:z6MkqexphEhpi9jKZi8XLYiwCEsSWMdUt6YzjCfqdxKecJXM', + }) + @IsString() + credentialId: string; + + @ApiProperty({ + name: 'type', + description: 'schema type of credential', + example: '{ schemaId, schemaType }', + }) + @IsObject() + @IsOptional() + type?: object; + + @ApiProperty({ + name: 'issuerDid', + description: 'issuerDid of credential', + example: 'did:hid:testnet:asdasd', + }) + @IsString() + issuerDid: string; + + @ApiProperty({ + name: 'persist', + description: + 'return credentialDocument if persist is set to true at the time of issuing credential', + example: true, + }) + persist: boolean; + + @ApiProperty({ + name: 'registerCredentialStatus', + description: 'if crendetialstatus was sent to blockchain', + example: true, + }) + registerCredentialStatus: boolean; + + @ApiProperty({ + name: 'transactionStatus', + description: 'transactionStatus of credential', + required: false, + }) + @IsOptional() + @IsObject() + transactionStatus: object; +} export enum Namespace { testnet = 'testnet', // mainnet = '', @@ -399,12 +450,13 @@ export class CreateCredentialResponse { type: CredStatus, }) credentialStatus: CredStatus; + @ApiProperty({ - name: 'persist', - description: 'Define whether to store cred or ust store its meta', - example: true, + name: 'metadata', + description: 'metadata for this credential', }) - persist: boolean; + @IsOptional() + metadata?: ResolveCredentialMetadata; } export class ResolvedCredentialStatus extends CredStatus { @@ -448,16 +500,9 @@ export class ResolveCredential { @ValidateNested({ each: true }) credentialStatus: ResolvedCredentialStatus; @ApiProperty({ - name: 'persist', - description: - 'return credentialDocument if persist is set to true at the time of issuing credential', - example: true, + name: 'metadata', + description: 'metadata for this credential', }) - persist: boolean; - @ApiProperty({ - description: 'If set true then return credential Document also', - name: 'retrieveCredential', - example: true, - }) - retrieveCredential: boolean; + @IsOptional() + metadata?: ResolveCredentialMetadata; } diff --git a/src/credential/dto/fetch-credential.dto.ts b/src/credential/dto/fetch-credential.dto.ts index a72e7f1b..01b807ae 100644 --- a/src/credential/dto/fetch-credential.dto.ts +++ b/src/credential/dto/fetch-credential.dto.ts @@ -1,7 +1,21 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsArray, IsNumber, IsString } from 'class-validator'; -import { Credential } from '../schemas/credntial.schema'; +import { IsArray, IsNumber, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +export class CredentialData { + @ApiProperty({ + name: 'credentialId', + description: 'Id of the credential', + example: 'vc:hid:testnet:............', + }) + credentialId: string; + @ApiProperty({ + name: 'createdAt', + description: 'Time at which document is created', + example: '2025-11-19T07:47:15.632Z', + }) + createdAt: string; +} export class GetCredentialList { @ApiProperty({ description: 'totalCount', @@ -12,11 +26,11 @@ export class GetCredentialList { @ApiProperty({ description: 'data', - type: Credential, - example: ['vc:hid:testnet:............'], + type: CredentialData, isArray: true, }) - @IsString() + @Type(() => CredentialData) + @ValidateNested() @IsArray() - data: Array; + data: CredentialData[]; } diff --git a/src/credential/interceptors/transformResponse.interseptor.ts b/src/credential/interceptors/transformResponse.interseptor.ts index 501cf91b..1951b0d7 100644 --- a/src/credential/interceptors/transformResponse.interseptor.ts +++ b/src/credential/interceptors/transformResponse.interseptor.ts @@ -24,13 +24,11 @@ export class CredentialResponseInterceptor implements NestInterceptor { data[0]['totalCount'].length > 0 ? data[0]['totalCount'][0].total : 0, - data: this.mapData(data[0]['data']), + data: data[0]['data'], }; return modifiedResponse; }), ); } - mapData(data) { - return data.map((credential) => credential.credentialId); - } + } diff --git a/src/credential/repository/credential.repository.ts b/src/credential/repository/credential.repository.ts index 8e71267c..0a22e16d 100644 --- a/src/credential/repository/credential.repository.ts +++ b/src/credential/repository/credential.repository.ts @@ -26,16 +26,21 @@ export class CredentialRepository { 'find() method: starts, finding list of credentials from db', 'CredentialRepository', ); + const match = { appId: credentialFilterQuery.appId }; + if (credentialFilterQuery.paginationOption.issuerDid) { + match['issuerDid'] = credentialFilterQuery.paginationOption.issuerDid; + } return await this.credentialModel.aggregate([ - { $match: { appId: credentialFilterQuery.appId } }, + { $match: match }, { $facet: { totalCount: [{ $count: 'total' }], data: [ + { $sort: { createdAt: -1 } }, { $skip: credentialFilterQuery.paginationOption.skip }, { $limit: credentialFilterQuery.paginationOption.limit }, { - $project: { credentialId: 1, _id: 0 }, + $project: { credentialId: 1, _id: 0, createdAt: 1 }, }, ], }, diff --git a/src/credential/schemas/credntial.schema.ts b/src/credential/schemas/credntial.schema.ts index 221abaf7..1bc1ff80 100644 --- a/src/credential/schemas/credntial.schema.ts +++ b/src/credential/schemas/credntial.schema.ts @@ -1,11 +1,13 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { IsBoolean, IsOptional, IsString } from 'class-validator'; +import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator'; import { IsDid } from 'src/utils/customDecorator/did.decorator'; import { IsVcId } from 'src/utils/customDecorator/vc.decorator'; export type CredentialModel = Credential & Document; -@Schema() +@Schema({ + timestamps: true, +}) export class Credential { @IsString() @Prop({ required: true }) @@ -30,13 +32,17 @@ export class Credential { @Prop({ required: true }) persist: boolean; + @IsBoolean() + @Prop({ required: true }) + registerCredentialStatus: boolean; + @IsString() @Prop() transactionHash: string; - @IsString() - @Prop() - type: string; + @IsObject() + @Prop({ type: 'object' }) + type: object; } const CredentialSchema = SchemaFactory.createForClass(Credential); diff --git a/src/credential/services/credential.service.ts b/src/credential/services/credential.service.ts index 61108feb..e07ab1c6 100644 --- a/src/credential/services/credential.service.ts +++ b/src/credential/services/credential.service.ts @@ -4,7 +4,10 @@ import { Logger, NotFoundException, } from '@nestjs/common'; -import { CreateCredentialDto } from '../dto/create-credential.dto'; +import { + CreateCredentialDto, + ResolveCredentialMetadata, +} from '../dto/create-credential.dto'; import { UpdateCredentialDto } from '../dto/update-credential.dto'; import { ConfigService } from '@nestjs/config'; import { CredentialSSIService } from './credential.ssi.service'; @@ -23,7 +26,9 @@ import { } from '../dto/register-credential.dto'; import { getAppVault, getAppMenemonic } from '../../utils/app-vault-service'; import { TxSendModuleService } from 'src/tx-send-module/tx-send-module.service'; - +import * as NodeCache from 'node-cache'; +import { StatusService } from 'src/status/status.service'; +const myCache = new NodeCache(); @Injectable() export class CredentialService { constructor( @@ -33,7 +38,8 @@ export class CredentialService { private credentialRepository: CredentialRepository, private readonly didRepositiory: DidRepository, private readonly txnService: TxSendModuleService, - ) {} + private readonly statusService: StatusService, + ) { } async checkAllowence(address) { const url = @@ -112,7 +118,16 @@ export class CredentialService { ); const seed = await this.hidWallet.getSeedFromMnemonic(issuerMnemonic); const hypersignDid = new HypersignDID(); - const { didDocument } = await hypersignDid.resolve({ did: issuerDid }); + let didDocument; + if (myCache.has(issuerDid)) { + didDocument = myCache.get(issuerDid); + console.log('Getting from Cache'); + } else { + const resp = await hypersignDid.resolve({ did: issuerDid }); + didDocument = resp.didDocument; + myCache.set(issuerDid, didDocument); + Logger.log('Setting Cache'); + } const verificationMethod = didDocument.verificationMethod.find( (vm) => vm.id === verificationMethodId, ); @@ -192,6 +207,7 @@ export class CredentialService { } = await hypersignVC.issue({ credential, issuerDid, + issuerDidDoc: didDocument, verificationMethodId, privateKeyMultibase, registerCredential: false, @@ -233,7 +249,7 @@ export class CredentialService { 'create() method: before creating credential doc in db', 'CredentialService', ); - await this.credentialRepository.create({ + const credentialDetail = await this.credentialRepository.create({ appId: appDetail.appId, credentialId: signedCredential.id, issuerDid, @@ -242,14 +258,25 @@ export class CredentialService { transactionHash: credentialStatusRegistrationResult ? credentialStatusRegistrationResult.transactionHash : '', - type: signedCredential.type[1], // TODO : MAYBE REMOVE HARDCODING MAYBE NOT + type: { schemaType: signedCredential.type[1], schemaId }, // TODO : MAYBE REMOVE HARDCODING MAYBE NOT + registerCredentialStatus: registerCredentialStatus + ? registerCredentialStatus + : false, }); Logger.log('create() method: ends....', 'CredentialService'); + const metadata = { + credentialId: credentialDetail.credentialId, + persist: credentialDetail.persist, + type: credentialDetail.type, + issuerDid: credentialDetail.issuerDid, + registerCredentialStatus: credentialDetail.registerCredentialStatus, + } as ResolveCredentialMetadata; + return { credentialDocument: signedCredential, credentialStatus: credStatusTemp, - persist, + metadata, }; } catch (e) { throw new BadRequestException([e.message]); @@ -305,22 +332,57 @@ export class CredentialService { 'resolveCredential() method: before initialising HypersignVerifiableCredential', 'CredentialService', ); - const hypersignCredential = new HypersignVerifiableCredential(); - let credentialStatus; - try { - credentialStatus = await hypersignCredential.resolveCredentialStatus({ - credentialId, - }); - } catch (e) { - credentialStatus = undefined; + + const metadata = { + credentialId: credentialDetail.credentialId, + persist: credentialDetail.persist, + type: credentialDetail.type, + issuerDid: credentialDetail.issuerDid, + registerCredentialStatus: credentialDetail.registerCredentialStatus, + } as ResolveCredentialMetadata; + let credentialStatus = undefined; + // If user had registered the credential on the blockchain + // Only then we will go ahead with credential status retrival + const shouldRetriveCredential = credentialDetail.registerCredentialStatus + ? credentialDetail.registerCredentialStatus + : true; // making default true for backwards compatibility + if (shouldRetriveCredential) { + /// First check this transaction was successful in lcoal db or there was some error + const statusResponse = await this.statusService.findBySsiId(credentialId); + let wasTransactionSuccess = false; + if (statusResponse) { + const firstResponse = statusResponse[0]; + if ( + firstResponse && + firstResponse.data && + firstResponse.totalCount.length > 0 + ) { + metadata['transactionStatus'] = firstResponse.data; + if (firstResponse.data.findIndex((x) => x['status'] == 0) >= 0) { + wasTransactionSuccess = true; + } + } + } + + console.log({ wasTransactionSuccess }); + /// Retrive status from the blockchain only when status = 0, otherwise skip + if (wasTransactionSuccess) { + try { + const hypersignCredential = new HypersignVerifiableCredential(); + credentialStatus = await hypersignCredential.resolveCredentialStatus({ + credentialId, + }); + } catch (e) { + credentialStatus = undefined; + } + Logger.log('resolveCredential() method: ends....', 'CredentialService'); + } } - Logger.log('resolveCredential() method: ends....', 'CredentialService'); return { credentialDocument: credential ? credential : undefined, credentialStatus, - persist: credentialDetail.persist, - retrieveCredential, + metadata, }; } @@ -337,8 +399,8 @@ export class CredentialService { status === 'SUSPEND' ? 'SUSPENDED' : status === 'REVOKE' - ? 'REVOKED' - : 'LIVE'; + ? 'REVOKED' + : 'LIVE'; const didOfvmId = verificationMethodId.split('#')[0]; const { edvId, kmsId } = appDetail; @@ -381,8 +443,8 @@ export class CredentialService { const nameSpace = namespace ? namespace : this.config.get('NETWORK') - ? this.config.get('NETWORK') - : namespace; + ? this.config.get('NETWORK') + : namespace; if ( verificationMethod && verificationMethod.type === IKeyType.BabyJubJubKey2021 @@ -436,6 +498,7 @@ export class CredentialService { updateCredenital?.credentialStatus, updateCredenital?.proofValue, appMenemonic, + appDetail, ); } else { updatedCredResult = await hypersignVC.updateCredentialStatus({ @@ -511,7 +574,7 @@ export class CredentialService { if ( verifyCredentialDto.credentialDocument && verifyCredentialDto.credentialDocument.proof.type === - SupportedSignatureType.BJJSignature2021 + SupportedSignatureType.BJJSignature2021 ) { verificationResult = await hypersignCredential.bjjVC.verify({ credential: verifyCredentialDto.credentialDocument as any, // will fix it latter @@ -588,7 +651,12 @@ export class CredentialService { ); } if (await this.checkAllowence(address)) { - await this.txnService.sendVCTxn(credentialStatus, proof, appMenemonic); + await this.txnService.sendVCTxn( + credentialStatus, + proof, + appMenemonic, + appDetail, + ); } else { registeredVC = await hypersignVC.registerCredentialStatus({ credentialStatus, diff --git a/src/credential/services/credential.ssi.service.ts b/src/credential/services/credential.ssi.service.ts index cd793ed8..0e244f26 100644 --- a/src/credential/services/credential.ssi.service.ts +++ b/src/credential/services/credential.ssi.service.ts @@ -1,7 +1,10 @@ import { Injectable, Scope, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { HypersignVerifiableCredential, HypersignSSISdk } from 'hs-ssi-sdk'; +import { + HypersignVerifiableCredential, + HypersignBJJVerifiableCredential, +} from 'hs-ssi-sdk'; import { HidWalletService } from 'src/hid-wallet/services/hid-wallet.service'; @Injectable({ scope: Scope.REQUEST }) @@ -43,13 +46,13 @@ export class CredentialSSIService { ); await this.hidWallet.generateWallet(mnemonic); const offlineSigner = this.hidWallet.getOfflineSigner(); - const hsSSiSdk = new HypersignSSISdk({ + const hypersignVC = new HypersignBJJVerifiableCredential({ offlineSigner, nodeRpcEndpoint, nodeRestEndpoint, namespace: namespace, }); - await hsSSiSdk.init(); - return hsSSiSdk.vc.bjjVC; + await hypersignVC.init(); + return hypersignVC; } } diff --git a/src/credit-manager/controllers/credit-manager.controller.spec.ts b/src/credit-manager/controllers/credit-manager.controller.spec.ts new file mode 100644 index 00000000..f538849b --- /dev/null +++ b/src/credit-manager/controllers/credit-manager.controller.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CreditManagerController } from './credit-manager.controller'; +import { CreditService } from '../services/credit-manager.service'; + +describe('CreditManagerController', () => { + let controller: CreditManagerController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [CreditManagerController], + providers: [CreditService], + }).compile(); + + controller = module.get(CreditManagerController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/credit-manager/controllers/credit-manager.controller.ts b/src/credit-manager/controllers/credit-manager.controller.ts new file mode 100644 index 00000000..4f849c31 --- /dev/null +++ b/src/credit-manager/controllers/credit-manager.controller.ts @@ -0,0 +1,148 @@ +import { + Controller, + Get, + Post, + Param, + UseFilters, + UseGuards, + Logger, + Req, + Body, +} from '@nestjs/common'; +import { CreditService } from '../services/credit-manager.service'; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiCreatedResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import { + createCreditResponse, + ActivateCredtiResponse, +} from '../dto/create-credit-manager.dto'; +import { AllExceptionsFilter } from 'src/utils/utils'; +import { + CreditError, + CreditNotFoundError, + CreditUnAuthorizeError, +} from '../dto/error-credit.dto'; +import { AuthGuard } from '@nestjs/passport'; +import { CreditAuthGuard } from '../gaurd/credit-token.gaurd'; +import { AccessGuard } from 'src/utils/guards/access.gaurd'; +import { Access } from 'src/utils/customDecorator/access.decorator'; +import { ACCESS_TYPES } from '../utils'; + +@UseFilters(AllExceptionsFilter) +@ApiTags('Credit') +@Controller('credit') +export class CreditManagerController { + constructor(private readonly creditManagerService: CreditService) {} + @ApiBearerAuth('Authorization') + @UseGuards(CreditAuthGuard, AccessGuard) + @ApiCreatedResponse({ + description: 'Credit detail is added successfully', + type: createCreditResponse, + }) + @ApiBadRequestResponse({ + description: 'Unable to add credit detail', + type: CreditError, + }) + @ApiNotFoundResponse({ + description: 'Missing', + type: CreditNotFoundError, + }) + @ApiUnauthorizedResponse({ + description: 'Authorization token is invalid or expired.', + type: CreditUnAuthorizeError, + }) + @Access(ACCESS_TYPES.WRITE_CREDIT) + @Post() + AddNewCreditDetail(@Req() req) { + Logger.log( + 'AddNewCreditDetail() method to add credit detail', + 'CreditManagerController', + ); + return this.creditManagerService.addCreditDetail(req.creditDetail); + } + @ApiBearerAuth('Authorization') + @UseGuards(AuthGuard('jwt'), AccessGuard) + @ApiOkResponse({ + description: 'Credit is activated successfully', + type: ActivateCredtiResponse, + }) + @ApiBadRequestResponse({ + description: 'Unable to activate credit detail', + type: CreditError, + }) + @ApiNotFoundResponse({ + description: 'Authorization token is invalid or expired.', + type: CreditNotFoundError, + }) + @ApiUnauthorizedResponse({ + description: 'Authorization token is invalid or expired.', + type: CreditUnAuthorizeError, + }) + @Access(ACCESS_TYPES.WRITE_CREDIT) + @Post(':creditId/activate') + activateCredit(@Param('creditId') creditId: string) { + Logger.log( + 'activateCredit() method to activate existing credit detail', + 'CreditManagerController', + ); + return this.creditManagerService.activateCredit(creditId); + } + @ApiBearerAuth('Authorization') + @UseGuards(AuthGuard('jwt'), AccessGuard) + @ApiOkResponse({ + description: 'Fetched all credit detail', + type: createCreditResponse, + isArray: true, + }) + @ApiBadRequestResponse({ + description: 'Unable to fetch credit detail', + type: CreditError, + }) + @ApiUnauthorizedResponse({ + description: 'Authorization token is invalid or expired.', + type: CreditUnAuthorizeError, + }) + @Access(ACCESS_TYPES.READ_CREDIT) + @Get() + fetchCreditDetails() { + Logger.log( + 'fetchCreditDetails() method to fetch all credit detail', + 'CreditManagerController', + ); + return this.creditManagerService.fetchCreditDetails(); + } + @ApiBearerAuth('Authorization') + @UseGuards(AuthGuard('jwt'), AccessGuard) + @ApiOkResponse({ + description: 'The details of the credit have been successfully fetched.', + type: createCreditResponse, + }) + @ApiBadRequestResponse({ + description: 'Unable to fetch particular credit detail', + type: CreditError, + }) + @ApiUnauthorizedResponse({ + description: 'Authorization token is invalid or expired.', + type: CreditUnAuthorizeError, + }) + @Access(ACCESS_TYPES.READ_CREDIT) + @Get(':creditId') + fetchParticularCreditDetail(@Param('creditId') creditId: string, @Req() req) { + Logger.log( + 'fetchParticularCreditDetail() method to fetch particular credit detail', + 'CreditManagerController', + ); + const appId = req.user.appId; + return this.creditManagerService.fetchParticularCreditDetail( + creditId, + appId, + ); + } +} diff --git a/src/credit-manager/credit-manager.module.ts b/src/credit-manager/credit-manager.module.ts new file mode 100644 index 00000000..2d9a586d --- /dev/null +++ b/src/credit-manager/credit-manager.module.ts @@ -0,0 +1,36 @@ +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { CreditManagerController } from './controllers/credit-manager.controller'; +import { creditSchemaProviders } from './schema/credit.provider'; +import { CreditService } from './services/credit-manager.service'; +import { CreditManagerRepository } from './repository/credit-manager.repository'; +import { JwtService } from '@nestjs/jwt'; +import { databaseProviders } from 'src/mongoose/tenant-mongoose-connections'; +import { CreditManagerService } from './managers/credit-manager.service'; +import { ApiCreditService } from './services/api-credit.service'; +import { StorageCreditService } from './services/storage-credit.service'; +import { AttestationCreditService } from './services/attestation-credit.service'; +import { WhitelistSSICorsMiddleware } from 'src/utils/middleware/cors.middleware'; + +@Module({ + imports: [], + controllers: [CreditManagerController], + providers: [ + CreditService, + ...databaseProviders, + ...creditSchemaProviders, + CreditManagerRepository, + JwtService, + CreditManagerService, + ApiCreditService, + StorageCreditService, + AttestationCreditService, + ], + exports: [CreditService, CreditManagerRepository, CreditManagerService], +}) +export class CreditManagerModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer + .apply(WhitelistSSICorsMiddleware) + .forRoutes(CreditManagerController); + } +} diff --git a/src/credit-manager/dto/create-credit-manager.dto.ts b/src/credit-manager/dto/create-credit-manager.dto.ts new file mode 100644 index 00000000..ab3d39bd --- /dev/null +++ b/src/credit-manager/dto/create-credit-manager.dto.ts @@ -0,0 +1,164 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber, IsString, Min, ValidateNested } from 'class-validator'; +import { Status } from '../schema/credit-manager.schema'; +import { Type } from 'class-transformer'; +export enum ValidityPeriodUnit { + DAYS = 'DAYS', + WEEK = 'WEEK', + MONTH = 'MONTH', + YEAR = 'YEAR', +} +export class CreateCreditManagerDto { + @ApiProperty({ + name: 'credit', + description: 'Number of credits available', + example: 1000, + }) + @IsNumber() + @Min(10) + totalCredits: number; + validityDuration: number; + validityDurationUnit: ValidityPeriodUnit; + serviceId: string; + creditDenom: string; +} + +export class Credit { + @ApiProperty({ + name: 'amount', + description: 'Total available hid', + example: '5000000', + }) + @IsNumber() + amount: number; + @ApiProperty({ + name: 'denom', + description: 'Token denom', + example: 'uhid', + }) + @IsNumber() + denom: number; + @ApiProperty({ + name: 'used', + description: 'Total used credit', + example: 0, + }) + @IsNumber() + used: number; +} +export class createCreditResponse { + @ApiProperty({ + name: 'totalCredits', + description: 'Total available credit', + example: 1000, + }) + @IsNumber() + totalCredits: number; + @ApiProperty({ + name: 'creditDenom', + description: 'Token denom', + example: 'uHID', + }) + @IsNumber() + creditDenom: string; + @ApiProperty({ + name: 'used', + description: 'Total number of credit used till now', + example: 0, + }) + @IsNumber() + used: number; + @ApiProperty({ + name: 'validityDuration', + description: + 'The number of days the credit is valid from the date of activation', + example: 42, + }) + @IsNumber() + validityDuration: number; + // @ApiProperty({ + // name: 'expiresAt', + // description: 'Time at which document is added', + // example: '2025-04-22T12:50:03.984Z', + // required: false + // }) + // expiresAt: Date; + @ApiProperty({ + name: 'status', + description: + 'The current status of the credit detail. Indicates whether the credit is active or inactive.', + enum: Status, + example: Status.INACTIVE, + }) + @IsString() + status: string; + @ApiProperty({ + name: 'serviceId', + description: 'Id of the service', + example: 'fc0392830696e097b1d7e0607968e9dd3400', + }) + @IsString() + serviceId: string; + @ApiProperty({ + name: 'credit', + type: Credit, + }) + @Type(() => Credit) + @ValidateNested() + credit: Credit; + @ApiProperty({ + name: 'creditScope', + description: 'Scope that one will get', + example: [ + 'MsgRegisterDID', + 'MsgDeactivateDID', + 'MsgRegisterCredentialSchema', + 'MsgUpdateDID', + 'MsgUpdateCredentialStatus', + 'MsgRegisterCredentialStatus', + ], + }) + @IsString() + creditScope: Array; + @ApiProperty({ + name: '_id', + description: 'Unique identifier of credit detail', + example: '66e0407bc7f8a92162d1e824', + }) + @IsString() + _id: string; + @ApiProperty({ + name: 'createdAt', + description: 'Time at which document is added', + example: '2025-03-10T12:50:03.984Z', + }) + @IsString() + createdAt: string; + @ApiProperty({ + name: 'updatedAt', + description: 'Time at which document last updated', + example: '2025-03-10T12:50:03.984Z', + }) + @IsString() + updatedAt: string; +} + +export class ActivateCredtiResponse extends createCreditResponse { + @ApiProperty({ + name: 'status', + description: + 'The current status of the credit detail. Indicates whether the credit is active or inactive.', + enum: Status, + example: Status.ACTIVE, + }) + @IsString() + status: string; + @ApiProperty({ + name: 'expiresAt', + description: + 'The date and time when the credit expires. After this timestamp, the credit is no longer valid.', + example: '2025-04-22T12:50:03.984Z', + }) + @IsString() + expiresAt: Date; +} diff --git a/src/credit-manager/dto/error-credit.dto.ts b/src/credit-manager/dto/error-credit.dto.ts new file mode 100644 index 00000000..23d44762 --- /dev/null +++ b/src/credit-manager/dto/error-credit.dto.ts @@ -0,0 +1,70 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber, IsString } from 'class-validator'; + +export class CreditError { + @ApiProperty({ + description: 'statusCode', + example: 400, + }) + @IsNumber() + statusCode: number; + + @ApiProperty({ + description: 'message', + example: ['error message 1', 'error message 2'], + }) + @IsString() + message: Array; + + @ApiProperty({ + description: 'error', + example: 'Bad Request', + }) + @IsString() + error: string; +} +export class CreditNotFoundError { + @ApiProperty({ + description: 'statusCode', + example: 404, + }) + @IsNumber() + statusCode: number; + + @ApiProperty({ + description: 'message', + example: ['error message 1', 'error message 2'], + }) + @IsString() + message: Array; + + @ApiProperty({ + description: 'error', + example: 'Not Found', + }) + @IsString() + error: string; +} + +export class CreditUnAuthorizeError { + @ApiProperty({ + description: 'statusCode', + example: 401, + }) + @IsNumber() + statusCode: number; + + @ApiProperty({ + description: 'message', + example: ['error message 1', 'error message 2'], + }) + @IsString() + message: Array; + + @ApiProperty({ + description: 'error', + example: 'Not Found', + }) + @IsString() + error: string; +} diff --git a/src/credit-manager/dto/update-credit-manager.dto.ts b/src/credit-manager/dto/update-credit-manager.dto.ts new file mode 100644 index 00000000..38707341 --- /dev/null +++ b/src/credit-manager/dto/update-credit-manager.dto.ts @@ -0,0 +1,6 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateCreditManagerDto } from './create-credit-manager.dto'; + +export class UpdateCreditManagerDto extends PartialType( + CreateCreditManagerDto, +) {} diff --git a/src/credit-manager/gaurd/credit-token.gaurd.ts b/src/credit-manager/gaurd/credit-token.gaurd.ts new file mode 100644 index 00000000..51fb021c --- /dev/null +++ b/src/credit-manager/gaurd/credit-token.gaurd.ts @@ -0,0 +1,77 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + Logger, + UnauthorizedException, + UseFilters, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { ApiBearerAuth } from '@nestjs/swagger'; +import { AllExceptionsFilter } from 'src/utils/utils'; +import { + CreateCreditManagerDto, + ValidityPeriodUnit, +} from '../dto/create-credit-manager.dto'; +import { redisClient } from 'src/utils/redis.provider'; + +@UseFilters(AllExceptionsFilter) +@Injectable() +@ApiBearerAuth('JWT') +export class CreditAuthGuard implements CanActivate { + constructor( + private jwtService: JwtService, + private readonly configService: ConfigService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + Logger.log('Inside CreditAuthGuard, canActivate()', 'CreditAuthGuard'); + const request: Request = context.switchToHttp().getRequest(); + const accessToken = this.extractTokenFromHeader(request); + if (!accessToken) { + throw new UnauthorizedException([ + 'Please pass the authorization token in the header', + ]); + } + const payload = await this.jwtService.verifyAsync(accessToken, { + secret: this.configService.get('JWT_SECRET'), + }); + if ( + !payload || + Object.keys(payload).length === 0 || + !payload['sessionId'] + ) { + throw new UnauthorizedException('Invalid authorization token'); + } + const sessionDetail = await redisClient.get(payload.sessionId); + if (!sessionDetail) { + throw new UnauthorizedException(['Token is expired or invalid']); + } + const sessionDetailJson = JSON.parse(sessionDetail); + if ( + !sessionDetailJson || + Object.keys(sessionDetailJson).length === 0 || + sessionDetailJson['purpose'] !== 'CreditRecharge' || + !sessionDetailJson['amount'] || + !sessionDetailJson['validityPeriod'] || + !sessionDetailJson['serviceId'] + ) { + throw new UnauthorizedException("Invalid token. Can't process credit"); + } + const creditDetail: CreateCreditManagerDto = { + totalCredits: sessionDetailJson['amount'], + validityDuration: sessionDetailJson['validityPeriod'], + validityDurationUnit: + sessionDetailJson['validityPeriodUnit'] || ValidityPeriodUnit.DAYS, + serviceId: sessionDetailJson['serviceId'], + creditDenom: sessionDetailJson['amountDenom'] || 'uHID', + }; + request['creditDetail'] = creditDetail; + return true; + } + private extractTokenFromHeader(request: Request): string | undefined { + const [type, token] = request.headers['authorization']?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } +} diff --git a/src/credit-manager/gaurd/reduce-credit.gaurd.ts b/src/credit-manager/gaurd/reduce-credit.gaurd.ts new file mode 100644 index 00000000..8dba6a7a --- /dev/null +++ b/src/credit-manager/gaurd/reduce-credit.gaurd.ts @@ -0,0 +1,150 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + Logger, +} from '@nestjs/common'; +import { Response } from 'express'; +import { CreditManagerService } from '../managers/credit-manager.service'; +import { CreditService } from '../services/credit-manager.service'; + +@Injectable() +export class ReduceCreditGuard implements CanActivate { + private readonly exemptedOrigin = 'https://entity.dashboard.hypersign.id'; + // private readonly exemptedOrigin = 'http://localhost:9001'; + + constructor( + private readonly creditManagerService: CreditManagerService, + private readonly creditService: CreditService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest(); + const res: Response = context.switchToHttp().getResponse(); + if (!req.app) return false; + + //Check if the user has a valid plan with enough balance + const creditDetails = await this.creditManagerService.hasValidCredit(req); + const activeCredit = await this.creditService.getActiveCredit( + String(creditDetails.attestationCost.hidCost), + ); + if (!creditDetails['hasSufficientFund']) { + Logger.error( + 'User does not have a valid plan or enough credits', + 'ReduceCreditGuard', + ); + res.status(403).json({ error: 'Insufficient credits or no active plan' }); + return false; + } + res.on('finish', async () => { + if (res.statusCode >= 200 && res.statusCode < 400) { + Logger.log( + 'Request successful. Deducting credits now...', + 'ReduceCreditGuard', + ); + const origin = req.headers.origin || req.headers.referer || ''; + if (req.method === 'GET' && origin?.startsWith(this.exemptedOrigin)) { + Logger.log( + `Skipping credit deduction for ${req.method} request from ${origin}`, + 'ReduceCreditGuard', + ); + return; + } + try { + let remainingCreditsNeeded = creditDetails.creditAmountRequired; + let remainingHIDNeeded = Number( + creditDetails.attestationCost.hidCost, + ); + const availableCredits = + activeCredit.totalCredits - activeCredit.used; + const availableHID = + Number(activeCredit.credit.amount) - activeCredit.credit.used; + if ( + availableCredits < remainingCreditsNeeded || + availableHID < remainingHIDNeeded + ) { + const deductedCredits = Math.min( + remainingCreditsNeeded, + availableCredits, + ); + const deductedHID = Math.min(remainingHIDNeeded, availableHID); + remainingCreditsNeeded -= deductedCredits; + remainingHIDNeeded -= deductedHID; + + if (remainingCreditsNeeded > 0) { + const inactiveCreditPlan = + await this.creditService.getNextAvailableCredit( + `${remainingHIDNeeded}`, + ); + if (inactiveCreditPlan) { + Logger.log( + `Activating new credit plan: ${inactiveCreditPlan._id}`, + 'ReduceCreditGuard', + ); + await this.creditService.activateCredit(inactiveCreditPlan._id); + await this.creditService.updateCreditDetail( + { _id: activeCredit._id }, + { + $inc: { + used: deductedCredits, + [`credit.used`]: deductedHID, + }, + status: 'Inactive', + }, + ); + Logger.log( + `Deducted ${deductedCredits} credits and ${deductedHID} HID from active plan`, + 'ReduceCreditGuard', + ); + await this.creditService.updateCreditDetail( + { _id: inactiveCreditPlan._id }, + { + $inc: { + used: remainingCreditsNeeded, + [`credit.used`]: remainingHIDNeeded, + }, + }, + ); + Logger.log( + `Deducted remaining ${remainingCreditsNeeded} credits from new plan`, + 'ReduceCreditGuard', + ); + remainingCreditsNeeded = 0; + } else { + Logger.error( + 'No inactive credit plan available to activate.', + 'ReduceCreditGuard', + ); + } + } + } else { + await this.creditService.updateCreditDetail( + { _id: activeCredit._id }, + { + $inc: { + used: creditDetails.creditAmountRequired, + [`credit.used`]: Number( + creditDetails.attestationCost.hidCost, + ), + }, + }, + ); + } + Logger.log('Credits deducted successfully', 'ReduceCreditGuard'); + } catch (error) { + Logger.error( + 'Error deducting credits: ' + error.message, + 'ReduceCreditGuard', + ); + } + } else { + Logger.warn( + 'Request failed. Skipping credit deduction.', + 'ReduceCreditGuard', + ); + } + }); + + return true; + } +} diff --git a/src/credit-manager/managers/credit-manager.service.ts b/src/credit-manager/managers/credit-manager.service.ts new file mode 100644 index 00000000..45318508 --- /dev/null +++ b/src/credit-manager/managers/credit-manager.service.ts @@ -0,0 +1,75 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { CreditService } from '../services/credit-manager.service'; +import { RMethods } from 'src/utils/utils'; +import { ApiCreditService } from '../services/api-credit.service'; +import { AttestationCreditService } from '../services/attestation-credit.service'; +import { StorageCreditService } from '../services/storage-credit.service'; +import { getApiDetail } from '../utils'; +@Injectable() +export class CreditManagerService { + constructor( + private readonly creditService: CreditService, + private readonly apiCreditService: ApiCreditService, + private readonly storageService: StorageCreditService, + private readonly attestationCreditService: AttestationCreditService, + ) {} + + async hasValidCredit( + req, + ): Promise<{ attestationCost; creditAmountRequired; hasSufficientFund }> { + const appId = req.user.appId; + const { storageType, attestationType, method } = await getApiDetail(req); + const apiCost = method + ? await this.apiCreditService.calculateCost(method) + : 0; + const storageCost = storageType + ? await this.storageService.calculateCost(storageType) + : 0; + const attestationCost = attestationType + ? await this.attestationCreditService.calculateCost(attestationType) + : { hidCost: 0, creditCost: 0 }; + const { hidCost, creditCost } = attestationCost; + const creditAmountRequired = apiCost + storageCost + creditCost; + + // Fetch user's active plan + const activeCredit = await this.creditService.getActiveCredit( + String(hidCost), + ); + + if ( + !activeCredit || + activeCredit.used >= activeCredit.totalCredits || + (activeCredit.expiresAt && + new Date(activeCredit.expiresAt) <= new Date()) || + activeCredit.totalCredits - activeCredit.used < creditAmountRequired + ) { + const availableCredit = await this.creditService.getNextAvailableCredit( + String(attestationCost), + ); + if (!availableCredit) { + throw new BadRequestException([ + 'No credits found or credit exhausted. Please contact the admin', + ]); + } + } + return { attestationCost, creditAmountRequired, hasSufficientFund: true }; + } + async getCreditDetailFromPath(apiMethod, apiPath) { + const { storageType, attestationType, method } = await getApiDetail({ + method: apiMethod, + url: apiPath, + }); + const apiCost = method + ? await this.apiCreditService.calculateCost(method) + : 0; + const storageCost = storageType + ? await this.storageService.calculateCost(storageType) + : 0; + const attestationCost = attestationType + ? await this.attestationCreditService.calculateCost(attestationType) + : { hidCost: 0, creditCost: 0 }; + const { hidCost, creditCost } = attestationCost; + const creditAmountRequired = apiCost + storageCost + creditCost; + return { hidCost, creditAmountRequired }; + } +} diff --git a/src/credit-manager/repository/credit-manager.repository.ts b/src/credit-manager/repository/credit-manager.repository.ts new file mode 100644 index 00000000..476dabe8 --- /dev/null +++ b/src/credit-manager/repository/credit-manager.repository.ts @@ -0,0 +1,43 @@ +import { Inject } from '@nestjs/common'; +import { FilterQuery, Model } from 'mongoose'; +import { + CreditManager, + CreditManagerType, +} from '../schema/credit-manager.schema'; +export class CreditManagerRepository { + constructor( + @Inject('CREDIT_STORE_MODEL') + private readonly creditConnection: Model, + ) {} + async saveCreditDetail(creditDetail: CreditManager): Promise { + const newCreditDetail = new this.creditConnection(creditDetail); + return newCreditDetail.save(); + } + + async findCreditDetailList( + creditFilterQuery: FilterQuery, + ): Promise { + return this.creditConnection.find(creditFilterQuery); + } + + async findParticularCreditDetail( + creditFilterQuery: FilterQuery, + ): Promise { + return this.creditConnection.findOne(creditFilterQuery); + } + + async updateCreditDetail( + creditFilterQuery: FilterQuery, + creditDetail, + ): Promise { + return this.creditConnection.findOneAndUpdate( + creditFilterQuery, + creditDetail, + { new: true }, + ); + } + + async findBasedOnAggregationPipeline(pipeline): Promise { + return this.creditConnection.aggregate(pipeline); + } +} diff --git a/src/credit-manager/schema/credit-manager.schema.ts b/src/credit-manager/schema/credit-manager.schema.ts new file mode 100644 index 00000000..4adab2b1 --- /dev/null +++ b/src/credit-manager/schema/credit-manager.schema.ts @@ -0,0 +1,40 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +export type CreditManagerType = CreditManager & Document; +export enum Status { + 'ACTIVE' = 'Active', + 'INACTIVE' = 'Inactive', +} +export class Credit { + @Prop({ required: true, type: Number }) + amount: number; + @Prop({ required: true, type: String, default: 'uHID' }) + denom?: string; +} +@Schema({ timestamps: true }) +export class CreditManager { + @Prop({ required: true, type: Number }) + totalCredits: number; + @Prop({ required: false, type: String, default: 'uHID' }) + creditDenom?: string; + @Prop({ required: true, type: Number, default: 0 }) + used?: number; + @Prop({ required: true, type: Number }) + validityDuration: number; // storing in days + @Prop({ reuired: false, type: Date }) + expiresAt?: Date; + @Prop({ + required: true, + enum: Status, + type: String, + default: Status.INACTIVE, + }) + status?: Status; + @Prop({ required: true, type: String }) + serviceId: string; + @Prop({ type: Credit, required: false }) + credit?: Credit; + @Prop({ required: false, type: [] }) + creditScope?: Array; +} +export const CreditManagerSchema = SchemaFactory.createForClass(CreditManager); +CreditManagerSchema.index({ status: 1 }); diff --git a/src/credit-manager/schema/credit.provider.ts b/src/credit-manager/schema/credit.provider.ts new file mode 100644 index 00000000..ff9ac69c --- /dev/null +++ b/src/credit-manager/schema/credit.provider.ts @@ -0,0 +1,10 @@ +import { Connection } from 'mongoose'; +import { CreditManagerSchema } from './credit-manager.schema'; +export const creditSchemaProviders = [ + { + provide: 'CREDIT_STORE_MODEL', + useFactory: (creditConnection: Connection) => + creditConnection.model('Credit', CreditManagerSchema), + inject: ['APPDATABASECONNECTIONS'], + }, +]; diff --git a/src/credit-manager/services/api-credit.service.ts b/src/credit-manager/services/api-credit.service.ts new file mode 100644 index 00000000..27cc47e2 --- /dev/null +++ b/src/credit-manager/services/api-credit.service.ts @@ -0,0 +1,13 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { CREDIT_COSTS } from 'src/utils/utils'; + +@Injectable() +export class ApiCreditService { + async calculateCost(type) { + Logger.log( + 'Inside calculateCost to calculate credit', + 'AttestationCreditService', + ); + return CREDIT_COSTS.API[type]; + } +} diff --git a/src/credit-manager/services/attestation-credit.service.ts b/src/credit-manager/services/attestation-credit.service.ts new file mode 100644 index 00000000..517d8993 --- /dev/null +++ b/src/credit-manager/services/attestation-credit.service.ts @@ -0,0 +1,15 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { CREDIT_COSTS } from 'src/utils/utils'; + +@Injectable() +export class AttestationCreditService { + async calculateCost(type) { + Logger.log( + 'Inside calculateCost to calculate credit', + 'AttestationCreditService', + ); + const hidCost = CREDIT_COSTS.ATTESTATION[type] || 50; + const creditCost = hidCost / 10; + return { hidCost, creditCost }; + } +} diff --git a/src/credit-manager/services/credit-manager.service.spec.ts b/src/credit-manager/services/credit-manager.service.spec.ts new file mode 100644 index 00000000..7b4889e4 --- /dev/null +++ b/src/credit-manager/services/credit-manager.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CreditManagerService } from './credit-manager.service'; + +describe('CreditManagerService', () => { + let service: CreditManagerService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [CreditManagerService], + }).compile(); + + service = module.get(CreditManagerService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/credit-manager/services/credit-manager.service.ts b/src/credit-manager/services/credit-manager.service.ts new file mode 100644 index 00000000..eb61f5dc --- /dev/null +++ b/src/credit-manager/services/credit-manager.service.ts @@ -0,0 +1,247 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { + CreateCreditManagerDto, + ValidityPeriodUnit, +} from '../dto/create-credit-manager.dto'; +import { CreditManagerRepository } from '../repository/credit-manager.repository'; +import { Status } from '../schema/credit-manager.schema'; +import { constant } from '../utils'; +import { urlSanitizer } from 'src/utils/sanitizeUrl.validator'; + +@Injectable() +export class CreditService { + constructor(private readonly creditRepository: CreditManagerRepository) {} + async addCreditDetail(createCreditManagerDto: CreateCreditManagerDto) { + Logger.log('addCreditDetail() method starts....', 'CreditService'); + const ifActivePlanExists = + await this.creditRepository.findParticularCreditDetail({ + status: Status.ACTIVE, + }); + const status = ifActivePlanExists ? Status.INACTIVE : Status.ACTIVE; + let expiryTime; + const validityPeriodInDays = this.convertValidityDurationToDays( + createCreditManagerDto.validityDuration, + createCreditManagerDto.validityDurationUnit, + ); + createCreditManagerDto.validityDuration = validityPeriodInDays; + Logger.debug(`Credit status:${status}`); + let grantDetail; + if (status === 'Active') { + expiryTime = this.calculateExpiryTime( + createCreditManagerDto.validityDuration, + ); + grantDetail = await this.grantAdminAllowanceForTxFee( + createCreditManagerDto.serviceId, + ); + grantDetail['credit']['used'] = 0; + } + const newCreditDetail = { + ...createCreditManagerDto, + status: status, + expiresAt: expiryTime, + credit: grantDetail?.credit, + creditScope: grantDetail?.creditScope, + }; + return this.creditRepository.saveCreditDetail(newCreditDetail); + } + + async activateCredit(creditId: string) { + Logger.log('activateCredit() method starts....', 'CreditManagerService'); + + let creditDocument; + try { + creditDocument = await this.creditRepository.findParticularCreditDetail({ + _id: creditId, + }); + } catch (e) { + if (e.name === 'CastError') { + throw new BadRequestException(['Invalid credit Id']); + } else { + throw new BadRequestException([e.message]); + } + } + if (!creditDocument) { + throw new NotFoundException([ + `No credit detail found for creditId: ${creditId}`, + ]); + } + await this.creditRepository.updateCreditDetail( + { status: 'Active' }, + { $set: { status: 'Inactive' } }, + ); + + Logger.log( + `activateCredit() method:: activating credit for id ${creditId}`, + 'CreditManagerService', + ); + const paramsToUpdate = { status: 'Active' }; + if (creditDocument && !creditDocument.expiresAt) { + const expiresAt = this.calculateExpiryTime( + creditDocument.validityDuration, + ); + paramsToUpdate['expiresAt'] = expiresAt; + + const grantDetail = await this.grantAdminAllowanceForTxFee( + creditDocument.serviceId, + ); + if (grantDetail) { + grantDetail['credit']['used'] = 0; + (paramsToUpdate['credit'] = grantDetail?.credit), + (paramsToUpdate['creditScope'] = grantDetail?.creditScope); + } + } + return this.creditRepository.updateCreditDetail( + { _id: creditId }, + paramsToUpdate, + ); + } + + fetchCreditDetails() { + Logger.log( + 'fetchCreditDetails() method to fetch list of credit details', + 'CreditManagerService', + ); + // check for serviceId + const pipeline = [ + { + $addFields: { + expiresAtExists: { + $cond: [{ $ifNull: ['$expiresAt', false] }, 1, 0], + }, + }, + }, + { + $sort: { + expiresAtExists: -1, + expiresAt: 1, + }, + }, + { $project: { expiresAtExists: 0 } }, + ]; + return this.creditRepository.findBasedOnAggregationPipeline(pipeline); + } + + fetchParticularCreditDetail(creditId: string, appId: string) { + // check for serviceId + Logger.log( + 'fetchParticularCreditDetail() method to fetch particular credit detail', + 'CreditManagerService', + ); + return this.creditRepository.findParticularCreditDetail({ + _id: creditId, + serviceId: appId, + }); + } + + calculateExpiryTime(validityDuration: number) { + const expiryDate = new Date(); + expiryDate.setDate(expiryDate.getDate() + validityDuration); + return expiryDate; + } + + async getActiveCredit(requiredAttestationCost = '0') { + Logger.log( + 'Inside getActiveCredit() to fetch available credit detail', + 'CreditmanagerService', + ); + const pipeline = [ + { + $match: { + status: 'Active', + expiresAt: { $gt: new Date() }, + }, + }, + { + $addFields: { + remainingCredits: { $subtract: ['$totalCredits', '$used'] }, + attestationAmount: { $toInt: '$credit.amount' }, + }, + }, + { + $match: { + remainingCredits: { $gt: 0 }, + ...(Number(requiredAttestationCost) > 0 + ? { attestationAmount: { $gte: Number(requiredAttestationCost) } } + : {}), + }, + }, + ]; + const activeCreditDetail = + await this.creditRepository.findBasedOnAggregationPipeline(pipeline); + return activeCreditDetail?.[0] ?? null; + } + + async getNextAvailableCredit(requiredAttestationCost = '0') { + Logger.log( + 'Inside getActiveCredit() to fetch available credit detail', + 'CreditmanagerService', + ); + + const pipeline = [ + { + $match: { + status: 'Inactive', + $or: [ + { expiresAt: { $exists: false } }, + { expiresAt: { $gt: new Date() } }, + ], + }, + }, + { + $addFields: { + remainingCredits: { $subtract: ['$totalCredits', '$used'] }, + attestationAmount: { $toInt: '$credit.amount' }, + }, + }, + { + $match: { + remainingCredits: { $gt: 0 }, + ...(Number(requiredAttestationCost) > 0 + ? { attestationAmount: { $gte: Number(requiredAttestationCost) } } + : {}), + }, + }, + { $sort: { createdAt: 1 } }, + { $limit: 1 }, + ]; + const nextAvailableCredit = + await this.creditRepository.findBasedOnAggregationPipeline(pipeline); + return nextAvailableCredit?.[0] ?? null; + } + + updateCreditDetail(filter: any, updateParam: any) { + Logger.log('updateCreditDetail() to update some parametr of credit'); + return this.creditRepository.updateCreditDetail(filter, updateParam); + } + + convertValidityDurationToDays( + validityDuration: number, + validityDurationUnit: ValidityPeriodUnit, + ): number { + switch (validityDurationUnit) { + case ValidityPeriodUnit.WEEK: + return validityDuration * 7; + case ValidityPeriodUnit.MONTH: + return validityDuration * 30; + case ValidityPeriodUnit.YEAR: + return validityDuration * 365; + case ValidityPeriodUnit.DAYS: + default: + return validityDuration; + } + } + + async grantAdminAllowanceForTxFee(appId) { + const url = `${urlSanitizer(constant.AUTHZ_URL, false)}/${appId}`; + const data = await fetch(url); + if (data && data.ok) { + const resp = await data.json(); + return resp; + } + } +} diff --git a/src/credit-manager/services/storage-credit.service.ts b/src/credit-manager/services/storage-credit.service.ts new file mode 100644 index 00000000..0233dbe9 --- /dev/null +++ b/src/credit-manager/services/storage-credit.service.ts @@ -0,0 +1,13 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { CREDIT_COSTS } from 'src/utils/utils'; + +@Injectable() +export class StorageCreditService { + async calculateCost(type) { + Logger.log( + 'Inside calculateCost to calculate credit', + 'StorageCreditService', + ); + return CREDIT_COSTS.STORAGE[type]; + } +} diff --git a/src/credit-manager/utils.ts b/src/credit-manager/utils.ts new file mode 100644 index 00000000..c66de236 --- /dev/null +++ b/src/credit-manager/utils.ts @@ -0,0 +1,138 @@ +import { ATTESTAION_TYPE, RMethods, StorageType } from 'src/utils/utils'; + +interface ApiDetail { + method: RMethods; + storageType: StorageType | null; + attestationType: ATTESTAION_TYPE | null; +} + +export function getApiDetail(req): ApiDetail { + const { method, url } = req; + const body = req.body || {}; + if (url.includes('/did/create')) { + return { + method, + storageType: StorageType.KEYSTORAGE, + attestationType: null, + }; + } + if (url.includes('/did/register')) { + return { + method, + storageType: null, + attestationType: ATTESTAION_TYPE.REGISTER_DID, + }; + } + if ( + url.includes('/did') && + method === RMethods.PATCH && + body['didDocument'] + ) { + return { + method, + storageType: null, + attestationType: ATTESTAION_TYPE.UPDATE_DID, + }; + } + if (url.includes('/schema') && method === RMethods.POST) { + return { + method, + storageType: null, + attestationType: ATTESTAION_TYPE.REGISTER_SCHEMA, + }; + } + const [basePath, queryString] = url.split('?'); + const queryParams = new URLSearchParams(queryString || ''); + const persistQuery = queryParams.get('persist') === 'true'; + const registerCredentialStatusQuery = + queryParams.get('registerCredentialStatus') === 'true'; + const persist = body?.persist ?? persistQuery; + const registerCredentialStatus = + body?.registerCredentialStatus ?? registerCredentialStatusQuery; + if ( + url.includes('/credential/issue') && + persist === true && + registerCredentialStatus === true + ) { + return { + method, + storageType: StorageType.DATASTORAGE, + attestationType: ATTESTAION_TYPE.REGISTER_CREDENTIAL, + }; + } else if ( + url.includes('/credential/issue') && + persist === false && + registerCredentialStatus === true + ) { + return { + method, + storageType: null, + attestationType: ATTESTAION_TYPE.REGISTER_CREDENTIAL, + }; + } else if ( + url.includes('/credential/issue') && + persist === true && + registerCredentialStatus === false + ) { + return { + method, + storageType: StorageType.DATASTORAGE, + attestationType: null, + }; + } + // else if (url.includes('/credential/issue') && body.persist === false && body.registerCredentialStatus === false) { + // return { + // method, storageType: null, attestationType: null + // } + // } check if this goes to default case or not + if (url.includes('/credential/verify')) { + return { + method, + storageType: null, + attestationType: null, + }; + } + if (url.includes('/credential/status/register')) { + return { + method, + storageType: null, + attestationType: ATTESTAION_TYPE.REGISTER_CREDENTIAL, + }; + } + const statusWithIdRegx = /^\/credential\/status\/[^\/]+$/; + if (statusWithIdRegx.test(url)) { + return { + method, + storageType: null, + attestationType: ATTESTAION_TYPE.UPDATE_CREDENTIAL, + }; + } + return { + method, + storageType: null, + attestationType: null, + }; +} + +export const constant = { + AUTHZ_URL: 'https://api.entity.dashboard.hypersign.id/api/v1/credits/authz', +}; + +export enum ACCESS_TYPES { + ALL = 'ALL', + READ_DID = 'READ_DID', + WRITE_DID = 'WRITE_DID', + WRITE_CREDIT = 'WRITE_CREDIT', + VERIFY_DID_SIGNATURE = 'VERIFY_DID_SIGNATURE', + READ_CREDIT = 'READ_CREDIT', + WRITE_SCHEMA = 'WRITE_SCHEMA', + READ_SCHEMA = 'READ_SCHEMA', + CHECK_LIVE_STATUS = 'CHECK_LIVE_STATUS', + READ_TX = 'READ_TX', + READ_CREDENTIAL = 'READ_CREDENTIAL', + VERIFY_CREDENTIAL = 'VERIFY_CREDENTIAL', + WRITE_CREDENTIAL = 'WRITE_CREDENTIAL', + READ_USAGE = 'READ_USAGE', + WRITE_PRESENTATION = 'WRITE_PRESENTATION', + VERIFY_PRESENTATION = 'VERIFY_PRESENTATION', +} diff --git a/src/did/controllers/did.controller.ts b/src/did/controllers/did.controller.ts index 823a3bbb..4a7a4c84 100644 --- a/src/did/controllers/did.controller.ts +++ b/src/did/controllers/did.controller.ts @@ -22,7 +22,11 @@ import { TxnHash, CreateDidResponse, } from '../dto/create-did.dto'; -import { UpdateDidDto, ResolvedDid } from '../dto/update-did.dto'; +import { + UpdateDidDto, + ResolvedDid, + UpdateDidResp, +} from '../dto/update-did.dto'; import { AuthGuard } from '@nestjs/passport'; import { ApiNotFoundResponse, @@ -52,15 +56,20 @@ import { AtLeastOneParamPipe } from 'src/utils/Pipes/atleastOneParam.pipe'; import { AddVMResponse, AddVerificationMethodDto } from '../dto/addVm.dto'; import { SignDidDto, SignedDidDocument } from '../dto/sign-did.dto'; import { VerifyDidDocResponseDto, VerifyDidDto } from '../dto/verify-did.dto'; +import { ReduceCreditGuard } from 'src/credit-manager/gaurd/reduce-credit.gaurd'; +import { AccessGuard } from 'src/utils/guards/access.gaurd'; +import { Access } from 'src/utils/customDecorator/access.decorator'; +import { ACCESS_TYPES } from 'src/credit-manager/utils'; @UseFilters(AllExceptionsFilter) @ApiTags('Did') @Controller('did') @ApiBearerAuth('Authorization') -@UseGuards(AuthGuard('jwt')) +@UseGuards(AuthGuard('jwt'), ReduceCreditGuard, AccessGuard) export class DidController { constructor(private readonly didService: DidService) {} @UsePipes(new ValidationPipe({ transform: true })) + @Access(ACCESS_TYPES.READ_DID) @Get() @ApiOkResponse({ description: 'DID List', @@ -104,7 +113,7 @@ export class DidController { return this.didService.getDidList(appDetail, pageOption); } - + @Access(ACCESS_TYPES.READ_DID) @Get('resolve/:did') @ApiOkResponse({ description: 'DID Resolved', @@ -136,6 +145,7 @@ export class DidController { forbidNonWhitelisted: true, }), ) + @Access(ACCESS_TYPES.WRITE_DID) @Post('create') @ApiCreatedResponse({ description: 'DID Created', @@ -210,7 +220,7 @@ export class DidController { return classToPlain(response, { excludePrefixes: ['transactionHash'] }); } } - + @Access(ACCESS_TYPES.WRITE_DID) @Post('/addVerificationMethod') @ApiOkResponse({ description: 'Added vm to Did Document', @@ -241,7 +251,7 @@ export class DidController { Logger.log('addVerificationMethod() method: starts', 'DidController'); return this.didService.addVerificationMethod(addVm); } - + @Access(ACCESS_TYPES.WRITE_DID) @Post('/auth/sign') @ApiOkResponse({ description: 'DidDocument is signed successfully', @@ -272,6 +282,7 @@ export class DidController { Logger.log('SignDidDocument() method: starts', 'DidController'); return this.didService.SignDidDocument(signDidDocDto, req.user); } + @Access(ACCESS_TYPES.VERIFY_DID_SIGNATURE) @Post('/auth/verify') @ApiOkResponse({ description: 'DidDocument is verified successfully', @@ -321,6 +332,7 @@ export class DidController { description: 'Origin as you set in application cors', required: false, }) + @Access(ACCESS_TYPES.WRITE_DID) @Post('/register') @UsePipes(ValidationPipe) register( @@ -333,12 +345,11 @@ export class DidController { const appDetail = req.user; return this.didService.register(registerDidDto, appDetail); } - + @Access(ACCESS_TYPES.WRITE_DID) @Patch() - @UsePipes(ValidationPipe) @ApiOkResponse({ description: 'DID Updated', - type: TxnHash, + type: UpdateDidResp, }) @ApiBadRequestResponse({ status: 400, @@ -360,6 +371,8 @@ export class DidController { description: 'Origin as you set in application cors', required: false, }) + @UsePipes(ValidationPipe) + @UsePipes(new AtLeastOneParamPipe(['name', 'didDocument'])) updateDid( @Headers('Authorization') authorization: string, @Req() req: any, @@ -390,6 +403,7 @@ export class DidController { required: false, }) @UsePipes(ValidationPipe) + @Access(ACCESS_TYPES.WRITE_DID) @Post('register/v2') registerV2( @Headers('Authorization') authorization: string, diff --git a/src/did/did.module.ts b/src/did/did.module.ts index 588b5d41..178dda1f 100644 --- a/src/did/did.module.ts +++ b/src/did/did.module.ts @@ -24,8 +24,17 @@ import { databaseProviders } from '../mongoose/tenant-mongoose-connections'; import { didProviders } from './providers/did.provider'; import { JwtStrategy } from '../utils/jwt.strategy'; import { TxSendModuleModule } from 'src/tx-send-module/tx-send-module.module'; +import { CreditManagerModule } from 'src/credit-manager/credit-manager.module'; +import { AppLoggerMiddleware } from 'src/utils/interceptor/http-interceptor'; +import { LogModule } from 'src/log/log.module'; @Module({ - imports: [EdvModule, HidWalletModule, TxSendModuleModule], + imports: [ + EdvModule, + HidWalletModule, + TxSendModuleModule, + CreditManagerModule, + LogModule, + ], controllers: [DidController], providers: [ JwtStrategy, @@ -55,5 +64,6 @@ export class DidModule implements NestModule { { path: 'did/:did', method: RequestMethod.GET }, ) .forRoutes(DidController); + consumer.apply(AppLoggerMiddleware).forRoutes(DidController); } } diff --git a/src/did/dto/create-did.dto.ts b/src/did/dto/create-did.dto.ts index b77b0b24..27f7dc3c 100644 --- a/src/did/dto/create-did.dto.ts +++ b/src/did/dto/create-did.dto.ts @@ -7,13 +7,17 @@ import { IsObject, IsOptional, IsString, + MaxLength, MinLength, ValidateIf, ValidateNested, } from 'class-validator'; import { RegistrationStatus } from '../schemas/did.schema'; import { DidDoc } from '../dto/update-did.dto'; -import { IsDid } from 'src/utils/customDecorator/did.decorator'; +import { + IsDid, + IsMethodSpecificId, +} from 'src/utils/customDecorator/did.decorator'; import { ValidatePublicKeyMultibase } from 'src/utils/customDecorator/pubKeyMultibase.decorator'; import { IVerificationRelationships, IKeyType } from 'hs-ssi-sdk'; import { IsKeyTypeArrayOrSingle } from 'src/utils/customDecorator/keyType.decorator'; @@ -100,15 +104,20 @@ export class CreateDidDto { message: "namespace must be one of the following values: 'testnet', '' ", }) namespace: string; - @IsOptional() - @IsString() - @MinLength(32) + @ApiProperty({ name: 'methodSpecificId', description: 'MethodSpecificId to be added in did', example: '0x19d73aeeBcc6FEf2d0342375090401301Fe9663F', required: false, + minLength: 32, + maxLength: 48, }) + @IsOptional() + @IsString() + @MinLength(32) + @MaxLength(48) + @IsMethodSpecificId() methodSpecificId?: string; @ApiProperty({ diff --git a/src/did/dto/update-did.dto.ts b/src/did/dto/update-did.dto.ts index 4510eee3..31819dc9 100644 --- a/src/did/dto/update-did.dto.ts +++ b/src/did/dto/update-did.dto.ts @@ -2,11 +2,14 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsArray, + IsBoolean, IsEnum, + IsNotEmpty, IsNotEmptyObject, IsOptional, IsString, Matches, + ValidateIf, ValidateNested, } from 'class-validator'; import { IsDid } from 'src/utils/customDecorator/did.decorator'; @@ -225,12 +228,22 @@ export class UpdateDidDto { @ApiProperty({ description: 'Did doc to be updated', type: DidDoc, + required: false, }) + @IsOptional() @IsNotEmptyObject() @Type(() => DidDoc) @ValidateNested() didDocument: DidDoc; - + @ApiProperty({ + description: 'DidDocument id', + example: 'did:hid:testnet:rt245vfnk.......', + required: false, + }) + @ValidateIf((dto) => dto.name !== undefined && dto.didDocument === undefined) + @IsString() + @IsNotEmpty() + did?: string; @ApiProperty({ description: 'Verification Method id for did registration', example: 'did:hid:testnet:........#key-${idx}', @@ -266,9 +279,48 @@ export class UpdateDidDto { @ApiProperty({ description: 'Field to check if to deactivate did or to update it ', example: false, + required: false, + }) + @ValidateIf((dto) => dto.didDocument !== undefined) + @IsBoolean() + deactivate?: boolean; + + @ApiProperty({ + name: 'name', + example: `Issuer Identity`, + description: 'Any human-readable name for this DID', + required: false, }) - deactivate: boolean; + @IsOptional() + name?: string; +} +export class UpdateDidResp { + @ApiProperty({ + name: 'transactionHash', + description: 'Transaction Hash', + example: 'XYAIFLKFLKHSLFHKLAOHFOAIHG..........', + required: false, + }) + @IsOptional() + transactionHash: string; + @ApiProperty({ + name: 'didDocument', + description: 'Resolved Did Document', + example: DidDoc, + required: false, + }) + @IsOptional() + didDocument: DidDoc; + @ApiProperty({ + description: 'DidDocument id', + example: 'did:hid:testnet:rt245vfnk.......', + required: false, + }) + @IsOptional() + @IsString() + @IsNotEmpty() + did?: string; @ApiProperty({ name: 'name', example: `Issuer Identity`, diff --git a/src/did/schemas/did.schema.ts b/src/did/schemas/did.schema.ts index 1d15f067..bf51d435 100644 --- a/src/did/schemas/did.schema.ts +++ b/src/did/schemas/did.schema.ts @@ -13,7 +13,9 @@ export enum RegistrationStatus { COMPLETED = 'COMPLETED', UNREGISTRED = 'UNREGISTRED', } -@Schema() +@Schema({ + timestamps: true, +}) export class Did { @ApiHideProperty() @IsOptional() diff --git a/src/did/services/did.service.ts b/src/did/services/did.service.ts index b620cba3..e05bd792 100644 --- a/src/did/services/did.service.ts +++ b/src/did/services/did.service.ts @@ -43,7 +43,7 @@ export class DidService { private readonly didSSIService: DidSSIService, private readonly config: ConfigService, private readonly txnService: TxSendModuleService, - ) {} + ) { } async checkAllowence(address) { const url = @@ -100,22 +100,22 @@ export class DidService { if (!address) { throw new BadRequestException([ 'options.walletAddress is not passed , required for keyType ' + - IKeyType.EcdsaSecp256k1RecoveryMethod2020, + IKeyType.EcdsaSecp256k1RecoveryMethod2020, ]); } if (!chainId) { throw new BadRequestException([ 'options.chainId is not passed , required for keyType ' + - IKeyType.EcdsaSecp256k1RecoveryMethod2020, + IKeyType.EcdsaSecp256k1RecoveryMethod2020, ]); } if (register === true) { throw new BadRequestException([ 'options.register is true for keyType ' + - IKeyType.EcdsaSecp256k1RecoveryMethod2020, + IKeyType.EcdsaSecp256k1RecoveryMethod2020, IKeyType.EcdsaSecp256k1RecoveryMethod2020 + - ' doesnot support register without signature being passed', + ' doesnot support register without signature being passed', 'options.register:false is strongly recomended', ]); } @@ -275,7 +275,7 @@ export class DidService { if (!insertedDoc) { throw new Error( 'Could not insert document for userCredential.walletAddress' + - userCredential.walletAddress, + userCredential.walletAddress, ); } const { id: userKMSId } = insertedDoc; @@ -432,7 +432,7 @@ export class DidService { if (!insertedDoc) { throw new Error( 'Could not insert document for userCredential.walletAddress' + - userCredential.walletAddress, + userCredential.walletAddress, ); } const { id: userKMSId } = insertedDoc; @@ -588,6 +588,7 @@ export class DidService { signInfos, verificationMethodId, appMenemonic, + appDetail, ); } else { registerDidDoc = await hypersignDid.register(params); @@ -756,12 +757,15 @@ export class DidService { const { wallet, address } = await this.hidWallet.generateWallet( appMenemonic, ); - if (await this.checkAllowence(address)) { + Logger.log(`Address: ${address}`); + const isDevMode = this.config.get('NODE_ENV') === 'development'; + if (!isDevMode && (await this.checkAllowence(address))) { await this.txnService.sendDIDTxn( didDocument, finalSignInfos, registerV2DidDto.signInfos, appMenemonic, + appDetail, ); } else { registerDidDoc = await hypersignDid.registerByClientSpec({ @@ -808,6 +812,7 @@ export class DidService { ); } return { + name: didInfo?.name || '', did: registerDidData.did, registrationStatus: registerDidData.registrationStatus, transactionHash: registerDidData.transactionHash, @@ -838,15 +843,20 @@ export class DidService { async resolveDid(appDetail, did: string) { Logger.log('resolveDid() method: starts....', 'DidService'); - const didInfo = await this.didRepositiory.findOne({ did, }); let resolvedDid; - if (didInfo !== null && didInfo.registrationStatus !== 'COMPLETED') { + const hypersignDid = new HypersignDID(); + const resolvedDIDDocFromBc = await hypersignDid.resolve({ did }); + if ( + (!resolvedDIDDocFromBc || + Object.keys(resolvedDIDDocFromBc.didDocument).length == 0) && + didInfo + ) { + // didInfo !== null && didInfo.registrationStatus !== 'COMPLETED') { const { edvId, kmsId } = appDetail; Logger.log('resolveDid() method: initialising edv service', 'DidService'); - const mnemonic = await getAppMenemonic(kmsId); const didSplitedArray = did.split(':'); // Todo Remove this worst way of doing it const namespace = didSplitedArray[2]; @@ -859,13 +869,14 @@ export class DidService { mnemonic, namespace, ); - const hdPathIndex = didInfo.hdPathIndex; - const slipPathKeys: Array = - this.hidWallet.makeSSIWalletPath(hdPathIndex); - const seed = await this.hidWallet.generateMemonicToSeedFromSlip10RawIndex( - slipPathKeys, + + const appVault = await getAppVault(kmsId, edvId); + const { mnemonic: userMnemonic } = await appVault.getDecryptedDocument( + didInfo.kmsId, ); + const seed = await this.hidWallet.getSeedFromMnemonic(userMnemonic); const { publicKeyMultibase } = await hypersignDid.generateKeys({ seed }); + Logger.log( 'resolveDid() method: before calling hypersignDid.generate', 'DidService', @@ -874,100 +885,34 @@ export class DidService { methodSpecificId, publicKeyMultibase, }); + const tempResolvedDid = { didDocument: resolvedDid, - didDocumentMetadata: {}, + didDocumentMetadata: null, name: didInfo.name, }; resolvedDid = tempResolvedDid; } else { - const hypersignDid = new HypersignDID(); - resolvedDid = await hypersignDid.resolve({ did }); + resolvedDid = resolvedDIDDocFromBc; resolvedDid['name'] = didInfo?.name; } return resolvedDid; } - async updateDid(updateDidDto: UpdateDidDto, appDetail): Promise { + async updateDid( + updateDidDto: UpdateDidDto, + appDetail, + ): Promise<{ + transactionHash?: string; + didDocument?: Did; + name?: string; + did?: string; + }> { Logger.log('updateDid() method: starts....', 'DidService'); - if ( - updateDidDto.didDocument['id'] == undefined || - updateDidDto.didDocument['id'] == '' - ) { - throw new BadRequestException('Invalid didDoc'); - } - let updatedDid; - Logger.debug( - `updateDid() method: verificationMethod: ${updateDidDto.verificationMethodId}`, - 'DidService', - ); - const hasKeyAgreementType = - updateDidDto.didDocument.verificationMethod.some( - (VM) => - VM.type === IKeyType.X25519KeyAgreementKey2020 || - VM.type === IKeyType.X25519KeyAgreementKeyEIP5630, - ); - if (!hasKeyAgreementType) { - updateDidDto.didDocument.keyAgreement = []; - } - if (!updateDidDto.verificationMethodId) { - const did = updateDidDto.didDocument['id']; - const { edvId, kmsId } = appDetail; - - const mnemonic = await getAppMenemonic(kmsId); - const hypersignDid = await this.didSSIService.initiateHypersignDid( - mnemonic, - this.config.get('NETWORK') ? this.config.get('NETWORK') : 'testnet', - ); - - const didInfo = await this.didRepositiory.findOne({ - appId: appDetail.appId, - did, - }); - const { signInfos } = updateDidDto; - - // If signature is passed then no need to check if it is present in db or not - if (!signInfos && (!didInfo || didInfo == null || didInfo == undefined)) { - throw new NotFoundException([ - `${did} not found`, - `${did} is not owned by the appId ${appDetail.appId}`, - `Resource not found`, - ]); - } - const { didDocumentMetadata: updatedDidDocMetaData } = - await hypersignDid.resolve({ did }); - if (updatedDidDocMetaData === null) { - throw new NotFoundException([`${did} is not registered on the chain`]); - } - try { - if (!updateDidDto.deactivate) { - Logger.log( - 'updateDid() method: before calling hypersignDid.updateByClientSpec to update did', - 'DidService', - ); - updatedDid = await hypersignDid.updateByClientSpec({ - didDocument: updateDidDto.didDocument as Did, - signInfos, - versionId: updatedDidDocMetaData.versionId, - }); - } else { - Logger.log( - 'updateDid() method: before calling hypersignDid.deactivateByClientSpec to deactivate did', - 'DidService', - ); - updatedDid = await hypersignDid.deactivateByClientSpec({ - didDocument: updateDidDto.didDocument as Did, - signInfos, - versionId: updatedDidDocMetaData.versionId, - }); - } - } catch (error) { - throw new BadRequestException([error.message]); - } - } else { - const { verificationMethodId } = updateDidDto; - const didOfVmId = verificationMethodId?.split('#')[0]; + const { name, didDocument } = updateDidDto; + const did = updateDidDto?.did || updateDidDto?.didDocument?.id; + if (didDocument) { if ( updateDidDto.didDocument['id'] == undefined || updateDidDto.didDocument['id'] == '' @@ -975,129 +920,226 @@ export class DidService { throw new BadRequestException('Invalid didDoc'); } - const did = updateDidDto.didDocument['id']; - const { edvId, kmsId } = appDetail; - - const { mnemonic: appMenemonic } = - await global.kmsVault.getDecryptedDocument(kmsId); - const namespace = this.config.get('NETWORK') - ? this.config.get('NETWORK') - : 'testnet'; - - const hypersignDid = await this.didSSIService.initiateHypersignDid( - appMenemonic, - namespace, + Logger.debug( + `updateDid() method: verificationMethod: ${updateDidDto.verificationMethodId}`, + 'DidService', ); - - const didInfo = await this.didRepositiory.findOne({ - appId: appDetail.appId, - did: didOfVmId, - }); - if (!didInfo || didInfo == null) { - throw new NotFoundException([ - `${verificationMethodId} not found`, - `${verificationMethodId} is not owned by the appId ${appDetail.appId}`, - `Resource not found`, - ]); - } - - const { didDocument: resolvedDid, didDocumentMetadata } = - await hypersignDid.resolve({ did: didOfVmId }); - - if (didDocumentMetadata === null) { - throw new NotFoundException([ - `${didOfVmId} is not registered on the chain`, - ]); - } - - const { didDocumentMetadata: updatedDidDocMetaData } = - await hypersignDid.resolve({ did }); - if (updatedDidDocMetaData === null) { - throw new NotFoundException([`${did} is not registered on the chain`]); + const hasKeyAgreementType = + updateDidDto.didDocument.verificationMethod.some( + (VM) => + VM.type === IKeyType.X25519KeyAgreementKey2020 || + VM.type === IKeyType.X25519KeyAgreementKeyEIP5630, + ); + if (!hasKeyAgreementType) { + updateDidDto.didDocument.keyAgreement = []; } - - const appVault = await getAppVault(kmsId, edvId); - const { mnemonic: userMnemonic } = await appVault.getDecryptedDocument( - didInfo.kmsId, - ); - const seed = await this.hidWallet.getSeedFromMnemonic(userMnemonic); - const { privateKeyMultibase } = await hypersignDid.generateKeys({ - seed, - }); - - try { - const { wallet, address } = await this.hidWallet.generateWallet( - appMenemonic, + if (!updateDidDto.verificationMethodId) { + const did = updateDidDto.didDocument['id']; + const { edvId, kmsId } = appDetail; + const mnemonic = await getAppMenemonic(kmsId); + const hypersignDid = await this.didSSIService.initiateHypersignDid( + mnemonic, + this.config.get('NETWORK') ? this.config.get('NETWORK') : 'testnet', ); - if (!updateDidDto.deactivate) { - Logger.debug( - 'updateDid() method: before calling hypersignDid.update to update did', - 'DidService', - ); - - if ((await this.checkAllowence(address)) == false) { - updatedDid = await hypersignDid.update({ + const didInfo = await this.didRepositiory.findOne({ + appId: appDetail.appId, + did, + }); + const { signInfos } = updateDidDto; + + // If signature is passed then no need to check if it is present in db or not + if ( + !signInfos && + (!didInfo || didInfo == null || didInfo == undefined) + ) { + throw new NotFoundException([ + `${did} not found`, + `${did} is not owned by the appId ${appDetail.appId}`, + `Resource not found`, + ]); + } + const { didDocumentMetadata: updatedDidDocMetaData } = + await hypersignDid.resolve({ did }); + if (updatedDidDocMetaData === null) { + throw new NotFoundException([ + `${did} is not registered on the chain`, + ]); + } + try { + if (!updateDidDto.deactivate) { + Logger.log( + 'updateDid() method: before calling hypersignDid.updateByClientSpec to update did', + 'DidService', + ); + updatedDid = await hypersignDid.updateByClientSpec({ didDocument: updateDidDto.didDocument as Did, - privateKeyMultibase, - verificationMethodId: resolvedDid['verificationMethod'][0].id, + signInfos, versionId: updatedDidDocMetaData.versionId, - readonly: false, }); } else { - updatedDid = await hypersignDid.update({ + Logger.log( + 'updateDid() method: before calling hypersignDid.deactivateByClientSpec to deactivate did', + 'DidService', + ); + updatedDid = await hypersignDid.deactivateByClientSpec({ didDocument: updateDidDto.didDocument as Did, - privateKeyMultibase, - verificationMethodId: resolvedDid['verificationMethod'][0].id, + signInfos, versionId: updatedDidDocMetaData.versionId, - readonly: true, }); - await this.txnService.sendDIDUpdate( - updatedDid.didDocument, - updatedDid.signInfos, - updatedDid.versionId, - appMenemonic, - ); } - } else { - Logger.debug( - 'updateDid() method: before calling hypersignDid.deactivate to deactivate did', - 'DidService', + } catch (error) { + throw new BadRequestException([error.message]); + } + } else { + const { verificationMethodId } = updateDidDto; + const didOfVmId = verificationMethodId?.split('#')[0]; + if ( + updateDidDto.didDocument['id'] == undefined || + updateDidDto.didDocument['id'] == '' + ) { + throw new BadRequestException('Invalid didDoc'); + } + + const did = updateDidDto.didDocument['id']; + const { edvId, kmsId } = appDetail; + + const { mnemonic: appMenemonic } = + await global.kmsVault.getDecryptedDocument(kmsId); + const namespace = this.config.get('NETWORK') + ? this.config.get('NETWORK') + : 'testnet'; + + const hypersignDid = await this.didSSIService.initiateHypersignDid( + appMenemonic, + namespace, + ); + + const didInfo = await this.didRepositiory.findOne({ + appId: appDetail.appId, + did: didOfVmId, + }); + if (!didInfo || didInfo == null) { + throw new NotFoundException([ + `${verificationMethodId} not found`, + `${verificationMethodId} is not owned by the appId ${appDetail.appId}`, + `Resource not found`, + ]); + } + + const { didDocument: resolvedDid, didDocumentMetadata } = + await hypersignDid.resolve({ did: didOfVmId }); + + if (didDocumentMetadata === null) { + throw new NotFoundException([ + `${didOfVmId} is not registered on the chain`, + ]); + } + + const { didDocumentMetadata: updatedDidDocMetaData } = + await hypersignDid.resolve({ did }); + if (updatedDidDocMetaData === null) { + throw new NotFoundException([ + `${did} is not registered on the chain`, + ]); + } + + const appVault = await getAppVault(kmsId, edvId); + const { mnemonic: userMnemonic } = await appVault.getDecryptedDocument( + didInfo.kmsId, + ); + const seed = await this.hidWallet.getSeedFromMnemonic(userMnemonic); + const { privateKeyMultibase } = await hypersignDid.generateKeys({ + seed, + }); + + try { + const { wallet, address } = await this.hidWallet.generateWallet( + appMenemonic, ); - if ((await this.checkAllowence(address)) == false) { - updatedDid = await hypersignDid.deactivate({ - didDocument: updateDidDto.didDocument as Did, - privateKeyMultibase, - verificationMethodId: resolvedDid['verificationMethod'][0].id, - versionId: updatedDidDocMetaData.versionId, - }); + if (!updateDidDto.deactivate) { + Logger.debug( + 'updateDid() method: before calling hypersignDid.update to update did', + 'DidService', + ); + + if ((await this.checkAllowence(address)) == false) { + updatedDid = await hypersignDid.update({ + didDocument: updateDidDto.didDocument as Did, + privateKeyMultibase, + verificationMethodId: resolvedDid['verificationMethod'][0].id, + versionId: updatedDidDocMetaData.versionId, + readonly: false, + }); + } else { + updatedDid = await hypersignDid.update({ + didDocument: updateDidDto.didDocument as Did, + privateKeyMultibase, + verificationMethodId: resolvedDid['verificationMethod'][0].id, + versionId: updatedDidDocMetaData.versionId, + readonly: true, + }); + await this.txnService.sendDIDUpdate( + updatedDid.didDocument, + updatedDid.signInfos, + updatedDid.versionId, + appMenemonic, + appDetail, + ); + } } else { - updatedDid = await hypersignDid.update({ - didDocument: updateDidDto.didDocument as Did, - privateKeyMultibase, - verificationMethodId: resolvedDid['verificationMethod'][0].id, - versionId: updatedDidDocMetaData.versionId, - readonly: true, - }); - await this.txnService.sendDIDDeactivate( - updatedDid.didDocument, - updatedDid.signInfos, - updatedDid.versionId, - appMenemonic, + Logger.debug( + 'updateDid() method: before calling hypersignDid.deactivate to deactivate did', + 'DidService', ); + + if ((await this.checkAllowence(address)) == false) { + updatedDid = await hypersignDid.deactivate({ + didDocument: updateDidDto.didDocument as Did, + privateKeyMultibase, + verificationMethodId: resolvedDid['verificationMethod'][0].id, + versionId: updatedDidDocMetaData.versionId, + }); + } else { + updatedDid = await hypersignDid.update({ + didDocument: updateDidDto.didDocument as Did, + privateKeyMultibase, + verificationMethodId: resolvedDid['verificationMethod'][0].id, + versionId: updatedDidDocMetaData.versionId, + readonly: true, + }); + Logger.log( + 'before calling sendDIDDeactivate to deactivate the did.', + 'DidService', + ); + await this.txnService.sendDIDDeactivate( + updatedDid.didDocument, + updatedDid.signInfos, + updatedDid.versionId, + appMenemonic, + appDetail, + ); + } } + } catch (error) { + Logger.error( + `updateDid() method: Error: ${error.message}`, + 'DidService', + ); + throw new BadRequestException([error.message]); } - } catch (error) { - Logger.error( - `updateDid() method: Error: ${error.message}`, - 'DidService', - ); - throw new BadRequestException([error.message]); } } - - return { transactionHash: updatedDid.transactionHash }; + if (name) { + this.didRepositiory.findOneAndUpdate({ did }, { name }); + } + return { + transactionHash: updatedDid?.transactionHash, + didDocument: updateDidDto?.didDocument, + did, + name: updateDidDto.name, + }; } async addVerificationMethod( diff --git a/src/log/dto/create-log.dto.ts b/src/log/dto/create-log.dto.ts new file mode 100644 index 00000000..fc376ee1 --- /dev/null +++ b/src/log/dto/create-log.dto.ts @@ -0,0 +1,50 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; + +export class CreateLogDto { + @ApiProperty({ + name: 'appId', + description: 'appId of the request', + example: 'jag-gasgah-aw14234-1234', + }) + @IsString() + appId: string; + @ApiProperty({ + name: 'method', + description: 'method of the request', + example: 'POST', + }) + @IsString() + method: string; + @ApiProperty({ + name: 'path', + description: 'path of the request', + example: '/api/v1/did', + }) + @IsString() + path: string; + @ApiProperty({ + name: 'statusCode', + description: 'statusCode of the request', + example: 200, + }) + @IsString() + statusCode: number; + @ApiProperty({ + name: 'contentLength', + description: 'contentLength of the request', + example: 200, + }) + @IsString() + contentLength: string; + @ApiProperty({ + name: 'userAgent', + description: 'userAgent of the request', + example: 'postman', + }) + @IsString() + userAgent: string; + @IsOptional() + @IsString() + dataRequest?: string; +} diff --git a/src/log/log.module.ts b/src/log/log.module.ts new file mode 100644 index 00000000..7f062ed2 --- /dev/null +++ b/src/log/log.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { logProviders } from './schema/log.provider'; +import { LogService } from './services/log.service'; +import { LogRepository } from './repository/log.repository'; +import { databaseProviders } from 'src/mongoose/tenant-mongoose-connections'; +import { CreditManagerModule } from 'src/credit-manager/credit-manager.module'; + +@Module({ + imports: [CreditManagerModule], + controllers: [], + providers: [LogService, LogRepository, ...logProviders, ...databaseProviders], + exports: [LogRepository, LogService], +}) +export class LogModule {} diff --git a/src/log/repository/log.repository.ts b/src/log/repository/log.repository.ts new file mode 100644 index 00000000..a3cd363b --- /dev/null +++ b/src/log/repository/log.repository.ts @@ -0,0 +1,25 @@ +import { Inject, Logger } from '@nestjs/common'; +import { Log, LogDoc } from '../schema/log.schema'; +import { FilterQuery, Model } from 'mongoose'; + +export class LogRepository { + constructor(@Inject('LOG_MODEL') private logModel: Model) {} + async create(log: Log): Promise { + Logger.log( + 'Inside create() of LogRepository to add logger of each api call', + 'LogRepository', + ); + const newLog = new this.logModel(log); + return newLog.save(); + } + async findByAppId(appId: string): Promise { + return this.logModel.find({ appId }); + } + + async findLogBetweenDates(filterQuery: FilterQuery): Promise { + return this.logModel.find(filterQuery); + } + async findDataBasedOnAgggregationPipeline(pipeine) { + return this.logModel.aggregate(pipeine); + } +} diff --git a/src/log/schema/log.provider.ts b/src/log/schema/log.provider.ts new file mode 100644 index 00000000..018ffbb0 --- /dev/null +++ b/src/log/schema/log.provider.ts @@ -0,0 +1,9 @@ +import { Connection } from 'mongoose'; +import { LogSchema } from './log.schema'; +export const logProviders = [ + { + provide: 'LOG_MODEL', + useFactory: (connection: Connection) => connection.model('Log', LogSchema), + inject: ['APPDATABASECONNECTIONS'], + }, +]; diff --git a/src/log/schema/log.schema copy.ts b/src/log/schema/log.schema copy.ts new file mode 100644 index 00000000..e61f2f7b --- /dev/null +++ b/src/log/schema/log.schema copy.ts @@ -0,0 +1,34 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; + +export type LogDoc = Log & Document; +@Schema({ timestamps: true }) +export class Log { + @Prop() + method: string; + @Prop() + path: string; + @Prop() + statusCode: number; + @Prop() + contentLength: string; + @Prop() + userAgent: string; + @Prop() + ip: string; + @Prop() + appId: string; + @Prop({ + isRequired: false, + }) + did: string; + @Prop({ + isRequired: false, + }) + dataRequest: string; + @Prop({ + isRequired: false, + }) + ref_id: string; +} + +export const LogSchema = SchemaFactory.createForClass(Log); diff --git a/src/log/schema/log.schema.ts b/src/log/schema/log.schema.ts new file mode 100644 index 00000000..b771e813 --- /dev/null +++ b/src/log/schema/log.schema.ts @@ -0,0 +1,24 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; + +export type LogDoc = Log & Document; +@Schema({ timestamps: true }) +export class Log { + @Prop() + method: string; + @Prop() + path: string; + @Prop() + statusCode: number; + @Prop() + contentLength: string; + @Prop() + userAgent: string; + @Prop() + appId: string; + @Prop({ + isRequired: false, + }) + dataRequest: string; +} + +export const LogSchema = SchemaFactory.createForClass(Log); diff --git a/src/log/services/log.service.spec.ts b/src/log/services/log.service.spec.ts new file mode 100644 index 00000000..a2863e81 --- /dev/null +++ b/src/log/services/log.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { LogService } from './log.service'; + +describe('LogService', () => { + let service: LogService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [LogService], + }).compile(); + + service = module.get(LogService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/log/services/log.service.ts b/src/log/services/log.service.ts new file mode 100644 index 00000000..7f746b97 --- /dev/null +++ b/src/log/services/log.service.ts @@ -0,0 +1,258 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { LogRepository } from '../repository/log.repository'; +import { CreateLogDto } from '../dto/create-log.dto'; +import { CreditManagerService } from 'src/credit-manager/managers/credit-manager.service'; + +@Injectable() +export class LogService { + constructor( + private readonly logRepo: LogRepository, + private readonly creditManagerService: CreditManagerService, + ) {} + async createLog(log: any) { + Logger.log( + `Storing log to db: ${log.method} ${log.path} ${log.statusCode} ${log.contentLenght} ${log.userAgent} ${log.appId}`, + 'LogService', + ); + + return this.logRepo.create(log); + } + + async findLogByAppId(appId: string): Promise { + Logger.log( + `Finding log by appId : + ${appId} + `, + 'LogService', + ); + return await this.logRepo.findByAppId(appId); + } + + async findLogBetweenDates( + startDate: Date, + endDate: Date, + appId: string, + ): Promise { + Logger.log( + `Finding log by appId : + ${appId} + `, + 'LogService', + ); + + return await this.logRepo.findLogBetweenDates({ + startDate, + endDate, + appId, + }); + } + async findBetweenDatesAndAgreegateByPath( + startDate: Date, + endDate: Date, + appId: string, + ): Promise { + Logger.log( + `Finding log by appId : + ${appId} and group by path + `, + 'LogService', + ); + const pipeline = [ + { + $match: { + createdAt: { $gte: startDate, $lte: endDate }, + $nor: [ + { path: { $regex: 'usage' } }, + { path: { $regex: 'credit' } }, + { path: { $regex: 'presentation' } }, + ], + }, + }, + { + $project: { + method: 1, + normalizedPath: { + $switch: { + branches: [ + { + case: { + $regexMatch: { + input: '$path', + regex: '^/api/v1/did/resolve/', + }, + }, + then: '/api/v1/did/resolve', + }, + { + case: { + $regexMatch: { + input: '$path', + regex: '^/api/v1/schema/sch:', + }, + }, + then: '/api/v1/schema/resolve', + }, + { + case: { + $regexMatch: { + input: '$path', + regex: '^/api/v1/credential/vc:', + }, + }, + then: '/api/v1/credential/resolve', + }, + ], + default: '$path', // If no match, keep the original path + }, + }, + }, + }, + { + $group: { + _id: { + path: '$normalizedPath', + method: '$method', + }, + count: { $sum: 1 }, + }, + }, + { + $project: { + _id: 0, + apiPath: '$_id.path', + method: '$_id.method', + count: 1, + }, + }, + ]; + + const serviceDetails = + await this.logRepo.findDataBasedOnAgggregationPipeline(pipeline); + const updatedServiceDetails = await Promise.all( + serviceDetails.map(async (x) => { + x['apiPath'] = x['apiPath']; + x['quantity'] = x['count']; + x['unit_cost'] = + await this.creditManagerService.getCreditDetailFromPath( + x['method'], + x['apiPath'], + ); + (x['onchain_unit_cost'] = x['unit_cost']['hidCost']), + (x['offchain_unit_cost'] = x['unit_cost']['creditAmountRequired']); + x['onchainAmount'] = Number( + (x['onchain_unit_cost'] * x['quantity']).toFixed(2), + ); + x['offchainAmount'] = Number( + (x['offchain_unit_cost'] * x['quantity']).toFixed(2), + ); + delete x['count']; + delete x['unit_cost']; + return x; + }), + ); + + return updatedServiceDetails; + } + async findDetailedLogBetweenDates( + startDate: Date, + endDate: Date, + appId: string, + ): Promise { + Logger.log( + `Finding log by appId : + ${appId} and group by session path + `, + 'LogService', + ); + const pipeline = [ + { + $match: { + createdAt: { $gte: startDate, $lte: endDate }, + path: { $exists: true, $ne: null }, + $nor: [ + { path: { $regex: 'usage' } }, + { path: { $regex: 'credit' } }, + { path: { $regex: 'presentation' } }, + ], + }, + }, + { + $project: { + method: 1, + createdAt: 1, + path: { $ifNull: ['$path', 'unknown'] }, + normalizedPath: { + $switch: { + branches: [ + { + case: { + $regexMatch: { + input: '$path', + regex: '^/api/v1/did/resolve/', + }, + }, + then: '/api/v1/did/resolve', + }, + { + case: { + $regexMatch: { + input: '$path', + regex: '^/api/v1/schema/sch:', + }, + }, + then: '/api/v1/schema/resolve', + }, + { + case: { + $regexMatch: { + input: '$path', + regex: '^/api/v1/credential/vc:', + }, + }, + then: '/api/v1/credential/resolve', + }, + ], + default: '$path', + }, + }, + }, + }, + { + $group: { + _id: { + path: '$normalizedPath', + date: { + $ifNull: [ + { $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } }, + 'unknown-date', + ], + }, + }, + + count: { $sum: 1 }, + }, + }, + { + $group: { + _id: '$_id.path', + data: { + $push: { + k: '$_id.date', + v: '$count', + }, + }, + quantity: { $sum: '$count' }, + }, + }, + { + $project: { + _id: 0, + apiPath: '$_id', + data: { $arrayToObject: '$data' }, + quantity: 1, + }, + }, + ]; + return this.logRepo.findDataBasedOnAgggregationPipeline(pipeline); + } +} diff --git a/src/main.ts b/src/main.ts index 6384617d..b231b02d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,8 +17,13 @@ import { DidModule } from './did/did.module'; import { SchemaModule } from './schema/schema.module'; import { PresentationModule } from './presentation/presentation.module'; import { CredentialModule } from './credential/credential.module'; +import { StatusModule } from './status/status.module'; +import { CreditManagerModule } from './credit-manager/credit-manager.module'; +import { UsageModule } from './usage/usage.module'; async function bootstrap() { - const app = await NestFactory.create(AppModule, { cors: true }); + const app = await NestFactory.create(AppModule, { + cors: true, + }); app.use(json({ limit: '10mb' })); app.use(urlencoded({ extended: true, limit: '10mb' })); app.use(express.static(path.join(__dirname, '../public'))); @@ -124,7 +129,15 @@ async function bootstrap() { .build(); const tenantDocuments = SwaggerModule.createDocument(app, tenantDocConfig, { - include: [DidModule, SchemaModule, CredentialModule, PresentationModule], // don't include, say, BearsModule + include: [ + DidModule, + SchemaModule, + CredentialModule, + PresentationModule, + StatusModule, + CreditManagerModule, + UsageModule, + ], // don't include, say, BearsModule }); const tenantOptions = { @@ -145,19 +158,6 @@ async function bootstrap() { `Server running on http://localhost:${process.env.PORT}`, 'Bootstrap', ); - setInterval(async () => await checkEdv(), 120000); -} - -async function checkEdv() { - try { - const resp = await fetch(process.env.EDV_BASE_URL + '/api'); - - if (resp.status == 200) { - process.env.EDV_STATUS = 'UP'; - } - } catch (error) { - process.env.EDV_STATUS = 'DOWN'; - } } bootstrap(); diff --git a/src/mongoose/tenant-mongoose-connections.ts b/src/mongoose/tenant-mongoose-connections.ts index 7e1c97f1..d4b0eb3f 100644 --- a/src/mongoose/tenant-mongoose-connections.ts +++ b/src/mongoose/tenant-mongoose-connections.ts @@ -3,6 +3,67 @@ import { Connection } from 'mongoose'; import { REQUEST } from '@nestjs/core'; import { Scope, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +// mongoose.connections = mongoose.connections;/ + +const connectionPromises: Record> = {}; +async function tenantConnection(tenantDB, uri) { + Logger.log( + `No active connection found for tenantDB = ${tenantDB}. Establishing a new one.`, + 'tenant-mongoose-connections', + ); + // // Find existing connection + const foundConn = mongoose.connections.find((con: Connection) => { + return con.name === tenantDB; + }); + + // Return the same connection if it exist + if (foundConn && foundConn.readyState === 1) { + Logger.log( + 'Found connection tenantDB = ' + tenantDB, + 'tenant-mongoose-connections', + ); + return foundConn; + } else { + Logger.log( + 'No connection found for tenantDB = ' + tenantDB, + 'tenant-mongoose-connections', + ); + } + + if (!foundConn) { + if (!connectionPromises[tenantDB]) { + connectionPromises[tenantDB] = mongoose + .createConnection(uri, { + maxConnecting: 10, + maxPoolSize: 100, + maxStalenessSeconds: 100, + maxIdleTimeMS: 500000, + serverSelectionTimeoutMS: 500000, + socketTimeoutMS: 500000, + connectTimeoutMS: 500000, + }) + .asPromise(); + } + + const newConnection = await connectionPromises[tenantDB]; + delete connectionPromises[tenantDB]; // Remove the promise after resolution + + newConnection.on('disconnected', () => { + Logger.log( + 'DB connection ' + newConnection.name + ' is disconnected', + 'tenant-mongoose-connections', + ); + }); + + newConnection.on('error', (err: Error) => { + Logger.error( + `Error in connection for tenantDB = ${tenantDB}: ${err.message}`, + 'tenant-mongoose-connections', + ); + }); + return newConnection; + } +} export const databaseProviders = [ { @@ -16,9 +77,9 @@ export const databaseProviders = [ 'Db connection database provider', 'tenant-mongoose-connections', ); - const connections: Connection[] = mongoose.connections; + Logger.log( - 'Number of open connections: ' + connections.length, + 'Number of open connections: ' + mongoose.connections.length, 'tenant-mongoose-connections', ); const subdomain = request['user']['subdomain']; @@ -31,25 +92,9 @@ export const databaseProviders = [ ':' + subdomain; - // // Find existing connection - const foundConn = connections.find((con: Connection) => { - return con.name === tenantDB; - }); - - // Return the same connection if it exist - if (foundConn && foundConn.readyState === 1) { - Logger.log( - 'Found connection tenantDB = ' + tenantDB, - 'tenant-mongoose-connections', - ); - return foundConn; - } else { - Logger.log( - 'No connection found for tenantDB = ' + tenantDB, - 'tenant-mongoose-connections', - ); - } + Logger.log({ tenantDB }); + /// // TODO: take this from env using configService const BASE_DB_PATH = config.get('BASE_DB_PATH'); const CONFIG_DB = config.get('DB_CONFIG'); @@ -58,18 +103,13 @@ export const databaseProviders = [ } const uri = `${BASE_DB_PATH}/${tenantDB}${CONFIG_DB}`; + Logger.log( 'Before creating new db connection...', 'tenant-mongoose-connections', ); - const newConnectionPerApp = await mongoose.createConnection(uri); + const newConnectionPerApp = await tenantConnection(tenantDB, uri); ///await mongoose.createConnection(uri); - newConnectionPerApp.on('disconnected', () => { - Logger.log( - 'DB connection ' + newConnectionPerApp.name + ' is disconnected', - 'tenant-mongoose-connections', - ); - }); return newConnectionPerApp; }, inject: [REQUEST, ConfigService], diff --git a/src/presentation/controllers/presentation.controller.ts b/src/presentation/controllers/presentation.controller.ts index 52d8b289..8fbe254e 100644 --- a/src/presentation/controllers/presentation.controller.ts +++ b/src/presentation/controllers/presentation.controller.ts @@ -350,9 +350,13 @@ export class PresentationController { verify( @Headers('Authorization') authorization: string, @Body() presentation: VerifyPresentationDto, + @Req() req, ) { Logger.log('verify() method: starts', 'PresentationController'); - return this.presentationRequestService.verifyPresentation(presentation); + return this.presentationRequestService.verifyPresentation( + presentation, + req.user, + ); } } diff --git a/src/presentation/presentation.module.ts b/src/presentation/presentation.module.ts index 0c9b52f1..cec02269 100644 --- a/src/presentation/presentation.module.ts +++ b/src/presentation/presentation.module.ts @@ -24,9 +24,11 @@ import { WhitelistSSICorsMiddleware } from 'src/utils/middleware/cors.middleware import { TrimMiddleware } from 'src/utils/middleware/trim.middleware'; import { presentationTemplateProviders } from './providers/presentation.provider'; import { databaseProviders } from '../mongoose/tenant-mongoose-connections'; +import { AppLoggerMiddleware } from 'src/utils/interceptor/http-interceptor'; +import { LogModule } from 'src/log/log.module'; @Module({ - imports: [DidModule], + imports: [DidModule, LogModule], controllers: [PresentationTempleteController, PresentationController], providers: [ PresentationService, @@ -53,5 +55,8 @@ export class PresentationModule implements NestModule { { path: 'presentation/template', method: RequestMethod.DELETE }, ) .forRoutes(PresentationTempleteController, PresentationController); + consumer + .apply(AppLoggerMiddleware) + .forRoutes(PresentationTempleteController, PresentationController); } } diff --git a/src/presentation/services/presentation.service.ts b/src/presentation/services/presentation.service.ts index 302d9b37..9673553d 100644 --- a/src/presentation/services/presentation.service.ts +++ b/src/presentation/services/presentation.service.ts @@ -21,12 +21,16 @@ import { } from 'hs-ssi-sdk'; import { ConfigService } from '@nestjs/config'; import { HidWalletService } from 'src/hid-wallet/services/hid-wallet.service'; -import { DidRepository } from 'src/did/repository/did.repository'; +import { + DidMetaDataRepo, + DidRepository, +} from 'src/did/repository/did.repository'; import { VerifyPresentationDto } from '../dto/verify-presentation.dto'; import { getAppVault } from '../../utils/app-vault-service'; import { generateAppId } from 'src/utils/utils'; import { VerificationMethodRelationships } from 'hs-ssi-sdk/build/libs/generated/ssi/client/enums'; import { DidSSIService } from 'src/did/services/did.ssi.service'; +import { DidService } from 'src/did/services/did.service'; @Injectable() export class PresentationService { constructor( @@ -200,6 +204,8 @@ export class PresentationRequestService { constructor( private readonly presentationtempleteReopsitory: PresentationTemplateRepository, private readonly didRepositiory: DidRepository, + private readonly didMetaRepositiory: DidMetaDataRepo, + private readonly didService: DidService, private readonly config: ConfigService, private readonly hidWallet: HidWalletService, private readonly didSSIService: DidSSIService, @@ -295,6 +301,11 @@ export class PresentationRequestService { const { didDocument } = await hypersignDID.resolve({ did: holderDid, }); + if (Object.keys(didDocument).length == 0) { + const did = await this.didService.resolveDid(appDetail, holderDid); + Object.assign(didDocument, did.didDocument); + } + const verificationMethodIdforAssert = verificationMethodId || didDocument.assertionMethod[0]; Logger.log( @@ -315,9 +326,11 @@ export class PresentationRequestService { // Holder Identity: - used for authenticating presentation const { edvId, kmsId } = appDetail; const appVault = await getAppVault(kmsId, edvId); + const vmWithAssertion = didDocument.verificationMethod.find( (vm) => vm.id === verificationMethodIdforAssert, ); + const { mnemonic: holderMnemonic } = await appVault.getDecryptedDocument( didInfo.kmsId, ); @@ -325,6 +338,7 @@ export class PresentationRequestService { let hypersignDid; let privateKeyMultibase; let signedVerifiablePresentation; + if ( vmWithAssertion && vmWithAssertion.type === IKeyType.BabyJubJubKey2021 @@ -340,11 +354,14 @@ export class PresentationRequestService { 'createPresentation() method: before calling hypersignVP.sign for bjj', 'PresentationRequestService', ); + signedVerifiablePresentation = await hypersignVP.bjjVp.sign({ presentation: unsignedverifiablePresentation as IVerifiablePresentation, - holderDid, + // holderDid, + holderDidDocSigned: didDocument as any, verificationMethodId: verificationMethodIdforAssert, challenge, + domain, privateKeyMultibase, }); } else { @@ -358,7 +375,9 @@ export class PresentationRequestService { ); signedVerifiablePresentation = await hypersignVP.sign({ presentation: unsignedverifiablePresentation as IVerifiablePresentation, - holderDid, + // holderDid, + holderDidDocSigned: didDocument as any, + domain, verificationMethodId: verificationMethodIdforAssert, challenge, privateKeyMultibase, @@ -371,7 +390,7 @@ export class PresentationRequestService { return { presentation: signedVerifiablePresentation }; } - async verifyPresentation(presentations: VerifyPresentationDto) { + async verifyPresentation(presentations: VerifyPresentationDto, appDetail) { Logger.log( 'verifyPresentation() method: starts....', 'PresentationRequestService', @@ -393,11 +412,17 @@ export class PresentationRequestService { const issuerDid = presentation['verifiableCredential'][0]['issuer']; const challenge = presentation['proof']['challenge']; const type = presentation['proof']['type']; + const domain = presentation['proof']['domain']; Logger.log( 'verifyPresentation() method:before calling hypersignVP.verify', 'PresentationRequestService', ); + const holderDidResolved = await this.didService.resolveDid( + appDetail, + holderDid, + ); + let verifiedPresentationDetail; const holderVerificationMethodId = presentations.holderVerificationMethodId || holderDid + '#key-1'; @@ -407,16 +432,19 @@ export class PresentationRequestService { verifiedPresentationDetail = await hypersignVP.bjjVp.verify({ signedPresentation: presentation as any, issuerDid, - holderDid, + holderDidDocSigned: holderDidResolved.didDocument, holderVerificationMethodId: holderVerificationMethodId, issuerVerificationMethodId: issuerVerificationMethodId, challenge, + domain, }); } else { + // holderDidResolved.didDocument.verificationMethod[0].publicKeyMultibase='z6MkuX5ydorS9Hyf6J1Yu4tKPvzLwUpe6TVfATXqn17SvJA4' verifiedPresentationDetail = await hypersignVP.verify({ signedPresentation: presentation as any, issuerDid, - holderDid, + domain, + holderDidDocSigned: holderDidResolved.didDocument, holderVerificationMethodId: holderVerificationMethodId, issuerVerificationMethodId: issuerVerificationMethodId, challenge, diff --git a/src/schema/controllers/schema.controller.ts b/src/schema/controllers/schema.controller.ts index ae01bbcd..3f1eb4b9 100644 --- a/src/schema/controllers/schema.controller.ts +++ b/src/schema/controllers/schema.controller.ts @@ -34,20 +34,25 @@ import { ApiQuery, ApiHeader, ApiOkResponse, + ApiExcludeEndpoint, } from '@nestjs/swagger'; import { Schemas } from '../schemas/schemas.schema'; import { SchemaResponseInterceptor } from '../interceptors/transformResponse.interseptor'; import { GetSchemaList } from '../dto/get-schema.dto'; import { RegisterSchemaDto } from '../dto/register-schema.dto'; import { TxnHash } from 'src/did/dto/create-did.dto'; +import { ReduceCreditGuard } from 'src/credit-manager/gaurd/reduce-credit.gaurd'; +import { AccessGuard } from 'src/utils/guards/access.gaurd'; +import { Access } from 'src/utils/customDecorator/access.decorator'; +import { ACCESS_TYPES } from 'src/credit-manager/utils'; @UseFilters(AllExceptionsFilter) @ApiTags('Schema') @Controller('schema') @ApiBearerAuth('Authorization') -@UseGuards(AuthGuard('jwt')) +@UseGuards(AuthGuard('jwt'), ReduceCreditGuard, AccessGuard) export class SchemaController { constructor(private readonly schemaService: SchemaService) {} - + @Access(ACCESS_TYPES.WRITE_SCHEMA) @Post() @ApiCreatedResponse({ description: 'Schema Created', @@ -84,6 +89,7 @@ export class SchemaController { return this.schemaService.create(createSchemaDto, appDetail); } @UsePipes(new ValidationPipe({ transform: true })) + @Access(ACCESS_TYPES.READ_SCHEMA) @Get() @ApiResponse({ status: 200, @@ -127,7 +133,7 @@ export class SchemaController { const appDetial = req.user; return this.schemaService.getSchemaList(appDetial, paginationOption); } - + @Access(ACCESS_TYPES.READ_SCHEMA) @Get(':schemaId') @ApiResponse({ status: 200, @@ -157,7 +163,8 @@ export class SchemaController { return this.schemaService.resolveSchema(schemaId); } - + @ApiExcludeEndpoint() + @Access(ACCESS_TYPES.WRITE_SCHEMA) @Post('/register') @ApiOkResponse({ description: 'Registered schema successfully', diff --git a/src/schema/schema.module.ts b/src/schema/schema.module.ts index 65e2bef2..6257d39c 100644 --- a/src/schema/schema.module.ts +++ b/src/schema/schema.module.ts @@ -16,9 +16,22 @@ import { TrimMiddleware } from 'src/utils/middleware/trim.middleware'; import { schemaProviders } from './providers/schema.provider'; import { databaseProviders } from '../mongoose/tenant-mongoose-connections'; import { TxSendModuleModule } from 'src/tx-send-module/tx-send-module.module'; +import { StatusService } from 'src/status/status.service'; +import { StatusModule } from 'src/status/status.module'; +import { TxnStatusRepository } from 'src/status/repository/status.repository'; +import { statusProviders } from 'src/status/providers/registration-status.provider'; +import { CreditManagerModule } from 'src/credit-manager/credit-manager.module'; +import { AppLoggerMiddleware } from 'src/utils/interceptor/http-interceptor'; +import { LogModule } from 'src/log/log.module'; @Module({ - imports: [DidModule, TxSendModuleModule], + imports: [ + DidModule, + TxSendModuleModule, + StatusModule, + CreditManagerModule, + LogModule, + ], controllers: [SchemaController], providers: [ SchemaService, @@ -26,8 +39,11 @@ import { TxSendModuleModule } from 'src/tx-send-module/tx-send-module.module'; DidService, HidWalletService, SchemaRepository, + StatusService, + TxnStatusRepository, ...databaseProviders, ...schemaProviders, + ...statusProviders, ], exports: [SchemaModule], }) @@ -43,5 +59,6 @@ export class SchemaModule implements NestModule { { path: 'schema/:schemaId', method: RequestMethod.GET }, ) .forRoutes(SchemaController); + consumer.apply(AppLoggerMiddleware).forRoutes(SchemaController); } } diff --git a/src/schema/services/schema.service.ts b/src/schema/services/schema.service.ts index 39c9529a..97475a87 100644 --- a/src/schema/services/schema.service.ts +++ b/src/schema/services/schema.service.ts @@ -21,6 +21,7 @@ import { RegisterSchemaDto } from '../dto/register-schema.dto'; import { Namespace } from 'src/did/dto/create-did.dto'; import { getAppVault, getAppMenemonic } from '../../utils/app-vault-service'; import { TxSendModuleService } from 'src/tx-send-module/tx-send-module.service'; +import { StatusService } from 'src/status/status.service'; @Injectable({ scope: Scope.REQUEST }) export class SchemaService { @@ -31,6 +32,7 @@ export class SchemaService { private readonly hidWallet: HidWalletService, private readonly didRepositiory: DidRepository, private readonly txnService: TxSendModuleService, + private readonly statusService: StatusService, ) {} async checkAllowence(address) { @@ -123,6 +125,7 @@ export class SchemaService { generatedSchema, signedSchema.proof, appMenemonic, + appDetail, ); } else { registeredSchema = await hypersignSchema.register({ @@ -201,15 +204,36 @@ export class SchemaService { 'SchemaService', ); - const resolvedSchema = await hypersignSchema.resolve({ schemaId }); - if (resolvedSchema == undefined) { + const statusResponse = await this.statusService.findBySsiId(schemaId); + if (statusResponse) { + const firstResponse = statusResponse[0]; + if (firstResponse && firstResponse.data) { + if (firstResponse.data.findIndex((x) => x['status'] != 0) >= 0) { + throw new BadRequestException([firstResponse]); + } + } + } + + let resolvedSchema; + try { + resolvedSchema = await hypersignSchema.resolve({ schemaId }); + } catch (e) { + Logger.error(e); + } + if ( + !resolvedSchema || + Object.keys(resolvedSchema).length == 0 || + !resolvedSchema.schema + ) { Logger.error( - 'resolveSchema() method: Error whilt resolving schema', + 'resolveSchema() method: Error whilt resolving schema schemaId' + + schemaId, 'SchemaService', ); - throw new NotFoundException([ - `${schemaId} could not resolve this schema`, - ]); + const tempResolvedDid = { + id: schemaId, + }; + return tempResolvedDid; } try { @@ -270,6 +294,7 @@ export class SchemaService { registerSchemaDto.schemaDocument, registerSchemaDto.schemaProof, appMenemonic, + appDetail, ); } else { registeredSchema = await hypersignSchema.register({ diff --git a/src/status/dto/registration-status.response.dto.ts b/src/status/dto/registration-status.response.dto.ts new file mode 100644 index 00000000..99b8c2f9 --- /dev/null +++ b/src/status/dto/registration-status.response.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber, IsString, IsArray } from 'class-validator'; + +import { RegistrationStatus } from '../schema/status.schema'; + +export class RegistrationStatusList { + @ApiProperty({ + description: 'totalCount', + example: 12, + }) + @IsNumber() + totalCount: number; + + @ApiProperty({ + description: 'data', + type: RegistrationStatus, + example: [], + isArray: true, + }) + @IsString() + @IsArray() + data: Array; +} diff --git a/src/status/providers/registration-status.provider.ts b/src/status/providers/registration-status.provider.ts new file mode 100644 index 00000000..fe0ffff3 --- /dev/null +++ b/src/status/providers/registration-status.provider.ts @@ -0,0 +1,11 @@ +import { Connection } from 'mongoose'; +import { RegistrationStatusSchema } from '../schema/status.schema'; + +export const statusProviders = [ + { + provide: 'STATUS_MODEL', + useFactory: (connection: Connection) => + connection.model('RegistrationStatus', RegistrationStatusSchema), + inject: ['APPDATABASECONNECTIONS'], + }, +]; diff --git a/src/status/repository/status.repository.ts b/src/status/repository/status.repository.ts new file mode 100644 index 00000000..acc0ec3b --- /dev/null +++ b/src/status/repository/status.repository.ts @@ -0,0 +1,34 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { FilterQuery, Model, ProjectionType, QueryOptions } from 'mongoose'; +import { RegistrationStatusDocument } from '../schema/status.schema'; +import { skip } from 'rxjs'; +import { RegistrationStatusList } from '../dto/registration-status.response.dto'; + +@Injectable() +export class TxnStatusRepository { + constructor( + @Inject('STATUS_MODEL') + private readonly registatiationStatusModel: Model, + ) {} + + async find( + registrationStatus: FilterQuery, + projection?: ProjectionType, + option?: QueryOptions, + ): Promise { + return this.registatiationStatusModel.aggregate([ + { $match: { ...registrationStatus } }, // Apply the query filter + { + $facet: { + totalCount: [{ $count: 'total' }], + + data: [ + { $project: { _id: 0 } }, + { $skip: Number(option.skip) }, // Apply the skip (pagination) + { $limit: Number(option.limit) }, // Apply the limit (pagination) + ], + }, + }, + ]); + } +} diff --git a/src/status/schema/status.schema.ts b/src/status/schema/status.schema.ts new file mode 100644 index 00000000..7658a1e3 --- /dev/null +++ b/src/status/schema/status.schema.ts @@ -0,0 +1,36 @@ +import { Document } from 'mongoose'; +import { Schema, Prop, SchemaFactory } from '@nestjs/mongoose'; + +export type RegistrationStatusDocument = RegistrationStatus & Document; + +@Schema({ + timestamps: true, +}) +export class RegistrationStatus { + @Prop() + txnHash: string; + + @Prop({ required: false }) + status?: number; + + @Prop() + id: string; + + @Prop() + type: string; + + @Prop({ required: false, type: Object }) + message?: object; +} + +const RegistrationStatusSchema = + SchemaFactory.createForClass(RegistrationStatus); +RegistrationStatusSchema.index( + { + txnHash: 1, + id: 1, + }, + { unique: true }, +); + +export { RegistrationStatusSchema }; diff --git a/src/status/status.controller.ts b/src/status/status.controller.ts new file mode 100644 index 00000000..0634dfc2 --- /dev/null +++ b/src/status/status.controller.ts @@ -0,0 +1,89 @@ +import { + Controller, + Get, + Param, + UseGuards, + Query, + UseFilters, + UseInterceptors, +} from '@nestjs/common'; +import { StatusService } from './status.service'; + +import { + ApiBearerAuth, + ApiParam, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { AuthGuard } from '@nestjs/passport'; +import { PaginationDto } from 'src/utils/pagination.dto'; +import { AllExceptionsFilter } from 'src/utils/utils'; +import { RegistrationStatus } from './schema/status.schema'; +import { RegistrationStatusList } from './dto/registration-status.response.dto'; +import { RegistrationStatusInterceptor } from './transformer/staus-response.interceptor'; +import { AccessGuard } from 'src/utils/guards/access.gaurd'; +import { Access } from 'src/utils/customDecorator/access.decorator'; +import { ACCESS_TYPES } from 'src/credit-manager/utils'; +@UseFilters(AllExceptionsFilter) +@ApiTags('Status') +@ApiBearerAuth('Authorization') +@Controller('status') +@UseGuards(AuthGuard('jwt'), AccessGuard) +export class StatusController { + constructor(private readonly statusService: StatusService) {} + @Access(ACCESS_TYPES.CHECK_LIVE_STATUS) + @Get('ssi/:id') + @ApiResponse({ + description: 'List of the txns', + type: RegistrationStatusList, + }) + @ApiQuery({ + name: 'page', + description: 'Page value', + required: false, + }) + @ApiQuery({ + name: 'limit', + description: 'Fetch limited list of data', + required: false, + }) + @ApiParam({ + name: 'id', + description: 'Enter didId or vcId or schemaId', + }) + @UseInterceptors(RegistrationStatusInterceptor) + getStatus( + @Param('id') id: string, + @Query() pagination: PaginationDto, + ): Promise { + return this.statusService.findBySsiId(id, pagination); + } + @Access(ACCESS_TYPES.READ_TX) + @Get('transaction/:transactionHash') + @ApiResponse({ + description: 'List of the txns', + type: RegistrationStatusList, + }) + @ApiQuery({ + name: 'page', + description: 'Page value', + required: false, + }) + @ApiQuery({ + name: 'limit', + description: 'Fetch limited list of data', + required: false, + }) + @ApiParam({ + name: 'transactionHash', + description: 'Enter transactionHash', + }) + @UseInterceptors(RegistrationStatusInterceptor) + getStatusByTransactionHash( + @Param('transactionHash') transactionHash: string, + @Query() pagination: PaginationDto, + ): Promise { + return this.statusService.findByTxnId(transactionHash, pagination); + } +} diff --git a/src/status/status.module.ts b/src/status/status.module.ts new file mode 100644 index 00000000..e2bcbf40 --- /dev/null +++ b/src/status/status.module.ts @@ -0,0 +1,26 @@ +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { StatusService } from './status.service'; +import { StatusController } from './status.controller'; +import { databaseProviders } from 'src/mongoose/tenant-mongoose-connections'; +import { TxnStatusRepository } from './repository/status.repository'; +import { statusProviders } from './providers/registration-status.provider'; +import { WhitelistSSICorsMiddleware } from 'src/utils/middleware/cors.middleware'; +import { TrimMiddleware } from 'src/utils/middleware/trim.middleware'; + +@Module({ + imports: [], + controllers: [StatusController], + providers: [ + StatusService, + TxnStatusRepository, + ...databaseProviders, + ...statusProviders, + ], + exports: [], +}) +export class StatusModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(WhitelistSSICorsMiddleware).forRoutes(StatusController); + consumer.apply(TrimMiddleware).forRoutes(StatusController); + } +} diff --git a/src/status/status.service.ts b/src/status/status.service.ts new file mode 100644 index 00000000..68bef767 --- /dev/null +++ b/src/status/status.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; +import { TxnStatusRepository } from './repository/status.repository'; +import { RegistrationStatusList } from './dto/registration-status.response.dto'; + +@Injectable() +export class StatusService { + constructor(private readonly txnStatusRepository: TxnStatusRepository) {} + findBySsiId(id: string, option?): Promise { + if (!option) { + option = { + page: 1, + limit: 10, + }; + } + const skip = (option.page - 1) * option.limit; + option['skip'] = skip; + return this.txnStatusRepository.find( + { + id, + }, + {}, + option, + ); + } + + findByTxnId(id: string, option): Promise { + const skip = (option.page - 1) * option.limit; + option['skip'] = skip; + return this.txnStatusRepository.find( + { + txnHash: id, + }, + {}, + { + skip: option.skip, + limit: option.limit, + }, + ); + } +} diff --git a/src/status/transformer/staus-response.interceptor.ts b/src/status/transformer/staus-response.interceptor.ts new file mode 100644 index 00000000..7a8cf0b9 --- /dev/null +++ b/src/status/transformer/staus-response.interceptor.ts @@ -0,0 +1,41 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { RegistrationStatus } from '../schema/status.schema'; +export interface Response { + totalCount: number; + data: Array; +} +@Injectable() +export class RegistrationStatusInterceptor implements NestInterceptor { + intercept( + context: ExecutionContext, + next: CallHandler, + ): Observable { + return next.handle().pipe( + map((data) => { + const modifiedResponse = { + totalCount: + data[0]['totalCount'].length > 0 + ? data[0]['totalCount'][0].total + : 0, + data: this.mapData(data[0]['data']), + }; + return modifiedResponse; + }), + ); + } + mapData(data) { + return data.map((data) => { + try { + data.message = JSON.parse(data.message); + } catch (e) {} + return data; + }); + } +} diff --git a/src/tx-send-module/tx-send-module.service.ts b/src/tx-send-module/tx-send-module.service.ts index 4f0d8a71..a1634bf9 100644 --- a/src/tx-send-module/tx-send-module.service.ts +++ b/src/tx-send-module/tx-send-module.service.ts @@ -30,7 +30,7 @@ export class TxSendModuleService { this.connect(); } - async invokeTxnController(address, granteeMnemonic) { + async invokeTxnController(address, granteeMnemonic, appDetail) { const podENV = { RMQ_URL: this.configService.get('RABBIT_MQ_URI'), QUEUE_NAME: 'TXN_QUEUE_' + address, @@ -43,6 +43,10 @@ export class TxSendModuleService { ESTIMATE_GAS_PRICE: '155303', podName: 'txn-dynamic', granteeWalletAddress: address, + tenent: appDetail.subdomain, + Tx_Query_API: + this.configService.get('Tx_Query_API') || + 'https://hypersign-testnet-api.polkachu.com/cosmos/tx/v1beta1/txs/', }; await this.channel.assertQueue('GLOBAL_TXN_CONTROLLER_QUEUE', { @@ -88,9 +92,10 @@ export class TxSendModuleService { case 'BabyJubJubKey2021': { signatureType = 'BJJSignature2021'; proofPurpose = 'assertionMethod'; + break; } default: { - throw Error('Type is not matched'); + throw Error(`${vm.type} type is not matched`); } } @@ -117,17 +122,32 @@ export class TxSendModuleService { } async connect() { - Logger.log('Connecting Rabbit'); - const connection = await amqp.connect( - this.configService.get('RABBIT_MQ_URI'), - ); - this.channel = await connection.createChannel(); - const { address: granterAddress } = - await this.hidWalletService.generateWallet( - this.configService.get('MNEMONIC'), + try { + Logger.log('Connecting Rabbit'); + const connection = await amqp.connect( + this.configService.get('RABBIT_MQ_URI'), ); - this.granterAddress = granterAddress; - Logger.log('Connected Rabbit'); + connection.on('error', (err) => { + console.error('Connection error:', err); + }); + + connection.on('close', () => { + Logger.error('Connection closed, reconnecting...', 'RabbitMQ'); + }); + this.channel = await connection.createChannel(); + this.channel.on('error', (err) => { + Logger.error(err, 'RabbitMQ'); + }); + + const { address: granterAddress } = + await this.hidWalletService.generateWallet( + this.configService.get('MNEMONIC'), + ); + this.granterAddress = granterAddress; + Logger.log('Connected Rabbit'); + } catch (error) { + Logger.error(error, 'RabbitMQ'); + } } async prepareRegisterCredentialStatus( @@ -150,7 +170,7 @@ export class TxSendModuleService { }); } - async sendUpdateVC(credentialStatus, proofValue, granteeMnemonic) { + async sendUpdateVC(credentialStatus, proofValue, granteeMnemonic, appDetail) { if (!this.channel) { await this.connect(); } @@ -206,10 +226,15 @@ export class TxSendModuleService { Buffer.from(JSON.stringify(data)), ); - await this.invokeTxnController(address, granteeMnemonic); + await this.invokeTxnController(address, granteeMnemonic, appDetail); } - async sendVCTxn(credentialStatus, credentialStatusProof, granteeMnemonic) { + async sendVCTxn( + credentialStatus, + credentialStatusProof, + granteeMnemonic, + appDetail, + ) { if (!this.channel) { await this.connect(); } @@ -266,7 +291,7 @@ export class TxSendModuleService { Buffer.from(JSON.stringify(data)), ); - await this.invokeTxnController(address, granteeMnemonic); + await this.invokeTxnController(address, granteeMnemonic, appDetail); } async prepareMsgUpdateDID(didDocument, signInfos, versionId, txAuthor) { @@ -283,7 +308,13 @@ export class TxSendModuleService { signInfos: any, versionId: any, granteeMnemonic: any, + appDetail, ) { + Logger.log( + 'Inside sendDIDDeactivate to deactivate the did.', + 'TxSendModuleService', + ); + if (!this.channel) { await this.connect(); } @@ -336,7 +367,7 @@ export class TxSendModuleService { Buffer.from(JSON.stringify(data)), ); - await this.invokeTxnController(address, granteeMnemonic); + await this.invokeTxnController(address, granteeMnemonic, appDetail); } prepareMsgDeactivateDID( didDocument: any, @@ -352,7 +383,13 @@ export class TxSendModuleService { }); } - async sendDIDUpdate(didDocument, signInfos, versionId, granteeMnemonic) { + async sendDIDUpdate( + didDocument, + signInfos, + versionId, + granteeMnemonic, + appDetail, + ) { if (!this.channel) { await this.connect(); } @@ -404,7 +441,7 @@ export class TxSendModuleService { Buffer.from(JSON.stringify(data)), ); - await this.invokeTxnController(address, granteeMnemonic); + await this.invokeTxnController(address, granteeMnemonic, appDetail); } async sendDIDTxn( @@ -412,6 +449,7 @@ export class TxSendModuleService { didDocumentSigned, verificationMethodId, granteeMnemonic, + appDetail, ) { if (!this.channel) { await this.connect(); @@ -463,7 +501,7 @@ export class TxSendModuleService { Buffer.from(JSON.stringify(data)), ); - await this.invokeTxnController(address, granteeMnemonic); + await this.invokeTxnController(address, granteeMnemonic, appDetail); } prepareSchemaMsg(schema, proof, txAuthor) { @@ -474,7 +512,7 @@ export class TxSendModuleService { }); } - async sendSchemaTxn(schema, proof, granteeMnemonic) { + async sendSchemaTxn(schema, proof, granteeMnemonic, appDetail) { if (!this.channel) { await this.connect(); } @@ -521,7 +559,7 @@ export class TxSendModuleService { Buffer.from(JSON.stringify(data)), ); - await this.invokeTxnController(address, granteeMnemonic); + await this.invokeTxnController(address, granteeMnemonic, appDetail); return sendToQueue1; } } diff --git a/src/usage/controllers/usage.controller.spec.ts b/src/usage/controllers/usage.controller.spec.ts new file mode 100644 index 00000000..36ca834d --- /dev/null +++ b/src/usage/controllers/usage.controller.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UsageController } from './usage.controller'; +import { UsageService } from './usage.service'; + +describe('UsageController', () => { + let controller: UsageController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [UsageController], + providers: [UsageService], + }).compile(); + + controller = module.get(UsageController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/usage/controllers/usage.controller.ts b/src/usage/controllers/usage.controller.ts new file mode 100644 index 00000000..e6656ce4 --- /dev/null +++ b/src/usage/controllers/usage.controller.ts @@ -0,0 +1,199 @@ +import { + Controller, + Get, + UseFilters, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { AllExceptionsFilter } from 'src/utils/utils'; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiNotFoundResponse, + ApiOkResponse, + ApiQuery, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import { LogService } from 'src/log/services/log.service'; +import { AuthGuard } from '@nestjs/passport'; +import { + FetchDetailUsageDto, + FetchUsageRespDetail, +} from '../dto/create-usage.dto'; +import { + UsageError, + UsageNotFoundError, + UsageUnAuthorizeError, +} from '../dto/error-usage.dto'; +import { AccessGuard } from 'src/utils/guards/access.gaurd'; +import { ACCESS_TYPES } from 'src/credit-manager/utils'; +import { Access } from 'src/utils/customDecorator/access.decorator'; +@UseFilters(AllExceptionsFilter) +@ApiTags('Utilities') +@ApiBearerAuth('Authorization') +@UseGuards(AuthGuard('jwt'), AccessGuard) +@Controller('usage') +@Access(ACCESS_TYPES.READ_USAGE) +export class UsageController { + constructor(private readonly logService: LogService) {} + + @Get() + @ApiOkResponse({ + description: 'Usage detail fetched successfully', + type: FetchUsageRespDetail, + }) + @ApiBadRequestResponse({ + description: 'Error has occurred at the time of fething usage detail', + type: UsageError, + }) + @ApiNotFoundResponse({ + description: 'No usage detail found', + type: UsageNotFoundError, + }) + @ApiUnauthorizedResponse({ + description: 'Authorization token is invalid or expired.', + type: UsageUnAuthorizeError, + }) + @ApiQuery({ + name: 'serviceId', + description: 'Service Id', + required: false, + }) + @ApiQuery({ + name: 'startDate', + required: false, + type: Date, + }) + @ApiQuery({ + name: 'endDate', + required: false, + type: Date, + }) + async getUsageByDate( + @Query('serviceId') appIdParam: string, + @Query('startDate') startDateParam: Date, + @Query('endDate') endDateParam: Date, + @Req() req, + ): Promise { + let appId; + if (!appIdParam) { + appId = req.app.appId; + } else { + appId = appIdParam; + } + + let startDate; + let endDate; + + const today = new Date(); + if (!startDateParam) { + const day = 1; + const month = today.getMonth(); + const year = today.getFullYear(); + startDate = new Date(year, month, day); + } else { + startDate = new Date(startDateParam); + } + + if (!endDateParam) { + endDate = today; + } else { + endDate = new Date(endDateParam); + } + + const serviceDetails = + await this.logService.findBetweenDatesAndAgreegateByPath( + startDate, + endDate, + appId, + ); + const response = { + serviceId: appId, + startDate, + endDate, + serviceDetails, + }; + + return response; + } + @Get('/detail') + @ApiOkResponse({ + description: 'Detail of api call made', + type: FetchDetailUsageDto, + }) + @ApiBadRequestResponse({ + description: 'Error has occurred at the time of fething usage detail', + type: UsageError, + }) + @ApiNotFoundResponse({ + description: 'No usage detail found', + type: UsageNotFoundError, + }) + @ApiUnauthorizedResponse({ + description: 'Authorization token is invalid or expired.', + type: UsageUnAuthorizeError, + }) + @ApiQuery({ + name: 'serviceId', + description: 'Service Id', + required: false, + }) + @ApiQuery({ + name: 'startDate', + required: false, + type: Date, + }) + @ApiQuery({ + name: 'endDate', + required: false, + type: Date, + }) + async getUsageDetailByDate( + @Query('serviceId') appIdParam: string, + @Query('startDate') startDateParam: Date, + @Query('endDate') endDateParam: Date, + @Req() req, + ): Promise { + let appId; + if (!appIdParam) { + appId = req.app.appId; + } else { + appId = appIdParam; + } + + let startDate; + let endDate; + + const today = new Date(); + if (!startDateParam) { + const day = 1; + const month = today.getMonth(); + const year = today.getFullYear(); + startDate = new Date(year, month, day); + } else { + startDate = new Date(startDateParam); + } + + if (!endDateParam) { + endDate = today; + } else { + endDate = new Date(endDateParam); + } + + const serviceDetails = await this.logService.findDetailedLogBetweenDates( + startDate, + endDate, + appId, + ); + const response = { + serviceId: appId, + startDate, + endDate, + serviceDetails, + }; + + return response; + } +} diff --git a/src/usage/dto/create-usage.dto.ts b/src/usage/dto/create-usage.dto.ts new file mode 100644 index 00000000..5fb99652 --- /dev/null +++ b/src/usage/dto/create-usage.dto.ts @@ -0,0 +1,120 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { ValidateNested } from 'class-validator'; + +export class ServiceDetails { + @ApiProperty({ + name: 'apiPath', + description: 'Api path', + example: + '/api/v1/did/resolve/did:hid:testnet:z6MkwA7dopBx7HGBNUwvfeg2Sh9LyWvvLuBKRtf2vmJxCsfv', + }) + apiPath: string; + @ApiProperty({ + name: 'method', + description: 'Api method detail', + example: 'GET', + }) + method: string; + @ApiProperty({ + name: 'quantity', + description: 'Number of api call for specific path.', + example: 8, + }) + quantity: number; + @ApiProperty({ + name: 'onchain_unit_cost', + description: 'On chain unit cost', + example: 0, + }) + onchain_unit_cost: number; + @ApiProperty({ + name: 'offchain_unit_cost', + description: 'off chain unit cost', + example: 1, + }) + offchain_unit_cost: number; + @ApiProperty({ + name: 'onchainAmount', + description: + 'Total amount required for onchain transaction(in uhid) for specificv api path.', + example: 0, + }) + onchainAmount: number; + @ApiProperty({ + name: 'offchainAmount', + description: + 'Total amount required for onchain transaction(in uhid) for specificv api path.', + example: 8, + }) + offchainAmount: number; +} + +export class FetchUsageRespDetail { + @ApiProperty({ + name: 'startDate', + description: 'Date from where usage detail is to be fetched', + example: '2025-02-28T18:30:00.000Z', + }) + startDate: Date; + @ApiProperty({ + name: 'endDate', + description: 'Date till which we have to fetch detail', + example: '2025-03-07T04:28:10.362Z', + }) + endDate: Date; + @ApiProperty({ + name: 'serviceDetails', + description: 'Detailed service description', + type: ServiceDetails, + isArray: true, + }) + @Type(() => ServiceDetails) + @ValidateNested({ each: true }) + serviceDetails: ServiceDetails; +} +export class DetailedServiceUsage { + @ApiProperty({ + name: 'apiPath', + description: 'Api path', + example: '/api/v1/credential?page=1&limit=100', + }) + apiPath: string; + + @ApiProperty({ + name: 'quantity', + description: 'Number of api call for specific path.', + example: 4, + }) + quantity: number; + + @ApiProperty({ + name: 'data', + description: 'detailed date wise data and quantity', + example: { '2025-03-07': 4 }, + }) + data: object; +} +export class FetchDetailUsageDto { + @ApiProperty({ + name: 'startDate', + description: 'Date from where usage detail is to be fetched', + example: '2025-02-28T18:30:00.000Z', + }) + startDate: Date; + @ApiProperty({ + name: 'endDate', + description: 'Date till which we have to fetch detail', + example: '2025-03-07T04:28:10.362Z', + }) + endDate: Date; + @ApiProperty({ + name: 'serviceDetails', + description: 'Detailed service description', + type: DetailedServiceUsage, + isArray: true, + }) + @Type(() => DetailedServiceUsage) + @ValidateNested({ each: true }) + serviceDetails: DetailedServiceUsage; +} diff --git a/src/usage/dto/error-usage.dto.ts b/src/usage/dto/error-usage.dto.ts new file mode 100644 index 00000000..4e01d2e8 --- /dev/null +++ b/src/usage/dto/error-usage.dto.ts @@ -0,0 +1,70 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber, IsString } from 'class-validator'; + +export class UsageError { + @ApiProperty({ + description: 'statusCode', + example: 400, + }) + @IsNumber() + statusCode: number; + + @ApiProperty({ + description: 'message', + example: ['error message 1', 'error message 2'], + }) + @IsString() + message: Array; + + @ApiProperty({ + description: 'error', + example: 'Bad Request', + }) + @IsString() + error: string; +} +export class UsageNotFoundError { + @ApiProperty({ + description: 'statusCode', + example: 404, + }) + @IsNumber() + statusCode: number; + + @ApiProperty({ + description: 'message', + example: ['error message 1', 'error message 2'], + }) + @IsString() + message: Array; + + @ApiProperty({ + description: 'error', + example: 'Not Found', + }) + @IsString() + error: string; +} + +export class UsageUnAuthorizeError { + @ApiProperty({ + description: 'statusCode', + example: 401, + }) + @IsNumber() + statusCode: number; + + @ApiProperty({ + description: 'message', + example: ['error message 1', 'error message 2'], + }) + @IsString() + message: Array; + + @ApiProperty({ + description: 'error', + example: 'Not Found', + }) + @IsString() + error: string; +} diff --git a/src/usage/dto/update-usage.dto.ts b/src/usage/dto/update-usage.dto.ts new file mode 100644 index 00000000..ab486ce9 --- /dev/null +++ b/src/usage/dto/update-usage.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { FetchUsageRespDetail } from './create-usage.dto'; + +export class UpdateUsageDto extends PartialType(FetchUsageRespDetail) {} diff --git a/src/usage/services/usage.service.spec.ts b/src/usage/services/usage.service.spec.ts new file mode 100644 index 00000000..f25ab6ef --- /dev/null +++ b/src/usage/services/usage.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UsageService } from './usage.service'; + +describe('UsageService', () => { + let service: UsageService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [UsageService], + }).compile(); + + service = module.get(UsageService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/usage/services/usage.service.ts b/src/usage/services/usage.service.ts new file mode 100644 index 00000000..8cb72f27 --- /dev/null +++ b/src/usage/services/usage.service.ts @@ -0,0 +1,4 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class UsageService {} diff --git a/src/usage/usage.module.ts b/src/usage/usage.module.ts new file mode 100644 index 00000000..db4e909a --- /dev/null +++ b/src/usage/usage.module.ts @@ -0,0 +1,16 @@ +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { UsageController } from './controllers/usage.controller'; +import { UsageService } from './services/usage.service'; +import { LogModule } from 'src/log/log.module'; +import { WhitelistSSICorsMiddleware } from 'src/utils/middleware/cors.middleware'; + +@Module({ + imports: [LogModule], + controllers: [UsageController], + providers: [UsageService], +}) +export class UsageModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(WhitelistSSICorsMiddleware).forRoutes(UsageController); + } +} diff --git a/src/utils/customDecorator/access.decorator.ts b/src/utils/customDecorator/access.decorator.ts new file mode 100644 index 00000000..e582c178 --- /dev/null +++ b/src/utils/customDecorator/access.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ACCESS_KEY = 'access_required'; + +export const Access = (...permissions: string[]) => + SetMetadata(ACCESS_KEY, permissions); diff --git a/src/utils/customDecorator/did.decorator.ts b/src/utils/customDecorator/did.decorator.ts index c3252ed7..e9fddab9 100644 --- a/src/utils/customDecorator/did.decorator.ts +++ b/src/utils/customDecorator/did.decorator.ts @@ -3,6 +3,12 @@ import { SetMetadata, BadRequestException, } from '@nestjs/common'; +import { + registerDecorator, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; export const IsDid = (): PropertyDecorator => { return applyDecorators( @@ -36,3 +42,87 @@ export const IsDid = (): PropertyDecorator => { }, ); }; + +// export const IsMethodSpecificId = (): PropertyDecorator => { +// return applyDecorators( +// SetMetadata('isMethodSpecificId', true), +// (target: object, propertyKey: string | symbol) => { +// let original = target[propertyKey]; +// const descriptor: PropertyDescriptor = { +// get: () => original, +// set: (val: any) => { +// if (val.trim() === '') { +// throw new BadRequestException([ +// `${propertyKey.toString()} cannot be empty`, +// ]); +// } + +// const did = val; +// if (did.includes('did:hid:')) { +// throw new BadRequestException([ +// `Invalid ${propertyKey.toString()}`, +// ]); +// } +// if (did.includes('hid')) { +// throw new BadRequestException([ +// `Invalid ${propertyKey.toString()}`, +// ]); +// } +// if (did.includes(':')) { +// throw new BadRequestException([ +// `Invalid ${propertyKey.toString()}`, +// ]); +// } +// if (did.includes('.')) { +// throw new BadRequestException([ +// `Invalid ${propertyKey.toString()}`, +// ]); +// } +// original = val; +// }, +// }; +// Object.defineProperty(target, propertyKey, descriptor); +// }, +// ); +// }; + +@ValidatorConstraint({ async: false }) +export class IsMethodSpecificIdConstraint + implements ValidatorConstraintInterface +{ + validate(value: any): boolean { + if (typeof value !== 'string' || value.trim() === '') { + throw new BadRequestException('Value cannot be empty'); + } + + const did = value.trim(); + if ( + did.includes('did:hid:') || + did.includes('hid') || + did.includes(':') || + did.includes('.') + ) { + throw new BadRequestException('Invalid method-specific ID'); + } + + return true; + } + + defaultMessage(): string { + return 'Invalid method-specific ID format'; + } +} + +export function IsMethodSpecificId( + validationOptions?: ValidationOptions, +): PropertyDecorator { + return function (object: object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + constraints: [], + validator: IsMethodSpecificIdConstraint, + }); + }; +} diff --git a/src/utils/guards/access.gaurd.ts b/src/utils/guards/access.gaurd.ts new file mode 100644 index 00000000..54c883af --- /dev/null +++ b/src/utils/guards/access.gaurd.ts @@ -0,0 +1,34 @@ +// access.guard.ts +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ACCESS_KEY } from '../customDecorator/access.decorator'; + +@Injectable() +export class AccessGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredPermissions = this.reflector.get( + ACCESS_KEY, + context.getHandler(), + ); + if (!requiredPermissions) return true; + const req = context.switchToHttp().getRequest(); + const tokenPermissions: string[] = req.user?.accessList ?? []; + const authorized = requiredPermissions.every((p) => + tokenPermissions.includes(p), + ); + if (!authorized) { + throw new ForbiddenException([ + 'Permission denied: Missing access rights', + ]); + } + + return true; + } +} diff --git a/src/utils/interceptor/http-interceptor.ts b/src/utils/interceptor/http-interceptor.ts new file mode 100644 index 00000000..61656bfb --- /dev/null +++ b/src/utils/interceptor/http-interceptor.ts @@ -0,0 +1,53 @@ +import { Injectable, NestMiddleware, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { Request, Response, NextFunction } from 'express'; +import { CreateLogDto } from 'src/log/dto/create-log.dto'; +import { LogService } from 'src/log/services/log.service'; + +@Injectable() +export class AppLoggerMiddleware implements NestMiddleware { + constructor( + private readonly configService: ConfigService, + private readonly logStore: LogService, + ) {} + private logger = new Logger('HTTP'); + + use(request: Request, response: Response, next: NextFunction): void { + const { method, path } = request; + const userAgent = request.get('user-agent') || ''; + + response.on('close', () => { + let { url: path } = request; + const { statusCode } = response; + const contentLength = response.get('content-length'); + const app = request['app']; + const { appId } = app as any; + if (path.includes('credential/issue')) { + const reqBody = request.body; + const persist = reqBody?.persist ?? true; + const registerCredentialStatus = + reqBody?.registerCredentialStatus ?? true; + path = `${path}?persist=${persist}®isterCredentialStatus=${registerCredentialStatus}`; + } + const logData: CreateLogDto = { + method, + path, + statusCode, + contentLength, + userAgent, + appId, + }; + + if ( + request?.body?.QueryRequest && + (statusCode === 201 || statusCode === 200) + ) { + logData.dataRequest = btoa(JSON.stringify(request?.body?.QueryRequest)); + } + this.logStore.createLog(logData); + }); + + next(); + } +} diff --git a/src/utils/jwt.strategy.ts b/src/utils/jwt.strategy.ts index c9dfa598..de54caf0 100644 --- a/src/utils/jwt.strategy.ts +++ b/src/utils/jwt.strategy.ts @@ -9,9 +9,10 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, secretOrKey: config.get('JWT_SECRET'), + passReqToCallback: true, }); } - async validate(payload) { + async validate(req: Request, payload) { type App = { appId: string; kmsId: string; @@ -19,12 +20,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { subdomain: string; edvId: string; }; + const sessionDetail = req['user']; const appDetail: App = { appId: payload?.appId, - kmsId: payload?.kmsId, - whitelistedCors: payload?.whitelistedCors, + kmsId: sessionDetail?.kmsId, + whitelistedCors: sessionDetail?.whitelistedCors, subdomain: payload?.subdomain, - edvId: payload?.edvId, + edvId: sessionDetail?.edvId, }; return appDetail; } diff --git a/src/utils/middleware/cors.middleware.ts b/src/utils/middleware/cors.middleware.ts index bd0365a1..8bb018fe 100644 --- a/src/utils/middleware/cors.middleware.ts +++ b/src/utils/middleware/cors.middleware.ts @@ -8,6 +8,7 @@ import { import * as jwt from 'jsonwebtoken'; import { NextFunction, Request, Response } from 'express'; +import { redisClient } from '../redis.provider'; @Injectable() export class WhitelistSSICorsMiddleware implements NestMiddleware { async use(req: Request, res: Response, next: NextFunction) { @@ -32,6 +33,7 @@ export class WhitelistSSICorsMiddleware implements NestMiddleware { const subdomain = req.subdomains.length > 0 ? req.subdomains.at(-1) : host.split('.')[0]; + let sessionDetailJson; Logger.debug(`Subdomain ${subdomain} `, 'Middleware'); Logger.debug(`Host ${host} `, 'Middleware'); @@ -82,7 +84,7 @@ export class WhitelistSSICorsMiddleware implements NestMiddleware { !decoded || Object.keys(decoded).length < 0 || !decoded['subdomain'] || - !decoded['whitelistedCors'] + !decoded['sessionId'] ) { throw new UnauthorizedException(['Invalid authorization token']); } @@ -98,11 +100,22 @@ export class WhitelistSSICorsMiddleware implements NestMiddleware { // if (matchOrigin && whitelistedOrigins.includes(matchOrigin[0])) { // return next(); // } - + const sessionDetail = await redisClient.get(decoded.sessionId); + if (!sessionDetail) { + throw new UnauthorizedException(['Token expired']); + } + sessionDetailJson = await JSON.parse(sessionDetail); + if ( + Object.keys(sessionDetailJson).length < 0 || + !sessionDetailJson['subdomain'] == decoded.subdomain || + !sessionDetailJson['whitelistedCors'] + ) { + throw new UnauthorizedException(['Invalid authorization token']); + } const appInfo: App = { - whitelistedCors: decoded['whitelistedCors'], - subdomain: decoded['subdomain'], - edvId: decoded['edvId'], + whitelistedCors: sessionDetailJson['whitelistedCors'], + subdomain: sessionDetailJson['subdomain'], + edvId: sessionDetailJson['edvId'], }; if (appInfo.subdomain != subdomain) { @@ -119,6 +132,7 @@ export class WhitelistSSICorsMiddleware implements NestMiddleware { req.user = {}; req.user['subdomain'] = subdomain; + req.user = { ...sessionDetailJson }; next(); } } diff --git a/src/utils/redis.provider.ts b/src/utils/redis.provider.ts new file mode 100644 index 00000000..b9548acc --- /dev/null +++ b/src/utils/redis.provider.ts @@ -0,0 +1,9 @@ +import Redis from 'ioredis'; +import * as dotenv from 'dotenv'; +dotenv.config(); +export const redisClient = new Redis({ + host: + process.env.REDIS_HOST || + 'redis-stack-service.hypermine-development.svc.cluster.local', + port: 6379, +}); diff --git a/src/utils/utils.ts b/src/utils/utils.ts index fbd610b8..15e56b88 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -15,7 +15,6 @@ import { ArgumentsHost, HttpStatus, } from '@nestjs/common'; -import { Did } from 'hs-ssi-sdk'; export const existDir = (dirPath) => { if (!dirPath) throw new Error('Directory path undefined'); @@ -116,3 +115,42 @@ export async function generateAppId(length = 36) { .toString('hex') .slice(0, length); } +export enum StorageType { + KEYSTORAGE = 'KEYSTORAGE', + DATASTORAGE = 'DATASTORAGE', +} +export enum RMethods { + 'GET' = 'GET', + 'POST' = 'POST', + 'PUT' = 'PUT', + 'PATCH' = 'PATCH', + 'DELETE' = 'DELETE', +} +export enum ATTESTAION_TYPE { + REGISTER_CREDENTIAL = 'REGISTER_CREDENTIAL', + REGISTER_DID = 'REGISTER_DID', + REGISTER_SCHEMA = 'REGISTER_SCHEMA', + UPDATE_DID = 'UPDATE_DID', + UPDATE_CREDENTIAL = 'UPDATE_CREDENTIAL', +} + +export const CREDIT_COSTS = { + API: { + GET: 1, + POST: 5, + PATCH: 3, + PUT: 3, + DELETE: 4, + }, + STORAGE: { + KEYSTORAGE: 2, + DATASTORAGE: 4, + }, + ATTESTATION: { + REGISTER_CREDENTIAL: 50, + REGISTER_DID: 50, + REGISTER_SCHEMA: 50, + UPDATE_DID: 50, + UPDATE_CREDENTIAL: 50, + }, +};