0
Fork 0
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:
Gao Sun 2023-07-03 14:33:19 +08:00 committed by GitHub
parent b347546f46
commit e89ccd4d4c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
60 changed files with 24 additions and 3522 deletions

1
.github/CODEOWNERS vendored
View file

@ -2,6 +2,5 @@
/packages/core @simeng-li @wangsijie @gao-sun
/packages/console @wangsijie @charIeszhao
/packages/ui @simeng-li @charIeszhao
/packages/cloud @simeng-li
/packages/integration-tests @simeng-li
/.changeset @gao-sun

View file

@ -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 }}

View file

@ -13,11 +13,6 @@ concurrency:
jobs:
package:
# See https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs#expanding-or-adding-matrix-configurations
strategy:
matrix:
env: [oss, cloud]
runs-on: ubuntu-latest
steps:
@ -27,26 +22,13 @@ jobs:
uses: silverhand-io/actions-node-pnpm-run-steps@v3
- name: Build and package
if: matrix.env != 'cloud'
run: |
pnpm -r build
./.scripts/package.sh
- name: Build and package (Cloud)
if: matrix.env == 'cloud'
run: |
pnpm -r build
./.scripts/package.sh
env:
IS_CLOUD: 1
CONSOLE_PUBLIC_URL: /
ADMIN_ENDPOINT: http://localhost:3002
# Setup ApplicationInsights for smoke testing. Note the connection string should be a dev string.
APPLICATIONINSIGHTS_CONNECTION_STRING: ${{ secrets.APPLICATIONINSIGHTS_CONNECTION_STRING }}
- uses: actions/upload-artifact@v3
with:
name: integration-test-${{ github.sha }}-${{ matrix.env }}
name: integration-test-${{ github.sha }}
path: /tmp/logto.tar.gz
retention-days: 3
@ -54,14 +36,12 @@ jobs:
strategy:
fail-fast: false
matrix:
target: [api, api-cloud, ui, ui-cloud]
target: [api, ui]
needs: package
runs-on: ubuntu-latest
env:
INTEGRATION_TEST: true
IS_CLOUD: ${{ contains(matrix.target, 'cloud') && '1' || '0' }}
PATH_BASED_MULTI_TENANCY: ${{ contains(matrix.target, 'cloud') && '1' || '0' }}
DB_URL: postgres://postgres:postgres@localhost:5432/postgres
steps:
@ -100,15 +80,14 @@ jobs:
- uses: actions/download-artifact@v3
with:
name: integration-test-${{ github.sha }}-${{ contains(matrix.target, 'cloud') && 'cloud' || 'oss' }}
name: integration-test-${{ github.sha }}
- name: Extract
working-directory: tests
run: |
npm run cli init -- \
-p ../logto \
--du ../logto.tar.gz \
${{ contains(matrix.target, 'cloud') && '--cloud' || '' }}
--du ../logto.tar.gz
- name: Check and add mock connectors
working-directory: tests
@ -128,11 +107,6 @@ jobs:
env:
REDIS_URL: 1
- name: Run Logto Cloud
working-directory: logto/
if: contains(matrix.target, 'cloud')
run: nohup npm run start:cloud > nohup-cloud.out 2> nohup-cloud.err < /dev/null &
- name: Sleep for 5 seconds
run: sleep 5
@ -151,13 +125,3 @@ jobs:
- name: Show error logs
working-directory: logto/
run: cat nohup.err
- name: Show cloud logs
working-directory: logto/
if: contains(matrix.target, 'cloud')
run: cat nohup-cloud.out
- name: Show cloud error logs
working-directory: logto/
if: contains(matrix.target, 'cloud')
run: cat nohup-cloud.err

View file

@ -87,12 +87,6 @@ jobs:
build-args: | # Test cloud build
additional_connector_args=--cloud
- name: Build cloud
uses: docker/build-push-action@v4
with:
file: Dockerfile.cloud
context: .
main-alteration:
runs-on: ubuntu-latest

View file

