diff --git a/.github/workflows/auth.release.dev.yml b/.github/workflows/auth.release.dev.yml new file mode 100644 index 000000000..ab309c735 --- /dev/null +++ b/.github/workflows/auth.release.dev.yml @@ -0,0 +1,177 @@ +name: Auth Server Dev + +concurrency: + group: auth_dev_environment + cancel-in-progress: true + +on: + push: + tags: + - '@standardnotes/auth-server@[0-9]*.[0-9]*.[0-9]*-alpha.[0-9]*' + - '@standardnotes/auth-server@[0-9]*.[0-9]*.[0-9]*-beta.[0-9]*' + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v1 + with: + node-version: '16.x' + - run: yarn lint:auth + - run: yarn test:auth + + publish-aws-ecr: + needs: test + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Build locally + run: yarn build:auth + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 + - name: Build, tag, and push image to Amazon ECR + id: build-image + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + ECR_REPOSITORY: auth + IMAGE_TAG: ${{ github.sha }} + run: | + yarn docker build @standardnotes/auth-server -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:dev + docker push $ECR_REGISTRY/$ECR_REPOSITORY:dev + + publish-docker-hub: + needs: test + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Build locally + run: yarn build:auth + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Build, tag, and push image to Docker Hub + run: | + yarn docker build @standardnotes/auth-server -t standardnotes/auth:${{ github.sha }} + docker push standardnotes/auth:${{ github.sha }} + docker tag standardnotes/auth:${{ github.sha }} standardnotes/auth:dev + docker push standardnotes/auth:dev + + deploy-web: + needs: publish-aws-ecr + + runs-on: ubuntu-latest + + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + - name: Download task definition + run: | + aws ecs describe-task-definition --task-definition auth-dev --query taskDefinition > task-definition.json + - name: Fill in the new version in the Amazon ECS task definition + run: | + jq '(.containerDefinitions[] | select(.name=="auth-dev") | .environment[] | select(.name=="VERSION")).value = "${{ github.sha }}"' task-definition.json > tmp.json && mv tmp.json task-definition.json + - name: Fill in the new image ID in the Amazon ECS task definition + id: task-def + uses: aws-actions/amazon-ecs-render-task-definition@v1 + with: + task-definition: task-definition.json + container-name: auth-dev + image: ${{ secrets.AWS_ECR_REGISTRY }}/auth:${{ github.sha }} + - name: Deploy Amazon ECS task definition + uses: aws-actions/amazon-ecs-deploy-task-definition@v1 + with: + task-definition: ${{ steps.task-def.outputs.task-definition }} + service: auth-dev + cluster: dev + wait-for-service-stability: true + + deploy-worker: + needs: publish-aws-ecr + + runs-on: ubuntu-latest + + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + - name: Download task definition + run: | + aws ecs describe-task-definition --task-definition auth-worker-dev --query taskDefinition > task-definition.json + - name: Fill in the new version in the Amazon ECS task definition + run: | + jq '(.containerDefinitions[] | select(.name=="auth-worker-dev") | .environment[] | select(.name=="VERSION")).value = "${{ github.sha }}"' task-definition.json > tmp.json && mv tmp.json task-definition.json + - name: Fill in the new image ID in the Amazon ECS task definition + id: task-def + uses: aws-actions/amazon-ecs-render-task-definition@v1 + with: + task-definition: task-definition.json + container-name: auth-worker-dev + image: ${{ secrets.AWS_ECR_REGISTRY }}/auth:${{ github.sha }} + - name: Deploy Amazon ECS task definition + uses: aws-actions/amazon-ecs-deploy-task-definition@v1 + with: + task-definition: ${{ steps.task-def.outputs.task-definition }} + service: auth-worker-dev + cluster: dev + wait-for-service-stability: true + + newrelic: + needs: [ deploy-web, deploy-worker ] + + runs-on: ubuntu-latest + steps: + - name: Create New Relic deployment marker for Web + uses: newrelic/deployment-marker-action@v1 + with: + accountId: ${{ secrets.NEW_RELIC_ACCOUNT_ID }} + apiKey: ${{ secrets.NEW_RELIC_API_KEY }} + applicationId: ${{ secrets.NEW_RELIC_APPLICATION_ID_AUTH_WEB_DEV }} + revision: "${{ github.sha }}" + description: "Automated Deployment via Github Actions" + user: "${{ github.actor }}" + - name: Create New Relic deployment marker for Worker + uses: newrelic/deployment-marker-action@v1 + with: + accountId: ${{ secrets.NEW_RELIC_ACCOUNT_ID }} + apiKey: ${{ secrets.NEW_RELIC_API_KEY }} + applicationId: ${{ secrets.NEW_RELIC_APPLICATION_ID_AUTH_WORKER_DEV }} + revision: "${{ github.sha }}" + description: "Automated Deployment via Github Actions" + user: "${{ github.actor }}" + + notify_discord: + needs: [ deploy-web, deploy-worker ] + + runs-on: ubuntu-latest + + steps: + - name: Run Discord Webhook + uses: johnnyhuy/actions-discord-git-webhook@main + with: + webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }} + diff --git a/.github/workflows/scheduler.release.dev.yml b/.github/workflows/scheduler.release.dev.yml index 91beeb328..823834565 100644 --- a/.github/workflows/scheduler.release.dev.yml +++ b/.github/workflows/scheduler.release.dev.yml @@ -118,7 +118,7 @@ jobs: with: accountId: ${{ secrets.NEW_RELIC_ACCOUNT_ID }} apiKey: ${{ secrets.NEW_RELIC_API_KEY }} - applicationId: ${{ secrets.NEW_RELIC_APPLICATION_ID_WORKER_DEV }} + applicationId: ${{ secrets.NEW_RELIC_APPLICATION_ID_SCHEDULER_WORKER_DEV }} revision: "${{ github.sha }}" description: "Automated Deployment via Github Actions" user: "${{ github.actor }}" diff --git a/.gitignore b/.gitignore index c799d4d2a..7153ae075 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ .eslintcache .DS_Store -.vscode .idea node_modules dist diff --git a/.pnp.cjs b/.pnp.cjs index baedca868..b1f903a53 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -20,6 +20,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "name": "@standardnotes/server-monorepo",\ "reference": "workspace:."\ },\ + {\ + "name": "@standardnotes/auth-server",\ + "reference": "workspace:packages/auth"\ + },\ {\ "name": "@standardnotes/scheduler-server",\ "reference": "workspace:packages/scheduler"\ @@ -28,6 +32,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "enableTopLevelFallback": true,\ "ignorePatternData": "(^(?:\\\\.yarn\\\\/sdks(?:\\\\/(?!\\\\.{1,2}(?:\\\\/|$))(?:(?:(?!(?:^|\\\\/)\\\\.{1,2}(?:\\\\/|$)).)*?)|$))$)",\ "fallbackExclusionList": [\ + ["@standardnotes/auth-server", ["workspace:packages/auth"]],\ ["@standardnotes/scheduler-server", ["workspace:packages/scheduler"]],\ ["@standardnotes/server-monorepo", ["workspace:."]]\ ],\ @@ -43,6 +48,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@lerna-lite/cli", "npm:1.5.1"],\ ["@lerna-lite/list", "npm:1.5.1"],\ ["@lerna-lite/run", "npm:1.5.1"],\ + ["@types/jest", "npm:28.1.3"],\ ["@typescript-eslint/parser", "virtual:8859b278716fedf3e7458b5628625f7e35678c418626878559a0b816445001b7e24c55546f4677ba4c20b521aa0cf52cc33ac07deff171e383ada6eeab69933f#npm:5.29.0"],\ ["eslint", "npm:8.18.0"],\ ["eslint-config-prettier", "virtual:8859b278716fedf3e7458b5628625f7e35678c418626878559a0b816445001b7e24c55546f4677ba4c20b521aa0cf52cc33ac07deff171e383ada6eeab69933f#npm:8.5.0"],\ @@ -291,10 +297,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.8.4", {\ - "packageLocation": "./.yarn/__virtual__/@babel-plugin-syntax-async-generators-virtual-2cd9dc19d1/0/cache/@babel-plugin-syntax-async-generators-npm-7.8.4-d10cf993c9-7ed1c1d9b9.zip/node_modules/@babel/plugin-syntax-async-generators/",\ + ["virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.8.4", {\ + "packageLocation": "./.yarn/__virtual__/@babel-plugin-syntax-async-generators-virtual-1c91e87c25/0/cache/@babel-plugin-syntax-async-generators-npm-7.8.4-d10cf993c9-7ed1c1d9b9.zip/node_modules/@babel/plugin-syntax-async-generators/",\ "packageDependencies": [\ - ["@babel/plugin-syntax-async-generators", "virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.8.4"],\ + ["@babel/plugin-syntax-async-generators", "virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.8.4"],\ ["@babel/core", "npm:7.18.5"],\ ["@babel/helper-plugin-utils", "npm:7.17.12"],\ ["@types/babel__core", "npm:7.1.19"]\ @@ -328,10 +334,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.8.3", {\ - "packageLocation": "./.yarn/__virtual__/@babel-plugin-syntax-bigint-virtual-f6f3089125/0/cache/@babel-plugin-syntax-bigint-npm-7.8.3-b05d971e6c-3a10849d83.zip/node_modules/@babel/plugin-syntax-bigint/",\ + ["virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.8.3", {\ + "packageLocation": "./.yarn/__virtual__/@babel-plugin-syntax-bigint-virtual-62067a9335/0/cache/@babel-plugin-syntax-bigint-npm-7.8.3-b05d971e6c-3a10849d83.zip/node_modules/@babel/plugin-syntax-bigint/",\ "packageDependencies": [\ - ["@babel/plugin-syntax-bigint", "virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.8.3"],\ + ["@babel/plugin-syntax-bigint", "virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.8.3"],\ ["@babel/core", "npm:7.18.5"],\ ["@babel/helper-plugin-utils", "npm:7.17.12"],\ ["@types/babel__core", "npm:7.1.19"]\ @@ -365,10 +371,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.12.13", {\ - "packageLocation": "./.yarn/__virtual__/@babel-plugin-syntax-class-properties-virtual-fa10fb6472/0/cache/@babel-plugin-syntax-class-properties-npm-7.12.13-002ee9d930-24f34b196d.zip/node_modules/@babel/plugin-syntax-class-properties/",\ + ["virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.12.13", {\ + "packageLocation": "./.yarn/__virtual__/@babel-plugin-syntax-class-properties-virtual-c73477fb62/0/cache/@babel-plugin-syntax-class-properties-npm-7.12.13-002ee9d930-24f34b196d.zip/node_modules/@babel/plugin-syntax-class-properties/",\ "packageDependencies": [\ - ["@babel/plugin-syntax-class-properties", "virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.12.13"],\ + ["@babel/plugin-syntax-class-properties", "virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.12.13"],\ ["@babel/core", "npm:7.18.5"],\ ["@babel/helper-plugin-utils", "npm:7.17.12"],\ ["@types/babel__core", "npm:7.1.19"]\ @@ -402,10 +408,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.10.4", {\ - "packageLocation": "./.yarn/__virtual__/@babel-plugin-syntax-import-meta-virtual-9f045d2d73/0/cache/@babel-plugin-syntax-import-meta-npm-7.10.4-4a0a0158bc-166ac1125d.zip/node_modules/@babel/plugin-syntax-import-meta/",\ + ["virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.10.4", {\ + "packageLocation": "./.yarn/__virtual__/@babel-plugin-syntax-import-meta-virtual-f94e1adec9/0/cache/@babel-plugin-syntax-import-meta-npm-7.10.4-4a0a0158bc-166ac1125d.zip/node_modules/@babel/plugin-syntax-import-meta/",\ "packageDependencies": [\ - ["@babel/plugin-syntax-import-meta", "virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.10.4"],\ + ["@babel/plugin-syntax-import-meta", "virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.10.4"],\ ["@babel/core", "npm:7.18.5"],\ ["@babel/helper-plugin-utils", "npm:7.17.12"],\ ["@types/babel__core", "npm:7.1.19"]\ @@ -439,10 +445,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.8.3", {\ - "packageLocation": "./.yarn/__virtual__/@babel-plugin-syntax-json-strings-virtual-6e3208441d/0/cache/@babel-plugin-syntax-json-strings-npm-7.8.3-6dc7848179-bf5aea1f31.zip/node_modules/@babel/plugin-syntax-json-strings/",\ + ["virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.8.3", {\ + "packageLocation": "./.yarn/__virtual__/@babel-plugin-syntax-json-strings-virtual-b788fe66a9/0/cache/@babel-plugin-syntax-json-strings-npm-7.8.3-6dc7848179-bf5aea1f31.zip/node_modules/@babel/plugin-syntax-json-strings/",\ "packageDependencies": [\ - ["@babel/plugin-syntax-json-strings", "virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.8.3"],\ + ["@babel/plugin-syntax-json-strings", "virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.8.3"],\ ["@babel/core", "npm:7.18.5"],\ ["@babel/helper-plugin-utils", "npm:7.17.12"],\ ["@types/babel__core", "npm:7.1.19"]\ @@ -476,10 +482,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.10.4", {\ - "packageLocation": "./.yarn/__virtual__/@babel-plugin-syntax-logical-assignment-operators-virtual-35df94378b/0/cache/@babel-plugin-syntax-logical-assignment-operators-npm-7.10.4-72ae00fdf6-aff3357703.zip/node_modules/@babel/plugin-syntax-logical-assignment-operators/",\ + ["virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.10.4", {\ + "packageLocation": "./.yarn/__virtual__/@babel-plugin-syntax-logical-assignment-operators-virtual-00106656c0/0/cache/@babel-plugin-syntax-logical-assignment-operators-npm-7.10.4-72ae00fdf6-aff3357703.zip/node_modules/@babel/plugin-syntax-logical-assignment-operators/",\ "packageDependencies": [\ - ["@babel/plugin-syntax-logical-assignment-operators", "virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.10.4"],\ + ["@babel/plugin-syntax-logical-assignment-operators", "virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.10.4"],\ ["@babel/core", "npm:7.18.5"],\ ["@babel/helper-plugin-utils", "npm:7.17.12"],\ ["@types/babel__core", "npm:7.1.19"]\ @@ -513,10 +519,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.8.3", {\ - "packageLocation": "./.yarn/__virtual__/@babel-plugin-syntax-nullish-coalescing-operator-virtual-52dc138d7c/0/cache/@babel-plugin-syntax-nullish-coalescing-operator-npm-7.8.3-8a723173b5-87aca49189.zip/node_modules/@babel/plugin-syntax-nullish-coalescing-operator/",\ + ["virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.8.3", {\ + "packageLocation": "./.yarn/__virtual__/@babel-plugin-syntax-nullish-coalescing-operator-virtual-0e94aeb633/0/cache/@babel-plugin-syntax-nullish-coalescing-operator-npm-7.8.3-8a723173b5-87aca49189.zip/node_modules/@babel/plugin-syntax-nullish-coalescing-operator/",\ "packageDependencies": [\ - ["@babel/plugin-syntax-nullish-coalescing-operator", "virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.8.3"],\ + ["@babel/plugin-syntax-nullish-coalescing-operator", "virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.8.3"],\ ["@babel/core", "npm:7.18.5"],\ ["@babel/helper-plugin-utils", "npm:7.17.12"],\ ["@types/babel__core", "npm:7.1.19"]\ @@ -550,10 +556,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.10.4", {\ - "packageLocation": "./.yarn/__virtual__/@babel-plugin-syntax-numeric-separator-virtual-85478328dc/0/cache/@babel-plugin-syntax-numeric-separator-npm-7.10.4-81444be605-01ec5547bd.zip/node_modules/@babel/plugin-syntax-numeric-separator/",\ + ["virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.10.4", {\ + "packageLocation": "./.yarn/__virtual__/@babel-plugin-syntax-numeric-separator-virtual-ae7862c24d/0/cache/@babel-plugin-syntax-numeric-separator-npm-7.10.4-81444be605-01ec5547bd.zip/node_modules/@babel/plugin-syntax-numeric-separator/",\ "packageDependencies": [\ - ["@babel/plugin-syntax-numeric-separator", "virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.10.4"],\ + ["@babel/plugin-syntax-numeric-separator", "virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.10.4"],\ ["@babel/core", "npm:7.18.5"],\ ["@babel/helper-plugin-utils", "npm:7.17.12"],\ ["@types/babel__core", "npm:7.1.19"]\ @@ -587,10 +593,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.8.3", {\ - "packageLocation": "./.yarn/__virtual__/@babel-plugin-syntax-object-rest-spread-virtual-2a4dcbcb6d/0/cache/@babel-plugin-syntax-object-rest-spread-npm-7.8.3-60bd05b6ae-fddcf581a5.zip/node_modules/@babel/plugin-syntax-object-rest-spread/",\ + ["virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.8.3", {\ + "packageLocation": "./.yarn/__virtual__/@babel-plugin-syntax-object-rest-spread-virtual-70d6d17cdf/0/cache/@babel-plugin-syntax-object-rest-spread-npm-7.8.3-60bd05b6ae-fddcf581a5.zip/node_modules/@babel/plugin-syntax-object-rest-spread/",\ "packageDependencies": [\ - ["@babel/plugin-syntax-object-rest-spread", "virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.8.3"],\ + ["@babel/plugin-syntax-object-rest-spread", "virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.8.3"],\ ["@babel/core", "npm:7.18.5"],\ ["@babel/helper-plugin-utils", "npm:7.17.12"],\ ["@types/babel__core", "npm:7.1.19"]\ @@ -624,10 +630,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.8.3", {\ - "packageLocation": "./.yarn/__virtual__/@babel-plugin-syntax-optional-catch-binding-virtual-9f72e10fdd/0/cache/@babel-plugin-syntax-optional-catch-binding-npm-7.8.3-ce337427d8-910d90e72b.zip/node_modules/@babel/plugin-syntax-optional-catch-binding/",\ + ["virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.8.3", {\ + "packageLocation": "./.yarn/__virtual__/@babel-plugin-syntax-optional-catch-binding-virtual-19cae39d4e/0/cache/@babel-plugin-syntax-optional-catch-binding-npm-7.8.3-ce337427d8-910d90e72b.zip/node_modules/@babel/plugin-syntax-optional-catch-binding/",\ "packageDependencies": [\ - ["@babel/plugin-syntax-optional-catch-binding", "virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.8.3"],\ + ["@babel/plugin-syntax-optional-catch-binding", "virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.8.3"],\ ["@babel/core", "npm:7.18.5"],\ ["@babel/helper-plugin-utils", "npm:7.17.12"],\ ["@types/babel__core", "npm:7.1.19"]\ @@ -661,10 +667,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.8.3", {\ - "packageLocation": "./.yarn/__virtual__/@babel-plugin-syntax-optional-chaining-virtual-1cbe1e4956/0/cache/@babel-plugin-syntax-optional-chaining-npm-7.8.3-f3f3c79579-eef94d53a1.zip/node_modules/@babel/plugin-syntax-optional-chaining/",\ + ["virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.8.3", {\ + "packageLocation": "./.yarn/__virtual__/@babel-plugin-syntax-optional-chaining-virtual-0faf2c8e15/0/cache/@babel-plugin-syntax-optional-chaining-npm-7.8.3-f3f3c79579-eef94d53a1.zip/node_modules/@babel/plugin-syntax-optional-chaining/",\ "packageDependencies": [\ - ["@babel/plugin-syntax-optional-chaining", "virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.8.3"],\ + ["@babel/plugin-syntax-optional-chaining", "virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.8.3"],\ ["@babel/core", "npm:7.18.5"],\ ["@babel/helper-plugin-utils", "npm:7.17.12"],\ ["@types/babel__core", "npm:7.1.19"]\ @@ -698,10 +704,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.14.5", {\ - "packageLocation": "./.yarn/__virtual__/@babel-plugin-syntax-top-level-await-virtual-cec8d6aa40/0/cache/@babel-plugin-syntax-top-level-await-npm-7.14.5-60a0a2e83b-bbd1a56b09.zip/node_modules/@babel/plugin-syntax-top-level-await/",\ + ["virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.14.5", {\ + "packageLocation": "./.yarn/__virtual__/@babel-plugin-syntax-top-level-await-virtual-4f23029d05/0/cache/@babel-plugin-syntax-top-level-await-npm-7.14.5-60a0a2e83b-bbd1a56b09.zip/node_modules/@babel/plugin-syntax-top-level-await/",\ "packageDependencies": [\ - ["@babel/plugin-syntax-top-level-await", "virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.14.5"],\ + ["@babel/plugin-syntax-top-level-await", "virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.14.5"],\ ["@babel/core", "npm:7.18.5"],\ ["@babel/helper-plugin-utils", "npm:7.17.12"],\ ["@types/babel__core", "npm:7.1.19"]\ @@ -1185,12 +1191,12 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:7b65acbdafa6770bdc2e104f1dd40dd64f2ab1944022ee39b8545f85c0a580e21a83af0627735c515bc4d759c96351d0028fa55a8e70f06b6491dc84bf1e68a5#npm:28.1.1", {\ - "packageLocation": "./.yarn/__virtual__/@jest-core-virtual-e249a9c7b0/0/cache/@jest-core-npm-28.1.1-fb910fbf90-fd4361f77b.zip/node_modules/@jest/core/",\ + ["virtual:6e20fd2eaaa940ccda315da6252a82baa2918f5ea3c40e2d7cb4d97f01b503d35a5076b4b63a33762fb1174e73a3313072cadf65e4a26d1b33660f964eda7880#npm:28.1.1", {\ + "packageLocation": "./.yarn/__virtual__/@jest-core-virtual-4b45c3242e/0/cache/@jest-core-npm-28.1.1-fb910fbf90-fd4361f77b.zip/node_modules/@jest/core/",\ "packageDependencies": [\ - ["@jest/core", "virtual:7b65acbdafa6770bdc2e104f1dd40dd64f2ab1944022ee39b8545f85c0a580e21a83af0627735c515bc4d759c96351d0028fa55a8e70f06b6491dc84bf1e68a5#npm:28.1.1"],\ + ["@jest/core", "virtual:6e20fd2eaaa940ccda315da6252a82baa2918f5ea3c40e2d7cb4d97f01b503d35a5076b4b63a33762fb1174e73a3313072cadf65e4a26d1b33660f964eda7880#npm:28.1.1"],\ ["@jest/console", "npm:28.1.1"],\ - ["@jest/reporters", "virtual:e249a9c7b0aa2d2eaa17a2c6540425f288ca38113428d6b19664aca6bcf9a83762c6c23638d4d23d93600b97ce11f732f013eda5363fa7d75729dae295335703#npm:28.1.1"],\ + ["@jest/reporters", "virtual:4b45c3242ed36b84511b3946081e5d3b347e0463d6e39ebfdee2ad8392eb4bd7a5761a69e4fccf0d230c488b171720ddcf381e7c249fe8f4fcdf9d4afc493b87#npm:28.1.1"],\ ["@jest/test-result", "npm:28.1.1"],\ ["@jest/transform", "npm:28.1.1"],\ ["@jest/types", "npm:28.1.1"],\ @@ -1202,7 +1208,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["exit", "npm:0.1.2"],\ ["graceful-fs", "npm:4.2.10"],\ ["jest-changed-files", "npm:28.0.2"],\ - ["jest-config", "virtual:e249a9c7b0aa2d2eaa17a2c6540425f288ca38113428d6b19664aca6bcf9a83762c6c23638d4d23d93600b97ce11f732f013eda5363fa7d75729dae295335703#npm:28.1.1"],\ + ["jest-config", "virtual:4b45c3242ed36b84511b3946081e5d3b347e0463d6e39ebfdee2ad8392eb4bd7a5761a69e4fccf0d230c488b171720ddcf381e7c249fe8f4fcdf9d4afc493b87#npm:28.1.1"],\ ["jest-haste-map", "npm:28.1.1"],\ ["jest-message-util", "npm:28.1.1"],\ ["jest-regex-util", "npm:28.0.2"],\ @@ -1297,10 +1303,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:e249a9c7b0aa2d2eaa17a2c6540425f288ca38113428d6b19664aca6bcf9a83762c6c23638d4d23d93600b97ce11f732f013eda5363fa7d75729dae295335703#npm:28.1.1", {\ - "packageLocation": "./.yarn/__virtual__/@jest-reporters-virtual-ebe745bacd/0/cache/@jest-reporters-npm-28.1.1-21fe131d02-8ad68d4a93.zip/node_modules/@jest/reporters/",\ + ["virtual:4b45c3242ed36b84511b3946081e5d3b347e0463d6e39ebfdee2ad8392eb4bd7a5761a69e4fccf0d230c488b171720ddcf381e7c249fe8f4fcdf9d4afc493b87#npm:28.1.1", {\ + "packageLocation": "./.yarn/__virtual__/@jest-reporters-virtual-ea52091ed4/0/cache/@jest-reporters-npm-28.1.1-21fe131d02-8ad68d4a93.zip/node_modules/@jest/reporters/",\ "packageDependencies": [\ - ["@jest/reporters", "virtual:e249a9c7b0aa2d2eaa17a2c6540425f288ca38113428d6b19664aca6bcf9a83762c6c23638d4d23d93600b97ce11f732f013eda5363fa7d75729dae295335703#npm:28.1.1"],\ + ["@jest/reporters", "virtual:4b45c3242ed36b84511b3946081e5d3b347e0463d6e39ebfdee2ad8392eb4bd7a5761a69e4fccf0d230c488b171720ddcf381e7c249fe8f4fcdf9d4afc493b87#npm:28.1.1"],\ ["@bcoe/v8-coverage", "npm:0.2.3"],\ ["@jest/console", "npm:28.1.1"],\ ["@jest/test-result", "npm:28.1.1"],\ @@ -1856,10 +1862,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:16bfd8597041deb71e4581ea0755edd4dcd1b09b8ab14bfbbf5e4d5ca6b5d47ed7fbe2a25cdf57fcbb8e092c30b6beb93d2e7533f9e31c5dc62f7f0e487d1e4b#npm:2.1.2", {\ - "packageLocation": "./.yarn/__virtual__/@newrelic-winston-enricher-virtual-9009720354/0/cache/@newrelic-winston-enricher-npm-2.1.2-732878a1b2-d001c13166.zip/node_modules/@newrelic/winston-enricher/",\ + ["virtual:31b5a94a105c89c9294c3d524a7f8929fe63ee5a2efadf21951ca4c0cfd2ecf02e8f4ef5a066bbda091f1e3a56e57c6749069a080618c96b22e51131a330fc4a#npm:2.1.2", {\ + "packageLocation": "./.yarn/__virtual__/@newrelic-winston-enricher-virtual-30a09b12b4/0/cache/@newrelic-winston-enricher-npm-2.1.2-732878a1b2-d001c13166.zip/node_modules/@newrelic/winston-enricher/",\ "packageDependencies": [\ - ["@newrelic/winston-enricher", "virtual:16bfd8597041deb71e4581ea0755edd4dcd1b09b8ab14bfbbf5e4d5ca6b5d47ed7fbe2a25cdf57fcbb8e092c30b6beb93d2e7533f9e31c5dc62f7f0e487d1e4b#npm:2.1.2"],\ + ["@newrelic/winston-enricher", "virtual:31b5a94a105c89c9294c3d524a7f8929fe63ee5a2efadf21951ca4c0cfd2ecf02e8f4ef5a066bbda091f1e3a56e57c6749069a080618c96b22e51131a330fc4a#npm:2.1.2"],\ ["@types/newrelic", "npm:7.0.3"],\ ["@types/winston", null],\ ["newrelic", "npm:8.6.0"],\ @@ -2204,6 +2210,60 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["@otplib/core", [\ + ["npm:12.0.1", {\ + "packageLocation": "./.yarn/cache/@otplib-core-npm-12.0.1-4b9787d379-b3c34bc20b.zip/node_modules/@otplib/core/",\ + "packageDependencies": [\ + ["@otplib/core", "npm:12.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@otplib/plugin-crypto", [\ + ["npm:12.0.1", {\ + "packageLocation": "./.yarn/cache/@otplib-plugin-crypto-npm-12.0.1-d0dc5d1d98-6867c74ee8.zip/node_modules/@otplib/plugin-crypto/",\ + "packageDependencies": [\ + ["@otplib/plugin-crypto", "npm:12.0.1"],\ + ["@otplib/core", "npm:12.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@otplib/plugin-thirty-two", [\ + ["npm:12.0.1", {\ + "packageLocation": "./.yarn/cache/@otplib-plugin-thirty-two-npm-12.0.1-b85109b20e-920099e40d.zip/node_modules/@otplib/plugin-thirty-two/",\ + "packageDependencies": [\ + ["@otplib/plugin-thirty-two", "npm:12.0.1"],\ + ["@otplib/core", "npm:12.0.1"],\ + ["thirty-two", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@otplib/preset-default", [\ + ["npm:12.0.1", {\ + "packageLocation": "./.yarn/cache/@otplib-preset-default-npm-12.0.1-77f04f54c4-8133231384.zip/node_modules/@otplib/preset-default/",\ + "packageDependencies": [\ + ["@otplib/preset-default", "npm:12.0.1"],\ + ["@otplib/core", "npm:12.0.1"],\ + ["@otplib/plugin-crypto", "npm:12.0.1"],\ + ["@otplib/plugin-thirty-two", "npm:12.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@otplib/preset-v11", [\ + ["npm:12.0.1", {\ + "packageLocation": "./.yarn/cache/@otplib-preset-v11-npm-12.0.1-df44c202c1-367cb09397.zip/node_modules/@otplib/preset-v11/",\ + "packageDependencies": [\ + ["@otplib/preset-v11", "npm:12.0.1"],\ + ["@otplib/core", "npm:12.0.1"],\ + ["@otplib/plugin-crypto", "npm:12.0.1"],\ + ["@otplib/plugin-thirty-two", "npm:12.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@protobufjs/aspromise", [\ ["npm:1.1.2", {\ "packageLocation": "./.yarn/cache/@protobufjs-aspromise-npm-1.1.2-71d00b938f-011fe7ef08.zip/node_modules/@protobufjs/aspromise/",\ @@ -2296,6 +2356,81 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["@sentry/core", [\ + ["npm:6.19.7", {\ + "packageLocation": "./.yarn/cache/@sentry-core-npm-6.19.7-4cbb62d040-d212e8ef07.zip/node_modules/@sentry/core/",\ + "packageDependencies": [\ + ["@sentry/core", "npm:6.19.7"],\ + ["@sentry/hub", "npm:6.19.7"],\ + ["@sentry/minimal", "npm:6.19.7"],\ + ["@sentry/types", "npm:6.19.7"],\ + ["@sentry/utils", "npm:6.19.7"],\ + ["tslib", "npm:1.14.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@sentry/hub", [\ + ["npm:6.19.7", {\ + "packageLocation": "./.yarn/cache/@sentry-hub-npm-6.19.7-6469362c23-10bb1c5cba.zip/node_modules/@sentry/hub/",\ + "packageDependencies": [\ + ["@sentry/hub", "npm:6.19.7"],\ + ["@sentry/types", "npm:6.19.7"],\ + ["@sentry/utils", "npm:6.19.7"],\ + ["tslib", "npm:1.14.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@sentry/minimal", [\ + ["npm:6.19.7", {\ + "packageLocation": "./.yarn/cache/@sentry-minimal-npm-6.19.7-7527a9814c-9153ac426e.zip/node_modules/@sentry/minimal/",\ + "packageDependencies": [\ + ["@sentry/minimal", "npm:6.19.7"],\ + ["@sentry/hub", "npm:6.19.7"],\ + ["@sentry/types", "npm:6.19.7"],\ + ["tslib", "npm:1.14.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@sentry/node", [\ + ["npm:6.19.7", {\ + "packageLocation": "./.yarn/cache/@sentry-node-npm-6.19.7-edcd5da482-2293b0d1d1.zip/node_modules/@sentry/node/",\ + "packageDependencies": [\ + ["@sentry/node", "npm:6.19.7"],\ + ["@sentry/core", "npm:6.19.7"],\ + ["@sentry/hub", "npm:6.19.7"],\ + ["@sentry/types", "npm:6.19.7"],\ + ["@sentry/utils", "npm:6.19.7"],\ + ["cookie", "npm:0.4.2"],\ + ["https-proxy-agent", "npm:5.0.1"],\ + ["lru_map", "npm:0.3.3"],\ + ["tslib", "npm:1.14.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@sentry/types", [\ + ["npm:6.19.7", {\ + "packageLocation": "./.yarn/cache/@sentry-types-npm-6.19.7-f75535a9f4-f46ef74a33.zip/node_modules/@sentry/types/",\ + "packageDependencies": [\ + ["@sentry/types", "npm:6.19.7"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@sentry/utils", [\ + ["npm:6.19.7", {\ + "packageLocation": "./.yarn/cache/@sentry-utils-npm-6.19.7-d61c6c8632-a000223b9c.zip/node_modules/@sentry/utils/",\ + "packageDependencies": [\ + ["@sentry/utils", "npm:6.19.7"],\ + ["@sentry/types", "npm:6.19.7"],\ + ["tslib", "npm:1.14.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@sinclair/typebox", [\ ["npm:0.23.5", {\ "packageLocation": "./.yarn/cache/@sinclair-typebox-npm-0.23.5-10c003c068-c96056d35d.zip/node_modules/@sinclair/typebox/",\ @@ -2305,6 +2440,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["@sindresorhus/is", [\ + ["npm:0.14.0", {\ + "packageLocation": "./.yarn/cache/@sindresorhus-is-npm-0.14.0-9f906ea34b-971e0441dd.zip/node_modules/@sindresorhus/is/",\ + "packageDependencies": [\ + ["@sindresorhus/is", "npm:0.14.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@sinonjs/commons", [\ ["npm:1.8.3", {\ "packageLocation": "./.yarn/cache/@sinonjs-commons-npm-1.8.3-30cf78d93f-6159726db5.zip/node_modules/@sinonjs/commons/",\ @@ -2334,6 +2478,30 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["@standardnotes/analytics", [\ + ["npm:1.6.0", {\ + "packageLocation": "./.yarn/cache/@standardnotes-analytics-npm-1.6.0-39bec110e3-6a5e861526.zip/node_modules/@standardnotes/analytics/",\ + "packageDependencies": [\ + ["@standardnotes/analytics", "npm:1.6.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@standardnotes/api", [\ + ["npm:1.1.13", {\ + "packageLocation": "./.yarn/cache/@standardnotes-api-npm-1.1.13-59feca8c9e-2ff21e04bb.zip/node_modules/@standardnotes/api/",\ + "packageDependencies": [\ + ["@standardnotes/api", "npm:1.1.13"],\ + ["@standardnotes/auth", "npm:3.19.3"],\ + ["@standardnotes/common", "npm:1.23.0"],\ + ["@standardnotes/encryption", "npm:1.8.19"],\ + ["@standardnotes/responses", "npm:1.6.36"],\ + ["@standardnotes/services", "npm:1.13.19"],\ + ["@standardnotes/utils", "npm:1.6.11"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@standardnotes/auth", [\ ["npm:3.19.3", {\ "packageLocation": "./.yarn/cache/@standardnotes-auth-npm-3.19.3-c77ec60e52-7e421b5eaf.zip/node_modules/@standardnotes/auth/",\ @@ -2345,6 +2513,67 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["@standardnotes/auth-server", [\ + ["workspace:packages/auth", {\ + "packageLocation": "./packages/auth/",\ + "packageDependencies": [\ + ["@standardnotes/auth-server", "workspace:packages/auth"],\ + ["@newrelic/native-metrics", "npm:7.0.2"],\ + ["@newrelic/winston-enricher", "virtual:31b5a94a105c89c9294c3d524a7f8929fe63ee5a2efadf21951ca4c0cfd2ecf02e8f4ef5a066bbda091f1e3a56e57c6749069a080618c96b22e51131a330fc4a#npm:2.1.2"],\ + ["@sentry/node", "npm:6.19.7"],\ + ["@standardnotes/analytics", "npm:1.6.0"],\ + ["@standardnotes/api", "npm:1.1.13"],\ + ["@standardnotes/auth", "npm:3.19.3"],\ + ["@standardnotes/common", "npm:1.23.0"],\ + ["@standardnotes/domain-events", "npm:2.32.2"],\ + ["@standardnotes/domain-events-infra", "npm:1.5.2"],\ + ["@standardnotes/features", "npm:1.45.5"],\ + ["@standardnotes/responses", "npm:1.6.36"],\ + ["@standardnotes/scheduler", "npm:1.1.1"],\ + ["@standardnotes/settings", "npm:1.14.3"],\ + ["@standardnotes/sncrypto-common", "npm:1.9.0"],\ + ["@standardnotes/sncrypto-node", "npm:1.8.3"],\ + ["@standardnotes/time", "npm:1.7.0"],\ + ["@types/bcryptjs", "npm:2.4.2"],\ + ["@types/cors", "npm:2.8.12"],\ + ["@types/express", "npm:4.17.13"],\ + ["@types/ioredis", "npm:4.28.10"],\ + ["@types/jest", "npm:28.1.3"],\ + ["@types/newrelic", "npm:7.0.3"],\ + ["@types/otplib", "npm:10.0.0"],\ + ["@types/prettyjson", "npm:0.0.29"],\ + ["@types/ua-parser-js", "npm:0.7.36"],\ + ["@types/uuid", "npm:8.3.4"],\ + ["@typescript-eslint/eslint-plugin", "virtual:31b5a94a105c89c9294c3d524a7f8929fe63ee5a2efadf21951ca4c0cfd2ecf02e8f4ef5a066bbda091f1e3a56e57c6749069a080618c96b22e51131a330fc4a#npm:5.29.0"],\ + ["aws-sdk", "npm:2.1159.0"],\ + ["axios", "npm:0.24.0"],\ + ["bcryptjs", "npm:2.4.3"],\ + ["cors", "npm:2.8.5"],\ + ["crypto-random-string", "npm:3.3.0"],\ + ["dayjs", "npm:1.11.3"],\ + ["dotenv", "npm:8.2.0"],\ + ["eslint", "npm:8.18.0"],\ + ["eslint-plugin-prettier", "virtual:31b5a94a105c89c9294c3d524a7f8929fe63ee5a2efadf21951ca4c0cfd2ecf02e8f4ef5a066bbda091f1e3a56e57c6749069a080618c96b22e51131a330fc4a#npm:4.0.0"],\ + ["express", "npm:4.17.1"],\ + ["inversify", "npm:6.0.1"],\ + ["inversify-express-utils", "npm:6.4.3"],\ + ["ioredis", "npm:5.0.6"],\ + ["jest", "virtual:31b5a94a105c89c9294c3d524a7f8929fe63ee5a2efadf21951ca4c0cfd2ecf02e8f4ef5a066bbda091f1e3a56e57c6749069a080618c96b22e51131a330fc4a#npm:28.1.1"],\ + ["mysql2", "npm:2.3.3"],\ + ["newrelic", "npm:8.6.0"],\ + ["nodemon", "npm:2.0.16"],\ + ["otplib", "npm:12.0.1"],\ + ["prettyjson", "npm:1.2.1"],\ + ["reflect-metadata", "npm:0.1.13"],\ + ["ts-jest", "virtual:31b5a94a105c89c9294c3d524a7f8929fe63ee5a2efadf21951ca4c0cfd2ecf02e8f4ef5a066bbda091f1e3a56e57c6749069a080618c96b22e51131a330fc4a#npm:28.0.5"],\ + ["typeorm", "virtual:31b5a94a105c89c9294c3d524a7f8929fe63ee5a2efadf21951ca4c0cfd2ecf02e8f4ef5a066bbda091f1e3a56e57c6749069a080618c96b22e51131a330fc4a#npm:0.3.6"],\ + ["ua-parser-js", "npm:1.0.2"],\ + ["uuid", "npm:8.3.2"],\ + ["winston", "npm:3.3.3"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ ["@standardnotes/common", [\ ["npm:1.23.0", {\ "packageLocation": "./.yarn/cache/@standardnotes-common-npm-1.23.0-9b07bb0353-9c51bf76e7.zip/node_modules/@standardnotes/common/",\ @@ -2382,6 +2611,18 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["@standardnotes/encryption", [\ + ["npm:1.8.19", {\ + "packageLocation": "./.yarn/cache/@standardnotes-encryption-npm-1.8.19-b0a1c08193-f663a6b9a2.zip/node_modules/@standardnotes/encryption/",\ + "packageDependencies": [\ + ["@standardnotes/encryption", "npm:1.8.19"],\ + ["@standardnotes/models", "npm:1.11.10"],\ + ["@standardnotes/responses", "npm:1.6.36"],\ + ["@standardnotes/services", "npm:1.13.19"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@standardnotes/features", [\ ["npm:1.45.5", {\ "packageLocation": "./.yarn/cache/@standardnotes-features-npm-1.45.5-869e7129f6-b5914d06b0.zip/node_modules/@standardnotes/features/",\ @@ -2393,6 +2634,30 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["@standardnotes/models", [\ + ["npm:1.11.10", {\ + "packageLocation": "./.yarn/cache/@standardnotes-models-npm-1.11.10-e4b5e4717d-d69fd3940e.zip/node_modules/@standardnotes/models/",\ + "packageDependencies": [\ + ["@standardnotes/models", "npm:1.11.10"],\ + ["@standardnotes/features", "npm:1.45.5"],\ + ["@standardnotes/responses", "npm:1.6.36"],\ + ["@standardnotes/utils", "npm:1.6.11"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@standardnotes/responses", [\ + ["npm:1.6.36", {\ + "packageLocation": "./.yarn/cache/@standardnotes-responses-npm-1.6.36-d245f42de1-bb78a2cefa.zip/node_modules/@standardnotes/responses/",\ + "packageDependencies": [\ + ["@standardnotes/responses", "npm:1.6.36"],\ + ["@standardnotes/auth", "npm:3.19.3"],\ + ["@standardnotes/common", "npm:1.23.0"],\ + ["@standardnotes/features", "npm:1.45.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@standardnotes/scheduler", [\ ["npm:1.1.1", {\ "packageLocation": "./.yarn/cache/@standardnotes-scheduler-npm-1.1.1-0193ff7839-483beec5ce.zip/node_modules/@standardnotes/scheduler/",\ @@ -2409,7 +2674,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "packageDependencies": [\ ["@standardnotes/scheduler-server", "workspace:packages/scheduler"],\ ["@newrelic/native-metrics", "npm:7.0.2"],\ - ["@newrelic/winston-enricher", "virtual:16bfd8597041deb71e4581ea0755edd4dcd1b09b8ab14bfbbf5e4d5ca6b5d47ed7fbe2a25cdf57fcbb8e092c30b6beb93d2e7533f9e31c5dc62f7f0e487d1e4b#npm:2.1.2"],\ + ["@newrelic/winston-enricher", "virtual:31b5a94a105c89c9294c3d524a7f8929fe63ee5a2efadf21951ca4c0cfd2ecf02e8f4ef5a066bbda091f1e3a56e57c6749069a080618c96b22e51131a330fc4a#npm:2.1.2"],\ ["@standardnotes/common", "npm:1.23.0"],\ ["@standardnotes/domain-events", "npm:2.32.2"],\ ["@standardnotes/domain-events-infra", "npm:1.5.2"],\ @@ -2426,12 +2691,12 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["eslint-plugin-prettier", "virtual:16bfd8597041deb71e4581ea0755edd4dcd1b09b8ab14bfbbf5e4d5ca6b5d47ed7fbe2a25cdf57fcbb8e092c30b6beb93d2e7533f9e31c5dc62f7f0e487d1e4b#npm:4.0.0"],\ ["inversify", "npm:5.0.5"],\ ["ioredis", "npm:5.0.6"],\ - ["jest", "virtual:16bfd8597041deb71e4581ea0755edd4dcd1b09b8ab14bfbbf5e4d5ca6b5d47ed7fbe2a25cdf57fcbb8e092c30b6beb93d2e7533f9e31c5dc62f7f0e487d1e4b#npm:28.1.1"],\ + ["jest", "virtual:31b5a94a105c89c9294c3d524a7f8929fe63ee5a2efadf21951ca4c0cfd2ecf02e8f4ef5a066bbda091f1e3a56e57c6749069a080618c96b22e51131a330fc4a#npm:28.1.1"],\ ["mysql2", "npm:2.3.3"],\ ["newrelic", "npm:8.6.0"],\ ["reflect-metadata", "npm:0.1.13"],\ ["ts-jest", "virtual:16bfd8597041deb71e4581ea0755edd4dcd1b09b8ab14bfbbf5e4d5ca6b5d47ed7fbe2a25cdf57fcbb8e092c30b6beb93d2e7533f9e31c5dc62f7f0e487d1e4b#npm:28.0.5"],\ - ["typeorm", "virtual:16bfd8597041deb71e4581ea0755edd4dcd1b09b8ab14bfbbf5e4d5ca6b5d47ed7fbe2a25cdf57fcbb8e092c30b6beb93d2e7533f9e31c5dc62f7f0e487d1e4b#npm:0.3.6"],\ + ["typeorm", "virtual:31b5a94a105c89c9294c3d524a7f8929fe63ee5a2efadf21951ca4c0cfd2ecf02e8f4ef5a066bbda091f1e3a56e57c6749069a080618c96b22e51131a330fc4a#npm:0.3.6"],\ ["winston", "npm:3.3.3"]\ ],\ "linkType": "SOFT"\ @@ -2447,6 +2712,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@lerna-lite/cli", "npm:1.5.1"],\ ["@lerna-lite/list", "npm:1.5.1"],\ ["@lerna-lite/run", "npm:1.5.1"],\ + ["@types/jest", "npm:28.1.3"],\ ["@typescript-eslint/parser", "virtual:8859b278716fedf3e7458b5628625f7e35678c418626878559a0b816445001b7e24c55546f4677ba4c20b521aa0cf52cc33ac07deff171e383ada6eeab69933f#npm:5.29.0"],\ ["eslint", "npm:8.18.0"],\ ["eslint-config-prettier", "virtual:8859b278716fedf3e7458b5628625f7e35678c418626878559a0b816445001b7e24c55546f4677ba4c20b521aa0cf52cc33ac07deff171e383ada6eeab69933f#npm:8.5.0"],\ @@ -2457,6 +2723,48 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "SOFT"\ }]\ ]],\ + ["@standardnotes/services", [\ + ["npm:1.13.19", {\ + "packageLocation": "./.yarn/cache/@standardnotes-services-npm-1.13.19-5574bb675a-c4c239c5e8.zip/node_modules/@standardnotes/services/",\ + "packageDependencies": [\ + ["@standardnotes/services", "npm:1.13.19"],\ + ["@standardnotes/auth", "npm:3.19.3"],\ + ["@standardnotes/common", "npm:1.23.0"],\ + ["@standardnotes/models", "npm:1.11.10"],\ + ["@standardnotes/responses", "npm:1.6.36"],\ + ["@standardnotes/utils", "npm:1.6.11"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@standardnotes/settings", [\ + ["npm:1.14.3", {\ + "packageLocation": "./.yarn/cache/@standardnotes-settings-npm-1.14.3-6f557bd9ab-60fbb2ca85.zip/node_modules/@standardnotes/settings/",\ + "packageDependencies": [\ + ["@standardnotes/settings", "npm:1.14.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@standardnotes/sncrypto-common", [\ + ["npm:1.9.0", {\ + "packageLocation": "./.yarn/cache/@standardnotes-sncrypto-common-npm-1.9.0-48773f745a-42252d7198.zip/node_modules/@standardnotes/sncrypto-common/",\ + "packageDependencies": [\ + ["@standardnotes/sncrypto-common", "npm:1.9.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@standardnotes/sncrypto-node", [\ + ["npm:1.8.3", {\ + "packageLocation": "./.yarn/cache/@standardnotes-sncrypto-node-npm-1.8.3-5d28cdd37d-b3c866bfba.zip/node_modules/@standardnotes/sncrypto-node/",\ + "packageDependencies": [\ + ["@standardnotes/sncrypto-node", "npm:1.8.3"],\ + ["@standardnotes/sncrypto-common", "npm:1.9.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@standardnotes/time", [\ ["npm:1.7.0", {\ "packageLocation": "./.yarn/cache/@standardnotes-time-npm-1.7.0-fa2b65b191-51b168d8a5.zip/node_modules/@standardnotes/time/",\ @@ -2469,6 +2777,28 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["@standardnotes/utils", [\ + ["npm:1.6.11", {\ + "packageLocation": "./.yarn/cache/@standardnotes-utils-npm-1.6.11-54d7210fab-c50999c0b0.zip/node_modules/@standardnotes/utils/",\ + "packageDependencies": [\ + ["@standardnotes/utils", "npm:1.6.11"],\ + ["@standardnotes/common", "npm:1.23.0"],\ + ["dompurify", "npm:2.3.8"],\ + ["lodash", "npm:4.17.21"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@szmarczak/http-timer", [\ + ["npm:1.1.2", {\ + "packageLocation": "./.yarn/cache/@szmarczak-http-timer-npm-1.1.2-ea82ca2d55-4d9158061c.zip/node_modules/@szmarczak/http-timer/",\ + "packageDependencies": [\ + ["@szmarczak/http-timer", "npm:1.1.2"],\ + ["defer-to-connect", "npm:1.1.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@tootallnate/once", [\ ["npm:1.1.2", {\ "packageLocation": "./.yarn/cache/@tootallnate-once-npm-1.1.2-0517220057-e1fb1bbbc1.zip/node_modules/@tootallnate/once/",\ @@ -2566,6 +2896,70 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["@types/bcryptjs", [\ + ["npm:2.4.2", {\ + "packageLocation": "./.yarn/cache/@types-bcryptjs-npm-2.4.2-3a0c115732-220dade7b0.zip/node_modules/@types/bcryptjs/",\ + "packageDependencies": [\ + ["@types/bcryptjs", "npm:2.4.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/body-parser", [\ + ["npm:1.19.2", {\ + "packageLocation": "./.yarn/cache/@types-body-parser-npm-1.19.2-f845b7b538-e17840c7d7.zip/node_modules/@types/body-parser/",\ + "packageDependencies": [\ + ["@types/body-parser", "npm:1.19.2"],\ + ["@types/connect", "npm:3.4.35"],\ + ["@types/node", "npm:18.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/connect", [\ + ["npm:3.4.35", {\ + "packageLocation": "./.yarn/cache/@types-connect-npm-3.4.35-7337eee0a3-fe81351470.zip/node_modules/@types/connect/",\ + "packageDependencies": [\ + ["@types/connect", "npm:3.4.35"],\ + ["@types/node", "npm:18.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/cors", [\ + ["npm:2.8.12", {\ + "packageLocation": "./.yarn/cache/@types-cors-npm-2.8.12-ff52e8e514-8c45f112c7.zip/node_modules/@types/cors/",\ + "packageDependencies": [\ + ["@types/cors", "npm:2.8.12"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/express", [\ + ["npm:4.17.13", {\ + "packageLocation": "./.yarn/cache/@types-express-npm-4.17.13-0e12fe9c24-12a2a0e6c4.zip/node_modules/@types/express/",\ + "packageDependencies": [\ + ["@types/express", "npm:4.17.13"],\ + ["@types/body-parser", "npm:1.19.2"],\ + ["@types/express-serve-static-core", "npm:4.17.29"],\ + ["@types/qs", "npm:6.9.7"],\ + ["@types/serve-static", "npm:1.13.10"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/express-serve-static-core", [\ + ["npm:4.17.29", {\ + "packageLocation": "./.yarn/cache/@types-express-serve-static-core-npm-4.17.29-9b96bc0e26-ec4194dc59.zip/node_modules/@types/express-serve-static-core/",\ + "packageDependencies": [\ + ["@types/express-serve-static-core", "npm:4.17.29"],\ + ["@types/node", "npm:18.0.0"],\ + ["@types/qs", "npm:6.9.7"],\ + ["@types/range-parser", "npm:1.2.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@types/graceful-fs", [\ ["npm:4.1.5", {\ "packageLocation": "./.yarn/cache/@types-graceful-fs-npm-4.1.5-91d62e1050-d076bb61f4.zip/node_modules/@types/graceful-fs/",\ @@ -2624,6 +3018,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["pretty-format", "npm:28.1.1"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:28.1.3", {\ + "packageLocation": "./.yarn/cache/@types-jest-npm-28.1.3-4e0f1f0cb8-28141f2d5b.zip/node_modules/@types/jest/",\ + "packageDependencies": [\ + ["@types/jest", "npm:28.1.3"],\ + ["jest-matcher-utils", "npm:28.1.1"],\ + ["pretty-format", "npm:28.1.1"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["@types/json-schema", [\ @@ -2635,6 +3038,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["@types/keyv", [\ + ["npm:3.1.4", {\ + "packageLocation": "./.yarn/cache/@types-keyv-npm-3.1.4-a8082ea56b-e009a2bfb5.zip/node_modules/@types/keyv/",\ + "packageDependencies": [\ + ["@types/keyv", "npm:3.1.4"],\ + ["@types/node", "npm:18.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@types/long", [\ ["npm:4.0.2", {\ "packageLocation": "./.yarn/cache/@types-long-npm-4.0.2-e7bdc00dd4-d16cde7240.zip/node_modules/@types/long/",\ @@ -2644,6 +3057,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["@types/mime", [\ + ["npm:1.3.2", {\ + "packageLocation": "./.yarn/cache/@types-mime-npm-1.3.2-ea71878ab3-0493368244.zip/node_modules/@types/mime/",\ + "packageDependencies": [\ + ["@types/mime", "npm:1.3.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@types/minimatch", [\ ["npm:3.0.5", {\ "packageLocation": "./.yarn/cache/@types-minimatch-npm-3.0.5-802bb0797f-c41d136f67.zip/node_modules/@types/minimatch/",\ @@ -2689,6 +3111,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["@types/otplib", [\ + ["npm:10.0.0", {\ + "packageLocation": "./.yarn/cache/@types-otplib-npm-10.0.0-6cfcbcf64e-aa081f0a55.zip/node_modules/@types/otplib/",\ + "packageDependencies": [\ + ["@types/otplib", "npm:10.0.0"],\ + ["otplib", "npm:12.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@types/parse-json", [\ ["npm:4.0.0", {\ "packageLocation": "./.yarn/cache/@types-parse-json-npm-4.0.0-298522afa6-fd6bce2b67.zip/node_modules/@types/parse-json/",\ @@ -2707,6 +3139,54 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["@types/prettyjson", [\ + ["npm:0.0.29", {\ + "packageLocation": "./.yarn/cache/@types-prettyjson-npm-0.0.29-26ae573a83-9ff6cb225d.zip/node_modules/@types/prettyjson/",\ + "packageDependencies": [\ + ["@types/prettyjson", "npm:0.0.29"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/qs", [\ + ["npm:6.9.7", {\ + "packageLocation": "./.yarn/cache/@types-qs-npm-6.9.7-4a3e6ca0d0-7fd6f9c250.zip/node_modules/@types/qs/",\ + "packageDependencies": [\ + ["@types/qs", "npm:6.9.7"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/range-parser", [\ + ["npm:1.2.4", {\ + "packageLocation": "./.yarn/cache/@types-range-parser-npm-1.2.4-23d797fbde-b7c0dfd508.zip/node_modules/@types/range-parser/",\ + "packageDependencies": [\ + ["@types/range-parser", "npm:1.2.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/responselike", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/@types-responselike-npm-1.0.0-85dd08af42-e99fc7cc62.zip/node_modules/@types/responselike/",\ + "packageDependencies": [\ + ["@types/responselike", "npm:1.0.0"],\ + ["@types/node", "npm:18.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/serve-static", [\ + ["npm:1.13.10", {\ + "packageLocation": "./.yarn/cache/@types-serve-static-npm-1.13.10-5434e2c519-eaca858739.zip/node_modules/@types/serve-static/",\ + "packageDependencies": [\ + ["@types/serve-static", "npm:1.13.10"],\ + ["@types/mime", "npm:1.3.2"],\ + ["@types/node", "npm:18.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@types/stack-utils", [\ ["npm:2.0.1", {\ "packageLocation": "./.yarn/cache/@types-stack-utils-npm-2.0.1-867718ab70-205fdbe332.zip/node_modules/@types/stack-utils/",\ @@ -2716,6 +3196,24 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["@types/ua-parser-js", [\ + ["npm:0.7.36", {\ + "packageLocation": "./.yarn/cache/@types-ua-parser-js-npm-0.7.36-f5ace9ead6-8c24d4dc12.zip/node_modules/@types/ua-parser-js/",\ + "packageDependencies": [\ + ["@types/ua-parser-js", "npm:0.7.36"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/uuid", [\ + ["npm:8.3.4", {\ + "packageLocation": "./.yarn/cache/@types-uuid-npm-8.3.4-7547f4402c-6f11f3ff70.zip/node_modules/@types/uuid/",\ + "packageDependencies": [\ + ["@types/uuid", "npm:8.3.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@types/yargs", [\ ["npm:17.0.10", {\ "packageLocation": "./.yarn/cache/@types-yargs-npm-17.0.10-04ed5382c7-f0673cbfc0.zip/node_modules/@types/yargs/",\ @@ -2760,7 +3258,37 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["ignore", "npm:5.2.0"],\ ["regexpp", "npm:3.2.0"],\ ["semver", "npm:7.3.7"],\ - ["tsutils", "virtual:f8d5e1f46dbb2f1fb352e8d15f1237589f03161f87569a6446ffa325c842024c20e3b7f196872650fbbdc62125c711d99dd1c2ba271f15e9b316292a2dec51bc#npm:3.21.0"],\ + ["tsutils", "virtual:e64d2841693653abb2dee666d19406912f5e913a8081a709c081d9877d2f39987ff853b7cd736901a2df59af98328f7249f3db0da01abf060cf1d858d4d4e43b#npm:3.21.0"],\ + ["typescript", null]\ + ],\ + "packagePeers": [\ + "@types/eslint",\ + "@types/typescript-eslint__parser",\ + "@types/typescript",\ + "@typescript-eslint/parser",\ + "eslint",\ + "typescript"\ + ],\ + "linkType": "HARD"\ + }],\ + ["virtual:31b5a94a105c89c9294c3d524a7f8929fe63ee5a2efadf21951ca4c0cfd2ecf02e8f4ef5a066bbda091f1e3a56e57c6749069a080618c96b22e51131a330fc4a#npm:5.29.0", {\ + "packageLocation": "./.yarn/__virtual__/@typescript-eslint-eslint-plugin-virtual-e64d284169/0/cache/@typescript-eslint-eslint-plugin-npm-5.29.0-d7e482bb3e-b1022a640f.zip/node_modules/@typescript-eslint/eslint-plugin/",\ + "packageDependencies": [\ + ["@typescript-eslint/eslint-plugin", "virtual:31b5a94a105c89c9294c3d524a7f8929fe63ee5a2efadf21951ca4c0cfd2ecf02e8f4ef5a066bbda091f1e3a56e57c6749069a080618c96b22e51131a330fc4a#npm:5.29.0"],\ + ["@types/eslint", null],\ + ["@types/typescript", null],\ + ["@types/typescript-eslint__parser", null],\ + ["@typescript-eslint/parser", null],\ + ["@typescript-eslint/scope-manager", "npm:5.29.0"],\ + ["@typescript-eslint/type-utils", "virtual:e64d2841693653abb2dee666d19406912f5e913a8081a709c081d9877d2f39987ff853b7cd736901a2df59af98328f7249f3db0da01abf060cf1d858d4d4e43b#npm:5.29.0"],\ + ["@typescript-eslint/utils", "virtual:e64d2841693653abb2dee666d19406912f5e913a8081a709c081d9877d2f39987ff853b7cd736901a2df59af98328f7249f3db0da01abf060cf1d858d4d4e43b#npm:5.29.0"],\ + ["debug", "virtual:b86a9fb34323a98c6519528ed55faa0d9b44ca8879307c0b29aa384bde47ff59a7d0c9051b31246f14521dfb71ba3c5d6d0b35c29fffc17bf875aa6ad977d9e8#npm:4.3.4"],\ + ["eslint", "npm:8.18.0"],\ + ["functional-red-black-tree", "npm:1.0.1"],\ + ["ignore", "npm:5.2.0"],\ + ["regexpp", "npm:3.2.0"],\ + ["semver", "npm:7.3.7"],\ + ["tsutils", "virtual:e64d2841693653abb2dee666d19406912f5e913a8081a709c081d9877d2f39987ff853b7cd736901a2df59af98328f7249f3db0da01abf060cf1d858d4d4e43b#npm:3.21.0"],\ ["typescript", null]\ ],\ "packagePeers": [\ @@ -2823,6 +3351,26 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ + ["virtual:e64d2841693653abb2dee666d19406912f5e913a8081a709c081d9877d2f39987ff853b7cd736901a2df59af98328f7249f3db0da01abf060cf1d858d4d4e43b#npm:5.29.0", {\ + "packageLocation": "./.yarn/__virtual__/@typescript-eslint-type-utils-virtual-96dd1bc160/0/cache/@typescript-eslint-type-utils-npm-5.29.0-063d15676f-686b8ff05a.zip/node_modules/@typescript-eslint/type-utils/",\ + "packageDependencies": [\ + ["@typescript-eslint/type-utils", "virtual:e64d2841693653abb2dee666d19406912f5e913a8081a709c081d9877d2f39987ff853b7cd736901a2df59af98328f7249f3db0da01abf060cf1d858d4d4e43b#npm:5.29.0"],\ + ["@types/eslint", null],\ + ["@types/typescript", null],\ + ["@typescript-eslint/utils", "virtual:e64d2841693653abb2dee666d19406912f5e913a8081a709c081d9877d2f39987ff853b7cd736901a2df59af98328f7249f3db0da01abf060cf1d858d4d4e43b#npm:5.29.0"],\ + ["debug", "virtual:b86a9fb34323a98c6519528ed55faa0d9b44ca8879307c0b29aa384bde47ff59a7d0c9051b31246f14521dfb71ba3c5d6d0b35c29fffc17bf875aa6ad977d9e8#npm:4.3.4"],\ + ["eslint", "npm:8.18.0"],\ + ["tsutils", "virtual:e64d2841693653abb2dee666d19406912f5e913a8081a709c081d9877d2f39987ff853b7cd736901a2df59af98328f7249f3db0da01abf060cf1d858d4d4e43b#npm:3.21.0"],\ + ["typescript", null]\ + ],\ + "packagePeers": [\ + "@types/eslint",\ + "@types/typescript",\ + "eslint",\ + "typescript"\ + ],\ + "linkType": "HARD"\ + }],\ ["virtual:f8d5e1f46dbb2f1fb352e8d15f1237589f03161f87569a6446ffa325c842024c20e3b7f196872650fbbdc62125c711d99dd1c2ba271f15e9b316292a2dec51bc#npm:5.29.0", {\ "packageLocation": "./.yarn/__virtual__/@typescript-eslint-type-utils-virtual-50574b5c8c/0/cache/@typescript-eslint-type-utils-npm-5.29.0-063d15676f-686b8ff05a.zip/node_modules/@typescript-eslint/type-utils/",\ "packageDependencies": [\ @@ -2832,7 +3380,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@typescript-eslint/utils", "virtual:f8d5e1f46dbb2f1fb352e8d15f1237589f03161f87569a6446ffa325c842024c20e3b7f196872650fbbdc62125c711d99dd1c2ba271f15e9b316292a2dec51bc#npm:5.29.0"],\ ["debug", "virtual:b86a9fb34323a98c6519528ed55faa0d9b44ca8879307c0b29aa384bde47ff59a7d0c9051b31246f14521dfb71ba3c5d6d0b35c29fffc17bf875aa6ad977d9e8#npm:4.3.4"],\ ["eslint", null],\ - ["tsutils", "virtual:f8d5e1f46dbb2f1fb352e8d15f1237589f03161f87569a6446ffa325c842024c20e3b7f196872650fbbdc62125c711d99dd1c2ba271f15e9b316292a2dec51bc#npm:3.21.0"],\ + ["tsutils", "virtual:e64d2841693653abb2dee666d19406912f5e913a8081a709c081d9877d2f39987ff853b7cd736901a2df59af98328f7249f3db0da01abf060cf1d858d4d4e43b#npm:3.21.0"],\ ["typescript", null]\ ],\ "packagePeers": [\ @@ -2881,10 +3429,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "HARD"\ }],\ - ["virtual:9b3cc2e468ebc82b101c5313a9afa58bf6c93ab196f710844b44e247fc606cd503de5b07cdee6c592a841949dbe5daecc3f46a7ae43ee5bbf7fe046d76ec335e#npm:5.29.0", {\ - "packageLocation": "./.yarn/__virtual__/@typescript-eslint-typescript-estree-virtual-655881211a/0/cache/@typescript-eslint-typescript-estree-npm-5.29.0-f23de2ab5c-b91107a9fc.zip/node_modules/@typescript-eslint/typescript-estree/",\ + ["virtual:4ec458b53cfcb38d153394fe4d0300908a12ce721ae6026f1e2d7bbe8409ed98079b29d9688a9eb93463ace5dbaac7d454b12c4582b1cd0b1d8210588cf0cb1c#npm:5.29.0", {\ + "packageLocation": "./.yarn/__virtual__/@typescript-eslint-typescript-estree-virtual-8de7b9cb0d/0/cache/@typescript-eslint-typescript-estree-npm-5.29.0-f23de2ab5c-b91107a9fc.zip/node_modules/@typescript-eslint/typescript-estree/",\ "packageDependencies": [\ - ["@typescript-eslint/typescript-estree", "virtual:9b3cc2e468ebc82b101c5313a9afa58bf6c93ab196f710844b44e247fc606cd503de5b07cdee6c592a841949dbe5daecc3f46a7ae43ee5bbf7fe046d76ec335e#npm:5.29.0"],\ + ["@typescript-eslint/typescript-estree", "virtual:4ec458b53cfcb38d153394fe4d0300908a12ce721ae6026f1e2d7bbe8409ed98079b29d9688a9eb93463ace5dbaac7d454b12c4582b1cd0b1d8210588cf0cb1c#npm:5.29.0"],\ ["@types/typescript", null],\ ["@typescript-eslint/types", "npm:5.29.0"],\ ["@typescript-eslint/visitor-keys", "npm:5.29.0"],\ @@ -2892,7 +3440,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["globby", "npm:11.1.0"],\ ["is-glob", "npm:4.0.3"],\ ["semver", "npm:7.3.7"],\ - ["tsutils", "virtual:f8d5e1f46dbb2f1fb352e8d15f1237589f03161f87569a6446ffa325c842024c20e3b7f196872650fbbdc62125c711d99dd1c2ba271f15e9b316292a2dec51bc#npm:3.21.0"],\ + ["tsutils", "virtual:e64d2841693653abb2dee666d19406912f5e913a8081a709c081d9877d2f39987ff853b7cd736901a2df59af98328f7249f3db0da01abf060cf1d858d4d4e43b#npm:3.21.0"],\ ["typescript", null]\ ],\ "packagePeers": [\ @@ -2910,6 +3458,25 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ + ["virtual:e64d2841693653abb2dee666d19406912f5e913a8081a709c081d9877d2f39987ff853b7cd736901a2df59af98328f7249f3db0da01abf060cf1d858d4d4e43b#npm:5.29.0", {\ + "packageLocation": "./.yarn/__virtual__/@typescript-eslint-utils-virtual-4ec458b53c/0/cache/@typescript-eslint-utils-npm-5.29.0-f3bfc5f3f3-216f51fb9c.zip/node_modules/@typescript-eslint/utils/",\ + "packageDependencies": [\ + ["@typescript-eslint/utils", "virtual:e64d2841693653abb2dee666d19406912f5e913a8081a709c081d9877d2f39987ff853b7cd736901a2df59af98328f7249f3db0da01abf060cf1d858d4d4e43b#npm:5.29.0"],\ + ["@types/eslint", null],\ + ["@types/json-schema", "npm:7.0.11"],\ + ["@typescript-eslint/scope-manager", "npm:5.29.0"],\ + ["@typescript-eslint/types", "npm:5.29.0"],\ + ["@typescript-eslint/typescript-estree", "virtual:4ec458b53cfcb38d153394fe4d0300908a12ce721ae6026f1e2d7bbe8409ed98079b29d9688a9eb93463ace5dbaac7d454b12c4582b1cd0b1d8210588cf0cb1c#npm:5.29.0"],\ + ["eslint", "npm:8.18.0"],\ + ["eslint-scope", "npm:5.1.1"],\ + ["eslint-utils", "virtual:3b3bfb190f25ed01591b1d51c8e6a15e818ab97d9cabea5c63912afc819a8f6e3ad395aaf338cd170314411b04e35eec5c8cff33dfa644476d292dcf2c5354d1#npm:3.0.0"]\ + ],\ + "packagePeers": [\ + "@types/eslint",\ + "eslint"\ + ],\ + "linkType": "HARD"\ + }],\ ["virtual:f8d5e1f46dbb2f1fb352e8d15f1237589f03161f87569a6446ffa325c842024c20e3b7f196872650fbbdc62125c711d99dd1c2ba271f15e9b316292a2dec51bc#npm:5.29.0", {\ "packageLocation": "./.yarn/__virtual__/@typescript-eslint-utils-virtual-9b3cc2e468/0/cache/@typescript-eslint-utils-npm-5.29.0-f3bfc5f3f3-216f51fb9c.zip/node_modules/@typescript-eslint/utils/",\ "packageDependencies": [\ @@ -2918,7 +3485,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["@types/json-schema", "npm:7.0.11"],\ ["@typescript-eslint/scope-manager", "npm:5.29.0"],\ ["@typescript-eslint/types", "npm:5.29.0"],\ - ["@typescript-eslint/typescript-estree", "virtual:9b3cc2e468ebc82b101c5313a9afa58bf6c93ab196f710844b44e247fc606cd503de5b07cdee6c592a841949dbe5daecc3f46a7ae43ee5bbf7fe046d76ec335e#npm:5.29.0"],\ + ["@typescript-eslint/typescript-estree", "virtual:4ec458b53cfcb38d153394fe4d0300908a12ce721ae6026f1e2d7bbe8409ed98079b29d9688a9eb93463ace5dbaac7d454b12c4582b1cd0b1d8210588cf0cb1c#npm:5.29.0"],\ ["eslint", null],\ ["eslint-scope", "npm:5.1.1"],\ ["eslint-utils", "virtual:9b3cc2e468ebc82b101c5313a9afa58bf6c93ab196f710844b44e247fc606cd503de5b07cdee6c592a841949dbe5daecc3f46a7ae43ee5bbf7fe046d76ec335e#npm:3.0.0"]\ @@ -2970,6 +3537,17 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["accepts", [\ + ["npm:1.3.8", {\ + "packageLocation": "./.yarn/cache/accepts-npm-1.3.8-9a812371c9-50c43d32e7.zip/node_modules/accepts/",\ + "packageDependencies": [\ + ["accepts", "npm:1.3.8"],\ + ["mime-types", "npm:2.1.35"],\ + ["negotiator", "npm:0.6.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["acorn", [\ ["npm:8.7.1", {\ "packageLocation": "./.yarn/cache/acorn-npm-8.7.1-7c7a019990-aca0aabf98.zip/node_modules/acorn/",\ @@ -3065,6 +3643,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["ansi-align", [\ + ["npm:3.0.1", {\ + "packageLocation": "./.yarn/cache/ansi-align-npm-3.0.1-8e6288d20a-6abfa08f21.zip/node_modules/ansi-align/",\ + "packageDependencies": [\ + ["ansi-align", "npm:3.0.1"],\ + ["string-width", "npm:4.2.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["ansi-escapes", [\ ["npm:4.3.2", {\ "packageLocation": "./.yarn/cache/ansi-escapes-npm-4.3.2-3ad173702f-93111c4218.zip/node_modules/ansi-escapes/",\ @@ -3193,6 +3781,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["array-flatten", [\ + ["npm:1.1.1", {\ + "packageLocation": "./.yarn/cache/array-flatten-npm-1.1.1-9d94ad5f1d-a9925bf351.zip/node_modules/array-flatten/",\ + "packageDependencies": [\ + ["array-flatten", "npm:1.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["array-ify", [\ ["npm:1.0.0", {\ "packageLocation": "./.yarn/cache/array-ify-npm-1.0.0-e09a371977-c0502015b3.zip/node_modules/array-ify/",\ @@ -3268,6 +3865,32 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["xml2js", "npm:0.4.19"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:2.1159.0", {\ + "packageLocation": "./.yarn/cache/aws-sdk-npm-2.1159.0-c10a984d83-f89d3a3483.zip/node_modules/aws-sdk/",\ + "packageDependencies": [\ + ["aws-sdk", "npm:2.1159.0"],\ + ["buffer", "npm:4.9.2"],\ + ["events", "npm:1.1.1"],\ + ["ieee754", "npm:1.1.13"],\ + ["jmespath", "npm:0.16.0"],\ + ["querystring", "npm:0.2.0"],\ + ["sax", "npm:1.2.1"],\ + ["url", "npm:0.10.3"],\ + ["uuid", "npm:8.0.0"],\ + ["xml2js", "npm:0.4.19"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["axios", [\ + ["npm:0.24.0", {\ + "packageLocation": "./.yarn/cache/axios-npm-0.24.0-39e5c1e79e-468cf496c0.zip/node_modules/axios/",\ + "packageDependencies": [\ + ["axios", "npm:0.24.0"],\ + ["follow-redirects", "virtual:39e5c1e79ea63134f0cf339f4463df92854aaf708a45210afd29a0b4b9f67f95b34a1abbcabaae6d0033ad99a1d5f690ab51ed8e5d3283b87ccbc3a9ab3ec05f#npm:1.15.1"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["babel-jest", [\ @@ -3278,15 +3901,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:705cb4c870e8e3eecece0e270f1edb4f1967b8ef32ae1a585c9ce11873c7277d4de1e2f798a22da1f6799240da1c0bd9532e5098d6ba00c4341d39a8fcebe4c4#npm:28.1.1", {\ - "packageLocation": "./.yarn/__virtual__/babel-jest-virtual-69afcad85e/0/cache/babel-jest-npm-28.1.1-a0706ab037-9c7c7f6006.zip/node_modules/babel-jest/",\ + ["virtual:805c813b6f046618cef5c7d6c026d202467ce267579e0c7a252be4f063439bc6f090ab5b924f50d7ae022b220d8e89e00ef15869e26244774ec68ef480e4e54d#npm:28.1.1", {\ + "packageLocation": "./.yarn/__virtual__/babel-jest-virtual-419439314f/0/cache/babel-jest-npm-28.1.1-a0706ab037-9c7c7f6006.zip/node_modules/babel-jest/",\ "packageDependencies": [\ - ["babel-jest", "virtual:705cb4c870e8e3eecece0e270f1edb4f1967b8ef32ae1a585c9ce11873c7277d4de1e2f798a22da1f6799240da1c0bd9532e5098d6ba00c4341d39a8fcebe4c4#npm:28.1.1"],\ + ["babel-jest", "virtual:805c813b6f046618cef5c7d6c026d202467ce267579e0c7a252be4f063439bc6f090ab5b924f50d7ae022b220d8e89e00ef15869e26244774ec68ef480e4e54d#npm:28.1.1"],\ ["@babel/core", "npm:7.18.5"],\ ["@jest/transform", "npm:28.1.1"],\ ["@types/babel__core", "npm:7.1.19"],\ ["babel-plugin-istanbul", "npm:6.1.1"],\ - ["babel-preset-jest", "virtual:69afcad85ee3ceef520de4713a67f63184081671d3466f598988598cfce78bac1f55d9b4d5dd3c684eec2a872203d1fbc45527eaabcd534af3b68ca1f30a2ef7#npm:28.1.1"],\ + ["babel-preset-jest", "virtual:419439314f6ac7e6aeb104f74d9bd1fb754b552a3112b86e2807390519b56dbd6a88f32cff6e239d59a2670b389ec32d1afc7812be06ebe4cb1eeb9c2c58cf9e#npm:28.1.1"],\ ["chalk", "npm:4.1.2"],\ ["graceful-fs", "npm:4.2.10"],\ ["slash", "npm:3.0.0"]\ @@ -3357,23 +3980,23 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "HARD"\ }],\ - ["virtual:b3595376960b0f58a85ece403d96ba3ed92c88121736aa53217ef000f14277e4a0354391bb00e26aa1febc9cab06ac8495baff102062aac6d5d4d7169e19f05c#npm:1.0.1", {\ - "packageLocation": "./.yarn/__virtual__/babel-preset-current-node-syntax-virtual-fad3eb877b/0/cache/babel-preset-current-node-syntax-npm-1.0.1-849ec71e32-d118c27424.zip/node_modules/babel-preset-current-node-syntax/",\ + ["virtual:a21946f32fecc6d2a4f39c804bc6851b8c98cf267db2bb0a25b0f443fe3cf1ff67012036ab014b3ec1309cc1f0a5678c35acb443e7d8c8a0d3c29071288e53d7#npm:1.0.1", {\ + "packageLocation": "./.yarn/__virtual__/babel-preset-current-node-syntax-virtual-511f18ec47/0/cache/babel-preset-current-node-syntax-npm-1.0.1-849ec71e32-d118c27424.zip/node_modules/babel-preset-current-node-syntax/",\ "packageDependencies": [\ - ["babel-preset-current-node-syntax", "virtual:b3595376960b0f58a85ece403d96ba3ed92c88121736aa53217ef000f14277e4a0354391bb00e26aa1febc9cab06ac8495baff102062aac6d5d4d7169e19f05c#npm:1.0.1"],\ + ["babel-preset-current-node-syntax", "virtual:a21946f32fecc6d2a4f39c804bc6851b8c98cf267db2bb0a25b0f443fe3cf1ff67012036ab014b3ec1309cc1f0a5678c35acb443e7d8c8a0d3c29071288e53d7#npm:1.0.1"],\ ["@babel/core", "npm:7.18.5"],\ - ["@babel/plugin-syntax-async-generators", "virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.8.4"],\ - ["@babel/plugin-syntax-bigint", "virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.8.3"],\ - ["@babel/plugin-syntax-class-properties", "virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.12.13"],\ - ["@babel/plugin-syntax-import-meta", "virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.10.4"],\ - ["@babel/plugin-syntax-json-strings", "virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.8.3"],\ - ["@babel/plugin-syntax-logical-assignment-operators", "virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.10.4"],\ - ["@babel/plugin-syntax-nullish-coalescing-operator", "virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.8.3"],\ - ["@babel/plugin-syntax-numeric-separator", "virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.10.4"],\ - ["@babel/plugin-syntax-object-rest-spread", "virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.8.3"],\ - ["@babel/plugin-syntax-optional-catch-binding", "virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.8.3"],\ - ["@babel/plugin-syntax-optional-chaining", "virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.8.3"],\ - ["@babel/plugin-syntax-top-level-await", "virtual:fad3eb877b554bbd5c3db3a299f58e6d079ddfcaa8f4752a2f65551d566171e19dc25e8ac4cfa1e1eac4c1c8aafbd35c8c8d5b02c841778ab929dcbe743adbbf#npm:7.14.5"],\ + ["@babel/plugin-syntax-async-generators", "virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.8.4"],\ + ["@babel/plugin-syntax-bigint", "virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.8.3"],\ + ["@babel/plugin-syntax-class-properties", "virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.12.13"],\ + ["@babel/plugin-syntax-import-meta", "virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.10.4"],\ + ["@babel/plugin-syntax-json-strings", "virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.8.3"],\ + ["@babel/plugin-syntax-logical-assignment-operators", "virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.10.4"],\ + ["@babel/plugin-syntax-nullish-coalescing-operator", "virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.8.3"],\ + ["@babel/plugin-syntax-numeric-separator", "virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.10.4"],\ + ["@babel/plugin-syntax-object-rest-spread", "virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.8.3"],\ + ["@babel/plugin-syntax-optional-catch-binding", "virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.8.3"],\ + ["@babel/plugin-syntax-optional-chaining", "virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.8.3"],\ + ["@babel/plugin-syntax-top-level-await", "virtual:511f18ec4797ab42da93993c808942012470709446ef400bc600a66a60e5e382e8081da234db4fe66d41d38d9212735169fb3c604c828d1ff8dc854506ed7b48#npm:7.14.5"],\ ["@types/babel__core", "npm:7.1.19"]\ ],\ "packagePeers": [\ @@ -3391,14 +4014,14 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:69afcad85ee3ceef520de4713a67f63184081671d3466f598988598cfce78bac1f55d9b4d5dd3c684eec2a872203d1fbc45527eaabcd534af3b68ca1f30a2ef7#npm:28.1.1", {\ - "packageLocation": "./.yarn/__virtual__/babel-preset-jest-virtual-b359537696/0/cache/babel-preset-jest-npm-28.1.1-05a1e38dd1-c581a81967.zip/node_modules/babel-preset-jest/",\ + ["virtual:419439314f6ac7e6aeb104f74d9bd1fb754b552a3112b86e2807390519b56dbd6a88f32cff6e239d59a2670b389ec32d1afc7812be06ebe4cb1eeb9c2c58cf9e#npm:28.1.1", {\ + "packageLocation": "./.yarn/__virtual__/babel-preset-jest-virtual-a21946f32f/0/cache/babel-preset-jest-npm-28.1.1-05a1e38dd1-c581a81967.zip/node_modules/babel-preset-jest/",\ "packageDependencies": [\ - ["babel-preset-jest", "virtual:69afcad85ee3ceef520de4713a67f63184081671d3466f598988598cfce78bac1f55d9b4d5dd3c684eec2a872203d1fbc45527eaabcd534af3b68ca1f30a2ef7#npm:28.1.1"],\ + ["babel-preset-jest", "virtual:419439314f6ac7e6aeb104f74d9bd1fb754b552a3112b86e2807390519b56dbd6a88f32cff6e239d59a2670b389ec32d1afc7812be06ebe4cb1eeb9c2c58cf9e#npm:28.1.1"],\ ["@babel/core", "npm:7.18.5"],\ ["@types/babel__core", "npm:7.1.19"],\ ["babel-plugin-jest-hoist", "npm:28.1.1"],\ - ["babel-preset-current-node-syntax", "virtual:b3595376960b0f58a85ece403d96ba3ed92c88121736aa53217ef000f14277e4a0354391bb00e26aa1febc9cab06ac8495baff102062aac6d5d4d7169e19f05c#npm:1.0.1"]\ + ["babel-preset-current-node-syntax", "virtual:a21946f32fecc6d2a4f39c804bc6851b8c98cf267db2bb0a25b0f443fe3cf1ff67012036ab014b3ec1309cc1f0a5678c35acb443e7d8c8a0d3c29071288e53d7#npm:1.0.1"]\ ],\ "packagePeers": [\ "@babel/core",\ @@ -3425,6 +4048,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["bcryptjs", [\ + ["npm:2.4.3", {\ + "packageLocation": "./.yarn/cache/bcryptjs-npm-2.4.3-32de4957eb-0e80ed852a.zip/node_modules/bcryptjs/",\ + "packageDependencies": [\ + ["bcryptjs", "npm:2.4.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["before-after-hook", [\ ["npm:2.2.2", {\ "packageLocation": "./.yarn/cache/before-after-hook-npm-2.2.2-b463f0552f-dc2e1ffe38.zip/node_modules/before-after-hook/",\ @@ -3434,6 +4066,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["binary-extensions", [\ + ["npm:2.2.0", {\ + "packageLocation": "./.yarn/cache/binary-extensions-npm-2.2.0-180c33fec7-ccd267956c.zip/node_modules/binary-extensions/",\ + "packageDependencies": [\ + ["binary-extensions", "npm:2.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["bl", [\ ["npm:4.1.0", {\ "packageLocation": "./.yarn/cache/bl-npm-4.1.0-7f94cdcf3f-9e8521fa7e.zip/node_modules/bl/",\ @@ -3446,6 +4087,61 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["body-parser", [\ + ["npm:1.19.0", {\ + "packageLocation": "./.yarn/cache/body-parser-npm-1.19.0-6e177cabfa-490231b4c8.zip/node_modules/body-parser/",\ + "packageDependencies": [\ + ["body-parser", "npm:1.19.0"],\ + ["bytes", "npm:3.1.0"],\ + ["content-type", "npm:1.0.4"],\ + ["debug", "virtual:6e177cabfad012f413f9c41366539c04d8701f0567119998690ab02224012faa99ec3a16b9f74f4d7920ab472c12b3e70f47f8f143239c06d0e2569e60ed9f62#npm:2.6.9"],\ + ["depd", "npm:1.1.2"],\ + ["http-errors", "npm:1.7.2"],\ + ["iconv-lite", "npm:0.4.24"],\ + ["on-finished", "npm:2.3.0"],\ + ["qs", "npm:6.7.0"],\ + ["raw-body", "npm:2.4.0"],\ + ["type-is", "npm:1.6.18"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:1.20.0", {\ + "packageLocation": "./.yarn/cache/body-parser-npm-1.20.0-1820eff49a-12fffdeac8.zip/node_modules/body-parser/",\ + "packageDependencies": [\ + ["body-parser", "npm:1.20.0"],\ + ["bytes", "npm:3.1.2"],\ + ["content-type", "npm:1.0.4"],\ + ["debug", "virtual:6e177cabfad012f413f9c41366539c04d8701f0567119998690ab02224012faa99ec3a16b9f74f4d7920ab472c12b3e70f47f8f143239c06d0e2569e60ed9f62#npm:2.6.9"],\ + ["depd", "npm:2.0.0"],\ + ["destroy", "npm:1.2.0"],\ + ["http-errors", "npm:2.0.0"],\ + ["iconv-lite", "npm:0.4.24"],\ + ["on-finished", "npm:2.4.1"],\ + ["qs", "npm:6.10.3"],\ + ["raw-body", "npm:2.5.1"],\ + ["type-is", "npm:1.6.18"],\ + ["unpipe", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["boxen", [\ + ["npm:5.1.2", {\ + "packageLocation": "./.yarn/cache/boxen-npm-5.1.2-364ee34f2f-82d03e42a7.zip/node_modules/boxen/",\ + "packageDependencies": [\ + ["boxen", "npm:5.1.2"],\ + ["ansi-align", "npm:3.0.1"],\ + ["camelcase", "npm:6.3.0"],\ + ["chalk", "npm:4.1.2"],\ + ["cli-boxes", "npm:2.2.1"],\ + ["string-width", "npm:4.2.3"],\ + ["type-fest", "npm:0.20.2"],\ + ["widest-line", "npm:3.1.0"],\ + ["wrap-ansi", "npm:7.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["brace-expansion", [\ ["npm:1.1.11", {\ "packageLocation": "./.yarn/cache/brace-expansion-npm-1.1.11-fb95eb05ad-faf34a7bb0.zip/node_modules/brace-expansion/",\ @@ -3576,6 +4272,22 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["bytes", [\ + ["npm:3.1.0", {\ + "packageLocation": "./.yarn/cache/bytes-npm-3.1.0-19c5b15405-7c3b21c5d9.zip/node_modules/bytes/",\ + "packageDependencies": [\ + ["bytes", "npm:3.1.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.1.2", {\ + "packageLocation": "./.yarn/cache/bytes-npm-3.1.2-28b8643004-e4bcd3948d.zip/node_modules/bytes/",\ + "packageDependencies": [\ + ["bytes", "npm:3.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["cacache", [\ ["npm:15.3.0", {\ "packageLocation": "./.yarn/cache/cacache-npm-15.3.0-a7e5239c6a-a07327c27a.zip/node_modules/cacache/",\ @@ -3628,6 +4340,22 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["cacheable-request", [\ + ["npm:6.1.0", {\ + "packageLocation": "./.yarn/cache/cacheable-request-npm-6.1.0-684b834873-b510b237b1.zip/node_modules/cacheable-request/",\ + "packageDependencies": [\ + ["cacheable-request", "npm:6.1.0"],\ + ["clone-response", "npm:1.0.2"],\ + ["get-stream", "npm:5.2.0"],\ + ["http-cache-semantics", "npm:4.1.0"],\ + ["keyv", "npm:3.1.0"],\ + ["lowercase-keys", "npm:2.0.0"],\ + ["normalize-url", "npm:4.5.1"],\ + ["responselike", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["call-bind", [\ ["npm:1.0.2", {\ "packageLocation": "./.yarn/cache/call-bind-npm-1.0.2-c957124861-f8e31de9d1.zip/node_modules/call-bind/",\ @@ -3724,6 +4452,23 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["chokidar", [\ + ["npm:3.5.3", {\ + "packageLocation": "./.yarn/cache/chokidar-npm-3.5.3-c5f9b0a56a-b49fcde401.zip/node_modules/chokidar/",\ + "packageDependencies": [\ + ["chokidar", "npm:3.5.3"],\ + ["anymatch", "npm:3.1.2"],\ + ["braces", "npm:3.0.2"],\ + ["fsevents", "patch:fsevents@npm%3A2.3.2#~builtin::version=2.3.2&hash=18f3a7"],\ + ["glob-parent", "npm:5.1.2"],\ + ["is-binary-path", "npm:2.1.0"],\ + ["is-glob", "npm:4.0.3"],\ + ["normalize-path", "npm:3.0.0"],\ + ["readdirp", "npm:3.6.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["chownr", [\ ["npm:2.0.0", {\ "packageLocation": "./.yarn/cache/chownr-npm-2.0.0-638f1c9c61-c57cf9dd07.zip/node_modules/chownr/",\ @@ -3734,6 +4479,13 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["ci-info", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/ci-info-npm-2.0.0-78012236a1-3b374666a8.zip/node_modules/ci-info/",\ + "packageDependencies": [\ + ["ci-info", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:3.3.2", {\ "packageLocation": "./.yarn/cache/ci-info-npm-3.3.2-fb5617e149-fd81f1edd2.zip/node_modules/ci-info/",\ "packageDependencies": [\ @@ -3760,6 +4512,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["cli-boxes", [\ + ["npm:2.2.1", {\ + "packageLocation": "./.yarn/cache/cli-boxes-npm-2.2.1-7125a5ba44-be79f8ec23.zip/node_modules/cli-boxes/",\ + "packageDependencies": [\ + ["cli-boxes", "npm:2.2.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["cli-cursor", [\ ["npm:3.1.0", {\ "packageLocation": "./.yarn/cache/cli-cursor-npm-3.1.0-fee1e46b5e-2692784c6c.zip/node_modules/cli-cursor/",\ @@ -3836,6 +4597,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["clone-response", [\ + ["npm:1.0.2", {\ + "packageLocation": "./.yarn/cache/clone-response-npm-1.0.2-135ae8239d-2d0e61547f.zip/node_modules/clone-response/",\ + "packageDependencies": [\ + ["clone-response", "npm:1.0.2"],\ + ["mimic-response", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["cluster-key-slot", [\ ["npm:1.1.0", {\ "packageLocation": "./.yarn/cache/cluster-key-slot-npm-1.1.0-c895b3234e-fc953c7520.zip/node_modules/cluster-key-slot/",\ @@ -3928,6 +4699,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["colors", [\ + ["npm:1.4.0", {\ + "packageLocation": "./.yarn/cache/colors-npm-1.4.0-7e2cf12234-98aa2c2418.zip/node_modules/colors/",\ + "packageDependencies": [\ + ["colors", "npm:1.4.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["colorspace", [\ ["npm:1.1.4", {\ "packageLocation": "./.yarn/cache/colorspace-npm-1.1.4-f01655548a-bb3934ef3c.zip/node_modules/colorspace/",\ @@ -3994,6 +4774,21 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["configstore", [\ + ["npm:5.0.1", {\ + "packageLocation": "./.yarn/cache/configstore-npm-5.0.1-739433cdc5-60ef65d493.zip/node_modules/configstore/",\ + "packageDependencies": [\ + ["configstore", "npm:5.0.1"],\ + ["dot-prop", "npm:5.3.0"],\ + ["graceful-fs", "npm:4.2.10"],\ + ["make-dir", "npm:3.1.0"],\ + ["unique-string", "npm:2.0.0"],\ + ["write-file-atomic", "npm:3.0.3"],\ + ["xdg-basedir", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["console-control-strings", [\ ["npm:1.1.0", {\ "packageLocation": "./.yarn/cache/console-control-strings-npm-1.1.0-e3160e5275-8755d76787.zip/node_modules/console-control-strings/",\ @@ -4003,6 +4798,33 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["content-disposition", [\ + ["npm:0.5.3", {\ + "packageLocation": "./.yarn/cache/content-disposition-npm-0.5.3-9a9a567e17-95bf164c0b.zip/node_modules/content-disposition/",\ + "packageDependencies": [\ + ["content-disposition", "npm:0.5.3"],\ + ["safe-buffer", "npm:5.1.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.5.4", {\ + "packageLocation": "./.yarn/cache/content-disposition-npm-0.5.4-2d93678616-afb9d545e2.zip/node_modules/content-disposition/",\ + "packageDependencies": [\ + ["content-disposition", "npm:0.5.4"],\ + ["safe-buffer", "npm:5.2.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["content-type", [\ + ["npm:1.0.4", {\ + "packageLocation": "./.yarn/cache/content-type-npm-1.0.4-3b1a5ca16b-3d93585fda.zip/node_modules/content-type/",\ + "packageDependencies": [\ + ["content-type", "npm:1.0.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["conventional-changelog-angular", [\ ["npm:5.0.13", {\ "packageLocation": "./.yarn/cache/conventional-changelog-angular-npm-5.0.13-50e4a302c4-6ed4972fce.zip/node_modules/conventional-changelog-angular/",\ @@ -4129,6 +4951,38 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["cookie", [\ + ["npm:0.4.0", {\ + "packageLocation": "./.yarn/cache/cookie-npm-0.4.0-4b3d629e45-760384ba0a.zip/node_modules/cookie/",\ + "packageDependencies": [\ + ["cookie", "npm:0.4.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.4.2", {\ + "packageLocation": "./.yarn/cache/cookie-npm-0.4.2-7761894d5f-a00833c998.zip/node_modules/cookie/",\ + "packageDependencies": [\ + ["cookie", "npm:0.4.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.5.0", {\ + "packageLocation": "./.yarn/cache/cookie-npm-0.5.0-e2d58a161a-1f4bd2ca57.zip/node_modules/cookie/",\ + "packageDependencies": [\ + ["cookie", "npm:0.5.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["cookie-signature", [\ + ["npm:1.0.6", {\ + "packageLocation": "./.yarn/cache/cookie-signature-npm-1.0.6-93f325f7f0-f4e1b0a98a.zip/node_modules/cookie-signature/",\ + "packageDependencies": [\ + ["cookie-signature", "npm:1.0.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["core-util-is", [\ ["npm:1.0.3", {\ "packageLocation": "./.yarn/cache/core-util-is-npm-1.0.3-ca74b76c90-9de8597363.zip/node_modules/core-util-is/",\ @@ -4138,6 +4992,17 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["cors", [\ + ["npm:2.8.5", {\ + "packageLocation": "./.yarn/cache/cors-npm-2.8.5-c9935a2d12-ced838404c.zip/node_modules/cors/",\ + "packageDependencies": [\ + ["cors", "npm:2.8.5"],\ + ["object-assign", "npm:4.1.1"],\ + ["vary", "npm:1.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["cosmiconfig", [\ ["npm:7.0.1", {\ "packageLocation": "./.yarn/cache/cosmiconfig-npm-7.0.1-dd19ae2403-4be63e7117.zip/node_modules/cosmiconfig/",\ @@ -4202,6 +5067,23 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["crypto-random-string", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/crypto-random-string-npm-2.0.0-8ab47992ef-0283879f55.zip/node_modules/crypto-random-string/",\ + "packageDependencies": [\ + ["crypto-random-string", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.3.0", {\ + "packageLocation": "./.yarn/cache/crypto-random-string-npm-3.3.0-4f73472f10-deff986631.zip/node_modules/crypto-random-string/",\ + "packageDependencies": [\ + ["crypto-random-string", "npm:3.3.0"],\ + ["type-fest", "npm:0.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["dargs", [\ ["npm:7.0.0", {\ "packageLocation": "./.yarn/cache/dargs-npm-7.0.0-62701e0c7a-b8f1e3cba5.zip/node_modules/dargs/",\ @@ -4239,6 +5121,20 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["debug", [\ + ["npm:2.6.9", {\ + "packageLocation": "./.yarn/cache/debug-npm-2.6.9-7d4cb597dc-d2f51589ca.zip/node_modules/debug/",\ + "packageDependencies": [\ + ["debug", "npm:2.6.9"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["npm:3.2.7", {\ + "packageLocation": "./.yarn/cache/debug-npm-3.2.7-754e818c7a-b3d8c59407.zip/node_modules/debug/",\ + "packageDependencies": [\ + ["debug", "npm:3.2.7"]\ + ],\ + "linkType": "SOFT"\ + }],\ ["npm:4.3.4", {\ "packageLocation": "./.yarn/cache/debug-npm-4.3.4-4513954577-3dbad3f94e.zip/node_modules/debug/",\ "packageDependencies": [\ @@ -4246,6 +5142,20 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ + ["virtual:6e177cabfad012f413f9c41366539c04d8701f0567119998690ab02224012faa99ec3a16b9f74f4d7920ab472c12b3e70f47f8f143239c06d0e2569e60ed9f62#npm:2.6.9", {\ + "packageLocation": "./.yarn/__virtual__/debug-virtual-53242bdd6a/0/cache/debug-npm-2.6.9-7d4cb597dc-d2f51589ca.zip/node_modules/debug/",\ + "packageDependencies": [\ + ["debug", "virtual:6e177cabfad012f413f9c41366539c04d8701f0567119998690ab02224012faa99ec3a16b9f74f4d7920ab472c12b3e70f47f8f143239c06d0e2569e60ed9f62#npm:2.6.9"],\ + ["@types/supports-color", null],\ + ["ms", "npm:2.0.0"],\ + ["supports-color", null]\ + ],\ + "packagePeers": [\ + "@types/supports-color",\ + "supports-color"\ + ],\ + "linkType": "HARD"\ + }],\ ["virtual:b86a9fb34323a98c6519528ed55faa0d9b44ca8879307c0b29aa384bde47ff59a7d0c9051b31246f14521dfb71ba3c5d6d0b35c29fffc17bf875aa6ad977d9e8#npm:4.3.4", {\ "packageLocation": "./.yarn/__virtual__/debug-virtual-4488998e89/0/cache/debug-npm-4.3.4-4513954577-3dbad3f94e.zip/node_modules/debug/",\ "packageDependencies": [\ @@ -4259,6 +5169,20 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "supports-color"\ ],\ "linkType": "HARD"\ + }],\ + ["virtual:f564cd587f82296d3fd6026dfab3e339413babae6e81b9c38de9addd7cd419ff4ad05c2c7d821d4792f5d97254f1f8a10edadcbab7fc3eef777350e5087c47c4#npm:3.2.7", {\ + "packageLocation": "./.yarn/__virtual__/debug-virtual-66717e1c5e/0/cache/debug-npm-3.2.7-754e818c7a-b3d8c59407.zip/node_modules/debug/",\ + "packageDependencies": [\ + ["debug", "virtual:f564cd587f82296d3fd6026dfab3e339413babae6e81b9c38de9addd7cd419ff4ad05c2c7d821d4792f5d97254f1f8a10edadcbab7fc3eef777350e5087c47c4#npm:3.2.7"],\ + ["@types/supports-color", null],\ + ["ms", "npm:2.1.3"],\ + ["supports-color", "npm:5.5.0"]\ + ],\ + "packagePeers": [\ + "@types/supports-color",\ + "supports-color"\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["decamelize", [\ @@ -4290,6 +5214,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["decompress-response", [\ + ["npm:3.3.0", {\ + "packageLocation": "./.yarn/cache/decompress-response-npm-3.3.0-6e7b6375c3-952552ac3b.zip/node_modules/decompress-response/",\ + "packageDependencies": [\ + ["decompress-response", "npm:3.3.0"],\ + ["mimic-response", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["dedent", [\ ["npm:0.7.0", {\ "packageLocation": "./.yarn/cache/dedent-npm-0.7.0-2dbb45a4c5-87de191050.zip/node_modules/dedent/",\ @@ -4299,6 +5233,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["deep-extend", [\ + ["npm:0.6.0", {\ + "packageLocation": "./.yarn/cache/deep-extend-npm-0.6.0-e182924219-7be7e5a8d4.zip/node_modules/deep-extend/",\ + "packageDependencies": [\ + ["deep-extend", "npm:0.6.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["deep-is", [\ ["npm:0.1.4", {\ "packageLocation": "./.yarn/cache/deep-is-npm-0.1.4-88938b5a67-edb65dd0d7.zip/node_modules/deep-is/",\ @@ -4327,6 +5270,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["defer-to-connect", [\ + ["npm:1.1.3", {\ + "packageLocation": "./.yarn/cache/defer-to-connect-npm-1.1.3-5887885147-9491b301dc.zip/node_modules/defer-to-connect/",\ + "packageDependencies": [\ + ["defer-to-connect", "npm:1.1.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["delegates", [\ ["npm:1.0.0", {\ "packageLocation": "./.yarn/cache/delegates-npm-1.0.0-9b1942d75f-a51744d9b5.zip/node_modules/delegates/",\ @@ -4359,6 +5311,13 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["depd", "npm:1.1.2"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/depd-npm-2.0.0-b6c51a4b43-abbe19c768.zip/node_modules/depd/",\ + "packageDependencies": [\ + ["depd", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["deprecation", [\ @@ -4370,6 +5329,22 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["destroy", [\ + ["npm:1.0.4", {\ + "packageLocation": "./.yarn/cache/destroy-npm-1.0.4-a2203e01cb-da9ab4961d.zip/node_modules/destroy/",\ + "packageDependencies": [\ + ["destroy", "npm:1.0.4"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:1.2.0", {\ + "packageLocation": "./.yarn/cache/destroy-npm-1.2.0-6a511802e2-0acb300b74.zip/node_modules/destroy/",\ + "packageDependencies": [\ + ["destroy", "npm:1.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["detect-indent", [\ ["npm:5.0.0", {\ "packageLocation": "./.yarn/cache/detect-indent-npm-5.0.0-123fa3fd0b-61763211da.zip/node_modules/detect-indent/",\ @@ -4433,6 +5408,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["dompurify", [\ + ["npm:2.3.8", {\ + "packageLocation": "./.yarn/cache/dompurify-npm-2.3.8-c4b696b00d-dc7b32ee57.zip/node_modules/dompurify/",\ + "packageDependencies": [\ + ["dompurify", "npm:2.3.8"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["dot-prop", [\ ["npm:5.3.0", {\ "packageLocation": "./.yarn/cache/dot-prop-npm-5.3.0-7bf6ee1eb8-d577579009.zip/node_modules/dot-prop/",\ @@ -4468,6 +5452,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["duplexer3", [\ + ["npm:0.1.4", {\ + "packageLocation": "./.yarn/cache/duplexer3-npm-0.1.4-361a33d994-c2fd696931.zip/node_modules/duplexer3/",\ + "packageDependencies": [\ + ["duplexer3", "npm:0.1.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["ecdsa-sig-formatter", [\ ["npm:1.0.11", {\ "packageLocation": "./.yarn/cache/ecdsa-sig-formatter-npm-1.0.11-b6784e7852-207f9ab1c2.zip/node_modules/ecdsa-sig-formatter/",\ @@ -4478,6 +5471,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["ee-first", [\ + ["npm:1.1.1", {\ + "packageLocation": "./.yarn/cache/ee-first-npm-1.1.1-33f8535b39-1b4cac778d.zip/node_modules/ee-first/",\ + "packageDependencies": [\ + ["ee-first", "npm:1.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["electron-to-chromium", [\ ["npm:1.4.161", {\ "packageLocation": "./.yarn/cache/electron-to-chromium-npm-1.4.161-30baaf5e01-a14137543f.zip/node_modules/electron-to-chromium/",\ @@ -4514,6 +5516,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["encodeurl", [\ + ["npm:1.0.2", {\ + "packageLocation": "./.yarn/cache/encodeurl-npm-1.0.2-f8c8454c41-e50e3d508c.zip/node_modules/encodeurl/",\ + "packageDependencies": [\ + ["encodeurl", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["encoding", [\ ["npm:0.1.13", {\ "packageLocation": "./.yarn/cache/encoding-npm-0.1.13-82a1837d30-bb98632f8f.zip/node_modules/encoding/",\ @@ -4524,6 +5535,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["end-of-stream", [\ + ["npm:1.4.4", {\ + "packageLocation": "./.yarn/cache/end-of-stream-npm-1.4.4-497fc6dee1-530a5a5a1e.zip/node_modules/end-of-stream/",\ + "packageDependencies": [\ + ["end-of-stream", "npm:1.4.4"],\ + ["once", "npm:1.4.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["env-paths", [\ ["npm:2.2.1", {\ "packageLocation": "./.yarn/cache/env-paths-npm-2.2.1-7c7577428c-65b5df55a8.zip/node_modules/env-paths/",\ @@ -4570,6 +5591,24 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["escape-goat", [\ + ["npm:2.1.1", {\ + "packageLocation": "./.yarn/cache/escape-goat-npm-2.1.1-2e437cf3fe-ce05c70c20.zip/node_modules/escape-goat/",\ + "packageDependencies": [\ + ["escape-goat", "npm:2.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["escape-html", [\ + ["npm:1.0.3", {\ + "packageLocation": "./.yarn/cache/escape-html-npm-1.0.3-376c22ee74-6213ca9ae0.zip/node_modules/escape-html/",\ + "packageDependencies": [\ + ["escape-html", "npm:1.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["escape-string-regexp", [\ ["npm:1.0.5", {\ "packageLocation": "./.yarn/cache/escape-string-regexp-npm-1.0.5-3284de402f-6092fda75c.zip/node_modules/escape-string-regexp/",\ @@ -4688,6 +5727,28 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "prettier"\ ],\ "linkType": "HARD"\ + }],\ + ["virtual:31b5a94a105c89c9294c3d524a7f8929fe63ee5a2efadf21951ca4c0cfd2ecf02e8f4ef5a066bbda091f1e3a56e57c6749069a080618c96b22e51131a330fc4a#npm:4.0.0", {\ + "packageLocation": "./.yarn/__virtual__/eslint-plugin-prettier-virtual-5c96b2a218/0/cache/eslint-plugin-prettier-npm-4.0.0-e632552861-03d69177a3.zip/node_modules/eslint-plugin-prettier/",\ + "packageDependencies": [\ + ["eslint-plugin-prettier", "virtual:31b5a94a105c89c9294c3d524a7f8929fe63ee5a2efadf21951ca4c0cfd2ecf02e8f4ef5a066bbda091f1e3a56e57c6749069a080618c96b22e51131a330fc4a#npm:4.0.0"],\ + ["@types/eslint", null],\ + ["@types/eslint-config-prettier", null],\ + ["@types/prettier", null],\ + ["eslint", "npm:8.18.0"],\ + ["eslint-config-prettier", null],\ + ["prettier", null],\ + ["prettier-linter-helpers", "npm:1.0.0"]\ + ],\ + "packagePeers": [\ + "@types/eslint-config-prettier",\ + "@types/eslint",\ + "@types/prettier",\ + "eslint-config-prettier",\ + "eslint",\ + "prettier"\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["eslint-scope", [\ @@ -4829,6 +5890,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["etag", [\ + ["npm:1.8.1", {\ + "packageLocation": "./.yarn/cache/etag-npm-1.8.1-54a3b989d9-571aeb3dbe.zip/node_modules/etag/",\ + "packageDependencies": [\ + ["etag", "npm:1.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["eventemitter3", [\ ["npm:4.0.7", {\ "packageLocation": "./.yarn/cache/eventemitter3-npm-4.0.7-7afcdd74ae-1875311c42.zip/node_modules/eventemitter3/",\ @@ -4888,6 +5958,83 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["express", [\ + ["npm:4.17.1", {\ + "packageLocation": "./.yarn/cache/express-npm-4.17.1-6815ee6bf9-d964e9e17a.zip/node_modules/express/",\ + "packageDependencies": [\ + ["express", "npm:4.17.1"],\ + ["accepts", "npm:1.3.8"],\ + ["array-flatten", "npm:1.1.1"],\ + ["body-parser", "npm:1.19.0"],\ + ["content-disposition", "npm:0.5.3"],\ + ["content-type", "npm:1.0.4"],\ + ["cookie", "npm:0.4.0"],\ + ["cookie-signature", "npm:1.0.6"],\ + ["debug", "virtual:6e177cabfad012f413f9c41366539c04d8701f0567119998690ab02224012faa99ec3a16b9f74f4d7920ab472c12b3e70f47f8f143239c06d0e2569e60ed9f62#npm:2.6.9"],\ + ["depd", "npm:1.1.2"],\ + ["encodeurl", "npm:1.0.2"],\ + ["escape-html", "npm:1.0.3"],\ + ["etag", "npm:1.8.1"],\ + ["finalhandler", "npm:1.1.2"],\ + ["fresh", "npm:0.5.2"],\ + ["merge-descriptors", "npm:1.0.1"],\ + ["methods", "npm:1.1.2"],\ + ["on-finished", "npm:2.3.0"],\ + ["parseurl", "npm:1.3.3"],\ + ["path-to-regexp", "npm:0.1.7"],\ + ["proxy-addr", "npm:2.0.7"],\ + ["qs", "npm:6.7.0"],\ + ["range-parser", "npm:1.2.1"],\ + ["safe-buffer", "npm:5.1.2"],\ + ["send", "npm:0.17.1"],\ + ["serve-static", "npm:1.14.1"],\ + ["setprototypeof", "npm:1.1.1"],\ + ["statuses", "npm:1.5.0"],\ + ["type-is", "npm:1.6.18"],\ + ["utils-merge", "npm:1.0.1"],\ + ["vary", "npm:1.1.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.18.1", {\ + "packageLocation": "./.yarn/cache/express-npm-4.18.1-842e583ae1-c3d44c92e4.zip/node_modules/express/",\ + "packageDependencies": [\ + ["express", "npm:4.18.1"],\ + ["accepts", "npm:1.3.8"],\ + ["array-flatten", "npm:1.1.1"],\ + ["body-parser", "npm:1.20.0"],\ + ["content-disposition", "npm:0.5.4"],\ + ["content-type", "npm:1.0.4"],\ + ["cookie", "npm:0.5.0"],\ + ["cookie-signature", "npm:1.0.6"],\ + ["debug", "virtual:6e177cabfad012f413f9c41366539c04d8701f0567119998690ab02224012faa99ec3a16b9f74f4d7920ab472c12b3e70f47f8f143239c06d0e2569e60ed9f62#npm:2.6.9"],\ + ["depd", "npm:2.0.0"],\ + ["encodeurl", "npm:1.0.2"],\ + ["escape-html", "npm:1.0.3"],\ + ["etag", "npm:1.8.1"],\ + ["finalhandler", "npm:1.2.0"],\ + ["fresh", "npm:0.5.2"],\ + ["http-errors", "npm:2.0.0"],\ + ["merge-descriptors", "npm:1.0.1"],\ + ["methods", "npm:1.1.2"],\ + ["on-finished", "npm:2.4.1"],\ + ["parseurl", "npm:1.3.3"],\ + ["path-to-regexp", "npm:0.1.7"],\ + ["proxy-addr", "npm:2.0.7"],\ + ["qs", "npm:6.10.3"],\ + ["range-parser", "npm:1.2.1"],\ + ["safe-buffer", "npm:5.2.1"],\ + ["send", "npm:0.18.0"],\ + ["serve-static", "npm:1.15.0"],\ + ["setprototypeof", "npm:1.2.0"],\ + ["statuses", "npm:2.0.1"],\ + ["type-is", "npm:1.6.18"],\ + ["utils-merge", "npm:1.0.1"],\ + ["vary", "npm:1.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["external-editor", [\ ["npm:3.1.0", {\ "packageLocation": "./.yarn/cache/external-editor-npm-3.1.0-878e7807af-1c2a616a73.zip/node_modules/external-editor/",\ @@ -5018,6 +6165,36 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["finalhandler", [\ + ["npm:1.1.2", {\ + "packageLocation": "./.yarn/cache/finalhandler-npm-1.1.2-55a75d6b53-617880460c.zip/node_modules/finalhandler/",\ + "packageDependencies": [\ + ["finalhandler", "npm:1.1.2"],\ + ["debug", "virtual:6e177cabfad012f413f9c41366539c04d8701f0567119998690ab02224012faa99ec3a16b9f74f4d7920ab472c12b3e70f47f8f143239c06d0e2569e60ed9f62#npm:2.6.9"],\ + ["encodeurl", "npm:1.0.2"],\ + ["escape-html", "npm:1.0.3"],\ + ["on-finished", "npm:2.3.0"],\ + ["parseurl", "npm:1.3.3"],\ + ["statuses", "npm:1.5.0"],\ + ["unpipe", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:1.2.0", {\ + "packageLocation": "./.yarn/cache/finalhandler-npm-1.2.0-593d001463-92effbfd32.zip/node_modules/finalhandler/",\ + "packageDependencies": [\ + ["finalhandler", "npm:1.2.0"],\ + ["debug", "virtual:6e177cabfad012f413f9c41366539c04d8701f0567119998690ab02224012faa99ec3a16b9f74f4d7920ab472c12b3e70f47f8f143239c06d0e2569e60ed9f62#npm:2.6.9"],\ + ["encodeurl", "npm:1.0.2"],\ + ["escape-html", "npm:1.0.3"],\ + ["on-finished", "npm:2.4.1"],\ + ["parseurl", "npm:1.3.3"],\ + ["statuses", "npm:2.0.1"],\ + ["unpipe", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["find-up", [\ ["npm:2.1.0", {\ "packageLocation": "./.yarn/cache/find-up-npm-2.1.0-9f6cb1765c-43284fe4da.zip/node_modules/find-up/",\ @@ -5075,6 +6252,46 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["follow-redirects", [\ + ["npm:1.15.1", {\ + "packageLocation": "./.yarn/cache/follow-redirects-npm-1.15.1-6b191885cd-6aa4e3e3cd.zip/node_modules/follow-redirects/",\ + "packageDependencies": [\ + ["follow-redirects", "npm:1.15.1"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:39e5c1e79ea63134f0cf339f4463df92854aaf708a45210afd29a0b4b9f67f95b34a1abbcabaae6d0033ad99a1d5f690ab51ed8e5d3283b87ccbc3a9ab3ec05f#npm:1.15.1", {\ + "packageLocation": "./.yarn/__virtual__/follow-redirects-virtual-acb5554ef1/0/cache/follow-redirects-npm-1.15.1-6b191885cd-6aa4e3e3cd.zip/node_modules/follow-redirects/",\ + "packageDependencies": [\ + ["follow-redirects", "virtual:39e5c1e79ea63134f0cf339f4463df92854aaf708a45210afd29a0b4b9f67f95b34a1abbcabaae6d0033ad99a1d5f690ab51ed8e5d3283b87ccbc3a9ab3ec05f#npm:1.15.1"],\ + ["@types/debug", null],\ + ["debug", null]\ + ],\ + "packagePeers": [\ + "@types/debug",\ + "debug"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["forwarded", [\ + ["npm:0.2.0", {\ + "packageLocation": "./.yarn/cache/forwarded-npm-0.2.0-6473dabe35-fd27e2394d.zip/node_modules/forwarded/",\ + "packageDependencies": [\ + ["forwarded", "npm:0.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["fresh", [\ + ["npm:0.5.2", {\ + "packageLocation": "./.yarn/cache/fresh-npm-0.5.2-ad2bb4c0a2-13ea8b08f9.zip/node_modules/fresh/",\ + "packageDependencies": [\ + ["fresh", "npm:0.5.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["fs-extra", [\ ["npm:10.1.0", {\ "packageLocation": "./.yarn/cache/fs-extra-npm-10.1.0-86573680ed-dc94ab3709.zip/node_modules/fs-extra/",\ @@ -5214,6 +6431,22 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["get-stream", [\ + ["npm:4.1.0", {\ + "packageLocation": "./.yarn/cache/get-stream-npm-4.1.0-314d430a5d-443e191417.zip/node_modules/get-stream/",\ + "packageDependencies": [\ + ["get-stream", "npm:4.1.0"],\ + ["pump", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:5.2.0", {\ + "packageLocation": "./.yarn/cache/get-stream-npm-5.2.0-2cfd3b452b-8bc1a23174.zip/node_modules/get-stream/",\ + "packageDependencies": [\ + ["get-stream", "npm:5.2.0"],\ + ["pump", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:6.0.1", {\ "packageLocation": "./.yarn/cache/get-stream-npm-6.0.1-83e51a4642-e04ecece32.zip/node_modules/get-stream/",\ "packageDependencies": [\ @@ -5342,6 +6575,14 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["ini", "npm:1.3.8"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/global-dirs-npm-3.0.0-45faebeb68-953c17cf14.zip/node_modules/global-dirs/",\ + "packageDependencies": [\ + ["global-dirs", "npm:3.0.0"],\ + ["ini", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["globals", [\ @@ -5376,6 +6617,28 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["got", [\ + ["npm:9.6.0", {\ + "packageLocation": "./.yarn/cache/got-npm-9.6.0-80edc15fd0-941807bd97.zip/node_modules/got/",\ + "packageDependencies": [\ + ["got", "npm:9.6.0"],\ + ["@sindresorhus/is", "npm:0.14.0"],\ + ["@szmarczak/http-timer", "npm:1.1.2"],\ + ["@types/keyv", "npm:3.1.4"],\ + ["@types/responselike", "npm:1.0.0"],\ + ["cacheable-request", "npm:6.1.0"],\ + ["decompress-response", "npm:3.3.0"],\ + ["duplexer3", "npm:0.1.4"],\ + ["get-stream", "npm:4.1.0"],\ + ["lowercase-keys", "npm:1.0.1"],\ + ["mimic-response", "npm:1.0.1"],\ + ["p-cancelable", "npm:1.1.0"],\ + ["to-readable-stream", "npm:1.0.0"],\ + ["url-parse-lax", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["graceful-fs", [\ ["npm:4.2.10", {\ "packageLocation": "./.yarn/cache/graceful-fs-npm-4.2.10-79c70989ca-3f109d70ae.zip/node_modules/graceful-fs/",\ @@ -5452,6 +6715,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["has-yarn", [\ + ["npm:2.1.0", {\ + "packageLocation": "./.yarn/cache/has-yarn-npm-2.1.0-b73f6750d9-5eb1d0bb85.zip/node_modules/has-yarn/",\ + "packageDependencies": [\ + ["has-yarn", "npm:2.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["highlight.js", [\ ["npm:10.7.3", {\ "packageLocation": "./.yarn/cache/highlight.js-npm-10.7.3-247e67d5c0-defeafcd54.zip/node_modules/highlight.js/",\ @@ -5504,6 +6776,44 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["http-errors", [\ + ["npm:1.7.2", {\ + "packageLocation": "./.yarn/cache/http-errors-npm-1.7.2-67163ae1df-5534b0ae08.zip/node_modules/http-errors/",\ + "packageDependencies": [\ + ["http-errors", "npm:1.7.2"],\ + ["depd", "npm:1.1.2"],\ + ["inherits", "npm:2.0.3"],\ + ["setprototypeof", "npm:1.1.1"],\ + ["statuses", "npm:1.5.0"],\ + ["toidentifier", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:1.7.3", {\ + "packageLocation": "./.yarn/cache/http-errors-npm-1.7.3-f6dc83b082-a59f359473.zip/node_modules/http-errors/",\ + "packageDependencies": [\ + ["http-errors", "npm:1.7.3"],\ + ["depd", "npm:1.1.2"],\ + ["inherits", "npm:2.0.4"],\ + ["setprototypeof", "npm:1.1.1"],\ + ["statuses", "npm:1.5.0"],\ + ["toidentifier", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/http-errors-npm-2.0.0-3f1c503428-9b0a378266.zip/node_modules/http-errors/",\ + "packageDependencies": [\ + ["http-errors", "npm:2.0.0"],\ + ["depd", "npm:2.0.0"],\ + ["inherits", "npm:2.0.4"],\ + ["setprototypeof", "npm:1.2.0"],\ + ["statuses", "npm:2.0.1"],\ + ["toidentifier", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["http-proxy-agent", [\ ["npm:4.0.1", {\ "packageLocation": "./.yarn/cache/http-proxy-agent-npm-4.0.1-ce9ef61788-c6a5da5a19.zip/node_modules/http-proxy-agent/",\ @@ -5526,6 +6836,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["http-status-codes", [\ + ["npm:2.2.0", {\ + "packageLocation": "./.yarn/cache/http-status-codes-npm-2.2.0-8d45a60399-31e1d73085.zip/node_modules/http-status-codes/",\ + "packageDependencies": [\ + ["http-status-codes", "npm:2.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["https-proxy-agent", [\ ["npm:5.0.1", {\ "packageLocation": "./.yarn/cache/https-proxy-agent-npm-5.0.1-42d65f358e-571fccdf38.zip/node_modules/https-proxy-agent/",\ @@ -5599,6 +6918,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["ignore-by-default", [\ + ["npm:1.0.1", {\ + "packageLocation": "./.yarn/cache/ignore-by-default-npm-1.0.1-78ea10bc54-441509147b.zip/node_modules/ignore-by-default/",\ + "packageDependencies": [\ + ["ignore-by-default", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["ignore-walk", [\ ["npm:5.0.1", {\ "packageLocation": "./.yarn/cache/ignore-walk-npm-5.0.1-58258fb4ca-1a4ef35174.zip/node_modules/ignore-walk/",\ @@ -5620,6 +6948,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["import-lazy", [\ + ["npm:2.1.0", {\ + "packageLocation": "./.yarn/cache/import-lazy-npm-2.1.0-b128ce6959-05294f3b9d.zip/node_modules/import-lazy/",\ + "packageDependencies": [\ + ["import-lazy", "npm:2.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["import-local", [\ ["npm:3.1.0", {\ "packageLocation": "./.yarn/cache/import-local-npm-3.1.0-8960af5e51-bfcdb63b5e.zip/node_modules/import-local/",\ @@ -5692,6 +7029,13 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["ini", "npm:1.3.8"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/ini-npm-2.0.0-28f7426761-e7aadc5fb2.zip/node_modules/ini/",\ + "packageDependencies": [\ + ["ini", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["inquirer", [\ @@ -5725,6 +7069,25 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["inversify", "npm:5.0.5"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:6.0.1", {\ + "packageLocation": "./.yarn/cache/inversify-npm-6.0.1-39ef6784da-b6c9b56ef7.zip/node_modules/inversify/",\ + "packageDependencies": [\ + ["inversify", "npm:6.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["inversify-express-utils", [\ + ["npm:6.4.3", {\ + "packageLocation": "./.yarn/cache/inversify-express-utils-npm-6.4.3-8478048fb7-4aa9a836fe.zip/node_modules/inversify-express-utils/",\ + "packageDependencies": [\ + ["inversify-express-utils", "npm:6.4.3"],\ + ["express", "npm:4.18.1"],\ + ["http-status-codes", "npm:2.2.0"],\ + ["inversify", "npm:6.0.1"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["ioredis", [\ @@ -5772,6 +7135,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["ipaddr.js", [\ + ["npm:1.9.1", {\ + "packageLocation": "./.yarn/cache/ipaddr.js-npm-1.9.1-19ae7878b4-f88d382598.zip/node_modules/ipaddr.js/",\ + "packageDependencies": [\ + ["ipaddr.js", "npm:1.9.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["is-arrayish", [\ ["npm:0.2.1", {\ "packageLocation": "./.yarn/cache/is-arrayish-npm-0.2.1-23927dfb15-eef4417e3c.zip/node_modules/is-arrayish/",\ @@ -5788,7 +7160,25 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["is-binary-path", [\ + ["npm:2.1.0", {\ + "packageLocation": "./.yarn/cache/is-binary-path-npm-2.1.0-e61d46f557-84192eb88c.zip/node_modules/is-binary-path/",\ + "packageDependencies": [\ + ["is-binary-path", "npm:2.1.0"],\ + ["binary-extensions", "npm:2.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["is-ci", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/is-ci-npm-2.0.0-8662a0f445-77b8690575.zip/node_modules/is-ci/",\ + "packageDependencies": [\ + ["is-ci", "npm:2.0.0"],\ + ["ci-info", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:3.0.1", {\ "packageLocation": "./.yarn/cache/is-ci-npm-3.0.1-d9aea361e1-192c66dc78.zip/node_modules/is-ci/",\ "packageDependencies": [\ @@ -5845,6 +7235,17 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["is-installed-globally", [\ + ["npm:0.4.0", {\ + "packageLocation": "./.yarn/cache/is-installed-globally-npm-0.4.0-a30dd056c7-3359840d59.zip/node_modules/is-installed-globally/",\ + "packageDependencies": [\ + ["is-installed-globally", "npm:0.4.0"],\ + ["global-dirs", "npm:3.0.0"],\ + ["is-path-inside", "npm:3.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["is-interactive", [\ ["npm:1.0.0", {\ "packageLocation": "./.yarn/cache/is-interactive-npm-1.0.0-7ff7c6e04a-824808776e.zip/node_modules/is-interactive/",\ @@ -5863,6 +7264,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["is-npm", [\ + ["npm:5.0.0", {\ + "packageLocation": "./.yarn/cache/is-npm-npm-5.0.0-2758bcd54b-9baff02b0c.zip/node_modules/is-npm/",\ + "packageDependencies": [\ + ["is-npm", "npm:5.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["is-number", [\ ["npm:7.0.0", {\ "packageLocation": "./.yarn/cache/is-number-npm-7.0.0-060086935c-456ac6f8e0.zip/node_modules/is-number/",\ @@ -5881,6 +7291,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["is-path-inside", [\ + ["npm:3.0.3", {\ + "packageLocation": "./.yarn/cache/is-path-inside-npm-3.0.3-2ea0ef44fd-abd50f0618.zip/node_modules/is-path-inside/",\ + "packageDependencies": [\ + ["is-path-inside", "npm:3.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["is-plain-obj", [\ ["npm:1.1.0", {\ "packageLocation": "./.yarn/cache/is-plain-obj-npm-1.1.0-1046f64c0b-0ee0480779.zip/node_modules/is-plain-obj/",\ @@ -5970,6 +7389,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["is-yarn-global", [\ + ["npm:0.3.0", {\ + "packageLocation": "./.yarn/cache/is-yarn-global-npm-0.3.0-18cad00879-bca013d65f.zip/node_modules/is-yarn-global/",\ + "packageDependencies": [\ + ["is-yarn-global", "npm:0.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["isarray", [\ ["npm:1.0.0", {\ "packageLocation": "./.yarn/cache/isarray-npm-1.0.0-db4f547720-f032df8e02.zip/node_modules/isarray/",\ @@ -6063,15 +7491,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:16bfd8597041deb71e4581ea0755edd4dcd1b09b8ab14bfbbf5e4d5ca6b5d47ed7fbe2a25cdf57fcbb8e092c30b6beb93d2e7533f9e31c5dc62f7f0e487d1e4b#npm:28.1.1", {\ - "packageLocation": "./.yarn/__virtual__/jest-virtual-7b65acbdaf/0/cache/jest-npm-28.1.1-a4158efd82-398a143d9e.zip/node_modules/jest/",\ + ["virtual:31b5a94a105c89c9294c3d524a7f8929fe63ee5a2efadf21951ca4c0cfd2ecf02e8f4ef5a066bbda091f1e3a56e57c6749069a080618c96b22e51131a330fc4a#npm:28.1.1", {\ + "packageLocation": "./.yarn/__virtual__/jest-virtual-6e20fd2eaa/0/cache/jest-npm-28.1.1-a4158efd82-398a143d9e.zip/node_modules/jest/",\ "packageDependencies": [\ - ["jest", "virtual:16bfd8597041deb71e4581ea0755edd4dcd1b09b8ab14bfbbf5e4d5ca6b5d47ed7fbe2a25cdf57fcbb8e092c30b6beb93d2e7533f9e31c5dc62f7f0e487d1e4b#npm:28.1.1"],\ - ["@jest/core", "virtual:7b65acbdafa6770bdc2e104f1dd40dd64f2ab1944022ee39b8545f85c0a580e21a83af0627735c515bc4d759c96351d0028fa55a8e70f06b6491dc84bf1e68a5#npm:28.1.1"],\ + ["jest", "virtual:31b5a94a105c89c9294c3d524a7f8929fe63ee5a2efadf21951ca4c0cfd2ecf02e8f4ef5a066bbda091f1e3a56e57c6749069a080618c96b22e51131a330fc4a#npm:28.1.1"],\ + ["@jest/core", "virtual:6e20fd2eaaa940ccda315da6252a82baa2918f5ea3c40e2d7cb4d97f01b503d35a5076b4b63a33762fb1174e73a3313072cadf65e4a26d1b33660f964eda7880#npm:28.1.1"],\ ["@jest/types", "npm:28.1.1"],\ ["@types/node-notifier", null],\ ["import-local", "npm:3.1.0"],\ - ["jest-cli", "virtual:7b65acbdafa6770bdc2e104f1dd40dd64f2ab1944022ee39b8545f85c0a580e21a83af0627735c515bc4d759c96351d0028fa55a8e70f06b6491dc84bf1e68a5#npm:28.1.1"],\ + ["jest-cli", "virtual:6e20fd2eaaa940ccda315da6252a82baa2918f5ea3c40e2d7cb4d97f01b503d35a5076b4b63a33762fb1174e73a3313072cadf65e4a26d1b33660f964eda7880#npm:28.1.1"],\ ["node-notifier", null]\ ],\ "packagePeers": [\ @@ -6128,11 +7556,11 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:7b65acbdafa6770bdc2e104f1dd40dd64f2ab1944022ee39b8545f85c0a580e21a83af0627735c515bc4d759c96351d0028fa55a8e70f06b6491dc84bf1e68a5#npm:28.1.1", {\ - "packageLocation": "./.yarn/__virtual__/jest-cli-virtual-5c0b48fcd9/0/cache/jest-cli-npm-28.1.1-7fb5826ae7-fce96f2f0c.zip/node_modules/jest-cli/",\ + ["virtual:6e20fd2eaaa940ccda315da6252a82baa2918f5ea3c40e2d7cb4d97f01b503d35a5076b4b63a33762fb1174e73a3313072cadf65e4a26d1b33660f964eda7880#npm:28.1.1", {\ + "packageLocation": "./.yarn/__virtual__/jest-cli-virtual-18fea92b00/0/cache/jest-cli-npm-28.1.1-7fb5826ae7-fce96f2f0c.zip/node_modules/jest-cli/",\ "packageDependencies": [\ - ["jest-cli", "virtual:7b65acbdafa6770bdc2e104f1dd40dd64f2ab1944022ee39b8545f85c0a580e21a83af0627735c515bc4d759c96351d0028fa55a8e70f06b6491dc84bf1e68a5#npm:28.1.1"],\ - ["@jest/core", "virtual:7b65acbdafa6770bdc2e104f1dd40dd64f2ab1944022ee39b8545f85c0a580e21a83af0627735c515bc4d759c96351d0028fa55a8e70f06b6491dc84bf1e68a5#npm:28.1.1"],\ + ["jest-cli", "virtual:6e20fd2eaaa940ccda315da6252a82baa2918f5ea3c40e2d7cb4d97f01b503d35a5076b4b63a33762fb1174e73a3313072cadf65e4a26d1b33660f964eda7880#npm:28.1.1"],\ + ["@jest/core", "virtual:6e20fd2eaaa940ccda315da6252a82baa2918f5ea3c40e2d7cb4d97f01b503d35a5076b4b63a33762fb1174e73a3313072cadf65e4a26d1b33660f964eda7880#npm:28.1.1"],\ ["@jest/test-result", "npm:28.1.1"],\ ["@jest/types", "npm:28.1.1"],\ ["@types/node-notifier", null],\ @@ -6140,7 +7568,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["exit", "npm:0.1.2"],\ ["graceful-fs", "npm:4.2.10"],\ ["import-local", "npm:3.1.0"],\ - ["jest-config", "virtual:5c0b48fcd900fbd877bb3869d64df30398fa72d1f862bbf691ed871cbaa65ce9e576424bfaf97747293d5524e8c23f0b0faa45c931e148de3ee435ddc77dcff0#npm:28.1.1"],\ + ["jest-config", "virtual:18fea92b00a9a17809e3136cba934f07b76b6365d781cde6e4e8ad39518603c42b210c61bd96fbdefd3c18ea76e15da85ec1d250fdb153b485bb120f2884a87d#npm:28.1.1"],\ ["jest-util", "npm:28.1.1"],\ ["jest-validate", "npm:28.1.1"],\ ["node-notifier", null],\ @@ -6162,16 +7590,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:5c0b48fcd900fbd877bb3869d64df30398fa72d1f862bbf691ed871cbaa65ce9e576424bfaf97747293d5524e8c23f0b0faa45c931e148de3ee435ddc77dcff0#npm:28.1.1", {\ - "packageLocation": "./.yarn/__virtual__/jest-config-virtual-3b285c01fb/0/cache/jest-config-npm-28.1.1-8c4e855059-8ce9f6b8f6.zip/node_modules/jest-config/",\ + ["virtual:18fea92b00a9a17809e3136cba934f07b76b6365d781cde6e4e8ad39518603c42b210c61bd96fbdefd3c18ea76e15da85ec1d250fdb153b485bb120f2884a87d#npm:28.1.1", {\ + "packageLocation": "./.yarn/__virtual__/jest-config-virtual-56145f3e40/0/cache/jest-config-npm-28.1.1-8c4e855059-8ce9f6b8f6.zip/node_modules/jest-config/",\ "packageDependencies": [\ - ["jest-config", "virtual:5c0b48fcd900fbd877bb3869d64df30398fa72d1f862bbf691ed871cbaa65ce9e576424bfaf97747293d5524e8c23f0b0faa45c931e148de3ee435ddc77dcff0#npm:28.1.1"],\ + ["jest-config", "virtual:18fea92b00a9a17809e3136cba934f07b76b6365d781cde6e4e8ad39518603c42b210c61bd96fbdefd3c18ea76e15da85ec1d250fdb153b485bb120f2884a87d#npm:28.1.1"],\ ["@babel/core", "npm:7.18.5"],\ ["@jest/test-sequencer", "npm:28.1.1"],\ ["@jest/types", "npm:28.1.1"],\ ["@types/node", null],\ ["@types/ts-node", null],\ - ["babel-jest", "virtual:705cb4c870e8e3eecece0e270f1edb4f1967b8ef32ae1a585c9ce11873c7277d4de1e2f798a22da1f6799240da1c0bd9532e5098d6ba00c4341d39a8fcebe4c4#npm:28.1.1"],\ + ["babel-jest", "virtual:805c813b6f046618cef5c7d6c026d202467ce267579e0c7a252be4f063439bc6f090ab5b924f50d7ae022b220d8e89e00ef15869e26244774ec68ef480e4e54d#npm:28.1.1"],\ ["chalk", "npm:4.1.2"],\ ["ci-info", "npm:3.3.2"],\ ["deepmerge", "npm:4.2.2"],\ @@ -6199,16 +7627,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "HARD"\ }],\ - ["virtual:e249a9c7b0aa2d2eaa17a2c6540425f288ca38113428d6b19664aca6bcf9a83762c6c23638d4d23d93600b97ce11f732f013eda5363fa7d75729dae295335703#npm:28.1.1", {\ - "packageLocation": "./.yarn/__virtual__/jest-config-virtual-705cb4c870/0/cache/jest-config-npm-28.1.1-8c4e855059-8ce9f6b8f6.zip/node_modules/jest-config/",\ + ["virtual:4b45c3242ed36b84511b3946081e5d3b347e0463d6e39ebfdee2ad8392eb4bd7a5761a69e4fccf0d230c488b171720ddcf381e7c249fe8f4fcdf9d4afc493b87#npm:28.1.1", {\ + "packageLocation": "./.yarn/__virtual__/jest-config-virtual-805c813b6f/0/cache/jest-config-npm-28.1.1-8c4e855059-8ce9f6b8f6.zip/node_modules/jest-config/",\ "packageDependencies": [\ - ["jest-config", "virtual:e249a9c7b0aa2d2eaa17a2c6540425f288ca38113428d6b19664aca6bcf9a83762c6c23638d4d23d93600b97ce11f732f013eda5363fa7d75729dae295335703#npm:28.1.1"],\ + ["jest-config", "virtual:4b45c3242ed36b84511b3946081e5d3b347e0463d6e39ebfdee2ad8392eb4bd7a5761a69e4fccf0d230c488b171720ddcf381e7c249fe8f4fcdf9d4afc493b87#npm:28.1.1"],\ ["@babel/core", "npm:7.18.5"],\ ["@jest/test-sequencer", "npm:28.1.1"],\ ["@jest/types", "npm:28.1.1"],\ ["@types/node", "npm:18.0.0"],\ ["@types/ts-node", null],\ - ["babel-jest", "virtual:705cb4c870e8e3eecece0e270f1edb4f1967b8ef32ae1a585c9ce11873c7277d4de1e2f798a22da1f6799240da1c0bd9532e5098d6ba00c4341d39a8fcebe4c4#npm:28.1.1"],\ + ["babel-jest", "virtual:805c813b6f046618cef5c7d6c026d202467ce267579e0c7a252be4f063439bc6f090ab5b924f50d7ae022b220d8e89e00ef15869e26244774ec68ef480e4e54d#npm:28.1.1"],\ ["chalk", "npm:4.1.2"],\ ["ci-info", "npm:3.3.2"],\ ["deepmerge", "npm:4.2.2"],\ @@ -6630,6 +8058,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["json-buffer", [\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/json-buffer-npm-3.0.0-21c267a314-0cecacb802.zip/node_modules/json-buffer/",\ + "packageDependencies": [\ + ["json-buffer", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["json-parse-better-errors", [\ ["npm:1.0.2", {\ "packageLocation": "./.yarn/cache/json-parse-better-errors-npm-1.0.2-7f37637d19-ff2b5ba2a7.zip/node_modules/json-parse-better-errors/",\ @@ -6746,6 +8183,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["keyv", [\ + ["npm:3.1.0", {\ + "packageLocation": "./.yarn/cache/keyv-npm-3.1.0-81c9ff4454-bb7e8f3acf.zip/node_modules/keyv/",\ + "packageDependencies": [\ + ["keyv", "npm:3.1.0"],\ + ["json-buffer", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["kind-of", [\ ["npm:6.0.3", {\ "packageLocation": "./.yarn/cache/kind-of-npm-6.0.3-ab15f36220-3ab01e7b1d.zip/node_modules/kind-of/",\ @@ -6773,6 +8220,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["latest-version", [\ + ["npm:5.1.0", {\ + "packageLocation": "./.yarn/cache/latest-version-npm-5.1.0-ddb9b0eb39-fbc72b071e.zip/node_modules/latest-version/",\ + "packageDependencies": [\ + ["latest-version", "npm:5.1.0"],\ + ["package-json", "npm:6.5.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["leven", [\ ["npm:3.1.0", {\ "packageLocation": "./.yarn/cache/leven-npm-3.1.0-b7697736a3-638401d534.zip/node_modules/leven/",\ @@ -7049,6 +8506,22 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["lowercase-keys", [\ + ["npm:1.0.1", {\ + "packageLocation": "./.yarn/cache/lowercase-keys-npm-1.0.1-0979e653b8-4d04502659.zip/node_modules/lowercase-keys/",\ + "packageDependencies": [\ + ["lowercase-keys", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/lowercase-keys-npm-2.0.0-1876065a32-24d7ebd56c.zip/node_modules/lowercase-keys/",\ + "packageDependencies": [\ + ["lowercase-keys", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["lru-cache", [\ ["npm:4.1.5", {\ "packageLocation": "./.yarn/cache/lru-cache-npm-4.1.5-ede304cc43-4bb4b58a36.zip/node_modules/lru-cache/",\ @@ -7075,6 +8548,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["lru_map", [\ + ["npm:0.3.3", {\ + "packageLocation": "./.yarn/cache/lru_map-npm-0.3.3-a038bb3418-ca9dd43c65.zip/node_modules/lru_map/",\ + "packageDependencies": [\ + ["lru_map", "npm:0.3.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["make-dir", [\ ["npm:2.1.0", {\ "packageLocation": "./.yarn/cache/make-dir-npm-2.1.0-1ddaf205e7-043548886b.zip/node_modules/make-dir/",\ @@ -7177,6 +8659,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["media-typer", [\ + ["npm:0.3.0", {\ + "packageLocation": "./.yarn/cache/media-typer-npm-0.3.0-8674f8f0f5-af1b38516c.zip/node_modules/media-typer/",\ + "packageDependencies": [\ + ["media-typer", "npm:0.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["meow", [\ ["npm:8.1.2", {\ "packageLocation": "./.yarn/cache/meow-npm-8.1.2-bcfe48d4f3-bc23bf1b44.zip/node_modules/meow/",\ @@ -7197,6 +8688,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["merge-descriptors", [\ + ["npm:1.0.1", {\ + "packageLocation": "./.yarn/cache/merge-descriptors-npm-1.0.1-615287aaa8-5abc259d2a.zip/node_modules/merge-descriptors/",\ + "packageDependencies": [\ + ["merge-descriptors", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["merge-stream", [\ ["npm:2.0.0", {\ "packageLocation": "./.yarn/cache/merge-stream-npm-2.0.0-2ac83efea5-6fa4dcc8d8.zip/node_modules/merge-stream/",\ @@ -7215,6 +8715,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["methods", [\ + ["npm:1.1.2", {\ + "packageLocation": "./.yarn/cache/methods-npm-1.1.2-92f6fdb39b-0917ff4041.zip/node_modules/methods/",\ + "packageDependencies": [\ + ["methods", "npm:1.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["micromatch", [\ ["npm:4.0.5", {\ "packageLocation": "./.yarn/cache/micromatch-npm-4.0.5-cfab5d7669-02a17b671c.zip/node_modules/micromatch/",\ @@ -7238,6 +8747,34 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["mime", [\ + ["npm:1.6.0", {\ + "packageLocation": "./.yarn/cache/mime-npm-1.6.0-60ae95038a-fef25e3926.zip/node_modules/mime/",\ + "packageDependencies": [\ + ["mime", "npm:1.6.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["mime-db", [\ + ["npm:1.52.0", {\ + "packageLocation": "./.yarn/cache/mime-db-npm-1.52.0-b5371d6fd2-0d99a03585.zip/node_modules/mime-db/",\ + "packageDependencies": [\ + ["mime-db", "npm:1.52.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["mime-types", [\ + ["npm:2.1.35", {\ + "packageLocation": "./.yarn/cache/mime-types-npm-2.1.35-dd9ea9f3e2-89a5b7f1de.zip/node_modules/mime-types/",\ + "packageDependencies": [\ + ["mime-types", "npm:2.1.35"],\ + ["mime-db", "npm:1.52.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["mimic-fn", [\ ["npm:2.1.0", {\ "packageLocation": "./.yarn/cache/mimic-fn-npm-2.1.0-4fbeb3abb4-d2421a3444.zip/node_modules/mimic-fn/",\ @@ -7247,6 +8784,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["mimic-response", [\ + ["npm:1.0.1", {\ + "packageLocation": "./.yarn/cache/mimic-response-npm-1.0.1-f6f85dde84-034c78753b.zip/node_modules/mimic-response/",\ + "packageDependencies": [\ + ["mimic-response", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["min-indent", [\ ["npm:1.0.1", {\ "packageLocation": "./.yarn/cache/min-indent-npm-1.0.1-77031f50e1-bfc6dd03c5.zip/node_modules/min-indent/",\ @@ -7410,6 +8956,20 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["ms", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/ms-npm-2.0.0-9e1101a471-0e6a22b8b7.zip/node_modules/ms/",\ + "packageDependencies": [\ + ["ms", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.1.1", {\ + "packageLocation": "./.yarn/cache/ms-npm-2.1.1-5b4fd72c86-0078a23cd9.zip/node_modules/ms/",\ + "packageDependencies": [\ + ["ms", "npm:2.1.1"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:2.1.2", {\ "packageLocation": "./.yarn/cache/ms-npm-2.1.2-ec0c1512ff-673cdb2c31.zip/node_modules/ms/",\ "packageDependencies": [\ @@ -7663,7 +9223,34 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["nodemon", [\ + ["npm:2.0.16", {\ + "packageLocation": "./.yarn/unplugged/nodemon-npm-2.0.16-f564cd587f/node_modules/nodemon/",\ + "packageDependencies": [\ + ["nodemon", "npm:2.0.16"],\ + ["chokidar", "npm:3.5.3"],\ + ["debug", "virtual:f564cd587f82296d3fd6026dfab3e339413babae6e81b9c38de9addd7cd419ff4ad05c2c7d821d4792f5d97254f1f8a10edadcbab7fc3eef777350e5087c47c4#npm:3.2.7"],\ + ["ignore-by-default", "npm:1.0.1"],\ + ["minimatch", "npm:3.1.2"],\ + ["pstree.remy", "npm:1.1.8"],\ + ["semver", "npm:5.7.1"],\ + ["supports-color", "npm:5.5.0"],\ + ["touch", "npm:3.1.0"],\ + ["undefsafe", "npm:2.0.5"],\ + ["update-notifier", "npm:5.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["nopt", [\ + ["npm:1.0.10", {\ + "packageLocation": "./.yarn/cache/nopt-npm-1.0.10-f3db192976-f62575acea.zip/node_modules/nopt/",\ + "packageDependencies": [\ + ["nopt", "npm:1.0.10"],\ + ["abbrev", "npm:1.1.1"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:5.0.0", {\ "packageLocation": "./.yarn/cache/nopt-npm-5.0.0-304b40fbfe-d35fdec187.zip/node_modules/nopt/",\ "packageDependencies": [\ @@ -7718,6 +9305,13 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["normalize-url", [\ + ["npm:4.5.1", {\ + "packageLocation": "./.yarn/cache/normalize-url-npm-4.5.1-603d40bc18-9a9dee01df.zip/node_modules/normalize-url/",\ + "packageDependencies": [\ + ["normalize-url", "npm:4.5.1"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:6.1.0", {\ "packageLocation": "./.yarn/cache/normalize-url-npm-6.1.0-b95bc12ece-4a49446311.zip/node_modules/normalize-url/",\ "packageDependencies": [\ @@ -7850,6 +9444,24 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["on-finished", [\ + ["npm:2.3.0", {\ + "packageLocation": "./.yarn/cache/on-finished-npm-2.3.0-4ce92f72c6-1db595bd96.zip/node_modules/on-finished/",\ + "packageDependencies": [\ + ["on-finished", "npm:2.3.0"],\ + ["ee-first", "npm:1.1.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.4.1", {\ + "packageLocation": "./.yarn/cache/on-finished-npm-2.4.1-907af70f88-d20929a25e.zip/node_modules/on-finished/",\ + "packageDependencies": [\ + ["on-finished", "npm:2.4.1"],\ + ["ee-first", "npm:1.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["once", [\ ["npm:1.4.0", {\ "packageLocation": "./.yarn/cache/once-npm-1.4.0-ccf03ef07a-cd0a885013.zip/node_modules/once/",\ @@ -7931,6 +9543,27 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["otplib", [\ + ["npm:12.0.1", {\ + "packageLocation": "./.yarn/cache/otplib-npm-12.0.1-77263e8084-4a1b91cf1b.zip/node_modules/otplib/",\ + "packageDependencies": [\ + ["otplib", "npm:12.0.1"],\ + ["@otplib/core", "npm:12.0.1"],\ + ["@otplib/preset-default", "npm:12.0.1"],\ + ["@otplib/preset-v11", "npm:12.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["p-cancelable", [\ + ["npm:1.1.0", {\ + "packageLocation": "./.yarn/cache/p-cancelable-npm-1.1.0-d147d5996f-2db3814fef.zip/node_modules/p-cancelable/",\ + "packageDependencies": [\ + ["p-cancelable", "npm:1.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["p-finally", [\ ["npm:1.0.0", {\ "packageLocation": "./.yarn/cache/p-finally-npm-1.0.0-35fbaa57c6-93a654c53d.zip/node_modules/p-finally/",\ @@ -8064,6 +9697,19 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["package-json", [\ + ["npm:6.5.0", {\ + "packageLocation": "./.yarn/cache/package-json-npm-6.5.0-30e58237bb-cc9f890d36.zip/node_modules/package-json/",\ + "packageDependencies": [\ + ["package-json", "npm:6.5.0"],\ + ["got", "npm:9.6.0"],\ + ["registry-auth-token", "npm:4.2.2"],\ + ["registry-url", "npm:5.1.0"],\ + ["semver", "npm:6.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["pacote", [\ ["npm:13.6.0", {\ "packageLocation": "./.yarn/cache/pacote-npm-13.6.0-96719678b2-9e68300fbe.zip/node_modules/pacote/",\ @@ -8178,6 +9824,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["parseurl", [\ + ["npm:1.3.3", {\ + "packageLocation": "./.yarn/cache/parseurl-npm-1.3.3-1542397e00-407cee8e0a.zip/node_modules/parseurl/",\ + "packageDependencies": [\ + ["parseurl", "npm:1.3.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["path", [\ ["npm:0.12.7", {\ "packageLocation": "./.yarn/cache/path-npm-0.12.7-bddabe2e86-5dedb71e78.zip/node_modules/path/",\ @@ -8232,6 +9887,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["path-to-regexp", [\ + ["npm:0.1.7", {\ + "packageLocation": "./.yarn/cache/path-to-regexp-npm-0.1.7-2605347373-69a14ea24d.zip/node_modules/path-to-regexp/",\ + "packageDependencies": [\ + ["path-to-regexp", "npm:0.1.7"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["path-type", [\ ["npm:3.0.0", {\ "packageLocation": "./.yarn/cache/path-type-npm-3.0.0-252361a0eb-735b35e256.zip/node_modules/path-type/",\ @@ -8325,6 +9989,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["prepend-http", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/prepend-http-npm-2.0.0-e1fc4332f2-7694a95254.zip/node_modules/prepend-http/",\ + "packageDependencies": [\ + ["prepend-http", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["prettier", [\ ["npm:2.7.1", {\ "packageLocation": "./.yarn/cache/prettier-npm-2.7.1-d1f40f5e1a-55a4409182.zip/node_modules/prettier/",\ @@ -8357,6 +10030,17 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["prettyjson", [\ + ["npm:1.2.1", {\ + "packageLocation": "./.yarn/cache/prettyjson-npm-1.2.1-045c44c3b6-4786cf7cb7.zip/node_modules/prettyjson/",\ + "packageDependencies": [\ + ["prettyjson", "npm:1.2.1"],\ + ["colors", "npm:1.4.0"],\ + ["minimist", "npm:1.2.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["proc-log", [\ ["npm:2.0.1", {\ "packageLocation": "./.yarn/cache/proc-log-npm-2.0.1-0593660460-f6f23564ff.zip/node_modules/proc-log/",\ @@ -8468,6 +10152,17 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["proxy-addr", [\ + ["npm:2.0.7", {\ + "packageLocation": "./.yarn/cache/proxy-addr-npm-2.0.7-dae6552872-29c6990ce9.zip/node_modules/proxy-addr/",\ + "packageDependencies": [\ + ["proxy-addr", "npm:2.0.7"],\ + ["forwarded", "npm:0.2.0"],\ + ["ipaddr.js", "npm:1.9.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["pseudomap", [\ ["npm:1.0.2", {\ "packageLocation": "./.yarn/cache/pseudomap-npm-1.0.2-0d0e40fee0-856c0aae0f.zip/node_modules/pseudomap/",\ @@ -8477,6 +10172,26 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["pstree.remy", [\ + ["npm:1.1.8", {\ + "packageLocation": "./.yarn/cache/pstree.remy-npm-1.1.8-2dd5d55de2-5cb53698d6.zip/node_modules/pstree.remy/",\ + "packageDependencies": [\ + ["pstree.remy", "npm:1.1.8"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pump", [\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/pump-npm-3.0.0-0080bf6a7a-e42e9229fb.zip/node_modules/pump/",\ + "packageDependencies": [\ + ["pump", "npm:3.0.0"],\ + ["end-of-stream", "npm:1.4.4"],\ + ["once", "npm:1.4.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["punycode", [\ ["npm:1.3.2", {\ "packageLocation": "./.yarn/cache/punycode-npm-1.3.2-3727a84cea-b8807fd594.zip/node_modules/punycode/",\ @@ -8493,6 +10208,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["pupa", [\ + ["npm:2.1.1", {\ + "packageLocation": "./.yarn/cache/pupa-npm-2.1.1-fb256825ba-49529e5037.zip/node_modules/pupa/",\ + "packageDependencies": [\ + ["pupa", "npm:2.1.1"],\ + ["escape-goat", "npm:2.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["q", [\ ["npm:1.5.1", {\ "packageLocation": "./.yarn/cache/q-npm-1.5.1-a28b3cfeaf-147baa93c8.zip/node_modules/q/",\ @@ -8503,6 +10228,14 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["qs", [\ + ["npm:6.10.3", {\ + "packageLocation": "./.yarn/cache/qs-npm-6.10.3-172e1a3fb7-0fac5e6c71.zip/node_modules/qs/",\ + "packageDependencies": [\ + ["qs", "npm:6.10.3"],\ + ["side-channel", "npm:1.0.4"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:6.10.5", {\ "packageLocation": "./.yarn/cache/qs-npm-6.10.5-e366a4a410-b3873189a1.zip/node_modules/qs/",\ "packageDependencies": [\ @@ -8510,6 +10243,13 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["side-channel", "npm:1.0.4"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:6.7.0", {\ + "packageLocation": "./.yarn/cache/qs-npm-6.7.0-15161a344c-dfd5f6adef.zip/node_modules/qs/",\ + "packageDependencies": [\ + ["qs", "npm:6.7.0"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["query-string", [\ @@ -8552,6 +10292,52 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["range-parser", [\ + ["npm:1.2.1", {\ + "packageLocation": "./.yarn/cache/range-parser-npm-1.2.1-1a470fa390-0a268d4fea.zip/node_modules/range-parser/",\ + "packageDependencies": [\ + ["range-parser", "npm:1.2.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["raw-body", [\ + ["npm:2.4.0", {\ + "packageLocation": "./.yarn/cache/raw-body-npm-2.4.0-14d9d633af-6343906939.zip/node_modules/raw-body/",\ + "packageDependencies": [\ + ["raw-body", "npm:2.4.0"],\ + ["bytes", "npm:3.1.0"],\ + ["http-errors", "npm:1.7.2"],\ + ["iconv-lite", "npm:0.4.24"],\ + ["unpipe", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.5.1", {\ + "packageLocation": "./.yarn/cache/raw-body-npm-2.5.1-9dd1d9fff9-5362adff15.zip/node_modules/raw-body/",\ + "packageDependencies": [\ + ["raw-body", "npm:2.5.1"],\ + ["bytes", "npm:3.1.2"],\ + ["http-errors", "npm:2.0.0"],\ + ["iconv-lite", "npm:0.4.24"],\ + ["unpipe", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["rc", [\ + ["npm:1.2.8", {\ + "packageLocation": "./.yarn/cache/rc-npm-1.2.8-d6768ac936-2e26e052f8.zip/node_modules/rc/",\ + "packageDependencies": [\ + ["rc", "npm:1.2.8"],\ + ["deep-extend", "npm:0.6.0"],\ + ["ini", "npm:1.3.8"],\ + ["minimist", "npm:1.2.6"],\ + ["strip-json-comments", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["react-is", [\ ["npm:18.2.0", {\ "packageLocation": "./.yarn/cache/react-is-npm-18.2.0-0cc5edb910-e72d0ba81b.zip/node_modules/react-is/",\ @@ -8655,6 +10441,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["readdirp", [\ + ["npm:3.6.0", {\ + "packageLocation": "./.yarn/cache/readdirp-npm-3.6.0-f950cc74ab-1ced032e6e.zip/node_modules/readdirp/",\ + "packageDependencies": [\ + ["readdirp", "npm:3.6.0"],\ + ["picomatch", "npm:2.3.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["redent", [\ ["npm:3.0.0", {\ "packageLocation": "./.yarn/cache/redent-npm-3.0.0-31892f4906-fa1ef20404.zip/node_modules/redent/",\ @@ -8712,6 +10508,26 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["registry-auth-token", [\ + ["npm:4.2.2", {\ + "packageLocation": "./.yarn/cache/registry-auth-token-npm-4.2.2-ffd70a9849-c503019854.zip/node_modules/registry-auth-token/",\ + "packageDependencies": [\ + ["registry-auth-token", "npm:4.2.2"],\ + ["rc", "npm:1.2.8"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["registry-url", [\ + ["npm:5.1.0", {\ + "packageLocation": "./.yarn/cache/registry-url-npm-5.1.0-f58d0ca7ff-bcea86c84a.zip/node_modules/registry-url/",\ + "packageDependencies": [\ + ["registry-url", "npm:5.1.0"],\ + ["rc", "npm:1.2.8"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["require-directory", [\ ["npm:2.1.1", {\ "packageLocation": "./.yarn/cache/require-directory-npm-2.1.1-8608aee50b-fb47e70bf0.zip/node_modules/require-directory/",\ @@ -8778,6 +10594,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["responselike", [\ + ["npm:1.0.2", {\ + "packageLocation": "./.yarn/cache/responselike-npm-1.0.2-d0bf50cde4-2e9e70f1dc.zip/node_modules/responselike/",\ + "packageDependencies": [\ + ["responselike", "npm:1.0.2"],\ + ["lowercase-keys", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["restore-cursor", [\ ["npm:3.1.0", {\ "packageLocation": "./.yarn/cache/restore-cursor-npm-3.1.0-52c5a4c98f-f877dd8741.zip/node_modules/restore-cursor/",\ @@ -8920,6 +10746,58 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["semver-diff", [\ + ["npm:3.1.1", {\ + "packageLocation": "./.yarn/cache/semver-diff-npm-3.1.1-1207a795e9-8bbe5a5d7a.zip/node_modules/semver-diff/",\ + "packageDependencies": [\ + ["semver-diff", "npm:3.1.1"],\ + ["semver", "npm:6.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["send", [\ + ["npm:0.17.1", {\ + "packageLocation": "./.yarn/cache/send-npm-0.17.1-aad5512679-d214c2fa42.zip/node_modules/send/",\ + "packageDependencies": [\ + ["send", "npm:0.17.1"],\ + ["debug", "virtual:6e177cabfad012f413f9c41366539c04d8701f0567119998690ab02224012faa99ec3a16b9f74f4d7920ab472c12b3e70f47f8f143239c06d0e2569e60ed9f62#npm:2.6.9"],\ + ["depd", "npm:1.1.2"],\ + ["destroy", "npm:1.0.4"],\ + ["encodeurl", "npm:1.0.2"],\ + ["escape-html", "npm:1.0.3"],\ + ["etag", "npm:1.8.1"],\ + ["fresh", "npm:0.5.2"],\ + ["http-errors", "npm:1.7.3"],\ + ["mime", "npm:1.6.0"],\ + ["ms", "npm:2.1.1"],\ + ["on-finished", "npm:2.3.0"],\ + ["range-parser", "npm:1.2.1"],\ + ["statuses", "npm:1.5.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.18.0", {\ + "packageLocation": "./.yarn/cache/send-npm-0.18.0-faadf6353f-74fc07ebb5.zip/node_modules/send/",\ + "packageDependencies": [\ + ["send", "npm:0.18.0"],\ + ["debug", "virtual:6e177cabfad012f413f9c41366539c04d8701f0567119998690ab02224012faa99ec3a16b9f74f4d7920ab472c12b3e70f47f8f143239c06d0e2569e60ed9f62#npm:2.6.9"],\ + ["depd", "npm:2.0.0"],\ + ["destroy", "npm:1.2.0"],\ + ["encodeurl", "npm:1.0.2"],\ + ["escape-html", "npm:1.0.3"],\ + ["etag", "npm:1.8.1"],\ + ["fresh", "npm:0.5.2"],\ + ["http-errors", "npm:2.0.0"],\ + ["mime", "npm:1.6.0"],\ + ["ms", "npm:2.1.3"],\ + ["on-finished", "npm:2.4.1"],\ + ["range-parser", "npm:1.2.1"],\ + ["statuses", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["seq-queue", [\ ["npm:0.0.5", {\ "packageLocation": "./.yarn/cache/seq-queue-npm-0.0.5-d5064d9793-f8695a6cb6.zip/node_modules/seq-queue/",\ @@ -8929,6 +10807,30 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["serve-static", [\ + ["npm:1.14.1", {\ + "packageLocation": "./.yarn/cache/serve-static-npm-1.14.1-a7afb1d3b3-c6b268e848.zip/node_modules/serve-static/",\ + "packageDependencies": [\ + ["serve-static", "npm:1.14.1"],\ + ["encodeurl", "npm:1.0.2"],\ + ["escape-html", "npm:1.0.3"],\ + ["parseurl", "npm:1.3.3"],\ + ["send", "npm:0.17.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:1.15.0", {\ + "packageLocation": "./.yarn/cache/serve-static-npm-1.15.0-86c81879f5-af57fc13be.zip/node_modules/serve-static/",\ + "packageDependencies": [\ + ["serve-static", "npm:1.15.0"],\ + ["encodeurl", "npm:1.0.2"],\ + ["escape-html", "npm:1.0.3"],\ + ["parseurl", "npm:1.3.3"],\ + ["send", "npm:0.18.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["set-blocking", [\ ["npm:2.0.0", {\ "packageLocation": "./.yarn/cache/set-blocking-npm-2.0.0-49e2cffa24-6e65a05f7c.zip/node_modules/set-blocking/",\ @@ -8938,6 +10840,22 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["setprototypeof", [\ + ["npm:1.1.1", {\ + "packageLocation": "./.yarn/cache/setprototypeof-npm-1.1.1-706b6318ec-a8bee29c1c.zip/node_modules/setprototypeof/",\ + "packageDependencies": [\ + ["setprototypeof", "npm:1.1.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:1.2.0", {\ + "packageLocation": "./.yarn/cache/setprototypeof-npm-1.2.0-0fedbdcd3a-be18cbbf70.zip/node_modules/setprototypeof/",\ + "packageDependencies": [\ + ["setprototypeof", "npm:1.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["sha.js", [\ ["npm:2.4.11", {\ "packageLocation": "./.yarn/cache/sha.js-npm-2.4.11-14868df4ca-ebd3f59d4b.zip/node_modules/sha.js/",\ @@ -9263,6 +11181,22 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["statuses", [\ + ["npm:1.5.0", {\ + "packageLocation": "./.yarn/cache/statuses-npm-1.5.0-f88f91b2e9-c469b9519d.zip/node_modules/statuses/",\ + "packageDependencies": [\ + ["statuses", "npm:1.5.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/statuses-npm-2.0.1-81d2b97fee-18c7623fdb.zip/node_modules/statuses/",\ + "packageDependencies": [\ + ["statuses", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["strict-uri-encode", [\ ["npm:2.0.0", {\ "packageLocation": "./.yarn/cache/strict-uri-encode-npm-2.0.0-1ec3189376-eaac4cf978.zip/node_modules/strict-uri-encode/",\ @@ -9359,6 +11293,13 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { }]\ ]],\ ["strip-json-comments", [\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/strip-json-comments-npm-2.0.1-e7883b2d04-1074ccb632.zip/node_modules/strip-json-comments/",\ + "packageDependencies": [\ + ["strip-json-comments", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:3.1.1", {\ "packageLocation": "./.yarn/cache/strip-json-comments-npm-3.1.1-dcb2324823-492f73e272.zip/node_modules/strip-json-comments/",\ "packageDependencies": [\ @@ -9519,6 +11460,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["thirty-two", [\ + ["npm:1.0.2", {\ + "packageLocation": "./.yarn/cache/thirty-two-npm-1.0.2-9d9270aa34-f6700b31d1.zip/node_modules/thirty-two/",\ + "packageDependencies": [\ + ["thirty-two", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["throat", [\ ["npm:6.0.1", {\ "packageLocation": "./.yarn/cache/throat-npm-6.0.1-1308a37a10-782d4171ee.zip/node_modules/throat/",\ @@ -9584,6 +11534,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["to-readable-stream", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/to-readable-stream-npm-1.0.0-4fa4da8130-2bd7778490.zip/node_modules/to-readable-stream/",\ + "packageDependencies": [\ + ["to-readable-stream", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["to-regex-range", [\ ["npm:5.0.1", {\ "packageLocation": "./.yarn/cache/to-regex-range-npm-5.0.1-f1e8263b00-f76fa01b3d.zip/node_modules/to-regex-range/",\ @@ -9594,6 +11553,32 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["toidentifier", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/toidentifier-npm-1.0.0-5dad252f90-199e6bfca1.zip/node_modules/toidentifier/",\ + "packageDependencies": [\ + ["toidentifier", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:1.0.1", {\ + "packageLocation": "./.yarn/cache/toidentifier-npm-1.0.1-f759712599-952c29e2a8.zip/node_modules/toidentifier/",\ + "packageDependencies": [\ + ["toidentifier", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["touch", [\ + ["npm:3.1.0", {\ + "packageLocation": "./.yarn/cache/touch-npm-3.1.0-e2eacebbda-e0be589cb5.zip/node_modules/touch/",\ + "packageDependencies": [\ + ["touch", "npm:3.1.0"],\ + ["nopt", "npm:1.0.10"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["tr46", [\ ["npm:0.0.3", {\ "packageLocation": "./.yarn/cache/tr46-npm-0.0.3-de53018915-726321c5ea.zip/node_modules/tr46/",\ @@ -9651,7 +11636,44 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ["bs-logger", "npm:0.2.6"],\ ["esbuild", null],\ ["fast-json-stable-stringify", "npm:2.1.0"],\ - ["jest", "virtual:16bfd8597041deb71e4581ea0755edd4dcd1b09b8ab14bfbbf5e4d5ca6b5d47ed7fbe2a25cdf57fcbb8e092c30b6beb93d2e7533f9e31c5dc62f7f0e487d1e4b#npm:28.1.1"],\ + ["jest", "virtual:31b5a94a105c89c9294c3d524a7f8929fe63ee5a2efadf21951ca4c0cfd2ecf02e8f4ef5a066bbda091f1e3a56e57c6749069a080618c96b22e51131a330fc4a#npm:28.1.1"],\ + ["jest-util", "npm:28.1.1"],\ + ["json5", "npm:2.2.1"],\ + ["lodash.memoize", "npm:4.1.2"],\ + ["make-error", "npm:1.3.6"],\ + ["semver", "npm:7.3.7"],\ + ["typescript", null],\ + ["yargs-parser", "npm:21.0.1"]\ + ],\ + "packagePeers": [\ + "@babel/core",\ + "@types/babel-jest",\ + "@types/babel__core",\ + "@types/esbuild",\ + "@types/jest",\ + "@types/typescript",\ + "babel-jest",\ + "esbuild",\ + "jest",\ + "typescript"\ + ],\ + "linkType": "HARD"\ + }],\ + ["virtual:31b5a94a105c89c9294c3d524a7f8929fe63ee5a2efadf21951ca4c0cfd2ecf02e8f4ef5a066bbda091f1e3a56e57c6749069a080618c96b22e51131a330fc4a#npm:28.0.5", {\ + "packageLocation": "./.yarn/__virtual__/ts-jest-virtual-21163ab03c/0/cache/ts-jest-npm-28.0.5-8c44d8b86f-53e05db5b7.zip/node_modules/ts-jest/",\ + "packageDependencies": [\ + ["ts-jest", "virtual:31b5a94a105c89c9294c3d524a7f8929fe63ee5a2efadf21951ca4c0cfd2ecf02e8f4ef5a066bbda091f1e3a56e57c6749069a080618c96b22e51131a330fc4a#npm:28.0.5"],\ + ["@babel/core", null],\ + ["@types/babel-jest", null],\ + ["@types/babel__core", null],\ + ["@types/esbuild", null],\ + ["@types/jest", "npm:28.1.3"],\ + ["@types/typescript", null],\ + ["babel-jest", null],\ + ["bs-logger", "npm:0.2.6"],\ + ["esbuild", null],\ + ["fast-json-stable-stringify", "npm:2.1.0"],\ + ["jest", "virtual:31b5a94a105c89c9294c3d524a7f8929fe63ee5a2efadf21951ca4c0cfd2ecf02e8f4ef5a066bbda091f1e3a56e57c6749069a080618c96b22e51131a330fc4a#npm:28.1.1"],\ ["jest-util", "npm:28.1.1"],\ ["json5", "npm:2.2.1"],\ ["lodash.memoize", "npm:4.1.2"],\ @@ -9794,10 +11816,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "HARD"\ }],\ - ["virtual:f8d5e1f46dbb2f1fb352e8d15f1237589f03161f87569a6446ffa325c842024c20e3b7f196872650fbbdc62125c711d99dd1c2ba271f15e9b316292a2dec51bc#npm:3.21.0", {\ - "packageLocation": "./.yarn/__virtual__/tsutils-virtual-c23225e234/0/cache/tsutils-npm-3.21.0-347e6636c5-1843f4c1b2.zip/node_modules/tsutils/",\ + ["virtual:e64d2841693653abb2dee666d19406912f5e913a8081a709c081d9877d2f39987ff853b7cd736901a2df59af98328f7249f3db0da01abf060cf1d858d4d4e43b#npm:3.21.0", {\ + "packageLocation": "./.yarn/__virtual__/tsutils-virtual-cd74663c37/0/cache/tsutils-npm-3.21.0-347e6636c5-1843f4c1b2.zip/node_modules/tsutils/",\ "packageDependencies": [\ - ["tsutils", "virtual:f8d5e1f46dbb2f1fb352e8d15f1237589f03161f87569a6446ffa325c842024c20e3b7f196872650fbbdc62125c711d99dd1c2ba271f15e9b316292a2dec51bc#npm:3.21.0"],\ + ["tsutils", "virtual:e64d2841693653abb2dee666d19406912f5e913a8081a709c081d9877d2f39987ff853b7cd736901a2df59af98328f7249f3db0da01abf060cf1d858d4d4e43b#npm:3.21.0"],\ ["@types/typescript", null],\ ["tslib", "npm:1.14.1"],\ ["typescript", null]\ @@ -9872,6 +11894,17 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["type-is", [\ + ["npm:1.6.18", {\ + "packageLocation": "./.yarn/cache/type-is-npm-1.6.18-6dee4d4961-2c8e47675d.zip/node_modules/type-is/",\ + "packageDependencies": [\ + ["type-is", "npm:1.6.18"],\ + ["media-typer", "npm:0.3.0"],\ + ["mime-types", "npm:2.1.35"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["typedarray", [\ ["npm:0.0.6", {\ "packageLocation": "./.yarn/cache/typedarray-npm-0.0.6-37638b2241-33b39f3d0e.zip/node_modules/typedarray/",\ @@ -9899,10 +11932,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { ],\ "linkType": "SOFT"\ }],\ - ["virtual:16bfd8597041deb71e4581ea0755edd4dcd1b09b8ab14bfbbf5e4d5ca6b5d47ed7fbe2a25cdf57fcbb8e092c30b6beb93d2e7533f9e31c5dc62f7f0e487d1e4b#npm:0.3.6", {\ - "packageLocation": "./.yarn/__virtual__/typeorm-virtual-5164e323fa/0/cache/typeorm-npm-0.3.6-8a40e705ca-7353b87a81.zip/node_modules/typeorm/",\ + ["virtual:31b5a94a105c89c9294c3d524a7f8929fe63ee5a2efadf21951ca4c0cfd2ecf02e8f4ef5a066bbda091f1e3a56e57c6749069a080618c96b22e51131a330fc4a#npm:0.3.6", {\ + "packageLocation": "./.yarn/__virtual__/typeorm-virtual-60e7e6c479/0/cache/typeorm-npm-0.3.6-8a40e705ca-7353b87a81.zip/node_modules/typeorm/",\ "packageDependencies": [\ - ["typeorm", "virtual:16bfd8597041deb71e4581ea0755edd4dcd1b09b8ab14bfbbf5e4d5ca6b5d47ed7fbe2a25cdf57fcbb8e092c30b6beb93d2e7533f9e31c5dc62f7f0e487d1e4b#npm:0.3.6"],\ + ["typeorm", "virtual:31b5a94a105c89c9294c3d524a7f8929fe63ee5a2efadf21951ca4c0cfd2ecf02e8f4ef5a066bbda091f1e3a56e57c6749069a080618c96b22e51131a330fc4a#npm:0.3.6"],\ ["@google-cloud/spanner", null],\ ["@sap/hana-client", null],\ ["@sqltools/formatter", "npm:1.2.3"],\ @@ -10003,6 +12036,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["ua-parser-js", [\ + ["npm:1.0.2", {\ + "packageLocation": "./.yarn/cache/ua-parser-js-npm-1.0.2-c3376785e2-ff7f6d79a9.zip/node_modules/ua-parser-js/",\ + "packageDependencies": [\ + ["ua-parser-js", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["uglify-js", [\ ["npm:3.16.1", {\ "packageLocation": "./.yarn/cache/uglify-js-npm-3.16.1-069246fed4-e4108b35af.zip/node_modules/uglify-js/",\ @@ -10012,6 +12054,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["undefsafe", [\ + ["npm:2.0.5", {\ + "packageLocation": "./.yarn/cache/undefsafe-npm-2.0.5-8c3bbf9354-f42ab3b577.zip/node_modules/undefsafe/",\ + "packageDependencies": [\ + ["undefsafe", "npm:2.0.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["unique-filename", [\ ["npm:1.1.1", {\ "packageLocation": "./.yarn/cache/unique-filename-npm-1.1.1-c885c5095b-cf4998c922.zip/node_modules/unique-filename/",\ @@ -10032,6 +12083,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["unique-string", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/unique-string-npm-2.0.0-3153c97e47-ef68f63913.zip/node_modules/unique-string/",\ + "packageDependencies": [\ + ["unique-string", "npm:2.0.0"],\ + ["crypto-random-string", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["universal-user-agent", [\ ["npm:6.0.0", {\ "packageLocation": "./.yarn/cache/universal-user-agent-npm-6.0.0-b148fb997a-5092bbc80d.zip/node_modules/universal-user-agent/",\ @@ -10050,6 +12111,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["unpipe", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/unpipe-npm-1.0.0-2ed2a3c2bf-4fa18d8d8d.zip/node_modules/unpipe/",\ + "packageDependencies": [\ + ["unpipe", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["upath", [\ ["npm:2.0.1", {\ "packageLocation": "./.yarn/cache/upath-npm-2.0.1-f0ea260247-2db04f24a0.zip/node_modules/upath/",\ @@ -10059,6 +12129,29 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["update-notifier", [\ + ["npm:5.1.0", {\ + "packageLocation": "./.yarn/cache/update-notifier-npm-5.1.0-6bf595ecee-461e5e5b00.zip/node_modules/update-notifier/",\ + "packageDependencies": [\ + ["update-notifier", "npm:5.1.0"],\ + ["boxen", "npm:5.1.2"],\ + ["chalk", "npm:4.1.2"],\ + ["configstore", "npm:5.0.1"],\ + ["has-yarn", "npm:2.1.0"],\ + ["import-lazy", "npm:2.1.0"],\ + ["is-ci", "npm:2.0.0"],\ + ["is-installed-globally", "npm:0.4.0"],\ + ["is-npm", "npm:5.0.0"],\ + ["is-yarn-global", "npm:0.3.0"],\ + ["latest-version", "npm:5.1.0"],\ + ["pupa", "npm:2.1.1"],\ + ["semver", "npm:7.3.7"],\ + ["semver-diff", "npm:3.1.1"],\ + ["xdg-basedir", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["uri-js", [\ ["npm:4.4.1", {\ "packageLocation": "./.yarn/cache/uri-js-npm-4.4.1-66d11cbcaf-7167432de6.zip/node_modules/uri-js/",\ @@ -10080,6 +12173,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["url-parse-lax", [\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/url-parse-lax-npm-3.0.0-92aa8effa0-1040e35775.zip/node_modules/url-parse-lax/",\ + "packageDependencies": [\ + ["url-parse-lax", "npm:3.0.0"],\ + ["prepend-http", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["util", [\ ["npm:0.10.4", {\ "packageLocation": "./.yarn/cache/util-npm-0.10.4-7c577db41a-913f9a90d0.zip/node_modules/util/",\ @@ -10099,6 +12202,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["utils-merge", [\ + ["npm:1.0.1", {\ + "packageLocation": "./.yarn/cache/utils-merge-npm-1.0.1-363bbdfbca-c810954932.zip/node_modules/utils-merge/",\ + "packageDependencies": [\ + ["utils-merge", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["uuid", [\ ["npm:8.0.0", {\ "packageLocation": "./.yarn/cache/uuid-npm-8.0.0-591e3a2e23-56d4e23aa7.zip/node_modules/uuid/",\ @@ -10166,6 +12278,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["vary", [\ + ["npm:1.1.2", {\ + "packageLocation": "./.yarn/cache/vary-npm-1.1.2-b49f70ae63-ae0123222c.zip/node_modules/vary/",\ + "packageDependencies": [\ + ["vary", "npm:1.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["walker", [\ ["npm:1.0.8", {\ "packageLocation": "./.yarn/cache/walker-npm-1.0.8-b0a05b9478-ad7a257ea1.zip/node_modules/walker/",\ @@ -10242,6 +12363,16 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["widest-line", [\ + ["npm:3.1.0", {\ + "packageLocation": "./.yarn/cache/widest-line-npm-3.1.0-717bf2680b-03db6c9d0a.zip/node_modules/widest-line/",\ + "packageDependencies": [\ + ["widest-line", "npm:3.1.0"],\ + ["string-width", "npm:4.2.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["winston", [\ ["npm:3.3.3", {\ "packageLocation": "./.yarn/cache/winston-npm-3.3.3-3fa4527b42-89a0a8db4e.zip/node_modules/winston/",\ @@ -10400,6 +12531,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) { "linkType": "HARD"\ }]\ ]],\ + ["xdg-basedir", [\ + ["npm:4.0.0", {\ + "packageLocation": "./.yarn/cache/xdg-basedir-npm-4.0.0-ed08d380e2-0073d5b59a.zip/node_modules/xdg-basedir/",\ + "packageDependencies": [\ + ["xdg-basedir", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["xml2js", [\ ["npm:0.4.19", {\ "packageLocation": "./.yarn/cache/xml2js-npm-0.4.19-104b7b16eb-ca8b2fee43.zip/node_modules/xml2js/",\ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..daaa5ee2e --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "arcanis.vscode-zipfs", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..62787842a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "search.exclude": { + "**/.yarn": true, + "**/.pnp.*": true + }, + "eslint.nodePath": ".yarn/sdks", + "prettier.prettierPath": ".yarn/sdks/prettier/index.js", + "typescript.tsdk": ".yarn/sdks/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true +} diff --git a/.yarn/cache/@otplib-core-npm-12.0.1-4b9787d379-b3c34bc20b.zip b/.yarn/cache/@otplib-core-npm-12.0.1-4b9787d379-b3c34bc20b.zip new file mode 100644 index 000000000..331fb0ab6 Binary files /dev/null and b/.yarn/cache/@otplib-core-npm-12.0.1-4b9787d379-b3c34bc20b.zip differ diff --git a/.yarn/cache/@otplib-plugin-crypto-npm-12.0.1-d0dc5d1d98-6867c74ee8.zip b/.yarn/cache/@otplib-plugin-crypto-npm-12.0.1-d0dc5d1d98-6867c74ee8.zip new file mode 100644 index 000000000..e91c34c65 Binary files /dev/null and b/.yarn/cache/@otplib-plugin-crypto-npm-12.0.1-d0dc5d1d98-6867c74ee8.zip differ diff --git a/.yarn/cache/@otplib-plugin-thirty-two-npm-12.0.1-b85109b20e-920099e40d.zip b/.yarn/cache/@otplib-plugin-thirty-two-npm-12.0.1-b85109b20e-920099e40d.zip new file mode 100644 index 000000000..7c12077b2 Binary files /dev/null and b/.yarn/cache/@otplib-plugin-thirty-two-npm-12.0.1-b85109b20e-920099e40d.zip differ diff --git a/.yarn/cache/@otplib-preset-default-npm-12.0.1-77f04f54c4-8133231384.zip b/.yarn/cache/@otplib-preset-default-npm-12.0.1-77f04f54c4-8133231384.zip new file mode 100644 index 000000000..e3b0629b3 Binary files /dev/null and b/.yarn/cache/@otplib-preset-default-npm-12.0.1-77f04f54c4-8133231384.zip differ diff --git a/.yarn/cache/@otplib-preset-v11-npm-12.0.1-df44c202c1-367cb09397.zip b/.yarn/cache/@otplib-preset-v11-npm-12.0.1-df44c202c1-367cb09397.zip new file mode 100644 index 000000000..591384043 Binary files /dev/null and b/.yarn/cache/@otplib-preset-v11-npm-12.0.1-df44c202c1-367cb09397.zip differ diff --git a/.yarn/cache/@sentry-core-npm-6.19.7-4cbb62d040-d212e8ef07.zip b/.yarn/cache/@sentry-core-npm-6.19.7-4cbb62d040-d212e8ef07.zip new file mode 100644 index 000000000..da52535f5 Binary files /dev/null and b/.yarn/cache/@sentry-core-npm-6.19.7-4cbb62d040-d212e8ef07.zip differ diff --git a/.yarn/cache/@sentry-hub-npm-6.19.7-6469362c23-10bb1c5cba.zip b/.yarn/cache/@sentry-hub-npm-6.19.7-6469362c23-10bb1c5cba.zip new file mode 100644 index 000000000..fd8ee15a2 Binary files /dev/null and b/.yarn/cache/@sentry-hub-npm-6.19.7-6469362c23-10bb1c5cba.zip differ diff --git a/.yarn/cache/@sentry-minimal-npm-6.19.7-7527a9814c-9153ac426e.zip b/.yarn/cache/@sentry-minimal-npm-6.19.7-7527a9814c-9153ac426e.zip new file mode 100644 index 000000000..a3c45368a Binary files /dev/null and b/.yarn/cache/@sentry-minimal-npm-6.19.7-7527a9814c-9153ac426e.zip differ diff --git a/.yarn/cache/@sentry-node-npm-6.19.7-edcd5da482-2293b0d1d1.zip b/.yarn/cache/@sentry-node-npm-6.19.7-edcd5da482-2293b0d1d1.zip new file mode 100644 index 000000000..432fe4784 Binary files /dev/null and b/.yarn/cache/@sentry-node-npm-6.19.7-edcd5da482-2293b0d1d1.zip differ diff --git a/.yarn/cache/@sentry-types-npm-6.19.7-f75535a9f4-f46ef74a33.zip b/.yarn/cache/@sentry-types-npm-6.19.7-f75535a9f4-f46ef74a33.zip new file mode 100644 index 000000000..026eb9b0a Binary files /dev/null and b/.yarn/cache/@sentry-types-npm-6.19.7-f75535a9f4-f46ef74a33.zip differ diff --git a/.yarn/cache/@sentry-utils-npm-6.19.7-d61c6c8632-a000223b9c.zip b/.yarn/cache/@sentry-utils-npm-6.19.7-d61c6c8632-a000223b9c.zip new file mode 100644 index 000000000..83524462f Binary files /dev/null and b/.yarn/cache/@sentry-utils-npm-6.19.7-d61c6c8632-a000223b9c.zip differ diff --git a/.yarn/cache/@sindresorhus-is-npm-0.14.0-9f906ea34b-971e0441dd.zip b/.yarn/cache/@sindresorhus-is-npm-0.14.0-9f906ea34b-971e0441dd.zip new file mode 100644 index 000000000..db20dee95 Binary files /dev/null and b/.yarn/cache/@sindresorhus-is-npm-0.14.0-9f906ea34b-971e0441dd.zip differ diff --git a/.yarn/cache/@standardnotes-analytics-npm-1.6.0-39bec110e3-6a5e861526.zip b/.yarn/cache/@standardnotes-analytics-npm-1.6.0-39bec110e3-6a5e861526.zip new file mode 100644 index 000000000..548cea7a8 Binary files /dev/null and b/.yarn/cache/@standardnotes-analytics-npm-1.6.0-39bec110e3-6a5e861526.zip differ diff --git a/.yarn/cache/@standardnotes-api-npm-1.1.13-59feca8c9e-2ff21e04bb.zip b/.yarn/cache/@standardnotes-api-npm-1.1.13-59feca8c9e-2ff21e04bb.zip new file mode 100644 index 000000000..b99332b30 Binary files /dev/null and b/.yarn/cache/@standardnotes-api-npm-1.1.13-59feca8c9e-2ff21e04bb.zip differ diff --git a/.yarn/cache/@standardnotes-encryption-npm-1.8.19-b0a1c08193-f663a6b9a2.zip b/.yarn/cache/@standardnotes-encryption-npm-1.8.19-b0a1c08193-f663a6b9a2.zip new file mode 100644 index 000000000..4e0cc7901 Binary files /dev/null and b/.yarn/cache/@standardnotes-encryption-npm-1.8.19-b0a1c08193-f663a6b9a2.zip differ diff --git a/.yarn/cache/@standardnotes-models-npm-1.11.10-e4b5e4717d-d69fd3940e.zip b/.yarn/cache/@standardnotes-models-npm-1.11.10-e4b5e4717d-d69fd3940e.zip new file mode 100644 index 000000000..04564c933 Binary files /dev/null and b/.yarn/cache/@standardnotes-models-npm-1.11.10-e4b5e4717d-d69fd3940e.zip differ diff --git a/.yarn/cache/@standardnotes-responses-npm-1.6.36-d245f42de1-bb78a2cefa.zip b/.yarn/cache/@standardnotes-responses-npm-1.6.36-d245f42de1-bb78a2cefa.zip new file mode 100644 index 000000000..2b5c2b83d Binary files /dev/null and b/.yarn/cache/@standardnotes-responses-npm-1.6.36-d245f42de1-bb78a2cefa.zip differ diff --git a/.yarn/cache/@standardnotes-services-npm-1.13.19-5574bb675a-c4c239c5e8.zip b/.yarn/cache/@standardnotes-services-npm-1.13.19-5574bb675a-c4c239c5e8.zip new file mode 100644 index 000000000..c95a7fcd1 Binary files /dev/null and b/.yarn/cache/@standardnotes-services-npm-1.13.19-5574bb675a-c4c239c5e8.zip differ diff --git a/.yarn/cache/@standardnotes-settings-npm-1.14.3-6f557bd9ab-60fbb2ca85.zip b/.yarn/cache/@standardnotes-settings-npm-1.14.3-6f557bd9ab-60fbb2ca85.zip new file mode 100644 index 000000000..6aab01b9f Binary files /dev/null and b/.yarn/cache/@standardnotes-settings-npm-1.14.3-6f557bd9ab-60fbb2ca85.zip differ diff --git a/.yarn/cache/@standardnotes-sncrypto-common-npm-1.9.0-48773f745a-42252d7198.zip b/.yarn/cache/@standardnotes-sncrypto-common-npm-1.9.0-48773f745a-42252d7198.zip new file mode 100644 index 000000000..b403908fd Binary files /dev/null and b/.yarn/cache/@standardnotes-sncrypto-common-npm-1.9.0-48773f745a-42252d7198.zip differ diff --git a/.yarn/cache/@standardnotes-sncrypto-node-npm-1.8.3-5d28cdd37d-b3c866bfba.zip b/.yarn/cache/@standardnotes-sncrypto-node-npm-1.8.3-5d28cdd37d-b3c866bfba.zip new file mode 100644 index 000000000..b5b3f06f0 Binary files /dev/null and b/.yarn/cache/@standardnotes-sncrypto-node-npm-1.8.3-5d28cdd37d-b3c866bfba.zip differ diff --git a/.yarn/cache/@standardnotes-utils-npm-1.6.11-54d7210fab-c50999c0b0.zip b/.yarn/cache/@standardnotes-utils-npm-1.6.11-54d7210fab-c50999c0b0.zip new file mode 100644 index 000000000..218d74445 Binary files /dev/null and b/.yarn/cache/@standardnotes-utils-npm-1.6.11-54d7210fab-c50999c0b0.zip differ diff --git a/.yarn/cache/@szmarczak-http-timer-npm-1.1.2-ea82ca2d55-4d9158061c.zip b/.yarn/cache/@szmarczak-http-timer-npm-1.1.2-ea82ca2d55-4d9158061c.zip new file mode 100644 index 000000000..01358f278 Binary files /dev/null and b/.yarn/cache/@szmarczak-http-timer-npm-1.1.2-ea82ca2d55-4d9158061c.zip differ diff --git a/.yarn/cache/@types-bcryptjs-npm-2.4.2-3a0c115732-220dade7b0.zip b/.yarn/cache/@types-bcryptjs-npm-2.4.2-3a0c115732-220dade7b0.zip new file mode 100644 index 000000000..9377117c8 Binary files /dev/null and b/.yarn/cache/@types-bcryptjs-npm-2.4.2-3a0c115732-220dade7b0.zip differ diff --git a/.yarn/cache/@types-body-parser-npm-1.19.2-f845b7b538-e17840c7d7.zip b/.yarn/cache/@types-body-parser-npm-1.19.2-f845b7b538-e17840c7d7.zip new file mode 100644 index 000000000..37c532e75 Binary files /dev/null and b/.yarn/cache/@types-body-parser-npm-1.19.2-f845b7b538-e17840c7d7.zip differ diff --git a/.yarn/cache/@types-connect-npm-3.4.35-7337eee0a3-fe81351470.zip b/.yarn/cache/@types-connect-npm-3.4.35-7337eee0a3-fe81351470.zip new file mode 100644 index 000000000..ae5f3a0f1 Binary files /dev/null and b/.yarn/cache/@types-connect-npm-3.4.35-7337eee0a3-fe81351470.zip differ diff --git a/.yarn/cache/@types-cors-npm-2.8.12-ff52e8e514-8c45f112c7.zip b/.yarn/cache/@types-cors-npm-2.8.12-ff52e8e514-8c45f112c7.zip new file mode 100644 index 000000000..3a10db9f6 Binary files /dev/null and b/.yarn/cache/@types-cors-npm-2.8.12-ff52e8e514-8c45f112c7.zip differ diff --git a/.yarn/cache/@types-express-npm-4.17.13-0e12fe9c24-12a2a0e6c4.zip b/.yarn/cache/@types-express-npm-4.17.13-0e12fe9c24-12a2a0e6c4.zip new file mode 100644 index 000000000..42b3aabd4 Binary files /dev/null and b/.yarn/cache/@types-express-npm-4.17.13-0e12fe9c24-12a2a0e6c4.zip differ diff --git a/.yarn/cache/@types-express-serve-static-core-npm-4.17.29-9b96bc0e26-ec4194dc59.zip b/.yarn/cache/@types-express-serve-static-core-npm-4.17.29-9b96bc0e26-ec4194dc59.zip new file mode 100644 index 000000000..5dd828c55 Binary files /dev/null and b/.yarn/cache/@types-express-serve-static-core-npm-4.17.29-9b96bc0e26-ec4194dc59.zip differ diff --git a/.yarn/cache/@types-jest-npm-28.1.3-4e0f1f0cb8-28141f2d5b.zip b/.yarn/cache/@types-jest-npm-28.1.3-4e0f1f0cb8-28141f2d5b.zip new file mode 100644 index 000000000..3e4984741 Binary files /dev/null and b/.yarn/cache/@types-jest-npm-28.1.3-4e0f1f0cb8-28141f2d5b.zip differ diff --git a/.yarn/cache/@types-keyv-npm-3.1.4-a8082ea56b-e009a2bfb5.zip b/.yarn/cache/@types-keyv-npm-3.1.4-a8082ea56b-e009a2bfb5.zip new file mode 100644 index 000000000..2521f3e92 Binary files /dev/null and b/.yarn/cache/@types-keyv-npm-3.1.4-a8082ea56b-e009a2bfb5.zip differ diff --git a/.yarn/cache/@types-mime-npm-1.3.2-ea71878ab3-0493368244.zip b/.yarn/cache/@types-mime-npm-1.3.2-ea71878ab3-0493368244.zip new file mode 100644 index 000000000..e363cbe58 Binary files /dev/null and b/.yarn/cache/@types-mime-npm-1.3.2-ea71878ab3-0493368244.zip differ diff --git a/.yarn/cache/@types-otplib-npm-10.0.0-6cfcbcf64e-aa081f0a55.zip b/.yarn/cache/@types-otplib-npm-10.0.0-6cfcbcf64e-aa081f0a55.zip new file mode 100644 index 000000000..b1c65bb40 Binary files /dev/null and b/.yarn/cache/@types-otplib-npm-10.0.0-6cfcbcf64e-aa081f0a55.zip differ diff --git a/.yarn/cache/@types-prettyjson-npm-0.0.29-26ae573a83-9ff6cb225d.zip b/.yarn/cache/@types-prettyjson-npm-0.0.29-26ae573a83-9ff6cb225d.zip new file mode 100644 index 000000000..ecd79d015 Binary files /dev/null and b/.yarn/cache/@types-prettyjson-npm-0.0.29-26ae573a83-9ff6cb225d.zip differ diff --git a/.yarn/cache/@types-qs-npm-6.9.7-4a3e6ca0d0-7fd6f9c250.zip b/.yarn/cache/@types-qs-npm-6.9.7-4a3e6ca0d0-7fd6f9c250.zip new file mode 100644 index 000000000..9137540a9 Binary files /dev/null and b/.yarn/cache/@types-qs-npm-6.9.7-4a3e6ca0d0-7fd6f9c250.zip differ diff --git a/.yarn/cache/@types-range-parser-npm-1.2.4-23d797fbde-b7c0dfd508.zip b/.yarn/cache/@types-range-parser-npm-1.2.4-23d797fbde-b7c0dfd508.zip new file mode 100644 index 000000000..951f3f106 Binary files /dev/null and b/.yarn/cache/@types-range-parser-npm-1.2.4-23d797fbde-b7c0dfd508.zip differ diff --git a/.yarn/cache/@types-responselike-npm-1.0.0-85dd08af42-e99fc7cc62.zip b/.yarn/cache/@types-responselike-npm-1.0.0-85dd08af42-e99fc7cc62.zip new file mode 100644 index 000000000..45d042f89 Binary files /dev/null and b/.yarn/cache/@types-responselike-npm-1.0.0-85dd08af42-e99fc7cc62.zip differ diff --git a/.yarn/cache/@types-serve-static-npm-1.13.10-5434e2c519-eaca858739.zip b/.yarn/cache/@types-serve-static-npm-1.13.10-5434e2c519-eaca858739.zip new file mode 100644 index 000000000..3c2a9460c Binary files /dev/null and b/.yarn/cache/@types-serve-static-npm-1.13.10-5434e2c519-eaca858739.zip differ diff --git a/.yarn/cache/@types-ua-parser-js-npm-0.7.36-f5ace9ead6-8c24d4dc12.zip b/.yarn/cache/@types-ua-parser-js-npm-0.7.36-f5ace9ead6-8c24d4dc12.zip new file mode 100644 index 000000000..0006be0a1 Binary files /dev/null and b/.yarn/cache/@types-ua-parser-js-npm-0.7.36-f5ace9ead6-8c24d4dc12.zip differ diff --git a/.yarn/cache/@types-uuid-npm-8.3.4-7547f4402c-6f11f3ff70.zip b/.yarn/cache/@types-uuid-npm-8.3.4-7547f4402c-6f11f3ff70.zip new file mode 100644 index 000000000..2e4a25adb Binary files /dev/null and b/.yarn/cache/@types-uuid-npm-8.3.4-7547f4402c-6f11f3ff70.zip differ diff --git a/.yarn/cache/accepts-npm-1.3.8-9a812371c9-50c43d32e7.zip b/.yarn/cache/accepts-npm-1.3.8-9a812371c9-50c43d32e7.zip new file mode 100644 index 000000000..416f55bd5 Binary files /dev/null and b/.yarn/cache/accepts-npm-1.3.8-9a812371c9-50c43d32e7.zip differ diff --git a/.yarn/cache/ansi-align-npm-3.0.1-8e6288d20a-6abfa08f21.zip b/.yarn/cache/ansi-align-npm-3.0.1-8e6288d20a-6abfa08f21.zip new file mode 100644 index 000000000..faf9ad445 Binary files /dev/null and b/.yarn/cache/ansi-align-npm-3.0.1-8e6288d20a-6abfa08f21.zip differ diff --git a/.yarn/cache/array-flatten-npm-1.1.1-9d94ad5f1d-a9925bf351.zip b/.yarn/cache/array-flatten-npm-1.1.1-9d94ad5f1d-a9925bf351.zip new file mode 100644 index 000000000..c6a8b5333 Binary files /dev/null and b/.yarn/cache/array-flatten-npm-1.1.1-9d94ad5f1d-a9925bf351.zip differ diff --git a/.yarn/cache/aws-sdk-npm-2.1159.0-c10a984d83-f89d3a3483.zip b/.yarn/cache/aws-sdk-npm-2.1159.0-c10a984d83-f89d3a3483.zip new file mode 100644 index 000000000..0bd40d171 Binary files /dev/null and b/.yarn/cache/aws-sdk-npm-2.1159.0-c10a984d83-f89d3a3483.zip differ diff --git a/.yarn/cache/axios-npm-0.24.0-39e5c1e79e-468cf496c0.zip b/.yarn/cache/axios-npm-0.24.0-39e5c1e79e-468cf496c0.zip new file mode 100644 index 000000000..5d8e81385 Binary files /dev/null and b/.yarn/cache/axios-npm-0.24.0-39e5c1e79e-468cf496c0.zip differ diff --git a/.yarn/cache/bcryptjs-npm-2.4.3-32de4957eb-0e80ed852a.zip b/.yarn/cache/bcryptjs-npm-2.4.3-32de4957eb-0e80ed852a.zip new file mode 100644 index 000000000..77c024569 Binary files /dev/null and b/.yarn/cache/bcryptjs-npm-2.4.3-32de4957eb-0e80ed852a.zip differ diff --git a/.yarn/cache/binary-extensions-npm-2.2.0-180c33fec7-ccd267956c.zip b/.yarn/cache/binary-extensions-npm-2.2.0-180c33fec7-ccd267956c.zip new file mode 100644 index 000000000..2ac750c15 Binary files /dev/null and b/.yarn/cache/binary-extensions-npm-2.2.0-180c33fec7-ccd267956c.zip differ diff --git a/.yarn/cache/body-parser-npm-1.19.0-6e177cabfa-490231b4c8.zip b/.yarn/cache/body-parser-npm-1.19.0-6e177cabfa-490231b4c8.zip new file mode 100644 index 000000000..be82c3b59 Binary files /dev/null and b/.yarn/cache/body-parser-npm-1.19.0-6e177cabfa-490231b4c8.zip differ diff --git a/.yarn/cache/body-parser-npm-1.20.0-1820eff49a-12fffdeac8.zip b/.yarn/cache/body-parser-npm-1.20.0-1820eff49a-12fffdeac8.zip new file mode 100644 index 000000000..74cdc3074 Binary files /dev/null and b/.yarn/cache/body-parser-npm-1.20.0-1820eff49a-12fffdeac8.zip differ diff --git a/.yarn/cache/boxen-npm-5.1.2-364ee34f2f-82d03e42a7.zip b/.yarn/cache/boxen-npm-5.1.2-364ee34f2f-82d03e42a7.zip new file mode 100644 index 000000000..2bfc37643 Binary files /dev/null and b/.yarn/cache/boxen-npm-5.1.2-364ee34f2f-82d03e42a7.zip differ diff --git a/.yarn/cache/bytes-npm-3.1.0-19c5b15405-7c3b21c5d9.zip b/.yarn/cache/bytes-npm-3.1.0-19c5b15405-7c3b21c5d9.zip new file mode 100644 index 000000000..a459fadbe Binary files /dev/null and b/.yarn/cache/bytes-npm-3.1.0-19c5b15405-7c3b21c5d9.zip differ diff --git a/.yarn/cache/bytes-npm-3.1.2-28b8643004-e4bcd3948d.zip b/.yarn/cache/bytes-npm-3.1.2-28b8643004-e4bcd3948d.zip new file mode 100644 index 000000000..07737e5cd Binary files /dev/null and b/.yarn/cache/bytes-npm-3.1.2-28b8643004-e4bcd3948d.zip differ diff --git a/.yarn/cache/cacheable-request-npm-6.1.0-684b834873-b510b237b1.zip b/.yarn/cache/cacheable-request-npm-6.1.0-684b834873-b510b237b1.zip new file mode 100644 index 000000000..9e62d1281 Binary files /dev/null and b/.yarn/cache/cacheable-request-npm-6.1.0-684b834873-b510b237b1.zip differ diff --git a/.yarn/cache/chokidar-npm-3.5.3-c5f9b0a56a-b49fcde401.zip b/.yarn/cache/chokidar-npm-3.5.3-c5f9b0a56a-b49fcde401.zip new file mode 100644 index 000000000..f5261bc27 Binary files /dev/null and b/.yarn/cache/chokidar-npm-3.5.3-c5f9b0a56a-b49fcde401.zip differ diff --git a/.yarn/cache/ci-info-npm-2.0.0-78012236a1-3b374666a8.zip b/.yarn/cache/ci-info-npm-2.0.0-78012236a1-3b374666a8.zip new file mode 100644 index 000000000..be3be89f4 Binary files /dev/null and b/.yarn/cache/ci-info-npm-2.0.0-78012236a1-3b374666a8.zip differ diff --git a/.yarn/cache/cli-boxes-npm-2.2.1-7125a5ba44-be79f8ec23.zip b/.yarn/cache/cli-boxes-npm-2.2.1-7125a5ba44-be79f8ec23.zip new file mode 100644 index 000000000..9f0f73138 Binary files /dev/null and b/.yarn/cache/cli-boxes-npm-2.2.1-7125a5ba44-be79f8ec23.zip differ diff --git a/.yarn/cache/clone-response-npm-1.0.2-135ae8239d-2d0e61547f.zip b/.yarn/cache/clone-response-npm-1.0.2-135ae8239d-2d0e61547f.zip new file mode 100644 index 000000000..5b5af5351 Binary files /dev/null and b/.yarn/cache/clone-response-npm-1.0.2-135ae8239d-2d0e61547f.zip differ diff --git a/.yarn/cache/colors-npm-1.4.0-7e2cf12234-98aa2c2418.zip b/.yarn/cache/colors-npm-1.4.0-7e2cf12234-98aa2c2418.zip new file mode 100644 index 000000000..74451b04a Binary files /dev/null and b/.yarn/cache/colors-npm-1.4.0-7e2cf12234-98aa2c2418.zip differ diff --git a/.yarn/cache/configstore-npm-5.0.1-739433cdc5-60ef65d493.zip b/.yarn/cache/configstore-npm-5.0.1-739433cdc5-60ef65d493.zip new file mode 100644 index 000000000..f14393361 Binary files /dev/null and b/.yarn/cache/configstore-npm-5.0.1-739433cdc5-60ef65d493.zip differ diff --git a/.yarn/cache/content-disposition-npm-0.5.3-9a9a567e17-95bf164c0b.zip b/.yarn/cache/content-disposition-npm-0.5.3-9a9a567e17-95bf164c0b.zip new file mode 100644 index 000000000..1047c54eb Binary files /dev/null and b/.yarn/cache/content-disposition-npm-0.5.3-9a9a567e17-95bf164c0b.zip differ diff --git a/.yarn/cache/content-disposition-npm-0.5.4-2d93678616-afb9d545e2.zip b/.yarn/cache/content-disposition-npm-0.5.4-2d93678616-afb9d545e2.zip new file mode 100644 index 000000000..5f9dc26d3 Binary files /dev/null and b/.yarn/cache/content-disposition-npm-0.5.4-2d93678616-afb9d545e2.zip differ diff --git a/.yarn/cache/content-type-npm-1.0.4-3b1a5ca16b-3d93585fda.zip b/.yarn/cache/content-type-npm-1.0.4-3b1a5ca16b-3d93585fda.zip new file mode 100644 index 000000000..9e1b5d890 Binary files /dev/null and b/.yarn/cache/content-type-npm-1.0.4-3b1a5ca16b-3d93585fda.zip differ diff --git a/.yarn/cache/cookie-npm-0.4.0-4b3d629e45-760384ba0a.zip b/.yarn/cache/cookie-npm-0.4.0-4b3d629e45-760384ba0a.zip new file mode 100644 index 000000000..45d9d6364 Binary files /dev/null and b/.yarn/cache/cookie-npm-0.4.0-4b3d629e45-760384ba0a.zip differ diff --git a/.yarn/cache/cookie-npm-0.4.2-7761894d5f-a00833c998.zip b/.yarn/cache/cookie-npm-0.4.2-7761894d5f-a00833c998.zip new file mode 100644 index 000000000..2a478448c Binary files /dev/null and b/.yarn/cache/cookie-npm-0.4.2-7761894d5f-a00833c998.zip differ diff --git a/.yarn/cache/cookie-npm-0.5.0-e2d58a161a-1f4bd2ca57.zip b/.yarn/cache/cookie-npm-0.5.0-e2d58a161a-1f4bd2ca57.zip new file mode 100644 index 000000000..ece428f31 Binary files /dev/null and b/.yarn/cache/cookie-npm-0.5.0-e2d58a161a-1f4bd2ca57.zip differ diff --git a/.yarn/cache/cookie-signature-npm-1.0.6-93f325f7f0-f4e1b0a98a.zip b/.yarn/cache/cookie-signature-npm-1.0.6-93f325f7f0-f4e1b0a98a.zip new file mode 100644 index 000000000..bf40b1449 Binary files /dev/null and b/.yarn/cache/cookie-signature-npm-1.0.6-93f325f7f0-f4e1b0a98a.zip differ diff --git a/.yarn/cache/cors-npm-2.8.5-c9935a2d12-ced838404c.zip b/.yarn/cache/cors-npm-2.8.5-c9935a2d12-ced838404c.zip new file mode 100644 index 000000000..b7ab2c53f Binary files /dev/null and b/.yarn/cache/cors-npm-2.8.5-c9935a2d12-ced838404c.zip differ diff --git a/.yarn/cache/crypto-random-string-npm-2.0.0-8ab47992ef-0283879f55.zip b/.yarn/cache/crypto-random-string-npm-2.0.0-8ab47992ef-0283879f55.zip new file mode 100644 index 000000000..90bce3322 Binary files /dev/null and b/.yarn/cache/crypto-random-string-npm-2.0.0-8ab47992ef-0283879f55.zip differ diff --git a/.yarn/cache/crypto-random-string-npm-3.3.0-4f73472f10-deff986631.zip b/.yarn/cache/crypto-random-string-npm-3.3.0-4f73472f10-deff986631.zip new file mode 100644 index 000000000..9957021aa Binary files /dev/null and b/.yarn/cache/crypto-random-string-npm-3.3.0-4f73472f10-deff986631.zip differ diff --git a/.yarn/cache/debug-npm-2.6.9-7d4cb597dc-d2f51589ca.zip b/.yarn/cache/debug-npm-2.6.9-7d4cb597dc-d2f51589ca.zip new file mode 100644 index 000000000..5a1127607 Binary files /dev/null and b/.yarn/cache/debug-npm-2.6.9-7d4cb597dc-d2f51589ca.zip differ diff --git a/.yarn/cache/debug-npm-3.2.7-754e818c7a-b3d8c59407.zip b/.yarn/cache/debug-npm-3.2.7-754e818c7a-b3d8c59407.zip new file mode 100644 index 000000000..b9eb5a9e8 Binary files /dev/null and b/.yarn/cache/debug-npm-3.2.7-754e818c7a-b3d8c59407.zip differ diff --git a/.yarn/cache/decompress-response-npm-3.3.0-6e7b6375c3-952552ac3b.zip b/.yarn/cache/decompress-response-npm-3.3.0-6e7b6375c3-952552ac3b.zip new file mode 100644 index 000000000..52b2ac76b Binary files /dev/null and b/.yarn/cache/decompress-response-npm-3.3.0-6e7b6375c3-952552ac3b.zip differ diff --git a/.yarn/cache/deep-extend-npm-0.6.0-e182924219-7be7e5a8d4.zip b/.yarn/cache/deep-extend-npm-0.6.0-e182924219-7be7e5a8d4.zip new file mode 100644 index 000000000..87f0270ec Binary files /dev/null and b/.yarn/cache/deep-extend-npm-0.6.0-e182924219-7be7e5a8d4.zip differ diff --git a/.yarn/cache/defer-to-connect-npm-1.1.3-5887885147-9491b301dc.zip b/.yarn/cache/defer-to-connect-npm-1.1.3-5887885147-9491b301dc.zip new file mode 100644 index 000000000..75ad626c8 Binary files /dev/null and b/.yarn/cache/defer-to-connect-npm-1.1.3-5887885147-9491b301dc.zip differ diff --git a/.yarn/cache/depd-npm-2.0.0-b6c51a4b43-abbe19c768.zip b/.yarn/cache/depd-npm-2.0.0-b6c51a4b43-abbe19c768.zip new file mode 100644 index 000000000..30053d1cf Binary files /dev/null and b/.yarn/cache/depd-npm-2.0.0-b6c51a4b43-abbe19c768.zip differ diff --git a/.yarn/cache/destroy-npm-1.0.4-a2203e01cb-da9ab4961d.zip b/.yarn/cache/destroy-npm-1.0.4-a2203e01cb-da9ab4961d.zip new file mode 100644 index 000000000..3c79469d7 Binary files /dev/null and b/.yarn/cache/destroy-npm-1.0.4-a2203e01cb-da9ab4961d.zip differ diff --git a/.yarn/cache/destroy-npm-1.2.0-6a511802e2-0acb300b74.zip b/.yarn/cache/destroy-npm-1.2.0-6a511802e2-0acb300b74.zip new file mode 100644 index 000000000..3bc30ea4d Binary files /dev/null and b/.yarn/cache/destroy-npm-1.2.0-6a511802e2-0acb300b74.zip differ diff --git a/.yarn/cache/dompurify-npm-2.3.8-c4b696b00d-dc7b32ee57.zip b/.yarn/cache/dompurify-npm-2.3.8-c4b696b00d-dc7b32ee57.zip new file mode 100644 index 000000000..cb2cd423a Binary files /dev/null and b/.yarn/cache/dompurify-npm-2.3.8-c4b696b00d-dc7b32ee57.zip differ diff --git a/.yarn/cache/duplexer3-npm-0.1.4-361a33d994-c2fd696931.zip b/.yarn/cache/duplexer3-npm-0.1.4-361a33d994-c2fd696931.zip new file mode 100644 index 000000000..858d0a852 Binary files /dev/null and b/.yarn/cache/duplexer3-npm-0.1.4-361a33d994-c2fd696931.zip differ diff --git a/.yarn/cache/ee-first-npm-1.1.1-33f8535b39-1b4cac778d.zip b/.yarn/cache/ee-first-npm-1.1.1-33f8535b39-1b4cac778d.zip new file mode 100644 index 000000000..458439cba Binary files /dev/null and b/.yarn/cache/ee-first-npm-1.1.1-33f8535b39-1b4cac778d.zip differ diff --git a/.yarn/cache/encodeurl-npm-1.0.2-f8c8454c41-e50e3d508c.zip b/.yarn/cache/encodeurl-npm-1.0.2-f8c8454c41-e50e3d508c.zip new file mode 100644 index 000000000..e9badb765 Binary files /dev/null and b/.yarn/cache/encodeurl-npm-1.0.2-f8c8454c41-e50e3d508c.zip differ diff --git a/.yarn/cache/end-of-stream-npm-1.4.4-497fc6dee1-530a5a5a1e.zip b/.yarn/cache/end-of-stream-npm-1.4.4-497fc6dee1-530a5a5a1e.zip new file mode 100644 index 000000000..fecd2286f Binary files /dev/null and b/.yarn/cache/end-of-stream-npm-1.4.4-497fc6dee1-530a5a5a1e.zip differ diff --git a/.yarn/cache/escape-goat-npm-2.1.1-2e437cf3fe-ce05c70c20.zip b/.yarn/cache/escape-goat-npm-2.1.1-2e437cf3fe-ce05c70c20.zip new file mode 100644 index 000000000..bcf798a59 Binary files /dev/null and b/.yarn/cache/escape-goat-npm-2.1.1-2e437cf3fe-ce05c70c20.zip differ diff --git a/.yarn/cache/escape-html-npm-1.0.3-376c22ee74-6213ca9ae0.zip b/.yarn/cache/escape-html-npm-1.0.3-376c22ee74-6213ca9ae0.zip new file mode 100644 index 000000000..d12a72b12 Binary files /dev/null and b/.yarn/cache/escape-html-npm-1.0.3-376c22ee74-6213ca9ae0.zip differ diff --git a/.yarn/cache/etag-npm-1.8.1-54a3b989d9-571aeb3dbe.zip b/.yarn/cache/etag-npm-1.8.1-54a3b989d9-571aeb3dbe.zip new file mode 100644 index 000000000..e4f07e5fb Binary files /dev/null and b/.yarn/cache/etag-npm-1.8.1-54a3b989d9-571aeb3dbe.zip differ diff --git a/.yarn/cache/express-npm-4.17.1-6815ee6bf9-d964e9e17a.zip b/.yarn/cache/express-npm-4.17.1-6815ee6bf9-d964e9e17a.zip new file mode 100644 index 000000000..88e15b0dd Binary files /dev/null and b/.yarn/cache/express-npm-4.17.1-6815ee6bf9-d964e9e17a.zip differ diff --git a/.yarn/cache/express-npm-4.18.1-842e583ae1-c3d44c92e4.zip b/.yarn/cache/express-npm-4.18.1-842e583ae1-c3d44c92e4.zip new file mode 100644 index 000000000..a9b46e3e1 Binary files /dev/null and b/.yarn/cache/express-npm-4.18.1-842e583ae1-c3d44c92e4.zip differ diff --git a/.yarn/cache/finalhandler-npm-1.1.2-55a75d6b53-617880460c.zip b/.yarn/cache/finalhandler-npm-1.1.2-55a75d6b53-617880460c.zip new file mode 100644 index 000000000..3d0f6f375 Binary files /dev/null and b/.yarn/cache/finalhandler-npm-1.1.2-55a75d6b53-617880460c.zip differ diff --git a/.yarn/cache/finalhandler-npm-1.2.0-593d001463-92effbfd32.zip b/.yarn/cache/finalhandler-npm-1.2.0-593d001463-92effbfd32.zip new file mode 100644 index 000000000..a79b4fb78 Binary files /dev/null and b/.yarn/cache/finalhandler-npm-1.2.0-593d001463-92effbfd32.zip differ diff --git a/.yarn/cache/follow-redirects-npm-1.15.1-6b191885cd-6aa4e3e3cd.zip b/.yarn/cache/follow-redirects-npm-1.15.1-6b191885cd-6aa4e3e3cd.zip new file mode 100644 index 000000000..f48c3fb38 Binary files /dev/null and b/.yarn/cache/follow-redirects-npm-1.15.1-6b191885cd-6aa4e3e3cd.zip differ diff --git a/.yarn/cache/forwarded-npm-0.2.0-6473dabe35-fd27e2394d.zip b/.yarn/cache/forwarded-npm-0.2.0-6473dabe35-fd27e2394d.zip new file mode 100644 index 000000000..64cd57a06 Binary files /dev/null and b/.yarn/cache/forwarded-npm-0.2.0-6473dabe35-fd27e2394d.zip differ diff --git a/.yarn/cache/fresh-npm-0.5.2-ad2bb4c0a2-13ea8b08f9.zip b/.yarn/cache/fresh-npm-0.5.2-ad2bb4c0a2-13ea8b08f9.zip new file mode 100644 index 000000000..643fb82ff Binary files /dev/null and b/.yarn/cache/fresh-npm-0.5.2-ad2bb4c0a2-13ea8b08f9.zip differ diff --git a/.yarn/cache/get-stream-npm-4.1.0-314d430a5d-443e191417.zip b/.yarn/cache/get-stream-npm-4.1.0-314d430a5d-443e191417.zip new file mode 100644 index 000000000..96506105c Binary files /dev/null and b/.yarn/cache/get-stream-npm-4.1.0-314d430a5d-443e191417.zip differ diff --git a/.yarn/cache/get-stream-npm-5.2.0-2cfd3b452b-8bc1a23174.zip b/.yarn/cache/get-stream-npm-5.2.0-2cfd3b452b-8bc1a23174.zip new file mode 100644 index 000000000..f5e0b29aa Binary files /dev/null and b/.yarn/cache/get-stream-npm-5.2.0-2cfd3b452b-8bc1a23174.zip differ diff --git a/.yarn/cache/global-dirs-npm-3.0.0-45faebeb68-953c17cf14.zip b/.yarn/cache/global-dirs-npm-3.0.0-45faebeb68-953c17cf14.zip new file mode 100644 index 000000000..3f2995afe Binary files /dev/null and b/.yarn/cache/global-dirs-npm-3.0.0-45faebeb68-953c17cf14.zip differ diff --git a/.yarn/cache/got-npm-9.6.0-80edc15fd0-941807bd97.zip b/.yarn/cache/got-npm-9.6.0-80edc15fd0-941807bd97.zip new file mode 100644 index 000000000..95d74887b Binary files /dev/null and b/.yarn/cache/got-npm-9.6.0-80edc15fd0-941807bd97.zip differ diff --git a/.yarn/cache/has-yarn-npm-2.1.0-b73f6750d9-5eb1d0bb85.zip b/.yarn/cache/has-yarn-npm-2.1.0-b73f6750d9-5eb1d0bb85.zip new file mode 100644 index 000000000..cca0d8a3a Binary files /dev/null and b/.yarn/cache/has-yarn-npm-2.1.0-b73f6750d9-5eb1d0bb85.zip differ diff --git a/.yarn/cache/http-errors-npm-1.7.2-67163ae1df-5534b0ae08.zip b/.yarn/cache/http-errors-npm-1.7.2-67163ae1df-5534b0ae08.zip new file mode 100644 index 000000000..a298ea7eb Binary files /dev/null and b/.yarn/cache/http-errors-npm-1.7.2-67163ae1df-5534b0ae08.zip differ diff --git a/.yarn/cache/http-errors-npm-1.7.3-f6dc83b082-a59f359473.zip b/.yarn/cache/http-errors-npm-1.7.3-f6dc83b082-a59f359473.zip new file mode 100644 index 000000000..efa2889c2 Binary files /dev/null and b/.yarn/cache/http-errors-npm-1.7.3-f6dc83b082-a59f359473.zip differ diff --git a/.yarn/cache/http-errors-npm-2.0.0-3f1c503428-9b0a378266.zip b/.yarn/cache/http-errors-npm-2.0.0-3f1c503428-9b0a378266.zip new file mode 100644 index 000000000..de7d02217 Binary files /dev/null and b/.yarn/cache/http-errors-npm-2.0.0-3f1c503428-9b0a378266.zip differ diff --git a/.yarn/cache/http-status-codes-npm-2.2.0-8d45a60399-31e1d73085.zip b/.yarn/cache/http-status-codes-npm-2.2.0-8d45a60399-31e1d73085.zip new file mode 100644 index 000000000..9edb83361 Binary files /dev/null and b/.yarn/cache/http-status-codes-npm-2.2.0-8d45a60399-31e1d73085.zip differ diff --git a/.yarn/cache/ignore-by-default-npm-1.0.1-78ea10bc54-441509147b.zip b/.yarn/cache/ignore-by-default-npm-1.0.1-78ea10bc54-441509147b.zip new file mode 100644 index 000000000..fecc35c21 Binary files /dev/null and b/.yarn/cache/ignore-by-default-npm-1.0.1-78ea10bc54-441509147b.zip differ diff --git a/.yarn/cache/import-lazy-npm-2.1.0-b128ce6959-05294f3b9d.zip b/.yarn/cache/import-lazy-npm-2.1.0-b128ce6959-05294f3b9d.zip new file mode 100644 index 000000000..9eabede0e Binary files /dev/null and b/.yarn/cache/import-lazy-npm-2.1.0-b128ce6959-05294f3b9d.zip differ diff --git a/.yarn/cache/ini-npm-2.0.0-28f7426761-e7aadc5fb2.zip b/.yarn/cache/ini-npm-2.0.0-28f7426761-e7aadc5fb2.zip new file mode 100644 index 000000000..377051d24 Binary files /dev/null and b/.yarn/cache/ini-npm-2.0.0-28f7426761-e7aadc5fb2.zip differ diff --git a/.yarn/cache/inversify-express-utils-npm-6.4.3-8478048fb7-4aa9a836fe.zip b/.yarn/cache/inversify-express-utils-npm-6.4.3-8478048fb7-4aa9a836fe.zip new file mode 100644 index 000000000..e76c2f7fb Binary files /dev/null and b/.yarn/cache/inversify-express-utils-npm-6.4.3-8478048fb7-4aa9a836fe.zip differ diff --git a/.yarn/cache/inversify-npm-6.0.1-39ef6784da-b6c9b56ef7.zip b/.yarn/cache/inversify-npm-6.0.1-39ef6784da-b6c9b56ef7.zip new file mode 100644 index 000000000..6211f9717 Binary files /dev/null and b/.yarn/cache/inversify-npm-6.0.1-39ef6784da-b6c9b56ef7.zip differ diff --git a/.yarn/cache/ipaddr.js-npm-1.9.1-19ae7878b4-f88d382598.zip b/.yarn/cache/ipaddr.js-npm-1.9.1-19ae7878b4-f88d382598.zip new file mode 100644 index 000000000..fe2963443 Binary files /dev/null and b/.yarn/cache/ipaddr.js-npm-1.9.1-19ae7878b4-f88d382598.zip differ diff --git a/.yarn/cache/is-binary-path-npm-2.1.0-e61d46f557-84192eb88c.zip b/.yarn/cache/is-binary-path-npm-2.1.0-e61d46f557-84192eb88c.zip new file mode 100644 index 000000000..b509d00f5 Binary files /dev/null and b/.yarn/cache/is-binary-path-npm-2.1.0-e61d46f557-84192eb88c.zip differ diff --git a/.yarn/cache/is-ci-npm-2.0.0-8662a0f445-77b8690575.zip b/.yarn/cache/is-ci-npm-2.0.0-8662a0f445-77b8690575.zip new file mode 100644 index 000000000..c45432484 Binary files /dev/null and b/.yarn/cache/is-ci-npm-2.0.0-8662a0f445-77b8690575.zip differ diff --git a/.yarn/cache/is-installed-globally-npm-0.4.0-a30dd056c7-3359840d59.zip b/.yarn/cache/is-installed-globally-npm-0.4.0-a30dd056c7-3359840d59.zip new file mode 100644 index 000000000..f94dbc064 Binary files /dev/null and b/.yarn/cache/is-installed-globally-npm-0.4.0-a30dd056c7-3359840d59.zip differ diff --git a/.yarn/cache/is-npm-npm-5.0.0-2758bcd54b-9baff02b0c.zip b/.yarn/cache/is-npm-npm-5.0.0-2758bcd54b-9baff02b0c.zip new file mode 100644 index 000000000..e09ab33f3 Binary files /dev/null and b/.yarn/cache/is-npm-npm-5.0.0-2758bcd54b-9baff02b0c.zip differ diff --git a/.yarn/cache/is-path-inside-npm-3.0.3-2ea0ef44fd-abd50f0618.zip b/.yarn/cache/is-path-inside-npm-3.0.3-2ea0ef44fd-abd50f0618.zip new file mode 100644 index 000000000..27f29d70b Binary files /dev/null and b/.yarn/cache/is-path-inside-npm-3.0.3-2ea0ef44fd-abd50f0618.zip differ diff --git a/.yarn/cache/is-yarn-global-npm-0.3.0-18cad00879-bca013d65f.zip b/.yarn/cache/is-yarn-global-npm-0.3.0-18cad00879-bca013d65f.zip new file mode 100644 index 000000000..2eadd438a Binary files /dev/null and b/.yarn/cache/is-yarn-global-npm-0.3.0-18cad00879-bca013d65f.zip differ diff --git a/.yarn/cache/json-buffer-npm-3.0.0-21c267a314-0cecacb802.zip b/.yarn/cache/json-buffer-npm-3.0.0-21c267a314-0cecacb802.zip new file mode 100644 index 000000000..e4303c629 Binary files /dev/null and b/.yarn/cache/json-buffer-npm-3.0.0-21c267a314-0cecacb802.zip differ diff --git a/.yarn/cache/keyv-npm-3.1.0-81c9ff4454-bb7e8f3acf.zip b/.yarn/cache/keyv-npm-3.1.0-81c9ff4454-bb7e8f3acf.zip new file mode 100644 index 000000000..b5940b4b8 Binary files /dev/null and b/.yarn/cache/keyv-npm-3.1.0-81c9ff4454-bb7e8f3acf.zip differ diff --git a/.yarn/cache/latest-version-npm-5.1.0-ddb9b0eb39-fbc72b071e.zip b/.yarn/cache/latest-version-npm-5.1.0-ddb9b0eb39-fbc72b071e.zip new file mode 100644 index 000000000..d4ad35985 Binary files /dev/null and b/.yarn/cache/latest-version-npm-5.1.0-ddb9b0eb39-fbc72b071e.zip differ diff --git a/.yarn/cache/lowercase-keys-npm-1.0.1-0979e653b8-4d04502659.zip b/.yarn/cache/lowercase-keys-npm-1.0.1-0979e653b8-4d04502659.zip new file mode 100644 index 000000000..524b89642 Binary files /dev/null and b/.yarn/cache/lowercase-keys-npm-1.0.1-0979e653b8-4d04502659.zip differ diff --git a/.yarn/cache/lowercase-keys-npm-2.0.0-1876065a32-24d7ebd56c.zip b/.yarn/cache/lowercase-keys-npm-2.0.0-1876065a32-24d7ebd56c.zip new file mode 100644 index 000000000..80588e7bf Binary files /dev/null and b/.yarn/cache/lowercase-keys-npm-2.0.0-1876065a32-24d7ebd56c.zip differ diff --git a/.yarn/cache/lru_map-npm-0.3.3-a038bb3418-ca9dd43c65.zip b/.yarn/cache/lru_map-npm-0.3.3-a038bb3418-ca9dd43c65.zip new file mode 100644 index 000000000..7cf30ebfc Binary files /dev/null and b/.yarn/cache/lru_map-npm-0.3.3-a038bb3418-ca9dd43c65.zip differ diff --git a/.yarn/cache/media-typer-npm-0.3.0-8674f8f0f5-af1b38516c.zip b/.yarn/cache/media-typer-npm-0.3.0-8674f8f0f5-af1b38516c.zip new file mode 100644 index 000000000..1bc097808 Binary files /dev/null and b/.yarn/cache/media-typer-npm-0.3.0-8674f8f0f5-af1b38516c.zip differ diff --git a/.yarn/cache/merge-descriptors-npm-1.0.1-615287aaa8-5abc259d2a.zip b/.yarn/cache/merge-descriptors-npm-1.0.1-615287aaa8-5abc259d2a.zip new file mode 100644 index 000000000..8bba31611 Binary files /dev/null and b/.yarn/cache/merge-descriptors-npm-1.0.1-615287aaa8-5abc259d2a.zip differ diff --git a/.yarn/cache/methods-npm-1.1.2-92f6fdb39b-0917ff4041.zip b/.yarn/cache/methods-npm-1.1.2-92f6fdb39b-0917ff4041.zip new file mode 100644 index 000000000..bce73c59e Binary files /dev/null and b/.yarn/cache/methods-npm-1.1.2-92f6fdb39b-0917ff4041.zip differ diff --git a/.yarn/cache/mime-db-npm-1.52.0-b5371d6fd2-0d99a03585.zip b/.yarn/cache/mime-db-npm-1.52.0-b5371d6fd2-0d99a03585.zip new file mode 100644 index 000000000..8db726357 Binary files /dev/null and b/.yarn/cache/mime-db-npm-1.52.0-b5371d6fd2-0d99a03585.zip differ diff --git a/.yarn/cache/mime-npm-1.6.0-60ae95038a-fef25e3926.zip b/.yarn/cache/mime-npm-1.6.0-60ae95038a-fef25e3926.zip new file mode 100644 index 000000000..498dc2d37 Binary files /dev/null and b/.yarn/cache/mime-npm-1.6.0-60ae95038a-fef25e3926.zip differ diff --git a/.yarn/cache/mime-types-npm-2.1.35-dd9ea9f3e2-89a5b7f1de.zip b/.yarn/cache/mime-types-npm-2.1.35-dd9ea9f3e2-89a5b7f1de.zip new file mode 100644 index 000000000..166d33254 Binary files /dev/null and b/.yarn/cache/mime-types-npm-2.1.35-dd9ea9f3e2-89a5b7f1de.zip differ diff --git a/.yarn/cache/mimic-response-npm-1.0.1-f6f85dde84-034c78753b.zip b/.yarn/cache/mimic-response-npm-1.0.1-f6f85dde84-034c78753b.zip new file mode 100644 index 000000000..acf641b2d Binary files /dev/null and b/.yarn/cache/mimic-response-npm-1.0.1-f6f85dde84-034c78753b.zip differ diff --git a/.yarn/cache/ms-npm-2.0.0-9e1101a471-0e6a22b8b7.zip b/.yarn/cache/ms-npm-2.0.0-9e1101a471-0e6a22b8b7.zip new file mode 100644 index 000000000..1cb6ffa5d Binary files /dev/null and b/.yarn/cache/ms-npm-2.0.0-9e1101a471-0e6a22b8b7.zip differ diff --git a/.yarn/cache/ms-npm-2.1.1-5b4fd72c86-0078a23cd9.zip b/.yarn/cache/ms-npm-2.1.1-5b4fd72c86-0078a23cd9.zip new file mode 100644 index 000000000..32b935a33 Binary files /dev/null and b/.yarn/cache/ms-npm-2.1.1-5b4fd72c86-0078a23cd9.zip differ diff --git a/.yarn/cache/nodemon-npm-2.0.16-f564cd587f-ff818aa91b.zip b/.yarn/cache/nodemon-npm-2.0.16-f564cd587f-ff818aa91b.zip new file mode 100644 index 000000000..f4c7c0b58 Binary files /dev/null and b/.yarn/cache/nodemon-npm-2.0.16-f564cd587f-ff818aa91b.zip differ diff --git a/.yarn/cache/nopt-npm-1.0.10-f3db192976-f62575acea.zip b/.yarn/cache/nopt-npm-1.0.10-f3db192976-f62575acea.zip new file mode 100644 index 000000000..1f5b95d52 Binary files /dev/null and b/.yarn/cache/nopt-npm-1.0.10-f3db192976-f62575acea.zip differ diff --git a/.yarn/cache/normalize-url-npm-4.5.1-603d40bc18-9a9dee01df.zip b/.yarn/cache/normalize-url-npm-4.5.1-603d40bc18-9a9dee01df.zip new file mode 100644 index 000000000..65664646c Binary files /dev/null and b/.yarn/cache/normalize-url-npm-4.5.1-603d40bc18-9a9dee01df.zip differ diff --git a/.yarn/cache/on-finished-npm-2.3.0-4ce92f72c6-1db595bd96.zip b/.yarn/cache/on-finished-npm-2.3.0-4ce92f72c6-1db595bd96.zip new file mode 100644 index 000000000..3afaa2a9b Binary files /dev/null and b/.yarn/cache/on-finished-npm-2.3.0-4ce92f72c6-1db595bd96.zip differ diff --git a/.yarn/cache/on-finished-npm-2.4.1-907af70f88-d20929a25e.zip b/.yarn/cache/on-finished-npm-2.4.1-907af70f88-d20929a25e.zip new file mode 100644 index 000000000..806952bfc Binary files /dev/null and b/.yarn/cache/on-finished-npm-2.4.1-907af70f88-d20929a25e.zip differ diff --git a/.yarn/cache/otplib-npm-12.0.1-77263e8084-4a1b91cf1b.zip b/.yarn/cache/otplib-npm-12.0.1-77263e8084-4a1b91cf1b.zip new file mode 100644 index 000000000..96e6ac6b1 Binary files /dev/null and b/.yarn/cache/otplib-npm-12.0.1-77263e8084-4a1b91cf1b.zip differ diff --git a/.yarn/cache/p-cancelable-npm-1.1.0-d147d5996f-2db3814fef.zip b/.yarn/cache/p-cancelable-npm-1.1.0-d147d5996f-2db3814fef.zip new file mode 100644 index 000000000..19c7d3aa4 Binary files /dev/null and b/.yarn/cache/p-cancelable-npm-1.1.0-d147d5996f-2db3814fef.zip differ diff --git a/.yarn/cache/package-json-npm-6.5.0-30e58237bb-cc9f890d36.zip b/.yarn/cache/package-json-npm-6.5.0-30e58237bb-cc9f890d36.zip new file mode 100644 index 000000000..c6a259133 Binary files /dev/null and b/.yarn/cache/package-json-npm-6.5.0-30e58237bb-cc9f890d36.zip differ diff --git a/.yarn/cache/parseurl-npm-1.3.3-1542397e00-407cee8e0a.zip b/.yarn/cache/parseurl-npm-1.3.3-1542397e00-407cee8e0a.zip new file mode 100644 index 000000000..794eb17d7 Binary files /dev/null and b/.yarn/cache/parseurl-npm-1.3.3-1542397e00-407cee8e0a.zip differ diff --git a/.yarn/cache/path-to-regexp-npm-0.1.7-2605347373-69a14ea24d.zip b/.yarn/cache/path-to-regexp-npm-0.1.7-2605347373-69a14ea24d.zip new file mode 100644 index 000000000..c89765e69 Binary files /dev/null and b/.yarn/cache/path-to-regexp-npm-0.1.7-2605347373-69a14ea24d.zip differ diff --git a/.yarn/cache/prepend-http-npm-2.0.0-e1fc4332f2-7694a95254.zip b/.yarn/cache/prepend-http-npm-2.0.0-e1fc4332f2-7694a95254.zip new file mode 100644 index 000000000..e068e24ed Binary files /dev/null and b/.yarn/cache/prepend-http-npm-2.0.0-e1fc4332f2-7694a95254.zip differ diff --git a/.yarn/cache/prettyjson-npm-1.2.1-045c44c3b6-4786cf7cb7.zip b/.yarn/cache/prettyjson-npm-1.2.1-045c44c3b6-4786cf7cb7.zip new file mode 100644 index 000000000..7db687a44 Binary files /dev/null and b/.yarn/cache/prettyjson-npm-1.2.1-045c44c3b6-4786cf7cb7.zip differ diff --git a/.yarn/cache/proxy-addr-npm-2.0.7-dae6552872-29c6990ce9.zip b/.yarn/cache/proxy-addr-npm-2.0.7-dae6552872-29c6990ce9.zip new file mode 100644 index 000000000..cd0d662a3 Binary files /dev/null and b/.yarn/cache/proxy-addr-npm-2.0.7-dae6552872-29c6990ce9.zip differ diff --git a/.yarn/cache/pstree.remy-npm-1.1.8-2dd5d55de2-5cb53698d6.zip b/.yarn/cache/pstree.remy-npm-1.1.8-2dd5d55de2-5cb53698d6.zip new file mode 100644 index 000000000..dccb458a6 Binary files /dev/null and b/.yarn/cache/pstree.remy-npm-1.1.8-2dd5d55de2-5cb53698d6.zip differ diff --git a/.yarn/cache/pump-npm-3.0.0-0080bf6a7a-e42e9229fb.zip b/.yarn/cache/pump-npm-3.0.0-0080bf6a7a-e42e9229fb.zip new file mode 100644 index 000000000..058568362 Binary files /dev/null and b/.yarn/cache/pump-npm-3.0.0-0080bf6a7a-e42e9229fb.zip differ diff --git a/.yarn/cache/pupa-npm-2.1.1-fb256825ba-49529e5037.zip b/.yarn/cache/pupa-npm-2.1.1-fb256825ba-49529e5037.zip new file mode 100644 index 000000000..2cb125c12 Binary files /dev/null and b/.yarn/cache/pupa-npm-2.1.1-fb256825ba-49529e5037.zip differ diff --git a/.yarn/cache/qs-npm-6.10.3-172e1a3fb7-0fac5e6c71.zip b/.yarn/cache/qs-npm-6.10.3-172e1a3fb7-0fac5e6c71.zip new file mode 100644 index 000000000..c8c26218e Binary files /dev/null and b/.yarn/cache/qs-npm-6.10.3-172e1a3fb7-0fac5e6c71.zip differ diff --git a/.yarn/cache/qs-npm-6.7.0-15161a344c-dfd5f6adef.zip b/.yarn/cache/qs-npm-6.7.0-15161a344c-dfd5f6adef.zip new file mode 100644 index 000000000..1b86b457b Binary files /dev/null and b/.yarn/cache/qs-npm-6.7.0-15161a344c-dfd5f6adef.zip differ diff --git a/.yarn/cache/range-parser-npm-1.2.1-1a470fa390-0a268d4fea.zip b/.yarn/cache/range-parser-npm-1.2.1-1a470fa390-0a268d4fea.zip new file mode 100644 index 000000000..7b40d5913 Binary files /dev/null and b/.yarn/cache/range-parser-npm-1.2.1-1a470fa390-0a268d4fea.zip differ diff --git a/.yarn/cache/raw-body-npm-2.4.0-14d9d633af-6343906939.zip b/.yarn/cache/raw-body-npm-2.4.0-14d9d633af-6343906939.zip new file mode 100644 index 000000000..3888b70fd Binary files /dev/null and b/.yarn/cache/raw-body-npm-2.4.0-14d9d633af-6343906939.zip differ diff --git a/.yarn/cache/raw-body-npm-2.5.1-9dd1d9fff9-5362adff15.zip b/.yarn/cache/raw-body-npm-2.5.1-9dd1d9fff9-5362adff15.zip new file mode 100644 index 000000000..1ab188289 Binary files /dev/null and b/.yarn/cache/raw-body-npm-2.5.1-9dd1d9fff9-5362adff15.zip differ diff --git a/.yarn/cache/rc-npm-1.2.8-d6768ac936-2e26e052f8.zip b/.yarn/cache/rc-npm-1.2.8-d6768ac936-2e26e052f8.zip new file mode 100644 index 000000000..f7372f98e Binary files /dev/null and b/.yarn/cache/rc-npm-1.2.8-d6768ac936-2e26e052f8.zip differ diff --git a/.yarn/cache/readdirp-npm-3.6.0-f950cc74ab-1ced032e6e.zip b/.yarn/cache/readdirp-npm-3.6.0-f950cc74ab-1ced032e6e.zip new file mode 100644 index 000000000..f3687812b Binary files /dev/null and b/.yarn/cache/readdirp-npm-3.6.0-f950cc74ab-1ced032e6e.zip differ diff --git a/.yarn/cache/registry-auth-token-npm-4.2.2-ffd70a9849-c503019854.zip b/.yarn/cache/registry-auth-token-npm-4.2.2-ffd70a9849-c503019854.zip new file mode 100644 index 000000000..aac4909a0 Binary files /dev/null and b/.yarn/cache/registry-auth-token-npm-4.2.2-ffd70a9849-c503019854.zip differ diff --git a/.yarn/cache/registry-url-npm-5.1.0-f58d0ca7ff-bcea86c84a.zip b/.yarn/cache/registry-url-npm-5.1.0-f58d0ca7ff-bcea86c84a.zip new file mode 100644 index 000000000..de1542129 Binary files /dev/null and b/.yarn/cache/registry-url-npm-5.1.0-f58d0ca7ff-bcea86c84a.zip differ diff --git a/.yarn/cache/responselike-npm-1.0.2-d0bf50cde4-2e9e70f1dc.zip b/.yarn/cache/responselike-npm-1.0.2-d0bf50cde4-2e9e70f1dc.zip new file mode 100644 index 000000000..28377c26a Binary files /dev/null and b/.yarn/cache/responselike-npm-1.0.2-d0bf50cde4-2e9e70f1dc.zip differ diff --git a/.yarn/cache/semver-diff-npm-3.1.1-1207a795e9-8bbe5a5d7a.zip b/.yarn/cache/semver-diff-npm-3.1.1-1207a795e9-8bbe5a5d7a.zip new file mode 100644 index 000000000..29223bb3d Binary files /dev/null and b/.yarn/cache/semver-diff-npm-3.1.1-1207a795e9-8bbe5a5d7a.zip differ diff --git a/.yarn/cache/send-npm-0.17.1-aad5512679-d214c2fa42.zip b/.yarn/cache/send-npm-0.17.1-aad5512679-d214c2fa42.zip new file mode 100644 index 000000000..fd6259c71 Binary files /dev/null and b/.yarn/cache/send-npm-0.17.1-aad5512679-d214c2fa42.zip differ diff --git a/.yarn/cache/send-npm-0.18.0-faadf6353f-74fc07ebb5.zip b/.yarn/cache/send-npm-0.18.0-faadf6353f-74fc07ebb5.zip new file mode 100644 index 000000000..72320b46d Binary files /dev/null and b/.yarn/cache/send-npm-0.18.0-faadf6353f-74fc07ebb5.zip differ diff --git a/.yarn/cache/serve-static-npm-1.14.1-a7afb1d3b3-c6b268e848.zip b/.yarn/cache/serve-static-npm-1.14.1-a7afb1d3b3-c6b268e848.zip new file mode 100644 index 000000000..7228e0cfc Binary files /dev/null and b/.yarn/cache/serve-static-npm-1.14.1-a7afb1d3b3-c6b268e848.zip differ diff --git a/.yarn/cache/serve-static-npm-1.15.0-86c81879f5-af57fc13be.zip b/.yarn/cache/serve-static-npm-1.15.0-86c81879f5-af57fc13be.zip new file mode 100644 index 000000000..b5719539a Binary files /dev/null and b/.yarn/cache/serve-static-npm-1.15.0-86c81879f5-af57fc13be.zip differ diff --git a/.yarn/cache/setprototypeof-npm-1.1.1-706b6318ec-a8bee29c1c.zip b/.yarn/cache/setprototypeof-npm-1.1.1-706b6318ec-a8bee29c1c.zip new file mode 100644 index 000000000..db6f60e87 Binary files /dev/null and b/.yarn/cache/setprototypeof-npm-1.1.1-706b6318ec-a8bee29c1c.zip differ diff --git a/.yarn/cache/setprototypeof-npm-1.2.0-0fedbdcd3a-be18cbbf70.zip b/.yarn/cache/setprototypeof-npm-1.2.0-0fedbdcd3a-be18cbbf70.zip new file mode 100644 index 000000000..f6bd1cbd7 Binary files /dev/null and b/.yarn/cache/setprototypeof-npm-1.2.0-0fedbdcd3a-be18cbbf70.zip differ diff --git a/.yarn/cache/statuses-npm-1.5.0-f88f91b2e9-c469b9519d.zip b/.yarn/cache/statuses-npm-1.5.0-f88f91b2e9-c469b9519d.zip new file mode 100644 index 000000000..5517a9447 Binary files /dev/null and b/.yarn/cache/statuses-npm-1.5.0-f88f91b2e9-c469b9519d.zip differ diff --git a/.yarn/cache/statuses-npm-2.0.1-81d2b97fee-18c7623fdb.zip b/.yarn/cache/statuses-npm-2.0.1-81d2b97fee-18c7623fdb.zip new file mode 100644 index 000000000..d54195d67 Binary files /dev/null and b/.yarn/cache/statuses-npm-2.0.1-81d2b97fee-18c7623fdb.zip differ diff --git a/.yarn/cache/strip-json-comments-npm-2.0.1-e7883b2d04-1074ccb632.zip b/.yarn/cache/strip-json-comments-npm-2.0.1-e7883b2d04-1074ccb632.zip new file mode 100644 index 000000000..9c537fe05 Binary files /dev/null and b/.yarn/cache/strip-json-comments-npm-2.0.1-e7883b2d04-1074ccb632.zip differ diff --git a/.yarn/cache/thirty-two-npm-1.0.2-9d9270aa34-f6700b31d1.zip b/.yarn/cache/thirty-two-npm-1.0.2-9d9270aa34-f6700b31d1.zip new file mode 100644 index 000000000..d469956ce Binary files /dev/null and b/.yarn/cache/thirty-two-npm-1.0.2-9d9270aa34-f6700b31d1.zip differ diff --git a/.yarn/cache/to-readable-stream-npm-1.0.0-4fa4da8130-2bd7778490.zip b/.yarn/cache/to-readable-stream-npm-1.0.0-4fa4da8130-2bd7778490.zip new file mode 100644 index 000000000..85ae12722 Binary files /dev/null and b/.yarn/cache/to-readable-stream-npm-1.0.0-4fa4da8130-2bd7778490.zip differ diff --git a/.yarn/cache/toidentifier-npm-1.0.0-5dad252f90-199e6bfca1.zip b/.yarn/cache/toidentifier-npm-1.0.0-5dad252f90-199e6bfca1.zip new file mode 100644 index 000000000..27ee34cbc Binary files /dev/null and b/.yarn/cache/toidentifier-npm-1.0.0-5dad252f90-199e6bfca1.zip differ diff --git a/.yarn/cache/toidentifier-npm-1.0.1-f759712599-952c29e2a8.zip b/.yarn/cache/toidentifier-npm-1.0.1-f759712599-952c29e2a8.zip new file mode 100644 index 000000000..595363e93 Binary files /dev/null and b/.yarn/cache/toidentifier-npm-1.0.1-f759712599-952c29e2a8.zip differ diff --git a/.yarn/cache/touch-npm-3.1.0-e2eacebbda-e0be589cb5.zip b/.yarn/cache/touch-npm-3.1.0-e2eacebbda-e0be589cb5.zip new file mode 100644 index 000000000..84e3b2380 Binary files /dev/null and b/.yarn/cache/touch-npm-3.1.0-e2eacebbda-e0be589cb5.zip differ diff --git a/.yarn/cache/type-is-npm-1.6.18-6dee4d4961-2c8e47675d.zip b/.yarn/cache/type-is-npm-1.6.18-6dee4d4961-2c8e47675d.zip new file mode 100644 index 000000000..3bfed96dc Binary files /dev/null and b/.yarn/cache/type-is-npm-1.6.18-6dee4d4961-2c8e47675d.zip differ diff --git a/.yarn/cache/ua-parser-js-npm-1.0.2-c3376785e2-ff7f6d79a9.zip b/.yarn/cache/ua-parser-js-npm-1.0.2-c3376785e2-ff7f6d79a9.zip new file mode 100644 index 000000000..c48c9df4a Binary files /dev/null and b/.yarn/cache/ua-parser-js-npm-1.0.2-c3376785e2-ff7f6d79a9.zip differ diff --git a/.yarn/cache/undefsafe-npm-2.0.5-8c3bbf9354-f42ab3b577.zip b/.yarn/cache/undefsafe-npm-2.0.5-8c3bbf9354-f42ab3b577.zip new file mode 100644 index 000000000..ef05395eb Binary files /dev/null and b/.yarn/cache/undefsafe-npm-2.0.5-8c3bbf9354-f42ab3b577.zip differ diff --git a/.yarn/cache/unique-string-npm-2.0.0-3153c97e47-ef68f63913.zip b/.yarn/cache/unique-string-npm-2.0.0-3153c97e47-ef68f63913.zip new file mode 100644 index 000000000..50776c317 Binary files /dev/null and b/.yarn/cache/unique-string-npm-2.0.0-3153c97e47-ef68f63913.zip differ diff --git a/.yarn/cache/unpipe-npm-1.0.0-2ed2a3c2bf-4fa18d8d8d.zip b/.yarn/cache/unpipe-npm-1.0.0-2ed2a3c2bf-4fa18d8d8d.zip new file mode 100644 index 000000000..380809cf6 Binary files /dev/null and b/.yarn/cache/unpipe-npm-1.0.0-2ed2a3c2bf-4fa18d8d8d.zip differ diff --git a/.yarn/cache/update-notifier-npm-5.1.0-6bf595ecee-461e5e5b00.zip b/.yarn/cache/update-notifier-npm-5.1.0-6bf595ecee-461e5e5b00.zip new file mode 100644 index 000000000..385b3119f Binary files /dev/null and b/.yarn/cache/update-notifier-npm-5.1.0-6bf595ecee-461e5e5b00.zip differ diff --git a/.yarn/cache/url-parse-lax-npm-3.0.0-92aa8effa0-1040e35775.zip b/.yarn/cache/url-parse-lax-npm-3.0.0-92aa8effa0-1040e35775.zip new file mode 100644 index 000000000..b267d7034 Binary files /dev/null and b/.yarn/cache/url-parse-lax-npm-3.0.0-92aa8effa0-1040e35775.zip differ diff --git a/.yarn/cache/utils-merge-npm-1.0.1-363bbdfbca-c810954932.zip b/.yarn/cache/utils-merge-npm-1.0.1-363bbdfbca-c810954932.zip new file mode 100644 index 000000000..8164f0572 Binary files /dev/null and b/.yarn/cache/utils-merge-npm-1.0.1-363bbdfbca-c810954932.zip differ diff --git a/.yarn/cache/vary-npm-1.1.2-b49f70ae63-ae0123222c.zip b/.yarn/cache/vary-npm-1.1.2-b49f70ae63-ae0123222c.zip new file mode 100644 index 000000000..6ef083146 Binary files /dev/null and b/.yarn/cache/vary-npm-1.1.2-b49f70ae63-ae0123222c.zip differ diff --git a/.yarn/cache/widest-line-npm-3.1.0-717bf2680b-03db6c9d0a.zip b/.yarn/cache/widest-line-npm-3.1.0-717bf2680b-03db6c9d0a.zip new file mode 100644 index 000000000..4b9315faf Binary files /dev/null and b/.yarn/cache/widest-line-npm-3.1.0-717bf2680b-03db6c9d0a.zip differ diff --git a/.yarn/cache/xdg-basedir-npm-4.0.0-ed08d380e2-0073d5b59a.zip b/.yarn/cache/xdg-basedir-npm-4.0.0-ed08d380e2-0073d5b59a.zip new file mode 100644 index 000000000..3bf6cb242 Binary files /dev/null and b/.yarn/cache/xdg-basedir-npm-4.0.0-ed08d380e2-0073d5b59a.zip differ diff --git a/jest.config.js b/jest.config.js index f1fab6045..942191f23 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,19 +2,12 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.ts$', - testPathIgnorePatterns: [ - '/node_modules/', - '/dist/', - '/goldstackLocal/', - '/distWeb/', - '/distLambda/', - '.d.ts', - ], globals: { 'ts-jest': { tsconfig: './linter.tsconfig.json', }, }, + testTimeout: 20000, coverageThreshold: { global: { branches: 100, diff --git a/linter.tsconfig.json b/linter.tsconfig.json new file mode 100644 index 000000000..67d92b038 --- /dev/null +++ b/linter.tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["dist", "test-setup.ts"] +} diff --git a/package.json b/package.json index ce9c3de21..5420d8ece 100644 --- a/package.json +++ b/package.json @@ -11,14 +11,19 @@ "node": ">=16.0.0 <17.0.0" }, "scripts": { - "lint": "yarn workspaces foreach -pt --parallel --jobs 10 --verbose run lint", + "lint": "yarn workspaces foreach -p -j 10 --verbose run lint", + "lint:auth": "yarn workspace @standardnotes/auth-server lint", "lint:scheduler": "yarn workspace @standardnotes/scheduler-server lint", - "test": "yarn workspaces foreach -pt --parallel --jobs 10 --verbose run test", + "test": "yarn workspaces foreach -p -j 10 --verbose run test", + "test:auth": "yarn workspace @standardnotes/auth-server test", "test:scheduler": "yarn workspace @standardnotes/scheduler-server test", - "clean": "yarn workspaces foreach -pt --parallel --jobs 10 --verbose run clean", - "build:all": "yarn workspaces foreach -pt --verbose run build", - "setup:env": "yarn workspaces foreach -pt --verbose run setup:env", + "clean": "yarn workspaces foreach -p --verbose run clean", + "setup:env": "yarn workspaces foreach -p --verbose run setup:env", + "build": "yarn workspaces foreach -pt -j 10 --verbose run build", + "build:auth": "yarn workspace @standardnotes/auth-server build", "build:scheduler": "yarn workspace @standardnotes/scheduler-server build", + "start:auth": "yarn workspace @standardnotes/auth-server start", + "start:auth-worker": "yarn workspace @standardnotes/auth-server worker", "start:scheduler": "yarn workspace @standardnotes/scheduler-server worker", "release:beta": "lerna version --conventional-prerelease --conventional-commits --yes -m \"chore(release): publish\"" }, @@ -28,6 +33,7 @@ "@lerna-lite/cli": "^1.5.1", "@lerna-lite/list": "^1.5.1", "@lerna-lite/run": "^1.5.1", + "@types/jest": "^28.1.3", "@typescript-eslint/parser": "^5.29.0", "eslint": "^8.17.0", "eslint-config-prettier": "^8.5.0", diff --git a/packages/auth/.env.sample b/packages/auth/.env.sample new file mode 100644 index 000000000..811c23a02 --- /dev/null +++ b/packages/auth/.env.sample @@ -0,0 +1,70 @@ +LOG_LEVEL=debug +NODE_ENV=development +VERSION=development + +JWT_SECRET=secret +LEGACY_JWT_SECRET=legacy_jwt_secret +AUTH_JWT_SECRET=auth_jwt_secret +AUTH_JWT_TTL=60000 + +# Must be a hex string exactly 32 bytes long +# e.g. feffe9928665731c6d6a8f9467308308feffe9928665731c6d6a8f9467308308 +ENCRYPTION_SERVER_KEY=change-me-! + +PORT=3000 + +DB_HOST=127.0.0.1 +DB_REPLICA_HOST=127.0.0.1 +DB_PORT=3306 +DB_USERNAME=auth +DB_PASSWORD=changeme123 +DB_DATABASE=auth +DB_DEBUG_LEVEL=all # "all" | "query" | "schema" | "error" | "warn" | "info" | "log" | "migration" +DB_MIGRATIONS_PATH=dist/migrations/*.js + +REDIS_URL=redis://cache + +DISABLE_USER_REGISTRATION=false + +ACCESS_TOKEN_AGE=5184000 +REFRESH_TOKEN_AGE=31556926 + +EPHEMERAL_SESSION_AGE=259200 + +MAX_LOGIN_ATTEMPTS=6 +FAILED_LOGIN_LOCKOUT=3600 + +PSEUDO_KEY_PARAMS_KEY=secret_key + +SNS_TOPIC_ARN= +SNS_AWS_REGION= +SQS_QUEUE_URL= +SQS_AWS_REGION= + +SYNCING_SERVER_URL=http://syncing-server-js:3000 + +REDIS_EVENTS_CHANNEL=events + +# (Optional) New Relic Setup +NEW_RELIC_ENABLED=false +NEW_RELIC_APP_NAME=Auth +NEW_RELIC_LICENSE_KEY= +NEW_RELIC_NO_CONFIG_FILE=true +NEW_RELIC_DISTRIBUTED_TRACING_ENABLED=false +NEW_RELIC_LOG_ENABLED=false +NEW_RELIC_LOG_LEVEL=info + +# (Optional) User Server +USER_SERVER_REGISTRATION_URL= +USER_SERVER_CHANGE_EMAIL_URL= +USER_SERVER_AUTH_KEY= + +# (Optional) Sentry Setup +SENTRY_DSN= +SENTRY_ENVIRONMENT= + +VALET_TOKEN_SECRET= +VALET_TOKEN_TTL= + +# (Optional) Analytics +ANALYTICS_ENABLED=false diff --git a/packages/auth/.eslintignore b/packages/auth/.eslintignore new file mode 100644 index 000000000..4186e3d19 --- /dev/null +++ b/packages/auth/.eslintignore @@ -0,0 +1,3 @@ +dist +test-setup.ts +data diff --git a/packages/auth/.eslintrc b/packages/auth/.eslintrc new file mode 100644 index 000000000..cb7136174 --- /dev/null +++ b/packages/auth/.eslintrc @@ -0,0 +1,6 @@ +{ + "extends": "../../.eslintrc", + "parserOptions": { + "project": "./linter.tsconfig.json" + } +} diff --git a/packages/auth/Dockerfile b/packages/auth/Dockerfile new file mode 100644 index 000000000..b878705d6 --- /dev/null +++ b/packages/auth/Dockerfile @@ -0,0 +1,25 @@ +FROM node:16.15.1-alpine AS builder + +# Install dependencies for building native libraries +RUN apk add --update git openssh-client python3 alpine-sdk + +WORKDIR /workspace + +# docker-build plugin copies everything needed for `yarn install` to `manifests` folder. +COPY manifests ./ + +RUN yarn install --immutable + +FROM node:16.15.1-alpine + +WORKDIR /workspace + +# Copy the installed dependencies from the previous stage. +COPY --from=builder /workspace ./ + +# docker-build plugin runs `yarn pack` in all workspace dependencies and copies them to `packs` folder. +COPY packs ./ + +ENTRYPOINT [ "/workspace/packages/auth/docker/entrypoint.sh" ] + +CMD [ "start-web" ] diff --git a/packages/auth/bin/backup.ts b/packages/auth/bin/backup.ts new file mode 100644 index 000000000..286be9dd1 --- /dev/null +++ b/packages/auth/bin/backup.ts @@ -0,0 +1,234 @@ +import 'reflect-metadata' + +import 'newrelic' + +import { Stream } from 'stream' + +import { Logger } from 'winston' +import * as dayjs from 'dayjs' +import * as utc from 'dayjs/plugin/utc' +import { AnalyticsActivity, AnalyticsStoreInterface, Period } from '@standardnotes/analytics' + +import { ContainerConfigLoader } from '../src/Bootstrap/Container' +import TYPES from '../src/Bootstrap/Types' +import { Env } from '../src/Bootstrap/Env' +import { DomainEventPublisherInterface } from '@standardnotes/domain-events' +import { DomainEventFactoryInterface } from '../src/Domain/Event/DomainEventFactoryInterface' +import { SettingRepositoryInterface } from '../src/Domain/Setting/SettingRepositoryInterface' +import { MuteFailedBackupsEmailsOption, MuteFailedCloudBackupsEmailsOption, SettingName } from '@standardnotes/settings' +import { RoleServiceInterface } from '../src/Domain/Role/RoleServiceInterface' +import { PermissionName } from '@standardnotes/features' +import { SettingServiceInterface } from '../src/Domain/Setting/SettingServiceInterface' +import { AnalyticsEntityRepositoryInterface } from '../src/Domain/Analytics/AnalyticsEntityRepositoryInterface' + +const inputArgs = process.argv.slice(2) +const backupProvider = inputArgs[0] +const backupFrequency = inputArgs[1] + +const shouldEmailBackupBeTriggered = async ( + analyticsId: number, + analyticsStore: AnalyticsStoreInterface, +): Promise => { + let periods = [Period.Today, Period.Yesterday] + if (backupFrequency === 'weekly') { + periods = [Period.ThisWeek, Period.LastWeek] + } + + for (const period of periods) { + const wasUnBackedUpDataCreatedInPeriod = await analyticsStore.wasActivityDone( + AnalyticsActivity.EmailUnbackedUpData, + analyticsId, + period, + ) + if (wasUnBackedUpDataCreatedInPeriod) { + return true + } + } + + return false +} + +const requestBackups = async ( + settingRepository: SettingRepositoryInterface, + roleService: RoleServiceInterface, + settingService: SettingServiceInterface, + domainEventFactory: DomainEventFactoryInterface, + domainEventPublisher: DomainEventPublisherInterface, + analyticsEntityRepository: AnalyticsEntityRepositoryInterface, + analyticsStore: AnalyticsStoreInterface, + logger: Logger, +): Promise => { + let settingName: SettingName, + permissionName: PermissionName, + muteEmailsSettingName: SettingName, + muteEmailsSettingValue: string, + providerTokenSettingName: SettingName + switch (backupProvider) { + case 'email': + settingName = SettingName.EmailBackupFrequency + permissionName = PermissionName.DailyEmailBackup + muteEmailsSettingName = SettingName.MuteFailedBackupsEmails + muteEmailsSettingValue = MuteFailedBackupsEmailsOption.Muted + break + case 'dropbox': + settingName = SettingName.DropboxBackupFrequency + permissionName = PermissionName.DailyDropboxBackup + muteEmailsSettingName = SettingName.MuteFailedCloudBackupsEmails + muteEmailsSettingValue = MuteFailedCloudBackupsEmailsOption.Muted + providerTokenSettingName = SettingName.DropboxBackupToken + break + case 'one_drive': + settingName = SettingName.OneDriveBackupFrequency + permissionName = PermissionName.DailyOneDriveBackup + muteEmailsSettingName = SettingName.MuteFailedCloudBackupsEmails + muteEmailsSettingValue = MuteFailedCloudBackupsEmailsOption.Muted + providerTokenSettingName = SettingName.OneDriveBackupToken + break + case 'google_drive': + settingName = SettingName.GoogleDriveBackupFrequency + permissionName = PermissionName.DailyGDriveBackup + muteEmailsSettingName = SettingName.MuteFailedCloudBackupsEmails + muteEmailsSettingValue = MuteFailedCloudBackupsEmailsOption.Muted + providerTokenSettingName = SettingName.GoogleDriveBackupToken + break + default: + throw new Error(`Not handled backup provider: ${backupProvider}`) + } + + const stream = await settingRepository.streamAllByNameAndValue(settingName, backupFrequency) + + return new Promise((resolve, reject) => { + stream + .pipe( + new Stream.Transform({ + objectMode: true, + transform: async (setting, _encoding, callback) => { + const userIsPermittedForEmailBackups = await roleService.userHasPermission( + setting.setting_user_uuid, + permissionName, + ) + if (!userIsPermittedForEmailBackups) { + callback() + + return + } + + let userHasEmailsMuted = false + const emailsMutedSetting = await settingRepository.findOneByNameAndUserUuid( + muteEmailsSettingName, + setting.setting_user_uuid, + ) + if (emailsMutedSetting !== null && emailsMutedSetting.value !== null) { + userHasEmailsMuted = emailsMutedSetting.value === muteEmailsSettingValue + } + + if (backupProvider === 'email') { + const analyticsEntity = await analyticsEntityRepository.findOneByUserUuid(setting.setting_user_uuid) + if (analyticsEntity === null) { + callback() + + return + } + + const emailBackupsShouldBeTriggered = await shouldEmailBackupBeTriggered( + analyticsEntity.id, + analyticsStore, + ) + if (!emailBackupsShouldBeTriggered) { + logger.info( + `Email backup for user ${setting.setting_user_uuid} should not be triggered due to inactivity. It will be triggered until further changes.`, + ) + } + + await domainEventPublisher.publish( + domainEventFactory.createEmailBackupRequestedEvent( + setting.setting_user_uuid, + emailsMutedSetting?.uuid as string, + userHasEmailsMuted, + ), + ) + + await analyticsStore.markActivity([AnalyticsActivity.EmailBackup], analyticsEntity.id, [ + Period.Today, + Period.ThisWeek, + ]) + await analyticsStore.unmarkActivity([AnalyticsActivity.EmailUnbackedUpData], analyticsEntity.id, [ + Period.Today, + Period.ThisWeek, + ]) + + callback() + + return + } + + const cloudBackupProviderToken = await settingService.findSettingWithDecryptedValue({ + settingName: providerTokenSettingName, + userUuid: setting.setting_user_uuid, + }) + if (cloudBackupProviderToken === null || cloudBackupProviderToken.value === null) { + callback() + + return + } + + await domainEventPublisher.publish( + domainEventFactory.createCloudBackupRequestedEvent( + backupProvider.toUpperCase() as 'DROPBOX' | 'ONE_DRIVE' | 'GOOGLE_DRIVE', + cloudBackupProviderToken.value, + setting.setting_user_uuid, + emailsMutedSetting?.uuid as string, + userHasEmailsMuted, + ), + ) + callback() + }, + }), + ) + .on('finish', resolve) + .on('error', reject) + }) +} + +const container = new ContainerConfigLoader() +void container.load().then((container) => { + dayjs.extend(utc) + + const env: Env = new Env() + env.load() + + const logger: Logger = container.get(TYPES.Logger) + + logger.info(`Starting ${backupFrequency} ${backupProvider} backup requesting...`) + + const settingRepository: SettingRepositoryInterface = container.get(TYPES.SettingRepository) + const roleService: RoleServiceInterface = container.get(TYPES.RoleService) + const settingService: SettingServiceInterface = container.get(TYPES.SettingService) + const domainEventFactory: DomainEventFactoryInterface = container.get(TYPES.DomainEventFactory) + const domainEventPublisher: DomainEventPublisherInterface = container.get(TYPES.DomainEventPublisher) + const analyticsEntityRepository: AnalyticsEntityRepositoryInterface = container.get(TYPES.AnalyticsEntityRepository) + const analyticsStore: AnalyticsStoreInterface = container.get(TYPES.AnalyticsStore) + + Promise.resolve( + requestBackups( + settingRepository, + roleService, + settingService, + domainEventFactory, + domainEventPublisher, + analyticsEntityRepository, + analyticsStore, + logger, + ), + ) + .then(() => { + logger.info(`${backupFrequency} ${backupProvider} backup requesting complete`) + + process.exit(0) + }) + .catch((error) => { + logger.error(`Could not finish ${backupFrequency} ${backupProvider} backup requesting: ${error.message}`) + + process.exit(1) + }) +}) diff --git a/packages/auth/bin/server.ts b/packages/auth/bin/server.ts new file mode 100644 index 000000000..2566222bd --- /dev/null +++ b/packages/auth/bin/server.ts @@ -0,0 +1,89 @@ +import 'reflect-metadata' + +import 'newrelic' + +import * as Sentry from '@sentry/node' + +import '../src/Controller/HealthCheckController' +import '../src/Controller/SessionController' +import '../src/Controller/SessionsController' +import '../src/Controller/UsersController' +import '../src/Controller/SettingsController' +import '../src/Controller/FeaturesController' +import '../src/Controller/WebSocketsController' +import '../src/Controller/AdminController' +import '../src/Controller/InternalController' +import '../src/Controller/SubscriptionTokensController' +import '../src/Controller/OfflineController' +import '../src/Controller/ValetTokenController' +import '../src/Controller/ListedController' +import '../src/Controller/SubscriptionInvitesController' +import '../src/Controller/SubscriptionSettingsController' + +import '../src/Infra/InversifyExpressUtils/InversifyExpressAuthController' + +import * as cors from 'cors' +import { urlencoded, json, Request, Response, NextFunction, RequestHandler, ErrorRequestHandler } from 'express' +import * as winston from 'winston' +import * as dayjs from 'dayjs' +import * as utc from 'dayjs/plugin/utc' + +import { InversifyExpressServer } from 'inversify-express-utils' +import { ContainerConfigLoader } from '../src/Bootstrap/Container' +import TYPES from '../src/Bootstrap/Types' +import { Env } from '../src/Bootstrap/Env' + +const container = new ContainerConfigLoader() +void container.load().then((container) => { + dayjs.extend(utc) + + const env: Env = new Env() + env.load() + + const server = new InversifyExpressServer(container) + + server.setConfig((app) => { + app.use((_request: Request, response: Response, next: NextFunction) => { + response.setHeader('X-Auth-Version', container.get(TYPES.VERSION)) + next() + }) + app.use(json()) + app.use(urlencoded({ extended: true })) + app.use(cors()) + + if (env.get('SENTRY_DSN', true)) { + Sentry.init({ + dsn: env.get('SENTRY_DSN'), + integrations: [new Sentry.Integrations.Http({ tracing: false, breadcrumbs: true })], + tracesSampleRate: 0, + }) + + app.use(Sentry.Handlers.requestHandler() as RequestHandler) + } + }) + + const logger: winston.Logger = container.get(TYPES.Logger) + + server.setErrorConfig((app) => { + if (env.get('SENTRY_DSN', true)) { + app.use(Sentry.Handlers.errorHandler() as ErrorRequestHandler) + } + + app.use((error: Record, _request: Request, response: Response, _next: NextFunction) => { + logger.error(error.stack) + + response.status(500).send({ + error: { + message: + "Unfortunately, we couldn't handle your request. Please try again or contact our support if the error persists.", + }, + }) + }) + }) + + const serverInstance = server.build() + + serverInstance.listen(env.get('PORT')) + + logger.info(`Server started on port ${process.env.PORT}`) +}) diff --git a/packages/auth/bin/worker.ts b/packages/auth/bin/worker.ts new file mode 100644 index 000000000..67004db20 --- /dev/null +++ b/packages/auth/bin/worker.ts @@ -0,0 +1,29 @@ +import 'reflect-metadata' + +import 'newrelic' + +import { Logger } from 'winston' + +import { ContainerConfigLoader } from '../src/Bootstrap/Container' +import TYPES from '../src/Bootstrap/Types' +import { Env } from '../src/Bootstrap/Env' +import { DomainEventSubscriberFactoryInterface } from '@standardnotes/domain-events' +import * as dayjs from 'dayjs' +import * as utc from 'dayjs/plugin/utc' + +const container = new ContainerConfigLoader() +void container.load().then((container) => { + dayjs.extend(utc) + + const env: Env = new Env() + env.load() + + const logger: Logger = container.get(TYPES.Logger) + + logger.info('Starting worker...') + + const subscriberFactory: DomainEventSubscriberFactoryInterface = container.get(TYPES.DomainEventSubscriberFactory) + subscriberFactory.create().start() + + setInterval(() => logger.info('Alive and kicking!'), 20 * 60 * 1000) +}) diff --git a/packages/auth/docker/entrypoint.sh b/packages/auth/docker/entrypoint.sh new file mode 100755 index 000000000..11de7c993 --- /dev/null +++ b/packages/auth/docker/entrypoint.sh @@ -0,0 +1,52 @@ +#!/bin/sh +set -e + +COMMAND=$1 && shift 1 + +case "$COMMAND" in + 'start-local' ) + echo "Starting Web..." + yarn start:local + ;; + + 'start-web' ) + echo "Starting Web..." + yarn start + ;; + + 'start-worker' ) + echo "Starting Worker..." + yarn worker + ;; + + 'email-daily-backup' ) + echo "Starting Email Daily Backup..." + yarn daily-backup:email + ;; + + 'email-weekly-backup' ) + echo "Starting Email Weekly Backup..." + yarn weekly-backup:email + ;; + + 'dropbox-daily-backup' ) + echo "Starting Dropbox Daily Backup..." + yarn daily-backup:dropbox + ;; + + 'google-drive-daily-backup' ) + echo "Starting Google Drive Daily Backup..." + yarn daily-backup:google_drive + ;; + + 'one-drive-daily-backup' ) + echo "Starting One Drive Daily Backup..." + yarn daily-backup:one_drive + ;; + + * ) + echo "Unknown command" + ;; +esac + +exec "$@" diff --git a/packages/auth/jest.config.js b/packages/auth/jest.config.js new file mode 100644 index 000000000..ec1c99454 --- /dev/null +++ b/packages/auth/jest.config.js @@ -0,0 +1,19 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const base = require('../../jest.config'); + +module.exports = { + ...base, + globals: { + 'ts-jest': { + tsconfig: 'tsconfig.json', + }, + }, + coveragePathIgnorePatterns: [ + '/Bootstrap/', + '/InversifyExpressUtils/', + 'HealthCheckController' + ], + setupFilesAfterEnv: [ + './test-setup.ts' + ] +}; diff --git a/packages/auth/linter.tsconfig.json b/packages/auth/linter.tsconfig.json new file mode 100644 index 000000000..67d92b038 --- /dev/null +++ b/packages/auth/linter.tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["dist", "test-setup.ts"] +} diff --git a/packages/auth/migrations/1606470249553-init_database.ts b/packages/auth/migrations/1606470249553-init_database.ts new file mode 100644 index 000000000..221ab9b68 --- /dev/null +++ b/packages/auth/migrations/1606470249553-init_database.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class initDatabase1606470249553 implements MigrationInterface { + name = 'initDatabase1606470249553' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'CREATE TABLE IF NOT EXISTS `sessions` (`uuid` varchar(36) NOT NULL, `user_uuid` varchar(255) NULL, `hashed_access_token` varchar(255) NOT NULL, `hashed_refresh_token` varchar(255) NOT NULL, `access_expiration` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `refresh_expiration` datetime NOT NULL, `api_version` varchar(255) NULL, `user_agent` text NULL, `created_at` datetime NOT NULL, `updated_at` datetime NOT NULL, INDEX `index_sessions_on_user_uuid` (`user_uuid`), INDEX `index_sessions_on_updated_at` (`updated_at`), PRIMARY KEY (`uuid`)) ENGINE=InnoDB', + ) + await queryRunner.query( + 'CREATE TABLE IF NOT EXISTS `users` (`uuid` varchar(36) NOT NULL, `version` varchar(255) NULL, `email` varchar(255) NULL, `pw_nonce` varchar(255) NULL, `kp_created` varchar(255) NULL, `kp_origination` varchar(255) NULL, `pw_cost` int(11) NULL, `pw_key_size` int(11) NULL, `pw_salt` varchar(255) NULL, `pw_alg` varchar(255) NULL, `pw_func` varchar(255) NULL, `encrypted_password` varchar(255) NOT NULL, `created_at` datetime NOT NULL, `updated_at` datetime NOT NULL, `locked_until` datetime NULL, `num_failed_attempts` int(11) NULL, `updated_with_user_agent` text NULL, INDEX `index_users_on_email` (`email`), PRIMARY KEY (`uuid`)) ENGINE=InnoDB', + ) + } + + public async down(_queryRunner: QueryRunner): Promise { + return + } +} diff --git a/packages/auth/migrations/1610015065194-add_revoked_sessions.ts b/packages/auth/migrations/1610015065194-add_revoked_sessions.ts new file mode 100644 index 000000000..ab649bd54 --- /dev/null +++ b/packages/auth/migrations/1610015065194-add_revoked_sessions.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addRevokedSessions1610015065194 implements MigrationInterface { + name = 'addRevokedSessions1610015065194' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'CREATE TABLE IF NOT EXISTS `revoked_sessions` (`uuid` varchar(36) NOT NULL, `user_uuid` varchar(36) NOT NULL, `received` tinyint(1) NOT NULL DEFAULT 0, `created_at` datetime NOT NULL, INDEX `index_revoked_sessions_on_user_uuid` (`user_uuid`), PRIMARY KEY (`uuid`)) ENGINE=InnoDB', + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX `index_revoked_sessions_on_user_uuid` ON `revoked_sessions`') + await queryRunner.query('DROP TABLE `revoked_sessions`') + } +} diff --git a/packages/auth/migrations/1610025371088-add_foreign_key_to_revoked_sessions.ts b/packages/auth/migrations/1610025371088-add_foreign_key_to_revoked_sessions.ts new file mode 100644 index 000000000..35ea4cced --- /dev/null +++ b/packages/auth/migrations/1610025371088-add_foreign_key_to_revoked_sessions.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addForeignKeyToRevokedSessions1610025371088 implements MigrationInterface { + name = 'addForeignKeyToRevokedSessions1610025371088' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE `revoked_sessions` ADD CONSTRAINT `FK_b357d1397b82bcda5e6cc9b0062` FOREIGN KEY (`user_uuid`) REFERENCES `users`(`uuid`) ON DELETE CASCADE ON UPDATE NO ACTION', + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `revoked_sessions` DROP FOREIGN KEY `FK_b357d1397b82bcda5e6cc9b0062`') + } +} diff --git a/packages/auth/migrations/1612191669523-add_roles_and_permissions.ts b/packages/auth/migrations/1612191669523-add_roles_and_permissions.ts new file mode 100644 index 000000000..aea50fa2f --- /dev/null +++ b/packages/auth/migrations/1612191669523-add_roles_and_permissions.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addRolesAndPermissions1612191669523 implements MigrationInterface { + name = 'addRolesAndPermissions1612191669523' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'CREATE TABLE `permissions` (`uuid` varchar(36) NOT NULL, `name` varchar(255) NOT NULL, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE INDEX `index_permissions_on_name` (`name`), PRIMARY KEY (`uuid`)) ENGINE=InnoDB', + ) + await queryRunner.query( + 'CREATE TABLE `roles` (`uuid` varchar(36) NOT NULL, `name` varchar(255) NOT NULL, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE INDEX `index_roles_on_name` (`name`), PRIMARY KEY (`uuid`)) ENGINE=InnoDB', + ) + await queryRunner.query( + 'CREATE TABLE `role_permissions` (`permission_uuid` varchar(36) NOT NULL, `role_uuid` varchar(36) NOT NULL, INDEX `IDX_f985b194ff27dde81fb470c192` (`permission_uuid`), INDEX `IDX_7be6db7b59fb622e6c16ba124c` (`role_uuid`), PRIMARY KEY (`permission_uuid`, `role_uuid`)) ENGINE=InnoDB', + ) + await queryRunner.query( + 'CREATE TABLE `user_roles` (`role_uuid` varchar(36) NOT NULL, `user_uuid` varchar(36) NOT NULL, INDEX `IDX_0ea82c7b2302d7af0f8b789d79` (`role_uuid`), INDEX `IDX_2ebc2e1e2cb1d730d018893dae` (`user_uuid`), PRIMARY KEY (`role_uuid`, `user_uuid`)) ENGINE=InnoDB', + ) + await queryRunner.query( + 'ALTER TABLE `role_permissions` ADD CONSTRAINT `FK_f985b194ff27dde81fb470c1920` FOREIGN KEY (`permission_uuid`) REFERENCES `permissions`(`uuid`) ON DELETE CASCADE ON UPDATE NO ACTION', + ) + await queryRunner.query( + 'ALTER TABLE `role_permissions` ADD CONSTRAINT `FK_7be6db7b59fb622e6c16ba124c8` FOREIGN KEY (`role_uuid`) REFERENCES `roles`(`uuid`) ON DELETE CASCADE ON UPDATE NO ACTION', + ) + await queryRunner.query( + 'ALTER TABLE `user_roles` ADD CONSTRAINT `FK_0ea82c7b2302d7af0f8b789d797` FOREIGN KEY (`role_uuid`) REFERENCES `roles`(`uuid`) ON DELETE CASCADE ON UPDATE NO ACTION', + ) + await queryRunner.query( + 'ALTER TABLE `user_roles` ADD CONSTRAINT `FK_2ebc2e1e2cb1d730d018893daef` FOREIGN KEY (`user_uuid`) REFERENCES `users`(`uuid`) ON DELETE CASCADE ON UPDATE NO ACTION', + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `user_roles` DROP FOREIGN KEY `FK_2ebc2e1e2cb1d730d018893daef`') + await queryRunner.query('ALTER TABLE `user_roles` DROP FOREIGN KEY `FK_0ea82c7b2302d7af0f8b789d797`') + await queryRunner.query('ALTER TABLE `role_permissions` DROP FOREIGN KEY `FK_7be6db7b59fb622e6c16ba124c8`') + await queryRunner.query('ALTER TABLE `role_permissions` DROP FOREIGN KEY `FK_f985b194ff27dde81fb470c1920`') + await queryRunner.query('DROP INDEX `IDX_2ebc2e1e2cb1d730d018893dae` ON `user_roles`') + await queryRunner.query('DROP INDEX `IDX_0ea82c7b2302d7af0f8b789d79` ON `user_roles`') + await queryRunner.query('DROP TABLE `user_roles`') + await queryRunner.query('DROP INDEX `IDX_7be6db7b59fb622e6c16ba124c` ON `role_permissions`') + await queryRunner.query('DROP INDEX `IDX_f985b194ff27dde81fb470c192` ON `role_permissions`') + await queryRunner.query('DROP TABLE `role_permissions`') + await queryRunner.query('DROP INDEX `index_roles_on_name` ON `roles`') + await queryRunner.query('DROP TABLE `roles`') + await queryRunner.query('DROP INDEX `index_permissions_on_name` ON `permissions`') + await queryRunner.query('DROP TABLE `permissions`') + } +} diff --git a/packages/auth/migrations/1612255683992-add_roles_and_permissions_data.ts b/packages/auth/migrations/1612255683992-add_roles_and_permissions_data.ts new file mode 100644 index 000000000..8f43c2e5f --- /dev/null +++ b/packages/auth/migrations/1612255683992-add_roles_and_permissions_data.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addRolesAndPermissionsData1612255683992 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('INSERT INTO `roles` (uuid, name) VALUES ("8802d6a3-b97c-4b25-968a-8fb21c65c3a1", "USER")') + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("ad1afda0-732e-407c-8162-0950b623e322", "SYNC_ITEMS")', + ) + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES ("8802d6a3-b97c-4b25-968a-8fb21c65c3a1", "ad1afda0-732e-407c-8162-0950b623e322")', + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DELETE FROM `role_permissions` WHERE role_uuid="8802d6a3-b97c-4b25-968a-8fb21c65c3a1"') + await queryRunner.query('DELETE FROM `roles` WHERE name="USER"') + await queryRunner.query('DELETE FROM `permissions` WHERE name="SYNC_ITEMS"') + } +} diff --git a/packages/auth/migrations/1612433739754-add_more_roles_and_permissions.ts b/packages/auth/migrations/1612433739754-add_more_roles_and_permissions.ts new file mode 100644 index 000000000..40f10c3f5 --- /dev/null +++ b/packages/auth/migrations/1612433739754-add_more_roles_and_permissions.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addMoreRolesAndPermissions1612433739754 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'INSERT INTO `roles` (uuid, name) VALUES ("dee6e144-724b-4450-86d1-cc784770b2e2", "PLUS_USER")', + ) + await queryRunner.query( + 'INSERT INTO `roles` (uuid, name) VALUES ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "PRO_USER")', + ) + + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("e4264cff-28e9-4a3d-b903-873dcb88ad2f", "EXTENDED_NOTE_HISTORY")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("0107a26a-6154-4333-a383-d45eb688f1a4", "UNLIMITED_NOTE_HISTORY")', + ) + + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES ("dee6e144-724b-4450-86d1-cc784770b2e2", "ad1afda0-732e-407c-8162-0950b623e322")', + ) + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES ("dee6e144-724b-4450-86d1-cc784770b2e2", "e4264cff-28e9-4a3d-b903-873dcb88ad2f")', + ) + + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "ad1afda0-732e-407c-8162-0950b623e322")', + ) + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "0107a26a-6154-4333-a383-d45eb688f1a4")', + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DELETE FROM `role_permissions` WHERE role_uuid="dee6e144-724b-4450-86d1-cc784770b2e2"') + await queryRunner.query('DELETE FROM `role_permissions` WHERE role_uuid="8047edbb-a10a-4ff8-8d53-c2cae600a8e8"') + await queryRunner.query('DELETE FROM `roles` WHERE name="PLUS_USER"') + await queryRunner.query('DELETE FROM `roles` WHERE name="PRO_USER"') + await queryRunner.query('DELETE FROM `permissions` WHERE name="EXTENDED_NOTE_HISTORY"') + await queryRunner.query('DELETE FROM `permissions` WHERE name="UNLIMITED_NOTE_HISTORY"') + } +} diff --git a/packages/auth/migrations/1614678016791-add_user_settings.ts b/packages/auth/migrations/1614678016791-add_user_settings.ts new file mode 100644 index 000000000..fb2d9a93a --- /dev/null +++ b/packages/auth/migrations/1614678016791-add_user_settings.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddUserSettings1614678016791 implements MigrationInterface { + name = 'AddUserSettings1614678016791' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'CREATE TABLE `settings` (`uuid` varchar(36) NOT NULL, `name` varchar(255) NOT NULL, `value` varchar(255) NOT NULL, `encrypted` tinyint(1) NOT NULL DEFAULT 0, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `user_uuid` varchar(36) NOT NULL, INDEX `index_settings_on_name_and_user_uuid` (`name`, `user_uuid`), PRIMARY KEY (`uuid`)) ENGINE=InnoDB', + ) + await queryRunner.query( + 'ALTER TABLE `settings` ADD CONSTRAINT `FK_1cc1d030b83d6030795d3e7e63f` FOREIGN KEY (`user_uuid`) REFERENCES `users`(`uuid`) ON DELETE CASCADE ON UPDATE NO ACTION', + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `settings` DROP FOREIGN KEY `FK_1cc1d030b83d6030795d3e7e63f`') + await queryRunner.query('DROP INDEX `index_settings_on_name_and_user_uuid` ON `settings`') + await queryRunner.query('DROP TABLE `settings`') + } +} diff --git a/packages/auth/migrations/1614771815912-add_encrypted_version.ts b/packages/auth/migrations/1614771815912-add_encrypted_version.ts new file mode 100644 index 000000000..27c73cfbf --- /dev/null +++ b/packages/auth/migrations/1614771815912-add_encrypted_version.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addEncryptedVersion1614771815912 implements MigrationInterface { + name = 'addEncryptedVersion1614771815912' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE `settings` CHANGE `encrypted` `server_encryption_version` tinyint(1) NOT NULL DEFAULT 0', + ) + await queryRunner.query('ALTER TABLE `users` ADD `encrypted_server_key` varchar(255) NULL') + await queryRunner.query( + 'ALTER TABLE `settings` CHANGE `server_encryption_version` `server_encryption_version` tinyint NOT NULL DEFAULT 0', + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE `settings` CHANGE `server_encryption_version` `server_encryption_version` tinyint(1) NOT NULL DEFAULT 0', + ) + await queryRunner.query('ALTER TABLE `users` DROP COLUMN `encrypted_server_key`') + await queryRunner.query( + 'ALTER TABLE `settings` CHANGE `server_encryption_version` `encrypted` tinyint(1) NOT NULL DEFAULT 0', + ) + } +} diff --git a/packages/auth/migrations/1614775877590-add_encrypted_version_for_user.ts b/packages/auth/migrations/1614775877590-add_encrypted_version_for_user.ts new file mode 100644 index 000000000..b7ec785d2 --- /dev/null +++ b/packages/auth/migrations/1614775877590-add_encrypted_version_for_user.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addEncryptedVersionForUser1614775877590 implements MigrationInterface { + name = 'addEncryptedVersionForUser1614775877590' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `users` ADD `server_encryption_version` tinyint NOT NULL DEFAULT 0') + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `users` DROP COLUMN `server_encryption_version`') + } +} diff --git a/packages/auth/migrations/1624434102642-update_roles_and_permissions.ts b/packages/auth/migrations/1624434102642-update_roles_and_permissions.ts new file mode 100644 index 000000000..f4102dfb7 --- /dev/null +++ b/packages/auth/migrations/1624434102642-update_roles_and_permissions.ts @@ -0,0 +1,196 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class updateRolesAndPermissions1624434102642 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Prune previous roles and permissions + await queryRunner.query('DELETE FROM `role_permissions` WHERE role_uuid="dee6e144-724b-4450-86d1-cc784770b2e2"') + await queryRunner.query('DELETE FROM `role_permissions` WHERE role_uuid="8047edbb-a10a-4ff8-8d53-c2cae600a8e8"') + await queryRunner.query('DELETE FROM `role_permissions` WHERE role_uuid="8802d6a3-b97c-4b25-968a-8fb21c65c3a1"') + await queryRunner.query('DELETE FROM `permissions` WHERE name="EXTENDED_NOTE_HISTORY"') + await queryRunner.query('DELETE FROM `permissions` WHERE name="UNLIMITED_NOTE_HISTORY"') + await queryRunner.query('DELETE FROM `permissions` WHERE name="SYNC_ITEMS"') + + // Add missing Basic User role + await queryRunner.query( + 'INSERT INTO `roles` (uuid, name) VALUES ("bde42e26-628c-44e6-9d76-21b08954b0bf", "BASIC_USER")', + ) + + // Permissions + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("42e11c9b-e99b-43a5-bd32-77600c2e5ece", "theme:midnight")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("6e69b059-5324-4087-ba9d-c6c77ed2483c", "theme:futura")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("6cbde260-5a00-46f5-907d-d9843fa87528", "theme:solarized-dark")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("2fdfe72e-2ec9-4600-97e5-5f19eaba8b6a", "theme:autobiography")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("09d0c788-63d7-4159-bd9f-58ec43ba9adf", "theme:focused")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("9bebbdc1-195b-4cb6-9950-d8c9676f5d4e", "theme:titanium")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("db84ebd6-5273-4af9-8d95-5603c6e3f75f", "editor:bold")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("b9d8488a-59aa-420a-8491-1f12b6484876", "editor:plus")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("e1a3c091-3479-4d8d-b4df-66ec6c9f13c2", "editor:markdown-basic")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("6ce3732f-f6bf-46e8-99be-6044903253b2", "editor:markdown-pro")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("39ebab56-00be-4495-8f59-ba25d5127f06", "editor:markdown-minimist")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("b04a7670-934e-4ab1-b8a3-0f27ff159511", "server:two-factor-auth")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("c1039ab1-0d77-49e8-919a-90d190333421", "server:note-history-unlimited")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("a6cb635c-b5ae-4196-90f3-3de269eb33f9", "server:note-history-365-days")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("90cde39c-f4ea-417a-ae25-15db8ef1d828", "server:note-history-30-days")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("3f7044d6-c74d-48c2-8b5d-ef69e8b3d922", "app:tag-nesting")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("baf6d127-70ef-45f1-834c-c9969b3a321f", "editor:task-editor")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("cd250253-82c2-40a5-8f9a-731f8bde7550", "editor:code-editor")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("897551c3-8ba8-48f0-8fb9-5c861b104fcf", "editor:token-vault")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("2ce06684-1f3d-45ee-87c1-df7b4447801b", "editor:sheets")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("82da5111-0066-44a9-acf6-cb15207a93c1", "component:cloud-link")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("1c6295d7-ffab-4881-bdf9-7c80df3885e9", "component:2fa-manager")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("94fec0a8-7581-4690-a482-9eadc9304c35", "app:files")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("eb0575a2-6e26-49e3-9501-f2e75d7dbda3", "server:daily-email-backup")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("c7453402-e16b-4f14-8621-0660a0dc65db", "server:daily-dropbox-backup")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("9c77734a-ba0b-4f7a-8b6b-3e6e1811945b", "server:daily-gdrive-backup")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("a31c94ca-a352-4aab-98d4-92ebb1103e1f", "server:daily-onedrive-backup")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("8650f269-6248-4d63-92cd-da4a29e87363", "listed:custom-domain")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("6b195abd-fa3e-4743-ba10-8d50733d377c", "server:files-25-gb")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("466da066-993a-4d34-b77c-786395fa285a", "server:files-5-gb")', + ) + + // Pro User Permissions + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "42e11c9b-e99b-43a5-bd32-77600c2e5ece"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "6e69b059-5324-4087-ba9d-c6c77ed2483c"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "6cbde260-5a00-46f5-907d-d9843fa87528"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "2fdfe72e-2ec9-4600-97e5-5f19eaba8b6a"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "09d0c788-63d7-4159-bd9f-58ec43ba9adf"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "9bebbdc1-195b-4cb6-9950-d8c9676f5d4e"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "db84ebd6-5273-4af9-8d95-5603c6e3f75f"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "b9d8488a-59aa-420a-8491-1f12b6484876"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "e1a3c091-3479-4d8d-b4df-66ec6c9f13c2"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "6ce3732f-f6bf-46e8-99be-6044903253b2"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "39ebab56-00be-4495-8f59-ba25d5127f06"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "b04a7670-934e-4ab1-b8a3-0f27ff159511"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "c1039ab1-0d77-49e8-919a-90d190333421"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "3f7044d6-c74d-48c2-8b5d-ef69e8b3d922"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "baf6d127-70ef-45f1-834c-c9969b3a321f"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "cd250253-82c2-40a5-8f9a-731f8bde7550"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "897551c3-8ba8-48f0-8fb9-5c861b104fcf"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "2ce06684-1f3d-45ee-87c1-df7b4447801b"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "82da5111-0066-44a9-acf6-cb15207a93c1"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "1c6295d7-ffab-4881-bdf9-7c80df3885e9"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "94fec0a8-7581-4690-a482-9eadc9304c35"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "eb0575a2-6e26-49e3-9501-f2e75d7dbda3"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "c7453402-e16b-4f14-8621-0660a0dc65db"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "9c77734a-ba0b-4f7a-8b6b-3e6e1811945b"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "a31c94ca-a352-4aab-98d4-92ebb1103e1f"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "8650f269-6248-4d63-92cd-da4a29e87363"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "6b195abd-fa3e-4743-ba10-8d50733d377c") \ + ', + ) + + // Plus User Permissions + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "42e11c9b-e99b-43a5-bd32-77600c2e5ece"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "6e69b059-5324-4087-ba9d-c6c77ed2483c"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "6cbde260-5a00-46f5-907d-d9843fa87528"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "09d0c788-63d7-4159-bd9f-58ec43ba9adf"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "9bebbdc1-195b-4cb6-9950-d8c9676f5d4e"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "db84ebd6-5273-4af9-8d95-5603c6e3f75f"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "b9d8488a-59aa-420a-8491-1f12b6484876"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "e1a3c091-3479-4d8d-b4df-66ec6c9f13c2"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "6ce3732f-f6bf-46e8-99be-6044903253b2"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "39ebab56-00be-4495-8f59-ba25d5127f06"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "b04a7670-934e-4ab1-b8a3-0f27ff159511"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "a6cb635c-b5ae-4196-90f3-3de269eb33f9"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "3f7044d6-c74d-48c2-8b5d-ef69e8b3d922"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "baf6d127-70ef-45f1-834c-c9969b3a321f"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "cd250253-82c2-40a5-8f9a-731f8bde7550"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "2ce06684-1f3d-45ee-87c1-df7b4447801b"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "82da5111-0066-44a9-acf6-cb15207a93c1"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "1c6295d7-ffab-4881-bdf9-7c80df3885e9"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "94fec0a8-7581-4690-a482-9eadc9304c35"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "eb0575a2-6e26-49e3-9501-f2e75d7dbda3"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "c7453402-e16b-4f14-8621-0660a0dc65db"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "9c77734a-ba0b-4f7a-8b6b-3e6e1811945b"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "a31c94ca-a352-4aab-98d4-92ebb1103e1f"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "8650f269-6248-4d63-92cd-da4a29e87363"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "466da066-993a-4d34-b77c-786395fa285a") \ + ', + ) + + // Basic User Permissions + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES \ + ("bde42e26-628c-44e6-9d76-21b08954b0bf", "42e11c9b-e99b-43a5-bd32-77600c2e5ece"), \ + ("bde42e26-628c-44e6-9d76-21b08954b0bf", "6e69b059-5324-4087-ba9d-c6c77ed2483c"), \ + ("bde42e26-628c-44e6-9d76-21b08954b0bf", "6cbde260-5a00-46f5-907d-d9843fa87528"), \ + ("bde42e26-628c-44e6-9d76-21b08954b0bf", "db84ebd6-5273-4af9-8d95-5603c6e3f75f"), \ + ("bde42e26-628c-44e6-9d76-21b08954b0bf", "b9d8488a-59aa-420a-8491-1f12b6484876"), \ + ("bde42e26-628c-44e6-9d76-21b08954b0bf", "e1a3c091-3479-4d8d-b4df-66ec6c9f13c2"), \ + ("bde42e26-628c-44e6-9d76-21b08954b0bf", "6ce3732f-f6bf-46e8-99be-6044903253b2"), \ + ("bde42e26-628c-44e6-9d76-21b08954b0bf", "39ebab56-00be-4495-8f59-ba25d5127f06"), \ + ("bde42e26-628c-44e6-9d76-21b08954b0bf", "1c6295d7-ffab-4881-bdf9-7c80df3885e9"), \ + ("bde42e26-628c-44e6-9d76-21b08954b0bf", "b04a7670-934e-4ab1-b8a3-0f27ff159511"), \ + ("bde42e26-628c-44e6-9d76-21b08954b0bf", "90cde39c-f4ea-417a-ae25-15db8ef1d828"), \ + ("bde42e26-628c-44e6-9d76-21b08954b0bf", "3f7044d6-c74d-48c2-8b5d-ef69e8b3d922") \ + ', + ) + } + + public async down(_queryRunner: QueryRunner): Promise { + return + } +} diff --git a/packages/auth/migrations/1625164984414-change_setting_timestamps.ts b/packages/auth/migrations/1625164984414-change_setting_timestamps.ts new file mode 100644 index 000000000..cc2a7be72 --- /dev/null +++ b/packages/auth/migrations/1625164984414-change_setting_timestamps.ts @@ -0,0 +1,12 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class changeSettingTimestamps1625164984414 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `settings` CHANGE `created_at` `created_at` BIGINT NOT NULL') + await queryRunner.query('ALTER TABLE `settings` CHANGE `updated_at` `updated_at` BIGINT NOT NULL') + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1625227894975-change_setting_value_size.ts b/packages/auth/migrations/1625227894975-change_setting_value_size.ts new file mode 100644 index 000000000..f511f8c18 --- /dev/null +++ b/packages/auth/migrations/1625227894975-change_setting_value_size.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class changeSettingValueSize1625227894975 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `settings` CHANGE `value` `value` TEXT NOT NULL') + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1625767770284-change_setting_value_to_nullable.ts b/packages/auth/migrations/1625767770284-change_setting_value_to_nullable.ts new file mode 100644 index 000000000..6a7badc32 --- /dev/null +++ b/packages/auth/migrations/1625767770284-change_setting_value_to_nullable.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class changeSettingValueToNullable1625767770284 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `settings` CHANGE `value` `value` TEXT') + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1625807999951-add_unique_setting_index.ts b/packages/auth/migrations/1625807999951-add_unique_setting_index.ts new file mode 100644 index 000000000..20dcaa3ac --- /dev/null +++ b/packages/auth/migrations/1625807999951-add_unique_setting_index.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addUniqueSettingIndex1625807999951 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX `index_settings_on_name_and_user_uuid` ON `settings`') + await queryRunner.query( + 'ALTER TABLE `settings` ADD UNIQUE INDEX `index_settings_on_name_and_user_uuid` (`name`, `user_uuid`)', + ) + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1626268390207-remove_unique_setting_index.ts b/packages/auth/migrations/1626268390207-remove_unique_setting_index.ts new file mode 100644 index 000000000..87a2e808b --- /dev/null +++ b/packages/auth/migrations/1626268390207-remove_unique_setting_index.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class removeUniqueSettingIndex1626268390207 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX `index_settings_on_name_and_user_uuid` ON `settings`') + await queryRunner.query( + 'ALTER TABLE `settings` ADD INDEX `index_settings_on_name_and_user_uuid` (`name`, `user_uuid`)', + ) + await queryRunner.query('ALTER TABLE `settings` ADD INDEX `index_settings_on_updated_at` (`updated_at`)') + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1626689139110-add_user_subscriptions.ts b/packages/auth/migrations/1626689139110-add_user_subscriptions.ts new file mode 100644 index 000000000..eb23e46e0 --- /dev/null +++ b/packages/auth/migrations/1626689139110-add_user_subscriptions.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addUserSubscriptions1626689139110 implements MigrationInterface { + name = 'addUserSubscriptions1626689139110' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'CREATE TABLE `user_subscriptions` (`uuid` varchar(36) NOT NULL, `plan_name` varchar(255) NOT NULL, `ends_at` bigint NOT NULL, `created_at` bigint NOT NULL, `updated_at` bigint NOT NULL, `user_uuid` varchar(36) NOT NULL, INDEX `updated_at` (`updated_at`), PRIMARY KEY (`uuid`)) ENGINE=InnoDB', + ) + await queryRunner.query( + 'ALTER TABLE `user_subscriptions` ADD CONSTRAINT `FK_f44dae0a64c70e6b50de5442d2b` FOREIGN KEY (`user_uuid`) REFERENCES `users`(`uuid`) ON DELETE NO ACTION ON UPDATE NO ACTION', + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `user_subscriptions` DROP FOREIGN KEY `FK_f44dae0a64c70e6b50de5442d2b`') + await queryRunner.query('DROP TABLE `user_subscriptions`') + } +} diff --git a/packages/auth/migrations/1626717016896-fix_subscription_foreign_key.ts b/packages/auth/migrations/1626717016896-fix_subscription_foreign_key.ts new file mode 100644 index 000000000..92a785aaf --- /dev/null +++ b/packages/auth/migrations/1626717016896-fix_subscription_foreign_key.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class fixSubscriptionForeignKey1626717016896 implements MigrationInterface { + name = 'fixSubscriptionForeignKey1626717016896' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `user_subscriptions` DROP FOREIGN KEY `FK_f44dae0a64c70e6b50de5442d2b`') + await queryRunner.query( + 'ALTER TABLE `user_subscriptions` ADD CONSTRAINT `FK_f44dae0a64c70e6b50de5442d2b` FOREIGN KEY (`user_uuid`) REFERENCES `users`(`uuid`) ON DELETE CASCADE ON UPDATE NO ACTION', + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `user_subscriptions` DROP FOREIGN KEY `FK_f44dae0a64c70e6b50de5442d2b`') + await queryRunner.query( + 'ALTER TABLE `user_subscriptions` ADD CONSTRAINT `FK_f44dae0a64c70e6b50de5442d2b` FOREIGN KEY (`user_uuid`) REFERENCES `users`(`uuid`) ON DELETE NO ACTION ON UPDATE NO ACTION', + ) + } +} diff --git a/packages/auth/migrations/1627638504691-move_mfa_items_to_user_settings.ts b/packages/auth/migrations/1627638504691-move_mfa_items_to_user_settings.ts new file mode 100644 index 000000000..ae4f64b2a --- /dev/null +++ b/packages/auth/migrations/1627638504691-move_mfa_items_to_user_settings.ts @@ -0,0 +1,73 @@ +import Redis, { Cluster } from 'ioredis' +import { SettingName } from '@standardnotes/settings' +import { MigrationInterface, QueryRunner } from 'typeorm' + +import { Setting } from '../src/Domain/Setting/Setting' +import { User } from '../src/Domain/User/User' +import { EncryptionVersion } from '../src/Domain/Encryption/EncryptionVersion' + +export class moveMfaItemsToUserSettings1627638504691 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const itemsTableExistsQueryResult = await queryRunner.manager.query( + 'SELECT COUNT(*) as count FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = "items"', + ) + const itemsTableExists = itemsTableExistsQueryResult[0].count === 1 + if (!itemsTableExists) { + return + } + + const items = await queryRunner.manager.query( + 'SELECT * FROM items WHERE content_type = "SF|MFA" ORDER BY updated_at_timestamp ASC', + ) + + const usersMFAStatus = new Map() + const usersMFAUpdatedAt = new Map() + + for (const item of items) { + const user = await queryRunner.manager.findOne(User, item['user_uuid']) + if (user === null) { + continue + } + + usersMFAStatus.set(item['user_uuid'], 1) + usersMFAUpdatedAt.set(item['user_uuid'], item['updated_at_timestamp']) + + const setting = new Setting() + setting.uuid = item['uuid'] + setting.name = SettingName.MfaSecret + setting.value = item['content'] + if (item['deleted']) { + setting.value = null + usersMFAStatus.set(item['user_uuid'], 0) + } + setting.serverEncryptionVersion = EncryptionVersion.Unencrypted + setting.createdAt = item['created_at_timestamp'] + setting.updatedAt = item['updated_at_timestamp'] + setting.user = Promise.resolve(user) + await queryRunner.manager.save(setting) + } + + const redisClient = this.getRedisClient() + + for (const userUuid of usersMFAStatus.keys()) { + await redisClient.set(`mfa:${userUuid}`, usersMFAStatus.get(userUuid) as number) + await redisClient.set(`mfa_ua:${userUuid}`, usersMFAUpdatedAt.get(userUuid) as number) + } + + await queryRunner.manager.query('DELETE FROM items WHERE content_type = "SF|MFA"') + } + + public async down(): Promise { + return + } + + private getRedisClient(): Redis | Cluster { + const redisUrl = process.env.REDIS_URL as string + const isRedisInClusterMode = redisUrl.indexOf(',') > 0 + if (isRedisInClusterMode) { + return new Redis.Cluster(redisUrl.split(',')) + } + + return new Redis(redisUrl) + } +} diff --git a/packages/auth/migrations/1629215600192-generate_user_server_key.ts b/packages/auth/migrations/1629215600192-generate_user_server_key.ts new file mode 100644 index 000000000..e63baaa8f --- /dev/null +++ b/packages/auth/migrations/1629215600192-generate_user_server_key.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' +import { CryptoNode } from '@standardnotes/sncrypto-node' + +export class generateUserServerKey1629215600192 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const crypto = new CryptoNode() + + const users = await queryRunner.manager.query( + 'SELECT * FROM users where encrypted_server_key IS NULL ORDER BY created_at', + ) + for (const user of users) { + const unencrypted = await crypto.generateRandomKey(256) + const iv = await crypto.generateRandomKey(128) + const encrypted = await crypto.aes256GcmEncrypt({ + unencrypted, + iv, + key: process.env.ENCRYPTION_SERVER_KEY as string, + }) + const encryptedServerKey = JSON.stringify({ version: 1, encrypted }) + + await queryRunner.manager.query( + `UPDATE users SET encrypted_server_key = '${encryptedServerKey}', server_encryption_version = 1 WHERE uuid = "${user['uuid']}"`, + ) + } + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1629217630132-encrypt_encoded_mfa_settings.ts b/packages/auth/migrations/1629217630132-encrypt_encoded_mfa_settings.ts new file mode 100644 index 000000000..41e065f90 --- /dev/null +++ b/packages/auth/migrations/1629217630132-encrypt_encoded_mfa_settings.ts @@ -0,0 +1,66 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' +import { CryptoNode } from '@standardnotes/sncrypto-node' + +export class encryptEncodedMfaSettings1629217630132 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const encodedMFASettings = await queryRunner.manager.query( + 'SELECT s.uuid as uuid, s.value as value, u.encrypted_server_key as encrypted_server_key FROM settings s LEFT JOIN users u ON u.uuid = s.user_uuid WHERE s.name = "MFA_SECRET" AND s.server_encryption_version = 0', + ) + + for (const encodedMFASetting of encodedMFASettings) { + if (!encodedMFASetting['value']) { + continue + } + + const mfaSecret = this.getDecodedMFASecret(encodedMFASetting['value']) + + if (!mfaSecret) { + continue + } + + const encryptedMFASecret = await this.encryptMFASecret(mfaSecret, encodedMFASetting['encrypted_server_key']) + + await queryRunner.manager.query( + `UPDATE settings s SET s.value = '${encryptedMFASecret}', s.server_encryption_version = 1 WHERE s.uuid="${encodedMFASetting['uuid']}"`, + ) + } + } + + public async down(): Promise { + return + } + + private async encryptMFASecret(secret: string, userEncryptedServerKey: string): Promise { + const crypto = new CryptoNode() + + const userServerKey = JSON.parse(userEncryptedServerKey) + + const decryptedUserServerKey = await crypto.aes256GcmDecrypt( + userServerKey.encrypted, + process.env.ENCRYPTION_SERVER_KEY as string, + ) + + const iv = await crypto.generateRandomKey(128) + + const encrypted = await crypto.aes256GcmEncrypt({ + unencrypted: secret, + iv, + key: decryptedUserServerKey, + }) + + return JSON.stringify({ version: 1, encrypted }) + } + + private getDecodedMFASecret(encodedValue: string): string | undefined { + const valueBuffer = Buffer.from(encodedValue.substring(3), 'base64') + const decodedValue = valueBuffer.toString() + + const decodedMFASecretObject = JSON.parse(decodedValue) + + if ('secret' in decodedMFASecretObject && decodedMFASecretObject.secret) { + return decodedMFASecretObject.secret + } + + return undefined + } +} diff --git a/packages/auth/migrations/1629223072059-flatten_mfa_setting_and_encrypt.ts b/packages/auth/migrations/1629223072059-flatten_mfa_setting_and_encrypt.ts new file mode 100644 index 000000000..de698554e --- /dev/null +++ b/packages/auth/migrations/1629223072059-flatten_mfa_setting_and_encrypt.ts @@ -0,0 +1,88 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' +import { CryptoNode } from '@standardnotes/sncrypto-node' + +export class flattenMfaSettingAndEncrypt1629223072059 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const encryptedAndEncodedMFASettings = await queryRunner.manager.query( + 'SELECT s.uuid as uuid, s.value as value, u.encrypted_server_key as encrypted_server_key FROM settings s LEFT JOIN users u ON u.uuid = s.user_uuid WHERE s.name = "MFA_SECRET" AND s.server_encryption_version = 2', + ) + + for (const encryptedAndEncodedMFASetting of encryptedAndEncodedMFASettings) { + if (!encryptedAndEncodedMFASetting['value']) { + continue + } + + const encodedMFASetting = await this.decryptMFASetting( + encryptedAndEncodedMFASetting['value'], + encryptedAndEncodedMFASetting['encrypted_server_key'], + ) + + const mfaSecret = this.getDecodedMFASecret(encodedMFASetting) + if (!mfaSecret) { + continue + } + + const encryptedMFASecret = await this.encryptMFASecret( + mfaSecret, + encryptedAndEncodedMFASetting['encrypted_server_key'], + ) + + await queryRunner.manager.query( + `UPDATE settings s SET s.value = '${encryptedMFASecret}', s.server_encryption_version = 1 WHERE s.uuid="${encryptedAndEncodedMFASetting['uuid']}"`, + ) + } + } + + public async down(): Promise { + return + } + + private async decryptMFASetting(encryptedMFASetting: string, userEncryptedServerKey: string) { + const crypto = new CryptoNode() + + const userServerKey = JSON.parse(userEncryptedServerKey) + + const decryptedUserServerKey = await crypto.aes256GcmDecrypt( + userServerKey.encrypted, + process.env.ENCRYPTION_SERVER_KEY as string, + ) + + const parsedVersionedEncrypted = JSON.parse(encryptedMFASetting) + + return crypto.aes256GcmDecrypt(parsedVersionedEncrypted.encrypted, decryptedUserServerKey) + } + + private async encryptMFASecret(secret: string, userEncryptedServerKey: string): Promise { + const crypto = new CryptoNode() + + const userServerKey = JSON.parse(userEncryptedServerKey) + + const decryptedUserServerKey = await crypto.aes256GcmDecrypt( + userServerKey.encrypted, + process.env.ENCRYPTION_SERVER_KEY as string, + ) + + const iv = await crypto.generateRandomKey(128) + + const encrypted = await crypto.aes256GcmEncrypt({ + unencrypted: secret, + iv, + key: decryptedUserServerKey, + }) + + return JSON.stringify({ version: 1, encrypted }) + } + + private getDecodedMFASecret(encodedValue: string): string | undefined { + const valueBuffer = Buffer.from(encodedValue.substring(3), 'base64') + const decodedValue = valueBuffer.toString() + + const decodedMFASecretObject = JSON.parse(decodedValue) + + if ('secret' in decodedMFASecretObject && decodedMFASecretObject.secret) { + return decodedMFASecretObject.secret + } + + return undefined + } +} diff --git a/packages/auth/migrations/1629703896382-add_markdown_math.ts b/packages/auth/migrations/1629703896382-add_markdown_math.ts new file mode 100644 index 000000000..18e2119b9 --- /dev/null +++ b/packages/auth/migrations/1629703896382-add_markdown_math.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addMarkdownMath1629703896382 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("5f53c349-3fe5-4e5f-a9c5-a7caae6eb90a", "editor:markdown-math")', + ) + + // Pro User Permissions + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "5f53c349-3fe5-4e5f-a9c5-a7caae6eb90a")', + ) + + // Plus User Permissions + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES ("dee6e144-724b-4450-86d1-cc784770b2e2", "5f53c349-3fe5-4e5f-a9c5-a7caae6eb90a")', + ) + + // Basic User Permissions + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES ("bde42e26-628c-44e6-9d76-21b08954b0bf", "5f53c349-3fe5-4e5f-a9c5-a7caae6eb90a")', + ) + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1629705289178-add_sensitive_flag.ts b/packages/auth/migrations/1629705289178-add_sensitive_flag.ts new file mode 100644 index 000000000..c8d73f628 --- /dev/null +++ b/packages/auth/migrations/1629705289178-add_sensitive_flag.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addSensitiveFlag1629705289178 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `settings` ADD `sensitive` tinyint NOT NULL DEFAULT 0') + + await queryRunner.query('UPDATE settings s SET s.sensitive = 1 WHERE s.name = "MFA_SECRET"') + await queryRunner.query('UPDATE settings s SET s.sensitive = 1 WHERE s.name = "EXTENSION_KEY"') + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `settings` DROP COLUMN `sensitive`') + } +} diff --git a/packages/auth/migrations/1629972294975-fix_basic_and_core_user.ts b/packages/auth/migrations/1629972294975-fix_basic_and_core_user.ts new file mode 100644 index 000000000..a1bb0a92d --- /dev/null +++ b/packages/auth/migrations/1629972294975-fix_basic_and_core_user.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class fixBasicAndCoreUser1629972294975 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('UPDATE `roles` SET `name` = "CORE_USER" WHERE `name` = "BASIC_USER"') + await queryRunner.query('UPDATE `roles` SET `name` = "BASIC_USER" WHERE `name` = "USER"') + + await queryRunner.query( + 'UPDATE `user_roles` SET `role_uuid` = "8802d6a3-b97c-4b25-968a-8fb21c65c3a1" WHERE `role_uuid` = "bde42e26-628c-44e6-9d76-21b08954b0bf"', + ) + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1630661830850-fix_encryption_version_on_mfa_settings.ts b/packages/auth/migrations/1630661830850-fix_encryption_version_on_mfa_settings.ts new file mode 100644 index 000000000..3610f41ac --- /dev/null +++ b/packages/auth/migrations/1630661830850-fix_encryption_version_on_mfa_settings.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class fixEncryptionVersionOnMfaSettings1630661830850 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'UPDATE `settings` SET `server_encryption_version` = 1 WHERE `server_encryption_version` = 2', + ) + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1630905831679-add-cancelled-flag.ts b/packages/auth/migrations/1630905831679-add-cancelled-flag.ts new file mode 100644 index 000000000..b5aaceaba --- /dev/null +++ b/packages/auth/migrations/1630905831679-add-cancelled-flag.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addCancelledFlag1630905831679 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `user_subscriptions` ADD `cancelled` tinyint NOT NULL DEFAULT 0') + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `user_subscriptions` DROP COLUMN `cancelled`') + } +} diff --git a/packages/auth/migrations/1634064348750-add_offline_settings.ts b/packages/auth/migrations/1634064348750-add_offline_settings.ts new file mode 100644 index 000000000..a96fceed1 --- /dev/null +++ b/packages/auth/migrations/1634064348750-add_offline_settings.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addOfflineSettings1634064348750 implements MigrationInterface { + name = 'addOfflineSettings1634064348750' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'CREATE TABLE `offline_settings` (`uuid` varchar(36) NOT NULL, `email` varchar(255) NOT NULL, `name` varchar(255) NOT NULL, `value` text NULL, `server_encryption_version` tinyint NOT NULL DEFAULT 0, `created_at` bigint NOT NULL, `updated_at` bigint NOT NULL, INDEX `index_offline_settings_on_name_and_email` (`name`, `email`), PRIMARY KEY (`uuid`)) ENGINE=InnoDB', + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX `index_offline_settings_on_name_and_email` ON `offline_settings`') + await queryRunner.query('DROP TABLE `offline_settings`') + } +} diff --git a/packages/auth/migrations/1634102065310-add_offline_subscriptions.ts b/packages/auth/migrations/1634102065310-add_offline_subscriptions.ts new file mode 100644 index 000000000..acc83c0e3 --- /dev/null +++ b/packages/auth/migrations/1634102065310-add_offline_subscriptions.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addOfflineSubscriptions1634102065310 implements MigrationInterface { + name = 'addOfflineSubscriptions1634102065310' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'CREATE TABLE `offline_user_subscriptions` (`uuid` varchar(36) NOT NULL, `email` varchar(255) NOT NULL, `plan_name` varchar(255) NOT NULL, `ends_at` bigint NOT NULL, `created_at` bigint NOT NULL, `updated_at` bigint NOT NULL, `cancelled` tinyint(1) NOT NULL DEFAULT 0, INDEX `email` (`email`), PRIMARY KEY (`uuid`)) ENGINE=InnoDB', + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX `email` ON `offline_user_subscriptions`') + await queryRunner.query('DROP TABLE `offline_user_subscriptions`') + } +} diff --git a/packages/auth/migrations/1634102764797-add_offline_user_roles.ts b/packages/auth/migrations/1634102764797-add_offline_user_roles.ts new file mode 100644 index 000000000..9064bdd38 --- /dev/null +++ b/packages/auth/migrations/1634102764797-add_offline_user_roles.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addOfflineUserRoles1634102764797 implements MigrationInterface { + name = 'addOfflineUserRoles1634102764797' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'CREATE TABLE `offline_user_roles` (`role_uuid` varchar(36) NOT NULL, `offline_user_subscription_uuid` varchar(36) NOT NULL, INDEX `IDX_027ba99043e6902f569d66417f` (`role_uuid`), INDEX `IDX_cd1b91693f6ee92d5f94ce2775` (`offline_user_subscription_uuid`), PRIMARY KEY (`role_uuid`, `offline_user_subscription_uuid`)) ENGINE=InnoDB', + ) + await queryRunner.query( + 'ALTER TABLE `offline_user_roles` ADD CONSTRAINT `FK_027ba99043e6902f569d66417f0` FOREIGN KEY (`role_uuid`) REFERENCES `roles`(`uuid`) ON DELETE CASCADE ON UPDATE NO ACTION', + ) + await queryRunner.query( + 'ALTER TABLE `offline_user_roles` ADD CONSTRAINT `FK_cd1b91693f6ee92d5f94ce27758` FOREIGN KEY (`offline_user_subscription_uuid`) REFERENCES `offline_user_subscriptions`(`uuid`) ON DELETE CASCADE ON UPDATE NO ACTION', + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `offline_user_roles` DROP FOREIGN KEY `FK_cd1b91693f6ee92d5f94ce27758`') + await queryRunner.query('ALTER TABLE `offline_user_roles` DROP FOREIGN KEY `FK_027ba99043e6902f569d66417f0`') + await queryRunner.query('DROP INDEX `IDX_cd1b91693f6ee92d5f94ce2775` ON `offline_user_roles`') + await queryRunner.query('DROP INDEX `IDX_027ba99043e6902f569d66417f` ON `offline_user_roles`') + await queryRunner.query('DROP TABLE `offline_user_roles`') + } +} diff --git a/packages/auth/migrations/1635167238332-remove_spreadsheets_from_plus_plan.ts b/packages/auth/migrations/1635167238332-remove_spreadsheets_from_plus_plan.ts new file mode 100644 index 000000000..02224215a --- /dev/null +++ b/packages/auth/migrations/1635167238332-remove_spreadsheets_from_plus_plan.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class removeSpreadsheetsFromPlusPlan1635167238332 implements MigrationInterface { + name = 'removeSpreadsheetsFromPlusPlan1635167238332' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'DELETE FROM `role_permissions` WHERE role_uuid="dee6e144-724b-4450-86d1-cc784770b2e2" AND permission_uuid="2ce06684-1f3d-45ee-87c1-df7b4447801b"', + ) + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1635172524403-add_subscription_id.ts b/packages/auth/migrations/1635172524403-add_subscription_id.ts new file mode 100644 index 000000000..ca39911ba --- /dev/null +++ b/packages/auth/migrations/1635172524403-add_subscription_id.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addSubscriptionId1635172524403 implements MigrationInterface { + name = 'addSubscriptionId1635172524403' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `offline_user_subscriptions` ADD `subscription_id` int(11) NULL') + await queryRunner.query('ALTER TABLE `user_subscriptions` ADD `subscription_id` int(11) NULL') + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `user_subscriptions` DROP COLUMN `subscription_id`') + await queryRunner.query('ALTER TABLE `offline_user_subscriptions` DROP COLUMN `subscription_id`') + } +} diff --git a/packages/auth/migrations/1635344737460-add_missing_permissions.ts b/packages/auth/migrations/1635344737460-add_missing_permissions.ts new file mode 100644 index 000000000..65d9f596f --- /dev/null +++ b/packages/auth/migrations/1635344737460-add_missing_permissions.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addMissingPermissions1635344737460 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("8f3d28cd-f17d-423b-8e4d-20143246ccf7", "component:filesafe")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("14283420-6d22-43e6-a63b-26e755604dc6", "component:folders")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("3d362e65-1874-4bcd-ba37-1918aa71f5f6", "theme:no-distraction")', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("53812c9b-9c3d-4c3f-927b-5e1479e1e3a0", "theme:dynamic")', + ) + + // Pro User Permissions + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "8f3d28cd-f17d-423b-8e4d-20143246ccf7"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "14283420-6d22-43e6-a63b-26e755604dc6"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "3d362e65-1874-4bcd-ba37-1918aa71f5f6"), \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "53812c9b-9c3d-4c3f-927b-5e1479e1e3a0") \ + ', + ) + + // Plus User Permissions + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "8f3d28cd-f17d-423b-8e4d-20143246ccf7"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "14283420-6d22-43e6-a63b-26e755604dc6"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "3d362e65-1874-4bcd-ba37-1918aa71f5f6"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "53812c9b-9c3d-4c3f-927b-5e1479e1e3a0") \ + ', + ) + + // Core User Permissions + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES \ + ("bde42e26-628c-44e6-9d76-21b08954b0bf", "14283420-6d22-43e6-a63b-26e755604dc6"), \ + ("bde42e26-628c-44e6-9d76-21b08954b0bf", "3d362e65-1874-4bcd-ba37-1918aa71f5f6"), \ + ("bde42e26-628c-44e6-9d76-21b08954b0bf", "53812c9b-9c3d-4c3f-927b-5e1479e1e3a0") \ + ', + ) + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1635860707639-add_mandatory_subscription_id.ts b/packages/auth/migrations/1635860707639-add_mandatory_subscription_id.ts new file mode 100644 index 000000000..b3be94199 --- /dev/null +++ b/packages/auth/migrations/1635860707639-add_mandatory_subscription_id.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addMandatorySubscriptionId1635860707639 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE `offline_user_subscriptions` CHANGE `subscription_id` `subscription_id` int(11) NOT NULL', + ) + await queryRunner.query( + 'ALTER TABLE `user_subscriptions` CHANGE `subscription_id` `subscription_id` int(11) NOT NULL', + ) + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1638388151083-add_tags_and_focus_permissions.ts b/packages/auth/migrations/1638388151083-add_tags_and_focus_permissions.ts new file mode 100644 index 000000000..b4c835e92 --- /dev/null +++ b/packages/auth/migrations/1638388151083-add_tags_and_focus_permissions.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addTagsAndFocusPermissions1638388151083 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("1cd5d412-cb57-4cc0-a982-10045ef92780", "app:focus-mode")', + ) + + // Pro User Permissions + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "1cd5d412-cb57-4cc0-a982-10045ef92780") \ + ', + ) + + // Plus User Permissions + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "1cd5d412-cb57-4cc0-a982-10045ef92780") \ + ', + ) + + // Core User Permissions + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES \ + ("bde42e26-628c-44e6-9d76-21b08954b0bf", "1cd5d412-cb57-4cc0-a982-10045ef92780") \ + ', + ) + + await queryRunner.query( + 'DELETE FROM `role_permissions` WHERE role_uuid = "bde42e26-628c-44e6-9d76-21b08954b0bf" AND permission_uuid = "3f7044d6-c74d-48c2-8b5d-ef69e8b3d922"', + ) + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1639998097029-add_role_version.ts b/packages/auth/migrations/1639998097029-add_role_version.ts new file mode 100644 index 000000000..52bfac716 --- /dev/null +++ b/packages/auth/migrations/1639998097029-add_role_version.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addRoleVersion1639998097029 implements MigrationInterface { + name = 'addRoleVersion1639998097029' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX `index_roles_on_name` ON `roles`') + await queryRunner.query('ALTER TABLE `roles` ADD `version` smallint NULL') + await queryRunner.query('UPDATE `roles` SET `version` = 1') + await queryRunner.query('ALTER TABLE `roles` CHANGE `version` `version` smallint NOT NULL') + await queryRunner.query('CREATE UNIQUE INDEX `name_and_version` ON `roles` (`name`, `version`)') + await queryRunner.query( + 'INSERT INTO `roles` (uuid, name, version) VALUES ("23bf88ca-bee1-4a4c-adf0-b7a48749eea7", "CORE_USER", 2)', + ) + await queryRunner.query( + 'INSERT INTO `role_permissions` (permission_uuid, role_uuid) VALUES \ + ("1c6295d7-ffab-4881-bdf9-7c80df3885e9", "23bf88ca-bee1-4a4c-adf0-b7a48749eea7"), \ + ("1cd5d412-cb57-4cc0-a982-10045ef92780", "23bf88ca-bee1-4a4c-adf0-b7a48749eea7"), \ + ("39ebab56-00be-4495-8f59-ba25d5127f06", "23bf88ca-bee1-4a4c-adf0-b7a48749eea7"), \ + ("3d362e65-1874-4bcd-ba37-1918aa71f5f6", "23bf88ca-bee1-4a4c-adf0-b7a48749eea7"), \ + ("42e11c9b-e99b-43a5-bd32-77600c2e5ece", "23bf88ca-bee1-4a4c-adf0-b7a48749eea7"), \ + ("53812c9b-9c3d-4c3f-927b-5e1479e1e3a0", "23bf88ca-bee1-4a4c-adf0-b7a48749eea7"), \ + ("5f53c349-3fe5-4e5f-a9c5-a7caae6eb90a", "23bf88ca-bee1-4a4c-adf0-b7a48749eea7"), \ + ("6cbde260-5a00-46f5-907d-d9843fa87528", "23bf88ca-bee1-4a4c-adf0-b7a48749eea7"), \ + ("6ce3732f-f6bf-46e8-99be-6044903253b2", "23bf88ca-bee1-4a4c-adf0-b7a48749eea7"), \ + ("6e69b059-5324-4087-ba9d-c6c77ed2483c", "23bf88ca-bee1-4a4c-adf0-b7a48749eea7"), \ + ("90cde39c-f4ea-417a-ae25-15db8ef1d828", "23bf88ca-bee1-4a4c-adf0-b7a48749eea7"), \ + ("b04a7670-934e-4ab1-b8a3-0f27ff159511", "23bf88ca-bee1-4a4c-adf0-b7a48749eea7"), \ + ("b9d8488a-59aa-420a-8491-1f12b6484876", "23bf88ca-bee1-4a4c-adf0-b7a48749eea7"), \ + ("db84ebd6-5273-4af9-8d95-5603c6e3f75f", "23bf88ca-bee1-4a4c-adf0-b7a48749eea7"), \ + ("e1a3c091-3479-4d8d-b4df-66ec6c9f13c2", "23bf88ca-bee1-4a4c-adf0-b7a48749eea7") \ + ', + ) + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1640701224273-add-smart-tags.ts b/packages/auth/migrations/1640701224273-add-smart-tags.ts new file mode 100644 index 000000000..cb6989c61 --- /dev/null +++ b/packages/auth/migrations/1640701224273-add-smart-tags.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addSmartTags1640701224273 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("777d5c74-6201-4253-a56a-26d503e2abbd", "app:smart-filters")', + ) + + // Core user v1 keep access + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES ("bde42e26-628c-44e6-9d76-21b08954b0bf", "777d5c74-6201-4253-a56a-26d503e2abbd")', + ) + + // Pro User Permissions + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "777d5c74-6201-4253-a56a-26d503e2abbd")', + ) + + // Plus User Permissions + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES ("dee6e144-724b-4450-86d1-cc784770b2e2", "777d5c74-6201-4253-a56a-26d503e2abbd")', + ) + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1640862425427-remove_no_distraction_theme.ts b/packages/auth/migrations/1640862425427-remove_no_distraction_theme.ts new file mode 100644 index 000000000..3fe868fb6 --- /dev/null +++ b/packages/auth/migrations/1640862425427-remove_no_distraction_theme.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class removeNoDistractionTheme1640862425427 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('DELETE FROM `permissions` WHERE `name` = "theme:no-distraction"') + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1645094434931-add_sign_in_alerts_permission.ts b/packages/auth/migrations/1645094434931-add_sign_in_alerts_permission.ts new file mode 100644 index 000000000..47c1d1c94 --- /dev/null +++ b/packages/auth/migrations/1645094434931-add_sign_in_alerts_permission.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addSignInAlertsPermission1645094434931 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("2074d312-78bc-4533-b008-38e1232226c0", "server:sign-in-alerts")', + ) + + // Pro User Permissions + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "2074d312-78bc-4533-b008-38e1232226c0") \ + ', + ) + + // Plus User Permissions + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "2074d312-78bc-4533-b008-38e1232226c0") \ + ', + ) + + // Core User Permissions + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES \ + ("bde42e26-628c-44e6-9d76-21b08954b0bf", "2074d312-78bc-4533-b008-38e1232226c0") \ + ', + ) + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1646817642385-add_markdown_visual_editor_permissions.ts b/packages/auth/migrations/1646817642385-add_markdown_visual_editor_permissions.ts new file mode 100644 index 000000000..7584e8a6e --- /dev/null +++ b/packages/auth/migrations/1646817642385-add_markdown_visual_editor_permissions.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addMarkdownVisualEditorPermissions1646817642385 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("8bb2f775-484d-4fe1-9617-cc5cd22461d9", "editor:markdown-visual")', + ) + + // Pro User Permissions + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "8bb2f775-484d-4fe1-9617-cc5cd22461d9") \ + ', + ) + + // Plus User Permissions + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "8bb2f775-484d-4fe1-9617-cc5cd22461d9") \ + ', + ) + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1647253634773-fix_storage_quota_on_plans.ts b/packages/auth/migrations/1647253634773-fix_storage_quota_on_plans.ts new file mode 100644 index 000000000..11ba46639 --- /dev/null +++ b/packages/auth/migrations/1647253634773-fix_storage_quota_on_plans.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class fixStorageQuotaOnPlans1647253634773 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'DELETE FROM `role_permissions` WHERE permission_uuid IN ("466da066-993a-4d34-b77c-786395fa285a", "6b195abd-fa3e-4743-ba10-8d50733d377c")', + ) + + await queryRunner.query( + 'UPDATE settings SET value = 0 WHERE name = "FILE_UPLOAD_BYTES_LIMIT" AND value = 5368709120', + ) + + await queryRunner.query( + 'UPDATE settings SET value = 5368709120 WHERE name = "FILE_UPLOAD_BYTES_LIMIT" AND value = 26843545600', + ) + + // Pro User Permissions - 25GB + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "6b195abd-fa3e-4743-ba10-8d50733d377c") \ + ', + ) + + // Plus User Permissions - 5GB + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "466da066-993a-4d34-b77c-786395fa285a") \ + ', + ) + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1647421277767-remove_user_agent.ts b/packages/auth/migrations/1647421277767-remove_user_agent.ts new file mode 100644 index 000000000..15eee1408 --- /dev/null +++ b/packages/auth/migrations/1647421277767-remove_user_agent.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class removeUserAgent1647421277767 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `users` DROP COLUMN `updated_with_user_agent`') + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1647862631224-add_readonly_sessions.ts b/packages/auth/migrations/1647862631224-add_readonly_sessions.ts new file mode 100644 index 000000000..bacdfc837 --- /dev/null +++ b/packages/auth/migrations/1647862631224-add_readonly_sessions.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addReadonlySessions1647862631224 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `sessions` ADD `readonly_access` tinyint(1) NOT NULL DEFAULT 0') + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `sessions` DROP COLUMN `readonly_access`') + } +} diff --git a/packages/auth/migrations/1648112718114-add_tag_nesting_permission.ts b/packages/auth/migrations/1648112718114-add_tag_nesting_permission.ts new file mode 100644 index 000000000..8276517ce --- /dev/null +++ b/packages/auth/migrations/1648112718114-add_tag_nesting_permission.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addTagNestingPermission1648112718114 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Core User V2 Permissions + // add missing server:sign-in-alerts + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES \ + ("23bf88ca-bee1-4a4c-adf0-b7a48749eea7", "2074d312-78bc-4533-b008-38e1232226c0") \ + ', + ) + + // Core User V1 Permissions + // add app:tag-nesting + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES \ + ("bde42e26-628c-44e6-9d76-21b08954b0bf", "3f7044d6-c74d-48c2-8b5d-ef69e8b3d922") \ + ', + ) + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1648458841415-add_shared_subscription_invitations.ts b/packages/auth/migrations/1648458841415-add_shared_subscription_invitations.ts new file mode 100644 index 000000000..cf6858c95 --- /dev/null +++ b/packages/auth/migrations/1648458841415-add_shared_subscription_invitations.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addSharedSubscriptionInvitations1648458841415 implements MigrationInterface { + name = 'addSharedSubscriptionInvitations1648458841415' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'CREATE TABLE `shared_subscription_invitations` (`uuid` varchar(36) NOT NULL, `inviter_identifier` varchar(255) NOT NULL, `inviter_identifier_type` varchar(24) NOT NULL, `invitee_identifier` varchar(255) NOT NULL, `invitee_identifier_type` varchar(24) NOT NULL, `status` varchar(255) NOT NULL, `subscription_id` int(11) NOT NULL, `created_at` bigint NOT NULL, `updated_at` bigint NOT NULL, INDEX `inviter_identifier` (`inviter_identifier`), INDEX `invitee_identifier` (`invitee_identifier`), INDEX `invitee_and_status` (`invitee_identifier`, `status`), PRIMARY KEY (`uuid`)) ENGINE=InnoDB', + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DROP INDEX `invitee_and_status` ON `shared_subscription_invitations`') + await queryRunner.query('DROP INDEX `invitee_identifier` ON `shared_subscription_invitations`') + await queryRunner.query('DROP INDEX `inviter_identifier` ON `shared_subscription_invitations`') + await queryRunner.query('DROP TABLE `shared_subscription_invitations`') + } +} diff --git a/packages/auth/migrations/1648550676786-add_subscription_types.ts b/packages/auth/migrations/1648550676786-add_subscription_types.ts new file mode 100644 index 000000000..f203571ee --- /dev/null +++ b/packages/auth/migrations/1648550676786-add_subscription_types.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addSubscriptionTypes1648550676786 implements MigrationInterface { + name = 'addSubscriptionTypes1648550676786' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `user_subscriptions` ADD `subscription_type` varchar(24) NULL') + await queryRunner.query('UPDATE user_subscriptions SET subscription_type = "regular"') + await queryRunner.query( + 'ALTER TABLE `user_subscriptions` CHANGE `subscription_type` `subscription_type` varchar(24) NOT NULL', + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `user_subscriptions` DROP COLUMN `subscription_type`') + } +} diff --git a/packages/auth/migrations/1648629732139-add_beta_files_user_role.ts b/packages/auth/migrations/1648629732139-add_beta_files_user_role.ts new file mode 100644 index 000000000..355ecaf1c --- /dev/null +++ b/packages/auth/migrations/1648629732139-add_beta_files_user_role.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addBetaFilesUserRole1648629732139 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'INSERT INTO `roles` (uuid, name, version) VALUES ("1cd9ee6e-bc95-4f32-957c-d8c41f94d4ef", "FILES_BETA_USER", 1)', + ) + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("155e6901-4c35-422b-8643-c99cdcbcf54d", "app:files-beta")', + ) + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES ("1cd9ee6e-bc95-4f32-957c-d8c41f94d4ef", "155e6901-4c35-422b-8643-c99cdcbcf54d")', + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('DELETE FROM `role_permissions` WHERE role_uuid="1cd9ee6e-bc95-4f32-957c-d8c41f94d4ef"') + await queryRunner.query('DELETE FROM `roles` WHERE name="FILES_BETA_USER"') + await queryRunner.query('DELETE FROM `permissions` WHERE name="app:files-beta"') + } +} diff --git a/packages/auth/migrations/1649660400536-add_subscription_settings.ts b/packages/auth/migrations/1649660400536-add_subscription_settings.ts new file mode 100644 index 000000000..f9a0428ef --- /dev/null +++ b/packages/auth/migrations/1649660400536-add_subscription_settings.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addSubscriptionSettings1649660400536 implements MigrationInterface { + name = 'addSubscriptionSettings1649660400536' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'CREATE TABLE `subscription_settings` (`uuid` varchar(36) NOT NULL, `name` varchar(255) NOT NULL, `value` text NULL, `server_encryption_version` tinyint NOT NULL DEFAULT 0, `created_at` bigint NOT NULL, `updated_at` bigint NOT NULL, `sensitive` tinyint(1) NOT NULL DEFAULT 0, `user_subscription_uuid` varchar(36) NOT NULL, INDEX `index_subcsription_settings_on_updated_at` (`updated_at`), INDEX `index_settings_on_name_and_user_subscription_uuid` (`name`, `user_subscription_uuid`), PRIMARY KEY (`uuid`)) ENGINE=InnoDB', + ) + await queryRunner.query( + 'ALTER TABLE `subscription_settings` ADD CONSTRAINT `FK_ad2907de2850d8b531ff23329f3` FOREIGN KEY (`user_subscription_uuid`) REFERENCES `user_subscriptions`(`uuid`) ON DELETE CASCADE ON UPDATE NO ACTION', + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `subscription_settings` DROP FOREIGN KEY `FK_ad2907de2850d8b531ff23329f3`') + await queryRunner.query('DROP INDEX `index_settings_on_name_and_user_subscription_uuid` ON `subscription_settings`') + await queryRunner.query('DROP INDEX `index_subcsription_settings_on_updated_at` ON `subscription_settings`') + await queryRunner.query('DROP TABLE `subscription_settings`') + } +} diff --git a/packages/auth/migrations/1649679945386-remove_files_settings_from_user_settings.ts b/packages/auth/migrations/1649679945386-remove_files_settings_from_user_settings.ts new file mode 100644 index 000000000..614bb3670 --- /dev/null +++ b/packages/auth/migrations/1649679945386-remove_files_settings_from_user_settings.ts @@ -0,0 +1,12 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class removeFilesSettingsFromUserSettings1649679945386 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('DELETE FROM `settings` WHERE name="FILE_UPLOAD_BYTES_LIMIT"') + await queryRunner.query('DELETE FROM `settings` WHERE name="FILE_UPLOAD_BYTES_USED"') + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1650890853447-change_upload_quota_tiers.ts b/packages/auth/migrations/1650890853447-change_upload_quota_tiers.ts new file mode 100644 index 000000000..46b1f03e4 --- /dev/null +++ b/packages/auth/migrations/1650890853447-change_upload_quota_tiers.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class changeUploadQuotaTiers1650890853447 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'UPDATE `permissions` SET name = "server:files-max-storage-tier" WHERE name = "server:files-25-gb"', + ) + await queryRunner.query( + 'UPDATE `permissions` SET name = "server:files-low-storage-tier" WHERE name = "server:files-5-gb"', + ) + + await queryRunner.query( + 'UPDATE subscription_settings SET value = 107374182400 WHERE name = "FILE_UPLOAD_BYTES_LIMIT" AND value = 26843545600', + ) + await queryRunner.query( + 'UPDATE subscription_settings SET value = 104857600 WHERE name = "FILE_UPLOAD_BYTES_LIMIT" AND value = 5368709120', + ) + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1651046286472-add_2fa.ts b/packages/auth/migrations/1651046286472-add_2fa.ts new file mode 100644 index 000000000..61ab5ce27 --- /dev/null +++ b/packages/auth/migrations/1651046286472-add_2fa.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class add2fa1651046286472 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // 2FA for BASIC_USER + await queryRunner.query( + 'INSERT INTO `role_permissions` (permission_uuid, role_uuid) VALUES \ + ("1c6295d7-ffab-4881-bdf9-7c80df3885e9", "8802d6a3-b97c-4b25-968a-8fb21c65c3a1"), \ + ("b04a7670-934e-4ab1-b8a3-0f27ff159511", "8802d6a3-b97c-4b25-968a-8fb21c65c3a1") \ + ', + ) + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1651064332146-remove_2fa_manager.ts b/packages/auth/migrations/1651064332146-remove_2fa_manager.ts new file mode 100644 index 000000000..cfd965159 --- /dev/null +++ b/packages/auth/migrations/1651064332146-remove_2fa_manager.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class remove2faManager1651064332146 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('DELETE FROM `permissions` WHERE name="component:2fa-manager"') + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1652258146238-add_missing_plus_permissions.ts b/packages/auth/migrations/1652258146238-add_missing_plus_permissions.ts new file mode 100644 index 000000000..d6c5963e9 --- /dev/null +++ b/packages/auth/migrations/1652258146238-add_missing_plus_permissions.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addMissingPlusPermissions1652258146238 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Plus User Permissions + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "2ce06684-1f3d-45ee-87c1-df7b4447801b"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "897551c3-8ba8-48f0-8fb9-5c861b104fcf"), \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "2fdfe72e-2ec9-4600-97e5-5f19eaba8b6a") \ + ', + ) + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1652786070920-remove_basic_user_role.ts b/packages/auth/migrations/1652786070920-remove_basic_user_role.ts new file mode 100644 index 000000000..65713723a --- /dev/null +++ b/packages/auth/migrations/1652786070920-remove_basic_user_role.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class removeBasicUserRole1652786070920 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('UPDATE `roles` SET name = "CORE_USER", version = 3 WHERE name = "BASIC_USER"') + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1652880249670-add_analytics_entities.ts b/packages/auth/migrations/1652880249670-add_analytics_entities.ts new file mode 100644 index 000000000..9e70cc7d6 --- /dev/null +++ b/packages/auth/migrations/1652880249670-add_analytics_entities.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addAnalyticsEntities1652880249670 implements MigrationInterface { + name = 'addAnalyticsEntities1652880249670' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'CREATE TABLE `analytics_entities` (`id` int NOT NULL AUTO_INCREMENT, `user_uuid` varchar(36) NOT NULL, UNIQUE INDEX `REL_d2717c4ce2600b9f7acb6b378c` (`user_uuid`), PRIMARY KEY (`id`)) ENGINE=InnoDB', + ) + await queryRunner.query( + 'ALTER TABLE `analytics_entities` ADD CONSTRAINT `FK_d2717c4ce2600b9f7acb6b378c5` FOREIGN KEY (`user_uuid`) REFERENCES `users`(`uuid`) ON DELETE CASCADE ON UPDATE NO ACTION', + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `analytics_entities` DROP FOREIGN KEY `FK_d2717c4ce2600b9f7acb6b378c5`') + await queryRunner.query('DROP INDEX `REL_d2717c4ce2600b9f7acb6b378c` ON `analytics_entities`') + await queryRunner.query('DROP TABLE `analytics_entities`') + } +} diff --git a/packages/auth/migrations/1654760926952-add_email_backup_permission.ts b/packages/auth/migrations/1654760926952-add_email_backup_permission.ts new file mode 100644 index 000000000..36d576ce2 --- /dev/null +++ b/packages/auth/migrations/1654760926952-add_email_backup_permission.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addEmailBackupPermission1654760926952 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Core User Permissions + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES \ + ("8802d6a3-b97c-4b25-968a-8fb21c65c3a1", "eb0575a2-6e26-49e3-9501-f2e75d7dbda3") \ + ', + ) + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/migrations/1654877423147-add_advanced_checklist_editor_permission.ts b/packages/auth/migrations/1654877423147-add_advanced_checklist_editor_permission.ts new file mode 100644 index 000000000..0df403d4d --- /dev/null +++ b/packages/auth/migrations/1654877423147-add_advanced_checklist_editor_permission.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class addAdvancedChecklistEditorPermission1654877423147 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'INSERT INTO `permissions` (uuid, name) VALUES ("0cfac84e-6e35-422d-90ed-fbe01a9a3a1d", "editor:advanced-checklist")', + ) + + // Pro User Permissions + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES \ + ("8047edbb-a10a-4ff8-8d53-c2cae600a8e8", "0cfac84e-6e35-422d-90ed-fbe01a9a3a1d") \ + ', + ) + + // Plus User Permissions + await queryRunner.query( + 'INSERT INTO `role_permissions` (role_uuid, permission_uuid) VALUES \ + ("dee6e144-724b-4450-86d1-cc784770b2e2", "0cfac84e-6e35-422d-90ed-fbe01a9a3a1d") \ + ', + ) + } + + public async down(): Promise { + return + } +} diff --git a/packages/auth/package.json b/packages/auth/package.json new file mode 100644 index 000000000..43b02790b --- /dev/null +++ b/packages/auth/package.json @@ -0,0 +1,85 @@ +{ + "name": "@standardnotes/auth-server", + "version": "1.0.0", + "engines": { + "node": ">=16.0.0 <17.0.0" + }, + "description": "Auth Server", + "main": "dist/src/index.js", + "typings": "dist/src/index.d.ts", + "repository": "git@github.com:standardnotes/auth.git", + "author": "Karol Sójko ", + "license": "AGPL-3.0-or-later", + "scripts": { + "clean": "rm -fr dist", + "prebuild": "yarn clean", + "build": "tsc --rootDir ./", + "lint": "eslint . --ext .ts", + "pretest": "yarn lint && yarn build", + "test": "jest --coverage --config=./jest.config.js --maxWorkers=50%", + "start": "yarn node dist/bin/server.js", + "worker": "yarn node dist/bin/worker.js", + "daily-backup:email": "yarn node dist/bin/backup.js email daily", + "daily-backup:dropbox": "yarn node dist/bin/backup.js dropbox daily", + "daily-backup:google_drive": "yarn node dist/bin/backup.js google_drive daily", + "daily-backup:one_drive": "yarn node dist/bin/backup.js one_drive daily", + "weekly-backup:email": "yarn node dist/bin/backup.js email weekly", + "typeorm": "typeorm-ts-node-commonjs" + }, + "dependencies": { + "@newrelic/native-metrics": "7.0.2", + "@newrelic/winston-enricher": "^2.1.0", + "@sentry/node": "^6.16.1", + "@standardnotes/analytics": "^1.6.0", + "@standardnotes/api": "^1.1.13", + "@standardnotes/auth": "^3.19.2", + "@standardnotes/common": "^1.23.0", + "@standardnotes/domain-events": "^2.31.1", + "@standardnotes/domain-events-infra": "^1.4.135", + "@standardnotes/features": "^1.45.2", + "@standardnotes/responses": "^1.6.15", + "@standardnotes/scheduler": "^1.1.1", + "@standardnotes/settings": "^1.14.2", + "@standardnotes/sncrypto-common": "^1.8.1", + "@standardnotes/sncrypto-node": "^1.8.1", + "@standardnotes/time": "^1.6.8", + "aws-sdk": "^2.1159.0", + "axios": "0.24.0", + "bcryptjs": "2.4.3", + "cors": "2.8.5", + "crypto-random-string": "3.3.0", + "dayjs": "^1.11.3", + "dotenv": "8.2.0", + "express": "4.17.1", + "inversify": "^6.0.1", + "inversify-express-utils": "^6.4.3", + "ioredis": "^5.0.6", + "mysql2": "^2.3.3", + "newrelic": "8.6.0", + "otplib": "12.0.1", + "prettyjson": "1.2.1", + "reflect-metadata": "0.1.13", + "typeorm": "^0.3.6", + "ua-parser-js": "1.0.2", + "uuid": "8.3.2", + "winston": "3.3.3" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.2", + "@types/cors": "^2.8.9", + "@types/express": "^4.17.11", + "@types/ioredis": "^4.28.10", + "@types/jest": "^28.1.3", + "@types/newrelic": "^7.0.2", + "@types/otplib": "^10.0.0", + "@types/prettyjson": "^0.0.29", + "@types/ua-parser-js": "^0.7.36", + "@types/uuid": "^8.3.0", + "@typescript-eslint/eslint-plugin": "^5.29.0", + "eslint": "^8.14.0", + "eslint-plugin-prettier": "^4.0.0", + "jest": "^28.1.1", + "nodemon": "^2.0.16", + "ts-jest": "^28.0.1" + } +} diff --git a/packages/auth/src/Bootstrap/Container.ts b/packages/auth/src/Bootstrap/Container.ts new file mode 100644 index 000000000..c4859ccf8 --- /dev/null +++ b/packages/auth/src/Bootstrap/Container.ts @@ -0,0 +1,610 @@ +import * as winston from 'winston' +import Redis from 'ioredis' +import * as AWS from 'aws-sdk' +import { Container } from 'inversify' +import { + DomainEventHandlerInterface, + DomainEventMessageHandlerInterface, + DomainEventSubscriberFactoryInterface, +} from '@standardnotes/domain-events' +import { TimerInterface, Timer } from '@standardnotes/time' +import { UAParser } from 'ua-parser-js' +import { AnalyticsStoreInterface, PeriodKeyGenerator, RedisAnalyticsStore } from '@standardnotes/analytics' + +import { Env } from './Env' +import TYPES from './Types' +import { AuthMiddleware } from '../Controller/AuthMiddleware' +import { AuthenticateUser } from '../Domain/UseCase/AuthenticateUser' +import { Repository } from 'typeorm' +import { AppDataSource } from './DataSource' +import { User } from '../Domain/User/User' +import { Session } from '../Domain/Session/Session' +import { SessionService } from '../Domain/Session/SessionService' +import { MySQLSessionRepository } from '../Infra/MySQL/MySQLSessionRepository' +import { MySQLUserRepository } from '../Infra/MySQL/MySQLUserRepository' +import { SessionProjector } from '../Projection/SessionProjector' +import { SessionMiddleware } from '../Controller/SessionMiddleware' +import { RefreshSessionToken } from '../Domain/UseCase/RefreshSessionToken' +import { KeyParamsFactory } from '../Domain/User/KeyParamsFactory' +import { SignIn } from '../Domain/UseCase/SignIn' +import { VerifyMFA } from '../Domain/UseCase/VerifyMFA' +import { UserProjector } from '../Projection/UserProjector' +import { AuthResponseFactory20161215 } from '../Domain/Auth/AuthResponseFactory20161215' +import { AuthResponseFactory20190520 } from '../Domain/Auth/AuthResponseFactory20190520' +import { AuthResponseFactory20200115 } from '../Domain/Auth/AuthResponseFactory20200115' +import { AuthResponseFactoryResolver } from '../Domain/Auth/AuthResponseFactoryResolver' +import { ClearLoginAttempts } from '../Domain/UseCase/ClearLoginAttempts' +import { IncreaseLoginAttempts } from '../Domain/UseCase/IncreaseLoginAttempts' +import { LockMiddleware } from '../Controller/LockMiddleware' +import { AuthMiddlewareWithoutResponse } from '../Controller/AuthMiddlewareWithoutResponse' +import { GetUserKeyParams } from '../Domain/UseCase/GetUserKeyParams/GetUserKeyParams' +import { UpdateUser } from '../Domain/UseCase/UpdateUser' +import { RedisEphemeralSessionRepository } from '../Infra/Redis/RedisEphemeralSessionRepository' +import { GetActiveSessionsForUser } from '../Domain/UseCase/GetActiveSessionsForUser' +import { DeletePreviousSessionsForUser } from '../Domain/UseCase/DeletePreviousSessionsForUser' +import { DeleteSessionForUser } from '../Domain/UseCase/DeleteSessionForUser' +import { Register } from '../Domain/UseCase/Register' +import { LockRepository } from '../Infra/Redis/LockRepository' +import { MySQLRevokedSessionRepository } from '../Infra/MySQL/MySQLRevokedSessionRepository' +import { AuthenticationMethodResolver } from '../Domain/Auth/AuthenticationMethodResolver' +import { RevokedSession } from '../Domain/Session/RevokedSession' +import { UserRegisteredEventHandler } from '../Domain/Handler/UserRegisteredEventHandler' +import { DomainEventFactory } from '../Domain/Event/DomainEventFactory' +import { AuthenticateRequest } from '../Domain/UseCase/AuthenticateRequest' +import { Role } from '../Domain/Role/Role' +import { RoleProjector } from '../Projection/RoleProjector' +import { PermissionProjector } from '../Projection/PermissionProjector' +import { MySQLRoleRepository } from '../Infra/MySQL/MySQLRoleRepository' +import { Setting } from '../Domain/Setting/Setting' +import { MySQLSettingRepository } from '../Infra/MySQL/MySQLSettingRepository' +import { CrypterInterface } from '../Domain/Encryption/CrypterInterface' +import { CrypterNode } from '../Domain/Encryption/CrypterNode' +import { CryptoNode } from '@standardnotes/sncrypto-node' +import { GetSettings } from '../Domain/UseCase/GetSettings/GetSettings' +import { SettingProjector } from '../Projection/SettingProjector' +import { GetSetting } from '../Domain/UseCase/GetSetting/GetSetting' +import { UpdateSetting } from '../Domain/UseCase/UpdateSetting/UpdateSetting' +import { AccountDeletionRequestedEventHandler } from '../Domain/Handler/AccountDeletionRequestedEventHandler' +import { SubscriptionPurchasedEventHandler } from '../Domain/Handler/SubscriptionPurchasedEventHandler' +import { SubscriptionRenewedEventHandler } from '../Domain/Handler/SubscriptionRenewedEventHandler' +import { SubscriptionRefundedEventHandler } from '../Domain/Handler/SubscriptionRefundedEventHandler' +import { SubscriptionExpiredEventHandler } from '../Domain/Handler/SubscriptionExpiredEventHandler' +import { DeleteAccount } from '../Domain/UseCase/DeleteAccount/DeleteAccount' +import { DeleteSetting } from '../Domain/UseCase/DeleteSetting/DeleteSetting' +import { SettingFactory } from '../Domain/Setting/SettingFactory' +import { SettingService } from '../Domain/Setting/SettingService' +import { WebSocketsConnectionRepositoryInterface } from '../Domain/WebSockets/WebSocketsConnectionRepositoryInterface' +import { RedisWebSocketsConnectionRepository } from '../Infra/Redis/RedisWebSocketsConnectionRepository' +import { AddWebSocketsConnection } from '../Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnection' +import { RemoveWebSocketsConnection } from '../Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnection' +import axios, { AxiosInstance } from 'axios' +import { UserSubscription } from '../Domain/Subscription/UserSubscription' +import { MySQLUserSubscriptionRepository } from '../Infra/MySQL/MySQLUserSubscriptionRepository' +import { WebSocketsClientService } from '../Infra/WebSockets/WebSocketsClientService' +import { RoleService } from '../Domain/Role/RoleService' +import { ClientServiceInterface } from '../Domain/Client/ClientServiceInterface' +import { RoleServiceInterface } from '../Domain/Role/RoleServiceInterface' +import { GetUserFeatures } from '../Domain/UseCase/GetUserFeatures/GetUserFeatures' +import { RoleToSubscriptionMapInterface } from '../Domain/Role/RoleToSubscriptionMapInterface' +import { RoleToSubscriptionMap } from '../Domain/Role/RoleToSubscriptionMap' +import { FeatureServiceInterface } from '../Domain/Feature/FeatureServiceInterface' +import { FeatureService } from '../Domain/Feature/FeatureService' +import { SettingServiceInterface } from '../Domain/Setting/SettingServiceInterface' +import { ExtensionKeyGrantedEventHandler } from '../Domain/Handler/ExtensionKeyGrantedEventHandler' +import { + RedisDomainEventPublisher, + RedisDomainEventSubscriberFactory, + RedisEventMessageHandler, + SNSDomainEventPublisher, + SQSDomainEventSubscriberFactory, + SQSEventMessageHandler, + SQSNewRelicEventMessageHandler, +} from '@standardnotes/domain-events-infra' +import { GetUserSubscription } from '../Domain/UseCase/GetUserSubscription/GetUserSubscription' +import { ChangeCredentials } from '../Domain/UseCase/ChangeCredentials/ChangeCredentials' +import { SubscriptionReassignedEventHandler } from '../Domain/Handler/SubscriptionReassignedEventHandler' +import { UserSubscriptionRepositoryInterface } from '../Domain/Subscription/UserSubscriptionRepositoryInterface' +import { CreateSubscriptionToken } from '../Domain/UseCase/CreateSubscriptionToken/CreateSubscriptionToken' +import { ApiGatewayAuthMiddleware } from '../Controller/ApiGatewayAuthMiddleware' +import { SubscriptionTokenRepositoryInterface } from '../Domain/Subscription/SubscriptionTokenRepositoryInterface' +import { RedisSubscriptionTokenRepository } from '../Infra/Redis/RedisSubscriptionTokenRepository' +import { AuthenticateSubscriptionToken } from '../Domain/UseCase/AuthenticateSubscriptionToken/AuthenticateSubscriptionToken' +import { OfflineSetting } from '../Domain/Setting/OfflineSetting' +import { OfflineSettingServiceInterface } from '../Domain/Setting/OfflineSettingServiceInterface' +import { OfflineSettingService } from '../Domain/Setting/OfflineSettingService' +import { OfflineSettingRepositoryInterface } from '../Domain/Setting/OfflineSettingRepositoryInterface' +import { SettingRepositoryInterface } from '../Domain/Setting/SettingRepositoryInterface' +import { MySQLOfflineSettingRepository } from '../Infra/MySQL/MySQLOfflineSettingRepository' +import { OfflineUserSubscription } from '../Domain/Subscription/OfflineUserSubscription' +import { OfflineUserSubscriptionRepositoryInterface } from '../Domain/Subscription/OfflineUserSubscriptionRepositoryInterface' +import { MySQLOfflineUserSubscriptionRepository } from '../Infra/MySQL/MySQLOfflineUserSubscriptionRepository' +import { OfflineUserAuthMiddleware } from '../Controller/OfflineUserAuthMiddleware' +import { OfflineSubscriptionTokenRepositoryInterface } from '../Domain/Auth/OfflineSubscriptionTokenRepositoryInterface' +import { RedisOfflineSubscriptionTokenRepository } from '../Infra/Redis/RedisOfflineSubscriptionTokenRepository' +import { CreateOfflineSubscriptionToken } from '../Domain/UseCase/CreateOfflineSubscriptionToken/CreateOfflineSubscriptionToken' +import { AuthenticateOfflineSubscriptionToken } from '../Domain/UseCase/AuthenticateOfflineSubscriptionToken/AuthenticateOfflineSubscriptionToken' +import { SubscriptionCancelledEventHandler } from '../Domain/Handler/SubscriptionCancelledEventHandler' +import { ContentDecoder, ContentDecoderInterface, ProtocolVersion } from '@standardnotes/common' +import { GetUserOfflineSubscription } from '../Domain/UseCase/GetUserOfflineSubscription/GetUserOfflineSubscription' +import { ApiGatewayOfflineAuthMiddleware } from '../Controller/ApiGatewayOfflineAuthMiddleware' +import { UserEmailChangedEventHandler } from '../Domain/Handler/UserEmailChangedEventHandler' +import { SettingsAssociationServiceInterface } from '../Domain/Setting/SettingsAssociationServiceInterface' +import { SettingsAssociationService } from '../Domain/Setting/SettingsAssociationService' +import { MuteFailedBackupsEmails } from '../Domain/UseCase/MuteFailedBackupsEmails/MuteFailedBackupsEmails' +import { SubscriptionSyncRequestedEventHandler } from '../Domain/Handler/SubscriptionSyncRequestedEventHandler' +import { + CrossServiceTokenData, + DeterministicSelector, + OfflineUserTokenData, + SelectorInterface, + SessionTokenData, + TokenDecoder, + TokenDecoderInterface, + TokenEncoder, + TokenEncoderInterface, + ValetTokenData, +} from '@standardnotes/auth' +import { FileUploadedEventHandler } from '../Domain/Handler/FileUploadedEventHandler' +import { CreateValetToken } from '../Domain/UseCase/CreateValetToken/CreateValetToken' +import { CreateListedAccount } from '../Domain/UseCase/CreateListedAccount/CreateListedAccount' +import { ListedAccountCreatedEventHandler } from '../Domain/Handler/ListedAccountCreatedEventHandler' +import { ListedAccountDeletedEventHandler } from '../Domain/Handler/ListedAccountDeletedEventHandler' +import { MuteSignInEmails } from '../Domain/UseCase/MuteSignInEmails/MuteSignInEmails' +import { FileRemovedEventHandler } from '../Domain/Handler/FileRemovedEventHandler' +import { UserDisabledSessionUserAgentLoggingEventHandler } from '../Domain/Handler/UserDisabledSessionUserAgentLoggingEventHandler' +import { SettingInterpreterInterface } from '../Domain/Setting/SettingInterpreterInterface' +import { SettingInterpreter } from '../Domain/Setting/SettingInterpreter' +import { SettingDecrypterInterface } from '../Domain/Setting/SettingDecrypterInterface' +import { SettingDecrypter } from '../Domain/Setting/SettingDecrypter' +import { SharedSubscriptionInvitationRepositoryInterface } from '../Domain/SharedSubscription/SharedSubscriptionInvitationRepositoryInterface' +import { MySQLSharedSubscriptionInvitationRepository } from '../Infra/MySQL/MySQLSharedSubscriptionInvitationRepository' +import { InviteToSharedSubscription } from '../Domain/UseCase/InviteToSharedSubscription/InviteToSharedSubscription' +import { SharedSubscriptionInvitation } from '../Domain/SharedSubscription/SharedSubscriptionInvitation' +import { AcceptSharedSubscriptionInvitation } from '../Domain/UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitation' +import { DeclineSharedSubscriptionInvitation } from '../Domain/UseCase/DeclineSharedSubscriptionInvitation/DeclineSharedSubscriptionInvitation' +import { CancelSharedSubscriptionInvitation } from '../Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitation' +import { SharedSubscriptionInvitationCreatedEventHandler } from '../Domain/Handler/SharedSubscriptionInvitationCreatedEventHandler' +import { SubscriptionSetting } from '../Domain/Setting/SubscriptionSetting' +import { SubscriptionSettingServiceInterface } from '../Domain/Setting/SubscriptionSettingServiceInterface' +import { SubscriptionSettingService } from '../Domain/Setting/SubscriptionSettingService' +import { SubscriptionSettingRepositoryInterface } from '../Domain/Setting/SubscriptionSettingRepositoryInterface' +import { MySQLSubscriptionSettingRepository } from '../Infra/MySQL/MySQLSubscriptionSettingRepository' +import { SettingFactoryInterface } from '../Domain/Setting/SettingFactoryInterface' +import { ListSharedSubscriptionInvitations } from '../Domain/UseCase/ListSharedSubscriptionInvitations/ListSharedSubscriptionInvitations' +import { UserSubscriptionServiceInterface } from '../Domain/Subscription/UserSubscriptionServiceInterface' +import { UserSubscriptionService } from '../Domain/Subscription/UserSubscriptionService' +import { SubscriptionSettingProjector } from '../Projection/SubscriptionSettingProjector' +import { GetSubscriptionSetting } from '../Domain/UseCase/GetSubscriptionSetting/GetSubscriptionSetting' +import { SubscriptionSettingsAssociationService } from '../Domain/Setting/SubscriptionSettingsAssociationService' +import { SubscriptionSettingsAssociationServiceInterface } from '../Domain/Setting/SubscriptionSettingsAssociationServiceInterface' +import { PKCERepositoryInterface } from '../Domain/User/PKCERepositoryInterface' +import { RedisPKCERepository } from '../Infra/Redis/RedisPKCERepository' +import { RoleRepositoryInterface } from '../Domain/Role/RoleRepositoryInterface' +import { RevokedSessionRepositoryInterface } from '../Domain/Session/RevokedSessionRepositoryInterface' +import { SessionRepositoryInterface } from '../Domain/Session/SessionRepositoryInterface' +import { UserRepositoryInterface } from '../Domain/User/UserRepositoryInterface' +import { AnalyticsEntity } from '../Domain/Analytics/AnalyticsEntity' +import { AnalyticsEntityRepositoryInterface } from '../Domain/Analytics/AnalyticsEntityRepositoryInterface' +import { MySQLAnalyticsEntityRepository } from '../Infra/MySQL/MySQLAnalyticsEntityRepository' +import { GetUserAnalyticsId } from '../Domain/UseCase/GetUserAnalyticsId/GetUserAnalyticsId' +import { AuthController } from '../Controller/AuthController' +import { VerifyPredicate } from '../Domain/UseCase/VerifyPredicate/VerifyPredicate' +import { PredicateVerificationRequestedEventHandler } from '../Domain/Handler/PredicateVerificationRequestedEventHandler' + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const newrelicWinstonEnricher = require('@newrelic/winston-enricher') + +export class ContainerConfigLoader { + async load(): Promise { + const env: Env = new Env() + env.load() + + const container = new Container() + + await AppDataSource.initialize() + + const redisUrl = env.get('REDIS_URL') + const isRedisInClusterMode = redisUrl.indexOf(',') > 0 + let redis + if (isRedisInClusterMode) { + redis = new Redis.Cluster(redisUrl.split(',')) + } else { + redis = new Redis(redisUrl) + } + + container.bind(TYPES.Redis).toConstantValue(redis) + + const winstonFormatters = [winston.format.splat(), winston.format.json()] + if (env.get('NEW_RELIC_ENABLED', true) === 'true') { + winstonFormatters.push(newrelicWinstonEnricher()) + } + + const logger = winston.createLogger({ + level: env.get('LOG_LEVEL') || 'info', + format: winston.format.combine(...winstonFormatters), + transports: [new winston.transports.Console({ level: env.get('LOG_LEVEL') || 'info' })], + }) + container.bind(TYPES.Logger).toConstantValue(logger) + + if (env.get('SNS_AWS_REGION', true)) { + container.bind(TYPES.SNS).toConstantValue( + new AWS.SNS({ + apiVersion: 'latest', + region: env.get('SNS_AWS_REGION', true), + }), + ) + } + + if (env.get('SQS_QUEUE_URL', true)) { + const sqsConfig: AWS.SQS.Types.ClientConfiguration = { + apiVersion: 'latest', + region: env.get('SQS_AWS_REGION', true), + } + if (env.get('SQS_ACCESS_KEY_ID', true) && env.get('SQS_SECRET_ACCESS_KEY', true)) { + sqsConfig.credentials = { + accessKeyId: env.get('SQS_ACCESS_KEY_ID', true), + secretAccessKey: env.get('SQS_SECRET_ACCESS_KEY', true), + } + } + container.bind(TYPES.SQS).toConstantValue(new AWS.SQS(sqsConfig)) + } + + // Controller + container.bind(TYPES.AuthController).to(AuthController) + + // Repositories + container.bind(TYPES.SessionRepository).to(MySQLSessionRepository) + container.bind(TYPES.RevokedSessionRepository).to(MySQLRevokedSessionRepository) + container.bind(TYPES.UserRepository).to(MySQLUserRepository) + container.bind(TYPES.SettingRepository).to(MySQLSettingRepository) + container + .bind(TYPES.SubscriptionSettingRepository) + .to(MySQLSubscriptionSettingRepository) + container.bind(TYPES.OfflineSettingRepository).to(MySQLOfflineSettingRepository) + container.bind(TYPES.RoleRepository).to(MySQLRoleRepository) + container + .bind(TYPES.UserSubscriptionRepository) + .to(MySQLUserSubscriptionRepository) + container + .bind(TYPES.OfflineUserSubscriptionRepository) + .to(MySQLOfflineUserSubscriptionRepository) + container + .bind(TYPES.EphemeralSessionRepository) + .to(RedisEphemeralSessionRepository) + container.bind(TYPES.LockRepository).to(LockRepository) + container + .bind(TYPES.WebSocketsConnectionRepository) + .to(RedisWebSocketsConnectionRepository) + container + .bind(TYPES.SubscriptionTokenRepository) + .to(RedisSubscriptionTokenRepository) + container + .bind(TYPES.OfflineSubscriptionTokenRepository) + .to(RedisOfflineSubscriptionTokenRepository) + container + .bind(TYPES.SharedSubscriptionInvitationRepository) + .to(MySQLSharedSubscriptionInvitationRepository) + container.bind(TYPES.PKCERepository).to(RedisPKCERepository) + container + .bind(TYPES.AnalyticsEntityRepository) + .to(MySQLAnalyticsEntityRepository) + + // ORM + container + .bind>(TYPES.ORMOfflineSettingRepository) + .toConstantValue(AppDataSource.getRepository(OfflineSetting)) + container + .bind>(TYPES.ORMOfflineUserSubscriptionRepository) + .toConstantValue(AppDataSource.getRepository(OfflineUserSubscription)) + container + .bind>(TYPES.ORMRevokedSessionRepository) + .toConstantValue(AppDataSource.getRepository(RevokedSession)) + container.bind>(TYPES.ORMRoleRepository).toConstantValue(AppDataSource.getRepository(Role)) + container + .bind>(TYPES.ORMSessionRepository) + .toConstantValue(AppDataSource.getRepository(Session)) + container + .bind>(TYPES.ORMSettingRepository) + .toConstantValue(AppDataSource.getRepository(Setting)) + container + .bind>(TYPES.ORMSharedSubscriptionInvitationRepository) + .toConstantValue(AppDataSource.getRepository(SharedSubscriptionInvitation)) + container + .bind>(TYPES.ORMSubscriptionSettingRepository) + .toConstantValue(AppDataSource.getRepository(SubscriptionSetting)) + container.bind>(TYPES.ORMUserRepository).toConstantValue(AppDataSource.getRepository(User)) + container + .bind>(TYPES.ORMUserSubscriptionRepository) + .toConstantValue(AppDataSource.getRepository(UserSubscription)) + container + .bind>(TYPES.ORMAnalyticsEntityRepository) + .toConstantValue(AppDataSource.getRepository(AnalyticsEntity)) + + // Middleware + container.bind(TYPES.AuthMiddleware).to(AuthMiddleware) + container.bind(TYPES.SessionMiddleware).to(SessionMiddleware) + container.bind(TYPES.LockMiddleware).to(LockMiddleware) + container.bind(TYPES.AuthMiddlewareWithoutResponse).to(AuthMiddlewareWithoutResponse) + container.bind(TYPES.ApiGatewayAuthMiddleware).to(ApiGatewayAuthMiddleware) + container + .bind(TYPES.ApiGatewayOfflineAuthMiddleware) + .to(ApiGatewayOfflineAuthMiddleware) + container.bind(TYPES.OfflineUserAuthMiddleware).to(OfflineUserAuthMiddleware) + + // Projectors + container.bind(TYPES.SessionProjector).to(SessionProjector) + container.bind(TYPES.UserProjector).to(UserProjector) + container.bind(TYPES.RoleProjector).to(RoleProjector) + container.bind(TYPES.PermissionProjector).to(PermissionProjector) + container.bind(TYPES.SettingProjector).to(SettingProjector) + container.bind(TYPES.SubscriptionSettingProjector).to(SubscriptionSettingProjector) + + // Factories + container.bind(TYPES.SettingFactory).to(SettingFactory) + + // env vars + container.bind(TYPES.JWT_SECRET).toConstantValue(env.get('JWT_SECRET')) + container.bind(TYPES.LEGACY_JWT_SECRET).toConstantValue(env.get('LEGACY_JWT_SECRET')) + container.bind(TYPES.AUTH_JWT_SECRET).toConstantValue(env.get('AUTH_JWT_SECRET')) + container.bind(TYPES.AUTH_JWT_TTL).toConstantValue(+env.get('AUTH_JWT_TTL')) + container.bind(TYPES.VALET_TOKEN_SECRET).toConstantValue(env.get('VALET_TOKEN_SECRET', true)) + container.bind(TYPES.VALET_TOKEN_TTL).toConstantValue(+env.get('VALET_TOKEN_TTL', true)) + container.bind(TYPES.ENCRYPTION_SERVER_KEY).toConstantValue(env.get('ENCRYPTION_SERVER_KEY')) + container.bind(TYPES.ACCESS_TOKEN_AGE).toConstantValue(env.get('ACCESS_TOKEN_AGE')) + container.bind(TYPES.REFRESH_TOKEN_AGE).toConstantValue(env.get('REFRESH_TOKEN_AGE')) + container.bind(TYPES.MAX_LOGIN_ATTEMPTS).toConstantValue(env.get('MAX_LOGIN_ATTEMPTS')) + container.bind(TYPES.FAILED_LOGIN_LOCKOUT).toConstantValue(env.get('FAILED_LOGIN_LOCKOUT')) + container.bind(TYPES.PSEUDO_KEY_PARAMS_KEY).toConstantValue(env.get('PSEUDO_KEY_PARAMS_KEY')) + container.bind(TYPES.EPHEMERAL_SESSION_AGE).toConstantValue(env.get('EPHEMERAL_SESSION_AGE')) + container.bind(TYPES.REDIS_URL).toConstantValue(env.get('REDIS_URL')) + container.bind(TYPES.DISABLE_USER_REGISTRATION).toConstantValue(env.get('DISABLE_USER_REGISTRATION') === 'true') + container.bind(TYPES.ANALYTICS_ENABLED).toConstantValue(env.get('ANALYTICS_ENABLED', true) === 'true') + container.bind(TYPES.SNS_TOPIC_ARN).toConstantValue(env.get('SNS_TOPIC_ARN', true)) + container.bind(TYPES.SNS_AWS_REGION).toConstantValue(env.get('SNS_AWS_REGION', true)) + container.bind(TYPES.SQS_QUEUE_URL).toConstantValue(env.get('SQS_QUEUE_URL', true)) + container.bind(TYPES.USER_SERVER_REGISTRATION_URL).toConstantValue(env.get('USER_SERVER_REGISTRATION_URL', true)) + container.bind(TYPES.USER_SERVER_AUTH_KEY).toConstantValue(env.get('USER_SERVER_AUTH_KEY', true)) + container.bind(TYPES.USER_SERVER_CHANGE_EMAIL_URL).toConstantValue(env.get('USER_SERVER_CHANGE_EMAIL_URL', true)) + container.bind(TYPES.REDIS_EVENTS_CHANNEL).toConstantValue(env.get('REDIS_EVENTS_CHANNEL')) + container.bind(TYPES.NEW_RELIC_ENABLED).toConstantValue(env.get('NEW_RELIC_ENABLED', true)) + container.bind(TYPES.SYNCING_SERVER_URL).toConstantValue(env.get('SYNCING_SERVER_URL')) + container.bind(TYPES.WEBSOCKETS_API_URL).toConstantValue(env.get('WEBSOCKETS_API_URL', true)) + container.bind(TYPES.VERSION).toConstantValue(env.get('VERSION')) + container.bind(TYPES.PAYMENTS_SERVER_URL).toConstantValue(env.get('PAYMENTS_SERVER_URL', true)) + + // use cases + container.bind(TYPES.AuthenticateUser).to(AuthenticateUser) + container.bind(TYPES.AuthenticateRequest).to(AuthenticateRequest) + container.bind(TYPES.RefreshSessionToken).to(RefreshSessionToken) + container.bind(TYPES.SignIn).to(SignIn) + container.bind(TYPES.VerifyMFA).to(VerifyMFA) + container.bind(TYPES.ClearLoginAttempts).to(ClearLoginAttempts) + container.bind(TYPES.IncreaseLoginAttempts).to(IncreaseLoginAttempts) + container.bind(TYPES.GetUserKeyParams).to(GetUserKeyParams) + container.bind(TYPES.UpdateUser).to(UpdateUser) + container.bind(TYPES.Register).to(Register) + container.bind(TYPES.GetActiveSessionsForUser).to(GetActiveSessionsForUser) + container.bind(TYPES.DeletePreviousSessionsForUser).to(DeletePreviousSessionsForUser) + container.bind(TYPES.DeleteSessionForUser).to(DeleteSessionForUser) + container.bind(TYPES.ChangeCredentials).to(ChangeCredentials) + container.bind(TYPES.GetSettings).to(GetSettings) + container.bind(TYPES.GetSetting).to(GetSetting) + container.bind(TYPES.GetUserFeatures).to(GetUserFeatures) + container.bind(TYPES.UpdateSetting).to(UpdateSetting) + container.bind(TYPES.DeleteSetting).to(DeleteSetting) + container.bind(TYPES.DeleteAccount).to(DeleteAccount) + container.bind(TYPES.AddWebSocketsConnection).to(AddWebSocketsConnection) + container.bind(TYPES.RemoveWebSocketsConnection).to(RemoveWebSocketsConnection) + container.bind(TYPES.GetUserSubscription).to(GetUserSubscription) + container.bind(TYPES.GetUserOfflineSubscription).to(GetUserOfflineSubscription) + container.bind(TYPES.CreateSubscriptionToken).to(CreateSubscriptionToken) + container.bind(TYPES.AuthenticateSubscriptionToken).to(AuthenticateSubscriptionToken) + container + .bind(TYPES.AuthenticateOfflineSubscriptionToken) + .to(AuthenticateOfflineSubscriptionToken) + container + .bind(TYPES.CreateOfflineSubscriptionToken) + .to(CreateOfflineSubscriptionToken) + container.bind(TYPES.MuteFailedBackupsEmails).to(MuteFailedBackupsEmails) + container.bind(TYPES.MuteSignInEmails).to(MuteSignInEmails) + container.bind(TYPES.CreateValetToken).to(CreateValetToken) + container.bind(TYPES.CreateListedAccount).to(CreateListedAccount) + container.bind(TYPES.InviteToSharedSubscription).to(InviteToSharedSubscription) + container + .bind(TYPES.AcceptSharedSubscriptionInvitation) + .to(AcceptSharedSubscriptionInvitation) + container + .bind(TYPES.DeclineSharedSubscriptionInvitation) + .to(DeclineSharedSubscriptionInvitation) + container + .bind(TYPES.CancelSharedSubscriptionInvitation) + .to(CancelSharedSubscriptionInvitation) + container + .bind(TYPES.ListSharedSubscriptionInvitations) + .to(ListSharedSubscriptionInvitations) + container.bind(TYPES.GetSubscriptionSetting).to(GetSubscriptionSetting) + container.bind(TYPES.GetUserAnalyticsId).to(GetUserAnalyticsId) + container.bind(TYPES.VerifyPredicate).to(VerifyPredicate) + + // Handlers + container.bind(TYPES.UserRegisteredEventHandler).to(UserRegisteredEventHandler) + container + .bind(TYPES.AccountDeletionRequestedEventHandler) + .to(AccountDeletionRequestedEventHandler) + container + .bind(TYPES.SubscriptionPurchasedEventHandler) + .to(SubscriptionPurchasedEventHandler) + container + .bind(TYPES.SubscriptionCancelledEventHandler) + .to(SubscriptionCancelledEventHandler) + container + .bind(TYPES.SubscriptionRenewedEventHandler) + .to(SubscriptionRenewedEventHandler) + container + .bind(TYPES.SubscriptionRefundedEventHandler) + .to(SubscriptionRefundedEventHandler) + container + .bind(TYPES.SubscriptionExpiredEventHandler) + .to(SubscriptionExpiredEventHandler) + container + .bind(TYPES.SubscriptionSyncRequestedEventHandler) + .to(SubscriptionSyncRequestedEventHandler) + container + .bind(TYPES.ExtensionKeyGrantedEventHandler) + .to(ExtensionKeyGrantedEventHandler) + container + .bind(TYPES.SubscriptionReassignedEventHandler) + .to(SubscriptionReassignedEventHandler) + container.bind(TYPES.UserEmailChangedEventHandler).to(UserEmailChangedEventHandler) + container.bind(TYPES.FileUploadedEventHandler).to(FileUploadedEventHandler) + container.bind(TYPES.FileRemovedEventHandler).to(FileRemovedEventHandler) + container + .bind(TYPES.ListedAccountCreatedEventHandler) + .to(ListedAccountCreatedEventHandler) + container + .bind(TYPES.ListedAccountDeletedEventHandler) + .to(ListedAccountDeletedEventHandler) + container + .bind(TYPES.UserDisabledSessionUserAgentLoggingEventHandler) + .to(UserDisabledSessionUserAgentLoggingEventHandler) + container + .bind(TYPES.SharedSubscriptionInvitationCreatedEventHandler) + .to(SharedSubscriptionInvitationCreatedEventHandler) + container + .bind(TYPES.PredicateVerificationRequestedEventHandler) + .to(PredicateVerificationRequestedEventHandler) + + // Services + container.bind(TYPES.DeviceDetector).toConstantValue(new UAParser()) + container.bind(TYPES.SessionService).to(SessionService) + container.bind(TYPES.AuthResponseFactory20161215).to(AuthResponseFactory20161215) + container.bind(TYPES.AuthResponseFactory20190520).to(AuthResponseFactory20190520) + container.bind(TYPES.AuthResponseFactory20200115).to(AuthResponseFactory20200115) + container.bind(TYPES.AuthResponseFactoryResolver).to(AuthResponseFactoryResolver) + container.bind(TYPES.KeyParamsFactory).to(KeyParamsFactory) + container + .bind>(TYPES.SessionTokenDecoder) + .toConstantValue(new TokenDecoder(container.get(TYPES.JWT_SECRET))) + container + .bind>(TYPES.FallbackSessionTokenDecoder) + .toConstantValue(new TokenDecoder(container.get(TYPES.LEGACY_JWT_SECRET))) + container + .bind>(TYPES.CrossServiceTokenDecoder) + .toConstantValue(new TokenDecoder(container.get(TYPES.AUTH_JWT_SECRET))) + container + .bind>(TYPES.OfflineUserTokenDecoder) + .toConstantValue(new TokenDecoder(container.get(TYPES.AUTH_JWT_SECRET))) + container + .bind>(TYPES.OfflineUserTokenEncoder) + .toConstantValue(new TokenEncoder(container.get(TYPES.AUTH_JWT_SECRET))) + container + .bind>(TYPES.SessionTokenEncoder) + .toConstantValue(new TokenEncoder(container.get(TYPES.JWT_SECRET))) + container + .bind>(TYPES.CrossServiceTokenEncoder) + .toConstantValue(new TokenEncoder(container.get(TYPES.AUTH_JWT_SECRET))) + container + .bind>(TYPES.ValetTokenEncoder) + .toConstantValue(new TokenEncoder(container.get(TYPES.VALET_TOKEN_SECRET))) + container.bind(TYPES.AuthenticationMethodResolver).to(AuthenticationMethodResolver) + container.bind(TYPES.DomainEventFactory).to(DomainEventFactory) + container.bind(TYPES.HTTPClient).toConstantValue(axios.create()) + container.bind(TYPES.Crypter).to(CrypterNode) + container.bind(TYPES.SettingService).to(SettingService) + container.bind(TYPES.SubscriptionSettingService).to(SubscriptionSettingService) + container.bind(TYPES.OfflineSettingService).to(OfflineSettingService) + container.bind(TYPES.CryptoNode).toConstantValue(new CryptoNode()) + container.bind(TYPES.Timer).toConstantValue(new Timer()) + container.bind(TYPES.ContenDecoder).toConstantValue(new ContentDecoder()) + container.bind(TYPES.WebSocketsClientService).to(WebSocketsClientService) + container.bind(TYPES.RoleService).to(RoleService) + container.bind(TYPES.RoleToSubscriptionMap).to(RoleToSubscriptionMap) + container.bind(TYPES.SettingsAssociationService).to(SettingsAssociationService) + container + .bind(TYPES.SubscriptionSettingsAssociationService) + .to(SubscriptionSettingsAssociationService) + container.bind(TYPES.FeatureService).to(FeatureService) + container.bind(TYPES.SettingInterpreter).to(SettingInterpreter) + container.bind(TYPES.SettingDecrypter).to(SettingDecrypter) + container + .bind>(TYPES.ProtocolVersionSelector) + .toConstantValue(new DeterministicSelector()) + container + .bind>(TYPES.BooleanSelector) + .toConstantValue(new DeterministicSelector()) + container.bind(TYPES.UserSubscriptionService).to(UserSubscriptionService) + container + .bind(TYPES.AnalyticsStore) + .toConstantValue(new RedisAnalyticsStore(new PeriodKeyGenerator(), container.get(TYPES.Redis))) + + if (env.get('SNS_TOPIC_ARN', true)) { + container + .bind(TYPES.DomainEventPublisher) + .toConstantValue(new SNSDomainEventPublisher(container.get(TYPES.SNS), container.get(TYPES.SNS_TOPIC_ARN))) + } else { + container + .bind(TYPES.DomainEventPublisher) + .toConstantValue( + new RedisDomainEventPublisher(container.get(TYPES.Redis), container.get(TYPES.REDIS_EVENTS_CHANNEL)), + ) + } + + const eventHandlers: Map = new Map([ + ['USER_REGISTERED', container.get(TYPES.UserRegisteredEventHandler)], + ['ACCOUNT_DELETION_REQUESTED', container.get(TYPES.AccountDeletionRequestedEventHandler)], + ['SUBSCRIPTION_PURCHASED', container.get(TYPES.SubscriptionPurchasedEventHandler)], + ['SUBSCRIPTION_CANCELLED', container.get(TYPES.SubscriptionCancelledEventHandler)], + ['SUBSCRIPTION_RENEWED', container.get(TYPES.SubscriptionRenewedEventHandler)], + ['SUBSCRIPTION_REFUNDED', container.get(TYPES.SubscriptionRefundedEventHandler)], + ['SUBSCRIPTION_EXPIRED', container.get(TYPES.SubscriptionExpiredEventHandler)], + ['SUBSCRIPTION_SYNC_REQUESTED', container.get(TYPES.SubscriptionSyncRequestedEventHandler)], + ['EXTENSION_KEY_GRANTED', container.get(TYPES.ExtensionKeyGrantedEventHandler)], + ['SUBSCRIPTION_REASSIGNED', container.get(TYPES.SubscriptionReassignedEventHandler)], + ['USER_EMAIL_CHANGED', container.get(TYPES.UserEmailChangedEventHandler)], + ['FILE_UPLOADED', container.get(TYPES.FileUploadedEventHandler)], + ['FILE_REMOVED', container.get(TYPES.FileRemovedEventHandler)], + ['LISTED_ACCOUNT_CREATED', container.get(TYPES.ListedAccountCreatedEventHandler)], + ['LISTED_ACCOUNT_DELETED', container.get(TYPES.ListedAccountDeletedEventHandler)], + [ + 'USER_DISABLED_SESSION_USER_AGENT_LOGGING', + container.get(TYPES.UserDisabledSessionUserAgentLoggingEventHandler), + ], + ['SHARED_SUBSCRIPTION_INVITATION_CREATED', container.get(TYPES.SharedSubscriptionInvitationCreatedEventHandler)], + ['PREDICATE_VERIFICATION_REQUESTED', container.get(TYPES.PredicateVerificationRequestedEventHandler)], + ]) + + if (env.get('SQS_QUEUE_URL', true)) { + container + .bind(TYPES.DomainEventMessageHandler) + .toConstantValue( + env.get('NEW_RELIC_ENABLED', true) === 'true' + ? new SQSNewRelicEventMessageHandler(eventHandlers, container.get(TYPES.Logger)) + : new SQSEventMessageHandler(eventHandlers, container.get(TYPES.Logger)), + ) + container + .bind(TYPES.DomainEventSubscriberFactory) + .toConstantValue( + new SQSDomainEventSubscriberFactory( + container.get(TYPES.SQS), + container.get(TYPES.SQS_QUEUE_URL), + container.get(TYPES.DomainEventMessageHandler), + ), + ) + } else { + container + .bind(TYPES.DomainEventMessageHandler) + .toConstantValue(new RedisEventMessageHandler(eventHandlers, container.get(TYPES.Logger))) + container + .bind(TYPES.DomainEventSubscriberFactory) + .toConstantValue( + new RedisDomainEventSubscriberFactory( + container.get(TYPES.Redis), + container.get(TYPES.DomainEventMessageHandler), + container.get(TYPES.REDIS_EVENTS_CHANNEL), + ), + ) + } + + return container + } +} diff --git a/packages/auth/src/Bootstrap/DataSource.ts b/packages/auth/src/Bootstrap/DataSource.ts new file mode 100644 index 000000000..6a44c7d2c --- /dev/null +++ b/packages/auth/src/Bootstrap/DataSource.ts @@ -0,0 +1,64 @@ +import { DataSource, LoggerOptions } from 'typeorm' +import { AnalyticsEntity } from '../Domain/Analytics/AnalyticsEntity' +import { Permission } from '../Domain/Permission/Permission' +import { Role } from '../Domain/Role/Role' +import { RevokedSession } from '../Domain/Session/RevokedSession' +import { Session } from '../Domain/Session/Session' +import { OfflineSetting } from '../Domain/Setting/OfflineSetting' +import { Setting } from '../Domain/Setting/Setting' +import { SubscriptionSetting } from '../Domain/Setting/SubscriptionSetting' +import { SharedSubscriptionInvitation } from '../Domain/SharedSubscription/SharedSubscriptionInvitation' +import { OfflineUserSubscription } from '../Domain/Subscription/OfflineUserSubscription' +import { UserSubscription } from '../Domain/Subscription/UserSubscription' +import { User } from '../Domain/User/User' +import { Env } from './Env' + +const env: Env = new Env() +env.load() + +const maxQueryExecutionTime = env.get('DB_MAX_QUERY_EXECUTION_TIME', true) + ? +env.get('DB_MAX_QUERY_EXECUTION_TIME', true) + : 45_000 + +export const AppDataSource = new DataSource({ + type: 'mysql', + supportBigNumbers: true, + bigNumberStrings: false, + maxQueryExecutionTime, + replication: { + master: { + host: env.get('DB_HOST'), + port: parseInt(env.get('DB_PORT')), + username: env.get('DB_USERNAME'), + password: env.get('DB_PASSWORD'), + database: env.get('DB_DATABASE'), + }, + slaves: [ + { + host: env.get('DB_REPLICA_HOST'), + port: parseInt(env.get('DB_PORT')), + username: env.get('DB_USERNAME'), + password: env.get('DB_PASSWORD'), + database: env.get('DB_DATABASE'), + }, + ], + removeNodeErrorCount: 10, + }, + entities: [ + User, + UserSubscription, + OfflineUserSubscription, + Session, + RevokedSession, + Role, + Permission, + Setting, + OfflineSetting, + SharedSubscriptionInvitation, + SubscriptionSetting, + AnalyticsEntity, + ], + migrations: [env.get('DB_MIGRATIONS_PATH')], + migrationsRun: true, + logging: env.get('DB_DEBUG_LEVEL'), +}) diff --git a/packages/auth/src/Bootstrap/Env.ts b/packages/auth/src/Bootstrap/Env.ts new file mode 100644 index 000000000..b26b07aca --- /dev/null +++ b/packages/auth/src/Bootstrap/Env.ts @@ -0,0 +1,24 @@ +import { config, DotenvParseOutput } from 'dotenv' +import { injectable } from 'inversify' + +@injectable() +export class Env { + private env?: DotenvParseOutput + + public load(): void { + const output = config() + this.env = output.parsed + } + + public get(key: string, optional = false): string { + if (!this.env) { + this.load() + } + + if (!process.env[key] && !optional) { + throw new Error(`Environment variable ${key} not set`) + } + + return process.env[key] + } +} diff --git a/packages/auth/src/Bootstrap/Types.ts b/packages/auth/src/Bootstrap/Types.ts new file mode 100644 index 000000000..bdaf02eb9 --- /dev/null +++ b/packages/auth/src/Bootstrap/Types.ts @@ -0,0 +1,188 @@ +const TYPES = { + Logger: Symbol.for('Logger'), + Redis: Symbol.for('Redis'), + SNS: Symbol.for('SNS'), + SQS: Symbol.for('SQS'), + // Controller + AuthController: Symbol.for('AuthController'), + // Repositories + UserRepository: Symbol.for('UserRepository'), + SessionRepository: Symbol.for('SessionRepository'), + EphemeralSessionRepository: Symbol.for('EphemeralSessionRepository'), + RevokedSessionRepository: Symbol.for('RevokedSessionRepository'), + SettingRepository: Symbol.for('SettingRepository'), + SubscriptionSettingRepository: Symbol.for('SubscriptionSettingRepository'), + OfflineSettingRepository: Symbol.for('OfflineSettingRepository'), + LockRepository: Symbol.for('LockRepository'), + RoleRepository: Symbol.for('RoleRepository'), + WebSocketsConnectionRepository: Symbol.for('WebSocketsConnectionRepository'), + UserSubscriptionRepository: Symbol.for('UserSubscriptionRepository'), + OfflineUserSubscriptionRepository: Symbol.for('OfflineUserSubscriptionRepository'), + SubscriptionTokenRepository: Symbol.for('SubscriptionTokenRepository'), + OfflineSubscriptionTokenRepository: Symbol.for('OfflineSubscriptionTokenRepository'), + SharedSubscriptionInvitationRepository: Symbol.for('SharedSubscriptionInvitationRepository'), + PKCERepository: Symbol.for('PKCERepository'), + AnalyticsEntityRepository: Symbol.for('AnalyticsEntityRepository'), + // ORM + ORMOfflineSettingRepository: Symbol.for('ORMOfflineSettingRepository'), + ORMOfflineUserSubscriptionRepository: Symbol.for('ORMOfflineUserSubscriptionRepository'), + ORMRevokedSessionRepository: Symbol.for('ORMRevokedSessionRepository'), + ORMRoleRepository: Symbol.for('ORMRoleRepository'), + ORMSessionRepository: Symbol.for('ORMSessionRepository'), + ORMSettingRepository: Symbol.for('ORMSettingRepository'), + ORMSharedSubscriptionInvitationRepository: Symbol.for('ORMSharedSubscriptionInvitationRepository'), + ORMSubscriptionSettingRepository: Symbol.for('ORMSubscriptionSettingRepository'), + ORMUserRepository: Symbol.for('ORMUserRepository'), + ORMUserSubscriptionRepository: Symbol.for('ORMUserSubscriptionRepository'), + ORMAnalyticsEntityRepository: Symbol.for('ORMAnalyticsEntityRepository'), + // Middleware + AuthMiddleware: Symbol.for('AuthMiddleware'), + ApiGatewayAuthMiddleware: Symbol.for('ApiGatewayAuthMiddleware'), + ApiGatewayOfflineAuthMiddleware: Symbol.for('ApiGatewayOfflineAuthMiddleware'), + OfflineUserAuthMiddleware: Symbol.for('OfflineUserAuthMiddleware'), + AuthMiddlewareWithoutResponse: Symbol.for('AuthMiddlewareWithoutResponse'), + LockMiddleware: Symbol.for('LockMiddleware'), + SessionMiddleware: Symbol.for('SessionMiddleware'), + // Projectors + SessionProjector: Symbol.for('SessionProjector'), + UserProjector: Symbol.for('UserProjector'), + RoleProjector: Symbol.for('RoleProjector'), + PermissionProjector: Symbol.for('PermissionProjector'), + SettingProjector: Symbol.for('SettingProjector'), + SubscriptionSettingProjector: Symbol.for('SubscriptionSettingProjector'), + // Factories + SettingFactory: Symbol.for('SettingFactory'), + // env vars + JWT_SECRET: Symbol.for('JWT_SECRET'), + LEGACY_JWT_SECRET: Symbol.for('LEGACY_JWT_SECRET'), + AUTH_JWT_SECRET: Symbol.for('AUTH_JWT_SECRET'), + AUTH_JWT_TTL: Symbol.for('AUTH_JWT_TTL'), + VALET_TOKEN_SECRET: Symbol.for('VALET_TOKEN_SECRET'), + VALET_TOKEN_TTL: Symbol.for('VALET_TOKEN_TTL'), + ENCRYPTION_SERVER_KEY: Symbol.for('ENCRYPTION_SERVER_KEY'), + ACCESS_TOKEN_AGE: Symbol.for('ACCESS_TOKEN_AGE'), + REFRESH_TOKEN_AGE: Symbol.for('REFRESH_TOKEN_AGE'), + EPHEMERAL_SESSION_AGE: Symbol.for('EPHEMERAL_SESSION_AGE'), + MAX_LOGIN_ATTEMPTS: Symbol.for('MAX_LOGIN_ATTEMPTS'), + FAILED_LOGIN_LOCKOUT: Symbol.for('FAILED_LOGIN_LOCKOUT'), + PSEUDO_KEY_PARAMS_KEY: Symbol.for('PSEUDO_KEY_PARAMS_KEY'), + REDIS_URL: Symbol.for('REDIS_URL'), + DISABLE_USER_REGISTRATION: Symbol.for('DISABLE_USER_REGISTRATION'), + ANALYTICS_ENABLED: Symbol.for('ANALYTICS_ENABLED'), + SNS_TOPIC_ARN: Symbol.for('SNS_TOPIC_ARN'), + SNS_AWS_REGION: Symbol.for('SNS_AWS_REGION'), + SQS_QUEUE_URL: Symbol.for('SQS_QUEUE_URL'), + SQS_AWS_REGION: Symbol.for('SQS_AWS_REGION'), + USER_SERVER_REGISTRATION_URL: Symbol.for('USER_SERVER_REGISTRATION_URL'), + USER_SERVER_AUTH_KEY: Symbol.for('USER_SERVER_AUTH_KEY'), + USER_SERVER_CHANGE_EMAIL_URL: Symbol.for('USER_SERVER_CHANGE_EMAIL_URL'), + REDIS_EVENTS_CHANNEL: Symbol.for('REDIS_EVENTS_CHANNEL'), + NEW_RELIC_ENABLED: Symbol.for('NEW_RELIC_ENABLED'), + SYNCING_SERVER_URL: Symbol.for('SYNCING_SERVER_URL'), + WEBSOCKETS_API_URL: Symbol.for('WEBSOCKETS_API_URL'), + VERSION: Symbol.for('VERSION'), + PAYMENTS_SERVER_URL: Symbol.for('PAYMENTS_SERVER_URL'), + // use cases + AuthenticateUser: Symbol.for('AuthenticateUser'), + AuthenticateRequest: Symbol.for('AuthenticateRequest'), + RefreshSessionToken: Symbol.for('RefreshSessionToken'), + VerifyMFA: Symbol.for('VerifyMFA'), + SignIn: Symbol.for('SignIn'), + ClearLoginAttempts: Symbol.for('ClearLoginAttempts'), + IncreaseLoginAttempts: Symbol.for('IncreaseLoginAttempts'), + GetUserKeyParams: Symbol.for('GetUserKeyParams'), + UpdateUser: Symbol.for('UpdateUser'), + Register: Symbol.for('Register'), + GetActiveSessionsForUser: Symbol.for('GetActiveSessionsForUser'), + DeletePreviousSessionsForUser: Symbol.for('DeletePreviousSessionsForUser'), + DeleteSessionForUser: Symbol.for('DeleteSessionForUser'), + ChangeCredentials: Symbol.for('ChangePassword'), + GetSettings: Symbol.for('GetSettings'), + GetSetting: Symbol.for('GetSetting'), + GetUserFeatures: Symbol.for('GetUserFeatures'), + UpdateSetting: Symbol.for('UpdateSetting'), + DeleteSetting: Symbol.for('DeleteSetting'), + DeleteAccount: Symbol.for('DeleteAccount'), + AddWebSocketsConnection: Symbol.for('AddWebSocketsConnection'), + RemoveWebSocketsConnection: Symbol.for('RemoveWebSocketsConnection'), + GetUserSubscription: Symbol.for('GetUserSubscription'), + GetUserOfflineSubscription: Symbol.for('GetUserOfflineSubscription'), + CreateSubscriptionToken: Symbol.for('CreateSubscriptionToken'), + AuthenticateSubscriptionToken: Symbol.for('AuthenticateSubscriptionToken'), + CreateOfflineSubscriptionToken: Symbol.for('CreateOfflineSubscriptionToken'), + AuthenticateOfflineSubscriptionToken: Symbol.for('AuthenticateOfflineSubscriptionToken'), + MuteFailedBackupsEmails: Symbol.for('MuteFailedBackupsEmails'), + MuteSignInEmails: Symbol.for('MuteSignInEmails'), + CreateValetToken: Symbol.for('CreateValetToken'), + CreateListedAccount: Symbol.for('CreateListedAccount'), + InviteToSharedSubscription: Symbol.for('InviteToSharedSubscription'), + AcceptSharedSubscriptionInvitation: Symbol.for('AcceptSharedSubscriptionInvitation'), + DeclineSharedSubscriptionInvitation: Symbol.for('DeclineSharedSubscriptionInvitation'), + CancelSharedSubscriptionInvitation: Symbol.for('CancelSharedSubscriptionInvitation'), + ListSharedSubscriptionInvitations: Symbol.for('ListSharedSubscriptionInvitations'), + GetSubscriptionSetting: Symbol.for('GetSubscriptionSetting'), + GetUserAnalyticsId: Symbol.for('GetUserAnalyticsId'), + VerifyPredicate: Symbol.for('VerifyPredicate'), + // Handlers + UserRegisteredEventHandler: Symbol.for('UserRegisteredEventHandler'), + AccountDeletionRequestedEventHandler: Symbol.for('AccountDeletionRequestedEventHandler'), + SubscriptionPurchasedEventHandler: Symbol.for('SubscriptionPurchasedEventHandler'), + SubscriptionCancelledEventHandler: Symbol.for('SubscriptionCancelledEventHandler'), + SubscriptionReassignedEventHandler: Symbol.for('SubscriptionReassignedEventHandler'), + SubscriptionRenewedEventHandler: Symbol.for('SubscriptionRenewedEventHandler'), + SubscriptionRefundedEventHandler: Symbol.for('SubscriptionRefundedEventHandler'), + SubscriptionExpiredEventHandler: Symbol.for('SubscriptionExpiredEventHandler'), + SubscriptionSyncRequestedEventHandler: Symbol.for('SubscriptionSyncRequestedEventHandler'), + ExtensionKeyGrantedEventHandler: Symbol.for('ExtensionKeyGrantedEventHandler'), + UserEmailChangedEventHandler: Symbol.for('UserEmailChangedEventHandler'), + FileUploadedEventHandler: Symbol.for('FileUploadedEventHandler'), + FileRemovedEventHandler: Symbol.for('FileRemovedEventHandler'), + ListedAccountCreatedEventHandler: Symbol.for('ListedAccountCreatedEventHandler'), + ListedAccountDeletedEventHandler: Symbol.for('ListedAccountDeletedEventHandler'), + UserDisabledSessionUserAgentLoggingEventHandler: Symbol.for('UserDisabledSessionUserAgentLoggingEventHandler'), + SharedSubscriptionInvitationCreatedEventHandler: Symbol.for('SharedSubscriptionInvitationCreatedEventHandler'), + PredicateVerificationRequestedEventHandler: Symbol.for('PredicateVerificationRequestedEventHandler'), + // Services + DeviceDetector: Symbol.for('DeviceDetector'), + SessionService: Symbol.for('SessionService'), + SettingService: Symbol.for('SettingService'), + SubscriptionSettingService: Symbol.for('SubscriptionSettingService'), + OfflineSettingService: Symbol.for('OfflineSettingService'), + AuthResponseFactory20161215: Symbol.for('AuthResponseFactory20161215'), + AuthResponseFactory20190520: Symbol.for('AuthResponseFactory20190520'), + AuthResponseFactory20200115: Symbol.for('AuthResponseFactory20200115'), + AuthResponseFactoryResolver: Symbol.for('AuthResponseFactoryResolver'), + KeyParamsFactory: Symbol.for('KeyParamsFactory'), + SessionTokenDecoder: Symbol.for('SessionTokenDecoder'), + FallbackSessionTokenDecoder: Symbol.for('FallbackSessionTokenDecoder'), + CrossServiceTokenDecoder: Symbol.for('CrossServiceTokenDecoder'), + OfflineUserTokenDecoder: Symbol.for('OfflineUserTokenDecoder'), + OfflineUserTokenEncoder: Symbol.for('OfflineUserTokenEncoder'), + CrossServiceTokenEncoder: Symbol.for('CrossServiceTokenEncoder'), + SessionTokenEncoder: Symbol.for('SessionTokenEncoder'), + ValetTokenEncoder: Symbol.for('ValetTokenEncoder'), + AuthenticationMethodResolver: Symbol.for('AuthenticationMethodResolver'), + DomainEventPublisher: Symbol.for('DomainEventPublisher'), + DomainEventSubscriberFactory: Symbol.for('DomainEventSubscriberFactory'), + DomainEventFactory: Symbol.for('DomainEventFactory'), + DomainEventMessageHandler: Symbol.for('DomainEventMessageHandler'), + HTTPClient: Symbol.for('HTTPClient'), + Crypter: Symbol.for('Crypter'), + CryptoNode: Symbol.for('CryptoNode'), + Timer: Symbol.for('Timer'), + ContenDecoder: Symbol.for('ContenDecoder'), + WebSocketsClientService: Symbol.for('WebSocketClientService'), + RoleService: Symbol.for('RoleService'), + RoleToSubscriptionMap: Symbol.for('RoleToSubscriptionMap'), + SettingsAssociationService: Symbol.for('SettingsAssociationService'), + SubscriptionSettingsAssociationService: Symbol.for('SubscriptionSettingsAssociationService'), + FeatureService: Symbol.for('FeatureService'), + SettingDecrypter: Symbol.for('SettingDecrypter'), + SettingInterpreter: Symbol.for('SettingInterpreter'), + ProtocolVersionSelector: Symbol.for('ProtocolVersionSelector'), + BooleanSelector: Symbol.for('BooleanSelector'), + UserSubscriptionService: Symbol.for('UserSubscriptionService'), + AnalyticsStore: Symbol.for('AnalyticsStore'), +} + +export default TYPES diff --git a/packages/auth/src/Controller/AdminController.spec.ts b/packages/auth/src/Controller/AdminController.spec.ts new file mode 100644 index 000000000..af6ca2cb2 --- /dev/null +++ b/packages/auth/src/Controller/AdminController.spec.ts @@ -0,0 +1,156 @@ +import 'reflect-metadata' + +import { AdminController } from './AdminController' +import { results } from 'inversify-express-utils' +import { User } from '../Domain/User/User' +import { UserRepositoryInterface } from '../Domain/User/UserRepositoryInterface' +import * as express from 'express' +import { DeleteSetting } from '../Domain/UseCase/DeleteSetting/DeleteSetting' +import { CreateSubscriptionToken } from '../Domain/UseCase/CreateSubscriptionToken/CreateSubscriptionToken' + +describe('AdminController', () => { + let deleteSetting: DeleteSetting + let userRepository: UserRepositoryInterface + let createSubscriptionToken: CreateSubscriptionToken + let request: express.Request + let user: User + + const createController = () => new AdminController(deleteSetting, userRepository, createSubscriptionToken) + + beforeEach(() => { + user = {} as jest.Mocked + user.uuid = '123' + + deleteSetting = {} as jest.Mocked + deleteSetting.execute = jest.fn().mockReturnValue({ success: true }) + + userRepository = {} as jest.Mocked + userRepository.findOneByEmail = jest.fn().mockReturnValue(user) + + createSubscriptionToken = {} as jest.Mocked + createSubscriptionToken.execute = jest.fn().mockReturnValue({ + subscriptionToken: { + token: '123-sub-token', + }, + }) + + request = { + headers: {}, + body: {}, + params: {}, + } as jest.Mocked + }) + + it('should return error if missing email parameter', async () => { + const httpResponse = await createController().getUser(request) + const result = await httpResponse.executeAsync() + + expect(httpResponse).toBeInstanceOf(results.JsonResult) + + expect(result.statusCode).toBe(400) + expect(await result.content.readAsStringAsync()).toEqual('{"error":{"message":"Missing email parameter."}}') + }) + + it('should return error if no user with such email exists', async () => { + request.params.email = 'test@sn.org' + + userRepository.findOneByEmail = jest.fn().mockReturnValue(null) + + const httpResponse = await createController().getUser(request) + const result = await httpResponse.executeAsync() + + expect(httpResponse).toBeInstanceOf(results.JsonResult) + + expect(result.statusCode).toBe(400) + expect(await result.content.readAsStringAsync()).toEqual( + '{"error":{"message":"No user with email \'test@sn.org\'."}}', + ) + }) + + it("should return the user's uuid", async () => { + request.params.email = 'test@sn.org' + + const httpResponse = await createController().getUser(request) + const result = await httpResponse.executeAsync() + + expect(httpResponse).toBeInstanceOf(results.JsonResult) + + expect(result.statusCode).toBe(200) + expect(await result.content.readAsStringAsync()).toEqual('{"uuid":"123"}') + }) + + it('should delete user mfa setting', async () => { + request.params.userUuid = '1-2-3' + + deleteSetting.execute = jest.fn().mockReturnValue({ success: true }) + + const httpResponse = await createController().deleteMFASetting(request) + const result = await httpResponse.executeAsync() + + expect(deleteSetting.execute).toHaveBeenCalledWith({ + userUuid: '1-2-3', + settingName: 'MFA_SECRET', + softDelete: true, + }) + + expect(result.statusCode).toEqual(200) + }) + + it('should fail if could not delete user mfa setting', async () => { + request.params.userUuid = '1-2-3' + + deleteSetting.execute = jest.fn().mockReturnValue({ success: false }) + + const httpResponse = await createController().deleteMFASetting(request) + const result = await httpResponse.executeAsync() + + expect(deleteSetting.execute).toHaveBeenCalledWith({ + userUuid: '1-2-3', + settingName: 'MFA_SECRET', + softDelete: true, + }) + + expect(result.statusCode).toEqual(400) + }) + + it("should return a new subscription token for the user's uuid", async () => { + request.params.userUuid = '1-2-3' + + const httpResponse = await createController().createToken(request) + const result = await httpResponse.executeAsync() + + expect(httpResponse).toBeInstanceOf(results.JsonResult) + + expect(result.statusCode).toBe(200) + expect(await result.content.readAsStringAsync()).toEqual('{"token":"123-sub-token"}') + }) + + it('should not delete email backup setting if value is null', async () => { + request.body = {} + request.params = { + userUuid: '1-2-3', + } + + deleteSetting.execute = jest.fn().mockReturnValue({ success: false }) + + const httpResponse = await createController().disableEmailBackups(request) + const result = await httpResponse.executeAsync() + + expect(result.statusCode).toEqual(400) + expect(await result.content.readAsStringAsync()).toEqual('No email backups found') + }) + + it('should disable email backups by deleting the setting', async () => { + request.body = {} + request.params = { + userUuid: '1-2-3', + } + + deleteSetting.execute = jest.fn().mockReturnValue({ success: true }) + + const httpResponse = await createController().disableEmailBackups(request) + const result = await httpResponse.executeAsync() + + expect(result.statusCode).toEqual(200) + }) +}) diff --git a/packages/auth/src/Controller/AdminController.ts b/packages/auth/src/Controller/AdminController.ts new file mode 100644 index 000000000..1cbb54f78 --- /dev/null +++ b/packages/auth/src/Controller/AdminController.ts @@ -0,0 +1,108 @@ +import { SettingName } from '@standardnotes/settings' +import { Request } from 'express' +import { inject } from 'inversify' +import { + BaseHttpController, + controller, + httpDelete, + httpGet, + httpPost, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + results, +} from 'inversify-express-utils' +import TYPES from '../Bootstrap/Types' +import { CreateSubscriptionToken } from '../Domain/UseCase/CreateSubscriptionToken/CreateSubscriptionToken' +import { DeleteSetting } from '../Domain/UseCase/DeleteSetting/DeleteSetting' +import { UserRepositoryInterface } from '../Domain/User/UserRepositoryInterface' + +@controller('/admin') +export class AdminController extends BaseHttpController { + constructor( + @inject(TYPES.DeleteSetting) private doDeleteSetting: DeleteSetting, + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.CreateSubscriptionToken) private createSubscriptionToken: CreateSubscriptionToken, + ) { + super() + } + + @httpGet('/user/:email') + async getUser(request: Request): Promise { + const email = 'email' in request.params ? request.params.email : undefined + + if (!email) { + return this.json( + { + error: { + message: 'Missing email parameter.', + }, + }, + 400, + ) + } + + const user = await this.userRepository.findOneByEmail(email) + + if (!user) { + return this.json( + { + error: { + message: `No user with email '${email}'.`, + }, + }, + 400, + ) + } + + return this.json({ + uuid: user.uuid, + }) + } + + @httpDelete('/users/:userUuid/mfa') + async deleteMFASetting(request: Request): Promise { + const { userUuid } = request.params + const { uuid, updatedAt } = request.body + + const result = await this.doDeleteSetting.execute({ + uuid, + userUuid, + settingName: SettingName.MfaSecret, + timestamp: updatedAt, + softDelete: true, + }) + + if (result.success) { + return this.json(result) + } + + return this.json(result, 400) + } + + @httpPost('/users/:userUuid/subscription-token') + async createToken(request: Request): Promise { + const { userUuid } = request.params + const result = await this.createSubscriptionToken.execute({ + userUuid, + }) + + return this.json({ + token: result.subscriptionToken.token, + }) + } + + @httpPost('/users/:userUuid/email-backups') + async disableEmailBackups(request: Request): Promise { + const { userUuid } = request.params + + const result = await this.doDeleteSetting.execute({ + userUuid, + settingName: SettingName.EmailBackupFrequency, + }) + + if (result.success) { + return this.ok() + } + + return this.badRequest('No email backups found') + } +} diff --git a/packages/auth/src/Controller/ApiGatewayAuthMiddleware.spec.ts b/packages/auth/src/Controller/ApiGatewayAuthMiddleware.spec.ts new file mode 100644 index 000000000..9dab8a992 --- /dev/null +++ b/packages/auth/src/Controller/ApiGatewayAuthMiddleware.spec.ts @@ -0,0 +1,99 @@ +import 'reflect-metadata' + +import { ApiGatewayAuthMiddleware } from './ApiGatewayAuthMiddleware' +import { NextFunction, Request, Response } from 'express' +import { Logger } from 'winston' +import { CrossServiceTokenData, TokenDecoderInterface } from '@standardnotes/auth' +import { RoleName } from '@standardnotes/common' + +describe('ApiGatewayAuthMiddleware', () => { + let tokenDecoder: TokenDecoderInterface + let request: Request + let response: Response + let next: NextFunction + + const logger = { + debug: jest.fn(), + } as unknown as jest.Mocked + + const createMiddleware = () => new ApiGatewayAuthMiddleware(tokenDecoder, logger) + + beforeEach(() => { + tokenDecoder = {} as jest.Mocked> + tokenDecoder.decodeToken = jest.fn().mockReturnValue({ + user: { + uuid: '1-2-3', + email: 'test@test.te', + }, + roles: [ + { + uuid: 'a-b-c', + name: RoleName.CoreUser, + }, + ], + }) + + request = { + headers: {}, + } as jest.Mocked + response = { + locals: {}, + } as jest.Mocked + response.status = jest.fn().mockReturnThis() + response.send = jest.fn() + next = jest.fn() + }) + + it('should authorize user', async () => { + request.headers['x-auth-token'] = 'auth-jwt-token' + + await createMiddleware().handler(request, response, next) + + expect(response.locals.user).toEqual({ + uuid: '1-2-3', + email: 'test@test.te', + }) + expect(response.locals.roles).toEqual([ + { + uuid: 'a-b-c', + name: RoleName.CoreUser, + }, + ]) + + expect(next).toHaveBeenCalled() + }) + + it('should not authorize if request is missing auth jwt token in headers', async () => { + await createMiddleware().handler(request, response, next) + + expect(response.status).toHaveBeenCalledWith(401) + expect(next).not.toHaveBeenCalled() + }) + + it('should not authorize if auth jwt token is malformed', async () => { + request.headers['x-auth-token'] = 'auth-jwt-token' + + tokenDecoder.decodeToken = jest.fn().mockReturnValue(undefined) + + await createMiddleware().handler(request, response, next) + + expect(response.status).toHaveBeenCalledWith(401) + expect(next).not.toHaveBeenCalled() + }) + + it('should pass the error to next middleware if one occurres', async () => { + request.headers['x-auth-token'] = 'auth-jwt-token' + + const error = new Error('Ooops') + + tokenDecoder.decodeToken = jest.fn().mockImplementation(() => { + throw error + }) + + await createMiddleware().handler(request, response, next) + + expect(response.status).not.toHaveBeenCalled() + + expect(next).toHaveBeenCalledWith(error) + }) +}) diff --git a/packages/auth/src/Controller/ApiGatewayAuthMiddleware.ts b/packages/auth/src/Controller/ApiGatewayAuthMiddleware.ts new file mode 100644 index 000000000..17fbd5e5c --- /dev/null +++ b/packages/auth/src/Controller/ApiGatewayAuthMiddleware.ts @@ -0,0 +1,59 @@ +import { CrossServiceTokenData, TokenDecoderInterface } from '@standardnotes/auth' +import { NextFunction, Request, Response } from 'express' +import { inject, injectable } from 'inversify' +import { BaseMiddleware } from 'inversify-express-utils' +import { Logger } from 'winston' +import TYPES from '../Bootstrap/Types' + +@injectable() +export class ApiGatewayAuthMiddleware extends BaseMiddleware { + constructor( + @inject(TYPES.CrossServiceTokenDecoder) private tokenDecoder: TokenDecoderInterface, + @inject(TYPES.Logger) private logger: Logger, + ) { + super() + } + + async handler(request: Request, response: Response, next: NextFunction): Promise { + try { + if (!request.headers['x-auth-token']) { + this.logger.debug('ApiGatewayAuthMiddleware missing x-auth-token header.') + + response.status(401).send({ + error: { + tag: 'invalid-auth', + message: 'Invalid login credentials.', + }, + }) + + return + } + + const token: CrossServiceTokenData | undefined = this.tokenDecoder.decodeToken( + request.headers['x-auth-token'] as string, + ) + + if (token === undefined) { + this.logger.debug('ApiGatewayAuthMiddleware authentication failure.') + + response.status(401).send({ + error: { + tag: 'invalid-auth', + message: 'Invalid login credentials.', + }, + }) + + return + } + + response.locals.user = token.user + response.locals.roles = token.roles + response.locals.session = token.session + response.locals.readOnlyAccess = token.session?.readonly_access ?? false + + return next() + } catch (error) { + return next(error) + } + } +} diff --git a/packages/auth/src/Controller/ApiGatewayOfflineAuthMiddleware.spec.ts b/packages/auth/src/Controller/ApiGatewayOfflineAuthMiddleware.spec.ts new file mode 100644 index 000000000..f3727b0b9 --- /dev/null +++ b/packages/auth/src/Controller/ApiGatewayOfflineAuthMiddleware.spec.ts @@ -0,0 +1,82 @@ +import 'reflect-metadata' + +import { ApiGatewayOfflineAuthMiddleware } from './ApiGatewayOfflineAuthMiddleware' +import { NextFunction, Request, Response } from 'express' +import { Logger } from 'winston' +import { OfflineUserTokenData, TokenDecoderInterface } from '@standardnotes/auth' + +describe('ApiGatewayOfflineAuthMiddleware', () => { + let tokenDecoder: TokenDecoderInterface + let request: Request + let response: Response + let next: NextFunction + + const logger = { + debug: jest.fn(), + } as unknown as jest.Mocked + + const createMiddleware = () => new ApiGatewayOfflineAuthMiddleware(tokenDecoder, logger) + + beforeEach(() => { + tokenDecoder = {} as jest.Mocked> + tokenDecoder.decodeToken = jest.fn().mockReturnValue({ + userEmail: 'test@test.te', + featuresToken: 'abc', + }) + + request = { + headers: {}, + } as jest.Mocked + response = { + locals: {}, + } as jest.Mocked + response.status = jest.fn().mockReturnThis() + response.send = jest.fn() + next = jest.fn() + }) + + it('should authorize user', async () => { + request.headers['x-auth-offline-token'] = 'auth-jwt-token' + + await createMiddleware().handler(request, response, next) + + expect(response.locals.userEmail).toEqual('test@test.te') + expect(response.locals.featuresToken).toEqual('abc') + + expect(next).toHaveBeenCalled() + }) + + it('should not authorize if request is missing auth jwt token in headers', async () => { + await createMiddleware().handler(request, response, next) + + expect(response.status).toHaveBeenCalledWith(401) + expect(next).not.toHaveBeenCalled() + }) + + it('should not authorize if auth jwt token is malformed', async () => { + request.headers['x-auth-offline-token'] = 'auth-jwt-token' + + tokenDecoder.decodeToken = jest.fn().mockReturnValue(undefined) + + await createMiddleware().handler(request, response, next) + + expect(response.status).toHaveBeenCalledWith(401) + expect(next).not.toHaveBeenCalled() + }) + + it('should pass the error to next middleware if one occurres', async () => { + request.headers['x-auth-offline-token'] = 'auth-jwt-token' + + const error = new Error('Ooops') + + tokenDecoder.decodeToken = jest.fn().mockImplementation(() => { + throw error + }) + + await createMiddleware().handler(request, response, next) + + expect(response.status).not.toHaveBeenCalled() + + expect(next).toHaveBeenCalledWith(error) + }) +}) diff --git a/packages/auth/src/Controller/ApiGatewayOfflineAuthMiddleware.ts b/packages/auth/src/Controller/ApiGatewayOfflineAuthMiddleware.ts new file mode 100644 index 000000000..7ce834814 --- /dev/null +++ b/packages/auth/src/Controller/ApiGatewayOfflineAuthMiddleware.ts @@ -0,0 +1,59 @@ +import { OfflineUserTokenData, TokenDecoderInterface } from '@standardnotes/auth' +import { NextFunction, Request, Response } from 'express' +import { inject, injectable } from 'inversify' +import { BaseMiddleware } from 'inversify-express-utils' +import { Logger } from 'winston' +import TYPES from '../Bootstrap/Types' + +@injectable() +export class ApiGatewayOfflineAuthMiddleware extends BaseMiddleware { + constructor( + @inject(TYPES.OfflineUserTokenDecoder) private tokenDecoder: TokenDecoderInterface, + @inject(TYPES.Logger) private logger: Logger, + ) { + super() + } + + async handler(request: Request, response: Response, next: NextFunction): Promise { + try { + if (!request.headers['x-auth-offline-token']) { + this.logger.debug('ApiGatewayOfflineAuthMiddleware missing x-auth-offline-token header.') + + response.status(401).send({ + error: { + tag: 'invalid-auth', + message: 'Invalid login credentials.', + }, + }) + + return + } + + const token: OfflineUserTokenData | undefined = this.tokenDecoder.decodeToken( + request.headers['x-auth-offline-token'] as string, + ) + + this.logger.debug('ApiGatewayOfflineAuthMiddleware decoded token %O', token) + + if (token === undefined) { + this.logger.debug('ApiGatewayOfflineAuthMiddleware authentication failure.') + + response.status(401).send({ + error: { + tag: 'invalid-auth', + message: 'Invalid login credentials.', + }, + }) + + return + } + + response.locals.featuresToken = token.featuresToken + response.locals.userEmail = token.userEmail + + return next() + } catch (error) { + return next(error) + } + } +} diff --git a/packages/auth/src/Controller/AuthController.spec.ts b/packages/auth/src/Controller/AuthController.spec.ts new file mode 100644 index 000000000..1229ced08 --- /dev/null +++ b/packages/auth/src/Controller/AuthController.spec.ts @@ -0,0 +1,112 @@ +import 'reflect-metadata' + +import { DomainEventInterface, DomainEventPublisherInterface } from '@standardnotes/domain-events' + +import { AuthController } from './AuthController' +import { ClearLoginAttempts } from '../Domain/UseCase/ClearLoginAttempts' +import { User } from '../Domain/User/User' +import { Register } from '../Domain/UseCase/Register' +import { DomainEventFactoryInterface } from '../Domain/Event/DomainEventFactoryInterface' +import { KeyParamsOrigination, ProtocolVersion } from '@standardnotes/common' +import { ApiVersion } from '@standardnotes/api' + +describe('AuthController', () => { + let clearLoginAttempts: ClearLoginAttempts + let register: Register + let domainEventPublisher: DomainEventPublisherInterface + let domainEventFactory: DomainEventFactoryInterface + let event: DomainEventInterface + let user: User + + const createController = () => + new AuthController(clearLoginAttempts, register, domainEventPublisher, domainEventFactory) + + beforeEach(() => { + register = {} as jest.Mocked + register.execute = jest.fn() + + user = {} as jest.Mocked + user.email = 'test@test.te' + + clearLoginAttempts = {} as jest.Mocked + clearLoginAttempts.execute = jest.fn() + + event = {} as jest.Mocked + + domainEventPublisher = {} as jest.Mocked + domainEventPublisher.publish = jest.fn() + + domainEventFactory = {} as jest.Mocked + domainEventFactory.createUserRegisteredEvent = jest.fn().mockReturnValue(event) + }) + + it('should register a user', async () => { + register.execute = jest.fn().mockReturnValue({ success: true, authResponse: { user } }) + + const response = await createController().register({ + email: 'test@test.te', + password: 'asdzxc', + version: ProtocolVersion.V004, + api: ApiVersion.v0, + origination: KeyParamsOrigination.Registration, + userAgent: 'Google Chrome', + identifier: 'test@test.te', + pw_nonce: '11', + ephemeral: false, + }) + + expect(register.execute).toHaveBeenCalledWith({ + apiVersion: '20200115', + kpOrigination: 'registration', + updatedWithUserAgent: 'Google Chrome', + ephemeralSession: false, + version: '004', + email: 'test@test.te', + password: 'asdzxc', + pwNonce: '11', + }) + + expect(domainEventPublisher.publish).toHaveBeenCalledWith(event) + + expect(response.status).toEqual(200) + expect(response.data).toEqual({ user: { email: 'test@test.te' } }) + }) + + it('should not register a user if request param is missing', async () => { + const response = await createController().register({ + email: 'test@test.te', + password: '', + version: ProtocolVersion.V004, + api: ApiVersion.v0, + origination: KeyParamsOrigination.Registration, + userAgent: 'Google Chrome', + identifier: 'test@test.te', + pw_nonce: '11', + ephemeral: false, + }) + + expect(domainEventPublisher.publish).not.toHaveBeenCalled() + + expect(response.status).toEqual(400) + }) + + it('should respond with error if registering a user fails', async () => { + register.execute = jest.fn().mockReturnValue({ success: false, errorMessage: 'Something bad happened' }) + + const response = await createController().register({ + email: 'test@test.te', + password: 'test', + version: ProtocolVersion.V004, + api: ApiVersion.v0, + origination: KeyParamsOrigination.Registration, + userAgent: 'Google Chrome', + identifier: 'test@test.te', + pw_nonce: '11', + ephemeral: false, + }) + + expect(domainEventPublisher.publish).not.toHaveBeenCalled() + + expect(response.status).toEqual(400) + }) +}) diff --git a/packages/auth/src/Controller/AuthController.ts b/packages/auth/src/Controller/AuthController.ts new file mode 100644 index 000000000..0f7509b26 --- /dev/null +++ b/packages/auth/src/Controller/AuthController.ts @@ -0,0 +1,73 @@ +import { inject, injectable } from 'inversify' +import { DomainEventPublisherInterface } from '@standardnotes/domain-events' +import { + HttpStatusCode, + UserRegistrationRequestParams, + UserRegistrationResponse, + UserServerInterface, +} from '@standardnotes/api' + +import TYPES from '../Bootstrap/Types' +import { ClearLoginAttempts } from '../Domain/UseCase/ClearLoginAttempts' +import { Register } from '../Domain/UseCase/Register' +import { DomainEventFactoryInterface } from '../Domain/Event/DomainEventFactoryInterface' + +@injectable() +export class AuthController implements UserServerInterface { + constructor( + @inject(TYPES.ClearLoginAttempts) private clearLoginAttempts: ClearLoginAttempts, + @inject(TYPES.Register) private registerUser: Register, + @inject(TYPES.DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface, + @inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface, + ) {} + + async register(params: UserRegistrationRequestParams): Promise { + if (!params.email || !params.password) { + return { + status: HttpStatusCode.BadRequest, + data: { + error: { + message: 'Please enter an email and a password to register.', + }, + }, + } + } + + const registerResult = await this.registerUser.execute({ + email: params.email, + password: params.password, + updatedWithUserAgent: params.userAgent as string, + apiVersion: params.api, + ephemeralSession: params.ephemeral, + pwNonce: params.pw_nonce, + kpOrigination: params.origination, + kpCreated: params.created, + version: params.version, + }) + + if (!registerResult.success) { + return { + status: HttpStatusCode.BadRequest, + data: { + error: { + message: registerResult.errorMessage, + }, + }, + } + } + + await this.clearLoginAttempts.execute({ email: registerResult.authResponse.user.email as string }) + + await this.domainEventPublisher.publish( + this.domainEventFactory.createUserRegisteredEvent( + registerResult.authResponse.user.uuid, + registerResult.authResponse.user.email, + ), + ) + + return { + status: HttpStatusCode.Success, + data: registerResult.authResponse, + } + } +} diff --git a/packages/auth/src/Controller/AuthMiddleware.spec.ts b/packages/auth/src/Controller/AuthMiddleware.spec.ts new file mode 100644 index 000000000..cce211cb6 --- /dev/null +++ b/packages/auth/src/Controller/AuthMiddleware.spec.ts @@ -0,0 +1,79 @@ +import 'reflect-metadata' + +import { AuthMiddleware } from './AuthMiddleware' +import { NextFunction, Request, Response } from 'express' +import { User } from '../Domain/User/User' +import { AuthenticateRequest } from '../Domain/UseCase/AuthenticateRequest' +import { Session } from '../Domain/Session/Session' +import { Logger } from 'winston' + +describe('AuthMiddleware', () => { + let authenticateRequest: AuthenticateRequest + let request: Request + let response: Response + let next: NextFunction + + const logger = { + debug: jest.fn(), + } as unknown as jest.Mocked + + const createMiddleware = () => new AuthMiddleware(authenticateRequest, logger) + + beforeEach(() => { + authenticateRequest = {} as jest.Mocked + authenticateRequest.execute = jest.fn() + + request = { + headers: {}, + } as jest.Mocked + response = { + locals: {}, + } as jest.Mocked + response.status = jest.fn().mockReturnThis() + response.send = jest.fn() + next = jest.fn() + }) + + it('should authorize user', async () => { + const user = {} as jest.Mocked + const session = {} as jest.Mocked + authenticateRequest.execute = jest.fn().mockReturnValue({ + success: true, + user, + session, + }) + + await createMiddleware().handler(request, response, next) + + expect(response.locals.user).toEqual(user) + expect(response.locals.session).toEqual(session) + + expect(next).toHaveBeenCalled() + }) + + it('should not authorize if request authentication fails', async () => { + authenticateRequest.execute = jest.fn().mockReturnValue({ + success: false, + responseCode: 401, + }) + + await createMiddleware().handler(request, response, next) + + expect(response.status).toHaveBeenCalledWith(401) + expect(next).not.toHaveBeenCalled() + }) + + it('should pass the error to next middleware if one occurres', async () => { + const error = new Error('Ooops') + + authenticateRequest.execute = jest.fn().mockImplementation(() => { + throw error + }) + + await createMiddleware().handler(request, response, next) + + expect(response.status).not.toHaveBeenCalled() + + expect(next).toHaveBeenCalledWith(error) + }) +}) diff --git a/packages/auth/src/Controller/AuthMiddleware.ts b/packages/auth/src/Controller/AuthMiddleware.ts new file mode 100644 index 000000000..c8a78eeb1 --- /dev/null +++ b/packages/auth/src/Controller/AuthMiddleware.ts @@ -0,0 +1,45 @@ +import { NextFunction, Request, Response } from 'express' +import { inject, injectable } from 'inversify' +import { BaseMiddleware } from 'inversify-express-utils' +import { Logger } from 'winston' +import TYPES from '../Bootstrap/Types' +import { AuthenticateRequest } from '../Domain/UseCase/AuthenticateRequest' + +@injectable() +export class AuthMiddleware extends BaseMiddleware { + constructor( + @inject(TYPES.AuthenticateRequest) private authenticateRequest: AuthenticateRequest, + @inject(TYPES.Logger) private logger: Logger, + ) { + super() + } + + async handler(request: Request, response: Response, next: NextFunction): Promise { + try { + const authenticateRequestResponse = await this.authenticateRequest.execute({ + authorizationHeader: request.headers.authorization, + }) + + if (!authenticateRequestResponse.success) { + this.logger.debug('AuthMiddleware authentication failure.') + + response.status(authenticateRequestResponse.responseCode).send({ + error: { + tag: authenticateRequestResponse.errorTag, + message: authenticateRequestResponse.errorMessage, + }, + }) + + return + } + + response.locals.user = authenticateRequestResponse.user + response.locals.session = authenticateRequestResponse.session + response.locals.readOnlyAccess = authenticateRequestResponse.session?.readonlyAccess ?? false + + return next() + } catch (error) { + return next(error) + } + } +} diff --git a/packages/auth/src/Controller/AuthMiddlewareWithoutResponse.spec.ts b/packages/auth/src/Controller/AuthMiddlewareWithoutResponse.spec.ts new file mode 100644 index 000000000..fcb968609 --- /dev/null +++ b/packages/auth/src/Controller/AuthMiddlewareWithoutResponse.spec.ts @@ -0,0 +1,68 @@ +import 'reflect-metadata' + +import { AuthMiddlewareWithoutResponse } from './AuthMiddlewareWithoutResponse' +import { NextFunction, Request, Response } from 'express' +import { User } from '../Domain/User/User' +import { AuthenticateRequest } from '../Domain/UseCase/AuthenticateRequest' +import { Session } from '../Domain/Session/Session' + +describe('AuthMiddlewareWithoutResponse', () => { + let authenticateRequest: AuthenticateRequest + let request: Request + let response: Response + let next: NextFunction + + const createMiddleware = () => new AuthMiddlewareWithoutResponse(authenticateRequest) + + beforeEach(() => { + authenticateRequest = {} as jest.Mocked + authenticateRequest.execute = jest.fn() + + request = { + headers: {}, + } as jest.Mocked + response = { + locals: {}, + } as jest.Mocked + response.status = jest.fn().mockReturnThis() + response.send = jest.fn() + next = jest.fn() + }) + + it('should authorize user', async () => { + const user = {} as jest.Mocked + const session = {} as jest.Mocked + authenticateRequest.execute = jest.fn().mockReturnValue({ + success: true, + user, + session, + }) + + await createMiddleware().handler(request, response, next) + + expect(response.locals.user).toEqual(user) + expect(response.locals.session).toEqual(session) + + expect(next).toHaveBeenCalled() + }) + + it('should skip middleware if authentication fails', async () => { + authenticateRequest.execute = jest.fn().mockReturnValue({ + success: false, + }) + + await createMiddleware().handler(request, response, next) + + expect(next).toHaveBeenCalled() + }) + + it('should skip middleware if authentication errors', async () => { + authenticateRequest.execute = jest.fn().mockImplementation(() => { + throw new Error('Ooops') + }) + + await createMiddleware().handler(request, response, next) + + expect(next).toHaveBeenCalled() + }) +}) diff --git a/packages/auth/src/Controller/AuthMiddlewareWithoutResponse.ts b/packages/auth/src/Controller/AuthMiddlewareWithoutResponse.ts new file mode 100644 index 000000000..4204770f2 --- /dev/null +++ b/packages/auth/src/Controller/AuthMiddlewareWithoutResponse.ts @@ -0,0 +1,32 @@ +import { NextFunction, Request, Response } from 'express' +import { inject, injectable } from 'inversify' +import { BaseMiddleware } from 'inversify-express-utils' +import TYPES from '../Bootstrap/Types' +import { AuthenticateRequest } from '../Domain/UseCase/AuthenticateRequest' + +@injectable() +export class AuthMiddlewareWithoutResponse extends BaseMiddleware { + constructor(@inject(TYPES.AuthenticateRequest) private authenticateRequest: AuthenticateRequest) { + super() + } + + async handler(request: Request, response: Response, next: NextFunction): Promise { + try { + const authenticateRequestResponse = await this.authenticateRequest.execute({ + authorizationHeader: request.headers.authorization, + }) + + if (!authenticateRequestResponse.success) { + return next() + } + + response.locals.user = authenticateRequestResponse.user + response.locals.session = authenticateRequestResponse.session + response.locals.readOnlyAccess = authenticateRequestResponse.session?.readonlyAccess ?? false + + return next() + } catch (error) { + return next() + } + } +} diff --git a/packages/auth/src/Controller/FeaturesController.spec.ts b/packages/auth/src/Controller/FeaturesController.spec.ts new file mode 100644 index 000000000..7595c0c7b --- /dev/null +++ b/packages/auth/src/Controller/FeaturesController.spec.ts @@ -0,0 +1,87 @@ +import 'reflect-metadata' + +import * as express from 'express' + +import { FeaturesController } from './FeaturesController' +import { results } from 'inversify-express-utils' +import { User } from '../Domain/User/User' +import { GetUserFeatures } from '../Domain/UseCase/GetUserFeatures/GetUserFeatures' + +describe('FeaturesController', () => { + let getUserFeatures: GetUserFeatures + + let request: express.Request + let response: express.Response + let user: User + + const createController = () => new FeaturesController(getUserFeatures) + + beforeEach(() => { + user = {} as jest.Mocked + user.uuid = '123' + + getUserFeatures = {} as jest.Mocked + getUserFeatures.execute = jest.fn() + + request = { + headers: {}, + body: {}, + params: {}, + } as jest.Mocked + + response = { + locals: {}, + } as jest.Mocked + }) + + it('should get authenticated user features', async () => { + request.params.userUuid = '1-2-3' + response.locals.user = { + uuid: '1-2-3', + } + + getUserFeatures.execute = jest.fn().mockReturnValue({ success: true }) + + const httpResponse = await createController().getFeatures(request, response) + const result = await httpResponse.executeAsync() + + expect(getUserFeatures.execute).toHaveBeenCalledWith({ + userUuid: '1-2-3', + offline: false, + }) + + expect(result.statusCode).toEqual(200) + }) + + it('should not get user features if the user with provided uuid does not exist', async () => { + request.params.userUuid = '1-2-3' + response.locals.user = { + uuid: '1-2-3', + } + + getUserFeatures.execute = jest.fn().mockReturnValue({ success: false }) + + const httpResponse = await createController().getFeatures(request, response) + const result = await httpResponse.executeAsync() + + expect(getUserFeatures.execute).toHaveBeenCalledWith({ userUuid: '1-2-3', offline: false }) + + expect(result.statusCode).toEqual(400) + }) + + it('should not get user features if not allowed', async () => { + request.params.userUuid = '1-2-3' + response.locals.user = { + uuid: '2-3-4', + } + + getUserFeatures.execute = jest.fn() + + const httpResponse = await createController().getFeatures(request, response) + const result = await httpResponse.executeAsync() + + expect(getUserFeatures.execute).not.toHaveBeenCalled() + + expect(result.statusCode).toEqual(401) + }) +}) diff --git a/packages/auth/src/Controller/FeaturesController.ts b/packages/auth/src/Controller/FeaturesController.ts new file mode 100644 index 000000000..274311846 --- /dev/null +++ b/packages/auth/src/Controller/FeaturesController.ts @@ -0,0 +1,43 @@ +import { Request, Response } from 'express' +import { inject } from 'inversify' +import { + BaseHttpController, + controller, + httpGet, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + results, +} from 'inversify-express-utils' +import TYPES from '../Bootstrap/Types' +import { GetUserFeatures } from '../Domain/UseCase/GetUserFeatures/GetUserFeatures' + +@controller('/users/:userUuid/features') +export class FeaturesController extends BaseHttpController { + constructor(@inject(TYPES.GetUserFeatures) private doGetUserFeatures: GetUserFeatures) { + super() + } + + @httpGet('/', TYPES.ApiGatewayAuthMiddleware) + async getFeatures(request: Request, response: Response): Promise { + if (request.params.userUuid !== response.locals.user.uuid) { + return this.json( + { + error: { + message: 'Operation not allowed.', + }, + }, + 401, + ) + } + + const result = await this.doGetUserFeatures.execute({ + userUuid: request.params.userUuid, + offline: false, + }) + + if (result.success) { + return this.json(result) + } + + return this.json(result, 400) + } +} diff --git a/packages/auth/src/Controller/HealthCheckController.ts b/packages/auth/src/Controller/HealthCheckController.ts new file mode 100644 index 000000000..431e74056 --- /dev/null +++ b/packages/auth/src/Controller/HealthCheckController.ts @@ -0,0 +1,9 @@ +import { controller, httpGet } from 'inversify-express-utils' + +@controller('/healthcheck') +export class HealthCheckController { + @httpGet('/') + public async get(): Promise { + return 'OK' + } +} diff --git a/packages/auth/src/Controller/InternalController.spec.ts b/packages/auth/src/Controller/InternalController.spec.ts new file mode 100644 index 000000000..4450898fd --- /dev/null +++ b/packages/auth/src/Controller/InternalController.spec.ts @@ -0,0 +1,164 @@ +import 'reflect-metadata' + +import * as express from 'express' + +import { InternalController } from './InternalController' +import { results } from 'inversify-express-utils' +import { User } from '../Domain/User/User' +import { GetUserFeatures } from '../Domain/UseCase/GetUserFeatures/GetUserFeatures' +import { GetSetting } from '../Domain/UseCase/GetSetting/GetSetting' +import { MuteFailedBackupsEmails } from '../Domain/UseCase/MuteFailedBackupsEmails/MuteFailedBackupsEmails' +import { MuteSignInEmails } from '../Domain/UseCase/MuteSignInEmails/MuteSignInEmails' + +describe('InternalController', () => { + let getUserFeatures: GetUserFeatures + let getSetting: GetSetting + let muteFailedBackupsEmails: MuteFailedBackupsEmails + let muteSignInEmails: MuteSignInEmails + + let request: express.Request + let user: User + + const createController = () => + new InternalController(getUserFeatures, getSetting, muteFailedBackupsEmails, muteSignInEmails) + + beforeEach(() => { + user = {} as jest.Mocked + user.uuid = '123' + + getUserFeatures = {} as jest.Mocked + getUserFeatures.execute = jest.fn() + + getSetting = {} as jest.Mocked + getSetting.execute = jest.fn() + + muteFailedBackupsEmails = {} as jest.Mocked + muteFailedBackupsEmails.execute = jest.fn() + + muteSignInEmails = {} as jest.Mocked + muteSignInEmails.execute = jest.fn() + + request = { + headers: {}, + body: {}, + params: {}, + } as jest.Mocked + }) + + it('should get user features', async () => { + request.params.userUuid = '1-2-3' + + getUserFeatures.execute = jest.fn().mockReturnValue({ success: true }) + + const httpResponse = await createController().getFeatures(request) + const result = await httpResponse.executeAsync() + + expect(getUserFeatures.execute).toHaveBeenCalledWith({ + userUuid: '1-2-3', + offline: false, + }) + + expect(result.statusCode).toEqual(200) + }) + + it('should not get user features if the user with provided uuid does not exist', async () => { + request.params.userUuid = '1-2-3' + + getUserFeatures.execute = jest.fn().mockReturnValue({ success: false }) + + const httpResponse = await createController().getFeatures(request) + const result = await httpResponse.executeAsync() + + expect(getUserFeatures.execute).toHaveBeenCalledWith({ userUuid: '1-2-3', offline: false }) + + expect(result.statusCode).toEqual(400) + }) + + it('should get user setting', async () => { + request.params.userUuid = '1-2-3' + request.params.settingName = 'foobar' + + getSetting.execute = jest.fn().mockReturnValue({ success: true }) + + const httpResponse = await createController().getSetting(request) + const result = await httpResponse.executeAsync() + + expect(getSetting.execute).toHaveBeenCalledWith({ + userUuid: '1-2-3', + settingName: 'foobar', + allowSensitiveRetrieval: true, + }) + + expect(result.statusCode).toEqual(200) + }) + + it('should not get user setting if the use case fails', async () => { + request.params.userUuid = '1-2-3' + request.params.settingName = 'foobar' + + getSetting.execute = jest.fn().mockReturnValue({ success: false }) + + const httpResponse = await createController().getSetting(request) + const result = await httpResponse.executeAsync() + + expect(getSetting.execute).toHaveBeenCalledWith({ + userUuid: '1-2-3', + settingName: 'foobar', + allowSensitiveRetrieval: true, + }) + + expect(result.statusCode).toEqual(400) + }) + + it('should mute failed backup emails user setting', async () => { + request.params.settingUuid = '1-2-3' + + muteFailedBackupsEmails.execute = jest.fn().mockReturnValue({ success: true }) + + const httpResponse = await createController().muteFailedBackupsEmails(request) + const result = await httpResponse.executeAsync() + + expect(muteFailedBackupsEmails.execute).toHaveBeenCalledWith({ settingUuid: '1-2-3' }) + + expect(result.statusCode).toEqual(200) + }) + + it('should not mute failed backup emails user setting if it does not exist', async () => { + request.params.settingUuid = '1-2-3' + + muteFailedBackupsEmails.execute = jest.fn().mockReturnValue({ success: false }) + + const httpResponse = await createController().muteFailedBackupsEmails(request) + const result = await httpResponse.executeAsync() + + expect(muteFailedBackupsEmails.execute).toHaveBeenCalledWith({ settingUuid: '1-2-3' }) + + expect(result.statusCode).toEqual(404) + }) + + it('should mute sign in emails user setting', async () => { + request.params.settingUuid = '1-2-3' + + muteSignInEmails.execute = jest.fn().mockReturnValue({ success: true }) + + const httpResponse = await createController().muteSignInEmails(request) + const result = await httpResponse.executeAsync() + + expect(muteSignInEmails.execute).toHaveBeenCalledWith({ settingUuid: '1-2-3' }) + + expect(result.statusCode).toEqual(200) + }) + + it('should not mute sign in emails user setting if it does not exist', async () => { + request.params.settingUuid = '1-2-3' + + muteSignInEmails.execute = jest.fn().mockReturnValue({ success: false }) + + const httpResponse = await createController().muteSignInEmails(request) + const result = await httpResponse.executeAsync() + + expect(muteSignInEmails.execute).toHaveBeenCalledWith({ settingUuid: '1-2-3' }) + + expect(result.statusCode).toEqual(404) + }) +}) diff --git a/packages/auth/src/Controller/InternalController.ts b/packages/auth/src/Controller/InternalController.ts new file mode 100644 index 000000000..03cffc720 --- /dev/null +++ b/packages/auth/src/Controller/InternalController.ts @@ -0,0 +1,83 @@ +import { Request } from 'express' +import { inject } from 'inversify' +import { + BaseHttpController, + controller, + httpGet, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + results, +} from 'inversify-express-utils' +import TYPES from '../Bootstrap/Types' +import { GetSetting } from '../Domain/UseCase/GetSetting/GetSetting' +import { GetUserFeatures } from '../Domain/UseCase/GetUserFeatures/GetUserFeatures' +import { MuteFailedBackupsEmails } from '../Domain/UseCase/MuteFailedBackupsEmails/MuteFailedBackupsEmails' +import { MuteSignInEmails } from '../Domain/UseCase/MuteSignInEmails/MuteSignInEmails' + +@controller('/internal') +export class InternalController extends BaseHttpController { + constructor( + @inject(TYPES.GetUserFeatures) private doGetUserFeatures: GetUserFeatures, + @inject(TYPES.GetSetting) private doGetSetting: GetSetting, + @inject(TYPES.MuteFailedBackupsEmails) private doMuteFailedBackupsEmails: MuteFailedBackupsEmails, + @inject(TYPES.MuteSignInEmails) private doMuteSignInEmails: MuteSignInEmails, + ) { + super() + } + + @httpGet('/users/:userUuid/features') + async getFeatures(request: Request): Promise { + const result = await this.doGetUserFeatures.execute({ + userUuid: request.params.userUuid, + offline: false, + }) + + if (result.success) { + return this.json(result) + } + + return this.json(result, 400) + } + + @httpGet('/users/:userUuid/settings/:settingName') + async getSetting(request: Request): Promise { + const result = await this.doGetSetting.execute({ + userUuid: request.params.userUuid, + settingName: request.params.settingName, + allowSensitiveRetrieval: true, + }) + + if (result.success) { + return this.json(result) + } + + return this.json(result, 400) + } + + @httpGet('/settings/email_backup/:settingUuid/mute') + async muteFailedBackupsEmails(request: Request): Promise { + const { settingUuid } = request.params + const result = await this.doMuteFailedBackupsEmails.execute({ + settingUuid, + }) + + if (result.success) { + return this.json({ message: result.message }) + } + + return this.json({ message: result.message }, 404) + } + + @httpGet('/settings/sign_in/:settingUuid/mute') + async muteSignInEmails(request: Request): Promise { + const { settingUuid } = request.params + const result = await this.doMuteSignInEmails.execute({ + settingUuid, + }) + + if (result.success) { + return this.json({ message: result.message }) + } + + return this.json({ message: result.message }, 404) + } +} diff --git a/packages/auth/src/Controller/ListedController.spec.ts b/packages/auth/src/Controller/ListedController.spec.ts new file mode 100644 index 000000000..6ecff0eb4 --- /dev/null +++ b/packages/auth/src/Controller/ListedController.spec.ts @@ -0,0 +1,68 @@ +import 'reflect-metadata' + +import * as express from 'express' +import { results } from 'inversify-express-utils' + +import { ListedController } from './ListedController' +import { User } from '../Domain/User/User' +import { CreateListedAccount } from '../Domain/UseCase/CreateListedAccount/CreateListedAccount' + +describe('ListedController', () => { + let createListedAccount: CreateListedAccount + + let request: express.Request + let response: express.Response + let user: User + + const createController = () => new ListedController(createListedAccount) + + beforeEach(() => { + user = {} as jest.Mocked + user.uuid = '123' + + createListedAccount = {} as jest.Mocked + createListedAccount.execute = jest.fn() + + request = { + headers: {}, + body: {}, + params: {}, + } as jest.Mocked + + response = { + locals: {}, + } as jest.Mocked + }) + + it('should create a listed account for user', async () => { + response.locals.user = { + uuid: '1-2-3', + email: 'test@test.com', + } + + const httpResponse = await createController().createListedAccount(request, response) + const result = await httpResponse.executeAsync() + + expect(createListedAccount.execute).toHaveBeenCalledWith({ + userUuid: '1-2-3', + userEmail: 'test@test.com', + }) + + expect(result.statusCode).toEqual(200) + }) + + it('should not create a listed account if sessions is read only', async () => { + response.locals.readOnlyAccess = true + response.locals.user = { + uuid: '1-2-3', + email: 'test@test.com', + } + + const httpResponse = await createController().createListedAccount(request, response) + const result = await httpResponse.executeAsync() + + expect(createListedAccount.execute).not.toHaveBeenCalled() + + expect(result.statusCode).toEqual(401) + }) +}) diff --git a/packages/auth/src/Controller/ListedController.ts b/packages/auth/src/Controller/ListedController.ts new file mode 100644 index 000000000..23e89797f --- /dev/null +++ b/packages/auth/src/Controller/ListedController.ts @@ -0,0 +1,38 @@ +import { inject } from 'inversify' +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { BaseHttpController, controller, httpPost, results } from 'inversify-express-utils' +import { Request, Response } from 'express' +import TYPES from '../Bootstrap/Types' +import { CreateListedAccount } from '../Domain/UseCase/CreateListedAccount/CreateListedAccount' +import { ErrorTag } from '@standardnotes/common' + +@controller('/listed') +export class ListedController extends BaseHttpController { + constructor(@inject(TYPES.CreateListedAccount) private doCreateListedAccount: CreateListedAccount) { + super() + } + + @httpPost('/', TYPES.ApiGatewayAuthMiddleware) + async createListedAccount(_request: Request, response: Response): Promise { + if (response.locals.readOnlyAccess) { + return this.json( + { + error: { + tag: ErrorTag.ReadOnlyAccess, + message: 'Session has read-only access.', + }, + }, + 401, + ) + } + + await this.doCreateListedAccount.execute({ + userUuid: response.locals.user.uuid, + userEmail: response.locals.user.email, + }) + + return this.json({ + message: 'Listed account creation requested successfully.', + }) + } +} diff --git a/packages/auth/src/Controller/LockMiddleware.spec.ts b/packages/auth/src/Controller/LockMiddleware.spec.ts new file mode 100644 index 000000000..c359b23d6 --- /dev/null +++ b/packages/auth/src/Controller/LockMiddleware.spec.ts @@ -0,0 +1,78 @@ +import 'reflect-metadata' + +import { LockMiddleware } from './LockMiddleware' +import { NextFunction, Request, Response } from 'express' +import { User } from '../Domain/User/User' +import { UserRepositoryInterface } from '../Domain/User/UserRepositoryInterface' +import { LockRepositoryInterface } from '../Domain/User/LockRepositoryInterface' + +describe('LockMiddleware', () => { + let userRepository: UserRepositoryInterface + let lockRepository: LockRepositoryInterface + let request: Request + let response: Response + let user: User + let next: NextFunction + + const createMiddleware = () => new LockMiddleware(userRepository, lockRepository) + + beforeEach(() => { + user = {} as jest.Mocked + + lockRepository = {} as jest.Mocked + lockRepository.isUserLocked = jest.fn().mockReturnValue(true) + + userRepository = {} as jest.Mocked + userRepository.findOneByEmail = jest.fn().mockReturnValue(user) + + request = { + body: {}, + } as jest.Mocked + response = {} as jest.Mocked + response.status = jest.fn().mockReturnThis() + response.send = jest.fn() + next = jest.fn() + }) + + it('should return locked response if user is locked', async () => { + await createMiddleware().handler(request, response, next) + + expect(response.status).toHaveBeenCalledWith(423) + + expect(next).not.toHaveBeenCalled() + }) + + it('should let the request pass if user is not locked', async () => { + lockRepository.isUserLocked = jest.fn().mockReturnValue(false) + + await createMiddleware().handler(request, response, next) + + expect(response.status).not.toHaveBeenCalled() + + expect(next).toHaveBeenCalled() + }) + + it('should return locked response if user is not found but the email is locked', async () => { + userRepository.findOneByEmail = jest.fn().mockReturnValue(null) + + await createMiddleware().handler(request, response, next) + + expect(response.status).toHaveBeenCalledWith(423) + + expect(next).not.toHaveBeenCalled() + }) + + it('should pass the error to next middleware if one occurres', async () => { + const error = new Error('Ooops') + + userRepository.findOneByEmail = jest.fn().mockImplementation(() => { + throw error + }) + + await createMiddleware().handler(request, response, next) + + expect(response.status).not.toHaveBeenCalled() + + expect(next).toHaveBeenCalledWith(error) + }) +}) diff --git a/packages/auth/src/Controller/LockMiddleware.ts b/packages/auth/src/Controller/LockMiddleware.ts new file mode 100644 index 000000000..0c2675133 --- /dev/null +++ b/packages/auth/src/Controller/LockMiddleware.ts @@ -0,0 +1,42 @@ +import { NextFunction, Request, Response } from 'express' +import { inject, injectable } from 'inversify' +import { BaseMiddleware } from 'inversify-express-utils' +import TYPES from '../Bootstrap/Types' +import { LockRepositoryInterface } from '../Domain/User/LockRepositoryInterface' + +import { UserRepositoryInterface } from '../Domain/User/UserRepositoryInterface' + +@injectable() +export class LockMiddleware extends BaseMiddleware { + constructor( + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.LockRepository) private lockRepository: LockRepositoryInterface, + ) { + super() + } + + async handler(request: Request, response: Response, next: NextFunction): Promise { + try { + let identifier = request.body.email + + const user = await this.userRepository.findOneByEmail(identifier) + if (user !== null) { + identifier = user.uuid + } + + if (await this.lockRepository.isUserLocked(identifier)) { + response.status(423).send({ + error: { + message: 'Too many successive login requests. Please try your request again later.', + }, + }) + + return + } + + return next() + } catch (error) { + return next(error) + } + } +} diff --git a/packages/auth/src/Controller/OfflineController.spec.ts b/packages/auth/src/Controller/OfflineController.spec.ts new file mode 100644 index 000000000..f0fb00c60 --- /dev/null +++ b/packages/auth/src/Controller/OfflineController.spec.ts @@ -0,0 +1,234 @@ +import 'reflect-metadata' + +import * as express from 'express' + +import { OfflineController } from './OfflineController' +import { results } from 'inversify-express-utils' +import { User } from '../Domain/User/User' +import { GetUserFeatures } from '../Domain/UseCase/GetUserFeatures/GetUserFeatures' +import { CreateOfflineSubscriptionToken } from '../Domain/UseCase/CreateOfflineSubscriptionToken/CreateOfflineSubscriptionToken' +import { CreateOfflineSubscriptionTokenResponse } from '../Domain/UseCase/CreateOfflineSubscriptionToken/CreateOfflineSubscriptionTokenResponse' +import { AuthenticateOfflineSubscriptionToken } from '../Domain/UseCase/AuthenticateOfflineSubscriptionToken/AuthenticateOfflineSubscriptionToken' +import { OfflineUserSubscription } from '../Domain/Subscription/OfflineUserSubscription' +import { GetUserOfflineSubscription } from '../Domain/UseCase/GetUserOfflineSubscription/GetUserOfflineSubscription' +import { OfflineUserTokenData, TokenEncoderInterface } from '@standardnotes/auth' +import { SubscriptionName } from '@standardnotes/common' +import { Logger } from 'winston' + +describe('OfflineController', () => { + let getUserFeatures: GetUserFeatures + let getUserOfflineSubscription: GetUserOfflineSubscription + let createOfflineSubscriptionToken: CreateOfflineSubscriptionToken + let authenticateToken: AuthenticateOfflineSubscriptionToken + let logger: Logger + let tokenEncoder: TokenEncoderInterface + const jwtTTL = 60 + + let request: express.Request + let response: express.Response + let user: User + + const createController = () => + new OfflineController( + getUserFeatures, + getUserOfflineSubscription, + createOfflineSubscriptionToken, + authenticateToken, + tokenEncoder, + jwtTTL, + logger, + ) + + beforeEach(() => { + user = {} as jest.Mocked + user.uuid = '123' + + getUserFeatures = {} as jest.Mocked + getUserFeatures.execute = jest.fn() + + createOfflineSubscriptionToken = {} as jest.Mocked + createOfflineSubscriptionToken.execute = jest.fn().mockReturnValue({ + success: true, + offlineSubscriptionToken: { + token: 'test', + }, + } as jest.Mocked) + + getUserOfflineSubscription = {} as jest.Mocked + getUserOfflineSubscription.execute = jest.fn().mockReturnValue({ + success: true, + subscription: { + planName: SubscriptionName.ProPlan, + }, + }) + + authenticateToken = {} as jest.Mocked + authenticateToken.execute = jest.fn().mockReturnValue({ + success: true, + email: 'test@test.com', + subscriptions: [{} as jest.Mocked], + }) + + logger = {} as jest.Mocked + logger.debug = jest.fn() + + tokenEncoder = {} as jest.Mocked> + tokenEncoder.encodeExpirableToken = jest.fn().mockReturnValue('foobar') + + request = { + headers: {}, + body: {}, + params: {}, + } as jest.Mocked + + response = { + locals: {}, + } as jest.Mocked + }) + + it('should get offline user features', async () => { + response.locals.offlineUserEmail = 'test@test.com' + + getUserFeatures.execute = jest.fn().mockReturnValue({ success: true }) + + const httpResponse = await createController().getOfflineFeatures(request, response) + const result = await httpResponse.executeAsync() + + expect(getUserFeatures.execute).toHaveBeenCalledWith({ + email: 'test@test.com', + offline: true, + }) + + expect(result.statusCode).toEqual(200) + }) + + it('should get offline user subscription', async () => { + response.locals.userEmail = 'test@test.com' + + const httpResponse = await createController().getSubscription(request, response) + const result = await httpResponse.executeAsync() + + expect(getUserOfflineSubscription.execute).toHaveBeenCalledWith({ + userEmail: 'test@test.com', + }) + + expect(result.statusCode).toEqual(200) + }) + + it('should not get offline user subscription if the procedure fails', async () => { + response.locals.userEmail = 'test@test.com' + + getUserOfflineSubscription.execute = jest.fn().mockReturnValue({ success: false }) + + const httpResponse = await createController().getSubscription(request, response) + const result = await httpResponse.executeAsync() + + expect(getUserOfflineSubscription.execute).toHaveBeenCalledWith({ + userEmail: 'test@test.com', + }) + + expect(result.statusCode).toEqual(400) + }) + + it('should not get offline user features if the procedure fails', async () => { + response.locals.offlineUserEmail = 'test@test.com' + + getUserFeatures.execute = jest.fn().mockReturnValue({ success: false }) + + const httpResponse = await createController().getOfflineFeatures(request, response) + const result = await httpResponse.executeAsync() + + expect(getUserFeatures.execute).toHaveBeenCalledWith({ + email: 'test@test.com', + offline: true, + }) + + expect(result.statusCode).toEqual(400) + }) + + it('should create a offline subscription token for authenticated user', async () => { + request.body.email = 'test@test.com' + + const httpResponse = await createController().createToken(request) + const result = await httpResponse.executeAsync() + + expect(createOfflineSubscriptionToken.execute).toHaveBeenCalledWith({ + userEmail: 'test@test.com', + }) + + expect(result.statusCode).toEqual(200) + }) + + it('should not create a offline subscription token for missing email in request', async () => { + const httpResponse = await createController().createToken(request) + const result = await httpResponse.executeAsync() + + expect(createOfflineSubscriptionToken.execute).not.toHaveBeenCalled() + + expect(result.statusCode).toEqual(400) + }) + + it('should not create a offline subscription token if the workflow fails with no subscription', async () => { + request.body.email = 'test@test.com' + + createOfflineSubscriptionToken.execute = jest.fn().mockReturnValue({ + success: false, + error: 'no-subscription', + }) + + const httpResponse = await createController().createToken(request) + const result = await httpResponse.executeAsync() + + expect(createOfflineSubscriptionToken.execute).toHaveBeenCalledWith({ + userEmail: 'test@test.com', + }) + + expect(httpResponse.json).toEqual({ success: false, error: { tag: 'no-subscription' } }) + expect(result.statusCode).toEqual(200) + }) + + it('should validate a offline subscription token for user', async () => { + request.params.token = 'test' + request.body.email = 'test@test.com' + + const httpResponse = await createController().validate(request) + const result = await httpResponse.executeAsync() + + expect(authenticateToken.execute).toHaveBeenCalledWith({ + token: 'test', + userEmail: 'test@test.com', + }) + + expect(result.statusCode).toEqual(200) + }) + + it('should not validate a offline subscription token for user if it is invalid', async () => { + request.body.email = 'test@test.com' + request.params.token = 'test' + + authenticateToken.execute = jest.fn().mockReturnValue({ + success: false, + }) + + const httpResponse = await createController().validate(request) + const result = await httpResponse.executeAsync() + + expect(authenticateToken.execute).toHaveBeenCalledWith({ + token: 'test', + userEmail: 'test@test.com', + }) + + expect(result.statusCode).toEqual(401) + }) + + it('should not validate a offline subscription token for user if email is missing', async () => { + request.params.token = 'test' + + const httpResponse = await createController().validate(request) + const result = await httpResponse.executeAsync() + + expect(authenticateToken.execute).not.toHaveBeenCalled() + + expect(result.statusCode).toEqual(400) + }) +}) diff --git a/packages/auth/src/Controller/OfflineController.ts b/packages/auth/src/Controller/OfflineController.ts new file mode 100644 index 000000000..3793e8088 --- /dev/null +++ b/packages/auth/src/Controller/OfflineController.ts @@ -0,0 +1,134 @@ +import { Request, Response } from 'express' +import { inject } from 'inversify' +import { + BaseHttpController, + controller, + httpGet, + httpPost, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + results, +} from 'inversify-express-utils' +import TYPES from '../Bootstrap/Types' +import { GetUserFeatures } from '../Domain/UseCase/GetUserFeatures/GetUserFeatures' +import { AuthenticateOfflineSubscriptionToken } from '../Domain/UseCase/AuthenticateOfflineSubscriptionToken/AuthenticateOfflineSubscriptionToken' +import { CreateOfflineSubscriptionToken } from '../Domain/UseCase/CreateOfflineSubscriptionToken/CreateOfflineSubscriptionToken' +import { GetUserOfflineSubscription } from '../Domain/UseCase/GetUserOfflineSubscription/GetUserOfflineSubscription' +import { Logger } from 'winston' +import { OfflineUserTokenData, TokenEncoderInterface } from '@standardnotes/auth' + +@controller('/offline') +export class OfflineController extends BaseHttpController { + constructor( + @inject(TYPES.GetUserFeatures) private doGetUserFeatures: GetUserFeatures, + @inject(TYPES.GetUserOfflineSubscription) private getUserOfflineSubscription: GetUserOfflineSubscription, + @inject(TYPES.CreateOfflineSubscriptionToken) + private createOfflineSubscriptionToken: CreateOfflineSubscriptionToken, + @inject(TYPES.AuthenticateOfflineSubscriptionToken) private authenticateToken: AuthenticateOfflineSubscriptionToken, + @inject(TYPES.OfflineUserTokenEncoder) private tokenEncoder: TokenEncoderInterface, + @inject(TYPES.AUTH_JWT_TTL) private jwtTTL: number, + @inject(TYPES.Logger) private logger: Logger, + ) { + super() + } + + @httpGet('/features', TYPES.OfflineUserAuthMiddleware) + async getOfflineFeatures(_request: Request, response: Response): Promise { + const result = await this.doGetUserFeatures.execute({ + email: response.locals.offlineUserEmail, + offline: true, + }) + + if (result.success) { + return this.json(result) + } + + return this.json(result, 400) + } + + @httpPost('/subscription-tokens') + async createToken(request: Request): Promise { + if (!request.body.email) { + return this.json( + { + error: { + tag: 'invalid-request', + message: 'Invalid request parameters.', + }, + }, + 400, + ) + } + + const response = await this.createOfflineSubscriptionToken.execute({ + userEmail: request.body.email, + }) + + if (!response.success) { + return this.json({ success: false, error: { tag: response.error } }) + } + + return this.json({ success: true }) + } + + @httpPost('/subscription-tokens/:token/validate') + async validate(request: Request): Promise { + if (!request.body.email) { + this.logger.debug('[Offline Subscription Token Validation] Missing email') + + return this.json( + { + error: { + tag: 'invalid-request', + message: 'Invalid request parameters.', + }, + }, + 400, + ) + } + + const authenticateTokenResponse = await this.authenticateToken.execute({ + token: request.params.token, + userEmail: request.body.email, + }) + + if (!authenticateTokenResponse.success) { + this.logger.debug('[Offline Subscription Token Validation] invalid token') + + return this.json( + { + error: { + tag: 'invalid-auth', + message: 'Invalid login credentials.', + }, + }, + 401, + ) + } + + const offlineAuthTokenData: OfflineUserTokenData = { + userEmail: authenticateTokenResponse.email, + featuresToken: authenticateTokenResponse.featuresToken, + } + + const authToken = this.tokenEncoder.encodeExpirableToken(offlineAuthTokenData, this.jwtTTL) + + this.logger.debug( + `[Offline Subscription Token Validation] authenticated token for user ${authenticateTokenResponse.email}`, + ) + + return this.json({ authToken }) + } + + @httpGet('/users/subscription', TYPES.ApiGatewayOfflineAuthMiddleware) + async getSubscription(_request: Request, response: Response): Promise { + const result = await this.getUserOfflineSubscription.execute({ + userEmail: response.locals.userEmail, + }) + + if (result.success) { + return this.json(result) + } + + return this.json(result, 400) + } +} diff --git a/packages/auth/src/Controller/OfflineUserAuthMiddleware.spec.ts b/packages/auth/src/Controller/OfflineUserAuthMiddleware.spec.ts new file mode 100644 index 000000000..2fe4cd7a4 --- /dev/null +++ b/packages/auth/src/Controller/OfflineUserAuthMiddleware.spec.ts @@ -0,0 +1,86 @@ +import 'reflect-metadata' + +import { OfflineUserAuthMiddleware } from './OfflineUserAuthMiddleware' +import { NextFunction, Request, Response } from 'express' +import { Logger } from 'winston' +import { OfflineSettingRepositoryInterface } from '../Domain/Setting/OfflineSettingRepositoryInterface' +import { OfflineSetting } from '../Domain/Setting/OfflineSetting' + +describe('OfflineUserAuthMiddleware', () => { + let offlineSettingRepository: OfflineSettingRepositoryInterface + let offlineSetting: OfflineSetting + let request: Request + let response: Response + let next: NextFunction + + const logger = { + debug: jest.fn(), + } as unknown as jest.Mocked + + const createMiddleware = () => new OfflineUserAuthMiddleware(offlineSettingRepository, logger) + + beforeEach(() => { + offlineSetting = { + email: 'test@test.com', + value: 'offline-features-token', + } as jest.Mocked + + offlineSettingRepository = {} as jest.Mocked + offlineSettingRepository.findOneByNameAndValue = jest.fn().mockReturnValue(offlineSetting) + + request = { + headers: {}, + } as jest.Mocked + response = { + locals: {}, + } as jest.Mocked + response.status = jest.fn().mockReturnThis() + response.send = jest.fn() + next = jest.fn() + }) + + it('should authorize user', async () => { + request.headers['x-offline-token'] = 'offline-features-token' + + await createMiddleware().handler(request, response, next) + + expect(response.locals.offlineUserEmail).toEqual('test@test.com') + expect(response.locals.offlineFeaturesToken).toEqual('offline-features-token') + + expect(next).toHaveBeenCalled() + }) + + it('should not authorize if request is missing offline token in headers', async () => { + await createMiddleware().handler(request, response, next) + + expect(response.status).toHaveBeenCalledWith(401) + expect(next).not.toHaveBeenCalled() + }) + + it('should not authorize if offline token setting is not found', async () => { + request.headers['x-offline-token'] = 'offline-features-token' + + offlineSettingRepository.findOneByNameAndValue = jest.fn().mockReturnValue(null) + + await createMiddleware().handler(request, response, next) + + expect(response.status).toHaveBeenCalledWith(401) + expect(next).not.toHaveBeenCalled() + }) + + it('should pass the error to next middleware if one occurres', async () => { + request.headers['x-offline-token'] = 'offline-features-token' + + const error = new Error('Ooops') + + offlineSettingRepository.findOneByNameAndValue = jest.fn().mockImplementation(() => { + throw error + }) + + await createMiddleware().handler(request, response, next) + + expect(response.status).not.toHaveBeenCalled() + + expect(next).toHaveBeenCalledWith(error) + }) +}) diff --git a/packages/auth/src/Controller/OfflineUserAuthMiddleware.ts b/packages/auth/src/Controller/OfflineUserAuthMiddleware.ts new file mode 100644 index 000000000..adec3ee9f --- /dev/null +++ b/packages/auth/src/Controller/OfflineUserAuthMiddleware.ts @@ -0,0 +1,58 @@ +import { NextFunction, Request, Response } from 'express' +import { inject, injectable } from 'inversify' +import { BaseMiddleware } from 'inversify-express-utils' +import { Logger } from 'winston' +import TYPES from '../Bootstrap/Types' +import { OfflineSettingName } from '../Domain/Setting/OfflineSettingName' +import { OfflineSettingRepositoryInterface } from '../Domain/Setting/OfflineSettingRepositoryInterface' + +@injectable() +export class OfflineUserAuthMiddleware extends BaseMiddleware { + constructor( + @inject(TYPES.OfflineSettingRepository) private offlineSettingRepository: OfflineSettingRepositoryInterface, + @inject(TYPES.Logger) private logger: Logger, + ) { + super() + } + + async handler(request: Request, response: Response, next: NextFunction): Promise { + try { + if (!request.headers['x-offline-token']) { + this.logger.debug('OfflineUserAuthMiddleware missing x-offline-token header.') + + response.status(401).send({ + error: { + tag: 'invalid-auth', + message: 'Invalid login credentials.', + }, + }) + + return + } + + const offlineFeaturesTokenSetting = await this.offlineSettingRepository.findOneByNameAndValue( + OfflineSettingName.FeaturesToken, + request.headers['x-offline-token'] as string, + ) + if (offlineFeaturesTokenSetting === null) { + this.logger.debug('OfflineUserAuthMiddleware authentication failure.') + + response.status(401).send({ + error: { + tag: 'invalid-auth', + message: 'Invalid login credentials.', + }, + }) + + return + } + + response.locals.offlineUserEmail = offlineFeaturesTokenSetting.email + response.locals.offlineFeaturesToken = offlineFeaturesTokenSetting.value + + return next() + } catch (error) { + return next(error) + } + } +} diff --git a/packages/auth/src/Controller/SessionController.spec.ts b/packages/auth/src/Controller/SessionController.spec.ts new file mode 100644 index 000000000..f926fbc2b --- /dev/null +++ b/packages/auth/src/Controller/SessionController.spec.ts @@ -0,0 +1,239 @@ +import 'reflect-metadata' + +import * as express from 'express' + +import { SessionController } from './SessionController' +import { results } from 'inversify-express-utils' +import { RefreshSessionToken } from '../Domain/UseCase/RefreshSessionToken' +import { DeletePreviousSessionsForUser } from '../Domain/UseCase/DeletePreviousSessionsForUser' +import { DeleteSessionForUser } from '../Domain/UseCase/DeleteSessionForUser' + +describe('SessionController', () => { + let deleteSessionForUser: DeleteSessionForUser + let deletePreviousSessionsForUser: DeletePreviousSessionsForUser + let refreshSessionToken: RefreshSessionToken + let request: express.Request + let response: express.Response + + const createController = () => + new SessionController(deleteSessionForUser, deletePreviousSessionsForUser, refreshSessionToken) + + beforeEach(() => { + deleteSessionForUser = {} as jest.Mocked + deleteSessionForUser.execute = jest.fn().mockReturnValue({ success: true }) + + deletePreviousSessionsForUser = {} as jest.Mocked + deletePreviousSessionsForUser.execute = jest.fn() + + refreshSessionToken = {} as jest.Mocked + refreshSessionToken.execute = jest.fn() + + request = { + body: {}, + } as jest.Mocked + + response = { + locals: {}, + } as jest.Mocked + response.status = jest.fn().mockReturnThis() + response.setHeader = jest.fn() + response.send = jest.fn() + }) + + it('should refresh session tokens', async () => { + request.body.access_token = '123' + request.body.refresh_token = '234' + + refreshSessionToken.execute = jest.fn().mockReturnValue({ + success: true, + sessionPayload: { + access_token: '1231', + refresh_token: '2341', + access_expiration: 123123, + refresh_expiration: 123123, + }, + }) + + await createController().refresh(request, response) + + expect(response.send).toHaveBeenCalledWith({ + session: { + access_token: '1231', + refresh_token: '2341', + access_expiration: 123123, + refresh_expiration: 123123, + }, + }) + }) + + it('should return bad request if tokens are missing from refresh token request', async () => { + const httpResponse = await createController().refresh(request, response) + expect(httpResponse.statusCode).toEqual(400) + }) + + it('should return bad request upon failed tokens refreshing', async () => { + request.body.access_token = '123' + request.body.refresh_token = '234' + + refreshSessionToken.execute = jest.fn().mockReturnValue({ + success: false, + errorTag: 'test', + errorMessage: 'something bad happened', + }) + + const httpResponse = await createController().refresh(request, response) + + expect(httpResponse.json).toEqual({ + error: { + tag: 'test', + message: 'something bad happened', + }, + }) + expect(httpResponse.statusCode).toEqual(400) + }) + + it('should delete a specific session for current user', async () => { + response.locals = { + user: { + uuid: '123', + }, + session: { + uuid: '234', + }, + } + request.body.uuid = '123' + + await createController().deleteSession(request, response) + + expect(deleteSessionForUser.execute).toBeCalledWith({ + userUuid: '123', + sessionUuid: '123', + }) + + expect(response.status).toHaveBeenCalledWith(204) + }) + + it('should not delete a specific session is current session has read only access', async () => { + response.locals = { + user: { + uuid: '123', + }, + session: { + uuid: '234', + }, + } + request.body.uuid = '123' + response.locals.readOnlyAccess = true + + const httpResponse = await createController().deleteSession(request, response) + const result = await httpResponse.executeAsync() + + expect(deleteSessionForUser.execute).not.toHaveBeenCalled() + + expect(result.statusCode).toEqual(401) + }) + + it('should not delete a specific session if request is missing params', async () => { + response.locals = { + user: { + uuid: '123', + }, + session: { + uuid: '234', + }, + } + + const httpResponse = await createController().deleteSession(request, response) + + expect(deleteSessionForUser.execute).not.toHaveBeenCalled() + + expect(httpResponse.statusCode).toEqual(400) + }) + + it('should not delete a specific session if it is the current session', async () => { + response.locals = { + user: { + uuid: '123', + }, + session: { + uuid: '234', + }, + } + request.body.uuid = '234' + + const httpResponse = await createController().deleteSession(request, response) + + expect(deleteSessionForUser.execute).not.toHaveBeenCalled() + + expect(httpResponse.statusCode).toEqual(400) + }) + + it('should respond with failure if deleting a specific session fails', async () => { + response.locals = { + user: { + uuid: '123', + }, + session: { + uuid: '234', + }, + } + request.body.uuid = '123' + + deleteSessionForUser.execute = jest.fn().mockReturnValue({ success: false }) + + const httpResponse = await createController().deleteSession(request, response) + + expect(httpResponse.statusCode).toEqual(400) + }) + + it('should delete all sessions except current for current user', async () => { + response.locals = { + user: { + uuid: '123', + }, + session: { + uuid: '234', + }, + } + await createController().deleteAllSessions(request, response) + + expect(deletePreviousSessionsForUser.execute).toHaveBeenCalledWith({ + userUuid: '123', + currentSessionUuid: '234', + }) + + expect(response.status).toHaveBeenCalledWith(204) + expect(response.send).toHaveBeenCalled() + }) + + it('should not delete all sessions if current sessions has read only access', async () => { + response.locals = { + user: { + uuid: '123', + }, + session: { + uuid: '234', + }, + } + response.locals.readOnlyAccess = true + + const httpResponse = await createController().deleteAllSessions(request, response) + const result = await httpResponse.executeAsync() + + expect(deletePreviousSessionsForUser.execute).not.toHaveBeenCalled() + + expect(result.statusCode).toEqual(401) + }) + + it('should return unauthorized if current user is missing', async () => { + response.locals = { + session: { + uuid: '234', + }, + } + const httpResponse = await createController().deleteAllSessions(request, response) + + expect(httpResponse.json).toEqual({ error: { message: 'No session exists with the provided identifier.' } }) + expect(httpResponse.statusCode).toEqual(401) + }) +}) diff --git a/packages/auth/src/Controller/SessionController.ts b/packages/auth/src/Controller/SessionController.ts new file mode 100644 index 000000000..5d856d034 --- /dev/null +++ b/packages/auth/src/Controller/SessionController.ts @@ -0,0 +1,152 @@ +import { ErrorTag } from '@standardnotes/common' +import { Request, Response } from 'express' +import { inject } from 'inversify' +import { + BaseHttpController, + controller, + httpDelete, + httpPost, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + results, +} from 'inversify-express-utils' +import TYPES from '../Bootstrap/Types' +import { DeletePreviousSessionsForUser } from '../Domain/UseCase/DeletePreviousSessionsForUser' +import { DeleteSessionForUser } from '../Domain/UseCase/DeleteSessionForUser' +import { RefreshSessionToken } from '../Domain/UseCase/RefreshSessionToken' + +@controller('/session') +export class SessionController extends BaseHttpController { + constructor( + @inject(TYPES.DeleteSessionForUser) private deleteSessionForUser: DeleteSessionForUser, + @inject(TYPES.DeletePreviousSessionsForUser) private deletePreviousSessionsForUser: DeletePreviousSessionsForUser, + @inject(TYPES.RefreshSessionToken) private refreshSessionToken: RefreshSessionToken, + ) { + super() + } + + @httpDelete('/', TYPES.AuthMiddleware, TYPES.SessionMiddleware) + async deleteSession(request: Request, response: Response): Promise { + if (response.locals.readOnlyAccess) { + return this.json( + { + error: { + tag: ErrorTag.ReadOnlyAccess, + message: 'Session has read-only access.', + }, + }, + 401, + ) + } + + if (!request.body.uuid) { + return this.json( + { + error: { + message: 'Please provide the session identifier.', + }, + }, + 400, + ) + } + + if (request.body.uuid === response.locals.session.uuid) { + return this.json( + { + error: { + message: 'You can not delete your current session.', + }, + }, + 400, + ) + } + + const useCaseResponse = await this.deleteSessionForUser.execute({ + userUuid: response.locals.user.uuid, + sessionUuid: request.body.uuid, + }) + + if (!useCaseResponse.success) { + return this.json( + { + error: { + message: useCaseResponse.errorMessage, + }, + }, + 400, + ) + } + + response.setHeader('x-invalidate-cache', response.locals.user.uuid) + response.status(204).send() + } + + @httpDelete('/all', TYPES.AuthMiddleware, TYPES.SessionMiddleware) + async deleteAllSessions(_request: Request, response: Response): Promise { + if (response.locals.readOnlyAccess) { + return this.json( + { + error: { + tag: ErrorTag.ReadOnlyAccess, + message: 'Session has read-only access.', + }, + }, + 401, + ) + } + + if (!response.locals.user) { + return this.json( + { + error: { + message: 'No session exists with the provided identifier.', + }, + }, + 401, + ) + } + + await this.deletePreviousSessionsForUser.execute({ + userUuid: response.locals.user.uuid, + currentSessionUuid: response.locals.session.uuid, + }) + + response.setHeader('x-invalidate-cache', response.locals.user.uuid) + response.status(204).send() + } + + @httpPost('/refresh') + async refresh(request: Request, response: Response): Promise { + if (!request.body.access_token || !request.body.refresh_token) { + return this.json( + { + error: { + message: 'Please provide all required parameters.', + }, + }, + 400, + ) + } + + const result = await this.refreshSessionToken.execute({ + accessToken: request.body.access_token, + refreshToken: request.body.refresh_token, + }) + + if (!result.success) { + return this.json( + { + error: { + tag: result.errorTag, + message: result.errorMessage, + }, + }, + 400, + ) + } + + response.setHeader('x-invalidate-cache', result.userUuid as string) + response.send({ + session: result.sessionPayload, + }) + } +} diff --git a/packages/auth/src/Controller/SessionMiddleware.spec.ts b/packages/auth/src/Controller/SessionMiddleware.spec.ts new file mode 100644 index 000000000..f85fe3ef5 --- /dev/null +++ b/packages/auth/src/Controller/SessionMiddleware.spec.ts @@ -0,0 +1,52 @@ +import 'reflect-metadata' + +import { SessionMiddleware } from './SessionMiddleware' +import { NextFunction, Request, Response } from 'express' + +describe('SessionMiddleware', () => { + let request: Request + let response: Response + let next: NextFunction + + const createMiddleware = () => new SessionMiddleware() + + beforeEach(() => { + request = { + headers: {}, + } as jest.Mocked + response = { + locals: {}, + } as jest.Mocked + response.status = jest.fn().mockReturnThis() + response.send = jest.fn() + next = jest.fn() + }) + + it('should do nothing if session is available', async () => { + response.locals.session = {} + + await createMiddleware().handler(request, response, next) + + expect(next).toHaveBeenCalled() + }) + + it('should send bad request error if session is not available', async () => { + await createMiddleware().handler(request, response, next) + + expect(response.status).toHaveBeenCalledWith(400) + + expect(next).not.toHaveBeenCalled() + }) + + it('should pass the error to next middleware if one occurres', async () => { + const error = new Error('Ooops') + + response.send = jest.fn().mockImplementation(() => { + throw error + }) + + await createMiddleware().handler(request, response, next) + + expect(next).toHaveBeenCalledWith(error) + }) +}) diff --git a/packages/auth/src/Controller/SessionMiddleware.ts b/packages/auth/src/Controller/SessionMiddleware.ts new file mode 100644 index 000000000..7a5edf1b8 --- /dev/null +++ b/packages/auth/src/Controller/SessionMiddleware.ts @@ -0,0 +1,25 @@ +import { NextFunction, Request, Response } from 'express' +import { injectable } from 'inversify' +import { BaseMiddleware } from 'inversify-express-utils' + +@injectable() +export class SessionMiddleware extends BaseMiddleware { + async handler(_request: Request, response: Response, next: NextFunction): Promise { + try { + if (!response.locals.session) { + response.status(400).send({ + error: { + tag: 'unsupported-account-version', + message: 'Account version not supported.', + }, + }) + + return + } + + return next() + } catch (error) { + return next(error) + } + } +} diff --git a/packages/auth/src/Controller/SessionsController.spec.ts b/packages/auth/src/Controller/SessionsController.spec.ts new file mode 100644 index 000000000..aa2359705 --- /dev/null +++ b/packages/auth/src/Controller/SessionsController.spec.ts @@ -0,0 +1,228 @@ +import 'reflect-metadata' + +import * as express from 'express' + +import { SessionsController } from './SessionsController' +import { results } from 'inversify-express-utils' +import { Session } from '../Domain/Session/Session' +import { ProjectorInterface } from '../Projection/ProjectorInterface' +import { GetActiveSessionsForUser } from '../Domain/UseCase/GetActiveSessionsForUser' +import { AuthenticateRequest } from '../Domain/UseCase/AuthenticateRequest' +import { User } from '../Domain/User/User' +import { Role } from '../Domain/Role/Role' +import { CrossServiceTokenData, TokenEncoderInterface } from '@standardnotes/auth' +import { GetUserAnalyticsId } from '../Domain/UseCase/GetUserAnalyticsId/GetUserAnalyticsId' + +describe('SessionsController', () => { + let getActiveSessionsForUser: GetActiveSessionsForUser + let authenticateRequest: AuthenticateRequest + let userProjector: ProjectorInterface + let tokenEncoder: TokenEncoderInterface + const jwtTTL = 60 + let sessionProjector: ProjectorInterface + let roleProjector: ProjectorInterface + let session: Session + let request: express.Request + let response: express.Response + let user: User + let role: Role + let getUserAnalyticsId: GetUserAnalyticsId + + const createController = () => + new SessionsController( + getActiveSessionsForUser, + authenticateRequest, + userProjector, + sessionProjector, + roleProjector, + tokenEncoder, + getUserAnalyticsId, + true, + jwtTTL, + ) + + beforeEach(() => { + session = {} as jest.Mocked + + user = {} as jest.Mocked + user.roles = Promise.resolve([role]) + + getActiveSessionsForUser = {} as jest.Mocked + getActiveSessionsForUser.execute = jest.fn().mockReturnValue({ sessions: [session] }) + + authenticateRequest = {} as jest.Mocked + authenticateRequest.execute = jest.fn() + + userProjector = {} as jest.Mocked> + userProjector.projectSimple = jest.fn().mockReturnValue({ bar: 'baz' }) + + roleProjector = {} as jest.Mocked> + roleProjector.projectSimple = jest.fn().mockReturnValue({ name: 'role1', uuid: '1-3-4' }) + + sessionProjector = {} as jest.Mocked> + sessionProjector.projectCustom = jest.fn().mockReturnValue({ foo: 'bar' }) + sessionProjector.projectSimple = jest.fn().mockReturnValue({ test: 'test' }) + + tokenEncoder = {} as jest.Mocked> + tokenEncoder.encodeExpirableToken = jest.fn().mockReturnValue('foobar') + + getUserAnalyticsId = {} as jest.Mocked + getUserAnalyticsId.execute = jest.fn().mockReturnValue({ analyticsId: 123 }) + + request = { + params: {}, + headers: {}, + } as jest.Mocked + + response = { + locals: {}, + } as jest.Mocked + }) + + it('should get all active sessions for current user', async () => { + response.locals = { + user: { + uuid: '123', + }, + session: { + uuid: '234', + }, + } + + const httpResponse = await createController().getSessions(request, response) + + expect(httpResponse).toBeInstanceOf(results.JsonResult) + + const result = await httpResponse.executeAsync() + expect(await result.content.readAsStringAsync()).toEqual('[{"foo":"bar"}]') + }) + + it('should validate a session from an incoming request', async () => { + authenticateRequest.execute = jest.fn().mockReturnValue({ + success: true, + user, + session, + }) + + request.headers.authorization = 'test' + + const httpResponse = await createController().validate(request) + + expect(httpResponse).toBeInstanceOf(results.JsonResult) + + const result = await httpResponse.executeAsync() + const httpResponseContent = await result.content.readAsStringAsync() + const httpResponseJSON = JSON.parse(httpResponseContent) + + expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith( + { + analyticsId: 123, + roles: [ + { + name: 'role1', + uuid: '1-3-4', + }, + ], + session: { + test: 'test', + }, + user: { + bar: 'baz', + }, + }, + 60, + ) + + expect(httpResponseJSON.authToken).toEqual('foobar') + }) + + it('should validate a session from an incoming request - disabled analytics', async () => { + authenticateRequest.execute = jest.fn().mockReturnValue({ + success: true, + user, + session, + }) + + request.headers.authorization = 'test' + + const controller = new SessionsController( + getActiveSessionsForUser, + authenticateRequest, + userProjector, + sessionProjector, + roleProjector, + tokenEncoder, + getUserAnalyticsId, + false, + jwtTTL, + ) + + const httpResponse = await controller.validate(request) + + expect(httpResponse).toBeInstanceOf(results.JsonResult) + + const result = await httpResponse.executeAsync() + const httpResponseContent = await result.content.readAsStringAsync() + const httpResponseJSON = JSON.parse(httpResponseContent) + + expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith( + { + roles: [ + { + name: 'role1', + uuid: '1-3-4', + }, + ], + session: { + test: 'test', + }, + user: { + bar: 'baz', + }, + }, + 60, + ) + + expect(httpResponseJSON.authToken).toEqual('foobar') + }) + + it('should validate a user from an incoming request', async () => { + authenticateRequest.execute = jest.fn().mockReturnValue({ + success: true, + user, + }) + + request.headers.authorization = 'test' + + const httpResponse = await createController().validate(request) + + expect(httpResponse).toBeInstanceOf(results.JsonResult) + + const result = await httpResponse.executeAsync() + const httpResponseContent = await result.content.readAsStringAsync() + const httpResponseJSON = JSON.parse(httpResponseContent) + + expect(httpResponseJSON.authToken).toEqual('foobar') + }) + + it('should not validate a session from an incoming request', async () => { + authenticateRequest.execute = jest.fn().mockReturnValue({ + success: false, + errorTag: 'invalid-auth', + errorMessage: 'Invalid login credentials.', + responseCode: 401, + }) + + request.headers.authorization = 'test' + + const httpResponse = await createController().validate(request) + + expect(httpResponse).toBeInstanceOf(results.JsonResult) + expect(httpResponse.statusCode).toEqual(401) + + const result = await httpResponse.executeAsync() + expect(await result.content.readAsStringAsync()).toEqual( + '{"error":{"tag":"invalid-auth","message":"Invalid login credentials."}}', + ) + }) +}) diff --git a/packages/auth/src/Controller/SessionsController.ts b/packages/auth/src/Controller/SessionsController.ts new file mode 100644 index 000000000..29a08d031 --- /dev/null +++ b/packages/auth/src/Controller/SessionsController.ts @@ -0,0 +1,128 @@ +import { Request, Response } from 'express' +import { inject } from 'inversify' +import { + BaseHttpController, + controller, + httpGet, + httpPost, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + results, +} from 'inversify-express-utils' +import TYPES from '../Bootstrap/Types' +import { Session } from '../Domain/Session/Session' +import { AuthenticateRequest } from '../Domain/UseCase/AuthenticateRequest' +import { GetActiveSessionsForUser } from '../Domain/UseCase/GetActiveSessionsForUser' +import { Role } from '../Domain/Role/Role' +import { User } from '../Domain/User/User' +import { ProjectorInterface } from '../Projection/ProjectorInterface' +import { SessionProjector } from '../Projection/SessionProjector' +import { CrossServiceTokenData, TokenEncoderInterface } from '@standardnotes/auth' +import { RoleName } from '@standardnotes/common' +import { GetUserAnalyticsId } from '../Domain/UseCase/GetUserAnalyticsId/GetUserAnalyticsId' + +@controller('/sessions') +export class SessionsController extends BaseHttpController { + constructor( + @inject(TYPES.GetActiveSessionsForUser) private getActiveSessionsForUser: GetActiveSessionsForUser, + @inject(TYPES.AuthenticateRequest) private authenticateRequest: AuthenticateRequest, + @inject(TYPES.UserProjector) private userProjector: ProjectorInterface, + @inject(TYPES.SessionProjector) private sessionProjector: ProjectorInterface, + @inject(TYPES.RoleProjector) private roleProjector: ProjectorInterface, + @inject(TYPES.CrossServiceTokenEncoder) private tokenEncoder: TokenEncoderInterface, + @inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId, + @inject(TYPES.ANALYTICS_ENABLED) private analyticsEnabled: boolean, + @inject(TYPES.AUTH_JWT_TTL) private jwtTTL: number, + ) { + super() + } + + @httpPost('/validate') + async validate(request: Request): Promise { + const authenticateRequestResponse = await this.authenticateRequest.execute({ + authorizationHeader: request.headers.authorization, + }) + + if (!authenticateRequestResponse.success) { + return this.json( + { + error: { + tag: authenticateRequestResponse.errorTag, + message: authenticateRequestResponse.errorMessage, + }, + }, + authenticateRequestResponse.responseCode, + ) + } + + const user = authenticateRequestResponse.user as User + + const roles = await user.roles + + const authTokenData: CrossServiceTokenData = { + user: this.projectUser(user), + roles: this.projectRoles(roles), + } + + if (this.analyticsEnabled) { + const { analyticsId } = await this.getUserAnalyticsId.execute({ userUuid: user.uuid }) + authTokenData.analyticsId = analyticsId + } + + if (authenticateRequestResponse.session !== undefined) { + authTokenData.session = this.projectSession(authenticateRequestResponse.session) + } + + const authToken = this.tokenEncoder.encodeExpirableToken(authTokenData, this.jwtTTL) + + return this.json({ authToken }) + } + + @httpGet('/', TYPES.AuthMiddleware, TYPES.SessionMiddleware) + async getSessions(_request: Request, response: Response): Promise { + const useCaseResponse = await this.getActiveSessionsForUser.execute({ + userUuid: response.locals.user.uuid, + }) + + return this.json( + useCaseResponse.sessions.map((session) => + this.sessionProjector.projectCustom( + SessionProjector.CURRENT_SESSION_PROJECTION.toString(), + session, + response.locals.session, + ), + ), + ) + } + + private projectUser(user: User): { uuid: string; email: string } { + return <{ uuid: string; email: string }>this.userProjector.projectSimple(user) + } + + private projectSession(session: Session): { + uuid: string + api_version: string + created_at: string + updated_at: string + device_info: string + readonly_access: boolean + access_expiration: string + refresh_expiration: string + } { + return < + { + uuid: string + api_version: string + created_at: string + updated_at: string + device_info: string + readonly_access: boolean + access_expiration: string + refresh_expiration: string + } + >this.sessionProjector.projectSimple(session) + } + + private projectRoles(roles: Array): Array<{ uuid: string; name: RoleName }> { + return roles.map((role) => <{ uuid: string; name: RoleName }>this.roleProjector.projectSimple(role)) + } +} diff --git a/packages/auth/src/Controller/SettingsController.spec.ts b/packages/auth/src/Controller/SettingsController.spec.ts new file mode 100644 index 000000000..315524eca --- /dev/null +++ b/packages/auth/src/Controller/SettingsController.spec.ts @@ -0,0 +1,329 @@ +import 'reflect-metadata' + +import * as express from 'express' + +import { SettingsController } from './SettingsController' +import { results } from 'inversify-express-utils' +import { User } from '../Domain/User/User' +import { GetSettings } from '../Domain/UseCase/GetSettings/GetSettings' +import { GetSetting } from '../Domain/UseCase/GetSetting/GetSetting' +import { UpdateSetting } from '../Domain/UseCase/UpdateSetting/UpdateSetting' +import { DeleteSetting } from '../Domain/UseCase/DeleteSetting/DeleteSetting' +import { EncryptionVersion } from '../Domain/Encryption/EncryptionVersion' + +describe('SettingsController', () => { + let deleteSetting: DeleteSetting + let getSettings: GetSettings + let getSetting: GetSetting + let updateSetting: UpdateSetting + + let request: express.Request + let response: express.Response + let user: User + + const createController = () => new SettingsController(getSettings, getSetting, updateSetting, deleteSetting) + + beforeEach(() => { + deleteSetting = {} as jest.Mocked + deleteSetting.execute = jest.fn().mockReturnValue({ success: true }) + + user = {} as jest.Mocked + user.uuid = '123' + + getSettings = {} as jest.Mocked + getSettings.execute = jest.fn() + + getSetting = {} as jest.Mocked + getSetting.execute = jest.fn() + + updateSetting = {} as jest.Mocked + updateSetting.execute = jest.fn() + + request = { + headers: {}, + body: {}, + params: {}, + } as jest.Mocked + + response = { + locals: {}, + } as jest.Mocked + }) + + it('should get user settings', async () => { + request.params.userUuid = '1-2-3' + response.locals.user = { + uuid: '1-2-3', + } + + const httpResponse = await createController().getSettings(request, response) + const result = await httpResponse.executeAsync() + + expect(getSettings.execute).toHaveBeenCalledWith({ userUuid: '1-2-3' }) + + expect(result.statusCode).toEqual(200) + }) + + it('should not get user settings if not allowed', async () => { + request.params.userUuid = '1-2-3' + response.locals.user = { + uuid: '2-3-4', + } + + const httpResponse = await createController().getSettings(request, response) + const result = await httpResponse.executeAsync() + + expect(getSettings.execute).not.toHaveBeenCalled() + + expect(result.statusCode).toEqual(401) + }) + + it('should get user setting', async () => { + request.params.userUuid = '1-2-3' + request.params.settingName = 'test' + response.locals.user = { + uuid: '1-2-3', + } + + getSetting.execute = jest.fn().mockReturnValue({ success: true }) + + const httpResponse = await createController().getSetting(request, response) + const result = await httpResponse.executeAsync() + + expect(getSetting.execute).toHaveBeenCalledWith({ userUuid: '1-2-3', settingName: 'test' }) + + expect(result.statusCode).toEqual(200) + }) + + it('should not get user setting if not allowed', async () => { + request.params.userUuid = '1-2-3' + request.params.settingName = 'test' + response.locals.user = { + uuid: '2-3-4', + } + + getSetting.execute = jest.fn() + + const httpResponse = await createController().getSetting(request, response) + const result = await httpResponse.executeAsync() + + expect(getSetting.execute).not.toHaveBeenCalled() + + expect(result.statusCode).toEqual(401) + }) + + it('should fail if could not get user setting', async () => { + request.params.userUuid = '1-2-3' + request.params.settingName = 'test' + response.locals.user = { + uuid: '1-2-3', + } + + getSetting.execute = jest.fn().mockReturnValue({ success: false }) + + const httpResponse = await createController().getSetting(request, response) + const result = await httpResponse.executeAsync() + + expect(getSetting.execute).toHaveBeenCalledWith({ userUuid: '1-2-3', settingName: 'test' }) + + expect(result.statusCode).toEqual(400) + }) + + it('should update user setting with default encryption', async () => { + request.params.userUuid = '1-2-3' + response.locals.user = { + uuid: '1-2-3', + } + + request.body = { + name: 'foo', + value: 'bar', + } + + updateSetting.execute = jest.fn().mockReturnValue({ success: true, statusCode: 200 }) + + const httpResponse = await createController().updateSetting(request, response) + const result = await httpResponse.executeAsync() + + expect(updateSetting.execute).toHaveBeenCalledWith({ + props: { + name: 'foo', + sensitive: false, + serverEncryptionVersion: 1, + unencryptedValue: 'bar', + }, + userUuid: '1-2-3', + }) + + expect(result.statusCode).toEqual(200) + }) + + it('should update user setting with different encryption setting', async () => { + request.params.userUuid = '1-2-3' + response.locals.user = { + uuid: '1-2-3', + } + + request.body = { + name: 'foo', + value: 'bar', + serverEncryptionVersion: EncryptionVersion.Unencrypted, + } + + updateSetting.execute = jest.fn().mockReturnValue({ success: true, statusCode: 200 }) + + const httpResponse = await createController().updateSetting(request, response) + const result = await httpResponse.executeAsync() + + expect(updateSetting.execute).toHaveBeenCalledWith({ + props: { + name: 'foo', + sensitive: false, + serverEncryptionVersion: 0, + unencryptedValue: 'bar', + }, + userUuid: '1-2-3', + }) + + expect(result.statusCode).toEqual(200) + }) + + it('should not update user setting if session has read only access', async () => { + request.params.userUuid = '1-2-3' + response.locals.user = { + uuid: '1-2-3', + } + response.locals.readOnlyAccess = true + + request.body = { + name: 'foo', + value: 'bar', + } + + const httpResponse = await createController().updateSetting(request, response) + const result = await httpResponse.executeAsync() + + expect(updateSetting.execute).not.toHaveBeenCalled() + + expect(result.statusCode).toEqual(401) + }) + + it('should not update user setting if not allowed', async () => { + request.params.userUuid = '1-2-3' + response.locals.user = { + uuid: '2-3-4', + } + + request.body = { + name: 'foo', + value: 'bar', + serverEncryptionVersion: EncryptionVersion.Default, + } + + updateSetting.execute = jest.fn() + + const httpResponse = await createController().updateSetting(request, response) + const result = await httpResponse.executeAsync() + + expect(updateSetting.execute).not.toHaveBeenCalled() + + expect(result.statusCode).toEqual(401) + }) + + it('should fail if could not update user setting', async () => { + request.params.userUuid = '1-2-3' + response.locals.user = { + uuid: '1-2-3', + } + + request.body = { + name: 'foo', + value: 'bar', + serverEncryptionVersion: EncryptionVersion.Default, + } + + updateSetting.execute = jest.fn().mockReturnValue({ success: false, statusCode: 404 }) + + const httpResponse = await createController().updateSetting(request, response) + const result = await httpResponse.executeAsync() + + expect(updateSetting.execute).toHaveBeenCalledWith({ + props: { + name: 'foo', + serverEncryptionVersion: 1, + sensitive: false, + unencryptedValue: 'bar', + }, + userUuid: '1-2-3', + }) + + expect(result.statusCode).toEqual(404) + }) + + it('should delete user setting', async () => { + request.params.userUuid = '1-2-3' + request.params.settingName = 'foo' + response.locals.user = { + uuid: '1-2-3', + } + + deleteSetting.execute = jest.fn().mockReturnValue({ success: true }) + + const httpResponse = await createController().deleteSetting(request, response) + const result = await httpResponse.executeAsync() + + expect(deleteSetting.execute).toHaveBeenCalledWith({ userUuid: '1-2-3', settingName: 'foo' }) + + expect(result.statusCode).toEqual(200) + }) + + it('should not delete user setting if session has read only access', async () => { + request.params.userUuid = '1-2-3' + request.params.settingName = 'foo' + response.locals.user = { + uuid: '1-2-3', + } + response.locals.readOnlyAccess = true + + const httpResponse = await createController().deleteSetting(request, response) + const result = await httpResponse.executeAsync() + + expect(deleteSetting.execute).not.toHaveBeenCalled() + + expect(result.statusCode).toEqual(401) + }) + + it('should not delete user setting if user is not allowed', async () => { + request.params.userUuid = '1-2-3' + request.params.settingName = 'foo' + response.locals.user = { + uuid: '2-3-4', + } + + deleteSetting.execute = jest.fn() + + const httpResponse = await createController().deleteSetting(request, response) + const result = await httpResponse.executeAsync() + + expect(deleteSetting.execute).not.toHaveBeenCalled() + + expect(result.statusCode).toEqual(401) + }) + + it('should fail if could not delete user setting', async () => { + request.params.userUuid = '1-2-3' + request.params.settingName = 'foo' + response.locals.user = { + uuid: '1-2-3', + } + + deleteSetting.execute = jest.fn().mockReturnValue({ success: false }) + + const httpResponse = await createController().deleteSetting(request, response) + const result = await httpResponse.executeAsync() + + expect(deleteSetting.execute).toHaveBeenCalledWith({ userUuid: '1-2-3', settingName: 'foo' }) + + expect(result.statusCode).toEqual(400) + }) +}) diff --git a/packages/auth/src/Controller/SettingsController.ts b/packages/auth/src/Controller/SettingsController.ts new file mode 100644 index 000000000..89306835b --- /dev/null +++ b/packages/auth/src/Controller/SettingsController.ts @@ -0,0 +1,158 @@ +import { ErrorTag } from '@standardnotes/common' +import { Request, Response } from 'express' +import { inject } from 'inversify' +import { + BaseHttpController, + controller, + httpDelete, + httpGet, + httpPut, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + results, +} from 'inversify-express-utils' +import TYPES from '../Bootstrap/Types' +import { EncryptionVersion } from '../Domain/Encryption/EncryptionVersion' +import { DeleteSetting } from '../Domain/UseCase/DeleteSetting/DeleteSetting' +import { GetSetting } from '../Domain/UseCase/GetSetting/GetSetting' +import { GetSettings } from '../Domain/UseCase/GetSettings/GetSettings' +import { UpdateSetting } from '../Domain/UseCase/UpdateSetting/UpdateSetting' + +@controller('/users/:userUuid') +export class SettingsController extends BaseHttpController { + constructor( + @inject(TYPES.GetSettings) private doGetSettings: GetSettings, + @inject(TYPES.GetSetting) private doGetSetting: GetSetting, + @inject(TYPES.UpdateSetting) private doUpdateSetting: UpdateSetting, + @inject(TYPES.DeleteSetting) private doDeleteSetting: DeleteSetting, + ) { + super() + } + + @httpGet('/settings', TYPES.ApiGatewayAuthMiddleware) + async getSettings(request: Request, response: Response): Promise { + if (request.params.userUuid !== response.locals.user.uuid) { + return this.json( + { + error: { + message: 'Operation not allowed.', + }, + }, + 401, + ) + } + + const { userUuid } = request.params + const result = await this.doGetSettings.execute({ userUuid }) + + return this.json(result) + } + + @httpGet('/settings/:settingName', TYPES.ApiGatewayAuthMiddleware) + async getSetting(request: Request, response: Response): Promise { + if (request.params.userUuid !== response.locals.user.uuid) { + return this.json( + { + error: { + message: 'Operation not allowed.', + }, + }, + 401, + ) + } + + const { userUuid, settingName } = request.params + const result = await this.doGetSetting.execute({ userUuid, settingName }) + + if (result.success) { + return this.json(result) + } + + return this.json(result, 400) + } + + @httpPut('/settings', TYPES.ApiGatewayAuthMiddleware) + async updateSetting(request: Request, response: Response): Promise { + if (response.locals.readOnlyAccess) { + return this.json( + { + error: { + tag: ErrorTag.ReadOnlyAccess, + message: 'Session has read-only access.', + }, + }, + 401, + ) + } + + if (request.params.userUuid !== response.locals.user.uuid) { + return this.json( + { + error: { + message: 'Operation not allowed.', + }, + }, + 401, + ) + } + + const { name, value, serverEncryptionVersion = EncryptionVersion.Default, sensitive = false } = request.body + + const props = { + name, + unencryptedValue: value, + serverEncryptionVersion, + sensitive, + } + + const { userUuid } = request.params + const result = await this.doUpdateSetting.execute({ + userUuid, + props, + }) + + if (result.success) { + return this.json({ setting: result.setting }, result.statusCode) + } + + return this.json(result, result.statusCode) + } + + @httpDelete('/settings/:settingName', TYPES.ApiGatewayAuthMiddleware) + async deleteSetting(request: Request, response: Response): Promise { + if (response.locals.readOnlyAccess) { + return this.json( + { + error: { + tag: ErrorTag.ReadOnlyAccess, + message: 'Session has read-only access.', + }, + }, + 401, + ) + } + + if (request.params.userUuid !== response.locals.user.uuid) { + return this.json( + { + error: { + message: 'Operation not allowed.', + }, + }, + 401, + ) + } + + const { userUuid, settingName } = request.params + + const result = await this.doDeleteSetting.execute({ + userUuid, + settingName, + }) + + if (result.success) { + return this.json(result) + } + + return this.json(result, 400) + } +} diff --git a/packages/auth/src/Controller/SubscriptionInvitesController.spec.ts b/packages/auth/src/Controller/SubscriptionInvitesController.spec.ts new file mode 100644 index 000000000..a62155629 --- /dev/null +++ b/packages/auth/src/Controller/SubscriptionInvitesController.spec.ts @@ -0,0 +1,243 @@ +import 'reflect-metadata' + +import * as express from 'express' + +import { SubscriptionInvitesController } from './SubscriptionInvitesController' +import { results } from 'inversify-express-utils' +import { User } from '../Domain/User/User' +import { InviteToSharedSubscription } from '../Domain/UseCase/InviteToSharedSubscription/InviteToSharedSubscription' +import { AcceptSharedSubscriptionInvitation } from '../Domain/UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitation' +import { DeclineSharedSubscriptionInvitation } from '../Domain/UseCase/DeclineSharedSubscriptionInvitation/DeclineSharedSubscriptionInvitation' +import { CancelSharedSubscriptionInvitation } from '../Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitation' +import { RoleName } from '@standardnotes/common' +import { ListSharedSubscriptionInvitations } from '../Domain/UseCase/ListSharedSubscriptionInvitations/ListSharedSubscriptionInvitations' + +describe('SubscriptionInvitesController', () => { + let inviteToSharedSubscription: InviteToSharedSubscription + let acceptSharedSubscriptionInvitation: AcceptSharedSubscriptionInvitation + let declineSharedSubscriptionInvitation: DeclineSharedSubscriptionInvitation + let cancelSharedSubscriptionInvitation: CancelSharedSubscriptionInvitation + let listSharedSubscriptionInvitations: ListSharedSubscriptionInvitations + + let request: express.Request + let response: express.Response + let user: User + + const createController = () => + new SubscriptionInvitesController( + inviteToSharedSubscription, + acceptSharedSubscriptionInvitation, + declineSharedSubscriptionInvitation, + cancelSharedSubscriptionInvitation, + listSharedSubscriptionInvitations, + ) + + beforeEach(() => { + user = {} as jest.Mocked + user.uuid = '123' + user.email = 'test@test.te' + + inviteToSharedSubscription = {} as jest.Mocked + inviteToSharedSubscription.execute = jest.fn() + + acceptSharedSubscriptionInvitation = {} as jest.Mocked + acceptSharedSubscriptionInvitation.execute = jest.fn() + + declineSharedSubscriptionInvitation = {} as jest.Mocked + declineSharedSubscriptionInvitation.execute = jest.fn() + + cancelSharedSubscriptionInvitation = {} as jest.Mocked + cancelSharedSubscriptionInvitation.execute = jest.fn() + + listSharedSubscriptionInvitations = {} as jest.Mocked + listSharedSubscriptionInvitations.execute = jest.fn() + + request = { + headers: {}, + body: {}, + params: {}, + } as jest.Mocked + + response = { + locals: {}, + } as jest.Mocked + response.locals.user = { + email: 'test@test.te', + } + response.locals.roles = [ + { + uuid: '1-2-3', + name: RoleName.CoreUser, + }, + ] + }) + + it('should get invitations to subscription sharing', async () => { + listSharedSubscriptionInvitations.execute = jest.fn().mockReturnValue({ + invitations: [], + }) + + const httpResponse = await createController().listInvites(request, response) + const result = await httpResponse.executeAsync() + + expect(listSharedSubscriptionInvitations.execute).toHaveBeenCalledWith({ + inviterEmail: 'test@test.te', + }) + + expect(result.statusCode).toEqual(200) + }) + + it('should cancel invitation to subscription sharing', async () => { + request.params.inviteUuid = '1-2-3' + + cancelSharedSubscriptionInvitation.execute = jest.fn().mockReturnValue({ + success: true, + }) + + const httpResponse = await createController().cancelSubscriptionSharing(request, response) + const result = await httpResponse.executeAsync() + + expect(cancelSharedSubscriptionInvitation.execute).toHaveBeenCalledWith({ + sharedSubscriptionInvitationUuid: '1-2-3', + inviterEmail: 'test@test.te', + }) + + expect(result.statusCode).toEqual(200) + }) + + it('should not cancel invitation to subscription sharing if the workflow fails', async () => { + request.params.inviteUuid = '1-2-3' + + cancelSharedSubscriptionInvitation.execute = jest.fn().mockReturnValue({ + success: false, + }) + + const httpResponse = await createController().cancelSubscriptionSharing(request, response) + const result = await httpResponse.executeAsync() + + expect(result.statusCode).toEqual(400) + }) + + it('should decline invitation to subscription sharing', async () => { + request.params.inviteUuid = '1-2-3' + + declineSharedSubscriptionInvitation.execute = jest.fn().mockReturnValue({ + success: true, + }) + + const httpResponse = await createController().declineInvite(request) + const result = await httpResponse.executeAsync() + + expect(declineSharedSubscriptionInvitation.execute).toHaveBeenCalledWith({ + sharedSubscriptionInvitationUuid: '1-2-3', + }) + + expect(result.statusCode).toEqual(200) + }) + + it('should not decline invitation to subscription sharing if the workflow fails', async () => { + request.params.inviteUuid = '1-2-3' + + declineSharedSubscriptionInvitation.execute = jest.fn().mockReturnValue({ + success: false, + }) + + const httpResponse = await createController().declineInvite(request) + const result = await httpResponse.executeAsync() + + expect(declineSharedSubscriptionInvitation.execute).toHaveBeenCalledWith({ + sharedSubscriptionInvitationUuid: '1-2-3', + }) + + expect(result.statusCode).toEqual(400) + }) + + it('should accept invitation to subscription sharing', async () => { + request.params.inviteUuid = '1-2-3' + + acceptSharedSubscriptionInvitation.execute = jest.fn().mockReturnValue({ + success: true, + }) + + const httpResponse = await createController().acceptInvite(request) + const result = await httpResponse.executeAsync() + + expect(acceptSharedSubscriptionInvitation.execute).toHaveBeenCalledWith({ + sharedSubscriptionInvitationUuid: '1-2-3', + }) + + expect(result.statusCode).toEqual(200) + }) + + it('should not accept invitation to subscription sharing if the workflow fails', async () => { + request.params.inviteUuid = '1-2-3' + + acceptSharedSubscriptionInvitation.execute = jest.fn().mockReturnValue({ + success: false, + }) + + const httpResponse = await createController().acceptInvite(request) + const result = await httpResponse.executeAsync() + + expect(acceptSharedSubscriptionInvitation.execute).toHaveBeenCalledWith({ + sharedSubscriptionInvitationUuid: '1-2-3', + }) + + expect(result.statusCode).toEqual(400) + }) + + it('should invite to user subscription', async () => { + request.body.identifier = 'invitee@test.te' + response.locals.user = { + uuid: '1-2-3', + email: 'test@test.te', + } + + inviteToSharedSubscription.execute = jest.fn().mockReturnValue({ + success: true, + }) + + const httpResponse = await createController().inviteToSubscriptionSharing(request, response) + const result = await httpResponse.executeAsync() + + expect(inviteToSharedSubscription.execute).toHaveBeenCalledWith({ + inviterEmail: 'test@test.te', + inviterUuid: '1-2-3', + inviteeIdentifier: 'invitee@test.te', + inviterRoles: ['CORE_USER'], + }) + + expect(result.statusCode).toEqual(200) + }) + + it('should not invite to user subscription if the identifier is missing in request', async () => { + response.locals.user = { + uuid: '1-2-3', + email: 'test@test.te', + } + + const httpResponse = await createController().inviteToSubscriptionSharing(request, response) + const result = await httpResponse.executeAsync() + + expect(inviteToSharedSubscription.execute).not.toHaveBeenCalled() + + expect(result.statusCode).toEqual(400) + }) + + it('should not invite to user subscription if the workflow does not run', async () => { + request.body.identifier = 'invitee@test.te' + response.locals.user = { + uuid: '1-2-3', + email: 'test@test.te', + } + + inviteToSharedSubscription.execute = jest.fn().mockReturnValue({ + success: false, + }) + + const httpResponse = await createController().inviteToSubscriptionSharing(request, response) + const result = await httpResponse.executeAsync() + + expect(result.statusCode).toEqual(400) + }) +}) diff --git a/packages/auth/src/Controller/SubscriptionInvitesController.ts b/packages/auth/src/Controller/SubscriptionInvitesController.ts new file mode 100644 index 000000000..222c6decf --- /dev/null +++ b/packages/auth/src/Controller/SubscriptionInvitesController.ts @@ -0,0 +1,104 @@ +import { Role } from '@standardnotes/auth' +import { Request, Response } from 'express' +import { inject } from 'inversify' +import { + BaseHttpController, + controller, + httpDelete, + httpGet, + httpPost, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + results, +} from 'inversify-express-utils' + +import TYPES from '../Bootstrap/Types' +import { AcceptSharedSubscriptionInvitation } from '../Domain/UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitation' +import { CancelSharedSubscriptionInvitation } from '../Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitation' +import { DeclineSharedSubscriptionInvitation } from '../Domain/UseCase/DeclineSharedSubscriptionInvitation/DeclineSharedSubscriptionInvitation' +import { InviteToSharedSubscription } from '../Domain/UseCase/InviteToSharedSubscription/InviteToSharedSubscription' +import { ListSharedSubscriptionInvitations } from '../Domain/UseCase/ListSharedSubscriptionInvitations/ListSharedSubscriptionInvitations' + +@controller('/subscription-invites') +export class SubscriptionInvitesController extends BaseHttpController { + constructor( + @inject(TYPES.InviteToSharedSubscription) private inviteToSharedSubscription: InviteToSharedSubscription, + @inject(TYPES.AcceptSharedSubscriptionInvitation) + private acceptSharedSubscriptionInvitation: AcceptSharedSubscriptionInvitation, + @inject(TYPES.DeclineSharedSubscriptionInvitation) + private declineSharedSubscriptionInvitation: DeclineSharedSubscriptionInvitation, + @inject(TYPES.CancelSharedSubscriptionInvitation) + private cancelSharedSubscriptionInvitation: CancelSharedSubscriptionInvitation, + @inject(TYPES.ListSharedSubscriptionInvitations) + private listSharedSubscriptionInvitations: ListSharedSubscriptionInvitations, + ) { + super() + } + + @httpGet('/:inviteUuid/accept') + async acceptInvite(request: Request): Promise { + const result = await this.acceptSharedSubscriptionInvitation.execute({ + sharedSubscriptionInvitationUuid: request.params.inviteUuid, + }) + + if (result.success) { + return this.json(result) + } + + return this.json(result, 400) + } + + @httpGet('/:inviteUuid/decline') + async declineInvite(request: Request): Promise { + const result = await this.declineSharedSubscriptionInvitation.execute({ + sharedSubscriptionInvitationUuid: request.params.inviteUuid, + }) + + if (result.success) { + return this.json(result) + } + + return this.json(result, 400) + } + + @httpPost('/', TYPES.ApiGatewayAuthMiddleware) + async inviteToSubscriptionSharing(request: Request, response: Response): Promise { + if (!request.body.identifier) { + return this.json({ error: { message: 'Missing invitee identifier' } }, 400) + } + const result = await this.inviteToSharedSubscription.execute({ + inviterEmail: response.locals.user.email, + inviterUuid: response.locals.user.uuid, + inviteeIdentifier: request.body.identifier, + inviterRoles: response.locals.roles.map((role: Role) => role.name), + }) + + if (result.success) { + return this.json(result) + } + + return this.json(result, 400) + } + + @httpDelete('/:inviteUuid', TYPES.ApiGatewayAuthMiddleware) + async cancelSubscriptionSharing(request: Request, response: Response): Promise { + const result = await this.cancelSharedSubscriptionInvitation.execute({ + sharedSubscriptionInvitationUuid: request.params.inviteUuid, + inviterEmail: response.locals.user.email, + }) + + if (result.success) { + return this.json(result) + } + + return this.json(result, 400) + } + + @httpGet('/', TYPES.ApiGatewayAuthMiddleware) + async listInvites(_request: Request, response: Response): Promise { + const result = await this.listSharedSubscriptionInvitations.execute({ + inviterEmail: response.locals.user.email, + }) + + return this.json(result) + } +} diff --git a/packages/auth/src/Controller/SubscriptionSettingsController.spec.ts b/packages/auth/src/Controller/SubscriptionSettingsController.spec.ts new file mode 100644 index 000000000..535623822 --- /dev/null +++ b/packages/auth/src/Controller/SubscriptionSettingsController.spec.ts @@ -0,0 +1,70 @@ +import 'reflect-metadata' + +import * as express from 'express' + +import { results } from 'inversify-express-utils' +import { User } from '../Domain/User/User' +import { GetSubscriptionSetting } from '../Domain/UseCase/GetSubscriptionSetting/GetSubscriptionSetting' +import { SubscriptionSettingsController } from './SubscriptionSettingsController' + +describe('SubscriptionSettingsController', () => { + let getSubscriptionSetting: GetSubscriptionSetting + + let request: express.Request + let response: express.Response + let user: User + + const createController = () => new SubscriptionSettingsController(getSubscriptionSetting) + + beforeEach(() => { + user = {} as jest.Mocked + user.uuid = '123' + + getSubscriptionSetting = {} as jest.Mocked + getSubscriptionSetting.execute = jest.fn() + + request = { + headers: {}, + body: {}, + params: {}, + } as jest.Mocked + + response = { + locals: {}, + } as jest.Mocked + }) + + it('should get subscription setting', async () => { + request.params.userUuid = '1-2-3' + request.params.subscriptionSettingName = 'test' + response.locals.user = { + uuid: '1-2-3', + } + + getSubscriptionSetting.execute = jest.fn().mockReturnValue({ success: true }) + + const httpResponse = await createController().getSubscriptionSetting(request, response) + const result = await httpResponse.executeAsync() + + expect(getSubscriptionSetting.execute).toHaveBeenCalledWith({ userUuid: '1-2-3', subscriptionSettingName: 'test' }) + + expect(result.statusCode).toEqual(200) + }) + + it('should fail if could not get subscription setting', async () => { + request.params.userUuid = '1-2-3' + request.params.subscriptionSettingName = 'test' + response.locals.user = { + uuid: '1-2-3', + } + + getSubscriptionSetting.execute = jest.fn().mockReturnValue({ success: false }) + + const httpResponse = await createController().getSubscriptionSetting(request, response) + const result = await httpResponse.executeAsync() + + expect(getSubscriptionSetting.execute).toHaveBeenCalledWith({ userUuid: '1-2-3', subscriptionSettingName: 'test' }) + + expect(result.statusCode).toEqual(400) + }) +}) diff --git a/packages/auth/src/Controller/SubscriptionSettingsController.ts b/packages/auth/src/Controller/SubscriptionSettingsController.ts new file mode 100644 index 000000000..4b5eecbec --- /dev/null +++ b/packages/auth/src/Controller/SubscriptionSettingsController.ts @@ -0,0 +1,33 @@ +import { SubscriptionSettingName } from '@standardnotes/settings' +import { Request, Response } from 'express' +import { inject } from 'inversify' +import { + BaseHttpController, + controller, + httpGet, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + results, +} from 'inversify-express-utils' +import TYPES from '../Bootstrap/Types' +import { GetSubscriptionSetting } from '../Domain/UseCase/GetSubscriptionSetting/GetSubscriptionSetting' + +@controller('/users/:userUuid') +export class SubscriptionSettingsController extends BaseHttpController { + constructor(@inject(TYPES.GetSubscriptionSetting) private doGetSubscriptionSetting: GetSubscriptionSetting) { + super() + } + + @httpGet('/subscription-settings/:subscriptionSettingName', TYPES.ApiGatewayAuthMiddleware) + async getSubscriptionSetting(request: Request, response: Response): Promise { + const result = await this.doGetSubscriptionSetting.execute({ + userUuid: response.locals.user.uuid, + subscriptionSettingName: request.params.subscriptionSettingName as SubscriptionSettingName, + }) + + if (result.success) { + return this.json(result) + } + + return this.json(result, 400) + } +} diff --git a/packages/auth/src/Controller/SubscriptionTokensController.spec.ts b/packages/auth/src/Controller/SubscriptionTokensController.spec.ts new file mode 100644 index 000000000..53cbf1c79 --- /dev/null +++ b/packages/auth/src/Controller/SubscriptionTokensController.spec.ts @@ -0,0 +1,190 @@ +import 'reflect-metadata' + +import * as express from 'express' +import { results } from 'inversify-express-utils' + +import { SubscriptionTokensController } from './SubscriptionTokensController' +import { User } from '../Domain/User/User' +import { CreateSubscriptionToken } from '../Domain/UseCase/CreateSubscriptionToken/CreateSubscriptionToken' +import { CreateSubscriptionTokenResponse } from '../Domain/UseCase/CreateSubscriptionToken/CreateSubscriptionTokenResponse' +import { AuthenticateSubscriptionToken } from '../Domain/UseCase/AuthenticateSubscriptionToken/AuthenticateSubscriptionToken' +import { ProjectorInterface } from '../Projection/ProjectorInterface' +import { Role } from '../Domain/Role/Role' +import { SettingServiceInterface } from '../Domain/Setting/SettingServiceInterface' +import { Setting } from '../Domain/Setting/Setting' +import { CrossServiceTokenData, TokenEncoderInterface } from '@standardnotes/auth' +import { GetUserAnalyticsId } from '../Domain/UseCase/GetUserAnalyticsId/GetUserAnalyticsId' + +describe('SubscriptionTokensController', () => { + let createSubscriptionToken: CreateSubscriptionToken + let authenticateToken: AuthenticateSubscriptionToken + const jwtTTL = 60 + let userProjector: ProjectorInterface + let roleProjector: ProjectorInterface + let settingService: SettingServiceInterface + let extensionKeySetting: Setting + let tokenEncoder: TokenEncoderInterface + let getUserAnalyticsId: GetUserAnalyticsId + + let request: express.Request + let response: express.Response + let user: User + let role: Role + + const createController = () => + new SubscriptionTokensController( + createSubscriptionToken, + authenticateToken, + settingService, + userProjector, + roleProjector, + tokenEncoder, + getUserAnalyticsId, + jwtTTL, + ) + + beforeEach(() => { + user = {} as jest.Mocked + user.uuid = '123' + user.roles = Promise.resolve([role]) + + createSubscriptionToken = {} as jest.Mocked + createSubscriptionToken.execute = jest.fn().mockReturnValue({ + subscriptionToken: { + token: 'test', + }, + } as jest.Mocked) + + authenticateToken = {} as jest.Mocked + authenticateToken.execute = jest.fn().mockReturnValue({ + success: true, + user, + }) + + userProjector = {} as jest.Mocked> + userProjector.projectSimple = jest.fn().mockReturnValue({ bar: 'baz' }) + + roleProjector = {} as jest.Mocked> + roleProjector.projectSimple = jest.fn().mockReturnValue({ name: 'role1', uuid: '1-3-4' }) + + extensionKeySetting = { + name: 'EXTENSION_KEY', + value: 'abc123', + } as jest.Mocked + + settingService = {} as jest.Mocked + settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(extensionKeySetting) + + tokenEncoder = {} as jest.Mocked> + tokenEncoder.encodeExpirableToken = jest.fn().mockReturnValue('foobar') + + getUserAnalyticsId = {} as jest.Mocked + getUserAnalyticsId.execute = jest.fn().mockReturnValue({ analyticsId: 123 }) + + request = { + headers: {}, + body: {}, + params: {}, + } as jest.Mocked + + response = { + locals: {}, + } as jest.Mocked + }) + + it('should create an subscription token for authenticated user', async () => { + response.locals.user = { + uuid: '1-2-3', + } + + const httpResponse = await createController().createToken(request, response) + const result = await httpResponse.executeAsync() + + expect(createSubscriptionToken.execute).toHaveBeenCalledWith({ + userUuid: '1-2-3', + }) + + expect(result.statusCode).toEqual(200) + }) + + it('should not create an subscription token if session has read only access', async () => { + response.locals.user = { + uuid: '1-2-3', + } + response.locals.readOnlyAccess = true + + const httpResponse = await createController().createToken(request, response) + const result = await httpResponse.executeAsync() + + expect(createSubscriptionToken.execute).not.toHaveBeenCalled() + + expect(result.statusCode).toEqual(401) + }) + + it('should validate an subscription token for user', async () => { + request.params.token = 'test' + + const httpResponse = await createController().validate(request) + const result = await httpResponse.executeAsync() + + expect(authenticateToken.execute).toHaveBeenCalledWith({ + token: 'test', + }) + + const responseBody = JSON.parse(await result.content.readAsStringAsync()) + expect(responseBody.authToken).toEqual('foobar') + expect(result.statusCode).toEqual(200) + + expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith( + { + analyticsId: 123, + extensionKey: 'abc123', + roles: [ + { + name: 'role1', + uuid: '1-3-4', + }, + ], + user: { + bar: 'baz', + }, + }, + 60, + ) + }) + + it('should validate an subscription token for user without an extension key setting', async () => { + request.params.token = 'test' + + settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(null) + + const httpResponse = await createController().validate(request) + const result = await httpResponse.executeAsync() + + expect(authenticateToken.execute).toHaveBeenCalledWith({ + token: 'test', + }) + + const responseBody = JSON.parse(await result.content.readAsStringAsync()) + + expect(responseBody.authToken).toEqual('foobar') + expect(result.statusCode).toEqual(200) + }) + + it('should not validate an subscription token for user if it is invalid', async () => { + request.params.token = 'test' + + authenticateToken.execute = jest.fn().mockReturnValue({ + success: false, + }) + + const httpResponse = await createController().validate(request) + const result = await httpResponse.executeAsync() + + expect(authenticateToken.execute).toHaveBeenCalledWith({ + token: 'test', + }) + + expect(result.statusCode).toEqual(401) + }) +}) diff --git a/packages/auth/src/Controller/SubscriptionTokensController.ts b/packages/auth/src/Controller/SubscriptionTokensController.ts new file mode 100644 index 000000000..aae67194e --- /dev/null +++ b/packages/auth/src/Controller/SubscriptionTokensController.ts @@ -0,0 +1,117 @@ +import { CrossServiceTokenData, TokenEncoderInterface } from '@standardnotes/auth' +import { ErrorTag, RoleName } from '@standardnotes/common' +import { SettingName } from '@standardnotes/settings' +import { Request, Response } from 'express' +import { inject } from 'inversify' +import { + BaseHttpController, + controller, + httpPost, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + results, +} from 'inversify-express-utils' + +import TYPES from '../Bootstrap/Types' +import { Role } from '../Domain/Role/Role' +import { SettingServiceInterface } from '../Domain/Setting/SettingServiceInterface' +import { AuthenticateSubscriptionToken } from '../Domain/UseCase/AuthenticateSubscriptionToken/AuthenticateSubscriptionToken' +import { CreateSubscriptionToken } from '../Domain/UseCase/CreateSubscriptionToken/CreateSubscriptionToken' +import { GetUserAnalyticsId } from '../Domain/UseCase/GetUserAnalyticsId/GetUserAnalyticsId' +import { User } from '../Domain/User/User' +import { ProjectorInterface } from '../Projection/ProjectorInterface' + +@controller('/subscription-tokens') +export class SubscriptionTokensController extends BaseHttpController { + constructor( + @inject(TYPES.CreateSubscriptionToken) private createSubscriptionToken: CreateSubscriptionToken, + @inject(TYPES.AuthenticateSubscriptionToken) private authenticateToken: AuthenticateSubscriptionToken, + @inject(TYPES.SettingService) private settingService: SettingServiceInterface, + @inject(TYPES.UserProjector) private userProjector: ProjectorInterface, + @inject(TYPES.RoleProjector) private roleProjector: ProjectorInterface, + @inject(TYPES.CrossServiceTokenEncoder) private tokenEncoder: TokenEncoderInterface, + @inject(TYPES.GetUserAnalyticsId) private getUserAnalyticsId: GetUserAnalyticsId, + @inject(TYPES.AUTH_JWT_TTL) private jwtTTL: number, + ) { + super() + } + + @httpPost('/', TYPES.ApiGatewayAuthMiddleware) + async createToken(_request: Request, response: Response): Promise { + if (response.locals.readOnlyAccess) { + return this.json( + { + error: { + tag: ErrorTag.ReadOnlyAccess, + message: 'Session has read-only access.', + }, + }, + 401, + ) + } + + const result = await this.createSubscriptionToken.execute({ + userUuid: response.locals.user.uuid, + }) + + return this.json({ + token: result.subscriptionToken.token, + }) + } + + @httpPost('/:token/validate') + async validate(request: Request): Promise { + const authenticateTokenResponse = await this.authenticateToken.execute({ + token: request.params.token, + }) + + if (!authenticateTokenResponse.success) { + return this.json( + { + error: { + tag: 'invalid-auth', + message: 'Invalid login credentials.', + }, + }, + 401, + ) + } + + const user = authenticateTokenResponse.user as User + let extensionKey = undefined + const extensionKeySetting = await this.settingService.findSettingWithDecryptedValue({ + settingName: SettingName.ExtensionKey, + userUuid: user.uuid, + }) + if (extensionKeySetting !== null) { + extensionKey = extensionKeySetting.value as string + } + + const roles = await user.roles + + const { analyticsId } = await this.getUserAnalyticsId.execute({ userUuid: user.uuid }) + + const authTokenData: CrossServiceTokenData = { + user: await this.projectUser(user), + roles: await this.projectRoles(roles), + extensionKey, + analyticsId, + } + + const authToken = this.tokenEncoder.encodeExpirableToken(authTokenData, this.jwtTTL) + + return this.json({ authToken }) + } + + private async projectUser(user: User): Promise<{ uuid: string; email: string }> { + return <{ uuid: string; email: string }>await this.userProjector.projectSimple(user) + } + + private async projectRoles(roles: Array): Promise> { + const roleProjections = [] + for (const role of roles) { + roleProjections.push(<{ uuid: string; name: RoleName }>await this.roleProjector.projectSimple(role)) + } + + return roleProjections + } +} diff --git a/packages/auth/src/Controller/UsersController.spec.ts b/packages/auth/src/Controller/UsersController.spec.ts new file mode 100644 index 000000000..3e1cd4285 --- /dev/null +++ b/packages/auth/src/Controller/UsersController.spec.ts @@ -0,0 +1,427 @@ +import 'reflect-metadata' + +import * as express from 'express' + +import { UsersController } from './UsersController' +import { results } from 'inversify-express-utils' +import { User } from '../Domain/User/User' +import { UpdateUser } from '../Domain/UseCase/UpdateUser' +import { GetUserKeyParams } from '../Domain/UseCase/GetUserKeyParams/GetUserKeyParams' +import { DeleteAccount } from '../Domain/UseCase/DeleteAccount/DeleteAccount' +import { GetUserSubscription } from '../Domain/UseCase/GetUserSubscription/GetUserSubscription' +import { ClearLoginAttempts } from '../Domain/UseCase/ClearLoginAttempts' +import { IncreaseLoginAttempts } from '../Domain/UseCase/IncreaseLoginAttempts' +import { ChangeCredentials } from '../Domain/UseCase/ChangeCredentials/ChangeCredentials' +import { InviteToSharedSubscription } from '../Domain/UseCase/InviteToSharedSubscription/InviteToSharedSubscription' + +describe('UsersController', () => { + let updateUser: UpdateUser + let deleteAccount: DeleteAccount + let getUserKeyParams: GetUserKeyParams + let getUserSubscription: GetUserSubscription + let clearLoginAttempts: ClearLoginAttempts + let increaseLoginAttempts: IncreaseLoginAttempts + let changeCredentials: ChangeCredentials + let inviteToSharedSubscription: InviteToSharedSubscription + + let request: express.Request + let response: express.Response + let user: User + + const createController = () => + new UsersController( + updateUser, + getUserKeyParams, + deleteAccount, + getUserSubscription, + clearLoginAttempts, + increaseLoginAttempts, + changeCredentials, + ) + + beforeEach(() => { + updateUser = {} as jest.Mocked + updateUser.execute = jest.fn() + + deleteAccount = {} as jest.Mocked + deleteAccount.execute = jest.fn().mockReturnValue({ success: true, message: 'A OK', responseCode: 200 }) + + user = {} as jest.Mocked + user.uuid = '123' + user.email = 'test@test.te' + + getUserKeyParams = {} as jest.Mocked + getUserKeyParams.execute = jest.fn() + + getUserSubscription = {} as jest.Mocked + getUserSubscription.execute = jest.fn() + + changeCredentials = {} as jest.Mocked + changeCredentials.execute = jest.fn() + + clearLoginAttempts = {} as jest.Mocked + clearLoginAttempts.execute = jest.fn() + + increaseLoginAttempts = {} as jest.Mocked + increaseLoginAttempts.execute = jest.fn() + + inviteToSharedSubscription = {} as jest.Mocked + inviteToSharedSubscription.execute = jest.fn() + + request = { + headers: {}, + body: {}, + params: {}, + } as jest.Mocked + + response = { + locals: {}, + } as jest.Mocked + + response.status = jest.fn().mockReturnThis() + response.setHeader = jest.fn() + response.send = jest.fn() + }) + + it('should update user', async () => { + request.body.version = '002' + request.body.api = '20190520' + request.body.origination = 'test' + request.params.userId = '123' + request.headers['user-agent'] = 'Google Chrome' + response.locals.user = user + + updateUser.execute = jest.fn().mockReturnValue({ success: true, authResponse: { foo: 'bar' } }) + + await createController().update(request, response) + + expect(updateUser.execute).toHaveBeenCalledWith({ + apiVersion: '20190520', + kpOrigination: 'test', + updatedWithUserAgent: 'Google Chrome', + version: '002', + user: { + uuid: '123', + email: 'test@test.te', + }, + }) + + expect(response.send).toHaveBeenCalledWith({ foo: 'bar' }) + }) + + it('should not update user if session has read only access', async () => { + request.body.version = '002' + request.body.api = '20190520' + request.body.origination = 'test' + request.params.userId = '123' + request.headers['user-agent'] = 'Google Chrome' + response.locals.user = user + response.locals.readOnlyAccess = true + + const httpResponse = await createController().update(request, response) + const result = await httpResponse.executeAsync() + + expect(updateUser.execute).not.toHaveBeenCalled() + + expect(result.statusCode).toEqual(401) + }) + + it('should not update a user if the procedure fails', async () => { + request.body.version = '002' + request.body.api = '20190520' + request.body.origination = 'test' + request.params.userId = '123' + request.headers['user-agent'] = 'Google Chrome' + response.locals.user = user + + updateUser.execute = jest.fn().mockReturnValue({ success: false }) + + const httpResponse = await createController().update(request, response) + const result = await httpResponse.executeAsync() + + expect(updateUser.execute).toHaveBeenCalledWith({ + apiVersion: '20190520', + kpOrigination: 'test', + updatedWithUserAgent: 'Google Chrome', + version: '002', + user: { + uuid: '123', + email: 'test@test.te', + }, + }) + + expect(result.statusCode).toEqual(400) + expect(await result.content.readAsStringAsync()).toEqual('{"error":{"message":"Could not update user."}}') + }) + + it('should not update a user if it is not the same as logged in user', async () => { + request.body.version = '002' + request.body.api = '20190520' + request.body.origination = 'test' + request.params.userId = '234' + request.headers['user-agent'] = 'Google Chrome' + response.locals.user = user + + const httpResponse = await createController().update(request, response) + const result = await httpResponse.executeAsync() + + expect(updateUser.execute).not.toHaveBeenCalled() + + expect(result.statusCode).toEqual(401) + expect(await result.content.readAsStringAsync()).toEqual('{"error":{"message":"Operation not allowed."}}') + }) + + it('should delete user', async () => { + request.params.email = 'test@test.te' + + const httpResponse = await createController().deleteAccount(request) + const result = await httpResponse.executeAsync() + + expect(deleteAccount.execute).toHaveBeenCalledWith({ email: 'test@test.te' }) + + expect(result.statusCode).toEqual(200) + expect(await result.content.readAsStringAsync()).toEqual('{"message":"A OK"}') + }) + + it('should get user key params', async () => { + request.query = { + email: 'test@test.te', + uuid: '1-2-3', + } + + getUserKeyParams.execute = jest.fn().mockReturnValue({ foo: 'bar' }) + + const httpResponse = await createController().keyParams(request) + const result = await httpResponse.executeAsync() + + expect(getUserKeyParams.execute).toHaveBeenCalledWith({ + email: 'test@test.te', + userUuid: '1-2-3', + authenticated: false, + }) + + expect(result.statusCode).toEqual(200) + }) + + it('should get authenticated user key params', async () => { + request.query = { + email: 'test@test.te', + uuid: '1-2-3', + authenticated: 'true', + } + + getUserKeyParams.execute = jest.fn().mockReturnValue({ foo: 'bar' }) + + const httpResponse = await createController().keyParams(request) + const result = await httpResponse.executeAsync() + + expect(getUserKeyParams.execute).toHaveBeenCalledWith({ + email: 'test@test.te', + userUuid: '1-2-3', + authenticated: true, + }) + + expect(result.statusCode).toEqual(200) + }) + + it('should not get user key params if email and user uuid is missing', async () => { + request.query = {} + + getUserKeyParams.execute = jest.fn().mockReturnValue({ foo: 'bar' }) + + const httpResponse = await createController().keyParams(request) + const result = await httpResponse.executeAsync() + + expect(getUserKeyParams.execute).not.toHaveBeenCalled() + + expect(result.statusCode).toEqual(400) + }) + + it('should get user subscription', async () => { + request.params.userUuid = '1-2-3' + response.locals.user = { + uuid: '1-2-3', + } + + getUserSubscription.execute = jest.fn().mockReturnValue({ + success: true, + }) + + const httpResponse = await createController().getSubscription(request, response) + const result = await httpResponse.executeAsync() + + expect(getUserSubscription.execute).toHaveBeenCalledWith({ + userUuid: '1-2-3', + }) + + expect(result.statusCode).toEqual(200) + }) + + it('should not get user subscription if the user with provided uuid does not exist', async () => { + request.params.userUuid = '1-2-3' + response.locals.user = { + uuid: '1-2-3', + } + + getUserSubscription.execute = jest.fn().mockReturnValue({ + success: false, + }) + + const httpResponse = await createController().getSubscription(request, response) + const result = await httpResponse.executeAsync() + + expect(getUserSubscription.execute).toHaveBeenCalledWith({ userUuid: '1-2-3' }) + + expect(result.statusCode).toEqual(400) + }) + + it('should not get user subscription if not allowed', async () => { + request.params.userUuid = '1-2-3' + response.locals.user = { + uuid: '2-3-4', + } + + getUserSubscription.execute = jest.fn() + + const httpResponse = await createController().getSubscription(request, response) + const result = await httpResponse.executeAsync() + + expect(getUserSubscription.execute).not.toHaveBeenCalled() + + expect(result.statusCode).toEqual(401) + }) + + it('should change a password', async () => { + request.body.version = '004' + request.body.api = '20190520' + request.body.current_password = 'test123' + request.body.new_password = 'test234' + request.body.pw_nonce = 'asdzxc' + request.body.origination = 'change-password' + request.body.created = '123' + request.headers['user-agent'] = 'Google Chrome' + response.locals.user = user + + changeCredentials.execute = jest.fn().mockReturnValue({ success: true, authResponse: { foo: 'bar' } }) + + await createController().changeCredentials(request, response) + + expect(changeCredentials.execute).toHaveBeenCalledWith({ + apiVersion: '20190520', + updatedWithUserAgent: 'Google Chrome', + currentPassword: 'test123', + newPassword: 'test234', + kpCreated: '123', + kpOrigination: 'change-password', + pwNonce: 'asdzxc', + protocolVersion: '004', + user: { + uuid: '123', + email: 'test@test.te', + }, + }) + + expect(clearLoginAttempts.execute).toHaveBeenCalled() + + expect(response.send).toHaveBeenCalledWith({ foo: 'bar' }) + }) + + it('should not change a password if session has read only access', async () => { + request.body.version = '004' + request.body.api = '20190520' + request.body.current_password = 'test123' + request.body.new_password = 'test234' + request.body.pw_nonce = 'asdzxc' + request.body.origination = 'change-password' + request.body.created = '123' + request.headers['user-agent'] = 'Google Chrome' + response.locals.user = user + response.locals.readOnlyAccess = true + + const httpResponse = await createController().changeCredentials(request, response) + const result = await httpResponse.executeAsync() + + expect(changeCredentials.execute).not.toHaveBeenCalled() + + expect(clearLoginAttempts.execute).not.toHaveBeenCalled() + + expect(result.statusCode).toEqual(401) + }) + + it('should indicate if changing a password fails', async () => { + request.body.version = '004' + request.body.api = '20190520' + request.body.current_password = 'test123' + request.body.new_password = 'test234' + request.body.pw_nonce = 'asdzxc' + request.headers['user-agent'] = 'Google Chrome' + response.locals.user = user + + changeCredentials.execute = jest.fn().mockReturnValue({ success: false, errorMessage: 'Something bad happened' }) + + const httpResponse = await createController().changeCredentials(request, response) + const result = await httpResponse.executeAsync() + + expect(increaseLoginAttempts.execute).toHaveBeenCalled() + + expect(result.statusCode).toEqual(401) + expect(await result.content.readAsStringAsync()).toEqual('{"error":{"message":"Something bad happened"}}') + }) + + it('should not change a password if current password is missing', async () => { + request.body.version = '004' + request.body.api = '20190520' + request.body.new_password = 'test234' + request.body.pw_nonce = 'asdzxc' + request.headers['user-agent'] = 'Google Chrome' + response.locals.user = user + + const httpResponse = await createController().changeCredentials(request, response) + const result = await httpResponse.executeAsync() + + expect(changeCredentials.execute).not.toHaveBeenCalled() + + expect(result.statusCode).toEqual(400) + expect(await result.content.readAsStringAsync()).toEqual( + '{"error":{"message":"Your current password is required to change your password. Please update your application if you do not see this option."}}', + ) + }) + + it('should not change a password if new password is missing', async () => { + request.body.version = '004' + request.body.api = '20190520' + request.body.current_password = 'test123' + request.body.pw_nonce = 'asdzxc' + request.headers['user-agent'] = 'Google Chrome' + response.locals.user = user + + const httpResponse = await createController().changeCredentials(request, response) + const result = await httpResponse.executeAsync() + + expect(changeCredentials.execute).not.toHaveBeenCalled() + + expect(result.statusCode).toEqual(400) + expect(await result.content.readAsStringAsync()).toEqual( + '{"error":{"message":"Your new password is required to change your password. Please try again."}}', + ) + }) + + it('should not change a password if password nonce is missing', async () => { + request.body.version = '004' + request.body.api = '20190520' + request.body.current_password = 'test123' + request.body.new_password = 'test234' + request.headers['user-agent'] = 'Google Chrome' + response.locals.user = user + + const httpResponse = await createController().changeCredentials(request, response) + const result = await httpResponse.executeAsync() + + expect(changeCredentials.execute).not.toHaveBeenCalled() + + expect(result.statusCode).toEqual(400) + expect(await result.content.readAsStringAsync()).toEqual( + '{"error":{"message":"The change password request is missing new auth parameters. Please try again."}}', + ) + }) +}) diff --git a/packages/auth/src/Controller/UsersController.ts b/packages/auth/src/Controller/UsersController.ts new file mode 100644 index 000000000..6af2d5a0a --- /dev/null +++ b/packages/auth/src/Controller/UsersController.ts @@ -0,0 +1,231 @@ +import { Request, Response } from 'express' +import { inject } from 'inversify' +import { + BaseHttpController, + controller, + httpDelete, + httpGet, + httpPatch, + httpPut, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + results, +} from 'inversify-express-utils' +import TYPES from '../Bootstrap/Types' +import { DeleteAccount } from '../Domain/UseCase/DeleteAccount/DeleteAccount' +import { GetUserKeyParams } from '../Domain/UseCase/GetUserKeyParams/GetUserKeyParams' +import { UpdateUser } from '../Domain/UseCase/UpdateUser' +import { GetUserSubscription } from '../Domain/UseCase/GetUserSubscription/GetUserSubscription' +import { ClearLoginAttempts } from '../Domain/UseCase/ClearLoginAttempts' +import { IncreaseLoginAttempts } from '../Domain/UseCase/IncreaseLoginAttempts' +import { ChangeCredentials } from '../Domain/UseCase/ChangeCredentials/ChangeCredentials' +import { ErrorTag } from '@standardnotes/common' + +@controller('/users') +export class UsersController extends BaseHttpController { + constructor( + @inject(TYPES.UpdateUser) private updateUser: UpdateUser, + @inject(TYPES.GetUserKeyParams) private getUserKeyParams: GetUserKeyParams, + @inject(TYPES.DeleteAccount) private doDeleteAccount: DeleteAccount, + @inject(TYPES.GetUserSubscription) private doGetUserSubscription: GetUserSubscription, + @inject(TYPES.ClearLoginAttempts) private clearLoginAttempts: ClearLoginAttempts, + @inject(TYPES.IncreaseLoginAttempts) private increaseLoginAttempts: IncreaseLoginAttempts, + @inject(TYPES.ChangeCredentials) private changeCredentialsUseCase: ChangeCredentials, + ) { + super() + } + + @httpPatch('/:userId', TYPES.ApiGatewayAuthMiddleware) + async update(request: Request, response: Response): Promise { + if (response.locals.readOnlyAccess) { + return this.json( + { + error: { + tag: ErrorTag.ReadOnlyAccess, + message: 'Session has read-only access.', + }, + }, + 401, + ) + } + + if (request.params.userId !== response.locals.user.uuid) { + return this.json( + { + error: { + message: 'Operation not allowed.', + }, + }, + 401, + ) + } + + const updateResult = await this.updateUser.execute({ + user: response.locals.user, + updatedWithUserAgent: request.headers['user-agent'], + apiVersion: request.body.api, + pwFunc: request.body.pw_func, + pwAlg: request.body.pw_alg, + pwCost: request.body.pw_cost, + pwKeySize: request.body.pw_key_size, + pwNonce: request.body.pw_nonce, + pwSalt: request.body.pw_salt, + kpOrigination: request.body.origination, + kpCreated: request.body.created, + version: request.body.version, + }) + + if (updateResult.success) { + response.setHeader('x-invalidate-cache', response.locals.user.uuid) + response.send(updateResult.authResponse) + + return + } + + return this.json( + { + error: { + message: 'Could not update user.', + }, + }, + 400, + ) + } + + @httpGet('/params') + async keyParams(request: Request): Promise { + const email = 'email' in request.query ? request.query.email : undefined + const userUuid = 'uuid' in request.query ? request.query.uuid : undefined + + if (!email && !userUuid) { + return this.json( + { + error: { + message: 'Missing mandatory request query parameters.', + }, + }, + 400, + ) + } + + const result = await this.getUserKeyParams.execute({ + email, + userUuid, + authenticated: request.query.authenticated === 'true', + }) + + return this.json(result.keyParams) + } + + @httpDelete('/:email') + async deleteAccount(request: Request): Promise { + const result = await this.doDeleteAccount.execute({ + email: request.params.email, + }) + + return this.json({ message: result.message }, result.responseCode) + } + + @httpGet('/:userUuid/subscription', TYPES.ApiGatewayAuthMiddleware) + async getSubscription(request: Request, response: Response): Promise { + if (request.params.userUuid !== response.locals.user.uuid) { + return this.json( + { + error: { + message: 'Operation not allowed.', + }, + }, + 401, + ) + } + + const result = await this.doGetUserSubscription.execute({ + userUuid: request.params.userUuid, + }) + + if (result.success) { + return this.json(result) + } + + return this.json(result, 400) + } + + @httpPut('/:userId/attributes/credentials', TYPES.AuthMiddleware) + async changeCredentials(request: Request, response: Response): Promise { + if (response.locals.readOnlyAccess) { + return this.json( + { + error: { + tag: ErrorTag.ReadOnlyAccess, + message: 'Session has read-only access.', + }, + }, + 401, + ) + } + + if (!request.body.current_password) { + return this.json( + { + error: { + message: + 'Your current password is required to change your password. Please update your application if you do not see this option.', + }, + }, + 400, + ) + } + + if (!request.body.new_password) { + return this.json( + { + error: { + message: 'Your new password is required to change your password. Please try again.', + }, + }, + 400, + ) + } + + if (!request.body.pw_nonce) { + return this.json( + { + error: { + message: 'The change password request is missing new auth parameters. Please try again.', + }, + }, + 400, + ) + } + + const changeCredentialsResult = await this.changeCredentialsUseCase.execute({ + user: response.locals.user, + apiVersion: request.body.api, + currentPassword: request.body.current_password, + newPassword: request.body.new_password, + newEmail: request.body.new_email, + pwNonce: request.body.pw_nonce, + kpCreated: request.body.created, + kpOrigination: request.body.origination, + updatedWithUserAgent: request.headers['user-agent'], + protocolVersion: request.body.version, + }) + + if (!changeCredentialsResult.success) { + await this.increaseLoginAttempts.execute({ email: response.locals.user.email }) + + return this.json( + { + error: { + message: changeCredentialsResult.errorMessage, + }, + }, + 401, + ) + } + + await this.clearLoginAttempts.execute({ email: response.locals.user.email }) + + response.setHeader('x-invalidate-cache', response.locals.user.uuid) + response.send(changeCredentialsResult.authResponse) + } +} diff --git a/packages/auth/src/Controller/ValetTokenController.spec.ts b/packages/auth/src/Controller/ValetTokenController.spec.ts new file mode 100644 index 000000000..8695a0eeb --- /dev/null +++ b/packages/auth/src/Controller/ValetTokenController.spec.ts @@ -0,0 +1,98 @@ +import 'reflect-metadata' + +import { Request, Response } from 'express' +import { results } from 'inversify-express-utils' +import { ValetTokenController } from './ValetTokenController' +import { CreateValetToken } from '../Domain/UseCase/CreateValetToken/CreateValetToken' + +describe('ValetTokenController', () => { + let createValetToken: CreateValetToken + let request: Request + let response: Response + + const createController = () => new ValetTokenController(createValetToken) + + beforeEach(() => { + createValetToken = {} as jest.Mocked + createValetToken.execute = jest.fn().mockReturnValue({ success: true, valetToken: 'foobar' }) + + request = { + body: { + operation: 'write', + resources: ['1-2-3/2-3-4'], + }, + } as jest.Mocked + + response = { + locals: {}, + } as jest.Mocked + + response.locals.user = { uuid: '1-2-3' } + }) + + it('should create a valet token', async () => { + const httpResponse = await createController().create(request, response) + const result = await httpResponse.executeAsync() + + expect(createValetToken.execute).toHaveBeenCalledWith({ + operation: 'write', + userUuid: '1-2-3', + resources: ['1-2-3/2-3-4'], + }) + expect(await result.content.readAsStringAsync()).toEqual('{"success":true,"valetToken":"foobar"}') + }) + + it('should create a read valet token for read only access session', async () => { + response.locals.readOnlyAccess = true + request.body.operation = 'read' + + const httpResponse = await createController().create(request, response) + const result = await httpResponse.executeAsync() + + expect(createValetToken.execute).toHaveBeenCalledWith({ + operation: 'read', + userUuid: '1-2-3', + resources: ['1-2-3/2-3-4'], + }) + expect(await result.content.readAsStringAsync()).toEqual('{"success":true,"valetToken":"foobar"}') + }) + + it('should not create a write valet token if session has read only access', async () => { + response.locals.readOnlyAccess = true + request.body.operation = 'write' + + const httpResponse = await createController().create(request, response) + const result = await httpResponse.executeAsync() + + expect(createValetToken.execute).not.toHaveBeenCalled() + + expect(result.statusCode).toEqual(401) + }) + + it('should not create a delete valet token if session has read only access', async () => { + response.locals.readOnlyAccess = true + request.body.operation = 'delete' + + const httpResponse = await createController().create(request, response) + const result = await httpResponse.executeAsync() + + expect(createValetToken.execute).not.toHaveBeenCalled() + + expect(result.statusCode).toEqual(401) + }) + + it('should not create a valet token if use case fails', async () => { + createValetToken.execute = jest.fn().mockReturnValue({ success: false }) + + const httpResponse = await createController().create(request, response) + const result = await httpResponse.executeAsync() + + expect(createValetToken.execute).toHaveBeenCalledWith({ + operation: 'write', + userUuid: '1-2-3', + resources: ['1-2-3/2-3-4'], + }) + + expect(await result.content.readAsStringAsync()).toEqual('{"success":false}') + }) +}) diff --git a/packages/auth/src/Controller/ValetTokenController.ts b/packages/auth/src/Controller/ValetTokenController.ts new file mode 100644 index 000000000..98f14ce53 --- /dev/null +++ b/packages/auth/src/Controller/ValetTokenController.ts @@ -0,0 +1,50 @@ +import { inject } from 'inversify' +import { Request, Response } from 'express' +import { + BaseHttpController, + controller, + httpPost, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + results, +} from 'inversify-express-utils' +import { CreateValetTokenPayload } from '@standardnotes/responses' + +import TYPES from '../Bootstrap/Types' +import { CreateValetToken } from '../Domain/UseCase/CreateValetToken/CreateValetToken' +import { ErrorTag } from '@standardnotes/common' + +@controller('/valet-tokens', TYPES.ApiGatewayAuthMiddleware) +export class ValetTokenController extends BaseHttpController { + constructor(@inject(TYPES.CreateValetToken) private createValetKey: CreateValetToken) { + super() + } + + @httpPost('/') + public async create(request: Request, response: Response): Promise { + const payload: CreateValetTokenPayload = request.body + + if (response.locals.readOnlyAccess && payload.operation !== 'read') { + return this.json( + { + error: { + tag: ErrorTag.ReadOnlyAccess, + message: 'Session has read-only access.', + }, + }, + 401, + ) + } + + const createValetKeyResponse = await this.createValetKey.execute({ + userUuid: response.locals.user.uuid, + operation: payload.operation, + resources: payload.resources, + }) + + if (!createValetKeyResponse.success) { + return this.json(createValetKeyResponse, 403) + } + + return this.json(createValetKeyResponse) + } +} diff --git a/packages/auth/src/Controller/WebSocketsController.spec.ts b/packages/auth/src/Controller/WebSocketsController.spec.ts new file mode 100644 index 000000000..09d9f54a8 --- /dev/null +++ b/packages/auth/src/Controller/WebSocketsController.spec.ts @@ -0,0 +1,65 @@ +import 'reflect-metadata' + +import * as express from 'express' +import { results } from 'inversify-express-utils' + +import { AddWebSocketsConnection } from '../Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnection' + +import { WebSocketsController } from './WebSocketsController' +import { RemoveWebSocketsConnection } from '../Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnection' + +describe('WebSocketsController', () => { + let addWebSocketsConnection: AddWebSocketsConnection + let removeWebSocketsConnection: RemoveWebSocketsConnection + let request: express.Request + let response: express.Response + + const createController = () => new WebSocketsController(addWebSocketsConnection, removeWebSocketsConnection) + + beforeEach(() => { + addWebSocketsConnection = {} as jest.Mocked + addWebSocketsConnection.execute = jest.fn() + + removeWebSocketsConnection = {} as jest.Mocked + removeWebSocketsConnection.execute = jest.fn() + + request = { + body: { + userUuid: '1-2-3', + }, + params: {}, + headers: {}, + } as jest.Mocked + request.params.connectionId = '2-3-4' + + response = { + locals: {}, + } as jest.Mocked + response.locals.user = { + uuid: '1-2-3', + } + }) + + it('should persist an established web sockets connection', async () => { + const httpResponse = await createController().storeWebSocketsConnection(request, response) + + expect(httpResponse).toBeInstanceOf(results.JsonResult) + expect((httpResponse).statusCode).toEqual(200) + + expect(addWebSocketsConnection.execute).toHaveBeenCalledWith({ + userUuid: '1-2-3', + connectionId: '2-3-4', + }) + }) + + it('should remove a disconnected web sockets connection', async () => { + const httpResponse = await createController().deleteWebSocketsConnection(request) + + expect(httpResponse).toBeInstanceOf(results.JsonResult) + expect((httpResponse).statusCode).toEqual(200) + + expect(removeWebSocketsConnection.execute).toHaveBeenCalledWith({ + connectionId: '2-3-4', + }) + }) +}) diff --git a/packages/auth/src/Controller/WebSocketsController.ts b/packages/auth/src/Controller/WebSocketsController.ts new file mode 100644 index 000000000..13540a245 --- /dev/null +++ b/packages/auth/src/Controller/WebSocketsController.ts @@ -0,0 +1,45 @@ +import { Request, Response } from 'express' +import { inject } from 'inversify' +import { + BaseHttpController, + controller, + httpDelete, + httpPost, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + results, +} from 'inversify-express-utils' +import TYPES from '../Bootstrap/Types' +import { AddWebSocketsConnection } from '../Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnection' +import { RemoveWebSocketsConnection } from '../Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnection' + +@controller('/sockets') +export class WebSocketsController extends BaseHttpController { + constructor( + @inject(TYPES.AddWebSocketsConnection) private addWebSocketsConnection: AddWebSocketsConnection, + @inject(TYPES.RemoveWebSocketsConnection) private removeWebSocketsConnection: RemoveWebSocketsConnection, + ) { + super() + } + + @httpPost('/:connectionId', TYPES.ApiGatewayAuthMiddleware) + async storeWebSocketsConnection( + request: Request, + response: Response, + ): Promise { + await this.addWebSocketsConnection.execute({ + userUuid: response.locals.user.uuid, + connectionId: request.params.connectionId, + }) + + return this.json({ success: true }) + } + + @httpDelete('/:connectionId') + async deleteWebSocketsConnection( + request: Request, + ): Promise { + await this.removeWebSocketsConnection.execute({ connectionId: request.params.connectionId }) + + return this.json({ success: true }) + } +} diff --git a/packages/auth/src/Domain/Analytics/AnalyticsEntity.ts b/packages/auth/src/Domain/Analytics/AnalyticsEntity.ts new file mode 100644 index 000000000..638a9d501 --- /dev/null +++ b/packages/auth/src/Domain/Analytics/AnalyticsEntity.ts @@ -0,0 +1,19 @@ +import { Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm' +import { User } from '../User/User' + +@Entity({ name: 'analytics_entities' }) +export class AnalyticsEntity { + @PrimaryGeneratedColumn() + declare id: number + + @OneToOne( + /* istanbul ignore next */ + () => User, + /* istanbul ignore next */ + (user) => user.analyticsEntity, + /* istanbul ignore next */ + { onDelete: 'CASCADE', nullable: false, lazy: true, eager: false }, + ) + @JoinColumn({ name: 'user_uuid', referencedColumnName: 'uuid' }) + declare user: Promise +} diff --git a/packages/auth/src/Domain/Analytics/AnalyticsEntityRepositoryInterface.ts b/packages/auth/src/Domain/Analytics/AnalyticsEntityRepositoryInterface.ts new file mode 100644 index 000000000..64e6efdd4 --- /dev/null +++ b/packages/auth/src/Domain/Analytics/AnalyticsEntityRepositoryInterface.ts @@ -0,0 +1,7 @@ +import { Uuid } from '@standardnotes/common' +import { AnalyticsEntity } from './AnalyticsEntity' + +export interface AnalyticsEntityRepositoryInterface { + save(analyticsEntity: AnalyticsEntity): Promise + findOneByUserUuid(userUuid: Uuid): Promise +} diff --git a/packages/auth/src/Domain/Api/ApiVersion.ts b/packages/auth/src/Domain/Api/ApiVersion.ts new file mode 100644 index 000000000..e664a2828 --- /dev/null +++ b/packages/auth/src/Domain/Api/ApiVersion.ts @@ -0,0 +1,5 @@ +export enum ApiVersion { + v20161215 = '20161215', + v20190520 = '20190520', + v20200115 = '20200115', +} diff --git a/packages/auth/src/Domain/Auth/AuthResponse.ts b/packages/auth/src/Domain/Auth/AuthResponse.ts new file mode 100644 index 000000000..192be9e13 --- /dev/null +++ b/packages/auth/src/Domain/Auth/AuthResponse.ts @@ -0,0 +1,8 @@ +import { Uuid } from '@standardnotes/common' + +export interface AuthResponse { + user: { + uuid: Uuid + email: string + } +} diff --git a/packages/auth/src/Domain/Auth/AuthResponse20161215.ts b/packages/auth/src/Domain/Auth/AuthResponse20161215.ts new file mode 100644 index 000000000..14a0a58ed --- /dev/null +++ b/packages/auth/src/Domain/Auth/AuthResponse20161215.ts @@ -0,0 +1,5 @@ +import { AuthResponse } from './AuthResponse' + +export interface AuthResponse20161215 extends AuthResponse { + token: string +} diff --git a/packages/auth/src/Domain/Auth/AuthResponse20200115.ts b/packages/auth/src/Domain/Auth/AuthResponse20200115.ts new file mode 100644 index 000000000..3efdd1a78 --- /dev/null +++ b/packages/auth/src/Domain/Auth/AuthResponse20200115.ts @@ -0,0 +1,8 @@ +import { KeyParamsData, SessionBody } from '@standardnotes/responses' + +import { AuthResponse } from './AuthResponse' + +export interface AuthResponse20200115 extends AuthResponse { + session: SessionBody + key_params: KeyParamsData +} diff --git a/packages/auth/src/Domain/Auth/AuthResponseFactory20161215.spec.ts b/packages/auth/src/Domain/Auth/AuthResponseFactory20161215.spec.ts new file mode 100644 index 000000000..1488132a1 --- /dev/null +++ b/packages/auth/src/Domain/Auth/AuthResponseFactory20161215.spec.ts @@ -0,0 +1,46 @@ +import 'reflect-metadata' + +import { SessionTokenData, TokenEncoderInterface } from '@standardnotes/auth' +import { Logger } from 'winston' + +import { ProjectorInterface } from '../../Projection/ProjectorInterface' +import { User } from '../User/User' +import { AuthResponseFactory20161215 } from './AuthResponseFactory20161215' + +describe('AuthResponseFactory20161215', () => { + let userProjector: ProjectorInterface + let tokenEncoder: TokenEncoderInterface + let user: User + let logger: Logger + + const createFactory = () => new AuthResponseFactory20161215(userProjector, tokenEncoder, logger) + + beforeEach(() => { + logger = {} as jest.Mocked + logger.debug = jest.fn() + + userProjector = {} as jest.Mocked> + userProjector.projectSimple = jest.fn().mockReturnValue({ foo: 'bar' }) + + user = {} as jest.Mocked + user.encryptedPassword = 'test123' + + tokenEncoder = {} as jest.Mocked> + tokenEncoder.encodeToken = jest.fn().mockReturnValue('foobar') + }) + + it('should create a 20161215 auth response', async () => { + const response = await createFactory().createResponse({ + user, + apiVersion: '20161215', + userAgent: 'Google Chrome', + ephemeralSession: false, + readonlyAccess: false, + }) + + expect(response).toEqual({ + user: { foo: 'bar' }, + token: 'foobar', + }) + }) +}) diff --git a/packages/auth/src/Domain/Auth/AuthResponseFactory20161215.ts b/packages/auth/src/Domain/Auth/AuthResponseFactory20161215.ts new file mode 100644 index 000000000..109e2d932 --- /dev/null +++ b/packages/auth/src/Domain/Auth/AuthResponseFactory20161215.ts @@ -0,0 +1,46 @@ +import { SessionTokenData, TokenEncoderInterface } from '@standardnotes/auth' +import { Uuid } from '@standardnotes/common' +import * as crypto from 'crypto' + +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' +import TYPES from '../../Bootstrap/Types' +import { ProjectorInterface } from '../../Projection/ProjectorInterface' + +import { User } from '../User/User' +import { AuthResponse20161215 } from './AuthResponse20161215' +import { AuthResponse20200115 } from './AuthResponse20200115' +import { AuthResponseFactoryInterface } from './AuthResponseFactoryInterface' + +@injectable() +export class AuthResponseFactory20161215 implements AuthResponseFactoryInterface { + constructor( + @inject(TYPES.UserProjector) protected userProjector: ProjectorInterface, + @inject(TYPES.SessionTokenEncoder) protected tokenEncoder: TokenEncoderInterface, + @inject(TYPES.Logger) protected logger: Logger, + ) {} + + async createResponse(dto: { + user: User + apiVersion: string + userAgent: string + ephemeralSession: boolean + readonlyAccess: boolean + }): Promise { + this.logger.debug(`Creating JWT auth response for user ${dto.user.uuid}`) + + const data: SessionTokenData = { + user_uuid: dto.user.uuid, + pw_hash: crypto.createHash('sha256').update(dto.user.encryptedPassword).digest('hex'), + } + + const token = this.tokenEncoder.encodeToken(data) + + this.logger.debug(`Created JWT token for user ${dto.user.uuid}: ${token}`) + + return { + user: this.userProjector.projectSimple(dto.user) as { uuid: Uuid; email: string }, + token, + } + } +} diff --git a/packages/auth/src/Domain/Auth/AuthResponseFactory20190520.spec.ts b/packages/auth/src/Domain/Auth/AuthResponseFactory20190520.spec.ts new file mode 100644 index 000000000..b7a9ab7dc --- /dev/null +++ b/packages/auth/src/Domain/Auth/AuthResponseFactory20190520.spec.ts @@ -0,0 +1,45 @@ +import { SessionTokenData, TokenEncoderInterface } from '@standardnotes/auth' +import 'reflect-metadata' +import { Logger } from 'winston' + +import { ProjectorInterface } from '../../Projection/ProjectorInterface' +import { User } from '../User/User' +import { AuthResponseFactory20190520 } from './AuthResponseFactory20190520' + +describe('AuthResponseFactory20190520', () => { + let userProjector: ProjectorInterface + let user: User + let logger: Logger + let tokenEncoder: TokenEncoderInterface + + const createFactory = () => new AuthResponseFactory20190520(userProjector, tokenEncoder, logger) + + beforeEach(() => { + logger = {} as jest.Mocked + logger.debug = jest.fn() + + userProjector = {} as jest.Mocked> + userProjector.projectSimple = jest.fn().mockReturnValue({ foo: 'bar' }) + + user = {} as jest.Mocked + user.encryptedPassword = 'test123' + + tokenEncoder = {} as jest.Mocked> + tokenEncoder.encodeToken = jest.fn().mockReturnValue('foobar') + }) + + it('should create a 20161215 auth response', async () => { + const response = await createFactory().createResponse({ + user, + apiVersion: '20161215', + userAgent: 'Google Chrome', + ephemeralSession: false, + readonlyAccess: false, + }) + + expect(response).toEqual({ + user: { foo: 'bar' }, + token: 'foobar', + }) + }) +}) diff --git a/packages/auth/src/Domain/Auth/AuthResponseFactory20190520.ts b/packages/auth/src/Domain/Auth/AuthResponseFactory20190520.ts new file mode 100644 index 000000000..1425c3356 --- /dev/null +++ b/packages/auth/src/Domain/Auth/AuthResponseFactory20190520.ts @@ -0,0 +1,5 @@ +import { injectable } from 'inversify' +import { AuthResponseFactory20161215 } from './AuthResponseFactory20161215' + +@injectable() +export class AuthResponseFactory20190520 extends AuthResponseFactory20161215 {} diff --git a/packages/auth/src/Domain/Auth/AuthResponseFactory20200115.spec.ts b/packages/auth/src/Domain/Auth/AuthResponseFactory20200115.spec.ts new file mode 100644 index 000000000..fd4872bd0 --- /dev/null +++ b/packages/auth/src/Domain/Auth/AuthResponseFactory20200115.spec.ts @@ -0,0 +1,165 @@ +import 'reflect-metadata' + +import { SessionTokenData, TokenEncoderInterface } from '@standardnotes/auth' +import { SessionBody } from '@standardnotes/responses' +import { Logger } from 'winston' + +import { ProjectorInterface } from '../../Projection/ProjectorInterface' +import { SessionServiceInterface } from '../Session/SessionServiceInterface' +import { KeyParamsFactoryInterface } from '../User/KeyParamsFactoryInterface' +import { User } from '../User/User' +import { AuthResponseFactory20200115 } from './AuthResponseFactory20200115' + +describe('AuthResponseFactory20200115', () => { + let sessionService: SessionServiceInterface + let keyParamsFactory: KeyParamsFactoryInterface + let userProjector: ProjectorInterface + let user: User + let sessionPayload: SessionBody + let logger: Logger + let tokenEncoder: TokenEncoderInterface + + const createFactory = () => + new AuthResponseFactory20200115(sessionService, keyParamsFactory, userProjector, tokenEncoder, logger) + + beforeEach(() => { + logger = {} as jest.Mocked + logger.debug = jest.fn() + + sessionPayload = { + access_token: 'access_token', + refresh_token: 'refresh_token', + access_expiration: 123, + refresh_expiration: 234, + readonly_access: false, + } + + sessionService = {} as jest.Mocked + sessionService.createNewSessionForUser = jest.fn().mockReturnValue(sessionPayload) + sessionService.createNewEphemeralSessionForUser = jest.fn().mockReturnValue(sessionPayload) + + keyParamsFactory = {} as jest.Mocked + keyParamsFactory.create = jest.fn().mockReturnValue({ + key1: 'value1', + key2: 'value2', + }) + + userProjector = {} as jest.Mocked> + userProjector.projectSimple = jest.fn().mockReturnValue({ foo: 'bar' }) + + user = {} as jest.Mocked + user.encryptedPassword = 'test123' + + tokenEncoder = {} as jest.Mocked> + tokenEncoder.encodeToken = jest.fn().mockReturnValue('foobar') + }) + + it('should create a 20161215 auth response if user does not support sessions', async () => { + user.supportsSessions = jest.fn().mockReturnValue(false) + + const response = await createFactory().createResponse({ + user, + apiVersion: '20161215', + userAgent: 'Google Chrome', + ephemeralSession: false, + readonlyAccess: false, + }) + + expect(response).toEqual({ + user: { foo: 'bar' }, + token: expect.any(String), + }) + }) + + it('should create a 20200115 auth response', async () => { + user.supportsSessions = jest.fn().mockReturnValue(true) + + const response = await createFactory().createResponse({ + user, + apiVersion: '20200115', + userAgent: 'Google Chrome', + ephemeralSession: false, + readonlyAccess: false, + }) + + expect(response).toEqual({ + key_params: { + key1: 'value1', + key2: 'value2', + }, + session: { + access_token: 'access_token', + refresh_token: 'refresh_token', + access_expiration: 123, + refresh_expiration: 234, + readonly_access: false, + }, + user: { + foo: 'bar', + }, + }) + }) + + it('should create a 20200115 auth response with an ephemeral session', async () => { + user.supportsSessions = jest.fn().mockReturnValue(true) + + const response = await createFactory().createResponse({ + user, + apiVersion: '20200115', + userAgent: 'Google Chrome', + ephemeralSession: true, + readonlyAccess: false, + }) + + expect(response).toEqual({ + key_params: { + key1: 'value1', + key2: 'value2', + }, + session: { + access_token: 'access_token', + refresh_token: 'refresh_token', + access_expiration: 123, + refresh_expiration: 234, + readonly_access: false, + }, + user: { + foo: 'bar', + }, + }) + }) + + it('should create a 20200115 auth response with a read only session', async () => { + user.supportsSessions = jest.fn().mockReturnValue(true) + + sessionService.createNewSessionForUser = jest.fn().mockReturnValue({ + ...sessionPayload, + readonly_access: true, + }) + + const response = await createFactory().createResponse({ + user, + apiVersion: '20200115', + userAgent: 'Google Chrome', + ephemeralSession: false, + readonlyAccess: true, + }) + + expect(response).toEqual({ + key_params: { + key1: 'value1', + key2: 'value2', + }, + session: { + access_token: 'access_token', + refresh_token: 'refresh_token', + access_expiration: 123, + refresh_expiration: 234, + readonly_access: true, + }, + user: { + foo: 'bar', + }, + }) + }) +}) diff --git a/packages/auth/src/Domain/Auth/AuthResponseFactory20200115.ts b/packages/auth/src/Domain/Auth/AuthResponseFactory20200115.ts new file mode 100644 index 000000000..e8845b109 --- /dev/null +++ b/packages/auth/src/Domain/Auth/AuthResponseFactory20200115.ts @@ -0,0 +1,68 @@ +import { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + SessionTokenData, + TokenEncoderInterface, +} from '@standardnotes/auth' +import { Uuid } from '@standardnotes/common' +import { SessionBody } from '@standardnotes/responses' +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' +import TYPES from '../../Bootstrap/Types' +import { ProjectorInterface } from '../../Projection/ProjectorInterface' +import { SessionServiceInterface } from '../Session/SessionServiceInterface' +import { KeyParamsFactoryInterface } from '../User/KeyParamsFactoryInterface' +import { User } from '../User/User' +import { AuthResponse20161215 } from './AuthResponse20161215' +import { AuthResponse20200115 } from './AuthResponse20200115' +import { AuthResponseFactory20190520 } from './AuthResponseFactory20190520' + +@injectable() +export class AuthResponseFactory20200115 extends AuthResponseFactory20190520 { + constructor( + @inject(TYPES.SessionService) private sessionService: SessionServiceInterface, + @inject(TYPES.KeyParamsFactory) private keyParamsFactory: KeyParamsFactoryInterface, + @inject(TYPES.UserProjector) userProjector: ProjectorInterface, + @inject(TYPES.SessionTokenEncoder) protected override tokenEncoder: TokenEncoderInterface, + @inject(TYPES.Logger) logger: Logger, + ) { + super(userProjector, tokenEncoder, logger) + } + + override async createResponse(dto: { + user: User + apiVersion: string + userAgent: string + ephemeralSession: boolean + readonlyAccess: boolean + }): Promise { + if (!dto.user.supportsSessions()) { + this.logger.debug(`User ${dto.user.uuid} does not support sessions. Falling back to JWT auth response`) + + return super.createResponse(dto) + } + + const sessionPayload = await this.createSession(dto) + + this.logger.debug('Created session payload for user %s: %O', dto.user.uuid, sessionPayload) + + return { + session: sessionPayload, + key_params: this.keyParamsFactory.create(dto.user, true), + user: this.userProjector.projectSimple(dto.user) as { uuid: Uuid; email: string }, + } + } + + private async createSession(dto: { + user: User + apiVersion: string + userAgent: string + ephemeralSession: boolean + readonlyAccess: boolean + }): Promise { + if (dto.ephemeralSession) { + return this.sessionService.createNewEphemeralSessionForUser(dto) + } + + return this.sessionService.createNewSessionForUser(dto) + } +} diff --git a/packages/auth/src/Domain/Auth/AuthResponseFactoryInterface.ts b/packages/auth/src/Domain/Auth/AuthResponseFactoryInterface.ts new file mode 100644 index 000000000..468100d00 --- /dev/null +++ b/packages/auth/src/Domain/Auth/AuthResponseFactoryInterface.ts @@ -0,0 +1,13 @@ +import { User } from '../User/User' +import { AuthResponse20161215 } from './AuthResponse20161215' +import { AuthResponse20200115 } from './AuthResponse20200115' + +export interface AuthResponseFactoryInterface { + createResponse(dto: { + user: User + apiVersion: string + userAgent: string + ephemeralSession: boolean + readonlyAccess: boolean + }): Promise +} diff --git a/packages/auth/src/Domain/Auth/AuthResponseFactoryResolver.spec.ts b/packages/auth/src/Domain/Auth/AuthResponseFactoryResolver.spec.ts new file mode 100644 index 000000000..08f446859 --- /dev/null +++ b/packages/auth/src/Domain/Auth/AuthResponseFactoryResolver.spec.ts @@ -0,0 +1,47 @@ +import 'reflect-metadata' +import { Logger } from 'winston' + +import { AuthResponseFactory20161215 } from './AuthResponseFactory20161215' +import { AuthResponseFactory20190520 } from './AuthResponseFactory20190520' +import { AuthResponseFactory20200115 } from './AuthResponseFactory20200115' +import { AuthResponseFactoryResolver } from './AuthResponseFactoryResolver' + +describe('AuthResponseFactoryResolver', () => { + let authResponseFactory20161215: AuthResponseFactory20161215 + let authResponseFactory20190520: AuthResponseFactory20190520 + let authResponseFactory20200115: AuthResponseFactory20200115 + let logger: Logger + + const createResolver = () => + new AuthResponseFactoryResolver( + authResponseFactory20161215, + authResponseFactory20190520, + authResponseFactory20200115, + logger, + ) + + beforeEach(() => { + logger = {} as jest.Mocked + logger.debug = jest.fn() + + authResponseFactory20161215 = {} as jest.Mocked + authResponseFactory20190520 = {} as jest.Mocked + authResponseFactory20200115 = {} as jest.Mocked + }) + + it('should resolve 2016 response factory', () => { + expect(createResolver().resolveAuthResponseFactoryVersion('20161215')).toEqual(authResponseFactory20161215) + }) + + it('should resolve 2019 response factory', () => { + expect(createResolver().resolveAuthResponseFactoryVersion('20190520')).toEqual(authResponseFactory20190520) + }) + + it('should resolve 2020 response factory', () => { + expect(createResolver().resolveAuthResponseFactoryVersion('20200115')).toEqual(authResponseFactory20200115) + }) + + it('should resolve 2016 response factory as default', () => { + expect(createResolver().resolveAuthResponseFactoryVersion('')).toEqual(authResponseFactory20161215) + }) +}) diff --git a/packages/auth/src/Domain/Auth/AuthResponseFactoryResolver.ts b/packages/auth/src/Domain/Auth/AuthResponseFactoryResolver.ts new file mode 100644 index 000000000..b0163def9 --- /dev/null +++ b/packages/auth/src/Domain/Auth/AuthResponseFactoryResolver.ts @@ -0,0 +1,32 @@ +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' +import TYPES from '../../Bootstrap/Types' +import { ApiVersion } from '../Api/ApiVersion' +import { AuthResponseFactory20161215 } from './AuthResponseFactory20161215' +import { AuthResponseFactory20190520 } from './AuthResponseFactory20190520' +import { AuthResponseFactory20200115 } from './AuthResponseFactory20200115' +import { AuthResponseFactoryInterface } from './AuthResponseFactoryInterface' +import { AuthResponseFactoryResolverInterface } from './AuthResponseFactoryResolverInterface' + +@injectable() +export class AuthResponseFactoryResolver implements AuthResponseFactoryResolverInterface { + constructor( + @inject(TYPES.AuthResponseFactory20161215) private authResponseFactory20161215: AuthResponseFactory20161215, + @inject(TYPES.AuthResponseFactory20190520) private authResponseFactory20190520: AuthResponseFactory20190520, + @inject(TYPES.AuthResponseFactory20200115) private authResponseFactory20200115: AuthResponseFactory20200115, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + resolveAuthResponseFactoryVersion(apiVersion: string): AuthResponseFactoryInterface { + this.logger.debug(`Resolving auth response factory for api version: ${apiVersion}`) + + switch (apiVersion) { + case ApiVersion.v20190520: + return this.authResponseFactory20190520 + case ApiVersion.v20200115: + return this.authResponseFactory20200115 + default: + return this.authResponseFactory20161215 + } + } +} diff --git a/packages/auth/src/Domain/Auth/AuthResponseFactoryResolverInterface.ts b/packages/auth/src/Domain/Auth/AuthResponseFactoryResolverInterface.ts new file mode 100644 index 000000000..3663288aa --- /dev/null +++ b/packages/auth/src/Domain/Auth/AuthResponseFactoryResolverInterface.ts @@ -0,0 +1,5 @@ +import { AuthResponseFactoryInterface } from './AuthResponseFactoryInterface' + +export interface AuthResponseFactoryResolverInterface { + resolveAuthResponseFactoryVersion(apiVersion: string): AuthResponseFactoryInterface +} diff --git a/packages/auth/src/Domain/Auth/AuthTokenDTO.ts b/packages/auth/src/Domain/Auth/AuthTokenDTO.ts new file mode 100644 index 000000000..83fa4993d --- /dev/null +++ b/packages/auth/src/Domain/Auth/AuthTokenDTO.ts @@ -0,0 +1,13 @@ +export type AuthTokenDTO = { + user: { + uuid: string + email: string + } + session: { + uuid: string + api_version: string + created_at: string + updated_at: string + device_info: string + } +} diff --git a/packages/auth/src/Domain/Auth/AuthenticationMethod.ts b/packages/auth/src/Domain/Auth/AuthenticationMethod.ts new file mode 100644 index 000000000..627174847 --- /dev/null +++ b/packages/auth/src/Domain/Auth/AuthenticationMethod.ts @@ -0,0 +1,11 @@ +import { RevokedSession } from '../Session/RevokedSession' +import { Session } from '../Session/Session' +import { User } from '../User/User' + +export type AuthenticationMethod = { + type: 'jwt' | 'session_token' | 'revoked' + user: User | null + claims?: Record + session?: Session + revokedSession?: RevokedSession +} diff --git a/packages/auth/src/Domain/Auth/AuthenticationMethodResolver.spec.ts b/packages/auth/src/Domain/Auth/AuthenticationMethodResolver.spec.ts new file mode 100644 index 000000000..ecbed4a8f --- /dev/null +++ b/packages/auth/src/Domain/Auth/AuthenticationMethodResolver.spec.ts @@ -0,0 +1,84 @@ +import 'reflect-metadata' + +import { SessionTokenData, TokenDecoderInterface } from '@standardnotes/auth' + +import { RevokedSession } from '../Session/RevokedSession' +import { Session } from '../Session/Session' +import { SessionServiceInterface } from '../Session/SessionServiceInterface' +import { User } from '../User/User' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' + +import { AuthenticationMethodResolver } from './AuthenticationMethodResolver' + +describe('AuthenticationMethodResolver', () => { + let userRepository: UserRepositoryInterface + let sessionService: SessionServiceInterface + let sessionTokenDecoder: TokenDecoderInterface + let fallbackTokenDecoder: TokenDecoderInterface + let user: User + let session: Session + let revokedSession: RevokedSession + + const createResolver = () => + new AuthenticationMethodResolver(userRepository, sessionService, sessionTokenDecoder, fallbackTokenDecoder) + + beforeEach(() => { + user = {} as jest.Mocked + + session = {} as jest.Mocked + + revokedSession = {} as jest.Mocked + + userRepository = {} as jest.Mocked + userRepository.findOneByUuid = jest.fn().mockReturnValue(user) + + sessionService = {} as jest.Mocked + sessionService.getSessionFromToken = jest.fn() + sessionService.getRevokedSessionFromToken = jest.fn() + sessionService.markRevokedSessionAsReceived = jest.fn().mockReturnValue(revokedSession) + + sessionTokenDecoder = {} as jest.Mocked> + sessionTokenDecoder.decodeToken = jest.fn() + + fallbackTokenDecoder = {} as jest.Mocked> + fallbackTokenDecoder.decodeToken = jest.fn() + }) + + it('should resolve jwt authentication method', async () => { + sessionTokenDecoder.decodeToken = jest.fn().mockReturnValue({ user_uuid: '123' }) + + expect(await createResolver().resolve('test')).toEqual({ + claims: { + user_uuid: '123', + }, + type: 'jwt', + user, + }) + }) + + it('should resolve session authentication method', async () => { + sessionService.getSessionFromToken = jest.fn().mockReturnValue(session) + + expect(await createResolver().resolve('test')).toEqual({ + session, + type: 'session_token', + user, + }) + }) + + it('should resolve archvied session authentication method', async () => { + sessionService.getRevokedSessionFromToken = jest.fn().mockReturnValue(revokedSession) + + expect(await createResolver().resolve('test')).toEqual({ + revokedSession, + type: 'revoked', + user: null, + }) + + expect(sessionService.markRevokedSessionAsReceived).toHaveBeenCalled() + }) + + it('should indicated that authentication method cannot be resolved', async () => { + expect(await createResolver().resolve('test')).toBeUndefined + }) +}) diff --git a/packages/auth/src/Domain/Auth/AuthenticationMethodResolver.ts b/packages/auth/src/Domain/Auth/AuthenticationMethodResolver.ts new file mode 100644 index 000000000..33dcdb704 --- /dev/null +++ b/packages/auth/src/Domain/Auth/AuthenticationMethodResolver.ts @@ -0,0 +1,53 @@ +import { SessionTokenData, TokenDecoderInterface } from '@standardnotes/auth' +import { inject, injectable } from 'inversify' +import TYPES from '../../Bootstrap/Types' +import { SessionServiceInterface } from '../Session/SessionServiceInterface' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { AuthenticationMethod } from './AuthenticationMethod' +import { AuthenticationMethodResolverInterface } from './AuthenticationMethodResolverInterface' + +@injectable() +export class AuthenticationMethodResolver implements AuthenticationMethodResolverInterface { + constructor( + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.SessionService) private sessionService: SessionServiceInterface, + @inject(TYPES.SessionTokenDecoder) private sessionTokenDecoder: TokenDecoderInterface, + @inject(TYPES.FallbackSessionTokenDecoder) + private fallbackSessionTokenDecoder: TokenDecoderInterface, + ) {} + + async resolve(token: string): Promise { + let decodedToken: SessionTokenData | undefined = this.sessionTokenDecoder.decodeToken(token) + if (decodedToken === undefined) { + decodedToken = this.fallbackSessionTokenDecoder.decodeToken(token) + } + + if (decodedToken) { + return { + type: 'jwt', + user: await this.userRepository.findOneByUuid(decodedToken.user_uuid), + claims: decodedToken, + } + } + + const session = await this.sessionService.getSessionFromToken(token) + if (session) { + return { + type: 'session_token', + user: await this.userRepository.findOneByUuid(session.userUuid), + session: session, + } + } + + const revokedSession = await this.sessionService.getRevokedSessionFromToken(token) + if (revokedSession) { + return { + type: 'revoked', + revokedSession: await this.sessionService.markRevokedSessionAsReceived(revokedSession), + user: null, + } + } + + return undefined + } +} diff --git a/packages/auth/src/Domain/Auth/AuthenticationMethodResolverInterface.ts b/packages/auth/src/Domain/Auth/AuthenticationMethodResolverInterface.ts new file mode 100644 index 000000000..d36fff1dd --- /dev/null +++ b/packages/auth/src/Domain/Auth/AuthenticationMethodResolverInterface.ts @@ -0,0 +1,5 @@ +import { AuthenticationMethod } from './AuthenticationMethod' + +export interface AuthenticationMethodResolverInterface { + resolve(token: string): Promise +} diff --git a/packages/auth/src/Domain/Auth/OfflineSubscriptionToken.ts b/packages/auth/src/Domain/Auth/OfflineSubscriptionToken.ts new file mode 100644 index 000000000..4062743d6 --- /dev/null +++ b/packages/auth/src/Domain/Auth/OfflineSubscriptionToken.ts @@ -0,0 +1,5 @@ +export type OfflineSubscriptionToken = { + userEmail: string + token: string + expiresAt: number +} diff --git a/packages/auth/src/Domain/Auth/OfflineSubscriptionTokenRepositoryInterface.ts b/packages/auth/src/Domain/Auth/OfflineSubscriptionTokenRepositoryInterface.ts new file mode 100644 index 000000000..515774cd5 --- /dev/null +++ b/packages/auth/src/Domain/Auth/OfflineSubscriptionTokenRepositoryInterface.ts @@ -0,0 +1,6 @@ +import { OfflineSubscriptionToken } from './OfflineSubscriptionToken' + +export interface OfflineSubscriptionTokenRepositoryInterface { + save(offlineSubscriptionToken: OfflineSubscriptionToken): Promise + getUserEmailByToken(token: string): Promise +} diff --git a/packages/auth/src/Domain/Client/ClientServiceInterface.ts b/packages/auth/src/Domain/Client/ClientServiceInterface.ts new file mode 100644 index 000000000..b97f26264 --- /dev/null +++ b/packages/auth/src/Domain/Client/ClientServiceInterface.ts @@ -0,0 +1,5 @@ +import { User } from '../User/User' + +export interface ClientServiceInterface { + sendUserRolesChangedEvent(user: User): Promise +} diff --git a/packages/auth/src/Domain/Encryption/CrypterInterface.ts b/packages/auth/src/Domain/Encryption/CrypterInterface.ts new file mode 100644 index 000000000..6c35dfaec --- /dev/null +++ b/packages/auth/src/Domain/Encryption/CrypterInterface.ts @@ -0,0 +1,11 @@ +import { Base64String, HexString, Utf8String } from '@standardnotes/sncrypto-common' +import { User } from '../User/User' + +export interface CrypterInterface { + encryptForUser(value: string, user: User): Promise + decryptForUser(value: string, user: User): Promise + generateEncryptedUserServerKey(): Promise + decryptUserServerKey(user: User): Promise + sha256Hash(text: Utf8String): HexString + base64URLEncode(text: Utf8String): Base64String +} diff --git a/packages/auth/src/Domain/Encryption/CrypterNode.spec.ts b/packages/auth/src/Domain/Encryption/CrypterNode.spec.ts new file mode 100644 index 000000000..76c2ff738 --- /dev/null +++ b/packages/auth/src/Domain/Encryption/CrypterNode.spec.ts @@ -0,0 +1,150 @@ +import { Aes256GcmEncrypted } from '@standardnotes/sncrypto-common' +import { CryptoNode } from '@standardnotes/sncrypto-node' +import { Logger } from 'winston' +import { User } from '../User/User' +import { CrypterNode } from './CrypterNode' + +describe('CrypterNode', () => { + let crypto: CryptoNode + let user: User + let logger: Logger + + const iv = 'iv' + + const createCrypter = () => new CrypterNode(serverKey, crypto, logger) + + const makeEncrypted = (ciphertext: string): Aes256GcmEncrypted => { + return { + iv, + tag: 'tag', + ciphertext, + encoding: 'encoding', + aad: '', + } + } + + const version = (encrypted: Aes256GcmEncrypted, v = 1) => { + return JSON.stringify({ + version: v, + encrypted, + }) + } + + const unencrypted = 'unencrypted' + const decrypted = 'decrypted' + const encryptedUserKey = makeEncrypted('encryptedUserKey') + const serverKey = '7365727665724b65792e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e' + const unsupportedVersion = 999999 + const encrypted = makeEncrypted('encrypted') + + beforeEach(() => { + crypto = {} as jest.Mocked + crypto.aes256GcmEncrypt = jest.fn().mockReturnValue(encrypted) + crypto.aes256GcmDecrypt = jest.fn().mockReturnValue(decrypted) + crypto.generateRandomKey = jest.fn().mockReturnValue(iv) + crypto.sha256 = jest.fn().mockReturnValue('sha256-hashed') + crypto.base64URLEncode = jest.fn().mockReturnValue('base64-url-encoded') + + user = {} as jest.Mocked + user.encryptedServerKey = version(encryptedUserKey) + + logger = {} as jest.Mocked + logger.debug = jest.fn() + }) + + it('should fail to instantiate on non-32-byte key', async () => { + expect(() => new CrypterNode('short-key', crypto, logger)).toThrow() + }) + + it('should encrypt a value for user', async () => { + expect(await createCrypter().encryptForUser(unencrypted, user)).toEqual(version(encrypted)) + + expect(crypto.aes256GcmDecrypt).toHaveBeenCalledWith(encryptedUserKey, serverKey) + + expect(crypto.aes256GcmEncrypt).toHaveBeenCalledWith({ unencrypted, iv, key: decrypted }) + }) + + it('should decrypt a value for user', async () => { + expect(await createCrypter().decryptForUser(version(encrypted), user)).toEqual(decrypted) + + expect(crypto.aes256GcmDecrypt).toHaveBeenNthCalledWith(1, encryptedUserKey, serverKey) + + expect(crypto.aes256GcmDecrypt).toHaveBeenNthCalledWith(2, encrypted, decrypted) + }) + + it('should generate an encrypted user server key', async () => { + const anotherUserKey = 'anotherUserKey' + crypto.generateRandomKey = jest.fn().mockReturnValueOnce(anotherUserKey).mockReturnValueOnce(iv) + + expect(await createCrypter().generateEncryptedUserServerKey()).toEqual(version(encrypted)) + + expect(crypto.aes256GcmEncrypt).toHaveBeenCalledWith({ + unencrypted: anotherUserKey, + iv, + key: serverKey, + }) + }) + + it('should decrypt a user server key', async () => { + expect(await createCrypter().decryptUserServerKey(user)).toEqual(decrypted) + + expect(crypto.aes256GcmDecrypt).toHaveBeenCalledWith(encryptedUserKey, serverKey) + }) + + it('should throw an error if the user server key is encrypted with unsupported version', async () => { + let error = null + user.encryptedServerKey = version(encryptedUserKey, unsupportedVersion) + try { + await createCrypter().decryptUserServerKey(user) + } catch (e) { + error = e + } + + expect(error).not.toBeNull() + }) + + it('should throw an error if the value is encrypted with unsupported version', async () => { + let error = null + try { + await createCrypter().decryptForUser(version(encrypted, unsupportedVersion), user) + } catch (e) { + error = e + } + + expect(error).not.toBeNull() + }) + + it('should throw an error if the user server key is encrypted with unsupported version', async () => { + let error = null + user.encryptedServerKey = version(encryptedUserKey, unsupportedVersion) + try { + await createCrypter().decryptUserServerKey(user) + } catch (e) { + error = e + } + + expect(error).not.toBeNull() + }) + + it('should throw an error if the user server key failed to decrypt', async () => { + let error = null + crypto.aes256GcmDecrypt = jest.fn().mockImplementation(() => { + throw Error('encryption error') + }) + try { + await createCrypter().decryptUserServerKey(user) + } catch (e) { + error = e + } + + expect(error).not.toBeNull() + }) + + it('should encrypt a string with sha256', () => { + expect(createCrypter().sha256Hash('test')).toEqual('sha256-hashed') + }) + + it('should encode a string with base64 url-safe', () => { + expect(createCrypter().base64URLEncode('test')).toEqual('base64-url-encoded') + }) +}) diff --git a/packages/auth/src/Domain/Encryption/CrypterNode.ts b/packages/auth/src/Domain/Encryption/CrypterNode.ts new file mode 100644 index 000000000..4538102fb --- /dev/null +++ b/packages/auth/src/Domain/Encryption/CrypterNode.ts @@ -0,0 +1,90 @@ +import { Aes256GcmEncrypted, Base64String, HexString, Utf8String } from '@standardnotes/sncrypto-common' +import { CryptoNode } from '@standardnotes/sncrypto-node' +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' +import TYPES from '../../Bootstrap/Types' +import { User } from '../User/User' +import { CrypterInterface } from './CrypterInterface' + +@injectable() +export class CrypterNode implements CrypterInterface { + constructor( + @inject(TYPES.ENCRYPTION_SERVER_KEY) private encryptionServerKey: string, + @inject(TYPES.CryptoNode) private cryptoNode: CryptoNode, + @inject(TYPES.Logger) private logger: Logger, + ) { + const keyBuffer = Buffer.from(encryptionServerKey, 'hex') + const { byteLength } = keyBuffer + + if (byteLength !== 32) { + throw Error('ENCRYPTION_SERVER_KEY must be a hex string exactly 32 bytes long!') + } + } + + sha256Hash(text: Utf8String): HexString { + return this.cryptoNode.sha256(text) + } + + base64URLEncode(text: Utf8String): Base64String { + return this.cryptoNode.base64URLEncode(text) + } + + async encryptForUser(unencrypted: string, user: User): Promise { + const decryptedUserServerKey = await this.decryptUserServerKey(user) + const iv = await this.cryptoNode.generateRandomKey(128) + + const encrypted = await this.cryptoNode.aes256GcmEncrypt({ + unencrypted, + iv, + key: decryptedUserServerKey, + }) + + return this.stringifyVersionedEncrypted(User.DEFAULT_ENCRYPTION_VERSION, encrypted) + } + + async decryptForUser(formattedEncryptedValue: string, user: User): Promise { + this.logger.debug('Decrypting for user value: %s', formattedEncryptedValue) + + const decryptedUserServerKey = await this.decryptUserServerKey(user) + + this.logger.debug('Decrypted user server key: %s', decryptedUserServerKey) + + const encrypted = this.parseVersionedEncrypted(formattedEncryptedValue) + + this.logger.debug('Encrypted value: %O', encrypted) + + return this.cryptoNode.aes256GcmDecrypt(encrypted, decryptedUserServerKey) + } + + async generateEncryptedUserServerKey(): Promise { + const unencrypted = await this.cryptoNode.generateRandomKey(256) + const iv = await this.cryptoNode.generateRandomKey(128) + + const encrypted = await this.cryptoNode.aes256GcmEncrypt({ + unencrypted, + iv, + key: this.encryptionServerKey, + }) + + return this.stringifyVersionedEncrypted(User.DEFAULT_ENCRYPTION_VERSION, encrypted) + } + + async decryptUserServerKey(user: User): Promise { + const encrypted = this.parseVersionedEncrypted(user.encryptedServerKey as string) + + return this.cryptoNode.aes256GcmDecrypt(encrypted, this.encryptionServerKey) + } + + private stringifyVersionedEncrypted(version: number, encrypted: Aes256GcmEncrypted): string { + return JSON.stringify({ version, encrypted }) + } + + private parseVersionedEncrypted(versionedEncryptedString: string): Aes256GcmEncrypted { + const { version, encrypted } = JSON.parse(versionedEncryptedString) + if (+version !== User.DEFAULT_ENCRYPTION_VERSION) { + throw Error(`Not supported encryption version: ${version}`) + } + + return encrypted + } +} diff --git a/packages/auth/src/Domain/Encryption/EncryptionVersion.ts b/packages/auth/src/Domain/Encryption/EncryptionVersion.ts new file mode 100644 index 000000000..81df2dcca --- /dev/null +++ b/packages/auth/src/Domain/Encryption/EncryptionVersion.ts @@ -0,0 +1,4 @@ +export enum EncryptionVersion { + Unencrypted = 0, + Default = 1, +} diff --git a/packages/auth/src/Domain/Error/MFAValidationError.ts b/packages/auth/src/Domain/Error/MFAValidationError.ts new file mode 100644 index 000000000..5406e2fba --- /dev/null +++ b/packages/auth/src/Domain/Error/MFAValidationError.ts @@ -0,0 +1,6 @@ +export class MFAValidationError extends Error { + constructor(message: string, public tag: string, public payload?: Record) { + super(message) + Object.setPrototypeOf(this, MFAValidationError.prototype) + } +} diff --git a/packages/auth/src/Domain/Event/DomainEventFactory.spec.ts b/packages/auth/src/Domain/Event/DomainEventFactory.spec.ts new file mode 100644 index 000000000..e43ccb000 --- /dev/null +++ b/packages/auth/src/Domain/Event/DomainEventFactory.spec.ts @@ -0,0 +1,322 @@ +import 'reflect-metadata' + +import { RoleName } from '@standardnotes/common' +import { PredicateName, PredicateAuthority, PredicateVerificationResult } from '@standardnotes/scheduler' +import { TimerInterface } from '@standardnotes/time' + +import { DomainEventFactory } from './DomainEventFactory' +import { InviteeIdentifierType } from '../SharedSubscription/InviteeIdentifierType' + +describe('DomainEventFactory', () => { + let timer: TimerInterface + + const createFactory = () => new DomainEventFactory(timer) + + beforeEach(() => { + timer = {} as jest.Mocked + timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(1) + timer.getUTCDate = jest.fn().mockReturnValue(new Date(1)) + }) + + it('should create a PREDICATE_VERIFIED event', () => { + expect( + createFactory().createPredicateVerifiedEvent({ + predicate: { + authority: PredicateAuthority.Auth, + jobUuid: '1-2-3', + name: PredicateName.EmailBackupsEnabled, + }, + predicateVerificationResult: PredicateVerificationResult.Affirmed, + userUuid: '2-3-4', + }), + ).toEqual({ + createdAt: expect.any(Date), + meta: { + correlation: { + userIdentifier: '2-3-4', + userIdentifierType: 'uuid', + }, + origin: 'auth', + }, + payload: { + predicate: { + authority: 'auth', + jobUuid: '1-2-3', + name: 'email-backups-enabled', + }, + predicateVerificationResult: 'affirmed', + }, + type: 'PREDICATE_VERIFIED', + }) + }) + + it('should create a SHARED_SUBSCRIPTION_INVITATION_CANCELED event', () => { + expect( + createFactory().createSharedSubscriptionInvitationCanceledEvent({ + inviterEmail: 'test@test.te', + inviterSubscriptionId: 1, + inviterSubscriptionUuid: '2-3-4', + inviteeIdentifier: 'invitee@test.te', + inviteeIdentifierType: InviteeIdentifierType.Email, + sharedSubscriptionInvitationUuid: '1-2-3', + }), + ).toEqual({ + createdAt: expect.any(Date), + meta: { + correlation: { + userIdentifier: 'test@test.te', + userIdentifierType: 'email', + }, + origin: 'auth', + }, + payload: { + inviterEmail: 'test@test.te', + inviterSubscriptionId: 1, + inviterSubscriptionUuid: '2-3-4', + inviteeIdentifier: 'invitee@test.te', + inviteeIdentifierType: InviteeIdentifierType.Email, + sharedSubscriptionInvitationUuid: '1-2-3', + }, + type: 'SHARED_SUBSCRIPTION_INVITATION_CANCELED', + }) + }) + + it('should create a SHARED_SUBSCRIPTION_INVITATION_CREATED event', () => { + expect( + createFactory().createSharedSubscriptionInvitationCreatedEvent({ + inviterEmail: 'test@test.te', + inviterSubscriptionId: 1, + inviteeIdentifier: 'invitee@test.te', + inviteeIdentifierType: InviteeIdentifierType.Email, + sharedSubscriptionInvitationUuid: '1-2-3', + }), + ).toEqual({ + createdAt: expect.any(Date), + meta: { + correlation: { + userIdentifier: 'test@test.te', + userIdentifierType: 'email', + }, + origin: 'auth', + }, + payload: { + inviterEmail: 'test@test.te', + inviterSubscriptionId: 1, + inviteeIdentifier: 'invitee@test.te', + inviteeIdentifierType: InviteeIdentifierType.Email, + sharedSubscriptionInvitationUuid: '1-2-3', + }, + type: 'SHARED_SUBSCRIPTION_INVITATION_CREATED', + }) + }) + + it('should create a USER_DISABLED_SESSION_USER_AGENT_LOGGING event', () => { + expect( + createFactory().createUserDisabledSessionUserAgentLoggingEvent({ + email: 'test@test.te', + userUuid: '1-2-3', + }), + ).toEqual({ + createdAt: expect.any(Date), + meta: { + correlation: { + userIdentifier: '1-2-3', + userIdentifierType: 'uuid', + }, + origin: 'auth', + }, + payload: { + userUuid: '1-2-3', + email: 'test@test.te', + }, + type: 'USER_DISABLED_SESSION_USER_AGENT_LOGGING', + }) + }) + + it('should create a USER_SIGNED_IN event', () => { + expect( + createFactory().createUserSignedInEvent({ + browser: 'Firefox 1', + device: 'iOS 1', + userEmail: 'test@test.te', + userUuid: '1-2-3', + signInAlertEnabled: true, + muteSignInEmailsSettingUuid: '2-3-4', + }), + ).toEqual({ + createdAt: expect.any(Date), + meta: { + correlation: { + userIdentifier: '1-2-3', + userIdentifierType: 'uuid', + }, + origin: 'auth', + }, + payload: { + userUuid: '1-2-3', + userEmail: 'test@test.te', + browser: 'Firefox 1', + device: 'iOS 1', + signInAlertEnabled: true, + muteSignInEmailsSettingUuid: '2-3-4', + }, + type: 'USER_SIGNED_IN', + }) + }) + + it('should create a LISTED_ACCOUNT_REQUESTED event', () => { + expect(createFactory().createListedAccountRequestedEvent('1-2-3', 'test@test.te')).toEqual({ + createdAt: expect.any(Date), + meta: { + correlation: { + userIdentifier: '1-2-3', + userIdentifierType: 'uuid', + }, + origin: 'auth', + }, + payload: { + userUuid: '1-2-3', + userEmail: 'test@test.te', + }, + type: 'LISTED_ACCOUNT_REQUESTED', + }) + }) + + it('should create a USER_REGISTERED event', () => { + expect(createFactory().createUserRegisteredEvent('1-2-3', 'test@test.te')).toEqual({ + createdAt: expect.any(Date), + meta: { + correlation: { + userIdentifier: '1-2-3', + userIdentifierType: 'uuid', + }, + origin: 'auth', + }, + payload: { + userUuid: '1-2-3', + email: 'test@test.te', + }, + type: 'USER_REGISTERED', + }) + }) + + it('should create a OFFLINE_SUBSCRIPTION_TOKEN_CREATED event', () => { + expect(createFactory().createOfflineSubscriptionTokenCreatedEvent('1-2-3', 'test@test.te')).toEqual({ + createdAt: expect.any(Date), + meta: { + correlation: { + userIdentifier: 'test@test.te', + userIdentifierType: 'email', + }, + origin: 'auth', + }, + payload: { + token: '1-2-3', + email: 'test@test.te', + }, + type: 'OFFLINE_SUBSCRIPTION_TOKEN_CREATED', + }) + }) + + it('should create a USER_CHANGED_EMAIL event', () => { + expect(createFactory().createUserEmailChangedEvent('1-2-3', 'test@test.te', 'test2@test.te')).toEqual({ + createdAt: expect.any(Date), + meta: { + correlation: { + userIdentifier: '1-2-3', + userIdentifierType: 'uuid', + }, + origin: 'auth', + }, + payload: { + userUuid: '1-2-3', + fromEmail: 'test@test.te', + toEmail: 'test2@test.te', + }, + type: 'USER_EMAIL_CHANGED', + }) + }) + + it('should create a CLOUD_BACKUP_REQUESTED event', () => { + expect(createFactory().createCloudBackupRequestedEvent('GOOGLE_DRIVE', 'test', '1-2-3', '2-3-4', true)).toEqual({ + createdAt: expect.any(Date), + meta: { + correlation: { + userIdentifier: '1-2-3', + userIdentifierType: 'uuid', + }, + origin: 'auth', + }, + payload: { + cloudProvider: 'GOOGLE_DRIVE', + cloudProviderToken: 'test', + userUuid: '1-2-3', + muteEmailsSettingUuid: '2-3-4', + userHasEmailsMuted: true, + }, + type: 'CLOUD_BACKUP_REQUESTED', + }) + }) + + it('should create a EMAIL_BACKUP_REQUESTED event', () => { + expect(createFactory().createEmailBackupRequestedEvent('1-2-3', '2-3-4', true)).toEqual({ + createdAt: expect.any(Date), + meta: { + correlation: { + userIdentifier: '1-2-3', + userIdentifierType: 'uuid', + }, + origin: 'auth', + }, + payload: { + userUuid: '1-2-3', + muteEmailsSettingUuid: '2-3-4', + userHasEmailsMuted: true, + }, + type: 'EMAIL_BACKUP_REQUESTED', + }) + }) + + it('should create a ACCOUNT_DELETION_REQUESTED event', () => { + expect( + createFactory().createAccountDeletionRequestedEvent({ + userUuid: '1-2-3', + regularSubscriptionUuid: '2-3-4', + }), + ).toEqual({ + createdAt: expect.any(Date), + meta: { + correlation: { + userIdentifier: '1-2-3', + userIdentifierType: 'uuid', + }, + origin: 'auth', + }, + payload: { + userUuid: '1-2-3', + regularSubscriptionUuid: '2-3-4', + }, + type: 'ACCOUNT_DELETION_REQUESTED', + }) + }) + + it('should create a USER_ROLE_CHANGED event', () => { + expect(createFactory().createUserRolesChangedEvent('1-2-3', 'test@test.com', [RoleName.ProUser])).toEqual({ + createdAt: expect.any(Date), + meta: { + correlation: { + userIdentifier: '1-2-3', + userIdentifierType: 'uuid', + }, + origin: 'auth', + }, + payload: { + userUuid: '1-2-3', + email: 'test@test.com', + currentRoles: [RoleName.ProUser], + timestamp: expect.any(Number), + }, + type: 'USER_ROLES_CHANGED', + }) + }) +}) diff --git a/packages/auth/src/Domain/Event/DomainEventFactory.ts b/packages/auth/src/Domain/Event/DomainEventFactory.ts new file mode 100644 index 000000000..71e50a438 --- /dev/null +++ b/packages/auth/src/Domain/Event/DomainEventFactory.ts @@ -0,0 +1,292 @@ +import { RoleName, Uuid } from '@standardnotes/common' +import { + AccountDeletionRequestedEvent, + UserEmailChangedEvent, + UserRegisteredEvent, + UserRolesChangedEvent, + OfflineSubscriptionTokenCreatedEvent, + EmailBackupRequestedEvent, + CloudBackupRequestedEvent, + ListedAccountRequestedEvent, + UserSignedInEvent, + UserDisabledSessionUserAgentLoggingEvent, + SharedSubscriptionInvitationCreatedEvent, + SharedSubscriptionInvitationCanceledEvent, + PredicateVerifiedEvent, + DomainEventService, +} from '@standardnotes/domain-events' +import { Predicate, PredicateVerificationResult } from '@standardnotes/scheduler' +import { TimerInterface } from '@standardnotes/time' +import { inject, injectable } from 'inversify' +import TYPES from '../../Bootstrap/Types' +import { InviteeIdentifierType } from '../SharedSubscription/InviteeIdentifierType' +import { DomainEventFactoryInterface } from './DomainEventFactoryInterface' + +@injectable() +export class DomainEventFactory implements DomainEventFactoryInterface { + constructor(@inject(TYPES.Timer) private timer: TimerInterface) {} + + createPredicateVerifiedEvent(dto: { + userUuid: Uuid + predicate: Predicate + predicateVerificationResult: PredicateVerificationResult + }): PredicateVerifiedEvent { + const { userUuid, ...payload } = dto + return { + type: 'PREDICATE_VERIFIED', + createdAt: this.timer.getUTCDate(), + meta: { + correlation: { + userIdentifier: userUuid, + userIdentifierType: 'uuid', + }, + origin: DomainEventService.Auth, + }, + payload, + } + } + + createSharedSubscriptionInvitationCanceledEvent(dto: { + inviterEmail: string + inviterSubscriptionId: number + inviterSubscriptionUuid: Uuid + inviteeIdentifier: string + inviteeIdentifierType: InviteeIdentifierType + sharedSubscriptionInvitationUuid: Uuid + }): SharedSubscriptionInvitationCanceledEvent { + return { + type: 'SHARED_SUBSCRIPTION_INVITATION_CANCELED', + createdAt: this.timer.getUTCDate(), + meta: { + correlation: { + userIdentifier: dto.inviterEmail, + userIdentifierType: 'email', + }, + origin: DomainEventService.Auth, + }, + payload: dto, + } + } + + createSharedSubscriptionInvitationCreatedEvent(dto: { + inviterEmail: string + inviterSubscriptionId: number + inviteeIdentifier: string + inviteeIdentifierType: InviteeIdentifierType + sharedSubscriptionInvitationUuid: string + }): SharedSubscriptionInvitationCreatedEvent { + return { + type: 'SHARED_SUBSCRIPTION_INVITATION_CREATED', + createdAt: this.timer.getUTCDate(), + meta: { + correlation: { + userIdentifier: dto.inviterEmail, + userIdentifierType: 'email', + }, + origin: DomainEventService.Auth, + }, + payload: dto, + } + } + + createUserDisabledSessionUserAgentLoggingEvent(dto: { + userUuid: string + email: string + }): UserDisabledSessionUserAgentLoggingEvent { + return { + type: 'USER_DISABLED_SESSION_USER_AGENT_LOGGING', + createdAt: this.timer.getUTCDate(), + meta: { + correlation: { + userIdentifier: dto.userUuid, + userIdentifierType: 'uuid', + }, + origin: DomainEventService.Auth, + }, + payload: dto, + } + } + + createUserSignedInEvent(dto: { + userUuid: string + userEmail: string + device: string + browser: string + signInAlertEnabled: boolean + muteSignInEmailsSettingUuid: Uuid + }): UserSignedInEvent { + return { + type: 'USER_SIGNED_IN', + createdAt: this.timer.getUTCDate(), + meta: { + correlation: { + userIdentifier: dto.userUuid, + userIdentifierType: 'uuid', + }, + origin: DomainEventService.Auth, + }, + payload: dto, + } + } + + createListedAccountRequestedEvent(userUuid: string, userEmail: string): ListedAccountRequestedEvent { + return { + type: 'LISTED_ACCOUNT_REQUESTED', + createdAt: this.timer.getUTCDate(), + meta: { + correlation: { + userIdentifier: userUuid, + userIdentifierType: 'uuid', + }, + origin: DomainEventService.Auth, + }, + payload: { + userUuid, + userEmail, + }, + } + } + + createCloudBackupRequestedEvent( + cloudProvider: 'DROPBOX' | 'ONE_DRIVE' | 'GOOGLE_DRIVE', + cloudProviderToken: string, + userUuid: string, + muteEmailsSettingUuid: string, + userHasEmailsMuted: boolean, + ): CloudBackupRequestedEvent { + return { + type: 'CLOUD_BACKUP_REQUESTED', + createdAt: this.timer.getUTCDate(), + meta: { + correlation: { + userIdentifier: userUuid, + userIdentifierType: 'uuid', + }, + origin: DomainEventService.Auth, + }, + payload: { + cloudProvider, + cloudProviderToken, + userUuid, + userHasEmailsMuted, + muteEmailsSettingUuid, + }, + } + } + + createEmailBackupRequestedEvent( + userUuid: string, + muteEmailsSettingUuid: string, + userHasEmailsMuted: boolean, + ): EmailBackupRequestedEvent { + return { + type: 'EMAIL_BACKUP_REQUESTED', + createdAt: this.timer.getUTCDate(), + meta: { + correlation: { + userIdentifier: userUuid, + userIdentifierType: 'uuid', + }, + origin: DomainEventService.Auth, + }, + payload: { + userUuid, + userHasEmailsMuted, + muteEmailsSettingUuid, + }, + } + } + + createAccountDeletionRequestedEvent(dto: { + userUuid: Uuid + regularSubscriptionUuid: Uuid | undefined + }): AccountDeletionRequestedEvent { + return { + type: 'ACCOUNT_DELETION_REQUESTED', + createdAt: this.timer.getUTCDate(), + meta: { + correlation: { + userIdentifier: dto.userUuid, + userIdentifierType: 'uuid', + }, + origin: DomainEventService.Auth, + }, + payload: dto, + } + } + + createOfflineSubscriptionTokenCreatedEvent(token: string, email: string): OfflineSubscriptionTokenCreatedEvent { + return { + type: 'OFFLINE_SUBSCRIPTION_TOKEN_CREATED', + createdAt: this.timer.getUTCDate(), + meta: { + correlation: { + userIdentifier: email, + userIdentifierType: 'email', + }, + origin: DomainEventService.Auth, + }, + payload: { + token, + email, + }, + } + } + + createUserRegisteredEvent(userUuid: string, email: string): UserRegisteredEvent { + return { + type: 'USER_REGISTERED', + createdAt: this.timer.getUTCDate(), + meta: { + correlation: { + userIdentifier: userUuid, + userIdentifierType: 'uuid', + }, + origin: DomainEventService.Auth, + }, + payload: { + userUuid, + email, + }, + } + } + + createUserEmailChangedEvent(userUuid: string, fromEmail: string, toEmail: string): UserEmailChangedEvent { + return { + type: 'USER_EMAIL_CHANGED', + createdAt: this.timer.getUTCDate(), + meta: { + correlation: { + userIdentifier: userUuid, + userIdentifierType: 'uuid', + }, + origin: DomainEventService.Auth, + }, + payload: { + userUuid, + fromEmail, + toEmail, + }, + } + } + + createUserRolesChangedEvent(userUuid: string, email: string, currentRoles: RoleName[]): UserRolesChangedEvent { + return { + type: 'USER_ROLES_CHANGED', + createdAt: this.timer.getUTCDate(), + meta: { + correlation: { + userIdentifier: userUuid, + userIdentifierType: 'uuid', + }, + origin: DomainEventService.Auth, + }, + payload: { + userUuid, + email, + currentRoles, + timestamp: this.timer.getTimestampInMicroseconds(), + }, + } + } +} diff --git a/packages/auth/src/Domain/Event/DomainEventFactoryInterface.ts b/packages/auth/src/Domain/Event/DomainEventFactoryInterface.ts new file mode 100644 index 000000000..7d6618567 --- /dev/null +++ b/packages/auth/src/Domain/Event/DomainEventFactoryInterface.ts @@ -0,0 +1,74 @@ +import { Uuid, RoleName } from '@standardnotes/common' +import { Predicate, PredicateVerificationResult } from '@standardnotes/scheduler' +import { + AccountDeletionRequestedEvent, + CloudBackupRequestedEvent, + UserRegisteredEvent, + UserRolesChangedEvent, + UserEmailChangedEvent, + OfflineSubscriptionTokenCreatedEvent, + EmailBackupRequestedEvent, + ListedAccountRequestedEvent, + UserSignedInEvent, + UserDisabledSessionUserAgentLoggingEvent, + SharedSubscriptionInvitationCreatedEvent, + SharedSubscriptionInvitationCanceledEvent, + PredicateVerifiedEvent, +} from '@standardnotes/domain-events' +import { InviteeIdentifierType } from '../SharedSubscription/InviteeIdentifierType' + +export interface DomainEventFactoryInterface { + createUserSignedInEvent(dto: { + userUuid: string + userEmail: string + device: string + browser: string + signInAlertEnabled: boolean + muteSignInEmailsSettingUuid: Uuid + }): UserSignedInEvent + createListedAccountRequestedEvent(userUuid: string, userEmail: string): ListedAccountRequestedEvent + createUserRegisteredEvent(userUuid: string, email: string): UserRegisteredEvent + createEmailBackupRequestedEvent( + userUuid: string, + muteEmailsSettingUuid: string, + userHasEmailsMuted: boolean, + ): EmailBackupRequestedEvent + createCloudBackupRequestedEvent( + cloudProvider: 'DROPBOX' | 'ONE_DRIVE' | 'GOOGLE_DRIVE', + cloudProviderToken: string, + userUuid: string, + muteEmailsSettingUuid: string, + userHasEmailsMuted: boolean, + ): CloudBackupRequestedEvent + createAccountDeletionRequestedEvent(dto: { + userUuid: Uuid + regularSubscriptionUuid: Uuid | undefined + }): AccountDeletionRequestedEvent + createUserRolesChangedEvent(userUuid: string, email: string, currentRoles: RoleName[]): UserRolesChangedEvent + createUserEmailChangedEvent(userUuid: string, fromEmail: string, toEmail: string): UserEmailChangedEvent + createOfflineSubscriptionTokenCreatedEvent(token: string, email: string): OfflineSubscriptionTokenCreatedEvent + createUserDisabledSessionUserAgentLoggingEvent(dto: { + userUuid: Uuid + email: string + }): UserDisabledSessionUserAgentLoggingEvent + createSharedSubscriptionInvitationCreatedEvent(dto: { + inviterEmail: string + inviterSubscriptionId: number + inviteeIdentifier: string + inviteeIdentifierType: InviteeIdentifierType + sharedSubscriptionInvitationUuid: string + }): SharedSubscriptionInvitationCreatedEvent + createSharedSubscriptionInvitationCanceledEvent(dto: { + inviterEmail: string + inviterSubscriptionId: number + inviterSubscriptionUuid: Uuid + inviteeIdentifier: string + inviteeIdentifierType: InviteeIdentifierType + sharedSubscriptionInvitationUuid: Uuid + }): SharedSubscriptionInvitationCanceledEvent + createPredicateVerifiedEvent(dto: { + userUuid: Uuid + predicate: Predicate + predicateVerificationResult: PredicateVerificationResult + }): PredicateVerifiedEvent +} diff --git a/packages/auth/src/Domain/Feature/FeatureService.spec.ts b/packages/auth/src/Domain/Feature/FeatureService.spec.ts new file mode 100644 index 000000000..dbd400152 --- /dev/null +++ b/packages/auth/src/Domain/Feature/FeatureService.spec.ts @@ -0,0 +1,407 @@ +import 'reflect-metadata' + +import { Role } from '@standardnotes/auth' +import { RoleName, SubscriptionName } from '@standardnotes/common' + +import { RoleToSubscriptionMapInterface } from '../Role/RoleToSubscriptionMapInterface' +import { User } from '../User/User' +import { UserSubscription } from '../Subscription/UserSubscription' + +import { FeatureService } from './FeatureService' +import { Permission, PermissionName } from '@standardnotes/features' +import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface' +import { TimerInterface } from '@standardnotes/time' +import { OfflineUserSubscription } from '../Subscription/OfflineUserSubscription' +import { UserSubscriptionType } from '../Subscription/UserSubscriptionType' + +describe('FeatureService', () => { + let roleToSubscriptionMap: RoleToSubscriptionMapInterface + let user: User + let role1: Role + let role2: Role + let subscription1: UserSubscription + let subscription2: UserSubscription + let subscription3: UserSubscription + let subscription4: UserSubscription + let permission1: Permission + let permission2: Permission + let permission3: Permission + let permission4: Permission + let offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface + let timer: TimerInterface + let offlineUserSubscription: OfflineUserSubscription + + const createService = () => new FeatureService(roleToSubscriptionMap, offlineUserSubscriptionRepository, timer) + + beforeEach(() => { + roleToSubscriptionMap = {} as jest.Mocked + roleToSubscriptionMap.filterNonSubscriptionRoles = jest.fn().mockReturnValue([]) + roleToSubscriptionMap.getRoleNameForSubscriptionName = jest + .fn() + .mockImplementation((subscriptionName: SubscriptionName) => { + if (subscriptionName === SubscriptionName.PlusPlan) { + return RoleName.PlusUser + } + if (subscriptionName === SubscriptionName.ProPlan) { + return RoleName.ProUser + } + + return undefined + }) + + permission1 = { + uuid: 'permission-1-1-1', + name: PermissionName.AutobiographyTheme, + } + permission2 = { + uuid: 'permission-2-2-2', + name: PermissionName.BoldEditor, + } + permission3 = { + uuid: 'permission-3-3-3', + name: PermissionName.TwoFactorAuth, + } + permission4 = { + uuid: 'permission-4-4-4', + name: 'Not existing' as PermissionName, + } + + role1 = { + name: RoleName.PlusUser, + uuid: 'role-1-1-1', + permissions: Promise.resolve([permission1, permission3]), + } as jest.Mocked + + role2 = { + name: RoleName.ProUser, + uuid: 'role-2-2-2', + permissions: Promise.resolve([permission2]), + } as jest.Mocked + + subscription1 = { + uuid: 'subscription-1-1-1', + createdAt: 111, + updatedAt: 222, + planName: SubscriptionName.PlusPlan, + endsAt: 555, + user: Promise.resolve(user), + cancelled: false, + subscriptionId: 1, + subscriptionType: UserSubscriptionType.Regular, + subscriptionSettings: Promise.resolve([]), + } + + subscription2 = { + uuid: 'subscription-2-2-2', + createdAt: 222, + updatedAt: 333, + planName: SubscriptionName.ProPlan, + endsAt: 777, + user: Promise.resolve(user), + cancelled: false, + subscriptionId: 2, + subscriptionType: UserSubscriptionType.Regular, + subscriptionSettings: Promise.resolve([]), + } + + subscription3 = { + uuid: 'subscription-3-3-3-canceled', + createdAt: 111, + updatedAt: 222, + planName: SubscriptionName.PlusPlan, + endsAt: 333, + user: Promise.resolve(user), + cancelled: true, + subscriptionId: 3, + subscriptionType: UserSubscriptionType.Regular, + subscriptionSettings: Promise.resolve([]), + } + + subscription4 = { + uuid: 'subscription-4-4-4-canceled', + createdAt: 111, + updatedAt: 222, + planName: SubscriptionName.PlusPlan, + endsAt: 333, + user: Promise.resolve(user), + cancelled: true, + subscriptionId: 4, + subscriptionType: UserSubscriptionType.Regular, + subscriptionSettings: Promise.resolve([]), + } + + user = { + uuid: 'user-1-1-1', + roles: Promise.resolve([role1]), + subscriptions: Promise.resolve([subscription1]), + } as jest.Mocked + + offlineUserSubscription = { + roles: Promise.resolve([role1]), + uuid: 'subscription-1-1-1', + createdAt: 111, + updatedAt: 222, + planName: SubscriptionName.PlusPlan, + endsAt: 555, + cancelled: false, + } as jest.Mocked + + offlineUserSubscriptionRepository = {} as jest.Mocked + offlineUserSubscriptionRepository.findByEmail = jest.fn().mockReturnValue([offlineUserSubscription]) + + timer = {} as jest.Mocked + timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123) + }) + + describe('offline subscribers', () => { + it('should return user features with `expires_at` field', async () => { + const features = await createService().getFeaturesForOfflineUser('test@test.com') + + expect(features).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + identifier: 'org.standardnotes.theme-autobiography', + expires_at: 555, + }), + ]), + ) + }) + + it('should not return user features if a subscription could not be found', async () => { + offlineUserSubscriptionRepository.findByEmail = jest.fn().mockReturnValue([]) + + expect(await createService().getFeaturesForOfflineUser('test@test.com')).toEqual([]) + }) + }) + + describe('online subscribers', () => { + it('should return user features with `expires_at` field', async () => { + const features = await createService().getFeaturesForUser(user) + expect(features).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + identifier: 'org.standardnotes.theme-autobiography', + expires_at: 555, + }), + ]), + ) + }) + + it('should return user features based on longest lasting subscription', async () => { + user = { + uuid: 'user-1-1-1', + roles: Promise.resolve([role1]), + subscriptions: Promise.resolve([subscription3, subscription1, subscription4]), + } as jest.Mocked + + const features = await createService().getFeaturesForUser(user) + expect(features).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + identifier: 'org.standardnotes.theme-autobiography', + expires_at: 555, + }), + ]), + ) + }) + + it('should not return user features if a subscription could not be found', async () => { + const subscriptions: Array = [] + + user = { + uuid: 'user-1-1-1', + roles: Promise.resolve([role1]), + subscriptions: Promise.resolve(subscriptions), + } as jest.Mocked + + expect(await createService().getFeaturesForUser(user)).toEqual([]) + }) + + it('should not return user features if those cannot be find for permissions', async () => { + role1 = { + name: RoleName.CoreUser, + uuid: 'role-1-1-1', + permissions: Promise.resolve([permission4]), + } as jest.Mocked + + roleToSubscriptionMap.filterNonSubscriptionRoles = jest.fn().mockReturnValue([role1]) + + user = { + uuid: 'user-1-1-1', + roles: Promise.resolve([role1]), + subscriptions: Promise.resolve([subscription3, subscription1, subscription4]), + } as jest.Mocked + + expect(await createService().getFeaturesForUser(user)).toEqual([]) + }) + + it('should not return user features if a role name could not be found', async () => { + subscription1 = { + uuid: 'subscription-1-1-1', + createdAt: 111, + updatedAt: 222, + planName: 'non existing plan name' as SubscriptionName, + endsAt: 555, + user: Promise.resolve(user), + cancelled: false, + subscriptionId: 1, + subscriptionType: UserSubscriptionType.Regular, + subscriptionSettings: Promise.resolve([]), + } + + user = { + uuid: 'user-1-1-1', + roles: Promise.resolve([role1]), + subscriptions: Promise.resolve([subscription1]), + } as jest.Mocked + + expect(await createService().getFeaturesForUser(user)).toEqual([]) + }) + + it('should not return user features if a role could not be found', async () => { + user.roles = Promise.resolve([]) + + expect(await createService().getFeaturesForUser(user)).toEqual([]) + }) + + it('should return user features with `expires_at` field when user has more than 1 role & subscription', async () => { + roleToSubscriptionMap.getSubscriptionNameForRoleName = jest + .fn() + .mockReturnValueOnce(SubscriptionName.PlusPlan) + .mockReturnValueOnce(SubscriptionName.ProPlan) + + user = { + uuid: 'user-1-1-1', + roles: Promise.resolve([role1, role2]), + subscriptions: Promise.resolve([subscription1, subscription2]), + } as jest.Mocked + + const features = await createService().getFeaturesForUser(user) + expect(features).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + identifier: 'org.standardnotes.theme-autobiography', + expires_at: 555, + }), + expect.objectContaining({ + identifier: 'org.standardnotes.bold-editor', + expires_at: 777, + }), + ]), + ) + }) + + it('should return user features along with features related to non subscription roles', async () => { + const nonSubscriptionPermission = { + uuid: 'files-beta-permission-1-1-1', + name: PermissionName.FilesBeta, + } as jest.Mocked + + const nonSubscriptionRole = { + name: RoleName.FilesBetaUser, + uuid: 'role-files-beta', + permissions: Promise.resolve([nonSubscriptionPermission]), + } as jest.Mocked + + roleToSubscriptionMap.filterNonSubscriptionRoles = jest.fn().mockReturnValue([nonSubscriptionRole]) + roleToSubscriptionMap.getSubscriptionNameForRoleName = jest + .fn() + .mockReturnValueOnce(SubscriptionName.PlusPlan) + .mockReturnValueOnce(SubscriptionName.ProPlan) + + user = { + uuid: 'user-1-1-1', + roles: Promise.resolve([role1, role2, nonSubscriptionRole]), + subscriptions: Promise.resolve([subscription1, subscription2]), + } as jest.Mocked + + const features = await createService().getFeaturesForUser(user) + expect(features).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + identifier: 'org.standardnotes.theme-autobiography', + expires_at: 555, + }), + expect.objectContaining({ + identifier: 'org.standardnotes.bold-editor', + expires_at: 777, + }), + expect.objectContaining({ + identifier: 'org.standardnotes.files-beta', + expires_at: undefined, + no_expire: true, + }), + ]), + ) + }) + + it('should set the longest expiration date for feature that matches duplicated permissions', async () => { + roleToSubscriptionMap.getSubscriptionNameForRoleName = jest + .fn() + .mockReturnValueOnce(SubscriptionName.PlusPlan) + .mockReturnValueOnce(SubscriptionName.ProPlan) + + role2 = { + name: RoleName.ProUser, + uuid: 'role-2-2-2', + permissions: Promise.resolve([permission1, permission2]), + } as jest.Mocked + user = { + uuid: 'user-1-1-1', + roles: Promise.resolve([role1, role2]), + subscriptions: Promise.resolve([subscription1, subscription2]), + } as jest.Mocked + + const longestExpireAt = 777 + + const features = await createService().getFeaturesForUser(user) + expect(features).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + identifier: 'org.standardnotes.theme-autobiography', + expires_at: longestExpireAt, + }), + expect.objectContaining({ + identifier: 'org.standardnotes.bold-editor', + expires_at: longestExpireAt, + }), + ]), + ) + }) + + it('should not set the lesser expiration date for feature that matches duplicated permissions', async () => { + roleToSubscriptionMap.getSubscriptionNameForRoleName = jest + .fn() + .mockReturnValueOnce(SubscriptionName.PlusPlan) + .mockReturnValueOnce(SubscriptionName.ProPlan) + + const lesserExpireAt = 111 + subscription2.endsAt = lesserExpireAt + + role2 = { + name: RoleName.ProUser, + uuid: 'role-2-2-2', + permissions: Promise.resolve([permission1, permission2]), + } as jest.Mocked + user = { + uuid: 'user-1-1-1', + roles: Promise.resolve([role1, role2]), + subscriptions: Promise.resolve([subscription1, subscription2]), + } as jest.Mocked + + const features = await createService().getFeaturesForUser(user) + expect(features).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + identifier: 'org.standardnotes.theme-autobiography', + expires_at: 555, + }), + expect.objectContaining({ + identifier: 'org.standardnotes.bold-editor', + expires_at: lesserExpireAt, + }), + ]), + ) + }) + }) +}) diff --git a/packages/auth/src/Domain/Feature/FeatureService.ts b/packages/auth/src/Domain/Feature/FeatureService.ts new file mode 100644 index 000000000..a708c317a --- /dev/null +++ b/packages/auth/src/Domain/Feature/FeatureService.ts @@ -0,0 +1,151 @@ +import { RoleName, SubscriptionName } from '@standardnotes/common' +import { FeatureDescription, GetFeatures } from '@standardnotes/features' +import { inject, injectable } from 'inversify' +import TYPES from '../../Bootstrap/Types' +import { RoleToSubscriptionMapInterface } from '../Role/RoleToSubscriptionMapInterface' + +import { User } from '../User/User' +import { UserSubscription } from '../Subscription/UserSubscription' +import { FeatureServiceInterface } from './FeatureServiceInterface' +import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface' +import { Role } from '../Role/Role' +import { OfflineUserSubscription } from '../Subscription/OfflineUserSubscription' +import { TimerInterface } from '@standardnotes/time' + +@injectable() +export class FeatureService implements FeatureServiceInterface { + constructor( + @inject(TYPES.RoleToSubscriptionMap) private roleToSubscriptionMap: RoleToSubscriptionMapInterface, + @inject(TYPES.OfflineUserSubscriptionRepository) + private offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface, + @inject(TYPES.Timer) private timer: TimerInterface, + ) {} + + async getFeaturesForOfflineUser(email: string): Promise { + const userSubscriptions = await this.offlineUserSubscriptionRepository.findByEmail( + email, + this.timer.getTimestampInMicroseconds(), + ) + const userRolesMap: Map = new Map() + for (const userSubscription of userSubscriptions) { + const subscriptionRoles = await userSubscription.roles + for (const subscriptionRole of subscriptionRoles) { + userRolesMap.set(subscriptionRole.name, subscriptionRole) + } + } + + return this.getFeaturesForSubscriptions(userSubscriptions, [...userRolesMap.values()]) + } + + async getFeaturesForUser(user: User): Promise> { + const userSubscriptions = await user.subscriptions + + return this.getFeaturesForSubscriptions(userSubscriptions, await user.roles) + } + + private async getFeaturesForSubscriptions( + userSubscriptions: Array, + userRoles: Array, + ): Promise> { + const userFeatures: Map = new Map() + + await this.appendFeaturesBasedOnSubscriptions(userSubscriptions, userRoles, userFeatures) + + await this.appendFeaturesBasedOnNonSubscriptionRoles(userRoles, userFeatures) + + return [...userFeatures.values()] + } + + private async appendFeaturesBasedOnNonSubscriptionRoles( + userRoles: Array, + userFeatures: Map, + ): Promise { + const nonSubscriptionRolesOfUser = this.roleToSubscriptionMap.filterNonSubscriptionRoles(userRoles) + + for (const nonSubscriptionRole of nonSubscriptionRolesOfUser) { + await this.appendFeaturesAssociatedWithRole(nonSubscriptionRole, userFeatures) + } + } + + private async appendFeaturesBasedOnSubscriptions( + userSubscriptions: Array, + userRoles: Array, + userFeatures: Map, + ): Promise { + const userSubscriptionNames: Array = [] + + userSubscriptions.map((userSubscription: UserSubscription | OfflineUserSubscription) => { + const subscriptionName = userSubscription.planName as SubscriptionName + if (!userSubscriptionNames.includes(subscriptionName)) { + userSubscriptionNames.push(subscriptionName) + } + }) + + for (const userSubscriptionName of userSubscriptionNames) { + const roleName = this.roleToSubscriptionMap.getRoleNameForSubscriptionName(userSubscriptionName) + if (roleName === undefined) { + continue + } + const role = userRoles.find((role: Role) => role.name === roleName) + if (role === undefined) { + continue + } + + const longestLastingSubscription = this.getLongestLastingSubscription(userSubscriptions, userSubscriptionName) + + await this.appendFeaturesAssociatedWithRole(role, userFeatures, longestLastingSubscription) + } + } + + private async appendFeaturesAssociatedWithRole( + role: Role, + userFeatures: Map, + longestLastingSubscription?: UserSubscription | OfflineUserSubscription, + ): Promise { + const rolePermissions = await role.permissions + for (const rolePermission of rolePermissions) { + const featureForPermission = GetFeatures().find( + (feature) => feature.permission_name === rolePermission.name, + ) as FeatureDescription + if (featureForPermission === undefined) { + continue + } + + const alreadyAddedFeature = userFeatures.get(rolePermission.name) + if (alreadyAddedFeature === undefined) { + userFeatures.set(rolePermission.name, { + ...featureForPermission, + expires_at: longestLastingSubscription ? longestLastingSubscription.endsAt : undefined, + no_expire: longestLastingSubscription ? false : true, + role_name: role.name as RoleName, + }) + + continue + } + + if ( + longestLastingSubscription !== undefined && + longestLastingSubscription.endsAt > (alreadyAddedFeature.expires_at as number) + ) { + alreadyAddedFeature.expires_at = longestLastingSubscription.endsAt + } + } + } + + private getLongestLastingSubscription( + userSubscriptions: Array, + subscriptionName?: SubscriptionName, + ): UserSubscription | OfflineUserSubscription { + return userSubscriptions + .filter((subscription) => subscription.planName === subscriptionName) + .sort((a, b) => { + if (a.endsAt < b.endsAt) { + return 1 + } + if (a.endsAt > b.endsAt) { + return -1 + } + return 0 + })[0] + } +} diff --git a/packages/auth/src/Domain/Feature/FeatureServiceInterface.ts b/packages/auth/src/Domain/Feature/FeatureServiceInterface.ts new file mode 100644 index 000000000..478f7893d --- /dev/null +++ b/packages/auth/src/Domain/Feature/FeatureServiceInterface.ts @@ -0,0 +1,8 @@ +import { FeatureDescription } from '@standardnotes/features' + +import { User } from '../User/User' + +export interface FeatureServiceInterface { + getFeaturesForUser(user: User): Promise> + getFeaturesForOfflineUser(email: string): Promise +} diff --git a/packages/auth/src/Domain/Handler/AccountDeletionRequestedEventHandler.spec.ts b/packages/auth/src/Domain/Handler/AccountDeletionRequestedEventHandler.spec.ts new file mode 100644 index 000000000..23403d68d --- /dev/null +++ b/packages/auth/src/Domain/Handler/AccountDeletionRequestedEventHandler.spec.ts @@ -0,0 +1,104 @@ +import 'reflect-metadata' + +import { AccountDeletionRequestedEvent } from '@standardnotes/domain-events' +import { Logger } from 'winston' +import { EphemeralSession } from '../Session/EphemeralSession' +import { EphemeralSessionRepositoryInterface } from '../Session/EphemeralSessionRepositoryInterface' +import { RevokedSession } from '../Session/RevokedSession' +import { RevokedSessionRepositoryInterface } from '../Session/RevokedSessionRepositoryInterface' +import { Session } from '../Session/Session' +import { SessionRepositoryInterface } from '../Session/SessionRepositoryInterface' +import { User } from '../User/User' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { AccountDeletionRequestedEventHandler } from './AccountDeletionRequestedEventHandler' + +describe('AccountDeletionRequestedEventHandler', () => { + let userRepository: UserRepositoryInterface + let sessionRepository: SessionRepositoryInterface + let ephemeralSessionRepository: EphemeralSessionRepositoryInterface + let revokedSessionRepository: RevokedSessionRepositoryInterface + let logger: Logger + let session: Session + let ephemeralSession: EphemeralSession + let revokedSession: RevokedSession + let user: User + let event: AccountDeletionRequestedEvent + + const createHandler = () => + new AccountDeletionRequestedEventHandler( + userRepository, + sessionRepository, + ephemeralSessionRepository, + revokedSessionRepository, + logger, + ) + + beforeEach(() => { + user = {} as jest.Mocked + + userRepository = {} as jest.Mocked + userRepository.findOneByUuid = jest.fn().mockReturnValue(user) + userRepository.remove = jest.fn() + + session = { + uuid: '1-2-3', + } as jest.Mocked + + sessionRepository = {} as jest.Mocked + sessionRepository.findAllByUserUuid = jest.fn().mockReturnValue([session]) + sessionRepository.remove = jest.fn() + + ephemeralSession = { + uuid: '2-3-4', + userUuid: '1-2-3', + } as jest.Mocked + + ephemeralSessionRepository = {} as jest.Mocked + ephemeralSessionRepository.findAllByUserUuid = jest.fn().mockReturnValue([ephemeralSession]) + ephemeralSessionRepository.deleteOne = jest.fn() + + revokedSession = { + uuid: '3-4-5', + } as jest.Mocked + + revokedSessionRepository = {} as jest.Mocked + revokedSessionRepository.findAllByUserUuid = jest.fn().mockReturnValue([revokedSession]) + revokedSessionRepository.remove = jest.fn() + + event = {} as jest.Mocked + event.createdAt = new Date(1) + event.payload = { + userUuid: '1-2-3', + regularSubscriptionUuid: '2-3-4', + } + + logger = {} as jest.Mocked + logger.info = jest.fn() + logger.warn = jest.fn() + }) + + it('should remove a user', async () => { + await createHandler().handle(event) + + expect(userRepository.remove).toHaveBeenCalledWith(user) + }) + + it('should not remove a user if one does not exist', async () => { + userRepository.findOneByUuid = jest.fn().mockReturnValue(null) + + await createHandler().handle(event) + + expect(userRepository.remove).not.toHaveBeenCalled() + expect(sessionRepository.remove).not.toHaveBeenCalled() + expect(revokedSessionRepository.remove).not.toHaveBeenCalled() + expect(ephemeralSessionRepository.deleteOne).not.toHaveBeenCalled() + }) + + it('should remove all user sessions', async () => { + await createHandler().handle(event) + + expect(sessionRepository.remove).toHaveBeenCalledWith(session) + expect(revokedSessionRepository.remove).toHaveBeenCalledWith(revokedSession) + expect(ephemeralSessionRepository.deleteOne).toHaveBeenCalledWith('2-3-4', '1-2-3') + }) +}) diff --git a/packages/auth/src/Domain/Handler/AccountDeletionRequestedEventHandler.ts b/packages/auth/src/Domain/Handler/AccountDeletionRequestedEventHandler.ts new file mode 100644 index 000000000..e7c6e45b4 --- /dev/null +++ b/packages/auth/src/Domain/Handler/AccountDeletionRequestedEventHandler.ts @@ -0,0 +1,52 @@ +import { AccountDeletionRequestedEvent, DomainEventHandlerInterface } from '@standardnotes/domain-events' +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' +import TYPES from '../../Bootstrap/Types' +import { EphemeralSessionRepositoryInterface } from '../Session/EphemeralSessionRepositoryInterface' +import { RevokedSessionRepositoryInterface } from '../Session/RevokedSessionRepositoryInterface' +import { SessionRepositoryInterface } from '../Session/SessionRepositoryInterface' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' + +@injectable() +export class AccountDeletionRequestedEventHandler implements DomainEventHandlerInterface { + constructor( + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.SessionRepository) private sessionRepository: SessionRepositoryInterface, + @inject(TYPES.EphemeralSessionRepository) private ephemeralSessionRepository: EphemeralSessionRepositoryInterface, + @inject(TYPES.RevokedSessionRepository) private revokedSessionRepository: RevokedSessionRepositoryInterface, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async handle(event: AccountDeletionRequestedEvent): Promise { + const user = await this.userRepository.findOneByUuid(event.payload.userUuid) + + if (user === null) { + this.logger.warn(`Could not find user with uuid: ${event.payload.userUuid}`) + + return + } + + await this.removeSessions(event.payload.userUuid) + + await this.userRepository.remove(user) + + this.logger.info(`Finished account cleanup for user: ${event.payload.userUuid}`) + } + + private async removeSessions(userUuid: string): Promise { + const sessions = await this.sessionRepository.findAllByUserUuid(userUuid) + for (const session of sessions) { + await this.sessionRepository.remove(session) + } + + const ephemeralSessions = await this.ephemeralSessionRepository.findAllByUserUuid(userUuid) + for (const ephemeralSession of ephemeralSessions) { + await this.ephemeralSessionRepository.deleteOne(ephemeralSession.uuid, ephemeralSession.userUuid) + } + + const revokedSessions = await this.revokedSessionRepository.findAllByUserUuid(userUuid) + for (const revokedSession of revokedSessions) { + await this.revokedSessionRepository.remove(revokedSession) + } + } +} diff --git a/packages/auth/src/Domain/Handler/ExtensionKeyGrantedEventHandler.spec.ts b/packages/auth/src/Domain/Handler/ExtensionKeyGrantedEventHandler.spec.ts new file mode 100644 index 000000000..02daa9290 --- /dev/null +++ b/packages/auth/src/Domain/Handler/ExtensionKeyGrantedEventHandler.spec.ts @@ -0,0 +1,117 @@ +import 'reflect-metadata' + +import { ExtensionKeyGrantedEvent } from '@standardnotes/domain-events' +import { Logger } from 'winston' + +import * as dayjs from 'dayjs' + +import { User } from '../User/User' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { ExtensionKeyGrantedEventHandler } from './ExtensionKeyGrantedEventHandler' +import { SettingServiceInterface } from '../Setting/SettingServiceInterface' +import { OfflineSettingServiceInterface } from '../Setting/OfflineSettingServiceInterface' +import { ContentDecoderInterface, SubscriptionName } from '@standardnotes/common' + +describe('ExtensionKeyGrantedEventHandler', () => { + let userRepository: UserRepositoryInterface + let logger: Logger + let user: User + let event: ExtensionKeyGrantedEvent + let settingService: SettingServiceInterface + let offlineSettingService: OfflineSettingServiceInterface + let contentDecoder: ContentDecoderInterface + let timestamp: number + + const createHandler = () => + new ExtensionKeyGrantedEventHandler(userRepository, settingService, offlineSettingService, contentDecoder, logger) + + beforeEach(() => { + user = { + uuid: '123', + } as jest.Mocked + + userRepository = {} as jest.Mocked + userRepository.findOneByEmail = jest.fn().mockReturnValue(user) + + settingService = {} as jest.Mocked + settingService.createOrReplace = jest.fn() + + offlineSettingService = {} as jest.Mocked + offlineSettingService.createOrUpdate = jest.fn() + + timestamp = dayjs.utc().valueOf() + + event = {} as jest.Mocked + event.createdAt = new Date(1) + event.payload = { + userEmail: 'test@test.com', + extensionKey: 'abc123', + offline: false, + offlineFeaturesToken: 'test', + subscriptionName: SubscriptionName.ProPlan, + origin: 'update-subscription', + timestamp, + payAmount: 1000, + billingEveryNMonths: 1, + activeUntil: new Date(10).toString(), + } + + contentDecoder = {} as jest.Mocked + contentDecoder.decode = jest.fn().mockReturnValue({ + featuresUrl: 'http://features-url', + extensionKey: 'key', + }) + + logger = {} as jest.Mocked + logger.info = jest.fn() + logger.warn = jest.fn() + }) + + it('should add extension key as an user offline features token for offline user setting', async () => { + event.payload.offline = true + + await createHandler().handle(event) + + expect(offlineSettingService.createOrUpdate).toHaveBeenCalledWith({ + email: 'test@test.com', + name: 'FEATURES_TOKEN', + value: 'key', + }) + }) + + it('should add extension key as an user offline features token if not possible to decode', async () => { + event.payload.offline = true + + contentDecoder.decode = jest.fn().mockReturnValue({}) + + await createHandler().handle(event) + + expect(offlineSettingService.createOrUpdate).not.toHaveBeenCalled() + }) + + it('should add extension key as user setting', async () => { + await createHandler().handle(event) + + expect(userRepository.findOneByEmail).toHaveBeenCalledWith('test@test.com') + + expect(settingService.createOrReplace).toHaveBeenCalledWith({ + props: { + name: 'EXTENSION_KEY', + serverEncryptionVersion: 1, + unencryptedValue: 'abc123', + sensitive: true, + }, + user: { + uuid: '123', + }, + }) + }) + + it('should not do anything if no user is found for specified email', async () => { + userRepository.findOneByEmail = jest.fn().mockReturnValue(null) + + await createHandler().handle(event) + + expect(settingService.createOrReplace).not.toHaveBeenCalled() + }) +}) diff --git a/packages/auth/src/Domain/Handler/ExtensionKeyGrantedEventHandler.ts b/packages/auth/src/Domain/Handler/ExtensionKeyGrantedEventHandler.ts new file mode 100644 index 000000000..3ac588be7 --- /dev/null +++ b/packages/auth/src/Domain/Handler/ExtensionKeyGrantedEventHandler.ts @@ -0,0 +1,64 @@ +import { DomainEventHandlerInterface, ExtensionKeyGrantedEvent } from '@standardnotes/domain-events' +import { SettingName } from '@standardnotes/settings' +import { OfflineFeaturesTokenData } from '@standardnotes/auth' +import { ContentDecoderInterface } from '@standardnotes/common' +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' + +import TYPES from '../../Bootstrap/Types' +import { EncryptionVersion } from '../Encryption/EncryptionVersion' +import { OfflineSettingServiceInterface } from '../Setting/OfflineSettingServiceInterface' +import { OfflineSettingName } from '../Setting/OfflineSettingName' +import { SettingServiceInterface } from '../Setting/SettingServiceInterface' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' + +@injectable() +export class ExtensionKeyGrantedEventHandler implements DomainEventHandlerInterface { + constructor( + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.SettingService) private settingService: SettingServiceInterface, + @inject(TYPES.OfflineSettingService) private offlineSettingService: OfflineSettingServiceInterface, + @inject(TYPES.ContenDecoder) private contentDecoder: ContentDecoderInterface, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async handle(event: ExtensionKeyGrantedEvent): Promise { + if (event.payload.offline) { + const offlineFeaturesTokenDecoded = this.contentDecoder.decode( + event.payload.offlineFeaturesToken as string, + 0, + ) as OfflineFeaturesTokenData + + if (!offlineFeaturesTokenDecoded.extensionKey) { + this.logger.warn('Could not decode offline features token') + + return + } + + await this.offlineSettingService.createOrUpdate({ + email: event.payload.userEmail, + name: OfflineSettingName.FeaturesToken, + value: offlineFeaturesTokenDecoded.extensionKey, + }) + + return + } + + const user = await this.userRepository.findOneByEmail(event.payload.userEmail) + + if (user === null) { + this.logger.warn(`Could not find user with email: ${event.payload.userEmail}`) + return + } + + await this.settingService.createOrReplace({ + user, + props: { + name: SettingName.ExtensionKey, + unencryptedValue: event.payload.extensionKey, + serverEncryptionVersion: EncryptionVersion.Default, + sensitive: true, + }, + }) + } +} diff --git a/packages/auth/src/Domain/Handler/FileRemovedEventHandler.spec.ts b/packages/auth/src/Domain/Handler/FileRemovedEventHandler.spec.ts new file mode 100644 index 000000000..66d7f497c --- /dev/null +++ b/packages/auth/src/Domain/Handler/FileRemovedEventHandler.spec.ts @@ -0,0 +1,142 @@ +import 'reflect-metadata' + +import { FileRemovedEvent } from '@standardnotes/domain-events' +import { Logger } from 'winston' + +import { User } from '../User/User' +import { FileRemovedEventHandler } from './FileRemovedEventHandler' +import { SubscriptionSettingServiceInterface } from '../Setting/SubscriptionSettingServiceInterface' +import { UserSubscription } from '../Subscription/UserSubscription' +import { UserSubscriptionType } from '../Subscription/UserSubscriptionType' +import { UserSubscriptionServiceInterface } from '../Subscription/UserSubscriptionServiceInterface' + +describe('FileRemovedEventHandler', () => { + let userSubscriptionService: UserSubscriptionServiceInterface + let logger: Logger + let user: User + let event: FileRemovedEvent + let subscriptionSettingService: SubscriptionSettingServiceInterface + let regularSubscription: UserSubscription + let sharedSubscription: UserSubscription + + const createHandler = () => new FileRemovedEventHandler(userSubscriptionService, subscriptionSettingService, logger) + + beforeEach(() => { + user = { + uuid: '123', + } as jest.Mocked + + regularSubscription = { + uuid: '1-2-3', + subscriptionType: UserSubscriptionType.Regular, + user: Promise.resolve(user), + } as jest.Mocked + + sharedSubscription = { + uuid: '2-3-4', + subscriptionType: UserSubscriptionType.Shared, + user: Promise.resolve(user), + } as jest.Mocked + + userSubscriptionService = {} as jest.Mocked + userSubscriptionService.findRegularSubscriptionForUserUuid = jest + .fn() + .mockReturnValue({ regularSubscription, sharedSubscription: null }) + + subscriptionSettingService = {} as jest.Mocked + subscriptionSettingService.findSubscriptionSettingWithDecryptedValue = jest.fn().mockReturnValue(null) + subscriptionSettingService.createOrReplace = jest.fn() + + event = {} as jest.Mocked + event.createdAt = new Date(1) + event.payload = { + userUuid: '1-2-3', + fileByteSize: 123, + filePath: '1-2-3/2-3-4', + fileName: '2-3-4', + regularSubscriptionUuid: '4-5-6', + } + + logger = {} as jest.Mocked + logger.warn = jest.fn() + }) + + it('should do nothing a bytes used setting does not exist', async () => { + await createHandler().handle(event) + + expect(subscriptionSettingService.createOrReplace).not.toHaveBeenCalled() + }) + + it('should not do anything if a user subscription is not found', async () => { + subscriptionSettingService.findSubscriptionSettingWithDecryptedValue = jest.fn().mockReturnValue({ + value: 345, + }) + userSubscriptionService.findRegularSubscriptionForUserUuid = jest + .fn() + .mockReturnValue({ regularSubscription: null, sharedSubscription: null }) + + await createHandler().handle(event) + + expect(subscriptionSettingService.createOrReplace).not.toHaveBeenCalled() + }) + + it('should update a bytes used setting', async () => { + subscriptionSettingService.findSubscriptionSettingWithDecryptedValue = jest.fn().mockReturnValue({ + value: 345, + }) + await createHandler().handle(event) + + expect(subscriptionSettingService.createOrReplace).toHaveBeenCalledWith({ + props: { + name: 'FILE_UPLOAD_BYTES_USED', + sensitive: false, + unencryptedValue: '222', + serverEncryptionVersion: 0, + }, + userSubscription: { + uuid: '1-2-3', + subscriptionType: 'regular', + user: Promise.resolve(user), + }, + }) + }) + + it('should update a bytes used setting on both shared and regular subscription', async () => { + userSubscriptionService.findRegularSubscriptionForUserUuid = jest + .fn() + .mockReturnValue({ regularSubscription, sharedSubscription }) + + subscriptionSettingService.findSubscriptionSettingWithDecryptedValue = jest.fn().mockReturnValue({ + value: 345, + }) + await createHandler().handle(event) + + expect(subscriptionSettingService.createOrReplace).toHaveBeenNthCalledWith(1, { + props: { + name: 'FILE_UPLOAD_BYTES_USED', + sensitive: false, + unencryptedValue: '222', + serverEncryptionVersion: 0, + }, + userSubscription: { + uuid: '1-2-3', + subscriptionType: 'regular', + user: Promise.resolve(user), + }, + }) + + expect(subscriptionSettingService.createOrReplace).toHaveBeenNthCalledWith(2, { + props: { + name: 'FILE_UPLOAD_BYTES_USED', + sensitive: false, + unencryptedValue: '222', + serverEncryptionVersion: 0, + }, + userSubscription: { + uuid: '2-3-4', + subscriptionType: 'shared', + user: Promise.resolve(user), + }, + }) + }) +}) diff --git a/packages/auth/src/Domain/Handler/FileRemovedEventHandler.ts b/packages/auth/src/Domain/Handler/FileRemovedEventHandler.ts new file mode 100644 index 000000000..e03a9040c --- /dev/null +++ b/packages/auth/src/Domain/Handler/FileRemovedEventHandler.ts @@ -0,0 +1,61 @@ +import { DomainEventHandlerInterface, FileRemovedEvent } from '@standardnotes/domain-events' +import { SubscriptionSettingName } from '@standardnotes/settings' +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' + +import TYPES from '../../Bootstrap/Types' +import { EncryptionVersion } from '../Encryption/EncryptionVersion' +import { SubscriptionSettingServiceInterface } from '../Setting/SubscriptionSettingServiceInterface' +import { UserSubscription } from '../Subscription/UserSubscription' +import { UserSubscriptionServiceInterface } from '../Subscription/UserSubscriptionServiceInterface' + +@injectable() +export class FileRemovedEventHandler implements DomainEventHandlerInterface { + constructor( + @inject(TYPES.UserSubscriptionService) private userSubscriptionService: UserSubscriptionServiceInterface, + @inject(TYPES.SubscriptionSettingService) private subscriptionSettingService: SubscriptionSettingServiceInterface, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async handle(event: FileRemovedEvent): Promise { + const { regularSubscription, sharedSubscription } = + await this.userSubscriptionService.findRegularSubscriptionForUserUuid(event.payload.userUuid) + if (regularSubscription === null) { + this.logger.warn(`Could not find regular user subscription for user with uuid: ${event.payload.userUuid}`) + + return + } + + await this.updateUploadBytesUsedSetting(regularSubscription, event.payload.fileByteSize) + + if (sharedSubscription !== null) { + await this.updateUploadBytesUsedSetting(sharedSubscription, event.payload.fileByteSize) + } + } + + private async updateUploadBytesUsedSetting(subscription: UserSubscription, byteSize: number): Promise { + const user = await subscription.user + const bytesUsedSetting = await this.subscriptionSettingService.findSubscriptionSettingWithDecryptedValue({ + userUuid: user.uuid, + userSubscriptionUuid: subscription.uuid, + subscriptionSettingName: SubscriptionSettingName.FileUploadBytesUsed, + }) + if (bytesUsedSetting === null) { + this.logger.warn(`Could not find bytes used setting for user with uuid: ${user.uuid}`) + + return + } + + const bytesUsed = bytesUsedSetting.value as string + + await this.subscriptionSettingService.createOrReplace({ + userSubscription: subscription, + props: { + name: SubscriptionSettingName.FileUploadBytesUsed, + unencryptedValue: (+bytesUsed - byteSize).toString(), + sensitive: false, + serverEncryptionVersion: EncryptionVersion.Unencrypted, + }, + }) + } +} diff --git a/packages/auth/src/Domain/Handler/FileUploadedEventHandler.spec.ts b/packages/auth/src/Domain/Handler/FileUploadedEventHandler.spec.ts new file mode 100644 index 000000000..13752f260 --- /dev/null +++ b/packages/auth/src/Domain/Handler/FileUploadedEventHandler.spec.ts @@ -0,0 +1,167 @@ +import 'reflect-metadata' + +import { FileUploadedEvent } from '@standardnotes/domain-events' +import { Logger } from 'winston' + +import { User } from '../User/User' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { FileUploadedEventHandler } from './FileUploadedEventHandler' +import { SubscriptionSettingServiceInterface } from '../Setting/SubscriptionSettingServiceInterface' +import { UserSubscription } from '../Subscription/UserSubscription' +import { UserSubscriptionServiceInterface } from '../Subscription/UserSubscriptionServiceInterface' +import { UserSubscriptionType } from '../Subscription/UserSubscriptionType' + +describe('FileUploadedEventHandler', () => { + let userRepository: UserRepositoryInterface + let userSubscriptionService: UserSubscriptionServiceInterface + let logger: Logger + let user: User + let event: FileUploadedEvent + let subscriptionSettingService: SubscriptionSettingServiceInterface + let regularSubscription: UserSubscription + let sharedSubscription: UserSubscription + + const createHandler = () => + new FileUploadedEventHandler(userRepository, userSubscriptionService, subscriptionSettingService, logger) + + beforeEach(() => { + user = { + uuid: '123', + } as jest.Mocked + + userRepository = {} as jest.Mocked + userRepository.findOneByUuid = jest.fn().mockReturnValue(user) + + regularSubscription = { + uuid: '1-2-3', + subscriptionType: UserSubscriptionType.Regular, + user: Promise.resolve(user), + } as jest.Mocked + + sharedSubscription = { + uuid: '2-3-4', + subscriptionType: UserSubscriptionType.Shared, + user: Promise.resolve(user), + } as jest.Mocked + + userSubscriptionService = {} as jest.Mocked + userSubscriptionService.findRegularSubscriptionForUserUuid = jest + .fn() + .mockReturnValue({ regularSubscription, sharedSubscription: null }) + + subscriptionSettingService = {} as jest.Mocked + subscriptionSettingService.findSubscriptionSettingWithDecryptedValue = jest.fn().mockReturnValue(null) + subscriptionSettingService.createOrReplace = jest.fn() + + event = {} as jest.Mocked + event.createdAt = new Date(1) + event.payload = { + userUuid: '1-2-3', + fileByteSize: 123, + filePath: '1-2-3/2-3-4', + fileName: '2-3-4', + } + + logger = {} as jest.Mocked + logger.warn = jest.fn() + }) + + it('should create a bytes used setting if one does not exist', async () => { + await createHandler().handle(event) + + expect(subscriptionSettingService.createOrReplace).toHaveBeenCalledWith({ + props: { + name: 'FILE_UPLOAD_BYTES_USED', + sensitive: false, + unencryptedValue: '123', + serverEncryptionVersion: 0, + }, + userSubscription: { + uuid: '1-2-3', + subscriptionType: 'regular', + user: Promise.resolve(user), + }, + }) + }) + + it('should not do anything if a user is not found', async () => { + userRepository.findOneByUuid = jest.fn().mockReturnValue(null) + + await createHandler().handle(event) + + expect(subscriptionSettingService.createOrReplace).not.toHaveBeenCalled() + }) + + it('should not do anything if a user subscription is not found', async () => { + subscriptionSettingService.findSubscriptionSettingWithDecryptedValue = jest.fn().mockReturnValue({ + value: 345, + }) + userSubscriptionService.findRegularSubscriptionForUserUuid = jest + .fn() + .mockReturnValue({ regularSubscription: null, sharedSubscription: null }) + + await createHandler().handle(event) + + expect(subscriptionSettingService.createOrReplace).not.toHaveBeenCalled() + }) + + it('should update a bytes used setting if one does exist', async () => { + subscriptionSettingService.findSubscriptionSettingWithDecryptedValue = jest.fn().mockReturnValue({ + value: 345, + }) + await createHandler().handle(event) + + expect(subscriptionSettingService.createOrReplace).toHaveBeenCalledWith({ + props: { + name: 'FILE_UPLOAD_BYTES_USED', + sensitive: false, + unencryptedValue: '468', + serverEncryptionVersion: 0, + }, + userSubscription: { + uuid: '1-2-3', + subscriptionType: 'regular', + user: Promise.resolve(user), + }, + }) + }) + + it('should update a bytes used setting on both regular and shared subscription', async () => { + userSubscriptionService.findRegularSubscriptionForUserUuid = jest + .fn() + .mockReturnValue({ regularSubscription, sharedSubscription }) + + subscriptionSettingService.findSubscriptionSettingWithDecryptedValue = jest.fn().mockReturnValue({ + value: 345, + }) + await createHandler().handle(event) + + expect(subscriptionSettingService.createOrReplace).toHaveBeenCalledWith({ + props: { + name: 'FILE_UPLOAD_BYTES_USED', + sensitive: false, + unencryptedValue: '468', + serverEncryptionVersion: 0, + }, + userSubscription: { + uuid: '1-2-3', + subscriptionType: 'regular', + user: Promise.resolve(user), + }, + }) + + expect(subscriptionSettingService.createOrReplace).toHaveBeenCalledWith({ + props: { + name: 'FILE_UPLOAD_BYTES_USED', + sensitive: false, + unencryptedValue: '468', + serverEncryptionVersion: 0, + }, + userSubscription: { + uuid: '2-3-4', + subscriptionType: 'shared', + user: Promise.resolve(user), + }, + }) + }) +}) diff --git a/packages/auth/src/Domain/Handler/FileUploadedEventHandler.ts b/packages/auth/src/Domain/Handler/FileUploadedEventHandler.ts new file mode 100644 index 000000000..714a8dc04 --- /dev/null +++ b/packages/auth/src/Domain/Handler/FileUploadedEventHandler.ts @@ -0,0 +1,66 @@ +import { DomainEventHandlerInterface, FileUploadedEvent } from '@standardnotes/domain-events' +import { SubscriptionSettingName } from '@standardnotes/settings' +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' + +import TYPES from '../../Bootstrap/Types' +import { EncryptionVersion } from '../Encryption/EncryptionVersion' +import { SubscriptionSettingServiceInterface } from '../Setting/SubscriptionSettingServiceInterface' +import { UserSubscription } from '../Subscription/UserSubscription' +import { UserSubscriptionServiceInterface } from '../Subscription/UserSubscriptionServiceInterface' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' + +@injectable() +export class FileUploadedEventHandler implements DomainEventHandlerInterface { + constructor( + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.UserSubscriptionService) private userSubscriptionService: UserSubscriptionServiceInterface, + @inject(TYPES.SubscriptionSettingService) private subscriptionSettingService: SubscriptionSettingServiceInterface, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async handle(event: FileUploadedEvent): Promise { + const user = await this.userRepository.findOneByUuid(event.payload.userUuid) + if (user === null) { + this.logger.warn(`Could not find user with uuid: ${event.payload.userUuid}`) + + return + } + + const { regularSubscription, sharedSubscription } = + await this.userSubscriptionService.findRegularSubscriptionForUserUuid(event.payload.userUuid) + if (regularSubscription === null) { + this.logger.warn(`Could not find regular user subscription for user with uuid: ${event.payload.userUuid}`) + + return + } + + await this.updateUploadBytesUsedSetting(regularSubscription, event.payload.fileByteSize) + + if (sharedSubscription !== null) { + await this.updateUploadBytesUsedSetting(sharedSubscription, event.payload.fileByteSize) + } + } + + private async updateUploadBytesUsedSetting(subscription: UserSubscription, byteSize: number): Promise { + let bytesUsed = '0' + const bytesUsedSetting = await this.subscriptionSettingService.findSubscriptionSettingWithDecryptedValue({ + userUuid: (await subscription.user).uuid, + userSubscriptionUuid: subscription.uuid, + subscriptionSettingName: SubscriptionSettingName.FileUploadBytesUsed, + }) + if (bytesUsedSetting !== null) { + bytesUsed = bytesUsedSetting.value as string + } + + await this.subscriptionSettingService.createOrReplace({ + userSubscription: subscription, + props: { + name: SubscriptionSettingName.FileUploadBytesUsed, + unencryptedValue: (+bytesUsed + byteSize).toString(), + sensitive: false, + serverEncryptionVersion: EncryptionVersion.Unencrypted, + }, + }) + } +} diff --git a/packages/auth/src/Domain/Handler/ListedAccountCreatedEventHandler.spec.ts b/packages/auth/src/Domain/Handler/ListedAccountCreatedEventHandler.spec.ts new file mode 100644 index 000000000..c669cbb43 --- /dev/null +++ b/packages/auth/src/Domain/Handler/ListedAccountCreatedEventHandler.spec.ts @@ -0,0 +1,81 @@ +import 'reflect-metadata' +import { ListedAccountCreatedEvent } from '@standardnotes/domain-events' +import { Logger } from 'winston' + +import { ListedAccountCreatedEventHandler } from './ListedAccountCreatedEventHandler' +import { SettingServiceInterface } from '../Setting/SettingServiceInterface' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { User } from '../User/User' +import { Setting } from '../Setting/Setting' + +describe('ListedAccountCreatedEventHandler', () => { + let settingService: SettingServiceInterface + let userRepository: UserRepositoryInterface + let event: ListedAccountCreatedEvent + let user: User + let logger: Logger + + const createHandler = () => new ListedAccountCreatedEventHandler(userRepository, settingService, logger) + + beforeEach(() => { + user = {} as jest.Mocked + + userRepository = {} as jest.Mocked + userRepository.findOneByEmail = jest.fn().mockReturnValue(user) + + settingService = {} as jest.Mocked + settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(null) + settingService.createOrReplace = jest.fn() + + event = {} as jest.Mocked + event.payload = { + userEmail: 'test@test.com', + userId: 1, + userName: 'testuser', + secret: 'new-secret', + hostUrl: 'https://dev.listed.to', + } + + logger = {} as jest.Mocked + logger.warn = jest.fn() + }) + + it('should not save the listed secret if user is not found', async () => { + userRepository.findOneByEmail = jest.fn().mockReturnValue(null) + + await createHandler().handle(event) + + expect(settingService.createOrReplace).not.toHaveBeenCalled() + }) + + it('should save the listed secret as a user setting', async () => { + await createHandler().handle(event) + + expect(settingService.createOrReplace).toHaveBeenCalledWith({ + user, + props: { + name: 'LISTED_AUTHOR_SECRETS', + sensitive: false, + unencryptedValue: '[{"authorId":1,"secret":"new-secret","hostUrl":"https://dev.listed.to"}]', + }, + }) + }) + + it('should add the listed secret as a user setting to an existing list of secrets', async () => { + settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue({ + value: '[{"authorId":2,"secret":"old-secret","hostUrl":"https://dev.listed.to"}]', + } as jest.Mocked) + + await createHandler().handle(event) + + expect(settingService.createOrReplace).toHaveBeenCalledWith({ + user, + props: { + name: 'LISTED_AUTHOR_SECRETS', + sensitive: false, + unencryptedValue: + '[{"authorId":2,"secret":"old-secret","hostUrl":"https://dev.listed.to"},{"authorId":1,"secret":"new-secret","hostUrl":"https://dev.listed.to"}]', + }, + }) + }) +}) diff --git a/packages/auth/src/Domain/Handler/ListedAccountCreatedEventHandler.ts b/packages/auth/src/Domain/Handler/ListedAccountCreatedEventHandler.ts new file mode 100644 index 000000000..1ab394497 --- /dev/null +++ b/packages/auth/src/Domain/Handler/ListedAccountCreatedEventHandler.ts @@ -0,0 +1,49 @@ +import { DomainEventHandlerInterface, ListedAccountCreatedEvent } from '@standardnotes/domain-events' +import { ListedAuthorSecretsData, SettingName } from '@standardnotes/settings' +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' + +import TYPES from '../../Bootstrap/Types' +import { SettingServiceInterface } from '../Setting/SettingServiceInterface' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' + +@injectable() +export class ListedAccountCreatedEventHandler implements DomainEventHandlerInterface { + constructor( + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.SettingService) private settingService: SettingServiceInterface, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async handle(event: ListedAccountCreatedEvent): Promise { + const user = await this.userRepository.findOneByEmail(event.payload.userEmail) + if (user === null) { + this.logger.warn(`Could not find user with email ${event.payload.userEmail}`) + + return + } + + const newSecret = { authorId: event.payload.userId, secret: event.payload.secret, hostUrl: event.payload.hostUrl } + + let authSecrets: ListedAuthorSecretsData = [newSecret] + + const listedAuthorSecretsSetting = await this.settingService.findSettingWithDecryptedValue({ + settingName: SettingName.ListedAuthorSecrets, + userUuid: user.uuid, + }) + if (listedAuthorSecretsSetting !== null) { + const existingSecrets: ListedAuthorSecretsData = JSON.parse(listedAuthorSecretsSetting.value as string) + existingSecrets.push(newSecret) + authSecrets = existingSecrets + } + + await this.settingService.createOrReplace({ + user, + props: { + name: SettingName.ListedAuthorSecrets, + unencryptedValue: JSON.stringify(authSecrets), + sensitive: false, + }, + }) + } +} diff --git a/packages/auth/src/Domain/Handler/ListedAccountDeletedEventHandler.spec.ts b/packages/auth/src/Domain/Handler/ListedAccountDeletedEventHandler.spec.ts new file mode 100644 index 000000000..16eb0d405 --- /dev/null +++ b/packages/auth/src/Domain/Handler/ListedAccountDeletedEventHandler.spec.ts @@ -0,0 +1,92 @@ +import 'reflect-metadata' +import { ListedAccountDeletedEvent } from '@standardnotes/domain-events' +import { Logger } from 'winston' + +import { ListedAccountDeletedEventHandler } from './ListedAccountDeletedEventHandler' +import { SettingServiceInterface } from '../Setting/SettingServiceInterface' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { User } from '../User/User' +import { Setting } from '../Setting/Setting' + +describe('ListedAccountDeletedEventHandler', () => { + let settingService: SettingServiceInterface + let userRepository: UserRepositoryInterface + let event: ListedAccountDeletedEvent + let user: User + let logger: Logger + + const createHandler = () => new ListedAccountDeletedEventHandler(userRepository, settingService, logger) + + beforeEach(() => { + user = {} as jest.Mocked + + userRepository = {} as jest.Mocked + userRepository.findOneByEmail = jest.fn().mockReturnValue(user) + + settingService = {} as jest.Mocked + settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue({ + value: '[{"authorId":1,"secret":"my-secret","hostUrl":"https://dev.listed.to"}]', + } as jest.Mocked) + settingService.createOrReplace = jest.fn() + + event = {} as jest.Mocked + event.payload = { + userEmail: 'test@test.com', + userId: 1, + userName: 'testuser', + secret: 'my-secret', + hostUrl: 'https://dev.listed.to', + } + + logger = {} as jest.Mocked + logger.warn = jest.fn() + }) + + it('should not remove the listed secret if user is not found', async () => { + userRepository.findOneByEmail = jest.fn().mockReturnValue(null) + + await createHandler().handle(event) + + expect(settingService.createOrReplace).not.toHaveBeenCalled() + }) + + it('should not remove the listed secret if setting is not found', async () => { + settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(null) + + await createHandler().handle(event) + + expect(settingService.createOrReplace).not.toHaveBeenCalled() + }) + + it('should remove the listed secret from the user setting', async () => { + await createHandler().handle(event) + + expect(settingService.createOrReplace).toHaveBeenCalledWith({ + user, + props: { + name: 'LISTED_AUTHOR_SECRETS', + sensitive: false, + unencryptedValue: '[]', + }, + }) + }) + + it('should remove the listed secret from an existing list of secrets', async () => { + settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue({ + value: + '[{"authorId":2,"secret":"old-secret","hostUrl":"https://dev.listed.to"},{"authorId":1,"secret":"my-secret","hostUrl":"https://dev.listed.to"},{"authorId":1,"secret":"my-secret","hostUrl":"https://local.listed.to"}]', + } as jest.Mocked) + + await createHandler().handle(event) + + expect(settingService.createOrReplace).toHaveBeenCalledWith({ + user, + props: { + name: 'LISTED_AUTHOR_SECRETS', + sensitive: false, + unencryptedValue: + '[{"authorId":2,"secret":"old-secret","hostUrl":"https://dev.listed.to"},{"authorId":1,"secret":"my-secret","hostUrl":"https://local.listed.to"}]', + }, + }) + }) +}) diff --git a/packages/auth/src/Domain/Handler/ListedAccountDeletedEventHandler.ts b/packages/auth/src/Domain/Handler/ListedAccountDeletedEventHandler.ts new file mode 100644 index 000000000..945a73728 --- /dev/null +++ b/packages/auth/src/Domain/Handler/ListedAccountDeletedEventHandler.ts @@ -0,0 +1,52 @@ +import { DomainEventHandlerInterface, ListedAccountDeletedEvent } from '@standardnotes/domain-events' +import { ListedAuthorSecretsData, SettingName } from '@standardnotes/settings' +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' + +import TYPES from '../../Bootstrap/Types' +import { SettingServiceInterface } from '../Setting/SettingServiceInterface' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' + +@injectable() +export class ListedAccountDeletedEventHandler implements DomainEventHandlerInterface { + constructor( + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.SettingService) private settingService: SettingServiceInterface, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async handle(event: ListedAccountDeletedEvent): Promise { + const user = await this.userRepository.findOneByEmail(event.payload.userEmail) + if (user === null) { + this.logger.warn(`Could not find user with email ${event.payload.userEmail}`) + + return + } + + const listedAuthorSecretsSetting = await this.settingService.findSettingWithDecryptedValue({ + settingName: SettingName.ListedAuthorSecrets, + userUuid: user.uuid, + }) + if (listedAuthorSecretsSetting === null) { + this.logger.warn(`Could not find listed secrets setting for user ${user.uuid}`) + + return + } + + const existingSecrets: ListedAuthorSecretsData = JSON.parse(listedAuthorSecretsSetting.value as string) + const filteredSecrets = existingSecrets.filter( + (secret) => + secret.authorId !== event.payload.userId || + (secret.authorId === event.payload.userId && secret.hostUrl !== event.payload.hostUrl), + ) + + await this.settingService.createOrReplace({ + user, + props: { + name: SettingName.ListedAuthorSecrets, + unencryptedValue: JSON.stringify(filteredSecrets), + sensitive: false, + }, + }) + } +} diff --git a/packages/auth/src/Domain/Handler/PredicateVerificationRequestedEventHandler.spec.ts b/packages/auth/src/Domain/Handler/PredicateVerificationRequestedEventHandler.spec.ts new file mode 100644 index 000000000..946756aba --- /dev/null +++ b/packages/auth/src/Domain/Handler/PredicateVerificationRequestedEventHandler.spec.ts @@ -0,0 +1,115 @@ +import 'reflect-metadata' + +import { + DomainEventPublisherInterface, + DomainEventService, + PredicateVerificationRequestedEvent, + PredicateVerificationRequestedEventPayload, + PredicateVerifiedEvent, +} from '@standardnotes/domain-events' +import { Predicate, PredicateVerificationResult } from '@standardnotes/scheduler' +import { Logger } from 'winston' + +import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface' +import { VerifyPredicate } from '../UseCase/VerifyPredicate/VerifyPredicate' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' + +import { PredicateVerificationRequestedEventHandler } from './PredicateVerificationRequestedEventHandler' +import { User } from '../User/User' + +describe('PredicateVerificationRequestedEventHandler', () => { + let verifyPredicate: VerifyPredicate + let userRepository: UserRepositoryInterface + let domainEventFactory: DomainEventFactoryInterface + let domainEventPublisher: DomainEventPublisherInterface + let logger: Logger + let event: PredicateVerificationRequestedEvent + + const createHandler = () => + new PredicateVerificationRequestedEventHandler( + verifyPredicate, + userRepository, + domainEventFactory, + domainEventPublisher, + logger, + ) + + beforeEach(() => { + verifyPredicate = {} as jest.Mocked + verifyPredicate.execute = jest + .fn() + .mockReturnValue({ predicateVerificationResult: PredicateVerificationResult.Affirmed }) + + userRepository = {} as jest.Mocked + userRepository.findOneByEmail = jest.fn().mockReturnValue({ uuid: '1-2-3' } as jest.Mocked) + + domainEventFactory = {} as jest.Mocked + domainEventFactory.createPredicateVerifiedEvent = jest + .fn() + .mockReturnValue({} as jest.Mocked) + + domainEventPublisher = {} as jest.Mocked + domainEventPublisher.publish = jest.fn() + + logger = {} as jest.Mocked + logger.warn = jest.fn() + logger.info = jest.fn() + + event = {} as jest.Mocked + event.meta = { + correlation: { + userIdentifier: '2-3-4', + userIdentifierType: 'uuid', + }, + origin: DomainEventService.Auth, + } + event.payload = { + predicate: {} as jest.Mocked, + } as jest.Mocked + }) + + it('should verify a predicate by user uuid', async () => { + await createHandler().handle(event) + + expect(verifyPredicate.execute).toHaveBeenCalledWith({ + predicate: event.payload.predicate, + userUuid: '2-3-4', + }) + expect(domainEventPublisher.publish).toHaveBeenCalled() + }) + + it('should verify a predicate by user uuid', async () => { + event.meta = { + correlation: { + userIdentifier: 'test@test.te', + userIdentifierType: 'email', + }, + origin: DomainEventService.Auth, + } + + await createHandler().handle(event) + + expect(verifyPredicate.execute).toHaveBeenCalledWith({ + predicate: event.payload.predicate, + userUuid: '1-2-3', + }) + expect(domainEventPublisher.publish).toHaveBeenCalled() + }) + + it('should not verify a predicate if user is missing', async () => { + event.meta = { + correlation: { + userIdentifier: 'test@test.te', + userIdentifierType: 'email', + }, + origin: DomainEventService.Auth, + } + + userRepository.findOneByEmail = jest.fn().mockReturnValue(null) + + await createHandler().handle(event) + + expect(verifyPredicate.execute).not.toHaveBeenCalled() + expect(domainEventPublisher.publish).not.toHaveBeenCalled() + }) +}) diff --git a/packages/auth/src/Domain/Handler/PredicateVerificationRequestedEventHandler.ts b/packages/auth/src/Domain/Handler/PredicateVerificationRequestedEventHandler.ts new file mode 100644 index 000000000..bd45ec0d2 --- /dev/null +++ b/packages/auth/src/Domain/Handler/PredicateVerificationRequestedEventHandler.ts @@ -0,0 +1,55 @@ +import { + DomainEventHandlerInterface, + DomainEventPublisherInterface, + PredicateVerificationRequestedEvent, +} from '@standardnotes/domain-events' +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' + +import TYPES from '../../Bootstrap/Types' +import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface' +import { VerifyPredicate } from '../UseCase/VerifyPredicate/VerifyPredicate' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' + +@injectable() +export class PredicateVerificationRequestedEventHandler implements DomainEventHandlerInterface { + constructor( + @inject(TYPES.VerifyPredicate) private verifyPredicate: VerifyPredicate, + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface, + @inject(TYPES.DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async handle(event: PredicateVerificationRequestedEvent): Promise { + this.logger.info(`Received verification request of predicate: ${event.payload.predicate.name}`) + + let userUuid = event.meta.correlation.userIdentifier + if (event.meta.correlation.userIdentifierType === 'email') { + const user = await this.userRepository.findOneByEmail(event.meta.correlation.userIdentifier) + if (user === null) { + this.logger.warn(`Could not find user ${event.meta.correlation.userIdentifier} for predicate verification`) + + return + } + userUuid = user.uuid + } + + const { predicateVerificationResult } = await this.verifyPredicate.execute({ + predicate: event.payload.predicate, + userUuid, + }) + + await this.domainEventPublisher.publish( + this.domainEventFactory.createPredicateVerifiedEvent({ + predicate: event.payload.predicate, + predicateVerificationResult, + userUuid, + }), + ) + + this.logger.info( + `Published predicate verification (${predicateVerificationResult}) result for: ${event.payload.predicate.name}`, + ) + } +} diff --git a/packages/auth/src/Domain/Handler/SharedSubscriptionInvitationCreatedEventHandler.spec.ts b/packages/auth/src/Domain/Handler/SharedSubscriptionInvitationCreatedEventHandler.spec.ts new file mode 100644 index 000000000..9068f0659 --- /dev/null +++ b/packages/auth/src/Domain/Handler/SharedSubscriptionInvitationCreatedEventHandler.spec.ts @@ -0,0 +1,45 @@ +import 'reflect-metadata' + +import { SharedSubscriptionInvitationCreatedEvent } from '@standardnotes/domain-events' + +import { InviteeIdentifierType } from '../SharedSubscription/InviteeIdentifierType' +import { AcceptSharedSubscriptionInvitation } from '../UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitation' + +import { SharedSubscriptionInvitationCreatedEventHandler } from './SharedSubscriptionInvitationCreatedEventHandler' + +describe('SharedSubscriptionInvitationCreatedEventHandler', () => { + let acceptSharedSubscriptionInvitation: AcceptSharedSubscriptionInvitation + + const createHandler = () => new SharedSubscriptionInvitationCreatedEventHandler(acceptSharedSubscriptionInvitation) + + beforeEach(() => { + acceptSharedSubscriptionInvitation = {} as jest.Mocked + acceptSharedSubscriptionInvitation.execute = jest.fn() + }) + + it('should accept automatically invitation for hash invitees', async () => { + const event = { + payload: { + inviteeIdentifierType: InviteeIdentifierType.Hash, + sharedSubscriptionInvitationUuid: '1-2-3', + }, + } as jest.Mocked + + await createHandler().handle(event) + + expect(acceptSharedSubscriptionInvitation.execute).toHaveBeenCalled() + }) + + it('should not accept automatically invitation for email invitees', async () => { + const event = { + payload: { + inviteeIdentifierType: InviteeIdentifierType.Email, + sharedSubscriptionInvitationUuid: '1-2-3', + }, + } as jest.Mocked + + await createHandler().handle(event) + + expect(acceptSharedSubscriptionInvitation.execute).not.toHaveBeenCalled() + }) +}) diff --git a/packages/auth/src/Domain/Handler/SharedSubscriptionInvitationCreatedEventHandler.ts b/packages/auth/src/Domain/Handler/SharedSubscriptionInvitationCreatedEventHandler.ts new file mode 100644 index 000000000..e1a4df77a --- /dev/null +++ b/packages/auth/src/Domain/Handler/SharedSubscriptionInvitationCreatedEventHandler.ts @@ -0,0 +1,24 @@ +import { DomainEventHandlerInterface, SharedSubscriptionInvitationCreatedEvent } from '@standardnotes/domain-events' +import { inject, injectable } from 'inversify' + +import TYPES from '../../Bootstrap/Types' +import { InviteeIdentifierType } from '../SharedSubscription/InviteeIdentifierType' +import { AcceptSharedSubscriptionInvitation } from '../UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitation' + +@injectable() +export class SharedSubscriptionInvitationCreatedEventHandler implements DomainEventHandlerInterface { + constructor( + @inject(TYPES.AcceptSharedSubscriptionInvitation) + private acceptSharedSubscriptionInvitation: AcceptSharedSubscriptionInvitation, + ) {} + + async handle(event: SharedSubscriptionInvitationCreatedEvent): Promise { + if (event.payload.inviteeIdentifierType != InviteeIdentifierType.Hash) { + return + } + + await this.acceptSharedSubscriptionInvitation.execute({ + sharedSubscriptionInvitationUuid: event.payload.sharedSubscriptionInvitationUuid, + }) + } +} diff --git a/packages/auth/src/Domain/Handler/SubscriptionCancelledEventHandler.spec.ts b/packages/auth/src/Domain/Handler/SubscriptionCancelledEventHandler.spec.ts new file mode 100644 index 000000000..f996d65ec --- /dev/null +++ b/packages/auth/src/Domain/Handler/SubscriptionCancelledEventHandler.spec.ts @@ -0,0 +1,54 @@ +import 'reflect-metadata' + +import { SubscriptionName } from '@standardnotes/common' +import { SubscriptionCancelledEvent } from '@standardnotes/domain-events' + +import * as dayjs from 'dayjs' + +import { SubscriptionCancelledEventHandler } from './SubscriptionCancelledEventHandler' +import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface' +import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface' + +describe('SubscriptionCancelledEventHandler', () => { + let userSubscriptionRepository: UserSubscriptionRepositoryInterface + let offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface + let event: SubscriptionCancelledEvent + let timestamp: number + + const createHandler = () => + new SubscriptionCancelledEventHandler(userSubscriptionRepository, offlineUserSubscriptionRepository) + + beforeEach(() => { + userSubscriptionRepository = {} as jest.Mocked + userSubscriptionRepository.updateCancelled = jest.fn() + + offlineUserSubscriptionRepository = {} as jest.Mocked + offlineUserSubscriptionRepository.updateCancelled = jest.fn() + + timestamp = dayjs.utc().valueOf() + + event = {} as jest.Mocked + event.createdAt = new Date(1) + event.payload = { + subscriptionId: 1, + userEmail: 'test@test.com', + subscriptionName: SubscriptionName.ProPlan, + timestamp, + offline: false, + } + }) + + it('should update subscription cancelled', async () => { + await createHandler().handle(event) + + expect(userSubscriptionRepository.updateCancelled).toHaveBeenCalledWith(1, true, timestamp) + }) + + it('should update offline subscription cancelled', async () => { + event.payload.offline = true + + await createHandler().handle(event) + + expect(offlineUserSubscriptionRepository.updateCancelled).toHaveBeenCalledWith(1, true, timestamp) + }) +}) diff --git a/packages/auth/src/Domain/Handler/SubscriptionCancelledEventHandler.ts b/packages/auth/src/Domain/Handler/SubscriptionCancelledEventHandler.ts new file mode 100644 index 000000000..16fbf39d3 --- /dev/null +++ b/packages/auth/src/Domain/Handler/SubscriptionCancelledEventHandler.ts @@ -0,0 +1,32 @@ +import { DomainEventHandlerInterface, SubscriptionCancelledEvent } from '@standardnotes/domain-events' +import { inject, injectable } from 'inversify' + +import TYPES from '../../Bootstrap/Types' +import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface' +import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface' + +@injectable() +export class SubscriptionCancelledEventHandler implements DomainEventHandlerInterface { + constructor( + @inject(TYPES.UserSubscriptionRepository) private userSubscriptionRepository: UserSubscriptionRepositoryInterface, + @inject(TYPES.OfflineUserSubscriptionRepository) + private offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface, + ) {} + async handle(event: SubscriptionCancelledEvent): Promise { + if (event.payload.offline) { + await this.updateOfflineSubscriptionCancelled(event.payload.subscriptionId, event.payload.timestamp) + + return + } + + await this.updateSubscriptionCancelled(event.payload.subscriptionId, event.payload.timestamp) + } + + private async updateSubscriptionCancelled(subscriptionId: number, timestamp: number): Promise { + await this.userSubscriptionRepository.updateCancelled(subscriptionId, true, timestamp) + } + + private async updateOfflineSubscriptionCancelled(subscriptionId: number, timestamp: number): Promise { + await this.offlineUserSubscriptionRepository.updateCancelled(subscriptionId, true, timestamp) + } +} diff --git a/packages/auth/src/Domain/Handler/SubscriptionExpiredEventHandler.spec.ts b/packages/auth/src/Domain/Handler/SubscriptionExpiredEventHandler.spec.ts new file mode 100644 index 000000000..4daadf38d --- /dev/null +++ b/packages/auth/src/Domain/Handler/SubscriptionExpiredEventHandler.spec.ts @@ -0,0 +1,110 @@ +import 'reflect-metadata' + +import { RoleName, SubscriptionName } from '@standardnotes/common' +import { SubscriptionExpiredEvent } from '@standardnotes/domain-events' +import { Logger } from 'winston' + +import * as dayjs from 'dayjs' + +import { User } from '../User/User' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { SubscriptionExpiredEventHandler } from './SubscriptionExpiredEventHandler' +import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface' +import { RoleServiceInterface } from '../Role/RoleServiceInterface' +import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface' +import { UserSubscription } from '../Subscription/UserSubscription' + +describe('SubscriptionExpiredEventHandler', () => { + let userRepository: UserRepositoryInterface + let userSubscriptionRepository: UserSubscriptionRepositoryInterface + let offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface + let roleService: RoleServiceInterface + let logger: Logger + let user: User + let event: SubscriptionExpiredEvent + let timestamp: number + + const createHandler = () => + new SubscriptionExpiredEventHandler( + userRepository, + userSubscriptionRepository, + offlineUserSubscriptionRepository, + roleService, + logger, + ) + + beforeEach(() => { + user = { + uuid: '123', + email: 'test@test.com', + roles: Promise.resolve([ + { + name: RoleName.ProUser, + }, + ]), + } as jest.Mocked + + userRepository = {} as jest.Mocked + userRepository.findOneByEmail = jest.fn().mockReturnValue(user) + userRepository.save = jest.fn().mockReturnValue(user) + + userSubscriptionRepository = {} as jest.Mocked + userSubscriptionRepository.updateEndsAt = jest.fn() + userSubscriptionRepository.findBySubscriptionId = jest + .fn() + .mockReturnValue([{ user: Promise.resolve(user) } as jest.Mocked]) + + offlineUserSubscriptionRepository = {} as jest.Mocked + offlineUserSubscriptionRepository.updateEndsAt = jest.fn() + + roleService = {} as jest.Mocked + roleService.removeUserRole = jest.fn() + + timestamp = dayjs.utc().valueOf() + + event = {} as jest.Mocked + event.createdAt = new Date(1) + event.payload = { + subscriptionId: 1, + userEmail: 'test@test.com', + subscriptionName: SubscriptionName.PlusPlan, + timestamp, + offline: false, + } + + logger = {} as jest.Mocked + logger.info = jest.fn() + logger.warn = jest.fn() + }) + + it('should update the user role', async () => { + await createHandler().handle(event) + + expect(userRepository.findOneByEmail).toHaveBeenCalledWith('test@test.com') + expect(roleService.removeUserRole).toHaveBeenCalledWith(user, SubscriptionName.PlusPlan) + }) + + it('should update subscription ends at', async () => { + await createHandler().handle(event) + + expect(userRepository.findOneByEmail).toHaveBeenCalledWith('test@test.com') + expect(userSubscriptionRepository.updateEndsAt).toHaveBeenCalledWith(1, timestamp, timestamp) + }) + + it('should update offline subscription ends at', async () => { + event.payload.offline = true + + await createHandler().handle(event) + + expect(offlineUserSubscriptionRepository.updateEndsAt).toHaveBeenCalledWith(1, timestamp, timestamp) + }) + + it('should not do anything if no user is found for specified email', async () => { + userRepository.findOneByEmail = jest.fn().mockReturnValue(null) + + await createHandler().handle(event) + + expect(roleService.removeUserRole).not.toHaveBeenCalled() + expect(userSubscriptionRepository.updateEndsAt).not.toHaveBeenCalled() + }) +}) diff --git a/packages/auth/src/Domain/Handler/SubscriptionExpiredEventHandler.ts b/packages/auth/src/Domain/Handler/SubscriptionExpiredEventHandler.ts new file mode 100644 index 000000000..6531f5d3a --- /dev/null +++ b/packages/auth/src/Domain/Handler/SubscriptionExpiredEventHandler.ts @@ -0,0 +1,58 @@ +import { SubscriptionName } from '@standardnotes/common' +import { DomainEventHandlerInterface, SubscriptionExpiredEvent } from '@standardnotes/domain-events' +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' + +import TYPES from '../../Bootstrap/Types' +import { RoleServiceInterface } from '../Role/RoleServiceInterface' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface' +import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface' + +@injectable() +export class SubscriptionExpiredEventHandler implements DomainEventHandlerInterface { + constructor( + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.UserSubscriptionRepository) private userSubscriptionRepository: UserSubscriptionRepositoryInterface, + @inject(TYPES.OfflineUserSubscriptionRepository) + private offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface, + @inject(TYPES.RoleService) private roleService: RoleServiceInterface, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async handle(event: SubscriptionExpiredEvent): Promise { + if (event.payload.offline) { + await this.updateOfflineSubscriptionEndsAt(event.payload.subscriptionId, event.payload.timestamp) + + return + } + + const user = await this.userRepository.findOneByEmail(event.payload.userEmail) + + if (user === null) { + this.logger.warn(`Could not find user with email: ${event.payload.userEmail}`) + return + } + + await this.updateSubscriptionEndsAt(event.payload.subscriptionId, event.payload.timestamp) + await this.removeRoleFromSubscriptionUsers(event.payload.subscriptionId, event.payload.subscriptionName) + } + + private async removeRoleFromSubscriptionUsers( + subscriptionId: number, + subscriptionName: SubscriptionName, + ): Promise { + const userSubscriptions = await this.userSubscriptionRepository.findBySubscriptionId(subscriptionId) + for (const userSubscription of userSubscriptions) { + await this.roleService.removeUserRole(await userSubscription.user, subscriptionName) + } + } + + private async updateSubscriptionEndsAt(subscriptionId: number, timestamp: number): Promise { + await this.userSubscriptionRepository.updateEndsAt(subscriptionId, timestamp, timestamp) + } + + private async updateOfflineSubscriptionEndsAt(subscriptionId: number, timestamp: number): Promise { + await this.offlineUserSubscriptionRepository.updateEndsAt(subscriptionId, timestamp, timestamp) + } +} diff --git a/packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.spec.ts b/packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.spec.ts new file mode 100644 index 000000000..f6d18cc5b --- /dev/null +++ b/packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.spec.ts @@ -0,0 +1,161 @@ +import 'reflect-metadata' + +import { RoleName, SubscriptionName } from '@standardnotes/common' +import { SubscriptionPurchasedEvent } from '@standardnotes/domain-events' +import { Logger } from 'winston' + +import * as dayjs from 'dayjs' + +import { RoleServiceInterface } from '../Role/RoleServiceInterface' +import { User } from '../User/User' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface' +import { SubscriptionPurchasedEventHandler } from './SubscriptionPurchasedEventHandler' +import { UserSubscription } from '../Subscription/UserSubscription' +import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface' +import { OfflineUserSubscription } from '../Subscription/OfflineUserSubscription' +import { SubscriptionSettingServiceInterface } from '../Setting/SubscriptionSettingServiceInterface' +import { UserSubscriptionType } from '../Subscription/UserSubscriptionType' + +describe('SubscriptionPurchasedEventHandler', () => { + let userRepository: UserRepositoryInterface + let userSubscriptionRepository: UserSubscriptionRepositoryInterface + let offlineUserSubscription: OfflineUserSubscription + let offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface + let roleService: RoleServiceInterface + let logger: Logger + let user: User + let subscription: UserSubscription + let event: SubscriptionPurchasedEvent + let subscriptionExpiresAt: number + let subscriptionSettingService: SubscriptionSettingServiceInterface + let timestamp: number + + const createHandler = () => + new SubscriptionPurchasedEventHandler( + userRepository, + userSubscriptionRepository, + offlineUserSubscriptionRepository, + roleService, + subscriptionSettingService, + logger, + ) + + beforeEach(() => { + user = { + uuid: '123', + email: 'test@test.com', + roles: Promise.resolve([ + { + name: RoleName.CoreUser, + }, + ]), + } as jest.Mocked + subscription = { + subscriptionType: UserSubscriptionType.Regular, + } as jest.Mocked + + userRepository = {} as jest.Mocked + userRepository.findOneByEmail = jest.fn().mockReturnValue(user) + userRepository.save = jest.fn().mockReturnValue(user) + + userSubscriptionRepository = {} as jest.Mocked + userSubscriptionRepository.save = jest.fn().mockReturnValue(subscription) + + offlineUserSubscription = {} as jest.Mocked + + offlineUserSubscriptionRepository = {} as jest.Mocked + offlineUserSubscriptionRepository.findOneBySubscriptionId = jest.fn().mockReturnValue(offlineUserSubscription) + offlineUserSubscriptionRepository.save = jest.fn().mockReturnValue(offlineUserSubscription) + + roleService = {} as jest.Mocked + roleService.addUserRole = jest.fn() + roleService.setOfflineUserRole = jest.fn() + + subscriptionExpiresAt = timestamp + 365 * 1000 + + event = {} as jest.Mocked + event.createdAt = new Date(1) + event.payload = { + subscriptionId: 1, + userEmail: 'test@test.com', + subscriptionName: SubscriptionName.ProPlan, + subscriptionExpiresAt, + timestamp: dayjs.utc().valueOf(), + offline: false, + } + + subscriptionSettingService = {} as jest.Mocked + subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription = jest.fn() + + logger = {} as jest.Mocked + logger.info = jest.fn() + logger.warn = jest.fn() + }) + + it('should update the user role', async () => { + await createHandler().handle(event) + + expect(userRepository.findOneByEmail).toHaveBeenCalledWith('test@test.com') + expect(roleService.addUserRole).toHaveBeenCalledWith(user, SubscriptionName.ProPlan) + }) + + it('should update user default settings', async () => { + await createHandler().handle(event) + + expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).toHaveBeenCalledWith( + subscription, + SubscriptionName.ProPlan, + ) + }) + + it('should update the offline user role', async () => { + event.payload.offline = true + + await createHandler().handle(event) + + expect(roleService.setOfflineUserRole).toHaveBeenCalledWith(offlineUserSubscription) + }) + + it('should create subscription', async () => { + await createHandler().handle(event) + + subscription.planName = SubscriptionName.ProPlan + subscription.endsAt = subscriptionExpiresAt + subscription.subscriptionId = 1 + subscription.user = Promise.resolve(user) + + expect(userRepository.findOneByEmail).toHaveBeenCalledWith('test@test.com') + expect(userSubscriptionRepository.save).toHaveBeenCalledWith({ + ...subscription, + createdAt: expect.any(Number), + updatedAt: expect.any(Number), + cancelled: false, + }) + }) + + it('should create an offline subscription', async () => { + event.payload.offline = true + + await createHandler().handle(event) + + expect(offlineUserSubscriptionRepository.save).toHaveBeenCalledWith({ + endsAt: subscriptionExpiresAt, + subscriptionId: 1, + planName: 'PRO_PLAN', + email: 'test@test.com', + createdAt: expect.any(Number), + updatedAt: expect.any(Number), + cancelled: false, + }) + }) + + it('should not do anything if no user is found for specified email', async () => { + userRepository.findOneByEmail = jest.fn().mockReturnValue(null) + + await createHandler().handle(event) + + expect(roleService.addUserRole).not.toHaveBeenCalled() + expect(userSubscriptionRepository.save).not.toHaveBeenCalled() + }) +}) diff --git a/packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.ts b/packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.ts new file mode 100644 index 000000000..e397b6702 --- /dev/null +++ b/packages/auth/src/Domain/Handler/SubscriptionPurchasedEventHandler.ts @@ -0,0 +1,109 @@ +import { SubscriptionName } from '@standardnotes/common' +import { DomainEventHandlerInterface, SubscriptionPurchasedEvent } from '@standardnotes/domain-events' +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' + +import TYPES from '../../Bootstrap/Types' +import { RoleServiceInterface } from '../Role/RoleServiceInterface' +import { User } from '../User/User' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { UserSubscription } from '../Subscription/UserSubscription' +import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface' +import { OfflineUserSubscription } from '../Subscription/OfflineUserSubscription' +import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface' +import { UserSubscriptionType } from '../Subscription/UserSubscriptionType' +import { SubscriptionSettingServiceInterface } from '../Setting/SubscriptionSettingServiceInterface' + +@injectable() +export class SubscriptionPurchasedEventHandler implements DomainEventHandlerInterface { + constructor( + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.UserSubscriptionRepository) private userSubscriptionRepository: UserSubscriptionRepositoryInterface, + @inject(TYPES.OfflineUserSubscriptionRepository) + private offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface, + @inject(TYPES.RoleService) private roleService: RoleServiceInterface, + @inject(TYPES.SubscriptionSettingService) private subscriptionSettingService: SubscriptionSettingServiceInterface, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async handle(event: SubscriptionPurchasedEvent): Promise { + if (event.payload.offline) { + const offlineUserSubscription = await this.createOfflineSubscription( + event.payload.subscriptionId, + event.payload.subscriptionName, + event.payload.userEmail, + event.payload.subscriptionExpiresAt, + event.payload.timestamp, + ) + + await this.roleService.setOfflineUserRole(offlineUserSubscription) + + return + } + + const user = await this.userRepository.findOneByEmail(event.payload.userEmail) + + if (user === null) { + this.logger.warn(`Could not find user with email: ${event.payload.userEmail}`) + return + } + + const userSubscription = await this.createSubscription( + event.payload.subscriptionId, + event.payload.subscriptionName, + user, + event.payload.subscriptionExpiresAt, + event.payload.timestamp, + ) + + await this.addUserRole(user, event.payload.subscriptionName) + + await this.subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription( + userSubscription, + event.payload.subscriptionName, + ) + } + + private async addUserRole(user: User, subscriptionName: SubscriptionName): Promise { + await this.roleService.addUserRole(user, subscriptionName) + } + + private async createSubscription( + subscriptionId: number, + subscriptionName: string, + user: User, + subscriptionExpiresAt: number, + timestamp: number, + ): Promise { + const subscription = new UserSubscription() + subscription.planName = subscriptionName + subscription.user = Promise.resolve(user) + subscription.createdAt = timestamp + subscription.updatedAt = timestamp + subscription.endsAt = subscriptionExpiresAt + subscription.cancelled = false + subscription.subscriptionId = subscriptionId + subscription.subscriptionType = UserSubscriptionType.Regular + + return this.userSubscriptionRepository.save(subscription) + } + + private async createOfflineSubscription( + subscriptionId: number, + subscriptionName: string, + email: string, + subscriptionExpiresAt: number, + timestamp: number, + ): Promise { + const subscription = new OfflineUserSubscription() + subscription.planName = subscriptionName + subscription.email = email + subscription.createdAt = timestamp + subscription.updatedAt = timestamp + subscription.endsAt = subscriptionExpiresAt + subscription.cancelled = false + subscription.subscriptionId = subscriptionId + + return this.offlineUserSubscriptionRepository.save(subscription) + } +} diff --git a/packages/auth/src/Domain/Handler/SubscriptionReassignedEventHandler.spec.ts b/packages/auth/src/Domain/Handler/SubscriptionReassignedEventHandler.spec.ts new file mode 100644 index 000000000..dfad0871a --- /dev/null +++ b/packages/auth/src/Domain/Handler/SubscriptionReassignedEventHandler.spec.ts @@ -0,0 +1,154 @@ +import 'reflect-metadata' + +import { RoleName, SubscriptionName } from '@standardnotes/common' +import { SubscriptionReassignedEvent } from '@standardnotes/domain-events' +import { Logger } from 'winston' + +import * as dayjs from 'dayjs' + +import { RoleServiceInterface } from '../Role/RoleServiceInterface' +import { User } from '../User/User' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface' +import { SubscriptionReassignedEventHandler } from './SubscriptionReassignedEventHandler' +import { UserSubscription } from '../Subscription/UserSubscription' +import { SettingServiceInterface } from '../Setting/SettingServiceInterface' +import { UserSubscriptionType } from '../Subscription/UserSubscriptionType' +import { SubscriptionSettingServiceInterface } from '../Setting/SubscriptionSettingServiceInterface' + +describe('SubscriptionReassignedEventHandler', () => { + let userRepository: UserRepositoryInterface + let userSubscriptionRepository: UserSubscriptionRepositoryInterface + let roleService: RoleServiceInterface + let logger: Logger + let user: User + let subscription: UserSubscription + let event: SubscriptionReassignedEvent + let subscriptionExpiresAt: number + let timestamp: number + let settingService: SettingServiceInterface + let subscriptionSettingService: SubscriptionSettingServiceInterface + + const createHandler = () => + new SubscriptionReassignedEventHandler( + userRepository, + userSubscriptionRepository, + roleService, + settingService, + subscriptionSettingService, + logger, + ) + + beforeEach(() => { + user = { + uuid: '123', + email: 'test@test.com', + roles: Promise.resolve([ + { + name: RoleName.CoreUser, + }, + ]), + } as jest.Mocked + subscription = { + subscriptionType: UserSubscriptionType.Regular, + } as jest.Mocked + + userRepository = {} as jest.Mocked + userRepository.findOneByEmail = jest.fn().mockReturnValue(user) + userRepository.save = jest.fn().mockReturnValue(user) + + userSubscriptionRepository = {} as jest.Mocked + userSubscriptionRepository.save = jest.fn().mockReturnValue(subscription) + + roleService = {} as jest.Mocked + roleService.addUserRole = jest.fn() + + subscriptionExpiresAt = timestamp + 365 * 1000 + + event = {} as jest.Mocked + event.createdAt = new Date(1) + event.payload = { + subscriptionId: 1, + offline: false, + extensionKey: 'abc123', + userEmail: 'test@test.com', + subscriptionName: SubscriptionName.ProPlan, + subscriptionExpiresAt, + timestamp: dayjs.utc().valueOf(), + } + + settingService = {} as jest.Mocked + settingService.createOrReplace = jest.fn() + + subscriptionSettingService = {} as jest.Mocked + subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription = jest.fn() + + logger = {} as jest.Mocked + logger.info = jest.fn() + logger.warn = jest.fn() + }) + + it('should update user default settings', async () => { + await createHandler().handle(event) + + expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).toHaveBeenCalledWith( + subscription, + SubscriptionName.ProPlan, + ) + }) + + it('should update the user role', async () => { + await createHandler().handle(event) + + expect(userRepository.findOneByEmail).toHaveBeenCalledWith('test@test.com') + expect(roleService.addUserRole).toHaveBeenCalledWith(user, SubscriptionName.ProPlan) + }) + + it('should create subscription', async () => { + await createHandler().handle(event) + + subscription.planName = SubscriptionName.ProPlan + subscription.endsAt = subscriptionExpiresAt + subscription.subscriptionId = 1 + subscription.user = Promise.resolve(user) + + expect(userRepository.findOneByEmail).toHaveBeenCalledWith('test@test.com') + expect(userSubscriptionRepository.save).toHaveBeenCalledWith({ + ...subscription, + createdAt: expect.any(Number), + updatedAt: expect.any(Number), + cancelled: false, + }) + }) + + it('should create an extension key setting for the user', async () => { + await createHandler().handle(event) + + expect(settingService.createOrReplace).toHaveBeenCalledWith({ + props: { + name: 'EXTENSION_KEY', + serverEncryptionVersion: 1, + unencryptedValue: 'abc123', + sensitive: true, + }, + user: { + uuid: '123', + email: 'test@test.com', + roles: Promise.resolve([ + { + name: RoleName.CoreUser, + }, + ]), + }, + }) + }) + + it('should not do anything if no user is found for specified email', async () => { + userRepository.findOneByEmail = jest.fn().mockReturnValue(null) + + await createHandler().handle(event) + + expect(roleService.addUserRole).not.toHaveBeenCalled() + expect(userSubscriptionRepository.save).not.toHaveBeenCalled() + }) +}) diff --git a/packages/auth/src/Domain/Handler/SubscriptionReassignedEventHandler.ts b/packages/auth/src/Domain/Handler/SubscriptionReassignedEventHandler.ts new file mode 100644 index 000000000..eb8b90a42 --- /dev/null +++ b/packages/auth/src/Domain/Handler/SubscriptionReassignedEventHandler.ts @@ -0,0 +1,87 @@ +import { SubscriptionName } from '@standardnotes/common' +import { DomainEventHandlerInterface, SubscriptionReassignedEvent } from '@standardnotes/domain-events' +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' + +import TYPES from '../../Bootstrap/Types' +import { RoleServiceInterface } from '../Role/RoleServiceInterface' +import { User } from '../User/User' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { UserSubscription } from '../Subscription/UserSubscription' +import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface' +import { SettingServiceInterface } from '../Setting/SettingServiceInterface' +import { SettingName } from '@standardnotes/settings' +import { EncryptionVersion } from '../Encryption/EncryptionVersion' +import { UserSubscriptionType } from '../Subscription/UserSubscriptionType' +import { SubscriptionSettingServiceInterface } from '../Setting/SubscriptionSettingServiceInterface' + +@injectable() +export class SubscriptionReassignedEventHandler implements DomainEventHandlerInterface { + constructor( + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.UserSubscriptionRepository) private userSubscriptionRepository: UserSubscriptionRepositoryInterface, + @inject(TYPES.RoleService) private roleService: RoleServiceInterface, + @inject(TYPES.SettingService) private settingService: SettingServiceInterface, + @inject(TYPES.SubscriptionSettingService) private subscriptionSettingService: SubscriptionSettingServiceInterface, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async handle(event: SubscriptionReassignedEvent): Promise { + const user = await this.userRepository.findOneByEmail(event.payload.userEmail) + + if (user === null) { + this.logger.warn(`Could not find user with email: ${event.payload.userEmail}`) + + return + } + + const userSubscription = await this.createSubscription( + event.payload.subscriptionId, + event.payload.subscriptionName, + user, + event.payload.subscriptionExpiresAt, + event.payload.timestamp, + ) + + await this.addUserRole(user, event.payload.subscriptionName) + + await this.settingService.createOrReplace({ + user, + props: { + name: SettingName.ExtensionKey, + unencryptedValue: event.payload.extensionKey, + serverEncryptionVersion: EncryptionVersion.Default, + sensitive: true, + }, + }) + + await this.subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription( + userSubscription, + event.payload.subscriptionName, + ) + } + + private async addUserRole(user: User, subscriptionName: SubscriptionName): Promise { + await this.roleService.addUserRole(user, subscriptionName) + } + + private async createSubscription( + subscriptionId: number, + subscriptionName: string, + user: User, + subscriptionExpiresAt: number, + timestamp: number, + ): Promise { + const subscription = new UserSubscription() + subscription.planName = subscriptionName + subscription.user = Promise.resolve(user) + subscription.createdAt = timestamp + subscription.updatedAt = timestamp + subscription.endsAt = subscriptionExpiresAt + subscription.cancelled = false + subscription.subscriptionId = subscriptionId + subscription.subscriptionType = UserSubscriptionType.Regular + + return this.userSubscriptionRepository.save(subscription) + } +} diff --git a/packages/auth/src/Domain/Handler/SubscriptionRefundedEventHandler.spec.ts b/packages/auth/src/Domain/Handler/SubscriptionRefundedEventHandler.spec.ts new file mode 100644 index 000000000..af49375d9 --- /dev/null +++ b/packages/auth/src/Domain/Handler/SubscriptionRefundedEventHandler.spec.ts @@ -0,0 +1,110 @@ +import 'reflect-metadata' + +import { RoleName, SubscriptionName } from '@standardnotes/common' +import { SubscriptionRefundedEvent } from '@standardnotes/domain-events' +import { Logger } from 'winston' + +import * as dayjs from 'dayjs' + +import { User } from '../User/User' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { SubscriptionRefundedEventHandler } from './SubscriptionRefundedEventHandler' +import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface' +import { RoleServiceInterface } from '../Role/RoleServiceInterface' +import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface' +import { UserSubscription } from '../Subscription/UserSubscription' + +describe('SubscriptionRefundedEventHandler', () => { + let userRepository: UserRepositoryInterface + let userSubscriptionRepository: UserSubscriptionRepositoryInterface + let offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface + let roleService: RoleServiceInterface + let logger: Logger + let user: User + let event: SubscriptionRefundedEvent + let timestamp: number + + const createHandler = () => + new SubscriptionRefundedEventHandler( + userRepository, + userSubscriptionRepository, + offlineUserSubscriptionRepository, + roleService, + logger, + ) + + beforeEach(() => { + user = { + uuid: '123', + email: 'test@test.com', + roles: Promise.resolve([ + { + name: RoleName.ProUser, + }, + ]), + } as jest.Mocked + + userRepository = {} as jest.Mocked + userRepository.findOneByEmail = jest.fn().mockReturnValue(user) + userRepository.save = jest.fn().mockReturnValue(user) + + userSubscriptionRepository = {} as jest.Mocked + userSubscriptionRepository.updateEndsAt = jest.fn() + userSubscriptionRepository.findBySubscriptionId = jest + .fn() + .mockReturnValue([{ user: Promise.resolve(user) } as jest.Mocked]) + + offlineUserSubscriptionRepository = {} as jest.Mocked + offlineUserSubscriptionRepository.updateEndsAt = jest.fn() + + roleService = {} as jest.Mocked + roleService.removeUserRole = jest.fn() + + timestamp = dayjs.utc().valueOf() + + event = {} as jest.Mocked + event.createdAt = new Date(1) + event.payload = { + subscriptionId: 1, + userEmail: 'test@test.com', + subscriptionName: SubscriptionName.PlusPlan, + timestamp, + offline: false, + } + + logger = {} as jest.Mocked + logger.info = jest.fn() + logger.warn = jest.fn() + }) + + it('should update the user role', async () => { + await createHandler().handle(event) + + expect(userRepository.findOneByEmail).toHaveBeenCalledWith('test@test.com') + expect(roleService.removeUserRole).toHaveBeenCalledWith(user, SubscriptionName.PlusPlan) + }) + + it('should update subscription ends at', async () => { + await createHandler().handle(event) + + expect(userRepository.findOneByEmail).toHaveBeenCalledWith('test@test.com') + expect(userSubscriptionRepository.updateEndsAt).toHaveBeenCalledWith(1, timestamp, timestamp) + }) + + it('should update offline subscription ends at', async () => { + event.payload.offline = true + + await createHandler().handle(event) + + expect(offlineUserSubscriptionRepository.updateEndsAt).toHaveBeenCalledWith(1, timestamp, timestamp) + }) + + it('should not do anything if no user is found for specified email', async () => { + userRepository.findOneByEmail = jest.fn().mockReturnValue(null) + + await createHandler().handle(event) + + expect(roleService.removeUserRole).not.toHaveBeenCalled() + expect(userSubscriptionRepository.updateEndsAt).not.toHaveBeenCalled() + }) +}) diff --git a/packages/auth/src/Domain/Handler/SubscriptionRefundedEventHandler.ts b/packages/auth/src/Domain/Handler/SubscriptionRefundedEventHandler.ts new file mode 100644 index 000000000..952a784ce --- /dev/null +++ b/packages/auth/src/Domain/Handler/SubscriptionRefundedEventHandler.ts @@ -0,0 +1,58 @@ +import { SubscriptionName } from '@standardnotes/common' +import { DomainEventHandlerInterface, SubscriptionRefundedEvent } from '@standardnotes/domain-events' +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' + +import TYPES from '../../Bootstrap/Types' +import { RoleServiceInterface } from '../Role/RoleServiceInterface' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface' +import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface' + +@injectable() +export class SubscriptionRefundedEventHandler implements DomainEventHandlerInterface { + constructor( + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.UserSubscriptionRepository) private userSubscriptionRepository: UserSubscriptionRepositoryInterface, + @inject(TYPES.OfflineUserSubscriptionRepository) + private offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface, + @inject(TYPES.RoleService) private roleService: RoleServiceInterface, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async handle(event: SubscriptionRefundedEvent): Promise { + if (event.payload.offline) { + await this.updateOfflineSubscriptionEndsAt(event.payload.subscriptionId, event.payload.timestamp) + + return + } + + const user = await this.userRepository.findOneByEmail(event.payload.userEmail) + + if (user === null) { + this.logger.warn(`Could not find user with email: ${event.payload.userEmail}`) + return + } + + await this.updateSubscriptionEndsAt(event.payload.subscriptionId, event.payload.timestamp) + await this.removeRoleFromSubscriptionUsers(event.payload.subscriptionId, event.payload.subscriptionName) + } + + private async removeRoleFromSubscriptionUsers( + subscriptionId: number, + subscriptionName: SubscriptionName, + ): Promise { + const userSubscriptions = await this.userSubscriptionRepository.findBySubscriptionId(subscriptionId) + for (const userSubscription of userSubscriptions) { + await this.roleService.removeUserRole(await userSubscription.user, subscriptionName) + } + } + + private async updateSubscriptionEndsAt(subscriptionId: number, timestamp: number): Promise { + await this.userSubscriptionRepository.updateEndsAt(subscriptionId, timestamp, timestamp) + } + + private async updateOfflineSubscriptionEndsAt(subscriptionId: number, timestamp: number): Promise { + await this.offlineUserSubscriptionRepository.updateEndsAt(subscriptionId, timestamp, timestamp) + } +} diff --git a/packages/auth/src/Domain/Handler/SubscriptionRenewedEventHandler.spec.ts b/packages/auth/src/Domain/Handler/SubscriptionRenewedEventHandler.spec.ts new file mode 100644 index 000000000..3a7300b9f --- /dev/null +++ b/packages/auth/src/Domain/Handler/SubscriptionRenewedEventHandler.spec.ts @@ -0,0 +1,138 @@ +import 'reflect-metadata' + +import { RoleName, SubscriptionName } from '@standardnotes/common' +import { SubscriptionRenewedEvent } from '@standardnotes/domain-events' +import * as dayjs from 'dayjs' +import { Logger } from 'winston' + +import { SubscriptionRenewedEventHandler } from './SubscriptionRenewedEventHandler' +import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface' +import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface' +import { User } from '../User/User' +import { UserSubscription } from '../Subscription/UserSubscription' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { OfflineUserSubscription } from '../Subscription/OfflineUserSubscription' +import { RoleServiceInterface } from '../Role/RoleServiceInterface' + +describe('SubscriptionRenewedEventHandler', () => { + let userRepository: UserRepositoryInterface + let userSubscriptionRepository: UserSubscriptionRepositoryInterface + let offlineUserSubscription: OfflineUserSubscription + let offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface + let roleService: RoleServiceInterface + let logger: Logger + let user: User + let subscription: UserSubscription + let event: SubscriptionRenewedEvent + let subscriptionExpiresAt: number + let timestamp: number + + const createHandler = () => + new SubscriptionRenewedEventHandler( + userRepository, + userSubscriptionRepository, + offlineUserSubscriptionRepository, + roleService, + logger, + ) + + beforeEach(() => { + user = { + uuid: '123', + email: 'test@test.com', + roles: Promise.resolve([ + { + name: RoleName.CoreUser, + }, + ]), + } as jest.Mocked + subscription = {} as jest.Mocked + + userRepository = {} as jest.Mocked + userRepository.findOneByEmail = jest.fn().mockReturnValue(user) + userRepository.save = jest.fn().mockReturnValue(user) + + userSubscriptionRepository = {} as jest.Mocked + userSubscriptionRepository.updateEndsAt = jest.fn() + userSubscriptionRepository.save = jest.fn().mockReturnValue(subscription) + userSubscriptionRepository.findBySubscriptionId = jest + .fn() + .mockReturnValue([{ user: Promise.resolve(user) } as jest.Mocked]) + + offlineUserSubscription = {} as jest.Mocked + + offlineUserSubscriptionRepository = {} as jest.Mocked + offlineUserSubscriptionRepository.findOneBySubscriptionId = jest.fn().mockReturnValue(offlineUserSubscription) + offlineUserSubscriptionRepository.save = jest.fn().mockReturnValue(offlineUserSubscription) + + roleService = {} as jest.Mocked + roleService.addUserRole = jest.fn() + roleService.setOfflineUserRole = jest.fn() + + timestamp = dayjs.utc().valueOf() + subscriptionExpiresAt = dayjs.utc().valueOf() + 365 * 1000 + + event = {} as jest.Mocked + event.createdAt = new Date(1) + event.payload = { + subscriptionId: 1, + userEmail: 'test@test.com', + subscriptionName: SubscriptionName.ProPlan, + subscriptionExpiresAt, + timestamp, + offline: false, + } + + logger = {} as jest.Mocked + logger.warn = jest.fn() + }) + + it('should update subscription ends at', async () => { + await createHandler().handle(event) + + expect(userSubscriptionRepository.updateEndsAt).toHaveBeenCalledWith(1, subscriptionExpiresAt, timestamp) + }) + + it('should update offline subscription ends at', async () => { + event.payload.offline = true + + await createHandler().handle(event) + + expect(offlineUserSubscriptionRepository.save).toHaveBeenCalledWith(offlineUserSubscription) + }) + + it('should update the user role', async () => { + await createHandler().handle(event) + + expect(userRepository.findOneByEmail).toHaveBeenCalledWith('test@test.com') + expect(roleService.addUserRole).toHaveBeenCalledWith(user, SubscriptionName.ProPlan) + }) + + it('should update the offline user role', async () => { + event.payload.offline = true + + await createHandler().handle(event) + + expect(roleService.setOfflineUserRole).toHaveBeenCalledWith(offlineUserSubscription) + }) + + it('should not do anything if no user is found for specified email', async () => { + userRepository.findOneByEmail = jest.fn().mockReturnValue(null) + + await createHandler().handle(event) + + expect(roleService.addUserRole).not.toHaveBeenCalled() + expect(userSubscriptionRepository.save).not.toHaveBeenCalled() + }) + + it('should not do anything if no offline subscription is found for specified id', async () => { + event.payload.offline = true + + offlineUserSubscriptionRepository.findOneBySubscriptionId = jest.fn().mockReturnValue(null) + + await createHandler().handle(event) + + expect(roleService.addUserRole).not.toHaveBeenCalled() + expect(userSubscriptionRepository.save).not.toHaveBeenCalled() + }) +}) diff --git a/packages/auth/src/Domain/Handler/SubscriptionRenewedEventHandler.ts b/packages/auth/src/Domain/Handler/SubscriptionRenewedEventHandler.ts new file mode 100644 index 000000000..6c850c446 --- /dev/null +++ b/packages/auth/src/Domain/Handler/SubscriptionRenewedEventHandler.ts @@ -0,0 +1,86 @@ +import { DomainEventHandlerInterface, SubscriptionRenewedEvent } from '@standardnotes/domain-events' +import { inject, injectable } from 'inversify' + +import TYPES from '../../Bootstrap/Types' +import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface' +import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface' +import { SubscriptionName } from '@standardnotes/common' +import { RoleServiceInterface } from '../Role/RoleServiceInterface' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { Logger } from 'winston' +import { OfflineUserSubscription } from '../Subscription/OfflineUserSubscription' + +@injectable() +export class SubscriptionRenewedEventHandler implements DomainEventHandlerInterface { + constructor( + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.UserSubscriptionRepository) private userSubscriptionRepository: UserSubscriptionRepositoryInterface, + @inject(TYPES.OfflineUserSubscriptionRepository) + private offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface, + @inject(TYPES.RoleService) private roleService: RoleServiceInterface, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async handle(event: SubscriptionRenewedEvent): Promise { + if (event.payload.offline) { + const offlineUserSubscription = await this.offlineUserSubscriptionRepository.findOneBySubscriptionId( + event.payload.subscriptionId, + ) + + if (offlineUserSubscription === null) { + this.logger.warn(`Could not find offline user subscription with id: ${event.payload.subscriptionId}`) + + return + } + + await this.updateOfflineSubscriptionEndsAt(offlineUserSubscription, event.payload.timestamp) + + await this.roleService.setOfflineUserRole(offlineUserSubscription) + + return + } + + await this.updateSubscriptionEndsAt( + event.payload.subscriptionId, + event.payload.subscriptionExpiresAt, + event.payload.timestamp, + ) + + const user = await this.userRepository.findOneByEmail(event.payload.userEmail) + + if (user === null) { + this.logger.warn(`Could not find user with email: ${event.payload.userEmail}`) + + return + } + + await this.addRoleToSubscriptionUsers(event.payload.subscriptionId, event.payload.subscriptionName) + } + + private async addRoleToSubscriptionUsers(subscriptionId: number, subscriptionName: SubscriptionName): Promise { + const userSubscriptions = await this.userSubscriptionRepository.findBySubscriptionId(subscriptionId) + for (const userSubscription of userSubscriptions) { + const user = await userSubscription.user + + await this.roleService.addUserRole(user, subscriptionName) + } + } + + private async updateSubscriptionEndsAt( + subscriptionId: number, + subscriptionExpiresAt: number, + timestamp: number, + ): Promise { + await this.userSubscriptionRepository.updateEndsAt(subscriptionId, subscriptionExpiresAt, timestamp) + } + + private async updateOfflineSubscriptionEndsAt( + offlineUserSubscription: OfflineUserSubscription, + timestamp: number, + ): Promise { + offlineUserSubscription.endsAt = timestamp + offlineUserSubscription.updatedAt = timestamp + + await this.offlineUserSubscriptionRepository.save(offlineUserSubscription) + } +} diff --git a/packages/auth/src/Domain/Handler/SubscriptionSyncRequestedEventHandler.spec.ts b/packages/auth/src/Domain/Handler/SubscriptionSyncRequestedEventHandler.spec.ts new file mode 100644 index 000000000..bafd37075 --- /dev/null +++ b/packages/auth/src/Domain/Handler/SubscriptionSyncRequestedEventHandler.spec.ts @@ -0,0 +1,252 @@ +import 'reflect-metadata' + +import { ContentDecoderInterface, RoleName, SubscriptionName } from '@standardnotes/common' +import { SubscriptionSyncRequestedEvent } from '@standardnotes/domain-events' +import { Logger } from 'winston' + +import * as dayjs from 'dayjs' + +import { RoleServiceInterface } from '../Role/RoleServiceInterface' +import { User } from '../User/User' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface' +import { SubscriptionSyncRequestedEventHandler } from './SubscriptionSyncRequestedEventHandler' +import { UserSubscription } from '../Subscription/UserSubscription' +import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface' +import { OfflineUserSubscription } from '../Subscription/OfflineUserSubscription' +import { SettingServiceInterface } from '../Setting/SettingServiceInterface' +import { OfflineSettingServiceInterface } from '../Setting/OfflineSettingServiceInterface' +import { UserSubscriptionType } from '../Subscription/UserSubscriptionType' +import { SubscriptionSettingServiceInterface } from '../Setting/SubscriptionSettingServiceInterface' + +describe('SubscriptionSyncRequestedEventHandler', () => { + let userRepository: UserRepositoryInterface + let userSubscriptionRepository: UserSubscriptionRepositoryInterface + let offlineUserSubscription: OfflineUserSubscription + let offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface + let roleService: RoleServiceInterface + let logger: Logger + let user: User + let subscription: UserSubscription + let event: SubscriptionSyncRequestedEvent + let subscriptionExpiresAt: number + let settingService: SettingServiceInterface + let subscriptionSettingService: SubscriptionSettingServiceInterface + let timestamp: number + let offlineSettingService: OfflineSettingServiceInterface + let contentDecoder: ContentDecoderInterface + + const createHandler = () => + new SubscriptionSyncRequestedEventHandler( + userRepository, + userSubscriptionRepository, + offlineUserSubscriptionRepository, + roleService, + settingService, + subscriptionSettingService, + offlineSettingService, + contentDecoder, + logger, + ) + + beforeEach(() => { + user = { + uuid: '123', + email: 'test@test.com', + roles: Promise.resolve([ + { + name: RoleName.CoreUser, + }, + ]), + } as jest.Mocked + subscription = { + subscriptionType: UserSubscriptionType.Regular, + } as jest.Mocked + + userRepository = {} as jest.Mocked + userRepository.findOneByEmail = jest.fn().mockReturnValue(user) + userRepository.save = jest.fn().mockReturnValue(user) + + userSubscriptionRepository = {} as jest.Mocked + userSubscriptionRepository.save = jest.fn().mockReturnValue(subscription) + userSubscriptionRepository.findBySubscriptionIdAndType = jest.fn().mockReturnValue([]) + + offlineUserSubscription = {} as jest.Mocked + + offlineUserSubscriptionRepository = {} as jest.Mocked + offlineUserSubscriptionRepository.findOneBySubscriptionId = jest.fn().mockReturnValue(null) + offlineUserSubscriptionRepository.save = jest.fn().mockReturnValue(offlineUserSubscription) + + offlineSettingService = {} as jest.Mocked + offlineSettingService.createOrUpdate = jest.fn() + + contentDecoder = {} as jest.Mocked + contentDecoder.decode = jest.fn().mockReturnValue({ + featuresUrl: 'http://features-url', + extensionKey: 'key', + }) + + roleService = {} as jest.Mocked + roleService.addUserRole = jest.fn() + roleService.setOfflineUserRole = jest.fn() + + subscriptionExpiresAt = timestamp + 365 * 1000 + + event = {} as jest.Mocked + event.createdAt = new Date(1) + event.payload = { + subscriptionId: 1, + userEmail: 'test@test.com', + subscriptionName: SubscriptionName.ProPlan, + subscriptionExpiresAt, + timestamp: dayjs.utc().valueOf(), + offline: false, + extensionKey: 'abc123', + offlineFeaturesToken: 'test', + canceled: false, + } + + settingService = {} as jest.Mocked + settingService.createOrReplace = jest.fn() + + subscriptionSettingService = {} as jest.Mocked + subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription = jest.fn() + + logger = {} as jest.Mocked + logger.info = jest.fn() + logger.warn = jest.fn() + }) + + it('should update the user role', async () => { + await createHandler().handle(event) + + expect(userRepository.findOneByEmail).toHaveBeenCalledWith('test@test.com') + expect(roleService.addUserRole).toHaveBeenCalledWith(user, SubscriptionName.ProPlan) + }) + + it('should update user default settings', async () => { + await createHandler().handle(event) + + expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).toHaveBeenCalledWith( + subscription, + SubscriptionName.ProPlan, + ) + + expect(settingService.createOrReplace).toHaveBeenCalledWith({ + props: { + name: 'EXTENSION_KEY', + serverEncryptionVersion: 1, + unencryptedValue: 'abc123', + sensitive: true, + }, + user: { + email: 'test@test.com', + roles: Promise.resolve([ + { + name: RoleName.CoreUser, + }, + ]), + uuid: '123', + }, + }) + }) + + it('should update the offline user role', async () => { + event.payload.offline = true + + await createHandler().handle(event) + + expect(roleService.setOfflineUserRole).toHaveBeenCalledWith(offlineUserSubscription) + }) + + it('should not update the offline user features token if it is not possible to decode the extension key', async () => { + event.payload.offline = true + + contentDecoder.decode = jest.fn().mockReturnValue({}) + + await createHandler().handle(event) + + expect(settingService.createOrReplace).not.toHaveBeenCalled() + }) + + it('should create subscription', async () => { + await createHandler().handle(event) + + subscription.planName = SubscriptionName.ProPlan + subscription.endsAt = subscriptionExpiresAt + subscription.subscriptionId = 1 + subscription.user = Promise.resolve(user) + + expect(userRepository.findOneByEmail).toHaveBeenCalledWith('test@test.com') + expect(userSubscriptionRepository.save).toHaveBeenCalledWith({ + ...subscription, + createdAt: expect.any(Number), + updatedAt: expect.any(Number), + cancelled: false, + }) + }) + + it('should update an existing subscription', async () => { + userSubscriptionRepository.findBySubscriptionIdAndType = jest + .fn() + .mockReturnValue([{} as jest.Mocked]) + await createHandler().handle(event) + + subscription.planName = SubscriptionName.ProPlan + subscription.endsAt = subscriptionExpiresAt + subscription.subscriptionId = 1 + subscription.user = Promise.resolve(user) + + expect(userRepository.findOneByEmail).toHaveBeenCalledWith('test@test.com') + expect(userSubscriptionRepository.save).toHaveBeenCalledWith({ + ...subscription, + createdAt: expect.any(Number), + updatedAt: expect.any(Number), + cancelled: false, + }) + }) + + it('should create an offline subscription', async () => { + event.payload.offline = true + + await createHandler().handle(event) + + expect(offlineUserSubscriptionRepository.save).toHaveBeenCalledWith({ + endsAt: subscriptionExpiresAt, + subscriptionId: 1, + planName: 'PRO_PLAN', + email: 'test@test.com', + createdAt: expect.any(Number), + updatedAt: expect.any(Number), + cancelled: false, + }) + }) + + it('should update an offline subscription', async () => { + offlineUserSubscriptionRepository.findOneBySubscriptionId = jest + .fn() + .mockReturnValue({} as jest.Mocked) + event.payload.offline = true + + await createHandler().handle(event) + + expect(offlineUserSubscriptionRepository.save).toHaveBeenCalledWith({ + endsAt: subscriptionExpiresAt, + subscriptionId: 1, + planName: 'PRO_PLAN', + email: 'test@test.com', + createdAt: expect.any(Number), + updatedAt: expect.any(Number), + cancelled: false, + }) + }) + + it('should not do anything if no user is found for specified email', async () => { + userRepository.findOneByEmail = jest.fn().mockReturnValue(null) + + await createHandler().handle(event) + + expect(roleService.addUserRole).not.toHaveBeenCalled() + expect(userSubscriptionRepository.save).not.toHaveBeenCalled() + }) +}) diff --git a/packages/auth/src/Domain/Handler/SubscriptionSyncRequestedEventHandler.ts b/packages/auth/src/Domain/Handler/SubscriptionSyncRequestedEventHandler.ts new file mode 100644 index 000000000..fe87ae329 --- /dev/null +++ b/packages/auth/src/Domain/Handler/SubscriptionSyncRequestedEventHandler.ts @@ -0,0 +1,158 @@ +import { OfflineFeaturesTokenData } from '@standardnotes/auth' +import { DomainEventHandlerInterface, SubscriptionSyncRequestedEvent } from '@standardnotes/domain-events' +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' + +import TYPES from '../../Bootstrap/Types' +import { RoleServiceInterface } from '../Role/RoleServiceInterface' +import { User } from '../User/User' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { UserSubscription } from '../Subscription/UserSubscription' +import { UserSubscriptionRepositoryInterface } from '../Subscription/UserSubscriptionRepositoryInterface' +import { OfflineUserSubscription } from '../Subscription/OfflineUserSubscription' +import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface' +import { SettingServiceInterface } from '../Setting/SettingServiceInterface' +import { OfflineSettingServiceInterface } from '../Setting/OfflineSettingServiceInterface' +import { ContentDecoderInterface } from '@standardnotes/common' +import { OfflineSettingName } from '../Setting/OfflineSettingName' +import { SettingName } from '@standardnotes/settings' +import { EncryptionVersion } from '../Encryption/EncryptionVersion' +import { UserSubscriptionType } from '../Subscription/UserSubscriptionType' +import { SubscriptionSettingServiceInterface } from '../Setting/SubscriptionSettingServiceInterface' + +@injectable() +export class SubscriptionSyncRequestedEventHandler implements DomainEventHandlerInterface { + constructor( + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.UserSubscriptionRepository) private userSubscriptionRepository: UserSubscriptionRepositoryInterface, + @inject(TYPES.OfflineUserSubscriptionRepository) + private offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface, + @inject(TYPES.RoleService) private roleService: RoleServiceInterface, + @inject(TYPES.SettingService) private settingService: SettingServiceInterface, + @inject(TYPES.SubscriptionSettingService) private subscriptionSettingService: SubscriptionSettingServiceInterface, + @inject(TYPES.OfflineSettingService) private offlineSettingService: OfflineSettingServiceInterface, + @inject(TYPES.ContenDecoder) private contentDecoder: ContentDecoderInterface, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async handle(event: SubscriptionSyncRequestedEvent): Promise { + if (event.payload.offline) { + const offlineUserSubscription = await this.createOrUpdateOfflineSubscription( + event.payload.subscriptionId, + event.payload.subscriptionName, + event.payload.canceled, + event.payload.userEmail, + event.payload.subscriptionExpiresAt, + event.payload.timestamp, + ) + + await this.roleService.setOfflineUserRole(offlineUserSubscription) + + const offlineFeaturesTokenDecoded = this.contentDecoder.decode( + event.payload.offlineFeaturesToken, + 0, + ) as OfflineFeaturesTokenData + + if (!offlineFeaturesTokenDecoded.extensionKey) { + this.logger.warn('Could not decode offline features token') + + return + } + + await this.offlineSettingService.createOrUpdate({ + email: event.payload.userEmail, + name: OfflineSettingName.FeaturesToken, + value: offlineFeaturesTokenDecoded.extensionKey, + }) + + return + } + + const user = await this.userRepository.findOneByEmail(event.payload.userEmail) + + if (user === null) { + this.logger.warn(`Could not find user with email: ${event.payload.userEmail}`) + return + } + + const userSubscription = await this.createOrUpdateSubscription( + event.payload.subscriptionId, + event.payload.subscriptionName, + event.payload.canceled, + user, + event.payload.subscriptionExpiresAt, + event.payload.timestamp, + ) + + await this.roleService.addUserRole(user, event.payload.subscriptionName) + + await this.subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription( + userSubscription, + event.payload.subscriptionName, + ) + + await this.settingService.createOrReplace({ + user, + props: { + name: SettingName.ExtensionKey, + unencryptedValue: event.payload.extensionKey, + serverEncryptionVersion: EncryptionVersion.Default, + sensitive: true, + }, + }) + } + + private async createOrUpdateSubscription( + subscriptionId: number, + subscriptionName: string, + canceled: boolean, + user: User, + subscriptionExpiresAt: number, + timestamp: number, + ): Promise { + let subscription = new UserSubscription() + + const subscriptions = await this.userSubscriptionRepository.findBySubscriptionIdAndType( + subscriptionId, + UserSubscriptionType.Regular, + ) + if (subscriptions.length === 1) { + subscription = subscriptions[0] + } + + subscription.planName = subscriptionName + subscription.user = Promise.resolve(user) + subscription.createdAt = timestamp + subscription.updatedAt = timestamp + subscription.endsAt = subscriptionExpiresAt + subscription.cancelled = canceled + subscription.subscriptionId = subscriptionId + subscription.subscriptionType = UserSubscriptionType.Regular + + return this.userSubscriptionRepository.save(subscription) + } + + private async createOrUpdateOfflineSubscription( + subscriptionId: number, + subscriptionName: string, + canceled: boolean, + email: string, + subscriptionExpiresAt: number, + timestamp: number, + ): Promise { + let subscription = await this.offlineUserSubscriptionRepository.findOneBySubscriptionId(subscriptionId) + if (subscription === null) { + subscription = new OfflineUserSubscription() + } + + subscription.planName = subscriptionName + subscription.email = email + subscription.createdAt = timestamp + subscription.updatedAt = timestamp + subscription.endsAt = subscriptionExpiresAt + subscription.cancelled = canceled + subscription.subscriptionId = subscriptionId + + return this.offlineUserSubscriptionRepository.save(subscription) + } +} diff --git a/packages/auth/src/Domain/Handler/UserDisabledSessionUserAgentLoggingEventHandler.spec.ts b/packages/auth/src/Domain/Handler/UserDisabledSessionUserAgentLoggingEventHandler.spec.ts new file mode 100644 index 000000000..f24619a5b --- /dev/null +++ b/packages/auth/src/Domain/Handler/UserDisabledSessionUserAgentLoggingEventHandler.spec.ts @@ -0,0 +1,30 @@ +import 'reflect-metadata' + +import { UserDisabledSessionUserAgentLoggingEvent } from '@standardnotes/domain-events' +import { SessionRepositoryInterface } from '../Session/SessionRepositoryInterface' + +import { UserDisabledSessionUserAgentLoggingEventHandler } from './UserDisabledSessionUserAgentLoggingEventHandler' + +describe('UserDisabledSessionUserAgentLoggingEventHandler', () => { + let sessionRepository: SessionRepositoryInterface + let event: UserDisabledSessionUserAgentLoggingEvent + + const createHandler = () => new UserDisabledSessionUserAgentLoggingEventHandler(sessionRepository) + + beforeEach(() => { + sessionRepository = {} as jest.Mocked + sessionRepository.clearUserAgentByUserUuid = jest.fn() + + event = {} as jest.Mocked + event.payload = { + userUuid: '1-2-3', + email: 'test@test.te', + } + }) + + it('should clear all user agent info from all user sessions', async () => { + await createHandler().handle(event) + + expect(sessionRepository.clearUserAgentByUserUuid).toHaveBeenCalledWith('1-2-3') + }) +}) diff --git a/packages/auth/src/Domain/Handler/UserDisabledSessionUserAgentLoggingEventHandler.ts b/packages/auth/src/Domain/Handler/UserDisabledSessionUserAgentLoggingEventHandler.ts new file mode 100644 index 000000000..ebc5a0959 --- /dev/null +++ b/packages/auth/src/Domain/Handler/UserDisabledSessionUserAgentLoggingEventHandler.ts @@ -0,0 +1,14 @@ +import { DomainEventHandlerInterface, UserDisabledSessionUserAgentLoggingEvent } from '@standardnotes/domain-events' +import { inject, injectable } from 'inversify' + +import TYPES from '../../Bootstrap/Types' +import { SessionRepositoryInterface } from '../Session/SessionRepositoryInterface' + +@injectable() +export class UserDisabledSessionUserAgentLoggingEventHandler implements DomainEventHandlerInterface { + constructor(@inject(TYPES.SessionRepository) private sessionRepository: SessionRepositoryInterface) {} + + async handle(event: UserDisabledSessionUserAgentLoggingEvent): Promise { + await this.sessionRepository.clearUserAgentByUserUuid(event.payload.userUuid) + } +} diff --git a/packages/auth/src/Domain/Handler/UserEmailChangedEventHandler.spec.ts b/packages/auth/src/Domain/Handler/UserEmailChangedEventHandler.spec.ts new file mode 100644 index 000000000..77dd5b6b9 --- /dev/null +++ b/packages/auth/src/Domain/Handler/UserEmailChangedEventHandler.spec.ts @@ -0,0 +1,63 @@ +import 'reflect-metadata' + +import { UserEmailChangedEvent } from '@standardnotes/domain-events' +import { Logger } from 'winston' +import { AxiosInstance } from 'axios' + +import { UserEmailChangedEventHandler } from './UserEmailChangedEventHandler' + +describe('UserEmailChangedEventHandler', () => { + let httpClient: AxiosInstance + const userServerChangeEmailUrl = 'https://user-server/change-email' + const userServerAuthKey = 'auth-key' + let event: UserEmailChangedEvent + let logger: Logger + + const createHandler = () => + new UserEmailChangedEventHandler(httpClient, userServerChangeEmailUrl, userServerAuthKey, logger) + + beforeEach(() => { + httpClient = {} as jest.Mocked + httpClient.request = jest.fn() + + event = {} as jest.Mocked + event.createdAt = new Date(1) + event.payload = { + userUuid: '1-2-3', + fromEmail: 'test@test.te', + toEmail: 'test2@test.te', + } + + logger = {} as jest.Mocked + logger.debug = jest.fn() + }) + + it('should send a request to the user management server about an email change', async () => { + await createHandler().handle(event) + + expect(httpClient.request).toHaveBeenCalledWith({ + method: 'POST', + url: 'https://user-server/change-email', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + data: { + key: 'auth-key', + user: { + uuid: '1-2-3', + from_email: 'test@test.te', + to_email: 'test2@test.te', + }, + }, + validateStatus: expect.any(Function), + }) + }) + + it('should not send a request to the user management server about an email change if url is not defined', async () => { + const handler = new UserEmailChangedEventHandler(httpClient, '', userServerAuthKey, logger) + await handler.handle(event) + + expect(httpClient.request).not.toHaveBeenCalled() + }) +}) diff --git a/packages/auth/src/Domain/Handler/UserEmailChangedEventHandler.ts b/packages/auth/src/Domain/Handler/UserEmailChangedEventHandler.ts new file mode 100644 index 000000000..b6522e709 --- /dev/null +++ b/packages/auth/src/Domain/Handler/UserEmailChangedEventHandler.ts @@ -0,0 +1,48 @@ +import { DomainEventHandlerInterface, UserEmailChangedEvent } from '@standardnotes/domain-events' +import { AxiosInstance } from 'axios' +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' + +import TYPES from '../../Bootstrap/Types' + +@injectable() +export class UserEmailChangedEventHandler implements DomainEventHandlerInterface { + constructor( + @inject(TYPES.HTTPClient) private httpClient: AxiosInstance, + @inject(TYPES.USER_SERVER_CHANGE_EMAIL_URL) private userServerChangeEmailUrl: string, + @inject(TYPES.USER_SERVER_AUTH_KEY) private userServerAuthKey: string, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async handle(event: UserEmailChangedEvent): Promise { + if (!this.userServerChangeEmailUrl) { + this.logger.debug('User server change email url not defined. Skipped post email change actions.') + + return + } + + this.logger.debug(`Changing user email from ${event.payload.fromEmail} to ${event.payload.toEmail}`) + + await this.httpClient.request({ + method: 'POST', + url: this.userServerChangeEmailUrl, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + data: { + key: this.userServerAuthKey, + user: { + uuid: event.payload.userUuid, + from_email: event.payload.fromEmail, + to_email: event.payload.toEmail, + }, + }, + validateStatus: + /* istanbul ignore next */ + (status: number) => status >= 200 && status < 500, + }) + + this.logger.debug(`Successfully changed user email to ${event.payload.toEmail}`) + } +} diff --git a/packages/auth/src/Domain/Handler/UserRegisteredEventHandler.spec.ts b/packages/auth/src/Domain/Handler/UserRegisteredEventHandler.spec.ts new file mode 100644 index 000000000..65cc4845c --- /dev/null +++ b/packages/auth/src/Domain/Handler/UserRegisteredEventHandler.spec.ts @@ -0,0 +1,60 @@ +import 'reflect-metadata' +import { UserRegisteredEvent } from '@standardnotes/domain-events' +import { Logger } from 'winston' + +import { UserRegisteredEventHandler } from './UserRegisteredEventHandler' +import { AxiosInstance } from 'axios' + +describe('UserRegisteredEventHandler', () => { + let httpClient: AxiosInstance + const userServerRegistrationUrl = 'https://user-server/registration' + const userServerAuthKey = 'auth-key' + let event: UserRegisteredEvent + let logger: Logger + + const createHandler = () => + new UserRegisteredEventHandler(httpClient, userServerRegistrationUrl, userServerAuthKey, logger) + + beforeEach(() => { + httpClient = {} as jest.Mocked + httpClient.request = jest.fn() + + event = {} as jest.Mocked + event.createdAt = new Date(1) + event.payload = { + userUuid: '1-2-3', + email: 'test@test.te', + } + + logger = {} as jest.Mocked + logger.debug = jest.fn() + }) + + it('should send a request to the user management server about a registration', async () => { + await createHandler().handle(event) + + expect(httpClient.request).toHaveBeenCalledWith({ + method: 'POST', + url: 'https://user-server/registration', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + data: { + key: 'auth-key', + user: { + created_at: new Date(1), + email: 'test@test.te', + }, + }, + validateStatus: expect.any(Function), + }) + }) + + it('should not send a request to the user management server about a registration if url is not defined', async () => { + const handler = new UserRegisteredEventHandler(httpClient, '', userServerAuthKey, logger) + await handler.handle(event) + + expect(httpClient.request).not.toHaveBeenCalled() + }) +}) diff --git a/packages/auth/src/Domain/Handler/UserRegisteredEventHandler.ts b/packages/auth/src/Domain/Handler/UserRegisteredEventHandler.ts new file mode 100644 index 000000000..c4ba7e9fe --- /dev/null +++ b/packages/auth/src/Domain/Handler/UserRegisteredEventHandler.ts @@ -0,0 +1,42 @@ +import { DomainEventHandlerInterface, UserRegisteredEvent } from '@standardnotes/domain-events' +import { AxiosInstance } from 'axios' +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' + +import TYPES from '../../Bootstrap/Types' + +@injectable() +export class UserRegisteredEventHandler implements DomainEventHandlerInterface { + constructor( + @inject(TYPES.HTTPClient) private httpClient: AxiosInstance, + @inject(TYPES.USER_SERVER_REGISTRATION_URL) private userServerRegistrationUrl: string, + @inject(TYPES.USER_SERVER_AUTH_KEY) private userServerAuthKey: string, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async handle(event: UserRegisteredEvent): Promise { + if (!this.userServerRegistrationUrl) { + this.logger.debug('User server registration url not defined. Skipped post-registration actions.') + return + } + + await this.httpClient.request({ + method: 'POST', + url: this.userServerRegistrationUrl, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + data: { + key: this.userServerAuthKey, + user: { + email: event.payload.email, + created_at: event.createdAt, + }, + }, + validateStatus: + /* istanbul ignore next */ + (status: number) => status >= 200 && status < 500, + }) + } +} diff --git a/packages/auth/src/Domain/Permission/Permission.ts b/packages/auth/src/Domain/Permission/Permission.ts new file mode 100644 index 000000000..68bf6efa5 --- /dev/null +++ b/packages/auth/src/Domain/Permission/Permission.ts @@ -0,0 +1,51 @@ +import { Column, Entity, Index, JoinTable, ManyToMany, PrimaryGeneratedColumn } from 'typeorm' +import { Role } from '../Role/Role' + +@Entity({ name: 'permissions' }) +export class Permission { + @PrimaryGeneratedColumn('uuid') + declare uuid: string + + @Column({ + length: 255, + }) + @Index('index_permissions_on_name', { unique: true }) + declare name: string + + @Column({ + name: 'created_at', + type: 'datetime', + default: + /* istanbul ignore next */ + () => 'CURRENT_TIMESTAMP', + }) + declare createdAt: Date + + @Column({ + name: 'updated_at', + type: 'datetime', + default: + /* istanbul ignore next */ + () => 'CURRENT_TIMESTAMP', + }) + declare updatedAt: Date + + @ManyToMany( + /* istanbul ignore next */ + () => Role, + /* istanbul ignore next */ + { lazy: true, eager: false }, + ) + @JoinTable({ + name: 'role_permissions', + joinColumn: { + name: 'permission_uuid', + referencedColumnName: 'uuid', + }, + inverseJoinColumn: { + name: 'role_uuid', + referencedColumnName: 'uuid', + }, + }) + declare roles: Promise> +} diff --git a/packages/auth/src/Domain/Role/Role.ts b/packages/auth/src/Domain/Role/Role.ts new file mode 100644 index 000000000..1c41332d4 --- /dev/null +++ b/packages/auth/src/Domain/Role/Role.ts @@ -0,0 +1,96 @@ +import { Column, Entity, Index, JoinTable, ManyToMany, PrimaryGeneratedColumn } from 'typeorm' +import { Permission } from '../Permission/Permission' +import { OfflineUserSubscription } from '../Subscription/OfflineUserSubscription' +import { User } from '../User/User' + +@Entity({ name: 'roles' }) +@Index('name_and_version', ['name', 'version'], { unique: true }) +export class Role { + @PrimaryGeneratedColumn('uuid') + declare uuid: string + + @Column({ + length: 255, + }) + declare name: string + + @Column({ + type: 'smallint', + }) + declare version: number + + @Column({ + name: 'created_at', + type: 'datetime', + default: + /* istanbul ignore next */ + () => 'CURRENT_TIMESTAMP', + }) + declare createdAt: Date + + @Column({ + name: 'updated_at', + type: 'datetime', + default: + /* istanbul ignore next */ + () => 'CURRENT_TIMESTAMP', + }) + declare updatedAt: Date + + @ManyToMany( + /* istanbul ignore next */ + () => User, + /* istanbul ignore next */ + { lazy: true, eager: false }, + ) + @JoinTable({ + name: 'user_roles', + joinColumn: { + name: 'role_uuid', + referencedColumnName: 'uuid', + }, + inverseJoinColumn: { + name: 'user_uuid', + referencedColumnName: 'uuid', + }, + }) + declare users: Promise> + + @ManyToMany( + /* istanbul ignore next */ + () => Permission, + /* istanbul ignore next */ + { lazy: true, eager: false }, + ) + @JoinTable({ + name: 'role_permissions', + joinColumn: { + name: 'role_uuid', + referencedColumnName: 'uuid', + }, + inverseJoinColumn: { + name: 'permission_uuid', + referencedColumnName: 'uuid', + }, + }) + declare permissions: Promise> + + @ManyToMany( + /* istanbul ignore next */ + () => OfflineUserSubscription, + /* istanbul ignore next */ + { lazy: true, eager: false }, + ) + @JoinTable({ + name: 'offline_user_roles', + joinColumn: { + name: 'role_uuid', + referencedColumnName: 'uuid', + }, + inverseJoinColumn: { + name: 'offline_user_subscription_uuid', + referencedColumnName: 'uuid', + }, + }) + declare offlineUserSubscriptions: Promise> +} diff --git a/packages/auth/src/Domain/Role/RoleRepositoryInterface.ts b/packages/auth/src/Domain/Role/RoleRepositoryInterface.ts new file mode 100644 index 000000000..c15bc927b --- /dev/null +++ b/packages/auth/src/Domain/Role/RoleRepositoryInterface.ts @@ -0,0 +1,5 @@ +import { Role } from './Role' + +export interface RoleRepositoryInterface { + findOneByName(name: string): Promise +} diff --git a/packages/auth/src/Domain/Role/RoleService.spec.ts b/packages/auth/src/Domain/Role/RoleService.spec.ts new file mode 100644 index 000000000..aac328ec3 --- /dev/null +++ b/packages/auth/src/Domain/Role/RoleService.spec.ts @@ -0,0 +1,235 @@ +import 'reflect-metadata' + +import { Logger } from 'winston' +import { User } from '../User/User' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { RoleRepositoryInterface } from '../Role/RoleRepositoryInterface' +import { RoleName, SubscriptionName } from '@standardnotes/common' +import { Role } from '../Role/Role' + +import { ClientServiceInterface } from '../Client/ClientServiceInterface' +import { RoleService } from './RoleService' +import { RoleToSubscriptionMapInterface } from './RoleToSubscriptionMapInterface' +import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface' +import { OfflineUserSubscription } from '../Subscription/OfflineUserSubscription' +import { PermissionName } from '@standardnotes/features' +import { Permission } from '../Permission/Permission' + +describe('RoleService', () => { + let userRepository: UserRepositoryInterface + let roleRepository: RoleRepositoryInterface + let offlineUserSubscription: OfflineUserSubscription + let offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface + let roleToSubscriptionMap: RoleToSubscriptionMapInterface + let webSocketsClientService: ClientServiceInterface + let logger: Logger + let user: User + let basicRole: Role + let proRole: Role + + const createService = () => + new RoleService( + userRepository, + roleRepository, + offlineUserSubscriptionRepository, + webSocketsClientService, + roleToSubscriptionMap, + logger, + ) + + beforeEach(() => { + basicRole = { + name: RoleName.CoreUser, + permissions: Promise.resolve([ + { + name: PermissionName.MarkdownBasicEditor, + } as jest.Mocked, + ]), + } as jest.Mocked + + proRole = { + name: RoleName.ProUser, + permissions: Promise.resolve([ + { + name: PermissionName.DailyEmailBackup, + } as jest.Mocked, + ]), + } as jest.Mocked + + userRepository = {} as jest.Mocked + + roleRepository = {} as jest.Mocked + roleRepository.findOneByName = jest.fn().mockReturnValue(proRole) + + roleToSubscriptionMap = {} as jest.Mocked + roleToSubscriptionMap.getRoleNameForSubscriptionName = jest.fn().mockReturnValue(RoleName.ProUser) + + offlineUserSubscription = { + endsAt: 100, + cancelled: false, + planName: SubscriptionName.ProPlan, + } as jest.Mocked + + offlineUserSubscriptionRepository = {} as jest.Mocked + offlineUserSubscriptionRepository.findOneByEmail = jest.fn().mockReturnValue(offlineUserSubscription) + offlineUserSubscriptionRepository.save = jest.fn().mockReturnValue(offlineUserSubscription) + + webSocketsClientService = {} as jest.Mocked + webSocketsClientService.sendUserRolesChangedEvent = jest.fn() + + logger = {} as jest.Mocked + logger.info = jest.fn() + logger.warn = jest.fn() + }) + + describe('adding roles', () => { + beforeEach(() => { + user = { + uuid: '123', + email: 'test@test.com', + roles: Promise.resolve([basicRole]), + } as jest.Mocked + + userRepository.findOneByEmail = jest.fn().mockReturnValue(user) + userRepository.save = jest.fn().mockReturnValue(user) + }) + + it('should add role to user', async () => { + await createService().addUserRole(user, SubscriptionName.ProPlan) + + expect(roleRepository.findOneByName).toHaveBeenCalledWith(RoleName.ProUser) + user.roles = Promise.resolve([basicRole, proRole]) + expect(userRepository.save).toHaveBeenCalledWith(user) + }) + + it('should not add duplicate role to user', async () => { + user = { + uuid: '123', + email: 'test@test.com', + roles: Promise.resolve([basicRole, proRole]), + } as jest.Mocked + + userRepository.findOneByEmail = jest.fn().mockReturnValue(user) + + await createService().addUserRole(user, SubscriptionName.ProPlan) + + expect(roleRepository.findOneByName).toHaveBeenCalledWith(RoleName.ProUser) + expect(userRepository.save).toHaveBeenCalledWith(user) + expect(await user.roles).toHaveLength(2) + }) + + it('should send websockets event', async () => { + await createService().addUserRole(user, SubscriptionName.ProPlan) + + expect(webSocketsClientService.sendUserRolesChangedEvent).toHaveBeenCalledWith(user) + }) + + it('should not add role if no role name exists for subscription name', async () => { + roleToSubscriptionMap.getRoleNameForSubscriptionName = jest.fn().mockReturnValue(undefined) + + await createService().addUserRole(user, 'test' as SubscriptionName) + + expect(userRepository.save).not.toHaveBeenCalled() + }) + + it('should not add role if no role exists for role name', async () => { + roleRepository.findOneByName = jest.fn().mockReturnValue(null) + await createService().addUserRole(user, SubscriptionName.ProPlan) + + expect(userRepository.save).not.toHaveBeenCalled() + }) + + it('should set offline role to offline subscription', async () => { + await createService().setOfflineUserRole(offlineUserSubscription) + + expect(roleRepository.findOneByName).toHaveBeenCalledWith(RoleName.ProUser) + expect(offlineUserSubscriptionRepository.save).toHaveBeenCalledWith({ + endsAt: 100, + cancelled: false, + planName: SubscriptionName.ProPlan, + roles: Promise.resolve([proRole]), + }) + }) + + it('should not set offline role if no role name exists for subscription name', async () => { + roleToSubscriptionMap.getRoleNameForSubscriptionName = jest.fn().mockReturnValue(undefined) + + await createService().setOfflineUserRole(offlineUserSubscription) + + expect(offlineUserSubscriptionRepository.save).not.toHaveBeenCalled() + }) + + it('should not set offline role if no role exists for role name', async () => { + roleRepository.findOneByName = jest.fn().mockReturnValue(null) + + await createService().setOfflineUserRole(offlineUserSubscription) + + expect(offlineUserSubscriptionRepository.save).not.toHaveBeenCalled() + }) + }) + + describe('removing roles', () => { + beforeEach(() => { + user = { + uuid: '123', + email: 'test@test.com', + roles: Promise.resolve([basicRole, proRole]), + } as jest.Mocked + + userRepository.findOneByEmail = jest.fn().mockReturnValue(user) + userRepository.save = jest.fn().mockReturnValue(user) + }) + + it('should remove role from user', async () => { + await createService().removeUserRole(user, SubscriptionName.ProPlan) + + expect(userRepository.save).toHaveBeenCalledWith(user) + }) + + it('should send websockets event', async () => { + await createService().removeUserRole(user, SubscriptionName.ProPlan) + + expect(webSocketsClientService.sendUserRolesChangedEvent).toHaveBeenCalledWith(user) + }) + + it('should not remove role if role name does not exist for subscription name', async () => { + roleToSubscriptionMap.getRoleNameForSubscriptionName = jest.fn().mockReturnValue(undefined) + + await createService().removeUserRole(user, 'test' as SubscriptionName) + + expect(userRepository.save).not.toHaveBeenCalled() + }) + }) + + describe('checking permissions', () => { + beforeEach(() => { + user = { + uuid: '123', + email: 'test@test.com', + roles: Promise.resolve([basicRole, proRole]), + } as jest.Mocked + + userRepository.findOneByUuid = jest.fn().mockReturnValue(user) + }) + + it('should indicate if a user has given permission', async () => { + const userHasPermission = await createService().userHasPermission('1-2-3', PermissionName.DailyEmailBackup) + + expect(userHasPermission).toBeTruthy() + }) + + it('should indicate if a user does not have a given permission', async () => { + const userHasPermission = await createService().userHasPermission('1-2-3', PermissionName.DailyGDriveBackup) + + expect(userHasPermission).toBeFalsy() + }) + + it('should indicate user does not have a permission if user could not be found', async () => { + userRepository.findOneByUuid = jest.fn().mockReturnValue(null) + + const userHasPermission = await createService().userHasPermission('1-2-3', PermissionName.DailyGDriveBackup) + + expect(userHasPermission).toBeFalsy() + }) + }) +}) diff --git a/packages/auth/src/Domain/Role/RoleService.ts b/packages/auth/src/Domain/Role/RoleService.ts new file mode 100644 index 000000000..726fd9e89 --- /dev/null +++ b/packages/auth/src/Domain/Role/RoleService.ts @@ -0,0 +1,116 @@ +import { SubscriptionName } from '@standardnotes/common' +import { PermissionName } from '@standardnotes/features' +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' + +import TYPES from '../../Bootstrap/Types' +import { User } from '../User/User' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { ClientServiceInterface } from '../Client/ClientServiceInterface' +import { RoleRepositoryInterface } from './RoleRepositoryInterface' +import { RoleServiceInterface } from './RoleServiceInterface' +import { RoleToSubscriptionMapInterface } from './RoleToSubscriptionMapInterface' +import { OfflineUserSubscriptionRepositoryInterface } from '../Subscription/OfflineUserSubscriptionRepositoryInterface' +import { Role } from './Role' +import { OfflineUserSubscription } from '../Subscription/OfflineUserSubscription' + +@injectable() +export class RoleService implements RoleServiceInterface { + constructor( + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.RoleRepository) private roleRepository: RoleRepositoryInterface, + @inject(TYPES.OfflineUserSubscriptionRepository) + private offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface, + @inject(TYPES.WebSocketsClientService) private webSocketsClientService: ClientServiceInterface, + @inject(TYPES.RoleToSubscriptionMap) private roleToSubscriptionMap: RoleToSubscriptionMapInterface, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async userHasPermission(userUuid: string, permissionName: PermissionName): Promise { + const user = await this.userRepository.findOneByUuid(userUuid) + if (user === null) { + this.logger.warn(`Could not find user with uuid ${userUuid} for permissions check`) + + return false + } + + const roles = await user.roles + for (const role of roles) { + const permissions = await role.permissions + for (const permission of permissions) { + if (permission.name === permissionName) { + return true + } + } + } + + return false + } + + async addUserRole(user: User, subscriptionName: SubscriptionName): Promise { + const roleName = this.roleToSubscriptionMap.getRoleNameForSubscriptionName(subscriptionName) + + if (roleName === undefined) { + this.logger.warn(`Could not find role name for subscription name: ${subscriptionName}`) + return + } + + const role = await this.roleRepository.findOneByName(roleName) + + if (role === null) { + this.logger.warn(`Could not find role for role name: ${roleName}`) + return + } + + const rolesMap = new Map() + const currentRoles = await user.roles + for (const currentRole of currentRoles) { + rolesMap.set(currentRole.name, currentRole) + } + if (!rolesMap.has(role.name)) { + rolesMap.set(role.name, role) + } + + user.roles = Promise.resolve([...rolesMap.values()]) + await this.userRepository.save(user) + await this.webSocketsClientService.sendUserRolesChangedEvent(user) + } + + async setOfflineUserRole(offlineUserSubscription: OfflineUserSubscription): Promise { + const roleName = this.roleToSubscriptionMap.getRoleNameForSubscriptionName( + offlineUserSubscription.planName as SubscriptionName, + ) + + if (roleName === undefined) { + this.logger.warn(`Could not find role name for subscription name: ${offlineUserSubscription.planName}`) + + return + } + + const role = await this.roleRepository.findOneByName(roleName) + + if (role === null) { + this.logger.warn(`Could not find role for role name: ${roleName}`) + + return + } + + offlineUserSubscription.roles = Promise.resolve([role]) + + await this.offlineUserSubscriptionRepository.save(offlineUserSubscription) + } + + async removeUserRole(user: User, subscriptionName: SubscriptionName): Promise { + const roleName = this.roleToSubscriptionMap.getRoleNameForSubscriptionName(subscriptionName) + + if (roleName === undefined) { + this.logger.warn(`Could not find role name for subscription name: ${subscriptionName}`) + return + } + + const currentRoles = await user.roles + user.roles = Promise.resolve(currentRoles.filter((role) => role.name !== roleName)) + await this.userRepository.save(user) + await this.webSocketsClientService.sendUserRolesChangedEvent(user) + } +} diff --git a/packages/auth/src/Domain/Role/RoleServiceInterface.ts b/packages/auth/src/Domain/Role/RoleServiceInterface.ts new file mode 100644 index 000000000..1a86a151c --- /dev/null +++ b/packages/auth/src/Domain/Role/RoleServiceInterface.ts @@ -0,0 +1,11 @@ +import { SubscriptionName } from '@standardnotes/common' +import { PermissionName } from '@standardnotes/features' +import { OfflineUserSubscription } from '../Subscription/OfflineUserSubscription' +import { User } from '../User/User' + +export interface RoleServiceInterface { + addUserRole(user: User, subscriptionName: SubscriptionName): Promise + setOfflineUserRole(offlineUserSubscription: OfflineUserSubscription): Promise + removeUserRole(user: User, subscriptionName: SubscriptionName): Promise + userHasPermission(userUuid: string, permissionName: PermissionName): Promise +} diff --git a/packages/auth/src/Domain/Role/RoleToSubscriptionMap.spec.ts b/packages/auth/src/Domain/Role/RoleToSubscriptionMap.spec.ts new file mode 100644 index 000000000..8ccfa7890 --- /dev/null +++ b/packages/auth/src/Domain/Role/RoleToSubscriptionMap.spec.ts @@ -0,0 +1,44 @@ +import 'reflect-metadata' + +import { RoleName, SubscriptionName } from '@standardnotes/common' + +import { RoleToSubscriptionMap } from './RoleToSubscriptionMap' +import { Role } from './Role' + +describe('RoleToSubscriptionMap', () => { + const createMap = () => new RoleToSubscriptionMap() + + it('should return subscription name for role name', () => { + expect(createMap().getSubscriptionNameForRoleName(RoleName.ProUser)).toEqual(SubscriptionName.ProPlan) + }) + + it('should return role name for subscription name', () => { + expect(createMap().getRoleNameForSubscriptionName(SubscriptionName.PlusPlan)).toEqual(RoleName.PlusUser) + }) + + it('should not return role name for subscription name that does not exist', () => { + expect(createMap().getRoleNameForSubscriptionName('test' as SubscriptionName)).toEqual(undefined) + }) + + it('should filter our non subscription roles from an array of roles', () => { + const roles = [ + { + name: RoleName.CoreUser, + } as jest.Mocked, + { + name: RoleName.FilesBetaUser, + } as jest.Mocked, + { + name: RoleName.PlusUser, + } as jest.Mocked, + ] + expect(createMap().filterNonSubscriptionRoles(roles)).toEqual([ + { + name: RoleName.CoreUser, + }, + { + name: RoleName.FilesBetaUser, + }, + ]) + }) +}) diff --git a/packages/auth/src/Domain/Role/RoleToSubscriptionMap.ts b/packages/auth/src/Domain/Role/RoleToSubscriptionMap.ts new file mode 100644 index 000000000..89221fd6d --- /dev/null +++ b/packages/auth/src/Domain/Role/RoleToSubscriptionMap.ts @@ -0,0 +1,32 @@ +import { RoleName, SubscriptionName } from '@standardnotes/common' +import { injectable } from 'inversify' +import { Role } from './Role' + +import { RoleToSubscriptionMapInterface } from './RoleToSubscriptionMapInterface' + +@injectable() +export class RoleToSubscriptionMap implements RoleToSubscriptionMapInterface { + private readonly roleNameToSubscriptionNameMap = new Map([ + [RoleName.PlusUser, SubscriptionName.PlusPlan], + [RoleName.ProUser, SubscriptionName.ProPlan], + ]) + + private readonly nonSubscriptionRoles = [RoleName.CoreUser, RoleName.FilesBetaUser] + + filterNonSubscriptionRoles(roles: Role[]): Array { + return roles.filter((role) => this.nonSubscriptionRoles.includes(role.name as RoleName)) + } + + getSubscriptionNameForRoleName(roleName: RoleName): SubscriptionName | undefined { + return this.roleNameToSubscriptionNameMap.get(roleName) + } + + getRoleNameForSubscriptionName(subscriptionName: SubscriptionName): RoleName | undefined { + for (const [roleNameItem, subscriptionNameItem] of this.roleNameToSubscriptionNameMap) { + if (subscriptionNameItem === subscriptionName) { + return roleNameItem + } + } + return undefined + } +} diff --git a/packages/auth/src/Domain/Role/RoleToSubscriptionMapInterface.ts b/packages/auth/src/Domain/Role/RoleToSubscriptionMapInterface.ts new file mode 100644 index 000000000..6b5da66a4 --- /dev/null +++ b/packages/auth/src/Domain/Role/RoleToSubscriptionMapInterface.ts @@ -0,0 +1,8 @@ +import { RoleName, SubscriptionName } from '@standardnotes/common' +import { Role } from './Role' + +export interface RoleToSubscriptionMapInterface { + filterNonSubscriptionRoles(roles: Role[]): Array + getSubscriptionNameForRoleName(roleName: RoleName): SubscriptionName | undefined + getRoleNameForSubscriptionName(subscriptionName: SubscriptionName): RoleName | undefined +} diff --git a/packages/auth/src/Domain/Session/EphemeralSession.ts b/packages/auth/src/Domain/Session/EphemeralSession.ts new file mode 100644 index 000000000..991143c20 --- /dev/null +++ b/packages/auth/src/Domain/Session/EphemeralSession.ts @@ -0,0 +1,3 @@ +import { Session } from './Session' + +export class EphemeralSession extends Session {} diff --git a/packages/auth/src/Domain/Session/EphemeralSessionRepositoryInterface.ts b/packages/auth/src/Domain/Session/EphemeralSessionRepositoryInterface.ts new file mode 100644 index 000000000..9c9d5d283 --- /dev/null +++ b/packages/auth/src/Domain/Session/EphemeralSessionRepositoryInterface.ts @@ -0,0 +1,16 @@ +import { EphemeralSession } from './EphemeralSession' + +export interface EphemeralSessionRepositoryInterface { + findOneByUuid(uuid: string): Promise + findOneByUuidAndUserUuid(uuid: string, userUuid: string): Promise + findAllByUserUuid(userUuid: string): Promise> + updateTokensAndExpirationDates( + uuid: string, + hashedAccessToken: string, + hashedRefreshToken: string, + accessExpiration: Date, + refreshExpiration: Date, + ): Promise + deleteOne(uuid: string, userUuid: string): Promise + save(ephemeralSession: EphemeralSession): Promise +} diff --git a/packages/auth/src/Domain/Session/RevokedSession.ts b/packages/auth/src/Domain/Session/RevokedSession.ts new file mode 100644 index 000000000..900493a65 --- /dev/null +++ b/packages/auth/src/Domain/Session/RevokedSession.ts @@ -0,0 +1,39 @@ +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm' +import { User } from '../User/User' + +@Entity({ name: 'revoked_sessions' }) +export class RevokedSession { + @PrimaryGeneratedColumn('uuid') + declare uuid: string + + @Column({ + name: 'user_uuid', + length: 36, + }) + @Index('index_revoked_sessions_on_user_uuid') + declare userUuid: string + + @Column({ + type: 'tinyint', + width: 1, + nullable: false, + default: 0, + }) + declare received: boolean + + @Column({ + name: 'created_at', + type: 'datetime', + }) + declare createdAt: Date + + @ManyToOne( + /* istanbul ignore next */ + () => User, + /* istanbul ignore next */ + (user) => user.revokedSessions, + { onDelete: 'CASCADE', lazy: true, eager: false }, + ) + @JoinColumn({ name: 'user_uuid', referencedColumnName: 'uuid' }) + declare user: Promise +} diff --git a/packages/auth/src/Domain/Session/RevokedSessionRepositoryInterface.ts b/packages/auth/src/Domain/Session/RevokedSessionRepositoryInterface.ts new file mode 100644 index 000000000..563bcb4de --- /dev/null +++ b/packages/auth/src/Domain/Session/RevokedSessionRepositoryInterface.ts @@ -0,0 +1,8 @@ +import { RevokedSession } from './RevokedSession' + +export interface RevokedSessionRepositoryInterface { + findOneByUuid(uuid: string): Promise + findAllByUserUuid(userUuid: string): Promise> + save(revokedSession: RevokedSession): Promise + remove(revokedSession: RevokedSession): Promise +} diff --git a/packages/auth/src/Domain/Session/Session.spec.ts b/packages/auth/src/Domain/Session/Session.spec.ts new file mode 100644 index 000000000..9572214bb --- /dev/null +++ b/packages/auth/src/Domain/Session/Session.spec.ts @@ -0,0 +1,9 @@ +import { Session } from './Session' + +describe('Session', () => { + const createSession = () => new Session() + + it('should instantiate', () => { + expect(createSession()).toBeInstanceOf(Session) + }) +}) diff --git a/packages/auth/src/Domain/Session/Session.ts b/packages/auth/src/Domain/Session/Session.ts new file mode 100644 index 000000000..3dd5f9da7 --- /dev/null +++ b/packages/auth/src/Domain/Session/Session.ts @@ -0,0 +1,78 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm' + +@Entity({ name: 'sessions' }) +export class Session { + @PrimaryGeneratedColumn('uuid') + declare uuid: string + + @Column({ + name: 'user_uuid', + length: 255, + nullable: true, + }) + @Index('index_sessions_on_user_uuid') + declare userUuid: string + + @Column({ + name: 'hashed_access_token', + length: 255, + }) + declare hashedAccessToken: string + + @Column({ + name: 'hashed_refresh_token', + length: 255, + }) + declare hashedRefreshToken: string + + @Column({ + name: 'access_expiration', + type: 'datetime', + default: + /* istanbul ignore next */ + () => 'CURRENT_TIMESTAMP', + }) + declare accessExpiration: Date + + @Column({ + name: 'refresh_expiration', + type: 'datetime', + }) + declare refreshExpiration: Date + + @Column({ + name: 'api_version', + length: 255, + nullable: true, + }) + declare apiVersion: string + + @Column({ + name: 'user_agent', + type: 'text', + nullable: true, + }) + declare userAgent: string | null + + @Column({ + name: 'created_at', + type: 'datetime', + }) + declare createdAt: Date + + @Column({ + name: 'updated_at', + type: 'datetime', + }) + @Index('index_sessions_on_updated_at') + declare updatedAt: Date + + @Column({ + name: 'readonly_access', + type: 'tinyint', + width: 1, + nullable: false, + default: 0, + }) + declare readonlyAccess: boolean +} diff --git a/packages/auth/src/Domain/Session/SessionRepositoryInterface.ts b/packages/auth/src/Domain/Session/SessionRepositoryInterface.ts new file mode 100644 index 000000000..12bc29bd9 --- /dev/null +++ b/packages/auth/src/Domain/Session/SessionRepositoryInterface.ts @@ -0,0 +1,16 @@ +import { Uuid } from '@standardnotes/common' +import { Session } from './Session' + +export interface SessionRepositoryInterface { + findOneByUuid(uuid: string): Promise + findOneByUuidAndUserUuid(uuid: string, userUuid: string): Promise + findAllByRefreshExpirationAndUserUuid(userUuid: string): Promise> + findAllByUserUuid(userUuid: string): Promise> + deleteAllByUserUuid(userUuid: string, currentSessionUuid: string): Promise + deleteOneByUuid(uuid: string): Promise + updateHashedTokens(uuid: string, hashedAccessToken: string, hashedRefreshToken: string): Promise + updatedTokenExpirationDates(uuid: string, accessExpiration: Date, refreshExpiration: Date): Promise + save(session: Session): Promise + remove(session: Session): Promise + clearUserAgentByUserUuid(userUuid: Uuid): Promise +} diff --git a/packages/auth/src/Domain/Session/SessionService.spec.ts b/packages/auth/src/Domain/Session/SessionService.spec.ts new file mode 100644 index 000000000..899c93519 --- /dev/null +++ b/packages/auth/src/Domain/Session/SessionService.spec.ts @@ -0,0 +1,491 @@ +import 'reflect-metadata' +import * as winston from 'winston' +import { TimerInterface } from '@standardnotes/time' + +import { Session } from './Session' +import { SessionRepositoryInterface } from './SessionRepositoryInterface' +import { SessionService } from './SessionService' +import { User } from '../User/User' +import { EphemeralSessionRepositoryInterface } from './EphemeralSessionRepositoryInterface' +import { EphemeralSession } from './EphemeralSession' +import { RevokedSessionRepositoryInterface } from './RevokedSessionRepositoryInterface' +import { RevokedSession } from './RevokedSession' +import { SettingServiceInterface } from '../Setting/SettingServiceInterface' +import { LogSessionUserAgentOption } from '@standardnotes/settings' +import { Setting } from '../Setting/Setting' + +describe('SessionService', () => { + let sessionRepository: SessionRepositoryInterface + let ephemeralSessionRepository: EphemeralSessionRepositoryInterface + let revokedSessionRepository: RevokedSessionRepositoryInterface + let session: Session + let ephemeralSession: EphemeralSession + let revokedSession: RevokedSession + let settingService: SettingServiceInterface + let deviceDetector: UAParser + let timer: TimerInterface + let logger: winston.Logger + + const createService = () => + new SessionService( + sessionRepository, + ephemeralSessionRepository, + revokedSessionRepository, + deviceDetector, + timer, + logger, + 123, + 234, + settingService, + ) + + beforeEach(() => { + session = {} as jest.Mocked + session.uuid = '2e1e43' + session.userUuid = '1-2-3' + session.userAgent = 'Chrome' + session.hashedAccessToken = '4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce' + session.hashedRefreshToken = '4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce' + + revokedSession = {} as jest.Mocked + revokedSession.uuid = '2e1e43' + + sessionRepository = {} as jest.Mocked + sessionRepository.findOneByUuid = jest.fn().mockReturnValue(null) + sessionRepository.deleteOneByUuid = jest.fn() + sessionRepository.save = jest.fn().mockReturnValue(session) + sessionRepository.updateHashedTokens = jest.fn() + sessionRepository.updatedTokenExpirationDates = jest.fn() + + settingService = {} as jest.Mocked + settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(null) + + ephemeralSessionRepository = {} as jest.Mocked + ephemeralSessionRepository.save = jest.fn() + ephemeralSessionRepository.findOneByUuid = jest.fn() + ephemeralSessionRepository.updateTokensAndExpirationDates = jest.fn() + ephemeralSessionRepository.deleteOne = jest.fn() + + revokedSessionRepository = {} as jest.Mocked + revokedSessionRepository.save = jest.fn() + + ephemeralSession = {} as jest.Mocked + ephemeralSession.uuid = '2-3-4' + ephemeralSession.userAgent = 'Mozilla Firefox' + ephemeralSession.hashedAccessToken = '4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce' + ephemeralSession.hashedRefreshToken = '4e07408562bedb8b60ce05c1decfe3ad16b72230967de01f640b7e4729b49fce' + + timer = {} as jest.Mocked + timer.convertStringDateToMilliseconds = jest.fn().mockReturnValue(123) + + deviceDetector = {} as jest.Mocked + deviceDetector.setUA = jest.fn().mockReturnThis() + deviceDetector.getResult = jest.fn().mockReturnValue({ + ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.182 Safari/537.36', + browser: { + name: 'Chrome', + version: '69.0', + }, + os: { + name: 'Mac', + version: '10.13', + }, + }) + + logger = {} as jest.Mocked + logger.warn = jest.fn() + logger.error = jest.fn() + logger.debug = jest.fn() + }) + + it('should mark a revoked session as received', async () => { + await createService().markRevokedSessionAsReceived(revokedSession) + + expect(revokedSessionRepository.save).toHaveBeenCalledWith({ + uuid: '2e1e43', + received: true, + }) + }) + + it('should refresh access and refresh tokens for a session', async () => { + expect(await createService().refreshTokens(session)).toEqual({ + access_expiration: 123, + access_token: expect.any(String), + refresh_token: expect.any(String), + refresh_expiration: 123, + readonly_access: false, + }) + + expect(sessionRepository.updateHashedTokens).toHaveBeenCalled() + expect(sessionRepository.updatedTokenExpirationDates).toHaveBeenCalled() + }) + + it('should create new session for a user', async () => { + const user = {} as jest.Mocked + user.uuid = '123' + + const sessionPayload = await createService().createNewSessionForUser({ + user, + apiVersion: '003', + userAgent: 'Google Chrome', + readonlyAccess: false, + }) + + expect(sessionRepository.save).toHaveBeenCalledWith(expect.any(Session)) + expect(sessionRepository.save).toHaveBeenCalledWith({ + accessExpiration: expect.any(Date), + apiVersion: '003', + createdAt: expect.any(Date), + hashedAccessToken: expect.any(String), + hashedRefreshToken: expect.any(String), + refreshExpiration: expect.any(Date), + updatedAt: expect.any(Date), + userAgent: 'Google Chrome', + userUuid: '123', + uuid: expect.any(String), + readonlyAccess: false, + }) + + expect(sessionPayload).toEqual({ + access_expiration: 123, + access_token: expect.any(String), + refresh_expiration: 123, + refresh_token: expect.any(String), + readonly_access: false, + }) + }) + + it('should create new session for a user with disabled user agent logging', async () => { + const user = {} as jest.Mocked + user.uuid = '123' + + settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue({ + value: LogSessionUserAgentOption.Disabled, + } as jest.Mocked) + + const sessionPayload = await createService().createNewSessionForUser({ + user, + apiVersion: '003', + userAgent: 'Google Chrome', + readonlyAccess: false, + }) + + expect(sessionRepository.save).toHaveBeenCalledWith(expect.any(Session)) + expect(sessionRepository.save).toHaveBeenCalledWith({ + accessExpiration: expect.any(Date), + apiVersion: '003', + createdAt: expect.any(Date), + hashedAccessToken: expect.any(String), + hashedRefreshToken: expect.any(String), + refreshExpiration: expect.any(Date), + updatedAt: expect.any(Date), + userUuid: '123', + uuid: expect.any(String), + readonlyAccess: false, + }) + + expect(sessionPayload).toEqual({ + access_expiration: 123, + access_token: expect.any(String), + refresh_expiration: 123, + refresh_token: expect.any(String), + readonly_access: false, + }) + }) + + it('should create new ephemeral session for a user', async () => { + const user = {} as jest.Mocked + user.uuid = '123' + + const sessionPayload = await createService().createNewEphemeralSessionForUser({ + user, + apiVersion: '003', + userAgent: 'Google Chrome', + readonlyAccess: false, + }) + + expect(ephemeralSessionRepository.save).toHaveBeenCalledWith(expect.any(EphemeralSession)) + expect(ephemeralSessionRepository.save).toHaveBeenCalledWith({ + accessExpiration: expect.any(Date), + apiVersion: '003', + createdAt: expect.any(Date), + hashedAccessToken: expect.any(String), + hashedRefreshToken: expect.any(String), + refreshExpiration: expect.any(Date), + updatedAt: expect.any(Date), + userAgent: 'Google Chrome', + userUuid: '123', + uuid: expect.any(String), + readonlyAccess: false, + }) + + expect(sessionPayload).toEqual({ + access_expiration: 123, + access_token: expect.any(String), + refresh_expiration: 123, + refresh_token: expect.any(String), + readonly_access: false, + }) + }) + + it('should delete a session by token', async () => { + sessionRepository.findOneByUuid = jest.fn().mockImplementation((uuid) => { + if (uuid === '2') { + return session + } + + return null + }) + + await createService().deleteSessionByToken('1:2:3') + + expect(sessionRepository.deleteOneByUuid).toHaveBeenCalledWith('2e1e43') + expect(ephemeralSessionRepository.deleteOne).toHaveBeenCalledWith('2e1e43', '1-2-3') + }) + + it('should not delete a session by token if session is not found', async () => { + sessionRepository.findOneByUuid = jest.fn().mockImplementation((uuid) => { + if (uuid === '2') { + return session + } + + return null + }) + + await createService().deleteSessionByToken('1:4:3') + + expect(sessionRepository.deleteOneByUuid).not.toHaveBeenCalled() + expect(ephemeralSessionRepository.deleteOne).not.toHaveBeenCalled() + }) + + it('should determine if a refresh token is valid', async () => { + expect(createService().isRefreshTokenValid(session, '1:2:3')).toBeTruthy() + expect(createService().isRefreshTokenValid(session, '1:2:4')).toBeFalsy() + expect(createService().isRefreshTokenValid(session, '1:2')).toBeFalsy() + }) + + it('should return device info based on user agent', () => { + expect(createService().getDeviceInfo(session)).toEqual('Chrome 69.0 on Mac 10.13') + }) + + it('should return device info based on undefined user agent', () => { + deviceDetector.getResult = jest.fn().mockReturnValue({ + ua: '', + browser: { name: undefined, version: undefined }, + os: { name: undefined, version: undefined }, + }) + expect(createService().getDeviceInfo(session)).toEqual('Unknown Client on Unknown OS') + }) + + it('should return a shorter info based on lack of client in user agent', () => { + deviceDetector.getResult = jest.fn().mockReturnValue({ + ua: 'dummy-data', + browser: { name: '', version: '' }, + os: { name: 'iOS', version: '10.3' }, + }) + + expect(createService().getDeviceInfo(session)).toEqual('iOS 10.3') + }) + + it('should return a shorter info based on lack of os in user agent', () => { + deviceDetector.getResult = jest.fn().mockReturnValue({ + ua: 'dummy-data', + browser: { name: 'Chrome', version: '69.0' }, + os: { name: '', version: '' }, + }) + + expect(createService().getDeviceInfo(session)).toEqual('Chrome 69.0') + }) + + it('should return unknown client and os if user agent is cleaned out', () => { + session.userAgent = null + + expect(createService().getDeviceInfo(session)).toEqual('Unknown Client on Unknown OS') + }) + + it('should return a shorter info based on partial os in user agent', () => { + deviceDetector.getResult = jest.fn().mockReturnValue({ + ua: 'dummy-data', + browser: { name: 'Chrome', version: '69.0' }, + os: { name: 'Windows', version: '' }, + }) + + expect(createService().getDeviceInfo(session)).toEqual('Chrome 69.0 on Windows') + + deviceDetector.getResult = jest.fn().mockReturnValue({ + ua: 'dummy-data', + browser: { name: 'Chrome', version: '69.0' }, + os: { name: '', version: '7' }, + }) + + expect(createService().getDeviceInfo(session)).toEqual('Chrome 69.0 on 7') + }) + + it('should return a shorter info based on partial client in user agent', () => { + deviceDetector.getResult = jest.fn().mockReturnValue({ + ua: 'dummy-data', + browser: { name: '', version: '69.0' }, + os: { name: 'Windows', version: '7' }, + }) + + expect(createService().getDeviceInfo(session)).toEqual('69.0 on Windows 7') + + deviceDetector.getResult = jest.fn().mockReturnValue({ + ua: 'dummy-data', + browser: { name: 'Chrome', version: '' }, + os: { name: 'Windows', version: '7' }, + }) + + expect(createService().getDeviceInfo(session)).toEqual('Chrome on Windows 7') + }) + + it('should return a shorter info based on iOS agent', () => { + deviceDetector.getResult = jest.fn().mockReturnValue({ + ua: 'StandardNotes/41 CFNetwork/1220.1 Darwin/20.3.0', + browser: { name: undefined, version: undefined, major: undefined }, + engine: { name: undefined, version: undefined }, + os: { name: 'iOS', version: undefined }, + device: { vendor: undefined, model: undefined, type: undefined }, + cpu: { architecture: undefined }, + }) + + expect(createService().getDeviceInfo(session)).toEqual('iOS') + }) + + it('should return a shorter info based on partial client and partial os in user agent', () => { + deviceDetector.getResult = jest.fn().mockReturnValue({ + ua: 'dummy-data', + browser: { name: '', version: '69.0' }, + os: { name: 'Windows', version: '' }, + }) + + expect(createService().getDeviceInfo(session)).toEqual('69.0 on Windows') + + deviceDetector.getResult = jest.fn().mockReturnValue({ + ua: 'dummy-data', + browser: { name: 'Chrome', version: '' }, + os: { name: '', version: '7' }, + }) + + expect(createService().getDeviceInfo(session)).toEqual('Chrome on 7') + }) + + it('should return only Android os for okHttp client', () => { + deviceDetector.getResult = jest.fn().mockReturnValue({ + ua: 'okhttp/3.12.12', + browser: { name: undefined, version: undefined, major: undefined }, + engine: { name: undefined, version: undefined }, + os: { name: undefined, version: undefined }, + device: { vendor: undefined, model: undefined, type: undefined }, + cpu: { architecture: undefined }, + }) + + expect(createService().getDeviceInfo(session)).toEqual('Android') + }) + + it('should detect the StandardNotes app in user agent', () => { + deviceDetector.getResult = jest.fn().mockReturnValue({ + ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_16_0) AppleWebKit/537.36 (KHTML, like Gecko) StandardNotes/3.5.18 Chrome/83.0.4103.122 Electron/9.2.1 Safari/537.36', + browser: { name: 'Chrome', version: '83.0.4103.122', major: '83' }, + engine: { name: 'Blink', version: '83.0.4103.122' }, + os: { name: 'Mac OS', version: '10.16.0' }, + device: { vendor: undefined, model: undefined, type: undefined }, + cpu: { architecture: undefined }, + }) + + expect(createService().getDeviceInfo(session)).toEqual('Standard Notes Desktop 3.5.18 on Mac OS 10.16.0') + }) + + it('should return unknown device info as fallback', () => { + deviceDetector.getResult = jest.fn().mockImplementation(() => { + throw new Error('something bad happened') + }) + + expect(createService().getDeviceInfo(session)).toEqual('Unknown Client on Unknown OS') + }) + + it('should retrieve a session from a session token', async () => { + sessionRepository.findOneByUuid = jest.fn().mockImplementation((uuid) => { + if (uuid === '2') { + return session + } + + return null + }) + + const result = await createService().getSessionFromToken('1:2:3') + + expect(result).toEqual(session) + }) + + it('should retrieve an ephemeral session from a session token', async () => { + ephemeralSessionRepository.findOneByUuid = jest.fn().mockReturnValue(ephemeralSession) + sessionRepository.findOneByUuid = jest.fn().mockReturnValue(null) + + const result = await createService().getSessionFromToken('1:2:3') + + expect(result).toEqual(ephemeralSession) + }) + + it('should not retrieve a session from a session token that has access token missing', async () => { + sessionRepository.findOneByUuid = jest.fn().mockImplementation((uuid) => { + if (uuid === '2') { + return session + } + + return null + }) + + const result = await createService().getSessionFromToken('1:2') + + expect(result).toBeUndefined() + }) + + it('should not retrieve a session that is missing', async () => { + sessionRepository.findOneByUuid = jest.fn().mockReturnValue(null) + + const result = await createService().getSessionFromToken('1:2:3') + + expect(result).toBeUndefined() + }) + + it('should not retrieve a session from a session token that has invalid access token', async () => { + sessionRepository.findOneByUuid = jest.fn().mockImplementation((uuid) => { + if (uuid === '2') { + return session + } + + return null + }) + + const result = await createService().getSessionFromToken('1:2:4') + + expect(result).toBeUndefined() + }) + + it('should revoked a session', async () => { + await createService().createRevokedSession(session) + + expect(revokedSessionRepository.save).toHaveBeenCalledWith({ + uuid: '2e1e43', + userUuid: '1-2-3', + createdAt: expect.any(Date), + }) + }) + + it('should retrieve an archvied session from a session token', async () => { + revokedSessionRepository.findOneByUuid = jest.fn().mockReturnValue(revokedSession) + + const result = await createService().getRevokedSessionFromToken('1:2:3') + + expect(result).toEqual(revokedSession) + }) + + it('should not retrieve an archvied session if session id is missing from token', async () => { + revokedSessionRepository.findOneByUuid = jest.fn().mockReturnValue(null) + + const result = await createService().getRevokedSessionFromToken('1::3') + + expect(result).toBeNull() + }) +}) diff --git a/packages/auth/src/Domain/Session/SessionService.ts b/packages/auth/src/Domain/Session/SessionService.ts new file mode 100644 index 000000000..a29732f75 --- /dev/null +++ b/packages/auth/src/Domain/Session/SessionService.ts @@ -0,0 +1,300 @@ +import * as crypto from 'crypto' +import * as winston from 'winston' +import * as dayjs from 'dayjs' +import * as cryptoRandomString from 'crypto-random-string' +import { UAParser } from 'ua-parser-js' +import { inject, injectable } from 'inversify' +import { v4 as uuidv4 } from 'uuid' +import { TimerInterface } from '@standardnotes/time' + +import TYPES from '../../Bootstrap/Types' +import { Session } from './Session' +import { SessionRepositoryInterface } from './SessionRepositoryInterface' +import { SessionServiceInterface } from './SessionServiceInterface' +import { User } from '../User/User' +import { EphemeralSessionRepositoryInterface } from './EphemeralSessionRepositoryInterface' +import { EphemeralSession } from './EphemeralSession' +import { RevokedSession } from './RevokedSession' +import { RevokedSessionRepositoryInterface } from './RevokedSessionRepositoryInterface' +import { SettingServiceInterface } from '../Setting/SettingServiceInterface' +import { LogSessionUserAgentOption, SettingName } from '@standardnotes/settings' +import { SessionBody } from '@standardnotes/responses' +import { Uuid } from '@standardnotes/common' + +@injectable() +export class SessionService implements SessionServiceInterface { + static readonly SESSION_TOKEN_VERSION = 1 + + constructor( + @inject(TYPES.SessionRepository) private sessionRepository: SessionRepositoryInterface, + @inject(TYPES.EphemeralSessionRepository) private ephemeralSessionRepository: EphemeralSessionRepositoryInterface, + @inject(TYPES.RevokedSessionRepository) private revokedSessionRepository: RevokedSessionRepositoryInterface, + @inject(TYPES.DeviceDetector) private deviceDetector: UAParser, + @inject(TYPES.Timer) private timer: TimerInterface, + @inject(TYPES.Logger) private logger: winston.Logger, + @inject(TYPES.ACCESS_TOKEN_AGE) private accessTokenAge: number, + @inject(TYPES.REFRESH_TOKEN_AGE) private refreshTokenAge: number, + @inject(TYPES.SettingService) private settingService: SettingServiceInterface, + ) {} + + async createNewSessionForUser(dto: { + user: User + apiVersion: string + userAgent: string + readonlyAccess: boolean + }): Promise { + const session = await this.createSession({ + ephemeral: false, + ...dto, + }) + + const sessionPayload = await this.createTokens(session) + + await this.sessionRepository.save(session) + + return sessionPayload + } + + async createNewEphemeralSessionForUser(dto: { + user: User + apiVersion: string + userAgent: string + readonlyAccess: boolean + }): Promise { + const ephemeralSession = await this.createSession({ + ephemeral: true, + ...dto, + }) + + const sessionPayload = await this.createTokens(ephemeralSession) + + await this.ephemeralSessionRepository.save(ephemeralSession) + + return sessionPayload + } + + async refreshTokens(session: Session): Promise { + const sessionPayload = await this.createTokens(session) + + await this.sessionRepository.updateHashedTokens(session.uuid, session.hashedAccessToken, session.hashedRefreshToken) + + await this.sessionRepository.updatedTokenExpirationDates( + session.uuid, + session.accessExpiration, + session.refreshExpiration, + ) + + await this.ephemeralSessionRepository.updateTokensAndExpirationDates( + session.uuid, + session.hashedAccessToken, + session.hashedRefreshToken, + session.accessExpiration, + session.refreshExpiration, + ) + + return sessionPayload + } + + isRefreshTokenValid(session: Session, token: string): boolean { + const tokenParts = token.split(':') + const refreshToken = tokenParts[2] + if (!refreshToken) { + return false + } + + const hashedRefreshToken = crypto.createHash('sha256').update(refreshToken).digest('hex') + + return crypto.timingSafeEqual(Buffer.from(hashedRefreshToken), Buffer.from(session.hashedRefreshToken)) + } + + getOperatingSystemInfoFromUserAgent(userAgent: string): string { + try { + const userAgentParsed = this.deviceDetector.setUA(userAgent).getResult() + + const osInfo = `${userAgentParsed.os.name ?? ''} ${userAgentParsed.os.version ?? ''}`.trim() + + if (userAgentParsed.ua.toLowerCase().indexOf('okhttp') >= 0) { + return 'Android' + } + + return osInfo + } catch (error) { + this.logger.warn(`Could not parse operating system info. User agent: ${userAgent}: ${(error as Error).message}`) + + return 'Unknown OS' + } + } + + getBrowserInfoFromUserAgent(userAgent: string): string { + try { + const userAgentParsed = this.deviceDetector.setUA(userAgent).getResult() + + let clientInfo = `${userAgentParsed.browser.name ?? ''} ${userAgentParsed.browser.version ?? ''}`.trim() + + const desktopAppMatches = [ + ...userAgentParsed.ua.matchAll(/(.*)StandardNotes\/((0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*))/g), + ] + if (desktopAppMatches[0] && desktopAppMatches[0][2]) { + clientInfo = `Standard Notes Desktop ${desktopAppMatches[0][2]}` + } + + return clientInfo + } catch (error) { + this.logger.warn(`Could not parse browser info. User agent: ${userAgent}: ${(error as Error).message}`) + + return 'Unknown Client' + } + } + + getDeviceInfo(session: Session): string { + if (!session.userAgent) { + return 'Unknown Client on Unknown OS' + } + + const browserInfo = this.getBrowserInfoFromUserAgent(session.userAgent) + const osInfo = this.getOperatingSystemInfoFromUserAgent(session.userAgent) + + if (osInfo && !browserInfo) { + return osInfo + } + + if (browserInfo && !osInfo) { + return browserInfo + } + + if (!browserInfo && !osInfo) { + return 'Unknown Client on Unknown OS' + } + + return `${browserInfo} on ${osInfo}` + } + + async getSessionFromToken(token: string): Promise { + const tokenParts = token.split(':') + const sessionUuid = tokenParts[1] + const accessToken = tokenParts[2] + if (!accessToken) { + return undefined + } + + const session = await this.getSession(sessionUuid) + if (!session) { + return undefined + } + + const hashedAccessToken = crypto.createHash('sha256').update(accessToken).digest('hex') + if (crypto.timingSafeEqual(Buffer.from(session.hashedAccessToken), Buffer.from(hashedAccessToken))) { + return session + } + + return undefined + } + + async getRevokedSessionFromToken(token: string): Promise { + const tokenParts = token.split(':') + const sessionUuid = tokenParts[1] + if (!sessionUuid) { + return null + } + + return this.revokedSessionRepository.findOneByUuid(sessionUuid) + } + + async markRevokedSessionAsReceived(revokedSession: RevokedSession): Promise { + revokedSession.received = true + + return this.revokedSessionRepository.save(revokedSession) + } + + async deleteSessionByToken(token: string): Promise { + const session = await this.getSessionFromToken(token) + + if (session) { + await this.sessionRepository.deleteOneByUuid(session.uuid) + await this.ephemeralSessionRepository.deleteOne(session.uuid, session.userUuid) + + return session.userUuid + } + + return null + } + + async createRevokedSession(session: Session): Promise { + const revokedSession = new RevokedSession() + revokedSession.uuid = session.uuid + revokedSession.userUuid = session.userUuid + revokedSession.createdAt = dayjs.utc().toDate() + + return this.revokedSessionRepository.save(revokedSession) + } + + private async createSession(dto: { + user: User + apiVersion: string + userAgent: string + ephemeral: boolean + readonlyAccess: boolean + }): Promise { + let session = new Session() + if (dto.ephemeral) { + session = new EphemeralSession() + } + session.uuid = uuidv4() + if (await this.isLoggingUserAgentEnabledOnSessions(dto.user)) { + session.userAgent = dto.userAgent + } + session.userUuid = dto.user.uuid + session.apiVersion = dto.apiVersion + session.createdAt = dayjs.utc().toDate() + session.updatedAt = dayjs.utc().toDate() + session.readonlyAccess = dto.readonlyAccess + + return session + } + + private async getSession(uuid: string): Promise { + let session = await this.ephemeralSessionRepository.findOneByUuid(uuid) + + if (!session) { + session = await this.sessionRepository.findOneByUuid(uuid) + } + + return session + } + + private async createTokens(session: Session): Promise { + const accessToken = cryptoRandomString({ length: 16, type: 'url-safe' }) + const refreshToken = cryptoRandomString({ length: 16, type: 'url-safe' }) + + const hashedAccessToken = crypto.createHash('sha256').update(accessToken).digest('hex') + const hashedRefreshToken = crypto.createHash('sha256').update(refreshToken).digest('hex') + session.hashedAccessToken = hashedAccessToken + session.hashedRefreshToken = hashedRefreshToken + + const accessTokenExpiration = dayjs.utc().add(this.accessTokenAge, 'second').toDate() + const refreshTokenExpiration = dayjs.utc().add(this.refreshTokenAge, 'second').toDate() + session.accessExpiration = accessTokenExpiration + session.refreshExpiration = refreshTokenExpiration + + return { + access_token: `${SessionService.SESSION_TOKEN_VERSION}:${session.uuid}:${accessToken}`, + refresh_token: `${SessionService.SESSION_TOKEN_VERSION}:${session.uuid}:${refreshToken}`, + access_expiration: this.timer.convertStringDateToMilliseconds(accessTokenExpiration.toString()), + refresh_expiration: this.timer.convertStringDateToMilliseconds(refreshTokenExpiration.toString()), + readonly_access: false, + } + } + + private async isLoggingUserAgentEnabledOnSessions(user: User): Promise { + const loggingSetting = await this.settingService.findSettingWithDecryptedValue({ + settingName: SettingName.LogSessionUserAgent, + userUuid: user.uuid, + }) + + if (loggingSetting === null) { + return true + } + + return loggingSetting.value === LogSessionUserAgentOption.Enabled + } +} diff --git a/packages/auth/src/Domain/Session/SessionServiceInterface.ts b/packages/auth/src/Domain/Session/SessionServiceInterface.ts new file mode 100644 index 000000000..9ba90dfaa --- /dev/null +++ b/packages/auth/src/Domain/Session/SessionServiceInterface.ts @@ -0,0 +1,30 @@ +import { Uuid } from '@standardnotes/common' +import { SessionBody } from '@standardnotes/responses' +import { User } from '../User/User' +import { RevokedSession } from './RevokedSession' +import { Session } from './Session' + +export interface SessionServiceInterface { + createNewSessionForUser(dto: { + user: User + apiVersion: string + userAgent: string + readonlyAccess: boolean + }): Promise + createNewEphemeralSessionForUser(dto: { + user: User + apiVersion: string + userAgent: string + readonlyAccess: boolean + }): Promise + refreshTokens(session: Session): Promise + getSessionFromToken(token: string): Promise + getRevokedSessionFromToken(token: string): Promise + markRevokedSessionAsReceived(revokedSession: RevokedSession): Promise + deleteSessionByToken(token: string): Promise + isRefreshTokenValid(session: Session, token: string): boolean + getDeviceInfo(session: Session): string + getOperatingSystemInfoFromUserAgent(userAgent: string): string + getBrowserInfoFromUserAgent(userAgent: string): string + createRevokedSession(session: Session): Promise +} diff --git a/packages/auth/src/Domain/Setting/CreateOrReplaceSettingDto.ts b/packages/auth/src/Domain/Setting/CreateOrReplaceSettingDto.ts new file mode 100644 index 000000000..cc7b768d5 --- /dev/null +++ b/packages/auth/src/Domain/Setting/CreateOrReplaceSettingDto.ts @@ -0,0 +1,7 @@ +import { User } from '../User/User' +import { SettingProps } from './SettingProps' + +export type CreateOrReplaceSettingDto = { + user: User + props: SettingProps +} diff --git a/packages/auth/src/Domain/Setting/CreateOrReplaceSettingResponse.ts b/packages/auth/src/Domain/Setting/CreateOrReplaceSettingResponse.ts new file mode 100644 index 000000000..935d4f292 --- /dev/null +++ b/packages/auth/src/Domain/Setting/CreateOrReplaceSettingResponse.ts @@ -0,0 +1,6 @@ +import { Setting } from './Setting' + +export type CreateOrReplaceSettingResponse = { + status: 'created' | 'replaced' + setting: Setting +} diff --git a/packages/auth/src/Domain/Setting/CreateOrReplaceSubscriptionSettingDTO.ts b/packages/auth/src/Domain/Setting/CreateOrReplaceSubscriptionSettingDTO.ts new file mode 100644 index 000000000..2ea761711 --- /dev/null +++ b/packages/auth/src/Domain/Setting/CreateOrReplaceSubscriptionSettingDTO.ts @@ -0,0 +1,7 @@ +import { UserSubscription } from '../Subscription/UserSubscription' +import { SubscriptionSettingProps } from './SubscriptionSettingProps' + +export type CreateOrReplaceSubscriptionSettingDTO = { + userSubscription: UserSubscription + props: SubscriptionSettingProps +} diff --git a/packages/auth/src/Domain/Setting/CreateOrReplaceSubscriptionSettingResponse.ts b/packages/auth/src/Domain/Setting/CreateOrReplaceSubscriptionSettingResponse.ts new file mode 100644 index 000000000..29fad9e3a --- /dev/null +++ b/packages/auth/src/Domain/Setting/CreateOrReplaceSubscriptionSettingResponse.ts @@ -0,0 +1,6 @@ +import { SubscriptionSetting } from './SubscriptionSetting' + +export type CreateOrReplaceSubscriptionSettingResponse = { + status: 'created' | 'replaced' + subscriptionSetting: SubscriptionSetting +} diff --git a/packages/auth/src/Domain/Setting/DeleteSettingDto.ts b/packages/auth/src/Domain/Setting/DeleteSettingDto.ts new file mode 100644 index 000000000..65543831d --- /dev/null +++ b/packages/auth/src/Domain/Setting/DeleteSettingDto.ts @@ -0,0 +1,6 @@ +import { Uuid } from '@standardnotes/common' + +export type DeleteSettingDto = { + settingName: string + userUuid: Uuid +} diff --git a/packages/auth/src/Domain/Setting/FindSettingDTO.ts b/packages/auth/src/Domain/Setting/FindSettingDTO.ts new file mode 100644 index 000000000..d341870bb --- /dev/null +++ b/packages/auth/src/Domain/Setting/FindSettingDTO.ts @@ -0,0 +1,8 @@ +import { Uuid } from '@standardnotes/common' +import { SettingName } from '@standardnotes/settings' + +export type FindSettingDTO = { + userUuid: string + settingName: SettingName + settingUuid?: Uuid +} diff --git a/packages/auth/src/Domain/Setting/FindSubscriptionSettingDTO.ts b/packages/auth/src/Domain/Setting/FindSubscriptionSettingDTO.ts new file mode 100644 index 000000000..549137efc --- /dev/null +++ b/packages/auth/src/Domain/Setting/FindSubscriptionSettingDTO.ts @@ -0,0 +1,9 @@ +import { Uuid } from '@standardnotes/common' +import { SubscriptionSettingName } from '@standardnotes/settings' + +export type FindSubscriptionSettingDTO = { + userUuid: Uuid + userSubscriptionUuid: Uuid + subscriptionSettingName: SubscriptionSettingName + settingUuid?: Uuid +} diff --git a/packages/auth/src/Domain/Setting/OfflineSetting.ts b/packages/auth/src/Domain/Setting/OfflineSetting.ts new file mode 100644 index 000000000..b6772517f --- /dev/null +++ b/packages/auth/src/Domain/Setting/OfflineSetting.ts @@ -0,0 +1,44 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm' +import { EncryptionVersion } from '../Encryption/EncryptionVersion' + +@Entity({ name: 'offline_settings' }) +@Index('index_offline_settings_on_name_and_email', ['name', 'email']) +export class OfflineSetting { + @PrimaryGeneratedColumn('uuid') + declare uuid: string + + @Column({ + length: 255, + }) + declare email: string + + @Column({ + length: 255, + }) + declare name: string + + @Column({ + type: 'text', + nullable: true, + }) + declare value: string | null + + @Column({ + name: 'server_encryption_version', + type: 'tinyint', + default: EncryptionVersion.Unencrypted, + }) + declare serverEncryptionVersion: number + + @Column({ + name: 'created_at', + type: 'bigint', + }) + declare createdAt: number + + @Column({ + name: 'updated_at', + type: 'bigint', + }) + declare updatedAt: number +} diff --git a/packages/auth/src/Domain/Setting/OfflineSettingName.ts b/packages/auth/src/Domain/Setting/OfflineSettingName.ts new file mode 100644 index 000000000..d181da09e --- /dev/null +++ b/packages/auth/src/Domain/Setting/OfflineSettingName.ts @@ -0,0 +1,3 @@ +export enum OfflineSettingName { + FeaturesToken = 'FEATURES_TOKEN', +} diff --git a/packages/auth/src/Domain/Setting/OfflineSettingRepositoryInterface.ts b/packages/auth/src/Domain/Setting/OfflineSettingRepositoryInterface.ts new file mode 100644 index 000000000..5db08d75e --- /dev/null +++ b/packages/auth/src/Domain/Setting/OfflineSettingRepositoryInterface.ts @@ -0,0 +1,8 @@ +import { OfflineSetting } from './OfflineSetting' +import { OfflineSettingName } from './OfflineSettingName' + +export interface OfflineSettingRepositoryInterface { + findOneByNameAndEmail(name: OfflineSettingName, email: string): Promise + findOneByNameAndValue(name: OfflineSettingName, value: string): Promise + save(offlineSetting: OfflineSetting): Promise +} diff --git a/packages/auth/src/Domain/Setting/OfflineSettingService.spec.ts b/packages/auth/src/Domain/Setting/OfflineSettingService.spec.ts new file mode 100644 index 000000000..824d7065d --- /dev/null +++ b/packages/auth/src/Domain/Setting/OfflineSettingService.spec.ts @@ -0,0 +1,59 @@ +import 'reflect-metadata' + +import { TimerInterface } from '@standardnotes/time' +import { OfflineSetting } from './OfflineSetting' +import { OfflineSettingName } from './OfflineSettingName' +import { OfflineSettingRepositoryInterface } from './OfflineSettingRepositoryInterface' + +import { OfflineSettingService } from './OfflineSettingService' + +describe('OfflineSettingService', () => { + let offlineSettingRepository: OfflineSettingRepositoryInterface + let timer: TimerInterface + let offlineSetting: OfflineSetting + + const createService = () => new OfflineSettingService(offlineSettingRepository, timer) + + beforeEach(() => { + offlineSetting = {} as jest.Mocked + + offlineSettingRepository = {} as jest.Mocked + offlineSettingRepository.findOneByNameAndEmail = jest.fn().mockReturnValue(null) + offlineSettingRepository.save = jest.fn() + + timer = {} as jest.Mocked + timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(123) + }) + + it('should save a new offline setting', async () => { + await createService().createOrUpdate({ + email: 'test@test.com', + name: OfflineSettingName.FeaturesToken, + value: 'test', + }) + + expect(offlineSettingRepository.save).toHaveBeenCalledWith({ + email: 'test@test.com', + name: 'FEATURES_TOKEN', + value: 'test', + createdAt: 123, + updatedAt: 123, + serverEncryptionVersion: 0, + }) + }) + + it('should update an existing offline setting', async () => { + offlineSettingRepository.findOneByNameAndEmail = jest.fn().mockReturnValue(offlineSetting) + + await createService().createOrUpdate({ + email: 'test@test.com', + name: OfflineSettingName.FeaturesToken, + value: 'test', + }) + + expect(offlineSettingRepository.save).toHaveBeenCalledWith({ + value: 'test', + updatedAt: 123, + }) + }) +}) diff --git a/packages/auth/src/Domain/Setting/OfflineSettingService.ts b/packages/auth/src/Domain/Setting/OfflineSettingService.ts new file mode 100644 index 000000000..02e12383d --- /dev/null +++ b/packages/auth/src/Domain/Setting/OfflineSettingService.ts @@ -0,0 +1,43 @@ +import { TimerInterface } from '@standardnotes/time' +import { inject, injectable } from 'inversify' + +import TYPES from '../../Bootstrap/Types' +import { EncryptionVersion } from '../Encryption/EncryptionVersion' + +import { OfflineSetting } from './OfflineSetting' +import { OfflineSettingName } from './OfflineSettingName' +import { OfflineSettingRepositoryInterface } from './OfflineSettingRepositoryInterface' +import { OfflineSettingServiceInterface } from './OfflineSettingServiceInterface' + +@injectable() +export class OfflineSettingService implements OfflineSettingServiceInterface { + constructor( + @inject(TYPES.OfflineSettingRepository) private offlineSettingRepository: OfflineSettingRepositoryInterface, + @inject(TYPES.Timer) private timer: TimerInterface, + ) {} + + async createOrUpdate(dto: { + email: string + name: OfflineSettingName + value: string + }): Promise<{ success: boolean; offlineSetting?: OfflineSetting | undefined }> { + let offlineSetting = await this.offlineSettingRepository.findOneByNameAndEmail(dto.name, dto.email) + if (offlineSetting === null) { + offlineSetting = new OfflineSetting() + offlineSetting.name = dto.name + offlineSetting.email = dto.email + offlineSetting.serverEncryptionVersion = EncryptionVersion.Unencrypted + offlineSetting.createdAt = this.timer.getTimestampInMicroseconds() + } + + offlineSetting.value = dto.value + offlineSetting.updatedAt = this.timer.getTimestampInMicroseconds() + + offlineSetting = await this.offlineSettingRepository.save(offlineSetting) + + return { + success: true, + offlineSetting, + } + } +} diff --git a/packages/auth/src/Domain/Setting/OfflineSettingServiceInterface.ts b/packages/auth/src/Domain/Setting/OfflineSettingServiceInterface.ts new file mode 100644 index 000000000..e578b9168 --- /dev/null +++ b/packages/auth/src/Domain/Setting/OfflineSettingServiceInterface.ts @@ -0,0 +1,10 @@ +import { OfflineSetting } from './OfflineSetting' +import { OfflineSettingName } from './OfflineSettingName' + +export interface OfflineSettingServiceInterface { + createOrUpdate(dto: { + email: string + name: OfflineSettingName + value: string + }): Promise<{ success: boolean; offlineSetting?: OfflineSetting }> +} diff --git a/packages/auth/src/Domain/Setting/Setting.ts b/packages/auth/src/Domain/Setting/Setting.ts new file mode 100644 index 000000000..a5c7b3685 --- /dev/null +++ b/packages/auth/src/Domain/Setting/Setting.ts @@ -0,0 +1,60 @@ +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm' +import { EncryptionVersion } from '../Encryption/EncryptionVersion' +import { User } from '../User/User' + +@Entity({ name: 'settings' }) +@Index('index_settings_on_name_and_user_uuid', ['name', 'user']) +export class Setting { + @PrimaryGeneratedColumn('uuid') + declare uuid: string + + @Column({ + length: 255, + }) + declare name: string + + @Column({ + type: 'text', + nullable: true, + }) + declare value: string | null + + @Column({ + name: 'server_encryption_version', + type: 'tinyint', + default: EncryptionVersion.Unencrypted, + }) + declare serverEncryptionVersion: number + + @Column({ + name: 'created_at', + type: 'bigint', + }) + declare createdAt: number + + @Column({ + name: 'updated_at', + type: 'bigint', + }) + @Index('index_settings_on_updated_at') + declare updatedAt: number + + @ManyToOne( + /* istanbul ignore next */ + () => User, + /* istanbul ignore next */ + (user) => user.settings, + /* istanbul ignore next */ + { onDelete: 'CASCADE', nullable: false, lazy: true, eager: false }, + ) + @JoinColumn({ name: 'user_uuid', referencedColumnName: 'uuid' }) + declare user: Promise + + @Column({ + type: 'tinyint', + width: 1, + nullable: false, + default: 0, + }) + declare sensitive: boolean +} diff --git a/packages/auth/src/Domain/Setting/SettingDecrypter.spec.ts b/packages/auth/src/Domain/Setting/SettingDecrypter.spec.ts new file mode 100644 index 000000000..59e151367 --- /dev/null +++ b/packages/auth/src/Domain/Setting/SettingDecrypter.spec.ts @@ -0,0 +1,72 @@ +import 'reflect-metadata' +import { CrypterInterface } from '../Encryption/CrypterInterface' +import { EncryptionVersion } from '../Encryption/EncryptionVersion' +import { User } from '../User/User' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { Setting } from './Setting' + +import { SettingDecrypter } from './SettingDecrypter' + +describe('SettingDecrypter', () => { + let userRepository: UserRepositoryInterface + let crypter: CrypterInterface + let user: User + + const createDecrypter = () => new SettingDecrypter(userRepository, crypter) + + beforeEach(() => { + crypter = {} as jest.Mocked + crypter.decryptForUser = jest.fn().mockReturnValue('decrypted') + + user = { + uuid: '4-5-6', + } as jest.Mocked + + userRepository = {} as jest.Mocked + userRepository.findOneByUuid = jest.fn().mockReturnValue(user) + }) + + it('should decrypt an encrypted value of a setting', async () => { + const setting = { + value: 'encrypted', + serverEncryptionVersion: EncryptionVersion.Default, + } as jest.Mocked + + expect(await createDecrypter().decryptSettingValue(setting, '1-2-3')).toEqual('decrypted') + }) + + it('should return null if the setting value is null', async () => { + const setting = { + value: null, + serverEncryptionVersion: EncryptionVersion.Default, + } as jest.Mocked + + expect(await createDecrypter().decryptSettingValue(setting, '1-2-3')).toBeNull() + }) + + it('should return unencrypted value if the setting value is unencrypted', async () => { + const setting = { + value: 'test', + serverEncryptionVersion: EncryptionVersion.Unencrypted, + } as jest.Mocked + + expect(await createDecrypter().decryptSettingValue(setting, '1-2-3')).toEqual('test') + }) + + it('should throw if the user could not be found', async () => { + const setting = { + value: 'encrypted', + serverEncryptionVersion: EncryptionVersion.Default, + } as jest.Mocked + userRepository.findOneByUuid = jest.fn().mockReturnValue(null) + + let caughtError = null + try { + await createDecrypter().decryptSettingValue(setting, '1-2-3') + } catch (error) { + caughtError = error + } + + expect(caughtError).not.toBeNull() + }) +}) diff --git a/packages/auth/src/Domain/Setting/SettingDecrypter.ts b/packages/auth/src/Domain/Setting/SettingDecrypter.ts new file mode 100644 index 000000000..cf462eb36 --- /dev/null +++ b/packages/auth/src/Domain/Setting/SettingDecrypter.ts @@ -0,0 +1,30 @@ +import { inject, injectable } from 'inversify' +import TYPES from '../../Bootstrap/Types' +import { CrypterInterface } from '../Encryption/CrypterInterface' +import { EncryptionVersion } from '../Encryption/EncryptionVersion' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { Setting } from './Setting' +import { SettingDecrypterInterface } from './SettingDecrypterInterface' +import { SubscriptionSetting } from './SubscriptionSetting' + +@injectable() +export class SettingDecrypter implements SettingDecrypterInterface { + constructor( + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.Crypter) private crypter: CrypterInterface, + ) {} + + async decryptSettingValue(setting: Setting | SubscriptionSetting, userUuid: string): Promise { + if (setting.value !== null && setting.serverEncryptionVersion === EncryptionVersion.Default) { + const user = await this.userRepository.findOneByUuid(userUuid) + + if (user === null) { + throw new Error(`Could not find user with uuid: ${userUuid}`) + } + + return this.crypter.decryptForUser(setting.value, user) + } + + return setting.value + } +} diff --git a/packages/auth/src/Domain/Setting/SettingDecrypterInterface.ts b/packages/auth/src/Domain/Setting/SettingDecrypterInterface.ts new file mode 100644 index 000000000..bc48a350d --- /dev/null +++ b/packages/auth/src/Domain/Setting/SettingDecrypterInterface.ts @@ -0,0 +1,7 @@ +import { Uuid } from '@standardnotes/common' +import { Setting } from './Setting' +import { SubscriptionSetting } from './SubscriptionSetting' + +export interface SettingDecrypterInterface { + decryptSettingValue(setting: Setting | SubscriptionSetting, userUuid: Uuid): Promise +} diff --git a/packages/auth/src/Domain/Setting/SettingDescription.ts b/packages/auth/src/Domain/Setting/SettingDescription.ts new file mode 100644 index 000000000..0067dd520 --- /dev/null +++ b/packages/auth/src/Domain/Setting/SettingDescription.ts @@ -0,0 +1,7 @@ +import { EncryptionVersion } from '../Encryption/EncryptionVersion' + +export type SettingDescription = { + value: string + sensitive: boolean + serverEncryptionVersion: EncryptionVersion +} diff --git a/packages/auth/src/Domain/Setting/SettingFactory.spec.ts b/packages/auth/src/Domain/Setting/SettingFactory.spec.ts new file mode 100644 index 000000000..170ddc750 --- /dev/null +++ b/packages/auth/src/Domain/Setting/SettingFactory.spec.ts @@ -0,0 +1,185 @@ +import 'reflect-metadata' + +import { TimerInterface } from '@standardnotes/time' +import { CrypterInterface } from '../Encryption/CrypterInterface' +import { EncryptionVersion } from '../Encryption/EncryptionVersion' +import { User } from '../User/User' +import { Setting } from './Setting' +import { SettingFactory } from './SettingFactory' +import { SettingProps } from './SettingProps' +import { SubscriptionSettingProps } from './SubscriptionSettingProps' +import { UserSubscription } from '../Subscription/UserSubscription' +import { SubscriptionSetting } from './SubscriptionSetting' + +describe('SettingFactory', () => { + let crypter: CrypterInterface + let timer: TimerInterface + let user: User + let userSubscription: UserSubscription + + const createFactory = () => new SettingFactory(crypter, timer) + + beforeEach(() => { + crypter = {} as jest.Mocked + crypter.encryptForUser = jest.fn().mockReturnValue('encrypted') + + timer = {} as jest.Mocked + timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(1) + + user = {} as jest.Mocked + + userSubscription = { + user: Promise.resolve(user), + } as jest.Mocked + }) + + it('should create a Setting', async () => { + const props: SettingProps = { + name: 'name', + unencryptedValue: 'value', + serverEncryptionVersion: EncryptionVersion.Unencrypted, + sensitive: false, + } + const actual = await createFactory().create(props, user) + + expect(actual).toEqual({ + createdAt: 1, + updatedAt: 1, + name: 'name', + sensitive: false, + serverEncryptionVersion: 0, + user: Promise.resolve(user), + uuid: expect.any(String), + value: 'value', + }) + }) + + it('should create a SubscriptionSetting', async () => { + const props: SubscriptionSettingProps = { + name: 'name', + unencryptedValue: 'value', + serverEncryptionVersion: EncryptionVersion.Unencrypted, + sensitive: false, + } + const actual = await createFactory().createSubscriptionSetting(props, userSubscription) + + expect(actual).toEqual({ + createdAt: 1, + updatedAt: 1, + name: 'name', + sensitive: false, + serverEncryptionVersion: 0, + userSubscription: Promise.resolve(userSubscription), + uuid: expect.any(String), + value: 'value', + }) + }) + + it('should create an encrypted SubscriptionSetting', async () => { + const value = 'value' + const props: SettingProps = { + name: 'name', + unencryptedValue: value, + sensitive: false, + } + + const actual = await createFactory().createSubscriptionSetting(props, userSubscription) + + expect(actual).toEqual({ + createdAt: 1, + updatedAt: 1, + name: 'name', + sensitive: false, + serverEncryptionVersion: 1, + userSubscription: Promise.resolve(userSubscription), + uuid: expect.any(String), + value: 'encrypted', + }) + }) + + it('should create a SubscriptionSetting replacement', async () => { + const original = { + userSubscription: Promise.resolve(userSubscription), + } as jest.Mocked + original.uuid = '2-3-4' + + const props: SettingProps = { + name: 'name', + unencryptedValue: 'value2', + serverEncryptionVersion: EncryptionVersion.Unencrypted, + sensitive: true, + } + + const actual = await createFactory().createSubscriptionSettingReplacement(original, props) + + expect(actual).toEqual({ + createdAt: 1, + updatedAt: 1, + name: 'name', + sensitive: true, + serverEncryptionVersion: 0, + userSubscription: Promise.resolve(userSubscription), + uuid: '2-3-4', + value: 'value2', + }) + }) + + it('should create a Setting replacement', async () => { + const original = {} as jest.Mocked + original.uuid = '2-3-4' + + const props: SettingProps = { + name: 'name', + unencryptedValue: 'value2', + serverEncryptionVersion: EncryptionVersion.Unencrypted, + sensitive: true, + } + + const actual = await createFactory().createReplacement(original, props) + + expect(actual).toEqual({ + createdAt: 1, + updatedAt: 1, + name: 'name', + sensitive: true, + serverEncryptionVersion: 0, + user: Promise.resolve(user), + uuid: '2-3-4', + value: 'value2', + }) + }) + + it('should create an encrypted Setting', async () => { + const value = 'value' + const props: SettingProps = { + name: 'name', + unencryptedValue: value, + sensitive: false, + } + + const actual = await createFactory().create(props, user) + + expect(actual).toEqual({ + createdAt: 1, + updatedAt: 1, + name: 'name', + sensitive: false, + serverEncryptionVersion: 1, + user: Promise.resolve(user), + uuid: expect.any(String), + value: 'encrypted', + }) + }) + + it('should throw for unrecognized encryption version', async () => { + const value = 'value' + const props: SettingProps = { + name: 'name', + unencryptedValue: value, + serverEncryptionVersion: 99999999999, + sensitive: false, + } + + await expect(async () => await createFactory().create(props, user)).rejects.toThrow() + }) +}) diff --git a/packages/auth/src/Domain/Setting/SettingFactory.ts b/packages/auth/src/Domain/Setting/SettingFactory.ts new file mode 100644 index 000000000..d670869b6 --- /dev/null +++ b/packages/auth/src/Domain/Setting/SettingFactory.ts @@ -0,0 +1,114 @@ +import { inject, injectable } from 'inversify' +import TYPES from '../../Bootstrap/Types' +import { User } from '../User/User' +import { Setting } from './Setting' +import { SettingProps } from './SettingProps' +import { v4 as uuidv4 } from 'uuid' +import { CrypterInterface } from '../Encryption/CrypterInterface' +import { TimerInterface } from '@standardnotes/time' +import { EncryptionVersion } from '../Encryption/EncryptionVersion' +import { SettingFactoryInterface } from './SettingFactoryInterface' +import { UserSubscription } from '../Subscription/UserSubscription' +import { SubscriptionSetting } from './SubscriptionSetting' +import { SubscriptionSettingProps } from './SubscriptionSettingProps' + +@injectable() +export class SettingFactory implements SettingFactoryInterface { + constructor( + @inject(TYPES.Crypter) private crypter: CrypterInterface, + @inject(TYPES.Timer) private timer: TimerInterface, + ) {} + + async createSubscriptionSetting( + props: SubscriptionSettingProps, + userSubscription: UserSubscription, + ): Promise { + const uuid = props.uuid ?? uuidv4() + const now = this.timer.getTimestampInMicroseconds() + const createdAt = props.createdAt ?? now + const updatedAt = props.updatedAt ?? now + + const { name, unencryptedValue, serverEncryptionVersion = EncryptionVersion.Default, sensitive } = props + + const subscriptionSetting = { + uuid, + userSubscription: Promise.resolve(userSubscription), + name, + value: await this.createValue({ + unencryptedValue, + serverEncryptionVersion, + user: await userSubscription.user, + }), + serverEncryptionVersion, + createdAt, + updatedAt, + sensitive, + } + + return Object.assign(new SubscriptionSetting(), subscriptionSetting) + } + + async createSubscriptionSettingReplacement( + original: SubscriptionSetting, + props: SubscriptionSettingProps, + ): Promise { + const { uuid, userSubscription } = original + + return Object.assign(await this.createSubscriptionSetting(props, await userSubscription), { + uuid, + }) + } + + async create(props: SettingProps, user: User): Promise { + const uuid = props.uuid ?? uuidv4() + const now = this.timer.getTimestampInMicroseconds() + const createdAt = props.createdAt ?? now + const updatedAt = props.updatedAt ?? now + + const { name, unencryptedValue, serverEncryptionVersion = EncryptionVersion.Default, sensitive } = props + + const setting = { + uuid, + user: Promise.resolve(user), + name, + value: await this.createValue({ + unencryptedValue, + serverEncryptionVersion, + user, + }), + serverEncryptionVersion, + createdAt, + updatedAt, + sensitive, + } + + return Object.assign(new Setting(), setting) + } + + async createReplacement(original: Setting, props: SettingProps): Promise { + const { uuid, user } = original + + return Object.assign(await this.create(props, await user), { + uuid, + }) + } + + async createValue({ + unencryptedValue, + serverEncryptionVersion, + user, + }: { + unencryptedValue: string | null + serverEncryptionVersion: number + user: User + }): Promise { + switch (serverEncryptionVersion) { + case EncryptionVersion.Unencrypted: + return unencryptedValue + case EncryptionVersion.Default: + return this.crypter.encryptForUser(unencryptedValue as string, user) + default: + throw Error(`Unrecognized encryption version: ${serverEncryptionVersion}!`) + } + } +} diff --git a/packages/auth/src/Domain/Setting/SettingFactoryInterface.ts b/packages/auth/src/Domain/Setting/SettingFactoryInterface.ts new file mode 100644 index 000000000..0c728f273 --- /dev/null +++ b/packages/auth/src/Domain/Setting/SettingFactoryInterface.ts @@ -0,0 +1,19 @@ +import { UserSubscription } from '../Subscription/UserSubscription' +import { User } from '../User/User' +import { Setting } from './Setting' +import { SettingProps } from './SettingProps' +import { SubscriptionSetting } from './SubscriptionSetting' +import { SubscriptionSettingProps } from './SubscriptionSettingProps' + +export interface SettingFactoryInterface { + create(props: SettingProps, user: User): Promise + createSubscriptionSetting( + props: SubscriptionSettingProps, + userSubscription: UserSubscription, + ): Promise + createReplacement(original: Setting, props: SettingProps): Promise + createSubscriptionSettingReplacement( + original: SubscriptionSetting, + props: SubscriptionSettingProps, + ): Promise +} diff --git a/packages/auth/src/Domain/Setting/SettingInterpreter.spec.ts b/packages/auth/src/Domain/Setting/SettingInterpreter.spec.ts new file mode 100644 index 000000000..231775750 --- /dev/null +++ b/packages/auth/src/Domain/Setting/SettingInterpreter.spec.ts @@ -0,0 +1,264 @@ +import { + CloudBackupRequestedEvent, + DomainEventPublisherInterface, + EmailBackupRequestedEvent, + UserDisabledSessionUserAgentLoggingEvent, +} from '@standardnotes/domain-events' +import { + EmailBackupFrequency, + LogSessionUserAgentOption, + OneDriveBackupFrequency, + SettingName, +} from '@standardnotes/settings' +import 'reflect-metadata' +import { Logger } from 'winston' +import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface' +import { User } from '../User/User' +import { Setting } from './Setting' +import { SettingDecrypterInterface } from './SettingDecrypterInterface' + +import { SettingInterpreter } from './SettingInterpreter' +import { SettingRepositoryInterface } from './SettingRepositoryInterface' + +describe('SettingInterpreter', () => { + let user: User + let domainEventPublisher: DomainEventPublisherInterface + let domainEventFactory: DomainEventFactoryInterface + let settingRepository: SettingRepositoryInterface + let settingDecrypter: SettingDecrypterInterface + let logger: Logger + + const createInterpreter = () => + new SettingInterpreter(domainEventPublisher, domainEventFactory, settingRepository, settingDecrypter, logger) + + beforeEach(() => { + user = { + uuid: '4-5-6', + email: 'test@test.te', + } as jest.Mocked + + settingRepository = {} as jest.Mocked + settingRepository.findLastByNameAndUserUuid = jest.fn().mockReturnValue(null) + settingRepository.findOneByNameAndUserUuid = jest.fn().mockReturnValue(null) + + settingDecrypter = {} as jest.Mocked + settingDecrypter.decryptSettingValue = jest.fn().mockReturnValue('decrypted') + + domainEventPublisher = {} as jest.Mocked + domainEventPublisher.publish = jest.fn() + + domainEventFactory = {} as jest.Mocked + domainEventFactory.createEmailBackupRequestedEvent = jest + .fn() + .mockReturnValue({} as jest.Mocked) + domainEventFactory.createCloudBackupRequestedEvent = jest + .fn() + .mockReturnValue({} as jest.Mocked) + domainEventFactory.createUserDisabledSessionUserAgentLoggingEvent = jest + .fn() + .mockReturnValue({} as jest.Mocked) + + logger = {} as jest.Mocked + logger.debug = jest.fn() + logger.warn = jest.fn() + logger.error = jest.fn() + }) + + it('should trigger session cleanup if user is disabling session user agent logging', async () => { + const setting = { + name: SettingName.LogSessionUserAgent, + value: LogSessionUserAgentOption.Disabled, + } as jest.Mocked + + await createInterpreter().interpretSettingUpdated(setting, user, LogSessionUserAgentOption.Disabled) + + expect(domainEventPublisher.publish).toHaveBeenCalled() + expect(domainEventFactory.createUserDisabledSessionUserAgentLoggingEvent).toHaveBeenCalledWith({ + userUuid: '4-5-6', + email: 'test@test.te', + }) + }) + + it('should trigger backup if email backup setting is created - emails not muted', async () => { + const setting = { + name: SettingName.EmailBackupFrequency, + value: EmailBackupFrequency.Daily, + } as jest.Mocked + + await createInterpreter().interpretSettingUpdated(setting, user, EmailBackupFrequency.Daily) + + expect(domainEventPublisher.publish).toHaveBeenCalled() + expect(domainEventFactory.createEmailBackupRequestedEvent).toHaveBeenCalledWith('4-5-6', '', false) + }) + + it('should trigger backup if email backup setting is created - emails muted', async () => { + const setting = { + name: SettingName.EmailBackupFrequency, + value: EmailBackupFrequency.Daily, + } as jest.Mocked + settingRepository.findOneByNameAndUserUuid = jest.fn().mockReturnValue({ + name: SettingName.MuteFailedBackupsEmails, + uuid: '6-7-8', + value: 'muted', + } as jest.Mocked) + + await createInterpreter().interpretSettingUpdated(setting, user, EmailBackupFrequency.Daily) + + expect(domainEventPublisher.publish).toHaveBeenCalled() + expect(domainEventFactory.createEmailBackupRequestedEvent).toHaveBeenCalledWith('4-5-6', '6-7-8', true) + }) + + it('should not trigger backup if email backup setting is disabled', async () => { + const setting = { + name: SettingName.EmailBackupFrequency, + value: EmailBackupFrequency.Disabled, + } as jest.Mocked + settingRepository.findOneByNameAndUserUuid = jest.fn().mockReturnValue(null) + + await createInterpreter().interpretSettingUpdated(setting, user, EmailBackupFrequency.Disabled) + + expect(domainEventPublisher.publish).not.toHaveBeenCalled() + expect(domainEventFactory.createEmailBackupRequestedEvent).not.toHaveBeenCalled() + }) + + it('should trigger cloud backup if dropbox backup setting is created', async () => { + const setting = { + name: SettingName.DropboxBackupToken, + value: 'test-token', + } as jest.Mocked + settingRepository.findOneByNameAndUserUuid = jest.fn().mockReturnValue(null) + + await createInterpreter().interpretSettingUpdated(setting, user, 'test-token') + + expect(domainEventPublisher.publish).toHaveBeenCalled() + expect(domainEventFactory.createCloudBackupRequestedEvent).toHaveBeenCalledWith( + 'DROPBOX', + 'test-token', + '4-5-6', + '', + false, + ) + }) + + it('should trigger cloud backup if dropbox backup setting is created - muted emails', async () => { + const setting = { + name: SettingName.DropboxBackupToken, + value: 'test-token', + } as jest.Mocked + settingRepository.findOneByNameAndUserUuid = jest.fn().mockReturnValue({ + name: SettingName.MuteFailedCloudBackupsEmails, + uuid: '6-7-8', + value: 'muted', + } as jest.Mocked) + + await createInterpreter().interpretSettingUpdated(setting, user, 'test-token') + + expect(domainEventPublisher.publish).toHaveBeenCalled() + expect(domainEventFactory.createCloudBackupRequestedEvent).toHaveBeenCalledWith( + 'DROPBOX', + 'test-token', + '4-5-6', + '6-7-8', + true, + ) + }) + + it('should trigger cloud backup if google drive backup setting is created', async () => { + const setting = { + name: SettingName.GoogleDriveBackupToken, + value: 'test-token', + } as jest.Mocked + settingRepository.findOneByNameAndUserUuid = jest.fn().mockReturnValue(null) + + await createInterpreter().interpretSettingUpdated(setting, user, 'test-token') + + expect(domainEventPublisher.publish).toHaveBeenCalled() + expect(domainEventFactory.createCloudBackupRequestedEvent).toHaveBeenCalledWith( + 'GOOGLE_DRIVE', + 'test-token', + '4-5-6', + '', + false, + ) + }) + + it('should trigger cloud backup if one drive backup setting is created', async () => { + const setting = { + name: SettingName.OneDriveBackupToken, + value: 'test-token', + } as jest.Mocked + settingRepository.findOneByNameAndUserUuid = jest.fn().mockReturnValue(null) + + await createInterpreter().interpretSettingUpdated(setting, user, 'test-token') + + expect(domainEventPublisher.publish).toHaveBeenCalled() + expect(domainEventFactory.createCloudBackupRequestedEvent).toHaveBeenCalledWith( + 'ONE_DRIVE', + 'test-token', + '4-5-6', + '', + false, + ) + }) + + it('should trigger cloud backup if backup frequency setting is updated and a backup token setting is present', async () => { + settingRepository.findLastByNameAndUserUuid = jest.fn().mockReturnValueOnce({ + name: SettingName.OneDriveBackupToken, + serverEncryptionVersion: 1, + value: 'encrypted-backup-token', + sensitive: true, + } as jest.Mocked) + const setting = { + name: SettingName.OneDriveBackupFrequency, + serverEncryptionVersion: 0, + value: 'daily', + sensitive: false, + } as jest.Mocked + + await createInterpreter().interpretSettingUpdated(setting, user, 'daily') + + expect(domainEventPublisher.publish).toHaveBeenCalled() + expect(domainEventFactory.createCloudBackupRequestedEvent).toHaveBeenCalledWith( + 'ONE_DRIVE', + 'decrypted', + '4-5-6', + '', + false, + ) + }) + + it('should not trigger cloud backup if backup frequency setting is updated as disabled', async () => { + settingRepository.findLastByNameAndUserUuid = jest.fn().mockReturnValueOnce({ + name: SettingName.OneDriveBackupToken, + serverEncryptionVersion: 1, + value: 'encrypted-backup-token', + sensitive: true, + } as jest.Mocked) + const setting = { + name: SettingName.OneDriveBackupFrequency, + serverEncryptionVersion: 0, + value: OneDriveBackupFrequency.Disabled, + sensitive: false, + } as jest.Mocked + + await createInterpreter().interpretSettingUpdated(setting, user, OneDriveBackupFrequency.Disabled) + + expect(domainEventPublisher.publish).not.toHaveBeenCalled() + expect(domainEventFactory.createCloudBackupRequestedEvent).not.toHaveBeenCalled() + }) + + it('should not trigger cloud backup if backup frequency setting is updated and a backup token setting is not present', async () => { + settingRepository.findLastByNameAndUserUuid = jest.fn().mockReturnValueOnce(null) + const setting = { + name: SettingName.OneDriveBackupFrequency, + serverEncryptionVersion: 0, + value: 'daily', + sensitive: false, + } as jest.Mocked + + await createInterpreter().interpretSettingUpdated(setting, user, 'daily') + + expect(domainEventPublisher.publish).not.toHaveBeenCalled() + expect(domainEventFactory.createCloudBackupRequestedEvent).not.toHaveBeenCalled() + }) +}) diff --git a/packages/auth/src/Domain/Setting/SettingInterpreter.ts b/packages/auth/src/Domain/Setting/SettingInterpreter.ts new file mode 100644 index 000000000..d2b68df1b --- /dev/null +++ b/packages/auth/src/Domain/Setting/SettingInterpreter.ts @@ -0,0 +1,169 @@ +import { DomainEventPublisherInterface } from '@standardnotes/domain-events' +import { + DropboxBackupFrequency, + EmailBackupFrequency, + GoogleDriveBackupFrequency, + LogSessionUserAgentOption, + MuteFailedBackupsEmailsOption, + MuteFailedCloudBackupsEmailsOption, + OneDriveBackupFrequency, + SettingName, +} from '@standardnotes/settings' +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' +import TYPES from '../../Bootstrap/Types' +import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface' +import { User } from '../User/User' +import { Setting } from './Setting' +import { SettingDecrypterInterface } from './SettingDecrypterInterface' +import { SettingInterpreterInterface } from './SettingInterpreterInterface' +import { SettingRepositoryInterface } from './SettingRepositoryInterface' + +@injectable() +export class SettingInterpreter implements SettingInterpreterInterface { + private readonly cloudBackupTokenSettings = [ + SettingName.DropboxBackupToken, + SettingName.GoogleDriveBackupToken, + SettingName.OneDriveBackupToken, + ] + + private readonly cloudBackupFrequencySettings = [ + SettingName.DropboxBackupFrequency, + SettingName.GoogleDriveBackupFrequency, + SettingName.OneDriveBackupFrequency, + ] + + private readonly cloudBackupFrequencyDisabledValues = [ + DropboxBackupFrequency.Disabled, + GoogleDriveBackupFrequency.Disabled, + OneDriveBackupFrequency.Disabled, + ] + + constructor( + @inject(TYPES.DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface, + @inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface, + @inject(TYPES.SettingRepository) private settingRepository: SettingRepositoryInterface, + @inject(TYPES.SettingDecrypter) private settingDecrypter: SettingDecrypterInterface, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async interpretSettingUpdated(updatedSetting: Setting, user: User, unencryptedValue: string | null): Promise { + if (this.isEnablingEmailBackupSetting(updatedSetting)) { + await this.triggerEmailBackup(user.uuid) + } + + if (this.isEnablingCloudBackupSetting(updatedSetting)) { + await this.triggerCloudBackup(updatedSetting, user.uuid, unencryptedValue) + } + + if (this.isDisablingSessionUserAgentLogging(updatedSetting)) { + await this.triggerSessionUserAgentCleanup(user) + } + } + + private async triggerEmailBackup(userUuid: string): Promise { + let userHasEmailsMuted = false + let muteEmailsSettingUuid = '' + const muteFailedEmailsBackupSetting = await this.settingRepository.findOneByNameAndUserUuid( + SettingName.MuteFailedBackupsEmails, + userUuid, + ) + if (muteFailedEmailsBackupSetting !== null) { + userHasEmailsMuted = muteFailedEmailsBackupSetting.value === MuteFailedBackupsEmailsOption.Muted + muteEmailsSettingUuid = muteFailedEmailsBackupSetting.uuid + } + + await this.domainEventPublisher.publish( + this.domainEventFactory.createEmailBackupRequestedEvent(userUuid, muteEmailsSettingUuid, userHasEmailsMuted), + ) + } + + private isEnablingEmailBackupSetting(setting: Setting): boolean { + return setting.name === SettingName.EmailBackupFrequency && setting.value !== EmailBackupFrequency.Disabled + } + + private isEnablingCloudBackupSetting(setting: Setting): boolean { + return ( + (this.cloudBackupFrequencySettings.includes(setting.name as SettingName) || + this.cloudBackupTokenSettings.includes(setting.name as SettingName)) && + !this.cloudBackupFrequencyDisabledValues.includes( + setting.value as DropboxBackupFrequency | OneDriveBackupFrequency | GoogleDriveBackupFrequency, + ) + ) + } + + private isDisablingSessionUserAgentLogging(setting: Setting): boolean { + return SettingName.LogSessionUserAgent === setting.name && LogSessionUserAgentOption.Disabled === setting.value + } + + private async triggerSessionUserAgentCleanup(user: User) { + await this.domainEventPublisher.publish( + this.domainEventFactory.createUserDisabledSessionUserAgentLoggingEvent({ + userUuid: user.uuid, + email: user.email, + }), + ) + } + + private async triggerCloudBackup(setting: Setting, userUuid: string, unencryptedValue: string | null): Promise { + let cloudProvider + let tokenSettingName + switch (setting.name) { + case SettingName.DropboxBackupToken: + case SettingName.DropboxBackupFrequency: + cloudProvider = 'DROPBOX' + tokenSettingName = SettingName.DropboxBackupToken + break + case SettingName.GoogleDriveBackupToken: + case SettingName.GoogleDriveBackupFrequency: + cloudProvider = 'GOOGLE_DRIVE' + tokenSettingName = SettingName.GoogleDriveBackupToken + break + case SettingName.OneDriveBackupToken: + case SettingName.OneDriveBackupFrequency: + cloudProvider = 'ONE_DRIVE' + tokenSettingName = SettingName.OneDriveBackupToken + break + } + + let backupToken = null + if (this.cloudBackupFrequencySettings.includes(setting.name as SettingName)) { + const tokenSetting = await this.settingRepository.findLastByNameAndUserUuid( + tokenSettingName as SettingName, + userUuid, + ) + if (tokenSetting !== null) { + backupToken = await this.settingDecrypter.decryptSettingValue(tokenSetting, userUuid) + } + } else { + backupToken = unencryptedValue + } + + if (!backupToken) { + this.logger.error(`Could not trigger backup. Missing backup token for user ${userUuid}`) + + return + } + + let userHasEmailsMuted = false + let muteEmailsSettingUuid = '' + const muteFailedCloudBackupSetting = await this.settingRepository.findOneByNameAndUserUuid( + SettingName.MuteFailedCloudBackupsEmails, + userUuid, + ) + if (muteFailedCloudBackupSetting !== null) { + userHasEmailsMuted = muteFailedCloudBackupSetting.value === MuteFailedCloudBackupsEmailsOption.Muted + muteEmailsSettingUuid = muteFailedCloudBackupSetting.uuid + } + + await this.domainEventPublisher.publish( + this.domainEventFactory.createCloudBackupRequestedEvent( + cloudProvider as 'DROPBOX' | 'GOOGLE_DRIVE' | 'ONE_DRIVE', + backupToken, + userUuid, + muteEmailsSettingUuid, + userHasEmailsMuted, + ), + ) + } +} diff --git a/packages/auth/src/Domain/Setting/SettingInterpreterInterface.ts b/packages/auth/src/Domain/Setting/SettingInterpreterInterface.ts new file mode 100644 index 000000000..5f1440c18 --- /dev/null +++ b/packages/auth/src/Domain/Setting/SettingInterpreterInterface.ts @@ -0,0 +1,6 @@ +import { User } from '../User/User' +import { Setting } from './Setting' + +export interface SettingInterpreterInterface { + interpretSettingUpdated(updatedSetting: Setting, user: User, newUnencryptedValue: string | null): Promise +} diff --git a/packages/auth/src/Domain/Setting/SettingProps.ts b/packages/auth/src/Domain/Setting/SettingProps.ts new file mode 100644 index 000000000..b0aa26a62 --- /dev/null +++ b/packages/auth/src/Domain/Setting/SettingProps.ts @@ -0,0 +1,12 @@ +import { Setting } from './Setting' + +export type SettingProps = Omit< + Setting, + 'uuid' | 'user' | 'createdAt' | 'updatedAt' | 'serverEncryptionVersion' | 'value' +> & { + uuid?: string + createdAt?: number + updatedAt?: number + unencryptedValue: string | null + serverEncryptionVersion?: number +} diff --git a/packages/auth/src/Domain/Setting/SettingRepositoryInterface.ts b/packages/auth/src/Domain/Setting/SettingRepositoryInterface.ts new file mode 100644 index 000000000..b09e4e1e1 --- /dev/null +++ b/packages/auth/src/Domain/Setting/SettingRepositoryInterface.ts @@ -0,0 +1,16 @@ +import { ReadStream } from 'fs' + +import { SettingName } from '@standardnotes/settings' +import { DeleteSettingDto } from '../UseCase/DeleteSetting/DeleteSettingDto' +import { Setting } from './Setting' + +export interface SettingRepositoryInterface { + findOneByUuid(uuid: string): Promise + findOneByUuidAndNames(uuid: string, names: SettingName[]): Promise + findOneByNameAndUserUuid(name: string, userUuid: string): Promise + findLastByNameAndUserUuid(name: string, userUuid: string): Promise + findAllByUserUuid(userUuid: string): Promise + streamAllByNameAndValue(name: SettingName, value: string): Promise + deleteByUserUuid(dto: DeleteSettingDto): Promise + save(setting: Setting): Promise +} diff --git a/packages/auth/src/Domain/Setting/SettingService.spec.ts b/packages/auth/src/Domain/Setting/SettingService.spec.ts new file mode 100644 index 000000000..6888ef895 --- /dev/null +++ b/packages/auth/src/Domain/Setting/SettingService.spec.ts @@ -0,0 +1,183 @@ +import 'reflect-metadata' + +import { LogSessionUserAgentOption, MuteSignInEmailsOption, SettingName } from '@standardnotes/settings' +import { Logger } from 'winston' +import { EncryptionVersion } from '../Encryption/EncryptionVersion' +import { User } from '../User/User' +import { Setting } from './Setting' +import { SettingRepositoryInterface } from './SettingRepositoryInterface' + +import { SettingService } from './SettingService' +import { SettingsAssociationServiceInterface } from './SettingsAssociationServiceInterface' +import { SettingInterpreterInterface } from './SettingInterpreterInterface' +import { SettingDecrypterInterface } from './SettingDecrypterInterface' +import { SettingFactoryInterface } from './SettingFactoryInterface' + +describe('SettingService', () => { + let setting: Setting + let user: User + let factory: SettingFactoryInterface + let settingRepository: SettingRepositoryInterface + let settingsAssociationService: SettingsAssociationServiceInterface + let settingInterpreter: SettingInterpreterInterface + let settingDecrypter: SettingDecrypterInterface + let logger: Logger + + const createService = () => + new SettingService( + factory, + settingRepository, + settingsAssociationService, + settingInterpreter, + settingDecrypter, + logger, + ) + + beforeEach(() => { + user = { + uuid: '4-5-6', + } as jest.Mocked + user.isPotentiallyAVaultAccount = jest.fn().mockReturnValue(false) + + setting = {} as jest.Mocked + + factory = {} as jest.Mocked + factory.create = jest.fn().mockReturnValue(setting) + factory.createReplacement = jest.fn().mockReturnValue(setting) + + settingRepository = {} as jest.Mocked + settingRepository.findLastByNameAndUserUuid = jest.fn().mockReturnValue(null) + settingRepository.findOneByNameAndUserUuid = jest.fn().mockReturnValue(null) + settingRepository.save = jest.fn().mockImplementation((setting) => setting) + + settingsAssociationService = {} as jest.Mocked + settingsAssociationService.getDefaultSettingsAndValuesForNewUser = jest.fn().mockReturnValue( + new Map([ + [ + SettingName.MuteSignInEmails, + { + value: MuteSignInEmailsOption.NotMuted, + sensitive: 0, + serverEncryptionVersion: EncryptionVersion.Unencrypted, + }, + ], + ]), + ) + + settingsAssociationService.getDefaultSettingsAndValuesForNewVaultAccount = jest.fn().mockReturnValue( + new Map([ + [ + SettingName.LogSessionUserAgent, + { + sensitive: false, + serverEncryptionVersion: EncryptionVersion.Unencrypted, + value: LogSessionUserAgentOption.Disabled, + }, + ], + ]), + ) + + settingInterpreter = {} as jest.Mocked + settingInterpreter.interpretSettingUpdated = jest.fn() + + settingDecrypter = {} as jest.Mocked + settingDecrypter.decryptSettingValue = jest.fn().mockReturnValue('decrypted') + + logger = {} as jest.Mocked + logger.debug = jest.fn() + logger.warn = jest.fn() + logger.error = jest.fn() + }) + + it('should create default settings for a newly registered user', async () => { + await createService().applyDefaultSettingsUponRegistration(user) + + expect(settingRepository.save).toHaveBeenCalledWith(setting) + }) + + it('should create default settings for a newly registered vault account', async () => { + user.isPotentiallyAVaultAccount = jest.fn().mockReturnValue(true) + + await createService().applyDefaultSettingsUponRegistration(user) + + expect(settingRepository.save).toHaveBeenCalledWith(setting) + }) + + it("should create setting if it doesn't exist", async () => { + const result = await createService().createOrReplace({ + user, + props: { + name: 'name', + unencryptedValue: 'value', + serverEncryptionVersion: 1, + sensitive: false, + }, + }) + + expect(result.status).toEqual('created') + }) + + it('should create setting with a given uuid if it does not exist', async () => { + settingRepository.findOneByUuid = jest.fn().mockReturnValue(null) + + const result = await createService().createOrReplace({ + user, + props: { + uuid: '1-2-3', + name: 'name', + unencryptedValue: 'value', + serverEncryptionVersion: 1, + sensitive: false, + }, + }) + + expect(result.status).toEqual('created') + }) + + it('should replace setting if it does exist', async () => { + settingRepository.findLastByNameAndUserUuid = jest.fn().mockReturnValue(setting) + + const result = await createService().createOrReplace({ + user: user, + props: { + ...setting, + unencryptedValue: 'value', + serverEncryptionVersion: 1, + }, + }) + + expect(result.status).toEqual('replaced') + }) + + it('should replace setting with a given uuid if it does exist', async () => { + settingRepository.findOneByUuid = jest.fn().mockReturnValue(setting) + + const result = await createService().createOrReplace({ + user: user, + props: { + ...setting, + uuid: '1-2-3', + unencryptedValue: 'value', + serverEncryptionVersion: 1, + }, + }) + + expect(result.status).toEqual('replaced') + }) + + it('should find and decrypt the value of a setting for user', async () => { + setting = { + value: 'encrypted', + serverEncryptionVersion: EncryptionVersion.Default, + } as jest.Mocked + + settingRepository.findLastByNameAndUserUuid = jest.fn().mockReturnValue(setting) + + expect( + await createService().findSettingWithDecryptedValue({ userUuid: '1-2-3', settingName: 'test' as SettingName }), + ).toEqual({ + serverEncryptionVersion: 1, + value: 'decrypted', + }) + }) +}) diff --git a/packages/auth/src/Domain/Setting/SettingService.ts b/packages/auth/src/Domain/Setting/SettingService.ts new file mode 100644 index 000000000..09a1b76e4 --- /dev/null +++ b/packages/auth/src/Domain/Setting/SettingService.ts @@ -0,0 +1,105 @@ +import { SettingName } from '@standardnotes/settings' +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' +import TYPES from '../../Bootstrap/Types' +import { User } from '../User/User' +import { CreateOrReplaceSettingDto } from './CreateOrReplaceSettingDto' +import { CreateOrReplaceSettingResponse } from './CreateOrReplaceSettingResponse' +import { FindSettingDTO } from './FindSettingDTO' +import { Setting } from './Setting' +import { SettingRepositoryInterface } from './SettingRepositoryInterface' +import { SettingServiceInterface } from './SettingServiceInterface' +import { SettingsAssociationServiceInterface } from './SettingsAssociationServiceInterface' +import { SettingInterpreterInterface } from './SettingInterpreterInterface' +import { SettingDecrypterInterface } from './SettingDecrypterInterface' +import { SettingFactoryInterface } from './SettingFactoryInterface' + +@injectable() +export class SettingService implements SettingServiceInterface { + constructor( + @inject(TYPES.SettingFactory) private factory: SettingFactoryInterface, + @inject(TYPES.SettingRepository) private settingRepository: SettingRepositoryInterface, + @inject(TYPES.SettingsAssociationService) private settingsAssociationService: SettingsAssociationServiceInterface, + @inject(TYPES.SettingInterpreter) private settingInterpreter: SettingInterpreterInterface, + @inject(TYPES.SettingDecrypter) private settingDecrypter: SettingDecrypterInterface, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async applyDefaultSettingsUponRegistration(user: User): Promise { + let defaultSettingsWithValues = this.settingsAssociationService.getDefaultSettingsAndValuesForNewUser() + if (user.isPotentiallyAVaultAccount()) { + defaultSettingsWithValues = this.settingsAssociationService.getDefaultSettingsAndValuesForNewVaultAccount() + } + + for (const settingName of defaultSettingsWithValues.keys()) { + this.logger.debug(`Creating setting ${settingName} for user ${user.uuid}`) + + const setting = defaultSettingsWithValues.get(settingName) as { + value: string + sensitive: boolean + serverEncryptionVersion: number + } + + await this.createOrReplace({ + user, + props: { + name: settingName, + unencryptedValue: setting.value, + serverEncryptionVersion: setting.serverEncryptionVersion, + sensitive: setting.sensitive, + }, + }) + } + } + + async findSettingWithDecryptedValue(dto: FindSettingDTO): Promise { + let setting: Setting | null + if (dto.settingUuid !== undefined) { + setting = await this.settingRepository.findOneByUuid(dto.settingUuid) + } else { + setting = await this.settingRepository.findLastByNameAndUserUuid(dto.settingName, dto.userUuid) + } + + if (setting === null) { + return null + } + + setting.value = await this.settingDecrypter.decryptSettingValue(setting, dto.userUuid) + + return setting + } + + async createOrReplace(dto: CreateOrReplaceSettingDto): Promise { + const { user, props } = dto + + const existing = await this.findSettingWithDecryptedValue({ + userUuid: user.uuid, + settingName: props.name as SettingName, + settingUuid: props.uuid, + }) + + if (existing === null) { + const setting = await this.settingRepository.save(await this.factory.create(props, user)) + + this.logger.debug('[%s] Created setting %s: %O', user.uuid, props.name, setting) + + await this.settingInterpreter.interpretSettingUpdated(setting, user, props.unencryptedValue) + + return { + status: 'created', + setting, + } + } + + const setting = await this.settingRepository.save(await this.factory.createReplacement(existing, props)) + + this.logger.debug('[%s] Replaced existing setting %s with: %O', user.uuid, props.name, setting) + + await this.settingInterpreter.interpretSettingUpdated(setting, user, props.unencryptedValue) + + return { + status: 'replaced', + setting, + } + } +} diff --git a/packages/auth/src/Domain/Setting/SettingServiceInterface.ts b/packages/auth/src/Domain/Setting/SettingServiceInterface.ts new file mode 100644 index 000000000..a173ec1b2 --- /dev/null +++ b/packages/auth/src/Domain/Setting/SettingServiceInterface.ts @@ -0,0 +1,11 @@ +import { User } from '../User/User' +import { CreateOrReplaceSettingDto } from './CreateOrReplaceSettingDto' +import { CreateOrReplaceSettingResponse } from './CreateOrReplaceSettingResponse' +import { FindSettingDTO } from './FindSettingDTO' +import { Setting } from './Setting' + +export interface SettingServiceInterface { + applyDefaultSettingsUponRegistration(user: User): Promise + createOrReplace(dto: CreateOrReplaceSettingDto): Promise + findSettingWithDecryptedValue(dto: FindSettingDTO): Promise +} diff --git a/packages/auth/src/Domain/Setting/SettingsAssociationService.spec.ts b/packages/auth/src/Domain/Setting/SettingsAssociationService.spec.ts new file mode 100644 index 000000000..2a09f4425 --- /dev/null +++ b/packages/auth/src/Domain/Setting/SettingsAssociationService.spec.ts @@ -0,0 +1,62 @@ +import 'reflect-metadata' + +import { SettingName } from '@standardnotes/settings' +import { PermissionName } from '@standardnotes/features' + +import { SettingsAssociationService } from './SettingsAssociationService' +import { EncryptionVersion } from '../Encryption/EncryptionVersion' +import { SettingDescription } from './SettingDescription' + +describe('SettingsAssociationService', () => { + const createService = () => new SettingsAssociationService() + + it('should tell if a setting is mutable by the client', () => { + expect(createService().isSettingMutableByClient(SettingName.DropboxBackupFrequency)).toBeTruthy() + }) + + it('should tell if a setting is immutable by the client', () => { + expect(createService().isSettingMutableByClient(SettingName.ListedAuthorSecrets)).toBeFalsy() + }) + + it('should return default encryption version for a setting which enecryption version is not strictly defined', () => { + expect(createService().getEncryptionVersionForSetting(SettingName.MfaSecret)).toEqual(EncryptionVersion.Default) + }) + + it('should return a defined encryption version for a setting which enecryption version is strictly defined', () => { + expect(createService().getEncryptionVersionForSetting(SettingName.EmailBackupFrequency)).toEqual( + EncryptionVersion.Unencrypted, + ) + }) + + it('should return default sensitivity for a setting which sensitivity is not strictly defined', () => { + expect(createService().getSensitivityForSetting(SettingName.DropboxBackupToken)).toBeTruthy() + }) + + it('should return a defined sensitivity for a setting which sensitivity is strictly defined', () => { + expect(createService().getSensitivityForSetting(SettingName.DropboxBackupFrequency)).toBeFalsy() + }) + + it('should return the default set of settings for a newly registered user', () => { + const settings = createService().getDefaultSettingsAndValuesForNewUser() + const flatSettings = [...(settings as Map).keys()] + expect(flatSettings).toEqual(['MUTE_SIGN_IN_EMAILS', 'LOG_SESSION_USER_AGENT']) + }) + + it('should return the default set of settings for a newly registered vault account', () => { + const settings = createService().getDefaultSettingsAndValuesForNewVaultAccount() + const flatSettings = [...(settings as Map).keys()] + expect(flatSettings).toEqual(['MUTE_SIGN_IN_EMAILS', 'LOG_SESSION_USER_AGENT']) + + expect(settings.get(SettingName.LogSessionUserAgent)?.value).toEqual('disabled') + }) + + it('should return a permission name associated to a given setting', () => { + expect(createService().getPermissionAssociatedWithSetting(SettingName.EmailBackupFrequency)).toEqual( + PermissionName.DailyEmailBackup, + ) + }) + + it('should not return a permission name if not associated to a given setting', () => { + expect(createService().getPermissionAssociatedWithSetting(SettingName.ExtensionKey)).toBeUndefined() + }) +}) diff --git a/packages/auth/src/Domain/Setting/SettingsAssociationService.ts b/packages/auth/src/Domain/Setting/SettingsAssociationService.ts new file mode 100644 index 000000000..61c8dddc9 --- /dev/null +++ b/packages/auth/src/Domain/Setting/SettingsAssociationService.ts @@ -0,0 +1,119 @@ +import { PermissionName } from '@standardnotes/features' +import { LogSessionUserAgentOption, MuteSignInEmailsOption, SettingName } from '@standardnotes/settings' +import { injectable } from 'inversify' + +import { EncryptionVersion } from '../Encryption/EncryptionVersion' +import { SettingDescription } from './SettingDescription' + +import { SettingsAssociationServiceInterface } from './SettingsAssociationServiceInterface' + +@injectable() +export class SettingsAssociationService implements SettingsAssociationServiceInterface { + private readonly UNENCRYPTED_SETTINGS = [ + SettingName.EmailBackupFrequency, + SettingName.MuteFailedBackupsEmails, + SettingName.MuteFailedCloudBackupsEmails, + SettingName.MuteSignInEmails, + SettingName.DropboxBackupFrequency, + SettingName.GoogleDriveBackupFrequency, + SettingName.OneDriveBackupFrequency, + SettingName.LogSessionUserAgent, + ] + + private readonly UNSENSITIVE_SETTINGS = [ + SettingName.DropboxBackupFrequency, + SettingName.GoogleDriveBackupFrequency, + SettingName.OneDriveBackupFrequency, + SettingName.EmailBackupFrequency, + SettingName.MuteFailedBackupsEmails, + SettingName.MuteFailedCloudBackupsEmails, + SettingName.MuteSignInEmails, + SettingName.ListedAuthorSecrets, + SettingName.LogSessionUserAgent, + ] + + private readonly CLIENT_IMMUTABLE_SETTINGS = [SettingName.ListedAuthorSecrets] + + private readonly permissionsAssociatedWithSettings = new Map([ + [SettingName.EmailBackupFrequency, PermissionName.DailyEmailBackup], + ]) + + private readonly defaultSettings = new Map([ + [ + SettingName.MuteSignInEmails, + { + sensitive: false, + serverEncryptionVersion: EncryptionVersion.Unencrypted, + value: MuteSignInEmailsOption.NotMuted, + }, + ], + [ + SettingName.LogSessionUserAgent, + { + sensitive: false, + serverEncryptionVersion: EncryptionVersion.Unencrypted, + value: LogSessionUserAgentOption.Enabled, + }, + ], + ]) + + private readonly vaultAccountDefaultSettingsOverwrites = new Map([ + [ + SettingName.LogSessionUserAgent, + { + sensitive: false, + serverEncryptionVersion: EncryptionVersion.Unencrypted, + value: LogSessionUserAgentOption.Disabled, + }, + ], + ]) + + isSettingMutableByClient(settingName: SettingName): boolean { + if (this.CLIENT_IMMUTABLE_SETTINGS.includes(settingName)) { + return false + } + + return true + } + + getSensitivityForSetting(settingName: SettingName): boolean { + if (this.UNSENSITIVE_SETTINGS.includes(settingName)) { + return false + } + + return true + } + + getEncryptionVersionForSetting(settingName: SettingName): EncryptionVersion { + if (this.UNENCRYPTED_SETTINGS.includes(settingName)) { + return EncryptionVersion.Unencrypted + } + + return EncryptionVersion.Default + } + + getPermissionAssociatedWithSetting(settingName: SettingName): PermissionName | undefined { + if (!this.permissionsAssociatedWithSettings.has(settingName)) { + return undefined + } + + return this.permissionsAssociatedWithSettings.get(settingName) + } + + getDefaultSettingsAndValuesForNewUser(): Map { + return this.defaultSettings + } + + getDefaultSettingsAndValuesForNewVaultAccount(): Map { + const defaultVaultSettings = new Map(this.defaultSettings) + + for (const vaultAccountDefaultSettingOverwriteKey of this.vaultAccountDefaultSettingsOverwrites.keys()) { + defaultVaultSettings.set( + vaultAccountDefaultSettingOverwriteKey, + this.vaultAccountDefaultSettingsOverwrites.get(vaultAccountDefaultSettingOverwriteKey) as SettingDescription, + ) + } + + return defaultVaultSettings + } +} diff --git a/packages/auth/src/Domain/Setting/SettingsAssociationServiceInterface.ts b/packages/auth/src/Domain/Setting/SettingsAssociationServiceInterface.ts new file mode 100644 index 000000000..3e7cce194 --- /dev/null +++ b/packages/auth/src/Domain/Setting/SettingsAssociationServiceInterface.ts @@ -0,0 +1,13 @@ +import { PermissionName } from '@standardnotes/features' +import { SettingName, SubscriptionSettingName } from '@standardnotes/settings' +import { EncryptionVersion } from '../Encryption/EncryptionVersion' +import { SettingDescription } from './SettingDescription' + +export interface SettingsAssociationServiceInterface { + getDefaultSettingsAndValuesForNewUser(): Map + getDefaultSettingsAndValuesForNewVaultAccount(): Map + getPermissionAssociatedWithSetting(settingName: SettingName): PermissionName | undefined + getEncryptionVersionForSetting(settingName: SettingName): EncryptionVersion + getSensitivityForSetting(settingName: SettingName): boolean + isSettingMutableByClient(settingName: SettingName | SubscriptionSettingName): boolean +} diff --git a/packages/auth/src/Domain/Setting/SimpleSetting.ts b/packages/auth/src/Domain/Setting/SimpleSetting.ts new file mode 100644 index 000000000..064a0c527 --- /dev/null +++ b/packages/auth/src/Domain/Setting/SimpleSetting.ts @@ -0,0 +1,3 @@ +import { Setting } from './Setting' + +export type SimpleSetting = Omit diff --git a/packages/auth/src/Domain/Setting/SimpleSubscriptionSetting.ts b/packages/auth/src/Domain/Setting/SimpleSubscriptionSetting.ts new file mode 100644 index 000000000..7d54828f5 --- /dev/null +++ b/packages/auth/src/Domain/Setting/SimpleSubscriptionSetting.ts @@ -0,0 +1,3 @@ +import { SubscriptionSetting } from './SubscriptionSetting' + +export type SimpleSubscriptionSetting = Omit diff --git a/packages/auth/src/Domain/Setting/SubscriptionSetting.ts b/packages/auth/src/Domain/Setting/SubscriptionSetting.ts new file mode 100644 index 000000000..297b8812a --- /dev/null +++ b/packages/auth/src/Domain/Setting/SubscriptionSetting.ts @@ -0,0 +1,60 @@ +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm' +import { EncryptionVersion } from '../Encryption/EncryptionVersion' +import { UserSubscription } from '../Subscription/UserSubscription' + +@Entity({ name: 'subscription_settings' }) +@Index('index_settings_on_name_and_user_subscription_uuid', ['name', 'userSubscription']) +export class SubscriptionSetting { + @PrimaryGeneratedColumn('uuid') + declare uuid: string + + @Column({ + length: 255, + }) + declare name: string + + @Column({ + type: 'text', + nullable: true, + }) + declare value: string | null + + @Column({ + name: 'server_encryption_version', + type: 'tinyint', + default: EncryptionVersion.Unencrypted, + }) + declare serverEncryptionVersion: number + + @Column({ + name: 'created_at', + type: 'bigint', + }) + declare createdAt: number + + @Column({ + name: 'updated_at', + type: 'bigint', + }) + @Index('index_subcsription_settings_on_updated_at') + declare updatedAt: number + + @ManyToOne( + /* istanbul ignore next */ + () => UserSubscription, + /* istanbul ignore next */ + (userSubscription) => userSubscription.subscriptionSettings, + /* istanbul ignore next */ + { onDelete: 'CASCADE', nullable: false, lazy: true, eager: false }, + ) + @JoinColumn({ name: 'user_subscription_uuid', referencedColumnName: 'uuid' }) + declare userSubscription: Promise + + @Column({ + type: 'tinyint', + width: 1, + nullable: false, + default: 0, + }) + declare sensitive: boolean +} diff --git a/packages/auth/src/Domain/Setting/SubscriptionSettingProps.ts b/packages/auth/src/Domain/Setting/SubscriptionSettingProps.ts new file mode 100644 index 000000000..58e7d1f53 --- /dev/null +++ b/packages/auth/src/Domain/Setting/SubscriptionSettingProps.ts @@ -0,0 +1,12 @@ +import { SubscriptionSetting } from './SubscriptionSetting' + +export type SubscriptionSettingProps = Omit< + SubscriptionSetting, + 'uuid' | 'userSubscription' | 'createdAt' | 'updatedAt' | 'serverEncryptionVersion' | 'value' +> & { + uuid?: string + createdAt?: number + updatedAt?: number + unencryptedValue: string | null + serverEncryptionVersion?: number +} diff --git a/packages/auth/src/Domain/Setting/SubscriptionSettingRepositoryInterface.ts b/packages/auth/src/Domain/Setting/SubscriptionSettingRepositoryInterface.ts new file mode 100644 index 000000000..374f15833 --- /dev/null +++ b/packages/auth/src/Domain/Setting/SubscriptionSettingRepositoryInterface.ts @@ -0,0 +1,7 @@ +import { SubscriptionSetting } from './SubscriptionSetting' + +export interface SubscriptionSettingRepositoryInterface { + findOneByUuid(uuid: string): Promise + findLastByNameAndUserSubscriptionUuid(name: string, userSubscriptionUuid: string): Promise + save(subscriptionSetting: SubscriptionSetting): Promise +} diff --git a/packages/auth/src/Domain/Setting/SubscriptionSettingService.spec.ts b/packages/auth/src/Domain/Setting/SubscriptionSettingService.spec.ts new file mode 100644 index 000000000..9ebdfb62e --- /dev/null +++ b/packages/auth/src/Domain/Setting/SubscriptionSettingService.spec.ts @@ -0,0 +1,174 @@ +import 'reflect-metadata' + +import { SubscriptionSettingName } from '@standardnotes/settings' +import { Logger } from 'winston' +import { EncryptionVersion } from '../Encryption/EncryptionVersion' + +import { SubscriptionSettingService } from './SubscriptionSettingService' +import { SettingDecrypterInterface } from './SettingDecrypterInterface' +import { SubscriptionSettingRepositoryInterface } from './SubscriptionSettingRepositoryInterface' +import { SubscriptionSetting } from './SubscriptionSetting' +import { UserSubscription } from '../Subscription/UserSubscription' +import { SubscriptionName } from '@standardnotes/common' +import { User } from '../User/User' +import { SettingFactoryInterface } from './SettingFactoryInterface' +import { SubscriptionSettingsAssociationServiceInterface } from './SubscriptionSettingsAssociationServiceInterface' + +describe('SubscriptionSettingService', () => { + let setting: SubscriptionSetting + let user: User + let userSubscription: UserSubscription + let factory: SettingFactoryInterface + let subscriptionSettingRepository: SubscriptionSettingRepositoryInterface + let subscriptionSettingsAssociationService: SubscriptionSettingsAssociationServiceInterface + let settingDecrypter: SettingDecrypterInterface + let logger: Logger + + const createService = () => + new SubscriptionSettingService( + factory, + subscriptionSettingRepository, + subscriptionSettingsAssociationService, + settingDecrypter, + logger, + ) + + beforeEach(() => { + user = {} as jest.Mocked + + userSubscription = { + uuid: '1-2-3', + user: Promise.resolve(user), + } as jest.Mocked + + setting = {} as jest.Mocked + + factory = {} as jest.Mocked + factory.createSubscriptionSetting = jest.fn().mockReturnValue(setting) + factory.createSubscriptionSettingReplacement = jest.fn().mockReturnValue(setting) + + subscriptionSettingRepository = {} as jest.Mocked + subscriptionSettingRepository.findLastByNameAndUserSubscriptionUuid = jest.fn().mockReturnValue(null) + subscriptionSettingRepository.save = jest.fn().mockImplementation((setting) => setting) + + subscriptionSettingsAssociationService = {} as jest.Mocked + subscriptionSettingsAssociationService.getDefaultSettingsAndValuesForSubscriptionName = jest.fn().mockReturnValue( + new Map([ + [ + SubscriptionSettingName.FileUploadBytesUsed, + { + value: '0', + sensitive: 0, + serverEncryptionVersion: EncryptionVersion.Unencrypted, + }, + ], + ]), + ) + + settingDecrypter = {} as jest.Mocked + settingDecrypter.decryptSettingValue = jest.fn().mockReturnValue('decrypted') + + logger = {} as jest.Mocked + logger.debug = jest.fn() + logger.warn = jest.fn() + logger.error = jest.fn() + }) + + it('should create default settings for a subscription', async () => { + await createService().applyDefaultSubscriptionSettingsForSubscription(userSubscription, SubscriptionName.PlusPlan) + + expect(subscriptionSettingRepository.save).toHaveBeenCalledWith(setting) + }) + + it('should not create default settings for a subscription if subscription has no defaults', async () => { + subscriptionSettingsAssociationService.getDefaultSettingsAndValuesForSubscriptionName = jest + .fn() + .mockReturnValue(undefined) + + await createService().applyDefaultSubscriptionSettingsForSubscription(userSubscription, SubscriptionName.PlusPlan) + + expect(subscriptionSettingRepository.save).not.toHaveBeenCalled() + }) + + it("should create setting if it doesn't exist", async () => { + const result = await createService().createOrReplace({ + userSubscription, + props: { + name: 'name', + unencryptedValue: 'value', + serverEncryptionVersion: 1, + sensitive: false, + }, + }) + + expect(result.status).toEqual('created') + }) + + it('should create setting with a given uuid if it does not exist', async () => { + subscriptionSettingRepository.findOneByUuid = jest.fn().mockReturnValue(null) + + const result = await createService().createOrReplace({ + userSubscription, + props: { + uuid: '1-2-3', + name: 'name', + unencryptedValue: 'value', + serverEncryptionVersion: 1, + sensitive: false, + }, + }) + + expect(result.status).toEqual('created') + }) + + it('should replace setting if it does exist', async () => { + subscriptionSettingRepository.findLastByNameAndUserSubscriptionUuid = jest.fn().mockReturnValue(setting) + + const result = await createService().createOrReplace({ + userSubscription, + props: { + ...setting, + unencryptedValue: 'value', + serverEncryptionVersion: 1, + }, + }) + + expect(result.status).toEqual('replaced') + }) + + it('should replace setting with a given uuid if it does exist', async () => { + subscriptionSettingRepository.findOneByUuid = jest.fn().mockReturnValue(setting) + + const result = await createService().createOrReplace({ + userSubscription, + props: { + ...setting, + uuid: '1-2-3', + unencryptedValue: 'value', + serverEncryptionVersion: 1, + }, + }) + + expect(result.status).toEqual('replaced') + }) + + it('should find and decrypt the value of a setting for user', async () => { + setting = { + value: 'encrypted', + serverEncryptionVersion: EncryptionVersion.Default, + } as jest.Mocked + + subscriptionSettingRepository.findLastByNameAndUserSubscriptionUuid = jest.fn().mockReturnValue(setting) + + expect( + await createService().findSubscriptionSettingWithDecryptedValue({ + userSubscriptionUuid: '2-3-4', + userUuid: '1-2-3', + subscriptionSettingName: 'test' as SubscriptionSettingName, + }), + ).toEqual({ + serverEncryptionVersion: 1, + value: 'decrypted', + }) + }) +}) diff --git a/packages/auth/src/Domain/Setting/SubscriptionSettingService.ts b/packages/auth/src/Domain/Setting/SubscriptionSettingService.ts new file mode 100644 index 000000000..bbfdcfc96 --- /dev/null +++ b/packages/auth/src/Domain/Setting/SubscriptionSettingService.ts @@ -0,0 +1,117 @@ +import { SubscriptionName } from '@standardnotes/common' +import { SubscriptionSettingName } from '@standardnotes/settings' +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' + +import TYPES from '../../Bootstrap/Types' +import { UserSubscription } from '../Subscription/UserSubscription' + +import { SettingDecrypterInterface } from './SettingDecrypterInterface' +import { SettingDescription } from './SettingDescription' +import { SubscriptionSettingServiceInterface } from './SubscriptionSettingServiceInterface' +import { CreateOrReplaceSubscriptionSettingDTO } from './CreateOrReplaceSubscriptionSettingDTO' +import { CreateOrReplaceSubscriptionSettingResponse } from './CreateOrReplaceSubscriptionSettingResponse' +import { SubscriptionSetting } from './SubscriptionSetting' +import { FindSubscriptionSettingDTO } from './FindSubscriptionSettingDTO' +import { SubscriptionSettingRepositoryInterface } from './SubscriptionSettingRepositoryInterface' +import { SettingFactoryInterface } from './SettingFactoryInterface' +import { SubscriptionSettingsAssociationServiceInterface } from './SubscriptionSettingsAssociationServiceInterface' + +@injectable() +export class SubscriptionSettingService implements SubscriptionSettingServiceInterface { + constructor( + @inject(TYPES.SettingFactory) private factory: SettingFactoryInterface, + @inject(TYPES.SubscriptionSettingRepository) + private subscriptionSettingRepository: SubscriptionSettingRepositoryInterface, + @inject(TYPES.SubscriptionSettingsAssociationService) + private subscriptionSettingAssociationService: SubscriptionSettingsAssociationServiceInterface, + @inject(TYPES.SettingDecrypter) private settingDecrypter: SettingDecrypterInterface, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async applyDefaultSubscriptionSettingsForSubscription( + userSubscription: UserSubscription, + subscriptionName: SubscriptionName, + ): Promise { + const defaultSettingsWithValues = + await this.subscriptionSettingAssociationService.getDefaultSettingsAndValuesForSubscriptionName(subscriptionName) + if (defaultSettingsWithValues === undefined) { + this.logger.warn(`Could not find settings for subscription: ${subscriptionName}`) + + return + } + + for (const settingName of defaultSettingsWithValues.keys()) { + const setting = defaultSettingsWithValues.get(settingName) as SettingDescription + + await this.createOrReplace({ + userSubscription, + props: { + name: settingName, + unencryptedValue: setting.value, + serverEncryptionVersion: setting.serverEncryptionVersion, + sensitive: setting.sensitive, + }, + }) + } + } + + async findSubscriptionSettingWithDecryptedValue( + dto: FindSubscriptionSettingDTO, + ): Promise { + let setting: SubscriptionSetting | null + if (dto.settingUuid !== undefined) { + setting = await this.subscriptionSettingRepository.findOneByUuid(dto.settingUuid) + } else { + setting = await this.subscriptionSettingRepository.findLastByNameAndUserSubscriptionUuid( + dto.subscriptionSettingName, + dto.userSubscriptionUuid, + ) + } + + if (setting === null) { + return null + } + + setting.value = await this.settingDecrypter.decryptSettingValue(setting, dto.userUuid) + + return setting + } + + async createOrReplace( + dto: CreateOrReplaceSubscriptionSettingDTO, + ): Promise { + const { userSubscription, props } = dto + + const existing = await this.findSubscriptionSettingWithDecryptedValue({ + userUuid: (await userSubscription.user).uuid, + userSubscriptionUuid: userSubscription.uuid, + subscriptionSettingName: props.name as SubscriptionSettingName, + settingUuid: props.uuid, + }) + + if (existing === null) { + const subscriptionSetting = await this.subscriptionSettingRepository.save( + await this.factory.createSubscriptionSetting(props, userSubscription), + ) + + this.logger.debug('Created subscription setting %s: %O', props.name, subscriptionSetting) + + return { + status: 'created', + subscriptionSetting, + } + } + + const subscriptionSetting = await this.subscriptionSettingRepository.save( + await this.factory.createSubscriptionSettingReplacement(existing, props), + ) + + this.logger.debug('Replaced existing subscription setting %s with: %O', props.name, subscriptionSetting) + + return { + status: 'replaced', + subscriptionSetting, + } + } +} diff --git a/packages/auth/src/Domain/Setting/SubscriptionSettingServiceInterface.ts b/packages/auth/src/Domain/Setting/SubscriptionSettingServiceInterface.ts new file mode 100644 index 000000000..664c82a4c --- /dev/null +++ b/packages/auth/src/Domain/Setting/SubscriptionSettingServiceInterface.ts @@ -0,0 +1,16 @@ +import { SubscriptionName } from '@standardnotes/common' +import { UserSubscription } from '../Subscription/UserSubscription' + +import { CreateOrReplaceSubscriptionSettingDTO } from './CreateOrReplaceSubscriptionSettingDTO' +import { CreateOrReplaceSubscriptionSettingResponse } from './CreateOrReplaceSubscriptionSettingResponse' +import { FindSubscriptionSettingDTO } from './FindSubscriptionSettingDTO' +import { SubscriptionSetting } from './SubscriptionSetting' + +export interface SubscriptionSettingServiceInterface { + applyDefaultSubscriptionSettingsForSubscription( + userSubscription: UserSubscription, + subscriptionName: SubscriptionName, + ): Promise + createOrReplace(dto: CreateOrReplaceSubscriptionSettingDTO): Promise + findSubscriptionSettingWithDecryptedValue(dto: FindSubscriptionSettingDTO): Promise +} diff --git a/packages/auth/src/Domain/Setting/SubscriptionSettingsAssociationService.spec.ts b/packages/auth/src/Domain/Setting/SubscriptionSettingsAssociationService.spec.ts new file mode 100644 index 000000000..3bfdf754d --- /dev/null +++ b/packages/auth/src/Domain/Setting/SubscriptionSettingsAssociationService.spec.ts @@ -0,0 +1,116 @@ +import 'reflect-metadata' + +import { RoleName, SubscriptionName } from '@standardnotes/common' +import { SubscriptionSettingName } from '@standardnotes/settings' + +import { PermissionName } from '@standardnotes/features' +import { EncryptionVersion } from '../Encryption/EncryptionVersion' +import { RoleRepositoryInterface } from '../Role/RoleRepositoryInterface' +import { RoleToSubscriptionMapInterface } from '../Role/RoleToSubscriptionMapInterface' +import { Role } from '../Role/Role' +import { Permission } from '../Permission/Permission' +import { SubscriptionSettingsAssociationService } from './SubscriptionSettingsAssociationService' + +describe('SubscriptionSettingsAssociationService', () => { + let roleToSubscriptionMap: RoleToSubscriptionMapInterface + let roleRepository: RoleRepositoryInterface + let role: Role + + const createService = () => new SubscriptionSettingsAssociationService(roleToSubscriptionMap, roleRepository) + + beforeEach(() => { + roleToSubscriptionMap = {} as jest.Mocked + roleToSubscriptionMap.getRoleNameForSubscriptionName = jest.fn().mockReturnValue(RoleName.PlusUser) + + role = {} as jest.Mocked + + roleRepository = {} as jest.Mocked + roleRepository.findOneByName = jest.fn().mockReturnValue(role) + }) + + it('should return default to 0 on file upload limit if user subscription permissions could not be found', async () => { + role.permissions = Promise.resolve([]) + roleRepository.findOneByName = jest.fn().mockReturnValue(role) + + const limit = await createService().getFileUploadLimit(SubscriptionName.PlusPlan) + + expect(limit).toEqual(0) + }) + + it('should return the default set of setting values for a pro subscription', async () => { + const permission = { + name: PermissionName.FilesMaximumStorageTier, + } as jest.Mocked + role.permissions = Promise.resolve([permission]) + roleRepository.findOneByName = jest.fn().mockReturnValue(role) + + const settings = await createService().getDefaultSettingsAndValuesForSubscriptionName(SubscriptionName.ProPlan) + + expect(settings).not.toBeUndefined() + + const flatSettings = [ + ...( + settings as Map< + SubscriptionSettingName, + { value: string; sensitive: boolean; serverEncryptionVersion: EncryptionVersion } + > + ).keys(), + ] + expect(flatSettings).toEqual(['FILE_UPLOAD_BYTES_USED', 'FILE_UPLOAD_BYTES_LIMIT']) + expect(settings?.get(SubscriptionSettingName.FileUploadBytesLimit)).toEqual({ + sensitive: false, + serverEncryptionVersion: 0, + value: '107374182400', + }) + }) + + it('should return the default set of setting values for a plus subscription', async () => { + const permission = { + name: PermissionName.FilesLowStorageTier, + } as jest.Mocked + role.permissions = Promise.resolve([permission]) + roleRepository.findOneByName = jest.fn().mockReturnValue(role) + + const settings = await createService().getDefaultSettingsAndValuesForSubscriptionName(SubscriptionName.PlusPlan) + + expect(settings).not.toBeUndefined() + + const flatSettings = [ + ...( + settings as Map< + SubscriptionSettingName, + { value: string; sensitive: boolean; serverEncryptionVersion: EncryptionVersion } + > + ).keys(), + ] + expect(flatSettings).toEqual(['FILE_UPLOAD_BYTES_USED', 'FILE_UPLOAD_BYTES_LIMIT']) + expect(settings?.get(SubscriptionSettingName.FileUploadBytesLimit)).toEqual({ + sensitive: false, + serverEncryptionVersion: 0, + value: '104857600', + }) + }) + + it('should throw error if a role is not found when getting default setting values for a subscription', async () => { + const permission = { + name: PermissionName.Files, + } as jest.Mocked + role.permissions = Promise.resolve([permission]) + roleRepository.findOneByName = jest.fn().mockReturnValue(null) + + let caughtError = null + try { + await createService().getDefaultSettingsAndValuesForSubscriptionName(SubscriptionName.ProPlan) + } catch (error) { + caughtError = error + } + + expect(caughtError).not.toBeNull() + }) + + it('should return undefined set of setting values for an undefined subscription', async () => { + const settings = await createService().getDefaultSettingsAndValuesForSubscriptionName('foobar' as SubscriptionName) + + expect(settings).toBeUndefined() + }) +}) diff --git a/packages/auth/src/Domain/Setting/SubscriptionSettingsAssociationService.ts b/packages/auth/src/Domain/Setting/SubscriptionSettingsAssociationService.ts new file mode 100644 index 000000000..bc481a1b3 --- /dev/null +++ b/packages/auth/src/Domain/Setting/SubscriptionSettingsAssociationService.ts @@ -0,0 +1,90 @@ +import { RoleName, SubscriptionName } from '@standardnotes/common' +import { PermissionName } from '@standardnotes/features' +import { SubscriptionSettingName } from '@standardnotes/settings' +import { inject, injectable } from 'inversify' + +import TYPES from '../../Bootstrap/Types' +import { EncryptionVersion } from '../Encryption/EncryptionVersion' +import { Permission } from '../Permission/Permission' +import { RoleRepositoryInterface } from '../Role/RoleRepositoryInterface' +import { RoleToSubscriptionMapInterface } from '../Role/RoleToSubscriptionMapInterface' + +import { SettingDescription } from './SettingDescription' +import { SubscriptionSettingsAssociationServiceInterface } from './SubscriptionSettingsAssociationServiceInterface' + +@injectable() +export class SubscriptionSettingsAssociationService implements SubscriptionSettingsAssociationServiceInterface { + constructor( + @inject(TYPES.RoleToSubscriptionMap) private roleToSubscriptionMap: RoleToSubscriptionMapInterface, + @inject(TYPES.RoleRepository) private roleRepository: RoleRepositoryInterface, + ) {} + + private readonly settingsToSubscriptionNameMap = new Map< + SubscriptionName, + Map + >([ + [ + SubscriptionName.PlusPlan, + new Map([ + [ + SubscriptionSettingName.FileUploadBytesUsed, + { sensitive: false, serverEncryptionVersion: EncryptionVersion.Unencrypted, value: '0' }, + ], + ]), + ], + [ + SubscriptionName.ProPlan, + new Map([ + [ + SubscriptionSettingName.FileUploadBytesUsed, + { sensitive: false, serverEncryptionVersion: EncryptionVersion.Unencrypted, value: '0' }, + ], + ]), + ], + ]) + + async getDefaultSettingsAndValuesForSubscriptionName( + subscriptionName: SubscriptionName, + ): Promise | undefined> { + const defaultSettings = this.settingsToSubscriptionNameMap.get(subscriptionName) + + if (defaultSettings === undefined) { + return undefined + } + + defaultSettings.set(SubscriptionSettingName.FileUploadBytesLimit, { + sensitive: false, + serverEncryptionVersion: EncryptionVersion.Unencrypted, + value: (await this.getFileUploadLimit(subscriptionName)).toString(), + }) + + return defaultSettings + } + + async getFileUploadLimit(subscriptionName: SubscriptionName): Promise { + const roleName = this.roleToSubscriptionMap.getRoleNameForSubscriptionName(subscriptionName) + + const role = await this.roleRepository.findOneByName(roleName as RoleName) + if (role === null) { + throw new Error(`Could not find role with name: ${roleName}`) + } + + const permissions = await role.permissions + + const uploadLimit100GB = permissions.find( + (permission: Permission) => permission.name === PermissionName.FilesMaximumStorageTier, + ) + if (uploadLimit100GB !== undefined) { + return 107_374_182_400 + } + + const uploadLimit100MB = permissions.find( + (permission: Permission) => permission.name === PermissionName.FilesLowStorageTier, + ) + if (uploadLimit100MB !== undefined) { + return 104_857_600 + } + + return 0 + } +} diff --git a/packages/auth/src/Domain/Setting/SubscriptionSettingsAssociationServiceInterface.ts b/packages/auth/src/Domain/Setting/SubscriptionSettingsAssociationServiceInterface.ts new file mode 100644 index 000000000..aa8250d8b --- /dev/null +++ b/packages/auth/src/Domain/Setting/SubscriptionSettingsAssociationServiceInterface.ts @@ -0,0 +1,11 @@ +import { SubscriptionName } from '@standardnotes/common' +import { SubscriptionSettingName } from '@standardnotes/settings' + +import { SettingDescription } from './SettingDescription' + +export interface SubscriptionSettingsAssociationServiceInterface { + getDefaultSettingsAndValuesForSubscriptionName( + subscriptionName: SubscriptionName, + ): Promise | undefined> + getFileUploadLimit(subscriptionName: SubscriptionName): Promise +} diff --git a/packages/auth/src/Domain/SharedSubscription/InvitationStatus.ts b/packages/auth/src/Domain/SharedSubscription/InvitationStatus.ts new file mode 100644 index 000000000..8337bb761 --- /dev/null +++ b/packages/auth/src/Domain/SharedSubscription/InvitationStatus.ts @@ -0,0 +1,6 @@ +export enum InvitationStatus { + Sent = 'sent', + Canceled = 'canceled', + Accepted = 'accepted', + Declined = 'declined', +} diff --git a/packages/auth/src/Domain/SharedSubscription/InviteeIdentifierType.ts b/packages/auth/src/Domain/SharedSubscription/InviteeIdentifierType.ts new file mode 100644 index 000000000..65c35816e --- /dev/null +++ b/packages/auth/src/Domain/SharedSubscription/InviteeIdentifierType.ts @@ -0,0 +1,5 @@ +export enum InviteeIdentifierType { + Email = 'email', + Hash = 'hash', + Uuid = 'uuid', +} diff --git a/packages/auth/src/Domain/SharedSubscription/InviterIdentifierType.ts b/packages/auth/src/Domain/SharedSubscription/InviterIdentifierType.ts new file mode 100644 index 000000000..147c1c3d4 --- /dev/null +++ b/packages/auth/src/Domain/SharedSubscription/InviterIdentifierType.ts @@ -0,0 +1,4 @@ +export enum InviterIdentifierType { + Email = 'email', + Uuid = 'uuid', +} diff --git a/packages/auth/src/Domain/SharedSubscription/SharedSubscriptionInvitation.ts b/packages/auth/src/Domain/SharedSubscription/SharedSubscriptionInvitation.ts new file mode 100644 index 000000000..650d20743 --- /dev/null +++ b/packages/auth/src/Domain/SharedSubscription/SharedSubscriptionInvitation.ts @@ -0,0 +1,66 @@ +import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm' +import { InviteeIdentifierType } from './InviteeIdentifierType' +import { InviterIdentifierType } from './InviterIdentifierType' +import { InvitationStatus } from './InvitationStatus' + +@Entity({ name: 'shared_subscription_invitations' }) +@Index('invitee_and_status', ['inviteeIdentifier', 'status']) +export class SharedSubscriptionInvitation { + @PrimaryGeneratedColumn('uuid') + declare uuid: string + + @Column({ + length: 255, + type: 'varchar', + name: 'inviter_identifier', + }) + @Index('inviter_identifier') + declare inviterIdentifier: string + + @Column({ + length: 24, + type: 'varchar', + name: 'inviter_identifier_type', + }) + declare inviterIdentifierType: InviterIdentifierType + + @Column({ + length: 255, + type: 'varchar', + name: 'invitee_identifier', + }) + @Index('invitee_identifier') + declare inviteeIdentifier: string + + @Column({ + length: 24, + type: 'varchar', + name: 'invitee_identifier_type', + }) + declare inviteeIdentifierType: InviteeIdentifierType + + @Column({ + length: 255, + type: 'varchar', + }) + declare status: InvitationStatus + + @Column({ + name: 'subscription_id', + type: 'int', + width: 11, + }) + declare subscriptionId: number + + @Column({ + name: 'created_at', + type: 'bigint', + }) + declare createdAt: number + + @Column({ + name: 'updated_at', + type: 'bigint', + }) + declare updatedAt: number +} diff --git a/packages/auth/src/Domain/SharedSubscription/SharedSubscriptionInvitationRepositoryInterface.ts b/packages/auth/src/Domain/SharedSubscription/SharedSubscriptionInvitationRepositoryInterface.ts new file mode 100644 index 000000000..fd02fa644 --- /dev/null +++ b/packages/auth/src/Domain/SharedSubscription/SharedSubscriptionInvitationRepositoryInterface.ts @@ -0,0 +1,11 @@ +import { Uuid } from '@standardnotes/common' +import { InvitationStatus } from './InvitationStatus' +import { SharedSubscriptionInvitation } from './SharedSubscriptionInvitation' + +export interface SharedSubscriptionInvitationRepositoryInterface { + save(sharedSubscriptionInvitation: SharedSubscriptionInvitation): Promise + findOneByUuidAndStatus(uuid: Uuid, status: InvitationStatus): Promise + findOneByUuid(uuid: Uuid): Promise + findByInviterEmail(inviterEmail: string): Promise + countByInviterEmailAndStatus(inviterEmail: Uuid, statuses: InvitationStatus[]): Promise +} diff --git a/packages/auth/src/Domain/Subscription/FindRegularSubscriptionResponse.ts b/packages/auth/src/Domain/Subscription/FindRegularSubscriptionResponse.ts new file mode 100644 index 000000000..bdd7d2896 --- /dev/null +++ b/packages/auth/src/Domain/Subscription/FindRegularSubscriptionResponse.ts @@ -0,0 +1,6 @@ +import { UserSubscription } from './UserSubscription' + +export type FindRegularSubscriptionResponse = { + regularSubscription: UserSubscription | null + sharedSubscription: UserSubscription | null +} diff --git a/packages/auth/src/Domain/Subscription/OfflineUserSubscription.ts b/packages/auth/src/Domain/Subscription/OfflineUserSubscription.ts new file mode 100644 index 000000000..3e416ca60 --- /dev/null +++ b/packages/auth/src/Domain/Subscription/OfflineUserSubscription.ts @@ -0,0 +1,74 @@ +import { Column, Entity, Index, JoinTable, ManyToMany, PrimaryGeneratedColumn } from 'typeorm' +import { Role } from '../Role/Role' + +@Entity({ name: 'offline_user_subscriptions' }) +export class OfflineUserSubscription { + @PrimaryGeneratedColumn('uuid') + declare uuid: string + + @Column({ + length: 255, + }) + @Index('email') + declare email: string + + @Column({ + name: 'plan_name', + length: 255, + nullable: false, + }) + declare planName: string + + @Column({ + name: 'ends_at', + type: 'bigint', + }) + declare endsAt: number + + @Column({ + name: 'created_at', + type: 'bigint', + }) + declare createdAt: number + + @Column({ + name: 'updated_at', + type: 'bigint', + }) + declare updatedAt: number + + @Column({ + type: 'tinyint', + width: 1, + nullable: false, + default: 0, + }) + declare cancelled: boolean + + @Column({ + name: 'subscription_id', + type: 'int', + width: 11, + nullable: true, + }) + declare subscriptionId: number | null + + @ManyToMany( + /* istanbul ignore next */ + () => Role, + /* istanbul ignore next */ + { lazy: true, eager: false }, + ) + @JoinTable({ + name: 'offline_user_roles', + joinColumn: { + name: 'offline_user_subscription_uuid', + referencedColumnName: 'uuid', + }, + inverseJoinColumn: { + name: 'role_uuid', + referencedColumnName: 'uuid', + }, + }) + declare roles: Promise> +} diff --git a/packages/auth/src/Domain/Subscription/OfflineUserSubscriptionRepositoryInterface.ts b/packages/auth/src/Domain/Subscription/OfflineUserSubscriptionRepositoryInterface.ts new file mode 100644 index 000000000..f047603d2 --- /dev/null +++ b/packages/auth/src/Domain/Subscription/OfflineUserSubscriptionRepositoryInterface.ts @@ -0,0 +1,10 @@ +import { OfflineUserSubscription } from './OfflineUserSubscription' + +export interface OfflineUserSubscriptionRepositoryInterface { + findOneByEmail(email: string): Promise + findOneBySubscriptionId(subscriptionId: number): Promise + findByEmail(email: string, activeAfter: number): Promise + updateEndsAt(subscriptionId: number, endsAt: number, updatedAt: number): Promise + updateCancelled(subscriptionId: number, cancelled: boolean, updatedAt: number): Promise + save(offlineUserSubscription: OfflineUserSubscription): Promise +} diff --git a/packages/auth/src/Domain/Subscription/SubscriptionToken.ts b/packages/auth/src/Domain/Subscription/SubscriptionToken.ts new file mode 100644 index 000000000..f069483ee --- /dev/null +++ b/packages/auth/src/Domain/Subscription/SubscriptionToken.ts @@ -0,0 +1,5 @@ +export type SubscriptionToken = { + userUuid: string + token: string + expiresAt: number +} diff --git a/packages/auth/src/Domain/Subscription/SubscriptionTokenRepositoryInterface.ts b/packages/auth/src/Domain/Subscription/SubscriptionTokenRepositoryInterface.ts new file mode 100644 index 000000000..b264fde0e --- /dev/null +++ b/packages/auth/src/Domain/Subscription/SubscriptionTokenRepositoryInterface.ts @@ -0,0 +1,8 @@ +import { Uuid } from '@standardnotes/common' + +import { SubscriptionToken } from './SubscriptionToken' + +export interface SubscriptionTokenRepositoryInterface { + save(subscriptionToken: SubscriptionToken): Promise + getUserUuidByToken(token: string): Promise +} diff --git a/packages/auth/src/Domain/Subscription/UserSubscription.ts b/packages/auth/src/Domain/Subscription/UserSubscription.ts new file mode 100644 index 000000000..6c53c6706 --- /dev/null +++ b/packages/auth/src/Domain/Subscription/UserSubscription.ts @@ -0,0 +1,80 @@ +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, PrimaryGeneratedColumn } from 'typeorm' +import { SubscriptionSetting } from '../Setting/SubscriptionSetting' +import { User } from '../User/User' +import { UserSubscriptionType } from './UserSubscriptionType' + +@Entity({ name: 'user_subscriptions' }) +export class UserSubscription { + @PrimaryGeneratedColumn('uuid') + declare uuid: string + + @Column({ + name: 'plan_name', + length: 255, + nullable: false, + }) + declare planName: string + + @Column({ + name: 'ends_at', + type: 'bigint', + }) + declare endsAt: number + + @Column({ + name: 'created_at', + type: 'bigint', + }) + declare createdAt: number + + @Column({ + name: 'updated_at', + type: 'bigint', + }) + @Index('updated_at') + declare updatedAt: number + + @Column({ + type: 'tinyint', + width: 1, + nullable: false, + default: 0, + }) + declare cancelled: boolean + + @Column({ + name: 'subscription_id', + type: 'int', + width: 11, + nullable: true, + }) + declare subscriptionId: number | null + + @Column({ + name: 'subscription_type', + length: 24, + type: 'varchar', + }) + declare subscriptionType: UserSubscriptionType + + @ManyToOne( + /* istanbul ignore next */ + () => User, + /* istanbul ignore next */ + (user) => user.subscriptions, + /* istanbul ignore next */ + { onDelete: 'CASCADE', nullable: false, lazy: true, eager: false }, + ) + @JoinColumn({ name: 'user_uuid', referencedColumnName: 'uuid' }) + declare user: Promise + + @OneToMany( + /* istanbul ignore next */ + () => SubscriptionSetting, + /* istanbul ignore next */ + (subscriptionSetting) => subscriptionSetting.userSubscription, + /* istanbul ignore next */ + { lazy: true, eager: false }, + ) + declare subscriptionSettings: Promise +} diff --git a/packages/auth/src/Domain/Subscription/UserSubscriptionRepositoryInterface.ts b/packages/auth/src/Domain/Subscription/UserSubscriptionRepositoryInterface.ts new file mode 100644 index 000000000..6db62ca93 --- /dev/null +++ b/packages/auth/src/Domain/Subscription/UserSubscriptionRepositoryInterface.ts @@ -0,0 +1,14 @@ +import { Uuid } from '@standardnotes/common' +import { UserSubscription } from './UserSubscription' +import { UserSubscriptionType } from './UserSubscriptionType' + +export interface UserSubscriptionRepositoryInterface { + findOneByUuid(uuid: Uuid): Promise + findOneByUserUuid(userUuid: Uuid): Promise + findOneByUserUuidAndSubscriptionId(userUuid: Uuid, subscriptionId: number): Promise + findBySubscriptionIdAndType(subscriptionId: number, type: UserSubscriptionType): Promise + findBySubscriptionId(subscriptionId: number): Promise + updateEndsAt(subscriptionId: number, endsAt: number, updatedAt: number): Promise + updateCancelled(subscriptionId: number, cancelled: boolean, updatedAt: number): Promise + save(subscription: UserSubscription): Promise +} diff --git a/packages/auth/src/Domain/Subscription/UserSubscriptionService.spec.ts b/packages/auth/src/Domain/Subscription/UserSubscriptionService.spec.ts new file mode 100644 index 000000000..ef52e7eed --- /dev/null +++ b/packages/auth/src/Domain/Subscription/UserSubscriptionService.spec.ts @@ -0,0 +1,114 @@ +import 'reflect-metadata' +import { User } from '../User/User' +import { UserSubscription } from './UserSubscription' +import { UserSubscriptionRepositoryInterface } from './UserSubscriptionRepositoryInterface' + +import { UserSubscriptionService } from './UserSubscriptionService' +import { UserSubscriptionType } from './UserSubscriptionType' + +describe('UserSubscriptionService', () => { + let userSubscriptionRepository: UserSubscriptionRepositoryInterface + let regularSubscription: UserSubscription + let sharedSubscription: UserSubscription + let user: User + + const createService = () => new UserSubscriptionService(userSubscriptionRepository) + + beforeEach(() => { + user = { + uuid: '1-2-3', + } as jest.Mocked + + regularSubscription = { + uuid: '1-2-3', + subscriptionType: UserSubscriptionType.Regular, + user: Promise.resolve(user), + } as jest.Mocked + sharedSubscription = { + uuid: '2-3-4', + subscriptionType: UserSubscriptionType.Shared, + user: Promise.resolve(user), + } as jest.Mocked + + userSubscriptionRepository = {} as jest.Mocked + userSubscriptionRepository.findOneByUserUuid = jest.fn().mockReturnValue(null) + userSubscriptionRepository.findOneByUuid = jest.fn().mockReturnValue(null) + userSubscriptionRepository.findBySubscriptionIdAndType = jest.fn().mockReturnValue([]) + }) + + describe('by uuid', () => { + it('should return undefined if there is no user subscription', async () => { + expect(await createService().findRegularSubscriptionForUuid('1-2-3')).toEqual({ + regularSubscription: null, + sharedSubscription: null, + }) + }) + + it('should return a regular subscription if the uuid corresponds to a regular subscription', async () => { + userSubscriptionRepository.findOneByUuid = jest.fn().mockReturnValue(regularSubscription) + + expect(await createService().findRegularSubscriptionForUuid('1-2-3')).toEqual({ + regularSubscription, + sharedSubscription: null, + }) + }) + + it('should return a regular subscription if the uuid corresponds to a shared subscription', async () => { + userSubscriptionRepository.findOneByUuid = jest.fn().mockReturnValue(sharedSubscription) + userSubscriptionRepository.findBySubscriptionIdAndType = jest.fn().mockReturnValue([regularSubscription]) + + expect(await createService().findRegularSubscriptionForUuid('1-2-3')).toEqual({ + regularSubscription, + sharedSubscription, + }) + }) + + it('should return undefined if a regular subscription is not found corresponding to the shared subscription', async () => { + userSubscriptionRepository.findOneByUuid = jest.fn().mockReturnValue(sharedSubscription) + userSubscriptionRepository.findBySubscriptionIdAndType = jest.fn().mockReturnValue([]) + + expect(await createService().findRegularSubscriptionForUuid('1-2-3')).toEqual({ + regularSubscription: null, + sharedSubscription, + }) + }) + }) + + describe('by user uuid', () => { + it('should return undefined if there is no user subscription', async () => { + expect(await createService().findRegularSubscriptionForUserUuid('1-2-3')).toEqual({ + regularSubscription: null, + sharedSubscription: null, + }) + }) + + it('should return a regular subscription if the uuid corresponds to a regular subscription', async () => { + userSubscriptionRepository.findOneByUserUuid = jest.fn().mockReturnValue(regularSubscription) + + expect(await createService().findRegularSubscriptionForUserUuid('1-2-3')).toEqual({ + regularSubscription, + sharedSubscription: null, + }) + }) + + it('should return a regular subscription if the uuid corresponds to a shared subscription', async () => { + userSubscriptionRepository.findOneByUserUuid = jest.fn().mockReturnValue(sharedSubscription) + userSubscriptionRepository.findBySubscriptionIdAndType = jest.fn().mockReturnValue([regularSubscription]) + + expect(await createService().findRegularSubscriptionForUserUuid('1-2-3')).toEqual({ + regularSubscription, + sharedSubscription, + }) + }) + + it('should return undefined if a regular subscription is not found corresponding to the shared subscription', async () => { + userSubscriptionRepository.findOneByUserUuid = jest.fn().mockReturnValue(sharedSubscription) + userSubscriptionRepository.findBySubscriptionIdAndType = jest.fn().mockReturnValue([]) + + expect(await createService().findRegularSubscriptionForUserUuid('1-2-3')).toEqual({ + regularSubscription: null, + sharedSubscription, + }) + }) + }) +}) diff --git a/packages/auth/src/Domain/Subscription/UserSubscriptionService.ts b/packages/auth/src/Domain/Subscription/UserSubscriptionService.ts new file mode 100644 index 000000000..8e94191fb --- /dev/null +++ b/packages/auth/src/Domain/Subscription/UserSubscriptionService.ts @@ -0,0 +1,63 @@ +import { Uuid } from '@standardnotes/common' +import { inject, injectable } from 'inversify' + +import TYPES from '../../Bootstrap/Types' +import { FindRegularSubscriptionResponse } from './FindRegularSubscriptionResponse' + +import { UserSubscription } from './UserSubscription' +import { UserSubscriptionRepositoryInterface } from './UserSubscriptionRepositoryInterface' +import { UserSubscriptionServiceInterface } from './UserSubscriptionServiceInterface' +import { UserSubscriptionType } from './UserSubscriptionType' + +@injectable() +export class UserSubscriptionService implements UserSubscriptionServiceInterface { + constructor( + @inject(TYPES.UserSubscriptionRepository) private userSubscriptionRepository: UserSubscriptionRepositoryInterface, + ) {} + + async findRegularSubscriptionForUserUuid(userUuid: string): Promise { + const userSubscription = await this.userSubscriptionRepository.findOneByUserUuid(userUuid) + + return this.findRegularSubscription(userSubscription) + } + + async findRegularSubscriptionForUuid(uuid: Uuid): Promise { + const userSubscription = await this.userSubscriptionRepository.findOneByUuid(uuid) + + return this.findRegularSubscription(userSubscription) + } + + private async findRegularSubscription( + userSubscription: UserSubscription | null, + ): Promise { + if (userSubscription === null) { + return { + regularSubscription: null, + sharedSubscription: null, + } + } + + if (userSubscription.subscriptionType === UserSubscriptionType.Regular) { + return { + regularSubscription: userSubscription, + sharedSubscription: null, + } + } + + const regularSubscriptions = await this.userSubscriptionRepository.findBySubscriptionIdAndType( + userSubscription.subscriptionId as number, + UserSubscriptionType.Regular, + ) + if (regularSubscriptions.length === 0) { + return { + regularSubscription: null, + sharedSubscription: userSubscription, + } + } + + return { + regularSubscription: regularSubscriptions[0], + sharedSubscription: userSubscription, + } + } +} diff --git a/packages/auth/src/Domain/Subscription/UserSubscriptionServiceInterface.ts b/packages/auth/src/Domain/Subscription/UserSubscriptionServiceInterface.ts new file mode 100644 index 000000000..7cf7d48e7 --- /dev/null +++ b/packages/auth/src/Domain/Subscription/UserSubscriptionServiceInterface.ts @@ -0,0 +1,7 @@ +import { Uuid } from '@standardnotes/common' +import { FindRegularSubscriptionResponse } from './FindRegularSubscriptionResponse' + +export interface UserSubscriptionServiceInterface { + findRegularSubscriptionForUuid(uuid: Uuid): Promise + findRegularSubscriptionForUserUuid(userUuid: Uuid): Promise +} diff --git a/packages/auth/src/Domain/Subscription/UserSubscriptionType.ts b/packages/auth/src/Domain/Subscription/UserSubscriptionType.ts new file mode 100644 index 000000000..a996fd427 --- /dev/null +++ b/packages/auth/src/Domain/Subscription/UserSubscriptionType.ts @@ -0,0 +1,4 @@ +export enum UserSubscriptionType { + Regular = 'regular', + Shared = 'shared', +} diff --git a/packages/auth/src/Domain/UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitation.spec.ts b/packages/auth/src/Domain/UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitation.spec.ts new file mode 100644 index 000000000..1f7e504be --- /dev/null +++ b/packages/auth/src/Domain/UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitation.spec.ts @@ -0,0 +1,157 @@ +import 'reflect-metadata' + +import { RoleName, SubscriptionName } from '@standardnotes/common' +import { TimerInterface } from '@standardnotes/time' + +import { RoleServiceInterface } from '../../Role/RoleServiceInterface' +import { SharedSubscriptionInvitation } from '../../SharedSubscription/SharedSubscriptionInvitation' +import { SharedSubscriptionInvitationRepositoryInterface } from '../../SharedSubscription/SharedSubscriptionInvitationRepositoryInterface' +import { UserSubscription } from '../../Subscription/UserSubscription' +import { UserSubscriptionRepositoryInterface } from '../../Subscription/UserSubscriptionRepositoryInterface' +import { User } from '../../User/User' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' + +import { AcceptSharedSubscriptionInvitation } from './AcceptSharedSubscriptionInvitation' +import { SubscriptionSettingServiceInterface } from '../../Setting/SubscriptionSettingServiceInterface' + +describe('AcceptSharedSubscriptionInvitation', () => { + let sharedSubscriptionInvitationRepository: SharedSubscriptionInvitationRepositoryInterface + let userRepository: UserRepositoryInterface + let userSubscriptionRepository: UserSubscriptionRepositoryInterface + let roleService: RoleServiceInterface + let subscriptionSettingService: SubscriptionSettingServiceInterface + let timer: TimerInterface + let invitee: User + let inviterSubscription: UserSubscription + let inviteeSubscription: UserSubscription + let invitation: SharedSubscriptionInvitation + + const createUseCase = () => + new AcceptSharedSubscriptionInvitation( + sharedSubscriptionInvitationRepository, + userRepository, + userSubscriptionRepository, + roleService, + subscriptionSettingService, + timer, + ) + + beforeEach(() => { + invitee = { + uuid: '123', + email: 'test@test.com', + roles: Promise.resolve([ + { + name: RoleName.CoreUser, + }, + ]), + } as jest.Mocked + + invitation = { + subscriptionId: 3, + } as jest.Mocked + + sharedSubscriptionInvitationRepository = {} as jest.Mocked + sharedSubscriptionInvitationRepository.findOneByUuidAndStatus = jest.fn().mockReturnValue(invitation) + sharedSubscriptionInvitationRepository.save = jest.fn() + + userRepository = {} as jest.Mocked + userRepository.findOneByEmail = jest.fn().mockReturnValue(invitee) + + inviteeSubscription = { endsAt: 3, planName: SubscriptionName.PlusPlan } as jest.Mocked + + inviterSubscription = { endsAt: 3, planName: SubscriptionName.PlusPlan } as jest.Mocked + + userSubscriptionRepository = {} as jest.Mocked + userSubscriptionRepository.findBySubscriptionIdAndType = jest.fn().mockReturnValue([inviterSubscription]) + userSubscriptionRepository.save = jest.fn().mockReturnValue(inviteeSubscription) + + roleService = {} as jest.Mocked + roleService.addUserRole = jest.fn() + + subscriptionSettingService = {} as jest.Mocked + subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription = jest.fn() + + timer = {} as jest.Mocked + timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(1) + }) + + it('should create a shared subscription upon accepting the invitation', async () => { + expect( + await createUseCase().execute({ + sharedSubscriptionInvitationUuid: '1-2-3', + }), + ).toEqual({ + success: true, + }) + + expect(sharedSubscriptionInvitationRepository.save).toHaveBeenCalledWith({ + status: 'accepted', + subscriptionId: 3, + updatedAt: 1, + }) + expect(userSubscriptionRepository.save).toHaveBeenCalledWith({ + cancelled: false, + createdAt: 1, + endsAt: 3, + planName: 'PLUS_PLAN', + subscriptionId: 3, + subscriptionType: 'shared', + updatedAt: 1, + user: Promise.resolve(invitee), + }) + expect(roleService.addUserRole).toHaveBeenCalledWith(invitee, 'PLUS_PLAN') + expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).toHaveBeenCalledWith( + inviteeSubscription, + 'PLUS_PLAN', + ) + }) + + it('should not create a shared subscription if invitiation is not found', async () => { + sharedSubscriptionInvitationRepository.findOneByUuidAndStatus = jest.fn().mockReturnValue(null) + expect( + await createUseCase().execute({ + sharedSubscriptionInvitationUuid: '1-2-3', + }), + ).toEqual({ + success: false, + }) + + expect(sharedSubscriptionInvitationRepository.save).not.toHaveBeenCalled() + expect(userSubscriptionRepository.save).not.toHaveBeenCalled() + expect(roleService.addUserRole).not.toHaveBeenCalled() + expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).not.toHaveBeenCalled() + }) + + it('should not create a shared subscription if invitee is not found', async () => { + userRepository.findOneByEmail = jest.fn().mockReturnValue(null) + expect( + await createUseCase().execute({ + sharedSubscriptionInvitationUuid: '1-2-3', + }), + ).toEqual({ + success: false, + }) + + expect(sharedSubscriptionInvitationRepository.save).not.toHaveBeenCalled() + expect(userSubscriptionRepository.save).not.toHaveBeenCalled() + expect(roleService.addUserRole).not.toHaveBeenCalled() + expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).not.toHaveBeenCalled() + }) + + it('should not create a shared subscription if inviter subscription is not found', async () => { + userSubscriptionRepository.findBySubscriptionIdAndType = jest.fn().mockReturnValue([]) + expect( + await createUseCase().execute({ + sharedSubscriptionInvitationUuid: '1-2-3', + }), + ).toEqual({ + success: false, + }) + + expect(sharedSubscriptionInvitationRepository.save).not.toHaveBeenCalled() + expect(userSubscriptionRepository.save).not.toHaveBeenCalled() + expect(roleService.addUserRole).not.toHaveBeenCalled() + expect(subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription).not.toHaveBeenCalled() + }) +}) diff --git a/packages/auth/src/Domain/UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitation.ts b/packages/auth/src/Domain/UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitation.ts new file mode 100644 index 000000000..b776a05aa --- /dev/null +++ b/packages/auth/src/Domain/UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitation.ts @@ -0,0 +1,108 @@ +import { SubscriptionName } from '@standardnotes/common' +import { TimerInterface } from '@standardnotes/time' +import { inject, injectable } from 'inversify' + +import TYPES from '../../../Bootstrap/Types' +import { RoleServiceInterface } from '../../Role/RoleServiceInterface' +import { SubscriptionSettingServiceInterface } from '../../Setting/SubscriptionSettingServiceInterface' +import { InvitationStatus } from '../../SharedSubscription/InvitationStatus' +import { SharedSubscriptionInvitationRepositoryInterface } from '../../SharedSubscription/SharedSubscriptionInvitationRepositoryInterface' +import { UserSubscription } from '../../Subscription/UserSubscription' +import { UserSubscriptionRepositoryInterface } from '../../Subscription/UserSubscriptionRepositoryInterface' +import { UserSubscriptionType } from '../../Subscription/UserSubscriptionType' +import { User } from '../../User/User' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' +import { UseCaseInterface } from '../UseCaseInterface' + +import { AcceptSharedSubscriptionInvitationDTO } from './AcceptSharedSubscriptionInvitationDTO' +import { AcceptSharedSubscriptionInvitationResponse } from './AcceptSharedSubscriptionInvitationResponse' + +@injectable() +export class AcceptSharedSubscriptionInvitation implements UseCaseInterface { + constructor( + @inject(TYPES.SharedSubscriptionInvitationRepository) + private sharedSubscriptionInvitationRepository: SharedSubscriptionInvitationRepositoryInterface, + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.UserSubscriptionRepository) private userSubscriptionRepository: UserSubscriptionRepositoryInterface, + @inject(TYPES.RoleService) private roleService: RoleServiceInterface, + @inject(TYPES.SubscriptionSettingService) private subscriptionSettingService: SubscriptionSettingServiceInterface, + @inject(TYPES.Timer) private timer: TimerInterface, + ) {} + + async execute(dto: AcceptSharedSubscriptionInvitationDTO): Promise { + const sharedSubscriptionInvitation = await this.sharedSubscriptionInvitationRepository.findOneByUuidAndStatus( + dto.sharedSubscriptionInvitationUuid, + InvitationStatus.Sent, + ) + if (sharedSubscriptionInvitation === null) { + return { + success: false, + } + } + + const invitee = await this.userRepository.findOneByEmail(sharedSubscriptionInvitation.inviteeIdentifier) + if (invitee === null) { + return { + success: false, + } + } + + const inviterUserSubscriptions = await this.userSubscriptionRepository.findBySubscriptionIdAndType( + sharedSubscriptionInvitation.subscriptionId, + UserSubscriptionType.Regular, + ) + if (inviterUserSubscriptions.length !== 1) { + return { + success: false, + } + } + const inviterUserSubscription = inviterUserSubscriptions[0] + + sharedSubscriptionInvitation.status = InvitationStatus.Accepted + sharedSubscriptionInvitation.updatedAt = this.timer.getTimestampInMicroseconds() + + await this.sharedSubscriptionInvitationRepository.save(sharedSubscriptionInvitation) + + const inviteeSubscription = await this.createSharedSubscription( + sharedSubscriptionInvitation.subscriptionId, + inviterUserSubscription.planName, + invitee, + inviterUserSubscription.endsAt, + ) + + await this.addUserRole(invitee, inviterUserSubscription.planName as SubscriptionName) + + await this.subscriptionSettingService.applyDefaultSubscriptionSettingsForSubscription( + inviteeSubscription, + inviteeSubscription.planName as SubscriptionName, + ) + + return { + success: true, + } + } + + private async addUserRole(user: User, subscriptionName: SubscriptionName): Promise { + await this.roleService.addUserRole(user, subscriptionName) + } + + private async createSharedSubscription( + subscriptionId: number, + subscriptionName: string, + user: User, + subscriptionExpiresAt: number, + ): Promise { + const subscription = new UserSubscription() + subscription.planName = subscriptionName + subscription.user = Promise.resolve(user) + const timestamp = this.timer.getTimestampInMicroseconds() + subscription.createdAt = timestamp + subscription.updatedAt = timestamp + subscription.endsAt = subscriptionExpiresAt + subscription.cancelled = false + subscription.subscriptionId = subscriptionId + subscription.subscriptionType = UserSubscriptionType.Shared + + return this.userSubscriptionRepository.save(subscription) + } +} diff --git a/packages/auth/src/Domain/UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitationDTO.ts b/packages/auth/src/Domain/UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitationDTO.ts new file mode 100644 index 000000000..6e3fd96f8 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitationDTO.ts @@ -0,0 +1,3 @@ +export type AcceptSharedSubscriptionInvitationDTO = { + sharedSubscriptionInvitationUuid: string +} diff --git a/packages/auth/src/Domain/UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitationResponse.ts b/packages/auth/src/Domain/UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitationResponse.ts new file mode 100644 index 000000000..c8443c951 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/AcceptSharedSubscriptionInvitation/AcceptSharedSubscriptionInvitationResponse.ts @@ -0,0 +1,3 @@ +export type AcceptSharedSubscriptionInvitationResponse = { + success: boolean +} diff --git a/packages/auth/src/Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnection.spec.ts b/packages/auth/src/Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnection.spec.ts new file mode 100644 index 000000000..4686984d6 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnection.spec.ts @@ -0,0 +1,26 @@ +import 'reflect-metadata' +import { Logger } from 'winston' +import { WebSocketsConnectionRepositoryInterface } from '../../WebSockets/WebSocketsConnectionRepositoryInterface' + +import { AddWebSocketsConnection } from './AddWebSocketsConnection' + +describe('AddWebSocketsConnection', () => { + let webSocketsConnectionRepository: WebSocketsConnectionRepositoryInterface + let logger: Logger + + const createUseCase = () => new AddWebSocketsConnection(webSocketsConnectionRepository, logger) + + beforeEach(() => { + webSocketsConnectionRepository = {} as jest.Mocked + webSocketsConnectionRepository.saveConnection = jest.fn() + + logger = {} as jest.Mocked + logger.debug = jest.fn() + }) + + it('should save a web sockets connection for a user for further communication', async () => { + await createUseCase().execute({ userUuid: '1-2-3', connectionId: '2-3-4' }) + + expect(webSocketsConnectionRepository.saveConnection).toHaveBeenCalledWith('1-2-3', '2-3-4') + }) +}) diff --git a/packages/auth/src/Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnection.ts b/packages/auth/src/Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnection.ts new file mode 100644 index 000000000..cfa2953ad --- /dev/null +++ b/packages/auth/src/Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnection.ts @@ -0,0 +1,26 @@ +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' +import TYPES from '../../../Bootstrap/Types' +import { WebSocketsConnectionRepositoryInterface } from '../../WebSockets/WebSocketsConnectionRepositoryInterface' +import { UseCaseInterface } from '../UseCaseInterface' +import { AddWebSocketsConnectionDTO } from './AddWebSocketsConnectionDTO' +import { AddWebSocketsConnectionResponse } from './AddWebSocketsConnectionResponse' + +@injectable() +export class AddWebSocketsConnection implements UseCaseInterface { + constructor( + @inject(TYPES.WebSocketsConnectionRepository) + private webSocketsConnectionRepository: WebSocketsConnectionRepositoryInterface, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async execute(dto: AddWebSocketsConnectionDTO): Promise { + this.logger.debug(`Persisting connection ${dto.connectionId} for user ${dto.userUuid}`) + + await this.webSocketsConnectionRepository.saveConnection(dto.userUuid, dto.connectionId) + + return { + success: true, + } + } +} diff --git a/packages/auth/src/Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnectionDTO.ts b/packages/auth/src/Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnectionDTO.ts new file mode 100644 index 000000000..6a9208d31 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnectionDTO.ts @@ -0,0 +1,4 @@ +export type AddWebSocketsConnectionDTO = { + userUuid: string + connectionId: string +} diff --git a/packages/auth/src/Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnectionResponse.ts b/packages/auth/src/Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnectionResponse.ts new file mode 100644 index 000000000..dca6eda92 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/AddWebSocketsConnection/AddWebSocketsConnectionResponse.ts @@ -0,0 +1,3 @@ +export type AddWebSocketsConnectionResponse = { + success: boolean +} diff --git a/packages/auth/src/Domain/UseCase/AuthenticateOfflineSubscriptionToken/AuthenticateOfflineSubscriptionToken.spec.ts b/packages/auth/src/Domain/UseCase/AuthenticateOfflineSubscriptionToken/AuthenticateOfflineSubscriptionToken.spec.ts new file mode 100644 index 000000000..4b4e87073 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/AuthenticateOfflineSubscriptionToken/AuthenticateOfflineSubscriptionToken.spec.ts @@ -0,0 +1,83 @@ +import 'reflect-metadata' + +import { OfflineSubscriptionTokenRepositoryInterface } from '../../Auth/OfflineSubscriptionTokenRepositoryInterface' + +import { AuthenticateOfflineSubscriptionToken } from './AuthenticateOfflineSubscriptionToken' +import { OfflineUserSubscriptionRepositoryInterface } from '../../Subscription/OfflineUserSubscriptionRepositoryInterface' +import { OfflineUserSubscription } from '../../Subscription/OfflineUserSubscription' +import { OfflineSettingRepositoryInterface } from '../../Setting/OfflineSettingRepositoryInterface' +import { OfflineSetting } from '../../Setting/OfflineSetting' + +describe('AuthenticateOfflineSubscriptionToken', () => { + let offlineSubscriptionTokenRepository: OfflineSubscriptionTokenRepositoryInterface + let offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface + let offlineSettingRepository: OfflineSettingRepositoryInterface + let offlineSetting: OfflineSetting + + const createUseCase = () => + new AuthenticateOfflineSubscriptionToken( + offlineSubscriptionTokenRepository, + offlineUserSubscriptionRepository, + offlineSettingRepository, + ) + + beforeEach(() => { + offlineSubscriptionTokenRepository = {} as jest.Mocked + offlineSubscriptionTokenRepository.getUserEmailByToken = jest.fn().mockReturnValue('test@test.com') + + offlineUserSubscriptionRepository = {} as jest.Mocked + offlineUserSubscriptionRepository.findByEmail = jest + .fn() + .mockReturnValue([{} as jest.Mocked]) + + offlineSetting = { + email: 'test@test.com', + value: 'offline-features-token', + } as jest.Mocked + + offlineSettingRepository = {} as jest.Mocked + offlineSettingRepository.findOneByNameAndEmail = jest.fn().mockReturnValue(offlineSetting) + }) + + it('should authenticate an dashboard token', async () => { + const response = await createUseCase().execute({ token: 'test', userEmail: 'test@test.com' }) + + expect(offlineUserSubscriptionRepository.findByEmail).toHaveBeenCalledWith('test@test.com', 0) + + expect(response.success).toBeTruthy() + + expect((<{ success: true; email: string }>response).email).toEqual('test@test.com') + }) + + it('should not authenticate an dashboard token if user has no features token', async () => { + offlineSettingRepository.findOneByNameAndEmail = jest.fn().mockReturnValue(null) + + const response = await createUseCase().execute({ token: 'test', userEmail: 'test@test.com' }) + + expect(offlineUserSubscriptionRepository.findByEmail).toHaveBeenCalledWith('test@test.com', 0) + + expect(response.success).toBeFalsy() + }) + + it('should not authenticate an dashboard token if it is not found', async () => { + offlineSubscriptionTokenRepository.getUserEmailByToken = jest.fn().mockReturnValue(undefined) + + const response = await createUseCase().execute({ token: 'test', userEmail: 'test@test.com' }) + + expect(response.success).toBeFalsy() + }) + + it('should not authenticate an dashboard token if it is for a different email', async () => { + const response = await createUseCase().execute({ token: 'test', userEmail: 'test2@test.com' }) + + expect(response.success).toBeFalsy() + }) + + it('should not authenticate an dashboard token if offline user subscription is not found', async () => { + offlineUserSubscriptionRepository.findByEmail = jest.fn().mockReturnValue([]) + + const response = await createUseCase().execute({ token: 'test', userEmail: 'test@test.com' }) + + expect(response.success).toBeFalsy() + }) +}) diff --git a/packages/auth/src/Domain/UseCase/AuthenticateOfflineSubscriptionToken/AuthenticateOfflineSubscriptionToken.ts b/packages/auth/src/Domain/UseCase/AuthenticateOfflineSubscriptionToken/AuthenticateOfflineSubscriptionToken.ts new file mode 100644 index 000000000..e68607abe --- /dev/null +++ b/packages/auth/src/Domain/UseCase/AuthenticateOfflineSubscriptionToken/AuthenticateOfflineSubscriptionToken.ts @@ -0,0 +1,53 @@ +import { inject, injectable } from 'inversify' + +import TYPES from '../../../Bootstrap/Types' +import { OfflineSubscriptionTokenRepositoryInterface } from '../../Auth/OfflineSubscriptionTokenRepositoryInterface' +import { OfflineSettingName } from '../../Setting/OfflineSettingName' +import { OfflineSettingRepositoryInterface } from '../../Setting/OfflineSettingRepositoryInterface' +import { OfflineUserSubscriptionRepositoryInterface } from '../../Subscription/OfflineUserSubscriptionRepositoryInterface' +import { UseCaseInterface } from '../UseCaseInterface' +import { AuthenticateOfflineSubscriptionTokenDTO } from './AuthenticateOfflineSubscriptionTokenDTO' +import { AuthenticateOfflineSubscriptionTokenResponse } from './AuthenticateOfflineSubscriptionTokenResponse' + +@injectable() +export class AuthenticateOfflineSubscriptionToken implements UseCaseInterface { + constructor( + @inject(TYPES.OfflineSubscriptionTokenRepository) + private offlineSubscriptionTokenRepository: OfflineSubscriptionTokenRepositoryInterface, + @inject(TYPES.OfflineUserSubscriptionRepository) + private offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface, + @inject(TYPES.OfflineSettingRepository) private offlineSettingRepository: OfflineSettingRepositoryInterface, + ) {} + + async execute(dto: AuthenticateOfflineSubscriptionTokenDTO): Promise { + const userEmail = await this.offlineSubscriptionTokenRepository.getUserEmailByToken(dto.token) + if (userEmail === undefined || userEmail !== dto.userEmail) { + return { + success: false, + } + } + + const subscriptions = await this.offlineUserSubscriptionRepository.findByEmail(userEmail, 0) + if (subscriptions.length === 0) { + return { + success: false, + } + } + + const offlineFeaturesTokenSetting = await this.offlineSettingRepository.findOneByNameAndEmail( + OfflineSettingName.FeaturesToken, + userEmail, + ) + if (offlineFeaturesTokenSetting === null) { + return { + success: false, + } + } + + return { + success: true, + email: userEmail, + featuresToken: offlineFeaturesTokenSetting.value as string, + } + } +} diff --git a/packages/auth/src/Domain/UseCase/AuthenticateOfflineSubscriptionToken/AuthenticateOfflineSubscriptionTokenDTO.ts b/packages/auth/src/Domain/UseCase/AuthenticateOfflineSubscriptionToken/AuthenticateOfflineSubscriptionTokenDTO.ts new file mode 100644 index 000000000..323e9cc95 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/AuthenticateOfflineSubscriptionToken/AuthenticateOfflineSubscriptionTokenDTO.ts @@ -0,0 +1,4 @@ +export type AuthenticateOfflineSubscriptionTokenDTO = { + token: string + userEmail: string +} diff --git a/packages/auth/src/Domain/UseCase/AuthenticateOfflineSubscriptionToken/AuthenticateOfflineSubscriptionTokenResponse.ts b/packages/auth/src/Domain/UseCase/AuthenticateOfflineSubscriptionToken/AuthenticateOfflineSubscriptionTokenResponse.ts new file mode 100644 index 000000000..612989e81 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/AuthenticateOfflineSubscriptionToken/AuthenticateOfflineSubscriptionTokenResponse.ts @@ -0,0 +1,9 @@ +export type AuthenticateOfflineSubscriptionTokenResponse = + | { + success: true + email: string + featuresToken: string + } + | { + success: false + } diff --git a/packages/auth/src/Domain/UseCase/AuthenticateRequest.spec.ts b/packages/auth/src/Domain/UseCase/AuthenticateRequest.spec.ts new file mode 100644 index 000000000..a05c48c73 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/AuthenticateRequest.spec.ts @@ -0,0 +1,103 @@ +import 'reflect-metadata' + +import * as winston from 'winston' + +import { AuthenticateRequest } from './AuthenticateRequest' +import { Session } from '../Session/Session' +import { AuthenticateUser } from './AuthenticateUser' +import { User } from '../User/User' + +describe('AuthenticateRequest', () => { + let logger: winston.Logger + let authenticateUser: AuthenticateUser + + const createUseCase = () => new AuthenticateRequest(authenticateUser, logger) + + beforeEach(() => { + authenticateUser = {} as jest.Mocked + authenticateUser.execute = jest.fn() + + logger = {} as jest.Mocked + logger.info = jest.fn() + logger.warn = jest.fn() + logger.error = jest.fn() + logger.debug = jest.fn() + }) + + it('should authorize request', async () => { + const user = {} as jest.Mocked + const session = {} as jest.Mocked + + authenticateUser.execute = jest.fn().mockReturnValue({ + success: true, + user, + session, + }) + + const response = await createUseCase().execute({ authorizationHeader: 'test' }) + + expect(response.success).toBeTruthy() + expect(response.responseCode).toEqual(200) + expect(response.user).toEqual(user) + expect(response.session).toEqual(session) + }) + + it('should not authorize if authorization header is missing', async () => { + const response = await createUseCase().execute({}) + + expect(response.success).toBeFalsy() + expect(response.responseCode).toEqual(401) + expect(response.errorTag).toEqual('invalid-auth') + }) + + it('should not authorize if an error occurres', async () => { + authenticateUser.execute = jest.fn().mockImplementation(() => { + throw new Error('something bad happened') + }) + + const response = await createUseCase().execute({ authorizationHeader: 'test' }) + + expect(response.success).toBeFalsy() + expect(response.responseCode).toEqual(401) + expect(response.errorTag).toEqual('invalid-auth') + }) + + it('should not authorize user if authentication fails', async () => { + authenticateUser.execute = jest.fn().mockReturnValue({ + success: false, + failureType: 'INVALID_AUTH', + }) + + const response = await createUseCase().execute({ authorizationHeader: 'test' }) + + expect(response.success).toBeFalsy() + expect(response.responseCode).toEqual(401) + expect(response.errorTag).toEqual('invalid-auth') + }) + + it('should not authorize user if the token is expired', async () => { + authenticateUser.execute = jest.fn().mockReturnValue({ + success: false, + failureType: 'EXPIRED_TOKEN', + }) + + const response = await createUseCase().execute({ authorizationHeader: 'test' }) + + expect(response.success).toBeFalsy() + expect(response.responseCode).toEqual(498) + expect(response.errorTag).toEqual('expired-access-token') + }) + + it('should not authorize user if the session is revoked', async () => { + authenticateUser.execute = jest.fn().mockReturnValue({ + success: false, + failureType: 'REVOKED_SESSION', + }) + + const response = await createUseCase().execute({ authorizationHeader: 'test' }) + + expect(response.success).toBeFalsy() + expect(response.responseCode).toEqual(401) + expect(response.errorTag).toEqual('revoked-session') + }) +}) diff --git a/packages/auth/src/Domain/UseCase/AuthenticateRequest.ts b/packages/auth/src/Domain/UseCase/AuthenticateRequest.ts new file mode 100644 index 000000000..5295320f8 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/AuthenticateRequest.ts @@ -0,0 +1,76 @@ +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' +import TYPES from '../../Bootstrap/Types' +import { AuthenticateRequestDTO } from './AuthenticateRequestDTO' +import { AuthenticateRequestResponse } from './AuthenticateRequestResponse' +import { AuthenticateUser } from './AuthenticateUser' +import { AuthenticateUserResponse } from './AuthenticateUserResponse' +import { UseCaseInterface } from './UseCaseInterface' + +@injectable() +export class AuthenticateRequest implements UseCaseInterface { + constructor( + @inject(TYPES.AuthenticateUser) private authenticateUser: AuthenticateUser, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async execute(dto: AuthenticateRequestDTO): Promise { + if (!dto.authorizationHeader) { + return { + success: false, + responseCode: 401, + errorTag: 'invalid-auth', + errorMessage: 'Invalid login credentials.', + } + } + + let authenticateResponse: AuthenticateUserResponse + try { + authenticateResponse = await this.authenticateUser.execute({ + token: dto.authorizationHeader.replace('Bearer ', ''), + }) + } catch (error) { + this.logger.error('Error occurred during authentication of a user %o', error) + + return { + success: false, + responseCode: 401, + errorTag: 'invalid-auth', + errorMessage: 'Invalid login credentials.', + } + } + + if (!authenticateResponse.success) { + switch (authenticateResponse.failureType) { + case 'EXPIRED_TOKEN': + return { + success: false, + responseCode: 498, + errorTag: 'expired-access-token', + errorMessage: 'The provided access token has expired.', + } + case 'INVALID_AUTH': + return { + success: false, + responseCode: 401, + errorTag: 'invalid-auth', + errorMessage: 'Invalid login credentials.', + } + case 'REVOKED_SESSION': + return { + success: false, + responseCode: 401, + errorTag: 'revoked-session', + errorMessage: 'Your session has been revoked.', + } + } + } + + return { + success: true, + responseCode: 200, + session: authenticateResponse.session, + user: authenticateResponse.user, + } + } +} diff --git a/packages/auth/src/Domain/UseCase/AuthenticateRequestDTO.ts b/packages/auth/src/Domain/UseCase/AuthenticateRequestDTO.ts new file mode 100644 index 000000000..b8e7e8211 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/AuthenticateRequestDTO.ts @@ -0,0 +1,3 @@ +export type AuthenticateRequestDTO = { + authorizationHeader?: string +} diff --git a/packages/auth/src/Domain/UseCase/AuthenticateRequestResponse.ts b/packages/auth/src/Domain/UseCase/AuthenticateRequestResponse.ts new file mode 100644 index 000000000..c7005e158 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/AuthenticateRequestResponse.ts @@ -0,0 +1,11 @@ +import { Session } from '../Session/Session' +import { User } from '../User/User' + +export type AuthenticateRequestResponse = { + success: boolean + responseCode: number + errorTag?: string + errorMessage?: string + session?: Session + user?: User +} diff --git a/packages/auth/src/Domain/UseCase/AuthenticateSubscriptionToken/AuthenticateSubscriptionToken.spec.ts b/packages/auth/src/Domain/UseCase/AuthenticateSubscriptionToken/AuthenticateSubscriptionToken.spec.ts new file mode 100644 index 000000000..e4c5ebc51 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/AuthenticateSubscriptionToken/AuthenticateSubscriptionToken.spec.ts @@ -0,0 +1,55 @@ +import 'reflect-metadata' + +import { RoleName } from '@standardnotes/common' + +import { SubscriptionTokenRepositoryInterface } from '../../Subscription/SubscriptionTokenRepositoryInterface' +import { User } from '../../User/User' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' + +import { AuthenticateSubscriptionToken } from './AuthenticateSubscriptionToken' + +describe('AuthenticateSubscriptionToken', () => { + let subscriptionTokenRepository: SubscriptionTokenRepositoryInterface + let userRepository: UserRepositoryInterface + let user: User + + const createUseCase = () => new AuthenticateSubscriptionToken(subscriptionTokenRepository, userRepository) + + beforeEach(() => { + subscriptionTokenRepository = {} as jest.Mocked + subscriptionTokenRepository.getUserUuidByToken = jest.fn().mockReturnValue('1-2-3') + + user = { + roles: Promise.resolve([{ name: RoleName.CoreUser }]), + } as jest.Mocked + + userRepository = {} as jest.Mocked + userRepository.findOneByUuid = jest.fn().mockReturnValue(user) + }) + + it('should authenticate an subscription token', async () => { + const response = await createUseCase().execute({ token: 'test' }) + + expect(userRepository.findOneByUuid).toHaveBeenCalledWith('1-2-3') + + expect(response.success).toBeTruthy() + + expect(response.user).toEqual(user) + }) + + it('should not authenticate an subscription token if it is not found', async () => { + subscriptionTokenRepository.getUserUuidByToken = jest.fn().mockReturnValue(undefined) + + const response = await createUseCase().execute({ token: 'test' }) + + expect(response.success).toBeFalsy() + }) + + it('should not authenticate an subscription token if user is not found', async () => { + userRepository.findOneByUuid = jest.fn().mockReturnValue(null) + + const response = await createUseCase().execute({ token: 'test' }) + + expect(response.success).toBeFalsy() + }) +}) diff --git a/packages/auth/src/Domain/UseCase/AuthenticateSubscriptionToken/AuthenticateSubscriptionToken.ts b/packages/auth/src/Domain/UseCase/AuthenticateSubscriptionToken/AuthenticateSubscriptionToken.ts new file mode 100644 index 000000000..d8ca7bed0 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/AuthenticateSubscriptionToken/AuthenticateSubscriptionToken.ts @@ -0,0 +1,38 @@ +import { inject, injectable } from 'inversify' + +import TYPES from '../../../Bootstrap/Types' +import { SubscriptionTokenRepositoryInterface } from '../../Subscription/SubscriptionTokenRepositoryInterface' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' +import { UseCaseInterface } from '../UseCaseInterface' +import { AuthenticateSubscriptionTokenDTO } from './AuthenticateSubscriptionTokenDTO' +import { AuthenticateSubscriptionTokenResponse } from './AuthenticateSubscriptionTokenResponse' + +@injectable() +export class AuthenticateSubscriptionToken implements UseCaseInterface { + constructor( + @inject(TYPES.SubscriptionTokenRepository) + private subscriptionTokenRepository: SubscriptionTokenRepositoryInterface, + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + ) {} + + async execute(dto: AuthenticateSubscriptionTokenDTO): Promise { + const userUuid = await this.subscriptionTokenRepository.getUserUuidByToken(dto.token) + if (userUuid === undefined) { + return { + success: false, + } + } + + const user = await this.userRepository.findOneByUuid(userUuid) + if (user === null) { + return { + success: false, + } + } + + return { + success: true, + user, + } + } +} diff --git a/packages/auth/src/Domain/UseCase/AuthenticateSubscriptionToken/AuthenticateSubscriptionTokenDTO.ts b/packages/auth/src/Domain/UseCase/AuthenticateSubscriptionToken/AuthenticateSubscriptionTokenDTO.ts new file mode 100644 index 000000000..2157fc75b --- /dev/null +++ b/packages/auth/src/Domain/UseCase/AuthenticateSubscriptionToken/AuthenticateSubscriptionTokenDTO.ts @@ -0,0 +1,3 @@ +export type AuthenticateSubscriptionTokenDTO = { + token: string +} diff --git a/packages/auth/src/Domain/UseCase/AuthenticateSubscriptionToken/AuthenticateSubscriptionTokenResponse.ts b/packages/auth/src/Domain/UseCase/AuthenticateSubscriptionToken/AuthenticateSubscriptionTokenResponse.ts new file mode 100644 index 000000000..8da4d3a64 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/AuthenticateSubscriptionToken/AuthenticateSubscriptionTokenResponse.ts @@ -0,0 +1,6 @@ +import { User } from '../../User/User' + +export type AuthenticateSubscriptionTokenResponse = { + success: boolean + user?: User +} diff --git a/packages/auth/src/Domain/UseCase/AuthenticateUser.spec.ts b/packages/auth/src/Domain/UseCase/AuthenticateUser.spec.ts new file mode 100644 index 000000000..be8fde8f6 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/AuthenticateUser.spec.ts @@ -0,0 +1,170 @@ +import 'reflect-metadata' +import * as dayjs from 'dayjs' + +import { Session } from '../Session/Session' + +import { User } from '../User/User' +import { AuthenticateUser } from './AuthenticateUser' +import { RevokedSession } from '../Session/RevokedSession' +import { AuthenticationMethodResolverInterface } from '../Auth/AuthenticationMethodResolverInterface' + +describe('AuthenticateUser', () => { + let user: User + let session: Session + let revokedSession: RevokedSession + let authenticationMethodResolver: AuthenticationMethodResolverInterface + + const createUseCase = () => new AuthenticateUser(authenticationMethodResolver) + + beforeEach(() => { + user = {} as jest.Mocked + user.supportsSessions = jest.fn().mockReturnValue(false) + + session = {} as jest.Mocked + session.accessExpiration = dayjs.utc().add(1, 'day').toDate() + session.refreshExpiration = dayjs.utc().add(1, 'day').toDate() + + revokedSession = {} as jest.Mocked + revokedSession.uuid = '1-2-3' + + authenticationMethodResolver = {} as jest.Mocked + authenticationMethodResolver.resolve = jest.fn() + }) + + it('should authenticate a user based on a JWT token', async () => { + user.encryptedPassword = 'test' + + authenticationMethodResolver.resolve = jest.fn().mockReturnValue({ + type: 'jwt', + claims: { + pw_hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', + }, + user, + }) + + const response = await createUseCase().execute({ token: 'test' }) + + expect(response.success).toBeTruthy() + }) + + it('should not authenticate a user if the password hashed in JWT token is inavlid', async () => { + user.encryptedPassword = 'test2' + + authenticationMethodResolver.resolve = jest.fn().mockReturnValue({ + type: 'jwt', + claims: { + pw_hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', + }, + user, + }) + + const response = await createUseCase().execute({ token: 'test' }) + + expect(response.success).toBeFalsy() + }) + + it('should not authenticate a user if the user is from JWT token is not found', async () => { + authenticationMethodResolver.resolve = jest.fn().mockReturnValue({ + type: 'jwt', + claims: { + pw_hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', + }, + }) + + const response = await createUseCase().execute({ token: 'test' }) + + expect(response.success).toBeFalsy() + }) + + it('should not authenticate a user if the user from JWT token supports sessions', async () => { + user.supportsSessions = jest.fn().mockReturnValue(true) + + authenticationMethodResolver.resolve = jest.fn().mockReturnValue({ + type: 'jwt', + claims: { + pw_hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', + }, + user, + }) + + const response = await createUseCase().execute({ token: 'test' }) + + expect(response.success).toBeFalsy() + }) + + it('should authenticate a user from a session token', async () => { + user.supportsSessions = jest.fn().mockReturnValue(true) + + authenticationMethodResolver.resolve = jest.fn().mockReturnValue({ + type: 'session_token', + session, + user, + }) + + const response = await createUseCase().execute({ token: 'test' }) + + expect(response.success).toBeTruthy() + }) + + it('should not authenticate a user from a session token if session is expired', async () => { + session.accessExpiration = dayjs.utc().subtract(1, 'day').toDate() + user.supportsSessions = jest.fn().mockReturnValue(true) + + authenticationMethodResolver.resolve = jest.fn().mockReturnValue({ + type: 'session_token', + session, + user, + }) + + const response = await createUseCase().execute({ token: 'test' }) + + expect(response.success).toBeFalsy() + }) + + it('should not authenticate a user from a session token if refresh token is expired', async () => { + session.refreshExpiration = dayjs.utc().subtract(1, 'day').toDate() + user.supportsSessions = jest.fn().mockReturnValue(true) + + authenticationMethodResolver.resolve = jest.fn().mockReturnValue({ + type: 'session_token', + session, + user, + }) + + const response = await createUseCase().execute({ token: 'test' }) + + expect(response.success).toBeFalsy() + }) + + it('should not authenticate a user from a session token if session is not found', async () => { + user.supportsSessions = jest.fn().mockReturnValue(true) + + authenticationMethodResolver.resolve = jest.fn().mockReturnValue({ + type: 'session_token', + user, + }) + + const response = await createUseCase().execute({ token: 'test' }) + + expect(response.success).toBeFalsy() + }) + + it('should not authenticate a user if a session is revoked', async () => { + authenticationMethodResolver.resolve = jest.fn().mockReturnValue({ + type: 'revoked', + revokedSession, + }) + + const response = await createUseCase().execute({ token: 'test' }) + + expect(response.success).toBeFalsy() + }) + + it('should not authenticate a user if authentication method could not be determined', async () => { + authenticationMethodResolver.resolve = jest.fn().mockReturnValue(undefined) + + const response = await createUseCase().execute({ token: 'test' }) + + expect(response.success).toBeFalsy() + }) +}) diff --git a/packages/auth/src/Domain/UseCase/AuthenticateUser.ts b/packages/auth/src/Domain/UseCase/AuthenticateUser.ts new file mode 100644 index 000000000..e03508e97 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/AuthenticateUser.ts @@ -0,0 +1,96 @@ +import * as crypto from 'crypto' +import * as dayjs from 'dayjs' +import { inject, injectable } from 'inversify' + +import TYPES from '../../Bootstrap/Types' +import { AuthenticationMethodResolverInterface } from '../Auth/AuthenticationMethodResolverInterface' + +import { AuthenticateUserDTO } from './AuthenticateUserDTO' +import { AuthenticateUserResponse } from './AuthenticateUserResponse' +import { UseCaseInterface } from './UseCaseInterface' + +@injectable() +export class AuthenticateUser implements UseCaseInterface { + constructor( + @inject(TYPES.AuthenticationMethodResolver) + private authenticationMethodResolver: AuthenticationMethodResolverInterface, + ) {} + + async execute(dto: AuthenticateUserDTO): Promise { + const authenticationMethod = await this.authenticationMethodResolver.resolve(dto.token) + if (!authenticationMethod) { + return { + success: false, + failureType: 'INVALID_AUTH', + } + } + + if (authenticationMethod.type === 'revoked') { + return { + success: false, + failureType: 'REVOKED_SESSION', + } + } + + const user = authenticationMethod.user + if (!user) { + return { + success: false, + failureType: 'INVALID_AUTH', + } + } + + if (authenticationMethod.type == 'jwt' && user.supportsSessions()) { + return { + success: false, + failureType: 'INVALID_AUTH', + } + } + + switch (authenticationMethod.type) { + case 'jwt': { + const pwHash = (>authenticationMethod.claims).pw_hash + const encryptedPasswordDigest = crypto.createHash('sha256').update(user.encryptedPassword).digest('hex') + + if (!pwHash || !crypto.timingSafeEqual(Buffer.from(pwHash), Buffer.from(encryptedPasswordDigest))) { + return { + success: false, + failureType: 'INVALID_AUTH', + } + } + break + } + case 'session_token': { + const session = authenticationMethod.session + if (!session) { + return { + success: false, + failureType: 'INVALID_AUTH', + } + } + + if (session.refreshExpiration < dayjs.utc().toDate()) { + return { + success: false, + failureType: 'INVALID_AUTH', + } + } + + if (session.accessExpiration < dayjs.utc().toDate()) { + return { + success: false, + failureType: 'EXPIRED_TOKEN', + } + } + + break + } + } + + return { + success: true, + user, + session: authenticationMethod.session, + } + } +} diff --git a/packages/auth/src/Domain/UseCase/AuthenticateUserDTO.ts b/packages/auth/src/Domain/UseCase/AuthenticateUserDTO.ts new file mode 100644 index 000000000..5b9846a33 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/AuthenticateUserDTO.ts @@ -0,0 +1,3 @@ +export type AuthenticateUserDTO = { + token: string +} diff --git a/packages/auth/src/Domain/UseCase/AuthenticateUserResponse.ts b/packages/auth/src/Domain/UseCase/AuthenticateUserResponse.ts new file mode 100644 index 000000000..e9057afcd --- /dev/null +++ b/packages/auth/src/Domain/UseCase/AuthenticateUserResponse.ts @@ -0,0 +1,9 @@ +import { Session } from '../Session/Session' +import { User } from '../User/User' + +export type AuthenticateUserResponse = { + success: boolean + failureType?: 'INVALID_AUTH' | 'EXPIRED_TOKEN' | 'REVOKED_SESSION' + user?: User + session?: Session +} diff --git a/packages/auth/src/Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitation.spec.ts b/packages/auth/src/Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitation.spec.ts new file mode 100644 index 000000000..93657ad71 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitation.spec.ts @@ -0,0 +1,213 @@ +import 'reflect-metadata' + +import { RoleName, SubscriptionName } from '@standardnotes/common' +import { TimerInterface } from '@standardnotes/time' + +import { RoleServiceInterface } from '../../Role/RoleServiceInterface' +import { SharedSubscriptionInvitation } from '../../SharedSubscription/SharedSubscriptionInvitation' +import { SharedSubscriptionInvitationRepositoryInterface } from '../../SharedSubscription/SharedSubscriptionInvitationRepositoryInterface' +import { UserSubscription } from '../../Subscription/UserSubscription' +import { UserSubscriptionRepositoryInterface } from '../../Subscription/UserSubscriptionRepositoryInterface' +import { User } from '../../User/User' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' + +import { CancelSharedSubscriptionInvitation } from './CancelSharedSubscriptionInvitation' +import { DomainEventPublisherInterface, SharedSubscriptionInvitationCanceledEvent } from '@standardnotes/domain-events' +import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface' +import { InviterIdentifierType } from '../../SharedSubscription/InviterIdentifierType' +import { InviteeIdentifierType } from '../../SharedSubscription/InviteeIdentifierType' + +describe('CancelSharedSubscriptionInvitation', () => { + let sharedSubscriptionInvitationRepository: SharedSubscriptionInvitationRepositoryInterface + let userRepository: UserRepositoryInterface + let userSubscriptionRepository: UserSubscriptionRepositoryInterface + let roleService: RoleServiceInterface + let timer: TimerInterface + let invitee: User + let inviterSubscription: UserSubscription + let invitation: SharedSubscriptionInvitation + let domainEventPublisher: DomainEventPublisherInterface + let domainEventFactory: DomainEventFactoryInterface + + const createUseCase = () => + new CancelSharedSubscriptionInvitation( + sharedSubscriptionInvitationRepository, + userRepository, + userSubscriptionRepository, + roleService, + domainEventPublisher, + domainEventFactory, + timer, + ) + + beforeEach(() => { + invitee = { + uuid: '123', + email: 'test@test.com', + roles: Promise.resolve([ + { + name: RoleName.CoreUser, + }, + ]), + } as jest.Mocked + + invitation = { + uuid: '1-2-3', + subscriptionId: 3, + inviterIdentifier: 'test@test.te', + inviterIdentifierType: InviterIdentifierType.Email, + inviteeIdentifier: 'invitee@test.te', + inviteeIdentifierType: InviteeIdentifierType.Email, + } as jest.Mocked + + sharedSubscriptionInvitationRepository = {} as jest.Mocked + sharedSubscriptionInvitationRepository.findOneByUuid = jest.fn().mockReturnValue(invitation) + sharedSubscriptionInvitationRepository.save = jest.fn() + + userRepository = {} as jest.Mocked + userRepository.findOneByEmail = jest.fn().mockReturnValue(invitee) + + inviterSubscription = { endsAt: 3, planName: SubscriptionName.PlusPlan } as jest.Mocked + + userSubscriptionRepository = {} as jest.Mocked + userSubscriptionRepository.findBySubscriptionIdAndType = jest.fn().mockReturnValue([inviterSubscription]) + userSubscriptionRepository.findOneByUserUuidAndSubscriptionId = jest + .fn() + .mockReturnValue({ user: Promise.resolve(invitee) } as jest.Mocked) + userSubscriptionRepository.save = jest.fn() + + roleService = {} as jest.Mocked + roleService.removeUserRole = jest.fn() + + timer = {} as jest.Mocked + timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(1) + + domainEventPublisher = {} as jest.Mocked + domainEventPublisher.publish = jest.fn() + + domainEventFactory = {} as jest.Mocked + domainEventFactory.createSharedSubscriptionInvitationCanceledEvent = jest + .fn() + .mockReturnValue({} as jest.Mocked) + }) + + it('should cancel a shared subscription invitation', async () => { + expect( + await createUseCase().execute({ + sharedSubscriptionInvitationUuid: '1-2-3', + inviterEmail: 'test@test.te', + }), + ).toEqual({ + success: true, + }) + + expect(sharedSubscriptionInvitationRepository.save).toHaveBeenCalledWith({ + status: 'canceled', + subscriptionId: 3, + updatedAt: 1, + inviterIdentifier: 'test@test.te', + uuid: '1-2-3', + inviterIdentifierType: 'email', + inviteeIdentifier: 'invitee@test.te', + inviteeIdentifierType: 'email', + }) + expect(userSubscriptionRepository.save).toHaveBeenCalledWith({ + endsAt: 1, + user: Promise.resolve(invitee), + }) + expect(roleService.removeUserRole).toHaveBeenCalledWith(invitee, 'PLUS_PLAN') + expect(domainEventPublisher.publish).toHaveBeenCalled() + expect(domainEventFactory.createSharedSubscriptionInvitationCanceledEvent).toHaveBeenCalledWith({ + inviteeIdentifier: '123', + inviteeIdentifierType: 'uuid', + inviterEmail: 'test@test.te', + inviterSubscriptionId: 3, + sharedSubscriptionInvitationUuid: '1-2-3', + }) + }) + + it('should cancel a shared subscription invitation without subscription removal is subscription is not found', async () => { + userSubscriptionRepository.findOneByUserUuidAndSubscriptionId = jest.fn().mockReturnValue(null) + + expect( + await createUseCase().execute({ + sharedSubscriptionInvitationUuid: '1-2-3', + inviterEmail: 'test@test.te', + }), + ).toEqual({ + success: true, + }) + + expect(sharedSubscriptionInvitationRepository.save).toHaveBeenCalledWith({ + status: 'canceled', + subscriptionId: 3, + updatedAt: 1, + inviterIdentifier: 'test@test.te', + uuid: '1-2-3', + inviterIdentifierType: 'email', + inviteeIdentifier: 'invitee@test.te', + inviteeIdentifierType: 'email', + }) + expect(userSubscriptionRepository.save).not.toHaveBeenCalled() + expect(roleService.removeUserRole).toHaveBeenCalledWith(invitee, 'PLUS_PLAN') + }) + + it('should not cancel a shared subscription invitation if it is not found', async () => { + sharedSubscriptionInvitationRepository.findOneByUuid = jest.fn().mockReturnValue(null) + expect( + await createUseCase().execute({ + sharedSubscriptionInvitationUuid: '1-2-3', + inviterEmail: 'test@test.te', + }), + ).toEqual({ + success: false, + }) + }) + + it('should not cancel a shared subscription invitation if it belongs to differen inviter', async () => { + expect( + await createUseCase().execute({ + sharedSubscriptionInvitationUuid: '1-2-3', + inviterEmail: 'test2@test.te', + }), + ).toEqual({ + success: false, + }) + }) + + it('should not cancel a shared subscription invitation if invitee is not found', async () => { + userRepository.findOneByEmail = jest.fn().mockReturnValue(null) + expect( + await createUseCase().execute({ + sharedSubscriptionInvitationUuid: '1-2-3', + inviterEmail: 'test@test.te', + }), + ).toEqual({ + success: false, + }) + }) + + it('should not cancel a shared subscription invitation if invitee is not found', async () => { + userRepository.findOneByEmail = jest.fn().mockReturnValue(null) + expect( + await createUseCase().execute({ + sharedSubscriptionInvitationUuid: '1-2-3', + inviterEmail: 'test@test.te', + }), + ).toEqual({ + success: false, + }) + }) + + it('should not cancel a shared subscription invitation if inviter subscription is not found', async () => { + userSubscriptionRepository.findBySubscriptionIdAndType = jest.fn().mockReturnValue([]) + expect( + await createUseCase().execute({ + sharedSubscriptionInvitationUuid: '1-2-3', + inviterEmail: 'test@test.te', + }), + ).toEqual({ + success: false, + }) + }) +}) diff --git a/packages/auth/src/Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitation.ts b/packages/auth/src/Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitation.ts new file mode 100644 index 000000000..4fdf94d5e --- /dev/null +++ b/packages/auth/src/Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitation.ts @@ -0,0 +1,107 @@ +import { SubscriptionName } from '@standardnotes/common' +import { DomainEventPublisherInterface } from '@standardnotes/domain-events' +import { TimerInterface } from '@standardnotes/time' +import { inject, injectable } from 'inversify' + +import TYPES from '../../../Bootstrap/Types' +import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface' +import { RoleServiceInterface } from '../../Role/RoleServiceInterface' +import { InvitationStatus } from '../../SharedSubscription/InvitationStatus' +import { InviteeIdentifierType } from '../../SharedSubscription/InviteeIdentifierType' +import { SharedSubscriptionInvitationRepositoryInterface } from '../../SharedSubscription/SharedSubscriptionInvitationRepositoryInterface' +import { UserSubscriptionRepositoryInterface } from '../../Subscription/UserSubscriptionRepositoryInterface' +import { UserSubscriptionType } from '../../Subscription/UserSubscriptionType' +import { User } from '../../User/User' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' +import { UseCaseInterface } from '../UseCaseInterface' + +import { CancelSharedSubscriptionInvitationDTO } from './CancelSharedSubscriptionInvitationDTO' +import { CancelSharedSubscriptionInvitationResponse } from './CancelSharedSubscriptionInvitationResponse' + +@injectable() +export class CancelSharedSubscriptionInvitation implements UseCaseInterface { + constructor( + @inject(TYPES.SharedSubscriptionInvitationRepository) + private sharedSubscriptionInvitationRepository: SharedSubscriptionInvitationRepositoryInterface, + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.UserSubscriptionRepository) private userSubscriptionRepository: UserSubscriptionRepositoryInterface, + @inject(TYPES.RoleService) private roleService: RoleServiceInterface, + @inject(TYPES.DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface, + @inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface, + @inject(TYPES.Timer) private timer: TimerInterface, + ) {} + + async execute(dto: CancelSharedSubscriptionInvitationDTO): Promise { + const sharedSubscriptionInvitation = await this.sharedSubscriptionInvitationRepository.findOneByUuid( + dto.sharedSubscriptionInvitationUuid, + ) + if (sharedSubscriptionInvitation === null) { + return { + success: false, + } + } + + if (dto.inviterEmail !== sharedSubscriptionInvitation.inviterIdentifier) { + return { + success: false, + } + } + + const invitee = await this.userRepository.findOneByEmail(sharedSubscriptionInvitation.inviteeIdentifier) + if (invitee === null) { + return { + success: false, + } + } + + const inviterUserSubscriptions = await this.userSubscriptionRepository.findBySubscriptionIdAndType( + sharedSubscriptionInvitation.subscriptionId, + UserSubscriptionType.Regular, + ) + if (inviterUserSubscriptions.length !== 1) { + return { + success: false, + } + } + const inviterUserSubscription = inviterUserSubscriptions[0] + + sharedSubscriptionInvitation.status = InvitationStatus.Canceled + sharedSubscriptionInvitation.updatedAt = this.timer.getTimestampInMicroseconds() + + await this.sharedSubscriptionInvitationRepository.save(sharedSubscriptionInvitation) + + await this.removeSharedSubscription(sharedSubscriptionInvitation.subscriptionId, invitee) + + await this.roleService.removeUserRole(invitee, inviterUserSubscription.planName as SubscriptionName) + + await this.domainEventPublisher.publish( + this.domainEventFactory.createSharedSubscriptionInvitationCanceledEvent({ + inviteeIdentifier: invitee.uuid, + inviteeIdentifierType: InviteeIdentifierType.Uuid, + inviterEmail: sharedSubscriptionInvitation.inviterIdentifier, + inviterSubscriptionId: sharedSubscriptionInvitation.subscriptionId, + inviterSubscriptionUuid: inviterUserSubscription.uuid, + sharedSubscriptionInvitationUuid: sharedSubscriptionInvitation.uuid, + }), + ) + + return { + success: true, + } + } + + private async removeSharedSubscription(subscriptionId: number, user: User): Promise { + const subscription = await this.userSubscriptionRepository.findOneByUserUuidAndSubscriptionId( + user.uuid, + subscriptionId, + ) + + if (subscription === null) { + return + } + + subscription.endsAt = this.timer.getTimestampInMicroseconds() + + await this.userSubscriptionRepository.save(subscription) + } +} diff --git a/packages/auth/src/Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitationDTO.ts b/packages/auth/src/Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitationDTO.ts new file mode 100644 index 000000000..c380cc811 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitationDTO.ts @@ -0,0 +1,6 @@ +import { Uuid } from '@standardnotes/common' + +export type CancelSharedSubscriptionInvitationDTO = { + sharedSubscriptionInvitationUuid: Uuid + inviterEmail: string +} diff --git a/packages/auth/src/Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitationResponse.ts b/packages/auth/src/Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitationResponse.ts new file mode 100644 index 000000000..196339af7 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/CancelSharedSubscriptionInvitation/CancelSharedSubscriptionInvitationResponse.ts @@ -0,0 +1,3 @@ +export type CancelSharedSubscriptionInvitationResponse = { + success: boolean +} diff --git a/packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentials.spec.ts b/packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentials.spec.ts new file mode 100644 index 000000000..20efe4615 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentials.spec.ts @@ -0,0 +1,189 @@ +import 'reflect-metadata' + +import { DomainEventPublisherInterface, UserEmailChangedEvent } from '@standardnotes/domain-events' +import { TimerInterface } from '@standardnotes/time' + +import { AuthResponseFactoryInterface } from '../../Auth/AuthResponseFactoryInterface' +import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface' + +import { AuthResponseFactoryResolverInterface } from '../../Auth/AuthResponseFactoryResolverInterface' +import { User } from '../../User/User' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' + +import { ChangeCredentials } from './ChangeCredentials' + +describe('ChangeCredentials', () => { + let userRepository: UserRepositoryInterface + let authResponseFactoryResolver: AuthResponseFactoryResolverInterface + let authResponseFactory: AuthResponseFactoryInterface + let domainEventPublisher: DomainEventPublisherInterface + let domainEventFactory: DomainEventFactoryInterface + let timer: TimerInterface + let user: User + + const createUseCase = () => + new ChangeCredentials(userRepository, authResponseFactoryResolver, domainEventPublisher, domainEventFactory, timer) + + beforeEach(() => { + userRepository = {} as jest.Mocked + userRepository.save = jest.fn() + + authResponseFactory = {} as jest.Mocked + authResponseFactory.createResponse = jest.fn().mockReturnValue({ foo: 'bar' }) + + authResponseFactoryResolver = {} as jest.Mocked + authResponseFactoryResolver.resolveAuthResponseFactoryVersion = jest.fn().mockReturnValue(authResponseFactory) + + user = {} as jest.Mocked + user.encryptedPassword = '$2a$11$K3g6XoTau8VmLJcai1bB0eD9/YvBSBRtBhMprJOaVZ0U3SgasZH3a' + user.uuid = '1-2-3' + user.email = 'test@test.te' + + domainEventPublisher = {} as jest.Mocked + domainEventPublisher.publish = jest.fn() + + domainEventFactory = {} as jest.Mocked + domainEventFactory.createUserEmailChangedEvent = jest.fn().mockReturnValue({} as jest.Mocked) + + timer = {} as jest.Mocked + timer.getUTCDate = jest.fn().mockReturnValue(new Date(1)) + }) + + it('should change password', async () => { + expect( + await createUseCase().execute({ + user, + apiVersion: '20190520', + currentPassword: 'qweqwe123123', + newPassword: 'test234', + pwNonce: 'asdzxc', + updatedWithUserAgent: 'Google Chrome', + kpCreated: '123', + kpOrigination: 'password-change', + }), + ).toEqual({ + success: true, + authResponse: { + foo: 'bar', + }, + }) + + expect(userRepository.save).toHaveBeenCalledWith({ + encryptedPassword: expect.any(String), + pwNonce: 'asdzxc', + kpCreated: '123', + email: 'test@test.te', + uuid: '1-2-3', + kpOrigination: 'password-change', + updatedAt: new Date(1), + }) + expect(domainEventPublisher.publish).not.toHaveBeenCalled() + expect(domainEventFactory.createUserEmailChangedEvent).not.toHaveBeenCalled() + }) + + it('should change email', async () => { + userRepository.findOneByEmail = jest.fn().mockReturnValue(null) + + expect( + await createUseCase().execute({ + user, + apiVersion: '20190520', + currentPassword: 'qweqwe123123', + newPassword: 'test234', + newEmail: 'new@test.te', + pwNonce: 'asdzxc', + updatedWithUserAgent: 'Google Chrome', + kpCreated: '123', + kpOrigination: 'password-change', + }), + ).toEqual({ + success: true, + authResponse: { + foo: 'bar', + }, + }) + + expect(userRepository.save).toHaveBeenCalledWith({ + encryptedPassword: expect.any(String), + email: 'new@test.te', + uuid: '1-2-3', + pwNonce: 'asdzxc', + kpCreated: '123', + kpOrigination: 'password-change', + updatedAt: new Date(1), + }) + expect(domainEventFactory.createUserEmailChangedEvent).toHaveBeenCalledWith('1-2-3', 'test@test.te', 'new@test.te') + expect(domainEventPublisher.publish).toHaveBeenCalled() + }) + + it('should not change email if already taken', async () => { + userRepository.findOneByEmail = jest.fn().mockReturnValue({} as jest.Mocked) + + expect( + await createUseCase().execute({ + user, + apiVersion: '20190520', + currentPassword: 'qweqwe123123', + newPassword: 'test234', + newEmail: 'new@test.te', + pwNonce: 'asdzxc', + updatedWithUserAgent: 'Google Chrome', + kpCreated: '123', + kpOrigination: 'password-change', + }), + ).toEqual({ + success: false, + errorMessage: 'The email you entered is already taken. Please try again.', + }) + + expect(userRepository.save).not.toHaveBeenCalled() + expect(domainEventFactory.createUserEmailChangedEvent).not.toHaveBeenCalled() + expect(domainEventPublisher.publish).not.toHaveBeenCalled() + }) + + it('should not change password if current password is incorrect', async () => { + expect( + await createUseCase().execute({ + user, + apiVersion: '20190520', + currentPassword: 'test123', + newPassword: 'test234', + pwNonce: 'asdzxc', + updatedWithUserAgent: 'Google Chrome', + }), + ).toEqual({ + success: false, + errorMessage: 'The current password you entered is incorrect. Please try again.', + }) + + expect(userRepository.save).not.toHaveBeenCalled() + }) + + it('should update protocol version while changing password', async () => { + expect( + await createUseCase().execute({ + user, + apiVersion: '20190520', + currentPassword: 'qweqwe123123', + newPassword: 'test234', + pwNonce: 'asdzxc', + updatedWithUserAgent: 'Google Chrome', + protocolVersion: '004', + }), + ).toEqual({ + success: true, + authResponse: { + foo: 'bar', + }, + }) + + expect(userRepository.save).toHaveBeenCalledWith({ + encryptedPassword: expect.any(String), + pwNonce: 'asdzxc', + version: '004', + email: 'test@test.te', + uuid: '1-2-3', + updatedAt: new Date(1), + }) + }) +}) diff --git a/packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentials.ts b/packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentials.ts new file mode 100644 index 000000000..876ed4902 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentials.ts @@ -0,0 +1,86 @@ +import * as bcrypt from 'bcryptjs' +import { inject, injectable } from 'inversify' +import TYPES from '../../../Bootstrap/Types' +import { AuthResponseFactoryResolverInterface } from '../../Auth/AuthResponseFactoryResolverInterface' + +import { User } from '../../User/User' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' +import { ChangeCredentialsDTO } from './ChangeCredentialsDTO' +import { ChangeCredentialsResponse } from './ChangeCredentialsResponse' +import { UseCaseInterface } from '../UseCaseInterface' +import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface' +import { DomainEventPublisherInterface, UserEmailChangedEvent } from '@standardnotes/domain-events' +import { TimerInterface } from '@standardnotes/time' + +@injectable() +export class ChangeCredentials implements UseCaseInterface { + constructor( + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.AuthResponseFactoryResolver) + private authResponseFactoryResolver: AuthResponseFactoryResolverInterface, + @inject(TYPES.DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface, + @inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface, + @inject(TYPES.Timer) private timer: TimerInterface, + ) {} + + async execute(dto: ChangeCredentialsDTO): Promise { + if (!(await bcrypt.compare(dto.currentPassword, dto.user.encryptedPassword))) { + return { + success: false, + errorMessage: 'The current password you entered is incorrect. Please try again.', + } + } + + dto.user.encryptedPassword = await bcrypt.hash(dto.newPassword, User.PASSWORD_HASH_COST) + + let userEmailChangedEvent: UserEmailChangedEvent | undefined = undefined + if (dto.newEmail !== undefined) { + const existingUser = await this.userRepository.findOneByEmail(dto.newEmail) + if (existingUser !== null) { + return { + success: false, + errorMessage: 'The email you entered is already taken. Please try again.', + } + } + + userEmailChangedEvent = this.domainEventFactory.createUserEmailChangedEvent( + dto.user.uuid, + dto.user.email, + dto.newEmail, + ) + + dto.user.email = dto.newEmail + } + + dto.user.pwNonce = dto.pwNonce + if (dto.protocolVersion) { + dto.user.version = dto.protocolVersion + } + if (dto.kpCreated) { + dto.user.kpCreated = dto.kpCreated + } + if (dto.kpOrigination) { + dto.user.kpOrigination = dto.kpOrigination + } + dto.user.updatedAt = this.timer.getUTCDate() + + const updatedUser = await this.userRepository.save(dto.user) + + if (userEmailChangedEvent !== undefined) { + await this.domainEventPublisher.publish(userEmailChangedEvent) + } + + const authResponseFactory = this.authResponseFactoryResolver.resolveAuthResponseFactoryVersion(dto.apiVersion) + + return { + success: true, + authResponse: await authResponseFactory.createResponse({ + user: updatedUser, + apiVersion: dto.apiVersion, + userAgent: dto.updatedWithUserAgent, + ephemeralSession: false, + readonlyAccess: false, + }), + } + } +} diff --git a/packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentialsDTO.ts b/packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentialsDTO.ts new file mode 100644 index 000000000..31a82363e --- /dev/null +++ b/packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentialsDTO.ts @@ -0,0 +1,14 @@ +import { User } from '../../User/User' + +export type ChangeCredentialsDTO = { + user: User + apiVersion: string + currentPassword: string + newPassword: string + newEmail?: string + pwNonce: string + updatedWithUserAgent: string + protocolVersion?: string + kpOrigination?: string + kpCreated?: string +} diff --git a/packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentialsResponse.ts b/packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentialsResponse.ts new file mode 100644 index 000000000..0bc8a4d6d --- /dev/null +++ b/packages/auth/src/Domain/UseCase/ChangeCredentials/ChangeCredentialsResponse.ts @@ -0,0 +1,8 @@ +import { AuthResponse20161215 } from '../../Auth/AuthResponse20161215' +import { AuthResponse20200115 } from '../../Auth/AuthResponse20200115' + +export type ChangeCredentialsResponse = { + success: boolean + authResponse?: AuthResponse20161215 | AuthResponse20200115 + errorMessage?: string +} diff --git a/packages/auth/src/Domain/UseCase/ClearLoginAttempts.spec.ts b/packages/auth/src/Domain/UseCase/ClearLoginAttempts.spec.ts new file mode 100644 index 000000000..9c01bb3fb --- /dev/null +++ b/packages/auth/src/Domain/UseCase/ClearLoginAttempts.spec.ts @@ -0,0 +1,47 @@ +import 'reflect-metadata' +import { Logger } from 'winston' +import { LockRepositoryInterface } from '../User/LockRepositoryInterface' + +import { User } from '../User/User' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { ClearLoginAttempts } from './ClearLoginAttempts' + +describe('ClearLoginAttempts', () => { + let userRepository: UserRepositoryInterface + let lockRepository: LockRepositoryInterface + let user: User + let logger: Logger + + const createUseCase = () => new ClearLoginAttempts(userRepository, lockRepository, logger) + + beforeEach(() => { + logger = {} as jest.Mocked + logger.debug = jest.fn() + + user = {} as jest.Mocked + user.uuid = '234' + + userRepository = {} as jest.Mocked + userRepository.findOneByEmail = jest.fn().mockReturnValue(user) + + lockRepository = {} as jest.Mocked + lockRepository.resetLockCounter = jest.fn() + }) + + it('should unlock an user by email and uuid', async () => { + expect(await createUseCase().execute({ email: 'test@test.te' })).toEqual({ success: true }) + + expect(lockRepository.resetLockCounter).toHaveBeenCalledTimes(2) + expect(lockRepository.resetLockCounter).toHaveBeenNthCalledWith(1, 'test@test.te') + expect(lockRepository.resetLockCounter).toHaveBeenNthCalledWith(2, '234') + }) + + it('should unlock an user by email and uuid if user does not exist', async () => { + userRepository.findOneByEmail = jest.fn().mockReturnValue(null) + + expect(await createUseCase().execute({ email: 'test@test.te' })).toEqual({ success: true }) + + expect(lockRepository.resetLockCounter).toHaveBeenCalledTimes(1) + expect(lockRepository.resetLockCounter).toHaveBeenCalledWith('test@test.te') + }) +}) diff --git a/packages/auth/src/Domain/UseCase/ClearLoginAttempts.ts b/packages/auth/src/Domain/UseCase/ClearLoginAttempts.ts new file mode 100644 index 000000000..d51bcdf1c --- /dev/null +++ b/packages/auth/src/Domain/UseCase/ClearLoginAttempts.ts @@ -0,0 +1,33 @@ +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' +import TYPES from '../../Bootstrap/Types' +import { LockRepositoryInterface } from '../User/LockRepositoryInterface' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { ClearLoginAttemptsDTO } from './ClearLoginAttemptsDTO' +import { ClearLoginAttemptsResponse } from './ClearLoginAttemptsResponse' +import { UseCaseInterface } from './UseCaseInterface' + +@injectable() +export class ClearLoginAttempts implements UseCaseInterface { + constructor( + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.LockRepository) private lockRepository: LockRepositoryInterface, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async execute(dto: ClearLoginAttemptsDTO): Promise { + await this.lockRepository.resetLockCounter(dto.email) + + const user = await this.userRepository.findOneByEmail(dto.email) + + if (!user) { + return { success: true } + } + + this.logger.debug(`Resetting lock counter for user ${user.uuid}`) + + await this.lockRepository.resetLockCounter(user.uuid) + + return { success: true } + } +} diff --git a/packages/auth/src/Domain/UseCase/ClearLoginAttemptsDTO.ts b/packages/auth/src/Domain/UseCase/ClearLoginAttemptsDTO.ts new file mode 100644 index 000000000..1b1e355b0 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/ClearLoginAttemptsDTO.ts @@ -0,0 +1,3 @@ +export type ClearLoginAttemptsDTO = { + email: string +} diff --git a/packages/auth/src/Domain/UseCase/ClearLoginAttemptsResponse.ts b/packages/auth/src/Domain/UseCase/ClearLoginAttemptsResponse.ts new file mode 100644 index 000000000..2e1c8ad80 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/ClearLoginAttemptsResponse.ts @@ -0,0 +1,3 @@ +export type ClearLoginAttemptsResponse = { + success: boolean +} diff --git a/packages/auth/src/Domain/UseCase/CreateListedAccount/CreateListedAccount.spec.ts b/packages/auth/src/Domain/UseCase/CreateListedAccount/CreateListedAccount.spec.ts new file mode 100644 index 000000000..c523cd857 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/CreateListedAccount/CreateListedAccount.spec.ts @@ -0,0 +1,29 @@ +import { DomainEventPublisherInterface, ListedAccountRequestedEvent } from '@standardnotes/domain-events' +import 'reflect-metadata' +import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface' + +import { CreateListedAccount } from './CreateListedAccount' + +describe('CreateListedAccount', () => { + let domainEventPublisher: DomainEventPublisherInterface + let domainEventFactory: DomainEventFactoryInterface + + const createUseCase = () => new CreateListedAccount(domainEventPublisher, domainEventFactory) + + beforeEach(() => { + domainEventPublisher = {} as jest.Mocked + domainEventPublisher.publish = jest.fn() + + domainEventFactory = {} as jest.Mocked + domainEventFactory.createListedAccountRequestedEvent = jest + .fn() + .mockReturnValue({} as jest.Mocked) + }) + + it('should publish a listed account requested event', async () => { + await createUseCase().execute({ userUuid: '1-2-3', userEmail: 'test@test.com' }) + + expect(domainEventFactory.createListedAccountRequestedEvent).toHaveBeenCalledWith('1-2-3', 'test@test.com') + expect(domainEventPublisher.publish).toHaveBeenCalled() + }) +}) diff --git a/packages/auth/src/Domain/UseCase/CreateListedAccount/CreateListedAccount.ts b/packages/auth/src/Domain/UseCase/CreateListedAccount/CreateListedAccount.ts new file mode 100644 index 000000000..9c8315689 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/CreateListedAccount/CreateListedAccount.ts @@ -0,0 +1,26 @@ +import { DomainEventPublisherInterface } from '@standardnotes/domain-events' +import { inject, injectable } from 'inversify' + +import TYPES from '../../../Bootstrap/Types' +import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface' +import { UseCaseInterface } from '../UseCaseInterface' +import { CreateListedAccountDTO } from './CreateListedAccountDTO' +import { CreateListedAccountResponse } from './CreateListedAccountResponse' + +@injectable() +export class CreateListedAccount implements UseCaseInterface { + constructor( + @inject(TYPES.DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface, + @inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface, + ) {} + + async execute(dto: CreateListedAccountDTO): Promise { + await this.domainEventPublisher.publish( + this.domainEventFactory.createListedAccountRequestedEvent(dto.userUuid, dto.userEmail), + ) + + return { + success: true, + } + } +} diff --git a/packages/auth/src/Domain/UseCase/CreateListedAccount/CreateListedAccountDTO.ts b/packages/auth/src/Domain/UseCase/CreateListedAccount/CreateListedAccountDTO.ts new file mode 100644 index 000000000..05bcd8ead --- /dev/null +++ b/packages/auth/src/Domain/UseCase/CreateListedAccount/CreateListedAccountDTO.ts @@ -0,0 +1,4 @@ +export type CreateListedAccountDTO = { + userUuid: string + userEmail: string +} diff --git a/packages/auth/src/Domain/UseCase/CreateListedAccount/CreateListedAccountResponse.ts b/packages/auth/src/Domain/UseCase/CreateListedAccount/CreateListedAccountResponse.ts new file mode 100644 index 000000000..e1d273428 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/CreateListedAccount/CreateListedAccountResponse.ts @@ -0,0 +1,3 @@ +export type CreateListedAccountResponse = { + success: boolean +} diff --git a/packages/auth/src/Domain/UseCase/CreateOfflineSubscriptionToken/CreateOfflineSubscriptionToken.spec.ts b/packages/auth/src/Domain/UseCase/CreateOfflineSubscriptionToken/CreateOfflineSubscriptionToken.spec.ts new file mode 100644 index 000000000..e29a4cecd --- /dev/null +++ b/packages/auth/src/Domain/UseCase/CreateOfflineSubscriptionToken/CreateOfflineSubscriptionToken.spec.ts @@ -0,0 +1,135 @@ +import 'reflect-metadata' + +import { CryptoNode } from '@standardnotes/sncrypto-node' +import { TimerInterface } from '@standardnotes/time' +import { OfflineSubscriptionTokenRepositoryInterface } from '../../Auth/OfflineSubscriptionTokenRepositoryInterface' + +import { CreateOfflineSubscriptionToken } from './CreateOfflineSubscriptionToken' +import { DomainEventPublisherInterface, OfflineSubscriptionTokenCreatedEvent } from '@standardnotes/domain-events' +import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface' +import { OfflineUserSubscriptionRepositoryInterface } from '../../Subscription/OfflineUserSubscriptionRepositoryInterface' +import { OfflineUserSubscription } from '../../Subscription/OfflineUserSubscription' +import { Logger } from 'winston' + +describe('CreateOfflineSubscriptionToken', () => { + let offlineSubscriptionTokenRepository: OfflineSubscriptionTokenRepositoryInterface + let offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface + let cryptoNode: CryptoNode + let domainEventPublisher: DomainEventPublisherInterface + let domainEventFactory: DomainEventFactoryInterface + let timer: TimerInterface + let logger: Logger + + const createUseCase = () => + new CreateOfflineSubscriptionToken( + offlineSubscriptionTokenRepository, + offlineUserSubscriptionRepository, + cryptoNode, + domainEventPublisher, + domainEventFactory, + timer, + logger, + ) + + beforeEach(() => { + offlineSubscriptionTokenRepository = {} as jest.Mocked + offlineSubscriptionTokenRepository.save = jest.fn() + + offlineUserSubscriptionRepository = {} as jest.Mocked + offlineUserSubscriptionRepository.findOneByEmail = jest + .fn() + .mockReturnValue({ cancelled: false, endsAt: 100 } as jest.Mocked) + + cryptoNode = {} as jest.Mocked + cryptoNode.generateRandomKey = jest.fn().mockReturnValueOnce('random-string') + + domainEventPublisher = {} as jest.Mocked + domainEventPublisher.publish = jest.fn() + + domainEventFactory = {} as jest.Mocked + domainEventFactory.createOfflineSubscriptionTokenCreatedEvent = jest + .fn() + .mockReturnValue({} as jest.Mocked) + + timer = {} as jest.Mocked + timer.convertStringDateToMicroseconds = jest.fn().mockReturnValue(1) + timer.getUTCDateNHoursAhead = jest.fn().mockReturnValue(new Date(1)) + timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(3) + + logger = {} as jest.Mocked + logger.debug = jest.fn() + }) + + it('should create an offline subscription token and persist it', async () => { + await createUseCase().execute({ + userEmail: 'test@test.com', + }) + + expect(offlineSubscriptionTokenRepository.save).toHaveBeenCalledWith({ + userEmail: 'test@test.com', + token: 'random-string', + expiresAt: 1, + }) + + expect(domainEventFactory.createOfflineSubscriptionTokenCreatedEvent).toHaveBeenCalledWith( + 'random-string', + 'test@test.com', + ) + expect(domainEventPublisher.publish).toHaveBeenCalled() + }) + + it('should not create an offline subscription token if email has no offline subscription', async () => { + offlineUserSubscriptionRepository.findOneByEmail = jest.fn().mockReturnValue(null) + + expect( + await createUseCase().execute({ + userEmail: 'test@test.com', + }), + ).toEqual({ + success: false, + error: 'no-subscription', + }) + + expect(offlineSubscriptionTokenRepository.save).not.toHaveBeenCalled() + expect(domainEventFactory.createOfflineSubscriptionTokenCreatedEvent).not.toHaveBeenCalled() + expect(domainEventPublisher.publish).not.toHaveBeenCalled() + }) + + it('should not create an offline subscription token if email has a cancelled subscription', async () => { + offlineUserSubscriptionRepository.findOneByEmail = jest + .fn() + .mockReturnValue({ cancelled: true, endsAt: 100 } as jest.Mocked) + + expect( + await createUseCase().execute({ + userEmail: 'test@test.com', + }), + ).toEqual({ + success: false, + error: 'subscription-canceled', + }) + + expect(offlineSubscriptionTokenRepository.save).not.toHaveBeenCalled() + expect(domainEventFactory.createOfflineSubscriptionTokenCreatedEvent).not.toHaveBeenCalled() + expect(domainEventPublisher.publish).not.toHaveBeenCalled() + }) + + it('should not create an offline subscription token if email has an outdated subscription', async () => { + offlineUserSubscriptionRepository.findOneByEmail = jest + .fn() + .mockReturnValue({ cancelled: false, endsAt: 2 } as jest.Mocked) + + expect( + await createUseCase().execute({ + userEmail: 'test@test.com', + }), + ).toEqual({ + success: false, + error: 'subscription-expired', + }) + + expect(offlineSubscriptionTokenRepository.save).not.toHaveBeenCalled() + expect(domainEventFactory.createOfflineSubscriptionTokenCreatedEvent).not.toHaveBeenCalled() + expect(domainEventPublisher.publish).not.toHaveBeenCalled() + }) +}) diff --git a/packages/auth/src/Domain/UseCase/CreateOfflineSubscriptionToken/CreateOfflineSubscriptionToken.ts b/packages/auth/src/Domain/UseCase/CreateOfflineSubscriptionToken/CreateOfflineSubscriptionToken.ts new file mode 100644 index 000000000..a13a95b55 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/CreateOfflineSubscriptionToken/CreateOfflineSubscriptionToken.ts @@ -0,0 +1,73 @@ +import { DomainEventPublisherInterface } from '@standardnotes/domain-events' +import { CryptoNode } from '@standardnotes/sncrypto-node' +import { TimerInterface } from '@standardnotes/time' +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' + +import TYPES from '../../../Bootstrap/Types' +import { OfflineSubscriptionTokenRepositoryInterface } from '../../Auth/OfflineSubscriptionTokenRepositoryInterface' +import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface' +import { OfflineUserSubscriptionRepositoryInterface } from '../../Subscription/OfflineUserSubscriptionRepositoryInterface' +import { UseCaseInterface } from '../UseCaseInterface' +import { CreateOfflineSubscriptionTokenDTO } from './CreateOfflineSubscriptionTokenDTO' +import { CreateOfflineSubscriptionTokenResponse } from './CreateOfflineSubscriptionTokenResponse' + +@injectable() +export class CreateOfflineSubscriptionToken implements UseCaseInterface { + constructor( + @inject(TYPES.OfflineSubscriptionTokenRepository) + private offlineSubscriptionTokenRepository: OfflineSubscriptionTokenRepositoryInterface, + @inject(TYPES.OfflineUserSubscriptionRepository) + private offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface, + @inject(TYPES.CryptoNode) private cryptoNode: CryptoNode, + @inject(TYPES.DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface, + @inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface, + @inject(TYPES.Timer) private timer: TimerInterface, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async execute(dto: CreateOfflineSubscriptionTokenDTO): Promise { + const existingSubscription = await this.offlineUserSubscriptionRepository.findOneByEmail(dto.userEmail) + if (existingSubscription === null) { + return { + success: false, + error: 'no-subscription', + } + } + + if (existingSubscription.cancelled) { + return { + success: false, + error: 'subscription-canceled', + } + } + + if (existingSubscription.endsAt < this.timer.getTimestampInMicroseconds()) { + return { + success: false, + error: 'subscription-expired', + } + } + + const token = await this.cryptoNode.generateRandomKey(128) + + const offlineSubscriptionToken = { + userEmail: dto.userEmail, + token, + expiresAt: this.timer.convertStringDateToMicroseconds(this.timer.getUTCDateNHoursAhead(3).toString()), + } + + this.logger.debug('Created offline subscription token: %O', offlineSubscriptionToken) + + await this.offlineSubscriptionTokenRepository.save(offlineSubscriptionToken) + + await this.domainEventPublisher.publish( + this.domainEventFactory.createOfflineSubscriptionTokenCreatedEvent(token, dto.userEmail), + ) + + return { + success: true, + offlineSubscriptionToken, + } + } +} diff --git a/packages/auth/src/Domain/UseCase/CreateOfflineSubscriptionToken/CreateOfflineSubscriptionTokenDTO.ts b/packages/auth/src/Domain/UseCase/CreateOfflineSubscriptionToken/CreateOfflineSubscriptionTokenDTO.ts new file mode 100644 index 000000000..53705fd77 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/CreateOfflineSubscriptionToken/CreateOfflineSubscriptionTokenDTO.ts @@ -0,0 +1,3 @@ +export type CreateOfflineSubscriptionTokenDTO = { + userEmail: string +} diff --git a/packages/auth/src/Domain/UseCase/CreateOfflineSubscriptionToken/CreateOfflineSubscriptionTokenResponse.ts b/packages/auth/src/Domain/UseCase/CreateOfflineSubscriptionToken/CreateOfflineSubscriptionTokenResponse.ts new file mode 100644 index 000000000..e2acd44b9 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/CreateOfflineSubscriptionToken/CreateOfflineSubscriptionTokenResponse.ts @@ -0,0 +1,11 @@ +import { OfflineSubscriptionToken } from '../../Auth/OfflineSubscriptionToken' + +export type CreateOfflineSubscriptionTokenResponse = + | { + success: true + offlineSubscriptionToken: OfflineSubscriptionToken + } + | { + success: false + error: 'no-subscription' | 'subscription-canceled' | 'subscription-expired' + } diff --git a/packages/auth/src/Domain/UseCase/CreateSubscriptionToken/CreateSubscriptionToken.spec.ts b/packages/auth/src/Domain/UseCase/CreateSubscriptionToken/CreateSubscriptionToken.spec.ts new file mode 100644 index 000000000..763b00f2b --- /dev/null +++ b/packages/auth/src/Domain/UseCase/CreateSubscriptionToken/CreateSubscriptionToken.spec.ts @@ -0,0 +1,39 @@ +import 'reflect-metadata' + +import { CryptoNode } from '@standardnotes/sncrypto-node' +import { TimerInterface } from '@standardnotes/time' +import { SubscriptionTokenRepositoryInterface } from '../../Subscription/SubscriptionTokenRepositoryInterface' + +import { CreateSubscriptionToken } from './CreateSubscriptionToken' + +describe('CreateSubscriptionToken', () => { + let subscriptionTokenRepository: SubscriptionTokenRepositoryInterface + let cryptoNode: CryptoNode + let timer: TimerInterface + + const createUseCase = () => new CreateSubscriptionToken(subscriptionTokenRepository, cryptoNode, timer) + + beforeEach(() => { + subscriptionTokenRepository = {} as jest.Mocked + subscriptionTokenRepository.save = jest.fn() + + cryptoNode = {} as jest.Mocked + cryptoNode.generateRandomKey = jest.fn().mockReturnValueOnce('random-string') + + timer = {} as jest.Mocked + timer.convertStringDateToMicroseconds = jest.fn().mockReturnValue(1) + timer.getUTCDateNHoursAhead = jest.fn().mockReturnValue(new Date(1)) + }) + + it('should create an subscription token and persist it', async () => { + await createUseCase().execute({ + userUuid: '1-2-3', + }) + + expect(subscriptionTokenRepository.save).toHaveBeenCalledWith({ + userUuid: '1-2-3', + token: 'random-string', + expiresAt: 1, + }) + }) +}) diff --git a/packages/auth/src/Domain/UseCase/CreateSubscriptionToken/CreateSubscriptionToken.ts b/packages/auth/src/Domain/UseCase/CreateSubscriptionToken/CreateSubscriptionToken.ts new file mode 100644 index 000000000..85e78ee90 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/CreateSubscriptionToken/CreateSubscriptionToken.ts @@ -0,0 +1,35 @@ +import { CryptoNode } from '@standardnotes/sncrypto-node' +import { TimerInterface } from '@standardnotes/time' +import { inject, injectable } from 'inversify' + +import TYPES from '../../../Bootstrap/Types' +import { SubscriptionTokenRepositoryInterface } from '../../Subscription/SubscriptionTokenRepositoryInterface' +import { UseCaseInterface } from '../UseCaseInterface' +import { CreateSubscriptionTokenDTO } from './CreateSubscriptionTokenDTO' +import { CreateSubscriptionTokenResponse } from './CreateSubscriptionTokenResponse' + +@injectable() +export class CreateSubscriptionToken implements UseCaseInterface { + constructor( + @inject(TYPES.SubscriptionTokenRepository) + private subscriptionTokenRepository: SubscriptionTokenRepositoryInterface, + @inject(TYPES.CryptoNode) private cryptoNode: CryptoNode, + @inject(TYPES.Timer) private timer: TimerInterface, + ) {} + + async execute(dto: CreateSubscriptionTokenDTO): Promise { + const token = await this.cryptoNode.generateRandomKey(128) + + const subscriptionToken = { + userUuid: dto.userUuid, + token, + expiresAt: this.timer.convertStringDateToMicroseconds(this.timer.getUTCDateNHoursAhead(3).toString()), + } + + await this.subscriptionTokenRepository.save(subscriptionToken) + + return { + subscriptionToken, + } + } +} diff --git a/packages/auth/src/Domain/UseCase/CreateSubscriptionToken/CreateSubscriptionTokenDTO.ts b/packages/auth/src/Domain/UseCase/CreateSubscriptionToken/CreateSubscriptionTokenDTO.ts new file mode 100644 index 000000000..d22d8c940 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/CreateSubscriptionToken/CreateSubscriptionTokenDTO.ts @@ -0,0 +1,3 @@ +export type CreateSubscriptionTokenDTO = { + userUuid: string +} diff --git a/packages/auth/src/Domain/UseCase/CreateSubscriptionToken/CreateSubscriptionTokenResponse.ts b/packages/auth/src/Domain/UseCase/CreateSubscriptionToken/CreateSubscriptionTokenResponse.ts new file mode 100644 index 000000000..dc0c6d49b --- /dev/null +++ b/packages/auth/src/Domain/UseCase/CreateSubscriptionToken/CreateSubscriptionTokenResponse.ts @@ -0,0 +1,5 @@ +import { SubscriptionToken } from '../../Subscription/SubscriptionToken' + +export type CreateSubscriptionTokenResponse = { + subscriptionToken: SubscriptionToken +} diff --git a/packages/auth/src/Domain/UseCase/CreateValetToken/CreateValetToken.spec.ts b/packages/auth/src/Domain/UseCase/CreateValetToken/CreateValetToken.spec.ts new file mode 100644 index 000000000..3780658c5 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/CreateValetToken/CreateValetToken.spec.ts @@ -0,0 +1,288 @@ +import 'reflect-metadata' + +import { TokenEncoderInterface, ValetTokenData } from '@standardnotes/auth' +import { CreateValetToken } from './CreateValetToken' +import { TimerInterface } from '@standardnotes/time' +import { UserSubscription } from '../../Subscription/UserSubscription' +import { SubscriptionSettingServiceInterface } from '../../Setting/SubscriptionSettingServiceInterface' +import { User } from '../../User/User' +import { UserSubscriptionType } from '../../Subscription/UserSubscriptionType' +import { SubscriptionSettingsAssociationServiceInterface } from '../../Setting/SubscriptionSettingsAssociationServiceInterface' +import { UserSubscriptionServiceInterface } from '../../Subscription/UserSubscriptionServiceInterface' + +describe('CreateValetToken', () => { + let tokenEncoder: TokenEncoderInterface + let subscriptionSettingService: SubscriptionSettingServiceInterface + let subscriptionSettingsAssociationService: SubscriptionSettingsAssociationServiceInterface + let userSubscriptionService: UserSubscriptionServiceInterface + let timer: TimerInterface + const valetTokenTTL = 123 + let regularSubscription: UserSubscription + let sharedSubscription: UserSubscription + let user: User + + const createUseCase = () => + new CreateValetToken( + tokenEncoder, + subscriptionSettingService, + subscriptionSettingsAssociationService, + userSubscriptionService, + timer, + valetTokenTTL, + ) + + beforeEach(() => { + tokenEncoder = {} as jest.Mocked> + tokenEncoder.encodeExpirableToken = jest.fn().mockReturnValue('foobar') + + subscriptionSettingService = {} as jest.Mocked + subscriptionSettingService.findSubscriptionSettingWithDecryptedValue = jest.fn().mockReturnValue({ + value: '123', + }) + + subscriptionSettingsAssociationService = {} as jest.Mocked + subscriptionSettingsAssociationService.getFileUploadLimit = jest.fn().mockReturnValue(5_368_709_120) + + user = { + uuid: '123', + } as jest.Mocked + + regularSubscription = { + uuid: '1-2-3', + subscriptionType: UserSubscriptionType.Regular, + user: Promise.resolve(user), + } as jest.Mocked + + sharedSubscription = { + uuid: '2-3-4', + subscriptionType: UserSubscriptionType.Shared, + user: Promise.resolve(user), + } as jest.Mocked + + userSubscriptionService = {} as jest.Mocked + userSubscriptionService.findRegularSubscriptionForUserUuid = jest + .fn() + .mockReturnValue({ regularSubscription, sharedSubscription: null }) + + timer = {} as jest.Mocked + timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(100) + }) + + it('should create a read valet token', async () => { + const response = await createUseCase().execute({ + operation: 'read', + userUuid: '1-2-3', + resources: [ + { + remoteIdentifier: '1-2-3/2-3-4', + unencryptedFileSize: 123, + }, + ], + }) + + expect(response).toEqual({ + success: true, + valetToken: 'foobar', + }) + }) + + it('should not create a valet token if a user has no subscription', async () => { + userSubscriptionService.findRegularSubscriptionForUserUuid = jest + .fn() + .mockReturnValue({ regularSubscription: null, sharedSubscription: null }) + + const response = await createUseCase().execute({ + operation: 'read', + userUuid: '1-2-3', + resources: [ + { + remoteIdentifier: '1-2-3/2-3-4', + unencryptedFileSize: 123, + }, + ], + }) + + expect(response).toEqual({ + success: false, + reason: 'no-subscription', + }) + }) + + it('should not create a valet token if a user has an expired subscription', async () => { + regularSubscription.endsAt = 1 + userSubscriptionService.findRegularSubscriptionForUserUuid = jest + .fn() + .mockReturnValue({ regularSubscription, sharedSubscription: null }) + + timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(150) + + const response = await createUseCase().execute({ + operation: 'read', + userUuid: '1-2-3', + resources: [ + { + remoteIdentifier: '1-2-3/2-3-4', + unencryptedFileSize: 123, + }, + ], + }) + + expect(response).toEqual({ + success: false, + reason: 'expired-subscription', + }) + }) + + it('should not create a write valet token if unencrypted file size has not been provided for a resource', async () => { + const response = await createUseCase().execute({ + operation: 'write', + resources: [ + { + remoteIdentifier: '2-3-4', + }, + ], + userUuid: '1-2-3', + }) + + expect(response).toEqual({ + success: false, + reason: 'invalid-parameters', + }) + }) + + it('should create a write valet token', async () => { + const response = await createUseCase().execute({ + operation: 'write', + resources: [ + { + remoteIdentifier: '2-3-4', + unencryptedFileSize: 123, + }, + ], + userUuid: '1-2-3', + }) + + expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith( + { + sharedSubscriptionUuid: undefined, + regularSubscriptionUuid: '1-2-3', + permittedOperation: 'write', + permittedResources: [ + { + remoteIdentifier: '2-3-4', + unencryptedFileSize: 123, + }, + ], + userUuid: '1-2-3', + uploadBytesUsed: 123, + uploadBytesLimit: 123, + }, + 123, + ) + + expect(response).toEqual({ + success: true, + valetToken: 'foobar', + }) + }) + + it('should create a write valet token for shared subscription', async () => { + userSubscriptionService.findRegularSubscriptionForUserUuid = jest + .fn() + .mockReturnValue({ regularSubscription, sharedSubscription }) + + const response = await createUseCase().execute({ + operation: 'write', + resources: [ + { + remoteIdentifier: '2-3-4', + unencryptedFileSize: 123, + }, + ], + userUuid: '1-2-3', + }) + + expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith( + { + sharedSubscriptionUuid: '2-3-4', + regularSubscriptionUuid: '1-2-3', + permittedOperation: 'write', + permittedResources: [ + { + remoteIdentifier: '2-3-4', + unencryptedFileSize: 123, + }, + ], + userUuid: '1-2-3', + uploadBytesUsed: 123, + uploadBytesLimit: 123, + }, + 123, + ) + + expect(response).toEqual({ + success: true, + valetToken: 'foobar', + }) + }) + + it('should not create a write valet token for shared subscription if regular subscription could not be found', async () => { + userSubscriptionService.findRegularSubscriptionForUserUuid = jest + .fn() + .mockReturnValue({ regularSubscription: null, sharedSubscription }) + + const response = await createUseCase().execute({ + operation: 'write', + resources: [ + { + remoteIdentifier: '2-3-4', + unencryptedFileSize: 123, + }, + ], + userUuid: '1-2-3', + }) + + expect(response).toEqual({ + success: false, + reason: 'no-subscription', + }) + }) + + it('should create a write valet token with default subscription upload limit if upload bytes settings do not exist', async () => { + subscriptionSettingService.findSubscriptionSettingWithDecryptedValue = jest.fn().mockReturnValue(null) + + const response = await createUseCase().execute({ + operation: 'write', + userUuid: '1-2-3', + resources: [ + { + remoteIdentifier: '2-3-4', + unencryptedFileSize: 123, + }, + ], + }) + + expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith( + { + sharedSubscriptionUuid: undefined, + regularSubscriptionUuid: '1-2-3', + permittedOperation: 'write', + permittedResources: [ + { + remoteIdentifier: '2-3-4', + unencryptedFileSize: 123, + }, + ], + userUuid: '1-2-3', + uploadBytesUsed: 0, + uploadBytesLimit: 5368709120, + }, + 123, + ) + + expect(response).toEqual({ + success: true, + valetToken: 'foobar', + }) + }) +}) diff --git a/packages/auth/src/Domain/UseCase/CreateValetToken/CreateValetToken.ts b/packages/auth/src/Domain/UseCase/CreateValetToken/CreateValetToken.ts new file mode 100644 index 000000000..d94a2d63a --- /dev/null +++ b/packages/auth/src/Domain/UseCase/CreateValetToken/CreateValetToken.ts @@ -0,0 +1,110 @@ +import { inject, injectable } from 'inversify' +import { SubscriptionName } from '@standardnotes/common' +import { TimerInterface } from '@standardnotes/time' +import { TokenEncoderInterface, ValetTokenData } from '@standardnotes/auth' +import { CreateValetTokenPayload, CreateValetTokenResponseData } from '@standardnotes/responses' +import { SubscriptionSettingName } from '@standardnotes/settings' + +import TYPES from '../../../Bootstrap/Types' +import { UseCaseInterface } from '../UseCaseInterface' +import { SubscriptionSettingServiceInterface } from '../../Setting/SubscriptionSettingServiceInterface' + +import { CreateValetTokenDTO } from './CreateValetTokenDTO' +import { SubscriptionSettingsAssociationServiceInterface } from '../../Setting/SubscriptionSettingsAssociationServiceInterface' +import { UserSubscriptionServiceInterface } from '../../Subscription/UserSubscriptionServiceInterface' + +@injectable() +export class CreateValetToken implements UseCaseInterface { + constructor( + @inject(TYPES.ValetTokenEncoder) private tokenEncoder: TokenEncoderInterface, + @inject(TYPES.SubscriptionSettingService) private subscriptionSettingService: SubscriptionSettingServiceInterface, + @inject(TYPES.SubscriptionSettingsAssociationService) + private subscriptionSettingsAssociationService: SubscriptionSettingsAssociationServiceInterface, + @inject(TYPES.UserSubscriptionService) private userSubscriptionService: UserSubscriptionServiceInterface, + @inject(TYPES.Timer) private timer: TimerInterface, + @inject(TYPES.VALET_TOKEN_TTL) private valetTokenTTL: number, + ) {} + + async execute(dto: CreateValetTokenDTO): Promise { + const { userUuid, ...payload } = dto + const { regularSubscription, sharedSubscription } = + await this.userSubscriptionService.findRegularSubscriptionForUserUuid(userUuid) + if (regularSubscription === null) { + return { + success: false, + reason: 'no-subscription', + } + } + + if (regularSubscription.endsAt < this.timer.getTimestampInMicroseconds()) { + return { + success: false, + reason: 'expired-subscription', + } + } + + if (!this.isValidWritePayload(payload)) { + return { + success: false, + reason: 'invalid-parameters', + } + } + + const regularSubscriptionUserUuid = (await regularSubscription.user).uuid + + let uploadBytesUsed = 0 + const uploadBytesUsedSetting = await this.subscriptionSettingService.findSubscriptionSettingWithDecryptedValue({ + userUuid: regularSubscriptionUserUuid, + userSubscriptionUuid: regularSubscription.uuid, + subscriptionSettingName: SubscriptionSettingName.FileUploadBytesUsed, + }) + if (uploadBytesUsedSetting !== null) { + uploadBytesUsed = +(uploadBytesUsedSetting.value as string) + } + + const defaultUploadBytesLimitForSubscription = await this.subscriptionSettingsAssociationService.getFileUploadLimit( + regularSubscription.planName as SubscriptionName, + ) + let uploadBytesLimit = defaultUploadBytesLimitForSubscription + const overwriteWithUserUploadBytesLimitSetting = + await this.subscriptionSettingService.findSubscriptionSettingWithDecryptedValue({ + userUuid: regularSubscriptionUserUuid, + userSubscriptionUuid: regularSubscription.uuid, + subscriptionSettingName: SubscriptionSettingName.FileUploadBytesLimit, + }) + if (overwriteWithUserUploadBytesLimitSetting !== null) { + uploadBytesLimit = +(overwriteWithUserUploadBytesLimitSetting.value as string) + } + + let sharedSubscriptionUuid = undefined + if (sharedSubscription !== null) { + sharedSubscriptionUuid = sharedSubscription.uuid + } + + const tokenData: ValetTokenData = { + userUuid: dto.userUuid, + permittedOperation: dto.operation, + permittedResources: dto.resources, + uploadBytesUsed, + uploadBytesLimit, + sharedSubscriptionUuid, + regularSubscriptionUuid: regularSubscription.uuid, + } + + const valetToken = this.tokenEncoder.encodeExpirableToken(tokenData, this.valetTokenTTL) + + return { success: true, valetToken } + } + + private isValidWritePayload(payload: CreateValetTokenPayload) { + if (payload.operation === 'write') { + for (const resource of payload.resources) { + if (resource.unencryptedFileSize === undefined) { + return false + } + } + } + + return true + } +} diff --git a/packages/auth/src/Domain/UseCase/CreateValetToken/CreateValetTokenDTO.ts b/packages/auth/src/Domain/UseCase/CreateValetToken/CreateValetTokenDTO.ts new file mode 100644 index 000000000..10b64c865 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/CreateValetToken/CreateValetTokenDTO.ts @@ -0,0 +1,5 @@ +import { CreateValetTokenPayload } from '@standardnotes/responses' + +export type CreateValetTokenDTO = CreateValetTokenPayload & { + userUuid: string +} diff --git a/packages/auth/src/Domain/UseCase/DeclineSharedSubscriptionInvitation/DeclineSharedSubscriptionInvitation.spec.ts b/packages/auth/src/Domain/UseCase/DeclineSharedSubscriptionInvitation/DeclineSharedSubscriptionInvitation.spec.ts new file mode 100644 index 000000000..c7ef04e9d --- /dev/null +++ b/packages/auth/src/Domain/UseCase/DeclineSharedSubscriptionInvitation/DeclineSharedSubscriptionInvitation.spec.ts @@ -0,0 +1,58 @@ +import 'reflect-metadata' + +import { TimerInterface } from '@standardnotes/time' + +import { SharedSubscriptionInvitation } from '../../SharedSubscription/SharedSubscriptionInvitation' +import { SharedSubscriptionInvitationRepositoryInterface } from '../../SharedSubscription/SharedSubscriptionInvitationRepositoryInterface' + +import { DeclineSharedSubscriptionInvitation } from './DeclineSharedSubscriptionInvitation' + +describe('DeclineSharedSubscriptionInvitation', () => { + let sharedSubscriptionInvitationRepository: SharedSubscriptionInvitationRepositoryInterface + let timer: TimerInterface + let invitation: SharedSubscriptionInvitation + + const createUseCase = () => new DeclineSharedSubscriptionInvitation(sharedSubscriptionInvitationRepository, timer) + + beforeEach(() => { + invitation = { + subscriptionId: 3, + } as jest.Mocked + + sharedSubscriptionInvitationRepository = {} as jest.Mocked + sharedSubscriptionInvitationRepository.findOneByUuidAndStatus = jest.fn().mockReturnValue(invitation) + sharedSubscriptionInvitationRepository.save = jest.fn() + + timer = {} as jest.Mocked + timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(1) + }) + + it('should decline the invitation', async () => { + expect( + await createUseCase().execute({ + sharedSubscriptionInvitationUuid: '1-2-3', + }), + ).toEqual({ + success: true, + }) + + expect(sharedSubscriptionInvitationRepository.save).toHaveBeenCalledWith({ + status: 'declined', + subscriptionId: 3, + updatedAt: 1, + }) + }) + + it('should not decline the invitation if it does not exist', async () => { + sharedSubscriptionInvitationRepository.findOneByUuidAndStatus = jest.fn().mockReturnValue(null) + expect( + await createUseCase().execute({ + sharedSubscriptionInvitationUuid: '1-2-3', + }), + ).toEqual({ + success: false, + }) + + expect(sharedSubscriptionInvitationRepository.save).not.toHaveBeenCalled() + }) +}) diff --git a/packages/auth/src/Domain/UseCase/DeclineSharedSubscriptionInvitation/DeclineSharedSubscriptionInvitation.ts b/packages/auth/src/Domain/UseCase/DeclineSharedSubscriptionInvitation/DeclineSharedSubscriptionInvitation.ts new file mode 100644 index 000000000..fe734b390 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/DeclineSharedSubscriptionInvitation/DeclineSharedSubscriptionInvitation.ts @@ -0,0 +1,40 @@ +import { TimerInterface } from '@standardnotes/time' +import { inject, injectable } from 'inversify' + +import TYPES from '../../../Bootstrap/Types' +import { InvitationStatus } from '../../SharedSubscription/InvitationStatus' +import { SharedSubscriptionInvitationRepositoryInterface } from '../../SharedSubscription/SharedSubscriptionInvitationRepositoryInterface' +import { UseCaseInterface } from '../UseCaseInterface' + +import { DeclineSharedSubscriptionInvitationDTO } from './DeclineSharedSubscriptionInvitationDTO' +import { DeclineSharedSubscriptionInvitationResponse } from './DeclineSharedSubscriptionInvitationResponse' + +@injectable() +export class DeclineSharedSubscriptionInvitation implements UseCaseInterface { + constructor( + @inject(TYPES.SharedSubscriptionInvitationRepository) + private sharedSubscriptionInvitationRepository: SharedSubscriptionInvitationRepositoryInterface, + @inject(TYPES.Timer) private timer: TimerInterface, + ) {} + + async execute(dto: DeclineSharedSubscriptionInvitationDTO): Promise { + const sharedSubscriptionInvitation = await this.sharedSubscriptionInvitationRepository.findOneByUuidAndStatus( + dto.sharedSubscriptionInvitationUuid, + InvitationStatus.Sent, + ) + if (sharedSubscriptionInvitation === null) { + return { + success: false, + } + } + + sharedSubscriptionInvitation.status = InvitationStatus.Declined + sharedSubscriptionInvitation.updatedAt = this.timer.getTimestampInMicroseconds() + + await this.sharedSubscriptionInvitationRepository.save(sharedSubscriptionInvitation) + + return { + success: true, + } + } +} diff --git a/packages/auth/src/Domain/UseCase/DeclineSharedSubscriptionInvitation/DeclineSharedSubscriptionInvitationDTO.ts b/packages/auth/src/Domain/UseCase/DeclineSharedSubscriptionInvitation/DeclineSharedSubscriptionInvitationDTO.ts new file mode 100644 index 000000000..96ff70359 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/DeclineSharedSubscriptionInvitation/DeclineSharedSubscriptionInvitationDTO.ts @@ -0,0 +1,3 @@ +export type DeclineSharedSubscriptionInvitationDTO = { + sharedSubscriptionInvitationUuid: string +} diff --git a/packages/auth/src/Domain/UseCase/DeclineSharedSubscriptionInvitation/DeclineSharedSubscriptionInvitationResponse.ts b/packages/auth/src/Domain/UseCase/DeclineSharedSubscriptionInvitation/DeclineSharedSubscriptionInvitationResponse.ts new file mode 100644 index 000000000..83f6eae4d --- /dev/null +++ b/packages/auth/src/Domain/UseCase/DeclineSharedSubscriptionInvitation/DeclineSharedSubscriptionInvitationResponse.ts @@ -0,0 +1,3 @@ +export type DeclineSharedSubscriptionInvitationResponse = { + success: boolean +} diff --git a/packages/auth/src/Domain/UseCase/DeleteAccount/DeleteAccount.spec.ts b/packages/auth/src/Domain/UseCase/DeleteAccount/DeleteAccount.spec.ts new file mode 100644 index 000000000..faef6389e --- /dev/null +++ b/packages/auth/src/Domain/UseCase/DeleteAccount/DeleteAccount.spec.ts @@ -0,0 +1,99 @@ +import 'reflect-metadata' + +import { AccountDeletionRequestedEvent, DomainEventPublisherInterface } from '@standardnotes/domain-events' +import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface' +import { User } from '../../User/User' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' +import { DeleteAccount } from './DeleteAccount' +import { UserSubscription } from '../../Subscription/UserSubscription' +import { UserSubscriptionType } from '../../Subscription/UserSubscriptionType' +import { UserSubscriptionServiceInterface } from '../../Subscription/UserSubscriptionServiceInterface' + +describe('DeleteAccount', () => { + let userRepository: UserRepositoryInterface + let domainEventPublisher: DomainEventPublisherInterface + let domainEventFactory: DomainEventFactoryInterface + let userSubscriptionService: UserSubscriptionServiceInterface + let user: User + let regularSubscription: UserSubscription + + const createUseCase = () => + new DeleteAccount(userRepository, userSubscriptionService, domainEventPublisher, domainEventFactory) + + beforeEach(() => { + user = { + uuid: '1-2-3', + } as jest.Mocked + + regularSubscription = { + uuid: '1-2-3', + subscriptionType: UserSubscriptionType.Regular, + user: Promise.resolve(user), + } as jest.Mocked + + userRepository = {} as jest.Mocked + userRepository.findOneByEmail = jest.fn().mockReturnValue(user) + + userSubscriptionService = {} as jest.Mocked + userSubscriptionService.findRegularSubscriptionForUserUuid = jest + .fn() + .mockReturnValue({ regularSubscription, sharedSubscription: null }) + + domainEventPublisher = {} as jest.Mocked + domainEventPublisher.publish = jest.fn() + + domainEventFactory = {} as jest.Mocked + domainEventFactory.createAccountDeletionRequestedEvent = jest + .fn() + .mockReturnValue({} as jest.Mocked) + }) + + it('should trigger account deletion - no subscription', async () => { + userSubscriptionService.findRegularSubscriptionForUserUuid = jest + .fn() + .mockReturnValue({ regularSubscription: null, sharedSubscription: null }) + + expect(await createUseCase().execute({ email: 'test@test.te' })).toEqual({ + message: 'Successfully deleted user', + responseCode: 200, + success: true, + }) + + expect(domainEventPublisher.publish).toHaveBeenCalledTimes(1) + expect(domainEventFactory.createAccountDeletionRequestedEvent).toHaveBeenLastCalledWith({ + userUuid: '1-2-3', + regularSubscriptionUuid: undefined, + }) + }) + + it('should trigger account deletion - subscription present', async () => { + userSubscriptionService.findRegularSubscriptionForUserUuid = jest + .fn() + .mockReturnValue({ regularSubscription, sharedSubscription: null }) + + expect(await createUseCase().execute({ email: 'test@test.te' })).toEqual({ + message: 'Successfully deleted user', + responseCode: 200, + success: true, + }) + + expect(domainEventPublisher.publish).toHaveBeenCalledTimes(1) + expect(domainEventFactory.createAccountDeletionRequestedEvent).toHaveBeenLastCalledWith({ + userUuid: '1-2-3', + regularSubscriptionUuid: '1-2-3', + }) + }) + + it('should not trigger account deletion if user is not found', async () => { + userRepository.findOneByEmail = jest.fn().mockReturnValue(null) + + expect(await createUseCase().execute({ email: 'test@test.te' })).toEqual({ + message: 'User not found', + responseCode: 404, + success: false, + }) + + expect(domainEventPublisher.publish).not.toHaveBeenCalled() + expect(domainEventFactory.createAccountDeletionRequestedEvent).not.toHaveBeenCalled() + }) +}) diff --git a/packages/auth/src/Domain/UseCase/DeleteAccount/DeleteAccount.ts b/packages/auth/src/Domain/UseCase/DeleteAccount/DeleteAccount.ts new file mode 100644 index 000000000..24fafcb93 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/DeleteAccount/DeleteAccount.ts @@ -0,0 +1,50 @@ +import { DomainEventPublisherInterface } from '@standardnotes/domain-events' +import { inject, injectable } from 'inversify' +import TYPES from '../../../Bootstrap/Types' +import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface' +import { UserSubscriptionServiceInterface } from '../../Subscription/UserSubscriptionServiceInterface' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' +import { UseCaseInterface } from '../UseCaseInterface' +import { DeleteAccountDTO } from './DeleteAccountDTO' +import { DeleteAccountResponse } from './DeleteAccountResponse' + +@injectable() +export class DeleteAccount implements UseCaseInterface { + constructor( + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.UserSubscriptionService) private userSubscriptionService: UserSubscriptionServiceInterface, + @inject(TYPES.DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface, + @inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface, + ) {} + + async execute(dto: DeleteAccountDTO): Promise { + const user = await this.userRepository.findOneByEmail(dto.email) + + if (user === null) { + return { + success: false, + responseCode: 404, + message: 'User not found', + } + } + + let regularSubscriptionUuid = undefined + const { regularSubscription } = await this.userSubscriptionService.findRegularSubscriptionForUserUuid(user.uuid) + if (regularSubscription !== null) { + regularSubscriptionUuid = regularSubscription.uuid + } + + await this.domainEventPublisher.publish( + this.domainEventFactory.createAccountDeletionRequestedEvent({ + userUuid: user.uuid, + regularSubscriptionUuid, + }), + ) + + return { + success: true, + message: 'Successfully deleted user', + responseCode: 200, + } + } +} diff --git a/packages/auth/src/Domain/UseCase/DeleteAccount/DeleteAccountDTO.ts b/packages/auth/src/Domain/UseCase/DeleteAccount/DeleteAccountDTO.ts new file mode 100644 index 000000000..1f8abb54f --- /dev/null +++ b/packages/auth/src/Domain/UseCase/DeleteAccount/DeleteAccountDTO.ts @@ -0,0 +1,3 @@ +export type DeleteAccountDTO = { + email: string +} diff --git a/packages/auth/src/Domain/UseCase/DeleteAccount/DeleteAccountResponse.ts b/packages/auth/src/Domain/UseCase/DeleteAccount/DeleteAccountResponse.ts new file mode 100644 index 000000000..069300017 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/DeleteAccount/DeleteAccountResponse.ts @@ -0,0 +1,5 @@ +export type DeleteAccountResponse = { + success: boolean + responseCode: number + message: string +} diff --git a/packages/auth/src/Domain/UseCase/DeletePreviousSessionsForUser.spec.ts b/packages/auth/src/Domain/UseCase/DeletePreviousSessionsForUser.spec.ts new file mode 100644 index 000000000..d5259ba8f --- /dev/null +++ b/packages/auth/src/Domain/UseCase/DeletePreviousSessionsForUser.spec.ts @@ -0,0 +1,39 @@ +import 'reflect-metadata' +import { Session } from '../Session/Session' +import { SessionRepositoryInterface } from '../Session/SessionRepositoryInterface' +import { SessionServiceInterface } from '../Session/SessionServiceInterface' + +import { DeletePreviousSessionsForUser } from './DeletePreviousSessionsForUser' + +describe('DeletePreviousSessionsForUser', () => { + let sessionRepository: SessionRepositoryInterface + let sessionService: SessionServiceInterface + let session: Session + let currentSession: Session + + const createUseCase = () => new DeletePreviousSessionsForUser(sessionRepository, sessionService) + + beforeEach(() => { + session = {} as jest.Mocked + session.uuid = '1-2-3' + + currentSession = {} as jest.Mocked + currentSession.uuid = '2-3-4' + + sessionRepository = {} as jest.Mocked + sessionRepository.deleteAllByUserUuid = jest.fn() + sessionRepository.findAllByUserUuid = jest.fn().mockReturnValue([session, currentSession]) + + sessionService = {} as jest.Mocked + sessionService.createRevokedSession = jest.fn() + }) + + it('should delete all sessions except current for a given user', async () => { + expect(await createUseCase().execute({ userUuid: '1-2-3', currentSessionUuid: '2-3-4' })).toEqual({ success: true }) + + expect(sessionRepository.deleteAllByUserUuid).toHaveBeenCalledWith('1-2-3', '2-3-4') + + expect(sessionService.createRevokedSession).toHaveBeenCalledWith(session) + expect(sessionService.createRevokedSession).not.toHaveBeenCalledWith(currentSession) + }) +}) diff --git a/packages/auth/src/Domain/UseCase/DeletePreviousSessionsForUser.ts b/packages/auth/src/Domain/UseCase/DeletePreviousSessionsForUser.ts new file mode 100644 index 000000000..7fc3201ba --- /dev/null +++ b/packages/auth/src/Domain/UseCase/DeletePreviousSessionsForUser.ts @@ -0,0 +1,32 @@ +import { inject, injectable } from 'inversify' +import TYPES from '../../Bootstrap/Types' +import { Session } from '../Session/Session' +import { SessionRepositoryInterface } from '../Session/SessionRepositoryInterface' +import { SessionServiceInterface } from '../Session/SessionServiceInterface' +import { DeletePreviousSessionsForUserDTO } from './DeletePreviousSessionsForUserDTO' +import { DeletePreviousSessionsForUserResponse } from './DeletePreviousSessionsForUserResponse' +import { UseCaseInterface } from './UseCaseInterface' + +@injectable() +export class DeletePreviousSessionsForUser implements UseCaseInterface { + constructor( + @inject(TYPES.SessionRepository) private sessionRepository: SessionRepositoryInterface, + @inject(TYPES.SessionService) private sessionService: SessionServiceInterface, + ) {} + + async execute(dto: DeletePreviousSessionsForUserDTO): Promise { + const sessions = await this.sessionRepository.findAllByUserUuid(dto.userUuid) + + await Promise.all( + sessions.map(async (session: Session) => { + if (session.uuid !== dto.currentSessionUuid) { + await this.sessionService.createRevokedSession(session) + } + }), + ) + + await this.sessionRepository.deleteAllByUserUuid(dto.userUuid, dto.currentSessionUuid) + + return { success: true } + } +} diff --git a/packages/auth/src/Domain/UseCase/DeletePreviousSessionsForUserDTO.ts b/packages/auth/src/Domain/UseCase/DeletePreviousSessionsForUserDTO.ts new file mode 100644 index 000000000..70ef3e05b --- /dev/null +++ b/packages/auth/src/Domain/UseCase/DeletePreviousSessionsForUserDTO.ts @@ -0,0 +1,4 @@ +export type DeletePreviousSessionsForUserDTO = { + userUuid: string + currentSessionUuid: string +} diff --git a/packages/auth/src/Domain/UseCase/DeletePreviousSessionsForUserResponse.ts b/packages/auth/src/Domain/UseCase/DeletePreviousSessionsForUserResponse.ts new file mode 100644 index 000000000..c4973d415 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/DeletePreviousSessionsForUserResponse.ts @@ -0,0 +1,3 @@ +export type DeletePreviousSessionsForUserResponse = { + success: boolean +} diff --git a/packages/auth/src/Domain/UseCase/DeleteSessionForUser.spec.ts b/packages/auth/src/Domain/UseCase/DeleteSessionForUser.spec.ts new file mode 100644 index 000000000..b89141af2 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/DeleteSessionForUser.spec.ts @@ -0,0 +1,72 @@ +import 'reflect-metadata' +import { EphemeralSession } from '../Session/EphemeralSession' +import { EphemeralSessionRepositoryInterface } from '../Session/EphemeralSessionRepositoryInterface' + +import { Session } from '../Session/Session' +import { SessionRepositoryInterface } from '../Session/SessionRepositoryInterface' +import { SessionServiceInterface } from '../Session/SessionServiceInterface' + +import { DeleteSessionForUser } from './DeleteSessionForUser' + +describe('DeleteSessionForUser', () => { + let sessionRepository: SessionRepositoryInterface + let ephemeralSessionRepository: EphemeralSessionRepositoryInterface + let sessionService: SessionServiceInterface + let session: Session + let ephemeralSession: EphemeralSession + + const createUseCase = () => new DeleteSessionForUser(sessionRepository, ephemeralSessionRepository, sessionService) + + beforeEach(() => { + session = {} as jest.Mocked + session.uuid = '2-3-4' + session.userUuid = '1-2-3' + + ephemeralSession = {} as jest.Mocked + ephemeralSession.uuid = '2-3-4' + ephemeralSession.userUuid = '1-2-3' + + sessionRepository = {} as jest.Mocked + sessionRepository.deleteOneByUuid = jest.fn() + sessionRepository.findOneByUuidAndUserUuid = jest.fn().mockReturnValue(session) + + ephemeralSessionRepository = {} as jest.Mocked + ephemeralSessionRepository.deleteOne = jest.fn() + ephemeralSessionRepository.findOneByUuidAndUserUuid = jest.fn().mockReturnValue(session) + + sessionService = {} as jest.Mocked + sessionService.createRevokedSession = jest.fn() + }) + + it('should delete a session for a given user', async () => { + expect(await createUseCase().execute({ userUuid: '1-2-3', sessionUuid: '2-3-4' })).toEqual({ success: true }) + + expect(sessionRepository.deleteOneByUuid).toHaveBeenCalledWith('2-3-4') + expect(ephemeralSessionRepository.deleteOne).toHaveBeenCalledWith('2-3-4', '1-2-3') + expect(sessionService.createRevokedSession).toHaveBeenCalledWith(session) + }) + + it('should delete an ephemeral session for a given user', async () => { + sessionRepository.findOneByUuidAndUserUuid = jest.fn().mockReturnValue(null) + + expect(await createUseCase().execute({ userUuid: '1-2-3', sessionUuid: '2-3-4' })).toEqual({ success: true }) + + expect(sessionRepository.deleteOneByUuid).toHaveBeenCalledWith('2-3-4') + expect(ephemeralSessionRepository.deleteOne).toHaveBeenCalledWith('2-3-4', '1-2-3') + expect(sessionService.createRevokedSession).toHaveBeenCalledWith(session) + }) + + it('should not delete a session if it does not exist for a given user', async () => { + sessionRepository.findOneByUuidAndUserUuid = jest.fn().mockReturnValue(null) + ephemeralSessionRepository.findOneByUuidAndUserUuid = jest.fn().mockReturnValue(null) + + expect(await createUseCase().execute({ userUuid: '1-2-3', sessionUuid: '2-3-4' })).toEqual({ + success: false, + errorMessage: 'No session exists with the provided identifier.', + }) + + expect(sessionRepository.deleteOneByUuid).not.toHaveBeenCalled() + expect(ephemeralSessionRepository.deleteOne).not.toHaveBeenCalled() + expect(sessionService.createRevokedSession).not.toHaveBeenCalled() + }) +}) diff --git a/packages/auth/src/Domain/UseCase/DeleteSessionForUser.ts b/packages/auth/src/Domain/UseCase/DeleteSessionForUser.ts new file mode 100644 index 000000000..f568999bc --- /dev/null +++ b/packages/auth/src/Domain/UseCase/DeleteSessionForUser.ts @@ -0,0 +1,43 @@ +import { inject, injectable } from 'inversify' +import TYPES from '../../Bootstrap/Types' +import { EphemeralSession } from '../Session/EphemeralSession' +import { EphemeralSessionRepositoryInterface } from '../Session/EphemeralSessionRepositoryInterface' +import { Session } from '../Session/Session' +import { SessionRepositoryInterface } from '../Session/SessionRepositoryInterface' +import { SessionServiceInterface } from '../Session/SessionServiceInterface' +import { DeleteSessionForUserDTO } from './DeleteSessionForUserDTO' +import { DeleteSessionForUserResponse } from './DeleteSessionForUserResponse' +import { UseCaseInterface } from './UseCaseInterface' + +@injectable() +export class DeleteSessionForUser implements UseCaseInterface { + constructor( + @inject(TYPES.SessionRepository) private sessionRepository: SessionRepositoryInterface, + @inject(TYPES.EphemeralSessionRepository) private ephemeralSessionRepository: EphemeralSessionRepositoryInterface, + @inject(TYPES.SessionService) private sessionService: SessionServiceInterface, + ) {} + + async execute(dto: DeleteSessionForUserDTO): Promise { + let session: Session | EphemeralSession | null + + session = await this.sessionRepository.findOneByUuidAndUserUuid(dto.sessionUuid, dto.userUuid) + if (session === null) { + session = await this.ephemeralSessionRepository.findOneByUuidAndUserUuid(dto.sessionUuid, dto.userUuid) + + if (session === null) { + return { + success: false, + errorMessage: 'No session exists with the provided identifier.', + } + } + } + + await this.sessionService.createRevokedSession(session) + + await this.sessionRepository.deleteOneByUuid(dto.sessionUuid) + + await this.ephemeralSessionRepository.deleteOne(dto.sessionUuid, dto.userUuid) + + return { success: true } + } +} diff --git a/packages/auth/src/Domain/UseCase/DeleteSessionForUserDTO.ts b/packages/auth/src/Domain/UseCase/DeleteSessionForUserDTO.ts new file mode 100644 index 000000000..3a4a41bf0 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/DeleteSessionForUserDTO.ts @@ -0,0 +1,4 @@ +export type DeleteSessionForUserDTO = { + sessionUuid: string + userUuid: string +} diff --git a/packages/auth/src/Domain/UseCase/DeleteSessionForUserResponse.ts b/packages/auth/src/Domain/UseCase/DeleteSessionForUserResponse.ts new file mode 100644 index 000000000..36e0a64fb --- /dev/null +++ b/packages/auth/src/Domain/UseCase/DeleteSessionForUserResponse.ts @@ -0,0 +1,4 @@ +export type DeleteSessionForUserResponse = { + success: boolean + errorMessage?: string +} diff --git a/packages/auth/src/Domain/UseCase/DeleteSetting/DeleteSetting.spec.ts b/packages/auth/src/Domain/UseCase/DeleteSetting/DeleteSetting.spec.ts new file mode 100644 index 000000000..81865ebc5 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/DeleteSetting/DeleteSetting.spec.ts @@ -0,0 +1,98 @@ +import 'reflect-metadata' + +import { TimerInterface } from '@standardnotes/time' + +import { Setting } from '../../Setting/Setting' +import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface' + +import { DeleteSetting } from './DeleteSetting' + +describe('DeleteSetting', () => { + let setting: Setting + let settingRepository: SettingRepositoryInterface + let timer: TimerInterface + + const createUseCase = () => new DeleteSetting(settingRepository, timer) + + beforeEach(() => { + setting = {} as jest.Mocked + + settingRepository = {} as jest.Mocked + settingRepository.findLastByNameAndUserUuid = jest.fn().mockReturnValue(setting) + settingRepository.findOneByUuid = jest.fn().mockReturnValue(setting) + settingRepository.deleteByUserUuid = jest.fn() + settingRepository.save = jest.fn() + + timer = {} as jest.Mocked + timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(1) + }) + + it('should delete a setting by name and user uuid', async () => { + await createUseCase().execute({ + settingName: 'test', + userUuid: '1-2-3', + }) + + expect(settingRepository.deleteByUserUuid).toHaveBeenCalledWith({ settingName: 'test', userUuid: '1-2-3' }) + }) + + it('should delete a setting by uuid', async () => { + await createUseCase().execute({ + settingName: 'test', + userUuid: '1-2-3', + uuid: '3-4-5', + }) + + expect(settingRepository.deleteByUserUuid).toHaveBeenCalledWith({ settingName: 'test', userUuid: '1-2-3' }) + }) + + it('should not delete a setting by name and user uuid if not found', async () => { + settingRepository.findLastByNameAndUserUuid = jest.fn().mockReturnValue(null) + + await createUseCase().execute({ + settingName: 'test', + userUuid: '1-2-3', + }) + + expect(settingRepository.deleteByUserUuid).not.toHaveBeenCalled() + }) + + it('should not delete a setting by uuid if not found', async () => { + settingRepository.findOneByUuid = jest.fn().mockReturnValue(null) + + await createUseCase().execute({ + settingName: 'test', + userUuid: '1-2-3', + uuid: '2-3-4', + }) + + expect(settingRepository.deleteByUserUuid).not.toHaveBeenCalled() + }) + + it('should soft delete a setting by name and user uuid', async () => { + await createUseCase().execute({ + settingName: 'test', + userUuid: '1-2-3', + softDelete: true, + }) + + expect(settingRepository.save).toHaveBeenCalledWith({ + updatedAt: 1, + value: null, + }) + }) + + it('should soft delete a setting with timestamp', async () => { + await createUseCase().execute({ + settingName: 'test', + userUuid: '1-2-3', + softDelete: true, + timestamp: 123, + }) + + expect(settingRepository.save).toHaveBeenCalledWith({ + updatedAt: 123, + value: null, + }) + }) +}) diff --git a/packages/auth/src/Domain/UseCase/DeleteSetting/DeleteSetting.ts b/packages/auth/src/Domain/UseCase/DeleteSetting/DeleteSetting.ts new file mode 100644 index 000000000..80770e25e --- /dev/null +++ b/packages/auth/src/Domain/UseCase/DeleteSetting/DeleteSetting.ts @@ -0,0 +1,57 @@ +import { inject, injectable } from 'inversify' +import { DeleteSettingDto } from './DeleteSettingDto' +import { DeleteSettingResponse } from './DeleteSettingResponse' +import { UseCaseInterface } from '../UseCaseInterface' +import TYPES from '../../../Bootstrap/Types' +import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface' +import { TimerInterface } from '@standardnotes/time' +import { Setting } from '../../Setting/Setting' + +@injectable() +export class DeleteSetting implements UseCaseInterface { + constructor( + @inject(TYPES.SettingRepository) private settingRepository: SettingRepositoryInterface, + @inject(TYPES.Timer) private timer: TimerInterface, + ) {} + + async execute(dto: DeleteSettingDto): Promise { + const { userUuid, settingName } = dto + + const setting = await this.getSetting(dto) + + if (setting === null) { + return { + success: false, + error: { + message: `Setting ${settingName} for user ${userUuid} not found.`, + }, + } + } + + if (dto.softDelete) { + setting.value = null + setting.updatedAt = dto.timestamp ?? this.timer.getTimestampInMicroseconds() + + await this.settingRepository.save(setting) + } else { + await this.settingRepository.deleteByUserUuid({ + userUuid, + settingName, + }) + } + + return { + success: true, + settingName, + userUuid, + } + } + + private async getSetting(dto: DeleteSettingDto): Promise { + if (dto.uuid !== undefined) { + return this.settingRepository.findOneByUuid(dto.uuid) + } + + return this.settingRepository.findLastByNameAndUserUuid(dto.settingName, dto.userUuid) + } +} diff --git a/packages/auth/src/Domain/UseCase/DeleteSetting/DeleteSettingDto.ts b/packages/auth/src/Domain/UseCase/DeleteSetting/DeleteSettingDto.ts new file mode 100644 index 000000000..46a260a16 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/DeleteSetting/DeleteSettingDto.ts @@ -0,0 +1,9 @@ +import { Uuid } from '@standardnotes/common' + +export type DeleteSettingDto = { + userUuid: Uuid + settingName: string + uuid?: string + timestamp?: number + softDelete?: boolean +} diff --git a/packages/auth/src/Domain/UseCase/DeleteSetting/DeleteSettingResponse.ts b/packages/auth/src/Domain/UseCase/DeleteSetting/DeleteSettingResponse.ts new file mode 100644 index 000000000..57770eab0 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/DeleteSetting/DeleteSettingResponse.ts @@ -0,0 +1,14 @@ +import { Uuid } from '@standardnotes/common' + +export type DeleteSettingResponse = + | { + success: true + userUuid: Uuid + settingName: string + } + | { + success: false + error: { + message: string + } + } diff --git a/packages/auth/src/Domain/UseCase/GetActiveSessionsForUser.spec.ts b/packages/auth/src/Domain/UseCase/GetActiveSessionsForUser.spec.ts new file mode 100644 index 000000000..6619f903c --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetActiveSessionsForUser.spec.ts @@ -0,0 +1,68 @@ +import 'reflect-metadata' +import { EphemeralSession } from '../Session/EphemeralSession' +import { EphemeralSessionRepositoryInterface } from '../Session/EphemeralSessionRepositoryInterface' +import { Session } from '../Session/Session' +import { SessionRepositoryInterface } from '../Session/SessionRepositoryInterface' + +import { GetActiveSessionsForUser } from './GetActiveSessionsForUser' + +describe('GetActiveSessionsForUser', () => { + let sessionRepository: SessionRepositoryInterface + let ephemeralSessionRepository: EphemeralSessionRepositoryInterface + let session1: Session + let session2: Session + let ephemeralSession1: EphemeralSession + let ephemeralSession2: EphemeralSession + + const createUseCase = () => new GetActiveSessionsForUser(sessionRepository, ephemeralSessionRepository) + + beforeEach(() => { + session1 = {} as jest.Mocked + session1.uuid = '1-2-3' + session1.refreshExpiration = new Date(1) + + session2 = {} as jest.Mocked + session2.uuid = '2-3-4' + session2.refreshExpiration = new Date(2) + + ephemeralSession1 = {} as jest.Mocked + ephemeralSession1.uuid = '3-4-5' + ephemeralSession1.refreshExpiration = new Date(3) + + ephemeralSession2 = {} as jest.Mocked + ephemeralSession2.uuid = '4-5-6' + ephemeralSession2.refreshExpiration = new Date(4) + + sessionRepository = {} as jest.Mocked + sessionRepository.findAllByRefreshExpirationAndUserUuid = jest.fn().mockReturnValue([session1, session2]) + + ephemeralSessionRepository = {} as jest.Mocked + ephemeralSessionRepository.findAllByUserUuid = jest.fn().mockReturnValue([ephemeralSession1, ephemeralSession2]) + }) + + it('should get all active sessions for a user', async () => { + expect(await createUseCase().execute({ userUuid: '1-2-3' })).toEqual({ + sessions: [ephemeralSession2, ephemeralSession1, session2, session1], + }) + + expect(sessionRepository.findAllByRefreshExpirationAndUserUuid).toHaveBeenCalledWith('1-2-3') + }) + + it('should get all active sessions for a user from stringified values', async () => { + const ephemeralSession3: Record = {} + ephemeralSession3.uuid = '3-4-5' + ephemeralSession3.refreshExpiration = '1970-01-01T00:00:00.003Z' + + const ephemeralSession4: Record = {} + ephemeralSession4.uuid = '4-5-6' + ephemeralSession4.refreshExpiration = '1970-01-01T00:00:00.004Z' + + ephemeralSessionRepository.findAllByUserUuid = jest.fn().mockReturnValue([ephemeralSession3, ephemeralSession4]) + + expect(await createUseCase().execute({ userUuid: '1-2-3' })).toEqual({ + sessions: [ephemeralSession4, ephemeralSession3, session2, session1], + }) + + expect(sessionRepository.findAllByRefreshExpirationAndUserUuid).toHaveBeenCalledWith('1-2-3') + }) +}) diff --git a/packages/auth/src/Domain/UseCase/GetActiveSessionsForUser.ts b/packages/auth/src/Domain/UseCase/GetActiveSessionsForUser.ts new file mode 100644 index 000000000..6986db68e --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetActiveSessionsForUser.ts @@ -0,0 +1,29 @@ +import { inject, injectable } from 'inversify' +import TYPES from '../../Bootstrap/Types' +import { EphemeralSessionRepositoryInterface } from '../Session/EphemeralSessionRepositoryInterface' +import { SessionRepositoryInterface } from '../Session/SessionRepositoryInterface' +import { GetActiveSessionsForUserDTO } from './GetActiveSessionsForUserDTO' +import { GetActiveSessionsForUserResponse } from './GetActiveSessionsForUserResponse' +import { UseCaseInterface } from './UseCaseInterface' + +@injectable() +export class GetActiveSessionsForUser implements UseCaseInterface { + constructor( + @inject(TYPES.SessionRepository) private sessionRepository: SessionRepositoryInterface, + @inject(TYPES.EphemeralSessionRepository) private ephemeralSessionRepository: EphemeralSessionRepositoryInterface, + ) {} + + async execute(dto: GetActiveSessionsForUserDTO): Promise { + const ephemeralSessions = await this.ephemeralSessionRepository.findAllByUserUuid(dto.userUuid) + const sessions = await this.sessionRepository.findAllByRefreshExpirationAndUserUuid(dto.userUuid) + + return { + sessions: sessions.concat(ephemeralSessions).sort((a, b) => { + const dateA = a.refreshExpiration instanceof Date ? a.refreshExpiration : new Date(a.refreshExpiration) + const dateB = b.refreshExpiration instanceof Date ? b.refreshExpiration : new Date(b.refreshExpiration) + + return dateB.getTime() - dateA.getTime() + }), + } + } +} diff --git a/packages/auth/src/Domain/UseCase/GetActiveSessionsForUserDTO.ts b/packages/auth/src/Domain/UseCase/GetActiveSessionsForUserDTO.ts new file mode 100644 index 000000000..9a3100b10 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetActiveSessionsForUserDTO.ts @@ -0,0 +1,3 @@ +export type GetActiveSessionsForUserDTO = { + userUuid: string +} diff --git a/packages/auth/src/Domain/UseCase/GetActiveSessionsForUserResponse.ts b/packages/auth/src/Domain/UseCase/GetActiveSessionsForUserResponse.ts new file mode 100644 index 000000000..264516ca4 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetActiveSessionsForUserResponse.ts @@ -0,0 +1,5 @@ +import { Session } from '../Session/Session' + +export type GetActiveSessionsForUserResponse = { + sessions: Array +} diff --git a/packages/auth/src/Domain/UseCase/GetSetting/GetSetting.spec.ts b/packages/auth/src/Domain/UseCase/GetSetting/GetSetting.spec.ts new file mode 100644 index 000000000..0d7ec9540 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetSetting/GetSetting.spec.ts @@ -0,0 +1,75 @@ +import { SettingName } from '@standardnotes/settings' +import 'reflect-metadata' +import { SettingProjector } from '../../../Projection/SettingProjector' +import { Setting } from '../../Setting/Setting' +import { SettingServiceInterface } from '../../Setting/SettingServiceInterface' + +import { GetSetting } from './GetSetting' + +describe('GetSetting', () => { + let settingProjector: SettingProjector + let setting: Setting + let settingService: SettingServiceInterface + + const createUseCase = () => new GetSetting(settingProjector, settingService) + + beforeEach(() => { + setting = {} as jest.Mocked + + settingService = {} as jest.Mocked + settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(setting) + + settingProjector = {} as jest.Mocked + settingProjector.projectSimple = jest.fn().mockReturnValue({ foo: 'bar' }) + }) + + it('should find a setting for user', async () => { + expect(await createUseCase().execute({ userUuid: '1-2-3', settingName: 'test' })).toEqual({ + success: true, + userUuid: '1-2-3', + setting: { foo: 'bar' }, + }) + }) + + it('should not get a setting for user if it does not exist', async () => { + settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(null) + + expect(await createUseCase().execute({ userUuid: '1-2-3', settingName: 'test' })).toEqual({ + success: false, + error: { + message: 'Setting test for user 1-2-3 not found!', + }, + }) + }) + + it('should not retrieve a sensitive setting for user', async () => { + setting = { + sensitive: true, + name: SettingName.MfaSecret, + } as jest.Mocked + + settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(setting) + + expect(await createUseCase().execute({ userUuid: '1-2-3', settingName: SettingName.MfaSecret })).toEqual({ + success: true, + sensitive: true, + }) + }) + + it('should retrieve a sensitive setting for user if explicitly told to', async () => { + setting = { + sensitive: true, + name: SettingName.MfaSecret, + } as jest.Mocked + + settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(setting) + + expect( + await createUseCase().execute({ userUuid: '1-2-3', settingName: 'MFA_SECRET', allowSensitiveRetrieval: true }), + ).toEqual({ + success: true, + userUuid: '1-2-3', + setting: { foo: 'bar' }, + }) + }) +}) diff --git a/packages/auth/src/Domain/UseCase/GetSetting/GetSetting.ts b/packages/auth/src/Domain/UseCase/GetSetting/GetSetting.ts new file mode 100644 index 000000000..e78041fec --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetSetting/GetSetting.ts @@ -0,0 +1,49 @@ +import { SettingName } from '@standardnotes/settings' +import { inject, injectable } from 'inversify' +import { GetSettingDto } from './GetSettingDto' +import { GetSettingResponse } from './GetSettingResponse' +import { UseCaseInterface } from '../UseCaseInterface' +import TYPES from '../../../Bootstrap/Types' +import { SettingProjector } from '../../../Projection/SettingProjector' +import { SettingServiceInterface } from '../../Setting/SettingServiceInterface' + +@injectable() +export class GetSetting implements UseCaseInterface { + constructor( + @inject(TYPES.SettingProjector) private settingProjector: SettingProjector, + @inject(TYPES.SettingService) private settingService: SettingServiceInterface, + ) {} + + async execute(dto: GetSettingDto): Promise { + const { userUuid, settingName } = dto + + const setting = await this.settingService.findSettingWithDecryptedValue({ + userUuid, + settingName: settingName as SettingName, + }) + + if (setting === null) { + return { + success: false, + error: { + message: `Setting ${settingName} for user ${userUuid} not found!`, + }, + } + } + + if (setting.sensitive && !dto.allowSensitiveRetrieval) { + return { + success: true, + sensitive: true, + } + } + + const simpleSetting = await this.settingProjector.projectSimple(setting) + + return { + success: true, + userUuid, + setting: simpleSetting, + } + } +} diff --git a/packages/auth/src/Domain/UseCase/GetSetting/GetSettingDto.ts b/packages/auth/src/Domain/UseCase/GetSetting/GetSettingDto.ts new file mode 100644 index 000000000..7c0acba8c --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetSetting/GetSettingDto.ts @@ -0,0 +1,7 @@ +import { Uuid } from '@standardnotes/common' + +export type GetSettingDto = { + userUuid: Uuid + settingName: string + allowSensitiveRetrieval?: boolean +} diff --git a/packages/auth/src/Domain/UseCase/GetSetting/GetSettingResponse.ts b/packages/auth/src/Domain/UseCase/GetSetting/GetSettingResponse.ts new file mode 100644 index 000000000..3e65381bb --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetSetting/GetSettingResponse.ts @@ -0,0 +1,20 @@ +import { Uuid } from '@standardnotes/common' + +import { SimpleSetting } from '../../Setting/SimpleSetting' + +export type GetSettingResponse = + | { + success: true + userUuid: Uuid + setting: SimpleSetting + } + | { + success: true + sensitive: true + } + | { + success: false + error: { + message: string + } + } diff --git a/packages/auth/src/Domain/UseCase/GetSettings/GetSettings.spec.ts b/packages/auth/src/Domain/UseCase/GetSettings/GetSettings.spec.ts new file mode 100644 index 000000000..60c92195e --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetSettings/GetSettings.spec.ts @@ -0,0 +1,133 @@ +import 'reflect-metadata' + +import { SettingName } from '@standardnotes/settings' + +import { SettingProjector } from '../../../Projection/SettingProjector' +import { Setting } from '../../Setting/Setting' +import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface' + +import { GetSettings } from './GetSettings' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' +import { User } from '../../User/User' +import { CrypterInterface } from '../../Encryption/CrypterInterface' +import { EncryptionVersion } from '../../Encryption/EncryptionVersion' + +describe('GetSettings', () => { + let settingRepository: SettingRepositoryInterface + let settingProjector: SettingProjector + let setting: Setting + let mfaSetting: Setting + let userRepository: UserRepositoryInterface + let user: User + let crypter: CrypterInterface + + const createUseCase = () => new GetSettings(settingRepository, settingProjector, userRepository, crypter) + + beforeEach(() => { + setting = { + name: 'test', + updatedAt: 345, + sensitive: false, + } as jest.Mocked + + mfaSetting = { + name: SettingName.MfaSecret, + updatedAt: 122, + sensitive: true, + } as jest.Mocked + + settingRepository = {} as jest.Mocked + settingRepository.findAllByUserUuid = jest.fn().mockReturnValue([setting, mfaSetting]) + + settingProjector = {} as jest.Mocked + settingProjector.projectManySimple = jest.fn().mockReturnValue([{ foo: 'bar' }]) + + user = {} as jest.Mocked + + userRepository = {} as jest.Mocked + userRepository.findOneByUuid = jest.fn().mockReturnValue(user) + + crypter = {} as jest.Mocked + crypter.decryptForUser = jest.fn().mockReturnValue('decrypted') + }) + + it('should fail if a user is not found', async () => { + userRepository.findOneByUuid = jest.fn().mockReturnValue(null) + + expect(await createUseCase().execute({ userUuid: '1-2-3' })).toEqual({ + success: false, + error: { + message: 'User 1-2-3 not found.', + }, + }) + }) + + it('should return all user settings except mfa', async () => { + expect(await createUseCase().execute({ userUuid: '1-2-3' })).toEqual({ + success: true, + userUuid: '1-2-3', + settings: [{ foo: 'bar' }], + }) + + expect(settingProjector.projectManySimple).toHaveBeenCalledWith([setting]) + }) + + it('should return all setting with decrypted values', async () => { + setting = { + name: 'test', + updatedAt: 345, + value: 'encrypted', + serverEncryptionVersion: EncryptionVersion.Default, + } as jest.Mocked + settingRepository.findAllByUserUuid = jest.fn().mockReturnValue([setting]) + + expect(await createUseCase().execute({ userUuid: '1-2-3' })).toEqual({ + success: true, + userUuid: '1-2-3', + settings: [{ foo: 'bar' }], + }) + + expect(settingProjector.projectManySimple).toHaveBeenCalledWith([ + { + name: 'test', + updatedAt: 345, + value: 'decrypted', + serverEncryptionVersion: 1, + }, + ]) + }) + + it('should return all user settings of certain name', async () => { + expect( + await createUseCase().execute({ userUuid: '1-2-3', settingName: 'test', allowSensitiveRetrieval: true }), + ).toEqual({ + success: true, + userUuid: '1-2-3', + settings: [{ foo: 'bar' }], + }) + + expect(settingProjector.projectManySimple).toHaveBeenCalledWith([setting]) + }) + + it('should return all user settings updated after', async () => { + expect( + await createUseCase().execute({ userUuid: '1-2-3', allowSensitiveRetrieval: true, updatedAfter: 123 }), + ).toEqual({ + success: true, + userUuid: '1-2-3', + settings: [{ foo: 'bar' }], + }) + + expect(settingProjector.projectManySimple).toHaveBeenCalledWith([setting]) + }) + + it('should return all sensitive user settings if explicit', async () => { + expect(await createUseCase().execute({ userUuid: '1-2-3', allowSensitiveRetrieval: true })).toEqual({ + success: true, + userUuid: '1-2-3', + settings: [{ foo: 'bar' }], + }) + + expect(settingProjector.projectManySimple).toHaveBeenCalledWith([setting, mfaSetting]) + }) +}) diff --git a/packages/auth/src/Domain/UseCase/GetSettings/GetSettings.ts b/packages/auth/src/Domain/UseCase/GetSettings/GetSettings.ts new file mode 100644 index 000000000..6619a0c90 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetSettings/GetSettings.ts @@ -0,0 +1,64 @@ +import { inject, injectable } from 'inversify' +import { GetSettingsDto } from './GetSettingsDto' +import { GetSettingsResponse } from './GetSettingsResponse' +import { UseCaseInterface } from '../UseCaseInterface' +import TYPES from '../../../Bootstrap/Types' +import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface' +import { SettingProjector } from '../../../Projection/SettingProjector' +import { Setting } from '../../Setting/Setting' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' +import { CrypterInterface } from '../../Encryption/CrypterInterface' +import { EncryptionVersion } from '../../Encryption/EncryptionVersion' + +@injectable() +export class GetSettings implements UseCaseInterface { + constructor( + @inject(TYPES.SettingRepository) private settingRepository: SettingRepositoryInterface, + @inject(TYPES.SettingProjector) private settingProjector: SettingProjector, + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.Crypter) private crypter: CrypterInterface, + ) {} + + async execute(dto: GetSettingsDto): Promise { + const { userUuid } = dto + + const user = await this.userRepository.findOneByUuid(userUuid) + + if (user === null) { + return { + success: false, + error: { + message: `User ${userUuid} not found.`, + }, + } + } + + let settings = await this.settingRepository.findAllByUserUuid(userUuid) + + if (dto.settingName !== undefined) { + settings = settings.filter((setting: Setting) => setting.name === dto.settingName) + } + + if (dto.updatedAfter !== undefined) { + settings = settings.filter((setting: Setting) => setting.updatedAt >= (dto.updatedAfter as number)) + } + + if (!dto.allowSensitiveRetrieval) { + settings = settings.filter((setting: Setting) => !setting.sensitive) + } + + for (const setting of settings) { + if (setting.value !== null && setting.serverEncryptionVersion === EncryptionVersion.Default) { + setting.value = await this.crypter.decryptForUser(setting.value, user) + } + } + + const simpleSettings = await this.settingProjector.projectManySimple(settings) + + return { + success: true, + userUuid, + settings: simpleSettings, + } + } +} diff --git a/packages/auth/src/Domain/UseCase/GetSettings/GetSettingsDto.ts b/packages/auth/src/Domain/UseCase/GetSettings/GetSettingsDto.ts new file mode 100644 index 000000000..381bf371b --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetSettings/GetSettingsDto.ts @@ -0,0 +1,8 @@ +import { Uuid } from '@standardnotes/common' + +export type GetSettingsDto = { + userUuid: Uuid + settingName?: string + allowSensitiveRetrieval?: boolean + updatedAfter?: number +} diff --git a/packages/auth/src/Domain/UseCase/GetSettings/GetSettingsResponse.ts b/packages/auth/src/Domain/UseCase/GetSettings/GetSettingsResponse.ts new file mode 100644 index 000000000..b48b6a7ed --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetSettings/GetSettingsResponse.ts @@ -0,0 +1,16 @@ +import { Uuid } from '@standardnotes/common' + +import { SimpleSetting } from '../../Setting/SimpleSetting' + +export type GetSettingsResponse = + | { + success: true + userUuid: Uuid + settings: SimpleSetting[] + } + | { + success: false + error: { + message: string + } + } diff --git a/packages/auth/src/Domain/UseCase/GetSubscriptionSetting/GetSubscriptionSetting.spec.ts b/packages/auth/src/Domain/UseCase/GetSubscriptionSetting/GetSubscriptionSetting.spec.ts new file mode 100644 index 000000000..0d8058a34 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetSubscriptionSetting/GetSubscriptionSetting.spec.ts @@ -0,0 +1,141 @@ +import 'reflect-metadata' + +import { SubscriptionSettingName } from '@standardnotes/settings' + +import { SubscriptionSettingProjector } from '../../../Projection/SubscriptionSettingProjector' +import { SubscriptionSetting } from '../../Setting/SubscriptionSetting' +import { SubscriptionSettingServiceInterface } from '../../Setting/SubscriptionSettingServiceInterface' +import { UserSubscriptionServiceInterface } from '../../Subscription/UserSubscriptionServiceInterface' + +import { GetSubscriptionSetting } from './GetSubscriptionSetting' +import { UserSubscription } from '../../Subscription/UserSubscription' +import { UserSubscriptionType } from '../../Subscription/UserSubscriptionType' +import { User } from '../../User/User' + +describe('GetSubscriptionSetting', () => { + let userSubscriptionService: UserSubscriptionServiceInterface + let subscriptionSettingService: SubscriptionSettingServiceInterface + let subscriptionSettingProjector: SubscriptionSettingProjector + let subscriptionSetting: SubscriptionSetting + let regularSubscription: UserSubscription + let user: User + + const createUseCase = () => + new GetSubscriptionSetting(userSubscriptionService, subscriptionSettingService, subscriptionSettingProjector) + + beforeEach(() => { + subscriptionSetting = {} as jest.Mocked + + user = { + uuid: '1-2-3', + } as jest.Mocked + + regularSubscription = { + uuid: '1-2-3', + subscriptionType: UserSubscriptionType.Regular, + user: Promise.resolve(user), + } as jest.Mocked + + userSubscriptionService = {} as jest.Mocked + userSubscriptionService.findRegularSubscriptionForUserUuid = jest + .fn() + .mockReturnValue({ regularSubscription, sharedSubscription: null }) + + subscriptionSettingService = {} as jest.Mocked + subscriptionSettingService.findSubscriptionSettingWithDecryptedValue = jest + .fn() + .mockReturnValue(subscriptionSetting) + + subscriptionSettingProjector = {} as jest.Mocked + subscriptionSettingProjector.projectSimple = jest.fn().mockReturnValue({ foo: 'bar' }) + }) + + it('should find a setting for user', async () => { + expect( + await createUseCase().execute({ + userUuid: '1-2-3', + subscriptionSettingName: SubscriptionSettingName.FileUploadBytesUsed, + }), + ).toEqual({ + success: true, + setting: { foo: 'bar' }, + }) + }) + + it('should not get a setting for user if user has no corresponding regular subscription', async () => { + userSubscriptionService.findRegularSubscriptionForUserUuid = jest + .fn() + .mockReturnValue({ regularSubscription: null, sharedSubscription: null }) + + expect( + await createUseCase().execute({ + userUuid: '1-2-3', + subscriptionSettingName: SubscriptionSettingName.FileUploadBytesLimit, + }), + ).toEqual({ + success: false, + error: { + message: 'No subscription found.', + }, + }) + }) + + it('should not get a setting for user if it does not exist', async () => { + subscriptionSettingService.findSubscriptionSettingWithDecryptedValue = jest.fn().mockReturnValue(null) + + expect( + await createUseCase().execute({ + userUuid: '1-2-3', + subscriptionSettingName: SubscriptionSettingName.FileUploadBytesLimit, + }), + ).toEqual({ + success: false, + error: { + message: 'Setting FILE_UPLOAD_BYTES_LIMIT for user 1-2-3 not found!', + }, + }) + }) + + it('should not retrieve a sensitive setting for user', async () => { + subscriptionSetting = { + sensitive: true, + name: SubscriptionSettingName.FileUploadBytesLimit, + } as jest.Mocked + + subscriptionSettingService.findSubscriptionSettingWithDecryptedValue = jest + .fn() + .mockReturnValue(subscriptionSetting) + + expect( + await createUseCase().execute({ + userUuid: '1-2-3', + subscriptionSettingName: SubscriptionSettingName.FileUploadBytesLimit, + }), + ).toEqual({ + success: true, + sensitive: true, + }) + }) + + it('should retrieve a sensitive setting for user if explicitly told to', async () => { + subscriptionSetting = { + sensitive: true, + name: SubscriptionSettingName.FileUploadBytesLimit, + } as jest.Mocked + + subscriptionSettingService.findSubscriptionSettingWithDecryptedValue = jest + .fn() + .mockReturnValue(subscriptionSetting) + + expect( + await createUseCase().execute({ + userUuid: '1-2-3', + subscriptionSettingName: SubscriptionSettingName.FileUploadBytesLimit, + allowSensitiveRetrieval: true, + }), + ).toEqual({ + success: true, + setting: { foo: 'bar' }, + }) + }) +}) diff --git a/packages/auth/src/Domain/UseCase/GetSubscriptionSetting/GetSubscriptionSetting.ts b/packages/auth/src/Domain/UseCase/GetSubscriptionSetting/GetSubscriptionSetting.ts new file mode 100644 index 000000000..d89bf837f --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetSubscriptionSetting/GetSubscriptionSetting.ts @@ -0,0 +1,61 @@ +import { inject, injectable } from 'inversify' + +import { GetSubscriptionSettingDTO } from './GetSubscriptionSettingDTO' +import { GetSubscriptionSettingResponse } from './GetSubscriptionSettingResponse' +import { UseCaseInterface } from '../UseCaseInterface' +import TYPES from '../../../Bootstrap/Types' +import { SubscriptionSettingServiceInterface } from '../../Setting/SubscriptionSettingServiceInterface' +import { UserSubscriptionServiceInterface } from '../../Subscription/UserSubscriptionServiceInterface' +import { SubscriptionSettingProjector } from '../../../Projection/SubscriptionSettingProjector' + +@injectable() +export class GetSubscriptionSetting implements UseCaseInterface { + constructor( + @inject(TYPES.UserSubscriptionService) private userSubscriptionService: UserSubscriptionServiceInterface, + @inject(TYPES.SubscriptionSettingService) private subscriptionSettingService: SubscriptionSettingServiceInterface, + @inject(TYPES.SubscriptionSettingProjector) private subscriptionSettingProjector: SubscriptionSettingProjector, + ) {} + + async execute(dto: GetSubscriptionSettingDTO): Promise { + const { regularSubscription } = await this.userSubscriptionService.findRegularSubscriptionForUserUuid(dto.userUuid) + if (regularSubscription === null) { + return { + success: false, + error: { + message: 'No subscription found.', + }, + } + } + + const regularSubscriptionUser = await regularSubscription.user + + const setting = await this.subscriptionSettingService.findSubscriptionSettingWithDecryptedValue({ + userUuid: regularSubscriptionUser.uuid, + userSubscriptionUuid: regularSubscription.uuid, + subscriptionSettingName: dto.subscriptionSettingName, + }) + + if (setting === null) { + return { + success: false, + error: { + message: `Setting ${dto.subscriptionSettingName} for user ${dto.userUuid} not found!`, + }, + } + } + + if (setting.sensitive && !dto.allowSensitiveRetrieval) { + return { + success: true, + sensitive: true, + } + } + + const simpleSetting = await this.subscriptionSettingProjector.projectSimple(setting) + + return { + success: true, + setting: simpleSetting, + } + } +} diff --git a/packages/auth/src/Domain/UseCase/GetSubscriptionSetting/GetSubscriptionSettingDTO.ts b/packages/auth/src/Domain/UseCase/GetSubscriptionSetting/GetSubscriptionSettingDTO.ts new file mode 100644 index 000000000..d2b9e8ae5 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetSubscriptionSetting/GetSubscriptionSettingDTO.ts @@ -0,0 +1,8 @@ +import { Uuid } from '@standardnotes/common' +import { SubscriptionSettingName } from '@standardnotes/settings' + +export type GetSubscriptionSettingDTO = { + userUuid: Uuid + subscriptionSettingName: SubscriptionSettingName + allowSensitiveRetrieval?: boolean +} diff --git a/packages/auth/src/Domain/UseCase/GetSubscriptionSetting/GetSubscriptionSettingResponse.ts b/packages/auth/src/Domain/UseCase/GetSubscriptionSetting/GetSubscriptionSettingResponse.ts new file mode 100644 index 000000000..157371071 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetSubscriptionSetting/GetSubscriptionSettingResponse.ts @@ -0,0 +1,17 @@ +import { SimpleSetting } from '../../Setting/SimpleSetting' + +export type GetSubscriptionSettingResponse = + | { + success: true + setting: SimpleSetting + } + | { + success: true + sensitive: true + } + | { + success: false + error: { + message: string + } + } diff --git a/packages/auth/src/Domain/UseCase/GetUserAnalyticsId/GetUserAnalyticsId.spec.ts b/packages/auth/src/Domain/UseCase/GetUserAnalyticsId/GetUserAnalyticsId.spec.ts new file mode 100644 index 000000000..c4e9c3b74 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetUserAnalyticsId/GetUserAnalyticsId.spec.ts @@ -0,0 +1,37 @@ +import 'reflect-metadata' + +import { AnalyticsEntity } from '../../Analytics/AnalyticsEntity' +import { AnalyticsEntityRepositoryInterface } from '../../Analytics/AnalyticsEntityRepositoryInterface' + +import { GetUserAnalyticsId } from './GetUserAnalyticsId' + +describe('GetUserAnalyticsId', () => { + let analyticsEntityRepository: AnalyticsEntityRepositoryInterface + let analyticsEntity: AnalyticsEntity + + const createUseCase = () => new GetUserAnalyticsId(analyticsEntityRepository) + + beforeEach(() => { + analyticsEntity = { id: 123 } as jest.Mocked + + analyticsEntityRepository = {} as jest.Mocked + analyticsEntityRepository.findOneByUserUuid = jest.fn().mockReturnValue(analyticsEntity) + }) + + it('should return analytics id for a user', async () => { + expect(await createUseCase().execute({ userUuid: '1-2-3' })).toEqual({ analyticsId: 123 }) + }) + + it('should throw error if user is missing analytics entity', async () => { + analyticsEntityRepository.findOneByUserUuid = jest.fn().mockReturnValue(null) + let error = null + + try { + await createUseCase().execute({ userUuid: '1-2-3' }) + } catch (caughtError) { + error = caughtError + } + + expect(error).not.toBeNull() + }) +}) diff --git a/packages/auth/src/Domain/UseCase/GetUserAnalyticsId/GetUserAnalyticsId.ts b/packages/auth/src/Domain/UseCase/GetUserAnalyticsId/GetUserAnalyticsId.ts new file mode 100644 index 000000000..5937e1fd5 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetUserAnalyticsId/GetUserAnalyticsId.ts @@ -0,0 +1,25 @@ +import { inject, injectable } from 'inversify' +import TYPES from '../../../Bootstrap/Types' +import { AnalyticsEntityRepositoryInterface } from '../../Analytics/AnalyticsEntityRepositoryInterface' +import { UseCaseInterface } from '../UseCaseInterface' +import { GetUserAnalyticsIdDTO } from './GetUserAnalyticsIdDTO' +import { GetUserAnalyticsIdResponse } from './GetUserAnalyticsIdResponse' + +@injectable() +export class GetUserAnalyticsId implements UseCaseInterface { + constructor( + @inject(TYPES.AnalyticsEntityRepository) private analyticsEntityRepository: AnalyticsEntityRepositoryInterface, + ) {} + + async execute(dto: GetUserAnalyticsIdDTO): Promise { + const analyticsEntity = await this.analyticsEntityRepository.findOneByUserUuid(dto.userUuid) + + if (analyticsEntity === null) { + throw new Error(`Could not find analytics entity for user ${dto.userUuid}`) + } + + return { + analyticsId: analyticsEntity.id, + } + } +} diff --git a/packages/auth/src/Domain/UseCase/GetUserAnalyticsId/GetUserAnalyticsIdDTO.ts b/packages/auth/src/Domain/UseCase/GetUserAnalyticsId/GetUserAnalyticsIdDTO.ts new file mode 100644 index 000000000..19c054e8f --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetUserAnalyticsId/GetUserAnalyticsIdDTO.ts @@ -0,0 +1,5 @@ +import { Uuid } from '@standardnotes/common' + +export type GetUserAnalyticsIdDTO = { + userUuid: Uuid +} diff --git a/packages/auth/src/Domain/UseCase/GetUserAnalyticsId/GetUserAnalyticsIdResponse.ts b/packages/auth/src/Domain/UseCase/GetUserAnalyticsId/GetUserAnalyticsIdResponse.ts new file mode 100644 index 000000000..4eedb6f23 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetUserAnalyticsId/GetUserAnalyticsIdResponse.ts @@ -0,0 +1,3 @@ +export type GetUserAnalyticsIdResponse = { + analyticsId: number +} diff --git a/packages/auth/src/Domain/UseCase/GetUserFeatures/GetUserFeatures.spec.ts b/packages/auth/src/Domain/UseCase/GetUserFeatures/GetUserFeatures.spec.ts new file mode 100644 index 000000000..4874f43a8 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetUserFeatures/GetUserFeatures.spec.ts @@ -0,0 +1,60 @@ +import 'reflect-metadata' +import { FeatureDescription } from '@standardnotes/features' +import { GetUserFeatures } from './GetUserFeatures' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' +import { User } from '../../User/User' +import { FeatureServiceInterface } from '../../Feature/FeatureServiceInterface' + +describe('GetUserFeatures', () => { + let user: User + let userRepository: UserRepositoryInterface + let feature1: FeatureDescription + let featureService: FeatureServiceInterface + + const createUseCase = () => new GetUserFeatures(userRepository, featureService) + + beforeEach(() => { + user = {} as jest.Mocked + userRepository = {} as jest.Mocked + userRepository.findOneByUuid = jest.fn().mockReturnValue(user) + + feature1 = { name: 'foobar' } as jest.Mocked + featureService = {} as jest.Mocked + featureService.getFeaturesForUser = jest.fn().mockReturnValue([feature1]) + featureService.getFeaturesForOfflineUser = jest.fn().mockReturnValue([feature1]) + }) + + it('should fail if a user is not found', async () => { + userRepository.findOneByUuid = jest.fn().mockReturnValue(null) + + expect(await createUseCase().execute({ userUuid: 'user-1-1-1', offline: false })).toEqual({ + success: false, + error: { + message: 'User user-1-1-1 not found.', + }, + }) + }) + + it('should return user features', async () => { + expect(await createUseCase().execute({ userUuid: 'user-1-1-1', offline: false })).toEqual({ + success: true, + userUuid: 'user-1-1-1', + features: [ + { + name: 'foobar', + }, + ], + }) + }) + + it('should return offline user features', async () => { + expect(await createUseCase().execute({ email: 'test@test.com', offline: true })).toEqual({ + success: true, + features: [ + { + name: 'foobar', + }, + ], + }) + }) +}) diff --git a/packages/auth/src/Domain/UseCase/GetUserFeatures/GetUserFeatures.ts b/packages/auth/src/Domain/UseCase/GetUserFeatures/GetUserFeatures.ts new file mode 100644 index 000000000..d653a4395 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetUserFeatures/GetUserFeatures.ts @@ -0,0 +1,45 @@ +import { UseCaseInterface } from '../UseCaseInterface' +import { inject, injectable } from 'inversify' +import TYPES from '../../../Bootstrap/Types' +import { GetUserFeaturesDto } from './GetUserFeaturesDto' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' +import { GetUserFeaturesResponse } from './GetUserFeaturesResponse' +import { FeatureServiceInterface } from '../../Feature/FeatureServiceInterface' + +@injectable() +export class GetUserFeatures implements UseCaseInterface { + constructor( + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.FeatureService) private featureService: FeatureServiceInterface, + ) {} + + async execute(dto: GetUserFeaturesDto): Promise { + if (dto.offline) { + const userFeatures = await this.featureService.getFeaturesForOfflineUser(dto.email) + + return { + success: true, + features: userFeatures, + } + } + + const user = await this.userRepository.findOneByUuid(dto.userUuid) + + if (user === null) { + return { + success: false, + error: { + message: `User ${dto.userUuid} not found.`, + }, + } + } + + const userFeatures = await this.featureService.getFeaturesForUser(user) + + return { + success: true, + userUuid: dto.userUuid, + features: userFeatures, + } + } +} diff --git a/packages/auth/src/Domain/UseCase/GetUserFeatures/GetUserFeaturesDto.ts b/packages/auth/src/Domain/UseCase/GetUserFeatures/GetUserFeaturesDto.ts new file mode 100644 index 000000000..5bd5cee60 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetUserFeatures/GetUserFeaturesDto.ts @@ -0,0 +1,11 @@ +import { Uuid } from '@standardnotes/common' + +export type GetUserFeaturesDto = + | { + userUuid: Uuid + offline: false + } + | { + email: string + offline: true + } diff --git a/packages/auth/src/Domain/UseCase/GetUserFeatures/GetUserFeaturesResponse.ts b/packages/auth/src/Domain/UseCase/GetUserFeatures/GetUserFeaturesResponse.ts new file mode 100644 index 000000000..199f6b4c3 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetUserFeatures/GetUserFeaturesResponse.ts @@ -0,0 +1,14 @@ +import { FeatureDescription } from '@standardnotes/features' + +export type GetUserFeaturesResponse = + | { + success: true + features: FeatureDescription[] + userUuid?: string + } + | { + success: false + error: { + message: string + } + } diff --git a/packages/auth/src/Domain/UseCase/GetUserKeyParams/GetUserKeyParams.spec.ts b/packages/auth/src/Domain/UseCase/GetUserKeyParams/GetUserKeyParams.spec.ts new file mode 100644 index 000000000..2758bf8ec --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetUserKeyParams/GetUserKeyParams.spec.ts @@ -0,0 +1,126 @@ +import 'reflect-metadata' +import { Logger } from 'winston' + +import { KeyParamsFactoryInterface } from '../../User/KeyParamsFactoryInterface' +import { PKCERepositoryInterface } from '../../User/PKCERepositoryInterface' +import { User } from '../../User/User' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' +import { GetUserKeyParams } from './GetUserKeyParams' + +describe('GetUserKeyParams', () => { + let keyParamsFactory: KeyParamsFactoryInterface + let userRepository: UserRepositoryInterface + let logger: Logger + let user: User + let pkceRepository: PKCERepositoryInterface + + const createUseCase = () => new GetUserKeyParams(keyParamsFactory, userRepository, pkceRepository, logger) + + beforeEach(() => { + keyParamsFactory = {} as jest.Mocked + keyParamsFactory.create = jest.fn().mockReturnValue({ foo: 'bar' }) + keyParamsFactory.createPseudoParams = jest.fn().mockReturnValue({ bar: 'baz' }) + + user = {} as jest.Mocked + + userRepository = {} as jest.Mocked + userRepository.findOneByEmail = jest.fn().mockReturnValue(user) + userRepository.findOneByUuid = jest.fn().mockReturnValue(user) + + logger = {} as jest.Mocked + logger.debug = jest.fn() + + pkceRepository = {} as jest.Mocked + pkceRepository.storeCodeChallenge = jest.fn() + }) + + it('should get key params for an authenticated user - searching by email', async () => { + expect( + await createUseCase().execute({ email: 'test@test.te', authenticated: true, authenticatedUser: user }), + ).toEqual({ + keyParams: { + foo: 'bar', + }, + }) + + expect(keyParamsFactory.create).toHaveBeenCalledWith(user, true) + }) + + it('should get key params for an unauthenticated user - searching by email', async () => { + expect(await createUseCase().execute({ email: 'test@test.te', authenticated: false })).toEqual({ + keyParams: { + foo: 'bar', + }, + }) + + expect(keyParamsFactory.create).toHaveBeenCalledWith(user, false) + }) + + it('should get key params for an authenticated user - searching by uuid', async () => { + expect(await createUseCase().execute({ userUuid: '1-2-3', authenticated: true, authenticatedUser: user })).toEqual({ + keyParams: { + foo: 'bar', + }, + }) + + expect(keyParamsFactory.create).toHaveBeenCalledWith(user, true) + }) + + it('should get key params for an unauthenticated user - searching by uuid', async () => { + expect(await createUseCase().execute({ userUuid: '1-2-3', authenticated: false })).toEqual({ + keyParams: { + foo: 'bar', + }, + }) + + expect(keyParamsFactory.create).toHaveBeenCalledWith(user, false) + }) + + it("should get key params for an unauthenticated user and store it's code challenge", async () => { + expect(await createUseCase().execute({ userUuid: '1-2-3', authenticated: false, codeChallenge: 'test' })).toEqual({ + keyParams: { + foo: 'bar', + }, + }) + + expect(pkceRepository.storeCodeChallenge).toHaveBeenCalledWith('test') + }) + + it('should get pseudo key params for a non existing user - when searching by email', async () => { + userRepository.findOneByEmail = jest.fn().mockReturnValue(null) + + expect(await createUseCase().execute({ email: 'test@test.te', authenticated: false })).toEqual({ + keyParams: { + bar: 'baz', + }, + }) + + expect(keyParamsFactory.createPseudoParams).toHaveBeenCalledWith('test@test.te') + }) + + it('should throw error for a non existing user - when searching by uuid', async () => { + userRepository.findOneByUuid = jest.fn().mockReturnValue(null) + + let error = null + try { + await createUseCase().execute({ userUuid: '1-2-3', authenticated: false }) + } catch (e) { + error = e + } + + expect(error).not.toBeNull() + }) + + it('should throw error for a non existing user - when search parameters are not given', async () => { + userRepository.findOneByUuid = jest.fn().mockReturnValue(null) + + let error = null + try { + await createUseCase().execute({ authenticated: false }) + } catch (e) { + error = e + } + + expect(error).not.toBeNull() + }) +}) diff --git a/packages/auth/src/Domain/UseCase/GetUserKeyParams/GetUserKeyParams.ts b/packages/auth/src/Domain/UseCase/GetUserKeyParams/GetUserKeyParams.ts new file mode 100644 index 000000000..9d44fe373 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetUserKeyParams/GetUserKeyParams.ts @@ -0,0 +1,74 @@ +import { inject, injectable } from 'inversify' +import TYPES from '../../../Bootstrap/Types' +import { KeyParamsFactoryInterface } from '../../User/KeyParamsFactoryInterface' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' +import { GetUserKeyParamsDTO } from './GetUserKeyParamsDTO' +import { GetUserKeyParamsResponse } from './GetUserKeyParamsResponse' +import { UseCaseInterface } from '../UseCaseInterface' +import { Logger } from 'winston' +import { User } from '../../User/User' +import { PKCERepositoryInterface } from '../../User/PKCERepositoryInterface' +import { GetUserKeyParamsDTOV2Challenged } from './GetUserKeyParamsDTOV2Challenged' +import { KeyParamsData } from '@standardnotes/responses' + +@injectable() +export class GetUserKeyParams implements UseCaseInterface { + constructor( + @inject(TYPES.KeyParamsFactory) private keyParamsFactory: KeyParamsFactoryInterface, + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.PKCERepository) private pkceRepository: PKCERepositoryInterface, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async execute(dto: GetUserKeyParamsDTO): Promise { + if (dto.authenticatedUser) { + this.logger.debug(`Creating key params for authenticated user ${dto.authenticatedUser.email}`) + + const keyParams = await this.createKeyParams(dto, dto.authenticatedUser, true) + + return { + keyParams, + } + } + + let user: User | null = null + if (dto.email !== undefined) { + user = await this.userRepository.findOneByEmail(dto.email) + if (!user) { + this.logger.debug(`No user with email ${dto.email}. Creating pseudo key params.`) + + return { + keyParams: this.keyParamsFactory.createPseudoParams(dto.email), + } + } + } else if (dto.userUuid) { + user = await this.userRepository.findOneByUuid(dto.userUuid) + } + + if (!user) { + this.logger.debug('Could not find user with given parameters: %O', dto) + + throw Error('Could not find user') + } + + this.logger.debug(`Creating key params for user ${user.email}. Authentication: ${dto.authenticated}`) + + const keyParams = await this.createKeyParams(dto, user, dto.authenticated) + + return { + keyParams, + } + } + + private async createKeyParams(dto: GetUserKeyParamsDTO, user: User, authenticated: boolean): Promise { + if (this.isCodeChallengedVersion(dto)) { + await this.pkceRepository.storeCodeChallenge(dto.codeChallenge) + } + + return this.keyParamsFactory.create(user, authenticated) + } + + private isCodeChallengedVersion(dto: GetUserKeyParamsDTO): dto is GetUserKeyParamsDTOV2Challenged { + return (dto as GetUserKeyParamsDTOV2Challenged).codeChallenge !== undefined + } +} diff --git a/packages/auth/src/Domain/UseCase/GetUserKeyParams/GetUserKeyParamsDTO.ts b/packages/auth/src/Domain/UseCase/GetUserKeyParams/GetUserKeyParamsDTO.ts new file mode 100644 index 000000000..15417fba3 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetUserKeyParams/GetUserKeyParamsDTO.ts @@ -0,0 +1,4 @@ +import { GetUserKeyParamsDTOV1Unchallenged } from './GetUserKeyParamsDTOV1Unchallenged' +import { GetUserKeyParamsDTOV2Challenged } from './GetUserKeyParamsDTOV2Challenged' + +export type GetUserKeyParamsDTO = GetUserKeyParamsDTOV1Unchallenged | GetUserKeyParamsDTOV2Challenged diff --git a/packages/auth/src/Domain/UseCase/GetUserKeyParams/GetUserKeyParamsDTOV1Unchallenged.ts b/packages/auth/src/Domain/UseCase/GetUserKeyParams/GetUserKeyParamsDTOV1Unchallenged.ts new file mode 100644 index 000000000..7356ff5e3 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetUserKeyParams/GetUserKeyParamsDTOV1Unchallenged.ts @@ -0,0 +1,8 @@ +import { User } from '../../User/User' + +export type GetUserKeyParamsDTOV1Unchallenged = { + authenticated: boolean + email?: string + userUuid?: string + authenticatedUser?: User +} diff --git a/packages/auth/src/Domain/UseCase/GetUserKeyParams/GetUserKeyParamsDTOV2Challenged.ts b/packages/auth/src/Domain/UseCase/GetUserKeyParams/GetUserKeyParamsDTOV2Challenged.ts new file mode 100644 index 000000000..ae18454b7 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetUserKeyParams/GetUserKeyParamsDTOV2Challenged.ts @@ -0,0 +1,9 @@ +import { User } from '../../User/User' + +export type GetUserKeyParamsDTOV2Challenged = { + authenticated: boolean + codeChallenge: string + email?: string + userUuid?: string + authenticatedUser?: User +} diff --git a/packages/auth/src/Domain/UseCase/GetUserKeyParams/GetUserKeyParamsResponse.ts b/packages/auth/src/Domain/UseCase/GetUserKeyParams/GetUserKeyParamsResponse.ts new file mode 100644 index 000000000..a87b8dfb5 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetUserKeyParams/GetUserKeyParamsResponse.ts @@ -0,0 +1,5 @@ +import { KeyParamsData } from '@standardnotes/responses' + +export type GetUserKeyParamsResponse = { + keyParams: KeyParamsData +} diff --git a/packages/auth/src/Domain/UseCase/GetUserOfflineSubscription/GetUserOfflineSubscription.spec.ts b/packages/auth/src/Domain/UseCase/GetUserOfflineSubscription/GetUserOfflineSubscription.spec.ts new file mode 100644 index 000000000..e1119acaf --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetUserOfflineSubscription/GetUserOfflineSubscription.spec.ts @@ -0,0 +1,31 @@ +import 'reflect-metadata' + +import { GetUserOfflineSubscription } from './GetUserOfflineSubscription' +import { SubscriptionName } from '@standardnotes/common' +import { OfflineUserSubscriptionRepositoryInterface } from '../../Subscription/OfflineUserSubscriptionRepositoryInterface' +import { OfflineUserSubscription } from '../../Subscription/OfflineUserSubscription' + +describe('GetUserOfflineSubscription', () => { + let userSubscription: OfflineUserSubscription + let offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface + + const createUseCase = () => new GetUserOfflineSubscription(offlineUserSubscriptionRepository) + + beforeEach(() => { + userSubscription = { + planName: SubscriptionName.ProPlan, + } as jest.Mocked + + offlineUserSubscriptionRepository = {} as jest.Mocked + offlineUserSubscriptionRepository.findOneByEmail = jest.fn().mockReturnValue(userSubscription) + }) + + it('should return user offline subscription', async () => { + expect(await createUseCase().execute({ userEmail: 'test@test.com' })).toEqual({ + success: true, + subscription: { + planName: SubscriptionName.ProPlan, + }, + }) + }) +}) diff --git a/packages/auth/src/Domain/UseCase/GetUserOfflineSubscription/GetUserOfflineSubscription.ts b/packages/auth/src/Domain/UseCase/GetUserOfflineSubscription/GetUserOfflineSubscription.ts new file mode 100644 index 000000000..49e2c8b87 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetUserOfflineSubscription/GetUserOfflineSubscription.ts @@ -0,0 +1,23 @@ +import { UseCaseInterface } from '../UseCaseInterface' +import { inject, injectable } from 'inversify' +import TYPES from '../../../Bootstrap/Types' +import { GetUserOfflineSubscriptionDto } from './GetUserOfflineSubscriptionDto' +import { GetUserOfflineSubscriptionResponse } from './GetUserOfflineSubscriptionResponse' +import { OfflineUserSubscriptionRepositoryInterface } from '../../Subscription/OfflineUserSubscriptionRepositoryInterface' + +@injectable() +export class GetUserOfflineSubscription implements UseCaseInterface { + constructor( + @inject(TYPES.OfflineUserSubscriptionRepository) + private offlineUserSubscriptionRepository: OfflineUserSubscriptionRepositoryInterface, + ) {} + + async execute(dto: GetUserOfflineSubscriptionDto): Promise { + const userSubscription = await this.offlineUserSubscriptionRepository.findOneByEmail(dto.userEmail) + + return { + success: true, + subscription: userSubscription, + } + } +} diff --git a/packages/auth/src/Domain/UseCase/GetUserOfflineSubscription/GetUserOfflineSubscriptionDto.ts b/packages/auth/src/Domain/UseCase/GetUserOfflineSubscription/GetUserOfflineSubscriptionDto.ts new file mode 100644 index 000000000..c1a4e1d23 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetUserOfflineSubscription/GetUserOfflineSubscriptionDto.ts @@ -0,0 +1,3 @@ +export type GetUserOfflineSubscriptionDto = { + userEmail: string +} diff --git a/packages/auth/src/Domain/UseCase/GetUserOfflineSubscription/GetUserOfflineSubscriptionResponse.ts b/packages/auth/src/Domain/UseCase/GetUserOfflineSubscription/GetUserOfflineSubscriptionResponse.ts new file mode 100644 index 000000000..324d7097b --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetUserOfflineSubscription/GetUserOfflineSubscriptionResponse.ts @@ -0,0 +1,13 @@ +import { OfflineUserSubscription } from '../../Subscription/OfflineUserSubscription' + +export type GetUserOfflineSubscriptionResponse = + | { + success: true + subscription: OfflineUserSubscription | null + } + | { + success: false + error: { + message: string + } + } diff --git a/packages/auth/src/Domain/UseCase/GetUserSubscription/GetUserSubscription.spec.ts b/packages/auth/src/Domain/UseCase/GetUserSubscription/GetUserSubscription.spec.ts new file mode 100644 index 000000000..3c639495a --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetUserSubscription/GetUserSubscription.spec.ts @@ -0,0 +1,49 @@ +import 'reflect-metadata' +import { GetUserSubscription } from './GetUserSubscription' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' +import { User } from '../../User/User' +import { UserSubscriptionRepositoryInterface } from '../../Subscription/UserSubscriptionRepositoryInterface' +import { UserSubscription } from '../../Subscription/UserSubscription' +import { SubscriptionName } from '@standardnotes/common' + +describe('GetUserSubscription', () => { + let user: User + let userSubscription: UserSubscription + let userRepository: UserRepositoryInterface + let userSubscriptionRepository: UserSubscriptionRepositoryInterface + + const createUseCase = () => new GetUserSubscription(userRepository, userSubscriptionRepository) + + beforeEach(() => { + user = { uuid: 'user-1-1-1', email: 'user-1-1-1@example.com' } as jest.Mocked + userRepository = {} as jest.Mocked + userRepository.findOneByUuid = jest.fn().mockReturnValue(user) + + userSubscription = { + planName: SubscriptionName.ProPlan, + } as jest.Mocked + userSubscriptionRepository = {} as jest.Mocked + userSubscriptionRepository.findOneByUserUuid = jest.fn().mockReturnValue(userSubscription) + }) + + it('should fail if a user is not found', async () => { + userRepository.findOneByUuid = jest.fn().mockReturnValue(null) + + expect(await createUseCase().execute({ userUuid: 'user-1-1-1' })).toEqual({ + success: false, + error: { + message: 'User user-1-1-1 not found.', + }, + }) + }) + + it('should return user subscription', async () => { + expect(await createUseCase().execute({ userUuid: 'user-1-1-1' })).toEqual({ + success: true, + user: { uuid: 'user-1-1-1', email: 'user-1-1-1@example.com' }, + subscription: { + planName: SubscriptionName.ProPlan, + }, + }) + }) +}) diff --git a/packages/auth/src/Domain/UseCase/GetUserSubscription/GetUserSubscription.ts b/packages/auth/src/Domain/UseCase/GetUserSubscription/GetUserSubscription.ts new file mode 100644 index 000000000..bbed915d7 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetUserSubscription/GetUserSubscription.ts @@ -0,0 +1,38 @@ +import { UseCaseInterface } from '../UseCaseInterface' +import { inject, injectable } from 'inversify' +import TYPES from '../../../Bootstrap/Types' +import { GetUserSubscriptionDto } from './GetUserSubscriptionDto' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' +import { GetUserSubscriptionResponse } from './GetUserSubscriptionResponse' +import { UserSubscriptionRepositoryInterface } from '../../Subscription/UserSubscriptionRepositoryInterface' + +@injectable() +export class GetUserSubscription implements UseCaseInterface { + constructor( + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.UserSubscriptionRepository) private userSubscriptionRepository: UserSubscriptionRepositoryInterface, + ) {} + + async execute(dto: GetUserSubscriptionDto): Promise { + const { userUuid } = dto + + const user = await this.userRepository.findOneByUuid(userUuid) + + if (user === null) { + return { + success: false, + error: { + message: `User ${userUuid} not found.`, + }, + } + } + + const userSubscription = await this.userSubscriptionRepository.findOneByUserUuid(userUuid) + + return { + success: true, + user: { uuid: user.uuid, email: user.email }, + subscription: userSubscription, + } + } +} diff --git a/packages/auth/src/Domain/UseCase/GetUserSubscription/GetUserSubscriptionDto.ts b/packages/auth/src/Domain/UseCase/GetUserSubscription/GetUserSubscriptionDto.ts new file mode 100644 index 000000000..9cdd207ab --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetUserSubscription/GetUserSubscriptionDto.ts @@ -0,0 +1,5 @@ +import { Uuid } from '@standardnotes/common' + +export type GetUserSubscriptionDto = { + userUuid: Uuid +} diff --git a/packages/auth/src/Domain/UseCase/GetUserSubscription/GetUserSubscriptionResponse.ts b/packages/auth/src/Domain/UseCase/GetUserSubscription/GetUserSubscriptionResponse.ts new file mode 100644 index 000000000..33a2a5f76 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/GetUserSubscription/GetUserSubscriptionResponse.ts @@ -0,0 +1,14 @@ +import { UserSubscription } from '../../Subscription/UserSubscription' + +export type GetUserSubscriptionResponse = + | { + success: true + user: { uuid: string; email: string } + subscription: UserSubscription | null + } + | { + success: false + error: { + message: string + } + } diff --git a/packages/auth/src/Domain/UseCase/IncreaseLoginAttempts.spec.ts b/packages/auth/src/Domain/UseCase/IncreaseLoginAttempts.spec.ts new file mode 100644 index 000000000..49be408a5 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/IncreaseLoginAttempts.spec.ts @@ -0,0 +1,62 @@ +import 'reflect-metadata' + +import { Logger } from 'winston' +import { LockRepositoryInterface } from '../User/LockRepositoryInterface' + +import { User } from '../User/User' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { IncreaseLoginAttempts } from './IncreaseLoginAttempts' + +describe('IncreaseLoginAttempts', () => { + let userRepository: UserRepositoryInterface + let lockRepository: LockRepositoryInterface + const maxLoginAttempts = 6 + let user: User + let logger: Logger + + const createUseCase = () => new IncreaseLoginAttempts(userRepository, lockRepository, maxLoginAttempts, logger) + + beforeEach(() => { + logger = {} as jest.Mocked + logger.debug = jest.fn() + + user = {} as jest.Mocked + user.uuid = '123' + + userRepository = {} as jest.Mocked + userRepository.findOneByEmail = jest.fn().mockReturnValue(user) + + lockRepository = {} as jest.Mocked + lockRepository.getLockCounter = jest.fn() + lockRepository.lockUser = jest.fn() + lockRepository.updateLockCounter = jest.fn() + }) + + it('should lock a user if the number of failed login attempts is breached', async () => { + lockRepository.getLockCounter = jest.fn().mockReturnValue(5) + + expect(await createUseCase().execute({ email: 'test@test.te' })).toEqual({ success: true }) + + expect(lockRepository.updateLockCounter).toHaveBeenCalledWith('123', 6) + expect(lockRepository.lockUser).toHaveBeenCalledWith('123') + }) + + it('should update the lock counter if a user is not exceeding the max failed login attempts', async () => { + lockRepository.getLockCounter = jest.fn().mockReturnValue(4) + + expect(await createUseCase().execute({ email: 'test@test.te' })).toEqual({ success: true }) + + expect(lockRepository.lockUser).not.toHaveBeenCalled() + expect(lockRepository.updateLockCounter).toHaveBeenCalledWith('123', 5) + }) + + it('should should update the lock counter based on email if user is not found', async () => { + lockRepository.getLockCounter = jest.fn().mockReturnValue(4) + userRepository.findOneByEmail = jest.fn().mockReturnValue(null) + + expect(await createUseCase().execute({ email: 'test@test.te' })).toEqual({ success: true }) + + expect(lockRepository.lockUser).not.toHaveBeenCalled() + expect(lockRepository.updateLockCounter).toHaveBeenCalledWith('test@test.te', 5) + }) +}) diff --git a/packages/auth/src/Domain/UseCase/IncreaseLoginAttempts.ts b/packages/auth/src/Domain/UseCase/IncreaseLoginAttempts.ts new file mode 100644 index 000000000..96b8015f2 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/IncreaseLoginAttempts.ts @@ -0,0 +1,41 @@ +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' +import TYPES from '../../Bootstrap/Types' +import { LockRepositoryInterface } from '../User/LockRepositoryInterface' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { IncreaseLoginAttemptsDTO } from './IncreaseLoginAttemptsDTO' +import { IncreaseLoginAttemptsResponse } from './IncreaseLoginAttemptsResponse' +import { UseCaseInterface } from './UseCaseInterface' + +@injectable() +export class IncreaseLoginAttempts implements UseCaseInterface { + constructor( + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.LockRepository) private lockRepository: LockRepositoryInterface, + @inject(TYPES.MAX_LOGIN_ATTEMPTS) private maxLoginAttempts: number, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async execute(dto: IncreaseLoginAttemptsDTO): Promise { + let identifier = dto.email + + const user = await this.userRepository.findOneByEmail(identifier) + if (user !== null) { + identifier = user.uuid + } + + let numberOfFailedAttempts = await this.lockRepository.getLockCounter(identifier) + + numberOfFailedAttempts += 1 + + await this.lockRepository.updateLockCounter(identifier, numberOfFailedAttempts) + + if (numberOfFailedAttempts >= this.maxLoginAttempts) { + this.logger.debug(`User ${identifier} breached number of allowed login attempts. Locking user.`) + + await this.lockRepository.lockUser(identifier) + } + + return { success: true } + } +} diff --git a/packages/auth/src/Domain/UseCase/IncreaseLoginAttemptsDTO.ts b/packages/auth/src/Domain/UseCase/IncreaseLoginAttemptsDTO.ts new file mode 100644 index 000000000..c8be1886a --- /dev/null +++ b/packages/auth/src/Domain/UseCase/IncreaseLoginAttemptsDTO.ts @@ -0,0 +1,3 @@ +export type IncreaseLoginAttemptsDTO = { + email: string +} diff --git a/packages/auth/src/Domain/UseCase/IncreaseLoginAttemptsResponse.ts b/packages/auth/src/Domain/UseCase/IncreaseLoginAttemptsResponse.ts new file mode 100644 index 000000000..9a58e8e4d --- /dev/null +++ b/packages/auth/src/Domain/UseCase/IncreaseLoginAttemptsResponse.ts @@ -0,0 +1,3 @@ +export type IncreaseLoginAttemptsResponse = { + success: boolean +} diff --git a/packages/auth/src/Domain/UseCase/InviteToSharedSubscription/InviteToSharedSubscription.spec.ts b/packages/auth/src/Domain/UseCase/InviteToSharedSubscription/InviteToSharedSubscription.spec.ts new file mode 100644 index 000000000..f96e931ff --- /dev/null +++ b/packages/auth/src/Domain/UseCase/InviteToSharedSubscription/InviteToSharedSubscription.spec.ts @@ -0,0 +1,162 @@ +import 'reflect-metadata' + +import { DomainEventPublisherInterface, SharedSubscriptionInvitationCreatedEvent } from '@standardnotes/domain-events' +import { TimerInterface } from '@standardnotes/time' +import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface' +import { SharedSubscriptionInvitationRepositoryInterface } from '../../SharedSubscription/SharedSubscriptionInvitationRepositoryInterface' + +import { InviteToSharedSubscription } from './InviteToSharedSubscription' +import { UserSubscriptionRepositoryInterface } from '../../Subscription/UserSubscriptionRepositoryInterface' +import { UserSubscription } from '../../Subscription/UserSubscription' +import { RoleName } from '@standardnotes/common' + +describe('InviteToSharedSubscription', () => { + let userSubscriptionRepository: UserSubscriptionRepositoryInterface + let timer: TimerInterface + let sharedSubscriptionInvitationRepository: SharedSubscriptionInvitationRepositoryInterface + let domainEventPublisher: DomainEventPublisherInterface + let domainEventFactory: DomainEventFactoryInterface + + const createUseCase = () => + new InviteToSharedSubscription( + userSubscriptionRepository, + timer, + sharedSubscriptionInvitationRepository, + domainEventPublisher, + domainEventFactory, + ) + + beforeEach(() => { + userSubscriptionRepository = {} as jest.Mocked + userSubscriptionRepository.findOneByUserUuid = jest + .fn() + .mockReturnValue({ subscriptionId: 2 } as jest.Mocked) + + timer = {} as jest.Mocked + timer.getTimestampInMicroseconds = jest.fn().mockReturnValue(1) + + sharedSubscriptionInvitationRepository = {} as jest.Mocked + sharedSubscriptionInvitationRepository.save = jest.fn().mockImplementation((same) => ({ ...same, uuid: '1-2-3' })) + sharedSubscriptionInvitationRepository.countByInviterEmailAndStatus = jest.fn().mockReturnValue(2) + + domainEventPublisher = {} as jest.Mocked + domainEventPublisher.publish = jest.fn() + + domainEventFactory = {} as jest.Mocked + domainEventFactory.createSharedSubscriptionInvitationCreatedEvent = jest + .fn() + .mockReturnValue({} as jest.Mocked) + }) + + it('should not create an inivitation for sharing the subscription if inviter has no subscription', async () => { + userSubscriptionRepository.findOneByUserUuid = jest.fn().mockReturnValue(null) + + await createUseCase().execute({ + inviteeIdentifier: 'invitee@test.te', + inviterUuid: '1-2-3', + inviterEmail: 'inviter@test.te', + inviterRoles: [RoleName.ProUser], + }) + + expect(sharedSubscriptionInvitationRepository.save).not.toHaveBeenCalled() + + expect(domainEventFactory.createSharedSubscriptionInvitationCreatedEvent).not.toHaveBeenCalled() + expect(domainEventPublisher.publish).not.toHaveBeenCalled() + }) + + it('should not create an inivitation if user is not a pro user', async () => { + expect( + await createUseCase().execute({ + inviteeIdentifier: 'invitee@test.te', + inviterUuid: '1-2-3', + inviterEmail: 'inviter@test.te', + inviterRoles: [RoleName.PlusUser], + }), + ).toEqual({ + success: false, + }) + + expect(sharedSubscriptionInvitationRepository.save).not.toHaveBeenCalled() + + expect(domainEventFactory.createSharedSubscriptionInvitationCreatedEvent).not.toHaveBeenCalled() + expect(domainEventPublisher.publish).not.toHaveBeenCalled() + }) + + it('should not create an inivitation if user is already reached the max limit of invites', async () => { + sharedSubscriptionInvitationRepository.countByInviterEmailAndStatus = jest.fn().mockReturnValue(5) + + expect( + await createUseCase().execute({ + inviteeIdentifier: 'invitee@test.te', + inviterUuid: '1-2-3', + inviterEmail: 'inviter@test.te', + inviterRoles: [RoleName.ProUser], + }), + ).toEqual({ + success: false, + }) + + expect(sharedSubscriptionInvitationRepository.save).not.toHaveBeenCalled() + + expect(domainEventFactory.createSharedSubscriptionInvitationCreatedEvent).not.toHaveBeenCalled() + expect(domainEventPublisher.publish).not.toHaveBeenCalled() + }) + + it('should create an inivitation for sharing the subscription', async () => { + await createUseCase().execute({ + inviteeIdentifier: 'invitee@test.te', + inviterUuid: '1-2-3', + inviterEmail: 'inviter@test.te', + inviterRoles: [RoleName.ProUser], + }) + + expect(sharedSubscriptionInvitationRepository.save).toHaveBeenCalledWith({ + createdAt: 1, + inviteeIdentifier: 'invitee@test.te', + inviteeIdentifierType: 'email', + inviterIdentifier: 'inviter@test.te', + inviterIdentifierType: 'email', + status: 'sent', + subscriptionId: 2, + updatedAt: 1, + }) + + expect(domainEventFactory.createSharedSubscriptionInvitationCreatedEvent).toHaveBeenCalledWith({ + inviteeIdentifier: 'invitee@test.te', + inviteeIdentifierType: 'email', + inviterEmail: 'inviter@test.te', + inviterSubscriptionId: 2, + sharedSubscriptionInvitationUuid: '1-2-3', + }) + expect(domainEventPublisher.publish).toHaveBeenCalled() + }) + + it('should create an inivitation for sharing the subscription with a vault account', async () => { + await createUseCase().execute({ + inviteeIdentifier: 'a75a31ce95365904ef0e0a8e6cefc1f5e99adfef81bbdb6d4499eeb10ae0ff67', + inviterEmail: 'inviter@test.te', + inviterUuid: '1-2-3', + inviterRoles: [RoleName.ProUser], + }) + + expect(sharedSubscriptionInvitationRepository.save).toHaveBeenCalledWith({ + createdAt: 1, + inviteeIdentifier: 'a75a31ce95365904ef0e0a8e6cefc1f5e99adfef81bbdb6d4499eeb10ae0ff67', + inviteeIdentifierType: 'hash', + inviterIdentifier: 'inviter@test.te', + inviterIdentifierType: 'email', + status: 'sent', + subscriptionId: 2, + updatedAt: 1, + }) + + expect(domainEventFactory.createSharedSubscriptionInvitationCreatedEvent).toHaveBeenCalledWith({ + inviteeIdentifier: 'a75a31ce95365904ef0e0a8e6cefc1f5e99adfef81bbdb6d4499eeb10ae0ff67', + inviteeIdentifierType: 'hash', + inviterEmail: 'inviter@test.te', + inviterSubscriptionId: 2, + sharedSubscriptionInvitationUuid: '1-2-3', + }) + expect(domainEventPublisher.publish).toHaveBeenCalled() + }) +}) diff --git a/packages/auth/src/Domain/UseCase/InviteToSharedSubscription/InviteToSharedSubscription.ts b/packages/auth/src/Domain/UseCase/InviteToSharedSubscription/InviteToSharedSubscription.ts new file mode 100644 index 000000000..778fbf71d --- /dev/null +++ b/packages/auth/src/Domain/UseCase/InviteToSharedSubscription/InviteToSharedSubscription.ts @@ -0,0 +1,90 @@ +import { RoleName } from '@standardnotes/common' +import { DomainEventPublisherInterface } from '@standardnotes/domain-events' +import { TimerInterface } from '@standardnotes/time' +import { inject, injectable } from 'inversify' + +import TYPES from '../../../Bootstrap/Types' +import { DomainEventFactoryInterface } from '../../Event/DomainEventFactoryInterface' +import { InvitationStatus } from '../../SharedSubscription/InvitationStatus' +import { InviteeIdentifierType } from '../../SharedSubscription/InviteeIdentifierType' +import { InviterIdentifierType } from '../../SharedSubscription/InviterIdentifierType' +import { SharedSubscriptionInvitation } from '../../SharedSubscription/SharedSubscriptionInvitation' +import { SharedSubscriptionInvitationRepositoryInterface } from '../../SharedSubscription/SharedSubscriptionInvitationRepositoryInterface' +import { UserSubscriptionRepositoryInterface } from '../../Subscription/UserSubscriptionRepositoryInterface' +import { UseCaseInterface } from '../UseCaseInterface' + +import { InviteToSharedSubscriptionDTO } from './InviteToSharedSubscriptionDTO' +import { InviteToSharedSubscriptionResult } from './InviteToSharedSubscriptionResult' + +@injectable() +export class InviteToSharedSubscription implements UseCaseInterface { + private readonly MAX_NUMBER_OF_INVITES = 5 + constructor( + @inject(TYPES.UserSubscriptionRepository) private userSubscriptionRepository: UserSubscriptionRepositoryInterface, + @inject(TYPES.Timer) private timer: TimerInterface, + @inject(TYPES.SharedSubscriptionInvitationRepository) + private sharedSubscriptionInvitationRepository: SharedSubscriptionInvitationRepositoryInterface, + @inject(TYPES.DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface, + @inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface, + ) {} + + async execute(dto: InviteToSharedSubscriptionDTO): Promise { + if (!dto.inviterRoles.includes(RoleName.ProUser)) { + return { + success: false, + } + } + + const numberOfUsedInvites = await this.sharedSubscriptionInvitationRepository.countByInviterEmailAndStatus( + dto.inviterEmail, + [InvitationStatus.Sent, InvitationStatus.Accepted], + ) + if (numberOfUsedInvites >= this.MAX_NUMBER_OF_INVITES) { + return { + success: false, + } + } + + const inviterUserSubscription = await this.userSubscriptionRepository.findOneByUserUuid(dto.inviterUuid) + if (inviterUserSubscription === null) { + return { + success: false, + } + } + + const sharedSubscriptionInvition = new SharedSubscriptionInvitation() + sharedSubscriptionInvition.inviterIdentifier = dto.inviterEmail + sharedSubscriptionInvition.inviterIdentifierType = InviterIdentifierType.Email + sharedSubscriptionInvition.inviteeIdentifier = dto.inviteeIdentifier + sharedSubscriptionInvition.inviteeIdentifierType = this.isInviteeIdentifierPotentiallyAVaultAccount( + dto.inviteeIdentifier, + ) + ? InviteeIdentifierType.Hash + : InviteeIdentifierType.Email + sharedSubscriptionInvition.status = InvitationStatus.Sent + sharedSubscriptionInvition.subscriptionId = inviterUserSubscription.subscriptionId as number + sharedSubscriptionInvition.createdAt = this.timer.getTimestampInMicroseconds() + sharedSubscriptionInvition.updatedAt = this.timer.getTimestampInMicroseconds() + + const savedInvitation = await this.sharedSubscriptionInvitationRepository.save(sharedSubscriptionInvition) + + await this.domainEventPublisher.publish( + this.domainEventFactory.createSharedSubscriptionInvitationCreatedEvent({ + inviterEmail: dto.inviterEmail, + inviterSubscriptionId: inviterUserSubscription.subscriptionId as number, + inviteeIdentifier: dto.inviteeIdentifier, + inviteeIdentifierType: savedInvitation.inviteeIdentifierType, + sharedSubscriptionInvitationUuid: savedInvitation.uuid, + }), + ) + + return { + success: true, + sharedSubscriptionInvitationUuid: savedInvitation.uuid, + } + } + + private isInviteeIdentifierPotentiallyAVaultAccount(identifier: string): boolean { + return identifier.length === 64 && !identifier.includes('@') + } +} diff --git a/packages/auth/src/Domain/UseCase/InviteToSharedSubscription/InviteToSharedSubscriptionDTO.ts b/packages/auth/src/Domain/UseCase/InviteToSharedSubscription/InviteToSharedSubscriptionDTO.ts new file mode 100644 index 000000000..05c32891a --- /dev/null +++ b/packages/auth/src/Domain/UseCase/InviteToSharedSubscription/InviteToSharedSubscriptionDTO.ts @@ -0,0 +1,8 @@ +import { RoleName, Uuid } from '@standardnotes/common' + +export type InviteToSharedSubscriptionDTO = { + inviterEmail: string + inviterUuid: Uuid + inviterRoles: RoleName[] + inviteeIdentifier: string +} diff --git a/packages/auth/src/Domain/UseCase/InviteToSharedSubscription/InviteToSharedSubscriptionResult.ts b/packages/auth/src/Domain/UseCase/InviteToSharedSubscription/InviteToSharedSubscriptionResult.ts new file mode 100644 index 000000000..e0cb3fb1d --- /dev/null +++ b/packages/auth/src/Domain/UseCase/InviteToSharedSubscription/InviteToSharedSubscriptionResult.ts @@ -0,0 +1,10 @@ +import { Uuid } from '@standardnotes/common' + +export type InviteToSharedSubscriptionResult = + | { + success: true + sharedSubscriptionInvitationUuid: Uuid + } + | { + success: false + } diff --git a/packages/auth/src/Domain/UseCase/ListSharedSubscriptionInvitations/ListSharedSubscriptionInvitations.spec.ts b/packages/auth/src/Domain/UseCase/ListSharedSubscriptionInvitations/ListSharedSubscriptionInvitations.spec.ts new file mode 100644 index 000000000..4d026dd7c --- /dev/null +++ b/packages/auth/src/Domain/UseCase/ListSharedSubscriptionInvitations/ListSharedSubscriptionInvitations.spec.ts @@ -0,0 +1,22 @@ +import 'reflect-metadata' + +import { SharedSubscriptionInvitationRepositoryInterface } from '../../SharedSubscription/SharedSubscriptionInvitationRepositoryInterface' + +import { ListSharedSubscriptionInvitations } from './ListSharedSubscriptionInvitations' + +describe('ListSharedSubscriptionInvitations', () => { + let sharedSubscriptionInvitationRepository: SharedSubscriptionInvitationRepositoryInterface + + const createUseCase = () => new ListSharedSubscriptionInvitations(sharedSubscriptionInvitationRepository) + + beforeEach(() => { + sharedSubscriptionInvitationRepository = {} as jest.Mocked + sharedSubscriptionInvitationRepository.findByInviterEmail = jest.fn().mockReturnValue([]) + }) + + it('should find all invitations made by inviter', async () => { + expect(await createUseCase().execute({ inviterEmail: 'test@test.te' })).toEqual({ invitations: [] }) + + expect(sharedSubscriptionInvitationRepository.findByInviterEmail).toHaveBeenCalledWith('test@test.te') + }) +}) diff --git a/packages/auth/src/Domain/UseCase/ListSharedSubscriptionInvitations/ListSharedSubscriptionInvitations.ts b/packages/auth/src/Domain/UseCase/ListSharedSubscriptionInvitations/ListSharedSubscriptionInvitations.ts new file mode 100644 index 000000000..355c8efe4 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/ListSharedSubscriptionInvitations/ListSharedSubscriptionInvitations.ts @@ -0,0 +1,23 @@ +import { inject, injectable } from 'inversify' + +import TYPES from '../../../Bootstrap/Types' +import { SharedSubscriptionInvitationRepositoryInterface } from '../../SharedSubscription/SharedSubscriptionInvitationRepositoryInterface' +import { UseCaseInterface } from '../UseCaseInterface' +import { ListSharedSubscriptionInvitationsDTO } from './ListSharedSubscriptionInvitationsDTO' +import { ListSharedSubscriptionInvitationsResponse } from './ListSharedSubscriptionInvitationsResponse' + +@injectable() +export class ListSharedSubscriptionInvitations implements UseCaseInterface { + constructor( + @inject(TYPES.SharedSubscriptionInvitationRepository) + private sharedSubscriptionInvitationRepository: SharedSubscriptionInvitationRepositoryInterface, + ) {} + + async execute(dto: ListSharedSubscriptionInvitationsDTO): Promise { + const invitations = await this.sharedSubscriptionInvitationRepository.findByInviterEmail(dto.inviterEmail) + + return { + invitations, + } + } +} diff --git a/packages/auth/src/Domain/UseCase/ListSharedSubscriptionInvitations/ListSharedSubscriptionInvitationsDTO.ts b/packages/auth/src/Domain/UseCase/ListSharedSubscriptionInvitations/ListSharedSubscriptionInvitationsDTO.ts new file mode 100644 index 000000000..97857744c --- /dev/null +++ b/packages/auth/src/Domain/UseCase/ListSharedSubscriptionInvitations/ListSharedSubscriptionInvitationsDTO.ts @@ -0,0 +1,3 @@ +export type ListSharedSubscriptionInvitationsDTO = { + inviterEmail: string +} diff --git a/packages/auth/src/Domain/UseCase/ListSharedSubscriptionInvitations/ListSharedSubscriptionInvitationsResponse.ts b/packages/auth/src/Domain/UseCase/ListSharedSubscriptionInvitations/ListSharedSubscriptionInvitationsResponse.ts new file mode 100644 index 000000000..b62c73e90 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/ListSharedSubscriptionInvitations/ListSharedSubscriptionInvitationsResponse.ts @@ -0,0 +1,5 @@ +import { SharedSubscriptionInvitation } from '../../SharedSubscription/SharedSubscriptionInvitation' + +export type ListSharedSubscriptionInvitationsResponse = { + invitations: Array +} diff --git a/packages/auth/src/Domain/UseCase/MuteFailedBackupsEmails/MuteFailedBackupsEmails.spec.ts b/packages/auth/src/Domain/UseCase/MuteFailedBackupsEmails/MuteFailedBackupsEmails.spec.ts new file mode 100644 index 000000000..76e158742 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/MuteFailedBackupsEmails/MuteFailedBackupsEmails.spec.ts @@ -0,0 +1,39 @@ +import 'reflect-metadata' +import { Setting } from '../../Setting/Setting' +import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface' + +import { MuteFailedBackupsEmails } from './MuteFailedBackupsEmails' + +describe('MuteFailedBackupsEmails', () => { + let settingRepository: SettingRepositoryInterface + + const createUseCase = () => new MuteFailedBackupsEmails(settingRepository) + + beforeEach(() => { + const setting = {} as jest.Mocked + + settingRepository = {} as jest.Mocked + settingRepository.findOneByUuidAndNames = jest.fn().mockReturnValue(setting) + settingRepository.save = jest.fn() + }) + + it('should not succeed if extension setting is not found', async () => { + settingRepository.findOneByUuidAndNames = jest.fn().mockReturnValue(null) + + expect(await createUseCase().execute({ settingUuid: '1-2-3' })).toEqual({ + success: false, + message: 'Could not find setting setting.', + }) + }) + + it('should update mute email setting on extension setting', async () => { + expect(await createUseCase().execute({ settingUuid: '1-2-3' })).toEqual({ + success: true, + message: 'These emails have been muted.', + }) + + expect(settingRepository.save).toHaveBeenCalledWith({ + value: 'muted', + }) + }) +}) diff --git a/packages/auth/src/Domain/UseCase/MuteFailedBackupsEmails/MuteFailedBackupsEmails.ts b/packages/auth/src/Domain/UseCase/MuteFailedBackupsEmails/MuteFailedBackupsEmails.ts new file mode 100644 index 000000000..cc3303912 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/MuteFailedBackupsEmails/MuteFailedBackupsEmails.ts @@ -0,0 +1,35 @@ +import { MuteFailedBackupsEmailsOption, SettingName } from '@standardnotes/settings' +import { inject, injectable } from 'inversify' +import TYPES from '../../../Bootstrap/Types' +import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface' +import { UseCaseInterface } from '../UseCaseInterface' +import { MuteFailedBackupsEmailsDTO } from './MuteFailedBackupsEmailsDTO' +import { MuteFailedBackupsEmailsResponse } from './MuteFailedBackupsEmailsResponse' + +@injectable() +export class MuteFailedBackupsEmails implements UseCaseInterface { + constructor(@inject(TYPES.SettingRepository) private settingRepository: SettingRepositoryInterface) {} + + async execute(dto: MuteFailedBackupsEmailsDTO): Promise { + const setting = await this.settingRepository.findOneByUuidAndNames(dto.settingUuid, [ + SettingName.MuteFailedBackupsEmails, + SettingName.MuteFailedCloudBackupsEmails, + ]) + + if (setting === null) { + return { + success: false, + message: 'Could not find setting setting.', + } + } + + setting.value = MuteFailedBackupsEmailsOption.Muted + + await this.settingRepository.save(setting) + + return { + success: true, + message: 'These emails have been muted.', + } + } +} diff --git a/packages/auth/src/Domain/UseCase/MuteFailedBackupsEmails/MuteFailedBackupsEmailsDTO.ts b/packages/auth/src/Domain/UseCase/MuteFailedBackupsEmails/MuteFailedBackupsEmailsDTO.ts new file mode 100644 index 000000000..089a60ec7 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/MuteFailedBackupsEmails/MuteFailedBackupsEmailsDTO.ts @@ -0,0 +1,3 @@ +export type MuteFailedBackupsEmailsDTO = { + settingUuid: string +} diff --git a/packages/auth/src/Domain/UseCase/MuteFailedBackupsEmails/MuteFailedBackupsEmailsResponse.ts b/packages/auth/src/Domain/UseCase/MuteFailedBackupsEmails/MuteFailedBackupsEmailsResponse.ts new file mode 100644 index 000000000..28c823671 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/MuteFailedBackupsEmails/MuteFailedBackupsEmailsResponse.ts @@ -0,0 +1,4 @@ +export type MuteFailedBackupsEmailsResponse = { + success: boolean + message: string +} diff --git a/packages/auth/src/Domain/UseCase/MuteSignInEmails/MuteSignInEmails.spec.ts b/packages/auth/src/Domain/UseCase/MuteSignInEmails/MuteSignInEmails.spec.ts new file mode 100644 index 000000000..133bd9c92 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/MuteSignInEmails/MuteSignInEmails.spec.ts @@ -0,0 +1,39 @@ +import 'reflect-metadata' +import { Setting } from '../../Setting/Setting' +import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface' + +import { MuteSignInEmails } from './MuteSignInEmails' + +describe('MuteSignInEmails', () => { + let settingRepository: SettingRepositoryInterface + + const createUseCase = () => new MuteSignInEmails(settingRepository) + + beforeEach(() => { + const setting = {} as jest.Mocked + + settingRepository = {} as jest.Mocked + settingRepository.findOneByUuidAndNames = jest.fn().mockReturnValue(setting) + settingRepository.save = jest.fn() + }) + + it('should not succeed if extension setting is not found', async () => { + settingRepository.findOneByUuidAndNames = jest.fn().mockReturnValue(null) + + expect(await createUseCase().execute({ settingUuid: '1-2-3' })).toEqual({ + success: false, + message: 'Could not find setting setting.', + }) + }) + + it('should update mute email setting on extension setting', async () => { + expect(await createUseCase().execute({ settingUuid: '1-2-3' })).toEqual({ + success: true, + message: 'These emails have been muted.', + }) + + expect(settingRepository.save).toHaveBeenCalledWith({ + value: 'muted', + }) + }) +}) diff --git a/packages/auth/src/Domain/UseCase/MuteSignInEmails/MuteSignInEmails.ts b/packages/auth/src/Domain/UseCase/MuteSignInEmails/MuteSignInEmails.ts new file mode 100644 index 000000000..76ba0314c --- /dev/null +++ b/packages/auth/src/Domain/UseCase/MuteSignInEmails/MuteSignInEmails.ts @@ -0,0 +1,32 @@ +import { MuteSignInEmailsOption, SettingName } from '@standardnotes/settings' +import { inject, injectable } from 'inversify' +import TYPES from '../../../Bootstrap/Types' +import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface' +import { UseCaseInterface } from '../UseCaseInterface' +import { MuteSignInEmailsDTO } from './MuteSignInEmailsDTO' +import { MuteSignInEmailsResponse } from './MuteSignInEmailsResponse' + +@injectable() +export class MuteSignInEmails implements UseCaseInterface { + constructor(@inject(TYPES.SettingRepository) private settingRepository: SettingRepositoryInterface) {} + + async execute(dto: MuteSignInEmailsDTO): Promise { + const setting = await this.settingRepository.findOneByUuidAndNames(dto.settingUuid, [SettingName.MuteSignInEmails]) + + if (setting === null) { + return { + success: false, + message: 'Could not find setting setting.', + } + } + + setting.value = MuteSignInEmailsOption.Muted + + await this.settingRepository.save(setting) + + return { + success: true, + message: 'These emails have been muted.', + } + } +} diff --git a/packages/auth/src/Domain/UseCase/MuteSignInEmails/MuteSignInEmailsDTO.ts b/packages/auth/src/Domain/UseCase/MuteSignInEmails/MuteSignInEmailsDTO.ts new file mode 100644 index 000000000..a59540c1c --- /dev/null +++ b/packages/auth/src/Domain/UseCase/MuteSignInEmails/MuteSignInEmailsDTO.ts @@ -0,0 +1,3 @@ +export type MuteSignInEmailsDTO = { + settingUuid: string +} diff --git a/packages/auth/src/Domain/UseCase/MuteSignInEmails/MuteSignInEmailsResponse.ts b/packages/auth/src/Domain/UseCase/MuteSignInEmails/MuteSignInEmailsResponse.ts new file mode 100644 index 000000000..32790e301 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/MuteSignInEmails/MuteSignInEmailsResponse.ts @@ -0,0 +1,4 @@ +export type MuteSignInEmailsResponse = { + success: boolean + message: string +} diff --git a/packages/auth/src/Domain/UseCase/RefreshSessionToken.spec.ts b/packages/auth/src/Domain/UseCase/RefreshSessionToken.spec.ts new file mode 100644 index 000000000..32cc0fed8 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/RefreshSessionToken.spec.ts @@ -0,0 +1,93 @@ +import 'reflect-metadata' +import * as dayjs from 'dayjs' + +import { Session } from '../Session/Session' +import { SessionServiceInterface } from '../Session/SessionServiceInterface' +import { RefreshSessionToken } from './RefreshSessionToken' + +describe('RefreshSessionToken', () => { + let sessionService: SessionServiceInterface + let session: Session + + const createUseCase = () => new RefreshSessionToken(sessionService) + + beforeEach(() => { + session = {} as jest.Mocked + session.uuid = '1-2-3' + session.refreshExpiration = dayjs.utc().add(1, 'day').toDate() + + sessionService = {} as jest.Mocked + sessionService.isRefreshTokenValid = jest.fn().mockReturnValue(true) + sessionService.getSessionFromToken = jest.fn().mockReturnValue(session) + sessionService.refreshTokens = jest.fn().mockReturnValue({ + access_token: 'token1', + refresh_token: 'token2', + access_expiration: 123, + refresh_expiration: 234, + }) + }) + + it('should refresh session token', async () => { + const result = await createUseCase().execute({ + accessToken: '123', + refreshToken: '234', + }) + + expect(sessionService.refreshTokens).toHaveBeenCalledWith(session) + + expect(result).toEqual({ + success: true, + sessionPayload: { + access_token: 'token1', + refresh_token: 'token2', + access_expiration: 123, + refresh_expiration: 234, + }, + }) + }) + + it('should not refresh a session token if session is not found', async () => { + sessionService.getSessionFromToken = jest.fn().mockReturnValue(null) + + const result = await createUseCase().execute({ + accessToken: '123', + refreshToken: '234', + }) + + expect(result).toEqual({ + success: false, + errorTag: 'invalid-parameters', + errorMessage: 'The provided parameters are not valid.', + }) + }) + + it('should not refresh a session token if refresh token is not valid', async () => { + sessionService.isRefreshTokenValid = jest.fn().mockReturnValue(false) + + const result = await createUseCase().execute({ + accessToken: '123', + refreshToken: '234', + }) + + expect(result).toEqual({ + success: false, + errorTag: 'invalid-refresh-token', + errorMessage: 'The refresh token is not valid.', + }) + }) + + it('should not refresh a session token if refresh token is expired', async () => { + session.refreshExpiration = dayjs.utc().subtract(1, 'day').toDate() + + const result = await createUseCase().execute({ + accessToken: '123', + refreshToken: '234', + }) + + expect(result).toEqual({ + success: false, + errorTag: 'expired-refresh-token', + errorMessage: 'The refresh token has expired.', + }) + }) +}) diff --git a/packages/auth/src/Domain/UseCase/RefreshSessionToken.ts b/packages/auth/src/Domain/UseCase/RefreshSessionToken.ts new file mode 100644 index 000000000..fdf709882 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/RefreshSessionToken.ts @@ -0,0 +1,47 @@ +import * as dayjs from 'dayjs' + +import { inject, injectable } from 'inversify' +import TYPES from '../../Bootstrap/Types' +import { SessionServiceInterface } from '../Session/SessionServiceInterface' +import { RefreshSessionTokenResponse } from './RefreshSessionTokenResponse' +import { RefreshSessionTokenDTO } from './RefreshSessionTokenDTO' + +@injectable() +export class RefreshSessionToken { + constructor(@inject(TYPES.SessionService) private sessionService: SessionServiceInterface) {} + + async execute(dto: RefreshSessionTokenDTO): Promise { + const session = await this.sessionService.getSessionFromToken(dto.accessToken) + if (!session) { + return { + success: false, + errorTag: 'invalid-parameters', + errorMessage: 'The provided parameters are not valid.', + } + } + + if (!this.sessionService.isRefreshTokenValid(session, dto.refreshToken)) { + return { + success: false, + errorTag: 'invalid-refresh-token', + errorMessage: 'The refresh token is not valid.', + } + } + + if (session.refreshExpiration < dayjs.utc().toDate()) { + return { + success: false, + errorTag: 'expired-refresh-token', + errorMessage: 'The refresh token has expired.', + } + } + + const sessionPayload = await this.sessionService.refreshTokens(session) + + return { + success: true, + sessionPayload, + userUuid: session.userUuid, + } + } +} diff --git a/packages/auth/src/Domain/UseCase/RefreshSessionTokenDTO.ts b/packages/auth/src/Domain/UseCase/RefreshSessionTokenDTO.ts new file mode 100644 index 000000000..c220c3f6f --- /dev/null +++ b/packages/auth/src/Domain/UseCase/RefreshSessionTokenDTO.ts @@ -0,0 +1,4 @@ +export type RefreshSessionTokenDTO = { + accessToken: string + refreshToken: string +} diff --git a/packages/auth/src/Domain/UseCase/RefreshSessionTokenResponse.ts b/packages/auth/src/Domain/UseCase/RefreshSessionTokenResponse.ts new file mode 100644 index 000000000..1dfcec810 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/RefreshSessionTokenResponse.ts @@ -0,0 +1,9 @@ +import { SessionBody } from '@standardnotes/responses' + +export type RefreshSessionTokenResponse = { + success: boolean + userUuid?: string + errorTag?: string + errorMessage?: string + sessionPayload?: SessionBody +} diff --git a/packages/auth/src/Domain/UseCase/Register.spec.ts b/packages/auth/src/Domain/UseCase/Register.spec.ts new file mode 100644 index 000000000..731f2361b --- /dev/null +++ b/packages/auth/src/Domain/UseCase/Register.spec.ts @@ -0,0 +1,209 @@ +import 'reflect-metadata' +import { TimerInterface } from '@standardnotes/time' + +import { CrypterInterface } from '../Encryption/CrypterInterface' +import { Role } from '../Role/Role' +import { RoleRepositoryInterface } from '../Role/RoleRepositoryInterface' +import { User } from '../User/User' + +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { Register } from './Register' +import { SettingServiceInterface } from '../Setting/SettingServiceInterface' +import { AnalyticsEntityRepositoryInterface } from '../Analytics/AnalyticsEntityRepositoryInterface' +import { AuthResponseFactory20200115 } from '../Auth/AuthResponseFactory20200115' + +describe('Register', () => { + let userRepository: UserRepositoryInterface + let roleRepository: RoleRepositoryInterface + let authResponseFactory: AuthResponseFactory20200115 + let settingService: SettingServiceInterface + let user: User + let crypter: CrypterInterface + let timer: TimerInterface + let analyticsEntityRepository: AnalyticsEntityRepositoryInterface + + const createUseCase = () => + new Register( + userRepository, + roleRepository, + authResponseFactory, + crypter, + false, + settingService, + timer, + analyticsEntityRepository, + ) + + beforeEach(() => { + userRepository = {} as jest.Mocked + userRepository.save = jest.fn() + userRepository.findOneByEmail = jest.fn().mockReturnValue(null) + + roleRepository = {} as jest.Mocked + roleRepository.findOneByName = jest.fn().mockReturnValue(null) + + authResponseFactory = {} as jest.Mocked + authResponseFactory.createResponse = jest.fn().mockReturnValue({ foo: 'bar' }) + + crypter = {} as jest.Mocked + crypter.generateEncryptedUserServerKey = jest.fn().mockReturnValue('test') + + user = {} as jest.Mocked + + settingService = {} as jest.Mocked + settingService.applyDefaultSettingsUponRegistration = jest.fn() + + timer = {} as jest.Mocked + timer.getUTCDate = jest.fn().mockReturnValue(new Date(1)) + + analyticsEntityRepository = {} as jest.Mocked + analyticsEntityRepository.save = jest.fn() + }) + + it('should register a new user', async () => { + expect( + await createUseCase().execute({ + email: 'test@test.te', + password: 'asdzxc', + updatedWithUserAgent: 'Mozilla', + apiVersion: '20200115', + ephemeralSession: false, + version: '004', + pwCost: 11, + pwSalt: 'qweqwe', + pwNonce: undefined, + }), + ).toEqual({ success: true, authResponse: { foo: 'bar' } }) + + expect(userRepository.save).toHaveBeenCalledWith({ + email: 'test@test.te', + encryptedPassword: expect.any(String), + encryptedServerKey: 'test', + serverEncryptionVersion: 1, + pwCost: 11, + pwNonce: undefined, + pwSalt: 'qweqwe', + updatedWithUserAgent: 'Mozilla', + uuid: expect.any(String), + version: '004', + createdAt: new Date(1), + updatedAt: new Date(1), + }) + + expect(settingService.applyDefaultSettingsUponRegistration).toHaveBeenCalled() + + expect(analyticsEntityRepository.save).toHaveBeenCalled() + }) + + it('should register a new user with default role', async () => { + const role = new Role() + role.name = 'role1' + roleRepository.findOneByName = jest.fn().mockReturnValue(role) + + expect( + await createUseCase().execute({ + email: 'test@test.te', + password: 'asdzxc', + updatedWithUserAgent: 'Mozilla', + apiVersion: '20200115', + ephemeralSession: false, + version: '004', + pwCost: 11, + pwSalt: 'qweqwe', + pwNonce: undefined, + }), + ).toEqual({ success: true, authResponse: { foo: 'bar' } }) + + expect(userRepository.save).toHaveBeenCalledWith({ + email: 'test@test.te', + encryptedPassword: expect.any(String), + encryptedServerKey: 'test', + serverEncryptionVersion: 1, + pwCost: 11, + pwNonce: undefined, + pwSalt: 'qweqwe', + updatedWithUserAgent: 'Mozilla', + uuid: expect.any(String), + version: '004', + createdAt: new Date(1), + updatedAt: new Date(1), + roles: Promise.resolve([role]), + }) + }) + + it('should fail to register if a user already exists', async () => { + userRepository.findOneByEmail = jest.fn().mockReturnValue(user) + + expect( + await createUseCase().execute({ + email: 'test@test.te', + password: 'asdzxc', + updatedWithUserAgent: 'Mozilla', + apiVersion: '20200115', + ephemeralSession: false, + version: '004', + pwCost: 11, + pwSalt: 'qweqwe', + pwNonce: undefined, + }), + ).toEqual({ + success: false, + errorMessage: 'This email is already registered.', + }) + + expect(userRepository.save).not.toHaveBeenCalled() + }) + + it('should fail to register for legacy api versions', async () => { + expect( + await createUseCase().execute({ + email: 'test@test.te', + password: 'asdzxc', + updatedWithUserAgent: 'Mozilla', + apiVersion: '20190520', + ephemeralSession: false, + version: '004', + pwCost: 11, + pwSalt: 'qweqwe', + pwNonce: undefined, + }), + ).toEqual({ + success: false, + errorMessage: 'Unsupported api version: 20190520', + }) + + expect(userRepository.save).not.toHaveBeenCalled() + }) + + it('should fail to register if a registration is disabled', async () => { + userRepository.findOneByEmail = jest.fn().mockReturnValue(user) + + expect( + await new Register( + userRepository, + roleRepository, + authResponseFactory, + crypter, + true, + settingService, + timer, + analyticsEntityRepository, + ).execute({ + email: 'test@test.te', + password: 'asdzxc', + updatedWithUserAgent: 'Mozilla', + apiVersion: '20200115', + version: '004', + ephemeralSession: false, + pwCost: 11, + pwSalt: 'qweqwe', + pwNonce: undefined, + }), + ).toEqual({ + success: false, + errorMessage: 'User registration is currently not allowed.', + }) + + expect(userRepository.save).not.toHaveBeenCalled() + }) +}) diff --git a/packages/auth/src/Domain/UseCase/Register.ts b/packages/auth/src/Domain/UseCase/Register.ts new file mode 100644 index 000000000..2416a7650 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/Register.ts @@ -0,0 +1,95 @@ +import * as bcrypt from 'bcryptjs' +import { RoleName } from '@standardnotes/common' +import { ApiVersion } from '@standardnotes/api' + +import { v4 as uuidv4 } from 'uuid' +import { inject, injectable } from 'inversify' +import TYPES from '../../Bootstrap/Types' +import { User } from '../User/User' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { RegisterDTO } from './RegisterDTO' +import { RegisterResponse } from './RegisterResponse' +import { UseCaseInterface } from './UseCaseInterface' +import { RoleRepositoryInterface } from '../Role/RoleRepositoryInterface' +import { CrypterInterface } from '../Encryption/CrypterInterface' +import { TimerInterface } from '@standardnotes/time' +import { SettingServiceInterface } from '../Setting/SettingServiceInterface' +import { AnalyticsEntityRepositoryInterface } from '../Analytics/AnalyticsEntityRepositoryInterface' +import { AnalyticsEntity } from '../Analytics/AnalyticsEntity' +import { AuthResponseFactory20200115 } from '../Auth/AuthResponseFactory20200115' +import { AuthResponse20200115 } from '../Auth/AuthResponse20200115' + +@injectable() +export class Register implements UseCaseInterface { + constructor( + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.RoleRepository) private roleRepository: RoleRepositoryInterface, + @inject(TYPES.AuthResponseFactory20200115) private authResponseFactory20200115: AuthResponseFactory20200115, + @inject(TYPES.Crypter) private crypter: CrypterInterface, + @inject(TYPES.DISABLE_USER_REGISTRATION) private disableUserRegistration: boolean, + @inject(TYPES.SettingService) private settingService: SettingServiceInterface, + @inject(TYPES.Timer) private timer: TimerInterface, + @inject(TYPES.AnalyticsEntityRepository) private analyticsEntityRepository: AnalyticsEntityRepositoryInterface, + ) {} + + async execute(dto: RegisterDTO): Promise { + if (this.disableUserRegistration) { + return { + success: false, + errorMessage: 'User registration is currently not allowed.', + } + } + + const { email, password, apiVersion, ephemeralSession, ...registrationFields } = dto + + if (apiVersion !== ApiVersion.v0) { + return { + success: false, + errorMessage: `Unsupported api version: ${apiVersion}`, + } + } + + const existingUser = await this.userRepository.findOneByEmail(email) + if (existingUser) { + return { + success: false, + errorMessage: 'This email is already registered.', + } + } + + let user = new User() + user.uuid = uuidv4() + user.email = email + user.createdAt = this.timer.getUTCDate() + user.updatedAt = this.timer.getUTCDate() + user.encryptedPassword = await bcrypt.hash(password, User.PASSWORD_HASH_COST) + user.encryptedServerKey = await this.crypter.generateEncryptedUserServerKey() + user.serverEncryptionVersion = User.DEFAULT_ENCRYPTION_VERSION + + const defaultRole = await this.roleRepository.findOneByName(RoleName.CoreUser) + if (defaultRole) { + user.roles = Promise.resolve([defaultRole]) + } + + Object.assign(user, registrationFields) + + user = await this.userRepository.save(user) + + await this.settingService.applyDefaultSettingsUponRegistration(user) + + const analyticsEntity = new AnalyticsEntity() + analyticsEntity.user = Promise.resolve(user) + await this.analyticsEntityRepository.save(analyticsEntity) + + return { + success: true, + authResponse: (await this.authResponseFactory20200115.createResponse({ + user, + apiVersion, + userAgent: dto.updatedWithUserAgent, + ephemeralSession, + readonlyAccess: false, + })) as AuthResponse20200115, + } + } +} diff --git a/packages/auth/src/Domain/UseCase/RegisterDTO.ts b/packages/auth/src/Domain/UseCase/RegisterDTO.ts new file mode 100644 index 000000000..85bc1276e --- /dev/null +++ b/packages/auth/src/Domain/UseCase/RegisterDTO.ts @@ -0,0 +1,13 @@ +export type RegisterDTO = { + email: string + password: string + updatedWithUserAgent: string + apiVersion: string + ephemeralSession: boolean + pwCost?: number + pwNonce?: string + pwSalt?: string + kpOrigination?: string + kpCreated?: string + version?: string +} diff --git a/packages/auth/src/Domain/UseCase/RegisterResponse.ts b/packages/auth/src/Domain/UseCase/RegisterResponse.ts new file mode 100644 index 000000000..3c05cd103 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/RegisterResponse.ts @@ -0,0 +1,11 @@ +import { AuthResponse20200115 } from '../Auth/AuthResponse20200115' + +export type RegisterResponse = + | { + success: true + authResponse: AuthResponse20200115 + } + | { + success: false + errorMessage: string + } diff --git a/packages/auth/src/Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnection.spec.ts b/packages/auth/src/Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnection.spec.ts new file mode 100644 index 000000000..4316009b6 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnection.spec.ts @@ -0,0 +1,26 @@ +import 'reflect-metadata' +import { Logger } from 'winston' +import { WebSocketsConnectionRepositoryInterface } from '../../WebSockets/WebSocketsConnectionRepositoryInterface' + +import { RemoveWebSocketsConnection } from './RemoveWebSocketsConnection' + +describe('RemoveWebSocketsConnection', () => { + let webSocketsConnectionRepository: WebSocketsConnectionRepositoryInterface + let logger: Logger + + const createUseCase = () => new RemoveWebSocketsConnection(webSocketsConnectionRepository, logger) + + beforeEach(() => { + webSocketsConnectionRepository = {} as jest.Mocked + webSocketsConnectionRepository.removeConnection = jest.fn() + + logger = {} as jest.Mocked + logger.debug = jest.fn() + }) + + it('should remove a web sockets connection', async () => { + await createUseCase().execute({ connectionId: '2-3-4' }) + + expect(webSocketsConnectionRepository.removeConnection).toHaveBeenCalledWith('2-3-4') + }) +}) diff --git a/packages/auth/src/Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnection.ts b/packages/auth/src/Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnection.ts new file mode 100644 index 000000000..3592d3845 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnection.ts @@ -0,0 +1,26 @@ +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' +import TYPES from '../../../Bootstrap/Types' +import { WebSocketsConnectionRepositoryInterface } from '../../WebSockets/WebSocketsConnectionRepositoryInterface' +import { UseCaseInterface } from '../UseCaseInterface' +import { RemoveWebSocketsConnectionDTO } from './RemoveWebSocketsConnectionDTO' +import { RemoveWebSocketsConnectionResponse } from './RemoveWebSocketsConnectionResponse' + +@injectable() +export class RemoveWebSocketsConnection implements UseCaseInterface { + constructor( + @inject(TYPES.WebSocketsConnectionRepository) + private webSocketsConnectionRepository: WebSocketsConnectionRepositoryInterface, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async execute(dto: RemoveWebSocketsConnectionDTO): Promise { + this.logger.debug(`Removing connection ${dto.connectionId}`) + + await this.webSocketsConnectionRepository.removeConnection(dto.connectionId) + + return { + success: true, + } + } +} diff --git a/packages/auth/src/Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnectionDTO.ts b/packages/auth/src/Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnectionDTO.ts new file mode 100644 index 000000000..ac77ebf56 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnectionDTO.ts @@ -0,0 +1,3 @@ +export type RemoveWebSocketsConnectionDTO = { + connectionId: string +} diff --git a/packages/auth/src/Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnectionResponse.ts b/packages/auth/src/Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnectionResponse.ts new file mode 100644 index 000000000..2340e8e02 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/RemoveWebSocketsConnection/RemoveWebSocketsConnectionResponse.ts @@ -0,0 +1,3 @@ +export type RemoveWebSocketsConnectionResponse = { + success: boolean +} diff --git a/packages/auth/src/Domain/UseCase/SignIn.spec.ts b/packages/auth/src/Domain/UseCase/SignIn.spec.ts new file mode 100644 index 000000000..c39ca2784 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/SignIn.spec.ts @@ -0,0 +1,293 @@ +import 'reflect-metadata' + +import { DomainEventPublisherInterface, UserSignedInEvent } from '@standardnotes/domain-events' +import { Logger } from 'winston' + +import { AuthResponseFactoryInterface } from '../Auth/AuthResponseFactoryInterface' +import { AuthResponseFactoryResolverInterface } from '../Auth/AuthResponseFactoryResolverInterface' +import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface' +import { SessionServiceInterface } from '../Session/SessionServiceInterface' +import { User } from '../User/User' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { SignIn } from './SignIn' +import { RoleServiceInterface } from '../Role/RoleServiceInterface' +import { SettingServiceInterface } from '../Setting/SettingServiceInterface' +import { Setting } from '../Setting/Setting' +import { MuteSignInEmailsOption } from '@standardnotes/settings' +import { PKCERepositoryInterface } from '../User/PKCERepositoryInterface' +import { CrypterInterface } from '../Encryption/CrypterInterface' + +describe('SignIn', () => { + let user: User + let userRepository: UserRepositoryInterface + let authResponseFactoryResolver: AuthResponseFactoryResolverInterface + let authResponseFactory: AuthResponseFactoryInterface + let domainEventPublisher: DomainEventPublisherInterface + let domainEventFactory: DomainEventFactoryInterface + let sessionService: SessionServiceInterface + let roleService: RoleServiceInterface + let logger: Logger + let settingService: SettingServiceInterface + let setting: Setting + let pkceRepository: PKCERepositoryInterface + let crypter: CrypterInterface + + const createUseCase = () => + new SignIn( + userRepository, + authResponseFactoryResolver, + domainEventPublisher, + domainEventFactory, + sessionService, + roleService, + settingService, + pkceRepository, + crypter, + logger, + ) + + beforeEach(() => { + user = { + uuid: '1-2-3', + email: 'test@test.com', + } as jest.Mocked + user.encryptedPassword = '$2a$11$K3g6XoTau8VmLJcai1bB0eD9/YvBSBRtBhMprJOaVZ0U3SgasZH3a' + + userRepository = {} as jest.Mocked + userRepository.findOneByEmail = jest.fn().mockReturnValue(user) + + authResponseFactory = {} as jest.Mocked + authResponseFactory.createResponse = jest.fn().mockReturnValue({ foo: 'bar' }) + + authResponseFactoryResolver = {} as jest.Mocked + authResponseFactoryResolver.resolveAuthResponseFactoryVersion = jest.fn().mockReturnValue(authResponseFactory) + + domainEventPublisher = {} as jest.Mocked + domainEventPublisher.publish = jest.fn() + + domainEventFactory = {} as jest.Mocked + domainEventFactory.createUserSignedInEvent = jest.fn().mockReturnValue({} as jest.Mocked) + + sessionService = {} as jest.Mocked + sessionService.getOperatingSystemInfoFromUserAgent = jest.fn().mockReturnValue('iOS 1') + sessionService.getBrowserInfoFromUserAgent = jest.fn().mockReturnValue('Firefox 1') + + roleService = {} as jest.Mocked + roleService.userHasPermission = jest.fn().mockReturnValue(true) + + setting = { + uuid: '3-4-5', + value: MuteSignInEmailsOption.NotMuted, + } as jest.Mocked + + settingService = {} as jest.Mocked + settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(setting) + settingService.createOrReplace = jest.fn().mockReturnValue({ + status: 'created', + setting, + }) + + pkceRepository = {} as jest.Mocked + pkceRepository.removeCodeChallenge = jest.fn().mockReturnValue(true) + + crypter = {} as jest.Mocked + crypter.base64URLEncode = jest.fn().mockReturnValue('base64-url-encoded') + crypter.sha256Hash = jest.fn().mockReturnValue('sha256-hashed') + + logger = {} as jest.Mocked + logger.debug = jest.fn() + logger.error = jest.fn() + }) + + it('should sign in a user', async () => { + expect( + await createUseCase().execute({ + email: 'test@test.te', + password: 'qweqwe123123', + userAgent: 'Google Chrome', + apiVersion: '20190520', + ephemeralSession: false, + }), + ).toEqual({ + success: true, + authResponse: { foo: 'bar' }, + }) + + expect(domainEventFactory.createUserSignedInEvent).toHaveBeenCalledWith({ + browser: 'Firefox 1', + device: 'iOS 1', + userEmail: 'test@test.com', + userUuid: '1-2-3', + signInAlertEnabled: true, + muteSignInEmailsSettingUuid: '3-4-5', + }) + expect(domainEventPublisher.publish).toHaveBeenCalled() + }) + + it('should sign in a user with valid code verifier', async () => { + expect( + await createUseCase().execute({ + email: 'test@test.te', + password: 'qweqwe123123', + userAgent: 'Google Chrome', + apiVersion: '20190520', + ephemeralSession: false, + codeVerifier: 'test', + }), + ).toEqual({ + success: true, + authResponse: { foo: 'bar' }, + }) + + expect(domainEventFactory.createUserSignedInEvent).toHaveBeenCalledWith({ + browser: 'Firefox 1', + device: 'iOS 1', + userEmail: 'test@test.com', + userUuid: '1-2-3', + signInAlertEnabled: true, + muteSignInEmailsSettingUuid: '3-4-5', + }) + expect(domainEventPublisher.publish).toHaveBeenCalled() + }) + + it('should sign in a user and disable sign in alert if setting is configured', async () => { + setting = { + uuid: '3-4-5', + value: MuteSignInEmailsOption.Muted, + } as jest.Mocked + + settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(setting) + + expect( + await createUseCase().execute({ + email: 'test@test.te', + password: 'qweqwe123123', + userAgent: 'Google Chrome', + apiVersion: '20190520', + ephemeralSession: false, + }), + ).toEqual({ + success: true, + authResponse: { foo: 'bar' }, + }) + + expect(domainEventFactory.createUserSignedInEvent).toHaveBeenCalledWith({ + browser: 'Firefox 1', + device: 'iOS 1', + userEmail: 'test@test.com', + userUuid: '1-2-3', + signInAlertEnabled: false, + muteSignInEmailsSettingUuid: '3-4-5', + }) + expect(domainEventPublisher.publish).toHaveBeenCalled() + }) + + it('should sign in a user and create mute sign in email setting if it does not exist', async () => { + settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(null) + + expect( + await createUseCase().execute({ + email: 'test@test.te', + password: 'qweqwe123123', + userAgent: 'Google Chrome', + apiVersion: '20190520', + ephemeralSession: false, + }), + ).toEqual({ + success: true, + authResponse: { foo: 'bar' }, + }) + + expect(domainEventFactory.createUserSignedInEvent).toHaveBeenCalledWith({ + browser: 'Firefox 1', + device: 'iOS 1', + userEmail: 'test@test.com', + userUuid: '1-2-3', + signInAlertEnabled: true, + muteSignInEmailsSettingUuid: '3-4-5', + }) + expect(domainEventPublisher.publish).toHaveBeenCalled() + expect(settingService.createOrReplace).toHaveBeenCalledWith({ + props: { + name: 'MUTE_SIGN_IN_EMAILS', + sensitive: false, + serverEncryptionVersion: 0, + unencryptedValue: 'not_muted', + }, + user: { + email: 'test@test.com', + encryptedPassword: '$2a$11$K3g6XoTau8VmLJcai1bB0eD9/YvBSBRtBhMprJOaVZ0U3SgasZH3a', + uuid: '1-2-3', + }, + }) + }) + + it('should sign in a user even if publishing a sign in event fails', async () => { + domainEventPublisher.publish = jest.fn().mockImplementation(() => { + throw new Error('Oops') + }) + + expect( + await createUseCase().execute({ + email: 'test@test.te', + password: 'qweqwe123123', + userAgent: 'Google Chrome', + apiVersion: '20190520', + ephemeralSession: false, + }), + ).toEqual({ + success: true, + authResponse: { foo: 'bar' }, + }) + }) + + it('should not sign in a user with wrong credentials', async () => { + expect( + await createUseCase().execute({ + email: 'test@test.te', + password: 'asdasd123123', + userAgent: 'Google Chrome', + apiVersion: '20190520', + ephemeralSession: false, + }), + ).toEqual({ + success: false, + errorMessage: 'Invalid email or password', + }) + }) + + it('should not sign in a user with invalid code verifier', async () => { + pkceRepository.removeCodeChallenge = jest.fn().mockReturnValue(false) + + expect( + await createUseCase().execute({ + email: 'test@test.te', + password: 'qweqwe123123', + userAgent: 'Google Chrome', + apiVersion: '20190520', + ephemeralSession: false, + codeVerifier: 'test', + }), + ).toEqual({ + success: false, + errorMessage: 'Invalid email or password', + }) + }) + + it('should not sign in a user that does not exist', async () => { + userRepository.findOneByEmail = jest.fn().mockReturnValue(null) + + expect( + await createUseCase().execute({ + email: 'test@test.te', + password: 'asdasd123123', + userAgent: 'Google Chrome', + apiVersion: '20190520', + ephemeralSession: false, + }), + ).toEqual({ + success: false, + errorMessage: 'Invalid email or password', + }) + }) +}) diff --git a/packages/auth/src/Domain/UseCase/SignIn.ts b/packages/auth/src/Domain/UseCase/SignIn.ts new file mode 100644 index 000000000..f316ae893 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/SignIn.ts @@ -0,0 +1,146 @@ +import * as bcrypt from 'bcryptjs' +import { DomainEventPublisherInterface } from '@standardnotes/domain-events' +import { PermissionName } from '@standardnotes/features' +import { MuteSignInEmailsOption, SettingName } from '@standardnotes/settings' + +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' +import TYPES from '../../Bootstrap/Types' +import { AuthResponseFactoryResolverInterface } from '../Auth/AuthResponseFactoryResolverInterface' +import { EncryptionVersion } from '../Encryption/EncryptionVersion' +import { DomainEventFactoryInterface } from '../Event/DomainEventFactoryInterface' +import { RoleServiceInterface } from '../Role/RoleServiceInterface' +import { SessionServiceInterface } from '../Session/SessionServiceInterface' +import { Setting } from '../Setting/Setting' +import { SettingServiceInterface } from '../Setting/SettingServiceInterface' +import { User } from '../User/User' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { SignInDTO } from './SignInDTO' +import { SignInResponse } from './SignInResponse' +import { UseCaseInterface } from './UseCaseInterface' +import { PKCERepositoryInterface } from '../User/PKCERepositoryInterface' +import { CrypterInterface } from '../Encryption/CrypterInterface' +import { SignInDTOV2Challenged } from './SignInDTOV2Challenged' + +@injectable() +export class SignIn implements UseCaseInterface { + constructor( + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.AuthResponseFactoryResolver) + private authResponseFactoryResolver: AuthResponseFactoryResolverInterface, + @inject(TYPES.DomainEventPublisher) private domainEventPublisher: DomainEventPublisherInterface, + @inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface, + @inject(TYPES.SessionService) private sessionService: SessionServiceInterface, + @inject(TYPES.RoleService) private roleService: RoleServiceInterface, + @inject(TYPES.SettingService) private settingService: SettingServiceInterface, + @inject(TYPES.PKCERepository) private pkceRepository: PKCERepositoryInterface, + @inject(TYPES.Crypter) private crypter: CrypterInterface, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async execute(dto: SignInDTO): Promise { + if (this.isCodeChallengedVersion(dto)) { + const validCodeVerifier = await this.validateCodeVerifier(dto.codeVerifier) + if (!validCodeVerifier) { + this.logger.debug('Code verifier does not match') + + return { + success: false, + errorMessage: 'Invalid email or password', + } + } + } + + const user = await this.userRepository.findOneByEmail(dto.email) + + if (!user) { + this.logger.debug(`User with email ${dto.email} was not found`) + + return { + success: false, + errorMessage: 'Invalid email or password', + } + } + + const passwordMatches = await bcrypt.compare(dto.password, user.encryptedPassword) + if (!passwordMatches) { + this.logger.debug('Password does not match') + + return { + success: false, + errorMessage: 'Invalid email or password', + } + } + + const authResponseFactory = this.authResponseFactoryResolver.resolveAuthResponseFactoryVersion(dto.apiVersion) + + await this.sendSignInEmailNotification(user, dto.userAgent) + + return { + success: true, + authResponse: await authResponseFactory.createResponse({ + user, + apiVersion: dto.apiVersion, + userAgent: dto.userAgent, + ephemeralSession: dto.ephemeralSession, + readonlyAccess: false, + }), + } + } + + private async validateCodeVerifier(codeVerifier: string): Promise { + const codeChallenge = this.crypter.base64URLEncode(this.crypter.sha256Hash(codeVerifier)) + + const matchingCodeChallengeWasPresentAndRemoved = await this.pkceRepository.removeCodeChallenge(codeChallenge) + + return matchingCodeChallengeWasPresentAndRemoved + } + + private async sendSignInEmailNotification(user: User, userAgent: string): Promise { + try { + const muteSignInEmailsSetting = await this.findOrCreateMuteSignInEmailsSetting(user) + + await this.domainEventPublisher.publish( + this.domainEventFactory.createUserSignedInEvent({ + userUuid: user.uuid, + userEmail: user.email, + device: this.sessionService.getOperatingSystemInfoFromUserAgent(userAgent), + browser: this.sessionService.getBrowserInfoFromUserAgent(userAgent), + signInAlertEnabled: + (await this.roleService.userHasPermission(user.uuid, PermissionName.SignInAlerts)) && + muteSignInEmailsSetting.value === MuteSignInEmailsOption.NotMuted, + muteSignInEmailsSettingUuid: muteSignInEmailsSetting.uuid, + }), + ) + } catch (error) { + this.logger.error(`Could not publish sign in event: ${(error as Error).message}`) + } + } + + private async findOrCreateMuteSignInEmailsSetting(user: User): Promise { + const existingMuteSignInEmailsSetting = await this.settingService.findSettingWithDecryptedValue({ + userUuid: user.uuid, + settingName: SettingName.MuteSignInEmails, + }) + + if (existingMuteSignInEmailsSetting !== null) { + return existingMuteSignInEmailsSetting + } + + const createSettingResult = await this.settingService.createOrReplace({ + user, + props: { + name: SettingName.MuteSignInEmails, + sensitive: false, + unencryptedValue: MuteSignInEmailsOption.NotMuted, + serverEncryptionVersion: EncryptionVersion.Unencrypted, + }, + }) + + return createSettingResult.setting + } + + private isCodeChallengedVersion(dto: SignInDTO): dto is SignInDTOV2Challenged { + return (dto as SignInDTOV2Challenged).codeVerifier !== undefined + } +} diff --git a/packages/auth/src/Domain/UseCase/SignInDTO.ts b/packages/auth/src/Domain/UseCase/SignInDTO.ts new file mode 100644 index 000000000..b8e58c1d0 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/SignInDTO.ts @@ -0,0 +1,4 @@ +import { SignInDTOV1Unchallenged } from './SignInDTOV1Unchallenged' +import { SignInDTOV2Challenged } from './SignInDTOV2Challenged' + +export type SignInDTO = SignInDTOV1Unchallenged | SignInDTOV2Challenged diff --git a/packages/auth/src/Domain/UseCase/SignInDTOV1Unchallenged.ts b/packages/auth/src/Domain/UseCase/SignInDTOV1Unchallenged.ts new file mode 100644 index 000000000..3d3114ef5 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/SignInDTOV1Unchallenged.ts @@ -0,0 +1,7 @@ +export type SignInDTOV1Unchallenged = { + apiVersion: string + userAgent: string + email: string + password: string + ephemeralSession: boolean +} diff --git a/packages/auth/src/Domain/UseCase/SignInDTOV2Challenged.ts b/packages/auth/src/Domain/UseCase/SignInDTOV2Challenged.ts new file mode 100644 index 000000000..421b5ef91 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/SignInDTOV2Challenged.ts @@ -0,0 +1,8 @@ +export type SignInDTOV2Challenged = { + apiVersion: string + userAgent: string + email: string + password: string + ephemeralSession: boolean + codeVerifier: string +} diff --git a/packages/auth/src/Domain/UseCase/SignInResponse.ts b/packages/auth/src/Domain/UseCase/SignInResponse.ts new file mode 100644 index 000000000..25705007f --- /dev/null +++ b/packages/auth/src/Domain/UseCase/SignInResponse.ts @@ -0,0 +1,8 @@ +import { AuthResponse20161215 } from '../Auth/AuthResponse20161215' +import { AuthResponse20200115 } from '../Auth/AuthResponse20200115' + +export type SignInResponse = { + success: boolean + authResponse?: AuthResponse20161215 | AuthResponse20200115 + errorMessage?: string +} diff --git a/packages/auth/src/Domain/UseCase/UpdateSetting/UpdateSetting.spec.ts b/packages/auth/src/Domain/UseCase/UpdateSetting/UpdateSetting.spec.ts new file mode 100644 index 000000000..8caf2ab15 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/UpdateSetting/UpdateSetting.spec.ts @@ -0,0 +1,180 @@ +import 'reflect-metadata' + +import { PermissionName } from '@standardnotes/features' +import { Logger } from 'winston' +import { SettingProjector } from '../../../Projection/SettingProjector' +import { EncryptionVersion } from '../../Encryption/EncryptionVersion' +import { RoleServiceInterface } from '../../Role/RoleServiceInterface' + +import { Setting } from '../../Setting/Setting' +import { SettingServiceInterface } from '../../Setting/SettingServiceInterface' +import { SettingsAssociationServiceInterface } from '../../Setting/SettingsAssociationServiceInterface' +import { SimpleSetting } from '../../Setting/SimpleSetting' +import { User } from '../../User/User' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' +import { UpdateSetting } from './UpdateSetting' +import { SettingName } from '@standardnotes/settings' + +describe('UpdateSetting', () => { + let settingService: SettingServiceInterface + let settingProjection: SimpleSetting + let settingProjector: SettingProjector + let settingsAssociationService: SettingsAssociationServiceInterface + let setting: Setting + let user: User + let userRepository: UserRepositoryInterface + let roleService: RoleServiceInterface + let logger: Logger + + const createUseCase = () => + new UpdateSetting(settingService, settingProjector, settingsAssociationService, userRepository, roleService, logger) + + beforeEach(() => { + setting = {} as jest.Mocked + + settingService = {} as jest.Mocked + settingService.createOrReplace = jest.fn().mockReturnValue({ status: 'created', setting }) + + settingProjector = {} as jest.Mocked + settingProjector.projectSimple = jest.fn().mockReturnValue(settingProjection) + + user = {} as jest.Mocked + + userRepository = {} as jest.Mocked + userRepository.findOneByUuid = jest.fn().mockReturnValue(user) + + settingsAssociationService = {} as jest.Mocked + settingsAssociationService.getPermissionAssociatedWithSetting = jest.fn().mockReturnValue(undefined) + settingsAssociationService.getEncryptionVersionForSetting = jest.fn().mockReturnValue(EncryptionVersion.Default) + settingsAssociationService.getSensitivityForSetting = jest.fn().mockReturnValue(false) + settingsAssociationService.isSettingMutableByClient = jest.fn().mockReturnValue(true) + + roleService = {} as jest.Mocked + roleService.addUserRole = jest.fn() + + logger = {} as jest.Mocked + logger.debug = jest.fn() + logger.error = jest.fn() + }) + + it('should create a setting', async () => { + const props = { + name: SettingName.ExtensionKey, + unencryptedValue: 'test-setting-value', + serverEncryptionVersion: EncryptionVersion.Default, + sensitive: false, + } + + const response = await createUseCase().execute({ props, userUuid: '1-2-3' }) + + expect(settingService.createOrReplace).toHaveBeenCalledWith({ + props: { + name: 'EXTENSION_KEY', + unencryptedValue: 'test-setting-value', + serverEncryptionVersion: 1, + sensitive: false, + }, + user, + }) + + expect(response).toEqual({ + success: true, + setting: settingProjection, + statusCode: 201, + }) + }) + + it('should not create a setting if user does not exist', async () => { + userRepository.findOneByUuid = jest.fn().mockReturnValue(null) + + const props = { + name: SettingName.ExtensionKey, + unencryptedValue: 'test-setting-value', + serverEncryptionVersion: EncryptionVersion.Unencrypted, + sensitive: false, + } + + const response = await createUseCase().execute({ props, userUuid: '1-2-3' }) + + expect(settingService.createOrReplace).not.toHaveBeenCalled() + + expect(response).toEqual({ + success: false, + error: { + message: 'User 1-2-3 not found.', + }, + statusCode: 404, + }) + }) + + it('should not create a setting if the setting name is invalid', async () => { + const props = { + name: 'random-setting', + unencryptedValue: 'test-setting-value', + serverEncryptionVersion: EncryptionVersion.Unencrypted, + sensitive: false, + } + + const response = await createUseCase().execute({ props, userUuid: '1-2-3' }) + + expect(settingService.createOrReplace).not.toHaveBeenCalled() + + expect(response).toEqual({ + success: false, + error: { + message: 'Setting name random-setting is invalid.', + }, + statusCode: 400, + }) + }) + + it('should not create a setting if user is not permitted to', async () => { + settingsAssociationService.getPermissionAssociatedWithSetting = jest + .fn() + .mockReturnValue(PermissionName.DailyEmailBackup) + + roleService.userHasPermission = jest.fn().mockReturnValue(false) + + const props = { + name: SettingName.ExtensionKey, + unencryptedValue: 'test-setting-value', + serverEncryptionVersion: EncryptionVersion.Unencrypted, + sensitive: false, + } + + const response = await createUseCase().execute({ props, userUuid: '1-2-3' }) + + expect(settingService.createOrReplace).not.toHaveBeenCalled() + + expect(response).toEqual({ + success: false, + error: { + message: 'User 1-2-3 is not permitted to change the setting.', + }, + statusCode: 401, + }) + }) + + it('should not create a setting if setting is not mutable by the client', async () => { + settingsAssociationService.isSettingMutableByClient = jest.fn().mockReturnValue(false) + + const props = { + name: SettingName.ExtensionKey, + unencryptedValue: 'test-setting-value', + serverEncryptionVersion: EncryptionVersion.Unencrypted, + sensitive: false, + } + + const response = await createUseCase().execute({ props, userUuid: '1-2-3' }) + + expect(settingService.createOrReplace).not.toHaveBeenCalled() + + expect(response).toEqual({ + success: false, + error: { + message: 'User 1-2-3 is not permitted to change the setting.', + }, + statusCode: 401, + }) + }) +}) diff --git a/packages/auth/src/Domain/UseCase/UpdateSetting/UpdateSetting.ts b/packages/auth/src/Domain/UseCase/UpdateSetting/UpdateSetting.ts new file mode 100644 index 000000000..b626703f7 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/UpdateSetting/UpdateSetting.ts @@ -0,0 +1,108 @@ +import { inject, injectable } from 'inversify' +import { UpdateSettingDto } from './UpdateSettingDto' +import { UpdateSettingResponse } from './UpdateSettingResponse' +import { UseCaseInterface } from '../UseCaseInterface' +import TYPES from '../../../Bootstrap/Types' +import { UserRepositoryInterface } from '../../User/UserRepositoryInterface' +import { CreateOrReplaceSettingResponse } from '../../Setting/CreateOrReplaceSettingResponse' +import { SettingProjector } from '../../../Projection/SettingProjector' +import { Logger } from 'winston' +import { SettingServiceInterface } from '../../Setting/SettingServiceInterface' +import { User } from '../../User/User' +import { SettingName } from '@standardnotes/settings' +import { RoleServiceInterface } from '../../Role/RoleServiceInterface' +import { SettingsAssociationServiceInterface } from '../../Setting/SettingsAssociationServiceInterface' + +@injectable() +export class UpdateSetting implements UseCaseInterface { + constructor( + @inject(TYPES.SettingService) private settingService: SettingServiceInterface, + @inject(TYPES.SettingProjector) private settingProjector: SettingProjector, + @inject(TYPES.SettingsAssociationService) private settingsAssociationService: SettingsAssociationServiceInterface, + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.RoleService) private roleService: RoleServiceInterface, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async execute(dto: UpdateSettingDto): Promise { + if (!Object.values(SettingName).includes(dto.props.name as SettingName)) { + return { + success: false, + error: { + message: `Setting name ${dto.props.name} is invalid.`, + }, + statusCode: 400, + } + } + + this.logger.debug('[%s] Updating setting: %O', dto.userUuid, dto) + + const { userUuid, props } = dto + + const user = await this.userRepository.findOneByUuid(userUuid) + + if (user === null) { + return { + success: false, + error: { + message: `User ${userUuid} not found.`, + }, + statusCode: 404, + } + } + + if (!(await this.userHasPermissionToUpdateSetting(user, props.name as SettingName))) { + return { + success: false, + error: { + message: `User ${userUuid} is not permitted to change the setting.`, + }, + statusCode: 401, + } + } + + props.serverEncryptionVersion = this.settingsAssociationService.getEncryptionVersionForSetting( + props.name as SettingName, + ) + props.sensitive = this.settingsAssociationService.getSensitivityForSetting(props.name as SettingName) + + const response = await this.settingService.createOrReplace({ + user, + props, + }) + + return { + success: true, + setting: await this.settingProjector.projectSimple(response.setting), + statusCode: this.statusToStatusCode(response), + } + } + + /* istanbul ignore next */ + private statusToStatusCode(response: CreateOrReplaceSettingResponse): number { + if (response.status === 'created') { + return 201 + } + if (response.status === 'replaced') { + return 200 + } + + const exhaustiveCheck: never = response.status + throw new Error(`Unrecognized status: ${exhaustiveCheck}!`) + } + + private async userHasPermissionToUpdateSetting(user: User, settingName: SettingName): Promise { + const settingIsMutableByClient = await this.settingsAssociationService.isSettingMutableByClient(settingName) + if (!settingIsMutableByClient) { + return false + } + + const permissionAssociatedWithSetting = + this.settingsAssociationService.getPermissionAssociatedWithSetting(settingName) + if (permissionAssociatedWithSetting === undefined) { + return true + } + + return this.roleService.userHasPermission(user.uuid, permissionAssociatedWithSetting) + } +} diff --git a/packages/auth/src/Domain/UseCase/UpdateSetting/UpdateSettingDto.ts b/packages/auth/src/Domain/UseCase/UpdateSetting/UpdateSettingDto.ts new file mode 100644 index 000000000..ad20cbcb8 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/UpdateSetting/UpdateSettingDto.ts @@ -0,0 +1,8 @@ +import { Uuid } from '@standardnotes/common' + +import { SettingProps } from '../../Setting/SettingProps' + +export type UpdateSettingDto = { + userUuid: Uuid + props: SettingProps +} diff --git a/packages/auth/src/Domain/UseCase/UpdateSetting/UpdateSettingResponse.ts b/packages/auth/src/Domain/UseCase/UpdateSetting/UpdateSettingResponse.ts new file mode 100644 index 000000000..b648b385d --- /dev/null +++ b/packages/auth/src/Domain/UseCase/UpdateSetting/UpdateSettingResponse.ts @@ -0,0 +1,15 @@ +import { SimpleSetting } from '../../Setting/SimpleSetting' + +export type UpdateSettingResponse = + | { + success: true + setting: SimpleSetting + statusCode: number + } + | { + success: false + error: { + message: string + } + statusCode: number + } diff --git a/packages/auth/src/Domain/UseCase/UpdateUser.spec.ts b/packages/auth/src/Domain/UseCase/UpdateUser.spec.ts new file mode 100644 index 000000000..75b4d90bc --- /dev/null +++ b/packages/auth/src/Domain/UseCase/UpdateUser.spec.ts @@ -0,0 +1,65 @@ +import 'reflect-metadata' + +import { TimerInterface } from '@standardnotes/time' + +import { User } from '../User/User' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { AuthResponseFactoryInterface } from '../Auth/AuthResponseFactoryInterface' +import { AuthResponseFactoryResolverInterface } from '../Auth/AuthResponseFactoryResolverInterface' + +import { UpdateUser } from './UpdateUser' + +describe('UpdateUser', () => { + let userRepository: UserRepositoryInterface + let authResponseFactoryResolver: AuthResponseFactoryResolverInterface + let authResponseFactory: AuthResponseFactoryInterface + let user: User + let timer: TimerInterface + + const createUseCase = () => new UpdateUser(userRepository, authResponseFactoryResolver, timer) + + beforeEach(() => { + userRepository = {} as jest.Mocked + userRepository.save = jest.fn() + userRepository.findOneByEmail = jest.fn().mockReturnValue(undefined) + + authResponseFactory = {} as jest.Mocked + authResponseFactory.createResponse = jest.fn().mockReturnValue({ foo: 'bar' }) + + authResponseFactoryResolver = {} as jest.Mocked + authResponseFactoryResolver.resolveAuthResponseFactoryVersion = jest.fn().mockReturnValue(authResponseFactory) + + user = {} as jest.Mocked + user.uuid = '123' + user.email = 'test@test.te' + user.createdAt = new Date(1) + + timer = {} as jest.Mocked + timer.getUTCDate = jest.fn().mockReturnValue(new Date(1)) + }) + + it('should update user fields and save it', async () => { + expect( + await createUseCase().execute({ + user, + updatedWithUserAgent: 'Mozilla', + apiVersion: '20190520', + version: '004', + pwCost: 11, + pwSalt: 'qweqwe', + pwNonce: undefined, + }), + ).toEqual({ success: true, authResponse: { foo: 'bar' } }) + + expect(userRepository.save).toHaveBeenCalledWith({ + createdAt: new Date(1), + pwCost: 11, + email: 'test@test.te', + pwSalt: 'qweqwe', + updatedWithUserAgent: 'Mozilla', + uuid: '123', + version: '004', + updatedAt: new Date(1), + }) + }) +}) diff --git a/packages/auth/src/Domain/UseCase/UpdateUser.ts b/packages/auth/src/Domain/UseCase/UpdateUser.ts new file mode 100644 index 000000000..43a40cd03 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/UpdateUser.ts @@ -0,0 +1,45 @@ +import { TimerInterface } from '@standardnotes/time' +import { inject, injectable } from 'inversify' +import TYPES from '../../Bootstrap/Types' +import { AuthResponseFactoryResolverInterface } from '../Auth/AuthResponseFactoryResolverInterface' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { UpdateUserDTO } from './UpdateUserDTO' +import { UpdateUserResponse } from './UpdateUserResponse' +import { UseCaseInterface } from './UseCaseInterface' + +@injectable() +export class UpdateUser implements UseCaseInterface { + constructor( + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.AuthResponseFactoryResolver) + private authResponseFactoryResolver: AuthResponseFactoryResolverInterface, + @inject(TYPES.Timer) private timer: TimerInterface, + ) {} + + async execute(dto: UpdateUserDTO): Promise { + const { user, apiVersion, ...updateFields } = dto + + Object.keys(updateFields).forEach( + (key) => (updateFields[key] === undefined || updateFields[key] === null) && delete updateFields[key], + ) + + Object.assign(user, updateFields) + + user.updatedAt = this.timer.getUTCDate() + + await this.userRepository.save(user) + + const authResponseFactory = this.authResponseFactoryResolver.resolveAuthResponseFactoryVersion(apiVersion) + + return { + success: true, + authResponse: await authResponseFactory.createResponse({ + user, + apiVersion, + userAgent: dto.updatedWithUserAgent, + ephemeralSession: false, + readonlyAccess: false, + }), + } + } +} diff --git a/packages/auth/src/Domain/UseCase/UpdateUserDTO.ts b/packages/auth/src/Domain/UseCase/UpdateUserDTO.ts new file mode 100644 index 000000000..90b3bf468 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/UpdateUserDTO.ts @@ -0,0 +1,18 @@ +import { User } from '../User/User' + +export type UpdateUserDTO = { + [key: string]: string | User | Date | undefined | number + user: User + updatedWithUserAgent: string + apiVersion: string + email?: string + pwFunc?: string + pwAlg?: string + pwCost?: number + pwKeySize?: number + pwNonce?: string + pwSalt?: string + kpOrigination?: string + kpCreated?: Date + version?: string +} diff --git a/packages/auth/src/Domain/UseCase/UpdateUserResponse.ts b/packages/auth/src/Domain/UseCase/UpdateUserResponse.ts new file mode 100644 index 000000000..81e8ab629 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/UpdateUserResponse.ts @@ -0,0 +1,7 @@ +import { AuthResponse20161215 } from '../Auth/AuthResponse20161215' +import { AuthResponse20200115 } from '../Auth/AuthResponse20200115' + +export type UpdateUserResponse = { + success: boolean + authResponse?: AuthResponse20161215 | AuthResponse20200115 +} diff --git a/packages/auth/src/Domain/UseCase/UseCaseInterface.ts b/packages/auth/src/Domain/UseCase/UseCaseInterface.ts new file mode 100644 index 000000000..7c8405a9a --- /dev/null +++ b/packages/auth/src/Domain/UseCase/UseCaseInterface.ts @@ -0,0 +1,3 @@ +export interface UseCaseInterface { + execute(...args: any[]): Promise> +} diff --git a/packages/auth/src/Domain/UseCase/VerifyMFA.spec.ts b/packages/auth/src/Domain/UseCase/VerifyMFA.spec.ts new file mode 100644 index 000000000..7aab91540 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/VerifyMFA.spec.ts @@ -0,0 +1,205 @@ +import 'reflect-metadata' +import { authenticator } from 'otplib' + +import { User } from '../User/User' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { VerifyMFA } from './VerifyMFA' +import { Setting } from '../Setting/Setting' +import { SettingServiceInterface } from '../Setting/SettingServiceInterface' +import { SettingName } from '@standardnotes/settings' +import { SelectorInterface } from '@standardnotes/auth' +import { LockRepositoryInterface } from '../User/LockRepositoryInterface' + +describe('VerifyMFA', () => { + let user: User + let setting: Setting + let userRepository: UserRepositoryInterface + let settingService: SettingServiceInterface + let booleanSelector: SelectorInterface + let lockRepository: LockRepositoryInterface + const pseudoKeyParamsKey = 'foobar' + + const createVerifyMFA = () => + new VerifyMFA(userRepository, settingService, booleanSelector, lockRepository, pseudoKeyParamsKey) + + beforeEach(() => { + user = {} as jest.Mocked + + userRepository = {} as jest.Mocked + userRepository.findOneByEmail = jest.fn().mockReturnValue(user) + + booleanSelector = {} as jest.Mocked> + booleanSelector.select = jest.fn().mockReturnValue(false) + + lockRepository = {} as jest.Mocked + lockRepository.isOTPLocked = jest.fn().mockReturnValue(false) + lockRepository.lockSuccessfullOTP = jest.fn() + + setting = { + name: SettingName.MfaSecret, + value: 'shhhh', + } as jest.Mocked + + settingService = {} as jest.Mocked + settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(setting) + }) + + it('should pass MFA verification if user has no MFA enabled', async () => { + settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(null) + + expect( + await createVerifyMFA().execute({ email: 'test@test.te', requestParams: {}, preventOTPFromFurtherUsage: true }), + ).toEqual({ + success: true, + }) + + expect(lockRepository.lockSuccessfullOTP).not.toHaveBeenCalled() + }) + + it('should pass MFA verification if user has MFA deleted', async () => { + setting = { + name: SettingName.MfaSecret, + value: null, + } as jest.Mocked + + settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(setting) + + expect( + await createVerifyMFA().execute({ email: 'test@test.te', requestParams: {}, preventOTPFromFurtherUsage: true }), + ).toEqual({ + success: true, + }) + + expect(lockRepository.lockSuccessfullOTP).not.toHaveBeenCalled() + }) + + it('should pass MFA verification if user is not found and pseudo mfa is not required', async () => { + userRepository.findOneByEmail = jest.fn().mockReturnValue(null) + expect( + await createVerifyMFA().execute({ email: 'test@test.te', requestParams: {}, preventOTPFromFurtherUsage: true }), + ).toEqual({ + success: true, + }) + + expect(lockRepository.lockSuccessfullOTP).not.toHaveBeenCalled() + }) + + it('should not pass MFA verification if user is not found and pseudo mfa is required', async () => { + booleanSelector.select = jest.fn().mockReturnValue(true) + userRepository.findOneByEmail = jest.fn().mockReturnValue(null) + + expect( + await createVerifyMFA().execute({ email: 'test@test.te', requestParams: {}, preventOTPFromFurtherUsage: true }), + ).toEqual({ + success: false, + errorTag: 'mfa-required', + errorMessage: 'Please enter your two-factor authentication code.', + errorPayload: { mfa_key: expect.stringMatching(/^mfa_/) }, + }) + }) + + it('should pass MFA verification if mfa key is correctly encrypted', async () => { + expect( + await createVerifyMFA().execute({ + email: 'test@test.te', + requestParams: { 'mfa_1-2-3': authenticator.generate('shhhh') }, + preventOTPFromFurtherUsage: true, + }), + ).toEqual({ + success: true, + }) + + expect(lockRepository.lockSuccessfullOTP).toHaveBeenCalledWith('test@test.te', expect.any(String)) + }) + + it('should pass MFA verification without locking otp', async () => { + expect( + await createVerifyMFA().execute({ + email: 'test@test.te', + requestParams: { 'mfa_1-2-3': authenticator.generate('shhhh') }, + preventOTPFromFurtherUsage: false, + }), + ).toEqual({ + success: true, + }) + + expect(lockRepository.lockSuccessfullOTP).not.toHaveBeenCalled() + }) + + it('should not pass MFA verification if otp is already used within lock out period', async () => { + lockRepository.isOTPLocked = jest.fn().mockReturnValue(true) + + expect( + await createVerifyMFA().execute({ + email: 'test@test.te', + requestParams: { 'mfa_1-2-3': authenticator.generate('shhhh') }, + preventOTPFromFurtherUsage: true, + }), + ).toEqual({ + success: false, + errorTag: 'mfa-invalid', + errorMessage: + 'The two-factor authentication code you entered has been already utilized. Please try again in a while.', + errorPayload: { mfa_key: 'mfa_1-2-3' }, + }) + + expect(lockRepository.lockSuccessfullOTP).not.toHaveBeenCalled() + }) + + it('should not pass MFA verification if mfa is not correct', async () => { + setting = { + name: SettingName.MfaSecret, + value: 'shhhh2', + } as jest.Mocked + + settingService = {} as jest.Mocked + settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(setting) + + expect( + await createVerifyMFA().execute({ + email: 'test@test.te', + requestParams: { 'mfa_1-2-3': 'test' }, + preventOTPFromFurtherUsage: true, + }), + ).toEqual({ + success: false, + errorTag: 'mfa-invalid', + errorMessage: 'The two-factor authentication code you entered is incorrect. Please try again.', + errorPayload: { mfa_key: 'mfa_1-2-3' }, + }) + }) + + it('should not pass MFA verification if no mfa param is found in the request', async () => { + expect( + await createVerifyMFA().execute({ + email: 'test@test.te', + requestParams: { foo: 'bar' }, + preventOTPFromFurtherUsage: true, + }), + ).toEqual({ + success: false, + errorTag: 'mfa-required', + errorMessage: 'Please enter your two-factor authentication code.', + errorPayload: { mfa_key: expect.stringMatching(/^mfa_/) }, + }) + }) + + it('should throw an error if the error is not handled mfa validation error', async () => { + settingService.findSettingWithDecryptedValue = jest.fn().mockImplementation(() => { + throw new Error('oops!') + }) + + let error = null + try { + await createVerifyMFA().execute({ + email: 'test@test.te', + requestParams: { 'mfa_1-2-3': 'test' }, + preventOTPFromFurtherUsage: true, + }) + } catch (caughtError) { + error = caughtError + } + + expect(error).not.toBeNull() + }) +}) diff --git a/packages/auth/src/Domain/UseCase/VerifyMFA.ts b/packages/auth/src/Domain/UseCase/VerifyMFA.ts new file mode 100644 index 000000000..2b0778068 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/VerifyMFA.ts @@ -0,0 +1,140 @@ +import * as crypto from 'crypto' +import { ErrorTag } from '@standardnotes/common' +import { SettingName } from '@standardnotes/settings' +import { v4 as uuidv4 } from 'uuid' +import { inject, injectable } from 'inversify' +import { authenticator } from 'otplib' + +import TYPES from '../../Bootstrap/Types' +import { MFAValidationError } from '../Error/MFAValidationError' +import { UserRepositoryInterface } from '../User/UserRepositoryInterface' +import { UseCaseInterface } from './UseCaseInterface' +import { VerifyMFADTO } from './VerifyMFADTO' +import { VerifyMFAResponse } from './VerifyMFAResponse' +import { SettingServiceInterface } from '../Setting/SettingServiceInterface' +import { SelectorInterface } from '@standardnotes/auth' +import { LockRepositoryInterface } from '../User/LockRepositoryInterface' + +@injectable() +export class VerifyMFA implements UseCaseInterface { + constructor( + @inject(TYPES.UserRepository) private userRepository: UserRepositoryInterface, + @inject(TYPES.SettingService) private settingService: SettingServiceInterface, + @inject(TYPES.BooleanSelector) private booleanSelector: SelectorInterface, + @inject(TYPES.LockRepository) private lockRepository: LockRepositoryInterface, + @inject(TYPES.PSEUDO_KEY_PARAMS_KEY) private pseudoKeyParamsKey: string, + ) {} + + async execute(dto: VerifyMFADTO): Promise { + try { + const user = await this.userRepository.findOneByEmail(dto.email) + if (user == null) { + const mfaSelectorHash = crypto + .createHash('sha256') + .update(`mfa-selector-${dto.email}${this.pseudoKeyParamsKey}`) + .digest('hex') + + const isPseudoMFARequired = this.booleanSelector.select(mfaSelectorHash, [true, false]) + + return isPseudoMFARequired + ? { + success: false, + errorTag: ErrorTag.MfaRequired, + errorMessage: 'Please enter your two-factor authentication code.', + errorPayload: { mfa_key: `mfa_${uuidv4()}` }, + } + : { + success: true, + } + } + + const mfaSecret = await this.settingService.findSettingWithDecryptedValue({ + userUuid: user.uuid, + settingName: SettingName.MfaSecret, + }) + if (mfaSecret === null || mfaSecret.value === null) { + return { + success: true, + } + } + + const verificationResult = await this.verifyMFASecret( + dto.email, + mfaSecret.value, + dto.requestParams, + dto.preventOTPFromFurtherUsage, + ) + + return verificationResult + } catch (error) { + if (error instanceof MFAValidationError) { + return { + success: false, + errorTag: error.tag, + errorMessage: error.message, + errorPayload: error.payload, + } + } + + throw error + } + } + + private getMFATokenAndParamKeyFromRequestParams(requestParams: Record): { + key: string + token: string + } { + let mfaParamKey = null + for (const key of Object.keys(requestParams)) { + if (key.startsWith('mfa_')) { + mfaParamKey = key + break + } + } + + if (mfaParamKey === null) { + throw new MFAValidationError('Please enter your two-factor authentication code.', ErrorTag.MfaRequired, { + mfa_key: `mfa_${uuidv4()}`, + }) + } + + return { + token: requestParams[mfaParamKey] as string, + key: mfaParamKey, + } + } + + private async verifyMFASecret( + email: string, + secret: string, + requestParams: Record, + preventOTPFromFurtherUsage: boolean, + ): Promise { + const tokenAndParamKey = this.getMFATokenAndParamKeyFromRequestParams(requestParams) + + const isOTPAlreadyUsed = await this.lockRepository.isOTPLocked(email, tokenAndParamKey.token) + if (isOTPAlreadyUsed) { + throw new MFAValidationError( + 'The two-factor authentication code you entered has been already utilized. Please try again in a while.', + ErrorTag.MfaInvalid, + { mfa_key: tokenAndParamKey.key }, + ) + } + + if (!authenticator.verify({ token: tokenAndParamKey.token, secret })) { + throw new MFAValidationError( + 'The two-factor authentication code you entered is incorrect. Please try again.', + ErrorTag.MfaInvalid, + { mfa_key: tokenAndParamKey.key }, + ) + } + + if (preventOTPFromFurtherUsage) { + await this.lockRepository.lockSuccessfullOTP(email, tokenAndParamKey.token) + } + + return { + success: true, + } + } +} diff --git a/packages/auth/src/Domain/UseCase/VerifyMFADTO.ts b/packages/auth/src/Domain/UseCase/VerifyMFADTO.ts new file mode 100644 index 000000000..8c0d8b12a --- /dev/null +++ b/packages/auth/src/Domain/UseCase/VerifyMFADTO.ts @@ -0,0 +1,5 @@ +export type VerifyMFADTO = { + email: string + requestParams: Record + preventOTPFromFurtherUsage: boolean +} diff --git a/packages/auth/src/Domain/UseCase/VerifyMFAResponse.ts b/packages/auth/src/Domain/UseCase/VerifyMFAResponse.ts new file mode 100644 index 000000000..2290ae223 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/VerifyMFAResponse.ts @@ -0,0 +1,6 @@ +export type VerifyMFAResponse = { + success: boolean + errorTag?: string + errorMessage?: string + errorPayload?: Record +} diff --git a/packages/auth/src/Domain/UseCase/VerifyPredicate/VerifyPredicate.spec.ts b/packages/auth/src/Domain/UseCase/VerifyPredicate/VerifyPredicate.spec.ts new file mode 100644 index 000000000..99485378d --- /dev/null +++ b/packages/auth/src/Domain/UseCase/VerifyPredicate/VerifyPredicate.spec.ts @@ -0,0 +1,110 @@ +import 'reflect-metadata' + +import { PredicateName, PredicateVerificationResult, PredicateAuthority } from '@standardnotes/scheduler' + +import { Setting } from '../../Setting/Setting' +import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface' +import { UserSubscription } from '../../Subscription/UserSubscription' +import { UserSubscriptionRepositoryInterface } from '../../Subscription/UserSubscriptionRepositoryInterface' + +import { VerifyPredicate } from './VerifyPredicate' +import { EmailBackupFrequency } from '@standardnotes/settings' + +describe('VerifyPredicate', () => { + let settingRepository: SettingRepositoryInterface + let userSubscriptionRepository: UserSubscriptionRepositoryInterface + let subscription: UserSubscription + let setting: Setting + + const createUseCase = () => new VerifyPredicate(settingRepository, userSubscriptionRepository) + + beforeEach(() => { + setting = {} as jest.Mocked + + settingRepository = {} as jest.Mocked + settingRepository.findOneByNameAndUserUuid = jest.fn().mockReturnValue(setting) + + subscription = {} as jest.Mocked + userSubscriptionRepository = {} as jest.Mocked + userSubscriptionRepository.findOneByUserUuid = jest.fn().mockReturnValue(subscription) + }) + + it('should tell that a user has enabled email backups', async () => { + setting = { value: EmailBackupFrequency.Weekly } as jest.Mocked + settingRepository.findOneByNameAndUserUuid = jest.fn().mockReturnValue(setting) + + expect( + await createUseCase().execute({ + predicate: { jobUuid: '2-3-4', authority: PredicateAuthority.Auth, name: PredicateName.EmailBackupsEnabled }, + userUuid: '1-2-3', + }), + ).toEqual({ + predicateVerificationResult: PredicateVerificationResult.Affirmed, + }) + }) + + it('should tell that a user has disabled email backups', async () => { + setting = { value: EmailBackupFrequency.Disabled } as jest.Mocked + settingRepository.findOneByNameAndUserUuid = jest.fn().mockReturnValue(setting) + + expect( + await createUseCase().execute({ + predicate: { jobUuid: '2-3-4', authority: PredicateAuthority.Auth, name: PredicateName.EmailBackupsEnabled }, + userUuid: '1-2-3', + }), + ).toEqual({ + predicateVerificationResult: PredicateVerificationResult.Denied, + }) + }) + + it('should tell that a user has disabled email backups - missing setting', async () => { + settingRepository.findOneByNameAndUserUuid = jest.fn().mockReturnValue(null) + + expect( + await createUseCase().execute({ + predicate: { jobUuid: '2-3-4', authority: PredicateAuthority.Auth, name: PredicateName.EmailBackupsEnabled }, + userUuid: '1-2-3', + }), + ).toEqual({ + predicateVerificationResult: PredicateVerificationResult.Denied, + }) + }) + + it('should tell that a user has a subscription', async () => { + expect( + await createUseCase().execute({ + predicate: { jobUuid: '2-3-4', authority: PredicateAuthority.Auth, name: PredicateName.SubscriptionPurchased }, + userUuid: '1-2-3', + }), + ).toEqual({ + predicateVerificationResult: PredicateVerificationResult.Affirmed, + }) + }) + + it('should tell that a user has no subscription', async () => { + userSubscriptionRepository.findOneByUserUuid = jest.fn().mockReturnValue(null) + + expect( + await createUseCase().execute({ + predicate: { jobUuid: '2-3-4', authority: PredicateAuthority.Auth, name: PredicateName.SubscriptionPurchased }, + userUuid: '1-2-3', + }), + ).toEqual({ + predicateVerificationResult: PredicateVerificationResult.Denied, + }) + }) + + it('should throw error upon not recognized predicate', async () => { + let caughtError = null + try { + await createUseCase().execute({ + predicate: { jobUuid: '2-3-4', authority: PredicateAuthority.Auth, name: 'foobar' as PredicateName }, + userUuid: '1-2-3', + }) + } catch (error) { + caughtError = error + } + + expect(caughtError).not.toBeNull() + }) +}) diff --git a/packages/auth/src/Domain/UseCase/VerifyPredicate/VerifyPredicate.ts b/packages/auth/src/Domain/UseCase/VerifyPredicate/VerifyPredicate.ts new file mode 100644 index 000000000..50528f8ae --- /dev/null +++ b/packages/auth/src/Domain/UseCase/VerifyPredicate/VerifyPredicate.ts @@ -0,0 +1,51 @@ +import { Uuid } from '@standardnotes/common' +import { PredicateName, PredicateVerificationResult } from '@standardnotes/scheduler' +import { EmailBackupFrequency, SettingName } from '@standardnotes/settings' +import { inject, injectable } from 'inversify' + +import TYPES from '../../../Bootstrap/Types' +import { SettingRepositoryInterface } from '../../Setting/SettingRepositoryInterface' +import { UserSubscriptionRepositoryInterface } from '../../Subscription/UserSubscriptionRepositoryInterface' +import { UseCaseInterface } from '../UseCaseInterface' + +import { VerifyPredicateDTO } from './VerifyPredicateDTO' +import { VerifyPredicateResponse } from './VerifyPredicateResponse' + +@injectable() +export class VerifyPredicate implements UseCaseInterface { + constructor( + @inject(TYPES.SettingRepository) private settingRepository: SettingRepositoryInterface, + @inject(TYPES.UserSubscriptionRepository) private userSubscriptionRepository: UserSubscriptionRepositoryInterface, + ) {} + + async execute(dto: VerifyPredicateDTO): Promise { + switch (dto.predicate.name) { + case PredicateName.EmailBackupsEnabled: + return (await this.hasUserEnabledEmailBackups(dto.userUuid)) + ? { predicateVerificationResult: PredicateVerificationResult.Affirmed } + : { predicateVerificationResult: PredicateVerificationResult.Denied } + case PredicateName.SubscriptionPurchased: + return (await this.hasUserBoughtASubscription(dto.userUuid)) + ? { predicateVerificationResult: PredicateVerificationResult.Affirmed } + : { predicateVerificationResult: PredicateVerificationResult.Denied } + default: + throw new Error(`Predicate not supported: ${dto.predicate.name}`) + } + } + + private async hasUserBoughtASubscription(userUuid: Uuid): Promise { + const subscription = await this.userSubscriptionRepository.findOneByUserUuid(userUuid) + + return subscription !== null + } + + private async hasUserEnabledEmailBackups(userUuid: Uuid): Promise { + const setting = await this.settingRepository.findOneByNameAndUserUuid(SettingName.EmailBackupFrequency, userUuid) + + if (setting === null || setting.value === EmailBackupFrequency.Disabled) { + return false + } + + return true + } +} diff --git a/packages/auth/src/Domain/UseCase/VerifyPredicate/VerifyPredicateDTO.ts b/packages/auth/src/Domain/UseCase/VerifyPredicate/VerifyPredicateDTO.ts new file mode 100644 index 000000000..2c44ddce6 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/VerifyPredicate/VerifyPredicateDTO.ts @@ -0,0 +1,7 @@ +import { Uuid } from '@standardnotes/common' +import { Predicate } from '@standardnotes/scheduler' + +export type VerifyPredicateDTO = { + predicate: Predicate + userUuid: Uuid +} diff --git a/packages/auth/src/Domain/UseCase/VerifyPredicate/VerifyPredicateResponse.ts b/packages/auth/src/Domain/UseCase/VerifyPredicate/VerifyPredicateResponse.ts new file mode 100644 index 000000000..73bb460b3 --- /dev/null +++ b/packages/auth/src/Domain/UseCase/VerifyPredicate/VerifyPredicateResponse.ts @@ -0,0 +1,5 @@ +import { PredicateVerificationResult } from '@standardnotes/scheduler' + +export type VerifyPredicateResponse = { + predicateVerificationResult: PredicateVerificationResult +} diff --git a/packages/auth/src/Domain/User/KeyParamsFactory.spec.ts b/packages/auth/src/Domain/User/KeyParamsFactory.spec.ts new file mode 100644 index 000000000..17e716f69 --- /dev/null +++ b/packages/auth/src/Domain/User/KeyParamsFactory.spec.ts @@ -0,0 +1,116 @@ +import 'reflect-metadata' + +import { SelectorInterface } from '@standardnotes/auth' +import { ProtocolVersion } from '@standardnotes/common' + +import { KeyParamsFactory } from './KeyParamsFactory' +import { User } from './User' + +describe('KeyParamsFactory', () => { + let user: User + let protocolVersionSelector: SelectorInterface + + const createFactory = () => new KeyParamsFactory('secret_key', protocolVersionSelector) + + beforeEach(() => { + user = new User() + user.version = 'test' + user.email = 'test@test.te' + user.kpCreated = 'kpCreated' + user.kpOrigination = 'kpOrigination' + user.pwNonce = 'pwNonce' + user.pwCost = 1 + user.pwSalt = 'qwe' + user.pwAlg = 'pwAlg' + user.pwFunc = 'pwFunc' + user.pwKeySize = 2 + + protocolVersionSelector = {} as jest.Mocked> + protocolVersionSelector.select = jest.fn().mockReturnValue(ProtocolVersion.V004) + }) + + it('should create a basic key params structure', () => { + expect(createFactory().create(user, true)).toEqual({ + identifier: 'test@test.te', + version: 'test', + }) + }) + + it('should create a key params structure for 001 version', () => { + user.version = '001' + + expect(createFactory().create(user, true)).toEqual({ + email: 'test@test.te', + identifier: 'test@test.te', + pw_alg: 'pwAlg', + pw_cost: 1, + pw_func: 'pwFunc', + pw_key_size: 2, + pw_salt: 'qwe', + version: '001', + }) + }) + + it('should create a key params structure for 002 version', () => { + user.version = '002' + + expect(createFactory().create(user, true)).toEqual({ + email: 'test@test.te', + identifier: 'test@test.te', + pw_cost: 1, + pw_salt: 'qwe', + version: '002', + }) + }) + + it('should create a key params structure for 003 version', () => { + user.version = '003' + + expect(createFactory().create(user, true)).toEqual({ + identifier: 'test@test.te', + pw_cost: 1, + pw_nonce: 'pwNonce', + version: '003', + }) + }) + + it('should create a key params structure for 004 version', () => { + user.version = '004' + + expect(createFactory().create(user, true)).toEqual({ + identifier: 'test@test.te', + created: 'kpCreated', + origination: 'kpOrigination', + pw_nonce: 'pwNonce', + version: '004', + }) + }) + + it('should create a key params structure for not authenticated 004 version', () => { + user.version = '004' + + expect(createFactory().create(user, false)).toEqual({ + identifier: 'test@test.te', + pw_nonce: 'pwNonce', + version: '004', + }) + }) + + it('should create pseudo key params', () => { + expect(createFactory().createPseudoParams('test@test.te')).toEqual({ + identifier: 'test@test.te', + pw_nonce: '2552d8b41fc63fcdbd8d07ef4d26a4e6fc61742b348e7094838b1e738c318736', + version: '004', + }) + }) + + it('should create a key params with sorted keys', () => { + user.version = '003' + + const expectedKeysOrder = ['identifier', 'pw_cost', 'pw_nonce', 'version'] + const keyParams = createFactory().create(user, true) + Object.keys(keyParams).forEach((key, index) => { + expect(key).toEqual(expectedKeysOrder[index]) + }) + }) +}) diff --git a/packages/auth/src/Domain/User/KeyParamsFactory.ts b/packages/auth/src/Domain/User/KeyParamsFactory.ts new file mode 100644 index 000000000..f7129c584 --- /dev/null +++ b/packages/auth/src/Domain/User/KeyParamsFactory.ts @@ -0,0 +1,81 @@ +import * as crypto from 'crypto' +import { KeyParamsData } from '@standardnotes/responses' +import { KeyParamsOrigination, ProtocolVersion } from '@standardnotes/common' + +import { inject, injectable } from 'inversify' +import TYPES from '../../Bootstrap/Types' +import { KeyParamsFactoryInterface } from './KeyParamsFactoryInterface' +import { User } from './User' +import { SelectorInterface } from '@standardnotes/auth' + +@injectable() +export class KeyParamsFactory implements KeyParamsFactoryInterface { + constructor( + @inject(TYPES.PSEUDO_KEY_PARAMS_KEY) private pseudoKeyParamsKey: string, + @inject(TYPES.ProtocolVersionSelector) private protocolVersionSelector: SelectorInterface, + ) {} + + createPseudoParams(email: string): KeyParamsData { + const versionSelectorHash = crypto + .createHash('sha256') + .update(`version-selector-${email}${this.pseudoKeyParamsKey}`) + .digest('hex') + const versionsThatAreCompliantWithPseudoParams = [ProtocolVersion.V003, ProtocolVersion.V004] + const version = this.protocolVersionSelector.select(versionSelectorHash, versionsThatAreCompliantWithPseudoParams) + + return this.sortKeys({ + identifier: email, + pw_nonce: crypto.createHash('sha256').update(`${email}${this.pseudoKeyParamsKey}`).digest('hex'), + version, + }) + } + + create(user: User, authenticated: boolean): KeyParamsData { + const keyParams: KeyParamsData = { + version: user.version as ProtocolVersion, + identifier: user.email, + } + + switch (user.version) { + case '004': + if (authenticated) { + keyParams.created = user.kpCreated + keyParams.origination = user.kpOrigination as KeyParamsOrigination + } + keyParams.pw_nonce = user.pwNonce + break + case '003': + keyParams.pw_nonce = user.pwNonce + keyParams.pw_cost = user.pwCost + break + case '002': + keyParams.email = user.email + keyParams.pw_cost = user.pwCost + keyParams.pw_salt = user.pwSalt + break + case '001': + keyParams.email = user.email + keyParams.pw_alg = user.pwAlg + keyParams.pw_cost = user.pwCost + keyParams.pw_func = user.pwFunc + keyParams.pw_salt = user.pwSalt + keyParams.pw_key_size = user.pwKeySize + break + } + + return this.sortKeys(keyParams) + } + + private sortKeys(keyParams: KeyParamsData): KeyParamsData { + const sortedKeyParams: { [key: string]: string | number | undefined } = {} + + Object.keys(keyParams) + .sort() + .forEach((key) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sortedKeyParams[key] = (keyParams as any)[key] + }) + + return sortedKeyParams + } +} diff --git a/packages/auth/src/Domain/User/KeyParamsFactoryInterface.ts b/packages/auth/src/Domain/User/KeyParamsFactoryInterface.ts new file mode 100644 index 000000000..1199998a5 --- /dev/null +++ b/packages/auth/src/Domain/User/KeyParamsFactoryInterface.ts @@ -0,0 +1,8 @@ +import { KeyParamsData } from '@standardnotes/responses' + +import { User } from './User' + +export interface KeyParamsFactoryInterface { + create(user: User, authenticated: boolean): KeyParamsData + createPseudoParams(email: string): KeyParamsData +} diff --git a/packages/auth/src/Domain/User/LockRepositoryInterface.ts b/packages/auth/src/Domain/User/LockRepositoryInterface.ts new file mode 100644 index 000000000..820b5bf93 --- /dev/null +++ b/packages/auth/src/Domain/User/LockRepositoryInterface.ts @@ -0,0 +1,9 @@ +export interface LockRepositoryInterface { + resetLockCounter(userIdentifier: string): Promise + updateLockCounter(userIdentifier: string, counter: number): Promise + getLockCounter(userIdentifier: string): Promise + lockUser(userIdentifier: string): Promise + isUserLocked(userIdentifier: string): Promise + lockSuccessfullOTP(userIdentifier: string, otp: string): Promise + isOTPLocked(userIdentifier: string, otp: string): Promise +} diff --git a/packages/auth/src/Domain/User/PKCERepositoryInterface.ts b/packages/auth/src/Domain/User/PKCERepositoryInterface.ts new file mode 100644 index 000000000..3665cdc5e --- /dev/null +++ b/packages/auth/src/Domain/User/PKCERepositoryInterface.ts @@ -0,0 +1,4 @@ +export interface PKCERepositoryInterface { + storeCodeChallenge(codeChallenge: string): Promise + removeCodeChallenge(codeChallenge: string): Promise +} diff --git a/packages/auth/src/Domain/User/User.spec.ts b/packages/auth/src/Domain/User/User.spec.ts new file mode 100644 index 000000000..f1d02f8c2 --- /dev/null +++ b/packages/auth/src/Domain/User/User.spec.ts @@ -0,0 +1,33 @@ +import { User } from './User' + +describe('User', () => { + const createUser = () => new User() + + it('should indicate if support sessions', () => { + const user = createUser() + user.version = '004' + + expect(user.supportsSessions()).toBeTruthy() + }) + + it('should indicate if does not support sessions', () => { + const user = createUser() + user.version = '003' + + expect(user.supportsSessions()).toBeFalsy() + }) + + it('should indicate if the user is potentially a vault account', () => { + const user = createUser() + user.email = 'a75a31ce95365904ef0e0a8e6cefc1f5e99adfef81bbdb6d4499eeb10ae0ff67' + + expect(user.isPotentiallyAVaultAccount()).toBeTruthy() + }) + + it('should indicate if the user is not a vault account', () => { + const user = createUser() + user.email = 'test@test.te' + + expect(user.isPotentiallyAVaultAccount()).toBeFalsy() + }) +}) diff --git a/packages/auth/src/Domain/User/User.ts b/packages/auth/src/Domain/User/User.ts new file mode 100644 index 000000000..4c6bcf7b7 --- /dev/null +++ b/packages/auth/src/Domain/User/User.ts @@ -0,0 +1,202 @@ +import { Column, Entity, Index, JoinTable, ManyToMany, OneToMany, OneToOne, PrimaryGeneratedColumn } from 'typeorm' +import { RevokedSession } from '../Session/RevokedSession' +import { Role } from '../Role/Role' +import { Setting } from '../Setting/Setting' +import { UserSubscription } from '../Subscription/UserSubscription' +import { AnalyticsEntity } from '../Analytics/AnalyticsEntity' +import { ProtocolVersion } from '@standardnotes/common' + +@Entity({ name: 'users' }) +export class User { + static readonly PASSWORD_HASH_COST = 11 + static readonly DEFAULT_ENCRYPTION_VERSION = 1 + + @PrimaryGeneratedColumn('uuid') + declare uuid: string + + @Column({ + length: 255, + nullable: true, + }) + declare version: string + + @Column({ + length: 255, + nullable: true, + }) + @Index('index_users_on_email') + declare email: string + + @Column({ + name: 'pw_nonce', + length: 255, + nullable: true, + }) + declare pwNonce: string + + @Column({ + name: 'encrypted_server_key', + length: 255, + type: 'varchar', + nullable: true, + }) + declare encryptedServerKey: string | null + + @Column({ + name: 'server_encryption_version', + type: 'tinyint', + default: 0, + }) + declare serverEncryptionVersion: number + + @Column({ + name: 'kp_created', + length: 255, + nullable: true, + }) + declare kpCreated: string + + @Column({ + name: 'kp_origination', + length: 255, + nullable: true, + }) + declare kpOrigination: string + + @Column({ + name: 'pw_cost', + width: 11, + type: 'int', + nullable: true, + }) + declare pwCost: number + + @Column({ + name: 'pw_key_size', + width: 11, + type: 'int', + nullable: true, + }) + declare pwKeySize: number + + @Column({ + name: 'pw_salt', + length: 255, + nullable: true, + }) + declare pwSalt: string + + @Column({ + name: 'pw_alg', + length: 255, + nullable: true, + }) + declare pwAlg: string + + @Column({ + name: 'pw_func', + length: 255, + nullable: true, + }) + declare pwFunc: string + + @Column({ + name: 'encrypted_password', + length: 255, + }) + declare encryptedPassword: string + + @Column({ + name: 'created_at', + type: 'datetime', + }) + declare createdAt: Date + + @Column({ + name: 'updated_at', + type: 'datetime', + }) + declare updatedAt: Date + + @Column({ + name: 'locked_until', + type: 'datetime', + nullable: true, + }) + declare lockedUntil: Date | null + + @Column({ + name: 'num_failed_attempts', + type: 'int', + width: 11, + nullable: true, + }) + declare numberOfFailedAttempts: number | null + + @OneToMany( + /* istanbul ignore next */ + () => RevokedSession, + /* istanbul ignore next */ + (revokedSession) => revokedSession.user, + /* istanbul ignore next */ + { lazy: true, eager: false }, + ) + declare revokedSessions: Promise + + @OneToMany( + /* istanbul ignore next */ + () => Setting, + /* istanbul ignore next */ + (setting) => setting.user, + /* istanbul ignore next */ + { lazy: true, eager: false }, + ) + declare settings: Promise + + @ManyToMany( + /* istanbul ignore next */ + () => Role, + /* istanbul ignore next */ + { lazy: true, eager: false }, + ) + @JoinTable({ + name: 'user_roles', + joinColumn: { + name: 'user_uuid', + referencedColumnName: 'uuid', + }, + inverseJoinColumn: { + name: 'role_uuid', + referencedColumnName: 'uuid', + }, + }) + declare roles: Promise> + + @OneToMany( + /* istanbul ignore next */ + () => UserSubscription, + /* istanbul ignore next */ + (subscription) => subscription.user, + /* istanbul ignore next */ + { lazy: true, eager: false }, + ) + declare subscriptions: Promise + + @OneToOne( + /* istanbul ignore next */ + () => AnalyticsEntity, + /* istanbul ignore next */ + (analyticsEntity) => analyticsEntity.user, + /* istanbul ignore next */ + { lazy: true, eager: false }, + ) + declare analyticsEntity: Promise + + supportsSessions(): boolean { + return parseInt(this.version) >= parseInt(ProtocolVersion.V004) + } + + isPotentiallyAVaultAccount(): boolean { + return this.email.length === 64 && !this.email.includes('@') + } +} diff --git a/packages/auth/src/Domain/User/UserRepositoryInterface.ts b/packages/auth/src/Domain/User/UserRepositoryInterface.ts new file mode 100644 index 000000000..91a4d3e92 --- /dev/null +++ b/packages/auth/src/Domain/User/UserRepositoryInterface.ts @@ -0,0 +1,10 @@ +import { ReadStream } from 'fs' +import { User } from './User' + +export interface UserRepositoryInterface { + streamAll(): Promise + findOneByUuid(uuid: string): Promise + findOneByEmail(email: string): Promise + save(user: User): Promise + remove(user: User): Promise +} diff --git a/packages/auth/src/Domain/WebSockets/WebSocketsConnectionRepositoryInterface.ts b/packages/auth/src/Domain/WebSockets/WebSocketsConnectionRepositoryInterface.ts new file mode 100644 index 000000000..2f59ea765 --- /dev/null +++ b/packages/auth/src/Domain/WebSockets/WebSocketsConnectionRepositoryInterface.ts @@ -0,0 +1,5 @@ +export interface WebSocketsConnectionRepositoryInterface { + findAllByUserUuid(userUuid: string): Promise + saveConnection(userUuid: string, connectionId: string): Promise + removeConnection(connectionId: string): Promise +} diff --git a/packages/auth/src/Infra/InversifyExpressUtils/InversifyExpressAuthController.ts b/packages/auth/src/Infra/InversifyExpressUtils/InversifyExpressAuthController.ts new file mode 100644 index 000000000..e45e47697 --- /dev/null +++ b/packages/auth/src/Infra/InversifyExpressUtils/InversifyExpressAuthController.ts @@ -0,0 +1,288 @@ +import { Request, Response } from 'express' +import { + BaseHttpController, + controller, + httpGet, + httpPost, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + results, +} from 'inversify-express-utils' + +import TYPES from '../../Bootstrap/Types' +import { SessionServiceInterface } from '../../Domain/Session/SessionServiceInterface' +import { SignIn } from '../../Domain/UseCase/SignIn' +import { ClearLoginAttempts } from '../../Domain/UseCase/ClearLoginAttempts' +import { VerifyMFA } from '../../Domain/UseCase/VerifyMFA' +import { IncreaseLoginAttempts } from '../../Domain/UseCase/IncreaseLoginAttempts' +import { Logger } from 'winston' +import { GetUserKeyParams } from '../../Domain/UseCase/GetUserKeyParams/GetUserKeyParams' +import { ErrorTag } from '@standardnotes/common' +import { inject } from 'inversify' +import { AuthController } from '../../Controller/AuthController' + +@controller('/auth') +export class InversifyExpressAuthController extends BaseHttpController { + constructor( + @inject(TYPES.SessionService) private sessionService: SessionServiceInterface, + @inject(TYPES.VerifyMFA) private verifyMFA: VerifyMFA, + @inject(TYPES.SignIn) private signInUseCase: SignIn, + @inject(TYPES.GetUserKeyParams) private getUserKeyParams: GetUserKeyParams, + @inject(TYPES.ClearLoginAttempts) private clearLoginAttempts: ClearLoginAttempts, + @inject(TYPES.IncreaseLoginAttempts) private increaseLoginAttempts: IncreaseLoginAttempts, + @inject(TYPES.Logger) private logger: Logger, + @inject(TYPES.AuthController) private authController: AuthController, + ) { + super() + } + + @httpGet('/params', TYPES.AuthMiddlewareWithoutResponse) + async params(request: Request, response: Response): Promise { + if (response.locals.session) { + const result = await this.getUserKeyParams.execute({ + email: response.locals.user.email, + authenticated: true, + authenticatedUser: response.locals.user, + }) + + return this.json(result.keyParams) + } + + if (!request.query.email) { + return this.json( + { + error: { + message: 'Please provide an email address.', + }, + }, + 400, + ) + } + + const verifyMFAResponse = await this.verifyMFA.execute({ + email: request.query.email, + requestParams: request.query, + preventOTPFromFurtherUsage: false, + }) + + if (!verifyMFAResponse.success) { + return this.json( + { + error: { + tag: verifyMFAResponse.errorTag, + message: verifyMFAResponse.errorMessage, + payload: verifyMFAResponse.errorPayload, + }, + }, + 401, + ) + } + + const result = await this.getUserKeyParams.execute({ + email: request.query.email, + authenticated: false, + }) + + return this.json(result.keyParams) + } + + @httpPost('/sign_in', TYPES.LockMiddleware) + async signIn(request: Request): Promise { + if (!request.body.email || !request.body.password) { + this.logger.debug('/auth/sign_in request missing credentials: %O', request.body) + + return this.json( + { + error: { + tag: 'invalid-auth', + message: 'Invalid login credentials.', + }, + }, + 401, + ) + } + + const verifyMFAResponse = await this.verifyMFA.execute({ + email: request.body.email, + requestParams: request.body, + preventOTPFromFurtherUsage: true, + }) + + if (!verifyMFAResponse.success) { + return this.json( + { + error: { + tag: verifyMFAResponse.errorTag, + message: verifyMFAResponse.errorMessage, + payload: verifyMFAResponse.errorPayload, + }, + }, + 401, + ) + } + + const signInResult = await this.signInUseCase.execute({ + apiVersion: request.body.api, + userAgent: request.headers['user-agent'], + email: request.body.email, + password: request.body.password, + ephemeralSession: request.body.ephemeral ?? false, + }) + + if (!signInResult.success) { + await this.increaseLoginAttempts.execute({ email: request.body.email }) + + return this.json( + { + error: { + message: signInResult.errorMessage, + }, + }, + 401, + ) + } + + await this.clearLoginAttempts.execute({ email: request.body.email }) + + return this.json(signInResult.authResponse) + } + + @httpPost('/pkce_params', TYPES.AuthMiddlewareWithoutResponse) + async pkceParams(request: Request, response: Response): Promise { + if (!request.body.code_challenge) { + return this.json( + { + error: { + message: 'Please provide the code challenge parameter.', + }, + }, + 400, + ) + } + + if (response.locals.session) { + const result = await this.getUserKeyParams.execute({ + email: response.locals.user.email, + authenticated: true, + authenticatedUser: response.locals.user, + codeChallenge: request.body.code_challenge as string, + }) + + return this.json(result.keyParams) + } + + if (!request.body.email) { + return this.json( + { + error: { + message: 'Please provide an email address.', + }, + }, + 400, + ) + } + + const verifyMFAResponse = await this.verifyMFA.execute({ + email: request.body.email, + requestParams: request.body, + preventOTPFromFurtherUsage: true, + }) + + if (!verifyMFAResponse.success) { + return this.json( + { + error: { + tag: verifyMFAResponse.errorTag, + message: verifyMFAResponse.errorMessage, + payload: verifyMFAResponse.errorPayload, + }, + }, + 401, + ) + } + + const result = await this.getUserKeyParams.execute({ + email: request.body.email, + authenticated: false, + codeChallenge: request.body.code_challenge as string, + }) + + return this.json(result.keyParams) + } + + @httpPost('/pkce_sign_in', TYPES.LockMiddleware) + async pkceSignIn(request: Request): Promise { + if (!request.body.email || !request.body.password || !request.body.code_verifier) { + this.logger.debug('/auth/sign_in request missing credentials: %O', request.body) + + return this.json( + { + error: { + tag: 'invalid-auth', + message: 'Invalid login credentials.', + }, + }, + 401, + ) + } + + const signInResult = await this.signInUseCase.execute({ + apiVersion: request.body.api, + userAgent: request.headers['user-agent'], + email: request.body.email, + password: request.body.password, + ephemeralSession: request.body.ephemeral ?? false, + codeVerifier: request.body.code_verifier, + }) + + if (!signInResult.success) { + await this.increaseLoginAttempts.execute({ email: request.body.email }) + + return this.json( + { + error: { + message: signInResult.errorMessage, + }, + }, + 401, + ) + } + + await this.clearLoginAttempts.execute({ email: request.body.email }) + + return this.json(signInResult.authResponse) + } + + @httpPost('/sign_out', TYPES.AuthMiddlewareWithoutResponse) + async signOut(request: Request, response: Response): Promise { + if (response.locals.readOnlyAccess) { + return this.json( + { + error: { + tag: ErrorTag.ReadOnlyAccess, + message: 'Session has read-only access.', + }, + }, + 401, + ) + } + + const authorizationHeader = request.headers.authorization + + const userUuid = await this.sessionService.deleteSessionByToken(authorizationHeader.replace('Bearer ', '')) + + if (userUuid !== null) { + response.setHeader('x-invalidate-cache', userUuid) + } + response.status(204).send() + } + + @httpPost('/') + async register(request: Request): Promise { + const response = await this.authController.register({ + ...request.body, + userAgent: request.headers['user-agent'], + }) + + return this.json(response.data, response.status) + } +} diff --git a/packages/auth/src/Infra/MySQL/MySQLAnalyticsEntityRepository.spec.ts b/packages/auth/src/Infra/MySQL/MySQLAnalyticsEntityRepository.spec.ts new file mode 100644 index 000000000..f345f4a93 --- /dev/null +++ b/packages/auth/src/Infra/MySQL/MySQLAnalyticsEntityRepository.spec.ts @@ -0,0 +1,42 @@ +import 'reflect-metadata' + +import { Repository, SelectQueryBuilder } from 'typeorm' + +import { AnalyticsEntity } from '../../Domain/Analytics/AnalyticsEntity' + +import { MySQLAnalyticsEntityRepository } from './MySQLAnalyticsEntityRepository' + +describe('MySQLAnalyticsEntityRepository', () => { + let ormRepository: Repository + let analyticsEntity: AnalyticsEntity + let queryBuilder: SelectQueryBuilder + + const createRepository = () => new MySQLAnalyticsEntityRepository(ormRepository) + + beforeEach(() => { + analyticsEntity = {} as jest.Mocked + + queryBuilder = {} as jest.Mocked> + + ormRepository = {} as jest.Mocked> + ormRepository.save = jest.fn() + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => queryBuilder) + }) + + it('should save', async () => { + await createRepository().save(analyticsEntity) + + expect(ormRepository.save).toHaveBeenCalledWith(analyticsEntity) + }) + + it('should find one by user uuid', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.getOne = jest.fn().mockReturnValue(analyticsEntity) + + const result = await createRepository().findOneByUserUuid('123') + + expect(queryBuilder.where).toHaveBeenCalledWith('analytics_entity.user_uuid = :userUuid', { userUuid: '123' }) + + expect(result).toEqual(analyticsEntity) + }) +}) diff --git a/packages/auth/src/Infra/MySQL/MySQLAnalyticsEntityRepository.ts b/packages/auth/src/Infra/MySQL/MySQLAnalyticsEntityRepository.ts new file mode 100644 index 000000000..2a0fe0d56 --- /dev/null +++ b/packages/auth/src/Infra/MySQL/MySQLAnalyticsEntityRepository.ts @@ -0,0 +1,26 @@ +import { Uuid } from '@standardnotes/common' +import { inject, injectable } from 'inversify' +import { Repository } from 'typeorm' + +import TYPES from '../../Bootstrap/Types' +import { AnalyticsEntity } from '../../Domain/Analytics/AnalyticsEntity' +import { AnalyticsEntityRepositoryInterface } from '../../Domain/Analytics/AnalyticsEntityRepositoryInterface' + +@injectable() +export class MySQLAnalyticsEntityRepository implements AnalyticsEntityRepositoryInterface { + constructor( + @inject(TYPES.ORMAnalyticsEntityRepository) + private ormRepository: Repository, + ) {} + + async findOneByUserUuid(userUuid: Uuid): Promise { + return this.ormRepository + .createQueryBuilder('analytics_entity') + .where('analytics_entity.user_uuid = :userUuid', { userUuid }) + .getOne() + } + + async save(analyticsEntity: AnalyticsEntity): Promise { + return this.ormRepository.save(analyticsEntity) + } +} diff --git a/packages/auth/src/Infra/MySQL/MySQLOfflineSettingRepository.spec.ts b/packages/auth/src/Infra/MySQL/MySQLOfflineSettingRepository.spec.ts new file mode 100644 index 000000000..8d39fae2e --- /dev/null +++ b/packages/auth/src/Infra/MySQL/MySQLOfflineSettingRepository.spec.ts @@ -0,0 +1,57 @@ +import 'reflect-metadata' + +import { Repository, SelectQueryBuilder } from 'typeorm' +import { OfflineSetting } from '../../Domain/Setting/OfflineSetting' +import { OfflineSettingName } from '../../Domain/Setting/OfflineSettingName' + +import { MySQLOfflineSettingRepository } from './MySQLOfflineSettingRepository' + +describe('MySQLOfflineSettingRepository', () => { + let queryBuilder: SelectQueryBuilder + let offlineSetting: OfflineSetting + let ormRepository: Repository + + const createRepository = () => new MySQLOfflineSettingRepository(ormRepository) + + beforeEach(() => { + queryBuilder = {} as jest.Mocked> + + offlineSetting = {} as jest.Mocked + + ormRepository = {} as jest.Mocked> + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => queryBuilder) + ormRepository.save = jest.fn() + }) + + it('should save', async () => { + await createRepository().save(offlineSetting) + + expect(ormRepository.save).toHaveBeenCalledWith(offlineSetting) + }) + + it('should find one setting by name and user email', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.getOne = jest.fn().mockReturnValue(offlineSetting) + + const result = await createRepository().findOneByNameAndEmail(OfflineSettingName.FeaturesToken, 'test@test.com') + + expect(queryBuilder.where).toHaveBeenCalledWith('offline_setting.name = :name AND offline_setting.email = :email', { + name: 'FEATURES_TOKEN', + email: 'test@test.com', + }) + expect(result).toEqual(offlineSetting) + }) + + it('should find one setting by name and value', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.getOne = jest.fn().mockReturnValue(offlineSetting) + + const result = await createRepository().findOneByNameAndValue(OfflineSettingName.FeaturesToken, 'features-token') + + expect(queryBuilder.where).toHaveBeenCalledWith('offline_setting.name = :name AND offline_setting.value = :value', { + name: 'FEATURES_TOKEN', + value: 'features-token', + }) + expect(result).toEqual(offlineSetting) + }) +}) diff --git a/packages/auth/src/Infra/MySQL/MySQLOfflineSettingRepository.ts b/packages/auth/src/Infra/MySQL/MySQLOfflineSettingRepository.ts new file mode 100644 index 000000000..afd2b3bfa --- /dev/null +++ b/packages/auth/src/Infra/MySQL/MySQLOfflineSettingRepository.ts @@ -0,0 +1,36 @@ +import { inject, injectable } from 'inversify' +import { Repository } from 'typeorm' + +import TYPES from '../../Bootstrap/Types' +import { OfflineSetting } from '../../Domain/Setting/OfflineSetting' +import { OfflineSettingName } from '../../Domain/Setting/OfflineSettingName' +import { OfflineSettingRepositoryInterface } from '../../Domain/Setting/OfflineSettingRepositoryInterface' + +@injectable() +export class MySQLOfflineSettingRepository implements OfflineSettingRepositoryInterface { + constructor(@inject(TYPES.ORMOfflineSettingRepository) private ormRepository: Repository) {} + + async save(offlineSetting: OfflineSetting): Promise { + return this.ormRepository.save(offlineSetting) + } + + async findOneByNameAndValue(name: OfflineSettingName, value: string): Promise { + return this.ormRepository + .createQueryBuilder('offline_setting') + .where('offline_setting.name = :name AND offline_setting.value = :value', { + name, + value, + }) + .getOne() + } + + async findOneByNameAndEmail(name: OfflineSettingName, email: string): Promise { + return this.ormRepository + .createQueryBuilder('offline_setting') + .where('offline_setting.name = :name AND offline_setting.email = :email', { + name, + email, + }) + .getOne() + } +} diff --git a/packages/auth/src/Infra/MySQL/MySQLOfflineUserSubscriptionRepository.spec.ts b/packages/auth/src/Infra/MySQL/MySQLOfflineUserSubscriptionRepository.spec.ts new file mode 100644 index 000000000..d043af125 --- /dev/null +++ b/packages/auth/src/Infra/MySQL/MySQLOfflineUserSubscriptionRepository.spec.ts @@ -0,0 +1,189 @@ +import 'reflect-metadata' + +import { SubscriptionName } from '@standardnotes/common' + +import { Repository, SelectQueryBuilder, UpdateQueryBuilder } from 'typeorm' + +import { MySQLOfflineUserSubscriptionRepository } from './MySQLOfflineUserSubscriptionRepository' +import { OfflineUserSubscription } from '../../Domain/Subscription/OfflineUserSubscription' + +describe('MySQLOfflineUserSubscriptionRepository', () => { + let selectQueryBuilder: SelectQueryBuilder + let updateQueryBuilder: UpdateQueryBuilder + let offlineSubscription: OfflineUserSubscription + let ormRepository: Repository + + const createRepository = () => new MySQLOfflineUserSubscriptionRepository(ormRepository) + + beforeEach(() => { + selectQueryBuilder = {} as jest.Mocked> + updateQueryBuilder = {} as jest.Mocked> + + offlineSubscription = { + planName: SubscriptionName.ProPlan, + cancelled: false, + email: 'test@test.com', + } as jest.Mocked + + ormRepository = {} as jest.Mocked> + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder) + ormRepository.save = jest.fn() + }) + + it('should save', async () => { + await createRepository().save(offlineSubscription) + + expect(ormRepository.save).toHaveBeenCalledWith(offlineSubscription) + }) + + it('should find one longest lasting uncanceled subscription by user email if there are canceled ones', async () => { + const canceledSubscription = { + planName: SubscriptionName.ProPlan, + cancelled: true, + email: 'test@test.com', + } as jest.Mocked + + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder) + + selectQueryBuilder.where = jest.fn().mockReturnThis() + selectQueryBuilder.orderBy = jest.fn().mockReturnThis() + selectQueryBuilder.getMany = jest.fn().mockReturnValue([canceledSubscription, offlineSubscription]) + + const result = await createRepository().findOneByEmail('test@test.com') + + expect(selectQueryBuilder.where).toHaveBeenCalledWith('email = :email', { + email: 'test@test.com', + }) + expect(selectQueryBuilder.orderBy).toHaveBeenCalledWith('ends_at', 'DESC') + expect(selectQueryBuilder.getMany).toHaveBeenCalled() + expect(result).toEqual(offlineSubscription) + }) + + it('should find one, longest lasting subscription by user email if there are no canceled ones', async () => { + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder) + + selectQueryBuilder.where = jest.fn().mockReturnThis() + selectQueryBuilder.orderBy = jest.fn().mockReturnThis() + selectQueryBuilder.getMany = jest.fn().mockReturnValue([offlineSubscription]) + + const result = await createRepository().findOneByEmail('test@test.com') + + expect(selectQueryBuilder.where).toHaveBeenCalledWith('email = :email', { + email: 'test@test.com', + }) + expect(selectQueryBuilder.orderBy).toHaveBeenCalledWith('ends_at', 'DESC') + expect(selectQueryBuilder.getMany).toHaveBeenCalled() + expect(result).toEqual(offlineSubscription) + }) + + it('should find one, longest lasting subscription by user email if there are no ucanceled ones', async () => { + offlineSubscription.cancelled = true + + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder) + + selectQueryBuilder.where = jest.fn().mockReturnThis() + selectQueryBuilder.orderBy = jest.fn().mockReturnThis() + selectQueryBuilder.getMany = jest.fn().mockReturnValue([offlineSubscription]) + + const result = await createRepository().findOneByEmail('test@test.com') + + expect(selectQueryBuilder.where).toHaveBeenCalledWith('email = :email', { + email: 'test@test.com', + }) + expect(selectQueryBuilder.orderBy).toHaveBeenCalledWith('ends_at', 'DESC') + expect(selectQueryBuilder.getMany).toHaveBeenCalled() + expect(result).toEqual(offlineSubscription) + }) + + it('should find none if there are no subscriptions for the user', async () => { + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder) + + selectQueryBuilder.where = jest.fn().mockReturnThis() + selectQueryBuilder.orderBy = jest.fn().mockReturnThis() + selectQueryBuilder.getMany = jest.fn().mockReturnValue([]) + + const result = await createRepository().findOneByEmail('test@test.com') + + expect(selectQueryBuilder.where).toHaveBeenCalledWith('email = :email', { + email: 'test@test.com', + }) + expect(selectQueryBuilder.orderBy).toHaveBeenCalledWith('ends_at', 'DESC') + expect(selectQueryBuilder.getMany).toHaveBeenCalled() + expect(result).toBeNull() + }) + + it('should find multiple by user email active after', async () => { + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder) + + selectQueryBuilder.where = jest.fn().mockReturnThis() + selectQueryBuilder.orderBy = jest.fn().mockReturnThis() + selectQueryBuilder.getMany = jest.fn().mockReturnValue([offlineSubscription]) + + const result = await createRepository().findByEmail('test@test.com', 123) + + expect(selectQueryBuilder.where).toHaveBeenCalledWith('email = :email AND ends_at > :endsAt', { + email: 'test@test.com', + endsAt: 123, + }) + expect(selectQueryBuilder.orderBy).toHaveBeenCalledWith('ends_at', 'DESC') + expect(selectQueryBuilder.getMany).toHaveBeenCalled() + expect(result).toEqual([offlineSubscription]) + }) + + it('should update cancelled by subscription id', async () => { + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => updateQueryBuilder) + + updateQueryBuilder.update = jest.fn().mockReturnThis() + updateQueryBuilder.set = jest.fn().mockReturnThis() + updateQueryBuilder.where = jest.fn().mockReturnThis() + updateQueryBuilder.execute = jest.fn() + + await createRepository().updateCancelled(1, true, 1000) + + expect(updateQueryBuilder.update).toHaveBeenCalled() + expect(updateQueryBuilder.set).toHaveBeenCalledWith({ + updatedAt: expect.any(Number), + cancelled: true, + }) + expect(updateQueryBuilder.where).toHaveBeenCalledWith('subscription_id = :subscriptionId', { + subscriptionId: 1, + }) + expect(updateQueryBuilder.execute).toHaveBeenCalled() + }) + + it('should update ends at by subscription id', async () => { + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => updateQueryBuilder) + + updateQueryBuilder.update = jest.fn().mockReturnThis() + updateQueryBuilder.set = jest.fn().mockReturnThis() + updateQueryBuilder.where = jest.fn().mockReturnThis() + updateQueryBuilder.execute = jest.fn() + + await createRepository().updateEndsAt(1, 1000, 1000) + + expect(updateQueryBuilder.update).toHaveBeenCalled() + expect(updateQueryBuilder.set).toHaveBeenCalledWith({ + updatedAt: expect.any(Number), + endsAt: 1000, + }) + expect(updateQueryBuilder.where).toHaveBeenCalledWith('subscription_id = :subscriptionId', { + subscriptionId: 1, + }) + expect(updateQueryBuilder.execute).toHaveBeenCalled() + }) + + it('should find one offline user subscription by user subscription id', async () => { + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder) + + selectQueryBuilder.where = jest.fn().mockReturnThis() + selectQueryBuilder.getOne = jest.fn().mockReturnValue(offlineSubscription) + + const result = await createRepository().findOneBySubscriptionId(123) + + expect(selectQueryBuilder.where).toHaveBeenCalledWith('subscription_id = :subscriptionId', { + subscriptionId: 123, + }) + expect(selectQueryBuilder.getOne).toHaveBeenCalled() + expect(result).toEqual(offlineSubscription) + }) +}) diff --git a/packages/auth/src/Infra/MySQL/MySQLOfflineUserSubscriptionRepository.ts b/packages/auth/src/Infra/MySQL/MySQLOfflineUserSubscriptionRepository.ts new file mode 100644 index 000000000..e59dffba5 --- /dev/null +++ b/packages/auth/src/Infra/MySQL/MySQLOfflineUserSubscriptionRepository.ts @@ -0,0 +1,87 @@ +import { inject, injectable } from 'inversify' +import { Repository } from 'typeorm' + +import TYPES from '../../Bootstrap/Types' +import { OfflineUserSubscription } from '../../Domain/Subscription/OfflineUserSubscription' +import { OfflineUserSubscriptionRepositoryInterface } from '../../Domain/Subscription/OfflineUserSubscriptionRepositoryInterface' + +@injectable() +export class MySQLOfflineUserSubscriptionRepository implements OfflineUserSubscriptionRepositoryInterface { + constructor( + @inject(TYPES.ORMOfflineUserSubscriptionRepository) + private ormRepository: Repository, + ) {} + + async save(offlineUserSubscription: OfflineUserSubscription): Promise { + return this.ormRepository.save(offlineUserSubscription) + } + + async findOneBySubscriptionId(subscriptionId: number): Promise { + return await this.ormRepository + .createQueryBuilder() + .where('subscription_id = :subscriptionId', { + subscriptionId, + }) + .getOne() + } + + async findByEmail(email: string, activeAfter: number): Promise { + return await this.ormRepository + .createQueryBuilder() + .where('email = :email AND ends_at > :endsAt', { + email, + endsAt: activeAfter, + }) + .orderBy('ends_at', 'DESC') + .getMany() + } + + async findOneByEmail(email: string): Promise { + const subscriptions = await this.ormRepository + .createQueryBuilder() + .where('email = :email', { + email, + }) + .orderBy('ends_at', 'DESC') + .getMany() + + const uncanceled = subscriptions.find((subscription) => !subscription.cancelled) + if (uncanceled !== undefined) { + return uncanceled + } + + if (subscriptions.length !== 0) { + return subscriptions[0] + } + + return null + } + + async updateCancelled(subscriptionId: number, cancelled: boolean, updatedAt: number): Promise { + await this.ormRepository + .createQueryBuilder() + .update() + .set({ + cancelled, + updatedAt, + }) + .where('subscription_id = :subscriptionId', { + subscriptionId, + }) + .execute() + } + + async updateEndsAt(subscriptionId: number, endsAt: number, updatedAt: number): Promise { + await this.ormRepository + .createQueryBuilder() + .update() + .set({ + endsAt, + updatedAt, + }) + .where('subscription_id = :subscriptionId', { + subscriptionId, + }) + .execute() + } +} diff --git a/packages/auth/src/Infra/MySQL/MySQLRevokedSessionRepository.spec.ts b/packages/auth/src/Infra/MySQL/MySQLRevokedSessionRepository.spec.ts new file mode 100644 index 000000000..14b4a73c0 --- /dev/null +++ b/packages/auth/src/Infra/MySQL/MySQLRevokedSessionRepository.spec.ts @@ -0,0 +1,58 @@ +import 'reflect-metadata' + +import { Repository, SelectQueryBuilder } from 'typeorm' + +import { RevokedSession } from '../../Domain/Session/RevokedSession' + +import { MySQLRevokedSessionRepository } from './MySQLRevokedSessionRepository' + +describe('MySQLRevokedSessionRepository', () => { + let ormRepository: Repository + let queryBuilder: SelectQueryBuilder + let session: RevokedSession + + const createRepository = () => new MySQLRevokedSessionRepository(ormRepository) + + beforeEach(() => { + queryBuilder = {} as jest.Mocked> + + session = {} as jest.Mocked + + ormRepository = {} as jest.Mocked> + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => queryBuilder) + ormRepository.save = jest.fn() + ormRepository.remove = jest.fn() + }) + + it('should save', async () => { + await createRepository().save(session) + + expect(ormRepository.save).toHaveBeenCalledWith(session) + }) + + it('should remove', async () => { + await createRepository().remove(session) + + expect(ormRepository.remove).toHaveBeenCalledWith(session) + }) + + it('should find one session by id', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.getOne = jest.fn().mockReturnValue(session) + + const result = await createRepository().findOneByUuid('123') + + expect(queryBuilder.where).toHaveBeenCalledWith('revoked_session.uuid = :uuid', { uuid: '123' }) + expect(result).toEqual(session) + }) + + it('should find all revoked sessions by user id', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.getMany = jest.fn().mockReturnValue([session]) + + const result = await createRepository().findAllByUserUuid('123') + + expect(queryBuilder.where).toHaveBeenCalledWith('revoked_session.user_uuid = :user_uuid', { user_uuid: '123' }) + expect(result).toEqual([session]) + }) +}) diff --git a/packages/auth/src/Infra/MySQL/MySQLRevokedSessionRepository.ts b/packages/auth/src/Infra/MySQL/MySQLRevokedSessionRepository.ts new file mode 100644 index 000000000..83b1a508c --- /dev/null +++ b/packages/auth/src/Infra/MySQL/MySQLRevokedSessionRepository.ts @@ -0,0 +1,38 @@ +import { inject, injectable } from 'inversify' +import { Repository } from 'typeorm' + +import TYPES from '../../Bootstrap/Types' +import { RevokedSession } from '../../Domain/Session/RevokedSession' +import { RevokedSessionRepositoryInterface } from '../../Domain/Session/RevokedSessionRepositoryInterface' + +@injectable() +export class MySQLRevokedSessionRepository implements RevokedSessionRepositoryInterface { + constructor( + @inject(TYPES.ORMRevokedSessionRepository) + private ormRepository: Repository, + ) {} + + async save(revokedSession: RevokedSession): Promise { + return this.ormRepository.save(revokedSession) + } + + async remove(revokedSession: RevokedSession): Promise { + return this.ormRepository.remove(revokedSession) + } + + async findAllByUserUuid(userUuid: string): Promise { + return this.ormRepository + .createQueryBuilder('revoked_session') + .where('revoked_session.user_uuid = :user_uuid', { + user_uuid: userUuid, + }) + .getMany() + } + + async findOneByUuid(uuid: string): Promise { + return this.ormRepository + .createQueryBuilder('revoked_session') + .where('revoked_session.uuid = :uuid', { uuid }) + .getOne() + } +} diff --git a/packages/auth/src/Infra/MySQL/MySQLRoleRepository.spec.ts b/packages/auth/src/Infra/MySQL/MySQLRoleRepository.spec.ts new file mode 100644 index 000000000..d7409883f --- /dev/null +++ b/packages/auth/src/Infra/MySQL/MySQLRoleRepository.spec.ts @@ -0,0 +1,49 @@ +import 'reflect-metadata' + +import { Repository, SelectQueryBuilder } from 'typeorm' +import { Role } from '../../Domain/Role/Role' + +import { MySQLRoleRepository } from './MySQLRoleRepository' + +describe('MySQLRoleRepository', () => { + let ormRepository: Repository + let queryBuilder: SelectQueryBuilder + let role: Role + + const createRepository = () => new MySQLRoleRepository(ormRepository) + + beforeEach(() => { + queryBuilder = {} as jest.Mocked> + queryBuilder.cache = jest.fn().mockReturnThis() + + role = {} as jest.Mocked + + ormRepository = {} as jest.Mocked> + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => queryBuilder) + }) + + it('should find latest version of a role by name', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.take = jest.fn().mockReturnThis() + queryBuilder.orderBy = jest.fn().mockReturnThis() + queryBuilder.getMany = jest.fn().mockReturnValue([role]) + + const result = await createRepository().findOneByName('test') + + expect(queryBuilder.where).toHaveBeenCalledWith('role.name = :name', { name: 'test' }) + expect(queryBuilder.take).toHaveBeenCalledWith(1) + expect(queryBuilder.orderBy).toHaveBeenCalledWith('version', 'DESC') + expect(result).toEqual(role) + }) + + it('should return null if not found the latest version of a role by name', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.take = jest.fn().mockReturnThis() + queryBuilder.orderBy = jest.fn().mockReturnThis() + queryBuilder.getMany = jest.fn().mockReturnValue([]) + + const result = await createRepository().findOneByName('test') + + expect(result).toBeNull() + }) +}) diff --git a/packages/auth/src/Infra/MySQL/MySQLRoleRepository.ts b/packages/auth/src/Infra/MySQL/MySQLRoleRepository.ts new file mode 100644 index 000000000..495dcd192 --- /dev/null +++ b/packages/auth/src/Infra/MySQL/MySQLRoleRepository.ts @@ -0,0 +1,30 @@ +import { inject, injectable } from 'inversify' +import { Repository } from 'typeorm' + +import TYPES from '../../Bootstrap/Types' +import { Role } from '../../Domain/Role/Role' +import { RoleRepositoryInterface } from '../../Domain/Role/RoleRepositoryInterface' + +@injectable() +export class MySQLRoleRepository implements RoleRepositoryInterface { + constructor( + @inject(TYPES.ORMRoleRepository) + private ormRepository: Repository, + ) {} + + async findOneByName(name: string): Promise { + const roles = await this.ormRepository + .createQueryBuilder('role') + .where('role.name = :name', { name }) + .orderBy('version', 'DESC') + .cache(`role_${name}`, 600000) + .take(1) + .getMany() + + if (roles.length === 0) { + return null + } + + return roles.shift() as Role + } +} diff --git a/packages/auth/src/Infra/MySQL/MySQLSessionRepository.spec.ts b/packages/auth/src/Infra/MySQL/MySQLSessionRepository.spec.ts new file mode 100644 index 000000000..b977b7e32 --- /dev/null +++ b/packages/auth/src/Infra/MySQL/MySQLSessionRepository.spec.ts @@ -0,0 +1,174 @@ +import 'reflect-metadata' + +import * as dayjs from 'dayjs' + +import { Repository, SelectQueryBuilder, UpdateQueryBuilder } from 'typeorm' +import { Session } from '../../Domain/Session/Session' + +import { MySQLSessionRepository } from './MySQLSessionRepository' + +describe('MySQLSessionRepository', () => { + let ormRepository: Repository + let queryBuilder: SelectQueryBuilder + let updateQueryBuilder: UpdateQueryBuilder + let session: Session + + const createRepository = () => new MySQLSessionRepository(ormRepository) + + beforeEach(() => { + queryBuilder = {} as jest.Mocked> + updateQueryBuilder = {} as jest.Mocked> + + session = {} as jest.Mocked + + ormRepository = {} as jest.Mocked> + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => queryBuilder) + ormRepository.save = jest.fn() + ormRepository.remove = jest.fn() + }) + + it('should save', async () => { + await createRepository().save(session) + + expect(ormRepository.save).toHaveBeenCalledWith(session) + }) + + it('should remove', async () => { + await createRepository().remove(session) + + expect(ormRepository.remove).toHaveBeenCalledWith(session) + }) + + it('should clear user agent data on all user sessions', async () => { + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => updateQueryBuilder) + + updateQueryBuilder.update = jest.fn().mockReturnThis() + updateQueryBuilder.set = jest.fn().mockReturnThis() + updateQueryBuilder.where = jest.fn().mockReturnThis() + updateQueryBuilder.execute = jest.fn() + + await createRepository().clearUserAgentByUserUuid('1-2-3') + + expect(updateQueryBuilder.update).toHaveBeenCalled() + expect(updateQueryBuilder.set).toHaveBeenCalledWith({ + userAgent: null, + }) + expect(updateQueryBuilder.where).toHaveBeenCalledWith('user_uuid = :userUuid', { userUuid: '1-2-3' }) + expect(updateQueryBuilder.execute).toHaveBeenCalled() + }) + + it('should update hashed tokens on a session', async () => { + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => updateQueryBuilder) + + updateQueryBuilder.update = jest.fn().mockReturnThis() + updateQueryBuilder.set = jest.fn().mockReturnThis() + updateQueryBuilder.where = jest.fn().mockReturnThis() + updateQueryBuilder.execute = jest.fn() + + await createRepository().updateHashedTokens('123', '234', '345') + + expect(updateQueryBuilder.update).toHaveBeenCalled() + expect(updateQueryBuilder.set).toHaveBeenCalledWith({ + hashedAccessToken: '234', + hashedRefreshToken: '345', + }) + expect(updateQueryBuilder.where).toHaveBeenCalledWith('uuid = :uuid', { uuid: '123' }) + expect(updateQueryBuilder.execute).toHaveBeenCalled() + }) + + it('should update token expiration dates on a session', async () => { + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => updateQueryBuilder) + + updateQueryBuilder.update = jest.fn().mockReturnThis() + updateQueryBuilder.set = jest.fn().mockReturnThis() + updateQueryBuilder.where = jest.fn().mockReturnThis() + updateQueryBuilder.execute = jest.fn() + + await createRepository().updatedTokenExpirationDates( + '123', + dayjs.utc('2020-11-26 13:34').toDate(), + dayjs.utc('2020-11-26 14:34').toDate(), + ) + + expect(updateQueryBuilder.update).toHaveBeenCalled() + expect(updateQueryBuilder.set).toHaveBeenCalledWith({ + accessExpiration: dayjs.utc('2020-11-26T13:34:00.000Z').toDate(), + refreshExpiration: dayjs.utc('2020-11-26T14:34:00.000Z').toDate(), + }) + expect(updateQueryBuilder.where).toHaveBeenCalledWith('uuid = :uuid', { uuid: '123' }) + expect(updateQueryBuilder.execute).toHaveBeenCalled() + }) + + it('should find active sessions by user id', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.getMany = jest.fn().mockReturnValue([session]) + + const result = await createRepository().findAllByRefreshExpirationAndUserUuid('123') + + expect(queryBuilder.where).toHaveBeenCalledWith( + 'session.refresh_expiration > :refresh_expiration AND session.user_uuid = :user_uuid', + { refresh_expiration: expect.any(Date), user_uuid: '123' }, + ) + expect(result).toEqual([session]) + }) + + it('should find all sessions by user id', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.getMany = jest.fn().mockReturnValue([session]) + + const result = await createRepository().findAllByUserUuid('123') + + expect(queryBuilder.where).toHaveBeenCalledWith('session.user_uuid = :user_uuid', { user_uuid: '123' }) + expect(result).toEqual([session]) + }) + + it('should find one session by id', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.getOne = jest.fn().mockReturnValue(session) + + const result = await createRepository().findOneByUuid('123') + + expect(queryBuilder.where).toHaveBeenCalledWith('session.uuid = :uuid', { uuid: '123' }) + expect(result).toEqual(session) + }) + + it('should find one session by id and user id', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.getOne = jest.fn().mockReturnValue(session) + + const result = await createRepository().findOneByUuidAndUserUuid('123', '234') + + expect(queryBuilder.where).toHaveBeenCalledWith('session.uuid = :uuid AND session.user_uuid = :user_uuid', { + uuid: '123', + user_uuid: '234', + }) + expect(result).toEqual(session) + }) + + it('should delete all session for a user except the current one', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.delete = jest.fn().mockReturnThis() + queryBuilder.execute = jest.fn() + + await createRepository().deleteAllByUserUuid('123', '234') + + expect(queryBuilder.delete).toHaveBeenCalled() + expect(queryBuilder.where).toHaveBeenCalledWith('user_uuid = :user_uuid AND uuid != :current_session_uuid', { + user_uuid: '123', + current_session_uuid: '234', + }) + expect(queryBuilder.execute).toHaveBeenCalled() + }) + + it('should delete one session by id', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.delete = jest.fn().mockReturnThis() + queryBuilder.execute = jest.fn() + + await createRepository().deleteOneByUuid('123') + + expect(queryBuilder.delete).toHaveBeenCalled() + expect(queryBuilder.where).toHaveBeenCalledWith('uuid = :uuid', { uuid: '123' }) + expect(queryBuilder.execute).toHaveBeenCalled() + }) +}) diff --git a/packages/auth/src/Infra/MySQL/MySQLSessionRepository.ts b/packages/auth/src/Infra/MySQL/MySQLSessionRepository.ts new file mode 100644 index 000000000..da327f3ae --- /dev/null +++ b/packages/auth/src/Infra/MySQL/MySQLSessionRepository.ts @@ -0,0 +1,104 @@ +import * as dayjs from 'dayjs' + +import { inject, injectable } from 'inversify' +import { Repository } from 'typeorm' +import TYPES from '../../Bootstrap/Types' + +import { Session } from '../../Domain/Session/Session' +import { SessionRepositoryInterface } from '../../Domain/Session/SessionRepositoryInterface' + +@injectable() +export class MySQLSessionRepository implements SessionRepositoryInterface { + constructor( + @inject(TYPES.ORMSessionRepository) + private ormRepository: Repository, + ) {} + + async save(session: Session): Promise { + return this.ormRepository.save(session) + } + + async remove(session: Session): Promise { + return this.ormRepository.remove(session) + } + + async clearUserAgentByUserUuid(userUuid: string): Promise { + await this.ormRepository + .createQueryBuilder('session') + .update() + .set({ + userAgent: null, + }) + .where('user_uuid = :userUuid', { userUuid }) + .execute() + } + + async updateHashedTokens(uuid: string, hashedAccessToken: string, hashedRefreshToken: string): Promise { + await this.ormRepository + .createQueryBuilder('session') + .update() + .set({ + hashedAccessToken, + hashedRefreshToken, + }) + .where('uuid = :uuid', { uuid }) + .execute() + } + + async updatedTokenExpirationDates(uuid: string, accessExpiration: Date, refreshExpiration: Date): Promise { + await this.ormRepository + .createQueryBuilder('session') + .update() + .set({ + accessExpiration, + refreshExpiration, + }) + .where('uuid = :uuid', { uuid }) + .execute() + } + + async findAllByRefreshExpirationAndUserUuid(userUuid: string): Promise { + return this.ormRepository + .createQueryBuilder('session') + .where('session.refresh_expiration > :refresh_expiration AND session.user_uuid = :user_uuid', { + refresh_expiration: dayjs.utc().toDate(), + user_uuid: userUuid, + }) + .getMany() + } + + async findOneByUuidAndUserUuid(uuid: string, userUuid: string): Promise { + return this.ormRepository + .createQueryBuilder('session') + .where('session.uuid = :uuid AND session.user_uuid = :user_uuid', { uuid, user_uuid: userUuid }) + .getOne() + } + + async deleteOneByUuid(uuid: string): Promise { + await this.ormRepository.createQueryBuilder('session').delete().where('uuid = :uuid', { uuid }).execute() + } + + async findOneByUuid(uuid: string): Promise { + return this.ormRepository.createQueryBuilder('session').where('session.uuid = :uuid', { uuid }).getOne() + } + + async findAllByUserUuid(userUuid: string): Promise> { + return this.ormRepository + .createQueryBuilder('session') + .where('session.user_uuid = :user_uuid', { + user_uuid: userUuid, + }) + .getMany() + } + + async deleteAllByUserUuid(userUuid: string, currentSessionUuid: string): Promise { + await this.ormRepository + .createQueryBuilder('session') + .delete() + .where('user_uuid = :user_uuid AND uuid != :current_session_uuid', { + user_uuid: userUuid, + current_session_uuid: currentSessionUuid, + }) + .execute() + } +} diff --git a/packages/auth/src/Infra/MySQL/MySQLSettingRepository.spec.ts b/packages/auth/src/Infra/MySQL/MySQLSettingRepository.spec.ts new file mode 100644 index 000000000..1c1bebcec --- /dev/null +++ b/packages/auth/src/Infra/MySQL/MySQLSettingRepository.spec.ts @@ -0,0 +1,140 @@ +import 'reflect-metadata' + +import { ReadStream } from 'fs' +import { Repository, SelectQueryBuilder } from 'typeorm' +import { Setting } from '../../Domain/Setting/Setting' + +import { MySQLSettingRepository } from './MySQLSettingRepository' +import { EmailBackupFrequency, SettingName } from '@standardnotes/settings' + +describe('MySQLSettingRepository', () => { + let ormRepository: Repository + let queryBuilder: SelectQueryBuilder + let setting: Setting + + const createRepository = () => new MySQLSettingRepository(ormRepository) + + beforeEach(() => { + queryBuilder = {} as jest.Mocked> + + setting = {} as jest.Mocked + + ormRepository = {} as jest.Mocked> + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => queryBuilder) + ormRepository.save = jest.fn() + }) + + it('should save', async () => { + await createRepository().save(setting) + + expect(ormRepository.save).toHaveBeenCalledWith(setting) + }) + + it('should stream all settings by name and value', async () => { + const stream = {} as jest.Mocked + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.orderBy = jest.fn().mockReturnThis() + queryBuilder.stream = jest.fn().mockReturnValue(stream) + + const result = await createRepository().streamAllByNameAndValue( + SettingName.EmailBackupFrequency, + EmailBackupFrequency.Daily, + ) + + expect(result).toEqual(stream) + }) + + it('should find one setting by uuid', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.getOne = jest.fn().mockReturnValue(setting) + + const result = await createRepository().findOneByUuid('1-2-3') + + expect(queryBuilder.where).toHaveBeenCalledWith('setting.uuid = :uuid', { uuid: '1-2-3' }) + expect(result).toEqual(setting) + }) + + it('should find one setting by name and user uuid', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.getOne = jest.fn().mockReturnValue(setting) + + const result = await createRepository().findOneByNameAndUserUuid('test', '1-2-3') + + expect(queryBuilder.where).toHaveBeenCalledWith('setting.name = :name AND setting.user_uuid = :user_uuid', { + name: 'test', + user_uuid: '1-2-3', + }) + expect(result).toEqual(setting) + }) + + it('should find one setting by name and uuid', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.getOne = jest.fn().mockReturnValue(setting) + + const result = await createRepository().findOneByUuidAndNames('1-2-3', ['test' as SettingName]) + + expect(queryBuilder.where).toHaveBeenCalledWith('setting.uuid = :uuid AND setting.name IN (:...names)', { + names: ['test'], + uuid: '1-2-3', + }) + expect(result).toEqual(setting) + }) + + it('should find last setting by name and user uuid', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.orderBy = jest.fn().mockReturnThis() + queryBuilder.limit = jest.fn().mockReturnThis() + queryBuilder.getMany = jest.fn().mockReturnValue([setting]) + + const result = await createRepository().findLastByNameAndUserUuid('test', '1-2-3') + + expect(queryBuilder.where).toHaveBeenCalledWith('setting.name = :name AND setting.user_uuid = :user_uuid', { + name: 'test', + user_uuid: '1-2-3', + }) + expect(queryBuilder.orderBy).toHaveBeenCalledWith('updated_at', 'DESC') + expect(queryBuilder.limit).toHaveBeenCalledWith(1) + expect(result).toEqual(setting) + }) + + it('should return null if not found last setting by name and user uuid', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.orderBy = jest.fn().mockReturnThis() + queryBuilder.limit = jest.fn().mockReturnThis() + queryBuilder.getMany = jest.fn().mockReturnValue([]) + + const result = await createRepository().findLastByNameAndUserUuid('test', '1-2-3') + + expect(result).toBeNull() + }) + + it('should find all by user uuid', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + + const settings = [setting] + queryBuilder.getMany = jest.fn().mockReturnValue(settings) + + const userUuid = '123' + const result = await createRepository().findAllByUserUuid(userUuid) + + expect(queryBuilder.where).toHaveBeenCalledWith('setting.user_uuid = :user_uuid', { user_uuid: userUuid }) + expect(result).toEqual(settings) + }) + + it('should delete setting if it does exist', async () => { + const queryBuilder = { + delete: () => queryBuilder, + where: () => queryBuilder, + execute: () => undefined, + } + + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => queryBuilder) + + const result = await createRepository().deleteByUserUuid({ + userUuid: 'userUuid', + settingName: 'settingName', + }) + + expect(result).toEqual(undefined) + }) +}) diff --git a/packages/auth/src/Infra/MySQL/MySQLSettingRepository.ts b/packages/auth/src/Infra/MySQL/MySQLSettingRepository.ts new file mode 100644 index 000000000..0e1be9e81 --- /dev/null +++ b/packages/auth/src/Infra/MySQL/MySQLSettingRepository.ts @@ -0,0 +1,98 @@ +import { SettingName } from '@standardnotes/settings' +import { ReadStream } from 'fs' +import { inject, injectable } from 'inversify' +import { Repository } from 'typeorm' +import TYPES from '../../Bootstrap/Types' +import { Setting } from '../../Domain/Setting/Setting' +import { SettingRepositoryInterface } from '../../Domain/Setting/SettingRepositoryInterface' +import { DeleteSettingDto } from '../../Domain/UseCase/DeleteSetting/DeleteSettingDto' + +@injectable() +export class MySQLSettingRepository implements SettingRepositoryInterface { + constructor( + @inject(TYPES.ORMSettingRepository) + private ormRepository: Repository, + ) {} + + async save(setting: Setting): Promise { + return this.ormRepository.save(setting) + } + + async findOneByUuidAndNames(uuid: string, names: SettingName[]): Promise { + return this.ormRepository + .createQueryBuilder('setting') + .where('setting.uuid = :uuid AND setting.name IN (:...names)', { + names, + uuid, + }) + .getOne() + } + + async streamAllByNameAndValue(name: SettingName, value: string): Promise { + return this.ormRepository + .createQueryBuilder('setting') + .where('setting.name = :name AND setting.value = :value', { + name, + value, + }) + .orderBy('updated_at', 'ASC') + .stream() + } + + async findOneByUuid(uuid: string): Promise { + return this.ormRepository + .createQueryBuilder('setting') + .where('setting.uuid = :uuid', { + uuid, + }) + .getOne() + } + + async findOneByNameAndUserUuid(name: string, userUuid: string): Promise { + return this.ormRepository + .createQueryBuilder('setting') + .where('setting.name = :name AND setting.user_uuid = :user_uuid', { + name, + user_uuid: userUuid, + }) + .getOne() + } + + async findLastByNameAndUserUuid(name: string, userUuid: string): Promise { + const settings = await this.ormRepository + .createQueryBuilder('setting') + .where('setting.name = :name AND setting.user_uuid = :user_uuid', { + name, + user_uuid: userUuid, + }) + .orderBy('updated_at', 'DESC') + .limit(1) + .getMany() + + if (settings.length === 0) { + return null + } + + return settings.pop() as Setting + } + + async findAllByUserUuid(userUuid: string): Promise { + return this.ormRepository + .createQueryBuilder('setting') + .where('setting.user_uuid = :user_uuid', { + user_uuid: userUuid, + }) + .getMany() + } + + async deleteByUserUuid({ settingName, userUuid }: DeleteSettingDto): Promise { + await this.ormRepository + .createQueryBuilder('setting') + .delete() + .where('name = :name AND user_uuid = :user_uuid', { + user_uuid: userUuid, + name: settingName, + }) + .execute() + } +} diff --git a/packages/auth/src/Infra/MySQL/MySQLSharedSubscriptionInvitationRepository.spec.ts b/packages/auth/src/Infra/MySQL/MySQLSharedSubscriptionInvitationRepository.spec.ts new file mode 100644 index 000000000..647ea41a2 --- /dev/null +++ b/packages/auth/src/Infra/MySQL/MySQLSharedSubscriptionInvitationRepository.spec.ts @@ -0,0 +1,83 @@ +import 'reflect-metadata' + +import { Repository, SelectQueryBuilder } from 'typeorm' + +import { MySQLSharedSubscriptionInvitationRepository } from './MySQLSharedSubscriptionInvitationRepository' +import { SharedSubscriptionInvitation } from '../../Domain/SharedSubscription/SharedSubscriptionInvitation' +import { InvitationStatus } from '../../Domain/SharedSubscription/InvitationStatus' + +describe('MySQLSharedSubscriptionInvitationRepository', () => { + let ormRepository: Repository + let queryBuilder: SelectQueryBuilder + let invitation: SharedSubscriptionInvitation + + const createRepository = () => new MySQLSharedSubscriptionInvitationRepository(ormRepository) + + beforeEach(() => { + queryBuilder = {} as jest.Mocked> + + invitation = {} as jest.Mocked + + ormRepository = {} as jest.Mocked> + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => queryBuilder) + ormRepository.save = jest.fn() + }) + + it('should save', async () => { + await createRepository().save(invitation) + + expect(ormRepository.save).toHaveBeenCalledWith(invitation) + }) + + it('should get invitations by inviter email', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.getMany = jest.fn().mockReturnValue([]) + + const result = await createRepository().findByInviterEmail('test@test.te') + + expect(queryBuilder.where).toHaveBeenCalledWith('invitation.inviter_identifier = :inviterEmail', { + inviterEmail: 'test@test.te', + }) + + expect(result).toEqual([]) + }) + + it('should count invitations by inviter email and statuses', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.getCount = jest.fn().mockReturnValue(3) + + const result = await createRepository().countByInviterEmailAndStatus('test@test.te', [InvitationStatus.Sent]) + + expect(queryBuilder.where).toHaveBeenCalledWith( + 'invitation.inviter_identifier = :inviterEmail AND invitation.status IN (:...statuses)', + { inviterEmail: 'test@test.te', statuses: ['sent'] }, + ) + + expect(result).toEqual(3) + }) + + it('should find one invitation by name and uuid', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.getOne = jest.fn().mockReturnValue(invitation) + + const result = await createRepository().findOneByUuidAndStatus('1-2-3', InvitationStatus.Sent) + + expect(queryBuilder.where).toHaveBeenCalledWith('invitation.uuid = :uuid AND invitation.status = :status', { + uuid: '1-2-3', + status: 'sent', + }) + + expect(result).toEqual(invitation) + }) + + it('should find one invitation by uuid', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.getOne = jest.fn().mockReturnValue(invitation) + + const result = await createRepository().findOneByUuid('1-2-3') + + expect(queryBuilder.where).toHaveBeenCalledWith('invitation.uuid = :uuid', { uuid: '1-2-3' }) + + expect(result).toEqual(invitation) + }) +}) diff --git a/packages/auth/src/Infra/MySQL/MySQLSharedSubscriptionInvitationRepository.ts b/packages/auth/src/Infra/MySQL/MySQLSharedSubscriptionInvitationRepository.ts new file mode 100644 index 000000000..9807d2395 --- /dev/null +++ b/packages/auth/src/Infra/MySQL/MySQLSharedSubscriptionInvitationRepository.ts @@ -0,0 +1,57 @@ +import { inject, injectable } from 'inversify' +import { Repository } from 'typeorm' +import TYPES from '../../Bootstrap/Types' + +import { InvitationStatus } from '../../Domain/SharedSubscription/InvitationStatus' +import { SharedSubscriptionInvitation } from '../../Domain/SharedSubscription/SharedSubscriptionInvitation' +import { SharedSubscriptionInvitationRepositoryInterface } from '../../Domain/SharedSubscription/SharedSubscriptionInvitationRepositoryInterface' + +@injectable() +export class MySQLSharedSubscriptionInvitationRepository implements SharedSubscriptionInvitationRepositoryInterface { + constructor( + @inject(TYPES.ORMSharedSubscriptionInvitationRepository) + private ormRepository: Repository, + ) {} + + async save(sharedSubscriptionInvitation: SharedSubscriptionInvitation): Promise { + return this.ormRepository.save(sharedSubscriptionInvitation) + } + + async findByInviterEmail(inviterEmail: string): Promise { + return this.ormRepository + .createQueryBuilder('invitation') + .where('invitation.inviter_identifier = :inviterEmail', { + inviterEmail, + }) + .getMany() + } + + async countByInviterEmailAndStatus(inviterEmail: string, statuses: InvitationStatus[]): Promise { + return this.ormRepository + .createQueryBuilder('invitation') + .where('invitation.inviter_identifier = :inviterEmail AND invitation.status IN (:...statuses)', { + inviterEmail, + statuses, + }) + .getCount() + } + + async findOneByUuid(uuid: string): Promise { + return this.ormRepository + .createQueryBuilder('invitation') + .where('invitation.uuid = :uuid', { + uuid, + }) + .getOne() + } + + async findOneByUuidAndStatus(uuid: string, status: InvitationStatus): Promise { + return this.ormRepository + .createQueryBuilder('invitation') + .where('invitation.uuid = :uuid AND invitation.status = :status', { + uuid, + status, + }) + .getOne() + } +} diff --git a/packages/auth/src/Infra/MySQL/MySQLSubscriptionSettingRepository.spec.ts b/packages/auth/src/Infra/MySQL/MySQLSubscriptionSettingRepository.spec.ts new file mode 100644 index 000000000..09ad164b8 --- /dev/null +++ b/packages/auth/src/Infra/MySQL/MySQLSubscriptionSettingRepository.spec.ts @@ -0,0 +1,68 @@ +import 'reflect-metadata' + +import { Repository, SelectQueryBuilder } from 'typeorm' +import { SubscriptionSetting } from '../../Domain/Setting/SubscriptionSetting' + +import { MySQLSubscriptionSettingRepository } from './MySQLSubscriptionSettingRepository' + +describe('MySQLSubscriptionSettingRepository', () => { + let ormRepository: Repository + let queryBuilder: SelectQueryBuilder + let setting: SubscriptionSetting + + const createRepository = () => new MySQLSubscriptionSettingRepository(ormRepository) + + beforeEach(() => { + queryBuilder = {} as jest.Mocked> + + setting = {} as jest.Mocked + + ormRepository = {} as jest.Mocked> + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => queryBuilder) + ormRepository.save = jest.fn() + }) + + it('should save', async () => { + await createRepository().save(setting) + + expect(ormRepository.save).toHaveBeenCalledWith(setting) + }) + + it('should find one setting by uuid', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.getOne = jest.fn().mockReturnValue(setting) + + const result = await createRepository().findOneByUuid('1-2-3') + + expect(queryBuilder.where).toHaveBeenCalledWith('setting.uuid = :uuid', { uuid: '1-2-3' }) + expect(result).toEqual(setting) + }) + + it('should find last setting by name and user uuid', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.orderBy = jest.fn().mockReturnThis() + queryBuilder.limit = jest.fn().mockReturnThis() + queryBuilder.getMany = jest.fn().mockReturnValue([setting]) + + const result = await createRepository().findLastByNameAndUserSubscriptionUuid('test', '1-2-3') + + expect(queryBuilder.where).toHaveBeenCalledWith( + 'setting.name = :name AND setting.user_subscription_uuid = :userSubscriptionUuid', + { name: 'test', userSubscriptionUuid: '1-2-3' }, + ) + expect(queryBuilder.orderBy).toHaveBeenCalledWith('updated_at', 'DESC') + expect(queryBuilder.limit).toHaveBeenCalledWith(1) + expect(result).toEqual(setting) + }) + + it('should return null if not found last setting by name and user uuid', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.orderBy = jest.fn().mockReturnThis() + queryBuilder.limit = jest.fn().mockReturnThis() + queryBuilder.getMany = jest.fn().mockReturnValue([]) + + const result = await createRepository().findLastByNameAndUserSubscriptionUuid('test', '1-2-3') + + expect(result).toBeNull() + }) +}) diff --git a/packages/auth/src/Infra/MySQL/MySQLSubscriptionSettingRepository.ts b/packages/auth/src/Infra/MySQL/MySQLSubscriptionSettingRepository.ts new file mode 100644 index 000000000..03b68852f --- /dev/null +++ b/packages/auth/src/Infra/MySQL/MySQLSubscriptionSettingRepository.ts @@ -0,0 +1,48 @@ +import { inject, injectable } from 'inversify' +import { Repository } from 'typeorm' +import TYPES from '../../Bootstrap/Types' + +import { SubscriptionSetting } from '../../Domain/Setting/SubscriptionSetting' +import { SubscriptionSettingRepositoryInterface } from '../../Domain/Setting/SubscriptionSettingRepositoryInterface' + +@injectable() +export class MySQLSubscriptionSettingRepository implements SubscriptionSettingRepositoryInterface { + constructor( + @inject(TYPES.ORMSubscriptionSettingRepository) + private ormRepository: Repository, + ) {} + + async save(subscriptionSetting: SubscriptionSetting): Promise { + return this.ormRepository.save(subscriptionSetting) + } + + async findOneByUuid(uuid: string): Promise { + return this.ormRepository + .createQueryBuilder('setting') + .where('setting.uuid = :uuid', { + uuid, + }) + .getOne() + } + + async findLastByNameAndUserSubscriptionUuid( + name: string, + userSubscriptionUuid: string, + ): Promise { + const settings = await this.ormRepository + .createQueryBuilder('setting') + .where('setting.name = :name AND setting.user_subscription_uuid = :userSubscriptionUuid', { + name, + userSubscriptionUuid, + }) + .orderBy('updated_at', 'DESC') + .limit(1) + .getMany() + + if (settings.length === 0) { + return null + } + + return settings.pop() as SubscriptionSetting + } +} diff --git a/packages/auth/src/Infra/MySQL/MySQLUserRepository.spec.ts b/packages/auth/src/Infra/MySQL/MySQLUserRepository.spec.ts new file mode 100644 index 000000000..00e539f5e --- /dev/null +++ b/packages/auth/src/Infra/MySQL/MySQLUserRepository.spec.ts @@ -0,0 +1,69 @@ +import 'reflect-metadata' + +import { ReadStream } from 'fs' + +import { Repository, SelectQueryBuilder } from 'typeorm' +import { User } from '../../Domain/User/User' + +import { MySQLUserRepository } from './MySQLUserRepository' + +describe('MySQLUserRepository', () => { + let ormRepository: Repository + let queryBuilder: SelectQueryBuilder + let user: User + + const createRepository = () => new MySQLUserRepository(ormRepository) + + beforeEach(() => { + queryBuilder = {} as jest.Mocked> + queryBuilder.cache = jest.fn().mockReturnThis() + + user = {} as jest.Mocked + + ormRepository = {} as jest.Mocked> + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => queryBuilder) + ormRepository.save = jest.fn() + ormRepository.remove = jest.fn() + }) + + it('should save', async () => { + await createRepository().save(user) + + expect(ormRepository.save).toHaveBeenCalledWith(user) + }) + + it('should remove', async () => { + await createRepository().remove(user) + + expect(ormRepository.remove).toHaveBeenCalledWith(user) + }) + + it('should find one user by id', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.getOne = jest.fn().mockReturnValue(user) + + const result = await createRepository().findOneByUuid('123') + + expect(queryBuilder.where).toHaveBeenCalledWith('user.uuid = :uuid', { uuid: '123' }) + expect(result).toEqual(user) + }) + + it('should stream all users', async () => { + const stream = {} as jest.Mocked + queryBuilder.stream = jest.fn().mockReturnValue(stream) + + const result = await createRepository().streamAll() + + expect(result).toEqual(stream) + }) + + it('should find one user by email', async () => { + queryBuilder.where = jest.fn().mockReturnThis() + queryBuilder.getOne = jest.fn().mockReturnValue(user) + + const result = await createRepository().findOneByEmail('test@test.te') + + expect(queryBuilder.where).toHaveBeenCalledWith('user.email = :email', { email: 'test@test.te' }) + expect(result).toEqual(user) + }) +}) diff --git a/packages/auth/src/Infra/MySQL/MySQLUserRepository.ts b/packages/auth/src/Infra/MySQL/MySQLUserRepository.ts new file mode 100644 index 000000000..9f5e5e515 --- /dev/null +++ b/packages/auth/src/Infra/MySQL/MySQLUserRepository.ts @@ -0,0 +1,43 @@ +import { ReadStream } from 'fs' +import { inject, injectable } from 'inversify' +import { Repository } from 'typeorm' +import TYPES from '../../Bootstrap/Types' + +import { User } from '../../Domain/User/User' +import { UserRepositoryInterface } from '../../Domain/User/UserRepositoryInterface' + +@injectable() +export class MySQLUserRepository implements UserRepositoryInterface { + constructor( + @inject(TYPES.ORMUserRepository) + private ormRepository: Repository, + ) {} + + async save(user: User): Promise { + return this.ormRepository.save(user) + } + + async remove(user: User): Promise { + return this.ormRepository.remove(user) + } + + async streamAll(): Promise { + return this.ormRepository.createQueryBuilder('user').stream() + } + + async findOneByUuid(uuid: string): Promise { + return this.ormRepository + .createQueryBuilder('user') + .where('user.uuid = :uuid', { uuid }) + .cache(`user_uuid_${uuid}`, 60000) + .getOne() + } + + async findOneByEmail(email: string): Promise { + return this.ormRepository + .createQueryBuilder('user') + .where('user.email = :email', { email }) + .cache(`user_email_${email}`, 60000) + .getOne() + } +} diff --git a/packages/auth/src/Infra/MySQL/MySQLUserSubscriptionRepository.spec.ts b/packages/auth/src/Infra/MySQL/MySQLUserSubscriptionRepository.spec.ts new file mode 100644 index 000000000..ae3e171db --- /dev/null +++ b/packages/auth/src/Infra/MySQL/MySQLUserSubscriptionRepository.spec.ts @@ -0,0 +1,223 @@ +import 'reflect-metadata' + +import { SubscriptionName } from '@standardnotes/common' + +import { Repository, SelectQueryBuilder, UpdateQueryBuilder } from 'typeorm' +import { UserSubscription } from '../../Domain/Subscription/UserSubscription' + +import { MySQLUserSubscriptionRepository } from './MySQLUserSubscriptionRepository' +import { UserSubscriptionType } from '../../Domain/Subscription/UserSubscriptionType' + +describe('MySQLUserSubscriptionRepository', () => { + let ormRepository: Repository + let selectQueryBuilder: SelectQueryBuilder + let updateQueryBuilder: UpdateQueryBuilder + let subscription: UserSubscription + + const createRepository = () => new MySQLUserSubscriptionRepository(ormRepository) + + beforeEach(() => { + selectQueryBuilder = {} as jest.Mocked> + updateQueryBuilder = {} as jest.Mocked> + + subscription = { + planName: SubscriptionName.ProPlan, + cancelled: false, + } as jest.Mocked + + ormRepository = {} as jest.Mocked> + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder) + ormRepository.save = jest.fn() + }) + + it('should save', async () => { + await createRepository().save(subscription) + + expect(ormRepository.save).toHaveBeenCalledWith(subscription) + }) + + it('should find one longest lasting uncanceled subscription by user uuid if there are canceled ones', async () => { + const canceledSubscription = { + planName: SubscriptionName.ProPlan, + cancelled: true, + } as jest.Mocked + + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder) + + selectQueryBuilder.where = jest.fn().mockReturnThis() + selectQueryBuilder.orderBy = jest.fn().mockReturnThis() + selectQueryBuilder.getMany = jest.fn().mockReturnValue([canceledSubscription, subscription]) + + const result = await createRepository().findOneByUserUuid('123') + + expect(selectQueryBuilder.where).toHaveBeenCalledWith('user_uuid = :user_uuid', { + user_uuid: '123', + }) + expect(selectQueryBuilder.orderBy).toHaveBeenCalledWith('ends_at', 'DESC') + expect(selectQueryBuilder.getMany).toHaveBeenCalled() + expect(result).toEqual(subscription) + }) + + it('should find one, longest lasting subscription by user uuid if there are no canceled ones', async () => { + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder) + + selectQueryBuilder.where = jest.fn().mockReturnThis() + selectQueryBuilder.orderBy = jest.fn().mockReturnThis() + selectQueryBuilder.getMany = jest.fn().mockReturnValue([subscription]) + + const result = await createRepository().findOneByUserUuid('123') + + expect(selectQueryBuilder.where).toHaveBeenCalledWith('user_uuid = :user_uuid', { + user_uuid: '123', + }) + expect(selectQueryBuilder.orderBy).toHaveBeenCalledWith('ends_at', 'DESC') + expect(selectQueryBuilder.getMany).toHaveBeenCalled() + expect(result).toEqual(subscription) + }) + + it('should find one, longest lasting subscription by user uuid if there are no ucanceled ones', async () => { + subscription.cancelled = true + + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder) + + selectQueryBuilder.where = jest.fn().mockReturnThis() + selectQueryBuilder.orderBy = jest.fn().mockReturnThis() + selectQueryBuilder.getMany = jest.fn().mockReturnValue([subscription]) + + const result = await createRepository().findOneByUserUuid('123') + + expect(selectQueryBuilder.where).toHaveBeenCalledWith('user_uuid = :user_uuid', { + user_uuid: '123', + }) + expect(selectQueryBuilder.orderBy).toHaveBeenCalledWith('ends_at', 'DESC') + expect(selectQueryBuilder.getMany).toHaveBeenCalled() + expect(result).toEqual(subscription) + }) + + it('should find none if there are no subscriptions for the user', async () => { + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder) + + selectQueryBuilder.where = jest.fn().mockReturnThis() + selectQueryBuilder.orderBy = jest.fn().mockReturnThis() + selectQueryBuilder.getMany = jest.fn().mockReturnValue([]) + + const result = await createRepository().findOneByUserUuid('123') + + expect(selectQueryBuilder.where).toHaveBeenCalledWith('user_uuid = :user_uuid', { + user_uuid: '123', + }) + expect(selectQueryBuilder.orderBy).toHaveBeenCalledWith('ends_at', 'DESC') + expect(selectQueryBuilder.getMany).toHaveBeenCalled() + expect(result).toBeNull() + }) + + it('should update ends at by subscription id', async () => { + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => updateQueryBuilder) + + updateQueryBuilder.update = jest.fn().mockReturnThis() + updateQueryBuilder.set = jest.fn().mockReturnThis() + updateQueryBuilder.where = jest.fn().mockReturnThis() + updateQueryBuilder.execute = jest.fn() + + await createRepository().updateEndsAt(1, 1000, 1000) + + expect(updateQueryBuilder.update).toHaveBeenCalled() + expect(updateQueryBuilder.set).toHaveBeenCalledWith({ + updatedAt: expect.any(Number), + endsAt: 1000, + }) + expect(updateQueryBuilder.where).toHaveBeenCalledWith('subscription_id = :subscriptionId', { + subscriptionId: 1, + }) + expect(updateQueryBuilder.execute).toHaveBeenCalled() + }) + + it('should update cancelled by subscription id', async () => { + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => updateQueryBuilder) + + updateQueryBuilder.update = jest.fn().mockReturnThis() + updateQueryBuilder.set = jest.fn().mockReturnThis() + updateQueryBuilder.where = jest.fn().mockReturnThis() + updateQueryBuilder.execute = jest.fn() + + await createRepository().updateCancelled(1, true, 1000) + + expect(updateQueryBuilder.update).toHaveBeenCalled() + expect(updateQueryBuilder.set).toHaveBeenCalledWith({ + updatedAt: expect.any(Number), + cancelled: true, + }) + expect(updateQueryBuilder.where).toHaveBeenCalledWith('subscription_id = :subscriptionId', { + subscriptionId: 1, + }) + expect(updateQueryBuilder.execute).toHaveBeenCalled() + }) + + it('should find subscriptions by id', async () => { + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder) + + selectQueryBuilder.where = jest.fn().mockReturnThis() + selectQueryBuilder.getMany = jest.fn().mockReturnValue([subscription]) + + const result = await createRepository().findBySubscriptionId(123) + + expect(selectQueryBuilder.where).toHaveBeenCalledWith('subscription_id = :subscriptionId', { + subscriptionId: 123, + }) + expect(selectQueryBuilder.getMany).toHaveBeenCalled() + expect(result).toEqual([subscription]) + }) + + it('should find subscriptions by id and type', async () => { + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder) + + selectQueryBuilder.where = jest.fn().mockReturnThis() + selectQueryBuilder.getMany = jest.fn().mockReturnValue([subscription]) + + const result = await createRepository().findBySubscriptionIdAndType(123, UserSubscriptionType.Regular) + + expect(selectQueryBuilder.where).toHaveBeenCalledWith( + 'subscription_id = :subscriptionId AND subscription_type = :type', + { + subscriptionId: 123, + type: 'regular', + }, + ) + expect(selectQueryBuilder.getMany).toHaveBeenCalled() + expect(result).toEqual([subscription]) + }) + + it('should find one subscription by id and user uuid', async () => { + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder) + + selectQueryBuilder.where = jest.fn().mockReturnThis() + selectQueryBuilder.getOne = jest.fn().mockReturnValue(subscription) + + const result = await createRepository().findOneByUserUuidAndSubscriptionId('1-2-3', 5) + + expect(selectQueryBuilder.where).toHaveBeenCalledWith( + 'user_uuid = :userUuid AND subscription_id = :subscriptionId', + { + subscriptionId: 5, + userUuid: '1-2-3', + }, + ) + expect(selectQueryBuilder.getOne).toHaveBeenCalled() + expect(result).toEqual(subscription) + }) + + it('should find one subscription by uuid', async () => { + ormRepository.createQueryBuilder = jest.fn().mockImplementation(() => selectQueryBuilder) + + selectQueryBuilder.where = jest.fn().mockReturnThis() + selectQueryBuilder.getOne = jest.fn().mockReturnValue(subscription) + + const result = await createRepository().findOneByUuid('1-2-3') + + expect(selectQueryBuilder.where).toHaveBeenCalledWith('uuid = :uuid', { + uuid: '1-2-3', + }) + expect(selectQueryBuilder.getOne).toHaveBeenCalled() + expect(result).toEqual(subscription) + }) +}) diff --git a/packages/auth/src/Infra/MySQL/MySQLUserSubscriptionRepository.ts b/packages/auth/src/Infra/MySQL/MySQLUserSubscriptionRepository.ts new file mode 100644 index 000000000..11db2fff7 --- /dev/null +++ b/packages/auth/src/Infra/MySQL/MySQLUserSubscriptionRepository.ts @@ -0,0 +1,107 @@ +import { Uuid } from '@standardnotes/common' +import { inject, injectable } from 'inversify' +import { Repository } from 'typeorm' +import TYPES from '../../Bootstrap/Types' + +import { UserSubscription } from '../../Domain/Subscription/UserSubscription' +import { UserSubscriptionRepositoryInterface } from '../../Domain/Subscription/UserSubscriptionRepositoryInterface' +import { UserSubscriptionType } from '../../Domain/Subscription/UserSubscriptionType' + +@injectable() +export class MySQLUserSubscriptionRepository implements UserSubscriptionRepositoryInterface { + constructor( + @inject(TYPES.ORMUserSubscriptionRepository) + private ormRepository: Repository, + ) {} + + async save(subscription: UserSubscription): Promise { + return this.ormRepository.save(subscription) + } + + async findOneByUserUuidAndSubscriptionId(userUuid: Uuid, subscriptionId: number): Promise { + return await this.ormRepository + .createQueryBuilder() + .where('user_uuid = :userUuid AND subscription_id = :subscriptionId', { + userUuid, + subscriptionId, + }) + .getOne() + } + + async findBySubscriptionIdAndType(subscriptionId: number, type: UserSubscriptionType): Promise { + return await this.ormRepository + .createQueryBuilder() + .where('subscription_id = :subscriptionId AND subscription_type = :type', { + subscriptionId, + type, + }) + .getMany() + } + + async findBySubscriptionId(subscriptionId: number): Promise { + return await this.ormRepository + .createQueryBuilder() + .where('subscription_id = :subscriptionId', { + subscriptionId, + }) + .getMany() + } + + async findOneByUuid(uuid: Uuid): Promise { + return await this.ormRepository + .createQueryBuilder() + .where('uuid = :uuid', { + uuid, + }) + .getOne() + } + + async findOneByUserUuid(userUuid: Uuid): Promise { + const subscriptions = await this.ormRepository + .createQueryBuilder() + .where('user_uuid = :user_uuid', { + user_uuid: userUuid, + }) + .orderBy('ends_at', 'DESC') + .getMany() + + const uncanceled = subscriptions.find((subscription) => !subscription.cancelled) + if (uncanceled !== undefined) { + return uncanceled + } + + if (subscriptions.length !== 0) { + return subscriptions[0] + } + + return null + } + + async updateEndsAt(subscriptionId: number, endsAt: number, updatedAt: number): Promise { + await this.ormRepository + .createQueryBuilder() + .update() + .set({ + endsAt, + updatedAt, + }) + .where('subscription_id = :subscriptionId', { + subscriptionId, + }) + .execute() + } + + async updateCancelled(subscriptionId: number, cancelled: boolean, updatedAt: number): Promise { + await this.ormRepository + .createQueryBuilder() + .update() + .set({ + cancelled, + updatedAt, + }) + .where('subscription_id = :subscriptionId', { + subscriptionId, + }) + .execute() + } +} diff --git a/packages/auth/src/Infra/Redis/LockRepository.spec.ts b/packages/auth/src/Infra/Redis/LockRepository.spec.ts new file mode 100644 index 000000000..f0b2bc7cb --- /dev/null +++ b/packages/auth/src/Infra/Redis/LockRepository.spec.ts @@ -0,0 +1,93 @@ +import 'reflect-metadata' + +import * as IORedis from 'ioredis' +import { LockRepository } from './LockRepository' + +describe('LockRepository', () => { + let redisClient: IORedis.Redis + const maxLoginAttempts = 3 + const failedLoginLockout = 120 + + const createRepository = () => new LockRepository(redisClient, maxLoginAttempts, failedLoginLockout) + + beforeEach(() => { + redisClient = {} as jest.Mocked + redisClient.expire = jest.fn() + redisClient.del = jest.fn() + redisClient.get = jest.fn() + redisClient.set = jest.fn() + redisClient.setex = jest.fn() + }) + + it('should lock a successfully used OTP for the lockout period', async () => { + await createRepository().lockSuccessfullOTP('test@test.te', '123456') + + expect(redisClient.setex).toHaveBeenCalledWith('otp-lock:test@test.te', 60, '123456') + }) + + it('should indicate if an OTP was already used in the lockout period', async () => { + redisClient.get = jest.fn().mockReturnValue('123456') + + expect(await createRepository().isOTPLocked('test@test.te', '123456')).toEqual(true) + }) + + it('should indicate if an OTP was not already used in the lockout period', async () => { + redisClient.get = jest.fn().mockReturnValue('654321') + + expect(await createRepository().isOTPLocked('test@test.te', '123456')).toEqual(false) + }) + + it('should lock a user for the lockout period', async () => { + await createRepository().lockUser('123') + + expect(redisClient.expire).toHaveBeenCalledWith('lock:123', 120) + }) + + it('should tell a user is locked if his counter is above threshold', async () => { + redisClient.get = jest.fn().mockReturnValue('4') + + expect(await createRepository().isUserLocked('123')).toBeTruthy() + }) + + it('should tell a user is locked if his counter is at the threshold', async () => { + redisClient.get = jest.fn().mockReturnValue('3') + + expect(await createRepository().isUserLocked('123')).toBeTruthy() + }) + + it('should tell a user is not locked if his counter is below threshold', async () => { + redisClient.get = jest.fn().mockReturnValue('2') + + expect(await createRepository().isUserLocked('123')).toBeFalsy() + }) + + it('should tell a user is not locked if he has no counter', async () => { + redisClient.get = jest.fn().mockReturnValue(null) + + expect(await createRepository().isUserLocked('123')).toBeFalsy() + }) + + it('should tell what the user lock counter is', async () => { + redisClient.get = jest.fn().mockReturnValue('3') + + expect(await createRepository().getLockCounter('123')).toStrictEqual(3) + }) + + it('should tell that the user lock counter is 0 when there is no counter', async () => { + redisClient.get = jest.fn().mockReturnValue(null) + + expect(await createRepository().getLockCounter('123')).toStrictEqual(0) + }) + + it('should reset a lock counter', async () => { + await createRepository().resetLockCounter('123') + + expect(redisClient.del).toHaveBeenCalledWith('lock:123') + }) + + it('should update a lock counter', async () => { + await createRepository().updateLockCounter('123', 3) + + expect(redisClient.set).toHaveBeenCalledWith('lock:123', 3) + }) +}) diff --git a/packages/auth/src/Infra/Redis/LockRepository.ts b/packages/auth/src/Infra/Redis/LockRepository.ts new file mode 100644 index 000000000..359143346 --- /dev/null +++ b/packages/auth/src/Infra/Redis/LockRepository.ts @@ -0,0 +1,55 @@ +import * as IORedis from 'ioredis' + +import { inject, injectable } from 'inversify' +import TYPES from '../../Bootstrap/Types' +import { LockRepositoryInterface } from '../../Domain/User/LockRepositoryInterface' + +@injectable() +export class LockRepository implements LockRepositoryInterface { + private readonly PREFIX = 'lock' + private readonly OTP_PREFIX = 'otp-lock' + + constructor( + @inject(TYPES.Redis) private redisClient: IORedis.Redis, + @inject(TYPES.MAX_LOGIN_ATTEMPTS) private maxLoginAttempts: number, + @inject(TYPES.FAILED_LOGIN_LOCKOUT) private failedLoginLockout: number, + ) {} + + async lockSuccessfullOTP(userIdentifier: string, otp: string): Promise { + await this.redisClient.setex(`${this.OTP_PREFIX}:${userIdentifier}`, 60, otp) + } + + async isOTPLocked(userIdentifier: string, otp: string): Promise { + const lock = await this.redisClient.get(`${this.OTP_PREFIX}:${userIdentifier}`) + + return lock === otp + } + + async resetLockCounter(userIdentifier: string): Promise { + await this.redisClient.del(`${this.PREFIX}:${userIdentifier}`) + } + + async updateLockCounter(userIdentifier: string, counter: number): Promise { + await this.redisClient.set(`${this.PREFIX}:${userIdentifier}`, counter) + } + + async getLockCounter(userIdentifier: string): Promise { + const counter = await this.redisClient.get(`${this.PREFIX}:${userIdentifier}`) + + if (!counter) { + return 0 + } + + return +counter + } + + async lockUser(userIdentifier: string): Promise { + await this.redisClient.expire(`${this.PREFIX}:${userIdentifier}`, this.failedLoginLockout) + } + + async isUserLocked(userIdentifier: string): Promise { + const counter = await this.getLockCounter(userIdentifier) + + return counter >= this.maxLoginAttempts + } +} diff --git a/packages/auth/src/Infra/Redis/RedisEphemeralSessionRepository.spec.ts b/packages/auth/src/Infra/Redis/RedisEphemeralSessionRepository.spec.ts new file mode 100644 index 000000000..47a2ef6b6 --- /dev/null +++ b/packages/auth/src/Infra/Redis/RedisEphemeralSessionRepository.spec.ts @@ -0,0 +1,152 @@ +import 'reflect-metadata' + +import * as IORedis from 'ioredis' + +import { RedisEphemeralSessionRepository } from './RedisEphemeralSessionRepository' +import { EphemeralSession } from '../../Domain/Session/EphemeralSession' + +describe('RedisEphemeralSessionRepository', () => { + let redisClient: IORedis.Redis + let pipeline: IORedis.Pipeline + + const createRepository = () => new RedisEphemeralSessionRepository(redisClient, 3600) + + beforeEach(() => { + redisClient = {} as jest.Mocked + + redisClient.get = jest.fn() + redisClient.smembers = jest.fn() + + pipeline = {} as jest.Mocked + pipeline.setex = jest.fn() + pipeline.expire = jest.fn() + pipeline.sadd = jest.fn() + pipeline.del = jest.fn() + pipeline.srem = jest.fn() + pipeline.exec = jest.fn() + + redisClient.pipeline = jest.fn().mockReturnValue(pipeline) + }) + + it('should delete an ephemeral', async () => { + await createRepository().deleteOne('1-2-3', '2-3-4') + + expect(pipeline.del).toHaveBeenCalledWith('session:1-2-3:2-3-4') + expect(pipeline.del).toHaveBeenCalledWith('session:1-2-3') + expect(pipeline.srem).toHaveBeenCalledWith('user-sessions:2-3-4', '1-2-3') + }) + + it('should save an ephemeral session', async () => { + const ephemeralSession = new EphemeralSession() + ephemeralSession.uuid = '1-2-3' + ephemeralSession.userUuid = '2-3-4' + ephemeralSession.userAgent = 'Mozilla Firefox' + ephemeralSession.createdAt = new Date(1) + ephemeralSession.updatedAt = new Date(2) + + await createRepository().save(ephemeralSession) + + expect(pipeline.setex).toHaveBeenCalledWith( + 'session:1-2-3:2-3-4', + 3600, + '{"uuid":"1-2-3","userUuid":"2-3-4","userAgent":"Mozilla Firefox","createdAt":"1970-01-01T00:00:00.001Z","updatedAt":"1970-01-01T00:00:00.002Z"}', + ) + expect(pipeline.sadd).toHaveBeenCalledWith('user-sessions:2-3-4', '1-2-3') + expect(pipeline.expire).toHaveBeenCalledWith('user-sessions:2-3-4', 3600) + }) + + it('should find all ephemeral sessions by user uuid', async () => { + redisClient.smembers = jest.fn().mockReturnValue(['1-2-3', '2-3-4', '3-4-5']) + + redisClient.get = jest + .fn() + .mockReturnValueOnce( + '{"uuid":"1-2-3","userUuid":"2-3-4","userAgent":"Mozilla Firefox","createdAt":"1970-01-01T00:00:00.001Z","updatedAt":"1970-01-01T00:00:00.002Z"}', + ) + .mockReturnValueOnce( + '{"uuid":"2-3-4","userUuid":"2-3-4","userAgent":"Google Chrome","createdAt":"1970-01-01T00:00:00.001Z","updatedAt":"1970-01-01T00:00:00.002Z"}', + ) + .mockReturnValueOnce(null) + + const ephemeralSessions = await createRepository().findAllByUserUuid('2-3-4') + + expect(ephemeralSessions.length).toEqual(2) + expect(ephemeralSessions[1].userAgent).toEqual('Google Chrome') + }) + + it('should find an ephemeral session by uuid', async () => { + redisClient.get = jest + .fn() + .mockReturnValue( + '{"uuid":"1-2-3","userUuid":"2-3-4","userAgent":"Mozilla Firefox","createdAt":"1970-01-01T00:00:00.001Z","updatedAt":"1970-01-01T00:00:00.002Z"}', + ) + + const ephemeralSession = await createRepository().findOneByUuid('1-2-3') + + expect(ephemeralSession).not.toBeUndefined() + expect(ephemeralSession.userAgent).toEqual('Mozilla Firefox') + }) + + it('should find an ephemeral session by uuid and user uuid', async () => { + redisClient.get = jest + .fn() + .mockReturnValue( + '{"uuid":"1-2-3","userUuid":"2-3-4","userAgent":"Mozilla Firefox","createdAt":"1970-01-01T00:00:00.001Z","updatedAt":"1970-01-01T00:00:00.002Z"}', + ) + + const ephemeralSession = await createRepository().findOneByUuidAndUserUuid('1-2-3', '2-3-4') + + expect(ephemeralSession).not.toBeUndefined() + expect(ephemeralSession.userAgent).toEqual('Mozilla Firefox') + }) + + it('should return undefined if session is not found', async () => { + redisClient.get = jest.fn().mockReturnValue(null) + + const ephemeralSession = await createRepository().findOneByUuid('1-2-3') + + expect(ephemeralSession).toBeNull() + }) + + it('should return undefined if ephemeral session is not found', async () => { + redisClient.get = jest.fn().mockReturnValue(null) + + const ephemeralSession = await createRepository().findOneByUuidAndUserUuid('1-2-3', '2-3-4') + + expect(ephemeralSession).toBeNull() + }) + + it('should update tokens and expirations dates', async () => { + redisClient.get = jest + .fn() + .mockReturnValue( + '{"uuid":"1-2-3","userUuid":"2-3-4","userAgent":"Mozilla Firefox","createdAt":"1970-01-01T00:00:00.001Z","updatedAt":"1970-01-01T00:00:00.002Z"}', + ) + + await createRepository().updateTokensAndExpirationDates( + '1-2-3', + 'dummy_access_token', + 'dummy_refresh_token', + new Date(3), + new Date(4), + ) + + expect(pipeline.setex).toHaveBeenCalledWith( + 'session:1-2-3:2-3-4', + 3600, + '{"uuid":"1-2-3","userUuid":"2-3-4","userAgent":"Mozilla Firefox","createdAt":"1970-01-01T00:00:00.001Z","updatedAt":"1970-01-01T00:00:00.002Z","hashedAccessToken":"dummy_access_token","hashedRefreshToken":"dummy_refresh_token","accessExpiration":"1970-01-01T00:00:00.003Z","refreshExpiration":"1970-01-01T00:00:00.004Z"}', + ) + }) + + it('should not update tokens and expirations dates if the ephemeral session does not exist', async () => { + await createRepository().updateTokensAndExpirationDates( + '1-2-3', + 'dummy_access_token', + 'dummy_refresh_token', + new Date(3), + new Date(4), + ) + + expect(pipeline.setex).not.toHaveBeenCalled() + }) +}) diff --git a/packages/auth/src/Infra/Redis/RedisEphemeralSessionRepository.ts b/packages/auth/src/Infra/Redis/RedisEphemeralSessionRepository.ts new file mode 100644 index 000000000..915de352f --- /dev/null +++ b/packages/auth/src/Infra/Redis/RedisEphemeralSessionRepository.ts @@ -0,0 +1,95 @@ +import * as IORedis from 'ioredis' + +import { inject, injectable } from 'inversify' +import TYPES from '../../Bootstrap/Types' +import { EphemeralSession } from '../../Domain/Session/EphemeralSession' +import { EphemeralSessionRepositoryInterface } from '../../Domain/Session/EphemeralSessionRepositoryInterface' + +@injectable() +export class RedisEphemeralSessionRepository implements EphemeralSessionRepositoryInterface { + private readonly PREFIX = 'session' + private readonly USER_SESSIONS_PREFIX = 'user-sessions' + + constructor( + @inject(TYPES.Redis) private redisClient: IORedis.Redis, + @inject(TYPES.EPHEMERAL_SESSION_AGE) private ephemeralSessionAge: number, + ) {} + + async deleteOne(uuid: string, userUuid: string): Promise { + const pipeline = this.redisClient.pipeline() + + pipeline.del(`${this.PREFIX}:${uuid}`) + pipeline.del(`${this.PREFIX}:${uuid}:${userUuid}`) + pipeline.srem(`${this.USER_SESSIONS_PREFIX}:${userUuid}`, uuid) + + await pipeline.exec() + } + + async updateTokensAndExpirationDates( + uuid: string, + hashedAccessToken: string, + hashedRefreshToken: string, + accessExpiration: Date, + refreshExpiration: Date, + ): Promise { + const session = await this.findOneByUuid(uuid) + if (!session) { + return + } + + session.hashedAccessToken = hashedAccessToken + session.hashedRefreshToken = hashedRefreshToken + session.accessExpiration = accessExpiration + session.refreshExpiration = refreshExpiration + + await this.save(session) + } + + async findAllByUserUuid(userUuid: string): Promise> { + const ephemeralSessionUuids = await this.redisClient.smembers(`${this.USER_SESSIONS_PREFIX}:${userUuid}`) + + const sessions = [] + for (const ephemeralSessionUuid of ephemeralSessionUuids) { + const stringifiedSession = await this.redisClient.get(`${this.PREFIX}:${ephemeralSessionUuid}`) + if (stringifiedSession !== null) { + sessions.push(JSON.parse(stringifiedSession)) + } + } + + return sessions + } + + async findOneByUuid(uuid: string): Promise { + const stringifiedSession = await this.redisClient.get(`${this.PREFIX}:${uuid}`) + if (!stringifiedSession) { + return null + } + + return JSON.parse(stringifiedSession) + } + + async findOneByUuidAndUserUuid(uuid: string, userUuid: string): Promise { + const stringifiedSession = await this.redisClient.get(`${this.PREFIX}:${uuid}:${userUuid}`) + if (!stringifiedSession) { + return null + } + + return JSON.parse(stringifiedSession) + } + + async save(ephemeralSession: EphemeralSession): Promise { + const ttl = this.ephemeralSessionAge + + const stringifiedSession = JSON.stringify(ephemeralSession) + + const pipeline = this.redisClient.pipeline() + + pipeline.setex(`${this.PREFIX}:${ephemeralSession.uuid}:${ephemeralSession.userUuid}`, ttl, stringifiedSession) + pipeline.setex(`${this.PREFIX}:${ephemeralSession.uuid}`, ttl, stringifiedSession) + + pipeline.sadd(`${this.USER_SESSIONS_PREFIX}:${ephemeralSession.userUuid}`, ephemeralSession.uuid) + pipeline.expire(`${this.USER_SESSIONS_PREFIX}:${ephemeralSession.userUuid}`, ttl) + + await pipeline.exec() + } +} diff --git a/packages/auth/src/Infra/Redis/RedisOfflineSubscriptionTokenRepository.spec.ts b/packages/auth/src/Infra/Redis/RedisOfflineSubscriptionTokenRepository.spec.ts new file mode 100644 index 000000000..12688e660 --- /dev/null +++ b/packages/auth/src/Infra/Redis/RedisOfflineSubscriptionTokenRepository.spec.ts @@ -0,0 +1,59 @@ +import 'reflect-metadata' + +import * as IORedis from 'ioredis' +import { TimerInterface } from '@standardnotes/time' + +import { RedisOfflineSubscriptionTokenRepository } from './RedisOfflineSubscriptionTokenRepository' +import { OfflineSubscriptionToken } from '../../Domain/Auth/OfflineSubscriptionToken' +import { Logger } from 'winston' + +describe('RedisOfflineSubscriptionTokenRepository', () => { + let redisClient: IORedis.Redis + let timer: TimerInterface + let logger: Logger + + const createRepository = () => new RedisOfflineSubscriptionTokenRepository(redisClient, timer, logger) + + beforeEach(() => { + redisClient = {} as jest.Mocked + redisClient.set = jest.fn() + redisClient.get = jest.fn() + redisClient.expireat = jest.fn() + + timer = {} as jest.Mocked + timer.convertMicrosecondsToSeconds = jest.fn().mockReturnValue(1) + + logger = {} as jest.Mocked + logger.debug = jest.fn() + }) + + it('should get a user uuid in exchange for an dashboard token', async () => { + redisClient.get = jest.fn().mockReturnValue('test@test.com') + + expect(await createRepository().getUserEmailByToken('random-string')).toEqual('test@test.com') + + expect(redisClient.get).toHaveBeenCalledWith('offline-subscription-token:random-string') + }) + + it('should return undefined if a user uuid is not exchanged for an dashboard token', async () => { + redisClient.get = jest.fn().mockReturnValue(null) + + expect(await createRepository().getUserEmailByToken('random-string')).toBeUndefined() + + expect(redisClient.get).toHaveBeenCalledWith('offline-subscription-token:random-string') + }) + + it('should save an dashboard token', async () => { + const offlineSubscriptionToken: OfflineSubscriptionToken = { + userEmail: 'test@test.com', + token: 'random-string', + expiresAt: 123, + } + + await createRepository().save(offlineSubscriptionToken) + + expect(redisClient.set).toHaveBeenCalledWith('offline-subscription-token:random-string', 'test@test.com') + + expect(redisClient.expireat).toHaveBeenCalledWith('offline-subscription-token:random-string', 1) + }) +}) diff --git a/packages/auth/src/Infra/Redis/RedisOfflineSubscriptionTokenRepository.ts b/packages/auth/src/Infra/Redis/RedisOfflineSubscriptionTokenRepository.ts new file mode 100644 index 000000000..44c5fef4c --- /dev/null +++ b/packages/auth/src/Infra/Redis/RedisOfflineSubscriptionTokenRepository.ts @@ -0,0 +1,38 @@ +import * as IORedis from 'ioredis' +import { inject, injectable } from 'inversify' + +import TYPES from '../../Bootstrap/Types' +import { OfflineSubscriptionToken } from '../../Domain/Auth/OfflineSubscriptionToken' +import { OfflineSubscriptionTokenRepositoryInterface } from '../../Domain/Auth/OfflineSubscriptionTokenRepositoryInterface' +import { TimerInterface } from '@standardnotes/time' +import { Logger } from 'winston' + +@injectable() +export class RedisOfflineSubscriptionTokenRepository implements OfflineSubscriptionTokenRepositoryInterface { + private readonly PREFIX = 'offline-subscription-token' + + constructor( + @inject(TYPES.Redis) private redisClient: IORedis.Redis, + @inject(TYPES.Timer) private timer: TimerInterface, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async getUserEmailByToken(token: string): Promise { + const userUuid = await this.redisClient.get(`${this.PREFIX}:${token}`) + if (!userUuid) { + return undefined + } + + return userUuid + } + + async save(offlineSubscriptionToken: OfflineSubscriptionToken): Promise { + const key = `${this.PREFIX}:${offlineSubscriptionToken.token}` + const expiresAtTimestampInSeconds = this.timer.convertMicrosecondsToSeconds(offlineSubscriptionToken.expiresAt) + + this.logger.debug(`Persisting key ${key} with expiration ${expiresAtTimestampInSeconds}`) + + await this.redisClient.set(key, offlineSubscriptionToken.userEmail) + await this.redisClient.expireat(key, expiresAtTimestampInSeconds) + } +} diff --git a/packages/auth/src/Infra/Redis/RedisPKCERepository.spec.ts b/packages/auth/src/Infra/Redis/RedisPKCERepository.spec.ts new file mode 100644 index 000000000..06d42d6ef --- /dev/null +++ b/packages/auth/src/Infra/Redis/RedisPKCERepository.spec.ts @@ -0,0 +1,34 @@ +import 'reflect-metadata' + +import * as IORedis from 'ioredis' +import { Logger } from 'winston' + +import { RedisPKCERepository } from './RedisPKCERepository' + +describe('RedisPKCERepository', () => { + let redisClient: IORedis.Redis + let logger: Logger + + const createRepository = () => new RedisPKCERepository(redisClient, logger) + + beforeEach(() => { + redisClient = {} as jest.Mocked + redisClient.setex = jest.fn() + redisClient.del = jest.fn().mockReturnValue(1) + + logger = {} as jest.Mocked + logger.debug = jest.fn() + }) + + it('should store a code challenge', async () => { + await createRepository().storeCodeChallenge('test') + + expect(redisClient.setex).toHaveBeenCalledWith('pkce:test', 3600, 'test') + }) + + it('should remove a code challenge and notify of success', async () => { + expect(await createRepository().removeCodeChallenge('test')).toBeTruthy() + + expect(redisClient.del).toHaveBeenCalledWith('pkce:test') + }) +}) diff --git a/packages/auth/src/Infra/Redis/RedisPKCERepository.ts b/packages/auth/src/Infra/Redis/RedisPKCERepository.ts new file mode 100644 index 000000000..b808a7072 --- /dev/null +++ b/packages/auth/src/Infra/Redis/RedisPKCERepository.ts @@ -0,0 +1,27 @@ +import * as IORedis from 'ioredis' +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' + +import TYPES from '../../Bootstrap/Types' +import { PKCERepositoryInterface } from '../../Domain/User/PKCERepositoryInterface' + +@injectable() +export class RedisPKCERepository implements PKCERepositoryInterface { + private readonly PREFIX = 'pkce' + + constructor(@inject(TYPES.Redis) private redisClient: IORedis.Redis, @inject(TYPES.Logger) private logger: Logger) {} + + async storeCodeChallenge(codeChallenge: string): Promise { + this.logger.debug(`Storing code challenge: ${codeChallenge}`) + + await this.redisClient.setex(`${this.PREFIX}:${codeChallenge}`, 3600, codeChallenge) + } + + async removeCodeChallenge(codeChallenge: string): Promise { + const entriesRemoved = await this.redisClient.del(`${this.PREFIX}:${codeChallenge}`) + + this.logger.debug(`Removed ${entriesRemoved} entries for code challenge: ${codeChallenge}`) + + return entriesRemoved === 1 + } +} diff --git a/packages/auth/src/Infra/Redis/RedisSubscriptionTokenRepository.spec.ts b/packages/auth/src/Infra/Redis/RedisSubscriptionTokenRepository.spec.ts new file mode 100644 index 000000000..d28f96dee --- /dev/null +++ b/packages/auth/src/Infra/Redis/RedisSubscriptionTokenRepository.spec.ts @@ -0,0 +1,54 @@ +import 'reflect-metadata' + +import * as IORedis from 'ioredis' +import { TimerInterface } from '@standardnotes/time' + +import { RedisSubscriptionTokenRepository } from './RedisSubscriptionTokenRepository' +import { SubscriptionToken } from '../../Domain/Subscription/SubscriptionToken' + +describe('RedisSubscriptionTokenRepository', () => { + let redisClient: IORedis.Redis + let timer: TimerInterface + + const createRepository = () => new RedisSubscriptionTokenRepository(redisClient, timer) + + beforeEach(() => { + redisClient = {} as jest.Mocked + redisClient.set = jest.fn() + redisClient.get = jest.fn() + redisClient.expireat = jest.fn() + + timer = {} as jest.Mocked + timer.convertMicrosecondsToSeconds = jest.fn().mockReturnValue(1) + }) + + it('should get a user uuid in exchange for an subscription token', async () => { + redisClient.get = jest.fn().mockReturnValue('1-2-3') + + expect(await createRepository().getUserUuidByToken('random-string')).toEqual('1-2-3') + + expect(redisClient.get).toHaveBeenCalledWith('subscription-token:random-string') + }) + + it('should return undefined if a user uuid is not exchanged for an subscription token', async () => { + redisClient.get = jest.fn().mockReturnValue(null) + + expect(await createRepository().getUserUuidByToken('random-string')).toBeUndefined() + + expect(redisClient.get).toHaveBeenCalledWith('subscription-token:random-string') + }) + + it('should save an subscription token', async () => { + const subscriptionToken: SubscriptionToken = { + userUuid: '1-2-3', + token: 'random-string', + expiresAt: 123, + } + + await createRepository().save(subscriptionToken) + + expect(redisClient.set).toHaveBeenCalledWith('subscription-token:random-string', '1-2-3') + + expect(redisClient.expireat).toHaveBeenCalledWith('subscription-token:random-string', 1) + }) +}) diff --git a/packages/auth/src/Infra/Redis/RedisSubscriptionTokenRepository.ts b/packages/auth/src/Infra/Redis/RedisSubscriptionTokenRepository.ts new file mode 100644 index 000000000..7290e2c09 --- /dev/null +++ b/packages/auth/src/Infra/Redis/RedisSubscriptionTokenRepository.ts @@ -0,0 +1,34 @@ +import * as IORedis from 'ioredis' +import { inject, injectable } from 'inversify' + +import TYPES from '../../Bootstrap/Types' +import { SubscriptionToken } from '../../Domain/Subscription/SubscriptionToken' +import { SubscriptionTokenRepositoryInterface } from '../../Domain/Subscription/SubscriptionTokenRepositoryInterface' +import { TimerInterface } from '@standardnotes/time' + +@injectable() +export class RedisSubscriptionTokenRepository implements SubscriptionTokenRepositoryInterface { + private readonly PREFIX = 'subscription-token' + + constructor( + @inject(TYPES.Redis) private redisClient: IORedis.Redis, + @inject(TYPES.Timer) private timer: TimerInterface, + ) {} + + async getUserUuidByToken(token: string): Promise { + const userUuid = await this.redisClient.get(`${this.PREFIX}:${token}`) + if (!userUuid) { + return undefined + } + + return userUuid + } + + async save(subscriptionToken: SubscriptionToken): Promise { + const key = `${this.PREFIX}:${subscriptionToken.token}` + const expiresAtTimestampInSeconds = this.timer.convertMicrosecondsToSeconds(subscriptionToken.expiresAt) + + await this.redisClient.set(key, subscriptionToken.userUuid) + await this.redisClient.expireat(key, expiresAtTimestampInSeconds) + } +} diff --git a/packages/auth/src/Infra/Redis/RedisWebSocketsConnectionRepository.spec.ts b/packages/auth/src/Infra/Redis/RedisWebSocketsConnectionRepository.spec.ts new file mode 100644 index 000000000..4db514670 --- /dev/null +++ b/packages/auth/src/Infra/Redis/RedisWebSocketsConnectionRepository.spec.ts @@ -0,0 +1,44 @@ +import 'reflect-metadata' + +import * as IORedis from 'ioredis' + +import { RedisWebSocketsConnectionRepository } from './RedisWebSocketsConnectionRepository' + +describe('RedisWebSocketsConnectionRepository', () => { + let redisClient: IORedis.Redis + + const createRepository = () => new RedisWebSocketsConnectionRepository(redisClient) + + beforeEach(() => { + redisClient = {} as jest.Mocked + redisClient.sadd = jest.fn() + redisClient.set = jest.fn() + redisClient.get = jest.fn() + redisClient.srem = jest.fn() + redisClient.del = jest.fn() + redisClient.smembers = jest.fn() + }) + + it('should save a connection to set of user connections', async () => { + await createRepository().saveConnection('1-2-3', '2-3-4') + + expect(redisClient.sadd).toHaveBeenCalledWith('ws_user_connections:1-2-3', '2-3-4') + expect(redisClient.set).toHaveBeenCalledWith('ws_connection:2-3-4', '1-2-3') + }) + + it('should remove a connection from the set of user connections', async () => { + redisClient.get = jest.fn().mockReturnValue('1-2-3') + + await createRepository().removeConnection('2-3-4') + + expect(redisClient.srem).toHaveBeenCalledWith('ws_user_connections:1-2-3', '2-3-4') + expect(redisClient.del).toHaveBeenCalledWith('ws_connection:2-3-4') + }) + + it('should return all connections for a user uuid', async () => { + const userUuid = '1-2-3' + + await createRepository().findAllByUserUuid(userUuid) + expect(redisClient.smembers).toHaveBeenCalledWith(`ws_user_connections:${userUuid}`) + }) +}) diff --git a/packages/auth/src/Infra/Redis/RedisWebSocketsConnectionRepository.ts b/packages/auth/src/Infra/Redis/RedisWebSocketsConnectionRepository.ts new file mode 100644 index 000000000..5e080475d --- /dev/null +++ b/packages/auth/src/Infra/Redis/RedisWebSocketsConnectionRepository.ts @@ -0,0 +1,28 @@ +import * as IORedis from 'ioredis' +import { inject, injectable } from 'inversify' +import TYPES from '../../Bootstrap/Types' +import { WebSocketsConnectionRepositoryInterface } from '../../Domain/WebSockets/WebSocketsConnectionRepositoryInterface' + +@injectable() +export class RedisWebSocketsConnectionRepository implements WebSocketsConnectionRepositoryInterface { + private readonly WEB_SOCKETS_USER_CONNECTIONS_PREFIX = 'ws_user_connections' + private readonly WEB_SOCKETS_CONNETION_PREFIX = 'ws_connection' + + constructor(@inject(TYPES.Redis) private redisClient: IORedis.Redis) {} + + async findAllByUserUuid(userUuid: string): Promise { + return await this.redisClient.smembers(`${this.WEB_SOCKETS_USER_CONNECTIONS_PREFIX}:${userUuid}`) + } + + async removeConnection(connectionId: string): Promise { + const userUuid = await this.redisClient.get(`${this.WEB_SOCKETS_CONNETION_PREFIX}:${connectionId}`) + + await this.redisClient.srem(`${this.WEB_SOCKETS_USER_CONNECTIONS_PREFIX}:${userUuid}`, connectionId) + await this.redisClient.del(`${this.WEB_SOCKETS_CONNETION_PREFIX}:${connectionId}`) + } + + async saveConnection(userUuid: string, connectionId: string): Promise { + await this.redisClient.set(`${this.WEB_SOCKETS_CONNETION_PREFIX}:${connectionId}`, userUuid) + await this.redisClient.sadd(`${this.WEB_SOCKETS_USER_CONNECTIONS_PREFIX}:${userUuid}`, connectionId) + } +} diff --git a/packages/auth/src/Infra/WebSockets/WebSocketsClientService.spec.ts b/packages/auth/src/Infra/WebSockets/WebSocketsClientService.spec.ts new file mode 100644 index 000000000..66f525d13 --- /dev/null +++ b/packages/auth/src/Infra/WebSockets/WebSocketsClientService.spec.ts @@ -0,0 +1,87 @@ +import 'reflect-metadata' + +import { UserRolesChangedEvent } from '@standardnotes/domain-events' +import { RoleName } from '@standardnotes/common' + +import { User } from '../../Domain/User/User' +import { WebSocketsClientService } from './WebSocketsClientService' +import { WebSocketsConnectionRepositoryInterface } from '../../Domain/WebSockets/WebSocketsConnectionRepositoryInterface' +import { DomainEventFactoryInterface } from '../../Domain/Event/DomainEventFactoryInterface' +import { AxiosInstance } from 'axios' +import { Logger } from 'winston' + +describe('WebSocketsClientService', () => { + let connectionIds: string[] + let user: User + let event: UserRolesChangedEvent + let webSocketsConnectionRepository: WebSocketsConnectionRepositoryInterface + let domainEventFactory: DomainEventFactoryInterface + let httpClient: AxiosInstance + let logger: Logger + + let webSocketsApiUrl = 'http://test-websockets' + + const createService = () => + new WebSocketsClientService( + webSocketsConnectionRepository, + domainEventFactory, + httpClient, + webSocketsApiUrl, + logger, + ) + + beforeEach(() => { + connectionIds = ['1', '2'] + + user = { + uuid: '123', + email: 'test@test.com', + roles: Promise.resolve([ + { + name: RoleName.ProUser, + }, + ]), + } as jest.Mocked + + event = {} as jest.Mocked + + webSocketsConnectionRepository = {} as jest.Mocked + webSocketsConnectionRepository.findAllByUserUuid = jest.fn().mockReturnValue(connectionIds) + + domainEventFactory = {} as jest.Mocked + domainEventFactory.createUserRolesChangedEvent = jest.fn().mockReturnValue(event) + + httpClient = {} as jest.Mocked + httpClient.request = jest.fn() + + logger = {} as jest.Mocked + logger.debug = jest.fn() + }) + + it('should send a user role changed event to all user connections', async () => { + await createService().sendUserRolesChangedEvent(user) + + expect(domainEventFactory.createUserRolesChangedEvent).toHaveBeenCalledWith('123', 'test@test.com', [ + RoleName.ProUser, + ]) + expect(httpClient.request).toHaveBeenCalledTimes(connectionIds.length) + connectionIds.map((id, index) => { + expect(httpClient.request).toHaveBeenNthCalledWith( + index + 1, + expect.objectContaining({ + method: 'POST', + url: `${webSocketsApiUrl}/${id}`, + data: JSON.stringify(event), + }), + ) + }) + }) + + it('should not send a user role changed event if web sockets api url not defined', async () => { + webSocketsApiUrl = '' + + await createService().sendUserRolesChangedEvent(user) + + expect(httpClient.request).not.toHaveBeenCalled() + }) +}) diff --git a/packages/auth/src/Infra/WebSockets/WebSocketsClientService.ts b/packages/auth/src/Infra/WebSockets/WebSocketsClientService.ts new file mode 100644 index 000000000..dd65cc7e1 --- /dev/null +++ b/packages/auth/src/Infra/WebSockets/WebSocketsClientService.ts @@ -0,0 +1,52 @@ +import { AxiosInstance } from 'axios' +import { RoleName } from '@standardnotes/common' +import { inject, injectable } from 'inversify' +import { Logger } from 'winston' + +import TYPES from '../../Bootstrap/Types' +import { DomainEventFactoryInterface } from '../../Domain/Event/DomainEventFactoryInterface' +import { User } from '../../Domain/User/User' +import { WebSocketsConnectionRepositoryInterface } from '../../Domain/WebSockets/WebSocketsConnectionRepositoryInterface' +import { ClientServiceInterface } from '../../Domain/Client/ClientServiceInterface' + +@injectable() +export class WebSocketsClientService implements ClientServiceInterface { + constructor( + @inject(TYPES.WebSocketsConnectionRepository) + private webSocketsConnectionRepository: WebSocketsConnectionRepositoryInterface, + @inject(TYPES.DomainEventFactory) private domainEventFactory: DomainEventFactoryInterface, + @inject(TYPES.HTTPClient) private httpClient: AxiosInstance, + @inject(TYPES.WEBSOCKETS_API_URL) private webSocketsApiUrl: string, + @inject(TYPES.Logger) private logger: Logger, + ) {} + + async sendUserRolesChangedEvent(user: User): Promise { + if (!this.webSocketsApiUrl) { + this.logger.debug('Web Sockets API url not defined. Skipped sending user role changed event.') + + return + } + + const userConnections = await this.webSocketsConnectionRepository.findAllByUserUuid(user.uuid) + const event = this.domainEventFactory.createUserRolesChangedEvent( + user.uuid, + user.email, + (await user.roles).map((role) => role.name) as RoleName[], + ) + + for (const connectionUuid of userConnections) { + await this.httpClient.request({ + method: 'POST', + url: `${this.webSocketsApiUrl}/${connectionUuid}`, + headers: { + Accept: 'text/plain', + 'Content-Type': 'text/plain', + }, + data: JSON.stringify(event), + validateStatus: + /* istanbul ignore next */ + (status: number) => status >= 200 && status < 500, + }) + } + } +} diff --git a/packages/auth/src/Projection/PermissionProjector.spec.ts b/packages/auth/src/Projection/PermissionProjector.spec.ts new file mode 100644 index 000000000..51c3b430e --- /dev/null +++ b/packages/auth/src/Projection/PermissionProjector.spec.ts @@ -0,0 +1,47 @@ +import 'reflect-metadata' + +import { Permission } from '../Domain/Permission/Permission' + +import { PermissionProjector } from './PermissionProjector' + +describe('PermissionProjector', () => { + let permission: Permission + + const createProjector = () => new PermissionProjector() + + beforeEach(() => { + permission = new Permission() + permission.uuid = '123' + permission.name = 'permission1' + permission.createdAt = new Date(1) + permission.updatedAt = new Date(2) + }) + + it('should create a simple projection', () => { + const projection = createProjector().projectSimple(permission) + expect(projection).toMatchObject({ + uuid: '123', + name: 'permission1', + }) + }) + + it('should throw error on custom projection', () => { + let error = null + try { + createProjector().projectCustom('test', permission) + } catch (e) { + error = e + } + expect(error).not.toBeNull() + }) + + it('should throw error on not implemetned full projection', () => { + let error = null + try { + createProjector().projectFull(permission) + } catch (e) { + error = e + } + expect(error).not.toBeNull() + }) +}) diff --git a/packages/auth/src/Projection/PermissionProjector.ts b/packages/auth/src/Projection/PermissionProjector.ts new file mode 100644 index 000000000..3a6cdcea1 --- /dev/null +++ b/packages/auth/src/Projection/PermissionProjector.ts @@ -0,0 +1,22 @@ +import { injectable } from 'inversify' +import { Permission } from '../Domain/Permission/Permission' + +import { ProjectorInterface } from './ProjectorInterface' + +@injectable() +export class PermissionProjector implements ProjectorInterface { + projectSimple(permission: Permission): Record { + return { + uuid: permission.uuid, + name: permission.name, + } + } + + projectCustom(_projectionType: string, _role: Permission): Record { + throw Error('not implemented') + } + + projectFull(_role: Permission): Record { + throw Error('not implemented') + } +} diff --git a/packages/auth/src/Projection/ProjectorInterface.ts b/packages/auth/src/Projection/ProjectorInterface.ts new file mode 100644 index 000000000..88b917570 --- /dev/null +++ b/packages/auth/src/Projection/ProjectorInterface.ts @@ -0,0 +1,5 @@ +export interface ProjectorInterface { + projectSimple(object: T): Record + projectFull(object: T): Record + projectCustom(projectionType: string, object: T, ...args: any[]): Record +} diff --git a/packages/auth/src/Projection/RoleProjector.spec.ts b/packages/auth/src/Projection/RoleProjector.spec.ts new file mode 100644 index 000000000..93dff667e --- /dev/null +++ b/packages/auth/src/Projection/RoleProjector.spec.ts @@ -0,0 +1,47 @@ +import 'reflect-metadata' + +import { Role } from '../Domain/Role/Role' + +import { RoleProjector } from './RoleProjector' + +describe('RoleProjector', () => { + let role: Role + + const createProjector = () => new RoleProjector() + + beforeEach(() => { + role = new Role() + role.uuid = '123' + role.name = 'role1' + role.createdAt = new Date(1) + role.updatedAt = new Date(2) + }) + + it('should create a simple projection', () => { + const projection = createProjector().projectSimple(role) + expect(projection).toMatchObject({ + uuid: '123', + name: 'role1', + }) + }) + + it('should throw error on custom projection', () => { + let error = null + try { + createProjector().projectCustom('test', role) + } catch (e) { + error = e + } + expect(error).not.toBeNull() + }) + + it('should throw error on not implemetned full projection', () => { + let error = null + try { + createProjector().projectFull(role) + } catch (e) { + error = e + } + expect(error).not.toBeNull() + }) +}) diff --git a/packages/auth/src/Projection/RoleProjector.ts b/packages/auth/src/Projection/RoleProjector.ts new file mode 100644 index 000000000..e9a59ca0a --- /dev/null +++ b/packages/auth/src/Projection/RoleProjector.ts @@ -0,0 +1,22 @@ +import { injectable } from 'inversify' +import { Role } from '../Domain/Role/Role' + +import { ProjectorInterface } from './ProjectorInterface' + +@injectable() +export class RoleProjector implements ProjectorInterface { + projectSimple(role: Role): Record { + return { + uuid: role.uuid, + name: role.name, + } + } + + projectCustom(_projectionType: string, _role: Role): Record { + throw Error('not implemented') + } + + projectFull(_role: Role): Record { + throw Error('not implemented') + } +} diff --git a/packages/auth/src/Projection/SessionProjector.spec.ts b/packages/auth/src/Projection/SessionProjector.spec.ts new file mode 100644 index 000000000..2299969d3 --- /dev/null +++ b/packages/auth/src/Projection/SessionProjector.spec.ts @@ -0,0 +1,109 @@ +import 'reflect-metadata' + +import { SessionServiceInterface } from '../Domain/Session/SessionServiceInterface' +import { SessionProjector } from './SessionProjector' +import { Session } from '../Domain/Session/Session' +import { TimerInterface } from '@standardnotes/time' + +describe('SessionProjector', () => { + let session: Session + let currentSession: Session + let sessionService: SessionServiceInterface + let timer: TimerInterface + + const createProjector = () => new SessionProjector(sessionService, timer) + + beforeEach(() => { + session = new Session() + session.uuid = '123' + session.hashedAccessToken = 'hashed access token' + session.userUuid = '234' + session.apiVersion = '004' + session.createdAt = new Date(1) + session.updatedAt = new Date(1) + session.accessExpiration = new Date(1) + session.refreshExpiration = new Date(1) + session.readonlyAccess = false + + currentSession = new Session() + currentSession.uuid = '234' + + sessionService = {} as jest.Mocked + sessionService.getDeviceInfo = jest.fn().mockReturnValue('Some Device Info') + + timer = {} as jest.Mocked + timer.convertDateToISOString = jest.fn().mockReturnValue('2020-11-26T13:34:00.000Z') + }) + + it('should create a simple projection of a session', () => { + const projection = createProjector().projectSimple(session) + expect(projection).toMatchObject({ + uuid: '123', + api_version: '004', + created_at: '2020-11-26T13:34:00.000Z', + updated_at: '2020-11-26T13:34:00.000Z', + device_info: 'Some Device Info', + readonly_access: false, + access_expiration: '2020-11-26T13:34:00.000Z', + refresh_expiration: '2020-11-26T13:34:00.000Z', + }) + }) + + it('should create a custom projection of a session', () => { + const projection = createProjector().projectCustom( + SessionProjector.CURRENT_SESSION_PROJECTION.toString(), + session, + currentSession, + ) + + expect(projection).toMatchObject({ + uuid: '123', + api_version: '004', + created_at: '2020-11-26T13:34:00.000Z', + updated_at: '2020-11-26T13:34:00.000Z', + device_info: 'Some Device Info', + current: false, + readonly_access: false, + }) + }) + + it('should create a custom projection of a current session', () => { + currentSession.uuid = '123' + + const projection = createProjector().projectCustom( + SessionProjector.CURRENT_SESSION_PROJECTION.toString(), + session, + currentSession, + ) + + expect(projection).toMatchObject({ + uuid: '123', + api_version: '004', + created_at: '2020-11-26T13:34:00.000Z', + updated_at: '2020-11-26T13:34:00.000Z', + device_info: 'Some Device Info', + current: true, + readonly_access: false, + }) + }) + + it('should throw error on unknown custom projection', () => { + let error = null + try { + createProjector().projectCustom('test', session, currentSession) + } catch (e) { + error = e + } + expect((error as Error).message).toEqual('Not supported projection type: test') + }) + + it('should throw error on not implemetned full projection', () => { + let error = null + try { + createProjector().projectFull(session) + } catch (e) { + error = e + } + expect((error as Error).message).toEqual('not implemented') + }) +}) diff --git a/packages/auth/src/Projection/SessionProjector.ts b/packages/auth/src/Projection/SessionProjector.ts new file mode 100644 index 000000000..70997fb4e --- /dev/null +++ b/packages/auth/src/Projection/SessionProjector.ts @@ -0,0 +1,51 @@ +import { TimerInterface } from '@standardnotes/time' +import { inject, injectable } from 'inversify' +import TYPES from '../Bootstrap/Types' + +import { Session } from '../Domain/Session/Session' +import { SessionServiceInterface } from '../Domain/Session/SessionServiceInterface' +import { ProjectorInterface } from './ProjectorInterface' + +@injectable() +export class SessionProjector implements ProjectorInterface { + static readonly CURRENT_SESSION_PROJECTION = 'CURRENT_SESSION_PROJECTION' + + constructor( + @inject(TYPES.SessionService) private sessionService: SessionServiceInterface, + @inject(TYPES.Timer) private timer: TimerInterface, + ) {} + + projectSimple(session: Session): Record { + return { + uuid: session.uuid, + api_version: session.apiVersion, + created_at: this.timer.convertDateToISOString(session.createdAt), + updated_at: this.timer.convertDateToISOString(session.updatedAt), + device_info: this.sessionService.getDeviceInfo(session), + readonly_access: session.readonlyAccess, + access_expiration: this.timer.convertDateToISOString(session.accessExpiration), + refresh_expiration: this.timer.convertDateToISOString(session.refreshExpiration), + } + } + + projectCustom(projectionType: string, session: Session, currentSession: Session): Record { + switch (projectionType) { + case SessionProjector.CURRENT_SESSION_PROJECTION.toString(): + return { + uuid: session.uuid, + api_version: session.apiVersion, + created_at: this.timer.convertDateToISOString(session.createdAt), + updated_at: this.timer.convertDateToISOString(session.updatedAt), + device_info: this.sessionService.getDeviceInfo(session), + current: session.uuid === currentSession.uuid, + readonly_access: session.readonlyAccess, + } + default: + throw new Error(`Not supported projection type: ${projectionType}`) + } + } + + projectFull(_session: Session): Record { + throw Error('not implemented') + } +} diff --git a/packages/auth/src/Projection/SettingProjector.spec.ts b/packages/auth/src/Projection/SettingProjector.spec.ts new file mode 100644 index 000000000..cf8ef6a9d --- /dev/null +++ b/packages/auth/src/Projection/SettingProjector.spec.ts @@ -0,0 +1,45 @@ +import 'reflect-metadata' + +import { Setting } from '../Domain/Setting/Setting' + +import { SettingProjector } from './SettingProjector' + +describe('SettingProjector', () => { + let setting: Setting + + const createProjector = () => new SettingProjector() + + beforeEach(() => { + setting = { + uuid: 'setting-uuid', + name: 'setting-name', + value: 'setting-value', + serverEncryptionVersion: 1, + createdAt: 1, + updatedAt: 2, + } as jest.Mocked + }) + + it('should create a simple projection of a setting', async () => { + const projection = await createProjector().projectSimple(setting) + expect(projection).toEqual({ + uuid: 'setting-uuid', + name: 'setting-name', + value: 'setting-value', + createdAt: 1, + updatedAt: 2, + }) + }) + it('should create a simple projection of list of settings', async () => { + const projection = await createProjector().projectManySimple([setting]) + expect(projection).toEqual([ + { + uuid: 'setting-uuid', + name: 'setting-name', + value: 'setting-value', + createdAt: 1, + updatedAt: 2, + }, + ]) + }) +}) diff --git a/packages/auth/src/Projection/SettingProjector.ts b/packages/auth/src/Projection/SettingProjector.ts new file mode 100644 index 000000000..ddacf99a6 --- /dev/null +++ b/packages/auth/src/Projection/SettingProjector.ts @@ -0,0 +1,26 @@ +import { injectable } from 'inversify' + +import { Setting } from '../Domain/Setting/Setting' +import { SimpleSetting } from '../Domain/Setting/SimpleSetting' + +@injectable() +export class SettingProjector { + async projectSimple(setting: Setting): Promise { + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + user, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + serverEncryptionVersion, + ...rest + } = setting + + return rest + } + async projectManySimple(settings: Setting[]): Promise { + return Promise.all( + settings.map(async (setting) => { + return this.projectSimple(setting) + }), + ) + } +} diff --git a/packages/auth/src/Projection/SubscriptionSettingProjector.spec.ts b/packages/auth/src/Projection/SubscriptionSettingProjector.spec.ts new file mode 100644 index 000000000..1e9f51746 --- /dev/null +++ b/packages/auth/src/Projection/SubscriptionSettingProjector.spec.ts @@ -0,0 +1,45 @@ +import 'reflect-metadata' + +import { SubscriptionSetting } from '../Domain/Setting/SubscriptionSetting' + +import { SubscriptionSettingProjector } from './SubscriptionSettingProjector' + +describe('SubscriptionSettingProjector', () => { + let setting: SubscriptionSetting + + const createProjector = () => new SubscriptionSettingProjector() + + beforeEach(() => { + setting = { + uuid: 'setting-uuid', + name: 'setting-name', + value: 'setting-value', + serverEncryptionVersion: 1, + createdAt: 1, + updatedAt: 2, + } as jest.Mocked + }) + + it('should create a simple projection of a setting', async () => { + const projection = await createProjector().projectSimple(setting) + expect(projection).toEqual({ + uuid: 'setting-uuid', + name: 'setting-name', + value: 'setting-value', + createdAt: 1, + updatedAt: 2, + }) + }) + it('should create a simple projection of list of settings', async () => { + const projection = await createProjector().projectManySimple([setting]) + expect(projection).toEqual([ + { + uuid: 'setting-uuid', + name: 'setting-name', + value: 'setting-value', + createdAt: 1, + updatedAt: 2, + }, + ]) + }) +}) diff --git a/packages/auth/src/Projection/SubscriptionSettingProjector.ts b/packages/auth/src/Projection/SubscriptionSettingProjector.ts new file mode 100644 index 000000000..278aed772 --- /dev/null +++ b/packages/auth/src/Projection/SubscriptionSettingProjector.ts @@ -0,0 +1,26 @@ +import { injectable } from 'inversify' + +import { SimpleSubscriptionSetting } from '../Domain/Setting/SimpleSubscriptionSetting' +import { SubscriptionSetting } from '../Domain/Setting/SubscriptionSetting' + +@injectable() +export class SubscriptionSettingProjector { + async projectSimple(setting: SubscriptionSetting): Promise { + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + userSubscription, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + serverEncryptionVersion, + ...rest + } = setting + + return rest + } + async projectManySimple(settings: SubscriptionSetting[]): Promise { + return Promise.all( + settings.map(async (setting) => { + return this.projectSimple(setting) + }), + ) + } +} diff --git a/packages/auth/src/Projection/UserProjector.spec.ts b/packages/auth/src/Projection/UserProjector.spec.ts new file mode 100644 index 000000000..52f8c3288 --- /dev/null +++ b/packages/auth/src/Projection/UserProjector.spec.ts @@ -0,0 +1,45 @@ +import 'reflect-metadata' + +import { UserProjector } from './UserProjector' +import { User } from '../Domain/User/User' + +describe('UserProjector', () => { + let user: User + + const createProjector = () => new UserProjector() + + beforeEach(() => { + user = new User() + user.uuid = '123' + user.email = 'test@test.te' + user.encryptedPassword = '123qwe345' + }) + + it('should create a simple projection of a user', () => { + const projection = createProjector().projectSimple(user) + expect(projection).toMatchObject({ + uuid: '123', + email: 'test@test.te', + }) + }) + + it('should throw error on custom projection', () => { + let error = null + try { + createProjector().projectCustom('test', user) + } catch (e) { + error = e + } + expect(error).not.toBeNull() + }) + + it('should throw error on not implemetned full projection', () => { + let error = null + try { + createProjector().projectFull(user) + } catch (e) { + error = e + } + expect(error).not.toBeNull() + }) +}) diff --git a/packages/auth/src/Projection/UserProjector.ts b/packages/auth/src/Projection/UserProjector.ts new file mode 100644 index 000000000..b65a5a811 --- /dev/null +++ b/packages/auth/src/Projection/UserProjector.ts @@ -0,0 +1,22 @@ +import { injectable } from 'inversify' + +import { User } from '../Domain/User/User' +import { ProjectorInterface } from './ProjectorInterface' + +@injectable() +export class UserProjector implements ProjectorInterface { + projectSimple(user: User): Record { + return { + uuid: user.uuid, + email: user.email, + } + } + + projectCustom(_projectionType: string, _user: User): Record { + throw Error('not implemented') + } + + projectFull(_user: User): Record { + throw Error('not implemented') + } +} diff --git a/packages/auth/test-setup.ts b/packages/auth/test-setup.ts new file mode 100644 index 000000000..acdb472a5 --- /dev/null +++ b/packages/auth/test-setup.ts @@ -0,0 +1,4 @@ +import * as dayjs from 'dayjs' +import * as utc from 'dayjs/plugin/utc' + +dayjs.extend(utc) diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json new file mode 100644 index 000000000..d87b89eeb --- /dev/null +++ b/packages/auth/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + }, + "include": [ + "src/**/*", + "bin/**/*", + "migrations/**/*", + ], + "references": [] +} diff --git a/packages/auth/wait-for.sh b/packages/auth/wait-for.sh new file mode 100755 index 000000000..f3d72b834 --- /dev/null +++ b/packages/auth/wait-for.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +set -e + +host="$1" +shift +port="$1" +shift +cmd="$@" + +while ! nc -vz $host $port; do + >&2 echo "$host:$port is unavailable yet - waiting for it to start" + sleep 10 +done + +>&2 echo "$host:$port is up - executing command" +exec $cmd diff --git a/packages/scheduler/docker/entrypoint.sh b/packages/scheduler/docker/entrypoint.sh index 41aa3196b..d80038c42 100755 --- a/packages/scheduler/docker/entrypoint.sh +++ b/packages/scheduler/docker/entrypoint.sh @@ -11,7 +11,7 @@ case "$COMMAND" in 'verify-jobs' ) echo "Starting jobs verification..." - yarn verify:jobs + yarn workspace @standardnotes/scheduler-server verify:jobs ;; * ) diff --git a/packages/scheduler/package.json b/packages/scheduler/package.json index d1e6bdc1a..2459e3601 100644 --- a/packages/scheduler/package.json +++ b/packages/scheduler/package.json @@ -15,7 +15,7 @@ "build": "tsc --rootDir ./", "lint": "eslint . --ext .ts", "pretest": "yarn lint && yarn build", - "test": "jest --collect-coverage --config=./jest.config.js --runInBand", + "test": "jest --coverage --config=./jest.config.js --maxWorkers=50%", "worker": "yarn node dist/bin/worker.js", "verify:jobs": "yarn node dist/bin/verify.js", "setup:env": "cp .env.sample .env", @@ -49,15 +49,5 @@ "eslint-plugin-prettier": "^4.0.0", "jest": "^28.1.1", "ts-jest": "^28.0.5" - }, - "jest": { - "preset": "../../.yarn/unplugged/@standardnotes-config-npm-2.4.3-f16699e480/node_modules/@standardnotes/config/src/jest.json", - "coveragePathIgnorePatterns": [ - "/node_modules/", - "/Bootstrap/" - ], - "setupFilesAfterEnv": [ - "/test-setup.ts" - ] } } diff --git a/tsconfig.json b/tsconfig.json index cc8a61bbb..19279f782 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,9 @@ "references": [ { "path": "./packages/scheduler" + }, + { + "path": "./packages/auth" } ] } diff --git a/yarn.lock b/yarn.lock index 956634ccf..280b2d1c2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1566,6 +1566,54 @@ __metadata: languageName: node linkType: hard +"@otplib/core@npm:^12.0.1": + version: 12.0.1 + resolution: "@otplib/core@npm:12.0.1" + checksum: b3c34bc20b31bc3f49cc0dc3c0eb070491c0101e8c1efa83cec48ca94158bd736aaca8187df667fc0c4a239d4ac52076bc44084bee04a50c80c3630caf77affa + languageName: node + linkType: hard + +"@otplib/plugin-crypto@npm:^12.0.1": + version: 12.0.1 + resolution: "@otplib/plugin-crypto@npm:12.0.1" + dependencies: + "@otplib/core": ^12.0.1 + checksum: 6867c74ee8aca6c2db9670362cf51e44f3648602c39318bf537421242e33f0012a172acd43bbed9a21d706e535dc4c66aff965380673391e9fd74cf685b5b13a + languageName: node + linkType: hard + +"@otplib/plugin-thirty-two@npm:^12.0.1": + version: 12.0.1 + resolution: "@otplib/plugin-thirty-two@npm:12.0.1" + dependencies: + "@otplib/core": ^12.0.1 + thirty-two: ^1.0.2 + checksum: 920099e40d3e8c2941291c84c70064c2d86d0d1ed17230d650445d5463340e406bc413ddf2e40c374ddc4ee988ef1e3facacab9b5248b1ff361fd13df52bf88f + languageName: node + linkType: hard + +"@otplib/preset-default@npm:^12.0.1": + version: 12.0.1 + resolution: "@otplib/preset-default@npm:12.0.1" + dependencies: + "@otplib/core": ^12.0.1 + "@otplib/plugin-crypto": ^12.0.1 + "@otplib/plugin-thirty-two": ^12.0.1 + checksum: 8133231384f6277f77eb8e42ef83bc32a8b01059bef147d1c358d9e9bfd292e1c239f581fe008367a48489dd68952b7ac0948e6c41412fc06079da2c91b71d16 + languageName: node + linkType: hard + +"@otplib/preset-v11@npm:^12.0.1": + version: 12.0.1 + resolution: "@otplib/preset-v11@npm:12.0.1" + dependencies: + "@otplib/core": ^12.0.1 + "@otplib/plugin-crypto": ^12.0.1 + "@otplib/plugin-thirty-two": ^12.0.1 + checksum: 367cb09397e617c21ec748d54e920ab43f1c5dfba70cbfd88edf73aecca399cf0c09fefe32518f79c7ee8a06e7058d14b200da378cc7d46af3cac4e22a153e2f + languageName: node + linkType: hard + "@protobufjs/aspromise@npm:^1.1.1, @protobufjs/aspromise@npm:^1.1.2": version: 1.1.2 resolution: "@protobufjs/aspromise@npm:1.1.2" @@ -1639,6 +1687,74 @@ __metadata: languageName: node linkType: hard +"@sentry/core@npm:6.19.7": + version: 6.19.7 + resolution: "@sentry/core@npm:6.19.7" + dependencies: + "@sentry/hub": 6.19.7 + "@sentry/minimal": 6.19.7 + "@sentry/types": 6.19.7 + "@sentry/utils": 6.19.7 + tslib: ^1.9.3 + checksum: d212e8ef07114549de4a93b81f8bfa217ca1550ca7a5eeaa611e5629faef78ff72663ce561ffa2cff48f3dc556745ef65177044f9965cdd3cbccf617cf3bf675 + languageName: node + linkType: hard + +"@sentry/hub@npm:6.19.7": + version: 6.19.7 + resolution: "@sentry/hub@npm:6.19.7" + dependencies: + "@sentry/types": 6.19.7 + "@sentry/utils": 6.19.7 + tslib: ^1.9.3 + checksum: 10bb1c5cba1b0f1e27a3dd0a186c22f94aeaf11c4662890ab07b2774f46f46af78d61e3ba71d76edc750a7b45af86edd032f35efecdb4efa2eaf551080ccdcb1 + languageName: node + linkType: hard + +"@sentry/minimal@npm:6.19.7": + version: 6.19.7 + resolution: "@sentry/minimal@npm:6.19.7" + dependencies: + "@sentry/hub": 6.19.7 + "@sentry/types": 6.19.7 + tslib: ^1.9.3 + checksum: 9153ac426ee056fc34c5be898f83d74ec08f559d69f544c5944ec05e584b62ed356b92d1a9b08993a7022ad42b5661c3d72881221adc19bee5fc1af3ad3864a8 + languageName: node + linkType: hard + +"@sentry/node@npm:^6.16.1": + version: 6.19.7 + resolution: "@sentry/node@npm:6.19.7" + dependencies: + "@sentry/core": 6.19.7 + "@sentry/hub": 6.19.7 + "@sentry/types": 6.19.7 + "@sentry/utils": 6.19.7 + cookie: ^0.4.1 + https-proxy-agent: ^5.0.0 + lru_map: ^0.3.3 + tslib: ^1.9.3 + checksum: 2293b0d1d1f9fac3a451eb94f820bc27721c8edddd1f373064666ddd6272f0a4c70dbe58c6c4b3d3ccaf4578aab8f466d71ee69f6f6ff93521bbb02dfe829ce5 + languageName: node + linkType: hard + +"@sentry/types@npm:6.19.7": + version: 6.19.7 + resolution: "@sentry/types@npm:6.19.7" + checksum: f46ef74a33376ad6ea9b128115515c58eb9369d89293c60aa67abca26b5d5d519aa4d0a736db56ae0d75ffd816643d62187018298523cbc2e6c2fb3a6b2a9035 + languageName: node + linkType: hard + +"@sentry/utils@npm:6.19.7": + version: 6.19.7 + resolution: "@sentry/utils@npm:6.19.7" + dependencies: + "@sentry/types": 6.19.7 + tslib: ^1.9.3 + checksum: a000223b9c646c64e3565e79cace1eeb75114342b768367c4dddd646476c215eb1bddfb70c63f05e2352d3bce2d7d415344e4757a001605d0e01ac74da5dd306 + languageName: node + linkType: hard + "@sinclair/typebox@npm:^0.23.3": version: 0.23.5 resolution: "@sinclair/typebox@npm:0.23.5" @@ -1646,6 +1762,13 @@ __metadata: languageName: node linkType: hard +"@sindresorhus/is@npm:^0.14.0": + version: 0.14.0 + resolution: "@sindresorhus/is@npm:0.14.0" + checksum: 971e0441dd44ba3909b467219a5e242da0fc584048db5324cfb8048148fa8dcc9d44d71e3948972c4f6121d24e5da402ef191420d1266a95f713bb6d6e59c98a + languageName: node + linkType: hard + "@sinonjs/commons@npm:^1.7.0": version: 1.8.3 resolution: "@sinonjs/commons@npm:1.8.3" @@ -1671,7 +1794,87 @@ __metadata: languageName: node linkType: hard -"@standardnotes/auth@npm:^3.19.3": +"@standardnotes/analytics@npm:^1.6.0": + version: 1.6.0 + resolution: "@standardnotes/analytics@npm:1.6.0" + checksum: 6a5e86152673ce9ddce43c52b5f699a1b3ba5141e58a944bda8eaa88fc2f4169df27239f82633226752bf3f10f9804f426721b9c919d10fbfbb51f952430eb1f + languageName: node + linkType: hard + +"@standardnotes/api@npm:^1.1.13": + version: 1.1.13 + resolution: "@standardnotes/api@npm:1.1.13" + dependencies: + "@standardnotes/auth": ^3.19.3 + "@standardnotes/common": ^1.23.0 + "@standardnotes/encryption": ^1.8.19 + "@standardnotes/responses": ^1.6.36 + "@standardnotes/services": ^1.13.19 + "@standardnotes/utils": ^1.6.11 + checksum: 2ff21e04bbeb7e638772b75a72d426120b6136312a53b39dc33ff55cb722fbebad5756f94d79a0fe63f6f3d2f74bbe032c694ee6d7d1eaea86324e3e8c436876 + languageName: node + linkType: hard + +"@standardnotes/auth-server@workspace:packages/auth": + version: 0.0.0-use.local + resolution: "@standardnotes/auth-server@workspace:packages/auth" + dependencies: + "@newrelic/native-metrics": 7.0.2 + "@newrelic/winston-enricher": ^2.1.0 + "@sentry/node": ^6.16.1 + "@standardnotes/analytics": ^1.6.0 + "@standardnotes/api": ^1.1.13 + "@standardnotes/auth": ^3.19.2 + "@standardnotes/common": ^1.23.0 + "@standardnotes/domain-events": ^2.31.1 + "@standardnotes/domain-events-infra": ^1.4.135 + "@standardnotes/features": ^1.45.2 + "@standardnotes/responses": ^1.6.15 + "@standardnotes/scheduler": ^1.1.1 + "@standardnotes/settings": ^1.14.2 + "@standardnotes/sncrypto-common": ^1.8.1 + "@standardnotes/sncrypto-node": ^1.8.1 + "@standardnotes/time": ^1.6.8 + "@types/bcryptjs": ^2.4.2 + "@types/cors": ^2.8.9 + "@types/express": ^4.17.11 + "@types/ioredis": ^4.28.10 + "@types/jest": ^28.1.3 + "@types/newrelic": ^7.0.2 + "@types/otplib": ^10.0.0 + "@types/prettyjson": ^0.0.29 + "@types/ua-parser-js": ^0.7.36 + "@types/uuid": ^8.3.0 + "@typescript-eslint/eslint-plugin": ^5.29.0 + aws-sdk: ^2.1159.0 + axios: 0.24.0 + bcryptjs: 2.4.3 + cors: 2.8.5 + crypto-random-string: 3.3.0 + dayjs: ^1.11.3 + dotenv: 8.2.0 + eslint: ^8.14.0 + eslint-plugin-prettier: ^4.0.0 + express: 4.17.1 + inversify: ^6.0.1 + inversify-express-utils: ^6.4.3 + ioredis: ^5.0.6 + jest: ^28.1.1 + mysql2: ^2.3.3 + newrelic: 8.6.0 + nodemon: ^2.0.16 + otplib: 12.0.1 + prettyjson: 1.2.1 + reflect-metadata: 0.1.13 + ts-jest: ^28.0.1 + typeorm: ^0.3.6 + ua-parser-js: 1.0.2 + uuid: 8.3.2 + winston: 3.3.3 + languageName: unknown + linkType: soft + +"@standardnotes/auth@npm:^3.19.2, @standardnotes/auth@npm:^3.19.3": version: 3.19.3 resolution: "@standardnotes/auth@npm:3.19.3" dependencies: @@ -1688,7 +1891,7 @@ __metadata: languageName: node linkType: hard -"@standardnotes/domain-events-infra@npm:^1.5.0": +"@standardnotes/domain-events-infra@npm:^1.4.135, @standardnotes/domain-events-infra@npm:^1.5.0": version: 1.5.2 resolution: "@standardnotes/domain-events-infra@npm:1.5.2" dependencies: @@ -1703,7 +1906,7 @@ __metadata: languageName: node linkType: hard -"@standardnotes/domain-events@npm:^2.32.0, @standardnotes/domain-events@npm:^2.32.2": +"@standardnotes/domain-events@npm:^2.31.1, @standardnotes/domain-events@npm:^2.32.0, @standardnotes/domain-events@npm:^2.32.2": version: 2.32.2 resolution: "@standardnotes/domain-events@npm:2.32.2" dependencies: @@ -1714,7 +1917,18 @@ __metadata: languageName: node linkType: hard -"@standardnotes/features@npm:^1.45.5": +"@standardnotes/encryption@npm:^1.8.19": + version: 1.8.19 + resolution: "@standardnotes/encryption@npm:1.8.19" + dependencies: + "@standardnotes/models": ^1.11.10 + "@standardnotes/responses": ^1.6.36 + "@standardnotes/services": ^1.13.19 + checksum: f663a6b9a21a77cb415fc82daea5b616655df40ddc926686c073d9596580dd2bec7e1f150dfafde3653b6797fa29696ed33725762e506badabd8fe4d997f05a8 + languageName: node + linkType: hard + +"@standardnotes/features@npm:^1.45.2, @standardnotes/features@npm:^1.45.5": version: 1.45.5 resolution: "@standardnotes/features@npm:1.45.5" dependencies: @@ -1724,6 +1938,28 @@ __metadata: languageName: node linkType: hard +"@standardnotes/models@npm:^1.11.10": + version: 1.11.10 + resolution: "@standardnotes/models@npm:1.11.10" + dependencies: + "@standardnotes/features": ^1.45.5 + "@standardnotes/responses": ^1.6.36 + "@standardnotes/utils": ^1.6.11 + checksum: d69fd3940e96a37d655af94c940d6c0197a514ad15b36b42f95edf122873dd6cfc1428137c5aecbbb12ae603ceaf7c7ebd879c92c106f4a28aa8f3adf0528c7d + languageName: node + linkType: hard + +"@standardnotes/responses@npm:^1.6.15, @standardnotes/responses@npm:^1.6.36": + version: 1.6.36 + resolution: "@standardnotes/responses@npm:1.6.36" + dependencies: + "@standardnotes/auth": ^3.19.3 + "@standardnotes/common": ^1.23.0 + "@standardnotes/features": ^1.45.5 + checksum: bb78a2cefadacf48010ce1ed45a1035b67e48acaf2ec746dbe9ff6d8c23340705a4cf66aee90728b233bdc41acf1e0cf89660cbfb03090c86075ed8ccfe78830 + languageName: node + linkType: hard + "@standardnotes/scheduler-server@workspace:packages/scheduler": version: 0.0.0-use.local resolution: "@standardnotes/scheduler-server@workspace:packages/scheduler" @@ -1774,6 +2010,7 @@ __metadata: "@lerna-lite/cli": ^1.5.1 "@lerna-lite/list": ^1.5.1 "@lerna-lite/run": ^1.5.1 + "@types/jest": ^28.1.3 "@typescript-eslint/parser": ^5.29.0 eslint: ^8.17.0 eslint-config-prettier: ^8.5.0 @@ -1783,7 +2020,43 @@ __metadata: languageName: unknown linkType: soft -"@standardnotes/time@npm:^1.7.0": +"@standardnotes/services@npm:^1.13.19": + version: 1.13.19 + resolution: "@standardnotes/services@npm:1.13.19" + dependencies: + "@standardnotes/auth": ^3.19.3 + "@standardnotes/common": ^1.23.0 + "@standardnotes/models": ^1.11.10 + "@standardnotes/responses": ^1.6.36 + "@standardnotes/utils": ^1.6.11 + checksum: c4c239c5e8c717e00ad30bf1f5845024336755c26dcd0600a917cc037ef86caef551c92994d1c5f56f4a0623d1bc0050b174fa15388a034ccbc7dce8dabc71e4 + languageName: node + linkType: hard + +"@standardnotes/settings@npm:^1.14.2": + version: 1.14.3 + resolution: "@standardnotes/settings@npm:1.14.3" + checksum: 60fbb2ca856083b1afdf3a93cfb2729151c8a4a34f02564a8a814a7caad9982285303ad79ffb1da7643046061a6efe39410e57e8e4703a8963be8dc0ffabeaa6 + languageName: node + linkType: hard + +"@standardnotes/sncrypto-common@npm:^1.8.1, @standardnotes/sncrypto-common@npm:^1.9.0": + version: 1.9.0 + resolution: "@standardnotes/sncrypto-common@npm:1.9.0" + checksum: 42252d71984b52756dff44ec3721961858e9f4227ca6555c0d60551852cb5f0a938b2b4969177c23c85d34e7f182369393f8b795afc65cff65b1c30b139f8f68 + languageName: node + linkType: hard + +"@standardnotes/sncrypto-node@npm:^1.8.1": + version: 1.8.3 + resolution: "@standardnotes/sncrypto-node@npm:1.8.3" + dependencies: + "@standardnotes/sncrypto-common": ^1.9.0 + checksum: b3c866bfba63fbf673ce78de0a25b0abff5f2cf2476892f3fba76d55554d0d84e304d6f454dd9a482f723585e995fc56565f47a67ea52e53f6c3075f1c160286 + languageName: node + linkType: hard + +"@standardnotes/time@npm:^1.6.8, @standardnotes/time@npm:^1.7.0": version: 1.7.0 resolution: "@standardnotes/time@npm:1.7.0" dependencies: @@ -1794,6 +2067,26 @@ __metadata: languageName: node linkType: hard +"@standardnotes/utils@npm:^1.6.11": + version: 1.6.11 + resolution: "@standardnotes/utils@npm:1.6.11" + dependencies: + "@standardnotes/common": ^1.23.0 + dompurify: ^2.3.6 + lodash: ^4.17.21 + checksum: c50999c0b0e1c6b81bf7217e516cf163496d6e541b45b6e064c3a42c72698173f1a3f7117f7ed484d1234f99ea2d44e1a7129864d6d1caf8a9595b0151219701 + languageName: node + linkType: hard + +"@szmarczak/http-timer@npm:^1.1.2": + version: 1.1.2 + resolution: "@szmarczak/http-timer@npm:1.1.2" + dependencies: + defer-to-connect: ^1.0.1 + checksum: 4d9158061c5f397c57b4988cde33a163244e4f02df16364f103971957a32886beb104d6180902cbe8b38cb940e234d9f98a4e486200deca621923f62f50a06fe + languageName: node + linkType: hard + "@tootallnate/once@npm:1": version: 1.1.2 resolution: "@tootallnate/once@npm:1.1.2" @@ -1877,6 +2170,62 @@ __metadata: languageName: node linkType: hard +"@types/bcryptjs@npm:^2.4.2": + version: 2.4.2 + resolution: "@types/bcryptjs@npm:2.4.2" + checksum: 220dade7b0312b41e23ccfb15f2ddde7804eb3c7ef41db41a6c49054be1e19a15eb3dd8c8ef196494f0866307cce22ad6f3f272941387124707d81dc66155bbc + languageName: node + linkType: hard + +"@types/body-parser@npm:*": + version: 1.19.2 + resolution: "@types/body-parser@npm:1.19.2" + dependencies: + "@types/connect": "*" + "@types/node": "*" + checksum: e17840c7d747a549f00aebe72c89313d09fbc4b632b949b2470c5cb3b1cb73863901ae84d9335b567a79ec5efcfb8a28ff8e3f36bc8748a9686756b6d5681f40 + languageName: node + linkType: hard + +"@types/connect@npm:*": + version: 3.4.35 + resolution: "@types/connect@npm:3.4.35" + dependencies: + "@types/node": "*" + checksum: fe81351470f2d3165e8b12ce33542eef89ea893e36dd62e8f7d72566dfb7e448376ae962f9f3ea888547ce8b55a40020ca0e01d637fab5d99567673084542641 + languageName: node + linkType: hard + +"@types/cors@npm:^2.8.9": + version: 2.8.12 + resolution: "@types/cors@npm:2.8.12" + checksum: 8c45f112c7d1d2d831b4b266f2e6ed33a1887a35dcbfe2a18b28370751fababb7cd045e745ef84a523c33a25932678097bf79afaa367c6cb3fa0daa7a6438257 + languageName: node + linkType: hard + +"@types/express-serve-static-core@npm:^4.17.18": + version: 4.17.29 + resolution: "@types/express-serve-static-core@npm:4.17.29" + dependencies: + "@types/node": "*" + "@types/qs": "*" + "@types/range-parser": "*" + checksum: ec4194dc59276ec6dd906887fc377be0cadf4aaa4d535d9052ab9624937ef2b984a8d9da2c11c96979e21f3d9f78f1da93e767dbcec637f7f13d2e3003151145 + languageName: node + linkType: hard + +"@types/express@npm:^4.17.11": + version: 4.17.13 + resolution: "@types/express@npm:4.17.13" + dependencies: + "@types/body-parser": "*" + "@types/express-serve-static-core": ^4.17.18 + "@types/qs": "*" + "@types/serve-static": "*" + checksum: 12a2a0e6c4b993fc0854bec665906788aea0d8ee4392389d7a98a5de1eefdd33c9e1e40a91f3afd274011119c506f7b4126acb97fae62ae20b654974d44cba12 + languageName: node + linkType: hard + "@types/graceful-fs@npm:^4.1.3": version: 4.1.5 resolution: "@types/graceful-fs@npm:4.1.5" @@ -1930,6 +2279,16 @@ __metadata: languageName: node linkType: hard +"@types/jest@npm:^28.1.3": + version: 28.1.3 + resolution: "@types/jest@npm:28.1.3" + dependencies: + jest-matcher-utils: ^28.0.0 + pretty-format: ^28.0.0 + checksum: 28141f2d5b3bafd063362de9790cb8f219488d9b0ad47524a84bef1142a4f0d9d35be0c56988d9f922205225cc83c986acd4be424bd8653b38dc27ab672455e2 + languageName: node + linkType: hard + "@types/json-schema@npm:^7.0.9": version: 7.0.11 resolution: "@types/json-schema@npm:7.0.11" @@ -1937,6 +2296,15 @@ __metadata: languageName: node linkType: hard +"@types/keyv@npm:^3.1.1": + version: 3.1.4 + resolution: "@types/keyv@npm:3.1.4" + dependencies: + "@types/node": "*" + checksum: e009a2bfb50e90ca9b7c6e8f648f8464067271fd99116f881073fa6fa76dc8d0133181dd65e6614d5fb1220d671d67b0124aef7d97dc02d7e342ab143a47779d + languageName: node + linkType: hard + "@types/long@npm:^4.0.1": version: 4.0.2 resolution: "@types/long@npm:4.0.2" @@ -1944,6 +2312,13 @@ __metadata: languageName: node linkType: hard +"@types/mime@npm:^1": + version: 1.3.2 + resolution: "@types/mime@npm:1.3.2" + checksum: 0493368244cced1a69cb791b485a260a422e6fcc857782e1178d1e6f219f1b161793e9f87f5fae1b219af0f50bee24fcbe733a18b4be8fdd07a38a8fb91146fd + languageName: node + linkType: hard + "@types/minimatch@npm:^3.0.3": version: 3.0.5 resolution: "@types/minimatch@npm:3.0.5" @@ -1979,6 +2354,15 @@ __metadata: languageName: node linkType: hard +"@types/otplib@npm:^10.0.0": + version: 10.0.0 + resolution: "@types/otplib@npm:10.0.0" + dependencies: + otplib: "*" + checksum: aa081f0a55c93374063f535be860774e0a442337fa39a5acc002d92bf3841d56ac5ddeed33f4f8428b3e9a1868c0079f9d3ce4b192ec75b91e77c2f18ead2745 + languageName: node + linkType: hard + "@types/parse-json@npm:^4.0.0": version: 4.0.0 resolution: "@types/parse-json@npm:4.0.0" @@ -1993,6 +2377,46 @@ __metadata: languageName: node linkType: hard +"@types/prettyjson@npm:^0.0.29": + version: 0.0.29 + resolution: "@types/prettyjson@npm:0.0.29" + checksum: 9ff6cb225de224b602fb5992e67ecdf47196becff3ff0c617c3df56ada3f2da5938650f944ccb70530911bb3cf51e27568fc41a4cb62e03ca95d428c7eb05c9a + languageName: node + linkType: hard + +"@types/qs@npm:*": + version: 6.9.7 + resolution: "@types/qs@npm:6.9.7" + checksum: 7fd6f9c25053e9b5bb6bc9f9f76c1d89e6c04f7707a7ba0e44cc01f17ef5284adb82f230f542c2d5557d69407c9a40f0f3515e8319afd14e1e16b5543ac6cdba + languageName: node + linkType: hard + +"@types/range-parser@npm:*": + version: 1.2.4 + resolution: "@types/range-parser@npm:1.2.4" + checksum: b7c0dfd5080a989d6c8bb0b6750fc0933d9acabeb476da6fe71d8bdf1ab65e37c136169d84148034802f48378ab94e3c37bb4ef7656b2bec2cb9c0f8d4146a95 + languageName: node + linkType: hard + +"@types/responselike@npm:^1.0.0": + version: 1.0.0 + resolution: "@types/responselike@npm:1.0.0" + dependencies: + "@types/node": "*" + checksum: e99fc7cc6265407987b30deda54c1c24bb1478803faf6037557a774b2f034c5b097ffd65847daa87e82a61a250d919f35c3588654b0fdaa816906650f596d1b0 + languageName: node + linkType: hard + +"@types/serve-static@npm:*": + version: 1.13.10 + resolution: "@types/serve-static@npm:1.13.10" + dependencies: + "@types/mime": ^1 + "@types/node": "*" + checksum: eaca858739483e3ded254cad7d7a679dc2c8b3f52c8bb0cd845b3b7eb1984bde0371fdcb0a5c83aa12e6daf61b6beb762545021f520f08a1fe882a3fa4ea5554 + languageName: node + linkType: hard + "@types/stack-utils@npm:^2.0.0": version: 2.0.1 resolution: "@types/stack-utils@npm:2.0.1" @@ -2000,6 +2424,20 @@ __metadata: languageName: node linkType: hard +"@types/ua-parser-js@npm:^0.7.36": + version: 0.7.36 + resolution: "@types/ua-parser-js@npm:0.7.36" + checksum: 8c24d4dc12ed1b8b98195838093391c358c81bf75e9cae0ecec8f7824b441e069daaa17b974a3e257172caddb671439f0c0b44bf43bfcf409b7a574a25aab948 + languageName: node + linkType: hard + +"@types/uuid@npm:^8.3.0": + version: 8.3.4 + resolution: "@types/uuid@npm:8.3.4" + checksum: 6f11f3ff70f30210edaa8071422d405e9c1d4e53abbe50fdce365150d3c698fe7bbff65c1e71ae080cbfb8fded860dbb5e174da96fdbbdfcaa3fb3daa474d20f + languageName: node + linkType: hard + "@types/yargs-parser@npm:*": version: 21.0.0 resolution: "@types/yargs-parser@npm:21.0.0" @@ -2159,6 +2597,16 @@ __metadata: languageName: node linkType: hard +"accepts@npm:~1.3.7, accepts@npm:~1.3.8": + version: 1.3.8 + resolution: "accepts@npm:1.3.8" + dependencies: + mime-types: ~2.1.34 + negotiator: 0.6.3 + checksum: 50c43d32e7b50285ebe84b613ee4a3aa426715a7d131b65b786e2ead0fd76b6b60091b9916d3478a75f11f162628a2139991b6c03ab3f1d9ab7c86075dc8eab4 + languageName: node + linkType: hard + "acorn-jsx@npm:^5.3.2": version: 5.3.2 resolution: "acorn-jsx@npm:5.3.2" @@ -2233,6 +2681,15 @@ __metadata: languageName: node linkType: hard +"ansi-align@npm:^3.0.0": + version: 3.0.1 + resolution: "ansi-align@npm:3.0.1" + dependencies: + string-width: ^4.1.0 + checksum: 6abfa08f2141d231c257162b15292467081fa49a208593e055c866aa0455b57f3a86b5a678c190c618faa79b4c59e254493099cb700dd9cf2293c6be2c8f5d8d + languageName: node + linkType: hard + "ansi-escapes@npm:^4.2.1": version: 4.3.2 resolution: "ansi-escapes@npm:4.3.2" @@ -2281,7 +2738,7 @@ __metadata: languageName: node linkType: hard -"anymatch@npm:^3.0.3": +"anymatch@npm:^3.0.3, anymatch@npm:~3.1.2": version: 3.1.2 resolution: "anymatch@npm:3.1.2" dependencies: @@ -2345,6 +2802,13 @@ __metadata: languageName: node linkType: hard +"array-flatten@npm:1.1.1": + version: 1.1.1 + resolution: "array-flatten@npm:1.1.1" + checksum: a9925bf3512d9dce202112965de90c222cd59a4fbfce68a0951d25d965cf44642931f40aac72309c41f12df19afa010ecadceb07cfff9ccc1621e99d89ab5f3b + languageName: node + linkType: hard + "array-ify@npm:^1.0.0": version: 1.0.0 resolution: "array-ify@npm:1.0.0" @@ -2414,6 +2878,32 @@ __metadata: languageName: node linkType: hard +"aws-sdk@npm:^2.1159.0": + version: 2.1159.0 + resolution: "aws-sdk@npm:2.1159.0" + dependencies: + buffer: 4.9.2 + events: 1.1.1 + ieee754: 1.1.13 + jmespath: 0.16.0 + querystring: 0.2.0 + sax: 1.2.1 + url: 0.10.3 + uuid: 8.0.0 + xml2js: 0.4.19 + checksum: f89d3a34830d5391e124070bda2740f512c0cc4658b8fd558114c2b9731871619a1c7ab02e21d884229b718aa535e18292e4b0c81cea6e3b0dc19f5ff0459728 + languageName: node + linkType: hard + +"axios@npm:0.24.0": + version: 0.24.0 + resolution: "axios@npm:0.24.0" + dependencies: + follow-redirects: ^1.14.4 + checksum: 468cf496c08a6aadfb7e699bebdac02851e3043d4e7d282350804ea8900e30d368daa6e3cd4ab83b8ddb5a3b1e17a5a21ada13fc9cebd27b74828f47a4236316 + languageName: node + linkType: hard + "babel-jest@npm:^28.1.1": version: 28.1.1 resolution: "babel-jest@npm:28.1.1" @@ -2504,6 +2994,13 @@ __metadata: languageName: node linkType: hard +"bcryptjs@npm:2.4.3": + version: 2.4.3 + resolution: "bcryptjs@npm:2.4.3" + checksum: 0e80ed852a41f5dfb1853f53ee14a7390b0ef263ce05dba6e2ef3cd919dfad025a7c21ebcfe5bc7fa04b100990edf90c7a877ff7fe623d3e479753253131b629 + languageName: node + linkType: hard + "before-after-hook@npm:^2.2.0": version: 2.2.2 resolution: "before-after-hook@npm:2.2.2" @@ -2511,6 +3008,13 @@ __metadata: languageName: node linkType: hard +"binary-extensions@npm:^2.0.0": + version: 2.2.0 + resolution: "binary-extensions@npm:2.2.0" + checksum: ccd267956c58d2315f5d3ea6757cf09863c5fc703e50fbeb13a7dc849b812ef76e3cf9ca8f35a0c48498776a7478d7b4a0418e1e2b8cb9cb9731f2922aaad7f8 + languageName: node + linkType: hard + "bl@npm:^4.1.0": version: 4.1.0 resolution: "bl@npm:4.1.0" @@ -2522,6 +3026,60 @@ __metadata: languageName: node linkType: hard +"body-parser@npm:1.19.0": + version: 1.19.0 + resolution: "body-parser@npm:1.19.0" + dependencies: + bytes: 3.1.0 + content-type: ~1.0.4 + debug: 2.6.9 + depd: ~1.1.2 + http-errors: 1.7.2 + iconv-lite: 0.4.24 + on-finished: ~2.3.0 + qs: 6.7.0 + raw-body: 2.4.0 + type-is: ~1.6.17 + checksum: 490231b4c89bbd43112762f7ba8e5342c174a6c9f64284a3b0fcabf63277e332f8316765596f1e5b15e4f3a6cf0422e005f4bb3149ed3a224bb025b7a36b9ac1 + languageName: node + linkType: hard + +"body-parser@npm:1.20.0": + version: 1.20.0 + resolution: "body-parser@npm:1.20.0" + dependencies: + bytes: 3.1.2 + content-type: ~1.0.4 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.10.3 + raw-body: 2.5.1 + type-is: ~1.6.18 + unpipe: 1.0.0 + checksum: 12fffdeac82fe20dddcab7074215d5156e7d02a69ae90cbe9fee1ca3efa2f28ef52097cbea76685ee0a1509c71d85abd0056a08e612c09077cad6277a644cf88 + languageName: node + linkType: hard + +"boxen@npm:^5.0.0": + version: 5.1.2 + resolution: "boxen@npm:5.1.2" + dependencies: + ansi-align: ^3.0.0 + camelcase: ^6.2.0 + chalk: ^4.1.0 + cli-boxes: ^2.2.1 + string-width: ^4.2.2 + type-fest: ^0.20.2 + widest-line: ^3.1.0 + wrap-ansi: ^7.0.0 + checksum: 82d03e42a72576ff235123f17b7c505372fe05c83f75f61e7d4fa4bcb393897ec95ce766fecb8f26b915f0f7a7227d66e5ec7cef43f5b2bd9d3aeed47ec55877 + languageName: node + linkType: hard + "brace-expansion@npm:^1.1.7": version: 1.1.11 resolution: "brace-expansion@npm:1.1.11" @@ -2541,7 +3099,7 @@ __metadata: languageName: node linkType: hard -"braces@npm:^3.0.2": +"braces@npm:^3.0.2, braces@npm:~3.0.2": version: 3.0.2 resolution: "braces@npm:3.0.2" dependencies: @@ -2644,6 +3202,20 @@ __metadata: languageName: node linkType: hard +"bytes@npm:3.1.0": + version: 3.1.0 + resolution: "bytes@npm:3.1.0" + checksum: 7c3b21c5d9d44ed455460d5d36a31abc6fa2ce3807964ba60a4b03fd44454c8cf07bb0585af83bfde1c5cc2ea4bbe5897bc3d18cd15e0acf25a3615a35aba2df + languageName: node + linkType: hard + +"bytes@npm:3.1.2": + version: 3.1.2 + resolution: "bytes@npm:3.1.2" + checksum: e4bcd3948d289c5127591fbedf10c0b639ccbf00243504e4e127374a15c3bc8eed0d28d4aaab08ff6f1cf2abc0cce6ba3085ed32f4f90e82a5683ce0014e1b6e + languageName: node + linkType: hard + "cacache@npm:^15.2.0": version: 15.3.0 resolution: "cacache@npm:15.3.0" @@ -2696,6 +3268,21 @@ __metadata: languageName: node linkType: hard +"cacheable-request@npm:^6.0.0": + version: 6.1.0 + resolution: "cacheable-request@npm:6.1.0" + dependencies: + clone-response: ^1.0.2 + get-stream: ^5.1.0 + http-cache-semantics: ^4.0.0 + keyv: ^3.0.0 + lowercase-keys: ^2.0.0 + normalize-url: ^4.1.0 + responselike: ^1.0.2 + checksum: b510b237b18d17e89942e9ee2d2a077cb38db03f12167fd100932dfa8fc963424bfae0bfa1598df4ae16c944a5484e43e03df8f32105b04395ee9495e9e4e9f1 + languageName: node + linkType: hard + "call-bind@npm:^1.0.0": version: 1.0.2 resolution: "call-bind@npm:1.0.2" @@ -2780,6 +3367,25 @@ __metadata: languageName: node linkType: hard +"chokidar@npm:^3.5.2": + version: 3.5.3 + resolution: "chokidar@npm:3.5.3" + dependencies: + anymatch: ~3.1.2 + braces: ~3.0.2 + fsevents: ~2.3.2 + glob-parent: ~5.1.2 + is-binary-path: ~2.1.0 + is-glob: ~4.0.1 + normalize-path: ~3.0.0 + readdirp: ~3.6.0 + dependenciesMeta: + fsevents: + optional: true + checksum: b49fcde40176ba007ff361b198a2d35df60d9bb2a5aab228279eb810feae9294a6b4649ab15981304447afe1e6ffbf4788ad5db77235dc770ab777c6e771980c + languageName: node + linkType: hard + "chownr@npm:^2.0.0": version: 2.0.0 resolution: "chownr@npm:2.0.0" @@ -2787,6 +3393,13 @@ __metadata: languageName: node linkType: hard +"ci-info@npm:^2.0.0": + version: 2.0.0 + resolution: "ci-info@npm:2.0.0" + checksum: 3b374666a85ea3ca43fa49aa3a048d21c9b475c96eb13c133505d2324e7ae5efd6a454f41efe46a152269e9b6a00c9edbe63ec7fa1921957165aae16625acd67 + languageName: node + linkType: hard + "ci-info@npm:^3.2.0": version: 3.3.2 resolution: "ci-info@npm:3.3.2" @@ -2808,6 +3421,13 @@ __metadata: languageName: node linkType: hard +"cli-boxes@npm:^2.2.1": + version: 2.2.1 + resolution: "cli-boxes@npm:2.2.1" + checksum: be79f8ec23a558b49e01311b39a1ea01243ecee30539c880cf14bf518a12e223ef40c57ead0cb44f509bffdffc5c129c746cd50d863ab879385370112af4f585 + languageName: node + linkType: hard + "cli-cursor@npm:^3.1.0": version: 3.1.0 resolution: "cli-cursor@npm:3.1.0" @@ -2869,6 +3489,15 @@ __metadata: languageName: node linkType: hard +"clone-response@npm:^1.0.2": + version: 1.0.2 + resolution: "clone-response@npm:1.0.2" + dependencies: + mimic-response: ^1.0.0 + checksum: 2d0e61547fc66276e0903be9654ada422515f5a15741691352000d47e8c00c226061221074ce2c0064d12e975e84a8687cfd35d8b405750cb4e772f87b256eda + languageName: node + linkType: hard + "clone@npm:^1.0.2": version: 1.0.4 resolution: "clone@npm:1.0.4" @@ -2958,6 +3587,13 @@ __metadata: languageName: node linkType: hard +"colors@npm:^1.1.2": + version: 1.4.0 + resolution: "colors@npm:1.4.0" + checksum: 98aa2c2418ad87dedf25d781be69dc5fc5908e279d9d30c34d8b702e586a0474605b3a189511482b9d5ed0d20c867515d22749537f7bc546256c6014f3ebdcec + languageName: node + linkType: hard + "colorspace@npm:1.1.x": version: 1.1.4 resolution: "colorspace@npm:1.1.4" @@ -3017,6 +3653,20 @@ __metadata: languageName: node linkType: hard +"configstore@npm:^5.0.1": + version: 5.0.1 + resolution: "configstore@npm:5.0.1" + dependencies: + dot-prop: ^5.2.0 + graceful-fs: ^4.1.2 + make-dir: ^3.0.0 + unique-string: ^2.0.0 + write-file-atomic: ^3.0.0 + xdg-basedir: ^4.0.0 + checksum: 60ef65d493b63f96e14b11ba7ec072fdbf3d40110a94fb7199d1c287761bdea5c5244e76b2596325f30c1b652213aa75de96ea20afd4a5f82065e61ea090988e + languageName: node + linkType: hard + "console-control-strings@npm:^1.1.0": version: 1.1.0 resolution: "console-control-strings@npm:1.1.0" @@ -3024,6 +3674,31 @@ __metadata: languageName: node linkType: hard +"content-disposition@npm:0.5.3": + version: 0.5.3 + resolution: "content-disposition@npm:0.5.3" + dependencies: + safe-buffer: 5.1.2 + checksum: 95bf164c0b0b8199d3f44b7631e51b37f683c6a90b9baa4315bd3d405a6d1bc81b7346f0981046aa004331fb3d7a28b629514d01fc209a5251573fc7e4d33380 + languageName: node + linkType: hard + +"content-disposition@npm:0.5.4": + version: 0.5.4 + resolution: "content-disposition@npm:0.5.4" + dependencies: + safe-buffer: 5.2.1 + checksum: afb9d545e296a5171d7574fcad634b2fdf698875f4006a9dd04a3e1333880c5c0c98d47b560d01216fb6505a54a2ba6a843ee3a02ec86d7e911e8315255f56c3 + languageName: node + linkType: hard + +"content-type@npm:~1.0.4": + version: 1.0.4 + resolution: "content-type@npm:1.0.4" + checksum: 3d93585fda985d1554eca5ebd251994327608d2e200978fdbfba21c0c679914d5faf266d17027de44b34a72c7b0745b18584ecccaa7e1fdfb6a68ac7114f12e0 + languageName: node + linkType: hard + "conventional-changelog-angular@npm:^5.0.11, conventional-changelog-angular@npm:^5.0.13": version: 5.0.13 resolution: "conventional-changelog-angular@npm:5.0.13" @@ -3146,6 +3821,34 @@ __metadata: languageName: node linkType: hard +"cookie-signature@npm:1.0.6": + version: 1.0.6 + resolution: "cookie-signature@npm:1.0.6" + checksum: f4e1b0a98a27a0e6e66fd7ea4e4e9d8e038f624058371bf4499cfcd8f3980be9a121486995202ba3fca74fbed93a407d6d54d43a43f96fd28d0bd7a06761591a + languageName: node + linkType: hard + +"cookie@npm:0.4.0": + version: 0.4.0 + resolution: "cookie@npm:0.4.0" + checksum: 760384ba0aef329c52523747e36a452b5e51bc49b34160363a6934e7b7df3f93fcc88b35e33450361535d40a92a96412da870e1816aba9aa6cc556a9fedd8492 + languageName: node + linkType: hard + +"cookie@npm:0.5.0": + version: 0.5.0 + resolution: "cookie@npm:0.5.0" + checksum: 1f4bd2ca5765f8c9689a7e8954183f5332139eb72b6ff783d8947032ec1fdf43109852c178e21a953a30c0dd42257828185be01b49d1eb1a67fd054ca588a180 + languageName: node + linkType: hard + +"cookie@npm:^0.4.1": + version: 0.4.2 + resolution: "cookie@npm:0.4.2" + checksum: a00833c998bedf8e787b4c342defe5fa419abd96b32f4464f718b91022586b8f1bafbddd499288e75c037642493c83083da426c6a9080d309e3bd90fd11baa9b + languageName: node + linkType: hard + "core-util-is@npm:~1.0.0": version: 1.0.3 resolution: "core-util-is@npm:1.0.3" @@ -3153,6 +3856,16 @@ __metadata: languageName: node linkType: hard +"cors@npm:2.8.5": + version: 2.8.5 + resolution: "cors@npm:2.8.5" + dependencies: + object-assign: ^4 + vary: ^1 + checksum: ced838404ccd184f61ab4fdc5847035b681c90db7ac17e428f3d81d69e2989d2b680cc254da0e2554f5ed4f8a341820a1ce3d1c16b499f6e2f47a1b9b07b5006 + languageName: node + linkType: hard + "cosmiconfig-typescript-loader@npm:^2.0.0": version: 2.0.1 resolution: "cosmiconfig-typescript-loader@npm:2.0.1" @@ -3198,6 +3911,22 @@ __metadata: languageName: node linkType: hard +"crypto-random-string@npm:3.3.0": + version: 3.3.0 + resolution: "crypto-random-string@npm:3.3.0" + dependencies: + type-fest: ^0.8.1 + checksum: deff9866311a3a17ffd26ecdcebbbe9e1e12cf2fca5dd6e89993c9a03342d6da83f9f82cb0bfd7b31265d45eea710f376bc2af37bf3b053ef0cade920b8b04ba + languageName: node + linkType: hard + +"crypto-random-string@npm:^2.0.0": + version: 2.0.0 + resolution: "crypto-random-string@npm:2.0.0" + checksum: 0283879f55e7c16fdceacc181f87a0a65c53bc16ffe1d58b9d19a6277adcd71900d02bb2c4843dd55e78c51e30e89b0fec618a7f170ebcc95b33182c28f05fd6 + languageName: node + linkType: hard + "dargs@npm:^7.0.0": version: 7.0.0 resolution: "dargs@npm:7.0.0" @@ -3226,6 +3955,15 @@ __metadata: languageName: node linkType: hard +"debug@npm:2.6.9": + version: 2.6.9 + resolution: "debug@npm:2.6.9" + dependencies: + ms: 2.0.0 + checksum: d2f51589ca66df60bf36e1fa6e4386b318c3f1e06772280eea5b1ae9fd3d05e9c2b7fd8a7d862457d00853c75b00451aa2d7459b924629ee385287a650f58fe6 + languageName: node + linkType: hard + "debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4": version: 4.3.4 resolution: "debug@npm:4.3.4" @@ -3238,6 +3976,15 @@ __metadata: languageName: node linkType: hard +"debug@npm:^3.2.7": + version: 3.2.7 + resolution: "debug@npm:3.2.7" + dependencies: + ms: ^2.1.1 + checksum: b3d8c5940799914d30314b7c3304a43305fd0715581a919dacb8b3176d024a782062368405b47491516d2091d6462d4d11f2f4974a405048094f8bfebfa3071c + languageName: node + linkType: hard + "decamelize-keys@npm:^1.1.0": version: 1.1.0 resolution: "decamelize-keys@npm:1.1.0" @@ -3262,6 +4009,15 @@ __metadata: languageName: node linkType: hard +"decompress-response@npm:^3.3.0": + version: 3.3.0 + resolution: "decompress-response@npm:3.3.0" + dependencies: + mimic-response: ^1.0.0 + checksum: 952552ac3bd7de2fc18015086b09468645c9638d98a551305e485230ada278c039c91116e946d07894b39ee53c0f0d5b6473f25a224029344354513b412d7380 + languageName: node + linkType: hard + "dedent@npm:^0.7.0": version: 0.7.0 resolution: "dedent@npm:0.7.0" @@ -3269,6 +4025,13 @@ __metadata: languageName: node linkType: hard +"deep-extend@npm:^0.6.0": + version: 0.6.0 + resolution: "deep-extend@npm:0.6.0" + checksum: 7be7e5a8d468d6b10e6a67c3de828f55001b6eb515d014f7aeb9066ce36bd5717161eb47d6a0f7bed8a9083935b465bc163ee2581c8b128d29bf61092fdf57a7 + languageName: node + linkType: hard + "deep-is@npm:^0.1.3": version: 0.1.4 resolution: "deep-is@npm:0.1.4" @@ -3292,6 +4055,13 @@ __metadata: languageName: node linkType: hard +"defer-to-connect@npm:^1.0.1": + version: 1.1.3 + resolution: "defer-to-connect@npm:1.1.3" + checksum: 9491b301dcfa04956f989481ba7a43c2231044206269eb4ab64a52d6639ee15b1252262a789eb4239fb46ab63e44d4e408641bae8e0793d640aee55398cb3930 + languageName: node + linkType: hard + "delegates@npm:^1.0.0": version: 1.0.0 resolution: "delegates@npm:1.0.0" @@ -3313,7 +4083,14 @@ __metadata: languageName: node linkType: hard -"depd@npm:^1.1.2": +"depd@npm:2.0.0": + version: 2.0.0 + resolution: "depd@npm:2.0.0" + checksum: abbe19c768c97ee2eed6282d8ce3031126662252c58d711f646921c9623f9052e3e1906443066beec1095832f534e57c523b7333f8e7e0d93051ab6baef5ab3a + languageName: node + linkType: hard + +"depd@npm:^1.1.2, depd@npm:~1.1.2": version: 1.1.2 resolution: "depd@npm:1.1.2" checksum: 6b406620d269619852885ce15965272b829df6f409724415e0002c8632ab6a8c0a08ec1f0bd2add05dc7bd7507606f7e2cc034fa24224ab829580040b835ecd9 @@ -3327,6 +4104,20 @@ __metadata: languageName: node linkType: hard +"destroy@npm:1.2.0": + version: 1.2.0 + resolution: "destroy@npm:1.2.0" + checksum: 0acb300b7478a08b92d810ab229d5afe0d2f4399272045ab22affa0d99dbaf12637659411530a6fcd597a9bdac718fc94373a61a95b4651bbc7b83684a565e38 + languageName: node + linkType: hard + +"destroy@npm:~1.0.4": + version: 1.0.4 + resolution: "destroy@npm:1.0.4" + checksum: da9ab4961dc61677c709da0c25ef01733042614453924d65636a7db37308fef8a24cd1e07172e61173d471ca175371295fbc984b0af5b2b4ff47cd57bd784c03 + languageName: node + linkType: hard + "detect-indent@npm:^5.0.0": version: 5.0.0 resolution: "detect-indent@npm:5.0.0" @@ -3380,7 +4171,14 @@ __metadata: languageName: node linkType: hard -"dot-prop@npm:^5.1.0": +"dompurify@npm:^2.3.6": + version: 2.3.8 + resolution: "dompurify@npm:2.3.8" + checksum: dc7b32ee57a03fe5166a850071200897cc13fa069287a709e3b2138052d73ec09a87026b9e28c8d2f254a74eaa52ef30644e98e54294c30acbca2a53f1bbc5f4 + languageName: node + linkType: hard + +"dot-prop@npm:^5.1.0, dot-prop@npm:^5.2.0": version: 5.3.0 resolution: "dot-prop@npm:5.3.0" dependencies: @@ -3403,6 +4201,13 @@ __metadata: languageName: node linkType: hard +"duplexer3@npm:^0.1.4": + version: 0.1.4 + resolution: "duplexer3@npm:0.1.4" + checksum: c2fd6969314607d23439c583699aaa43c4100d66b3e161df55dccd731acc57d5c81a64bb4f250805fbe434ddb1d2623fee2386fb890f5886ca1298690ec53415 + languageName: node + linkType: hard + "duplexer@npm:^0.1.1": version: 0.1.2 resolution: "duplexer@npm:0.1.2" @@ -3419,6 +4224,13 @@ __metadata: languageName: node linkType: hard +"ee-first@npm:1.1.1": + version: 1.1.1 + resolution: "ee-first@npm:1.1.1" + checksum: 1b4cac778d64ce3b582a7e26b218afe07e207a0f9bfe13cc7395a6d307849cfe361e65033c3251e00c27dd060cab43014c2d6b2647676135e18b77d2d05b3f4f + languageName: node + linkType: hard + "electron-to-chromium@npm:^1.4.147": version: 1.4.161 resolution: "electron-to-chromium@npm:1.4.161" @@ -3447,6 +4259,13 @@ __metadata: languageName: node linkType: hard +"encodeurl@npm:~1.0.2": + version: 1.0.2 + resolution: "encodeurl@npm:1.0.2" + checksum: e50e3d508cdd9c4565ba72d2012e65038e5d71bdc9198cb125beb6237b5b1ade6c0d343998da9e170fb2eae52c1bed37d4d6d98a46ea423a0cddbed5ac3f780c + languageName: node + linkType: hard + "encoding@npm:^0.1.12, encoding@npm:^0.1.13": version: 0.1.13 resolution: "encoding@npm:0.1.13" @@ -3456,6 +4275,15 @@ __metadata: languageName: node linkType: hard +"end-of-stream@npm:^1.1.0": + version: 1.4.4 + resolution: "end-of-stream@npm:1.4.4" + dependencies: + once: ^1.4.0 + checksum: 530a5a5a1e517e962854a31693dbb5c0b2fc40b46dad2a56a2deec656ca040631124f4795823acc68238147805f8b021abbe221f4afed5ef3c8e8efc2024908b + languageName: node + linkType: hard + "env-paths@npm:^2.2.0": version: 2.2.1 resolution: "env-paths@npm:2.2.1" @@ -3495,6 +4323,20 @@ __metadata: languageName: node linkType: hard +"escape-goat@npm:^2.0.0": + version: 2.1.1 + resolution: "escape-goat@npm:2.1.1" + checksum: ce05c70c20dd7007b60d2d644b625da5412325fdb57acf671ba06cb2ab3cd6789e2087026921a05b665b0a03fadee2955e7fc0b9a67da15a6551a980b260eba7 + languageName: node + linkType: hard + +"escape-html@npm:~1.0.3": + version: 1.0.3 + resolution: "escape-html@npm:1.0.3" + checksum: 6213ca9ae00d0ab8bccb6d8d4e0a98e76237b2410302cf7df70aaa6591d509a2a37ce8998008cbecae8fc8ffaadf3fb0229535e6a145f3ce0b211d060decbb24 + languageName: node + linkType: hard + "escape-string-regexp@npm:^1.0.5": version: 1.0.5 resolution: "escape-string-regexp@npm:1.0.5" @@ -3587,7 +4429,7 @@ __metadata: languageName: node linkType: hard -"eslint@npm:^8.17.0": +"eslint@npm:^8.14.0, eslint@npm:^8.17.0": version: 8.18.0 resolution: "eslint@npm:8.18.0" dependencies: @@ -3692,6 +4534,13 @@ __metadata: languageName: node linkType: hard +"etag@npm:~1.8.1": + version: 1.8.1 + resolution: "etag@npm:1.8.1" + checksum: 571aeb3dbe0f2bbd4e4fadbdb44f325fc75335cd5f6f6b6a091e6a06a9f25ed5392f0863c5442acb0646787446e816f13cbfc6edce5b07658541dff573cab1ff + languageName: node + linkType: hard + "eventemitter3@npm:^4.0.4": version: 4.0.7 resolution: "eventemitter3@npm:4.0.7" @@ -3743,6 +4592,83 @@ __metadata: languageName: node linkType: hard +"express@npm:4.17.1": + version: 4.17.1 + resolution: "express@npm:4.17.1" + dependencies: + accepts: ~1.3.7 + array-flatten: 1.1.1 + body-parser: 1.19.0 + content-disposition: 0.5.3 + content-type: ~1.0.4 + cookie: 0.4.0 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: ~1.1.2 + encodeurl: ~1.0.2 + escape-html: ~1.0.3 + etag: ~1.8.1 + finalhandler: ~1.1.2 + fresh: 0.5.2 + merge-descriptors: 1.0.1 + methods: ~1.1.2 + on-finished: ~2.3.0 + parseurl: ~1.3.3 + path-to-regexp: 0.1.7 + proxy-addr: ~2.0.5 + qs: 6.7.0 + range-parser: ~1.2.1 + safe-buffer: 5.1.2 + send: 0.17.1 + serve-static: 1.14.1 + setprototypeof: 1.1.1 + statuses: ~1.5.0 + type-is: ~1.6.18 + utils-merge: 1.0.1 + vary: ~1.1.2 + checksum: d964e9e17af331ea6fa2f84999b063bc47189dd71b4a735df83f9126d3bb2b92e830f1cb1d7c2742530eb625e2689d7a9a9c71f0c3cc4dd6015c3cd32a01abd5 + languageName: node + linkType: hard + +"express@npm:^4.17.1": + version: 4.18.1 + resolution: "express@npm:4.18.1" + dependencies: + accepts: ~1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.0 + content-disposition: 0.5.4 + content-type: ~1.0.4 + cookie: 0.5.0 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: ~1.0.2 + escape-html: ~1.0.3 + etag: ~1.8.1 + finalhandler: 1.2.0 + fresh: 0.5.2 + http-errors: 2.0.0 + merge-descriptors: 1.0.1 + methods: ~1.1.2 + on-finished: 2.4.1 + parseurl: ~1.3.3 + path-to-regexp: 0.1.7 + proxy-addr: ~2.0.7 + qs: 6.10.3 + range-parser: ~1.2.1 + safe-buffer: 5.2.1 + send: 0.18.0 + serve-static: 1.15.0 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: ~1.6.18 + utils-merge: 1.0.1 + vary: ~1.1.2 + checksum: c3d44c92e48226ef32ec978becfedb0ecf0ca21316bfd33674b3c5d20459840584f2325726a4f17f33d9c99f769636f728982d1c5433a5b6fe6eb95b8cf0c854 + languageName: node + linkType: hard + "external-editor@npm:^3.0.3": version: 3.1.0 resolution: "external-editor@npm:3.1.0" @@ -3854,6 +4780,36 @@ __metadata: languageName: node linkType: hard +"finalhandler@npm:1.2.0": + version: 1.2.0 + resolution: "finalhandler@npm:1.2.0" + dependencies: + debug: 2.6.9 + encodeurl: ~1.0.2 + escape-html: ~1.0.3 + on-finished: 2.4.1 + parseurl: ~1.3.3 + statuses: 2.0.1 + unpipe: ~1.0.0 + checksum: 92effbfd32e22a7dff2994acedbd9bcc3aa646a3e919ea6a53238090e87097f8ef07cced90aa2cc421abdf993aefbdd5b00104d55c7c5479a8d00ed105b45716 + languageName: node + linkType: hard + +"finalhandler@npm:~1.1.2": + version: 1.1.2 + resolution: "finalhandler@npm:1.1.2" + dependencies: + debug: 2.6.9 + encodeurl: ~1.0.2 + escape-html: ~1.0.3 + on-finished: ~2.3.0 + parseurl: ~1.3.3 + statuses: ~1.5.0 + unpipe: ~1.0.0 + checksum: 617880460c5138dd7ccfd555cb5dde4d8f170f4b31b8bd51e4b646bb2946c30f7db716428a1f2882d730d2b72afb47d1f67cc487b874cb15426f95753a88965e + languageName: node + linkType: hard + "find-up@npm:^2.0.0": version: 2.1.0 resolution: "find-up@npm:2.1.0" @@ -3907,6 +4863,30 @@ __metadata: languageName: node linkType: hard +"follow-redirects@npm:^1.14.4": + version: 1.15.1 + resolution: "follow-redirects@npm:1.15.1" + peerDependenciesMeta: + debug: + optional: true + checksum: 6aa4e3e3cdfa3b9314801a1cd192ba756a53479d9d8cca65bf4db3a3e8834e62139245cd2f9566147c8dfe2efff1700d3e6aefd103de4004a7b99985e71dd533 + languageName: node + linkType: hard + +"forwarded@npm:0.2.0": + version: 0.2.0 + resolution: "forwarded@npm:0.2.0" + checksum: fd27e2394d8887ebd16a66ffc889dc983fbbd797d5d3f01087c020283c0f019a7d05ee85669383d8e0d216b116d720fc0cef2f6e9b7eb9f4c90c6e0bc7fd28e6 + languageName: node + linkType: hard + +"fresh@npm:0.5.2": + version: 0.5.2 + resolution: "fresh@npm:0.5.2" + checksum: 13ea8b08f91e669a64e3ba3a20eb79d7ca5379a81f1ff7f4310d54e2320645503cc0c78daedc93dfb6191287295f6479544a649c64d8e41a1c0fb0c221552346 + languageName: node + linkType: hard + "fs-extra@npm:^10.0.0, fs-extra@npm:^10.1.0": version: 10.1.0 resolution: "fs-extra@npm:10.1.0" @@ -3934,7 +4914,7 @@ __metadata: languageName: node linkType: hard -"fsevents@npm:^2.3.2": +"fsevents@npm:^2.3.2, fsevents@npm:~2.3.2": version: 2.3.2 resolution: "fsevents@npm:2.3.2" dependencies: @@ -3944,7 +4924,7 @@ __metadata: languageName: node linkType: hard -"fsevents@patch:fsevents@^2.3.2#~builtin": +"fsevents@patch:fsevents@^2.3.2#~builtin, fsevents@patch:fsevents@~2.3.2#~builtin": version: 2.3.2 resolution: "fsevents@patch:fsevents@npm%3A2.3.2#~builtin::version=2.3.2&hash=18f3a7" dependencies: @@ -4038,6 +5018,24 @@ __metadata: languageName: node linkType: hard +"get-stream@npm:^4.1.0": + version: 4.1.0 + resolution: "get-stream@npm:4.1.0" + dependencies: + pump: ^3.0.0 + checksum: 443e1914170c15bd52ff8ea6eff6dfc6d712b031303e36302d2778e3de2506af9ee964d6124010f7818736dcfde05c04ba7ca6cc26883106e084357a17ae7d73 + languageName: node + linkType: hard + +"get-stream@npm:^5.1.0": + version: 5.2.0 + resolution: "get-stream@npm:5.2.0" + dependencies: + pump: ^3.0.0 + checksum: 8bc1a23174a06b2b4ce600df38d6c98d2ef6d84e020c1ddad632ad75bac4e092eeb40e4c09e0761c35fc2dbc5e7fff5dab5e763a383582c4a167dd69a905bd12 + languageName: node + linkType: hard + "get-stream@npm:^6.0.0, get-stream@npm:^6.0.1": version: 6.0.1 resolution: "get-stream@npm:6.0.1" @@ -4110,7 +5108,7 @@ __metadata: languageName: node linkType: hard -"glob-parent@npm:^5.1.2": +"glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2": version: 5.1.2 resolution: "glob-parent@npm:5.1.2" dependencies: @@ -4164,6 +5162,15 @@ __metadata: languageName: node linkType: hard +"global-dirs@npm:^3.0.0": + version: 3.0.0 + resolution: "global-dirs@npm:3.0.0" + dependencies: + ini: 2.0.0 + checksum: 953c17cf14bf6ee0e2100ae82a0d779934eed8a3ec5c94a7a4f37c5b3b592c31ea015fb9a15cf32484de13c79f4a814f3015152f3e1d65976cfbe47c1bfe4a88 + languageName: node + linkType: hard + "globals@npm:^11.1.0": version: 11.12.0 resolution: "globals@npm:11.12.0" @@ -4194,6 +5201,25 @@ __metadata: languageName: node linkType: hard +"got@npm:^9.6.0": + version: 9.6.0 + resolution: "got@npm:9.6.0" + dependencies: + "@sindresorhus/is": ^0.14.0 + "@szmarczak/http-timer": ^1.1.2 + cacheable-request: ^6.0.0 + decompress-response: ^3.3.0 + duplexer3: ^0.1.4 + get-stream: ^4.1.0 + lowercase-keys: ^1.0.1 + mimic-response: ^1.0.1 + p-cancelable: ^1.0.0 + to-readable-stream: ^1.0.0 + url-parse-lax: ^3.0.0 + checksum: 941807bd9704bacf5eb401f0cc1212ffa1f67c6642f2d028fd75900471c221b1da2b8527f4553d2558f3faeda62ea1cf31665f8b002c6137f5de8732f07370b0 + languageName: node + linkType: hard + "graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.15, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.10, graceful-fs@npm:^4.2.6, graceful-fs@npm:^4.2.9": version: 4.2.10 resolution: "graceful-fs@npm:4.2.10" @@ -4254,6 +5280,13 @@ __metadata: languageName: node linkType: hard +"has-yarn@npm:^2.1.0": + version: 2.1.0 + resolution: "has-yarn@npm:2.1.0" + checksum: 5eb1d0bb8518103d7da24532bdbc7124ffc6d367b5d3c10840b508116f2f1bcbcf10fd3ba843ff6e2e991bdf9969fd862d42b2ed58aade88343326c950b7e7f7 + languageName: node + linkType: hard + "has@npm:^1.0.3": version: 1.0.3 resolution: "has@npm:1.0.3" @@ -4302,13 +5335,52 @@ __metadata: languageName: node linkType: hard -"http-cache-semantics@npm:^4.1.0": +"http-cache-semantics@npm:^4.0.0, http-cache-semantics@npm:^4.1.0": version: 4.1.0 resolution: "http-cache-semantics@npm:4.1.0" checksum: 974de94a81c5474be07f269f9fd8383e92ebb5a448208223bfb39e172a9dbc26feff250192ecc23b9593b3f92098e010406b0f24bd4d588d631f80214648ed42 languageName: node linkType: hard +"http-errors@npm:1.7.2": + version: 1.7.2 + resolution: "http-errors@npm:1.7.2" + dependencies: + depd: ~1.1.2 + inherits: 2.0.3 + setprototypeof: 1.1.1 + statuses: ">= 1.5.0 < 2" + toidentifier: 1.0.0 + checksum: 5534b0ae08e77f5a45a2380f500e781f6580c4ff75b816cb1f09f99a290b57e78a518be6d866db1b48cca6b052c09da2c75fc91fb16a2fe3da3c44d9acbb9972 + languageName: node + linkType: hard + +"http-errors@npm:2.0.0": + version: 2.0.0 + resolution: "http-errors@npm:2.0.0" + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + checksum: 9b0a3782665c52ce9dc658a0d1560bcb0214ba5699e4ea15aefb2a496e2ca83db03ebc42e1cce4ac1f413e4e0d2d736a3fd755772c556a9a06853ba2a0b7d920 + languageName: node + linkType: hard + +"http-errors@npm:~1.7.2": + version: 1.7.3 + resolution: "http-errors@npm:1.7.3" + dependencies: + depd: ~1.1.2 + inherits: 2.0.4 + setprototypeof: 1.1.1 + statuses: ">= 1.5.0 < 2" + toidentifier: 1.0.0 + checksum: a59f359473f4b3ea78305beee90d186268d6075432622a46fb7483059068a2dd4c854a20ac8cd438883127e06afb78c1309168bde6cdfeed1e3700eb42487d99 + languageName: node + linkType: hard + "http-proxy-agent@npm:^4.0.1": version: 4.0.1 resolution: "http-proxy-agent@npm:4.0.1" @@ -4331,6 +5403,13 @@ __metadata: languageName: node linkType: hard +"http-status-codes@npm:^2.1.4": + version: 2.2.0 + resolution: "http-status-codes@npm:2.2.0" + checksum: 31e1d730856210445da0907d9b484629e69e4fe92ac032478a7aa4d89e5b215e2b4e75d7ebce40d0537b6850bd281b2f65c7cc36cc2677e5de056d6cea1045ce + languageName: node + linkType: hard + "https-proxy-agent@npm:^5.0.0": version: 5.0.1 resolution: "https-proxy-agent@npm:5.0.1" @@ -4357,7 +5436,7 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:^0.4.24": +"iconv-lite@npm:0.4.24, iconv-lite@npm:^0.4.24": version: 0.4.24 resolution: "iconv-lite@npm:0.4.24" dependencies: @@ -4389,6 +5468,13 @@ __metadata: languageName: node linkType: hard +"ignore-by-default@npm:^1.0.1": + version: 1.0.1 + resolution: "ignore-by-default@npm:1.0.1" + checksum: 441509147b3615e0365e407a3c18e189f78c07af08564176c680be1fabc94b6c789cad1342ad887175d4ecd5225de86f73d376cec8e06b42fd9b429505ffcf8a + languageName: node + linkType: hard + "ignore-walk@npm:^5.0.1": version: 5.0.1 resolution: "ignore-walk@npm:5.0.1" @@ -4415,6 +5501,13 @@ __metadata: languageName: node linkType: hard +"import-lazy@npm:^2.1.0": + version: 2.1.0 + resolution: "import-lazy@npm:2.1.0" + checksum: 05294f3b9dd4971d3a996f0d2f176410fb6745d491d6e73376429189f5c1c3d290548116b2960a7cf3e89c20cdf11431739d1d2d8c54b84061980795010e803a + languageName: node + linkType: hard + "import-local@npm:^3.0.2, import-local@npm:^3.1.0": version: 3.1.0 resolution: "import-local@npm:3.1.0" @@ -4458,7 +5551,7 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3": +"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 4a48a733847879d6cf6691860a6b1e3f0f4754176e4d71494c41f3475553768b10f84b5ce1d40fbd0e34e6bfbb864ee35858ad4dd2cf31e02fc4a154b724d7f1 @@ -4472,7 +5565,14 @@ __metadata: languageName: node linkType: hard -"ini@npm:^1.3.2, ini@npm:^1.3.4": +"ini@npm:2.0.0": + version: 2.0.0 + resolution: "ini@npm:2.0.0" + checksum: e7aadc5fb2e4aefc666d74ee2160c073995a4061556b1b5b4241ecb19ad609243b9cceafe91bae49c219519394bbd31512516cb22a3b1ca6e66d869e0447e84e + languageName: node + linkType: hard + +"ini@npm:^1.3.2, ini@npm:^1.3.4, ini@npm:~1.3.0": version: 1.3.8 resolution: "ini@npm:1.3.8" checksum: dfd98b0ca3a4fc1e323e38a6c8eb8936e31a97a918d3b377649ea15bdb15d481207a0dda1021efbd86b464cae29a0d33c1d7dcaf6c5672bee17fa849bc50a1b3 @@ -4502,6 +5602,17 @@ __metadata: languageName: node linkType: hard +"inversify-express-utils@npm:^6.4.3": + version: 6.4.3 + resolution: "inversify-express-utils@npm:6.4.3" + dependencies: + express: ^4.17.1 + http-status-codes: ^2.1.4 + inversify: ^6.0.1 + checksum: 4aa9a836fee66d810ad77c5f3fc400954f601ea151d479bd512eabe2c04b0004f5ef4af317710c1b06452bb448451745a2a98ccb1fdcfac97d488f0aa985167c + languageName: node + linkType: hard + "inversify@npm:5.0.5": version: 5.0.5 resolution: "inversify@npm:5.0.5" @@ -4509,6 +5620,13 @@ __metadata: languageName: node linkType: hard +"inversify@npm:^6.0.1": + version: 6.0.1 + resolution: "inversify@npm:6.0.1" + checksum: b6c9b56ef7817a71534b06101c2b0a0130b67c47a769f58794d9c0643591a83ac02be30a48c00cb729c0c83ece96dde369c419d48c32d0201c6cc7407629fbc5 + languageName: node + linkType: hard + "ioredis@npm:^4.28.5": version: 4.28.5 resolution: "ioredis@npm:4.28.5" @@ -4552,6 +5670,13 @@ __metadata: languageName: node linkType: hard +"ipaddr.js@npm:1.9.1": + version: 1.9.1 + resolution: "ipaddr.js@npm:1.9.1" + checksum: f88d3825981486f5a1942414c8d77dd6674dd71c065adcfa46f578d677edcb99fda25af42675cb59db492fdf427b34a5abfcde3982da11a8fd83a500b41cfe77 + languageName: node + linkType: hard + "is-arrayish@npm:^0.2.1": version: 0.2.1 resolution: "is-arrayish@npm:0.2.1" @@ -4566,6 +5691,26 @@ __metadata: languageName: node linkType: hard +"is-binary-path@npm:~2.1.0": + version: 2.1.0 + resolution: "is-binary-path@npm:2.1.0" + dependencies: + binary-extensions: ^2.0.0 + checksum: 84192eb88cff70d320426f35ecd63c3d6d495da9d805b19bc65b518984b7c0760280e57dbf119b7e9be6b161784a5a673ab2c6abe83abb5198a432232ad5b35c + languageName: node + linkType: hard + +"is-ci@npm:^2.0.0": + version: 2.0.0 + resolution: "is-ci@npm:2.0.0" + dependencies: + ci-info: ^2.0.0 + bin: + is-ci: bin.js + checksum: 77b869057510f3efa439bbb36e9be429d53b3f51abd4776eeea79ab3b221337fe1753d1e50058a9e2c650d38246108beffb15ccfd443929d77748d8c0cc90144 + languageName: node + linkType: hard + "is-ci@npm:^3.0.1": version: 3.0.1 resolution: "is-ci@npm:3.0.1" @@ -4607,7 +5752,7 @@ __metadata: languageName: node linkType: hard -"is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:^4.0.3": +"is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:^4.0.3, is-glob@npm:~4.0.1": version: 4.0.3 resolution: "is-glob@npm:4.0.3" dependencies: @@ -4616,6 +5761,16 @@ __metadata: languageName: node linkType: hard +"is-installed-globally@npm:^0.4.0": + version: 0.4.0 + resolution: "is-installed-globally@npm:0.4.0" + dependencies: + global-dirs: ^3.0.0 + is-path-inside: ^3.0.2 + checksum: 3359840d5982d22e9b350034237b2cda2a12bac1b48a721912e1ab8e0631dd07d45a2797a120b7b87552759a65ba03e819f1bd63f2d7ab8657ec0b44ee0bf399 + languageName: node + linkType: hard + "is-interactive@npm:^1.0.0": version: 1.0.0 resolution: "is-interactive@npm:1.0.0" @@ -4630,6 +5785,13 @@ __metadata: languageName: node linkType: hard +"is-npm@npm:^5.0.0": + version: 5.0.0 + resolution: "is-npm@npm:5.0.0" + checksum: 9baff02b0c69a3d3c79b162cb2f9e67fb40ef6d172c16601b2e2471c21e9a4fa1fc9885a308d7bc6f3a3cd2a324c27fa0bf284c133c3349bb22571ab70d041cc + languageName: node + linkType: hard + "is-number@npm:^7.0.0": version: 7.0.0 resolution: "is-number@npm:7.0.0" @@ -4644,6 +5806,13 @@ __metadata: languageName: node linkType: hard +"is-path-inside@npm:^3.0.2": + version: 3.0.3 + resolution: "is-path-inside@npm:3.0.3" + checksum: abd50f06186a052b349c15e55b182326f1936c89a78bf6c8f2b707412517c097ce04bc49a0ca221787bc44e1049f51f09a2ffb63d22899051988d3a618ba13e9 + languageName: node + linkType: hard + "is-plain-obj@npm:^1.0.0, is-plain-obj@npm:^1.1.0": version: 1.1.0 resolution: "is-plain-obj@npm:1.1.0" @@ -4720,6 +5889,13 @@ __metadata: languageName: node linkType: hard +"is-yarn-global@npm:^0.3.0": + version: 0.3.0 + resolution: "is-yarn-global@npm:0.3.0" + checksum: bca013d65fee2862024c9fbb3ba13720ffca2fe750095174c1c80922fdda16402b5c233f5ac9e265bc12ecb5446e7b7f519a32d9541788f01d4d44e24d2bf481 + languageName: node + linkType: hard + "isarray@npm:^1.0.0, isarray@npm:~1.0.0": version: 1.0.0 resolution: "isarray@npm:1.0.0" @@ -5278,6 +6454,13 @@ __metadata: languageName: node linkType: hard +"json-buffer@npm:3.0.0": + version: 3.0.0 + resolution: "json-buffer@npm:3.0.0" + checksum: 0cecacb8025370686a916069a2ff81f7d55167421b6aa7270ee74e244012650dd6bce22b0852202ea7ff8624fce50ff0ec1bdf95914ccb4553426e290d5a63fa + languageName: node + linkType: hard + "json-parse-better-errors@npm:^1.0.1": version: 1.0.2 resolution: "json-parse-better-errors@npm:1.0.2" @@ -5381,6 +6564,15 @@ __metadata: languageName: node linkType: hard +"keyv@npm:^3.0.0": + version: 3.1.0 + resolution: "keyv@npm:3.1.0" + dependencies: + json-buffer: 3.0.0 + checksum: bb7e8f3acffdbafbc2dd5b63f377fe6ec4c0e2c44fc82720449ef8ab54f4a7ce3802671ed94c0f475ae0a8549703353a2124561fcf3317010c141b32ca1ce903 + languageName: node + linkType: hard + "kind-of@npm:^6.0.2, kind-of@npm:^6.0.3": version: 6.0.3 resolution: "kind-of@npm:6.0.3" @@ -5402,6 +6594,15 @@ __metadata: languageName: node linkType: hard +"latest-version@npm:^5.1.0": + version: 5.1.0 + resolution: "latest-version@npm:5.1.0" + dependencies: + package-json: ^6.3.0 + checksum: fbc72b071eb66c40f652441fd783a9cca62f08bf42433651937f078cd9ef94bf728ec7743992777826e4e89305aef24f234b515e6030503a2cbee7fc9bdc2c0f + languageName: node + linkType: hard + "leven@npm:^3.1.0": version: 3.1.0 resolution: "leven@npm:3.1.0" @@ -5638,6 +6839,20 @@ __metadata: languageName: node linkType: hard +"lowercase-keys@npm:^1.0.0, lowercase-keys@npm:^1.0.1": + version: 1.0.1 + resolution: "lowercase-keys@npm:1.0.1" + checksum: 4d045026595936e09953e3867722e309415ff2c80d7701d067546d75ef698dac218a4f53c6d1d0e7368b47e45fd7529df47e6cb56fbb90523ba599f898b3d147 + languageName: node + linkType: hard + +"lowercase-keys@npm:^2.0.0": + version: 2.0.0 + resolution: "lowercase-keys@npm:2.0.0" + checksum: 24d7ebd56ccdf15ff529ca9e08863f3c54b0b9d1edb97a3ae1af34940ae666c01a1e6d200707bce730a8ef76cb57cc10e65f245ecaaf7e6bc8639f2fb460ac23 + languageName: node + linkType: hard + "lru-cache@npm:^4.1.3": version: 4.1.5 resolution: "lru-cache@npm:4.1.5" @@ -5664,6 +6879,13 @@ __metadata: languageName: node linkType: hard +"lru_map@npm:^0.3.3": + version: 0.3.3 + resolution: "lru_map@npm:0.3.3" + checksum: ca9dd43c65ed7a4f117c548028101c5b6855e10923ea9d1f635af53ad20c5868ff428c364d454a7b57fe391b89c704982275410c3c5099cca5aeee00d76e169a + languageName: node + linkType: hard + "make-dir@npm:^2.1.0": version: 2.1.0 resolution: "make-dir@npm:2.1.0" @@ -5761,6 +6983,13 @@ __metadata: languageName: node linkType: hard +"media-typer@npm:0.3.0": + version: 0.3.0 + resolution: "media-typer@npm:0.3.0" + checksum: af1b38516c28ec95d6b0826f6c8f276c58aec391f76be42aa07646b4e39d317723e869700933ca6995b056db4b09a78c92d5440dc23657e6764be5d28874bba1 + languageName: node + linkType: hard + "meow@npm:^8.0.0": version: 8.1.2 resolution: "meow@npm:8.1.2" @@ -5780,6 +7009,13 @@ __metadata: languageName: node linkType: hard +"merge-descriptors@npm:1.0.1": + version: 1.0.1 + resolution: "merge-descriptors@npm:1.0.1" + checksum: 5abc259d2ae25bb06d19ce2b94a21632583c74e2a9109ee1ba7fd147aa7362b380d971e0251069f8b3eb7d48c21ac839e21fa177b335e82c76ec172e30c31a26 + languageName: node + linkType: hard + "merge-stream@npm:^2.0.0": version: 2.0.0 resolution: "merge-stream@npm:2.0.0" @@ -5794,6 +7030,13 @@ __metadata: languageName: node linkType: hard +"methods@npm:~1.1.2": + version: 1.1.2 + resolution: "methods@npm:1.1.2" + checksum: 0917ff4041fa8e2f2fda5425a955fe16ca411591fbd123c0d722fcf02b73971ed6f764d85f0a6f547ce49ee0221ce2c19a5fa692157931cecb422984f1dcd13a + languageName: node + linkType: hard + "micromatch@npm:^4.0.4": version: 4.0.5 resolution: "micromatch@npm:4.0.5" @@ -5815,6 +7058,31 @@ __metadata: languageName: node linkType: hard +"mime-db@npm:1.52.0": + version: 1.52.0 + resolution: "mime-db@npm:1.52.0" + checksum: 0d99a03585f8b39d68182803b12ac601d9c01abfa28ec56204fa330bc9f3d1c5e14beb049bafadb3dbdf646dfb94b87e24d4ec7b31b7279ef906a8ea9b6a513f + languageName: node + linkType: hard + +"mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": + version: 2.1.35 + resolution: "mime-types@npm:2.1.35" + dependencies: + mime-db: 1.52.0 + checksum: 89a5b7f1def9f3af5dad6496c5ed50191ae4331cc5389d7c521c8ad28d5fdad2d06fd81baf38fed813dc4e46bb55c8145bb0ff406330818c9cf712fb2e9b3836 + languageName: node + linkType: hard + +"mime@npm:1.6.0": + version: 1.6.0 + resolution: "mime@npm:1.6.0" + bin: + mime: cli.js + checksum: fef25e39263e6d207580bdc629f8872a3f9772c923c7f8c7e793175cee22777bbe8bba95e5d509a40aaa292d8974514ce634ae35769faa45f22d17edda5e8557 + languageName: node + linkType: hard + "mimic-fn@npm:^2.1.0": version: 2.1.0 resolution: "mimic-fn@npm:2.1.0" @@ -5822,6 +7090,13 @@ __metadata: languageName: node linkType: hard +"mimic-response@npm:^1.0.0, mimic-response@npm:^1.0.1": + version: 1.0.1 + resolution: "mimic-response@npm:1.0.1" + checksum: 034c78753b0e622bc03c983663b1cdf66d03861050e0c8606563d149bc2b02d63f62ce4d32be4ab50d0553ae0ffe647fc34d1f5281184c6e1e8cf4d85e8d9823 + languageName: node + linkType: hard + "min-indent@npm:^1.0.0": version: 1.0.1 resolution: "min-indent@npm:1.0.1" @@ -5976,6 +7251,20 @@ __metadata: languageName: node linkType: hard +"ms@npm:2.0.0": + version: 2.0.0 + resolution: "ms@npm:2.0.0" + checksum: 0e6a22b8b746d2e0b65a430519934fefd41b6db0682e3477c10f60c76e947c4c0ad06f63ffdf1d78d335f83edee8c0aa928aa66a36c7cd95b69b26f468d527f4 + languageName: node + linkType: hard + +"ms@npm:2.1.1": + version: 2.1.1 + resolution: "ms@npm:2.1.1" + checksum: 0078a23cd916a9a7435c413caa14c57d4b4f6e2470e0ab554b6964163c8a4436448ac7ae020e883685475da6b6796cc396b670f579cb275db288a21e3e57721e + languageName: node + linkType: hard + "ms@npm:2.1.2": version: 2.1.2 resolution: "ms@npm:2.1.2" @@ -5983,7 +7272,7 @@ __metadata: languageName: node linkType: hard -"ms@npm:^2.0.0, ms@npm:^2.1.1": +"ms@npm:2.1.3, ms@npm:^2.0.0, ms@npm:^2.1.1": version: 2.1.3 resolution: "ms@npm:2.1.3" checksum: aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d @@ -6062,7 +7351,7 @@ __metadata: languageName: node linkType: hard -"negotiator@npm:^0.6.2, negotiator@npm:^0.6.3": +"negotiator@npm:0.6.3, negotiator@npm:^0.6.2, negotiator@npm:^0.6.3": version: 0.6.3 resolution: "negotiator@npm:0.6.3" checksum: b8ffeb1e262eff7968fc90a2b6767b04cfd9842582a9d0ece0af7049537266e7b2506dfb1d107a32f06dd849ab2aea834d5830f7f4d0e5cb7d36e1ae55d021d9 @@ -6217,6 +7506,26 @@ __metadata: languageName: node linkType: hard +"nodemon@npm:^2.0.16": + version: 2.0.16 + resolution: "nodemon@npm:2.0.16" + dependencies: + chokidar: ^3.5.2 + debug: ^3.2.7 + ignore-by-default: ^1.0.1 + minimatch: ^3.0.4 + pstree.remy: ^1.1.8 + semver: ^5.7.1 + supports-color: ^5.5.0 + touch: ^3.1.0 + undefsafe: ^2.0.5 + update-notifier: ^5.1.0 + bin: + nodemon: bin/nodemon.js + checksum: ff818aa91b283bd0f1f6cfb8fdc9b5c3e74d5efa0cb72276dc242b742dc35d3f0720d35d99dbe85ecf6df7185f083ef9b1f7908094e43fd4e2508b6805d644dc + languageName: node + linkType: hard + "nopt@npm:^5.0.0": version: 5.0.0 resolution: "nopt@npm:5.0.0" @@ -6228,6 +7537,17 @@ __metadata: languageName: node linkType: hard +"nopt@npm:~1.0.10": + version: 1.0.10 + resolution: "nopt@npm:1.0.10" + dependencies: + abbrev: 1 + bin: + nopt: ./bin/nopt.js + checksum: f62575aceaa3be43f365bf37a596b89bbac2e796b001b6d2e2a85c2140a4e378ff919e2753ccba959c4fd344776fc88c29b393bc167fa939fb1513f126f4cd45 + languageName: node + linkType: hard + "normalize-package-data@npm:^2.3.2, normalize-package-data@npm:^2.5.0": version: 2.5.0 resolution: "normalize-package-data@npm:2.5.0" @@ -6264,13 +7584,20 @@ __metadata: languageName: node linkType: hard -"normalize-path@npm:^3.0.0": +"normalize-path@npm:^3.0.0, normalize-path@npm:~3.0.0": version: 3.0.0 resolution: "normalize-path@npm:3.0.0" checksum: 88eeb4da891e10b1318c4b2476b6e2ecbeb5ff97d946815ffea7794c31a89017c70d7f34b3c2ebf23ef4e9fc9fb99f7dffe36da22011b5b5c6ffa34f4873ec20 languageName: node linkType: hard +"normalize-url@npm:^4.1.0": + version: 4.5.1 + resolution: "normalize-url@npm:4.5.1" + checksum: 9a9dee01df02ad23e171171893e56e22d752f7cff86fb96aafeae074819b572ea655b60f8302e2d85dbb834dc885c972cc1c573892fea24df46b2765065dd05a + languageName: node + linkType: hard + "normalize-url@npm:^6.1.0": version: 6.1.0 resolution: "normalize-url@npm:6.1.0" @@ -6376,7 +7703,7 @@ __metadata: languageName: node linkType: hard -"object-assign@npm:^4.0.1": +"object-assign@npm:^4, object-assign@npm:^4.0.1": version: 4.1.1 resolution: "object-assign@npm:4.1.1" checksum: fcc6e4ea8c7fe48abfbb552578b1c53e0d194086e2e6bbbf59e0a536381a292f39943c6e9628af05b5528aa5e3318bb30d6b2e53cadaf5b8fe9e12c4b69af23f @@ -6390,7 +7717,25 @@ __metadata: languageName: node linkType: hard -"once@npm:^1.3.0, once@npm:^1.4.0": +"on-finished@npm:2.4.1": + version: 2.4.1 + resolution: "on-finished@npm:2.4.1" + dependencies: + ee-first: 1.1.1 + checksum: d20929a25e7f0bb62f937a425b5edeb4e4cde0540d77ba146ec9357f00b0d497cdb3b9b05b9c8e46222407d1548d08166bff69cc56dfa55ba0e4469228920ff0 + languageName: node + linkType: hard + +"on-finished@npm:~2.3.0": + version: 2.3.0 + resolution: "on-finished@npm:2.3.0" + dependencies: + ee-first: 1.1.1 + checksum: 1db595bd963b0124d6fa261d18320422407b8f01dc65863840f3ddaaf7bcad5b28ff6847286703ca53f4ec19595bd67a2f1253db79fc4094911ec6aa8df1671b + languageName: node + linkType: hard + +"once@npm:^1.3.0, once@npm:^1.3.1, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" dependencies: @@ -6462,6 +7807,24 @@ __metadata: languageName: node linkType: hard +"otplib@npm:*, otplib@npm:12.0.1": + version: 12.0.1 + resolution: "otplib@npm:12.0.1" + dependencies: + "@otplib/core": ^12.0.1 + "@otplib/preset-default": ^12.0.1 + "@otplib/preset-v11": ^12.0.1 + checksum: 4a1b91cf1b8e920b50ad4bac2ef2a89126630c62daf68e9b32ff15106b2551db905d3b979955cf5f8f114da0a8883cec3d636901d65e793c1745bb4174e2a572 + languageName: node + linkType: hard + +"p-cancelable@npm:^1.0.0": + version: 1.1.0 + resolution: "p-cancelable@npm:1.1.0" + checksum: 2db3814fef6d9025787f30afaee4496a8857a28be3c5706432cbad76c688a6db1874308f48e364a42f5317f5e41e8e7b4f2ff5c8ff2256dbb6264bc361704ece + languageName: node + linkType: hard + "p-finally@npm:^1.0.0": version: 1.0.0 resolution: "p-finally@npm:1.0.0" @@ -6586,6 +7949,18 @@ __metadata: languageName: node linkType: hard +"package-json@npm:^6.3.0": + version: 6.5.0 + resolution: "package-json@npm:6.5.0" + dependencies: + got: ^9.6.0 + registry-auth-token: ^4.0.0 + registry-url: ^5.0.0 + semver: ^6.2.0 + checksum: cc9f890d3667d7610e6184decf543278b87f657d1ace0deb4a9c9155feca738ef88f660c82200763d3348010f4e42e9c7adc91e96ab0f86a770955995b5351e2 + languageName: node + linkType: hard + "pacote@npm:^13.6.0": version: 13.6.0 resolution: "pacote@npm:13.6.0" @@ -6695,6 +8070,13 @@ __metadata: languageName: node linkType: hard +"parseurl@npm:~1.3.3": + version: 1.3.3 + resolution: "parseurl@npm:1.3.3" + checksum: 407cee8e0a3a4c5cd472559bca8b6a45b82c124e9a4703302326e9ab60fc1081442ada4e02628efef1eb16197ddc7f8822f5a91fd7d7c86b51f530aedb17dfa2 + languageName: node + linkType: hard + "path-exists@npm:^3.0.0": version: 3.0.0 resolution: "path-exists@npm:3.0.0" @@ -6730,6 +8112,13 @@ __metadata: languageName: node linkType: hard +"path-to-regexp@npm:0.1.7": + version: 0.1.7 + resolution: "path-to-regexp@npm:0.1.7" + checksum: 69a14ea24db543e8b0f4353305c5eac6907917031340e5a8b37df688e52accd09e3cebfe1660b70d76b6bd89152f52183f28c74813dbf454ba1a01c82a38abce + languageName: node + linkType: hard + "path-type@npm:^3.0.0": version: 3.0.0 resolution: "path-type@npm:3.0.0" @@ -6763,7 +8152,7 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^2.0.4, picomatch@npm:^2.2.3, picomatch@npm:^2.3.1": +"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.2.3, picomatch@npm:^2.3.1": version: 2.3.1 resolution: "picomatch@npm:2.3.1" checksum: 050c865ce81119c4822c45d3c84f1ced46f93a0126febae20737bd05ca20589c564d6e9226977df859ed5e03dc73f02584a2b0faad36e896936238238b0446cf @@ -6821,6 +8210,13 @@ __metadata: languageName: node linkType: hard +"prepend-http@npm:^2.0.0": + version: 2.0.0 + resolution: "prepend-http@npm:2.0.0" + checksum: 7694a9525405447662c1ffd352fcb41b6410c705b739b6f4e3a3e21cf5fdede8377890088e8934436b8b17ba55365a615f153960f30877bf0d0392f9e93503ea + languageName: node + linkType: hard + "prettier-linter-helpers@npm:^1.0.0": version: 1.0.0 resolution: "prettier-linter-helpers@npm:1.0.0" @@ -6851,6 +8247,18 @@ __metadata: languageName: node linkType: hard +"prettyjson@npm:1.2.1": + version: 1.2.1 + resolution: "prettyjson@npm:1.2.1" + dependencies: + colors: ^1.1.2 + minimist: ^1.2.0 + bin: + prettyjson: ./bin/prettyjson + checksum: 4786cf7cb74ddc2293eaf67587a5f21cee6aa6111a53c0b8ec8d5b77fe5d006b6ca09d2c72d52467772c86130fa95c1acfd3bfab0da0a854b8977ff7db04ebf2 + languageName: node + linkType: hard + "proc-log@npm:^2.0.0": version: 2.0.1 resolution: "proc-log@npm:2.0.1" @@ -6937,6 +8345,16 @@ __metadata: languageName: node linkType: hard +"proxy-addr@npm:~2.0.5, proxy-addr@npm:~2.0.7": + version: 2.0.7 + resolution: "proxy-addr@npm:2.0.7" + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + checksum: 29c6990ce9364648255454842f06f8c46fcd124d3e6d7c5066df44662de63cdc0bad032e9bf5a3d653ff72141cc7b6019873d685708ac8210c30458ad99f2b74 + languageName: node + linkType: hard + "pseudomap@npm:^1.0.2": version: 1.0.2 resolution: "pseudomap@npm:1.0.2" @@ -6944,6 +8362,23 @@ __metadata: languageName: node linkType: hard +"pstree.remy@npm:^1.1.8": + version: 1.1.8 + resolution: "pstree.remy@npm:1.1.8" + checksum: 5cb53698d6bb34dfb278c8a26957964aecfff3e161af5fbf7cee00bbe9d8547c7aced4bd9cb193bce15fb56e9e4220fc02a5bf9c14345ffb13a36b858701ec2d + languageName: node + linkType: hard + +"pump@npm:^3.0.0": + version: 3.0.0 + resolution: "pump@npm:3.0.0" + dependencies: + end-of-stream: ^1.1.0 + once: ^1.3.1 + checksum: e42e9229fba14732593a718b04cb5e1cfef8254544870997e0ecd9732b189a48e1256e4e5478148ecb47c8511dca2b09eae56b4d0aad8009e6fac8072923cfc9 + languageName: node + linkType: hard + "punycode@npm:1.3.2": version: 1.3.2 resolution: "punycode@npm:1.3.2" @@ -6958,6 +8393,15 @@ __metadata: languageName: node linkType: hard +"pupa@npm:^2.1.1": + version: 2.1.1 + resolution: "pupa@npm:2.1.1" + dependencies: + escape-goat: ^2.0.0 + checksum: 49529e50372ffdb0cccf0efa0f3b3cb0a2c77805d0d9cc2725bd2a0f6bb414631e61c93a38561b26be1259550b7bb6c2cb92315aa09c8bf93f3bdcb49f2b2fb7 + languageName: node + linkType: hard + "q@npm:^1.5.1": version: 1.5.1 resolution: "q@npm:1.5.1" @@ -6965,6 +8409,22 @@ __metadata: languageName: node linkType: hard +"qs@npm:6.10.3": + version: 6.10.3 + resolution: "qs@npm:6.10.3" + dependencies: + side-channel: ^1.0.4 + checksum: 0fac5e6c7191d0295a96d0e83c851aeb015df7e990e4d3b093897d3ac6c94e555dbd0a599739c84d7fa46d7fee282d94ba76943983935cf33bba6769539b8019 + languageName: node + linkType: hard + +"qs@npm:6.7.0": + version: 6.7.0 + resolution: "qs@npm:6.7.0" + checksum: dfd5f6adef50e36e908cfa70a6233871b5afe66fbaca37ecc1da352ba29eb2151a3797991948f158bb37fccde51bd57845cb619a8035287bfc24e4591172c347 + languageName: node + linkType: hard + "qs@npm:^6.9.4": version: 6.10.5 resolution: "qs@npm:6.10.5" @@ -7007,6 +8467,51 @@ __metadata: languageName: node linkType: hard +"range-parser@npm:~1.2.1": + version: 1.2.1 + resolution: "range-parser@npm:1.2.1" + checksum: 0a268d4fea508661cf5743dfe3d5f47ce214fd6b7dec1de0da4d669dd4ef3d2144468ebe4179049eff253d9d27e719c88dae55be64f954e80135a0cada804ec9 + languageName: node + linkType: hard + +"raw-body@npm:2.4.0": + version: 2.4.0 + resolution: "raw-body@npm:2.4.0" + dependencies: + bytes: 3.1.0 + http-errors: 1.7.2 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + checksum: 6343906939e018c6e633a34a938a5d6d1e93ffcfa48646e00207d53b418e941953b521473950c079347220944dc75ba10e7b3c08bf97e3ac72c7624882db09bb + languageName: node + linkType: hard + +"raw-body@npm:2.5.1": + version: 2.5.1 + resolution: "raw-body@npm:2.5.1" + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + checksum: 5362adff1575d691bb3f75998803a0ffed8c64eabeaa06e54b4ada25a0cd1b2ae7f4f5ec46565d1bec337e08b5ac90c76eaa0758de6f72a633f025d754dec29e + languageName: node + linkType: hard + +"rc@npm:1.2.8, rc@npm:^1.2.8": + version: 1.2.8 + resolution: "rc@npm:1.2.8" + dependencies: + deep-extend: ^0.6.0 + ini: ~1.3.0 + minimist: ^1.2.0 + strip-json-comments: ~2.0.1 + bin: + rc: ./cli.js + checksum: 2e26e052f8be2abd64e6d1dabfbd7be03f80ec18ccbc49562d31f617d0015fbdbcf0f9eed30346ea6ab789e0fdfe4337f033f8016efdbee0df5354751842080e + languageName: node + linkType: hard + "react-is@npm:^18.0.0": version: 18.2.0 resolution: "react-is@npm:18.2.0" @@ -7106,6 +8611,15 @@ __metadata: languageName: node linkType: hard +"readdirp@npm:~3.6.0": + version: 3.6.0 + resolution: "readdirp@npm:3.6.0" + dependencies: + picomatch: ^2.2.1 + checksum: 1ced032e6e45670b6d7352d71d21ce7edf7b9b928494dcaba6f11fba63180d9da6cd7061ebc34175ffda6ff529f481818c962952004d273178acd70f7059b320 + languageName: node + linkType: hard + "redent@npm:^3.0.0": version: 3.0.0 resolution: "redent@npm:3.0.0" @@ -7139,7 +8653,7 @@ __metadata: languageName: node linkType: hard -"reflect-metadata@npm:^0.1.13": +"reflect-metadata@npm:0.1.13, reflect-metadata@npm:^0.1.13": version: 0.1.13 resolution: "reflect-metadata@npm:0.1.13" checksum: 798d379a7b6f6455501145419505c97dd11cbc23857a386add2b9ef15963ccf15a48d9d15507afe01d4cd74116df8a213247200bac00320bd7c11ddeaa5e8fb4 @@ -7153,6 +8667,24 @@ __metadata: languageName: node linkType: hard +"registry-auth-token@npm:^4.0.0": + version: 4.2.2 + resolution: "registry-auth-token@npm:4.2.2" + dependencies: + rc: 1.2.8 + checksum: c5030198546ecfdcbcb0722cbc3e260c4f5f174d8d07bdfedd4620e79bfdf17a2db735aa230d600bd388fce6edd26c0a9ed2eb7e9b4641ec15213a28a806688b + languageName: node + linkType: hard + +"registry-url@npm:^5.0.0": + version: 5.1.0 + resolution: "registry-url@npm:5.1.0" + dependencies: + rc: ^1.2.8 + checksum: bcea86c84a0dbb66467b53187fadebfea79017cddfb4a45cf27530d7275e49082fe9f44301976eb0164c438e395684bcf3dae4819b36ff9d1640d8cc60c73df9 + languageName: node + linkType: hard + "require-directory@npm:^2.1.1": version: 2.1.1 resolution: "require-directory@npm:2.1.1" @@ -7225,6 +8757,15 @@ __metadata: languageName: node linkType: hard +"responselike@npm:^1.0.2": + version: 1.0.2 + resolution: "responselike@npm:1.0.2" + dependencies: + lowercase-keys: ^1.0.0 + checksum: 2e9e70f1dcca3da621a80ce71f2f9a9cad12c047145c6ece20df22f0743f051cf7c73505e109814915f23f9e34fb0d358e22827723ee3d56b623533cab8eafcd + languageName: node + linkType: hard + "restore-cursor@npm:^3.1.0": version: 3.1.0 resolution: "restore-cursor@npm:3.1.0" @@ -7285,20 +8826,20 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:^5.0.1, safe-buffer@npm:~5.2.0": - version: 5.2.1 - resolution: "safe-buffer@npm:5.2.1" - checksum: b99c4b41fdd67a6aaf280fcd05e9ffb0813654894223afb78a31f14a19ad220bba8aba1cb14eddce1fcfb037155fe6de4e861784eb434f7d11ed58d1e70dd491 - languageName: node - linkType: hard - -"safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1": +"safe-buffer@npm:5.1.2, safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1": version: 5.1.2 resolution: "safe-buffer@npm:5.1.2" checksum: f2f1f7943ca44a594893a852894055cf619c1fbcb611237fc39e461ae751187e7baf4dc391a72125e0ac4fb2d8c5c0b3c71529622e6a58f46b960211e704903c languageName: node linkType: hard +"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.0.1, safe-buffer@npm:~5.2.0": + version: 5.2.1 + resolution: "safe-buffer@npm:5.2.1" + checksum: b99c4b41fdd67a6aaf280fcd05e9ffb0813654894223afb78a31f14a19ad220bba8aba1cb14eddce1fcfb037155fe6de4e861784eb434f7d11ed58d1e70dd491 + languageName: node + linkType: hard + "safe-stable-stringify@npm:^2.3.1": version: 2.3.1 resolution: "safe-stable-stringify@npm:2.3.1" @@ -7327,7 +8868,16 @@ __metadata: languageName: node linkType: hard -"semver@npm:2 || 3 || 4 || 5, semver@npm:^5.3.0, semver@npm:^5.5.1, semver@npm:^5.6.0": +"semver-diff@npm:^3.1.1": + version: 3.1.1 + resolution: "semver-diff@npm:3.1.1" + dependencies: + semver: ^6.3.0 + checksum: 8bbe5a5d7add2d5e51b72314a9215cd294d71f41cdc2bf6bd59ee76411f3610b576172896f1d191d0d7294cb9f2f847438d2ee158adacc0c224dca79052812fe + languageName: node + linkType: hard + +"semver@npm:2 || 3 || 4 || 5, semver@npm:^5.3.0, semver@npm:^5.5.1, semver@npm:^5.6.0, semver@npm:^5.7.1": version: 5.7.1 resolution: "semver@npm:5.7.1" bin: @@ -7347,7 +8897,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^6.0.0, semver@npm:^6.3.0": +"semver@npm:^6.0.0, semver@npm:^6.2.0, semver@npm:^6.3.0": version: 6.3.0 resolution: "semver@npm:6.3.0" bin: @@ -7356,6 +8906,48 @@ __metadata: languageName: node linkType: hard +"send@npm:0.17.1": + version: 0.17.1 + resolution: "send@npm:0.17.1" + dependencies: + debug: 2.6.9 + depd: ~1.1.2 + destroy: ~1.0.4 + encodeurl: ~1.0.2 + escape-html: ~1.0.3 + etag: ~1.8.1 + fresh: 0.5.2 + http-errors: ~1.7.2 + mime: 1.6.0 + ms: 2.1.1 + on-finished: ~2.3.0 + range-parser: ~1.2.1 + statuses: ~1.5.0 + checksum: d214c2fa42e7fae3f8fc1aa3931eeb3e6b78c2cf141574e09dbe159915c1e3a337269fc6b7512e7dfddcd7d6ff5974cb62f7c3637ba86a55bde20a92c18bdca0 + languageName: node + linkType: hard + +"send@npm:0.18.0": + version: 0.18.0 + resolution: "send@npm:0.18.0" + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: ~1.0.2 + escape-html: ~1.0.3 + etag: ~1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: ~1.2.1 + statuses: 2.0.1 + checksum: 74fc07ebb58566b87b078ec63e5a3e41ecd987e4272ba67b7467e86c6ad51bc6b0b0154133b6d8b08a2ddda360464f71382f7ef864700f34844a76c8027817a8 + languageName: node + linkType: hard + "seq-queue@npm:^0.0.5": version: 0.0.5 resolution: "seq-queue@npm:0.0.5" @@ -7363,6 +8955,30 @@ __metadata: languageName: node linkType: hard +"serve-static@npm:1.14.1": + version: 1.14.1 + resolution: "serve-static@npm:1.14.1" + dependencies: + encodeurl: ~1.0.2 + escape-html: ~1.0.3 + parseurl: ~1.3.3 + send: 0.17.1 + checksum: c6b268e8486d39ecd54b86c7f2d0ee4a38cd7514ddd9c92c8d5793bb005afde5e908b12395898ae206782306ccc848193d93daa15b86afb3cbe5a8414806abe8 + languageName: node + linkType: hard + +"serve-static@npm:1.15.0": + version: 1.15.0 + resolution: "serve-static@npm:1.15.0" + dependencies: + encodeurl: ~1.0.2 + escape-html: ~1.0.3 + parseurl: ~1.3.3 + send: 0.18.0 + checksum: af57fc13be40d90a12562e98c0b7855cf6e8bd4c107fe9a45c212bf023058d54a1871b1c89511c3958f70626fff47faeb795f5d83f8cf88514dbaeb2b724464d + languageName: node + linkType: hard + "set-blocking@npm:^2.0.0": version: 2.0.0 resolution: "set-blocking@npm:2.0.0" @@ -7370,6 +8986,20 @@ __metadata: languageName: node linkType: hard +"setprototypeof@npm:1.1.1": + version: 1.1.1 + resolution: "setprototypeof@npm:1.1.1" + checksum: a8bee29c1c64c245d460ce53f7460af8cbd0aceac68d66e5215153992cc8b3a7a123416353e0c642060e85cc5fd4241c92d1190eec97eda0dcb97436e8fcca3b + languageName: node + linkType: hard + +"setprototypeof@npm:1.2.0": + version: 1.2.0 + resolution: "setprototypeof@npm:1.2.0" + checksum: be18cbbf70e7d8097c97f713a2e76edf84e87299b40d085c6bf8b65314e994cc15e2e317727342fa6996e38e1f52c59720b53fe621e2eb593a6847bf0356db89 + languageName: node + linkType: hard + "sha.js@npm:^2.4.11": version: 2.4.11 resolution: "sha.js@npm:2.4.11" @@ -7647,6 +9277,20 @@ __metadata: languageName: node linkType: hard +"statuses@npm:2.0.1": + version: 2.0.1 + resolution: "statuses@npm:2.0.1" + checksum: 18c7623fdb8f646fb213ca4051be4df7efb3484d4ab662937ca6fbef7ced9b9e12842709872eb3020cc3504b93bde88935c9f6417489627a7786f24f8031cbcb + languageName: node + linkType: hard + +"statuses@npm:>= 1.5.0 < 2, statuses@npm:~1.5.0": + version: 1.5.0 + resolution: "statuses@npm:1.5.0" + checksum: c469b9519de16a4bb19600205cffb39ee471a5f17b82589757ca7bd40a8d92ebb6ed9f98b5a540c5d302ccbc78f15dc03cc0280dd6e00df1335568a5d5758a5c + languageName: node + linkType: hard + "strict-uri-encode@npm:^2.0.0": version: 2.0.0 resolution: "strict-uri-encode@npm:2.0.0" @@ -7664,7 +9308,7 @@ __metadata: languageName: node linkType: hard -"string-width@npm:^1.0.2 || 2 || 3 || 4, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": +"string-width@npm:^1.0.2 || 2 || 3 || 4, string-width@npm:^4.0.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.2, string-width@npm:^4.2.3": version: 4.2.3 resolution: "string-width@npm:4.2.3" dependencies: @@ -7739,6 +9383,13 @@ __metadata: languageName: node linkType: hard +"strip-json-comments@npm:~2.0.1": + version: 2.0.1 + resolution: "strip-json-comments@npm:2.0.1" + checksum: 1074ccb63270d32ca28edfb0a281c96b94dc679077828135141f27d52a5a398ef5e78bcf22809d23cadc2b81dfbe345eb5fd8699b385c8b1128907dec4a7d1e1 + languageName: node + linkType: hard + "strong-log-transformer@npm:^2.1.0": version: 2.1.0 resolution: "strong-log-transformer@npm:2.1.0" @@ -7752,7 +9403,7 @@ __metadata: languageName: node linkType: hard -"supports-color@npm:^5.3.0": +"supports-color@npm:^5.3.0, supports-color@npm:^5.5.0": version: 5.5.0 resolution: "supports-color@npm:5.5.0" dependencies: @@ -7877,6 +9528,13 @@ __metadata: languageName: node linkType: hard +"thirty-two@npm:^1.0.2": + version: 1.0.2 + resolution: "thirty-two@npm:1.0.2" + checksum: f6700b31d16ef942fdc0d14daed8a2f69ea8b60b0e85db8b83adf58d84bbeafe95a17d343ab55efaae571bb5148b62fc0ee12b04781323bf7af7d7e9693eec76 + languageName: node + linkType: hard + "throat@npm:^6.0.1": version: 6.0.1 resolution: "throat@npm:6.0.1" @@ -7933,6 +9591,13 @@ __metadata: languageName: node linkType: hard +"to-readable-stream@npm:^1.0.0": + version: 1.0.0 + resolution: "to-readable-stream@npm:1.0.0" + checksum: 2bd7778490b6214a2c40276065dd88949f4cf7037ce3964c76838b8cb212893aeb9cceaaf4352a4c486e3336214c350270f3263e1ce7a0c38863a715a4d9aeb5 + languageName: node + linkType: hard + "to-regex-range@npm:^5.0.1": version: 5.0.1 resolution: "to-regex-range@npm:5.0.1" @@ -7942,6 +9607,31 @@ __metadata: languageName: node linkType: hard +"toidentifier@npm:1.0.0": + version: 1.0.0 + resolution: "toidentifier@npm:1.0.0" + checksum: 199e6bfca1531d49b3506cff02353d53ec987c9ee10ee272ca6484ed97f1fc10fb77c6c009079ca16d5c5be4a10378178c3cacdb41ce9ec954c3297c74c6053e + languageName: node + linkType: hard + +"toidentifier@npm:1.0.1": + version: 1.0.1 + resolution: "toidentifier@npm:1.0.1" + checksum: 952c29e2a85d7123239b5cfdd889a0dde47ab0497f0913d70588f19c53f7e0b5327c95f4651e413c74b785147f9637b17410ac8c846d5d4a20a5a33eb6dc3a45 + languageName: node + linkType: hard + +"touch@npm:^3.1.0": + version: 3.1.0 + resolution: "touch@npm:3.1.0" + dependencies: + nopt: ~1.0.10 + bin: + nodetouch: ./bin/nodetouch.js + checksum: e0be589cb5b0e6dbfce6e7e077d4a0d5f0aba558ef769c6d9c33f635e00d73d5be49da6f8631db302ee073919d82b5b7f56da2987feb28765c95a7673af68647 + languageName: node + linkType: hard + "tr46@npm:^3.0.0": version: 3.0.0 resolution: "tr46@npm:3.0.0" @@ -7972,7 +9662,7 @@ __metadata: languageName: node linkType: hard -"ts-jest@npm:^28.0.5": +"ts-jest@npm:^28.0.1, ts-jest@npm:^28.0.5": version: 28.0.5 resolution: "ts-jest@npm:28.0.5" dependencies: @@ -8040,7 +9730,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^1.8.1": +"tslib@npm:^1.8.1, tslib@npm:^1.9.3": version: 1.14.1 resolution: "tslib@npm:1.14.1" checksum: dbe628ef87f66691d5d2959b3e41b9ca0045c3ee3c7c7b906cc1e328b39f199bb1ad9e671c39025bd56122ac57dfbf7385a94843b1cc07c60a4db74795829acd @@ -8123,6 +9813,16 @@ __metadata: languageName: node linkType: hard +"type-is@npm:~1.6.17, type-is@npm:~1.6.18": + version: 1.6.18 + resolution: "type-is@npm:1.6.18" + dependencies: + media-typer: 0.3.0 + mime-types: ~2.1.24 + checksum: 2c8e47675d55f8b4e404bcf529abdf5036c537a04c2b20177bcf78c9e3c1da69da3942b1346e6edb09e823228c0ee656ef0e033765ec39a70d496ef601a0c657 + languageName: node + linkType: hard + "typedarray-to-buffer@npm:^3.1.5": version: 3.1.5 resolution: "typedarray-to-buffer@npm:3.1.5" @@ -8241,6 +9941,13 @@ __metadata: languageName: node linkType: hard +"ua-parser-js@npm:1.0.2": + version: 1.0.2 + resolution: "ua-parser-js@npm:1.0.2" + checksum: ff7f6d79a9c1a38aa85a0e751040fc7e17a0b621bda876838d14ebe55aca4e50e68da0350f181e58801c2d8a35e7db4e12473776e558910c4b7cabcec96aa3bf + languageName: node + linkType: hard + "uglify-js@npm:^3.1.4": version: 3.16.1 resolution: "uglify-js@npm:3.16.1" @@ -8250,6 +9957,13 @@ __metadata: languageName: node linkType: hard +"undefsafe@npm:^2.0.5": + version: 2.0.5 + resolution: "undefsafe@npm:2.0.5" + checksum: f42ab3b5770fedd4ada175fc1b2eb775b78f609156f7c389106aafd231bfc210813ee49f54483d7191d7b76e483bc7f537b5d92d19ded27156baf57592eb02cc + languageName: node + linkType: hard + "unique-filename@npm:^1.1.1": version: 1.1.1 resolution: "unique-filename@npm:1.1.1" @@ -8268,6 +9982,15 @@ __metadata: languageName: node linkType: hard +"unique-string@npm:^2.0.0": + version: 2.0.0 + resolution: "unique-string@npm:2.0.0" + dependencies: + crypto-random-string: ^2.0.0 + checksum: ef68f639136bcfe040cf7e3cd7a8dff076a665288122855148a6f7134092e6ed33bf83a7f3a9185e46c98dddc445a0da6ac25612afa1a7c38b8b654d6c02498e + languageName: node + linkType: hard + "universal-user-agent@npm:^6.0.0": version: 6.0.0 resolution: "universal-user-agent@npm:6.0.0" @@ -8282,6 +10005,13 @@ __metadata: languageName: node linkType: hard +"unpipe@npm:1.0.0, unpipe@npm:~1.0.0": + version: 1.0.0 + resolution: "unpipe@npm:1.0.0" + checksum: 4fa18d8d8d977c55cb09715385c203197105e10a6d220087ec819f50cb68870f02942244f1017565484237f1f8c5d3cd413631b1ae104d3096f24fdfde1b4aa2 + languageName: node + linkType: hard + "upath@npm:^2.0.1": version: 2.0.1 resolution: "upath@npm:2.0.1" @@ -8289,6 +10019,28 @@ __metadata: languageName: node linkType: hard +"update-notifier@npm:^5.1.0": + version: 5.1.0 + resolution: "update-notifier@npm:5.1.0" + dependencies: + boxen: ^5.0.0 + chalk: ^4.1.0 + configstore: ^5.0.1 + has-yarn: ^2.1.0 + import-lazy: ^2.1.0 + is-ci: ^2.0.0 + is-installed-globally: ^0.4.0 + is-npm: ^5.0.0 + is-yarn-global: ^0.3.0 + latest-version: ^5.1.0 + pupa: ^2.1.1 + semver: ^7.3.4 + semver-diff: ^3.1.1 + xdg-basedir: ^4.0.0 + checksum: 461e5e5b002419296d3868ee2abe0f9ab3e1846d9db642936d0c46f838872ec56069eddfe662c45ce1af0a8d6d5026353728de2e0a95ab2e3546a22ea077caf1 + languageName: node + linkType: hard + "uri-js@npm:^4.2.2": version: 4.4.1 resolution: "uri-js@npm:4.4.1" @@ -8298,6 +10050,15 @@ __metadata: languageName: node linkType: hard +"url-parse-lax@npm:^3.0.0": + version: 3.0.0 + resolution: "url-parse-lax@npm:3.0.0" + dependencies: + prepend-http: ^2.0.0 + checksum: 1040e357750451173132228036aff1fd04abbd43eac1fb3e4fca7495a078bcb8d33cb765fe71ad7e473d9c94d98fd67adca63bd2716c815a2da066198dd37217 + languageName: node + linkType: hard + "url@npm:0.10.3": version: 0.10.3 resolution: "url@npm:0.10.3" @@ -8324,6 +10085,13 @@ __metadata: languageName: node linkType: hard +"utils-merge@npm:1.0.1": + version: 1.0.1 + resolution: "utils-merge@npm:1.0.1" + checksum: c81095493225ecfc28add49c106ca4f09cdf56bc66731aa8dabc2edbbccb1e1bfe2de6a115e5c6a380d3ea166d1636410b62ef216bb07b3feb1cfde1d95d5080 + languageName: node + linkType: hard + "uuid@npm:8.0.0": version: 8.0.0 resolution: "uuid@npm:8.0.0" @@ -8333,7 +10101,7 @@ __metadata: languageName: node linkType: hard -"uuid@npm:^8.3.2": +"uuid@npm:8.3.2, uuid@npm:^8.3.2": version: 8.3.2 resolution: "uuid@npm:8.3.2" bin: @@ -8386,6 +10154,13 @@ __metadata: languageName: node linkType: hard +"vary@npm:^1, vary@npm:~1.1.2": + version: 1.1.2 + resolution: "vary@npm:1.1.2" + checksum: ae0123222c6df65b437669d63dfa8c36cee20a504101b2fcd97b8bf76f91259c17f9f2b4d70a1e3c6bbcee7f51b28392833adb6b2770b23b01abec84e369660b + languageName: node + linkType: hard + "walker@npm:^1.0.8": version: 1.0.8 resolution: "walker@npm:1.0.8" @@ -8458,6 +10233,15 @@ __metadata: languageName: node linkType: hard +"widest-line@npm:^3.1.0": + version: 3.1.0 + resolution: "widest-line@npm:3.1.0" + dependencies: + string-width: ^4.0.0 + checksum: 03db6c9d0af9329c37d74378ff1d91972b12553c7d72a6f4e8525fe61563fa7adb0b9d6e8d546b7e059688712ea874edd5ded475999abdeedf708de9849310e0 + languageName: node + linkType: hard + "winston-transport@npm:^4.4.0, winston-transport@npm:^4.5.0": version: 4.5.0 resolution: "winston-transport@npm:4.5.0" @@ -8608,6 +10392,13 @@ __metadata: languageName: node linkType: hard +"xdg-basedir@npm:^4.0.0": + version: 4.0.0 + resolution: "xdg-basedir@npm:4.0.0" + checksum: 0073d5b59a37224ed3a5ac0dd2ec1d36f09c49f0afd769008a6e9cd3cd666bd6317bd1c7ce2eab47e1de285a286bad11a9b038196413cd753b79770361855f3c + languageName: node + linkType: hard + "xml2js@npm:0.4.19": version: 0.4.19 resolution: "xml2js@npm:0.4.19"