mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(connector): can access all user email even if no public email is set (#5737)
This commit is contained in:
parent
f8221a38db
commit
0227822b2d
7 changed files with 179 additions and 76 deletions
8
.changeset/pretty-mirrors-peel.md
Normal file
8
.changeset/pretty-mirrors-peel.md
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
"@logto/connector-github": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
fetch GitHub account's private email address list and pick the verified primary email as a fallback
|
||||||
|
|
||||||
|
- Add `user:email` as part of default scope to fetch GitHub account's private email address list
|
||||||
|
- Pick the verified primary email among private email address list as a fallback if the user does not set a public email for GitHub account
|
|
@ -6,7 +6,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@logto/connector-kit": "workspace:^3.0.0",
|
"@logto/connector-kit": "workspace:^3.0.0",
|
||||||
"@silverhand/essentials": "^2.9.0",
|
"@silverhand/essentials": "^2.9.0",
|
||||||
"got": "^14.0.0",
|
"ky": "^1.2.3",
|
||||||
"query-string": "^9.0.0",
|
"query-string": "^9.0.0",
|
||||||
"snakecase-keys": "^8.0.0",
|
"snakecase-keys": "^8.0.0",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
|
@ -64,7 +64,7 @@
|
||||||
"@vitest/coverage-v8": "^1.4.0",
|
"@vitest/coverage-v8": "^1.4.0",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"lint-staged": "^15.0.2",
|
"lint-staged": "^15.0.2",
|
||||||
"nock": "^13.3.1",
|
"nock": "14.0.0-beta.6",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.0.0",
|
||||||
"rollup": "^4.12.0",
|
"rollup": "^4.12.0",
|
||||||
"rollup-plugin-output-size": "^1.3.0",
|
"rollup-plugin-output-size": "^1.3.0",
|
||||||
|
|
|
@ -2,9 +2,15 @@ import type { ConnectorMetadata } from '@logto/connector-kit';
|
||||||
import { ConnectorPlatform, ConnectorConfigFormItemType } from '@logto/connector-kit';
|
import { ConnectorPlatform, ConnectorConfigFormItemType } from '@logto/connector-kit';
|
||||||
|
|
||||||
export const authorizationEndpoint = 'https://github.com/login/oauth/authorize';
|
export const authorizationEndpoint = 'https://github.com/login/oauth/authorize';
|
||||||
export const scope = 'read:user';
|
/**
|
||||||
|
* `read:user` read user profile data; `user:email` read user email addresses (including private email addresses).
|
||||||
|
* Ref: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps
|
||||||
|
*/
|
||||||
|
export const scope = 'read:user user:email';
|
||||||
export const accessTokenEndpoint = 'https://github.com/login/oauth/access_token';
|
export const accessTokenEndpoint = 'https://github.com/login/oauth/access_token';
|
||||||
export const userInfoEndpoint = 'https://api.github.com/user';
|
export const userInfoEndpoint = 'https://api.github.com/user';
|
||||||
|
// Ref: https://docs.github.com/en/rest/users/emails?apiVersion=2022-11-28#list-email-addresses-for-the-authenticated-user
|
||||||
|
export const userEmailsEndpoint = 'https://api.github.com/user/emails';
|
||||||
|
|
||||||
export const defaultMetadata: ConnectorMetadata = {
|
export const defaultMetadata: ConnectorMetadata = {
|
||||||
id: 'github-universal',
|
id: 'github-universal',
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
import nock from 'nock';
|
import nock from 'nock';
|
||||||
|
|
||||||
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit';
|
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit';
|
||||||
import qs from 'query-string';
|
|
||||||
|
|
||||||
import { accessTokenEndpoint, authorizationEndpoint, userInfoEndpoint } from './constant.js';
|
import {
|
||||||
|
accessTokenEndpoint,
|
||||||
|
authorizationEndpoint,
|
||||||
|
userEmailsEndpoint,
|
||||||
|
userInfoEndpoint,
|
||||||
|
} from './constant.js';
|
||||||
import createConnector, { getAccessToken } from './index.js';
|
import createConnector, { getAccessToken } from './index.js';
|
||||||
import { mockedConfig } from './mock.js';
|
import { mockedConfig } from './mock.js';
|
||||||
|
|
||||||
|
@ -28,7 +32,7 @@ describe('getAuthorizationUri', () => {
|
||||||
vi.fn()
|
vi.fn()
|
||||||
);
|
);
|
||||||
expect(authorizationUri).toEqual(
|
expect(authorizationUri).toEqual(
|
||||||
`${authorizationEndpoint}?client_id=%3Cclient-id%3E&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&state=some_state&scope=read%3Auser`
|
`${authorizationEndpoint}?client_id=%3Cclient-id%3E&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&state=some_state&scope=read%3Auser+user%3Aemail`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -40,16 +44,11 @@ describe('getAccessToken', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should get an accessToken by exchanging with code', async () => {
|
it('should get an accessToken by exchanging with code', async () => {
|
||||||
nock(accessTokenEndpoint)
|
nock(accessTokenEndpoint).post('').reply(200, {
|
||||||
.post('')
|
access_token: 'access_token',
|
||||||
.reply(
|
scope: 'scope',
|
||||||
200,
|
token_type: 'token_type',
|
||||||
qs.stringify({
|
});
|
||||||
access_token: 'access_token',
|
|
||||||
scope: 'scope',
|
|
||||||
token_type: 'token_type',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const { accessToken } = await getAccessToken(mockedConfig, { code: 'code' });
|
const { accessToken } = await getAccessToken(mockedConfig, { code: 'code' });
|
||||||
expect(accessToken).toEqual('access_token');
|
expect(accessToken).toEqual('access_token');
|
||||||
});
|
});
|
||||||
|
@ -57,7 +56,7 @@ describe('getAccessToken', () => {
|
||||||
it('throws SocialAuthCodeInvalid error if accessToken not found in response', async () => {
|
it('throws SocialAuthCodeInvalid error if accessToken not found in response', async () => {
|
||||||
nock(accessTokenEndpoint)
|
nock(accessTokenEndpoint)
|
||||||
.post('')
|
.post('')
|
||||||
.reply(200, qs.stringify({ access_token: '', scope: 'scope', token_type: 'token_type' }));
|
.reply(200, { access_token: '', scope: 'scope', token_type: 'token_type' });
|
||||||
await expect(getAccessToken(mockedConfig, { code: 'code' })).rejects.toStrictEqual(
|
await expect(getAccessToken(mockedConfig, { code: 'code' })).rejects.toStrictEqual(
|
||||||
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)
|
new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid)
|
||||||
);
|
);
|
||||||
|
@ -66,16 +65,11 @@ describe('getAccessToken', () => {
|
||||||
|
|
||||||
describe('getUserInfo', () => {
|
describe('getUserInfo', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
nock(accessTokenEndpoint)
|
nock(accessTokenEndpoint).post('').query(true).reply(200, {
|
||||||
.post('')
|
access_token: 'access_token',
|
||||||
.reply(
|
scope: 'scope',
|
||||||
200,
|
token_type: 'token_type',
|
||||||
qs.stringify({
|
});
|
||||||
access_token: 'access_token',
|
|
||||||
scope: 'scope',
|
|
||||||
token_type: 'token_type',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
@ -91,6 +85,7 @@ describe('getUserInfo', () => {
|
||||||
email: 'octocat@github.com',
|
email: 'octocat@github.com',
|
||||||
foo: 'bar',
|
foo: 'bar',
|
||||||
});
|
});
|
||||||
|
nock(userEmailsEndpoint).get('').reply(200, []);
|
||||||
const connector = await createConnector({ getConfig });
|
const connector = await createConnector({ getConfig });
|
||||||
const socialUserInfo = await connector.getUserInfo({ code: 'code' }, vi.fn());
|
const socialUserInfo = await connector.getUserInfo({ code: 'code' }, vi.fn());
|
||||||
expect(socialUserInfo).toStrictEqual({
|
expect(socialUserInfo).toStrictEqual({
|
||||||
|
@ -99,11 +94,70 @@ describe('getUserInfo', () => {
|
||||||
name: 'monalisa octocat',
|
name: 'monalisa octocat',
|
||||||
email: 'octocat@github.com',
|
email: 'octocat@github.com',
|
||||||
rawData: {
|
rawData: {
|
||||||
id: 1,
|
userInfo: {
|
||||||
avatar_url: 'https://github.com/images/error/octocat_happy.gif',
|
id: 1,
|
||||||
name: 'monalisa octocat',
|
avatar_url: 'https://github.com/images/error/octocat_happy.gif',
|
||||||
email: 'octocat@github.com',
|
name: 'monalisa octocat',
|
||||||
foo: 'bar',
|
email: 'octocat@github.com',
|
||||||
|
foo: 'bar',
|
||||||
|
},
|
||||||
|
userEmails: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback to verified primary email if not public is available', async () => {
|
||||||
|
nock(userInfoEndpoint).get('').reply(200, {
|
||||||
|
id: 1,
|
||||||
|
avatar_url: 'https://github.com/images/error/octocat_happy.gif',
|
||||||
|
name: 'monalisa octocat',
|
||||||
|
email: undefined,
|
||||||
|
foo: 'bar',
|
||||||
|
});
|
||||||
|
nock(userEmailsEndpoint)
|
||||||
|
.get('')
|
||||||
|
.reply(200, [
|
||||||
|
{
|
||||||
|
email: 'foo@logto.io',
|
||||||
|
verified: true,
|
||||||
|
primary: true,
|
||||||
|
visibility: 'public',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: 'foo1@logto.io',
|
||||||
|
verified: true,
|
||||||
|
primary: false,
|
||||||
|
visibility: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const connector = await createConnector({ getConfig });
|
||||||
|
const socialUserInfo = await connector.getUserInfo({ code: 'code' }, vi.fn());
|
||||||
|
expect(socialUserInfo).toStrictEqual({
|
||||||
|
id: '1',
|
||||||
|
avatar: 'https://github.com/images/error/octocat_happy.gif',
|
||||||
|
name: 'monalisa octocat',
|
||||||
|
email: 'foo@logto.io',
|
||||||
|
rawData: {
|
||||||
|
userInfo: {
|
||||||
|
id: 1,
|
||||||
|
avatar_url: 'https://github.com/images/error/octocat_happy.gif',
|
||||||
|
name: 'monalisa octocat',
|
||||||
|
foo: 'bar',
|
||||||
|
},
|
||||||
|
userEmails: [
|
||||||
|
{
|
||||||
|
email: 'foo@logto.io',
|
||||||
|
verified: true,
|
||||||
|
primary: true,
|
||||||
|
visibility: 'public',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: 'foo1@logto.io',
|
||||||
|
verified: true,
|
||||||
|
primary: false,
|
||||||
|
visibility: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -115,15 +169,14 @@ describe('getUserInfo', () => {
|
||||||
name: null,
|
name: null,
|
||||||
email: null,
|
email: null,
|
||||||
});
|
});
|
||||||
|
nock(userEmailsEndpoint).get('').reply(200, []);
|
||||||
const connector = await createConnector({ getConfig });
|
const connector = await createConnector({ getConfig });
|
||||||
const socialUserInfo = await connector.getUserInfo({ code: 'code' }, vi.fn());
|
const socialUserInfo = await connector.getUserInfo({ code: 'code' }, vi.fn());
|
||||||
expect(socialUserInfo).toMatchObject({
|
expect(socialUserInfo).toMatchObject({
|
||||||
id: '1',
|
id: '1',
|
||||||
rawData: {
|
rawData: {
|
||||||
id: 1,
|
userInfo: { id: 1, avatar_url: null, name: null, email: null },
|
||||||
avatar_url: null,
|
userEmails: [],
|
||||||
name: null,
|
|
||||||
email: null,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,12 @@
|
||||||
import { assert, conditional } from '@silverhand/essentials';
|
import { assert, conditional } from '@silverhand/essentials';
|
||||||
import { got, HTTPError } from 'got';
|
|
||||||
|
|
||||||
|
import {
|
||||||
|
ConnectorError,
|
||||||
|
ConnectorErrorCodes,
|
||||||
|
validateConfig,
|
||||||
|
ConnectorType,
|
||||||
|
jsonGuard,
|
||||||
|
} from '@logto/connector-kit';
|
||||||
import type {
|
import type {
|
||||||
GetAuthorizationUri,
|
GetAuthorizationUri,
|
||||||
GetUserInfo,
|
GetUserInfo,
|
||||||
|
@ -8,20 +14,14 @@ import type {
|
||||||
CreateConnector,
|
CreateConnector,
|
||||||
GetConnectorConfig,
|
GetConnectorConfig,
|
||||||
} from '@logto/connector-kit';
|
} from '@logto/connector-kit';
|
||||||
import {
|
import ky, { HTTPError } from 'ky';
|
||||||
ConnectorError,
|
|
||||||
ConnectorErrorCodes,
|
|
||||||
validateConfig,
|
|
||||||
ConnectorType,
|
|
||||||
parseJson,
|
|
||||||
} from '@logto/connector-kit';
|
|
||||||
import qs from 'query-string';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
authorizationEndpoint,
|
authorizationEndpoint,
|
||||||
accessTokenEndpoint,
|
accessTokenEndpoint,
|
||||||
scope as defaultScope,
|
scope as defaultScope,
|
||||||
userInfoEndpoint,
|
userInfoEndpoint,
|
||||||
|
userEmailsEndpoint,
|
||||||
defaultMetadata,
|
defaultMetadata,
|
||||||
defaultTimeout,
|
defaultTimeout,
|
||||||
} from './constant.js';
|
} from './constant.js';
|
||||||
|
@ -29,6 +29,7 @@ import type { GithubConfig } from './types.js';
|
||||||
import {
|
import {
|
||||||
authorizationCallbackErrorGuard,
|
authorizationCallbackErrorGuard,
|
||||||
githubConfigGuard,
|
githubConfigGuard,
|
||||||
|
emailAddressGuard,
|
||||||
accessTokenResponseGuard,
|
accessTokenResponseGuard,
|
||||||
userInfoResponseGuard,
|
userInfoResponseGuard,
|
||||||
authResponseGuard,
|
authResponseGuard,
|
||||||
|
@ -79,17 +80,18 @@ export const getAccessToken = async (config: GithubConfig, codeObject: { code: s
|
||||||
const { code } = codeObject;
|
const { code } = codeObject;
|
||||||
const { clientId: client_id, clientSecret: client_secret } = config;
|
const { clientId: client_id, clientSecret: client_secret } = config;
|
||||||
|
|
||||||
const httpResponse = await got.post({
|
const httpResponse = await ky
|
||||||
url: accessTokenEndpoint,
|
.post(accessTokenEndpoint, {
|
||||||
json: {
|
body: new URLSearchParams({
|
||||||
client_id,
|
client_id,
|
||||||
client_secret,
|
client_secret,
|
||||||
code,
|
code,
|
||||||
},
|
}),
|
||||||
timeout: { request: defaultTimeout },
|
timeout: defaultTimeout,
|
||||||
});
|
})
|
||||||
|
.json();
|
||||||
|
|
||||||
const result = accessTokenResponseGuard.safeParse(qs.parse(httpResponse.body));
|
const result = accessTokenResponseGuard.safeParse(httpResponse);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
|
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
|
||||||
|
@ -110,34 +112,54 @@ const getUserInfo =
|
||||||
validateConfig(config, githubConfigGuard);
|
validateConfig(config, githubConfigGuard);
|
||||||
const { accessToken } = await getAccessToken(config, { code });
|
const { accessToken } = await getAccessToken(config, { code });
|
||||||
|
|
||||||
try {
|
const authedApi = ky.create({
|
||||||
const httpResponse = await got.get(userInfoEndpoint, {
|
timeout: defaultTimeout,
|
||||||
headers: {
|
hooks: {
|
||||||
authorization: `token ${accessToken}`,
|
beforeRequest: [
|
||||||
},
|
(request) => {
|
||||||
timeout: { request: defaultTimeout },
|
request.headers.set('Authorization', `Bearer ${accessToken}`);
|
||||||
});
|
},
|
||||||
const rawData = parseJson(httpResponse.body);
|
],
|
||||||
const result = userInfoResponseGuard.safeParse(rawData);
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
try {
|
||||||
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
|
const [userInfo, userEmails] = await Promise.all([
|
||||||
|
authedApi.get(userInfoEndpoint).json(),
|
||||||
|
authedApi.get(userEmailsEndpoint).json(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const userInfoResult = userInfoResponseGuard.safeParse(userInfo);
|
||||||
|
const userEmailsResult = emailAddressGuard.array().safeParse(userEmails);
|
||||||
|
|
||||||
|
if (!userInfoResult.success) {
|
||||||
|
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, userInfoResult.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id, avatar_url: avatar, email, name } = result.data;
|
if (!userEmailsResult.success) {
|
||||||
|
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, userEmailsResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, avatar_url: avatar, email: publicEmail, name } = userInfoResult.data;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: String(id),
|
id: String(id),
|
||||||
avatar: conditional(avatar),
|
avatar: conditional(avatar),
|
||||||
email: conditional(email),
|
email: conditional(
|
||||||
|
publicEmail ??
|
||||||
|
userEmailsResult.data.find(({ verified, primary }) => verified && primary)?.email
|
||||||
|
),
|
||||||
name: conditional(name),
|
name: conditional(name),
|
||||||
rawData,
|
rawData: jsonGuard.parse({
|
||||||
|
userInfo,
|
||||||
|
userEmails,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof HTTPError) {
|
if (error instanceof HTTPError) {
|
||||||
const { statusCode, body: rawBody } = error.response;
|
const { status, body: rawBody } = error.response;
|
||||||
|
|
||||||
if (statusCode === 401) {
|
if (status === 401) {
|
||||||
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
|
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,17 @@ export const githubConfigGuard = z.object({
|
||||||
|
|
||||||
export type GithubConfig = z.infer<typeof githubConfigGuard>;
|
export type GithubConfig = z.infer<typeof githubConfigGuard>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This guard is used to validate the response from the GitHub API when requesting the user's email addresses.
|
||||||
|
* Ref: https://docs.github.com/en/rest/users/emails?apiVersion=2022-11-28#list-email-addresses-for-the-authenticated-user
|
||||||
|
*/
|
||||||
|
export const emailAddressGuard = z.object({
|
||||||
|
email: z.string(),
|
||||||
|
primary: z.boolean(),
|
||||||
|
verified: z.boolean(),
|
||||||
|
visibility: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
export const accessTokenResponseGuard = z.object({
|
export const accessTokenResponseGuard = z.object({
|
||||||
access_token: z.string(),
|
access_token: z.string(),
|
||||||
scope: z.string(),
|
scope: z.string(),
|
||||||
|
|
|
@ -981,9 +981,9 @@ importers:
|
||||||
'@silverhand/essentials':
|
'@silverhand/essentials':
|
||||||
specifier: ^2.9.0
|
specifier: ^2.9.0
|
||||||
version: 2.9.0
|
version: 2.9.0
|
||||||
got:
|
ky:
|
||||||
specifier: ^14.0.0
|
specifier: ^1.2.3
|
||||||
version: 14.0.0
|
version: 1.2.3
|
||||||
query-string:
|
query-string:
|
||||||
specifier: ^9.0.0
|
specifier: ^9.0.0
|
||||||
version: 9.0.0
|
version: 9.0.0
|
||||||
|
@ -1028,8 +1028,8 @@ importers:
|
||||||
specifier: ^15.0.2
|
specifier: ^15.0.2
|
||||||
version: 15.0.2
|
version: 15.0.2
|
||||||
nock:
|
nock:
|
||||||
specifier: ^13.3.1
|
specifier: 14.0.0-beta.6
|
||||||
version: 13.3.1
|
version: 14.0.0-beta.6
|
||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
|
@ -18007,6 +18007,9 @@ packages:
|
||||||
resolution: {integrity: sha512-2GTVocFkwblV/TIg9AmT7TI2fO4xdWkyN8aFUEVtiVNWt96GTR3FgQyHFValfCbcj1k9Xf962Ws2hYXYUr9k1Q==}
|
resolution: {integrity: sha512-2GTVocFkwblV/TIg9AmT7TI2fO4xdWkyN8aFUEVtiVNWt96GTR3FgQyHFValfCbcj1k9Xf962Ws2hYXYUr9k1Q==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@parcel/core':
|
||||||
|
optional: true
|
||||||
dependencies:
|
dependencies:
|
||||||
'@parcel/config-default': 2.9.3(@parcel/core@2.9.3)(postcss@8.4.31)
|
'@parcel/config-default': 2.9.3(@parcel/core@2.9.3)(postcss@8.4.31)
|
||||||
'@parcel/core': 2.9.3
|
'@parcel/core': 2.9.3
|
||||||
|
|
Loading…
Reference in a new issue