mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
refactor: cloud cleanup (#4103)
* refactor: cleanup cloud * chore: fix cloud dependencies * chore: update LICENSE
This commit is contained in:
parent
b347546f46
commit
e89ccd4d4c
60 changed files with 24 additions and 3522 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -2,6 +2,5 @@
|
|||
/packages/core @simeng-li @wangsijie @gao-sun
|
||||
/packages/console @wangsijie @charIeszhao
|
||||
/packages/ui @simeng-li @charIeszhao
|
||||
/packages/cloud @simeng-li
|
||||
/packages/integration-tests @simeng-li
|
||||
/.changeset @gao-sun
|
||||
|
|
21
.github/workflows/clean-up-images.yml
vendored
21
.github/workflows/clean-up-images.yml
vendored
|
@ -1,21 +0,0 @@
|
|||
name: Clean Up Images
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # every day at midnight
|
||||
|
||||
jobs:
|
||||
clean-ghcr:
|
||||
name: Clean up untagged images
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Delete untagged images older than three days
|
||||
uses: snok/container-retention-policy@v2
|
||||
with:
|
||||
image-names: logto
|
||||
cut-off: Three days ago UTC
|
||||
account-type: org
|
||||
org-name: logto-io
|
||||
keep-at-least: 1
|
||||
filter-tags: "sha-*"
|
||||
token: ${{ secrets.BOT_PAT }}
|
44
.github/workflows/integration-test.yml
vendored
44
.github/workflows/integration-test.yml
vendored
|
@ -13,11 +13,6 @@ concurrency:
|
|||
|
||||
jobs:
|
||||
package:
|
||||
# See https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs#expanding-or-adding-matrix-configurations
|
||||
strategy:
|
||||
matrix:
|
||||
env: [oss, cloud]
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
@ -27,26 +22,13 @@ jobs:
|
|||
uses: silverhand-io/actions-node-pnpm-run-steps@v3
|
||||
|
||||
- name: Build and package
|
||||
if: matrix.env != 'cloud'
|
||||
run: |
|
||||
pnpm -r build
|
||||
./.scripts/package.sh
|
||||
|
||||
- name: Build and package (Cloud)
|
||||
if: matrix.env == 'cloud'
|
||||
run: |
|
||||
pnpm -r build
|
||||
./.scripts/package.sh
|
||||
env:
|
||||
IS_CLOUD: 1
|
||||
CONSOLE_PUBLIC_URL: /
|
||||
ADMIN_ENDPOINT: http://localhost:3002
|
||||
# Setup ApplicationInsights for smoke testing. Note the connection string should be a dev string.
|
||||
APPLICATIONINSIGHTS_CONNECTION_STRING: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING }}
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: integration-test-${{ github.sha }}-${{ matrix.env }}
|
||||
name: integration-test-${{ github.sha }}
|
||||
path: /tmp/logto.tar.gz
|
||||
retention-days: 3
|
||||
|
||||
|
@ -54,14 +36,12 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: [api, api-cloud, ui, ui-cloud]
|
||||
target: [api, ui]
|
||||
|
||||
needs: package
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
INTEGRATION_TEST: true
|
||||
IS_CLOUD: ${{ contains(matrix.target, 'cloud') && '1' || '0' }}
|
||||
PATH_BASED_MULTI_TENANCY: ${{ contains(matrix.target, 'cloud') && '1' || '0' }}
|
||||
DB_URL: postgres://postgres:postgres@localhost:5432/postgres
|
||||
|
||||
steps:
|
||||
|
@ -100,15 +80,14 @@ jobs:
|
|||
|
||||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: integration-test-${{ github.sha }}-${{ contains(matrix.target, 'cloud') && 'cloud' || 'oss' }}
|
||||
name: integration-test-${{ github.sha }}
|
||||
|
||||
- name: Extract
|
||||
working-directory: tests
|
||||
run: |
|
||||
npm run cli init -- \
|
||||
-p ../logto \
|
||||
--du ../logto.tar.gz \
|
||||
${{ contains(matrix.target, 'cloud') && '--cloud' || '' }}
|
||||
--du ../logto.tar.gz
|
||||
|
||||
- name: Check and add mock connectors
|
||||
working-directory: tests
|
||||
|
@ -128,11 +107,6 @@ jobs:
|
|||
env:
|
||||
REDIS_URL: 1
|
||||
|
||||
- name: Run Logto Cloud
|
||||
working-directory: logto/
|
||||
if: contains(matrix.target, 'cloud')
|
||||
run: nohup npm run start:cloud > nohup-cloud.out 2> nohup-cloud.err < /dev/null &
|
||||
|
||||
- name: Sleep for 5 seconds
|
||||
run: sleep 5
|
||||
|
||||
|
@ -151,13 +125,3 @@ jobs:
|
|||
- name: Show error logs
|
||||
working-directory: logto/
|
||||
run: cat nohup.err
|
||||
|
||||
- name: Show cloud logs
|
||||
working-directory: logto/
|
||||
if: contains(matrix.target, 'cloud')
|
||||
run: cat nohup-cloud.out
|
||||
|
||||
- name: Show cloud error logs
|
||||
working-directory: logto/
|
||||
if: contains(matrix.target, 'cloud')
|
||||
run: cat nohup-cloud.err
|
||||
|
|
6
.github/workflows/main.yml
vendored
6
.github/workflows/main.yml
vendored
|
@ -87,12 +87,6 @@ jobs:
|
|||
build-args: | # Test cloud build
|
||||
additional_connector_args=--cloud
|
||||
|
||||
- name: Build cloud
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
file: Dockerfile.cloud
|
||||
context: .
|
||||
|
||||
main-alteration:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
|
150
.github/workflows/release.yml
vendored
150
.github/workflows/release.yml
vendored
|
@ -2,16 +2,6 @@ name: Release
|
|||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target:
|
||||
description: 'The release target of Logto'
|
||||
required: true
|
||||
default: dev
|
||||
type: choice
|
||||
options:
|
||||
- prod
|
||||
- dev
|
||||
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
@ -20,10 +10,10 @@ concurrency:
|
|||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
dockerize-core:
|
||||
environment: ${{ startsWith(github.ref, 'refs/tags/') && 'release' || '' }}
|
||||
# Use normal machine for OSS release since we'll build on Depot
|
||||
runs-on: ${{ (inputs.target || 'dev') == 'dev' && 'ubuntu-latest' || 'ubuntu-latest-4-cores' }}
|
||||
dockerize:
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
environment: 'release'
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
@ -45,7 +35,6 @@ jobs:
|
|||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=${{ inputs.target || 'dev' }},enable=${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
type=edge
|
||||
|
||||
- name: Login to DockerHub
|
||||
|
@ -60,29 +49,11 @@ jobs:
|
|||
registry: ghcr.io
|
||||
username: silverhand-bot
|
||||
password: ${{ secrets.BOT_PAT }}
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Build and push
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
additional_connector_args=--cloud
|
||||
applicationinsights_connection_string=${{ (inputs.target || 'dev') == 'dev' && secrets.APPLICATIONINSIGHTS_CONNECTION_STRING || secrets.APPLICATIONINSIGHTS_CONNECTION_STRING_PROD }}
|
||||
|
||||
- name: Setup Depot
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
uses: depot/setup-action@v1
|
||||
|
||||
- name: Build and push
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
uses: depot/build-push-action@v1
|
||||
with:
|
||||
platforms: linux/amd64, linux/arm64
|
||||
|
@ -91,118 +62,7 @@ jobs:
|
|||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
dockerize-cloud:
|
||||
# Use normal machine for OSS release since we'll build on Depot
|
||||
runs-on: ${{ (inputs.target || 'dev') == 'dev' && 'ubuntu-latest' || 'ubuntu-latest-4-cores' }}
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/logto-io/cloud
|
||||
# https://github.com/docker/metadata-action
|
||||
tags: |
|
||||
type=raw,value=${{ inputs.target || 'dev' }},enable=${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
type=edge
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: silverhand-bot
|
||||
password: ${{ secrets.BOT_PAT }}
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
file: Dockerfile.cloud
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
applicationinsights_connection_string=${{ (inputs.target || 'dev') == 'dev' && secrets.APPLICATIONINSIGHTS_CONNECTION_STRING || secrets.APPLICATIONINSIGHTS_CONNECTION_STRING_PROD }}
|
||||
|
||||
deploy:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: core
|
||||
image: logto
|
||||
- target: cloud
|
||||
image: cloud
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
needs: [dockerize-core, dockerize-cloud]
|
||||
environment: ${{ inputs.target || 'dev' }}-${{ matrix.target }}-staging
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node and pnpm
|
||||
if: ${{ (inputs.target || 'dev') == 'dev' && matrix.target == 'core' }}
|
||||
uses: silverhand-io/actions-node-pnpm-run-steps@v3
|
||||
|
||||
- name: Deploy database alteration
|
||||
if: ${{ (inputs.target || 'dev') == 'dev' && matrix.target == 'core' }}
|
||||
run: |
|
||||
pnpm prepack
|
||||
pnpm cli db alt deploy next
|
||||
env:
|
||||
DB_URL: ${{ secrets.DB_URL }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: silverhand-bot
|
||||
password: ${{ secrets.BOT_PAT }}
|
||||
|
||||
- name: Login via Azure CLI
|
||||
uses: azure/login@v1
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_CREDENTIALS }}
|
||||
|
||||
- name: Deploy ${{ matrix.target }} to containerapp
|
||||
uses: azure/webapps-deploy@v2
|
||||
with:
|
||||
app-name: ${{ vars.APP_NAME }}
|
||||
slot-name: staging
|
||||
images: ghcr.io/logto-io/${{ matrix.image }}:${{ (inputs.target || 'dev') }}
|
||||
|
||||
swap-staging-prod:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: [core, cloud]
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
needs: deploy
|
||||
environment: ${{ inputs.target || 'dev' }}-${{ matrix.target }}
|
||||
|
||||
steps:
|
||||
- name: Login via Azure CLI
|
||||
uses: azure/login@v1
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_CREDENTIALS }}
|
||||
|
||||
- name: Swap ${{ matrix.target }} to production
|
||||
# See https://learn.microsoft.com/en-us/cli/azure/webapp/deployment/slot?view=azure-cli-latest#az-webapp-deployment-slot-swap
|
||||
run: az webapp deployment slot swap -g ${{ vars.RESOURCE_GROUP }} -n ${{ vars.APP_NAME }} --slot staging
|
||||
|
||||
|
||||
# Publish packages and create git tags if needed
|
||||
publish-and-tag:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
@ -1,44 +0,0 @@
|
|||
###### [STAGE] Build ######
|
||||
FROM node:18-alpine as builder
|
||||
WORKDIR /etc/logto
|
||||
ENV CI=true
|
||||
|
||||
# No need for Docker build
|
||||
ENV PUPPETEER_SKIP_DOWNLOAD=true
|
||||
|
||||
### Install toolchain ###
|
||||
RUN npm add --location=global pnpm@^8.0.0
|
||||
# https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md#node-gyp-alpine
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
COPY . .
|
||||
|
||||
### Install dependencies ###
|
||||
RUN node .scripts/update-parcelrc.js
|
||||
RUN pnpm i
|
||||
|
||||
### Build ###
|
||||
# Admin Console build env
|
||||
ENV CONSOLE_PUBLIC_URL=/
|
||||
ENV IS_CLOUD=1
|
||||
ARG applicationinsights_connection_string
|
||||
ENV APPLICATIONINSIGHTS_CONNECTION_STRING=${applicationinsights_connection_string}
|
||||
RUN pnpm prepack
|
||||
RUN pnpm connectors:build && pnpm -r --filter @logto/console --filter @logto/cloud build
|
||||
|
||||
### Add official connectors ###
|
||||
RUN pnpm cli connector link -p .
|
||||
|
||||
### Prune dependencies for production ###
|
||||
RUN rm -rf node_modules packages/**/node_modules
|
||||
RUN NODE_ENV=production pnpm i
|
||||
|
||||
### Clean up ###
|
||||
RUN rm -rf .scripts .parcel-cache pnpm-*.yaml
|
||||
|
||||
###### [STAGE] Seal ######
|
||||
FROM node:18-alpine as app
|
||||
WORKDIR /etc/logto-cloud
|
||||
COPY --from=builder /etc/logto .
|
||||
EXPOSE 3003
|
||||
ENTRYPOINT ["npm", "run", "start:cloud"]
|
6
LICENSE
6
LICENSE
|
@ -1,9 +1,3 @@
|
|||
Portions of this software are licensed as follows:
|
||||
|
||||
* All content that resides under the "packages/cloud" directory of this repository, if that directory exists, is licensed under the license defined in "packages/cloud/LICENSE" (Elastic-2.0).
|
||||
* All third party components incorporated into this software are licensed under the original license provided by the owner of the applicable component.
|
||||
* Content outside of the above mentioned directories or restrictions above is available under the "MPL-2.0" license as defined below.
|
||||
|
||||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
|
|
|
@ -9,11 +9,8 @@
|
|||
"prepare": "if test \"$NODE_ENV\" != \"production\" && test \"$CI\" != \"true\" ; then husky install ; fi",
|
||||
"prepack": "pnpm -r prepack",
|
||||
"dev": "pnpm -r prepack && pnpm start:dev",
|
||||
"dev:cloud": "IS_CLOUD=1 pnpm -r prepack && pnpm start:dev:cloud",
|
||||
"start:dev": "pnpm -r --parallel --filter=!@logto/integration-tests --filter \"!./packages/connectors/connector-*\" --filter=!@logto/cloud dev",
|
||||
"start:dev:cloud": "CONSOLE_PUBLIC_URL=/ IS_CLOUD=1 pnpm -r --parallel --filter=!@logto/integration-tests --filter \"!./packages/connectors/connector-*\" dev",
|
||||
"start:dev": "pnpm -r --parallel --filter=!@logto/integration-tests --filter \"!./packages/connectors/connector-*\" dev",
|
||||
"start": "cd packages/core && NODE_ENV=production node .",
|
||||
"start:cloud": "cd packages/cloud && NODE_ENV=production node .",
|
||||
"cli": "logto",
|
||||
"alteration": "logto db alt",
|
||||
"connectors:build": "pnpm -r --filter \"./packages/connectors/connector-*\" build",
|
||||
|
|
|
@ -1,121 +0,0 @@
|
|||
# @logto/cloud
|
||||
|
||||
## 0.2.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ac65c8de4: ### Enable strict CSP policy check header
|
||||
|
||||
This change removes the report only flag from CSP security header settings, which will enables the strict CSP policy check for all requests.
|
||||
|
||||
- Updated dependencies [2cab3787c]
|
||||
- Updated dependencies [73666f8fa]
|
||||
- Updated dependencies [268dc50e7]
|
||||
- Updated dependencies [fa0dbafe8]
|
||||
- Updated dependencies [497d5b526]
|
||||
- @logto/schemas@1.5.0
|
||||
- @logto/cli@1.5.0
|
||||
|
||||
## 0.2.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3c84d81ff]
|
||||
- Updated dependencies [457cb2822]
|
||||
- Updated dependencies [736d6d212]
|
||||
- Updated dependencies [4945b0be2]
|
||||
- Updated dependencies [30033421c]
|
||||
- Updated dependencies [91906f0eb]
|
||||
- @logto/cli@1.2.0
|
||||
- @logto/schemas@1.2.0
|
||||
- @logto/shared@2.0.0
|
||||
- @logto/core-kit@2.0.0
|
||||
- @logto/connector-kit@1.1.1
|
||||
|
||||
## 0.2.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [e2ec1f93e]
|
||||
- @logto/cli@1.1.0
|
||||
- @logto/schemas@1.1.0
|
||||
- @logto/shared@1.0.3
|
||||
|
||||
## 0.2.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [5b4da1e3d]
|
||||
- @logto/schemas@1.0.7
|
||||
- @logto/cli@1.0.3
|
||||
- @logto/shared@1.0.2
|
||||
|
||||
## 0.2.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [621b09ba1]
|
||||
- @logto/schemas@1.0.1
|
||||
- @logto/cli@1.0.2
|
||||
- @logto/shared@1.0.1
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @logto/cli@1.0.1
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 343b1090f: Add demo social connectors for new tenant
|
||||
- 343b1090f: Allow admin tenant admin to create tenants without limitation
|
||||
- 343b1090f: Add send sms service
|
||||
- 343b1090f: Add Cloud API: send email
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 343b1090f: **Seed data for cloud**
|
||||
|
||||
- cli!: remove `oidc` option for `database seed` command as it's unused
|
||||
- cli: add hidden `--cloud` option for `database seed` command to init cloud data
|
||||
- cli, cloud: appending Redirect URIs to Admin Console will deduplicate values before update
|
||||
- move `UrlSet` and `GlobalValues` to `@logto/shared`
|
||||
|
||||
- Updated dependencies [343b1090f]
|
||||
- Updated dependencies [f41fd3f05]
|
||||
- Updated dependencies [e63f5f8b0]
|
||||
- Updated dependencies [f41fd3f05]
|
||||
- Updated dependencies [343b1090f]
|
||||
- Updated dependencies [343b1090f]
|
||||
- Updated dependencies [c12717412]
|
||||
- Updated dependencies [343b1090f]
|
||||
- Updated dependencies [38970fb88]
|
||||
- Updated dependencies [343b1090f]
|
||||
- Updated dependencies [343b1090f]
|
||||
- Updated dependencies [343b1090f]
|
||||
- Updated dependencies [343b1090f]
|
||||
- Updated dependencies [1c9160112]
|
||||
- Updated dependencies [1c9160112]
|
||||
- Updated dependencies [f41fd3f05]
|
||||
- Updated dependencies [7fb689b73]
|
||||
- Updated dependencies [1c9160112]
|
||||
- Updated dependencies [343b1090f]
|
||||
- Updated dependencies [f41fd3f05]
|
||||
- Updated dependencies [f41fd3f05]
|
||||
- Updated dependencies [2d45cc3e6]
|
||||
- Updated dependencies [3ff2e90cd]
|
||||
- @logto/schemas@1.0.0
|
||||
- @logto/shared@1.0.0
|
||||
- @logto/cli@1.0.0
|
||||
- @logto/connector-kit@1.1.0
|
||||
- @logto/core-kit@1.1.0
|
||||
|
||||
## 0.1.1-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [c12717412]
|
||||
- @logto/schemas@1.0.0-rc.1
|
||||
- @logto/shared@1.0.0-rc.1
|
|
@ -1,93 +0,0 @@
|
|||
Elastic License 2.0
|
||||
|
||||
URL: https://www.elastic.co/licensing/elastic-license
|
||||
|
||||
## Acceptance
|
||||
|
||||
By using the software, you agree to all of the terms and conditions below.
|
||||
|
||||
## Copyright License
|
||||
|
||||
The licensor grants you a non-exclusive, royalty-free, worldwide,
|
||||
non-sublicensable, non-transferable license to use, copy, distribute, make
|
||||
available, and prepare derivative works of the software, in each case subject to
|
||||
the limitations and conditions below.
|
||||
|
||||
## Limitations
|
||||
|
||||
You may not provide the software to third parties as a hosted or managed
|
||||
service, where the service provides users with access to any substantial set of
|
||||
the features or functionality of the software.
|
||||
|
||||
You may not move, change, disable, or circumvent the license key functionality
|
||||
in the software, and you may not remove or obscure any functionality in the
|
||||
software that is protected by the license key.
|
||||
|
||||
You may not alter, remove, or obscure any licensing, copyright, or other notices
|
||||
of the licensor in the software. Any use of the licensor’s trademarks is subject
|
||||
to applicable law.
|
||||
|
||||
## Patents
|
||||
|
||||
The licensor grants you a license, under any patent claims the licensor can
|
||||
license, or becomes able to license, to make, have made, use, sell, offer for
|
||||
sale, import and have imported the software, in each case subject to the
|
||||
limitations and conditions in this license. This license does not cover any
|
||||
patent claims that you cause to be infringed by modifications or additions to
|
||||
the software. If you or your company make any written claim that the software
|
||||
infringes or contributes to infringement of any patent, your patent license for
|
||||
the software granted under these terms ends immediately. If your company makes
|
||||
such a claim, your patent license ends immediately for work on behalf of your
|
||||
company.
|
||||
|
||||
## Notices
|
||||
|
||||
You must ensure that anyone who gets a copy of any part of the software from you
|
||||
also gets a copy of these terms.
|
||||
|
||||
If you modify the software, you must include in any modified copies of the
|
||||
software prominent notices stating that you have modified the software.
|
||||
|
||||
## No Other Rights
|
||||
|
||||
These terms do not imply any licenses other than those expressly granted in
|
||||
these terms.
|
||||
|
||||
## Termination
|
||||
|
||||
If you use the software in violation of these terms, such use is not licensed,
|
||||
and your licenses will automatically terminate. If the licensor provides you
|
||||
with a notice of your violation, and you cease all violation of this license no
|
||||
later than 30 days after you receive that notice, your licenses will be
|
||||
reinstated retroactively. However, if you violate these terms after such
|
||||
reinstatement, any additional violation of these terms will cause your licenses
|
||||
to terminate automatically and permanently.
|
||||
|
||||
## No Liability
|
||||
|
||||
*As far as the law allows, the software comes as is, without any warranty or
|
||||
condition, and the licensor will not be liable to you for any damages arising
|
||||
out of these terms or the use or nature of the software, under any kind of
|
||||
legal claim.*
|
||||
|
||||
## Definitions
|
||||
|
||||
The **licensor** is the entity offering these terms, and the **software** is the
|
||||
software the licensor makes available under these terms, including any portion
|
||||
of it.
|
||||
|
||||
**you** refers to the individual or entity agreeing to these terms.
|
||||
|
||||
**your company** is any legal entity, sole proprietorship, or other kind of
|
||||
organization that you work for, plus all organizations that have control over,
|
||||
are under the control of, or are under common control with that
|
||||
organization. **control** means ownership of substantially all the assets of an
|
||||
entity, or the power to direct its management and policies by vote, contract, or
|
||||
otherwise. Control can be direct or indirect.
|
||||
|
||||
**your licenses** are all the licenses granted to you for the software under
|
||||
these terms.
|
||||
|
||||
**use** means anything you do with the software requiring one of your licenses.
|
||||
|
||||
**trademark** means trademarks, service marks, and similar rights.
|
|
@ -1,11 +0,0 @@
|
|||
import baseConfig from '@silverhand/jest-config';
|
||||
|
||||
/** @type {import('jest').Config} */
|
||||
const config = {
|
||||
...baseConfig,
|
||||
coveragePathIgnorePatterns: ['/node_modules/', '/test-utils/'],
|
||||
collectCoverageFrom: ['**/*.js'],
|
||||
roots: ['./build'],
|
||||
setupFilesAfterEnv: ['./jest.setup.js'],
|
||||
};
|
||||
export default config;
|
|
@ -1,7 +0,0 @@
|
|||
/**
|
||||
* Setup environment variables for unit test
|
||||
*/
|
||||
|
||||
process.env.DB_URL = 'postgres://mock.db.url';
|
||||
process.env.ENDPOINT = 'https://logto.test';
|
||||
process.env.NODE_ENV = 'test';
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"exec": "tsc -p tsconfig.build.json --incremental && node ./build/index.js || exit 1",
|
||||
"ignore": [
|
||||
"node_modules/**/node_modules",
|
||||
"../integration-tests/"
|
||||
],
|
||||
"watch": [
|
||||
"./src/",
|
||||
"../core/src/",
|
||||
"./node_modules/",
|
||||
"../../.env"
|
||||
],
|
||||
"ext": "json,js,jsx,ts,tsx",
|
||||
"delay": 1000
|
||||
}
|
|
@ -1,74 +0,0 @@
|
|||
{
|
||||
"name": "@logto/cloud",
|
||||
"version": "0.2.6",
|
||||
"description": "Logto Cloud service.",
|
||||
"main": "build/index.js",
|
||||
"author": "Silverhand Inc. <contact@silverhand.io>",
|
||||
"license": "Elastic-2.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"exports": {
|
||||
"./routes": "./build/routes/index.js"
|
||||
},
|
||||
"imports": {
|
||||
"#src/*": "./build/*"
|
||||
},
|
||||
"scripts": {
|
||||
"precommit": "lint-staged",
|
||||
"build": "rm -rf build/ && tsc -p tsconfig.build.json",
|
||||
"build:test": "rm -rf build/ && tsc -p tsconfig.test.json --sourcemap",
|
||||
"lint": "eslint --ext .ts src",
|
||||
"lint:report": "pnpm lint --format json --output-file report.json",
|
||||
"dev": "rm -rf build/ && nodemon",
|
||||
"start": "NODE_ENV=production node .",
|
||||
"prepack": "pnpm build",
|
||||
"test:only": "NODE_OPTIONS=\"--experimental-vm-modules --max_old_space_size=4096\" jest --logHeapUsage",
|
||||
"test": "pnpm build:test && pnpm test:only",
|
||||
"test:ci": "pnpm test:only --coverage --silent"
|
||||
},
|
||||
"dependencies": {
|
||||
"@logto/app-insights": "workspace:^1.0.0",
|
||||
"@logto/cli": "workspace:^1.5.0",
|
||||
"@logto/connector-kit": "workspace:^1.1.1",
|
||||
"@logto/core-kit": "workspace:^2.0.0",
|
||||
"@logto/schemas": "workspace:^1.5.0",
|
||||
"@logto/shared": "workspace:^2.0.0",
|
||||
"@silverhand/essentials": "^2.5.0",
|
||||
"@withtyped/postgres": "^0.12.0",
|
||||
"@withtyped/server": "^0.12.0",
|
||||
"accepts": "^1.3.8",
|
||||
"chalk": "^5.0.0",
|
||||
"decamelize": "^6.0.0",
|
||||
"dotenv": "^16.0.0",
|
||||
"fetch-retry": "^5.0.4",
|
||||
"find-up": "^6.3.0",
|
||||
"helmet": "^7.0.0",
|
||||
"http-proxy": "^1.18.1",
|
||||
"jose": "^4.11.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@silverhand/eslint-config": "3.0.1",
|
||||
"@silverhand/jest-config": "3.0.0",
|
||||
"@silverhand/ts-config": "3.0.0",
|
||||
"@types/accepts": "^1.3.5",
|
||||
"@types/http-proxy": "^1.17.9",
|
||||
"@types/jest": "^29.4.0",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/node": "^18.11.18",
|
||||
"eslint": "^8.21.0",
|
||||
"jest": "^29.5.0",
|
||||
"lint-staged": "^13.0.0",
|
||||
"nodemon": "^2.0.19",
|
||||
"prettier": "^2.8.1",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.12.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "@silverhand"
|
||||
},
|
||||
"prettier": "@silverhand/eslint-config/.prettierrc"
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
import { GlobalValues } from '@logto/shared';
|
||||
|
||||
export const EnvSet = {
|
||||
global: new GlobalValues(),
|
||||
|
||||
get isProduction() {
|
||||
return this.global.isProduction;
|
||||
},
|
||||
};
|
|
@ -1,59 +0,0 @@
|
|||
import { cloudApiIndicator } from '@logto/schemas';
|
||||
import type { RequestContext } from '@withtyped/server';
|
||||
import createServer, { withBody, compose, withRequest } from '@withtyped/server';
|
||||
import dotenv from 'dotenv';
|
||||
import { findUp } from 'find-up';
|
||||
|
||||
dotenv.config({ path: await findUp('.env', {}) });
|
||||
|
||||
const { appInsights } = await import('@logto/app-insights/node');
|
||||
|
||||
if (await appInsights.setup('cloud')) {
|
||||
console.debug('Initialized ApplicationInsights');
|
||||
}
|
||||
|
||||
const { default: withAuth } = await import('./middleware/with-auth.js');
|
||||
const { default: withHttpProxy } = await import('./middleware/with-http-proxy.js');
|
||||
const { default: withPathname } = await import('./middleware/with-pathname.js');
|
||||
const { default: withSpa } = await import('./middleware/with-spa.js');
|
||||
const { default: withErrorReport } = await import('./middleware/with-error-report.js');
|
||||
const { default: withSecurityHeaders } = await import('./middleware/with-security-headers.js');
|
||||
|
||||
const { EnvSet } = await import('./env-set/index.js');
|
||||
const { default: router } = await import('./routes/index.js');
|
||||
const { default: anonymousRouter } = await import('./routes-anonymous/index.js');
|
||||
|
||||
const ignorePathnames = ['/api'];
|
||||
|
||||
const { listen } = createServer({
|
||||
port: 3003,
|
||||
composer: compose()
|
||||
.and(withErrorReport())
|
||||
.and(withRequest())
|
||||
.and(anonymousRouter.routes())
|
||||
.and(withSecurityHeaders())
|
||||
.and(
|
||||
withPathname(
|
||||
'/api',
|
||||
compose<RequestContext>()
|
||||
.and(withBody())
|
||||
.and(
|
||||
withAuth({ endpoint: EnvSet.global.adminUrlSet.endpoint, audience: cloudApiIndicator })
|
||||
)
|
||||
.and(router.routes())
|
||||
)
|
||||
)
|
||||
.and(
|
||||
EnvSet.isProduction
|
||||
? withSpa({ pathname: '/', root: '../console/dist', ignorePathnames })
|
||||
: withHttpProxy('/', {
|
||||
target: 'http://localhost:5002',
|
||||
changeOrigin: true,
|
||||
ignorePathnames,
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
await listen((port) => {
|
||||
console.log(`Logto cloud is running at http://localhost:${port}`);
|
||||
});
|
|
@ -1,125 +0,0 @@
|
|||
import { buildRawConnector, defaultConnectorMethods } from '@logto/cli/lib/connector/index.js';
|
||||
import type {
|
||||
AllConnector,
|
||||
EmailConnector,
|
||||
SendMessagePayload,
|
||||
ConnectorType,
|
||||
SmsConnector,
|
||||
} from '@logto/connector-kit';
|
||||
import { validateConfig } from '@logto/connector-kit';
|
||||
import type { ServiceLogType } from '@logto/schemas';
|
||||
import { adminTenantId } from '@logto/schemas';
|
||||
import { generateStandardId } from '@logto/shared';
|
||||
import { trySafe } from '@silverhand/essentials';
|
||||
import { RequestError } from '@withtyped/server';
|
||||
|
||||
import type { Queries } from '#src/queries/index.js';
|
||||
import type { LogtoConnector } from '#src/utils/connector/index.js';
|
||||
import { loadConnectorFactories } from '#src/utils/connector/index.js';
|
||||
import { jsonObjectGuard } from '#src/utils/guard.js';
|
||||
|
||||
export const serviceCountLimitForTenant = 100;
|
||||
|
||||
export class ServicesLibrary {
|
||||
constructor(public readonly queries: Queries) {}
|
||||
|
||||
async getTenantIdFromApplicationId(applicationId: string) {
|
||||
const application = await this.queries.applications.findApplicationById(
|
||||
applicationId,
|
||||
adminTenantId
|
||||
);
|
||||
|
||||
return application.customClientMetadata.tenantId;
|
||||
}
|
||||
|
||||
async getAdminTenantLogtoConnectors(): Promise<LogtoConnector[]> {
|
||||
const databaseConnectors = await this.queries.connectors.findAllConnectors(adminTenantId);
|
||||
|
||||
const logtoConnectors = await Promise.all(
|
||||
databaseConnectors.map(async (databaseConnector) => {
|
||||
const { id, metadata, connectorId } = databaseConnector;
|
||||
|
||||
const connectorFactories = await loadConnectorFactories();
|
||||
|
||||
const connectorFactory = connectorFactories.find(
|
||||
({ metadata }) => metadata.id === connectorId
|
||||
);
|
||||
|
||||
if (!connectorFactory) {
|
||||
return;
|
||||
}
|
||||
|
||||
return trySafe(async () => {
|
||||
const { rawConnector, rawMetadata } = await buildRawConnector(
|
||||
connectorFactory,
|
||||
async () => {
|
||||
const databaseConnectors = await this.queries.connectors.findAllConnectors(
|
||||
adminTenantId
|
||||
);
|
||||
const connector = databaseConnectors.find((connector) => connector.id === id);
|
||||
|
||||
if (!connector) {
|
||||
throw new RequestError(`Unable to find connector ${id}`, 500);
|
||||
}
|
||||
|
||||
return connector.config;
|
||||
}
|
||||
);
|
||||
|
||||
const connector: AllConnector = {
|
||||
...defaultConnectorMethods,
|
||||
...rawConnector,
|
||||
metadata: {
|
||||
...rawMetadata,
|
||||
...metadata,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...connector,
|
||||
validateConfig: (config: unknown) => {
|
||||
validateConfig(config, rawConnector.configGuard);
|
||||
},
|
||||
dbEntry: databaseConnector,
|
||||
};
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
return logtoConnectors.filter(
|
||||
(logtoConnector): logtoConnector is LogtoConnector => logtoConnector !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
async sendMessage(type: ConnectorType.Email | ConnectorType.Sms, data: SendMessagePayload) {
|
||||
const connectors = await this.getAdminTenantLogtoConnectors();
|
||||
|
||||
const connector = connectors.find(
|
||||
(connector): connector is LogtoConnector<EmailConnector | SmsConnector> =>
|
||||
connector.type === type
|
||||
);
|
||||
|
||||
if (!connector) {
|
||||
throw new RequestError(`Unable to find ${type} connector`, 500);
|
||||
}
|
||||
|
||||
const { sendMessage } = connector;
|
||||
|
||||
return sendMessage(data);
|
||||
}
|
||||
|
||||
async addLog(tenantId: string, type: ServiceLogType, payload?: unknown) {
|
||||
return this.queries.serviceLogs.insertLog({
|
||||
id: generateStandardId(),
|
||||
type,
|
||||
tenantId,
|
||||
payload: trySafe(() => jsonObjectGuard.parse(payload)),
|
||||
});
|
||||
}
|
||||
|
||||
async getTenantBalanceForType(tenantId: string, type: ServiceLogType) {
|
||||
const usedCount = await this.queries.serviceLogs.countTenantLogs(tenantId, type);
|
||||
|
||||
return serviceCountLimitForTenant - usedCount;
|
||||
}
|
||||
}
|
|
@ -1,241 +0,0 @@
|
|||
import {
|
||||
generateOidcCookieKey,
|
||||
generateOidcPrivateKey,
|
||||
} from '@logto/cli/lib/commands/database/utils.js';
|
||||
import { DemoConnector, ServiceConnector } from '@logto/connector-kit';
|
||||
import { createTenantMetadata } from '@logto/core-kit';
|
||||
import {
|
||||
type LogtoOidcConfigType,
|
||||
createAdminTenantApplicationRole,
|
||||
AdminTenantRole,
|
||||
createTenantMachineToMachineApplication,
|
||||
LogtoOidcConfigKey,
|
||||
LogtoConfigs,
|
||||
SignInExperiences,
|
||||
createDefaultAdminConsoleConfig,
|
||||
createDefaultSignInExperience,
|
||||
adminTenantId,
|
||||
createAdminData,
|
||||
createAdminDataInAdminTenant,
|
||||
getManagementApiResourceIndicator,
|
||||
type TenantModel,
|
||||
} from '@logto/schemas';
|
||||
import type { TenantInfo } from '@logto/schemas/models';
|
||||
import { generateStandardId } from '@logto/shared';
|
||||
import { appendPath } from '@silverhand/essentials';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import { createApplicationsQueries } from '#src/queries/application.js';
|
||||
import { createConnectorsQuery } from '#src/queries/connector.js';
|
||||
import type { Queries } from '#src/queries/index.js';
|
||||
import { createRolesQuery } from '#src/queries/roles.js';
|
||||
import { createSystemsQuery } from '#src/queries/system.js';
|
||||
import { createTenantsQueries } from '#src/queries/tenants.js';
|
||||
import { createUsersQueries } from '#src/queries/users.js';
|
||||
import { getDatabaseName } from '#src/queries/utils.js';
|
||||
import { createCloudServiceConnector } from '#src/utils/connector/seed.js';
|
||||
import { insertInto } from '#src/utils/query.js';
|
||||
import { getTenantIdFromManagementApiIndicator } from '#src/utils/tenant.js';
|
||||
|
||||
const demoSocialConnectorId = 'logto-social-demo';
|
||||
|
||||
const oidcConfigBuilders: {
|
||||
[key in LogtoOidcConfigKey]: () => Promise<LogtoOidcConfigType[key]>;
|
||||
} = {
|
||||
[LogtoOidcConfigKey.CookieKeys]: async () => [generateOidcCookieKey()],
|
||||
[LogtoOidcConfigKey.PrivateKeys]: async () => [await generateOidcPrivateKey()],
|
||||
};
|
||||
|
||||
export class TenantsLibrary {
|
||||
constructor(public readonly queries: Queries) {}
|
||||
|
||||
async getAvailableTenants(userId: string): Promise<TenantInfo[]> {
|
||||
const { getManagementApiLikeIndicatorsForUser, getTenantsByIds } = this.queries.tenants;
|
||||
const { rows } = await getManagementApiLikeIndicatorsForUser(userId);
|
||||
|
||||
const tenantIds = rows
|
||||
.map(({ indicator }) => getTenantIdFromManagementApiIndicator(indicator))
|
||||
.filter((id): id is string => typeof id === 'string');
|
||||
|
||||
if (tenantIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rawTenantInfos = await getTenantsByIds(tenantIds);
|
||||
return rawTenantInfos.map(({ id, name, tag }) => ({
|
||||
id,
|
||||
name,
|
||||
tag,
|
||||
indicator: getManagementApiResourceIndicator(id),
|
||||
}));
|
||||
}
|
||||
|
||||
async updateTenantById(
|
||||
tenantId: string,
|
||||
payload: Partial<Pick<TenantModel, 'name' | 'tag'>>
|
||||
): Promise<TenantInfo> {
|
||||
const { id, name, tag } = await this.queries.tenants.updateTenantById(tenantId, payload);
|
||||
|
||||
return { id, name, tag, indicator: getManagementApiResourceIndicator(id) };
|
||||
}
|
||||
|
||||
async deleteTenantById(tenantId: string) {
|
||||
const {
|
||||
deleteTenantById,
|
||||
deleteDatabaseRoleForTenant,
|
||||
deleteClientTenantManagementApiResourceByTenantId,
|
||||
deleteClientTenantRoleById,
|
||||
getTenantById,
|
||||
deleteClientTenantManagementApplicationById,
|
||||
removeUrisFromAdminConsoleRedirectUris,
|
||||
} = this.queries.tenants;
|
||||
const { cloudUrlSet } = EnvSet.global;
|
||||
|
||||
/** `dbUser` is defined as nullable but we always specified this value when creating a new tenant. */
|
||||
const { dbUser } = await getTenantById(tenantId);
|
||||
if (dbUser) {
|
||||
/** DB role for building connection for the current tenant. */
|
||||
await deleteDatabaseRoleForTenant(dbUser);
|
||||
}
|
||||
|
||||
await deleteTenantById(tenantId);
|
||||
|
||||
/**
|
||||
* All applications, resources, scopes and roles attached to the current tenant
|
||||
* will be deleted per DB design.
|
||||
* Need to manually delete following applications, roles, resources since they
|
||||
* are created for admin tenant which will not be deleted automatically.
|
||||
*/
|
||||
/** Delete management API for the current tenant. */
|
||||
/** `scopes` will be automatically deleted if its related resources have been removed. */
|
||||
await deleteClientTenantManagementApiResourceByTenantId(tenantId);
|
||||
/** Delete admin tenant admin role for the current tenant. */
|
||||
await deleteClientTenantRoleById(tenantId);
|
||||
/** Delete M2M application for the current principal (tenant, characterized by `tenantId`). */
|
||||
await deleteClientTenantManagementApplicationById(tenantId);
|
||||
|
||||
await removeUrisFromAdminConsoleRedirectUris(
|
||||
...cloudUrlSet.deduplicated().map((url) => appendPath(url, tenantId, 'callback'))
|
||||
);
|
||||
}
|
||||
|
||||
async createNewTenant(
|
||||
forUserId: string,
|
||||
payload: Partial<Pick<TenantModel, 'name' | 'tag'>>
|
||||
): Promise<TenantInfo> {
|
||||
const databaseName = await getDatabaseName(this.queries.client);
|
||||
const { id: tenantId, parentRole, role, password } = createTenantMetadata(databaseName);
|
||||
|
||||
// Init tenant
|
||||
const createTenant = {
|
||||
id: tenantId,
|
||||
dbUser: role,
|
||||
dbUserPassword: password,
|
||||
...payload,
|
||||
};
|
||||
const transaction = await this.queries.client.transaction();
|
||||
const tenants = createTenantsQueries(transaction);
|
||||
const users = createUsersQueries(transaction);
|
||||
const applications = createApplicationsQueries(transaction);
|
||||
const roles = createRolesQuery(transaction);
|
||||
const connectors = createConnectorsQuery(transaction);
|
||||
const systems = createSystemsQuery(transaction);
|
||||
|
||||
/* === Start === */
|
||||
await transaction.start();
|
||||
|
||||
// Init tenant
|
||||
await tenants.insertTenant(createTenant);
|
||||
const insertedTenant = await tenants.getTenantById(tenantId);
|
||||
await tenants.createTenantRole(parentRole, role, password);
|
||||
|
||||
// Create admin data set (resource, roles, etc.)
|
||||
const adminDataInAdminTenant = createAdminDataInAdminTenant(tenantId);
|
||||
await tenants.insertAdminData(adminDataInAdminTenant);
|
||||
await tenants.insertAdminData(createAdminData(tenantId));
|
||||
await users.assignRoleToUser({
|
||||
id: generateStandardId(),
|
||||
tenantId: adminTenantId,
|
||||
userId: forUserId,
|
||||
roleId: adminDataInAdminTenant.role.id,
|
||||
});
|
||||
// Create M2M App
|
||||
const m2mRoleId = await roles.findRoleIdByName(
|
||||
AdminTenantRole.TenantApplication,
|
||||
adminTenantId
|
||||
);
|
||||
const m2mApplication = createTenantMachineToMachineApplication(tenantId);
|
||||
await applications.insertApplication(m2mApplication);
|
||||
await applications.assignRoleToApplication(
|
||||
createAdminTenantApplicationRole(m2mApplication.id, m2mRoleId)
|
||||
);
|
||||
|
||||
// Create initial configs
|
||||
await Promise.all([
|
||||
...Object.entries(oidcConfigBuilders).map(async ([key, build]) =>
|
||||
transaction.query(insertInto({ tenantId, key, value: await build() }, LogtoConfigs.table))
|
||||
),
|
||||
transaction.query(insertInto(createDefaultAdminConsoleConfig(tenantId), LogtoConfigs.table)),
|
||||
transaction.query(
|
||||
insertInto(createDefaultSignInExperience(tenantId, true), SignInExperiences.table)
|
||||
),
|
||||
]);
|
||||
|
||||
// Create demo connectors
|
||||
const { cloudUrlSet } = EnvSet.global;
|
||||
|
||||
await Promise.all(
|
||||
[ServiceConnector.Email, DemoConnector.Sms].map(async (connectorId) => {
|
||||
return connectors.insertConnector(
|
||||
createCloudServiceConnector({
|
||||
tenantId,
|
||||
connectorId,
|
||||
appId: m2mApplication.id,
|
||||
appSecret: m2mApplication.secret,
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// Create demo social connectors
|
||||
const presetSocialConnectors = await systems.getDemoSocialValue();
|
||||
|
||||
if (presetSocialConnectors) {
|
||||
await Promise.all(
|
||||
presetSocialConnectors.map(async (connector) => {
|
||||
return connectors.insertConnector({
|
||||
id: generateStandardId(),
|
||||
tenantId,
|
||||
connectorId: demoSocialConnectorId,
|
||||
metadata: {
|
||||
name: { en: connector.name },
|
||||
target: connector.provider,
|
||||
logo: connector.logo,
|
||||
logoDark: connector.logoDark,
|
||||
},
|
||||
config: {
|
||||
provider: connector.provider,
|
||||
clientId: connector.clientId,
|
||||
redirectUri: `${cloudUrlSet.endpoint.toString()}social-demo-callback`,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Update Redirect URI for Admin Console
|
||||
await tenants.appendAdminConsoleRedirectUris(
|
||||
...cloudUrlSet.deduplicated().map((url) => appendPath(url, createTenant.id, 'callback'))
|
||||
);
|
||||
|
||||
await transaction.end();
|
||||
/* === End === */
|
||||
|
||||
return {
|
||||
id: tenantId,
|
||||
name: insertedTenant.name,
|
||||
tag: insertedTenant.tag,
|
||||
indicator: adminDataInAdminTenant.resource.indicator,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -1,96 +0,0 @@
|
|||
import assert from 'node:assert';
|
||||
import type { IncomingHttpHeaders } from 'node:http';
|
||||
|
||||
import { appendPath, tryThat } from '@silverhand/essentials';
|
||||
import type { NextFunction, RequestContext } from '@withtyped/server';
|
||||
import { RequestError } from '@withtyped/server';
|
||||
import fetchRetry from 'fetch-retry';
|
||||
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
|
||||
const bearerTokenIdentifier = 'Bearer';
|
||||
|
||||
export const extractBearerTokenFromHeaders = ({ authorization }: IncomingHttpHeaders) => {
|
||||
assert(authorization, new RequestError('Authorization header is missing.', 401));
|
||||
assert(
|
||||
authorization.startsWith(bearerTokenIdentifier),
|
||||
new RequestError(
|
||||
`Authorization token type is not supported. Valid type: "${bearerTokenIdentifier}".`,
|
||||
401
|
||||
)
|
||||
);
|
||||
|
||||
return authorization.slice(bearerTokenIdentifier.length + 1);
|
||||
};
|
||||
|
||||
export type WithAuthContext<Context = RequestContext> = Context & {
|
||||
auth: {
|
||||
id: string;
|
||||
scopes: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export type WithAuthConfig = {
|
||||
/** The Logto admin tenant endpoint. */
|
||||
endpoint: URL;
|
||||
/** The audience (i.e. Resource Indicator) to expect. */
|
||||
audience: string;
|
||||
/** The scopes (i.e. permissions) to expect. */
|
||||
scopes?: string[];
|
||||
};
|
||||
|
||||
export default function withAuth<InputContext extends RequestContext>({
|
||||
endpoint,
|
||||
audience,
|
||||
scopes: expectScopes = [],
|
||||
}: WithAuthConfig) {
|
||||
const fetch = fetchRetry(global.fetch);
|
||||
const getJwkSet = (async () => {
|
||||
const fetched = await fetch(
|
||||
new Request(appendPath(endpoint, 'oidc/.well-known/openid-configuration')),
|
||||
{ retries: 5, retryDelay: (attempt) => 2 ** attempt * 1000 }
|
||||
);
|
||||
const { jwks_uri: jwksUri, issuer } = z
|
||||
.object({ jwks_uri: z.string(), issuer: z.string() })
|
||||
.parse(await fetched.json());
|
||||
|
||||
return Object.freeze([createRemoteJWKSet(new URL(jwksUri)), issuer] as const);
|
||||
})();
|
||||
|
||||
return async (context: InputContext, next: NextFunction<WithAuthContext<InputContext>>) => {
|
||||
const userId = context.request.headers['development-user-id']?.toString();
|
||||
|
||||
if (!EnvSet.isProduction && userId) {
|
||||
console.log(`Found dev user ID ${userId}, skip token validation.`);
|
||||
|
||||
return next({ ...context, auth: { id: userId, scopes: expectScopes } });
|
||||
}
|
||||
|
||||
const [getKey, issuer] = await getJwkSet;
|
||||
|
||||
const {
|
||||
payload: { sub, scope },
|
||||
} = await tryThat(
|
||||
jwtVerify(extractBearerTokenFromHeaders(context.request.headers), getKey, {
|
||||
issuer,
|
||||
audience,
|
||||
}),
|
||||
(error) => {
|
||||
console.error(error);
|
||||
throw new RequestError('JWT verification failed.', 401);
|
||||
}
|
||||
);
|
||||
|
||||
assert(sub, new RequestError('"sub" is missing in JWT.', 401));
|
||||
|
||||
const scopes = typeof scope === 'string' ? scope.split(' ') : [];
|
||||
assert(
|
||||
expectScopes.every((scope) => scopes.includes(scope)),
|
||||
new RequestError('Forbidden. Please check your permissions.', 403)
|
||||
);
|
||||
|
||||
return next({ ...context, auth: { id: sub, scopes } });
|
||||
};
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
import { appInsights } from '@logto/app-insights/node';
|
||||
import { tryThat } from '@silverhand/essentials';
|
||||
import type { BaseContext, NextFunction } from '@withtyped/server';
|
||||
|
||||
/**
|
||||
* Build a middleware function that reports error to Azure Application Insights.
|
||||
*/
|
||||
export default function withErrorReport<InputContext extends BaseContext>() {
|
||||
return async (context: InputContext, next: NextFunction<InputContext>) => {
|
||||
await tryThat(next(context), (error) => {
|
||||
void appInsights.trackException(error);
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
import type { HttpContext, NextFunction, RequestContext } from '@withtyped/server';
|
||||
import chalk from 'chalk';
|
||||
import type { ServerOptions } from 'http-proxy';
|
||||
import HttpProxy from 'http-proxy';
|
||||
|
||||
import { matchPathname } from '#src/utils/url.js';
|
||||
|
||||
const { createProxy } = HttpProxy;
|
||||
|
||||
export type WithHttpProxyOptions = ServerOptions & {
|
||||
/** An array of pathname prefixes to ignore. */
|
||||
ignorePathnames?: string[];
|
||||
};
|
||||
|
||||
export default function withHttpProxy<InputContext extends RequestContext>(
|
||||
pathname: string,
|
||||
{ ignorePathnames, ...options }: WithHttpProxyOptions
|
||||
) {
|
||||
const proxy = createProxy(options);
|
||||
|
||||
proxy.on('start', (request, __, target) => {
|
||||
console.log(
|
||||
`\t${chalk.italic(chalk.gray('proxy ->'))}`,
|
||||
new URL(request.url ?? '/', typeof target === 'object' ? target.href : target).toString()
|
||||
);
|
||||
});
|
||||
|
||||
return async (
|
||||
context: InputContext,
|
||||
next: NextFunction<InputContext>,
|
||||
{ request, response }: HttpContext
|
||||
) => {
|
||||
const {
|
||||
request: { url },
|
||||
} = context;
|
||||
|
||||
const matched = matchPathname(pathname, url.pathname, ignorePathnames);
|
||||
|
||||
if (!matched) {
|
||||
return next(context);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
response.once('finish', resolve);
|
||||
proxy.web(request, response, options);
|
||||
});
|
||||
|
||||
return next({ ...context, status: 'ignore' });
|
||||
};
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
import type {
|
||||
HttpContext,
|
||||
MiddlewareFunction,
|
||||
NextFunction,
|
||||
RequestContext,
|
||||
} from '@withtyped/server';
|
||||
|
||||
import { matchPathname } from '#src/utils/url.js';
|
||||
|
||||
/**
|
||||
* Build a middleware function that conditionally runs the given middleware function when:
|
||||
*
|
||||
* - The current pathname matches the given pathname prefix; and
|
||||
* - `context.status` is unset (i.e. `undefined`).
|
||||
*
|
||||
* @param pathname The pathname prefix to match.
|
||||
* @param run The middleware function to run with the prefix matches.
|
||||
*/
|
||||
export default function withPathname<
|
||||
InputContext extends RequestContext,
|
||||
OutputContext extends RequestContext
|
||||
>(pathname: string, run: MiddlewareFunction<InputContext, InputContext | OutputContext>) {
|
||||
return async (
|
||||
context: InputContext,
|
||||
next: NextFunction<InputContext | OutputContext>,
|
||||
httpContext: HttpContext
|
||||
) => {
|
||||
if (context.status ?? !matchPathname(pathname, context.request.url.pathname)) {
|
||||
return next(context);
|
||||
}
|
||||
|
||||
return run(context, next, httpContext);
|
||||
};
|
||||
}
|
|
@ -1,124 +0,0 @@
|
|||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { conditionalArray } from '@silverhand/essentials';
|
||||
import type { NextFunction, HttpContext, RequestContext } from '@withtyped/server';
|
||||
import helmet, { type HelmetOptions } from 'helmet';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
|
||||
/**
|
||||
* Apply security headers to the response using helmet
|
||||
* @see https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html for recommended headers
|
||||
* @see https://helmetjs.github.io/ for more details
|
||||
* @returns middleware
|
||||
*/
|
||||
|
||||
const helmetPromise = async (
|
||||
settings: HelmetOptions,
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse
|
||||
) =>
|
||||
promisify((callback) => {
|
||||
helmet(settings)(request, response, (error) => {
|
||||
// Make TS happy
|
||||
callback(error, null);
|
||||
});
|
||||
})();
|
||||
|
||||
export default function withSecurityHeaders<InputContext extends RequestContext>() {
|
||||
const {
|
||||
global: { adminUrlSet, cloudUrlSet, urlSet },
|
||||
isProduction,
|
||||
} = EnvSet;
|
||||
|
||||
const adminOrigins = adminUrlSet.origins;
|
||||
const cloudOrigins = cloudUrlSet.origins;
|
||||
const coreOrigins = urlSet.origins;
|
||||
const developmentOrigins = conditionalArray(!isProduction && 'ws:');
|
||||
const appInsightsOrigins = ['https://*.applicationinsights.azure.com'];
|
||||
// Gtag will load by `<script />`
|
||||
const gtagOrigins = [
|
||||
'https://*.googletagmanager.com',
|
||||
'https://*.doubleclick.net',
|
||||
'https://*.googleadservices.com',
|
||||
];
|
||||
|
||||
return async (
|
||||
context: InputContext,
|
||||
next: NextFunction<InputContext>,
|
||||
{ response, request }: HttpContext
|
||||
) => {
|
||||
const requestPath = context.request.url.pathname;
|
||||
|
||||
/**
|
||||
* Default Applied rules:
|
||||
* - crossOriginOpenerPolicy: https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#cross-origin-opener-policy-coop
|
||||
* - crossOriginResourcePolicy: https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#cross-origin-resource-policy-corp
|
||||
* - crossOriginEmbedderPolicy: https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#cross-origin-embedder-policy-coep
|
||||
* - hidePoweredBy: https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#x-powered-by
|
||||
* - hsts: https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#strict-transport-security-hsts
|
||||
* - ieNoOpen: https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#x-download-options
|
||||
* - noSniff: https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#x-content-type-options
|
||||
* - permittedCrossDomainPolicies: https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#x-permitted-cross-domain-policies
|
||||
* - referrerPolicy: https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#referrer-policy
|
||||
* - xssFilter: https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#x-xss-protection
|
||||
* - originAgentCluster: https://whatpr.org/html/6214/origin.html#origin-keyed-agent-clusters
|
||||
* - frameguard: https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#x-frame-options
|
||||
*/
|
||||
|
||||
const basicSecurityHeaderSettings: HelmetOptions = {
|
||||
contentSecurityPolicy: false, // Exclusively set for console app only
|
||||
crossOriginEmbedderPolicy: { policy: 'credentialless' },
|
||||
dnsPrefetchControl: false,
|
||||
referrerPolicy: {
|
||||
policy: 'strict-origin-when-cross-origin',
|
||||
},
|
||||
};
|
||||
|
||||
if (requestPath.startsWith('/api')) {
|
||||
// FrameOptions: https://cheatsheetseries.owasp.org/cheatsheets/HTTP_Headers_Cheat_Sheet.html#x-frame-options
|
||||
|
||||
await helmetPromise(basicSecurityHeaderSettings, request, response);
|
||||
|
||||
return next(context);
|
||||
}
|
||||
|
||||
// For cloud console
|
||||
// ContentSecurityPolicy: https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html
|
||||
await helmetPromise(
|
||||
// @ts-expect-error: helmet typings has lots of {A?: T, B?: never} | {A?: never, B?: T} options definitions. Optional settings type can not inferred correctly.
|
||||
{
|
||||
...basicSecurityHeaderSettings,
|
||||
frameguard: false,
|
||||
contentSecurityPolicy: {
|
||||
useDefaults: true,
|
||||
directives: {
|
||||
'upgrade-insecure-requests': null,
|
||||
imgSrc: ["'self'", 'data:', 'https:'],
|
||||
scriptSrc: [
|
||||
"'self'",
|
||||
...gtagOrigins,
|
||||
// Non-production environment allow "unsafe-eval" and "unsafe-inline" for debugging purpose
|
||||
...conditionalArray(!isProduction && ["'unsafe-eval'", "'unsafe-inline'"]),
|
||||
],
|
||||
connectSrc: [
|
||||
"'self'",
|
||||
...adminOrigins,
|
||||
...cloudOrigins,
|
||||
...coreOrigins,
|
||||
...developmentOrigins,
|
||||
...appInsightsOrigins,
|
||||
...gtagOrigins,
|
||||
],
|
||||
frameSrc: ["'self'", ...coreOrigins, ...adminOrigins],
|
||||
},
|
||||
},
|
||||
},
|
||||
request,
|
||||
response
|
||||
);
|
||||
|
||||
return next(context);
|
||||
};
|
||||
}
|
|
@ -1,136 +0,0 @@
|
|||
import { createReadStream } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import path from 'node:path';
|
||||
|
||||
import { assert, conditional } from '@silverhand/essentials';
|
||||
import type { HttpContext, NextFunction, RequestContext } from '@withtyped/server';
|
||||
import accepts from 'accepts';
|
||||
import mime from 'mime-types';
|
||||
|
||||
import { matchPathname } from '#src/utils/url.js';
|
||||
|
||||
export type WithSpaConfig = {
|
||||
/**
|
||||
* Browser cache max-age in seconds.
|
||||
* @default 604_800 // 7 days
|
||||
*/
|
||||
maxAge?: number;
|
||||
/** The root directory to serve files. */
|
||||
root: string;
|
||||
/**
|
||||
* The URL pathname to serve as root.
|
||||
* @default '/'
|
||||
*/
|
||||
pathname?: string;
|
||||
/** An array of pathname prefixes to ignore. */
|
||||
ignorePathnames?: string[];
|
||||
/**
|
||||
* The path to file to serve when the given path cannot be found in the file system.
|
||||
* @default 'index.html'
|
||||
*/
|
||||
indexPath?: string;
|
||||
/**
|
||||
* Explicitly disable cache for the index file in order to keep fresh and avoid cache invalidation issues.
|
||||
* @default true
|
||||
*/
|
||||
disableIndexCache?: boolean;
|
||||
};
|
||||
|
||||
export default function withSpa<InputContext extends RequestContext>({
|
||||
maxAge = 604_800,
|
||||
root,
|
||||
pathname: rootPathname = '/',
|
||||
ignorePathnames,
|
||||
indexPath: index = 'index.html',
|
||||
disableIndexCache = true,
|
||||
}: WithSpaConfig) {
|
||||
assert(root, new Error('Root directory is required to serve files.'));
|
||||
|
||||
return async (
|
||||
context: InputContext,
|
||||
next: NextFunction<InputContext>,
|
||||
{ request }: HttpContext
|
||||
) => {
|
||||
const {
|
||||
headers,
|
||||
request: { url },
|
||||
} = context;
|
||||
|
||||
const pathname = matchPathname(rootPathname, url.pathname, ignorePathnames);
|
||||
|
||||
if (!pathname) {
|
||||
return next(context);
|
||||
}
|
||||
|
||||
const indexPath = path.resolve(root, index);
|
||||
const requestPath = path.resolve(path.join(root, pathname));
|
||||
const isHidden = pathname.split('/').some((segment) => segment.startsWith('.'));
|
||||
|
||||
// Intended
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
const result = (!isHidden && (await tryStat(requestPath))) || (await tryStat(indexPath));
|
||||
|
||||
if (!result) {
|
||||
return next({ ...context, status: 404 });
|
||||
}
|
||||
|
||||
const [originalPath] = result;
|
||||
const [pathLike, stat, compression] = (await tryCompressedFile(request, result[0])) ?? result;
|
||||
|
||||
return next({
|
||||
...context,
|
||||
headers: {
|
||||
...headers,
|
||||
...(compression && { 'Content-Encoding': compression }),
|
||||
...(!compression && { 'Content-Length': stat.size }),
|
||||
'Content-Type': mime.lookup(originalPath),
|
||||
'Last-Modified': stat.mtime.toUTCString(),
|
||||
'Cache-Control':
|
||||
disableIndexCache && originalPath === indexPath
|
||||
? 'no-cache, no-store, must-revalidate'
|
||||
: `max-age=${maxAge}`,
|
||||
ETag: `"${stat.size.toString(16)}-${stat.mtimeMs.toString(16)}"`,
|
||||
},
|
||||
stream: createReadStream(pathLike),
|
||||
status: 200,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
type CompressionEncoding = keyof typeof compressionExtensions;
|
||||
|
||||
const compressionExtensions = {
|
||||
br: 'br',
|
||||
gzip: 'gz',
|
||||
} as const;
|
||||
|
||||
const compressionEncodings = Object.freeze(Object.keys(compressionExtensions));
|
||||
|
||||
const isValidEncoding = (value?: string): value is CompressionEncoding =>
|
||||
Boolean(value && compressionEncodings.includes(value));
|
||||
|
||||
const tryCompressedFile = async (request: IncomingMessage, pathLike: string) => {
|
||||
// Honor the compression preference
|
||||
const compression = conditional(accepts(request).encodings([...compressionEncodings]));
|
||||
|
||||
if (!isValidEncoding(compression)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await tryStat(pathLike + '.' + compressionExtensions[compression]);
|
||||
|
||||
if (result) {
|
||||
return [...result, compression] as const;
|
||||
}
|
||||
};
|
||||
|
||||
const tryStat = async (pathLike: string) => {
|
||||
try {
|
||||
const stat = await fs.stat(pathLike);
|
||||
|
||||
if (stat.isFile()) {
|
||||
return [pathLike, stat] as const;
|
||||
}
|
||||
} catch {}
|
||||
};
|
|
@ -1,37 +0,0 @@
|
|||
import type { Application, CreateApplication, CreateApplicationsRole } from '@logto/schemas';
|
||||
import type { PostgreSql } from '@withtyped/postgres';
|
||||
import { sql } from '@withtyped/postgres';
|
||||
import type { Queryable } from '@withtyped/server';
|
||||
|
||||
import { insertInto } from '#src/utils/query.js';
|
||||
|
||||
export type ApplicationsQuery = ReturnType<typeof createApplicationsQueries>;
|
||||
|
||||
export const createApplicationsQueries = (client: Queryable<PostgreSql>) => {
|
||||
const insertApplication = async (data: CreateApplication) =>
|
||||
client.query(insertInto(data, 'applications'));
|
||||
|
||||
const assignRoleToApplication = async (data: CreateApplicationsRole) =>
|
||||
client.query(insertInto(data, 'applications_roles'));
|
||||
|
||||
const findApplicationById = async (id: string, tenantId: string) => {
|
||||
// TODO implement "buildFindById" in withTyped
|
||||
const { rows } = await client.query<Application>(sql`
|
||||
select id, name, secret, description,
|
||||
custom_client_metadata as "customClientMetadata",
|
||||
created_at as "createdAt",
|
||||
oidc_client_metadata as "oidcClientMetadata"
|
||||
from applications
|
||||
where id=${id}
|
||||
and tenant_id=${tenantId}
|
||||
`);
|
||||
|
||||
if (!rows[0]) {
|
||||
throw new Error(`Application ${id} not found.`);
|
||||
}
|
||||
|
||||
return rows[0];
|
||||
};
|
||||
|
||||
return { insertApplication, assignRoleToApplication, findApplicationById };
|
||||
};
|
|
@ -1,32 +0,0 @@
|
|||
import type { Connector, CreateConnector } from '@logto/schemas';
|
||||
import type { PostgreSql } from '@withtyped/postgres';
|
||||
import { sql } from '@withtyped/postgres';
|
||||
import type { JsonObject, Queryable } from '@withtyped/server';
|
||||
|
||||
import { insertInto } from '#src/utils/query.js';
|
||||
|
||||
export type ConnectorsQuery = ReturnType<typeof createConnectorsQuery>;
|
||||
|
||||
export const createConnectorsQuery = (client: Queryable<PostgreSql>) => {
|
||||
const findAllConnectors = async (tenantId: string) => {
|
||||
const { rows } = await client.query<Connector>(sql`
|
||||
select id, sync_profile as "syncProfile",
|
||||
config, metadata, connector_id as "connectorId",
|
||||
created_at as "createdAt"
|
||||
from connectors
|
||||
where tenant_id=${tenantId}
|
||||
`);
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
const insertConnector = async (
|
||||
// TODO @sijie update with-typed "JsonObject" to support "unknown" value
|
||||
connector: Pick<CreateConnector, 'id' | 'tenantId' | 'connectorId'> & {
|
||||
config: JsonObject;
|
||||
metadata?: JsonObject;
|
||||
}
|
||||
) => client.query(insertInto(connector, 'connectors'));
|
||||
|
||||
return { findAllConnectors, insertConnector };
|
||||
};
|
|
@ -1,23 +0,0 @@
|
|||
import { createQueryClient } from '@withtyped/postgres';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import { parseDsn } from '#src/utils/postgres.js';
|
||||
|
||||
import { createApplicationsQueries } from './application.js';
|
||||
import { createConnectorsQuery } from './connector.js';
|
||||
import { createServiceLogsQueries } from './service-logs.js';
|
||||
import { createSystemsQuery } from './system.js';
|
||||
import { createTenantsQueries } from './tenants.js';
|
||||
import { createUsersQueries } from './users.js';
|
||||
|
||||
export class Queries {
|
||||
static default = new Queries();
|
||||
|
||||
public readonly client = createQueryClient(parseDsn(EnvSet.global.dbUrl));
|
||||
public readonly tenants = createTenantsQueries(this.client);
|
||||
public readonly users = createUsersQueries(this.client);
|
||||
public readonly applications = createApplicationsQueries(this.client);
|
||||
public readonly connectors = createConnectorsQuery(this.client);
|
||||
public readonly serviceLogs = createServiceLogsQueries(this.client);
|
||||
public readonly systems = createSystemsQuery(this.client);
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
import type { PostgreSql } from '@withtyped/postgres';
|
||||
import { sql } from '@withtyped/postgres';
|
||||
import type { Queryable } from '@withtyped/server';
|
||||
|
||||
export type RolesQuery = ReturnType<typeof createRolesQuery>;
|
||||
|
||||
export const createRolesQuery = (client: Queryable<PostgreSql>) => {
|
||||
const findRoleIdByName = async (roleName: string, tenantId: string) => {
|
||||
const { rows } = await client.query<{ id: string }>(sql`
|
||||
select id from roles
|
||||
where name=${roleName}
|
||||
and tenant_id=${tenantId}
|
||||
`);
|
||||
|
||||
if (!rows[0]) {
|
||||
throw new Error(`Role ${roleName} not found.`);
|
||||
}
|
||||
|
||||
return rows[0].id;
|
||||
};
|
||||
|
||||
return { findRoleIdByName };
|
||||
};
|
|
@ -1,29 +0,0 @@
|
|||
import type { CreateServiceLog, ServiceLogType } from '@logto/schemas';
|
||||
import type { PostgreSql } from '@withtyped/postgres';
|
||||
import { sql } from '@withtyped/postgres';
|
||||
import type { JsonObject, Queryable } from '@withtyped/server';
|
||||
|
||||
import { insertInto } from '#src/utils/query.js';
|
||||
|
||||
export type ServiceLogsQueries = ReturnType<typeof createServiceLogsQueries>;
|
||||
|
||||
export const createServiceLogsQueries = (client: Queryable<PostgreSql>) => {
|
||||
const insertLog = async (data: Omit<CreateServiceLog, 'payload'> & { payload?: JsonObject }) =>
|
||||
client.query(insertInto(data, 'service_logs'));
|
||||
|
||||
const countTenantLogs = async (tenantId: string, type: ServiceLogType) => {
|
||||
const { rows } = await client.query<{ count: number }>(sql`
|
||||
select count(id) as count
|
||||
from service_logs
|
||||
where tenant_id = ${tenantId}
|
||||
and type = ${type}
|
||||
`);
|
||||
|
||||
return rows[0]?.count ?? 0;
|
||||
};
|
||||
|
||||
return {
|
||||
insertLog,
|
||||
countTenantLogs,
|
||||
};
|
||||
};
|
|
@ -1,29 +0,0 @@
|
|||
import type { System } from '@logto/schemas';
|
||||
import { demoSocialDataGuard, DemoSocialKey } from '@logto/schemas';
|
||||
import type { PostgreSql } from '@withtyped/postgres';
|
||||
import { sql } from '@withtyped/postgres';
|
||||
import type { Queryable } from '@withtyped/server';
|
||||
|
||||
export type SystemsQuery = ReturnType<typeof createSystemsQuery>;
|
||||
|
||||
export const createSystemsQuery = (client: Queryable<PostgreSql>) => {
|
||||
const getDemoSocialValue = async () => {
|
||||
const { rows } = await client.query<System>(sql`
|
||||
select key, value
|
||||
from systems
|
||||
where key=${DemoSocialKey.DemoSocial}
|
||||
`);
|
||||
|
||||
if (!rows[0]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = demoSocialDataGuard.safeParse(rows[0].value);
|
||||
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
};
|
||||
|
||||
return { getDemoSocialValue };
|
||||
};
|
|
@ -1,220 +0,0 @@
|
|||
import assert from 'node:assert';
|
||||
|
||||
import {
|
||||
adminConsoleApplicationId,
|
||||
adminTenantId,
|
||||
getManagementApiResourceIndicator,
|
||||
getManagementApiAdminName,
|
||||
PredefinedScope,
|
||||
ApplicationType,
|
||||
type AdminData,
|
||||
type CreateRolesScope,
|
||||
} from '@logto/schemas';
|
||||
import type { TenantModel } from '@logto/schemas/models';
|
||||
import { generateStandardId } from '@logto/shared';
|
||||
import {
|
||||
type PostgreSql,
|
||||
jsonb,
|
||||
dangerousRaw,
|
||||
id,
|
||||
sql,
|
||||
jsonIfNeeded,
|
||||
jsonbIfNeeded,
|
||||
} from '@withtyped/postgres';
|
||||
import type { Queryable } from '@withtyped/server';
|
||||
|
||||
import { insertInto } from '#src/utils/query.js';
|
||||
|
||||
export type TenantsQueries = ReturnType<typeof createTenantsQueries>;
|
||||
|
||||
export const createTenantsQueries = (client: Queryable<PostgreSql>) => {
|
||||
const getManagementApiLikeIndicatorsForUser = async (userId: string) =>
|
||||
client.query<{ indicator: string }>(sql`
|
||||
select resources.indicator from roles
|
||||
join users_roles
|
||||
on users_roles.role_id = roles.id
|
||||
and users_roles.user_id = ${userId}
|
||||
join roles_scopes
|
||||
on roles.id = roles_scopes.role_id
|
||||
join scopes
|
||||
on roles_scopes.scope_id = scopes.id
|
||||
and scopes.name = ${PredefinedScope.All}
|
||||
join resources
|
||||
on scopes.resource_id = resources.id
|
||||
and resources.indicator like ${getManagementApiResourceIndicator('%')}
|
||||
where roles.tenant_id = ${adminTenantId};
|
||||
`);
|
||||
|
||||
const insertTenant = async (
|
||||
tenant: Pick<TenantModel, 'id' | 'dbUser' | 'dbUserPassword'> &
|
||||
Partial<Pick<TenantModel, 'name' | 'tag'>>
|
||||
) => client.query(insertInto(tenant, 'tenants'));
|
||||
|
||||
const updateTenantById = async (
|
||||
tenantId: string,
|
||||
payload: Partial<Pick<TenantModel, 'name' | 'tag'>>
|
||||
) => {
|
||||
const tenant = await client.maybeOne<TenantModel>(sql`
|
||||
update tenants
|
||||
set ${Object.entries(payload).map(([key, value]) => sql`${id(key)}=${jsonIfNeeded(value)}`)}
|
||||
where id = ${tenantId}
|
||||
returning *;
|
||||
`);
|
||||
|
||||
if (!tenant) {
|
||||
throw new Error(`Tenant ${tenantId} not found.`);
|
||||
}
|
||||
|
||||
return tenant;
|
||||
};
|
||||
|
||||
const createTenantRole = async (parentRole: string, role: string, password: string) =>
|
||||
client.query(sql`
|
||||
create role ${id(role)} with inherit login
|
||||
password '${dangerousRaw(password)}'
|
||||
in role ${id(parentRole)};
|
||||
`);
|
||||
|
||||
const deleteDatabaseRoleForTenant = async (role: string) =>
|
||||
client.query(sql`drop role ${id(role)};`);
|
||||
|
||||
const insertAdminData = async (data: AdminData) => {
|
||||
const { resource, scopes, role } = data;
|
||||
|
||||
assert(
|
||||
resource.tenantId && scopes.every(({ tenantId }) => tenantId) && role.tenantId,
|
||||
new Error('Tenant ID cannot be empty.')
|
||||
);
|
||||
|
||||
assert(
|
||||
scopes.every(
|
||||
(scope) => resource.tenantId === scope.tenantId && scope.tenantId === role.tenantId
|
||||
),
|
||||
new Error('All data should have the same tenant ID.')
|
||||
);
|
||||
|
||||
await client.query(insertInto(resource, 'resources'));
|
||||
await Promise.all(scopes.map(async (scope) => client.query(insertInto(scope, 'scopes'))));
|
||||
await client.query(insertInto(role, 'roles'));
|
||||
|
||||
const { tenantId } = resource;
|
||||
await Promise.all(
|
||||
scopes.map(async ({ id }) =>
|
||||
client.query(
|
||||
insertInto(
|
||||
{
|
||||
id: generateStandardId(),
|
||||
roleId: role.id,
|
||||
scopeId: id,
|
||||
tenantId,
|
||||
} satisfies CreateRolesScope,
|
||||
'roles_scopes'
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const getTenantById = async (
|
||||
id: string
|
||||
): Promise<Pick<TenantModel, 'dbUser' | 'name' | 'tag'>> => {
|
||||
return client.one<Pick<TenantModel, 'dbUser' | 'name' | 'tag'>>(sql`
|
||||
select db_user as "dbUser", name, tag from tenants
|
||||
where id = ${id}
|
||||
`);
|
||||
};
|
||||
|
||||
const getTenantsByIds = async (
|
||||
tenantIds: string[]
|
||||
): Promise<Array<Pick<TenantModel, 'id' | 'name' | 'tag'>>> => {
|
||||
const { rows } = await client.query<Pick<TenantModel, 'id' | 'name' | 'tag'>>(sql`
|
||||
select id, name, tag from tenants
|
||||
where id in (${tenantIds.map((tenantId) => jsonIfNeeded(tenantId))})
|
||||
order by created_at desc, name desc;
|
||||
`);
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
const deleteClientTenantManagementApplicationById = async (tenantId: string) => {
|
||||
await client.query(sql`
|
||||
delete from applications where custom_client_metadata->>'tenantId' = ${tenantId} and tenant_id = ${adminTenantId} and "type" = ${ApplicationType.MachineToMachine}
|
||||
`);
|
||||
};
|
||||
|
||||
const deleteClientTenantManagementApiResourceByTenantId = async (tenantId: string) => {
|
||||
await client.query(sql`
|
||||
delete from resources
|
||||
where tenant_id = ${adminTenantId} and indicator = ${getManagementApiResourceIndicator(
|
||||
tenantId
|
||||
)}
|
||||
`);
|
||||
};
|
||||
|
||||
const deleteClientTenantRoleById = async (tenantId: string) => {
|
||||
await client.query(sql`
|
||||
delete from roles
|
||||
where tenant_id = ${adminTenantId} and name = ${getManagementApiAdminName(tenantId)}
|
||||
`);
|
||||
};
|
||||
|
||||
const deleteTenantById = async (id: string) => {
|
||||
await client.query(sql`
|
||||
delete from tenants
|
||||
where id = ${id}
|
||||
`);
|
||||
};
|
||||
|
||||
const appendAdminConsoleRedirectUris = async (...urls: URL[]) => {
|
||||
const metadataKey = id('oidc_client_metadata');
|
||||
|
||||
await client.query(sql`
|
||||
update applications
|
||||
set ${metadataKey} = jsonb_set(
|
||||
${metadataKey},
|
||||
'{redirectUris}',
|
||||
(select jsonb_agg(distinct value) from jsonb_array_elements(
|
||||
${metadataKey}->'redirectUris' || ${jsonb(urls.map(String))}
|
||||
))
|
||||
)
|
||||
where id = ${adminConsoleApplicationId}
|
||||
and tenant_id = ${adminTenantId}
|
||||
`);
|
||||
};
|
||||
|
||||
const removeUrisFromAdminConsoleRedirectUris = async (...urls: URL[]) => {
|
||||
const metadataKey = id('oidc_client_metadata');
|
||||
|
||||
const { redirectUris } = await client.one<{ redirectUris: string[] }>(
|
||||
sql`select ${metadataKey}->'redirectUris' as "redirectUris" from applications where id = ${adminConsoleApplicationId} and tenant_id = ${adminTenantId}`
|
||||
);
|
||||
const restRedirectUris = redirectUris.filter(
|
||||
(redirectUri) => !urls.map(String).includes(redirectUri)
|
||||
);
|
||||
|
||||
await client.query(sql`
|
||||
update applications
|
||||
set ${metadataKey} = jsonb_set(${metadataKey}, '{redirectUris}', ${jsonbIfNeeded(
|
||||
restRedirectUris
|
||||
)})
|
||||
where id = ${adminConsoleApplicationId} and tenant_id = ${adminTenantId};
|
||||
`);
|
||||
};
|
||||
|
||||
return {
|
||||
getManagementApiLikeIndicatorsForUser,
|
||||
insertTenant,
|
||||
updateTenantById,
|
||||
createTenantRole,
|
||||
deleteDatabaseRoleForTenant,
|
||||
insertAdminData,
|
||||
getTenantById,
|
||||
getTenantsByIds,
|
||||
deleteClientTenantManagementApplicationById,
|
||||
deleteClientTenantManagementApiResourceByTenantId,
|
||||
deleteClientTenantRoleById,
|
||||
deleteTenantById,
|
||||
appendAdminConsoleRedirectUris,
|
||||
removeUrisFromAdminConsoleRedirectUris,
|
||||
};
|
||||
};
|
|
@ -1,13 +0,0 @@
|
|||
import type { UsersRole } from '@logto/schemas';
|
||||
import type { PostgreSql } from '@withtyped/postgres';
|
||||
import type { Queryable } from '@withtyped/server';
|
||||
|
||||
import { insertInto } from '#src/utils/query.js';
|
||||
|
||||
export type UsersQueries = ReturnType<typeof createUsersQueries>;
|
||||
|
||||
export const createUsersQueries = (client: Queryable<PostgreSql>) => {
|
||||
const assignRoleToUser = async (data: UsersRole) => client.query(insertInto(data, 'users_roles'));
|
||||
|
||||
return { assignRoleToUser };
|
||||
};
|
|
@ -1,18 +0,0 @@
|
|||
import type { PostgreSql } from '@withtyped/postgres';
|
||||
import { sql } from '@withtyped/postgres';
|
||||
import type { Queryable } from '@withtyped/server';
|
||||
import { RequestError } from '@withtyped/server';
|
||||
|
||||
export const getDatabaseName = async (client: Queryable<PostgreSql>) => {
|
||||
const {
|
||||
rows: [first],
|
||||
} = await client.query<{ currentDatabase: string }>(sql`
|
||||
select current_database() as "currentDatabase";
|
||||
`);
|
||||
|
||||
if (!first) {
|
||||
throw new RequestError(undefined, 500);
|
||||
}
|
||||
|
||||
return first.currentDatabase.replaceAll('-', '_');
|
||||
};
|
|
@ -1,33 +0,0 @@
|
|||
import { isKeyInObject } from '@logto/shared';
|
||||
|
||||
import { buildRequestContext, createHttpContext } from '#src/test-utils/context.js';
|
||||
|
||||
import router from './index.js';
|
||||
|
||||
describe('GET /api/status', () => {
|
||||
it('should set status to 204', async () => {
|
||||
await router.routes()(
|
||||
buildRequestContext('GET /api/status'),
|
||||
async ({ status, json, stream }) => {
|
||||
expect(status).toBe(204);
|
||||
expect(json).toBe(undefined);
|
||||
expect(stream).toBe(undefined);
|
||||
},
|
||||
createHttpContext()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/teapot', () => {
|
||||
it('should refuse to brew coffee', async () => {
|
||||
await router.routes()(
|
||||
buildRequestContext('GET /api/teapot'),
|
||||
async ({ status, json, stream }) => {
|
||||
expect(status).toBe(418);
|
||||
expect(isKeyInObject(json, 'message')).toBeTruthy();
|
||||
expect(stream).toBe(undefined);
|
||||
},
|
||||
createHttpContext()
|
||||
);
|
||||
});
|
||||
});
|
|
@ -1,9 +0,0 @@
|
|||
import { createRouter } from '@withtyped/server';
|
||||
|
||||
const router = createRouter('/api')
|
||||
.get('/status', {}, async (context, next) => next({ ...context, status: 204 }))
|
||||
.get('/teapot', {}, async (context, next) =>
|
||||
next({ ...context, status: 418, json: { message: 'The server refuses to brew coffee.' } })
|
||||
);
|
||||
|
||||
export default router;
|
|
@ -1,15 +0,0 @@
|
|||
import { createRouter } from '@withtyped/server';
|
||||
|
||||
import { ServicesLibrary } from '#src/libraries/services.js';
|
||||
import { TenantsLibrary } from '#src/libraries/tenants.js';
|
||||
import type { WithAuthContext } from '#src/middleware/with-auth.js';
|
||||
import { Queries } from '#src/queries/index.js';
|
||||
|
||||
import { servicesRoutes } from './services.js';
|
||||
import { tenantsRoutes } from './tenants.js';
|
||||
|
||||
const router = createRouter<WithAuthContext, '/api'>('/api')
|
||||
.pack(tenantsRoutes(new TenantsLibrary(Queries.default)))
|
||||
.pack(servicesRoutes(new ServicesLibrary(Queries.default)));
|
||||
|
||||
export default router;
|
|
@ -1,137 +0,0 @@
|
|||
import { CloudScope, ConnectorType, ServiceLogType } from '@logto/schemas';
|
||||
|
||||
import { buildRequestAuthContext, createHttpContext } from '#src/test-utils/context.js';
|
||||
import { noop } from '#src/test-utils/function.js';
|
||||
import { MockServicesLibrary } from '#src/test-utils/libraries.js';
|
||||
|
||||
import { servicesRoutes } from './services.js';
|
||||
|
||||
const mockSendMessagePayload = {
|
||||
to: 'logto@gmail.com',
|
||||
type: 'SignIn',
|
||||
payload: { code: '1234' },
|
||||
};
|
||||
|
||||
describe('POST /api/services/send-email', () => {
|
||||
const library = new MockServicesLibrary();
|
||||
const router = servicesRoutes(library);
|
||||
|
||||
it('should throw 403 when lack of permission', async () => {
|
||||
await expect(
|
||||
router.routes()(
|
||||
buildRequestAuthContext('POST /services/send-email', {
|
||||
body: { data: mockSendMessagePayload },
|
||||
})(),
|
||||
noop,
|
||||
createHttpContext()
|
||||
)
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('should throw 403 when tenant id not found', async () => {
|
||||
await expect(
|
||||
router.routes()(
|
||||
buildRequestAuthContext('POST /services/send-email', {
|
||||
body: { data: mockSendMessagePayload },
|
||||
})([CloudScope.SendEmail]),
|
||||
noop,
|
||||
createHttpContext()
|
||||
)
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('should throw 403 when insufficient funds', async () => {
|
||||
library.getTenantIdFromApplicationId.mockResolvedValueOnce('tenantId');
|
||||
library.getTenantBalanceForType.mockResolvedValueOnce(0);
|
||||
|
||||
await expect(
|
||||
router.routes()(
|
||||
buildRequestAuthContext('POST /services/send-email', {
|
||||
body: { data: mockSendMessagePayload },
|
||||
})([CloudScope.SendEmail]),
|
||||
noop,
|
||||
createHttpContext()
|
||||
)
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('should return 201', async () => {
|
||||
library.getTenantIdFromApplicationId.mockResolvedValueOnce('tenantId');
|
||||
|
||||
await router.routes()(
|
||||
buildRequestAuthContext('POST /services/send-email', {
|
||||
body: { data: mockSendMessagePayload },
|
||||
})([CloudScope.SendEmail]),
|
||||
async ({ status }) => {
|
||||
expect(status).toBe(201);
|
||||
expect(library.sendMessage).toBeCalledWith(ConnectorType.Email, mockSendMessagePayload);
|
||||
expect(library.addLog).toBeCalledWith('tenantId', ServiceLogType.SendEmail, {
|
||||
data: mockSendMessagePayload,
|
||||
});
|
||||
},
|
||||
createHttpContext()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/services/send-sms', () => {
|
||||
const library = new MockServicesLibrary();
|
||||
const router = servicesRoutes(library);
|
||||
|
||||
it('should throw 403 when lack of permission', async () => {
|
||||
await expect(
|
||||
router.routes()(
|
||||
buildRequestAuthContext('POST /services/send-sms', {
|
||||
body: { data: mockSendMessagePayload },
|
||||
})(),
|
||||
noop,
|
||||
createHttpContext()
|
||||
)
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('should throw 403 when tenant id not found', async () => {
|
||||
await expect(
|
||||
router.routes()(
|
||||
buildRequestAuthContext('POST /services/send-sms', {
|
||||
body: { data: mockSendMessagePayload },
|
||||
})([CloudScope.SendSms]),
|
||||
noop,
|
||||
createHttpContext()
|
||||
)
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('should throw 403 when insufficient funds', async () => {
|
||||
library.getTenantIdFromApplicationId.mockResolvedValueOnce('tenantId');
|
||||
library.getTenantBalanceForType.mockResolvedValueOnce(0);
|
||||
|
||||
await expect(
|
||||
router.routes()(
|
||||
buildRequestAuthContext('POST /services/send-sms', {
|
||||
body: { data: mockSendMessagePayload },
|
||||
})([CloudScope.SendSms]),
|
||||
noop,
|
||||
createHttpContext()
|
||||
)
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('should return 201', async () => {
|
||||
library.getTenantIdFromApplicationId.mockResolvedValueOnce('tenantId');
|
||||
|
||||
await router.routes()(
|
||||
buildRequestAuthContext('POST /services/send-sms', {
|
||||
body: { data: mockSendMessagePayload },
|
||||
})([CloudScope.SendSms]),
|
||||
async ({ status }) => {
|
||||
expect(status).toBe(201);
|
||||
expect(library.sendMessage).toBeCalledWith(ConnectorType.Sms, mockSendMessagePayload);
|
||||
expect(library.addLog).toBeCalledWith('tenantId', ServiceLogType.SendSms, {
|
||||
data: mockSendMessagePayload,
|
||||
});
|
||||
},
|
||||
createHttpContext()
|
||||
);
|
||||
});
|
||||
});
|
|
@ -1,62 +0,0 @@
|
|||
import { ConnectorType, sendMessagePayloadGuard } from '@logto/connector-kit';
|
||||
import { CloudScope, ServiceLogType } from '@logto/schemas';
|
||||
import { createRouter, RequestError } from '@withtyped/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { ServicesLibrary } from '#src/libraries/services.js';
|
||||
import type { WithAuthContext } from '#src/middleware/with-auth.js';
|
||||
|
||||
export const servicesRoutes = (library: ServicesLibrary) =>
|
||||
createRouter<WithAuthContext, '/services'>('/services')
|
||||
.post(
|
||||
'/send-email',
|
||||
{ body: z.object({ data: sendMessagePayloadGuard }) },
|
||||
async (context, next) => {
|
||||
if (![CloudScope.SendEmail].some((scope) => context.auth.scopes.includes(scope))) {
|
||||
throw new RequestError('Forbidden due to lack of permission.', 403);
|
||||
}
|
||||
|
||||
const tenantId = await library.getTenantIdFromApplicationId(context.auth.id);
|
||||
|
||||
if (!tenantId) {
|
||||
throw new RequestError('Unable to find tenant id.', 403);
|
||||
}
|
||||
|
||||
const balance = await library.getTenantBalanceForType(tenantId, ServiceLogType.SendEmail);
|
||||
|
||||
if (!balance) {
|
||||
throw new RequestError('Service usage limit reached.', 403);
|
||||
}
|
||||
|
||||
await library.sendMessage(ConnectorType.Email, context.guarded.body.data);
|
||||
await library.addLog(tenantId, ServiceLogType.SendEmail, context.guarded.body);
|
||||
|
||||
return next({ ...context, status: 201 });
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/send-sms',
|
||||
{ body: z.object({ data: sendMessagePayloadGuard }) },
|
||||
async (context, next) => {
|
||||
if (![CloudScope.SendSms].some((scope) => context.auth.scopes.includes(scope))) {
|
||||
throw new RequestError('Forbidden due to lack of permission.', 403);
|
||||
}
|
||||
|
||||
const tenantId = await library.getTenantIdFromApplicationId(context.auth.id);
|
||||
|
||||
if (!tenantId) {
|
||||
throw new RequestError('Unable to find tenant id.', 403);
|
||||
}
|
||||
|
||||
const balance = await library.getTenantBalanceForType(tenantId, ServiceLogType.SendSms);
|
||||
|
||||
if (!balance) {
|
||||
throw new RequestError('Service usage limit reached.', 403);
|
||||
}
|
||||
|
||||
await library.sendMessage(ConnectorType.Sms, context.guarded.body.data);
|
||||
await library.addLog(tenantId, ServiceLogType.SendSms, context.guarded.body);
|
||||
|
||||
return next({ ...context, status: 201 });
|
||||
}
|
||||
);
|
|
@ -1,277 +0,0 @@
|
|||
import { CloudScope } from '@logto/schemas';
|
||||
import { type TenantInfo, TenantTag } from '@logto/schemas/models';
|
||||
|
||||
import { buildRequestAuthContext, createHttpContext } from '#src/test-utils/context.js';
|
||||
import { noop } from '#src/test-utils/function.js';
|
||||
import { MockTenantsLibrary } from '#src/test-utils/libraries.js';
|
||||
|
||||
import { tenantsRoutes } from './tenants.js';
|
||||
|
||||
describe('GET /api/tenants', () => {
|
||||
const library = new MockTenantsLibrary();
|
||||
const router = tenantsRoutes(library);
|
||||
|
||||
it('should return whatever the library returns', async () => {
|
||||
const tenants: TenantInfo[] = [
|
||||
{
|
||||
id: 'tenant_a',
|
||||
name: 'tenant_a',
|
||||
tag: TenantTag.Development,
|
||||
indicator: 'https://foo.bar',
|
||||
},
|
||||
];
|
||||
library.getAvailableTenants.mockResolvedValueOnce(tenants);
|
||||
|
||||
await router.routes()(
|
||||
buildRequestAuthContext('GET /tenants')(),
|
||||
async ({ json, status }) => {
|
||||
expect(json).toBe(tenants);
|
||||
expect(status).toBe(200);
|
||||
},
|
||||
createHttpContext()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/tenants', () => {
|
||||
const library = new MockTenantsLibrary();
|
||||
const router = tenantsRoutes(library);
|
||||
|
||||
it('should throw 403 when lack of permission', async () => {
|
||||
await expect(
|
||||
router.routes()(
|
||||
buildRequestAuthContext('POST /tenants', {
|
||||
body: { name: 'tenant', tag: TenantTag.Development },
|
||||
})(),
|
||||
noop,
|
||||
createHttpContext()
|
||||
)
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('should throw 403 when trying to create more than 3 tenants with `CreateTenant` scope', async () => {
|
||||
const tenant: TenantInfo = {
|
||||
id: 'tenant',
|
||||
name: 'tenant',
|
||||
tag: TenantTag.Development,
|
||||
indicator: 'https://tenant.foo.bar',
|
||||
};
|
||||
library.getAvailableTenants.mockResolvedValueOnce([tenant, tenant, tenant]);
|
||||
|
||||
await expect(
|
||||
router.routes()(
|
||||
buildRequestAuthContext('POST /tenants', {
|
||||
body: { name: 'tenant', tag: TenantTag.Development },
|
||||
})([CloudScope.CreateTenant]),
|
||||
noop,
|
||||
createHttpContext()
|
||||
)
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('should be able to create a new tenant', async () => {
|
||||
const tenant: TenantInfo = {
|
||||
id: 'tenant_a',
|
||||
name: 'tenant_a',
|
||||
tag: TenantTag.Development,
|
||||
indicator: 'https://foo.bar',
|
||||
};
|
||||
library.getAvailableTenants.mockResolvedValueOnce([]);
|
||||
library.createNewTenant.mockImplementationOnce(async (_, payload) => {
|
||||
return { ...tenant, ...payload };
|
||||
});
|
||||
|
||||
await router.routes()(
|
||||
buildRequestAuthContext('POST /tenants', {
|
||||
body: { name: 'tenant_named', tag: TenantTag.Staging },
|
||||
})([CloudScope.CreateTenant]),
|
||||
async ({ json, status }) => {
|
||||
expect(json).toStrictEqual({ ...tenant, name: 'tenant_named', tag: TenantTag.Staging });
|
||||
expect(status).toBe(201);
|
||||
},
|
||||
createHttpContext()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /api/tenants/:tenantId', () => {
|
||||
const library = new MockTenantsLibrary();
|
||||
const router = tenantsRoutes(library);
|
||||
|
||||
it('should throw 403 when lack of permission', async () => {
|
||||
await expect(
|
||||
router.routes()(
|
||||
buildRequestAuthContext('PATCH /tenants/tenant_a', { body: {} })(),
|
||||
noop,
|
||||
createHttpContext()
|
||||
)
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('should throw 404 operating unavailable tenants', async () => {
|
||||
const tenant: TenantInfo = {
|
||||
id: 'tenant_a',
|
||||
name: 'tenant_a',
|
||||
tag: TenantTag.Development,
|
||||
indicator: 'https://foo.bar',
|
||||
};
|
||||
library.getAvailableTenants.mockResolvedValueOnce([tenant]);
|
||||
|
||||
await expect(
|
||||
router.routes()(
|
||||
buildRequestAuthContext('PATCH /tenants/tenant_b', { body: {} })([
|
||||
CloudScope.ManageTenantSelf,
|
||||
]),
|
||||
noop,
|
||||
createHttpContext()
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should be able to update arbitrary tenant with `ManageTenant` scope', async () => {
|
||||
const tenant: TenantInfo = {
|
||||
id: 'tenant_a',
|
||||
name: 'tenant_a',
|
||||
tag: TenantTag.Development,
|
||||
indicator: 'https://foo.bar',
|
||||
};
|
||||
library.updateTenantById.mockImplementationOnce(async (_, payload): Promise<TenantInfo> => {
|
||||
return { ...tenant, ...payload };
|
||||
});
|
||||
|
||||
await router.routes()(
|
||||
buildRequestAuthContext('PATCH /tenants/tenant_a', {
|
||||
body: {
|
||||
name: 'tenant_b',
|
||||
tag: TenantTag.Staging,
|
||||
},
|
||||
})([CloudScope.ManageTenant]),
|
||||
async ({ json, status }) => {
|
||||
expect(json).toStrictEqual({ ...tenant, name: 'tenant_b', tag: TenantTag.Staging });
|
||||
expect(status).toBe(200);
|
||||
},
|
||||
createHttpContext()
|
||||
);
|
||||
});
|
||||
|
||||
it('should be able to update available tenant with `ManageTenantSelf` scope', async () => {
|
||||
const tenant: TenantInfo = {
|
||||
id: 'tenant_a',
|
||||
name: 'tenant_a',
|
||||
tag: TenantTag.Development,
|
||||
indicator: 'https://foo.bar',
|
||||
};
|
||||
library.getAvailableTenants.mockResolvedValueOnce([tenant]);
|
||||
library.updateTenantById.mockImplementationOnce(async (_, payload): Promise<TenantInfo> => {
|
||||
return { ...tenant, ...payload };
|
||||
});
|
||||
|
||||
await router.routes()(
|
||||
buildRequestAuthContext('PATCH /tenants/tenant_a', {
|
||||
body: {
|
||||
name: 'tenant_b',
|
||||
tag: TenantTag.Staging,
|
||||
},
|
||||
})([CloudScope.ManageTenant]),
|
||||
async ({ json, status }) => {
|
||||
expect(json).toStrictEqual({ ...tenant, name: 'tenant_b', tag: TenantTag.Staging });
|
||||
expect(status).toBe(200);
|
||||
},
|
||||
createHttpContext()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/tenants/:tenantId', () => {
|
||||
const library = new MockTenantsLibrary();
|
||||
const router = tenantsRoutes(library);
|
||||
|
||||
it('should throw 422 when try to delete `admin` tenant', async () => {
|
||||
await expect(
|
||||
router.routes()(
|
||||
buildRequestAuthContext('DELETE /tenants/admin', { body: {} })(),
|
||||
noop,
|
||||
createHttpContext()
|
||||
)
|
||||
).rejects.toMatchObject({ status: 422 });
|
||||
});
|
||||
|
||||
it('should throw 422 when try to delete `default` tenant', async () => {
|
||||
await expect(
|
||||
router.routes()(
|
||||
buildRequestAuthContext('DELETE /tenants/default', { body: {} })(),
|
||||
noop,
|
||||
createHttpContext()
|
||||
)
|
||||
).rejects.toMatchObject({ status: 422 });
|
||||
});
|
||||
|
||||
it('should throw 403 when lack of permission', async () => {
|
||||
await expect(
|
||||
router.routes()(
|
||||
buildRequestAuthContext('DELETE /tenants/tenant_a', { body: {} })(),
|
||||
noop,
|
||||
createHttpContext()
|
||||
)
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('should throw 404 operating unavailable tenants', async () => {
|
||||
const tenant: TenantInfo = {
|
||||
id: 'tenant_a',
|
||||
name: 'tenant_a',
|
||||
tag: TenantTag.Development,
|
||||
indicator: 'https://foo.bar',
|
||||
};
|
||||
library.getAvailableTenants.mockResolvedValueOnce([tenant]);
|
||||
|
||||
await expect(
|
||||
router.routes()(
|
||||
buildRequestAuthContext('DELETE /tenants/tenant_b', { body: {} })([
|
||||
CloudScope.ManageTenantSelf,
|
||||
]),
|
||||
noop,
|
||||
createHttpContext()
|
||||
)
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it('should be able to delete arbitrary tenant with `ManageTenant` scope', async () => {
|
||||
const tenant: TenantInfo = {
|
||||
id: 'tenant_a',
|
||||
name: 'tenant_a',
|
||||
tag: TenantTag.Development,
|
||||
indicator: 'https://foo.bar',
|
||||
};
|
||||
library.deleteTenantById.mockResolvedValueOnce();
|
||||
|
||||
await router.routes()(
|
||||
buildRequestAuthContext(`DELETE /tenants/${tenant.id}`)([CloudScope.ManageTenant]),
|
||||
async ({ json, status }) => {
|
||||
expect(json).toBeUndefined();
|
||||
expect(status).toBe(204);
|
||||
},
|
||||
createHttpContext()
|
||||
);
|
||||
});
|
||||
|
||||
it('should be able to delete available tenant with `ManageTenantSelf` scope', async () => {
|
||||
const tenant: TenantInfo = {
|
||||
id: 'tenant_a',
|
||||
name: 'tenant_a',
|
||||
tag: TenantTag.Development,
|
||||
indicator: 'https://foo.bar',
|
||||
};
|
||||
library.getAvailableTenants.mockResolvedValueOnce([tenant]);
|
||||
library.deleteTenantById.mockResolvedValueOnce();
|
||||
|
||||
await router.routes()(
|
||||
buildRequestAuthContext(`DELETE /tenants/${tenant.id}`)([CloudScope.ManageTenant]),
|
||||
async ({ json, status }) => {
|
||||
expect(json).toBeUndefined();
|
||||
expect(status).toBe(204);
|
||||
},
|
||||
createHttpContext()
|
||||
);
|
||||
});
|
||||
});
|
|
@ -1,118 +0,0 @@
|
|||
import { CloudScope, adminTenantId, defaultTenantId } from '@logto/schemas';
|
||||
import { Tenants, tenantInfoGuard } from '@logto/schemas/models';
|
||||
import { assert } from '@silverhand/essentials';
|
||||
import { createRouter, RequestError } from '@withtyped/server';
|
||||
|
||||
import type { TenantsLibrary } from '#src/libraries/tenants.js';
|
||||
import type { WithAuthContext } from '#src/middleware/with-auth.js';
|
||||
|
||||
export const tenantsRoutes = (library: TenantsLibrary) =>
|
||||
createRouter<WithAuthContext, '/tenants'>('/tenants')
|
||||
.get('/', { response: tenantInfoGuard.array() }, async (context, next) => {
|
||||
return next({
|
||||
...context,
|
||||
json: await library.getAvailableTenants(context.auth.id),
|
||||
status: 200,
|
||||
});
|
||||
})
|
||||
.patch(
|
||||
'/:tenantId',
|
||||
{
|
||||
body: Tenants.guard('patch').pick({ name: true, tag: true }),
|
||||
response: tenantInfoGuard,
|
||||
},
|
||||
async (context, next) => {
|
||||
/** Users w/o either `ManageTenant` or `ManageTenantSelf` scope does not have permission. */
|
||||
if (
|
||||
![CloudScope.ManageTenant, CloudScope.ManageTenantSelf].some((scope) =>
|
||||
context.auth.scopes.includes(scope)
|
||||
)
|
||||
) {
|
||||
throw new RequestError('Forbidden due to lack of permission.', 403);
|
||||
}
|
||||
|
||||
/** Should throw 404 when users with `ManageTenantSelf` scope are attempting to change an unavailable tenant. */
|
||||
if (!context.auth.scopes.includes(CloudScope.ManageTenant)) {
|
||||
const availableTenants = await library.getAvailableTenants(context.auth.id);
|
||||
assert(
|
||||
availableTenants.map(({ id }) => id).includes(context.guarded.params.tenantId),
|
||||
new RequestError(
|
||||
`Can not find tenant whose id is '${context.guarded.params.tenantId}'.`,
|
||||
404
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return next({
|
||||
...context,
|
||||
json: await library.updateTenantById(
|
||||
context.guarded.params.tenantId,
|
||||
context.guarded.body
|
||||
),
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/',
|
||||
{
|
||||
body: Tenants.guard('create').pick({ name: true, tag: true }),
|
||||
response: tenantInfoGuard,
|
||||
},
|
||||
async (context, next) => {
|
||||
if (
|
||||
![CloudScope.CreateTenant, CloudScope.ManageTenant].some((scope) =>
|
||||
context.auth.scopes.includes(scope)
|
||||
)
|
||||
) {
|
||||
throw new RequestError('Forbidden due to lack of permission.', 403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Should throw 403 when users with `CreateTenant` scope are attempting to create more than 3 tenants.
|
||||
* This does not apply to users with `ManageTenant` scope.
|
||||
*/
|
||||
if (context.auth.scopes.includes(CloudScope.CreateTenant)) {
|
||||
const availableTenants = await library.getAvailableTenants(context.auth.id);
|
||||
assert(
|
||||
availableTenants.length < 3,
|
||||
new RequestError(`Can not have more than 3 tenants.`, 403)
|
||||
);
|
||||
}
|
||||
|
||||
return next({
|
||||
...context,
|
||||
json: await library.createNewTenant(context.auth.id, context.guarded.body),
|
||||
status: 201,
|
||||
});
|
||||
}
|
||||
)
|
||||
.delete('/:tenantId', {}, async (context, next) => {
|
||||
if ([adminTenantId, defaultTenantId].includes(context.guarded.params.tenantId)) {
|
||||
throw new RequestError(`Should not delete built-in tenants.`, 422);
|
||||
}
|
||||
|
||||
/** Users w/o either `ManageTenant` or `ManageTenantSelf` scope does not have permission. */
|
||||
if (
|
||||
![CloudScope.ManageTenant, CloudScope.ManageTenantSelf].some((scope) =>
|
||||
context.auth.scopes.includes(scope)
|
||||
)
|
||||
) {
|
||||
throw new RequestError('Forbidden due to lack of permission.', 403);
|
||||
}
|
||||
|
||||
/** Should throw 404 when users with `ManageTenantSelf` scope are attempting to change an unavailable tenant. */
|
||||
if (!context.auth.scopes.includes(CloudScope.ManageTenant)) {
|
||||
const availableTenants = await library.getAvailableTenants(context.auth.id);
|
||||
assert(
|
||||
availableTenants.map(({ id }) => id).includes(context.guarded.params.tenantId),
|
||||
new RequestError(
|
||||
`Can not find tenant whose id is '${context.guarded.params.tenantId}'.`,
|
||||
404
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await library.deleteTenantById(context.guarded.params.tenantId);
|
||||
return next({ ...context, status: 204 });
|
||||
});
|
|
@ -1,46 +0,0 @@
|
|||
import { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { Socket } from 'node:net';
|
||||
import { TLSSocket } from 'node:tls';
|
||||
|
||||
import type { HttpContext, RequestContext, RequestMethod } from '@withtyped/server';
|
||||
|
||||
import type { WithAuthContext } from '#src/middleware/with-auth.js';
|
||||
|
||||
export const createHttpContext: (isHttps?: boolean) => HttpContext = (isHttps = false) => {
|
||||
const request = new IncomingMessage(isHttps ? new TLSSocket(new Socket()) : new Socket());
|
||||
|
||||
return {
|
||||
request,
|
||||
response: new ServerResponse(request),
|
||||
};
|
||||
};
|
||||
|
||||
type BuildRequestContext = Partial<RequestContext['request']>;
|
||||
|
||||
const splitPath = <Pathname extends string, Path extends `${RequestMethod} ${Pathname}`>(
|
||||
path: Path
|
||||
): [RequestMethod, Pathname] => {
|
||||
const [method, ...rest] = path.split(' ');
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return [method, rest.join('')] as [RequestMethod, Pathname];
|
||||
};
|
||||
|
||||
export const buildRequestContext = <Path extends `${RequestMethod} ${string}`>(
|
||||
path: Path,
|
||||
{ headers = {}, body }: BuildRequestContext = {}
|
||||
): RequestContext => {
|
||||
const [method, pathname] = splitPath(path);
|
||||
|
||||
return {
|
||||
request: { method, headers, url: new URL(pathname, 'http://localhost'), body },
|
||||
};
|
||||
};
|
||||
|
||||
export const buildRequestAuthContext =
|
||||
<Path extends `${RequestMethod} ${string}`>(
|
||||
...args: Parameters<typeof buildRequestContext<Path>>
|
||||
) =>
|
||||
(scopes: string[] = []): WithAuthContext => {
|
||||
return { ...buildRequestContext(...args), auth: { id: 'foo', scopes } };
|
||||
};
|
|
@ -1,2 +0,0 @@
|
|||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
export const noop = async () => {};
|
|
@ -1,41 +0,0 @@
|
|||
import type { ServiceLogType } from '@logto/schemas';
|
||||
import type { TenantInfo, TenantTag } from '@logto/schemas/models';
|
||||
|
||||
import type { ServicesLibrary } from '#src/libraries/services.js';
|
||||
import type { TenantsLibrary } from '#src/libraries/tenants.js';
|
||||
import type { Queries } from '#src/queries/index.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
export class MockTenantsLibrary implements TenantsLibrary {
|
||||
public get queries(): Queries {
|
||||
throw new Error('Not implemented.');
|
||||
}
|
||||
|
||||
public getAvailableTenants = jest.fn<Promise<TenantInfo[]>, [string]>();
|
||||
public createNewTenant = jest.fn<Promise<TenantInfo>, [string, Record<string, unknown>]>();
|
||||
public updateTenantById = jest.fn<
|
||||
Promise<TenantInfo>,
|
||||
[string, { name?: string; tag?: TenantTag }]
|
||||
>();
|
||||
|
||||
public deleteTenantById = jest.fn<Promise<void>, [string]>();
|
||||
}
|
||||
|
||||
export class MockServicesLibrary implements ServicesLibrary {
|
||||
public get queries(): Queries {
|
||||
throw new Error('Not implemented.');
|
||||
}
|
||||
|
||||
public getTenantIdFromApplicationId = jest.fn<Promise<string>, [string]>();
|
||||
|
||||
public sendMessage = jest.fn();
|
||||
|
||||
public getAdminTenantLogtoConnectors = jest.fn();
|
||||
|
||||
public addLog = jest.fn();
|
||||
|
||||
public getTenantBalanceForType = jest
|
||||
.fn<Promise<number>, [string, ServiceLogType]>()
|
||||
.mockResolvedValue(100);
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { loadConnectorFactories as _loadConnectorFactories } from '@logto/cli/lib/connector/index.js';
|
||||
import { connectorDirectory } from '@logto/cli/lib/constants.js';
|
||||
import { getConnectorPackagesFromDirectory } from '@logto/cli/lib/utils.js';
|
||||
import { findPackage } from '@logto/shared';
|
||||
|
||||
export * from './types.js';
|
||||
|
||||
export const loadConnectorFactories = async () => {
|
||||
const currentDirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const cloudDirectory = await findPackage(currentDirname);
|
||||
const coreDirectory = cloudDirectory && path.join(cloudDirectory, '..', 'core');
|
||||
const directory = coreDirectory && path.join(coreDirectory, connectorDirectory);
|
||||
|
||||
if (!directory || !existsSync(directory)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const connectorPackages = await getConnectorPackagesFromDirectory(directory);
|
||||
|
||||
const connectorFactories = await _loadConnectorFactories(connectorPackages, false);
|
||||
|
||||
return connectorFactories;
|
||||
};
|
|
@ -1,27 +0,0 @@
|
|||
import { cloudApiIndicator } from '@logto/schemas';
|
||||
import { generateStandardId, GlobalValues } from '@logto/shared';
|
||||
import { appendPath } from '@silverhand/essentials';
|
||||
|
||||
export const createCloudServiceConnector = (data: {
|
||||
tenantId: string;
|
||||
connectorId: string;
|
||||
appId: string;
|
||||
appSecret: string;
|
||||
}) => {
|
||||
const globalValues = new GlobalValues();
|
||||
const { cloudUrlSet, adminUrlSet } = globalValues;
|
||||
const { tenantId, connectorId, appId, appSecret } = data;
|
||||
|
||||
return {
|
||||
id: generateStandardId(),
|
||||
tenantId,
|
||||
connectorId,
|
||||
config: {
|
||||
appId,
|
||||
appSecret,
|
||||
tokenEndpoint: appendPath(adminUrlSet.endpoint, 'oidc/token').toString(),
|
||||
endpoint: appendPath(cloudUrlSet.endpoint, 'api').toString(),
|
||||
resource: cloudApiIndicator,
|
||||
},
|
||||
};
|
||||
};
|
|
@ -1,11 +0,0 @@
|
|||
import type { AllConnector } from '@logto/connector-kit';
|
||||
import type { Connector } from '@logto/schemas';
|
||||
|
||||
export { ConnectorType } from '@logto/schemas';
|
||||
|
||||
/**
|
||||
* The connector type with full context.
|
||||
*/
|
||||
export type LogtoConnector<T extends AllConnector = AllConnector> = T & {
|
||||
validateConfig: (config: unknown) => void;
|
||||
} & { dbEntry: Connector };
|
|
@ -1,13 +0,0 @@
|
|||
import type { Json } from '@withtyped/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* `jsonGuard` copied from https://github.com/colinhacks/zod#json-type.
|
||||
* Can be moved to @logto/shared if needed.
|
||||
*/
|
||||
|
||||
const jsonGuard: z.ZodType<Json> = z.lazy(() =>
|
||||
z.union([z.number(), z.boolean(), z.string(), z.null(), z.array(jsonGuard), z.record(jsonGuard)])
|
||||
);
|
||||
|
||||
export const jsonObjectGuard = z.record(jsonGuard);
|
|
@ -1,63 +0,0 @@
|
|||
import type { createQueryClient } from '@withtyped/postgres';
|
||||
|
||||
type CreateClientConfig = Parameters<typeof createQueryClient>[0];
|
||||
|
||||
/* eslint-disable @silverhand/fp/no-mutation */
|
||||
// Edited from https://github.com/gajus/slonik/blob/d66b76c44638c8b424cea55475d6e1385c2caae8/src/utilities/parseDsn.ts
|
||||
export const parseDsn = (dsn?: string): CreateClientConfig => {
|
||||
if (!dsn?.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(dsn);
|
||||
|
||||
const config: NonNullable<CreateClientConfig> = {};
|
||||
|
||||
if (url.host) {
|
||||
config.host = decodeURIComponent(url.hostname);
|
||||
}
|
||||
|
||||
if (url.port) {
|
||||
config.port = Number(url.port);
|
||||
}
|
||||
|
||||
const database = url.pathname.split('/')[1];
|
||||
|
||||
if (database) {
|
||||
config.database = decodeURIComponent(database);
|
||||
}
|
||||
|
||||
if (url.username) {
|
||||
config.user = decodeURIComponent(url.username);
|
||||
}
|
||||
|
||||
if (url.password) {
|
||||
config.password = decodeURIComponent(url.password);
|
||||
}
|
||||
|
||||
const {
|
||||
application_name: applicationName,
|
||||
sslmode: sslMode,
|
||||
...unsupportedOptions
|
||||
} = Object.fromEntries(url.searchParams);
|
||||
|
||||
if (Object.keys(unsupportedOptions).length > 0) {
|
||||
console.warn(
|
||||
{
|
||||
unsupportedOptions,
|
||||
},
|
||||
'unsupported DSN parameters'
|
||||
);
|
||||
}
|
||||
|
||||
if (applicationName) {
|
||||
config.application_name = applicationName;
|
||||
}
|
||||
|
||||
if (sslMode === 'require') {
|
||||
config.ssl = true;
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
/* eslint-enable @silverhand/fp/no-mutation */
|
|
@ -1,13 +0,0 @@
|
|||
import { id, jsonIfNeeded, sql } from '@withtyped/postgres';
|
||||
import type { JsonObject } from '@withtyped/server';
|
||||
import decamelize from 'decamelize';
|
||||
|
||||
export const insertInto = <T extends JsonObject>(object: T, table: string) => {
|
||||
const entries = Object.entries(object);
|
||||
|
||||
return sql`
|
||||
insert into ${id(table)}
|
||||
(${entries.map(([key]) => id(decamelize(key)))})
|
||||
values (${entries.map(([, value]) => jsonIfNeeded(value))})
|
||||
`;
|
||||
};
|
|
@ -1,9 +0,0 @@
|
|||
import { getManagementApiResourceIndicator } from '@logto/schemas';
|
||||
import { trySafe } from '@silverhand/essentials';
|
||||
|
||||
export const getTenantIdFromManagementApiIndicator = (indicator: string) => {
|
||||
const toMatch = '^' + getManagementApiResourceIndicator('([^.]*)') + '$';
|
||||
const url = trySafe(() => new URL(indicator));
|
||||
|
||||
return url && new RegExp(toMatch).exec(url.href)?.[1];
|
||||
};
|
|
@ -1,34 +0,0 @@
|
|||
import path from 'node:path';
|
||||
|
||||
export const normalizePath = (pathLike: string) => {
|
||||
const value = path.normalize(pathLike);
|
||||
|
||||
return value.length > 1 && value.endsWith('/') ? value.slice(0, -1) : value;
|
||||
};
|
||||
|
||||
export const matchPathname = (
|
||||
toMatch: string,
|
||||
pathname: string,
|
||||
ignorePathnames: string[] = []
|
||||
) => {
|
||||
const toMatchPathname = normalizePath(toMatch);
|
||||
const normalized = normalizePath(pathname);
|
||||
|
||||
if (ignorePathnames.some((prefix) => matchPathname(prefix, pathname))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalized === toMatchPathname) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
if (toMatchPathname === '/') {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (normalized.startsWith(toMatchPathname + '/')) {
|
||||
return normalized.slice(toMatchPathname.length);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"extends": "./tsconfig",
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/__mocks__/",
|
||||
]
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"extends": "@silverhand/ts-config/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"types": ["node", "jest"],
|
||||
"declaration": true,
|
||||
"outDir": "build",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"#src/*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"extends": "./tsconfig",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": false,
|
||||
"allowJs": true,
|
||||
}
|
||||
}
|
|
@ -26,7 +26,7 @@
|
|||
"@fontsource/roboto-mono": "^5.0.0",
|
||||
"@jest/types": "^29.5.0",
|
||||
"@logto/app-insights": "workspace:^1.3.1",
|
||||
"@logto/cloud": "workspace:^",
|
||||
"@logto/cloud": "0.2.5-1a68662",
|
||||
"@logto/connector-kit": "workspace:^1.1.1",
|
||||
"@logto/core-kit": "workspace:^2.0.1",
|
||||
"@logto/language-kit": "workspace:^1.0.0",
|
||||
|
|
|
@ -12,11 +12,9 @@
|
|||
"scripts": {
|
||||
"build": "rm -rf lib/ && tsc -p tsconfig.test.json --sourcemap",
|
||||
"test:only": "NODE_OPTIONS=--experimental-vm-modules jest",
|
||||
"test": "pnpm build && pnpm test:api && pnpm test:api-cloud && pnpm test:ui",
|
||||
"test": "pnpm build && pnpm test:api && pnpm test:ui",
|
||||
"test:api": "pnpm test:only -i ./lib/tests/api/",
|
||||
"test:api-cloud": "pnpm test:only -i ./lib/tests/api-cloud/",
|
||||
"test:ui": "pnpm test:only -i --config=jest.config.ui.js ./lib/tests/ui/",
|
||||
"test:ui-cloud": "pnpm test:only -i --config=jest.config.ui.js ./lib/tests/ui-cloud/",
|
||||
"lint": "eslint --ext .ts src",
|
||||
"lint:report": "pnpm lint --format json --output-file report.json",
|
||||
"start": "pnpm test"
|
||||
|
|
|
@ -1,162 +0,0 @@
|
|||
import {
|
||||
adminTenantId,
|
||||
getManagementApiResourceIndicator,
|
||||
getManagementApiAdminName,
|
||||
cloudApiIndicator,
|
||||
CloudScope,
|
||||
AdminTenantRole,
|
||||
ApplicationType,
|
||||
adminConsoleApplicationId,
|
||||
type Application,
|
||||
type Resource,
|
||||
type Scope,
|
||||
type Role,
|
||||
defaultTenantId,
|
||||
} from '@logto/schemas';
|
||||
import { TenantTag, type TenantInfo } from '@logto/schemas/models';
|
||||
import { GlobalValues } from '@logto/shared';
|
||||
import { appendPath } from '@silverhand/essentials';
|
||||
|
||||
import { authedAdminTenantApi } from '#src/api/api.js';
|
||||
import { updateTenant, createTenant, getTenants, deleteTenant } from '#src/api/tenant.js';
|
||||
import { createUserAndSignInToCloudClient } from '#src/helpers/admin-tenant.js';
|
||||
|
||||
describe('Tenant APIs', () => {
|
||||
it('should be able to create multiple tenants for `admin` role', async () => {
|
||||
const { client } = await createUserAndSignInToCloudClient(AdminTenantRole.Admin);
|
||||
const accessToken = await client.getAccessToken(cloudApiIndicator);
|
||||
const payload1 = {
|
||||
name: 'tenant1',
|
||||
tag: TenantTag.Staging,
|
||||
};
|
||||
const tenant1 = await createTenant(accessToken, payload1);
|
||||
const payload2 = {
|
||||
name: 'tenant2',
|
||||
tag: TenantTag.Production,
|
||||
};
|
||||
const tenant2 = await createTenant(accessToken, payload2);
|
||||
for (const [payload, tenant] of [
|
||||
[payload1, tenant1],
|
||||
[payload2, tenant2],
|
||||
] as Array<[{ name: string; tag: TenantTag }, TenantInfo]>) {
|
||||
expect(tenant).toHaveProperty('id');
|
||||
expect(tenant).toHaveProperty('tag', payload.tag);
|
||||
expect(tenant).toHaveProperty('name', payload.name);
|
||||
}
|
||||
const tenant2Updated = await updateTenant(accessToken, tenant2.id, {
|
||||
tag: TenantTag.Staging,
|
||||
name: 'tenant2-updated',
|
||||
});
|
||||
expect(tenant2Updated.id).toEqual(tenant2.id);
|
||||
expect(tenant2Updated).toHaveProperty('tag', TenantTag.Staging);
|
||||
expect(tenant2Updated).toHaveProperty('name', 'tenant2-updated');
|
||||
const tenants = await getTenants(accessToken);
|
||||
expect(tenants.length).toBeGreaterThan(2);
|
||||
expect(tenants.find((tenant) => tenant.id === tenant1.id)).toStrictEqual(tenant1);
|
||||
expect(tenants.find((tenant) => tenant.id === tenant2Updated.id)).toStrictEqual(tenant2Updated);
|
||||
await expect(deleteTenant(accessToken, adminTenantId)).rejects.toThrow();
|
||||
await expect(deleteTenant(accessToken, defaultTenantId)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should be able to create multiple tenants for `user` role', async () => {
|
||||
const { client } = await createUserAndSignInToCloudClient(AdminTenantRole.User);
|
||||
const accessToken = await client.getAccessToken(cloudApiIndicator);
|
||||
const payload1 = {
|
||||
name: 'tenant1',
|
||||
tag: TenantTag.Staging,
|
||||
};
|
||||
const tenant1 = await createTenant(accessToken, payload1);
|
||||
const payload2 = {
|
||||
name: 'tenant2',
|
||||
tag: TenantTag.Development,
|
||||
};
|
||||
const tenant2 = await createTenant(accessToken, payload2);
|
||||
const payload3 = {
|
||||
name: 'tenant3',
|
||||
tag: TenantTag.Production,
|
||||
};
|
||||
const tenant3 = await createTenant(accessToken, payload3);
|
||||
for (const [payload, tenant] of [
|
||||
[payload1, tenant1],
|
||||
[payload2, tenant2],
|
||||
[payload3, tenant3],
|
||||
] as Array<[{ name: string; tag: TenantTag }, TenantInfo]>) {
|
||||
expect(tenant).toHaveProperty('id');
|
||||
expect(tenant).toHaveProperty('tag', payload.tag);
|
||||
expect(tenant).toHaveProperty('name', payload.name);
|
||||
}
|
||||
await expect(
|
||||
createTenant(accessToken, { name: 'tenant4', tag: TenantTag.Staging })
|
||||
).rejects.toThrow();
|
||||
await deleteTenant(accessToken, tenant3.id);
|
||||
const resources = await authedAdminTenantApi.get('resources').json<Resource[]>();
|
||||
expect(
|
||||
resources.filter(
|
||||
(resource) =>
|
||||
resource.tenantId === adminTenantId &&
|
||||
resource.indicator === getManagementApiResourceIndicator(tenant3.id)
|
||||
).length
|
||||
).toBe(0);
|
||||
const roles = await authedAdminTenantApi.get('roles').json<Role[]>();
|
||||
expect(
|
||||
roles.filter(
|
||||
(role) =>
|
||||
role.tenantId === adminTenantId && role.name === getManagementApiAdminName(tenant3.id)
|
||||
).length
|
||||
).toBe(0);
|
||||
const applications = await authedAdminTenantApi.get('applications').json<Application[]>();
|
||||
expect(
|
||||
applications.filter(
|
||||
(application) =>
|
||||
application.tenantId === adminTenantId &&
|
||||
application.type === ApplicationType.MachineToMachine &&
|
||||
application.customClientMetadata.tenantId === tenant3.id
|
||||
).length
|
||||
).toBe(0);
|
||||
const adminConsoleApplication = applications.find(
|
||||
(application) =>
|
||||
application.tenantId === adminTenantId && application.id === adminConsoleApplicationId
|
||||
);
|
||||
expect(adminConsoleApplication).toBeDefined();
|
||||
const urls = new GlobalValues().cloudUrlSet
|
||||
.deduplicated()
|
||||
.map((endpoint) => appendPath(endpoint, tenant3.id, 'callback'))
|
||||
.map(String);
|
||||
expect(
|
||||
urls.every((url) => !adminConsoleApplication!.oidcClientMetadata.redirectUris.includes(url))
|
||||
).toBeTruthy();
|
||||
const tenants = await getTenants(accessToken);
|
||||
expect(tenants.length).toEqual(2);
|
||||
expect(tenants.find((tenant) => tenant.id === tenant1.id)).toStrictEqual(tenant1);
|
||||
expect(tenants.find((tenant) => tenant.id === tenant2.id)).toStrictEqual(tenant2);
|
||||
const { client: anotherClient } = await createUserAndSignInToCloudClient(AdminTenantRole.User);
|
||||
const anotherAccessToken = await anotherClient.getAccessToken(cloudApiIndicator);
|
||||
const anotherTenant = await createTenant(anotherAccessToken, {
|
||||
name: 'another-tenant',
|
||||
tag: TenantTag.Development,
|
||||
});
|
||||
await expect(
|
||||
updateTenant(accessToken, anotherTenant.id, { name: 'another-tenant-updated' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('`user` role should have `CloudScope.ManageTenantSelf` scope', async () => {
|
||||
const resources = await authedAdminTenantApi.get('resources').json<Resource[]>();
|
||||
const cloudApiResource = resources.find(({ indicator }) => indicator === cloudApiIndicator);
|
||||
expect(cloudApiResource).toBeDefined();
|
||||
const scopes = await authedAdminTenantApi
|
||||
.get(`resources/${cloudApiResource!.id}/scopes`)
|
||||
.json<Scope[]>();
|
||||
const manageTenantSelfScope = scopes.find(
|
||||
(scope) => scope.name === CloudScope.ManageTenantSelf
|
||||
);
|
||||
expect(manageTenantSelfScope).toBeDefined();
|
||||
const roles = await authedAdminTenantApi.get('roles').json<Role[]>();
|
||||
const userRole = roles.find(({ name }) => name === 'user');
|
||||
expect(userRole).toBeDefined();
|
||||
const roleScopes = await authedAdminTenantApi
|
||||
.get(`roles/${userRole!.id}/scopes`)
|
||||
.json<Scope[]>();
|
||||
expect(roleScopes.find(({ id }) => id === manageTenantSelfScope!.id)).toBeDefined();
|
||||
});
|
||||
});
|
|
@ -1,94 +0,0 @@
|
|||
import { type Page } from 'puppeteer';
|
||||
|
||||
export const onboardingWelcome = async (page: Page) => {
|
||||
// Select the project type option
|
||||
await page.click('div[role=radio]:has(input[name=project][value=personal])');
|
||||
|
||||
// Select the deployment type option
|
||||
await page.click('div[role=radio]:has(input[name=deploymentType][value=open-source])');
|
||||
|
||||
// Click the next button
|
||||
await page.click('div[class$=actions] button:first-child');
|
||||
};
|
||||
|
||||
export const onboardingUserSurvey = async (page: Page) => {
|
||||
// Wait for the sie config to load
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Select the first reason option
|
||||
await page.click('div[role=button][class$=item]');
|
||||
|
||||
// Click the next button
|
||||
await expect(page).toClick('div[class$=actions] button', { text: 'Next' });
|
||||
};
|
||||
|
||||
export const onboardingSieConfig = async (page: Page) => {
|
||||
// Wait for the sie config to load
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Select username as the identifier
|
||||
await page.click('div[role=radio]:has(input[name=identifier][value=username])');
|
||||
|
||||
// Click the finish button
|
||||
await page.click('div[class$=continueActions] button:last-child');
|
||||
};
|
||||
|
||||
export const onboardingFinish = async (page: Page) => {
|
||||
// Wait for the sie config to load
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Click the enter ac button
|
||||
await page.click('div[class$=content] >button');
|
||||
|
||||
// Wait for the admin console to load
|
||||
await page.waitForNavigation({ waitUntil: 'networkidle0' });
|
||||
};
|
||||
|
||||
export const openTenantDropdown = async (page: Page) => {
|
||||
// Click 'current tenant card' locates in topbar
|
||||
const currentTenantCard = await page.waitForSelector(
|
||||
'div[class$=topbar] > div[class$=currentTenantCard][role=button]:has(div[class$=name])'
|
||||
);
|
||||
await currentTenantCard?.click();
|
||||
};
|
||||
|
||||
export const openCreateTenantModal = async (page: Page) => {
|
||||
const createTenantButton = await page.waitForSelector(
|
||||
'div[class$=ReactModalPortal] div[class$=dropdownContainer] div[class$=dropdown] button[class$=createTenantButton]:has(div)'
|
||||
);
|
||||
await createTenantButton?.click();
|
||||
};
|
||||
|
||||
export const fillAndCreateTenant = async (page: Page, tenantName: string) => {
|
||||
// Create tenant with name 'new-tenant' and tag 'production'
|
||||
await page.waitForTimeout(500);
|
||||
await page.waitForSelector(
|
||||
'div[class$=ReactModalPortal] div[class*=card][class$=medium] input[type=text][name=name]'
|
||||
);
|
||||
await page.waitForSelector(
|
||||
'div[class$=ReactModalPortal] div[class*=radioGroup][class$=small] div[class*=radio][class$=small][role=radio] > div[class$=content]:has(input[value=production])'
|
||||
);
|
||||
await page.type(
|
||||
'div[class$=ReactModalPortal] div[class*=card][class$=medium] input[type=text][name=name]',
|
||||
tenantName
|
||||
);
|
||||
await page.click(
|
||||
'div[class$=ReactModalPortal] div[class*=radioGroup][class$=small] div[class*=radio][class$=small][role=radio] > div[class$=content]:has(input[value=production])'
|
||||
);
|
||||
|
||||
// Click create button
|
||||
await page.waitForTimeout(500);
|
||||
await page.click(
|
||||
'div[class$=ReactModalPortal] div[class*=card][class$=medium] div[class$=footer] button[type=submit]'
|
||||
);
|
||||
};
|
||||
|
||||
export const createNewTenant = async (page: Page, tenantName: string) => {
|
||||
await page.waitForTimeout(500);
|
||||
await openTenantDropdown(page);
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
await openCreateTenantModal(page);
|
||||
|
||||
await fillAndCreateTenant(page, tenantName);
|
||||
};
|
|
@ -1,245 +0,0 @@
|
|||
import { defaultTenantId } from '@logto/schemas';
|
||||
import { appendPath } from '@silverhand/essentials';
|
||||
import { setDefaultOptions } from 'expect-puppeteer';
|
||||
|
||||
import { logtoCloudUrl as logtoCloudUrlString, logtoConsoleUrl } from '#src/constants.js';
|
||||
import { generatePassword } from '#src/utils.js';
|
||||
|
||||
import {
|
||||
onboardingWelcome,
|
||||
onboardingUserSurvey,
|
||||
onboardingSieConfig,
|
||||
onboardingFinish,
|
||||
createNewTenant,
|
||||
fillAndCreateTenant,
|
||||
openTenantDropdown,
|
||||
openCreateTenantModal,
|
||||
} from './operations.js';
|
||||
|
||||
await page.setViewport({ width: 1280, height: 720 });
|
||||
setDefaultOptions({ timeout: 5000 });
|
||||
|
||||
/**
|
||||
* NOTE: This test suite assumes test cases will run sequentially (which is Jest default).
|
||||
* Parallel execution will lead to errors.
|
||||
*/
|
||||
// Tip: See https://github.com/argos-ci/jest-puppeteer/blob/main/packages/expect-puppeteer/README.md
|
||||
// for convenient expect methods
|
||||
describe('smoke testing for cloud', () => {
|
||||
const consoleUsername = 'admin';
|
||||
const consolePassword = generatePassword();
|
||||
const logtoCloudUrl = new URL(logtoCloudUrlString);
|
||||
const adminTenantUrl = new URL(logtoConsoleUrl); // In dev mode, the console URL is actually for admin tenant
|
||||
|
||||
const createTenantName = 'new-tenant';
|
||||
|
||||
it('can open with app element and navigate to register page', async () => {
|
||||
await page.goto(logtoCloudUrl.href);
|
||||
await page.waitForNavigation({ waitUntil: 'networkidle0' });
|
||||
|
||||
await expect(page.waitForSelector('#app')).resolves.not.toBeNull();
|
||||
expect(page.url()).toBe(appendPath(adminTenantUrl, '/register').href);
|
||||
});
|
||||
|
||||
it('can register the first admin account', async () => {
|
||||
await expect(page).toFill('input[name=identifier]', consoleUsername);
|
||||
await expect(page).toClick('button[name=submit]');
|
||||
|
||||
await page.waitForNavigation({ waitUntil: 'networkidle0' });
|
||||
expect(page.url()).toBe(appendPath(adminTenantUrl, '/register/password').href);
|
||||
|
||||
await expect(page).toFillForm('form', {
|
||||
newPassword: consolePassword,
|
||||
confirmPassword: consolePassword,
|
||||
});
|
||||
await expect(page).toClick('button[name=submit]');
|
||||
|
||||
await page.waitForNavigation({ waitUntil: 'networkidle0' });
|
||||
|
||||
expect(page.url()).toBe(
|
||||
appendPath(logtoCloudUrl, `/${defaultTenantId}/onboarding/welcome`).href
|
||||
);
|
||||
});
|
||||
|
||||
it('can complete the onboarding welcome process and enter the user survey page', async () => {
|
||||
await onboardingWelcome(page);
|
||||
|
||||
// Wait for the next page to load
|
||||
await expect(page).toMatchElement('div[class$=content] div[class$=title]', {
|
||||
text: 'A little bit about you',
|
||||
});
|
||||
|
||||
expect(new URL(page.url()).pathname.endsWith('/onboarding/about-user')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('can complete the onboarding user survey process and enter the sie page', async () => {
|
||||
await onboardingUserSurvey(page);
|
||||
|
||||
// Wait for the next page to load
|
||||
await expect(page).toMatchElement('div[class$=config] div[class$=title]', {
|
||||
text: 'Let’s first customize your sign-in experience with ease',
|
||||
});
|
||||
|
||||
expect(new URL(page.url()).pathname.endsWith('/onboarding/sign-in-experience')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('can complete the sie configuration process and enter the congrats page', async () => {
|
||||
await onboardingSieConfig(page);
|
||||
|
||||
// Wait for the next page to load
|
||||
await expect(page).toMatchElement('div[class$=content] div[class$=title]', {
|
||||
text: 'Great news! You are qualified to earn Logto Cloud early credit!',
|
||||
});
|
||||
|
||||
expect(new URL(page.url()).pathname.endsWith('/onboarding/congrats')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('can complete the onboarding process and enter the admin console', async () => {
|
||||
await onboardingFinish(page);
|
||||
const mainContent = await page.waitForSelector('div[class$=main]:has(div[class$=title])');
|
||||
await expect(mainContent).toMatchElement('div[class$=title]', {
|
||||
text: 'Something to explore to help you succeed',
|
||||
});
|
||||
|
||||
expect(new URL(page.url()).pathname.endsWith('/get-started')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('can create a new tenant using tenant dropdown', async () => {
|
||||
await page.waitForTimeout(2000);
|
||||
await createNewTenant(page, createTenantName);
|
||||
|
||||
expect(new URL(page.url()).pathname.endsWith(`/get-started`)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should navigate to the new tenant', async () => {
|
||||
// Wait for toast to disappear.
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
// Click 'current tenant card' locates in topbar
|
||||
const currentTenantCard = await page.waitForSelector(
|
||||
'div[class$=topbar] > div[class$=currentTenantCard][role=button]:has(div[class$=name])'
|
||||
);
|
||||
await expect(currentTenantCard).toMatchElement('div[class$=name]', { text: createTenantName });
|
||||
});
|
||||
|
||||
it('can sign out of admin console', async () => {
|
||||
const userInfoButton = await page.waitForSelector('div[class$=topbar] > div[class$=container]');
|
||||
await userInfoButton?.click();
|
||||
|
||||
// Try awaiting for 500ms before clicking sign-out button
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const signOutButton = await page.waitForSelector(
|
||||
'div[class$=ReactModalPortal] div[class$=dropdownContainer] div[class$=dropdownItem]:last-child'
|
||||
);
|
||||
await signOutButton?.click();
|
||||
|
||||
await page.waitForNavigation({ waitUntil: 'networkidle0' });
|
||||
|
||||
expect(page.url()).toBe(new URL('sign-in', logtoConsoleUrl).href);
|
||||
});
|
||||
|
||||
it('can create another account', async () => {
|
||||
const newUsername = 'another_admin';
|
||||
const newPassword = generatePassword();
|
||||
|
||||
await expect(page).toClick('a', { text: 'Create account' });
|
||||
await expect(page).toMatchElement('button', { text: 'Create account' });
|
||||
await expect(page).toFill('input[name=identifier]', newUsername);
|
||||
await expect(page).toClick('button[name=submit]');
|
||||
|
||||
await page.waitForNavigation({ waitUntil: 'networkidle0' });
|
||||
expect(page.url()).toBe(new URL('/register/password', logtoConsoleUrl).href);
|
||||
|
||||
await expect(page).toFillForm('form', {
|
||||
newPassword,
|
||||
confirmPassword: newPassword,
|
||||
});
|
||||
|
||||
await expect(page).toClick('button[name=submit]');
|
||||
await page.waitForNavigation({ waitUntil: 'networkidle0' });
|
||||
|
||||
expect(page.url().startsWith(logtoCloudUrl.origin)).toBeTruthy();
|
||||
expect(page.url().endsWith('/onboarding/welcome')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('can complete onboarding process with new account', async () => {
|
||||
await onboardingWelcome(page);
|
||||
await onboardingUserSurvey(page);
|
||||
await onboardingSieConfig(page);
|
||||
await onboardingFinish(page);
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
expect(new URL(page.url()).pathname.endsWith('/get-started')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('go to tenant settings and delete current tenant', async () => {
|
||||
await page.waitForTimeout(2000);
|
||||
const tenantSettingButton = await page.waitForSelector(
|
||||
'div[class$=content] > div[class$=sidebar] a[class$=row][href$=tenant-settings] > div[class$=title]'
|
||||
);
|
||||
await tenantSettingButton?.click();
|
||||
|
||||
const deleteButton = await page.waitForSelector(
|
||||
'div[class$=main] form[class$=container] div[class$=deletionButtonContainer] button[class$=medium][type=button]'
|
||||
);
|
||||
await deleteButton?.click();
|
||||
|
||||
const textInput = await page.waitForSelector(
|
||||
'div[class$=ReactModalPortal] div[class$=container] input[type=text]'
|
||||
);
|
||||
await textInput?.type('My Project');
|
||||
|
||||
const deleteConfirmButton = await page.waitForSelector(
|
||||
'div[class$=ReactModalPortal] div[class$=footer] > button:last-child'
|
||||
);
|
||||
await deleteConfirmButton?.click();
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
const placeholderTitle = await page.waitForSelector(
|
||||
'div[class$=pageContainer] div[class$=placeholder]:has(div[class$=title])'
|
||||
);
|
||||
await expect(placeholderTitle).toMatchElement('div[class$=title]', {
|
||||
text: 'You haven’t created a tenant yet',
|
||||
});
|
||||
});
|
||||
|
||||
it('can create tenant from landing page', async () => {
|
||||
const createTenantButton = await page.waitForSelector(
|
||||
'div[class$=pageContainer] div[class$=placeholder] button[class$=button][type=button]'
|
||||
);
|
||||
await createTenantButton?.click();
|
||||
|
||||
await fillAndCreateTenant(page, 'tenant1');
|
||||
await page.waitForTimeout(5000);
|
||||
expect(new URL(page.url()).pathname.endsWith('/get-started')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('can create two more tenant for new account', async () => {
|
||||
await createNewTenant(page, 'tenant2');
|
||||
await page.waitForTimeout(5000);
|
||||
await createNewTenant(page, 'tenant3');
|
||||
await page.waitForTimeout(5000);
|
||||
expect(new URL(page.url()).pathname.endsWith('/get-started')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('can not open create tenant modal when reach the limit', async () => {
|
||||
await page.waitForTimeout(2000);
|
||||
await openTenantDropdown(page);
|
||||
await openCreateTenantModal(page);
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
const pageTitle = await page.waitForSelector(
|
||||
'div[class$=main] > div[class$=container] > div[class$=header]:has(div[class$=title])'
|
||||
);
|
||||
await expect(pageTitle).toMatchElement('div[class$=title]', {
|
||||
text: 'Something to explore to help you succeed',
|
||||
});
|
||||
|
||||
const createTenantModalTitleSelector =
|
||||
'div[class$=ReactModalPortal] div[class$=iconAndTitle] > div[class*=container][class$=large]:has(div[class*=title][class$=titleEllipsis])';
|
||||
await expect(
|
||||
page.waitForSelector(createTenantModalTitleSelector, { timeout: 3000 })
|
||||
).rejects.toThrow(); // Throws error if selector is not found.
|
||||
}, 20_000);
|
||||
});
|
162
pnpm-lock.yaml
162
pnpm-lock.yaml
|
@ -234,112 +234,6 @@ importers:
|
|||
specifier: ^5.0.0
|
||||
version: 5.0.2
|
||||
|
||||
packages/cloud:
|
||||
dependencies:
|
||||
'@logto/app-insights':
|
||||
specifier: workspace:^1.0.0
|
||||
version: link:../app-insights
|
||||
'@logto/cli':
|
||||
specifier: workspace:^1.5.0
|
||||
version: link:../cli
|
||||
'@logto/connector-kit':
|
||||
specifier: workspace:^1.1.1
|
||||
version: link:../toolkit/connector-kit
|
||||
'@logto/core-kit':
|
||||
specifier: workspace:^2.0.0
|
||||
version: link:../toolkit/core-kit
|
||||
'@logto/schemas':
|
||||
specifier: workspace:^1.5.0
|
||||
version: link:../schemas
|
||||
'@logto/shared':
|
||||
specifier: workspace:^2.0.0
|
||||
version: link:../shared
|
||||
'@silverhand/essentials':
|
||||
specifier: ^2.5.0
|
||||
version: 2.5.0
|
||||
'@withtyped/postgres':
|
||||
specifier: ^0.12.0
|
||||
version: 0.12.0(@withtyped/server@0.12.0)
|
||||
'@withtyped/server':
|
||||
specifier: ^0.12.0
|
||||
version: 0.12.0(zod@3.20.2)
|
||||
accepts:
|
||||
specifier: ^1.3.8
|
||||
version: 1.3.8
|
||||
chalk:
|
||||
specifier: ^5.0.0
|
||||
version: 5.1.2
|
||||
decamelize:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
dotenv:
|
||||
specifier: ^16.0.0
|
||||
version: 16.0.0
|
||||
fetch-retry:
|
||||
specifier: ^5.0.4
|
||||
version: 5.0.4
|
||||
find-up:
|
||||
specifier: ^6.3.0
|
||||
version: 6.3.0
|
||||
helmet:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0
|
||||
http-proxy:
|
||||
specifier: ^1.18.1
|
||||
version: 1.18.1
|
||||
jose:
|
||||
specifier: ^4.11.0
|
||||
version: 4.11.1
|
||||
mime-types:
|
||||
specifier: ^2.1.35
|
||||
version: 2.1.35
|
||||
zod:
|
||||
specifier: ^3.20.2
|
||||
version: 3.20.2
|
||||
devDependencies:
|
||||
'@silverhand/eslint-config':
|
||||
specifier: 3.0.1
|
||||
version: 3.0.1(eslint@8.34.0)(prettier@2.8.4)(typescript@5.0.2)
|
||||
'@silverhand/jest-config':
|
||||
specifier: 3.0.0
|
||||
version: 3.0.0(jest@29.5.0)
|
||||
'@silverhand/ts-config':
|
||||
specifier: 3.0.0
|
||||
version: 3.0.0(typescript@5.0.2)
|
||||
'@types/accepts':
|
||||
specifier: ^1.3.5
|
||||
version: 1.3.5
|
||||
'@types/http-proxy':
|
||||
specifier: ^1.17.9
|
||||
version: 1.17.9
|
||||
'@types/jest':
|
||||
specifier: ^29.4.0
|
||||
version: 29.4.0
|
||||
'@types/mime-types':
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
'@types/node':
|
||||
specifier: ^18.11.18
|
||||
version: 18.11.18
|
||||
eslint:
|
||||
specifier: ^8.21.0
|
||||
version: 8.34.0
|
||||
jest:
|
||||
specifier: ^29.5.0
|
||||
version: 29.5.0(@types/node@18.11.18)
|
||||
lint-staged:
|
||||
specifier: ^13.0.0
|
||||
version: 13.0.0
|
||||
nodemon:
|
||||
specifier: ^2.0.19
|
||||
version: 2.0.19
|
||||
prettier:
|
||||
specifier: ^2.8.1
|
||||
version: 2.8.4
|
||||
typescript:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.2
|
||||
|
||||
packages/connectors/connector-alipay-native:
|
||||
dependencies:
|
||||
'@logto/connector-kit':
|
||||
|
@ -2782,8 +2676,8 @@ importers:
|
|||
specifier: workspace:^1.3.1
|
||||
version: link:../app-insights
|
||||
'@logto/cloud':
|
||||
specifier: workspace:^
|
||||
version: link:../cloud
|
||||
specifier: 0.2.5-1a68662
|
||||
version: 0.2.5-1a68662(zod@3.20.2)
|
||||
'@logto/connector-kit':
|
||||
specifier: workspace:^1.1.1
|
||||
version: link:../toolkit/connector-kit
|
||||
|
@ -7201,6 +7095,15 @@ packages:
|
|||
jose: 4.14.4
|
||||
dev: true
|
||||
|
||||
/@logto/cloud@0.2.5-1a68662(zod@3.20.2):
|
||||
resolution: {integrity: sha512-lmJiFO0cFGurg/B1dlebeyMMN8jIx7lALyqwATcAi1m902l6L92iN5DRkojnNOVw8d2XQ8zzeOyW+9I/Jew5Uw==}
|
||||
engines: {node: ^18.12.0}
|
||||
dependencies:
|
||||
'@withtyped/server': 0.12.0(zod@3.20.2)
|
||||
transitivePeerDependencies:
|
||||
- zod
|
||||
dev: true
|
||||
|
||||
/@logto/js@2.1.1:
|
||||
resolution: {integrity: sha512-PHikheavVK+l4ivgtzi14p184hEPgXjqQEAom1Gme1MZoopx+WlwxvHSEQBsmyvVqRtI0oiojhoU5tgYi1FKJw==}
|
||||
dependencies:
|
||||
|
@ -8724,17 +8627,6 @@ packages:
|
|||
resolution: {integrity: sha512-1b5u2BGEa14V3o8XzaE7eL+nuwmQe8c1wqSMcGvq+KAusPPZo9tV4glbfF16Xi/ohv37vUpBGJ2DNf4CfuxBLw==}
|
||||
engines: {node: ^16.13.0 || ^18.12.0 || ^19.2.0, pnpm: ^8.0.0}
|
||||
|
||||
/@silverhand/jest-config@3.0.0(jest@29.5.0):
|
||||
resolution: {integrity: sha512-5ezkX1/EVQiL2Y3lMY3jLT00GvyRgNKWAB/LIToVN0BQPnasdzKV6sSn2wrQD6XY3lNvR9TBdZajGmG/jtcRjA==}
|
||||
engines: {node: ^18.12.0}
|
||||
peerDependencies:
|
||||
jest: ^29.0.0 || ^29.1.2
|
||||
dependencies:
|
||||
'@jest/types': 29.5.0
|
||||
deepmerge: 4.2.2
|
||||
jest: 29.5.0(@types/node@18.11.18)
|
||||
dev: true
|
||||
|
||||
/@silverhand/ts-config-react@3.0.0(typescript@5.0.2):
|
||||
resolution: {integrity: sha512-zQL7kB6ropASS9/p7/g9PEsOIPAwjI20EVER8hShfV1jDURK9zXGDlbAhZbxLNS1n3z2AyPE+0ELEMZ7aIntRA==}
|
||||
engines: {node: ^18.12.0}
|
||||
|
@ -9301,12 +9193,6 @@ packages:
|
|||
resolution: {integrity: sha512-EqX+YQxINb+MeXaIqYDASb6U6FCHbWjkj4a1CKDBks3d/QiB2+PqBLyO72vLDgAO1wUI4O+9gweRcQK11bTL/w==}
|
||||
dev: true
|
||||
|
||||
/@types/http-proxy@1.17.9:
|
||||
resolution: {integrity: sha512-QsbSjA/fSk7xB+UXlCT3wHBy5ai9wOcNDWwZAtud+jXhwOM3l+EYZh8Lng4+/6n8uar0J7xILzqftJdJ/Wdfkw==}
|
||||
dependencies:
|
||||
'@types/node': 18.11.18
|
||||
dev: true
|
||||
|
||||
/@types/inquirer@9.0.3:
|
||||
resolution: {integrity: sha512-CzNkWqQftcmk2jaCWdBTf9Sm7xSw4rkI1zpU/Udw3HX5//adEZUIm9STtoRP1qgWj0CWQtJ9UTvqmO2NNjhMJw==}
|
||||
dependencies:
|
||||
|
@ -9441,10 +9327,6 @@ packages:
|
|||
resolution: {integrity: sha512-JPEv4iAl0I+o7g8yVWDwk30es8mfVrjkvh5UeVR2sYPpZCK44vrAPsbJpIS+rJAUxLgaSAMKTEH5Vn5qd9XsrQ==}
|
||||
dev: true
|
||||
|
||||
/@types/mime-types@2.1.1:
|
||||
resolution: {integrity: sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==}
|
||||
dev: true
|
||||
|
||||
/@types/mime@1.3.2:
|
||||
resolution: {integrity: sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==}
|
||||
dev: true
|
||||
|
@ -9500,6 +9382,7 @@ packages:
|
|||
'@types/node': 18.11.18
|
||||
pg-protocol: 1.6.0
|
||||
pg-types: 2.2.0
|
||||
dev: true
|
||||
|
||||
/@types/pluralize@0.0.29:
|
||||
resolution: {integrity: sha512-BYOID+l2Aco2nBik+iYS4SZX0Lf20KPILP5RGmM1IgzdwNdTs0eebiFriOPcej1sX9mLnSoiNte5zcFxssgpGA==}
|
||||
|
@ -9831,19 +9714,6 @@ packages:
|
|||
- zod
|
||||
dev: true
|
||||
|
||||
/@withtyped/postgres@0.12.0(@withtyped/server@0.12.0):
|
||||
resolution: {integrity: sha512-NLdWJu1RmVMOLrY8JDgKW0/qovJOr+D/hjdLKI2vK41xTzGf5op2B9oVlpdGFyLBDO+9E7jdMxpceN9j/MpBRg==}
|
||||
peerDependencies:
|
||||
'@withtyped/server': ^0.12.0
|
||||
dependencies:
|
||||
'@types/pg': 8.6.6
|
||||
'@withtyped/server': 0.12.0(zod@3.20.2)
|
||||
'@withtyped/shared': 0.2.1
|
||||
pg: 8.8.0
|
||||
transitivePeerDependencies:
|
||||
- pg-native
|
||||
dev: false
|
||||
|
||||
/@withtyped/server@0.12.0(zod@3.20.2):
|
||||
resolution: {integrity: sha512-u5Qe+gr1kK/5CJi7NKf2iIQkbXlxhPXdDYqc7IeoMn0QHGn1hSkB9G3FB6gtx7kI28LY1gSUii4CJf7vX40PZw==}
|
||||
peerDependencies:
|
||||
|
@ -12470,10 +12340,6 @@ packages:
|
|||
web-streams-polyfill: 3.2.1
|
||||
dev: true
|
||||
|
||||
/fetch-retry@5.0.4:
|
||||
resolution: {integrity: sha512-LXcdgpdcVedccGg0AZqg+S8lX/FCdwXD92WNZ5k5qsb0irRhSFsBOpcJt7oevyqT2/C2nEE0zSFNdBEpj3YOSw==}
|
||||
dev: false
|
||||
|
||||
/figures@5.0.0:
|
||||
resolution: {integrity: sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg==}
|
||||
engines: {node: '>=14'}
|
||||
|
@ -14455,10 +14321,6 @@ packages:
|
|||
resolution: {integrity: sha512-wLe+lJHeG8Xt6uEubS4x0LVjS/3kXXu9dGoj9BNnlhYq7Kts0Pbb2pvv5KiI0yaKH/eaiR0LUOBhOVo9ktd05A==}
|
||||
dev: false
|
||||
|
||||
/jose@4.11.1:
|
||||
resolution: {integrity: sha512-YRv4Tk/Wlug8qicwqFNFVEZSdbROCHRAC6qu/i0dyNKr5JQdoa2pIGoS04lLO/jXQX7Z9omoNewYIVIxqZBd9Q==}
|
||||
dev: false
|
||||
|
||||
/jose@4.14.2:
|
||||
resolution: {integrity: sha512-Fcbi5lskAiSvs8qhdQBusANZWwyATdp7IxgHJTXiaU74sbVjX9uAw+myDPvI8pNo2wXKHECXCR63hqhRkN/SSQ==}
|
||||
dev: true
|
||||
|
|
Loading…
Reference in a new issue