From 697cdd54ee3ea3dcb1e6c29f551689252c8165e5 Mon Sep 17 00:00:00 2001 From: doveyoung Date: Wed, 28 Feb 2024 10:23:41 +0800 Subject: [PATCH] feat(connector): add connector-wecom (#5411) Co-authored-by: dove --- .gitignore | 1 + packages/connectors/connector-wecom/README.md | 49 ++++ packages/connectors/connector-wecom/logo.svg | 5 + .../connectors/connector-wecom/package.json | 50 ++++ .../connector-wecom/src/constant.ts | 66 ++++++ .../connector-wecom/src/index.test.ts | 218 ++++++++++++++++++ .../connectors/connector-wecom/src/index.ts | 196 ++++++++++++++++ .../connectors/connector-wecom/src/mock.ts | 6 + .../connectors/connector-wecom/src/types.ts | 36 +++ pnpm-lock.yaml | 79 +++++++ 10 files changed, 706 insertions(+) create mode 100644 packages/connectors/connector-wecom/README.md create mode 100644 packages/connectors/connector-wecom/logo.svg create mode 100644 packages/connectors/connector-wecom/package.json create mode 100644 packages/connectors/connector-wecom/src/constant.ts create mode 100644 packages/connectors/connector-wecom/src/index.test.ts create mode 100644 packages/connectors/connector-wecom/src/index.ts create mode 100644 packages/connectors/connector-wecom/src/mock.ts create mode 100644 packages/connectors/connector-wecom/src/types.ts diff --git a/.gitignore b/.gitignore index 851fd6240..0c3f3abc9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules /.pnp .pnp.js +.pnpm-store # testing /packages/**/coverage diff --git a/packages/connectors/connector-wecom/README.md b/packages/connectors/connector-wecom/README.md new file mode 100644 index 000000000..7b3a376e0 --- /dev/null +++ b/packages/connectors/connector-wecom/README.md @@ -0,0 +1,49 @@ +# WeCom OAuth2 connector + +The custom connector for WeCom (maybe called WXwork) social sign-in. + +I suggest using a different sign-in method if available. Logging in from WeCom is not user-friendly enough. + +## Get started + +Sign in to [WeCom WebUI](https://work.weixin.qq.com/) with an admin account or click **Manage the enterprise(管理企业)** from the WeCom app. + +In the "App Management" tab, scroll the page down and click "Create an app". + +Fill in the appropriate information according to your app. Then create. + +Now we have the Agent ID (NOT APPID) and Secret. + +### Website info + +Set the things you need on this page. It would be like: + +- Allowed users: _who can see this app_ +- App Homepage: _Your app homepage. E.g., `logto.io/demo-app`_ + +**Important** +There are three items on this page regarding the "Developer API(开发者接口)". + +1. Web Authorization and JS-SDK; +2. Log in to via authorization by WeCom; +3. Enterprise Trusted IP; + +Fill them according to the guide of WeCom. + +> Maybe you should do something according to the guide of WeCom who should act according to the requirements of some others. God bless you. + +### Corp ID + +If you are familiar with WeChat development, you may notice that the use of Corp ID is the same as APP ID. + +You can find the Corp ID at the bottom of the "My Enterprise(我的企业)" tab page. It seems like **ww\*\*\*\*** . + +## Configure the connector + +So we have the Agent ID, Secret, and Corp ID. + +Let's complete the form for the connector. + +You can leave the `Scope` field blank as it is optional. Alternatively, you can fill in `snsapi_base` or `snsapi_privateinfo`. I apologize for not testing it fully. + +**Save and done** diff --git a/packages/connectors/connector-wecom/logo.svg b/packages/connectors/connector-wecom/logo.svg new file mode 100644 index 000000000..e95019700 --- /dev/null +++ b/packages/connectors/connector-wecom/logo.svg @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/packages/connectors/connector-wecom/package.json b/packages/connectors/connector-wecom/package.json new file mode 100644 index 000000000..3531f3394 --- /dev/null +++ b/packages/connectors/connector-wecom/package.json @@ -0,0 +1,50 @@ +{ + "name": "@logto/connector-wecom", + "version": "0.1.0", + "description": "Wecom connector implementation.", + "author": "Dove fork from Wechat Web connector", + "dependencies": { + "@logto/connector-kit": "workspace:^2.0.0" + }, + "main": "./lib/index.js", + "module": "./lib/index.js", + "exports": "./lib/index.js", + "license": "MPL-2.0", + "type": "module", + "files": [ + "lib", + "docs", + "logo.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:only": "NODE_OPTIONS=--experimental-vm-modules jest", + "test": "pnpm build:test && pnpm test:only", + "test:ci": "pnpm test:only --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-wecom/src/constant.ts b/packages/connectors/connector-wecom/src/constant.ts new file mode 100644 index 000000000..8a9c32e29 --- /dev/null +++ b/packages/connectors/connector-wecom/src/constant.ts @@ -0,0 +1,66 @@ +import type { ConnectorMetadata } from '@logto/connector-kit'; +import { ConnectorConfigFormItemType } from '@logto/connector-kit'; + +export const authorizationEndpointInside = 'https://open.weixin.qq.com/connect/oauth2/authorize'; +export const authorizationEndpointQrcode = 'https://open.work.weixin.qq.com/wwopen/sso/qrConnect'; +export const accessTokenEndpoint = 'https://qyapi.weixin.qq.com/cgi-bin/gettoken'; +export const userInfoEndpoint = 'https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo'; +export const scope = 'snsapi_userinfo'; + +// See https://developer.work.weixin.qq.com/document/path/90313 to know more about WeCom response error code +export const invalidAuthCodeErrcode = [40_029, 40_163, 42_003]; + +export const invalidAccessTokenErrcode = [40_001, 40_014]; + +export const defaultMetadata: ConnectorMetadata = { + id: 'wecom-universal', + target: 'wecom', + platform: null, + name: { + en: 'WeCom', + 'zh-CN': '企业微信', + 'tr-TR': 'WeCom', + ko: 'WeCom', + }, + logo: './logo.svg', + logoDark: null, + description: { + en: 'WeCom is a cross-platform instant messaging app for team. It is the enterprise version of WeChat.', + 'zh-CN': '企业微信,是腾讯微信团队为企业打造的专业办公管理工具。', + }, + readme: './README.md', + formItems: [ + { + key: 'corpId', + label: 'Corp ID', + required: true, + type: ConnectorConfigFormItemType.Text, + placeholder: '', + }, + { + key: 'appSecret', + label: 'App Secret', + required: true, + type: ConnectorConfigFormItemType.Text, + placeholder: '', + }, + { + key: 'agentId', + label: 'Agent ID', + 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-wecom/src/index.test.ts b/packages/connectors/connector-wecom/src/index.test.ts new file mode 100644 index 000000000..474b1949d --- /dev/null +++ b/packages/connectors/connector-wecom/src/index.test.ts @@ -0,0 +1,218 @@ +import nock from 'nock'; + +import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; + +import { + accessTokenEndpoint, + authorizationEndpointInside, + authorizationEndpointQrcode, + userInfoEndpoint, +} from './constant.js'; +import createConnector, { getAccessToken } from './index.js'; +import { mockedConfig } from './mock.js'; + +const { jest } = import.meta; + +const getConfig = jest.fn().mockResolvedValue(mockedConfig); + +describe('getAuthorizationUri', () => { + afterEach(() => { + jest.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:3001/callback', + connectorId: 'some_connector_id', + connectorFactoryId: 'some_connector_factory_id', + jti: 'some_jti', + headers: {}, + }, + jest.fn() + ); + const userAgent = 'some_UA'; + const isWecom = userAgent.toLowerCase().includes('wxwork'); + const authorizationEndpoint = isWecom + ? authorizationEndpointInside + : authorizationEndpointQrcode; + + expect(authorizationUri).toEqual( + `${authorizationEndpoint}?appid=%3Ccorp-id%3E&redirect_uri=http%3A%2F%2Flocalhost%3A3001%2Fcallback&response_type=code&scope=snsapi_userinfo&state=some_state&agentid=%3Cagent-id%3E#wechat_redirect` + ); + }); +}); + +describe('getAccessToken', () => { + afterEach(() => { + nock.cleanAll(); + jest.clearAllMocks(); + }); + + const accessTokenEndpointUrl = new URL(accessTokenEndpoint); + const parameters = new URLSearchParams({ + corpid: '', + corpsecret: '', + }); + + it('should get an accessToken by exchanging with code', async () => { + nock(accessTokenEndpointUrl.origin) + .get(accessTokenEndpointUrl.pathname) + .query(parameters) + .reply(200, { + access_token: 'access_token', + }); + const { accessToken } = await getAccessToken(mockedConfig); + expect(accessToken).toEqual('access_token'); + // Expect(openid).toEqual('openid'); + }); + + it('throws SocialAuthCodeInvalid error if errcode is 40029', async () => { + nock(accessTokenEndpointUrl.origin) + .get(accessTokenEndpointUrl.pathname) + .query(parameters) + .reply(200, { errcode: 40_029, errmsg: 'invalid code' }); + await expect(getAccessToken(mockedConfig)).rejects.toMatchError( + new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, 'invalid code') + ); + }); + + it('throws SocialAuthCodeInvalid error if errcode is 40163', async () => { + nock(accessTokenEndpointUrl.origin) + .get(accessTokenEndpointUrl.pathname) + .query(true) + .reply(200, { errcode: 40_163, errmsg: 'code been used' }); + await expect(getAccessToken(mockedConfig)).rejects.toMatchError( + new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, 'code been used') + ); + }); + + it('throws error with message otherwise', async () => { + nock(accessTokenEndpointUrl.origin) + .get(accessTokenEndpointUrl.pathname) + .query(true) + .reply(200, { errcode: -1, errmsg: 'system error' }); + await expect(getAccessToken(mockedConfig)).rejects.toMatchError( + new ConnectorError(ConnectorErrorCodes.General, { + errorDescription: 'system error', + errcode: -1, + }) + ); + }); +}); + +const nockNoOpenIdAccessTokenResponse = () => { + const accessTokenEndpointUrl = new URL(accessTokenEndpoint); + nock(accessTokenEndpointUrl.origin).get(accessTokenEndpointUrl.pathname).query(true).reply(200, { + access_token: 'access_token', + }); +}; + +describe('getUserInfo', () => { + beforeEach(() => { + const accessTokenEndpointUrl = new URL(accessTokenEndpoint); + const parameters = new URLSearchParams({ + corpid: '', + corpsecret: '', + }); + + nock(accessTokenEndpointUrl.origin) + .get(accessTokenEndpointUrl.pathname) + .query(parameters) + .reply(200, { + access_token: 'access_token', + }); + }); + + afterEach(() => { + nock.cleanAll(); + jest.clearAllMocks(); + }); + + const userInfoEndpointUrl = new URL(userInfoEndpoint); + const parameters = new URLSearchParams({ access_token: 'access_token', code: 'code' }); + + it('should get valid SocialUserInfo', async () => { + nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(0, { + userid: 'wecom_id', + }); + const connector = await createConnector({ getConfig }); + const socialUserInfo = await connector.getUserInfo( + { + code: 'code', + }, + jest.fn() + ); + expect(socialUserInfo).toMatchObject({ + id: 'wecom_id', + avatar: '', + name: 'wecom_id', + }); + }); + + it('throws General error if code not provided in input', async () => { + const connector = await createConnector({ getConfig }); + await expect(connector.getUserInfo({}, jest.fn())).rejects.toMatchError( + new ConnectorError(ConnectorErrorCodes.General, '{}') + ); + }); + + it('throws error if `openid` is missing', async () => { + nockNoOpenIdAccessTokenResponse(); + nock(userInfoEndpointUrl.origin) + .get(userInfoEndpointUrl.pathname) + .query(parameters) + .reply(200, { + errcode: 41_009, + errmsg: 'missing openid', + }); + const connector = await createConnector({ getConfig }); + await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError( + new ConnectorError(ConnectorErrorCodes.General, { + errorDescription: 'missing openid', + errcode: 41_009, + }) + ); + }); + + it('throws SocialAccessTokenInvalid error if errcode is 40001', async () => { + nock(userInfoEndpointUrl.origin) + .get(userInfoEndpointUrl.pathname) + .query(parameters) + .reply(200, { errcode: 40_001, errmsg: 'invalid credential' }); + const connector = await createConnector({ getConfig }); + await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError( + new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, 'invalid credential') + ); + }); + + it('throws unrecognized error', async () => { + nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(500); + const connector = await createConnector({ getConfig }); + await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toThrow(); + }); + + it('throws Error if request failed and errcode is not 40001', async () => { + nock(userInfoEndpointUrl.origin) + .get(userInfoEndpointUrl.pathname) + .query(parameters) + .reply(200, { errcode: 40_003, errmsg: 'invalid openid' }); + const connector = await createConnector({ getConfig }); + await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError( + new ConnectorError(ConnectorErrorCodes.General, { + errorDescription: 'invalid openid', + errcode: 40_003, + }) + ); + }); + + it('throws SocialAccessTokenInvalid error if response code is 401', async () => { + nock(userInfoEndpointUrl.origin).get(userInfoEndpointUrl.pathname).query(parameters).reply(401); + const connector = await createConnector({ getConfig }); + await expect(connector.getUserInfo({ code: 'code' }, jest.fn())).rejects.toMatchError( + new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid) + ); + }); +}); diff --git a/packages/connectors/connector-wecom/src/index.ts b/packages/connectors/connector-wecom/src/index.ts new file mode 100644 index 000000000..1f8ee1114 --- /dev/null +++ b/packages/connectors/connector-wecom/src/index.ts @@ -0,0 +1,196 @@ +/** + * The Implementation of OpenID Connect of WeChat Web Open Platform. + * https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html + */ + +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 { + authorizationEndpointInside, + authorizationEndpointQrcode, + accessTokenEndpoint, + userInfoEndpoint, + scope as defaultScope, + defaultMetadata, + defaultTimeout, + invalidAccessTokenErrcode, + invalidAuthCodeErrcode, +} from './constant.js'; +import type { + GetAccessTokenErrorHandler, + UserInfoResponseMessageParser, + WecomConfig, +} from './types.js'; +import { + wecomConfigGuard, + accessTokenResponseGuard, + userInfoResponseGuard, + authResponseGuard, +} from './types.js'; + +const getAuthorizationUri = + (getConfig: GetConnectorConfig): GetAuthorizationUri => + async ({ state, redirectUri, headers: { userAgent } }) => { + const config = await getConfig(defaultMetadata.id); + validateConfig(config, wecomConfigGuard); + + // WeCom has two different authorizationEndpoint, one for the WeCom built-in browser and another for regular web browsers. + const isWecom: boolean | undefined = userAgent?.toLowerCase().includes('wxwork'); + const { corpId, scope, agentId } = config; + + const queryParameters = new URLSearchParams({ + appid: corpId, + redirect_uri: encodeURI(redirectUri), // The variable `redirectUri` should match {appId, appSecret} + response_type: 'code', + scope: scope ?? defaultScope, + state, + agentid: agentId, + }); + // We switch to different authorizationEndpoint based on the value of isWecom + const authorizationEndpoint = isWecom + ? authorizationEndpointInside + : authorizationEndpointQrcode; + + return `${authorizationEndpoint}?${queryParameters.toString()}#wechat_redirect`; + }; + +export const getAccessToken = async (config: WecomConfig): Promise<{ accessToken: string }> => { + const { corpId: corpid, appSecret: corpsecret } = config; + + const httpResponse = await got.get(accessTokenEndpoint, { + searchParams: { corpid, corpsecret }, + timeout: { request: defaultTimeout }, + }); + + const result = accessTokenResponseGuard.safeParse(parseJson(httpResponse.body)); + + if (!result.success) { + throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); + } + + const { access_token: accessToken } = result.data; + + getAccessTokenErrorHandler(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, wecomConfigGuard); + const { accessToken } = await getAccessToken(config); + + try { + const httpResponse = await got.get(userInfoEndpoint, { + searchParams: { access_token: accessToken, code }, + timeout: { request: defaultTimeout }, + }); + + const result = userInfoResponseGuard.safeParse(parseJson(httpResponse.body)); + + if (!result.success) { + throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); + } + // Response properties of user info can be separated into two groups: (1) {unionid, headimgurl, nickname}, (2) {errcode, errmsg}. + // These two groups are mutually exclusive: if group (1) is not empty, group (2) should be empty and vice versa. + // 'errmsg' and 'errcode' turn to non-empty values or empty values at the same time. Hence, if 'errmsg' is non-empty then 'errcode' should be non-empty. + errorResponseHandler(result.data); + // + const { userid, openid } = result.data; + + if (userid) { + return { id: userid, avatar: '', name: userid }; + } + if (openid) { + return { id: openid, avatar: '', name: openid }; + } + throw new Error('Both userid and openid are undefined or null.'); + // Both userid and openid are null + } catch (error: unknown) { + return getUserInfoErrorHandler(error); + } + }; + +// See https://developers.weixin.qq.com/doc/oplatform/Return_codes/Return_code_descriptions_new.html +const getAccessTokenErrorHandler: GetAccessTokenErrorHandler = (accessToken) => { + const { errcode, errmsg } = accessToken; + + if (errcode) { + assert( + !invalidAuthCodeErrcode.includes(errcode), + new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid, errmsg) + ); + + throw new ConnectorError(ConnectorErrorCodes.General, { errorDescription: errmsg, errcode }); + } +}; + +const errorResponseHandler: UserInfoResponseMessageParser = (userInfo) => { + const { errcode, errmsg } = userInfo; + + if (errcode) { + assert( + !invalidAccessTokenErrcode.includes(errcode), + new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid, errmsg) + ); + + throw new ConnectorError(ConnectorErrorCodes.General, { errorDescription: errmsg, errcode }); + } +}; + +const getUserInfoErrorHandler = (error: unknown) => { + if (error instanceof HTTPError) { + const { statusCode, body: rawBody } = error.response; + + if (statusCode === 401) { + 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 createWecomConnector: CreateConnector = async ({ getConfig }) => { + return { + metadata: defaultMetadata, + type: ConnectorType.Social, + configGuard: wecomConfigGuard, + getAuthorizationUri: getAuthorizationUri(getConfig), + getUserInfo: getUserInfo(getConfig), + }; +}; + +export default createWecomConnector; diff --git a/packages/connectors/connector-wecom/src/mock.ts b/packages/connectors/connector-wecom/src/mock.ts new file mode 100644 index 000000000..e5d556b35 --- /dev/null +++ b/packages/connectors/connector-wecom/src/mock.ts @@ -0,0 +1,6 @@ +export const mockedConfig = { + corpId: '', + appSecret: '', + agentId: '', + access_token: 'access_token', +}; diff --git a/packages/connectors/connector-wecom/src/types.ts b/packages/connectors/connector-wecom/src/types.ts new file mode 100644 index 000000000..319809195 --- /dev/null +++ b/packages/connectors/connector-wecom/src/types.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +export const wecomConfigGuard = z.object({ + corpId: z.string(), + appSecret: z.string(), + scope: z.string().optional(), + agentId: z.string(), +}); + +export type WecomConfig = z.infer; + +export const accessTokenResponseGuard = z.object({ + access_token: z.string().optional(), + expires_in: z.number().optional(), // In seconds + errcode: z.number().optional(), + errmsg: z.string().optional(), +}); + +export type AccessTokenResponse = z.infer; + +export type GetAccessTokenErrorHandler = (accessToken: Partial) => void; + +export const userInfoResponseGuard = z.object({ + errcode: z.number().optional(), + errmsg: z.string().optional(), + userid: z.string().optional(), + user_ticket: z.string().optional(), + openid: z.string().optional(), + external_userid: 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 fc3d39cc4..6e7ba2956 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2828,6 +2828,85 @@ importers: specifier: ^5.3.3 version: 5.3.3 + packages/connectors/connector-wecom: + dependencies: + '@logto/connector-kit': + specifier: workspace:^2.0.0 + version: link:../../toolkit/connector-kit + '@silverhand/essentials': + specifier: ^2.9.0 + version: 2.9.0 + got: + specifier: ^14.0.0 + version: 14.0.0 + snakecase-keys: + specifier: ^6.0.0 + version: 6.0.0 + zod: + specifier: ^3.22.4 + version: 3.22.4 + devDependencies: + '@jest/types': + specifier: ^29.5.0 + version: 29.6.3 + '@rollup/plugin-commonjs': + specifier: ^25.0.0 + version: 25.0.7(rollup@4.12.0) + '@rollup/plugin-json': + specifier: ^6.0.0 + version: 6.1.0(rollup@4.12.0) + '@rollup/plugin-node-resolve': + specifier: ^15.0.1 + version: 15.2.3(rollup@4.12.0) + '@rollup/plugin-typescript': + specifier: ^11.0.0 + version: 11.1.6(rollup@4.12.0)(typescript@5.3.3) + '@silverhand/eslint-config': + specifier: 5.0.0 + version: 5.0.0(eslint@8.44.0)(prettier@3.0.0)(typescript@5.3.3) + '@silverhand/ts-config': + specifier: 5.0.0 + version: 5.0.0(typescript@5.3.3) + '@types/jest': + specifier: ^29.4.0 + version: 29.4.0 + '@types/node': + specifier: ^20.9.5 + version: 20.10.4 + '@types/supertest': + specifier: ^6.0.0 + version: 6.0.1 + eslint: + specifier: ^8.44.0 + version: 8.44.0 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.10.4) + jest-matcher-specific-error: + specifier: ^1.0.0 + version: 1.0.0 + lint-staged: + specifier: ^15.0.0 + version: 15.0.2 + nock: + specifier: ^13.2.2 + version: 13.3.1 + prettier: + specifier: ^3.0.0 + version: 3.0.0 + rollup: + specifier: ^4.0.0 + version: 4.12.0 + rollup-plugin-summary: + specifier: ^2.0.0 + version: 2.0.0(rollup@4.12.0) + supertest: + specifier: ^6.2.2 + version: 6.2.2 + typescript: + specifier: ^5.3.3 + version: 5.3.3 + packages/console: devDependencies: '@fontsource/roboto-mono':