Compare commits

...

3 Commits

Author SHA1 Message Date
07e84f910b feat: add role decorator 2026-02-08 10:54:40 +03:00
59f57ded54 feat: add guards and decorators 2026-02-08 09:28:28 +03:00
cce2d83101 feat: add refresh, logout methods 2026-02-04 06:20:27 +03:00
21 changed files with 348 additions and 13 deletions

View File

@ -30,8 +30,10 @@
"@nestjs/swagger": "^11.2.5", "@nestjs/swagger": "^11.2.5",
"@teacinema/common": "^1.0.0", "@teacinema/common": "^1.0.0",
"@teacinema/contracts": "^1.0.0", "@teacinema/contracts": "^1.0.0",
"@teacinema/passport": "^1.0.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.3", "class-validator": "^0.14.3",
"cookie-parser": "^1.4.7",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
@ -42,6 +44,7 @@
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@teacinema/core": "1.0.7", "@teacinema/core": "1.0.7",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",

View File

@ -1,13 +1,24 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config' import { ConfigModule, ConfigService } from '@nestjs/config'
import { PassportModule } from '@teacinema/passport'
import { AccountModule } from '../modules/account/account.module'
import { AuthModule } from '../modules/auth/auth.module' import { AuthModule } from '../modules/auth/auth.module'
import { AppController } from './app.controller' import { AppController } from './app.controller'
import { AppService } from './app.service' import { AppService } from './app.service'
import { getPassportConfig } from './config'
@Module({ @Module({
imports: [ConfigModule.forRoot({ isGlobal: true }), AuthModule], imports: [
ConfigModule.forRoot({ isGlobal: true }),
AuthModule,
AccountModule,
PassportModule.registerAsync({
useFactory: getPassportConfig,
inject: [ConfigService]
})
],
controllers: [AppController], controllers: [AppController],
providers: [AppService] providers: [AppService]
}) })

View File

@ -1,3 +1,4 @@
export * from './cors.config' export * from './cors.config'
export * from './swagger.config' export * from './swagger.config'
export * from './validation-pipe.config' export * from './validation-pipe.config'
export * from './passport.config'

View File

@ -0,0 +1,10 @@
import { ConfigService } from '@nestjs/config'
import { PassportOptions } from '@teacinema/passport'
export function getPassportConfig(
configService: ConfigService
): PassportOptions {
return {
secretKey: configService.getOrThrow<string>('PASSPORT_SECRET_KEY')
}
}

7
src/global.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
declare namespace Express {
export interface Request {
user?: {
id: string
}
}
}

View File

