feat: add ConfigService with Env validator

- add Role decorator
This commit is contained in:
M1000fr 2024-12-02 13:57:47 +01:00
parent a95ed47302
commit 1988673f80
34 changed files with 404 additions and 231 deletions

View File

@ -1 +1,11 @@
JWT_SECRET=
DATABASE_URL="mysql://USER:PASS@IP:PORT/DB"
JWT_SECRET=
JWT_EXPIRES_IN=1h
REFRESH_JWT_SECRET=
REFRESH_JWT_EXPIRES_IN=7d
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_CALLBACK_URL=http://localhost:3000/auth/discord/callback

View File

@ -25,6 +25,7 @@
"axios": "^1.7.7",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"joi": "^17.13.3",
"passport": "^0.7.0",
"passport-discord": "^0.1.4",
"passport-jwt": "^4.0.1",
@ -38,6 +39,7 @@
"@types/express": "^5.0.0",
"@types/node": "^20.3.1",
"@types/passport-discord": "^0.1.14",
"@types/passport-jwt": "^4.0.1",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"eslint": "^9.0.0",

View File

@ -60,5 +60,5 @@ model RoomDocument {
enum Role {
STUDENT
TEACHER
ADMIN
}

View File

@ -1,24 +1,31 @@
import env from "@Config/env";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { JwtModule } from "@nestjs/jwt";
import { envValidation } from "@Validations/env.validation";
import { AppService } from "./app.service";
import { UserModule } from "./user/user.module";
import { AuthModule } from './auth/auth.module';
import { AuthModule } from "@Modules/auth/auth.module";
import { UserModule } from "@Modules/user/user.module";
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [env],
validationSchema: envValidation,
}),
JwtModule.register({
JwtModule.registerAsync({
global: true,
secret: process.env.JWT_SECRET,
inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>("JWT.secret"),
signOptions: {
expiresIn: configService.get<string>("JWT.expiresIn"),
},
}),
}),
UserModule,
AuthModule,
],
providers: [AppService],
})
export class AppModule {}

View File

@ -1,10 +0,0 @@
import { Injectable } from "@nestjs/common";
@Injectable()
export class AppService {
getHello() {
return {
message: "Hello World!",
};
}
}

View File

@ -1,33 +0,0 @@
import { Injectable } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { PassportStrategy } from "@nestjs/passport";
import { Profile, Strategy } from "passport-discord";
@Injectable()
export class DiscordStrategy extends PassportStrategy(Strategy, "discord") {
constructor(private jwtService: JwtService) {
super({
clientID: process.env.DISCORD_CLIENT_ID,
clientSecret: process.env.DISCORD_CLIENT_SECRET,
callbackURL: process.env.DISCORD_CALLBACK_URL,
scope: ["identify", "email"]
});
}
async validate(
_accessToken: string,
_refreshToken: string,
profile: Profile,
done: Function,
) {
const jwtPayload = {
id: profile.id,
username: profile.username,
email: profile.email,
};
const jwt = this.jwtService.sign(jwtPayload);
done(null, { jwt });
}
}

View File

@ -1,19 +0,0 @@
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { Profile } from "passport-discord";
import { Strategy, ExtractJwt } from "passport-jwt";
@Injectable()
export class JWTStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExipration: false,
secretOrKey: process.env.JWT_SECRET,
});
}
async validate(profile: Profile): Promise<any> {
return profile;
}
}

17
src/config/env.ts Normal file
View File

@ -0,0 +1,17 @@
export default () => ({
JWT: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRES_IN,
refresh: {
secret: process.env.REFRESH_JWT_SECRET,
expiresIn: process.env.REFRESH_JWT_EXPIRES_IN,
},
},
oauth2: {
discord: {
clientId: process.env.DISCORD_CLIENT_ID,
clientSecret: process.env.DISCORD_CLIENT_SECRET,
callbackUrl: process.env.DISCORD_CALLBACK_URL,
},
},
});

View File

