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/core @simeng-li @wangsijie @gao-sun
|
||||||
/packages/console @wangsijie @charIeszhao
|
/packages/console @wangsijie @charIeszhao
|
||||||
/packages/ui @simeng-li @charIeszhao
|
/packages/ui @simeng-li @charIeszhao
|
||||||
/packages/cloud @simeng-li
|
|
||||||
/packages/integration-tests @simeng-li
|
/packages/integration-tests @simeng-li
|
||||||
/.changeset @gao-sun
|
/.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:
|
jobs:
|
||||||
package:
|
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
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
@ -27,26 +22,13 @@ jobs:
|
||||||
uses: silverhand-io/actions-node-pnpm-run-steps@v3
|
uses: silverhand-io/actions-node-pnpm-run-steps@v3
|
||||||
|
|
||||||
- name: Build and package
|
- name: Build and package
|
||||||
if: matrix.env != 'cloud'
|
|
||||||
run: |
|
run: |
|
||||||
pnpm -r build
|
pnpm -r build
|
||||||
./.scripts/package.sh
|
./.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
|
- uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: integration-test-${{ github.sha }}-${{ matrix.env }}
|
name: integration-test-${{ github.sha }}
|
||||||
path: /tmp/logto.tar.gz
|
path: /tmp/logto.tar.gz
|
||||||
retention-days: 3
|
retention-days: 3
|
||||||
|
|
||||||
|
@ -54,14 +36,12 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
target: [api, api-cloud, ui, ui-cloud]
|
target: [api, ui]
|
||||||
|
|
||||||
needs: package
|
needs: package
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
INTEGRATION_TEST: true
|
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
|
DB_URL: postgres://postgres:postgres@localhost:5432/postgres
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
@ -100,15 +80,14 @@ jobs:
|
||||||
|
|
||||||
- uses: actions/download-artifact@v3
|
- uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: integration-test-${{ github.sha }}-${{ contains(matrix.target, 'cloud') && 'cloud' || 'oss' }}
|
name: integration-test-${{ github.sha }}
|
||||||
|
|
||||||
- name: Extract
|
- name: Extract
|
||||||
working-directory: tests
|
working-directory: tests
|
||||||
run: |
|
run: |
|
||||||
npm run cli init -- \
|
npm run cli init -- \
|
||||||
-p ../logto \
|
-p ../logto \
|
||||||
--du ../logto.tar.gz \
|
--du ../logto.tar.gz
|
||||||
${{ contains(matrix.target, 'cloud') && '--cloud' || '' }}
|
|
||||||
|
|
||||||
- name: Check and add mock connectors
|
- name: Check and add mock connectors
|
||||||
working-directory: tests
|
working-directory: tests
|
||||||
|
@ -128,11 +107,6 @@ jobs:
|
||||||
env:
|
env:
|
||||||
REDIS_URL: 1
|
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
|
- name: Sleep for 5 seconds
|
||||||
run: sleep 5
|
run: sleep 5
|
||||||
|
|
||||||
|
@ -151,13 +125,3 @@ jobs:
|
||||||
- name: Show error logs
|
- name: Show error logs
|
||||||
working-directory: logto/
|
working-directory: logto/
|
||||||
run: cat nohup.err
|
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
|
build-args: | # Test cloud build
|
||||||
additional_connector_args=--cloud
|
additional_connector_args=--cloud
|
||||||
|
|
||||||
- name: Build cloud
|
|
||||||
uses: docker/build-push-action@v4
|
|
||||||
with:
|
|
||||||
file: Dockerfile.cloud
|
|
||||||
context: .
|
|
||||||
|
|
||||||
main-alteration:
|
main-alteration:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
|
148
.github/workflows/release.yml
vendored
148
.github/workflows/release.yml
vendored
|
@ -2,16 +2,6 @@ name: Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
|
||||||
target:
|
|
||||||
description: 'The release target of Logto'
|
|
||||||
required: true
|
|
||||||
default: dev
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- prod
|
|
||||||
- dev
|
|
||||||
|
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
|
@ -20,10 +10,10 @@ concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
dockerize-core:
|
dockerize:
|
||||||
environment: ${{ startsWith(github.ref, 'refs/tags/') && 'release' || '' }}
|
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||||
# Use normal machine for OSS release since we'll build on Depot
|
environment: 'release'
|
||||||
runs-on: ${{ (inputs.target || 'dev') == 'dev' && 'ubuntu-latest' || 'ubuntu-latest-4-cores' }}
|
runs-on: 'ubuntu-latest'
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
id-token: write
|
id-token: write
|
||||||
|
@ -45,7 +35,6 @@ jobs:
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
type=raw,value=${{ inputs.target || 'dev' }},enable=${{ !startsWith(github.ref, 'refs/tags/') }}
|
|
||||||
type=edge
|
type=edge
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
|
@ -61,28 +50,10 @@ jobs:
|
||||||
username: silverhand-bot
|
username: silverhand-bot
|
||||||
password: ${{ secrets.BOT_PAT }}
|
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
|
- name: Setup Depot
|
||||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
|
||||||
uses: depot/setup-action@v1
|
uses: depot/setup-action@v1
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
|
||||||
uses: depot/build-push-action@v1
|
uses: depot/build-push-action@v1
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64, linux/arm64
|
platforms: linux/amd64, linux/arm64
|
||||||
|
@ -92,117 +63,6 @@ jobs:
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
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 packages and create git tags if needed
|
||||||
publish-and-tag:
|
publish-and-tag:
|
||||||
runs-on: ubuntu-latest
|
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
|
Mozilla Public License Version 2.0
|
||||||
==================================
|
==================================
|
||||||
|
|
||||||
|
|
|
@ -9,11 +9,8 @@
|
||||||
"prepare": "if test \"$NODE_ENV\" != \"production\" && test \"$CI\" != \"true\" ; then husky install ; fi",
|
"prepare": "if test \"$NODE_ENV\" != \"production\" && test \"$CI\" != \"true\" ; then husky install ; fi",
|
||||||
"prepack": "pnpm -r prepack",
|
"prepack": "pnpm -r prepack",
|
||||||
"dev": "pnpm -r prepack && pnpm start:dev",
|
"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-*\" dev",
|
||||||
"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": "cd packages/core && NODE_ENV=production node .",
|
"start": "cd packages/core && NODE_ENV=production node .",
|
||||||
"start:cloud": "cd packages/cloud && NODE_ENV=production node .",
|
|
||||||
"cli": "logto",
|
"cli": "logto",
|
||||||
"alteration": "logto db alt",
|
"alteration": "logto db alt",
|
||||||
"connectors:build": "pnpm -r --filter \"./packages/connectors/connector-*\" build",
|
"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",
|
"@fontsource/roboto-mono": "^5.0.0",
|
||||||
"@jest/types": "^29.5.0",
|
"@jest/types": "^29.5.0",
|
||||||
"@logto/app-insights": "workspace:^1.3.1",
|
"@logto/app-insights": "workspace:^1.3.1",
|
||||||
"@logto/cloud": "workspace:^",
|
"@logto/cloud": "0.2.5-1a68662",
|
||||||
"@logto/connector-kit": "workspace:^1.1.1",
|
"@logto/connector-kit": "workspace:^1.1.1",
|
||||||
"@logto/core-kit": "workspace:^2.0.1",
|
"@logto/core-kit": "workspace:^2.0.1",
|
||||||
"@logto/language-kit": "workspace:^1.0.0",
|
"@logto/language-kit": "workspace:^1.0.0",
|
||||||
|
|
|
@ -12,11 +12,9 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "rm -rf lib/ && tsc -p tsconfig.test.json --sourcemap",
|
"build": "rm -rf lib/ && tsc -p tsconfig.test.json --sourcemap",
|
||||||
"test:only": "NODE_OPTIONS=--experimental-vm-modules jest",
|
"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": "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": "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": "eslint --ext .ts src",
|
||||||
"lint:report": "pnpm lint --format json --output-file report.json",
|
"lint:report": "pnpm lint --format json --output-file report.json",
|
||||||
"start": "pnpm test"
|
"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
|
specifier: ^5.0.0
|
||||||
version: 5.0.2
|
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:
|
packages/connectors/connector-alipay-native:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@logto/connector-kit':
|
'@logto/connector-kit':
|
||||||
|
@ -2782,8 +2676,8 @@ importers:
|
||||||
specifier: workspace:^1.3.1
|
specifier: workspace:^1.3.1
|
||||||
version: link:../app-insights
|
version: link:../app-insights
|
||||||
'@logto/cloud':
|
'@logto/cloud':
|
||||||
specifier: workspace:^
|
specifier: 0.2.5-1a68662
|
||||||
version: link:../cloud
|
version: 0.2.5-1a68662(zod@3.20.2)
|
||||||
'@logto/connector-kit':
|
'@logto/connector-kit':
|
||||||
specifier: workspace:^1.1.1
|
specifier: workspace:^1.1.1
|
||||||
version: link:../toolkit/connector-kit
|
version: link:../toolkit/connector-kit
|
||||||
|
@ -7201,6 +7095,15 @@ packages:
|
||||||
jose: 4.14.4
|
jose: 4.14.4
|
||||||
dev: true
|
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:
|
/@logto/js@2.1.1:
|
||||||
resolution: {integrity: sha512-PHikheavVK+l4ivgtzi14p184hEPgXjqQEAom1Gme1MZoopx+WlwxvHSEQBsmyvVqRtI0oiojhoU5tgYi1FKJw==}
|
resolution: {integrity: sha512-PHikheavVK+l4ivgtzi14p184hEPgXjqQEAom1Gme1MZoopx+WlwxvHSEQBsmyvVqRtI0oiojhoU5tgYi1FKJw==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -8724,17 +8627,6 @@ packages:
|
||||||
resolution: {integrity: sha512-1b5u2BGEa14V3o8XzaE7eL+nuwmQe8c1wqSMcGvq+KAusPPZo9tV4glbfF16Xi/ohv37vUpBGJ2DNf4CfuxBLw==}
|
resolution: {integrity: sha512-1b5u2BGEa14V3o8XzaE7eL+nuwmQe8c1wqSMcGvq+KAusPPZo9tV4glbfF16Xi/ohv37vUpBGJ2DNf4CfuxBLw==}
|
||||||
engines: {node: ^16.13.0 || ^18.12.0 || ^19.2.0, pnpm: ^8.0.0}
|
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):
|
/@silverhand/ts-config-react@3.0.0(typescript@5.0.2):
|
||||||
resolution: {integrity: sha512-zQL7kB6ropASS9/p7/g9PEsOIPAwjI20EVER8hShfV1jDURK9zXGDlbAhZbxLNS1n3z2AyPE+0ELEMZ7aIntRA==}
|
resolution: {integrity: sha512-zQL7kB6ropASS9/p7/g9PEsOIPAwjI20EVER8hShfV1jDURK9zXGDlbAhZbxLNS1n3z2AyPE+0ELEMZ7aIntRA==}
|
||||||
engines: {node: ^18.12.0}
|
engines: {node: ^18.12.0}
|
||||||
|
@ -9301,12 +9193,6 @@ packages:
|
||||||
resolution: {integrity: sha512-EqX+YQxINb+MeXaIqYDASb6U6FCHbWjkj4a1CKDBks3d/QiB2+PqBLyO72vLDgAO1wUI4O+9gweRcQK11bTL/w==}
|
resolution: {integrity: sha512-EqX+YQxINb+MeXaIqYDASb6U6FCHbWjkj4a1CKDBks3d/QiB2+PqBLyO72vLDgAO1wUI4O+9gweRcQK11bTL/w==}
|
||||||
dev: true
|
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:
|
/@types/inquirer@9.0.3:
|
||||||
resolution: {integrity: sha512-CzNkWqQftcmk2jaCWdBTf9Sm7xSw4rkI1zpU/Udw3HX5//adEZUIm9STtoRP1qgWj0CWQtJ9UTvqmO2NNjhMJw==}
|
resolution: {integrity: sha512-CzNkWqQftcmk2jaCWdBTf9Sm7xSw4rkI1zpU/Udw3HX5//adEZUIm9STtoRP1qgWj0CWQtJ9UTvqmO2NNjhMJw==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -9441,10 +9327,6 @@ packages:
|
||||||
resolution: {integrity: sha512-JPEv4iAl0I+o7g8yVWDwk30es8mfVrjkvh5UeVR2sYPpZCK44vrAPsbJpIS+rJAUxLgaSAMKTEH5Vn5qd9XsrQ==}
|
resolution: {integrity: sha512-JPEv4iAl0I+o7g8yVWDwk30es8mfVrjkvh5UeVR2sYPpZCK44vrAPsbJpIS+rJAUxLgaSAMKTEH5Vn5qd9XsrQ==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@types/mime-types@2.1.1:
|
|
||||||
resolution: {integrity: sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==}
|
|
||||||
dev: true
|
|
||||||
|
|
||||||
/@types/mime@1.3.2:
|
/@types/mime@1.3.2:
|
||||||
resolution: {integrity: sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==}
|
resolution: {integrity: sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==}
|
||||||
dev: true
|
dev: true
|
||||||
|
@ -9500,6 +9382,7 @@ packages:
|
||||||
'@types/node': 18.11.18
|
'@types/node': 18.11.18
|
||||||
pg-protocol: 1.6.0
|
pg-protocol: 1.6.0
|
||||||
pg-types: 2.2.0
|
pg-types: 2.2.0
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/pluralize@0.0.29:
|
/@types/pluralize@0.0.29:
|
||||||
resolution: {integrity: sha512-BYOID+l2Aco2nBik+iYS4SZX0Lf20KPILP5RGmM1IgzdwNdTs0eebiFriOPcej1sX9mLnSoiNte5zcFxssgpGA==}
|
resolution: {integrity: sha512-BYOID+l2Aco2nBik+iYS4SZX0Lf20KPILP5RGmM1IgzdwNdTs0eebiFriOPcej1sX9mLnSoiNte5zcFxssgpGA==}
|
||||||
|
@ -9831,19 +9714,6 @@ packages:
|
||||||
- zod
|
- zod
|
||||||
dev: true
|
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):
|
/@withtyped/server@0.12.0(zod@3.20.2):
|
||||||
resolution: {integrity: sha512-u5Qe+gr1kK/5CJi7NKf2iIQkbXlxhPXdDYqc7IeoMn0QHGn1hSkB9G3FB6gtx7kI28LY1gSUii4CJf7vX40PZw==}
|
resolution: {integrity: sha512-u5Qe+gr1kK/5CJi7NKf2iIQkbXlxhPXdDYqc7IeoMn0QHGn1hSkB9G3FB6gtx7kI28LY1gSUii4CJf7vX40PZw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -12470,10 +12340,6 @@ packages:
|
||||||
web-streams-polyfill: 3.2.1
|
web-streams-polyfill: 3.2.1
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/fetch-retry@5.0.4:
|
|
||||||
resolution: {integrity: sha512-LXcdgpdcVedccGg0AZqg+S8lX/FCdwXD92WNZ5k5qsb0irRhSFsBOpcJt7oevyqT2/C2nEE0zSFNdBEpj3YOSw==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/figures@5.0.0:
|
/figures@5.0.0:
|
||||||
resolution: {integrity: sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg==}
|
resolution: {integrity: sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
@ -14455,10 +14321,6 @@ packages:
|
||||||
resolution: {integrity: sha512-wLe+lJHeG8Xt6uEubS4x0LVjS/3kXXu9dGoj9BNnlhYq7Kts0Pbb2pvv5KiI0yaKH/eaiR0LUOBhOVo9ktd05A==}
|
resolution: {integrity: sha512-wLe+lJHeG8Xt6uEubS4x0LVjS/3kXXu9dGoj9BNnlhYq7Kts0Pbb2pvv5KiI0yaKH/eaiR0LUOBhOVo9ktd05A==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/jose@4.11.1:
|
|
||||||
resolution: {integrity: sha512-YRv4Tk/Wlug8qicwqFNFVEZSdbROCHRAC6qu/i0dyNKr5JQdoa2pIGoS04lLO/jXQX7Z9omoNewYIVIxqZBd9Q==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/jose@4.14.2:
|
/jose@4.14.2:
|
||||||
resolution: {integrity: sha512-Fcbi5lskAiSvs8qhdQBusANZWwyATdp7IxgHJTXiaU74sbVjX9uAw+myDPvI8pNo2wXKHECXCR63hqhRkN/SSQ==}
|
resolution: {integrity: sha512-Fcbi5lskAiSvs8qhdQBusANZWwyATdp7IxgHJTXiaU74sbVjX9uAw+myDPvI8pNo2wXKHECXCR63hqhRkN/SSQ==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
Loading…
Reference in a new issue