@ -2,6 +2,7 @@ import { Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config' import { ConfigService } from '@nestjs/config'
import { NestFactory } from '@nestjs/core' import { NestFactory } from '@nestjs/core'
import { SwaggerModule } from '@nestjs/swagger' import { SwaggerModule } from '@nestjs/swagger'
import cookieParser from 'cookie-parser'
import { AppModule } from './core/app.module' import { AppModule } from './core/app.module'
import { getCorsConfig, swaggerConfig, validationPipe } from './core/config' import { getCorsConfig, swaggerConfig, validationPipe } from './core/config'
@ -13,6 +14,8 @@ async function bootstrap() {
const logger = new Logger() const logger = new Logger()
const config = app.get(ConfigService) const config = app.get(ConfigService)
app.use(cookieParser(config.getOrThrow<string>('COOKIES_SECRET')))
app.useGlobalPipes(validationPipe) app.useGlobalPipes(validationPipe)
app.useGlobalFilters(new GrpcExceptionFilter()) app.useGlobalFilters(new GrpcExceptionFilter())

View File

@ -0,0 +1,22 @@
import { Inject, Injectable, OnModuleInit } from '@nestjs/common'
import type { ClientGrpc } from '@nestjs/microservices'
import {
AccountServiceClient,
GetAccountRequest
} from '@teacinema/contracts/gen/account'
@Injectable()
export class AccountClientGrpc implements OnModuleInit {
private accountService: AccountServiceClient
constructor(@Inject('ACCOUNT_PACKAGE') private readonly client: ClientGrpc) {}
onModuleInit() {
this.accountService =
this.client.getService<AccountServiceClient>('AccountService')
}
public getAccount(request: GetAccountRequest) {
return this.accountService.getAccount(request)
}
}

View File

@ -0,0 +1,28 @@
import { Module } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { ClientsModule, Transport } from '@nestjs/microservices'
import { PROTO_PATHS } from '@teacinema/contracts'
import { AccountClientGrpc } from './account.grpc'
@Module({
imports: [
ClientsModule.registerAsync([
{
name: 'ACCOUNT_PACKAGE',
useFactory: (configService: ConfigService) => ({
transport: Transport.GRPC,
options: {
package: 'account.v1',
protoPath: PROTO_PATHS.ACCOUNT,
url: configService.getOrThrow<string>('AUTH_GRPC_URL')
}
}),
inject: [ConfigService]
}
])
],
providers: [AccountClientGrpc],
exports: [AccountClientGrpc]
})
export class AccountModule {}

View File

@ -1,12 +1,26 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common' import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
Req,
Res
} from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { ApiOperation } from '@nestjs/swagger' import { ApiOperation } from '@nestjs/swagger'
import type { Request, Response } from 'express'
import { lastValueFrom } from 'rxjs'
import { AuthClientGrpc } from './auth.grpc' import { AuthClientGrpc } from './auth.grpc'
import { SendOtpRequestDto, VerifyOtpRequestDto } from './dto' import { SendOtpRequestDto, VerifyOtpRequestDto } from './dto'
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
constructor(private readonly client: AuthClientGrpc) {} constructor(
private readonly client: AuthClientGrpc,
private readonly config: ConfigService
) {}
@ApiOperation({ @ApiOperation({
summary: 'Send OTP', summary: 'Send OTP',
@ -24,7 +38,60 @@ export class AuthController {
}) })
@Post('otp/verify') @Post('otp/verify')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
public verifyOtp(@Body() dto: VerifyOtpRequestDto) { public async verifyOtp(
return this.client.verifyOtp(dto) @Body() dto: VerifyOtpRequestDto,
@Res({ passthrough: true }) res: Response
) {
const { accessToken, refreshToken } = await lastValueFrom(
this.client.verifyOtp(dto)
)
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
domain: this.config.getOrThrow('COOKIES_DOMAIN'),
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000
})
return { accessToken }
}
@ApiOperation({
summary: 'Refresh',
description: 'Refresh access token'
})
@Post('refresh')
@HttpCode(HttpStatus.OK)
public async refresh(
@Req() req: Request,
@Res({ passthrough: true }) res: Response
) {
const refreshToken = req.cookies?.refreshToken as string
const { accessToken, refreshToken: newRefreshToken } = await lastValueFrom(
this.client.refresh({ refreshToken })
)
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
domain: this.config.getOrThrow('COOKIES_DOMAIN'),
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000
})
return { accessToken }
}
@ApiOperation({
summary: 'Logout',
description: 'Logout user'
})
@Post('logout')
@HttpCode(HttpStatus.OK)
public logout(@Res({ passthrough: true }) res: Response) {
res.clearCookie('refreshToken')
return { ok: true }
} }
} }

View File

@ -2,6 +2,7 @@ import { Inject, Injectable, OnModuleInit } from '@nestjs/common'
import type { ClientGrpc } from '@nestjs/microservices' import type { ClientGrpc } from '@nestjs/microservices'
import { import {
AuthServiceClient, AuthServiceClient,
RefreshRequest,
SendOtpRequest, SendOtpRequest,
VerifyOtpRequest VerifyOtpRequest
} from '@teacinema/contracts/gen/auth' } from '@teacinema/contracts/gen/auth'
@ -23,4 +24,8 @@ export class AuthClientGrpc implements OnModuleInit {
public verifyOtp(request: VerifyOtpRequest) { public verifyOtp(request: VerifyOtpRequest) {
return this.authService.verifyOtp(request) return this.authService.verifyOtp(request)
} }
public refresh(request: RefreshRequest) {
return this.authService.refresh(request)
}
} }

