diff --git a/packages/connectors/connector-dingtalk-web/README.md b/packages/connectors/connector-dingtalk-web/README.md new file mode 100644 index 000000000..b2df1b138 --- /dev/null +++ b/packages/connectors/connector-dingtalk-web/README.md @@ -0,0 +1,144 @@ +# DingTalk Web Connector + +The official Logto connector for DingTalk social sign-in in web apps. + +钉钉 web 应用社交登录官方 Logto 连接器 [中文文档](#钉钉网页连接器) + +**Table of contents** + +- [DingTalk Web Connector](#dingtalk-web-connector) + - [Get Started](#get-started) + - [Create a Web App in the DingTalk Open Platform](#create-a-web-app-in-the-dingtalk-open-platform) + - [Register a DingTalk Developer Account](#register-a-dingtalk-developer-account) + - [Create an Application](#create-an-application) + - [Configure Permissions](#configure-permissions) + - [Configure Your Connector](#configure-your-connector) + - [Config Types](#config-types) + - [Test DingTalk Connector](#test-dingtalk-connector) + - [Support](#support) +- [钉钉网页连接器](#钉钉网页连接器) + - [开始上手](#开始上手) + - [在钉钉开放平台新建一个应用](#在钉钉开放平台新建一个应用) + - [注册钉钉开发者账号](#注册钉钉开发者账号) + - [创建应用](#创建应用) + - [配置权限](#配置权限) + - [配置你的连接器](#配置你的连接器) + - [配置类型](#配置类型) + - [测试钉钉连接器](#测试钉钉连接器) + - [支持](#支持) + +## Get started + +The DingTalk web connector is designed for desktop web applications. It uses the OAuth 2.0 authentication flow. + +## Create a web app in the DingTalk Open Platform + +> 💡 **Tip** +> +> You can skip some sections if you have already finished. + +### Register a DingTalk developer account + +If you do not have a DingTalk developer account, please register at the [DingTalk Open Platform](https://open.dingtalk.com). + +### Create an application + +1. In the [DingTalk Developer Console](https://open-dev.dingtalk.com/console/index), click "Create Application" +2. Choose "Self-built Application", fill in the application name and basic information, and click "Create" +3. In the left navigation bar, select "Development Configuration" -> "Security Settings", find and configure the "Redirect URL" `${your_logto_origin}/callback/${connector_id}`. You can find the `connector_id` on the connector details page after adding the respective connector in the management console +4. In the left navigation bar, select "Basic Information" -> "Credentials and Basic Information" to get the "Client ID" and "Client Secret" +5. In the left navigation bar, select "Application Release" -> "Version Management and Release", create and release the first version to activate the "Client ID" and "Client Secret" + +> ℹ️ **Note** +> If the application does not release a version, the obtained "Client ID" and "Client Secret" cannot be used, or requests will fail. + +### Configure permissions + +1. In "Development Configuration" -> "Permission Management", select `Contact.User.Read` and `Contact.User.mobile` permissions and authorize them +2. After confirming the permission configuration, click "Save" and publish the application + +## Configure your connector + +Fill out the `clientId` and `clientSecret` field with _Client ID(formerly AppKey and SuiteKey)_ and _Client Secret(formerly AppKey and SuiteKey)_ you've got from OAuth app detail pages mentioned in the previous section. + +`scope` currently supports two values: `openid` and `openid corpid`. `openid` allows obtaining the user's `userid` after authorization, while `openid corpid` allows obtaining both the user's `id` and the organization `id` selected during the login process. The values should be space-delimited. Note: URL encoding is required. + +### Config types + +| Name | Type | +|--------------|--------| +| clientId | string | +| clientSecret | string | +| scope | string | + +## Test DingTalk connector + +That's it. The DingTalk connector should be available now. Don't forget to [Enable connector in sign-in experience](https://docs.logto.io/docs/tutorials/get-started/passwordless-sign-in-by-adding-connectors#enable-social-sign-in). + +Once DingTalk web connector is enabled, you can sign in to your app again to see if it works. + +> ℹ️ **Note** +> Please ensure strict compliance with the usage specifications and development guidelines of the DingTalk Open Platform during the development process. + +## Support + +If you have any questions or need further assistance, please visit the [DingTalk Developer Documentation](https://open.dingtalk.com/document/orgapp/obtain-identity-credentials) or contact DingTalk technical support. + +# 钉钉网页连接器 + +## 开始上手 + +钉钉网页连接器是为桌面网页应用设计的。它采用了 OAuth 2.0 认证流程。 + +## 在钉钉开放平台新建一个应用 + +> 💡 **Tip** +> +> 你可以跳过已经完成的部分。 + +### 注册钉钉开发者账号 + +如果你还没有钉钉开发者账号,请在 [钉钉开放平台](https://open.dingtalk.com) 注册。 + +### 创建应用 + +1. 在 [钉钉开发者后台](https://open-dev.dingtalk.com/console/index) 中,点击「创建应用」 +2. 选择「自建应用」,填写应用名称和基本信息,点击「创建」 +3. 在左侧导航栏选择「开发配置」->「安全设置」,找到并配置「重定向 URL」 `${your_logto_origin}/callback/${connector_id}`。其中 `connector_id` 在管理控制台添加了相应的连接器之后,可以在连接器的详情页中找到 +4. 在左侧导航栏选择「基础信息」->「凭证与基础信息」中可以获取「Client ID」、「Client Secret」 +5. 在左侧导航栏选择「应用发布」->「版本管理与发布」,创建并发布第一个版本,以使「Client ID」、「Client Secret」生效 + +> ℹ️ **Note** +> 应用不发布版本,所获取的「Client ID」、「Client Secret」 均无法使用,或请求错误。 + +### 配置权限 + +1. 在「开发配置」->「权限管理」中,选择`通讯录个人信息读权限`和`个人手机号信息`权限并进行授权 +2. 确认权限配置后,点击「保存」并发布应用 + +## 配置你的连接器 + +在 clientId 和 clientSecret 字段中填入你在上一个部分中提到的 OAuth 应用详情页面获取的 Client ID(原 AppKey 和 SuiteKey) 和 Client Secret(原 AppKey 和 SuiteKey) 。 + +scope 目前支持两种值:openid 和 openid corpid。openid 授权后可以获取用户的 userid,而 openid corpid 授权后可以获取用户的 id 和登录过程中用户选择的组织 id。这些值应以空格分隔。注意:需要进行 URL 编码。 + +### 配置类型 + +| Name | Type | +|--------------|--------| +| clientId | string | +| clientSecret | string | +| scope | string | + +## 测试钉钉连接器 + +大功告成。别忘了 [在登录体验中启用本连接器](https://docs.logto.io/docs/tutorials/get-started/passwordless-sign-in-by-adding-connectors#enable-social-sign-in)。 + +在钉钉web连接器启用后,你可以构建并运行你的应用看看是否生效。 + +> ℹ️ **Note** +> 请确保在开发过程中,严格遵守钉钉开放平台的使用规范和开发指南。 + +## 支持 + +如有任何问题或需进一步帮助,请访问 [钉钉开发者文档](https://open.dingtalk.com/document/orgapp/obtain-identity-credentials) 或联系钉钉技术支持。 diff --git a/packages/connectors/connector-dingtalk-web/logo.svg b/packages/connectors/connector-dingtalk-web/logo.svg new file mode 100644 index 000000000..0d15a9e4e --- /dev/null +++ b/packages/connectors/connector-dingtalk-web/logo.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/connectors/connector-dingtalk-web/package.json b/packages/connectors/connector-dingtalk-web/package.json new file mode 100644 index 000000000..b37aabbfc --- /dev/null +++ b/packages/connectors/connector-dingtalk-web/package.json @@ -0,0 +1,76 @@ +{ + "name": "@logto/connector-dingtalk-web", + "version": "1.0.0", + "description": "Dingtalk web connector implementation.", + "dependencies": { + "@logto/connector-kit": "workspace:^3.0.0", + "@silverhand/essentials": "^2.9.1", + "dayjs": "^1.10.5", + "got": "^14.0.0", + "iconv-lite": "^0.6.3", + "snakecase-keys": "^8.0.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@shopify/jest-koa-mocks": "^5.0.0", + "@silverhand/eslint-config": "6.0.1", + "@silverhand/ts-config": "6.0.0", + "@types/node": "^20.11.20", + "@types/supertest": "^6.0.2", + "@vitest/coverage-v8": "^1.4.0", + "eslint": "^8.56.0", + "lint-staged": "^15.0.2", + "nock": "^13.3.1", + "prettier": "^3.0.0", + "rollup": "^4.12.0", + "rollup-plugin-output-size": "^1.3.0", + "supertest": "^7.0.0", + "typescript": "^5.3.3", + "vitest": "^1.4.0" + }, + "main": "./lib/index.js", + "module": "./lib/index.js", + "exports": "./lib/index.js", + "license": "MPL-2.0", + "type": "module", + "files": [ + "lib", + "docs", + "logo.svg", + "logo-dark.svg" + ], + "scripts": { + "precommit": "lint-staged", + "build:test": "rm -rf lib/ && tsc -p tsconfig.test.json --sourcemap", + "build": "rm -rf lib/ && tsc -p tsconfig.build.json --noEmit && rollup -c", + "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput --incremental", + "lint": "eslint --ext .ts src", + "lint:report": "pnpm lint --format json --output-file report.json", + "test": "vitest src", + "test:ci": "pnpm run test --silent --coverage", + "prepublishOnly": "pnpm build" + }, + "engines": { + "node": "^20.9.0" + }, + "eslintConfig": { + "extends": "@silverhand", + "settings": { + "import/core-modules": [ + "@silverhand/essentials", + "got", + "nock", + "snakecase-keys", + "zod" + ] + } + }, + "prettier": "@silverhand/eslint-config/.prettierrc", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/connectors/connector-dingtalk-web/src/constant.ts b/packages/connectors/connector-dingtalk-web/src/constant.ts new file mode 100644 index 000000000..f05d1046e --- /dev/null +++ b/packages/connectors/connector-dingtalk-web/src/constant.ts @@ -0,0 +1,60 @@ +import type { ConnectorMetadata } from '@logto/connector-kit'; +import { ConnectorConfigFormItemType, ConnectorPlatform } from '@logto/connector-kit'; + +// https://open.dingtalk.com/document/orgapp-server/use-dingtalk-account-to-log-on-to-third-party-websites-1 +export const authorizationEndpoint = 'https://login.dingtalk.com/oauth2/auth'; +// https://open.dingtalk.com/document/isvapp/obtain-user-token +export const accessTokenEndpoint = 'https://api.dingtalk.com/v1.0/oauth2/userAccessToken'; +// https://open.dingtalk.com/document/isvapp/dingtalk-retrieve-user-information +// To obtain the current authorized person's information, the unionId parameter can be set to "me". +export const userInfoEndpoint = 'https://api.dingtalk.com/v1.0/contact/users/me'; +export const scope = 'openid'; + +export const defaultMetadata: ConnectorMetadata = { + id: 'dingtalk-web', + target: 'dingtalk', + platform: ConnectorPlatform.Web, + name: { + en: 'DingTalk', + 'zh-CN': '钉钉', + 'tr-TR': 'DingTalk', + ko: 'DingTalk', + }, + logo: './logo.svg', + logoDark: null, + description: { + en: 'DingTalk is an enterprise-level intelligent mobile office platform launched by Alibaba Group.', + 'zh-CN': '钉钉是一个由阿里巴巴集团推出的企业级智能移动办公平台。', + 'tr-TR': + 'DingTalk, Alibaba Grubu tarafından piyasaya sürülen kurumsal düzeyde akıllı mobil ofis platformudur.', + ko: '딩톡은 알리바바 그룹이 출시한 기업용 지능형 모바일 오피스 플랫폼입니다.', + }, + readme: './README.md', + formItems: [ + { + key: 'clientId', + label: 'Client ID', + required: true, + type: ConnectorConfigFormItemType.Text, + placeholder: '', + }, + { + key: 'clientSecret', + label: 'Client Secret', + required: true, + type: ConnectorConfigFormItemType.Text, + placeholder: '', + }, + { + key: 'scope', + type: ConnectorConfigFormItemType.Text, + label: 'Scope', + required: false, + placeholder: '', + description: + "The `scope` determines permissions granted by the user's authorization. If you are not sure what to enter, do not worry, just leave it blank.", + }, + ], +}; + +export const defaultTimeout = 5000; diff --git a/packages/connectors/connector-dingtalk-web/src/index.test.ts b/packages/connectors/connector-dingtalk-web/src/index.test.ts new file mode 100644 index 000000000..ba8eb7710 --- /dev/null +++ b/packages/connectors/connector-dingtalk-web/src/index.test.ts @@ -0,0 +1,119 @@ +import nock from 'nock'; + +import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; + +import { accessTokenEndpoint, authorizationEndpoint, userInfoEndpoint } from './constant.js'; +import createConnector, { getAccessToken } from './index.js'; +import { mockedConfig } from './mock.js'; + +const getConfig = vi.fn().mockResolvedValue(mockedConfig); + +describe('Dingtalk connector', () => { + describe('getAuthorizationUri', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should get a valid authorizationUri with redirectUri and state', async () => { + const connector = await createConnector({ getConfig }); + const authorizationUri = await connector.getAuthorizationUri( + { + state: 'some_state', + redirectUri: 'http://localhost:3000/callback', + connectorId: 'some_connector_id', + connectorFactoryId: 'some_connector_factory_id', + jti: 'some_jti', + headers: {}, + }, + vi.fn() + ); + expect(authorizationUri).toEqual( + `${authorizationEndpoint}?client_id=%3Cclient-id%3E&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&response_type=code&scope=openid&state=some_state&prompt=consent` + ); + }); + }); + + describe('getAccessToken', () => { + afterEach(() => { + nock.cleanAll(); + vi.clearAllMocks(); + }); + + it('should get an accessToken by exchanging with code', async () => { + nock(accessTokenEndpoint).post('').reply(200, { + accessToken: 'accessToken', + refreshToken: 'scope', + expires_in: 7200, + corpId: 'corpId', + }); + + const { accessToken } = await getAccessToken('code', mockedConfig); + expect(accessToken).toEqual('accessToken'); + }); + }); + + describe('getUserInfo', () => { + beforeEach(() => { + nock(accessTokenEndpoint).post('').reply(200, { + accessToken: 'accessToken', + refreshToken: 'scope', + expires_in: 7200, + corpId: 'corpId', + }); + }); + + afterEach(() => { + nock.cleanAll(); + vi.clearAllMocks(); + }); + + it('should get valid SocialUserInfo', async () => { + nock(userInfoEndpoint).get('').reply(200, { + nick: 'zhangsan', + avatarUrl: 'https://xxx', + mobile: '150xxxx9144', + openId: '123', + unionId: '123', + email: 'zhangsan@alibaba-inc.com', + stateCode: '86', + }); + const connector = await createConnector({ getConfig }); + const socialUserInfo = await connector.getUserInfo( + { + code: 'code', + }, + vi.fn() + ); + expect(socialUserInfo).toStrictEqual({ + id: '123', + avatar: 'https://xxx', + email: 'zhangsan@alibaba-inc.com', + name: 'zhangsan', + phone: '86150xxxx9144', + rawData: { + nick: 'zhangsan', + avatarUrl: 'https://xxx', + mobile: '150xxxx9144', + openId: '123', + unionId: '123', + email: 'zhangsan@alibaba-inc.com', + stateCode: '86', + }, + }); + }); + + it('throws SocialAccessTokenInvalid error if remote response code is 400', async () => { + nock(userInfoEndpoint).get('').reply(400); + const connector = await createConnector({ getConfig }); + await expect(connector.getUserInfo({ code: 'code' }, vi.fn())).rejects.toStrictEqual( + new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid) + ); + }); + + it('throws unrecognized error', async () => { + nock(userInfoEndpoint).get('').reply(500); + const connector = await createConnector({ getConfig }); + await expect(connector.getUserInfo({ code: 'code' }, vi.fn())).rejects.toThrow(); + }); + }); +}); diff --git a/packages/connectors/connector-dingtalk-web/src/index.ts b/packages/connectors/connector-dingtalk-web/src/index.ts new file mode 100644 index 000000000..1ecd56e08 --- /dev/null +++ b/packages/connectors/connector-dingtalk-web/src/index.ts @@ -0,0 +1,159 @@ +/** + * DingTalk OAuth2 Connector + * https://open.dingtalk.com/document/orgapp/obtain-identity-credentials#title-4up-u8w-5ug + */ + +import { assert } from '@silverhand/essentials'; +import { got, HTTPError } from 'got'; + +import type { + GetAuthorizationUri, + GetUserInfo, + GetConnectorConfig, + CreateConnector, + SocialConnector, +} from '@logto/connector-kit'; +import { + ConnectorError, + ConnectorErrorCodes, + validateConfig, + ConnectorType, + parseJson, +} from '@logto/connector-kit'; + +import { + authorizationEndpoint, + accessTokenEndpoint, + userInfoEndpoint, + scope as defaultScope, + defaultMetadata, + defaultTimeout, +} from './constant.js'; +import type { DingtalkConfig } from './types.js'; +import { + dingtalkConfigGuard, + accessTokenResponseGuard, + userInfoResponseGuard, + authResponseGuard, +} from './types.js'; + +const getAuthorizationUri = + (getConfig: GetConnectorConfig): GetAuthorizationUri => + async ({ state, redirectUri }) => { + const config = await getConfig(defaultMetadata.id); + validateConfig(config, dingtalkConfigGuard); + + const { clientId, scope } = config; + + const queryParameters = new URLSearchParams({ + client_id: clientId, + redirect_uri: encodeURI(redirectUri), // The variable `redirectUri` should match {clientId, clientSecret} + response_type: 'code', + scope: scope ?? defaultScope, + state, + prompt: 'consent', + }); + + return `${authorizationEndpoint}?${queryParameters.toString()}`; + }; + +export const getAccessToken = async ( + code: string, + config: DingtalkConfig +): Promise<{ accessToken: string }> => { + const { clientId, clientSecret } = config; + + const httpResponse = await got.post(accessTokenEndpoint, { + json: { + clientId, + clientSecret, + code, + grantType: 'authorization_code', + }, + timeout: { request: defaultTimeout }, + }); + + const result = accessTokenResponseGuard.safeParse(parseJson(httpResponse.body)); + + if (!result.success) { + throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); + } + + const { accessToken } = result.data; + assert(accessToken, new ConnectorError(ConnectorErrorCodes.InvalidResponse)); + + return { accessToken }; +}; + +const getUserInfo = + (getConfig: GetConnectorConfig): GetUserInfo => + async (data) => { + const { code } = await authorizationCallbackHandler(data); + const config = await getConfig(defaultMetadata.id); + validateConfig(config, dingtalkConfigGuard); + const { accessToken } = await getAccessToken(code, config); + + try { + const httpResponse = await got.get(userInfoEndpoint, { + headers: { + 'x-acs-dingtalk-access-token': accessToken, + }, + timeout: { request: defaultTimeout }, + }); + const rawData = parseJson(httpResponse.body); + const result = userInfoResponseGuard.safeParse(rawData); + + if (!result.success) { + throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); + } + + const { nick: name, avatarUrl: avatar, unionId: id, email, mobile, stateCode } = result.data; + return { + id, + avatar, + phone: stateCode && mobile ? `${stateCode}${mobile}` : undefined, + email, + name, + rawData, + }; + } catch (error: unknown) { + return getUserInfoErrorHandler(error); + } + }; + +const getUserInfoErrorHandler = (error: unknown) => { + // https://open.dingtalk.com/document/personalapp/error-code-2#title-m5s-krt-vds + if (error instanceof HTTPError) { + const { statusCode, body: rawBody } = error.response; + + if (statusCode === 400) { + throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid); + } + + throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(rawBody)); + } + + throw error; +}; + +const authorizationCallbackHandler = async (parameterObject: unknown) => { + const result = authResponseGuard.safeParse(parameterObject); + + if (!result.success) { + throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject)); + } + + return result.data; +}; + +const createDingtalkConnector: CreateConnector = async ({ getConfig }) => { + return { + metadata: defaultMetadata, + type: ConnectorType.Social, + configGuard: dingtalkConfigGuard, + getAuthorizationUri: getAuthorizationUri(getConfig), + getUserInfo: getUserInfo(getConfig), + }; +}; + +export default createDingtalkConnector; diff --git a/packages/connectors/connector-dingtalk-web/src/mock.ts b/packages/connectors/connector-dingtalk-web/src/mock.ts new file mode 100644 index 000000000..2361ae0a1 --- /dev/null +++ b/packages/connectors/connector-dingtalk-web/src/mock.ts @@ -0,0 +1,5 @@ +export const mockedConfig = { + clientId: '', + clientSecret: '', + scode: '', +}; diff --git a/packages/connectors/connector-dingtalk-web/src/types.ts b/packages/connectors/connector-dingtalk-web/src/types.ts new file mode 100644 index 000000000..7bf76dea5 --- /dev/null +++ b/packages/connectors/connector-dingtalk-web/src/types.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +export const dingtalkConfigGuard = z.object({ + clientId: z.string(), + clientSecret: z.string(), + scope: z.string().optional(), +}); + +export type DingtalkConfig = z.infer; + +export const accessTokenResponseGuard = z.object({ + accessToken: z.string(), + refreshToken: z.string().optional(), + expireIn: z.number().optional(), // In seconds + corpId: z.string().optional(), +}); + +// https://open.dingtalk.com/document/isvapp/dingtalk-retrieve-user-information +export const userInfoResponseGuard = z.object({ + nick: z.string().optional(), + avatarUrl: z.string().optional(), + mobile: z.string().optional(), + openId: z.string().optional(), // DingTalk no longer recommends using OpenId for integration. Instead, use unionId. + unionId: z.string(), + email: z.string().optional(), + stateCode: z.string().optional(), +}); + +export type UserInfoResponse = z.infer; + +export type UserInfoResponseMessageParser = (userInfo: Partial) => void; + +export const authResponseGuard = z.object({ code: z.string() }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e63a4582..7c90b2fe8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -754,6 +754,88 @@ importers: specifier: ^1.4.0 version: 1.4.0(@types/node@20.11.20)(jsdom@20.0.2)(sass@1.56.1) + packages/connectors/connector-dingtalk-web: + dependencies: + '@logto/connector-kit': + specifier: workspace:^3.0.0 + version: link:../../toolkit/connector-kit + '@silverhand/essentials': + specifier: ^2.9.1 + version: 2.9.1 + dayjs: + specifier: ^1.10.5 + version: 1.11.6 + got: + specifier: ^14.0.0 + version: 14.0.0 + iconv-lite: + specifier: ^0.6.3 + version: 0.6.3 + snakecase-keys: + specifier: ^8.0.0 + version: 8.0.0 + zod: + specifier: ^3.22.4 + version: 3.22.4 + devDependencies: + '@rollup/plugin-commonjs': + specifier: ^25.0.7 + version: 25.0.7(rollup@4.14.3) + '@rollup/plugin-json': + specifier: ^6.1.0 + version: 6.1.0(rollup@4.14.3) + '@rollup/plugin-node-resolve': + specifier: ^15.2.3 + version: 15.2.3(rollup@4.14.3) + '@rollup/plugin-typescript': + specifier: ^11.1.6 + version: 11.1.6(rollup@4.14.3)(tslib@2.6.2)(typescript@5.3.3) + '@shopify/jest-koa-mocks': + specifier: ^5.0.0 + version: 5.0.0 + '@silverhand/eslint-config': + specifier: 6.0.1 + version: 6.0.1(eslint@8.57.0)(prettier@3.0.0)(typescript@5.3.3) + '@silverhand/ts-config': + specifier: 6.0.0 + version: 6.0.0(typescript@5.3.3) + '@types/node': + specifier: ^20.11.20 + version: 20.12.7 + '@types/supertest': + specifier: ^6.0.2 + version: 6.0.2 + '@vitest/coverage-v8': + specifier: ^1.4.0 + version: 1.4.0(vitest@1.4.0(@types/node@20.12.7)(jsdom@20.0.2)(sass@1.56.1)) + eslint: + specifier: ^8.56.0 + version: 8.57.0 + lint-staged: + specifier: ^15.0.2 + version: 15.0.2 + nock: + specifier: ^13.3.1 + version: 13.3.1 + prettier: + specifier: ^3.0.0 + version: 3.0.0 + rollup: + specifier: ^4.12.0 + version: 4.14.3 + rollup-plugin-output-size: + specifier: ^1.3.0 + version: 1.3.0(rollup@4.14.3) + supertest: + specifier: ^7.0.0 + version: 7.0.0 + typescript: + specifier: ^5.3.3 + version: 5.3.3 + vitest: + specifier: ^1.4.0 + version: 1.4.0(@types/node@20.12.7)(jsdom@20.0.2)(sass@1.56.1) + packages/connectors/connector-discord: dependencies: '@logto/connector-kit': @@ -15602,7 +15684,7 @@ snapshots: deepmerge: 4.3.1 is-builtin-module: 3.2.1 is-module: 1.0.0 - resolve: 1.22.2 + resolve: 1.22.8 optionalDependencies: rollup: 4.14.3