@ -2,16 +2,6 @@ name: Release
on:
workflow_dispatch:
inputs:
target:
description: 'The release target of Logto'
required: true
default: dev
type: choice
options:
- prod
- dev
push:
branches:
- master
@ -20,10 +10,10 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
jobs:
dockerize-core:
environment: ${{ startsWith(github.ref, 'refs/tags/') && 'release' || '' }}
# Use normal machine for OSS release since we'll build on Depot
runs-on: ${{ (inputs.target || 'dev') == 'dev' && 'ubuntu-latest' || 'ubuntu-latest-4-cores' }}
dockerize:
if: ${{ startsWith(github.ref, 'refs/tags/') }}
environment: 'release'
runs-on: 'ubuntu-latest'
permissions:
contents: read
id-token: write
@ -45,7 +35,6 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=${{ inputs.target || 'dev' }},enable=${{ !startsWith(github.ref, 'refs/tags/') }}
type=edge
- name: Login to DockerHub
@ -61,28 +50,10 @@ jobs:
username: silverhand-bot
password: ${{ secrets.BOT_PAT }}
- name: Setup Docker Buildx
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
uses: docker/setup-buildx-action@v2
- name: Build and push
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
uses: docker/build-push-action@v4
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
additional_connector_args=--cloud
applicationinsights_connection_string=${{ (inputs.target || 'dev') == 'dev' && secrets.APPLICATIONINSIGHTS_CONNECTION_STRING || secrets.APPLICATIONINSIGHTS_CONNECTION_STRING_PROD }}
- name: Setup Depot
if: ${{ startsWith(github.ref, 'refs/tags/') }}
uses: depot/setup-action@v1
- name: Build and push
if: ${{ startsWith(github.ref, 'refs/tags/') }}
uses: depot/build-push-action@v1
with:
platforms: linux/amd64, linux/arm64
@ -92,117 +63,6 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
dockerize-cloud:
# Use normal machine for OSS release since we'll build on Depot
runs-on: ${{ (inputs.target || 'dev') == 'dev' && 'ubuntu-latest' || 'ubuntu-latest-4-cores' }}
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: |
ghcr.io/logto-io/cloud
# https://github.com/docker/metadata-action
tags: |
type=raw,value=${{ inputs.target || 'dev' }},enable=${{ !startsWith(github.ref, 'refs/tags/') }}
type=edge
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: silverhand-bot
password: ${{ secrets.BOT_PAT }}
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Build and push
uses: docker/build-push-action@v4
with:
file: Dockerfile.cloud
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
applicationinsights_connection_string=${{ (inputs.target || 'dev') == 'dev' && secrets.APPLICATIONINSIGHTS_CONNECTION_STRING || secrets.APPLICATIONINSIGHTS_CONNECTION_STRING_PROD }}
deploy:
strategy:
fail-fast: false
matrix:
include:
- target: core
image: logto
- target: cloud
image: cloud
runs-on: ubuntu-latest
needs: [dockerize-core, dockerize-cloud]
environment: ${{ inputs.target || 'dev' }}-${{ matrix.target }}-staging
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
steps:
- uses: actions/checkout@v3
- name: Setup Node and pnpm
if: ${{ (inputs.target || 'dev') == 'dev' && matrix.target == 'core' }}
uses: silverhand-io/actions-node-pnpm-run-steps@v3
- name: Deploy database alteration
if: ${{ (inputs.target || 'dev') == 'dev' && matrix.target == 'core' }}
run: |
pnpm prepack
pnpm cli db alt deploy next
env:
DB_URL: ${{ secrets.DB_URL }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: silverhand-bot
password: ${{ secrets.BOT_PAT }}
- name: Login via Azure CLI
uses: azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Deploy ${{ matrix.target }} to containerapp
uses: azure/webapps-deploy@v2
with:
app-name: ${{ vars.APP_NAME }}
slot-name: staging
images: ghcr.io/logto-io/${{ matrix.image }}:${{ (inputs.target || 'dev') }}
swap-staging-prod:
strategy:
fail-fast: false
matrix:
target: [core, cloud]
runs-on: ubuntu-latest
needs: deploy
environment: ${{ inputs.target || 'dev' }}-${{ matrix.target }}
steps:
- name: Login via Azure CLI
uses: azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Swap ${{ matrix.target }} to production
# See https://learn.microsoft.com/en-us/cli/azure/webapp/deployment/slot?view=azure-cli-latest#az-webapp-deployment-slot-swap
run: az webapp deployment slot swap -g ${{ vars.RESOURCE_GROUP }} -n ${{ vars.APP_NAME }} --slot staging
# Publish packages and create git tags if needed
publish-and-tag:
runs-on: ubuntu-latest

View file

@ -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"]

View file

@ -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
==================================

View file

@ -9,11 +9,8 @@
"prepare": "if test \"$NODE_ENV\" != \"production\" && test \"$CI\" != \"true\" ; then husky install ; fi",
"prepack": "pnpm -r prepack",
"dev": "pnpm -r prepack && pnpm start:dev",
"dev:cloud": "IS_CLOUD=1 pnpm -r prepack && pnpm start:dev:cloud",
"start:dev": "pnpm -r --parallel --filter=!@logto/integration-tests --filter \"!./packages/connectors/connector-*\" --filter=!@logto/cloud dev",
"start:dev:cloud": "CONSOLE_PUBLIC_URL=/ IS_CLOUD=1 pnpm -r --parallel --filter=!@logto/integration-tests --filter \"!./packages/connectors/connector-*\" dev",
"start:dev": "pnpm -r --parallel --filter=!@logto/integration-tests --filter \"!./packages/connectors/connector-*\" dev",
"start": "cd packages/core && NODE_ENV=production node .",
"start:cloud": "cd packages/cloud && NODE_ENV=production node .",
"cli": "logto",
"alteration": "logto db alt",
"connectors:build": "pnpm -r --filter \"./packages/connectors/connector-*\" build",

View file

@ -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

View file

@ -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 licensors 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.

View file

@ -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;

View file

@ -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';

View file

@ -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
}