View File

@ -3,6 +3,8 @@ import { ConfigService } from '@nestjs/config'
import { ClientsModule, Transport } from '@nestjs/microservices' import { ClientsModule, Transport } from '@nestjs/microservices'
import { PROTO_PATHS } from '@teacinema/contracts' import { PROTO_PATHS } from '@teacinema/contracts'
import { AccountModule } from '../account/account.module'
import { AuthController } from './auth.controller' import { AuthController } from './auth.controller'
import { AuthClientGrpc } from './auth.grpc' import { AuthClientGrpc } from './auth.grpc'
@ -21,7 +23,8 @@ import { AuthClientGrpc } from './auth.grpc'
}), }),
inject: [ConfigService] inject: [ConfigService]
} }
]) ]),
AccountModule
], ],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthClientGrpc] providers: [AuthClientGrpc]

View File

@ -6,7 +6,7 @@ import { IdentifierValidator } from '../../../../shared/validators'
export class SendOtpRequestDto { export class SendOtpRequestDto {
@ApiProperty({ @ApiProperty({
description: 'User phone or email', description: 'User phone or email',
example: '+791234567890' example: '+79123456789'
}) })
@Validate(IdentifierValidator) @Validate(IdentifierValidator)
@IsString() @IsString()

View File

@ -13,7 +13,7 @@ import { IdentifierValidator } from '../../../../shared/validators'
export class VerifyOtpRequestDto { export class VerifyOtpRequestDto {
@ApiProperty({ @ApiProperty({
description: 'User phone or email', description: 'User phone or email',
example: '+791234567890' example: '+79123456789'
}) })
@Validate(IdentifierValidator) @Validate(IdentifierValidator)
@IsString() @IsString()

View File

@ -0,0 +1,7 @@
import { createParamDecorator } from '@nestjs/common'
import type { Request } from 'express'
export const CurrentUser = createParamDecorator((_, ctx) => {
const req = ctx.switchToHttp().getRequest<Request>()
return req.user?.id ?? null
})

View File

@ -0,0 +1,3 @@
export * from './protected.decorator'
export * from './current-user.decorator'
export * from './roles.decorator'

View File

@ -0,0 +1,14 @@
import { applyDecorators, UseGuards } from '@nestjs/common'
import { Role } from '@teacinema/contracts/gen/account'
import { AuthGuard, RolesGuard } from '../guards'
import { Roles } from './roles.decorator'
export const Protected = (...roles: Role[]) => {
if (roles.length === 0) {
return applyDecorators(UseGuards(AuthGuard))
}
return applyDecorators(Roles(...roles), UseGuards(AuthGuard, RolesGuard))
}

View File

@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common'
import { Role } from '@teacinema/contracts/gen/account'
export const ROLES_KEY = 'required_roles'
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles)

View File

@ -0,0 +1,38 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException
} from '@nestjs/common'
import { PassportService } from '@teacinema/passport'
import { Request } from 'express'
@Injectable()
export class AuthGuard implements CanActivate {
public constructor(private readonly passportService: PassportService) {}
public canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>()
const token = this.extractToken(request)
const result = this.passportService.verify(token)
if (!result.valid) {
throw new UnauthorizedException(result.reason)
}
request.user = {
id: result.userId
}
return true
}
private extractToken(request: Request) {
const [type, token] = request.headers.authorization?.split(' ') ?? []
if (!token || type !== 'Bearer') {
throw new UnauthorizedException('Authorization header is missing')
}
return token
}
}

View File

@ -0,0 +1,2 @@
export * from './auth.guard'
export * from './roles.guard'

View File

@ -0,0 +1,54 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
NotFoundException
} from '@nestjs/common'
import { Reflector } from '@nestjs/core'
import { Role } from '@teacinema/contracts/gen/account'
import { Request } from 'express'
import { lastValueFrom } from 'rxjs'
import { AccountClientGrpc } from '../../modules/account/account.grpc'
import { ROLES_KEY } from '../decorators'
@Injectable()
export class RolesGuard implements CanActivate {
public constructor(
private readonly reflector: Reflector,
private readonly accountClient: AccountClientGrpc
) {}
public async canActivate(context: ExecutionContext) {
const required = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass()
])
if (!required || required.length === 0) {
return true
}
const request = context.switchToHttp().getRequest<Request>()
const user = request.user
if (!user) {
throw new ForbiddenException('User context missing')
}
const account = await lastValueFrom(
this.accountClient.getAccount({ id: user.id })
)
if (!account) {
throw new NotFoundException('Account not found')
}
if (!required.includes(account.role)) {
throw new ForbiddenException('Forbidden')
}
return true
}
}