@ -1,6 +1,6 @@
import { NestFactory } from "@nestjs/core";
import { NestFactory, Reflector } from "@nestjs/core";
import { AppModule } from "./app.module";
import { ValidationPipe } from "@nestjs/common";
import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
@ -11,6 +11,10 @@ async function bootstrap() {
app.useGlobalPipes(new ValidationPipe());
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector), {
excludeExtraneousValues: true,
}));
await app.listen(process.env.PORT ?? 3000, "0.0.0.0");
}
bootstrap();

View File

@ -0,0 +1,4 @@
import { SetMetadata } from "@nestjs/common";
import { $Enums } from "@prisma/client";
export const Role = (role: $Enums.Role) => SetMetadata("role", role);

View File

@ -0,0 +1,39 @@
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
UnauthorizedException,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const role = this.reflector.get<string>(
"role",
context.getHandler(),
);
if (!role) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) {
throw new ForbiddenException("User not authenticated");
}
const hasRole = role === user.role;
if (!hasRole) {
throw new UnauthorizedException(
`You need to have the role ${role} to access this resource`,
);
}
return true;
}
}

View File

@ -0,0 +1,37 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
import { PassportStrategy } from "@nestjs/passport";
import { Profile, Strategy } from "passport-discord";
@Injectable()
export class DiscordStrategy extends PassportStrategy(Strategy, "discord") {
constructor(
private readonly jwtService: JwtService,
configService: ConfigService,
) {
super({
clientID: configService.get<string>("oauth2.discord.clientId"),
clientSecret: configService.get<string>(
"oauth2.discord.clientSecret",
),
callbackURL: configService.get<string>(
"oauth2.discord.callbackUrl",
),
scope: ["identify", "email"],
});
}
async validate(
_accessToken: string,
_refreshToken: string,
profile: Profile,
done: Function,
) {
const jwt = this.jwtService.sign({
id: profile.id,
});
done(null, { jwt });
}
}

View File

@ -0,0 +1,30 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { User } from "@prisma/client";
import { Strategy, ExtractJwt } from "passport-jwt";
import { UserService } from "@Modules/user/user.service";
import { ConfigService } from "@nestjs/config";
@Injectable()
export class JWTStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly userService: UserService,
private readonly configService: ConfigService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExipration: false,
secretOrKey: configService.get<string>("JWT.secret"),
});
}
async validate(payload: any): Promise<User> {
const user = await this.userService.findById(payload.id);
if (!user) {
throw new UnauthorizedException("User not found");
}
return user;
}
}

View File