View file

@ -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"
}

View file

@ -1,9 +0,0 @@
import { GlobalValues } from '@logto/shared';
export const EnvSet = {
global: new GlobalValues(),
get isProduction() {
return this.global.isProduction;
},
};

View file

@ -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}`);
});

View file

@ -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;
}
}

View file

@ -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,
};
}
}

View file

@ -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 } });
};
}

View file

@ -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;
});
};
}

View file

@ -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' });
};
}

View file

@ -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);
};
}

View file

@ -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);
};
}

View file

@ -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 {}
};

View file

@ -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 };
};

View file

@ -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 };
};

View file

@ -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);
}

View file

@ -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 };
};

View file

@ -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,
};
};

View file

@ -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 };
};

View file

@ -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,
};
};

View file

@ -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 };
};

View file

@ -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('-', '_');
};

View file

@ -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()
);
});
});

View file

@ -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;

View file

@ -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;

View file

@ -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()
);
});
});

View file

@ -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 });
}
);

View file

@ -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()
);
});
});

View file

@ -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 });
});

View file

@ -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 } };
};

View file

@ -1,2 +0,0 @@
// eslint-disable-next-line @typescript-eslint/no-empty-function
export const noop = async () => {};

View file

@ -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);
}

View file

@ -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;
};

View file

@ -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,
},
};
};

View file

@ -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 };

View file

@ -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);

View file

@ -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 */

View file

@ -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))})
`;
};

View file

@ -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];
};

View file

@ -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;
};

View file

@ -1,7 +0,0 @@
{
"extends": "./tsconfig",
"exclude": [
"src/**/*.test.ts",
"src/**/__mocks__/",
]
}

View file

@ -1,15 +0,0 @@
{
"extends": "@silverhand/ts-config/tsconfig.base",
"compilerOptions": {
"types": ["node", "jest"],
"declaration": true,
"outDir": "build",
"baseUrl": ".",
"paths": {
"#src/*": [
"src/*"
]
}
},
"include": ["src"]
}

