add jwt & crypto service

This commit is contained in:
Paul Coral
2025-08-16 23:02:44 +02:00
parent 1f0a27a2e1
commit 3b304e4158
9 changed files with 177 additions and 30 deletions

View File

@@ -27,6 +27,7 @@
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/passport": "^11.0.5", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@types/jsonwebtoken": "^9.0.10",
"@types/passport-local": "^1.0.38", "@types/passport-local": "^1.0.38",
"argon2": "^0.44.0", "argon2": "^0.44.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
@@ -34,6 +35,7 @@
"ejs": "^3.1.10", "ejs": "^3.1.10",
"i18next": "^25.3.2", "i18next": "^25.3.2",
"i18next-fs-backend": "^2.6.0", "i18next-fs-backend": "^2.6.0",
"jsonwebtoken": "^9.0.2",
"kysely": "^0.28.3", "kysely": "^0.28.3",
"nodemailer": "^7.0.5", "nodemailer": "^7.0.5",
"passport": "^0.7.0", "passport": "^0.7.0",

View File

@@ -1,20 +1,23 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { AuthRegistry } from './auth-registry'; import { AuthRegistry } from './auth-registry';
import { generateSecureToken } from '../../../lib/crypto';
import * as dayjs from 'dayjs'; import * as dayjs from 'dayjs';
import { isDefined, ResetAuth } from '@my-monorepo/common'; import { isDefined, ResetAuth } from '@my-monorepo/common';
import { CryptoService } from '../../../services/core/crypto/crypto.service';
const RESET_TOKEN_VALIDITY_MINUTES = 15; const RESET_TOKEN_VALIDITY_MINUTES = 15;
@Injectable() @Injectable()
export class AuthCoreService { export class AuthCoreService {
constructor(private authRegistry: AuthRegistry) {} constructor(
private authRegistry: AuthRegistry,
private cryptoService: CryptoService,
) {}
async addNewResetToken( async addNewResetToken(
userId: string, userId: string,
): Promise<{ token: string; user_id: string }> { ): Promise<{ token: string; user_id: string }> {
const res = await this.authRegistry.addResetToken({ const res = await this.authRegistry.addResetToken({
token: generateSecureToken(), token: this.cryptoService.generateSecureToken(),
userId, userId,
validUntil: dayjs().add(RESET_TOKEN_VALIDITY_MINUTES, 'minutes'), validUntil: dayjs().add(RESET_TOKEN_VALIDITY_MINUTES, 'minutes'),
}); });

View File

@@ -6,13 +6,14 @@ import {
import { UsersCoreService } from '../../users/users-core/users-core.service'; import { UsersCoreService } from '../../users/users-core/users-core.service';
import { AuthCoreService } from '../auth-core/auth-core.service'; import { AuthCoreService } from '../auth-core/auth-core.service';
import { ResetAuth } from '@my-monorepo/common'; import { ResetAuth } from '@my-monorepo/common';
import { hashPassword } from '../../../lib/crypto'; import { CryptoService } from '../../../services/core/crypto/crypto.service';
@Injectable() @Injectable()
export class AuthControllerService { export class AuthControllerService {
constructor( constructor(
private userCoreService: UsersCoreService, private userCoreService: UsersCoreService,
private authCoreService: AuthCoreService, private authCoreService: AuthCoreService,
private cryptoService: CryptoService,
) {} ) {}
async verifyCredentials() { async verifyCredentials() {
@@ -30,7 +31,7 @@ export class AuthControllerService {
} }
await this.userCoreService.setHashPassword( await this.userCoreService.setHashPassword(
resetData.userId, resetData.userId,
await hashPassword(resetData.newPassword), await this.cryptoService.hashPassword(resetData.newPassword),
); );
} }
} }

View File

@@ -1,17 +0,0 @@
import * as crypto from 'crypto';
import * as argon2 from 'argon2';
export function generateSecureToken(length = 32): string {
return crypto.randomBytes(length).toString('hex');
}
export function hashPassword(password: string): Promise<string> {
return argon2.hash(password);
}
export function verifyPassword(
passwordHash: string,
clearTextPassword: string,
): Promise<boolean> {
return argon2.verify(passwordHash, clearTextPassword);
}

View File

@@ -6,6 +6,7 @@ import { LoggerService } from './logger/logger.service';
import { MailerService } from './mailer/mailer.service'; import { MailerService } from './mailer/mailer.service';
import { TemplateRendererService } from './template-renderer/template-renderer.service'; import { TemplateRendererService } from './template-renderer/template-renderer.service';
import { LinkBuilderService } from './link-builder/link-builder.service'; import { LinkBuilderService } from './link-builder/link-builder.service';
import { CryptoService } from './crypto/crypto.service';
const DECLARATIONS = [ const DECLARATIONS = [
DatabaseService, DatabaseService,
@@ -15,6 +16,7 @@ const DECLARATIONS = [
MailerService, MailerService,
TemplateRendererService, TemplateRendererService,
LinkBuilderService, LinkBuilderService,
CryptoService,
]; ];
@Global() @Global()

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CryptoService } from './crypto.service';
describe('CryptoService', () => {
let service: CryptoService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [CryptoService],
}).compile();
service = module.get<CryptoService>(CryptoService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import * as crypto from 'crypto';
import * as argon2 from 'argon2';
import * as jwt from 'jsonwebtoken';
import { EnvService } from '../env/env.service';
import { isDefined } from '@my-monorepo/common';
@Injectable()
export class CryptoService {
constructor(private envService: EnvService) {}
generateSecureToken(length = 32): string {
return crypto.randomBytes(length).toString('hex');
}
hashPassword(password: string): Promise<string> {
return argon2.hash(password);
}
verifyPassword(
passwordHash: string,
clearTextPassword: string,
): Promise<boolean> {
return argon2.verify(passwordHash, clearTextPassword);
}
createJwt(payload: string | object): string {
return jwt.sign(payload, this.envService.config.secrets.authSign);
}
async verifyJwt(token: string): Promise<string | object | undefined> {
return await new Promise<string | object | undefined>((resolve) => {
jwt.verify(
token,
this.envService.config.secrets.authSign,
(err, decoded) =>
resolve(!isDefined(err) && isDefined(decoded) ? decoded : undefined),
);
});
}
}

View File

@@ -14,6 +14,7 @@ const envParser = z
MAIL_USER: z.string().email(), MAIL_USER: z.string().email(),
MAIL_PASSWORD: z.string(), MAIL_PASSWORD: z.string(),
APP_FRONTEND_URL: z.string(), APP_FRONTEND_URL: z.string(),
SECRET_AUTH_SIGN: z.string(),
}) })
.transform((parsed) => ({ .transform((parsed) => ({
database: { database: {
@@ -32,6 +33,9 @@ const envParser = z
app: { app: {
frontendUrl: parsed.APP_FRONTEND_URL, frontendUrl: parsed.APP_FRONTEND_URL,
}, },
secrets: {
authSign: parsed.SECRET_AUTH_SIGN,
},
})); }));
export type EnvConfig = Readonly<z.infer<typeof envParser>>; export type EnvConfig = Readonly<z.infer<typeof envParser>>;

109
pnpm-lock.yaml generated
View File

@@ -25,6 +25,9 @@ importers:
'@nestjs/platform-express': '@nestjs/platform-express':
specifier: ^11.0.1 specifier: ^11.0.1
version: 11.1.3(@nestjs/common@11.1.3(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3) version: 11.1.3(@nestjs/common@11.1.3(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3)
'@types/jsonwebtoken':
specifier: ^9.0.10
version: 9.0.10
'@types/passport-local': '@types/passport-local':
specifier: ^1.0.38 specifier: ^1.0.38
version: 1.0.38 version: 1.0.38
@@ -46,6 +49,9 @@ importers:
i18next-fs-backend: i18next-fs-backend:
specifier: ^2.6.0 specifier: ^2.6.0
version: 2.6.0 version: 2.6.0
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.2
kysely: kysely:
specifier: ^0.28.3 specifier: ^0.28.3
version: 0.28.3 version: 0.28.3
@@ -1081,12 +1087,18 @@ packages:
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/jsonwebtoken@9.0.10':
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
'@types/methods@1.1.4': '@types/methods@1.1.4':
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
'@types/mime@1.3.5': '@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/node@22.16.3': '@types/node@22.16.3':
resolution: {integrity: sha512-sr4Xz74KOUeYadexo1r8imhRtlVXcs+j3XK3TcoiYk7B1t3YRVJgtaD3cwX73NYb71pmVuMLNRhJ9XKdoDB74g==} resolution: {integrity: sha512-sr4Xz74KOUeYadexo1r8imhRtlVXcs+j3XK3TcoiYk7B1t3YRVJgtaD3cwX73NYb71pmVuMLNRhJ9XKdoDB74g==}
@@ -1480,6 +1492,9 @@ packages:
buffer-crc32@0.2.13: buffer-crc32@0.2.13:
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer-from@1.1.2: buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -1792,6 +1807,9 @@ packages:
eastasianwidth@0.2.0: eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
ee-first@1.1.1: ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -2556,6 +2574,16 @@ packages:
jsonfile@6.1.0: jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
jsonwebtoken@9.0.2:
resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
engines: {node: '>=12', npm: '>=6'}
jwa@1.4.2:
resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
jws@3.2.2:
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
keyv@4.5.4: keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -2633,12 +2661,33 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
lodash.isboolean@3.0.3:
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
lodash.isinteger@4.0.4:
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
lodash.isnumber@3.0.3:
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
lodash.isstring@4.0.1:
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
lodash.memoize@4.1.2: lodash.memoize@4.1.2:
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
lodash.merge@4.6.2: lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
lodash@4.17.21: lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
@@ -3183,11 +3232,6 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true hasBin: true
semver@7.7.1:
resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==}
engines: {node: '>=10'}
hasBin: true
semver@7.7.2: semver@7.7.2:
resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -4870,10 +4914,17 @@ snapshots:
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 2.1.0
'@types/node': 22.16.3
'@types/methods@1.1.4': {} '@types/methods@1.1.4': {}
'@types/mime@1.3.5': {} '@types/mime@1.3.5': {}
'@types/ms@2.1.0': {}
'@types/node@22.16.3': '@types/node@22.16.3':
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
@@ -5446,6 +5497,8 @@ snapshots:
buffer-crc32@0.2.13: {} buffer-crc32@0.2.13: {}
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {} buffer-from@1.1.2: {}
buffer@5.7.1: buffer@5.7.1:
@@ -5713,6 +5766,10 @@ snapshots:
eastasianwidth@0.2.0: {} eastasianwidth@0.2.0: {}
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
ee-first@1.1.1: {} ee-first@1.1.1: {}
ejs@3.1.10: ejs@3.1.10:
@@ -6312,7 +6369,7 @@ snapshots:
'@babel/parser': 7.28.0 '@babel/parser': 7.28.0
'@istanbuljs/schema': 0.1.3 '@istanbuljs/schema': 0.1.3
istanbul-lib-coverage: 3.2.2 istanbul-lib-coverage: 3.2.2
semver: 7.7.1 semver: 7.7.2
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -6699,6 +6756,30 @@ snapshots:
optionalDependencies: optionalDependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
jsonwebtoken@9.0.2:
dependencies:
jws: 3.2.2
lodash.includes: 4.3.0
lodash.isboolean: 3.0.3
lodash.isinteger: 4.0.4
lodash.isnumber: 3.0.3
lodash.isplainobject: 4.0.6
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
semver: 7.7.2
jwa@1.4.2:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jws@3.2.2:
dependencies:
jwa: 1.4.2
safe-buffer: 5.2.1
keyv@4.5.4: keyv@4.5.4:
dependencies: dependencies:
json-buffer: 3.0.1 json-buffer: 3.0.1
@@ -6747,10 +6828,24 @@ snapshots:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
lodash.includes@4.3.0: {}
lodash.isboolean@3.0.3: {}
lodash.isinteger@4.0.4: {}
lodash.isnumber@3.0.3: {}
lodash.isplainobject@4.0.6: {}
lodash.isstring@4.0.1: {}
lodash.memoize@4.1.2: {} lodash.memoize@4.1.2: {}
lodash.merge@4.6.2: {} lodash.merge@4.6.2: {}
lodash.once@4.1.1: {}
lodash@4.17.21: {} lodash@4.17.21: {}
log-symbols@4.1.0: log-symbols@4.1.0:
@@ -7216,8 +7311,6 @@ snapshots:
semver@6.3.1: {} semver@6.3.1: {}
semver@7.7.1: {}
semver@7.7.2: {} semver@7.7.2: {}
send@1.2.0: send@1.2.0: