0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(connector): add dingtalk connector (#5915)

* feat: add dingtalk connector

* refactor(connector): optimize codes

* refactor(connector): optimize the logic of getting user phone

* docs(connector): add English configuration guide for DingTalk

* docs(connector): add table of contents

* docs(connector): optimize format

---------

Co-authored-by: aidenlu <aiden_lu@wochacha.com>
This commit is contained in:
aiden 2024-05-29 15:15:45 +08:00 committed by GitHub
parent 458746c9ac
commit 87bffee3f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 683 additions and 1 deletions

View file

@ -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) 或联系钉钉技术支持。

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" image-rendering="optimizeSpeed">
<path fill="#1791f5" d="M58.182 25.136a25.576 25.576 0 0 0-2.016-5.354 27.163 27.163 0 0 0-.704-1.282h-.001q-1.751-2.975-4.367-5.591C45.824 7.638 39.456 5 32 5S18.18 7.638 12.908 12.91C7.638 18.18 5 24.544 5 32c0 7.456 2.637 13.823 7.908 19.094 3.893 3.893 8.38 6.347 13.437 7.359A28.66 28.66 0 0 0 32 59c7.457 0 13.823-2.634 19.094-7.905 2.78-2.78 4.828-5.863 6.14-9.256.973-2.514 1.545-5.194 1.714-8.051.034-.584.052-1.181.052-1.788 0-2.388-.27-4.668-.818-6.864z"/>
<path fill="#fff" d="M16.843 14.608c0 2.747.375 6.646 2.35 8.773 1.06 1.141 2.807 1.451 4.21 1.956 3.065 1.104 6.458 2.783 9.726 3.069l-4.298-.631-9.726-1.857c.02.98.219 1.805.59 2.714 2.43 5.974 8.3 4.75 13.66 4.75-2.02.614-4.245.687-6.333.968-.696.094-2.037.118-2.472.774-.733 1.104 1.34 2.622 2.02 3.175 2.967 2.411 5.932 1.95 9.273.511l-1.13 5.655h3.618l-1.81 8.143 4.243-5.428 4.805-6.56h-4.75c1.558-2.928 3.476-5.659 5.026-8.595.68-1.286 1.625-2.777 1.21-4.298-.648-2.369-4.876-3.41-6.914-4.11-5.601-1.92-11.023-4.17-16.512-6.373-2.143-.86-4.453-2.515-6.786-2.636z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

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

View file

@ -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: '<client-id>',
},
{
key: 'clientSecret',
label: 'Client Secret',
required: true,
type: ConnectorConfigFormItemType.Text,
placeholder: '<client-secret>',
},
{
key: 'scope',
type: ConnectorConfigFormItemType.Text,
label: 'Scope',
required: false,
placeholder: '<scope>',
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;

View file

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

View file

@ -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<SocialConnector> = async ({ getConfig }) => {
return {
metadata: defaultMetadata,
type: ConnectorType.Social,
configGuard: dingtalkConfigGuard,
getAuthorizationUri: getAuthorizationUri(getConfig),
getUserInfo: getUserInfo(getConfig),
};
};
export default createDingtalkConnector;

View file

@ -0,0 +1,5 @@
export const mockedConfig = {
clientId: '<client-id>',
clientSecret: '<client-secret>',
scode: '<scope>',
};

View file

@ -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<typeof dingtalkConfigGuard>;
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<typeof userInfoResponseGuard>;
export type UserInfoResponseMessageParser = (userInfo: Partial<UserInfoResponse>) => void;
export const authResponseGuard = z.object({ code: z.string() });

View file

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