View file

@ -1,7 +0,0 @@
{
"extends": "./tsconfig",
"compilerOptions": {
"isolatedModules": false,
"allowJs": true,
}
}

View file

@ -26,7 +26,7 @@
"@fontsource/roboto-mono": "^5.0.0",
"@jest/types": "^29.5.0",
"@logto/app-insights": "workspace:^1.3.1",
"@logto/cloud": "workspace:^",
"@logto/cloud": "0.2.5-1a68662",
"@logto/connector-kit": "workspace:^1.1.1",
"@logto/core-kit": "workspace:^2.0.1",
"@logto/language-kit": "workspace:^1.0.0",

View file

@ -12,11 +12,9 @@
"scripts": {
"build": "rm -rf lib/ && tsc -p tsconfig.test.json --sourcemap",
"test:only": "NODE_OPTIONS=--experimental-vm-modules jest",
"test": "pnpm build && pnpm test:api && pnpm test:api-cloud && pnpm test:ui",
"test": "pnpm build && pnpm test:api && pnpm test:ui",
"test:api": "pnpm test:only -i ./lib/tests/api/",
"test:api-cloud": "pnpm test:only -i ./lib/tests/api-cloud/",
"test:ui": "pnpm test:only -i --config=jest.config.ui.js ./lib/tests/ui/",
"test:ui-cloud": "pnpm test:only -i --config=jest.config.ui.js ./lib/tests/ui-cloud/",
"lint": "eslint --ext .ts src",
"lint:report": "pnpm lint --format json --output-file report.json",
"start": "pnpm test"

View file

@ -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();
});
});

View file

@ -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);
};

View file

@ -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: 'Lets 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 havent 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);
});

View file

