feat(api): project-completed (#50701)

Co-authored-by: Mrugesh Mohapatra <1884376+raisedadead@users.noreply.github.com>
Co-authored-by: Niraj Nandish <nirajnandish@icloud.com>
This commit is contained in:
Oliver Eyton-Williams
2023-07-17 10:03:17 +02:00
committed by GitHub
parent 42a2712041
commit 5482650dd0
12 changed files with 755 additions and 30 deletions
+5 -3
View File
@@ -12,14 +12,17 @@
"@fastify/swagger-ui": "^1.5.0",
"@immobiliarelabs/fastify-sentry": "^6.0.0",
"@prisma/client": "4.16.2",
"connect-mongo": "4.6.0",
"ajv": "8.12.0",
"ajv-formats": "^2.1.1",
"bad-words": "3.0.4",
"connect-mongo": "4.6.0",
"fast-uri": "2.2.0",
"fastify": "4.19.2",
"fastify-auth0-verify": "^1.0.0",
"fastify-plugin": "^4.3.0",
"jsonwebtoken": "9.0.1",
"mongodb": "^4.16.0",
"nanoid": "3",
"mongodb": "4",
"nodemon": "2.0.22",
"query-string": "^7.1.3"
},
@@ -30,7 +33,6 @@
"@types/express-session": "1.17.7",
"@types/jsonwebtoken": "^9.0.2",
"@types/supertest": "2.0.12",
"ajv": "8.12.0",
"dotenv-cli": "7.2.1",
"jest": "29.6.1",
"pino-pretty": "10.0.1",
+27
View File
@@ -7,6 +7,7 @@ import Fastify, {
RawRequestDefaultExpression,
RawServerDefault
} from 'fastify';
import Ajv from 'ajv';
import middie from '@fastify/middie';
import fastifySession from '@fastify/session';
import fastifyCookie from '@fastify/cookie';
@@ -16,6 +17,8 @@ import fastifySwagger from '@fastify/swagger';
import fastifySwaggerUI from '@fastify/swagger-ui';
import fastifyCsrfProtection from '@fastify/csrf-protection';
import fastifySentry from '@immobiliarelabs/fastify-sentry';
import uriResolver from 'fast-uri';
import addFormats from 'ajv-formats';
import cors from './plugins/cors';
import jwtAuthz from './plugins/fastify-jwt-authz';
@@ -40,10 +43,12 @@ import {
FCC_ENABLE_DEV_LOGIN_MODE,
SENTRY_DSN
} from './utils/env';
import { challengeRoutes } from './routes/challenge';
import { userRoutes } from './routes/user';
import { donateRoutes } from './routes/donate';
import { statusRoute } from './routes/status';
import { unsubscribeDeprecated } from './routes/deprecated-unsubscribe';
import { isObjectID } from './utils/validation';
export type FastifyInstanceWithTypeProvider = FastifyInstance<
RawServerDefault,
@@ -53,6 +58,25 @@ export type FastifyInstanceWithTypeProvider = FastifyInstance<
TypeBoxTypeProvider
>;
// Options that fastify uses
const ajv = new Ajv({
coerceTypes: 'array', // change data type of data to match type keyword
useDefaults: true, // replace missing properties and items with the values from corresponding default keyword
removeAdditional: true, // remove additional properties
uriResolver,
addUsedSchema: false,
// Explicitly set allErrors to `false`.
// When set to `true`, a DoS attack is possible.
allErrors: false
});
// add the default formatters from avj-formats
addFormats(ajv);
ajv.addFormat('objectid', {
type: 'string',
validate: (str: string) => isObjectID(str)
});
export const build = async (
options: FastifyHttpOptions<RawServerDefault, FastifyBaseLogger> = {}
): Promise<FastifyInstanceWithTypeProvider> => {
@@ -60,6 +84,8 @@ export const build = async (
// Watch when implementing in client
const fastify = Fastify(options).withTypeProvider<TypeBoxTypeProvider>();
fastify.setValidatorCompiler(({ schema }) => ajv.compile(schema));
void fastify.register(security);
fastify.get('/', async (_request, _reply) => {
@@ -169,6 +195,7 @@ export const build = async (
if (FCC_ENABLE_DEV_LOGIN_MODE) {
void fastify.register(devLoginCallback, { prefix: '/auth' });
}
void fastify.register(challengeRoutes);
void fastify.register(settingRoutes);
void fastify.register(donateRoutes);
void fastify.register(userRoutes);
+318
View File
@@ -0,0 +1,318 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { setupServer, superRequest } from '../../jest.utils';
const isValidChallengeCompletionErrorMsg = {
type: 'error',
message: 'That does not appear to be a valid challenge submission.'
};
const id1 = 'bd7123c8c441eddfaeb5bdef';
const id2 = 'bd7123c8c441eddfaeb5bdec';
const codeallyProject = {
id: id1,
challengeType: 13,
solution: 'https://any.valid/url'
};
const backendProject = {
id: id2,
challengeType: 4,
solution: 'https://any.valid/url',
githubLink: 'https://github.com/anything/valid/'
};
const partialCompletion = { id: id1, completedDate: 1 };
describe('challengeRoutes', () => {
setupServer();
describe('Authenticated user', () => {
let setCookies: string[];
// Authenticate user
beforeAll(async () => {
const res = await superRequest('/auth/dev-callback', { method: 'GET' });
expect(res.status).toBe(200);
setCookies = res.get('Set-Cookie');
});
describe('/project-completed', () => {
describe('validation', () => {
it('POST rejects requests without ids', async () => {
const response = await superRequest('/project-completed', {
method: 'POST',
setCookies
}).send({});
expect(response.body).toStrictEqual(
isValidChallengeCompletionErrorMsg
);
expect(response.statusCode).toBe(400);
});
it('POST rejects requests without valid ObjectIDs', async () => {
const response = await superRequest('/project-completed', {
method: 'POST',
setCookies
// This is a departure from api-server, which does not require a
// solution to give this error. However, the validator will reject
// based on the missing solution before it gets to the invalid id.
}).send({ id: 'not-a-valid-id', solution: '' });
expect(response.body).toStrictEqual(
isValidChallengeCompletionErrorMsg
);
expect(response.statusCode).toBe(400);
});
it('POST rejects requests with invalid challengeTypes', async () => {
const response = await superRequest('/project-completed', {
method: 'POST',
setCookies
}).send({
id: id1,
challengeType: 'not-a-valid-challenge-type',
// TODO(Post-MVP): drop these comments, since the api-server will not
// exist.
// a solution is required, because otherwise the request will be
// rejected before it gets to the challengeType validation. NOTE: this
// is a departure from the api-server, but only in the message sent.
solution: ''
});
expect(response.body).toStrictEqual(
isValidChallengeCompletionErrorMsg
);
expect(response.statusCode).toBe(400);
});
it('POST rejects requests without solutions', async () => {
const response = await superRequest('/project-completed', {
method: 'POST',
setCookies
}).send({
id: id1,
challengeType: 3
});
expect(response.body).toStrictEqual({
type: 'error',
message:
'You have not provided the valid links for us to inspect your work.'
});
expect(response.statusCode).toBe(400);
});
it('POST rejects requests with solutions that are not urls', async () => {
const response = await superRequest('/project-completed', {
method: 'POST',
setCookies
}).send({
id: id1,
challengeType: 3,
solution: 'not-a-valid-solution'
});
expect(response.body).toStrictEqual(
isValidChallengeCompletionErrorMsg
);
expect(response.statusCode).toBe(400);
});
it('POST rejects CodeRoad/CodeAlly projects when the user has not completed the required challenges', async () => {
const response = await superRequest('/project-completed', {
method: 'POST',
setCookies
}).send({
id: id1, // not a codeally challenge id, but does not matter
challengeType: 13, // this does matter, however, since there's special logic for that challenge type
solution: 'https://any.valid/url'
});
expect(response.body).toStrictEqual({
type: 'error',
message:
'You have to complete the project before you can submit a URL.'
});
// It's not really a bad request, since the client is sending a valid
// body. It's just that the user is not allowed to do this - hence 403.
expect(response.statusCode).toBe(403);
});
});
describe('handling', () => {
beforeEach(async () => {
// setup: complete the challenges that codeally projects require
await fastifyTestInstance.prisma.user.updateMany({
where: { email: 'foo@bar.com' },
data: {
partiallyCompletedChallenges: [{ id: id1, completedDate: 1 }],
completedChallenges: [],
progressTimestamps: []
}
});
});
afterEach(async () => {
await fastifyTestInstance.prisma.user.updateMany({
where: { email: 'foo@bar.com' },
data: {
partiallyCompletedChallenges: [],
completedChallenges: [],
savedChallenges: []
}
});
});
it('POST accepts CodeRoad/CodeAlly projects when the user has completed the required challenges', async () => {
const now = Date.now();
const response = await superRequest('/project-completed', {
method: 'POST',
setCookies
}).send(codeallyProject);
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: 'foo@bar.com' }
});
expect(user).toMatchObject({
partiallyCompletedChallenges: [],
completedChallenges: [
{
...codeallyProject,
completedDate: expect.any(Number)
}
]
});
const completedDate = user?.completedChallenges[0]?.completedDate;
// TODO: use a custom matcher for this
expect(completedDate).toBeGreaterThan(now);
expect(completedDate).toBeLessThan(now + 1000);
expect(response.body).toStrictEqual({
alreadyCompleted: false,
points: 1,
completedDate
});
expect(response.statusCode).toBe(200);
});
it('POST accepts backend projects', async () => {
const now = Date.now();
const response = await superRequest('/project-completed', {
method: 'POST',
setCookies
}).send(backendProject);
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: 'foo@bar.com' }
});
expect(user).toMatchObject({
partiallyCompletedChallenges: [partialCompletion],
completedChallenges: [
{
...backendProject,
completedDate: expect.any(Number)
}
]
});
const completedDate = user?.completedChallenges[0]?.completedDate;
// TODO: use a custom matcher for this
expect(completedDate).toBeGreaterThan(now);
expect(completedDate).toBeLessThan(now + 1000);
expect(response.body).toStrictEqual({
alreadyCompleted: false,
points: 1,
completedDate
});
expect(response.statusCode).toBe(200);
});
it('POST correctly handles multiple requests', async () => {
const resOriginal = await superRequest('/project-completed', {
method: 'POST',
setCookies
}).send(codeallyProject);
const resBackend = await superRequest('/project-completed', {
method: 'POST',
setCookies
}).send(backendProject);
// sending backendProject again should update its solution, but not
// progressTimestamps or its completedDate
const resUpdate = await superRequest('/project-completed', {
method: 'POST',
setCookies
}).send({ ...codeallyProject, solution: 'https://any.other/url' });
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: 'foo@bar.com' }
});
const expectedProgressTimestamps = user?.completedChallenges.map(
challenge => challenge.completedDate
);
expect(user).toMatchObject({
completedChallenges: [
{
...codeallyProject,
solution: 'https://any.other/url',
completedDate: resOriginal.body.completedDate
},
{
...backendProject,
completedDate: resBackend.body.completedDate
}
],
progressTimestamps: expectedProgressTimestamps
});
expect(resUpdate.body).toStrictEqual({
alreadyCompleted: true,
points: 2,
completedDate: expect.any(Number)
});
// It should return an updated completedDate
expect(resUpdate.body.completedDate).not.toBe(
resOriginal.body.completedDate
);
expect(resUpdate.statusCode).toBe(200);
});
});
});
});
describe('Unauthenticated user', () => {
let setCookies: string[];
// Get the CSRF cookies from an unprotected route
beforeAll(async () => {
const res = await superRequest('/', { method: 'GET' });
setCookies = res.get('Set-Cookie');
});
describe('/project-completed', () => {
test('POST returns 401 status code with error message', async () => {
const response = await superRequest('/project-completed', {
method: 'POST',
setCookies
});
expect(response?.statusCode).toBe(401);
});
});
});
});
+109
View File
@@ -0,0 +1,109 @@
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import { formatValidationError } from '../utils/error-formatting';
import { ProgressTimestamp, getPoints } from '../utils/progress';
import { schemas } from '../schemas';
import {
canSubmitCodeRoadCertProject,
createProject,
updateProject
} from './helpers/challenge-helpers';
export const challengeRoutes: FastifyPluginCallbackTypebox = (
fastify,
_options,
done
) => {
// eslint-disable-next-line @typescript-eslint/unbound-method
fastify.addHook('onRequest', fastify.csrfProtection);
fastify.addHook('onRequest', fastify.authenticateSession);
fastify.post(
'/project-completed',
{
schema: schemas.projectCompleted,
errorHandler(error, request, reply) {
if (error.validation) {
void reply.code(400);
return formatValidationError(error.validation);
} else {
fastify.errorHandler(error, request, reply);
}
}
},
async (req, reply) => {
const { id: projectId, challengeType, solution, githubLink } = req.body;
const userId = req.session.user.id;
try {
const user = await fastify.prisma.user.findUniqueOrThrow({
where: { id: userId },
select: {
completedChallenges: true,
partiallyCompletedChallenges: true,
progressTimestamps: true
}
});
if (
challengeType === 13 &&
!canSubmitCodeRoadCertProject(projectId, user)
) {
void reply.code(403);
return {
type: 'error',
message:
'You have to complete the project before you can submit a URL.'
} as const;
}
const completedDate = Date.now();
const oldChallenge = user.completedChallenges?.find(
({ id }) => id === projectId
);
const updatedChallenge = {
challengeType,
solution,
githubLink
};
const newChallenge = {
...updatedChallenge,
id: projectId,
completedDate
};
const alreadyCompleted = !!oldChallenge;
const progressTimestamps =
user.progressTimestamps as ProgressTimestamp[];
const points = getPoints(progressTimestamps);
const data = alreadyCompleted
? updateProject(projectId, updatedChallenge)
: createProject(projectId, newChallenge, progressTimestamps);
await fastify.prisma.user.update({
where: { id: userId },
data
});
return {
alreadyCompleted,
// TODO(Post-MVP): audit the client and remove this if the client does
// not use it.
completedDate,
points: alreadyCompleted ? points : points + 1
};
} catch (err) {
// TODO: send to Sentry
fastify.log.error(err);
void reply.code(500);
return {
message:
'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.',
type: 'danger'
} as const;
}
}
);
done();
};
@@ -0,0 +1,71 @@
import type {
PartiallyCompletedChallenge,
CompletedChallenge
} from '@prisma/client';
import { canSubmitCodeRoadCertProject } from './challenge-helpers';
const id = 'abc';
const partiallyCompletedChallenges: PartiallyCompletedChallenge[] = [
{
id,
completedDate: 1
}
];
const completedChallenges: CompletedChallenge[] = [
{
id,
completedDate: 1,
challengeType: 1,
files: [],
githubLink: null,
solution: null,
isManuallyApproved: false
}
];
describe('Challenge Helpers', () => {
describe('canSubmitCodeRoadCertProject', () => {
it('returns true if the user has completed the required challenges or partially completed them', () => {
expect(
canSubmitCodeRoadCertProject(id, {
partiallyCompletedChallenges,
completedChallenges
})
).toBe(true);
expect(
canSubmitCodeRoadCertProject(id, {
partiallyCompletedChallenges: [],
completedChallenges
})
).toBe(true);
expect(
canSubmitCodeRoadCertProject(id, {
partiallyCompletedChallenges,
completedChallenges: []
})
).toBe(true);
});
it('returns false if the user has not completed the required challenges', () => {
expect(
canSubmitCodeRoadCertProject(id, {
partiallyCompletedChallenges: [],
completedChallenges: []
})
).toBe(false);
});
it('returns false if the id is undefined', () => {
expect(
canSubmitCodeRoadCertProject(undefined, {
partiallyCompletedChallenges,
completedChallenges
})
).toBe(false);
});
});
});
@@ -0,0 +1,37 @@
import type { Prisma } from '@prisma/client';
import type { ProgressTimestamp } from '../../utils/progress';
export const canSubmitCodeRoadCertProject = (
id: string | undefined,
{
partiallyCompletedChallenges,
completedChallenges
}: {
partiallyCompletedChallenges: { id: string }[];
completedChallenges: { id: string }[];
}
) => {
if (partiallyCompletedChallenges.some(c => c.id === id)) return true;
if (completedChallenges.some(c => c.id === id)) return true;
return false;
};
export const updateProject = (
id: string,
newChallenge: Prisma.CompletedChallengeUpdateInput
) => ({
completedChallenges: {
updateMany: { where: { id }, data: newChallenge }
},
partiallyCompletedChallenges: { deleteMany: { where: { id } } }
});
export const createProject = (
id: string,
newChallenge: Prisma.CompletedChallengeCreateInput,
progressTimestamps: ProgressTimestamp[]
) => ({
completedChallenges: { push: newChallenge },
partiallyCompletedChallenges: { deleteMany: { where: { id } } },
progressTimestamps: [...progressTimestamps, newChallenge.completedDate]
});
+51 -12
View File
@@ -1,5 +1,12 @@
import { Type } from '@fastify/type-provider-typebox';
const generic500 = Type.Object({
message: Type.Literal(
'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.'
),
type: Type.Literal('danger')
});
export const schemas = {
// Settings:
updateMyProfileUI: {
@@ -165,23 +172,13 @@ export const schemas = {
deleteMyAccount: {
response: {
200: Type.Object({}),
500: Type.Object({
message: Type.Literal(
'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.'
),
type: Type.Literal('danger')
})
500: generic500
}
},
resetMyProgress: {
response: {
200: Type.Object({}),
500: Type.Object({
message: Type.Literal(
'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.'
),
type: Type.Literal('danger')
})
500: generic500
}
},
getSessionUser: {
@@ -324,5 +321,47 @@ export const schemas = {
})
})
}
},
// Challenges:
projectCompleted: {
body: Type.Object({
id: Type.String({ format: 'objectid', maxLength: 24, minLength: 24 }),
challengeType: Type.Optional(Type.Number()),
solution: Type.String({ format: 'url', maxLength: 1024 }),
// TODO(Post-MVP): require format: 'url' for githubLink
githubLink: Type.Optional(Type.String())
}),
response: {
200: Type.Object({
// TODO(Post-MVP): delete completedDate and alreadyCompleted? As far as
// I can tell, they are not used anywhere
completedDate: Type.Number(),
points: Type.Number(),
alreadyCompleted: Type.Boolean()
}),
400: Type.Object({
type: Type.Literal('error'),
message: Type.Union([
Type.Literal(
'That does not appear to be a valid challenge submission.'
),
Type.Literal(
'You have not provided the valid links for us to inspect your work.'
)
])
}),
403: Type.Object({
type: Type.Literal('error'),
message: Type.Literal(
'You have to complete the project before you can submit a URL.'
)
}),
500: Type.Object({
message: Type.Literal(
'Oops! Something went wrong. Please try again in a moment or contact support@freecodecamp.org if the error persists.'
),
type: Type.Literal('danger')
})
}
}
};
+72
View File
@@ -0,0 +1,72 @@
import { ErrorObject } from 'ajv';
import { formatValidationError } from './error-formatting';
const missingSolutionError = {
instancePath: '',
schemaPath: '#/required',
keyword: 'required',
params: { missingProperty: 'solution' },
message: "must have required property 'solution'"
};
const missingIdError = {
instancePath: '',
schemaPath: '#/required',
keyword: 'required',
params: { missingProperty: 'id' },
message: "must have required property 'id'"
};
describe('Error formatting', () => {
describe('formatValidationError', () => {
it('should handle missing solutions', () => {
const formattedError = formatValidationError([missingSolutionError]);
expect(formattedError).toStrictEqual({
type: 'error',
message:
'You have not provided the valid links for us to inspect your work.'
});
});
it('should handle missing ids', () => {
const formattedError = formatValidationError([missingIdError]);
expect(formattedError).toStrictEqual({
type: 'error',
message: 'That does not appear to be a valid challenge submission.'
});
});
it('should return a generic error message for other errors', () => {
const formattedError = formatValidationError([
{
...missingSolutionError,
params: { missingProperty: 'notSolution' }
}
]);
expect(formattedError).toStrictEqual({
type: 'error',
message: 'That does not appear to be a valid challenge submission.'
});
});
it('should throw if passed zero errors', () => {
expect(() => formatValidationError([] as ErrorObject[])).toThrow(
Error(
'Bad Argument: the array of errors must have exactly one element.'
)
);
});
it('should throw if passed more than one error', () => {
expect(() => formatValidationError([{}, {}] as ErrorObject[])).toThrow(
Error(
'Bad Argument: the array of errors must have exactly one element.'
)
);
});
});
});
+32
View File
@@ -0,0 +1,32 @@
import { ErrorObject } from 'ajv';
export type FormattedError = {
type: 'error';
message:
| 'You have not provided the valid links for us to inspect your work.'
| 'That does not appear to be a valid challenge submission.'
// the next isn't generated here, but the type is more general.
| 'You have to complete the project before you can submit a URL.';
};
// This only formats invalid challenge submission for now.
export const formatValidationError = (
errors: ErrorObject[]
): FormattedError => {
if (errors.length !== 1) {
throw new Error(
'Bad Argument: the array of errors must have exactly one element.'
);
}
return errors[0]?.params.missingProperty === 'solution'
? {
type: 'error',
message:
'You have not provided the valid links for us to inspect your work.'
}
: {
type: 'error',
message: 'That does not appear to be a valid challenge submission.'
};
};
+16
View File
@@ -0,0 +1,16 @@
import { isObjectID } from './validation';
describe('Validation', () => {
describe('isObjectID', () => {
it('returns true for valid ObjectIDs', () => {
expect(isObjectID('5f1e0f3b5d2c12b0b8f7a6b9')).toBe(true);
});
it('returns false for invalid ObjectIDs', () => {
expect(isObjectID('5f1e0f3b5d2c12b0b8f7a6b')).toBe(false);
expect(isObjectID('5f1e0f3b5d2c12b0b8f7a6b99')).toBe(false);
expect(isObjectID('5f1e0f3b5d2c12b0b8f7a6b-')).toBe(false);
expect(isObjectID(undefined)).toBe(false);
});
});
});
+6
View File
@@ -0,0 +1,6 @@
import { ObjectId } from 'mongodb';
// This is trivial, but makes it simple to refactor if we swap monogodb for
// bson, say.
export const isObjectID = (id?: string): boolean =>
id ? ObjectId.isValid(id) : false;
+11 -15
View File
@@ -195,12 +195,21 @@ importers:
'@prisma/client':
specifier: 4.16.2
version: 4.16.2(prisma@4.16.2)
ajv:
specifier: 8.12.0
version: 8.12.0
ajv-formats:
specifier: ^2.1.1
version: 2.1.1(ajv@8.12.0)
bad-words:
specifier: 3.0.4
version: 3.0.4
connect-mongo:
specifier: 4.6.0
version: 4.6.0(express-session@1.17.3)(mongodb@4.16.0)
fast-uri:
specifier: 2.2.0
version: 2.2.0
fastify:
specifier: 4.19.2
version: 4.19.2
@@ -214,7 +223,7 @@ importers:
specifier: 9.0.1
version: 9.0.1
mongodb:
specifier: '4'
specifier: ^4.16.0
version: 4.16.0
nanoid:
specifier: '3'
@@ -241,9 +250,6 @@ importers:
'@types/supertest':
specifier: 2.0.12
version: 2.0.12
ajv:
specifier: 8.12.0
version: 8.12.0
dotenv-cli:
specifier: 7.2.1
version: 7.2.1
@@ -20767,23 +20773,13 @@ packages:
dependencies:
'@types/express': 4.17.17
'@types/http-proxy': 1.17.10
http-proxy: 1.18.1
http-proxy: 1.18.1(debug@3.2.7)
is-glob: 4.0.3
is-plain-obj: 3.0.0
micromatch: 4.0.5
transitivePeerDependencies:
- debug
/http-proxy@1.18.1:
resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==}
engines: {node: '>=8.0.0'}
dependencies:
eventemitter3: 4.0.7
follow-redirects: 1.15.2(debug@4.3.4)
requires-port: 1.0.0
transitivePeerDependencies:
- debug
/http-proxy@1.18.1(debug@3.2.7):
resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==}
engines: {node: '>=8.0.0'}