mirror of
https://github.com/logto-io/logto.git
synced 2025-01-13 21:30:30 -05:00
feat(core): wrap facebook connector (#672)
* feat(core): wrap facebook connector * feat(core): connectors package.json private should be FALSE
This commit is contained in:
parent
54b62094c8
commit
cbd6cfae8a
13 changed files with 472 additions and 0 deletions
2
packages/connector-facebook/README.md
Normal file
2
packages/connector-facebook/README.md
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
### FB Social Connector README
|
||||||
|
placeholder
|
6
packages/connector-facebook/docs/config-template.md
Normal file
6
packages/connector-facebook/docs/config-template.md
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"clientId": "<client-id>",
|
||||||
|
"clientSecret": "<client-secret>"
|
||||||
|
}
|
||||||
|
```
|
8
packages/connector-facebook/jest.config.ts
Normal file
8
packages/connector-facebook/jest.config.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { Config, merge } from '@logto/jest-config';
|
||||||
|
|
||||||
|
const config: Config.InitialOptions = merge({
|
||||||
|
testEnvironment: 'node',
|
||||||
|
setupFilesAfterEnv: ['jest-matcher-specific-error'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export default config;
|
58
packages/connector-facebook/package.json
Normal file
58
packages/connector-facebook/package.json
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
{
|
||||||
|
"name": "@logto/connector-facebook",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Facebook web connector implementation.",
|
||||||
|
"main": "./lib/index.js",
|
||||||
|
"exports": "./lib/index.js",
|
||||||
|
"author": "Silverhand Inc. <contact@silverhand.io>",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"files": [
|
||||||
|
"lib",
|
||||||
|
"docs"
|
||||||
|
],
|
||||||
|
"private": false,
|
||||||
|
"scripts": {
|
||||||
|
"preinstall": "npx only-allow pnpm",
|
||||||
|
"precommit": "lint-staged",
|
||||||
|
"build": "rm -rf lib/ && tsc -p tsconfig.build.json",
|
||||||
|
"lint": "eslint --ext .ts src",
|
||||||
|
"lint:report": "pnpm lint -- --format json --output-file report.json",
|
||||||
|
"dev": "rm -rf lib/ && tsc-watch -p tsconfig.build.json --preserveWatchOutput --onSuccess \"node ./lib/index.js\"",
|
||||||
|
"test": "jest",
|
||||||
|
"test:coverage": "jest --coverage --silent",
|
||||||
|
"prepack": "pnpm build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@logto/connector-types": "^0.1.0",
|
||||||
|
"@logto/jest-config": "^0.1.0",
|
||||||
|
"@logto/shared": "^0.1.0",
|
||||||
|
"@logto/schemas": "^0.1.0",
|
||||||
|
"@silverhand/essentials": "^1.1.0",
|
||||||
|
"got": "^11.8.2",
|
||||||
|
"query-string": "^7.0.1",
|
||||||
|
"zod": "^3.14.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@jest/types": "^27.5.1",
|
||||||
|
"@silverhand/eslint-config": "^0.10.2",
|
||||||
|
"@silverhand/ts-config": "^0.10.2",
|
||||||
|
"@types/jest": "^27.4.1",
|
||||||
|
"@types/node": "^16.3.1",
|
||||||
|
"eslint": "^8.10.0",
|
||||||
|
"jest": "^27.5.1",
|
||||||
|
"jest-matcher-specific-error": "^1.0.0",
|
||||||
|
"lint-staged": "^11.1.1",
|
||||||
|
"nock": "^13.2.2",
|
||||||
|
"prettier": "^2.3.2",
|
||||||
|
"ts-jest": "^27.1.1",
|
||||||
|
"tsc-watch": "^4.4.0",
|
||||||
|
"typescript": "^4.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^16.0.0"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": "@silverhand"
|
||||||
|
},
|
||||||
|
"prettier": "@silverhand/eslint-config/.prettierrc"
|
||||||
|
}
|
51
packages/connector-facebook/src/constant.ts
Normal file
51
packages/connector-facebook/src/constant.ts
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { ConnectorMetadata, ConnectorType } from '@logto/connector-types';
|
||||||
|
import { getFileContents } from '@logto/shared';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note: If you do not include a version number we will default to the oldest available version, so it's recommended to include the version number in your requests.
|
||||||
|
* https://developers.facebook.com/docs/graph-api/overview#versions
|
||||||
|
*/
|
||||||
|
export const authorizationEndpoint = 'https://www.facebook.com/v13.0/dialog/oauth';
|
||||||
|
export const accessTokenEndpoint = 'https://graph.facebook.com/v13.0/oauth/access_token';
|
||||||
|
/**
|
||||||
|
* Note: The /me node is a special endpoint that translates to the object ID of the person or Page whose access token is currently being used to make the API calls.
|
||||||
|
* https://developers.facebook.com/docs/graph-api/overview#me
|
||||||
|
* https://developers.facebook.com/docs/graph-api/reference/user#Reading
|
||||||
|
*/
|
||||||
|
export const userInfoEndpoint = 'https://graph.facebook.com/v13.0/me';
|
||||||
|
export const scope = 'email,public_profile';
|
||||||
|
|
||||||
|
export const facebookConfigGuard = z.object({
|
||||||
|
clientId: z.string(),
|
||||||
|
clientSecret: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FacebookConfig = z.infer<typeof facebookConfigGuard>;
|
||||||
|
|
||||||
|
// eslint-disable-next-line unicorn/prefer-module
|
||||||
|
const currentPath = __dirname;
|
||||||
|
const pathToReadmeFile = path.join(currentPath, '..', 'README.md');
|
||||||
|
const pathToConfigTemplate = path.join(currentPath, '..', 'docs', 'config-template.md');
|
||||||
|
const readmeContentFallback = 'Please check README.md file directory.';
|
||||||
|
const configTemplateFallback = 'Please check config-template.md file directory.';
|
||||||
|
|
||||||
|
export const defaultMetadata: ConnectorMetadata = {
|
||||||
|
id: 'facebook',
|
||||||
|
type: ConnectorType.Social,
|
||||||
|
name: {
|
||||||
|
en: 'Sign In with Facebook',
|
||||||
|
'zh-CN': 'Facebook 登录',
|
||||||
|
},
|
||||||
|
logo: './logo.png',
|
||||||
|
description: {
|
||||||
|
en: 'Sign In with Facebook',
|
||||||
|
'zh-CN': 'Facebook 登录',
|
||||||
|
},
|
||||||
|
readme: getFileContents(pathToReadmeFile, readmeContentFallback),
|
||||||
|
configTemplate: getFileContents(pathToConfigTemplate, configTemplateFallback),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultTimeout = 5000;
|
138
packages/connector-facebook/src/index.test.ts
Normal file
138
packages/connector-facebook/src/index.test.ts
Normal file
|
@ -0,0 +1,138 @@
|
||||||
|
import { ConnectorError, ConnectorErrorCodes, GetConnectorConfig } from '@logto/connector-types';
|
||||||
|
import nock from 'nock';
|
||||||
|
|
||||||
|
import { FacebookConnector } from '.';
|
||||||
|
import {
|
||||||
|
FacebookConfig,
|
||||||
|
accessTokenEndpoint,
|
||||||
|
authorizationEndpoint,
|
||||||
|
userInfoEndpoint,
|
||||||
|
} from './constant';
|
||||||
|
import { clientId, clientSecret, code, dummyRedirectUri, fields, mockedConfig } from './mock';
|
||||||
|
|
||||||
|
const getConnectorConfig = jest.fn() as GetConnectorConfig<FacebookConfig>;
|
||||||
|
|
||||||
|
const facebookMethods = new FacebookConnector(getConnectorConfig);
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
jest.spyOn(facebookMethods, 'getConfig').mockResolvedValue(mockedConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('facebook connector', () => {
|
||||||
|
describe('validateConfig', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass on valid config', async () => {
|
||||||
|
await expect(
|
||||||
|
facebookMethods.validateConfig({ clientId, clientSecret })
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on invalid config', async () => {
|
||||||
|
await expect(facebookMethods.validateConfig({})).rejects.toThrow();
|
||||||
|
await expect(facebookMethods.validateConfig({ clientId })).rejects.toThrow();
|
||||||
|
await expect(facebookMethods.validateConfig({ clientSecret })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAuthorizationUri', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get a valid authorizationUri with redirectUri and state', async () => {
|
||||||
|
const redirectUri = 'http://localhost:3000/callback';
|
||||||
|
const state = 'some_state';
|
||||||
|
const authorizationUri = await facebookMethods.getAuthorizationUri(redirectUri, state);
|
||||||
|
|
||||||
|
const encodedRedirectUri = encodeURIComponent(redirectUri);
|
||||||
|
expect(authorizationUri).toEqual(
|
||||||
|
`${authorizationEndpoint}?client_id=${clientId}&redirect_uri=${encodedRedirectUri}&response_type=code&scope=email%2Cpublic_profile&state=${state}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAccessToken', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
nock.cleanAll();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get an accessToken by exchanging with code', async () => {
|
||||||
|
nock(accessTokenEndpoint)
|
||||||
|
.get('')
|
||||||
|
.query({
|
||||||
|
code,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
redirect_uri: dummyRedirectUri,
|
||||||
|
})
|
||||||
|
.reply(200, {
|
||||||
|
access_token: 'access_token',
|
||||||
|
scope: 'scope',
|
||||||
|
token_type: 'token_type',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { accessToken } = await facebookMethods.getAccessToken(code, dummyRedirectUri);
|
||||||
|
expect(accessToken).toEqual('access_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws SocialAuthCodeInvalid error if accessToken not found in response', async () => {
|
||||||
|
nock(accessTokenEndpoint)
|
||||||
|
.get('')
|
||||||
|
.query({
|
||||||
|
code,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
redirect_uri: dummyRedirectUri,
|
||||||
|
})
|
||||||
|
.reply(200, {});
|
||||||
|
|
||||||
|
await expect(facebookMethods.getAccessToken(code, dummyRedirectUri)).rejects.toMatchError(
|
||||||
|
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getUserInfo', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
nock.cleanAll();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get valid SocialUserInfo', async () => {
|
||||||
|
const avatar = 'https://github.com/images/error/octocat_happy.gif';
|
||||||
|
nock(userInfoEndpoint)
|
||||||
|
.get('')
|
||||||
|
.query({ fields })
|
||||||
|
.reply(200, {
|
||||||
|
id: '1234567890',
|
||||||
|
name: 'monalisa octocat',
|
||||||
|
email: 'octocat@facebook.com',
|
||||||
|
picture: { data: { url: avatar } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const socialUserInfo = await facebookMethods.getUserInfo({ accessToken: code });
|
||||||
|
expect(socialUserInfo).toMatchObject({
|
||||||
|
id: '1234567890',
|
||||||
|
avatar,
|
||||||
|
name: 'monalisa octocat',
|
||||||
|
email: 'octocat@facebook.com',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws SocialAccessTokenInvalid error if remote response code is 401', async () => {
|
||||||
|
nock(userInfoEndpoint).get('').query({ fields }).reply(400);
|
||||||
|
await expect(facebookMethods.getUserInfo({ accessToken: code })).rejects.toMatchError(
|
||||||
|
new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws unrecognized error', async () => {
|
||||||
|
nock(userInfoEndpoint).get('').reply(500);
|
||||||
|
await expect(facebookMethods.getUserInfo({ accessToken: code })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
125
packages/connector-facebook/src/index.ts
Normal file
125
packages/connector-facebook/src/index.ts
Normal file
|
@ -0,0 +1,125 @@
|
||||||
|
/**
|
||||||
|
* Reference: Manually Build a Login Flow
|
||||||
|
* https://developers.facebook.com/docs/facebook-login/manually-build-a-login-flow
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ConnectorError,
|
||||||
|
ConnectorErrorCodes,
|
||||||
|
ConnectorMetadata,
|
||||||
|
GetAccessToken,
|
||||||
|
GetAuthorizationUri,
|
||||||
|
GetUserInfo,
|
||||||
|
ValidateConfig,
|
||||||
|
SocialConnector,
|
||||||
|
GetConnectorConfig,
|
||||||
|
} from '@logto/connector-types';
|
||||||
|
import { assert } from '@silverhand/essentials';
|
||||||
|
import got, { RequestError as GotRequestError } from 'got';
|
||||||
|
import { stringify } from 'query-string';
|
||||||
|
|
||||||
|
import {
|
||||||
|
accessTokenEndpoint,
|
||||||
|
authorizationEndpoint,
|
||||||
|
scope,
|
||||||
|
userInfoEndpoint,
|
||||||
|
defaultMetadata,
|
||||||
|
defaultTimeout,
|
||||||
|
facebookConfigGuard,
|
||||||
|
FacebookConfig,
|
||||||
|
} from './constant';
|
||||||
|
|
||||||
|
export class FacebookConnector implements SocialConnector {
|
||||||
|
public metadata: ConnectorMetadata = defaultMetadata;
|
||||||
|
|
||||||
|
public readonly getConfig: GetConnectorConfig<FacebookConfig>;
|
||||||
|
|
||||||
|
constructor(getConnectorConfig: GetConnectorConfig<FacebookConfig>) {
|
||||||
|
this.getConfig = getConnectorConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
public validateConfig: ValidateConfig = async (config: unknown) => {
|
||||||
|
const result = facebookConfigGuard.safeParse(config);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public getAuthorizationUri: GetAuthorizationUri = async (redirectUri, state) => {
|
||||||
|
const config = await this.getConfig(this.metadata.id);
|
||||||
|
|
||||||
|
return `${authorizationEndpoint}?${stringify({
|
||||||
|
client_id: config.clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
response_type: 'code',
|
||||||
|
state,
|
||||||
|
scope, // Only support fixed scope for v1.
|
||||||
|
})}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
public getAccessToken: GetAccessToken = async (code, redirectUri) => {
|
||||||
|
type AccessTokenResponse = {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
expires_in: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { clientId: client_id, clientSecret: client_secret } = await this.getConfig(
|
||||||
|
this.metadata.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const { access_token: accessToken } = await got
|
||||||
|
.get(accessTokenEndpoint, {
|
||||||
|
searchParams: {
|
||||||
|
code,
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
},
|
||||||
|
timeout: defaultTimeout,
|
||||||
|
})
|
||||||
|
.json<AccessTokenResponse>();
|
||||||
|
|
||||||
|
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
|
||||||
|
|
||||||
|
return { accessToken };
|
||||||
|
};
|
||||||
|
|
||||||
|
public getUserInfo: GetUserInfo = async (accessTokenObject) => {
|
||||||
|
type UserInfoResponse = {
|
||||||
|
id: string;
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
picture?: { data: { url: string } };
|
||||||
|
};
|
||||||
|
|
||||||
|
const { accessToken } = accessTokenObject;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id, email, name, picture } = await got
|
||||||
|
.get(userInfoEndpoint, {
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
searchParams: {
|
||||||
|
fields: 'id,name,email,picture',
|
||||||
|
},
|
||||||
|
timeout: defaultTimeout,
|
||||||
|
})
|
||||||
|
.json<UserInfoResponse>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
avatar: picture?.data.url,
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof GotRequestError && error.response?.statusCode === 400) {
|
||||||
|
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
7
packages/connector-facebook/src/mock.ts
Normal file
7
packages/connector-facebook/src/mock.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
export const clientId = 'client_id_value';
|
||||||
|
export const clientSecret = 'client_secret_value';
|
||||||
|
export const code = 'code';
|
||||||
|
export const dummyRedirectUri = 'dummyRedirectUri';
|
||||||
|
export const fields = 'id,name,email,picture';
|
||||||
|
|
||||||
|
export const mockedConfig = { clientId, clientSecret };
|
10
packages/connector-facebook/tsconfig.base.json
Normal file
10
packages/connector-facebook/tsconfig.base.json
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"extends": "@silverhand/ts-config/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "lib",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
5
packages/connector-facebook/tsconfig.build.json
Normal file
5
packages/connector-facebook/tsconfig.build.json
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.base",
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
|
}
|
7
packages/connector-facebook/tsconfig.json
Normal file
7
packages/connector-facebook/tsconfig.json
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["node", "jest", "jest-matcher-specific-error"]
|
||||||
|
},
|
||||||
|
"include": ["src", "jest.config.ts"]
|
||||||
|
}
|
6
packages/connector-facebook/tsconfig.test.json
Normal file
6
packages/connector-facebook/tsconfig.test.json
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig",
|
||||||
|
"compilerOptions": {
|
||||||
|
"isolatedModules": false
|
||||||
|
}
|
||||||
|
}
|
49
pnpm-lock.yaml
generated
49
pnpm-lock.yaml
generated
|
@ -122,6 +122,55 @@ importers:
|
||||||
tsc-watch: 4.6.2_typescript@4.6.3
|
tsc-watch: 4.6.2_typescript@4.6.3
|
||||||
typescript: 4.6.3
|
typescript: 4.6.3
|
||||||
|
|
||||||
|
packages/connector-facebook:
|
||||||
|
specifiers:
|
||||||
|
'@jest/types': ^27.5.1
|
||||||
|
'@logto/connector-types': ^0.1.0
|
||||||
|
'@logto/jest-config': ^0.1.0
|
||||||
|
'@logto/schemas': ^0.1.0
|
||||||
|
'@logto/shared': ^0.1.0
|
||||||
|
'@silverhand/eslint-config': ^0.10.2
|
||||||
|
'@silverhand/essentials': ^1.1.0
|
||||||
|
'@silverhand/ts-config': ^0.10.2
|
||||||
|
'@types/jest': ^27.4.1
|
||||||
|
'@types/node': ^16.3.1
|
||||||
|
eslint: ^8.10.0
|
||||||
|
got: ^11.8.2
|
||||||
|
jest: ^27.5.1
|
||||||
|
jest-matcher-specific-error: ^1.0.0
|
||||||
|
lint-staged: ^11.1.1
|
||||||
|
nock: ^13.2.2
|
||||||
|
prettier: ^2.3.2
|
||||||
|
query-string: ^7.0.1
|
||||||
|
ts-jest: ^27.1.1
|
||||||
|
tsc-watch: ^4.4.0
|
||||||
|
typescript: ^4.6.2
|
||||||
|
zod: ^3.14.3
|
||||||
|
dependencies:
|
||||||
|
'@logto/connector-types': link:../connector-types
|
||||||
|
'@logto/jest-config': link:../jest-config
|
||||||
|
'@logto/schemas': link:../schemas
|
||||||
|
'@logto/shared': link:../shared
|
||||||
|
'@silverhand/essentials': 1.1.7
|
||||||
|
got: 11.8.3
|
||||||
|
query-string: 7.0.1
|
||||||
|
zod: 3.14.3
|
||||||
|
devDependencies:
|
||||||
|
'@jest/types': 27.5.1
|
||||||
|
'@silverhand/eslint-config': 0.10.2_bbe1a6794670f389df81805f22999709
|
||||||
|
'@silverhand/ts-config': 0.10.2_typescript@4.6.3
|
||||||
|
'@types/jest': 27.4.1
|
||||||
|
'@types/node': 16.11.12
|
||||||
|
eslint: 8.10.0
|
||||||
|
jest: 27.5.1
|
||||||
|
jest-matcher-specific-error: 1.0.0
|
||||||
|
lint-staged: 11.2.6
|
||||||
|
nock: 13.2.2
|
||||||
|
prettier: 2.5.1
|
||||||
|
ts-jest: 27.1.1_9985e1834e803358b7be1e6ce5ca0eea
|
||||||
|
tsc-watch: 4.6.2_typescript@4.6.3
|
||||||
|
typescript: 4.6.3
|
||||||
|
|
||||||
packages/connector-types:
|
packages/connector-types:
|
||||||
specifiers:
|
specifiers:
|
||||||
'@jest/types': ^27.5.1
|
'@jest/types': ^27.5.1
|
||||||
|
|
Loading…
Add table
Reference in a new issue