@ -234,112 +234,6 @@ importers:
specifier: ^5.0.0
version: 5.0.2
packages/cloud:
dependencies:
'@logto/app-insights':
specifier: workspace:^1.0.0
version: link:../app-insights
'@logto/cli':
specifier: workspace:^1.5.0
version: link:../cli
'@logto/connector-kit':
specifier: workspace:^1.1.1
version: link:../toolkit/connector-kit
'@logto/core-kit':
specifier: workspace:^2.0.0
version: link:../toolkit/core-kit
'@logto/schemas':
specifier: workspace:^1.5.0
version: link:../schemas
'@logto/shared':
specifier: workspace:^2.0.0
version: link:../shared
'@silverhand/essentials':
specifier: ^2.5.0
version: 2.5.0
'@withtyped/postgres':
specifier: ^0.12.0
version: 0.12.0(@withtyped/server@0.12.0)
'@withtyped/server':
specifier: ^0.12.0
version: 0.12.0(zod@3.20.2)
accepts:
specifier: ^1.3.8
version: 1.3.8
chalk:
specifier: ^5.0.0
version: 5.1.2
decamelize:
specifier: ^6.0.0
version: 6.0.0
dotenv:
specifier: ^16.0.0
version: 16.0.0
fetch-retry:
specifier: ^5.0.4
version: 5.0.4
find-up:
specifier: ^6.3.0
version: 6.3.0
helmet:
specifier: ^7.0.0
version: 7.0.0
http-proxy:
specifier: ^1.18.1
version: 1.18.1
jose:
specifier: ^4.11.0
version: 4.11.1
mime-types:
specifier: ^2.1.35
version: 2.1.35
zod:
specifier: ^3.20.2
version: 3.20.2
devDependencies:
'@silverhand/eslint-config':
specifier: 3.0.1
version: 3.0.1(eslint@8.34.0)(prettier@2.8.4)(typescript@5.0.2)
'@silverhand/jest-config':
specifier: 3.0.0
version: 3.0.0(jest@29.5.0)
'@silverhand/ts-config':
specifier: 3.0.0
version: 3.0.0(typescript@5.0.2)
'@types/accepts':
specifier: ^1.3.5
version: 1.3.5
'@types/http-proxy':
specifier: ^1.17.9
version: 1.17.9
'@types/jest':
specifier: ^29.4.0
version: 29.4.0
'@types/mime-types':
specifier: ^2.1.1
version: 2.1.1
'@types/node':
specifier: ^18.11.18
version: 18.11.18
eslint:
specifier: ^8.21.0
version: 8.34.0
jest:
specifier: ^29.5.0
version: 29.5.0(@types/node@18.11.18)
lint-staged:
specifier: ^13.0.0
version: 13.0.0
nodemon:
specifier: ^2.0.19
version: 2.0.19
prettier:
specifier: ^2.8.1
version: 2.8.4
typescript:
specifier: ^5.0.0
version: 5.0.2
packages/connectors/connector-alipay-native:
dependencies:
'@logto/connector-kit':
@ -2782,8 +2676,8 @@ importers:
specifier: workspace:^1.3.1
version: link:../app-insights
'@logto/cloud':
specifier: workspace:^
version: link:../cloud
specifier: 0.2.5-1a68662
version: 0.2.5-1a68662(zod@3.20.2)
'@logto/connector-kit':
specifier: workspace:^1.1.1
version: link:../toolkit/connector-kit
@ -7201,6 +7095,15 @@ packages:
jose: 4.14.4
dev: true
/@logto/cloud@0.2.5-1a68662(zod@3.20.2):
resolution: {integrity: sha512-lmJiFO0cFGurg/B1dlebeyMMN8jIx7lALyqwATcAi1m902l6L92iN5DRkojnNOVw8d2XQ8zzeOyW+9I/Jew5Uw==}
engines: {node: ^18.12.0}
dependencies:
'@withtyped/server': 0.12.0(zod@3.20.2)
transitivePeerDependencies:
- zod
dev: true
/@logto/js@2.1.1:
resolution: {integrity: sha512-PHikheavVK+l4ivgtzi14p184hEPgXjqQEAom1Gme1MZoopx+WlwxvHSEQBsmyvVqRtI0oiojhoU5tgYi1FKJw==}
dependencies:
@ -8724,17 +8627,6 @@ packages:
resolution: {integrity: sha512-1b5u2BGEa14V3o8XzaE7eL+nuwmQe8c1wqSMcGvq+KAusPPZo9tV4glbfF16Xi/ohv37vUpBGJ2DNf4CfuxBLw==}
engines: {node: ^16.13.0 || ^18.12.0 || ^19.2.0, pnpm: ^8.0.0}
/@silverhand/jest-config@3.0.0(jest@29.5.0):
resolution: {integrity: sha512-5ezkX1/EVQiL2Y3lMY3jLT00GvyRgNKWAB/LIToVN0BQPnasdzKV6sSn2wrQD6XY3lNvR9TBdZajGmG/jtcRjA==}
engines: {node: ^18.12.0}
peerDependencies:
jest: ^29.0.0 || ^29.1.2
dependencies:
'@jest/types': 29.5.0
deepmerge: 4.2.2
jest: 29.5.0(@types/node@18.11.18)
dev: true
/@silverhand/ts-config-react@3.0.0(typescript@5.0.2):
resolution: {integrity: sha512-zQL7kB6ropASS9/p7/g9PEsOIPAwjI20EVER8hShfV1jDURK9zXGDlbAhZbxLNS1n3z2AyPE+0ELEMZ7aIntRA==}
engines: {node: ^18.12.0}
@ -9301,12 +9193,6 @@ packages:
resolution: {integrity: sha512-EqX+YQxINb+MeXaIqYDASb6U6FCHbWjkj4a1CKDBks3d/QiB2+PqBLyO72vLDgAO1wUI4O+9gweRcQK11bTL/w==}
dev: true
/@types/http-proxy@1.17.9:
resolution: {integrity: sha512-QsbSjA/fSk7xB+UXlCT3wHBy5ai9wOcNDWwZAtud+jXhwOM3l+EYZh8Lng4+/6n8uar0J7xILzqftJdJ/Wdfkw==}
dependencies:
'@types/node': 18.11.18
dev: true
/@types/inquirer@9.0.3:
resolution: {integrity: sha512-CzNkWqQftcmk2jaCWdBTf9Sm7xSw4rkI1zpU/Udw3HX5//adEZUIm9STtoRP1qgWj0CWQtJ9UTvqmO2NNjhMJw==}
dependencies:
@ -9441,10 +9327,6 @@ packages:
resolution: {integrity: sha512-JPEv4iAl0I+o7g8yVWDwk30es8mfVrjkvh5UeVR2sYPpZCK44vrAPsbJpIS+rJAUxLgaSAMKTEH5Vn5qd9XsrQ==}
dev: true
/@types/mime-types@2.1.1:
resolution: {integrity: sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==}
dev: true
/@types/mime@1.3.2:
resolution: {integrity: sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==}
dev: true
@ -9500,6 +9382,7 @@ packages:
'@types/node': 18.11.18
pg-protocol: 1.6.0
pg-types: 2.2.0
dev: true
/@types/pluralize@0.0.29:
resolution: {integrity: sha512-BYOID+l2Aco2nBik+iYS4SZX0Lf20KPILP5RGmM1IgzdwNdTs0eebiFriOPcej1sX9mLnSoiNte5zcFxssgpGA==}
@ -9831,19 +9714,6 @@ packages:
- zod
dev: true
/@withtyped/postgres@0.12.0(@withtyped/server@0.12.0):
resolution: {integrity: sha512-NLdWJu1RmVMOLrY8JDgKW0/qovJOr+D/hjdLKI2vK41xTzGf5op2B9oVlpdGFyLBDO+9E7jdMxpceN9j/MpBRg==}
peerDependencies:
'@withtyped/server': ^0.12.0
dependencies:
'@types/pg': 8.6.6
'@withtyped/server': 0.12.0(zod@3.20.2)
'@withtyped/shared': 0.2.1
pg: 8.8.0
transitivePeerDependencies:
- pg-native
dev: false
/@withtyped/server@0.12.0(zod@3.20.2):
resolution: {integrity: sha512-u5Qe+gr1kK/5CJi7NKf2iIQkbXlxhPXdDYqc7IeoMn0QHGn1hSkB9G3FB6gtx7kI28LY1gSUii4CJf7vX40PZw==}
peerDependencies:
@ -12470,10 +12340,6 @@ packages:
web-streams-polyfill: 3.2.1
dev: true
/fetch-retry@5.0.4:
resolution: {integrity: sha512-LXcdgpdcVedccGg0AZqg+S8lX/FCdwXD92WNZ5k5qsb0irRhSFsBOpcJt7oevyqT2/C2nEE0zSFNdBEpj3YOSw==}
dev: false
/figures@5.0.0:
resolution: {integrity: sha512-ej8ksPF4x6e5wvK9yevct0UCXh8TTFlWGVLlgjZuoBH1HwjIfKE/IdL5mq89sFA7zELi1VhKpmtDnrs7zWyeyg==}
engines: {node: '>=14'}
@ -14455,10 +14321,6 @@ packages:
resolution: {integrity: sha512-wLe+lJHeG8Xt6uEubS4x0LVjS/3kXXu9dGoj9BNnlhYq7Kts0Pbb2pvv5KiI0yaKH/eaiR0LUOBhOVo9ktd05A==}
dev: false
/jose@4.11.1:
resolution: {integrity: sha512-YRv4Tk/Wlug8qicwqFNFVEZSdbROCHRAC6qu/i0dyNKr5JQdoa2pIGoS04lLO/jXQX7Z9omoNewYIVIxqZBd9Q==}
dev: false
/jose@4.14.2:
resolution: {integrity: sha512-Fcbi5lskAiSvs8qhdQBusANZWwyATdp7IxgHJTXiaU74sbVjX9uAw+myDPvI8pNo2wXKHECXCR63hqhRkN/SSQ==}
dev: true