@ -1,16 +1,18 @@
import { Controller, Get, Query, Req, Res, UseGuards } from "@nestjs/common";
import { Controller, Get, Req, Res, UseGuards } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { URLSearchParams } from "node:url";
import { JwtAuthGuard } from "./Guards/jwt.guard";
import { DiscordAuthGuard } from "./Guards/discord.guard";
@Controller("auth")
export class AuthController {
constructor(private readonly configService: ConfigService) {}
@Get("providers")
Providers() {
getProviders() {
const discordOauth2Params = new URLSearchParams({
client_id: process.env.DISCORD_CLIENT_ID,
client_id: this.configService.get<string>("oauth2.discord.clientId"),
response_type: "code",
redirect_uri: process.env.DISCORD_CALLBACK_URL,
redirect_uri: this.configService.get<string>("oauth2.discord.callbackUrl"),
scope: "identify email",
});
@ -28,10 +30,4 @@ export class AuthController {
res.send(user);
}
@Get("profile")
@UseGuards(JwtAuthGuard)
Profile(@Req() req) {
return req.user;
}
}

View File

@ -2,10 +2,12 @@ import { Module } from "@nestjs/common";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { UserModule } from "@Modules/user/user.module";
import { DiscordStrategy } from "./Strategy/discord.strategy";
import { JWTStrategy } from "./Strategy/jwt.strategy";
@Module({
imports: [UserModule],
controllers: [AuthController],
providers: [AuthService, DiscordStrategy, JWTStrategy],
})

View File

@ -0,0 +1,9 @@
import { ArrayMaxSize, ArrayMinSize, IsArray, IsString } from "class-validator";
export class BulkDeleteUserDTO {
@IsArray()
@IsString({ each: true })
@ArrayMinSize(1)
@ArrayMaxSize(10)
ids: string[];
}

View File

@ -0,0 +1,6 @@
import { IsString } from "class-validator";
export class CreateUserDTO {
@IsString()
username: string;
}

View File

@ -1,9 +1,9 @@
import { PartialType } from "@nestjs/mapped-types";
import { IsString } from "class-validator";
import { CreateUserInput } from "./create-user.input";
import { CreateUserDTO } from "./create-user.dto";
export class UpdateUserInput extends PartialType(CreateUserInput) {
export class UpdateUserDTO extends PartialType(CreateUserDTO) {
@IsString()
id: string;

View File

@ -0,0 +1,52 @@
import {
Body,
Controller,
Delete,
Get,
Post,
Query,
UseGuards,
} from "@nestjs/common";
import { Role } from "@Modules/auth/Decorators/roles.decorator";
import { JwtAuthGuard } from "@Modules/auth/Guards/jwt.guard";
import { RolesGuard } from "@Modules/auth/Guards/role.guard";
import { BulkDeleteUserDTO } from "./dto/bulk-delete-user.dto";
import { CreateUserDTO } from "./dto/create-user.dto";
import { UserEntity } from "./user.entity";
import { UserService } from "./user.service";
@Controller("user")
@UseGuards(RolesGuard)
@UseGuards(JwtAuthGuard)
export class UserController {
constructor(private readonly userService: UserService) {}
@Get()
@Role("ADMIN")
async findAll(): Promise<UserEntity[]> {
const users = await this.userService.findAll();
return users.map((user) => new UserEntity(user));
}
@Post()
@Role("ADMIN")
create(@Body() createUserInput: CreateUserDTO) {
return this.userService.create(createUserInput);
}
@Delete()
@Role("ADMIN")
delete(@Query("id") id: string) {
return this.userService.delete(id);
}
@Delete("/bulk")
@Role("ADMIN")
bulkDelete(@Body() { ids }: BulkDeleteUserDTO) {
return this.userService.bulkDelete(ids);
}
}

View File

@ -0,0 +1,17 @@
import { $Enums } from "@prisma/client";
import { Expose } from "class-transformer";
export class UserEntity {
@Expose()
id: string;
@Expose()
username: string;
@Expose()
role: $Enums.Role
constructor(partial: Partial<UserEntity>) {
Object.assign(this, partial);
}
}

View File

@ -2,11 +2,12 @@ import { Module } from "@nestjs/common";
import { UserService } from "./user.service";
import { PrismaService } from "src/prisma/prisma.service";
import { PrismaService } from "@Modules/prisma/prisma.service";
import { UserController } from './user.controller';
@Module({
providers: [UserService, PrismaService],
controllers: [UserController]
controllers: [UserController],
exports: [UserService],
})
export class UserModule {}

View File

@ -0,0 +1,56 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "@Modules/prisma/prisma.service";
import { CreateUserDTO } from "./dto/create-user.dto";
import { UpdateUserDTO } from "./dto/update-user.dto";
@Injectable()
export class UserService {
constructor(private readonly prisma: PrismaService) {}
async findAll() {
return await this.prisma.user.findMany();
}
async findById(id: string) {
return await this.prisma.user.findUnique({
where: { id },
});
}
async create(createUserInput: CreateUserDTO) {
return await this.prisma.user.create({
data: {
username: createUserInput.username,
},
});
}
async update(updateUserInput: UpdateUserDTO) {
return await this.prisma.user.update({
where: { id: updateUserInput.id },
data: {
username: updateUserInput.username,
},
});
}
async delete(id: string) {
const exist = await this.prisma.user.findUnique({ where: { id } });
if (!exist) throw new NotFoundException("User not found");
return await this.prisma.user.delete({
where: { id },
});
}
async bulkDelete(ids: string[]) {
return await this.prisma.user.deleteMany({
where: {
id: {
in: ids,
},
},
})
}
}

View File

@ -1,9 +0,0 @@
import { IsBoolean, IsString } from "class-validator";
export class CreateUserInput {
@IsString()
username: string;
@IsBoolean()
isAdmin: boolean;
}

View File

@ -1,9 +0,0 @@
import { IsString } from "class-validator";
export class SetUserPasswordInput {
@IsString()
id: string;
@IsString()
password: string;
}

View File

@ -1,34 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
UseGuards,
} from "@nestjs/common";
import { UserService } from "./user.service";
import { JwtAuthGuard } from "src/auth/Guards/jwt.guard";
import { CreateUserInput } from "./dto/create-user.input";
@Controller("user")
@UseGuards(JwtAuthGuard)
export class UserController {
constructor(private readonly userService: UserService) {}
@Post()
create(@Body() createUserInput: CreateUserInput) {
return this.userService.create(createUserInput);
}
@Get()
findAll() {
return this.userService.findAll();
}
@Delete(":id")
removeUser(@Param("id") id: string) {
return this.userService.remove(id);
}
}

View File

@ -1,12 +0,0 @@
import { Exclude } from "class-transformer";
export class UserEntity {
id: string;
username: string;
isAdmin: boolean;
@Exclude()
password: string;
}

View File

@ -1,77 +0,0 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { plainToClass } from "class-transformer";
import { PrismaService } from "src/prisma/prisma.service";
import { CreateUserInput } from "./dto/create-user.input";
import { UpdateUserInput } from "./dto/update-user.input";
import { SetUserPasswordInput } from "./dto/setpassword-user.input";
import { UserEntity } from "./user.entity";
@Injectable()
export class UserService {
constructor(private readonly prisma: PrismaService) {}
async create(createUserInput: CreateUserInput) {
const user = await this.prisma.user.create({
data: {
username: createUserInput.username,
isAdmin: createUserInput.isAdmin,
},
});
return plainToClass(UserEntity, user);
}
async update(updateUserInput: UpdateUserInput) {
const user = await this.prisma.user.update({
where: { id: updateUserInput.id },
data: {
username: updateUserInput.username,
isAdmin: updateUserInput.isAdmin,
},
});
return plainToClass(UserEntity, user);
}
async setPassword(setUserPasswordInput: SetUserPasswordInput) {
const exist = await this.prisma.user.findUnique({
where: { id: setUserPasswordInput.id },
});
if (!exist) throw new NotFoundException("User not found");
const user = await this.prisma.user.update({
where: { id: setUserPasswordInput.id },
data: {
password: setUserPasswordInput.password,
},
});
return plainToClass(UserEntity, user);
}
async findAll() {
const users = await this.prisma.user.findMany();
return users.map((user) => plainToClass(UserEntity, user));
}
async findOne(id: string) {
const user = await this.prisma.user.findUnique({
where: { id },
});
return plainToClass(UserEntity, user);
}
async remove(id: string) {
const exist = await this.prisma.user.findUnique({ where: { id } });
if (!exist) throw new NotFoundException("User not found");
const user = await this.prisma.user.delete({
where: { id },
});
return plainToClass(UserEntity, user);
}
}

View File

@ -0,0 +1,17 @@
import * as Joi from 'joi';
export const envValidation = Joi.object({
DATABASE_URL: Joi.string().required(),
JWT_SECRET: Joi.string().required(),
JWT_EXPIRES_IN: Joi.string().required(),
REFRESH_JWT_SECRET: Joi.string().required(),
REFRESH_JWT_EXPIRES_IN: Joi.string().required(),
DISCORD_CLIENT_ID: Joi.string().required(),
DISCORD_CLIENT_SECRET: Joi.string().required(),
DISCORD_CALLBACK_URL: Joi.string().required(),
PORT: Joi.number().optional(),
});

View File

@ -9,7 +9,14 @@
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"baseUrl": "./src",
"paths": {
"@/*": ["*"],
"@Modules/*": ["modules/*"],
"@Interfaces/*": ["interfaces/*"],
"@Config/*": ["config/*"],
"@Validations/*": ["validations/*"],
},
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,

View File

@ -121,6 +121,18 @@
dependencies:
levn "^0.4.1"
"@hapi/hoek@^9.0.0", "@hapi/hoek@^9.3.0":
version "9.3.0"
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb"
integrity sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==
"@hapi/topo@^5.1.0":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.1.0.tgz#dc448e332c6c6e37a4dc02fd84ba8d44b9afb012"
integrity sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==
dependencies:
"@hapi/hoek" "^9.0.0"
"@humanfs/core@^0.19.1":
version "0.19.1"
resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77"
@ -397,6 +409,23 @@
dependencies:
"@prisma/debug" "5.22.0"
"@sideway/address@^4.1.5":
version "4.1.5"
resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.5.tgz#4bc149a0076623ced99ca8208ba780d65a99b9d5"
integrity sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==
dependencies:
"@hapi/hoek" "^9.0.0"
"@sideway/formula@^3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.1.tgz#80fcbcbaf7ce031e0ef2dd29b1bfc7c3f583611f"
integrity sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==
"@sideway/pinpoint@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df"
integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==
"@tsconfig/node10@^1.0.7":
version "1.0.11"
resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2"
@ -483,6 +512,13 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
"@types/jsonwebtoken@*":
version "9.0.7"
resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-9.0.7.tgz#e49b96c2b29356ed462e9708fc73b833014727d2"
integrity sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==
dependencies:
"@types/node" "*"
"@types/jsonwebtoken@9.0.5":
version "9.0.5"
resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz#0bd9b841c9e6c5a937c17656e2368f65da025588"
@ -525,6 +561,14 @@
"@types/passport" "*"
"@types/passport-oauth2" "*"
"@types/passport-jwt@^4.0.1":
version "4.0.1"
resolved "https://registry.yarnpkg.com/@types/passport-jwt/-/passport-jwt-4.0.1.tgz#080fbe934fb9f6954fb88ec4cdf4bb2cc7c4d435"
integrity sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==
dependencies:
"@types/jsonwebtoken" "*"
"@types/passport-strategy" "*"
"@types/passport-oauth2@*":
version "1.4.17"
resolved "https://registry.yarnpkg.com/@types/passport-oauth2/-/passport-oauth2-1.4.17.tgz#d5d54339d44f6883d03e69dc0cc0e2114067abb4"
@ -534,6 +578,14 @@
"@types/oauth" "*"
"@types/passport" "*"
"@types/passport-strategy@*":
version "0.2.38"
resolved "https://registry.yarnpkg.com/@types/passport-strategy/-/passport-strategy-0.2.38.tgz#482abba0b165cd4553ec8b748f30b022bd6c04d3"
integrity sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==
dependencies:
"@types/express" "*"
"@types/passport" "*"
"@types/passport@*":
version "1.0.17"
resolved "https://registry.yarnpkg.com/@types/passport/-/passport-1.0.17.tgz#718a8d1f7000ebcf6bbc0853da1bc8c4bc7ea5e6"
@ -2057,6 +2109,17 @@ jest-worker@^27.4.5:
merge-stream "^2.0.0"
supports-color "^8.0.0"
joi@^17.13.3:
version "17.13.3"
resolved "https://registry.yarnpkg.com/joi/-/joi-17.13.3.tgz#0f5cc1169c999b30d344366d384b12d92558bcec"
integrity sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==
dependencies:
"@hapi/hoek" "^9.3.0"
"@hapi/topo" "^5.1.0"
"@sideway/address" "^4.1.5"
"@sideway/formula" "^3.0.1"
"@sideway/pinpoint" "^2.0.0"
js-tokens@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"