View File

@ -1011,6 +1011,17 @@
load-esm "1.0.3" load-esm "1.0.3"
tslib "2.8.1" tslib "2.8.1"
"@nestjs/common@^11.1.12":
version "11.1.13"
resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-11.1.13.tgz#3d4bcc2a593236546bc62f7801b0d4450b26a9ac"
integrity sha512-ieqWtipT+VlyDWLz5Rvz0f3E5rXcVAnaAi+D53DEHLjc1kmFxCgZ62qVfTX2vwkywwqNkTNXvBgGR72hYqV//Q==
dependencies:
uid "2.0.2"
file-type "21.3.0"
iterare "1.2.1"
load-esm "1.0.3"
tslib "2.8.1"
"@nestjs/config@^4.0.2": "@nestjs/config@^4.0.2":
version "4.0.2" version "4.0.2"
resolved "https://registry.yarnpkg.com/@nestjs/config/-/config-4.0.2.tgz#a2777a1fd2d0d594bab3953f50fbca95c14cce52" resolved "https://registry.yarnpkg.com/@nestjs/config/-/config-4.0.2.tgz#a2777a1fd2d0d594bab3953f50fbca95c14cce52"
@ -1032,6 +1043,18 @@
path-to-regexp "8.3.0" path-to-regexp "8.3.0"
tslib "2.8.1" tslib "2.8.1"
"@nestjs/core@^11.1.12":
version "11.1.13"
resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-11.1.13.tgz#4444e124d255759d545a631f63e9d84fe1a1e3e5"
integrity sha512-Tq9EIKiC30EBL8hLK93tNqaToy0hzbuVGYt29V8NhkVJUsDzlmiVf6c3hSPtzx2krIUVbTgQ2KFeaxr72rEyzQ==
dependencies:
uid "2.0.2"
"@nuxt/opencollective" "0.4.1"
fast-safe-stringify "2.1.1"
iterare "1.2.1"
path-to-regexp "8.3.0"
tslib "2.8.1"
"@nestjs/mapped-types@2.1.0": "@nestjs/mapped-types@2.1.0":
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/@nestjs/mapped-types/-/mapped-types-2.1.0.tgz#b9b536b7c3571567aa1d0223db8baa1a51505a19" resolved "https://registry.yarnpkg.com/@nestjs/mapped-types/-/mapped-types-2.1.0.tgz#b9b536b7c3571567aa1d0223db8baa1a51505a19"
@ -1198,9 +1221,9 @@
integrity sha512-L006mySvhRpUlRHf3ONbXA0RG42gbQ9QAnmQuZx4zejHEsWB9702xe18MSYcBilv+lmYkQpiX/g3p8P7suLfqQ== integrity sha512-L006mySvhRpUlRHf3ONbXA0RG42gbQ9QAnmQuZx4zejHEsWB9702xe18MSYcBilv+lmYkQpiX/g3p8P7suLfqQ==
"@teacinema/contracts@^1.0.0": "@teacinema/contracts@^1.0.0":
version "1.0.4" version "1.0.6"
resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcontracts/-/1.0.4/contracts-1.0.4.tgz#178dc6392aae52c4dc2b60bb1470a0a2885a8715" resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcontracts/-/1.0.6/contracts-1.0.6.tgz#7aa97d4c3081b791908c90d2a12d771b5fb79bd5"
integrity sha512-3jHP4NAoH9munaN2Iegvg0X6KN/SR1rwZU7KpbuBtlcTJqsLljTaHwG+XKjEL84Y9b+sF73jGGbg/w2Y8IBQmw== integrity sha512-2IU2S9hWtKeMpn4zRS9pJAaSryIODrRjS1q9QezQOXF6554vtQlMVr0fLVTGhNmXlbRfuje0f6HggApqPDFMaA==
dependencies: dependencies:
"@nestjs/microservices" "^11.1.12" "@nestjs/microservices" "^11.1.12"
protoc "33.4.0" protoc "33.4.0"
@ -1214,6 +1237,16 @@
dependencies: dependencies:
"@trivago/prettier-plugin-sort-imports" "^5.2.2" "@trivago/prettier-plugin-sort-imports" "^5.2.2"
"@teacinema/passport@^1.0.0":
version "1.0.1"
resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fpassport/-/1.0.1/passport-1.0.1.tgz#83a8a605534b23929d3b1090d28023626fd5cc9f"
integrity sha512-hgFHXtWgXOemAf9InBLuhHbtF8wS01HKmot0nSaUqGlQCtu74ArMMCG8mup1SR50UGz5DEpHPEL8G5CzVnTTSQ==
dependencies:
"@nestjs/common" "^11.1.12"
"@nestjs/core" "^11.1.12"
reflect-metadata "^0.2.2"
rxjs "^7.8.2"
"@tokenizer/inflate@^0.4.1": "@tokenizer/inflate@^0.4.1":
version "0.4.1" version "0.4.1"
resolved "https://registry.yarnpkg.com/@tokenizer/inflate/-/inflate-0.4.1.tgz#fa6cdb8366151b3cc8426bf9755c1ea03a2fba08" resolved "https://registry.yarnpkg.com/@tokenizer/inflate/-/inflate-0.4.1.tgz#fa6cdb8366151b3cc8426bf9755c1ea03a2fba08"
@ -1314,6 +1347,11 @@
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
"@types/cookie-parser@^1.4.10":
version "1.4.10"
resolved "https://registry.yarnpkg.com/@types/cookie-parser/-/cookie-parser-1.4.10.tgz#a045272a383a30597a01955d4f9c790018f214e4"
integrity sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==
"@types/cookiejar@^2.1.5": "@types/cookiejar@^2.1.5":
version "2.1.5" version "2.1.5"
resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.5.tgz#14a3e83fa641beb169a2dd8422d91c3c345a9a78" resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.5.tgz#14a3e83fa641beb169a2dd8422d91c3c345a9a78"
@ -2375,12 +2413,25 @@ convert-source-map@^2.0.0:
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a"
integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==
cookie-parser@^1.4.7:
version "1.4.7"
resolved "https://registry.yarnpkg.com/cookie-parser/-/cookie-parser-1.4.7.tgz#e2125635dfd766888ffe90d60c286404fa0e7b26"
integrity sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==
dependencies:
cookie "0.7.2"
cookie-signature "1.0.6"
cookie-signature@1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==
cookie-signature@^1.2.1, cookie-signature@^1.2.2: cookie-signature@^1.2.1, cookie-signature@^1.2.2:
version "1.2.2" version "1.2.2"
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793" resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793"
integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==
cookie@^0.7.1: cookie@0.7.2, cookie@^0.7.1:
version "0.7.2" version "0.7.2"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7"
integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==