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

feat(connector): add Xiaomi social login connector (#6905)

* feat(connector): add Xiaomi social login connector

* chore: update README and pnpm lock

* chore: update changeset

* fix(connector): fix connector-xiaomi test fail & enhance error handling

* refactor(connector): remove unnecessary logs and code

---------

Co-authored-by: Charles Zhao <charleszhao@silverhand.io>
This commit is contained in:
u0x01 2024-12-30 20:02:12 +08:00 committed by GitHub
parent 659ec5c298
commit 3fa2b796e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 739 additions and 1 deletions

View file

@ -0,0 +1,5 @@
---
"@logto/connector-xiaomi": minor
---
add Xiaomi social connector

View file

@ -0,0 +1,69 @@
# Xiaomi social connector
The official Logto connector for Xiaomi social sign-in. [中文文档](https://github.com/logto-io/logto/tree/master/packages/connectors/connector-xiaomi/README.zh-CN.md)
**Table of contents**
- [Xiaomi social connector](#xiaomi-social-connector)
- [Get started](#get-started)
- [Configure Xiaomi OAuth application](#configure-xiaomi-oauth-application)
- [Scopes description](#scopes-description)
- [Test Xiaomi connector](#test-xiaomi-connector)
- [References](#references)
## Get started
1. Create a developer account at [Xiaomi Open Platform](https://dev.mi.com/)
2. Visit [Xiaomi Account Service](https://dev.mi.com/passport/oauth2/applist)
3. Create a new application if you don't have one
## Configure Xiaomi OAuth application
1. Visit [Xiaomi Account Service](https://dev.mi.com/passport/oauth2/applist)
2. Configure OAuth settings:
- Open the application you want to use for login, click on "Callback URL" (if you haven't edited the callback URL, it will display as "Enabled")
- Add authorization callback URL: `${your_logto_origin}/callback/${connector_id}`
- `connector_id` can be found on the top of the connector details page in Logto Console
3. Get `AppID` and `AppSecret` from the application details page
4. Fill in the `clientId` and `clientSecret` fields in Logto Console with the values from step 3
5. Optional configuration:
- `skipConfirm`: Whether to skip the Xiaomi authorization confirmation page when user is already logged in to Xiaomi account, defaults to false
## Scopes description
By default, the connector requests the following scope:
- `1`: Read user profile
Available scopes:
| Scope Value | Description | API Interface |
|-------------|-------------|---------------|
| 1 | Get user profile | user/profile |
| 3 | Get user open_id | user/openIdV2 |
| 1000 | Get Xiaomi router info | Mi Router |
| 1001 | Access all Xiaomi router info | Mi Router |
| 2001 | Access Xiaomi cloud calendar | Mi Cloud |
| 2002 | Access Xiaomi cloud alarm | Mi Cloud |
| 6000 | Use Mi Home smart home service | Mi Home |
| 6002 | Add third-party devices to Mi Home | Mi Home |
| 6003 | Alexa control Xiaomi devices | Mi Home |
| 6004 | Third-party service access to Xiaomi devices | Mi Home |
| 7000 | Follow Yellow Pages service account | Mi Cloud |
| 11000 | Get Xiaomi cloud photos | Mi Cloud |
| 12001 | Save app data to Mi Cloud | Mi Cloud |
| 12005 | Use health ECG service | Health |
| 16000 | Get Mi Wallet passes | app/get_pass |
| 20000 | Enable XiaoAI voice service | XiaoAI |
| 40000 | Enable cloud AI service | Internal Use |
Multiple scopes can be configured by separating them with spaces, e.g.: `1 3 6000`.
## Test Xiaomi connector
That's it. Don't forget to [Enable social sign-in](https://docs.logto.io/connectors/social-connectors#enable-social-sign-in) in the sign-in experience.
## References
- [Xiaomi OAuth 2.0 Documentation](https://dev.mi.com/xiaomihyperos/documentation/detail?pId=1708)
- [Xiaomi Get User Profile Documentation](https://dev.mi.com/xiaomihyperos/documentation/detail?pId=1517)

View file

@ -0,0 +1,71 @@
# 小米社交连接器
小米社交登录 Logto 官方连接器 [中文文档](#小米社交连接器)
**目录**
- [小米社交连接器](#小米社交连接器)
- [开始上手](#开始上手)
- [配置小米 OAuth 应用](#配置小米-oauth-应用)
- [权限范围说明](#权限范围说明)
- [测试小米连接器](#测试小米连接器)
- [参考](#参考)
小米是一家全球知名的科技公司,提供包括智能手机、智能家居等在内的多种产品和服务。本连接器可以帮助终端用户使用小米账号登录你的应用。
## 开始上手
1. 在[小米开放平台](https://dev.mi.com/)创建开发者账号
2. 访问[小米帐号服务](https://dev.mi.com/passport/oauth2/applist)
3. 创建一个新应用(如果还没有)
## 配置小米 OAuth 应用
1. 访问[小米帐号服务](https://dev.mi.com/passport/oauth2/applist)
2. 配置 OAuth 设置:
- 打开要用于登录的应用,点击「回调地址」(如果没有编辑过回调地址,会显示为「启用」)
- 添加授权回调地址: `${your_logto_origin}/callback/${connector_id}`
- `connector_id` 可以在 Logto 管理控制台连接器详情页顶部找到
3. 从应用详情页获取 `AppID``AppSecret`
4. 将第 3 步获取的值填入 Logto 管理控制台的 `clientId``clientSecret` 字段
5. 可选配置:
- `skipConfirm`: 在用户已登录小米账号的情况下,是否跳过小米授权确认页面,默认为 false
## 权限范围说明
默认情况下,连接器请求以下权限:
- `1`: 读取用户资料
可配置的权限范围:
| 权限值 | 描述 | API 接口 |
|-------|------|----------|
| 1 | 获取用户资料 | user/profile |
| 3 | 获取用户 open_id | user/openIdV2 |
| 1000 | 获取小米路由器信息 | 路由器 |
| 1001 | 访问所有小米路由器信息 | 路由器 |
| 2001 | 访问小米云日历 | 小米云 |
| 2002 | 访问小米云闹钟 | 小米云 |
| 6000 | 使用米家智能家居服务 | 米家 |
| 6002 | 添加第三方设备到米家 | 米家 |
| 6003 | Alexa 控制小米设备 | 米家 |
| 6004 | 第三方服务访问小米设备 | 米家 |
| 7000 | 关注黄页服务号 | 小米云 |
| 11000 | 获取小米云相册 | 小米云 |
| 12001 | 保存应用数据到小米云 | 小米云 |
| 12005 | 使用健康心电图服务 | 健康 |
| 16000 | 获取小米卡包卡券 | app/get_pass |
| 20000 | 启用小爱智能语音服务 | 小爱 |
| 40000 | 启用云端 AI 服务 | 内部使用 |
可以通过空格分隔配置多个权限范围,例如: `1 3 6000`
## 测试小米连接器
大功告成!别忘了在[登录体验](https://docs.logto.io/zh-CN/connectors/social-connectors#enable-social-sign-in)中启用该连接器。
## 参考
- [小米 OAuth 2.0 文档](https://dev.mi.com/xiaomihyperos/documentation/detail?pId=1708)
- [小米获取用户信息文档](https://dev.mi.com/xiaomihyperos/documentation/detail?pId=1517)

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
y="0px" width="512px" height="512px" viewBox="-200.008 -199.727 512 512"
enable-background="new -200.008 -199.727 512 512" xml:space="preserve">
<g>
<path id="path-1" fill="#FF6900" d="M258.626-146.231c-48.304-48.118-117.759-53.496-202.634-53.496
c-84.982,0-154.542,5.44-202.826,53.688c-48.277,48.228-53.174,117.676-53.174,202.561c0,84.899,4.897,154.368,53.194,202.613
c48.281,48.255,117.833,53.139,202.806,53.139c84.974,0,154.514-4.884,202.795-53.139
c48.294-48.254,53.205-117.714,53.205-202.613C311.992-28.472,307.028-97.995,258.626-146.231L258.626-146.231z" />
</g>
<g>
<path id="path-2" fill="#FFFFFF" d="M204.546-41.122c1.759,0,3.223,1.417,3.223,3.161v189.386
c0,1.715-1.464,3.139-3.223,3.139H163.05c-1.781,0-3.228-1.424-3.228-3.139V-37.961c0-1.743,1.446-3.161,3.228-3.161H204.546z
M24.468-41.122c31.303,0,64.033,1.435,80.176,17.589c15.871,15.897,17.59,47.549,17.656,78.286v96.671
c0,1.715-1.446,3.139-3.219,3.139h-41.49c-1.777,0-3.229-1.424-3.229-3.139V53.09c-0.044-17.167-1.031-34.81-9.884-43.692
c-7.62-7.641-21.839-9.391-36.625-9.754h-75.21c-1.764,0-3.208,1.419-3.208,3.136v148.645c0,1.715-1.462,3.139-3.237,3.139
h-41.516c-1.774,0-3.201-1.424-3.201-3.139V-37.961c0-1.743,1.426-3.161,3.201-3.161H24.468z M33.755,34.305
c1.766,0,3.201,1.413,3.201,3.143v113.977c0,1.715-1.436,3.139-3.201,3.139H-9.829c-1.792,0-3.228-1.424-3.228-3.139V37.448
c0-1.73,1.436-3.143,3.228-3.143H33.755z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,69 @@
{
"name": "@logto/connector-xiaomi",
"version": "1.0.0",
"description": "Xiaomi web connector implementation.",
"author": "Silverhand Inc. <contact@silverhand.io>",
"dependencies": {
"@logto/connector-kit": "workspace:^4.0.0",
"@silverhand/essentials": "^2.9.1",
"ky": "^1.2.3",
"zod": "^3.23.8"
},
"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",
"check": "tsc --noEmit",
"build": "tsup",
"dev": "tsup --watch",
"lint": "eslint --ext .ts src",
"lint:fix": "eslint --ext .ts src --fix",
"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"
},
"devDependencies": {
"@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": "^2.1.8",
"eslint": "^8.56.0",
"lint-staged": "^15.0.2",
"nock": "14.0.0-beta.15",
"prettier": "^3.0.0",
"supertest": "^7.0.0",
"tsup": "^8.3.0",
"typescript": "^5.5.3",
"vitest": "^2.1.8"
}
}

View file

@ -0,0 +1,59 @@
import type { ConnectorMetadata } from '@logto/connector-kit';
import { ConnectorPlatform, ConnectorConfigFormItemType } from '@logto/connector-kit';
export const authorizationEndpoint = 'https://account.xiaomi.com/oauth2/authorize';
export const accessTokenEndpoint = 'https://account.xiaomi.com/oauth2/token';
export const userInfoEndpoint = 'https://open.account.xiaomi.com/user/profile';
// Default scope is read user profile
export const defaultScope = '1';
export const defaultMetadata: ConnectorMetadata = {
id: 'xiaomi-universal',
target: 'xiaomi',
platform: ConnectorPlatform.Universal,
name: {
en: 'Xiaomi',
'zh-CN': '小米',
},
logo: './logo.svg',
logoDark: null,
description: {
en: 'Xiaomi is a Chinese electronics company.',
'zh-CN': '小米是一家中国的电子产品公司。',
},
readme: './README.md',
formItems: [
{
key: 'clientId',
type: ConnectorConfigFormItemType.Text,
label: 'Client ID',
required: true,
placeholder: '<client-id>',
},
{
key: 'clientSecret',
type: ConnectorConfigFormItemType.Text,
label: 'Client Secret',
required: true,
placeholder: '<client-secret>',
},
{
key: 'scope',
type: ConnectorConfigFormItemType.Text,
label: 'Scope',
required: false,
placeholder: '<scope>',
description: 'The scope determines permissions granted by the user.',
},
{
key: 'skipConfirm',
type: ConnectorConfigFormItemType.Switch,
label: 'Skip Auth Confirm Page',
required: false,
description: 'Skip the Xiaomi auth confirm page when the user is already logged in.',
},
],
};
export const defaultTimeout = 5000;

View file

@ -0,0 +1,133 @@
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, mockedAccessTokenResponse, mockedUserInfoResponse } from './mock.js';
const getConfig = vi.fn().mockResolvedValue(mockedConfig);
describe('getAuthorizationUri', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('should get a valid uri by 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&state=some_state&scope=1&skip_confirm=true`
);
});
});
describe('getAccessToken', () => {
afterEach(() => {
nock.cleanAll();
vi.clearAllMocks();
});
it('should get an accessToken by exchanging with code', async () => {
nock(accessTokenEndpoint)
.post('')
.reply(200, `&&&START&&&${JSON.stringify(mockedAccessTokenResponse)}`);
const { accessToken } = await getAccessToken(mockedConfig, { code: 'code' }, '');
expect(accessToken).toEqual('access_token');
});
it('throws SocialAuthCodeInvalid error if accessToken not found in response', async () => {
nock(accessTokenEndpoint)
.post('')
.reply(200, '&&&START&&&{"error":96010,"error_description":"invalid redirect uri"}');
await expect(getAccessToken(mockedConfig, { code: 'code' }, '')).rejects.toStrictEqual(
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)
);
});
});
describe('getUserInfo', () => {
beforeEach(() => {
nock(accessTokenEndpoint)
.post('')
.reply(200, `&&&START&&&${JSON.stringify(mockedAccessTokenResponse)}`);
});
afterEach(() => {
nock.cleanAll();
vi.clearAllMocks();
});
it('should get valid SocialUserInfo', async () => {
nock(userInfoEndpoint).get('').query(true).reply(200, mockedUserInfoResponse);
const connector = await createConnector({ getConfig });
const socialUserInfo = await connector.getUserInfo(
{
code: 'valid_code',
redirectUri: 'http://localhost:3000/callback',
},
vi.fn()
);
expect(socialUserInfo).toStrictEqual({
id: 'union_id',
avatar: 'https://avatar.example.com/user.jpg',
name: 'Test User',
rawData: mockedUserInfoResponse,
});
});
it('throws SocialAccessTokenInvalid error if remote response code is 403', async () => {
// Mock the userinfo endpoint to return 403
nock(userInfoEndpoint).get('').query(true).reply(403, {
// eslint-disable-next-line unicorn/numeric-separators-style
code: 96008,
description: 'token invalid or expired',
result: 'error',
});
const connector = await createConnector({ getConfig });
await expect(
connector.getUserInfo(
{
code: 'some_code',
redirectUri: 'http://localhost:3000/callback',
},
vi.fn()
)
).rejects.toThrow(new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid));
});
it('throws General error if remote response code is 401', async () => {
const errorResponse = {
// eslint-disable-next-line unicorn/numeric-separators-style
code: 96012,
description: 'server rejected auth request',
result: 'error',
};
nock(userInfoEndpoint).get('').query(true).reply(401, JSON.stringify(errorResponse));
const connector = await createConnector({ getConfig });
await expect(
connector.getUserInfo(
{
code: 'some_code',
redirectUri: 'http://localhost:3000/callback',
},
vi.fn()
)
).rejects.toThrow(
new ConnectorError(ConnectorErrorCodes.General, {
code: errorResponse.code,
description: errorResponse.description,
})
);
});
});

View file

@ -0,0 +1,181 @@
import { assert, conditional } from '@silverhand/essentials';
import {
ConnectorError,
ConnectorErrorCodes,
validateConfig,
ConnectorType,
jsonGuard,
type GetAuthorizationUri,
type GetUserInfo,
type SocialConnector,
type CreateConnector,
type GetConnectorConfig,
} from '@logto/connector-kit';
import ky, { HTTPError } from 'ky';
import {
authorizationEndpoint,
accessTokenEndpoint,
userInfoEndpoint,
defaultScope,
defaultMetadata,
defaultTimeout,
} from './constant.js';
import type { XiaomiConfig } from './types.js';
import {
xiaomiConfigGuard,
accessTokenResponseGuard,
userInfoResponseGuard,
authorizationCallbackErrorGuard,
authResponseGuard,
getUserInfoErrorGuard,
} from './types.js';
const authorizationCallbackHandler = async (parameterObject: unknown) => {
const result = authResponseGuard.safeParse(parameterObject);
if (!result.success) {
const parsedError = authorizationCallbackErrorGuard.safeParse(parameterObject);
if (!parsedError.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
}
const { error, error_description } = parsedError.data;
throw new ConnectorError(ConnectorErrorCodes.General, {
error,
errorDescription: error_description,
});
}
return result.data;
};
const getAuthorizationUri =
(getConfig: GetConnectorConfig): GetAuthorizationUri =>
async ({ state, redirectUri }) => {
const config = await getConfig(defaultMetadata.id);
validateConfig(config, xiaomiConfigGuard);
const queryParameters = new URLSearchParams({
client_id: config.clientId,
redirect_uri: redirectUri,
response_type: 'code',
state,
scope: config.scope ?? defaultScope,
skip_confirm: String(config.skipConfirm ?? false),
});
return `${authorizationEndpoint}?${queryParameters.toString()}`;
};
export const getAccessToken = async (
config: XiaomiConfig,
codeObject: { code: string },
redirectUri: string
) => {
const { code } = codeObject;
const { clientId: client_id, clientSecret: client_secret } = config;
const formData = new URLSearchParams({
client_id,
client_secret,
code,
grant_type: 'authorization_code',
redirect_uri: redirectUri,
});
const httpResponse = await ky
.post(accessTokenEndpoint, {
body: formData,
timeout: defaultTimeout,
})
.text();
const jsonResponse = jsonGuard.parse(JSON.parse(httpResponse.replace('&&&START&&&', '')));
const result = accessTokenResponseGuard.safeParse(jsonResponse);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid);
}
const { access_token: accessToken } = result.data;
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
return { accessToken };
};
const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo =>
async (data) => {
const { code, redirectUri } = await authorizationCallbackHandler(data);
const config = await getConfig(defaultMetadata.id);
validateConfig(config, xiaomiConfigGuard);
const { accessToken } = await getAccessToken(config, { code }, redirectUri);
try {
const response = await ky
.get(userInfoEndpoint, {
searchParams: {
clientId: config.clientId,
token: accessToken,
},
timeout: defaultTimeout,
})
.json();
const userInfoResult = userInfoResponseGuard.safeParse(response);
if (!userInfoResult.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse);
}
const {
data: { miliaoNick, unionId, miliaoIcon },
} = userInfoResult.data;
return {
id: unionId,
avatar: conditional(miliaoIcon),
name: conditional(miliaoNick),
rawData: jsonGuard.parse(response),
};
} catch (error: unknown) {
if (error instanceof HTTPError) {
const errorBody: unknown = await error.response.json();
const parsedError = getUserInfoErrorGuard.safeParse(errorBody);
if (!parsedError.success) {
throw new ConnectorError(ConnectorErrorCodes.General, parsedError.error);
}
const { code, description } = parsedError.data;
if (error.response.status === 403) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
}
throw new ConnectorError(ConnectorErrorCodes.General, {
code,
description,
});
}
throw error;
}
};
const createXiaomiConnector: CreateConnector<SocialConnector> = async ({ getConfig }) => {
return {
metadata: defaultMetadata,
type: ConnectorType.Social,
configGuard: xiaomiConfigGuard,
getAuthorizationUri: getAuthorizationUri(getConfig),
getUserInfo: getUserInfo(getConfig),
};
};
export default createXiaomiConnector;

View file

@ -0,0 +1,26 @@
export const mockedConfig = {
clientId: '<client-id>',
clientSecret: '<client-secret>',
redirectUri: 'http://localhost:3000/callback',
skipConfirm: true,
};
export const mockedAccessTokenResponse = {
access_token: 'access_token',
expires_in: 3600,
refresh_token: 'refresh_token',
scope: '1',
openId: 'openId',
union_id: 'union_id',
};
export const mockedUserInfoResponse = {
result: 'ok',
code: 0,
description: 'no error',
data: {
miliaoNick: 'Test User',
unionId: 'union_id',
miliaoIcon: 'https://avatar.example.com/user.jpg',
},
};

View file

@ -0,0 +1,48 @@
import { z } from 'zod';
export const xiaomiConfigGuard = z.object({
clientId: z.string(),
clientSecret: z.string(),
scope: z.string().optional(),
redirectUri: z.string().optional(),
skipConfirm: z.boolean().optional(),
});
export type XiaomiConfig = z.infer<typeof xiaomiConfigGuard>;
export const accessTokenResponseGuard = z.object({
access_token: z.string(),
expires_in: z.number(),
refresh_token: z.string(),
scope: z.string(),
openId: z.string(),
union_id: z.string(),
});
export type AccessTokenResponse = z.infer<typeof accessTokenResponseGuard>;
export const userInfoResponseGuard = z.object({
result: z.string(),
code: z.number(),
description: z.string(),
data: z.object({
unionId: z.string(),
miliaoNick: z.string().optional(),
miliaoIcon: z.string().optional(),
}),
});
export type UserInfoResponse = z.infer<typeof userInfoResponseGuard>;
export const authorizationCallbackErrorGuard = z.object({
error: z.string(),
error_description: z.string(),
});
export const authResponseGuard = z.object({ code: z.string(), redirectUri: z.string() });
export const getUserInfoErrorGuard = z.object({
code: z.number(),
description: z.string(),
result: z.string(),
});

View file

@ -2739,6 +2739,61 @@ importers:
specifier: ^2.1.8
version: 2.1.8(@types/node@20.10.4)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8)
packages/connectors/connector-xiaomi:
dependencies:
'@logto/connector-kit':
specifier: workspace:^4.0.0
version: link:../../toolkit/connector-kit
'@silverhand/essentials':
specifier: ^2.9.1
version: 2.9.2
ky:
specifier: ^1.2.3
version: 1.2.3
zod:
specifier: ^3.23.8
version: 3.23.8
devDependencies:
'@silverhand/eslint-config':
specifier: 6.0.1
version: 6.0.1(eslint@8.57.0)(prettier@3.0.0)(typescript@5.5.3)
'@silverhand/ts-config':
specifier: 6.0.0
version: 6.0.0(typescript@5.5.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: ^2.1.8
version: 2.1.8(vitest@2.1.8(@types/node@20.12.7)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8))
eslint:
specifier: ^8.56.0
version: 8.57.0
lint-staged:
specifier: ^15.0.2
version: 15.0.2
nock:
specifier: 14.0.0-beta.15
version: 14.0.0-beta.15
prettier:
specifier: ^3.0.0
version: 3.0.0
supertest:
specifier: ^7.0.0
version: 7.0.0
tsup:
specifier: ^8.3.0
version: 8.3.0(@swc/core@1.3.52(@swc/helpers@0.5.1))(jiti@1.21.0)(postcss@8.4.49)(typescript@5.5.3)(yaml@2.4.5)
typescript:
specifier: ^5.5.3
version: 5.5.3
vitest:
specifier: ^2.1.8
version: 2.1.8(@types/node@20.12.7)(jsdom@20.0.2)(lightningcss@1.25.1)(sass@1.77.8)
packages/console:
devDependencies:
'@fontsource/roboto-mono':
@ -16382,7 +16437,7 @@ snapshots:
'@logto/client@2.7.2':
dependencies:
'@logto/js': 4.1.4
'@silverhand/essentials': 2.9.1
'@silverhand/essentials': 2.9.2
camelcase-keys: 7.0.2
jose: 5.9.6