From 782f35ecd4f2bcbae975315e039a068b8080e942 Mon Sep 17 00:00:00 2001 From: rajdip-b Date: Wed, 10 Apr 2024 20:41:06 +0530 Subject: [PATCH] feat: Add realtime update feature --- .github/workflows/validate-api.yaml | 2 +- apps/api/jest.e2e-config.ts | 1 + apps/api/package.json | 5 + apps/api/src/app/app.module.ts | 8 +- apps/api/src/app/e2e.setup.ts | 57 ---- .../controller/approval.controller.spec.ts | 10 +- .../approval/service/approval.service.spec.ts | 10 +- apps/api/src/auth/guard/auth/auth.guard.ts | 24 +- apps/api/src/common/environment.ts | 3 + apps/api/src/main.ts | 8 +- .../migration.sql | 11 + apps/api/src/prisma/schema.prisma | 8 + apps/api/src/provider/provider.module.ts | 14 + apps/api/src/provider/redis.provider.ts | 40 +++ .../controller/secret.controller.spec.ts | 6 + apps/api/src/secret/secret.e2e.spec.ts | 5 + .../src/secret/service/secret.service.spec.ts | 6 + apps/api/src/secret/service/secret.service.ts | 39 ++- apps/api/src/socket/change-notifier.socket.ts | 231 ++++++++++++++++ apps/api/src/socket/redis.adapter.ts | 26 ++ apps/api/src/socket/socket.module.ts | 7 + apps/api/src/socket/socket.types.ts | 15 ++ .../controller/variable.controller.spec.ts | 10 +- .../controller/variable.controller.ts | 3 +- .../variable/service/variable.service.spec.ts | 10 +- .../src/variable/service/variable.service.ts | 44 ++- apps/api/src/variable/variable.e2e.spec.ts | 5 + apps/api/tsconfig.json | 2 +- docker-compose-test.yml | 6 + docker-compose.yml | 12 +- package.json | 3 +- pnpm-lock.yaml | 250 +++++++++++++++++- 32 files changed, 788 insertions(+), 93 deletions(-) delete mode 100644 apps/api/src/app/e2e.setup.ts create mode 100644 apps/api/src/common/environment.ts create mode 100644 apps/api/src/prisma/migrations/20240410130019_add_change_notification_socket_map/migration.sql create mode 100644 apps/api/src/provider/provider.module.ts create mode 100644 apps/api/src/provider/redis.provider.ts create mode 100644 apps/api/src/socket/change-notifier.socket.ts create mode 100644 apps/api/src/socket/redis.adapter.ts create mode 100644 apps/api/src/socket/socket.module.ts create mode 100644 apps/api/src/socket/socket.types.ts diff --git a/.github/workflows/validate-api.yaml b/.github/workflows/validate-api.yaml index f1298f06..c3051eb5 100644 --- a/.github/workflows/validate-api.yaml +++ b/.github/workflows/validate-api.yaml @@ -51,7 +51,7 @@ jobs: - name: Unit tests run: | pnpm run db:generate-types - pnpm run test:api + pnpm run unit:api - name: E2E tests env: diff --git a/apps/api/jest.e2e-config.ts b/apps/api/jest.e2e-config.ts index 99689f06..65e7b461 100644 --- a/apps/api/jest.e2e-config.ts +++ b/apps/api/jest.e2e-config.ts @@ -1,5 +1,6 @@ /* eslint-disable */ export default { + forceExit: true, displayName: 'api', preset: '../../jest.preset.js', testEnvironment: 'node', diff --git a/apps/api/package.json b/apps/api/package.json index 0cc6ef1f..a06c4859 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -20,9 +20,12 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", "@nestjs/platform-fastify": "^10.3.3", + "@nestjs/platform-socket.io": "^10.3.7", "@nestjs/schedule": "^4.0.1", "@nestjs/swagger": "^7.3.0", + "@nestjs/websockets": "^10.3.7", "@prisma/client": "^5.10.1", + "@socket.io/redis-adapter": "^8.3.0", "@supabase/supabase-js": "^2.39.6", "@types/uuid": "^9.0.8", "chalk": "^4.1.2", @@ -34,8 +37,10 @@ "passport-github2": "^0.1.12", "passport-google-oauth20": "^2.0.0", "prisma": "^5.10.1", + "redis": "^4.6.13", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", + "socket.io": "^4.7.5", "uuid": "^9.0.1" }, "devDependencies": { diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index d897a76b..7af83f5a 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -18,6 +18,9 @@ import { ApiKeyGuard } from '../auth/guard/api-key/api-key.guard' import { EventModule } from '../event/event.module' import { VariableModule } from '../variable/variable.module' import { ApprovalModule } from '../approval/approval.module' +import { SocketModule } from '../socket/socket.module' +import { ProviderModule } from '../provider/provider.module' +import { ScheduleModule } from '@nestjs/schedule' @Module({ controllers: [AppController], @@ -25,6 +28,7 @@ import { ApprovalModule } from '../approval/approval.module' ConfigModule.forRoot({ isGlobal: true }), + ScheduleModule.forRoot(), PassportModule, AuthModule, PrismaModule, @@ -38,7 +42,9 @@ import { ApprovalModule } from '../approval/approval.module' WorkspaceRoleModule, EventModule, VariableModule, - ApprovalModule + ApprovalModule, + SocketModule, + ProviderModule ], providers: [ { diff --git a/apps/api/src/app/e2e.setup.ts b/apps/api/src/app/e2e.setup.ts deleted file mode 100644 index 5b797c9b..00000000 --- a/apps/api/src/app/e2e.setup.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common' -import { PrismaClient } from '@prisma/client' - -@Injectable() -export class E2ESetup implements OnModuleInit { - private readonly prisma: PrismaClient - private readonly logger: Logger = new Logger(E2ESetup.name) - - constructor(prisma: PrismaClient) { - this.prisma = prisma - } - - async onModuleInit() { - if (process.env.NODE_ENV === 'e2e') { - // Clean the DB - await this.prisma.user.deleteMany() - - // Create admin user - const adminUser = await this.prisma.user.create({ - data: { - email: 'admin@keyshade.xyz', - isActive: true, - isAdmin: true, - isOnboardingFinished: true, - name: 'Admin' - } - }) - this.logger.log(`Created admin user: ${adminUser.email}`) - - // Create regular user - const regularUser = await this.prisma.user.create({ - data: { - email: 'johndoe@keyshade.xyz', - isActive: true, - isAdmin: false, - isOnboardingFinished: true, - name: 'John Doe' - } - }) - this.logger.log(`Created regular user: ${regularUser.email}`) - - // Create regular user's workspace - await this.prisma.workspace.create({ - data: { - name: `My Workspace`, - description: 'My default workspace', - ownerId: regularUser.id, - lastUpdatedBy: { - connect: { - id: regularUser.id - } - } - } - }) - } - } -} diff --git a/apps/api/src/approval/controller/approval.controller.spec.ts b/apps/api/src/approval/controller/approval.controller.spec.ts index b88eea6c..ff475fd8 100644 --- a/apps/api/src/approval/controller/approval.controller.spec.ts +++ b/apps/api/src/approval/controller/approval.controller.spec.ts @@ -10,12 +10,17 @@ import { ApprovalService } from '../service/approval.service' import { MAIL_SERVICE } from '../../mail/services/interface.service' import { MockMailService } from '../../mail/services/mock.service' import { JwtService } from '@nestjs/jwt' +import { REDIS_CLIENT } from '../../provider/redis.provider' +import { RedisClientType } from 'redis' +import { mockDeep } from 'jest-mock-extended' +import { ProviderModule } from '../../provider/provider.module' describe('ApprovalController', () => { let controller: ApprovalController beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ + imports: [ProviderModule], controllers: [ApprovalController], providers: [ ApprovalService, @@ -31,7 +36,10 @@ describe('ApprovalController', () => { useClass: MockMailService } ] - }).compile() + }) + .overrideProvider(REDIS_CLIENT) + .useValue(mockDeep()) + .compile() controller = module.get(ApprovalController) }) diff --git a/apps/api/src/approval/service/approval.service.spec.ts b/apps/api/src/approval/service/approval.service.spec.ts index a0d54638..cb600041 100644 --- a/apps/api/src/approval/service/approval.service.spec.ts +++ b/apps/api/src/approval/service/approval.service.spec.ts @@ -9,12 +9,17 @@ import { SecretService } from '../../secret/service/secret.service' import { MAIL_SERVICE } from '../../mail/services/interface.service' import { MockMailService } from '../../mail/services/mock.service' import { JwtService } from '@nestjs/jwt' +import { REDIS_CLIENT } from '../../provider/redis.provider' +import { RedisClientType } from 'redis' +import { mockDeep } from 'jest-mock-extended' +import { ProviderModule } from '../../provider/provider.module' describe('ApprovalService', () => { let service: ApprovalService beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ + imports: [ProviderModule], providers: [ ApprovalService, PrismaService, @@ -29,7 +34,10 @@ describe('ApprovalService', () => { useClass: MockMailService } ] - }).compile() + }) + .overrideProvider(REDIS_CLIENT) + .useValue(mockDeep()) + .compile() service = module.get(ApprovalService) }) diff --git a/apps/api/src/auth/guard/auth/auth.guard.ts b/apps/api/src/auth/guard/auth/auth.guard.ts index 7baced91..a6452e1e 100644 --- a/apps/api/src/auth/guard/auth/auth.guard.ts +++ b/apps/api/src/auth/guard/auth/auth.guard.ts @@ -6,7 +6,6 @@ import { UnauthorizedException } from '@nestjs/common' import { JwtService } from '@nestjs/jwt' -import { Request } from 'express' import { Reflector } from '@nestjs/core' import { IS_PUBLIC_KEY } from '../../../decorators/public.decorator' import { PrismaService } from '../../../prisma/prisma.service' @@ -42,6 +41,10 @@ export class AuthGuard implements CanActivate { const request = context.switchToHttp().getRequest() const authType = this.getAuthType(request) + if (process.env.NODE_ENV !== 'e2e' && authType === 'NONE') { + throw new ForbiddenException('No authentication provided') + } + // In case the environment is e2e, we want to authenticate the user using the email // else we want to authenticate the user using the JWT token. if (authType !== 'API_KEY' && process.env.NODE_ENV === 'e2e') { @@ -129,25 +132,28 @@ export class AuthGuard implements CanActivate { return true } - private getAuthType(request: Request): 'JWT' | 'API_KEY' | 'NONE' { - if (request.headers[X_KEYSHADE_TOKEN]) { + private getAuthType(request: any): 'JWT' | 'API_KEY' | 'NONE' { + const headers = request.headers || request.handshake.headers // For websockets + if (headers[X_KEYSHADE_TOKEN]) { return 'API_KEY' } - if (request.headers[AUTHORIZATION]) { + if (headers[AUTHORIZATION]) { return 'JWT' } return 'NONE' } - private extractTokenFromHeader(request: Request): string | undefined { - const [type, token] = request.headers.authorization?.split(' ') ?? [] + private extractTokenFromHeader(request: any): string | undefined { + const headers = request.headers || request.handshake.headers // For websockets + const [type, token] = headers.authorization?.split(' ') ?? [] return type === 'Bearer' ? token : undefined } - private extractApiKeyFromHeader(request: Request): string | undefined { - if (Array.isArray(request.headers[X_KEYSHADE_TOKEN])) { + private extractApiKeyFromHeader(request: any): string | undefined { + const headers = request.headers || request.handshake.headers // For websockets + if (Array.isArray(headers[X_KEYSHADE_TOKEN])) { throw new Error('Bad auth') } - return request.headers[X_KEYSHADE_TOKEN] + return headers[X_KEYSHADE_TOKEN] } } diff --git a/apps/api/src/common/environment.ts b/apps/api/src/common/environment.ts new file mode 100644 index 00000000..b6364392 --- /dev/null +++ b/apps/api/src/common/environment.ts @@ -0,0 +1,3 @@ +export const Environment = { + DATABASE_URL: process.env.DATABASE_URL! +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 1af6cfa1..c29a7f5a 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -13,6 +13,7 @@ import { QueryTransformPipe } from './common/query.transform.pipe' import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' import * as Sentry from '@sentry/node' import { ProfilingIntegration } from '@sentry/profiling-node' +import { RedisIoAdapter } from './socket/redis.adapter' export const sentryEnv = process.env.SENTRY_ENV || 'production' @@ -78,11 +79,16 @@ async function initializeSentry() { async function initializeNestApp() { const logger = new CustomLogger() const app = await NestFactory.create(AppModule, { - logger + logger, + cors: false }) app.use(Sentry.Handlers.requestHandler()) app.use(Sentry.Handlers.tracingHandler()) + const redisIpoAdapter = new RedisIoAdapter(app) + await redisIpoAdapter.connectToRedis() + app.useWebSocketAdapter(redisIpoAdapter) + const globalPrefix = 'api' app.setGlobalPrefix(globalPrefix) app.useGlobalPipes( diff --git a/apps/api/src/prisma/migrations/20240410130019_add_change_notification_socket_map/migration.sql b/apps/api/src/prisma/migrations/20240410130019_add_change_notification_socket_map/migration.sql new file mode 100644 index 00000000..263c16cc --- /dev/null +++ b/apps/api/src/prisma/migrations/20240410130019_add_change_notification_socket_map/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "ChangeNotificationSocketMap" ( + "id" TEXT NOT NULL, + "socketId" TEXT NOT NULL, + "environmentId" TEXT NOT NULL, + + CONSTRAINT "ChangeNotificationSocketMap_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "ChangeNotificationSocketMap_environmentId_socketId_idx" ON "ChangeNotificationSocketMap"("environmentId", "socketId"); diff --git a/apps/api/src/prisma/schema.prisma b/apps/api/src/prisma/schema.prisma index 9e1ccab7..1b1228fa 100644 --- a/apps/api/src/prisma/schema.prisma +++ b/apps/api/src/prisma/schema.prisma @@ -455,3 +455,11 @@ model Approval { @@index([itemType, itemId]) } + +model ChangeNotificationSocketMap { + id String @id @default(cuid()) + socketId String + environmentId String + + @@index([environmentId, socketId]) +} diff --git a/apps/api/src/provider/provider.module.ts b/apps/api/src/provider/provider.module.ts new file mode 100644 index 00000000..8e4d878e --- /dev/null +++ b/apps/api/src/provider/provider.module.ts @@ -0,0 +1,14 @@ +import { Global, Module } from '@nestjs/common' +import { REDIS_CLIENT, RedisProvider } from './redis.provider' + +@Global() +@Module({ + exports: [ + { + provide: REDIS_CLIENT, + useValue: RedisProvider + } + ], + providers: [RedisProvider] +}) +export class ProviderModule {} diff --git a/apps/api/src/provider/redis.provider.ts b/apps/api/src/provider/redis.provider.ts new file mode 100644 index 00000000..cf51baa2 --- /dev/null +++ b/apps/api/src/provider/redis.provider.ts @@ -0,0 +1,40 @@ +import { Logger, Provider } from '@nestjs/common' +import { exit } from 'process' +import * as redis from 'redis' + +export const REDIS_CLIENT = 'RedisClient' + +export const RedisProvider: Provider = { + provide: REDIS_CLIENT, + useFactory: async () => { + const logger = new Logger('RedisProvider') + if (!process.env.REDIS_URL) { + logger.error('Redis credentials are not set. Stopping the application.') + exit(1) + } + + const subscriber = redis.createClient({ + url: process.env.REDIS_URL, + password: process.env.REDIS_PASSWORD + }) + const publisher = redis.createClient({ + url: process.env.REDIS_URL, + password: process.env.REDIS_PASSWORD + }) + + publisher.on('error', (error) => { + logger.error('Redis client error:', error) + }) + await publisher.connect() + + subscriber.on('error', (error) => { + logger.error('Redis client error:', error) + }) + await subscriber.connect() + + return { + subscriber, + publisher + } + } +} diff --git a/apps/api/src/secret/controller/secret.controller.spec.ts b/apps/api/src/secret/controller/secret.controller.spec.ts index 6d1d940c..69c1de26 100644 --- a/apps/api/src/secret/controller/secret.controller.spec.ts +++ b/apps/api/src/secret/controller/secret.controller.spec.ts @@ -5,12 +5,16 @@ import { MockMailService } from '../../mail/services/mock.service' import { SecretService } from '../service/secret.service' import { PrismaService } from '../../prisma/prisma.service' import { mockDeep } from 'jest-mock-extended' +import { REDIS_CLIENT } from '../../provider/redis.provider' +import { RedisClientType } from 'redis' +import { ProviderModule } from '../../provider/provider.module' describe('SecretController', () => { let controller: SecretController beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ + imports: [ProviderModule], controllers: [SecretController], providers: [ PrismaService, @@ -21,6 +25,8 @@ describe('SecretController', () => { SecretService ] }) + .overrideProvider(REDIS_CLIENT) + .useValue(mockDeep()) .overrideProvider(PrismaService) .useValue(mockDeep()) .compile() diff --git a/apps/api/src/secret/secret.e2e.spec.ts b/apps/api/src/secret/secret.e2e.spec.ts index a6cff478..f83b92a9 100644 --- a/apps/api/src/secret/secret.e2e.spec.ts +++ b/apps/api/src/secret/secret.e2e.spec.ts @@ -32,6 +32,9 @@ import { v4 } from 'uuid' import fetchEvents from '../common/fetch-events' import { SecretService } from './service/secret.service' import { EventService } from '../event/service/event.service' +import { REDIS_CLIENT } from '../provider/redis.provider' +import { RedisClientType } from 'redis' +import { mockDeep } from 'jest-mock-extended' describe('Secret Controller Tests', () => { let app: NestFastifyApplication @@ -63,6 +66,8 @@ describe('Secret Controller Tests', () => { }) .overrideProvider(MAIL_SERVICE) .useClass(MockMailService) + .overrideProvider(REDIS_CLIENT) + .useValue(mockDeep()) .compile() app = moduleRef.createNestApplication( diff --git a/apps/api/src/secret/service/secret.service.spec.ts b/apps/api/src/secret/service/secret.service.spec.ts index 771108a5..408b4ae6 100644 --- a/apps/api/src/secret/service/secret.service.spec.ts +++ b/apps/api/src/secret/service/secret.service.spec.ts @@ -4,12 +4,16 @@ import { MAIL_SERVICE } from '../../mail/services/interface.service' import { MockMailService } from '../../mail/services/mock.service' import { PrismaService } from '../../prisma/prisma.service' import { mockDeep } from 'jest-mock-extended' +import { REDIS_CLIENT } from '../../provider/redis.provider' +import { RedisClientType } from 'redis' +import { ProviderModule } from '../../provider/provider.module' describe('SecretService', () => { let service: SecretService beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ + imports: [ProviderModule], providers: [ PrismaService, { @@ -19,6 +23,8 @@ describe('SecretService', () => { SecretService ] }) + .overrideProvider(REDIS_CLIENT) + .useValue(mockDeep()) .overrideProvider(PrismaService) .useValue(mockDeep()) .compile() diff --git a/apps/api/src/secret/service/secret.service.ts b/apps/api/src/secret/service/secret.service.ts index 2eb5267e..379e413a 100644 --- a/apps/api/src/secret/service/secret.service.ts +++ b/apps/api/src/secret/service/secret.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, ConflictException, + Inject, Injectable, Logger, NotFoundException @@ -37,12 +38,24 @@ import getDefaultEnvironmentOfProject from '../../common/get-default-project-env import workspaceApprovalEnabled from '../../common/workspace-approval-enabled' import createApproval from '../../common/create-approval' import { UpdateSecretMetadata } from '../../approval/approval.types' +import { RedisClientType } from 'redis' +import { REDIS_CLIENT } from '../../provider/redis.provider' +import { CHANGE_NOTIFIER_RSC } from '../../socket/change-notifier.socket' @Injectable() export class SecretService { private readonly logger = new Logger(SecretService.name) + private readonly redis: RedisClientType - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + @Inject(REDIS_CLIENT) + readonly redisClient: { + publisher: RedisClientType + } + ) { + this.redis = redisClient.publisher + } async createSecret( user: User, @@ -581,6 +594,16 @@ export class SecretService { } } }) + + await this.redis.publish( + CHANGE_NOTIFIER_RSC, + JSON.stringify({ + environmentId: secret.environmentId, + name: secret.name, + value: dto.value, + isSecret: true + }) + ) } else { result = await this.prisma.secret.update({ where: { @@ -622,7 +645,7 @@ export class SecretService { async updateEnvironment( user: User, - secret: SecretWithProject, + secret: SecretWithProjectAndVersion, environment: Environment ) { // Update the secret @@ -662,7 +685,7 @@ export class SecretService { async rollback( user: User, - secret: SecretWithProject, + secret: SecretWithProjectAndVersion, rollbackVersion: number ) { // Rollback the secret @@ -675,6 +698,16 @@ export class SecretService { } }) + await this.redis.publish( + CHANGE_NOTIFIER_RSC, + JSON.stringify({ + environmentId: secret.environmentId, + name: secret.name, + value: secret.versions[rollbackVersion - 1].value, + isSecret: true + }) + ) + await createEvent( { triggeredBy: user, diff --git a/apps/api/src/socket/change-notifier.socket.ts b/apps/api/src/socket/change-notifier.socket.ts new file mode 100644 index 00000000..8b817667 --- /dev/null +++ b/apps/api/src/socket/change-notifier.socket.ts @@ -0,0 +1,231 @@ +import { Inject, Logger, UseGuards } from '@nestjs/common' +import { + ConnectedSocket, + MessageBody, + OnGatewayConnection, + OnGatewayDisconnect, + OnGatewayInit, + SubscribeMessage, + WebSocketGateway, + WebSocketServer +} from '@nestjs/websockets' +import { Server, Socket } from 'socket.io' +import { + ChangeNotificationEvent, + ChangeNotifierRegistration +} from './socket.types' +import { Authority, User } from '@prisma/client' +import getWorkspaceWithAuthority from '../common/get-workspace-with-authority' +import { CurrentUser } from '../decorators/user.decorator' +import getProjectWithAuthority from '../common/get-project-with-authority' +import getEnvironmentWithAuthority from '../common/get-environment-with-authority' +import { PrismaService } from '../prisma/prisma.service' +import { REDIS_CLIENT } from '../provider/redis.provider' +import { RedisClientType } from 'redis' +import { ApiKeyGuard } from '../auth/guard/api-key/api-key.guard' +import { AuthGuard } from '../auth/guard/auth/auth.guard' +import { RequiredApiKeyAuthorities } from '../decorators/required-api-key-authorities.decorator' +import { Cron, CronExpression } from '@nestjs/schedule' + +// The redis subscription channel for configuration updates +export const CHANGE_NOTIFIER_RSC = 'configuration-updates' + +// This will store the mapping of environmentId -> socketId[] +const ENV_TO_SOCKET_PREFIX = 'env_to_socket:' + +@WebSocketGateway({ + namespace: 'change-notifier', + transports: ['websocket'], + cors: false +}) +export default class ChangeNotifier + implements OnGatewayDisconnect, OnGatewayConnection, OnGatewayInit +{ + private readonly logger = new Logger(ChangeNotifier.name) + @WebSocketServer() server: Server + private readonly redis: RedisClientType + private readonly redisSubscriber: RedisClientType + + constructor( + @Inject(REDIS_CLIENT) + readonly redisClient: { + subscriber: RedisClientType + publisher: RedisClientType + }, + private readonly prisma: PrismaService + ) { + this.redis = redisClient.publisher + this.redisSubscriber = redisClient.subscriber + } + + /** + * We want the socket gateway to subscribe to the Redis channel. + * This approach allows us to handle distributed computing where + * multiple clients can connect to different instances of the API. + * Any server that will get an update, will publish it to the Redis + * channel, and all connected clients will receive the update. Out + * of them, the ones that have sockets registered for the particular + * environmentId will receive the update. + */ + async afterInit() { + this.logger.log('Initialized change notifier socket gateway') + await this.redisSubscriber.subscribe( + CHANGE_NOTIFIER_RSC, + this.notifyConfigurationUpdate.bind(this) + ) + } + + async handleConnection(client: Socket, ...args: any[]) { + this.logger.log(`Client connected: ${client.id}. Data: ${args}`) + } + + async handleDisconnect(client: Socket) { + await this.removeClientFromEnvironment(client) + this.logger.log(`Client disconnected: ${client.id}`) + } + + /** + * This event is emitted from the client app to register + * itself with our services so that it can receive updates. + */ + @RequiredApiKeyAuthorities( + Authority.READ_WORKSPACE, + Authority.READ_PROJECT, + Authority.READ_ENVIRONMENT + ) + @UseGuards(AuthGuard, ApiKeyGuard) + @SubscribeMessage('register-client-app') + async handleRegister( + @ConnectedSocket() client: Socket, + @MessageBody() data: ChangeNotifierRegistration, + @CurrentUser() user: User + ) { + // Check if the user has access to the workspace + const workspace = await this.prisma.workspace.findFirst({ + where: { + name: data.workspaceName + } + }) + await getWorkspaceWithAuthority( + user.id, + workspace.id, + Authority.READ_WORKSPACE, + this.prisma + ) + + // Check if the user has access to the project + const project = await this.prisma.project.findFirst({ + where: { + name: data.projectName + } + }) + await getProjectWithAuthority( + user.id, + project.id, + Authority.READ_PROJECT, + this.prisma + ) + + // Check if the user has access to the environment + const environment = await this.prisma.environment.findFirst({ + where: { + name: data.environmentName + } + }) + await getEnvironmentWithAuthority( + user.id, + environment.id, + Authority.READ_ENVIRONMENT, + this.prisma + ) + + // Add the client to the environment + await this.addClientToEnvironment(client, environment.id) + + // Send ACK to client + client.send({ + status: 200 + }) + } + + private async addClientToEnvironment(client: Socket, environmentId: string) { + await this.prisma.changeNotificationSocketMap.create({ + data: { + socketId: client.id, + environmentId + } + }) + await this.redis.sAdd(`${ENV_TO_SOCKET_PREFIX}${environmentId}`, client.id) + + this.logger.log( + `Client registered: ${client.id} for environment: ${environmentId}` + ) + } + + private async removeClientFromEnvironment(client: Socket) { + // Get the environment that the client was connected to + const socketMap = await this.prisma.changeNotificationSocketMap.findFirst({ + where: { + socketId: client.id + } + }) + if (!socketMap) { + return + } + const environmentId = socketMap.environmentId + + // Remove the client from the environment's list of connected clients + await this.redis.sRem(`${ENV_TO_SOCKET_PREFIX}${environmentId}`, client.id) + + // Remove socketId -> environmentId mapping + await this.prisma.changeNotificationSocketMap.deleteMany({ + where: { + socketId: client.id, + environmentId + } + }) + + this.logger.log( + `Client deregistered: ${client.id} from environment: ${environmentId}` + ) + } + + private async notifyConfigurationUpdate(rawData: string) { + const data = JSON.parse(rawData) as ChangeNotificationEvent + + // Get the environment that the entity belongs to + const environmentId: string = data.environmentId + + // Get the list of connected clients + const clientIds: string[] = await this.redis.sMembers( + `${ENV_TO_SOCKET_PREFIX}${environmentId}` + ) + + data.environmentId = undefined + + // Notify each connected client + if (clientIds) { + for (const clientId of clientIds) { + this.server.to(clientId).emit('configuration-updated', data) + } + this.logger.log( + `Notified ${clientIds.length} clients for environment: ${environmentId}` + ) + } + } + + @Cron(CronExpression.EVERY_30_SECONDS) + async rehydrateCache() { + this.logger.log('Rehydrating ChangeNotifier cache') + const socketMaps = await this.prisma.changeNotificationSocketMap.findMany() + this.logger.log(`Found ${socketMaps.length} socket maps`) + + for (const socketMap of socketMaps) { + await this.redis.sAdd( + `${ENV_TO_SOCKET_PREFIX}${socketMap.environmentId}`, + socketMap.socketId + ) + } + this.logger.log('Rehydrated ChangeNotifier cache') + } +} diff --git a/apps/api/src/socket/redis.adapter.ts b/apps/api/src/socket/redis.adapter.ts new file mode 100644 index 00000000..4fa95d7c --- /dev/null +++ b/apps/api/src/socket/redis.adapter.ts @@ -0,0 +1,26 @@ +import { IoAdapter } from '@nestjs/platform-socket.io' +import { ServerOptions } from 'socket.io' +import { createAdapter } from '@socket.io/redis-adapter' +import { createClient } from 'redis' + +export class RedisIoAdapter extends IoAdapter { + private adapterConstructor: ReturnType + + async connectToRedis(): Promise { + const pubClient = createClient({ + url: process.env.REDIS_URL, + password: process.env.REDIS_PASSWORD + }) + const subClient = pubClient.duplicate() + + await Promise.all([pubClient.connect(), subClient.connect()]) + + this.adapterConstructor = createAdapter(pubClient, subClient) + } + + createIOServer(port: number, options?: ServerOptions): any { + const server = super.createIOServer(port, options) + server.adapter(this.adapterConstructor) + return server + } +} diff --git a/apps/api/src/socket/socket.module.ts b/apps/api/src/socket/socket.module.ts new file mode 100644 index 00000000..0171a914 --- /dev/null +++ b/apps/api/src/socket/socket.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common' +import ChangeNotifier from './change-notifier.socket' + +@Module({ + providers: [ChangeNotifier] +}) +export class SocketModule {} diff --git a/apps/api/src/socket/socket.types.ts b/apps/api/src/socket/socket.types.ts new file mode 100644 index 00000000..92bea27d --- /dev/null +++ b/apps/api/src/socket/socket.types.ts @@ -0,0 +1,15 @@ +export interface ChangeNotifierRegistration { + workspaceName: string + projectName: string + environmentName: string +} + +export interface ChangeNotification { + name: string + value: string + isSecret: boolean +} + +export interface ChangeNotificationEvent extends ChangeNotification { + environmentId: string +} diff --git a/apps/api/src/variable/controller/variable.controller.spec.ts b/apps/api/src/variable/controller/variable.controller.spec.ts index 71e372d4..233655ec 100644 --- a/apps/api/src/variable/controller/variable.controller.spec.ts +++ b/apps/api/src/variable/controller/variable.controller.spec.ts @@ -4,12 +4,17 @@ import { PrismaService } from '../../prisma/prisma.service' import { MAIL_SERVICE } from '../../mail/services/interface.service' import { MockMailService } from '../../mail/services/mock.service' import { VariableService } from '../service/variable.service' +import { REDIS_CLIENT } from '../../provider/redis.provider' +import { RedisClientType } from 'redis' +import { mockDeep } from 'jest-mock-extended' +import { ProviderModule } from '../../provider/provider.module' describe('VariableController', () => { let controller: VariableController beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ + imports: [ProviderModule], providers: [ PrismaService, { @@ -19,7 +24,10 @@ describe('VariableController', () => { VariableService ], controllers: [VariableController] - }).compile() + }) + .overrideProvider(REDIS_CLIENT) + .useValue(mockDeep()) + .compile() controller = module.get(VariableController) }) diff --git a/apps/api/src/variable/controller/variable.controller.ts b/apps/api/src/variable/controller/variable.controller.ts index dfca5d45..24cdea2a 100644 --- a/apps/api/src/variable/controller/variable.controller.ts +++ b/apps/api/src/variable/controller/variable.controller.ts @@ -14,6 +14,7 @@ import { RequiredApiKeyAuthorities } from '../../decorators/required-api-key-aut import { Authority, User } from '@prisma/client' import { CurrentUser } from '../../decorators/user.decorator' import { CreateVariable } from '../dto/create.variable/create.variable' +import { UpdateVariable } from '../dto/update.variable/update.variable' @ApiTags('Variable Controller') @Controller('variable') @@ -41,7 +42,7 @@ export class VariableController { async updateVariable( @CurrentUser() user: User, @Param('variableId') variableId: string, - @Body() dto: CreateVariable, + @Body() dto: UpdateVariable, @Query('reason') reason: string ) { return await this.variableService.updateVariable( diff --git a/apps/api/src/variable/service/variable.service.spec.ts b/apps/api/src/variable/service/variable.service.spec.ts index b00a288f..da77e3de 100644 --- a/apps/api/src/variable/service/variable.service.spec.ts +++ b/apps/api/src/variable/service/variable.service.spec.ts @@ -3,12 +3,17 @@ import { VariableService } from './variable.service' import { PrismaService } from '../../prisma/prisma.service' import { MAIL_SERVICE } from '../../mail/services/interface.service' import { MockMailService } from '../../mail/services/mock.service' +import { REDIS_CLIENT } from '../../provider/redis.provider' +import { RedisClientType } from 'redis' +import { mockDeep } from 'jest-mock-extended' +import { ProviderModule } from '../../provider/provider.module' describe('VariableService', () => { let service: VariableService beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ + imports: [ProviderModule], providers: [ PrismaService, { @@ -17,7 +22,10 @@ describe('VariableService', () => { }, VariableService ] - }).compile() + }) + .overrideProvider(REDIS_CLIENT) + .useValue(mockDeep()) + .compile() service = module.get(VariableService) }) diff --git a/apps/api/src/variable/service/variable.service.ts b/apps/api/src/variable/service/variable.service.ts index a91f5503..86f130f2 100644 --- a/apps/api/src/variable/service/variable.service.ts +++ b/apps/api/src/variable/service/variable.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, ConflictException, + Inject, Injectable, Logger, NotFoundException @@ -29,13 +30,28 @@ import getVariableWithAuthority from '../../common/get-variable-with-authority' import workspaceApprovalEnabled from '../../common/workspace-approval-enabled' import createApproval from '../../common/create-approval' import { UpdateVariableMetadata } from '../../approval/approval.types' -import { VariableWithProject } from '../variable.types' +import { + VariableWithProject, + VariableWithProjectAndVersion +} from '../variable.types' +import { RedisClientType } from 'redis' +import { REDIS_CLIENT } from '../../provider/redis.provider' +import { CHANGE_NOTIFIER_RSC } from '../../socket/change-notifier.socket' @Injectable() export class VariableService { private readonly logger = new Logger(VariableService.name) + private readonly redis: RedisClientType - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + @Inject(REDIS_CLIENT) + readonly redisClient: { + publisher: RedisClientType + } + ) { + this.redis = redisClient.publisher + } async createVariable( user: User, @@ -488,7 +504,7 @@ export class VariableService { async update( dto: UpdateVariable | UpdateVariableMetadata, user: User, - variable: VariableWithProject + variable: VariableWithProjectAndVersion ) { let result @@ -525,6 +541,16 @@ export class VariableService { } } }) + + await this.redis.publish( + CHANGE_NOTIFIER_RSC, + JSON.stringify({ + environmentId: variable.environmentId, + name: variable.name, + value: dto.value, + isSecret: false + }) + ) } else { result = await this.prisma.variable.update({ where: { @@ -607,7 +633,7 @@ export class VariableService { async rollback( user: User, - variable: VariableWithProject, + variable: VariableWithProjectAndVersion, rollbackVersion: VariableVersion['version'] ) { // Rollback the variable @@ -620,6 +646,16 @@ export class VariableService { } }) + await this.redis.publish( + CHANGE_NOTIFIER_RSC, + JSON.stringify({ + environmentId: variable.environmentId, + name: variable.name, + value: variable.versions[rollbackVersion - 1].value, + isSecret: false + }) + ) + await createEvent( { triggeredBy: user, diff --git a/apps/api/src/variable/variable.e2e.spec.ts b/apps/api/src/variable/variable.e2e.spec.ts index c8999b86..edf81835 100644 --- a/apps/api/src/variable/variable.e2e.spec.ts +++ b/apps/api/src/variable/variable.e2e.spec.ts @@ -32,6 +32,9 @@ import { v4 } from 'uuid' import fetchEvents from '../common/fetch-events' import { VariableService } from './service/variable.service' import { EventService } from '../event/service/event.service' +import { REDIS_CLIENT } from '../provider/redis.provider' +import { mockDeep } from 'jest-mock-extended' +import { RedisClientType } from 'redis' describe('Variable Controller Tests', () => { let app: NestFastifyApplication @@ -63,6 +66,8 @@ describe('Variable Controller Tests', () => { }) .overrideProvider(MAIL_SERVICE) .useClass(MockMailService) + .overrideProvider(REDIS_CLIENT) + .useValue(mockDeep()) .compile() app = moduleRef.createNestApplication( diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 000fbb17..95f5641c 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -18,4 +18,4 @@ "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false } -} \ No newline at end of file +} diff --git a/docker-compose-test.yml b/docker-compose-test.yml index e0e90f02..7af87799 100644 --- a/docker-compose-test.yml +++ b/docker-compose-test.yml @@ -15,6 +15,12 @@ services: POSTGRES_DB: tests networks: - keyshade-test + redis: + image: redis:6 + ports: + - '6379:6379' + networks: + - keyshade-test networks: keyshade-test: driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index 5cb7457b..003c841b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.9' - services: db: image: postgres:13 @@ -10,9 +8,17 @@ services: POSTGRES_PASSWORD: password POSTGRES_DB: keyshade_db volumes: - - ./data:/var/lib/postgresql/data + - ./data/db:/var/lib/postgresql/data networks: - keyshade-dev + redis: + image: redis:6 + ports: + - '6379:6379' + networks: + - keyshade-dev + volumes: + - ./data/redis:/data networks: keyshade-dev: driver: bridge diff --git a/package.json b/package.json index 2c02c57f..94a9be12 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,8 @@ "start:web": "turbo run start --filter=web", "start:workspace": "turbo run start --filter=workspace", "test": "turbo run test", - "test:api": "pnpm db:generate-types && turbo run test --filter=api -- --config=jest.config.ts", + "test:api": "pnpm unit:api && pnpm e2e:api", + "unit:api": "pnpm db:generate-types && turbo run test --filter=api -- --config=jest.config.ts", "e2e:api:prepare": "docker compose down && docker compose -f docker-compose-test.yml up -d && pnpm db:generate-types && NODE_ENV='e2e' DATABASE_URL='postgresql://prisma:prisma@localhost:5432/tests' pnpm run db:deploy-migrations", "e2e:api": "pnpm run e2e:api:prepare && NODE_ENV='e2e' DATABASE_URL='postgresql://prisma:prisma@localhost:5432/tests' turbo run test --no-cache --filter=api -- --runInBand --config=jest.e2e-config.ts --coverage --coverageDirectory=../../coverage-e2e/api --coverageReporters=json && pnpm run e2e:api:teardown", "e2e:api:teardown": "docker compose -f docker-compose-test.yml down", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee47398a..585ee87b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,7 +56,7 @@ importers: version: 3.2.0(@nestjs/common@10.3.2)(rxjs@7.8.1) '@nestjs/core': specifier: ^10.0.0 - version: 10.3.2(@nestjs/common@10.3.2)(@nestjs/platform-express@10.3.2)(reflect-metadata@0.2.1)(rxjs@7.8.1) + version: 10.3.2(@nestjs/common@10.3.2)(@nestjs/platform-express@10.3.2)(@nestjs/websockets@10.3.7)(reflect-metadata@0.2.1)(rxjs@7.8.1) '@nestjs/jwt': specifier: ^10.2.0 version: 10.2.0(@nestjs/common@10.3.2) @@ -69,15 +69,24 @@ importers: '@nestjs/platform-fastify': specifier: ^10.3.3 version: 10.3.3(@nestjs/common@10.3.2)(@nestjs/core@10.3.2) + '@nestjs/platform-socket.io': + specifier: ^10.3.7 + version: 10.3.7(@nestjs/common@10.3.2)(@nestjs/websockets@10.3.7)(rxjs@7.8.1) '@nestjs/schedule': specifier: ^4.0.1 version: 4.0.1(@nestjs/common@10.3.2)(@nestjs/core@10.3.2) '@nestjs/swagger': specifier: ^7.3.0 version: 7.3.0(@nestjs/common@10.3.2)(@nestjs/core@10.3.2)(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.1) + '@nestjs/websockets': + specifier: ^10.3.7 + version: 10.3.7(@nestjs/common@10.3.2)(@nestjs/core@10.3.2)(@nestjs/platform-socket.io@10.3.7)(reflect-metadata@0.2.1)(rxjs@7.8.1) '@prisma/client': specifier: ^5.10.1 version: 5.10.2(prisma@5.10.2) + '@socket.io/redis-adapter': + specifier: ^8.3.0 + version: 8.3.0(socket.io-adapter@2.5.4) '@supabase/supabase-js': specifier: ^2.39.6 version: 2.39.6 @@ -111,12 +120,18 @@ importers: prisma: specifier: ^5.10.1 version: 5.10.2 + redis: + specifier: ^4.6.13 + version: 4.6.13 reflect-metadata: specifier: ^0.2.0 version: 0.2.1 rxjs: specifier: ^7.8.1 version: 7.8.1 + socket.io: + specifier: ^4.7.5 + version: 4.7.5 uuid: specifier: ^9.0.1 version: 9.0.1 @@ -2711,7 +2726,7 @@ packages: uuid: 9.0.1 dev: false - /@nestjs/core@10.3.2(@nestjs/common@10.3.2)(@nestjs/platform-express@10.3.2)(reflect-metadata@0.2.1)(rxjs@7.8.1): + /@nestjs/core@10.3.2(@nestjs/common@10.3.2)(@nestjs/platform-express@10.3.2)(@nestjs/websockets@10.3.7)(reflect-metadata@0.2.1)(rxjs@7.8.1): resolution: {integrity: sha512-JW3bQvDFY1gB+xXR6E5DzCdKftRszyWtd0YyDkdlKh1+44e2IGybFhSa5HcQBOiRqdVgPqAM5Vqc81rmhgeBnQ==} requiresBuild: true peerDependencies: @@ -2731,6 +2746,7 @@ packages: dependencies: '@nestjs/common': 10.3.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.1)(rxjs@7.8.1) '@nestjs/platform-express': 10.3.2(@nestjs/common@10.3.2)(@nestjs/core@10.3.2) + '@nestjs/websockets': 10.3.7(@nestjs/common@10.3.2)(@nestjs/core@10.3.2)(@nestjs/platform-socket.io@10.3.7)(reflect-metadata@0.2.1)(rxjs@7.8.1) '@nuxtjs/opencollective': 0.3.2 fast-safe-stringify: 2.1.1 iterare: 1.2.1 @@ -2788,7 +2804,7 @@ packages: '@nestjs/core': ^10.0.0 dependencies: '@nestjs/common': 10.3.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.1)(rxjs@7.8.1) - '@nestjs/core': 10.3.2(@nestjs/common@10.3.2)(@nestjs/platform-express@10.3.2)(reflect-metadata@0.2.1)(rxjs@7.8.1) + '@nestjs/core': 10.3.2(@nestjs/common@10.3.2)(@nestjs/platform-express@10.3.2)(@nestjs/websockets@10.3.7)(reflect-metadata@0.2.1)(rxjs@7.8.1) body-parser: 1.20.2 cors: 2.8.5 express: 4.18.2 @@ -2814,7 +2830,7 @@ packages: '@fastify/formbody': 7.4.0 '@fastify/middie': 8.3.0 '@nestjs/common': 10.3.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.1)(rxjs@7.8.1) - '@nestjs/core': 10.3.2(@nestjs/common@10.3.2)(@nestjs/platform-express@10.3.2)(reflect-metadata@0.2.1)(rxjs@7.8.1) + '@nestjs/core': 10.3.2(@nestjs/common@10.3.2)(@nestjs/platform-express@10.3.2)(@nestjs/websockets@10.3.7)(reflect-metadata@0.2.1)(rxjs@7.8.1) fastify: 4.26.0 light-my-request: 5.11.0 path-to-regexp: 3.2.0 @@ -2823,6 +2839,23 @@ packages: - supports-color dev: false + /@nestjs/platform-socket.io@10.3.7(@nestjs/common@10.3.2)(@nestjs/websockets@10.3.7)(rxjs@7.8.1): + resolution: {integrity: sha512-T9VbVgEUnbid/RiywN9/8YQ8pAGDP++0nX73l4kIWeDWkz5DEh4aLB7O/JvLA3/xRHdjTZ4RiRZazwqSWi1Sog==} + peerDependencies: + '@nestjs/common': ^10.0.0 + '@nestjs/websockets': ^10.0.0 + rxjs: ^7.1.0 + dependencies: + '@nestjs/common': 10.3.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.1)(rxjs@7.8.1) + '@nestjs/websockets': 10.3.7(@nestjs/common@10.3.2)(@nestjs/core@10.3.2)(@nestjs/platform-socket.io@10.3.7)(reflect-metadata@0.2.1)(rxjs@7.8.1) + rxjs: 7.8.1 + socket.io: 4.7.5 + tslib: 2.6.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + /@nestjs/schedule@4.0.1(@nestjs/common@10.3.2)(@nestjs/core@10.3.2): resolution: {integrity: sha512-cz2FNjsuoma+aGsG0cMmG6Dqg/BezbBWet1UTHtAuu6d2mXNTVcmoEQM2DIVG5Lfwb2hfSE2yZt8Moww+7y+mA==} peerDependencies: @@ -2830,7 +2863,7 @@ packages: '@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0 dependencies: '@nestjs/common': 10.3.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.1)(rxjs@7.8.1) - '@nestjs/core': 10.3.2(@nestjs/common@10.3.2)(@nestjs/platform-express@10.3.2)(reflect-metadata@0.2.1)(rxjs@7.8.1) + '@nestjs/core': 10.3.2(@nestjs/common@10.3.2)(@nestjs/platform-express@10.3.2)(@nestjs/websockets@10.3.7)(reflect-metadata@0.2.1)(rxjs@7.8.1) cron: 3.1.6 uuid: 9.0.1 dev: false @@ -2869,7 +2902,7 @@ packages: dependencies: '@microsoft/tsdoc': 0.14.2 '@nestjs/common': 10.3.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.1)(rxjs@7.8.1) - '@nestjs/core': 10.3.2(@nestjs/common@10.3.2)(@nestjs/platform-express@10.3.2)(reflect-metadata@0.2.1)(rxjs@7.8.1) + '@nestjs/core': 10.3.2(@nestjs/common@10.3.2)(@nestjs/platform-express@10.3.2)(@nestjs/websockets@10.3.7)(reflect-metadata@0.2.1)(rxjs@7.8.1) '@nestjs/mapped-types': 2.0.5(@nestjs/common@10.3.2)(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.1) class-transformer: 0.5.1 class-validator: 0.14.1 @@ -2894,11 +2927,32 @@ packages: optional: true dependencies: '@nestjs/common': 10.3.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.1)(rxjs@7.8.1) - '@nestjs/core': 10.3.2(@nestjs/common@10.3.2)(@nestjs/platform-express@10.3.2)(reflect-metadata@0.2.1)(rxjs@7.8.1) + '@nestjs/core': 10.3.2(@nestjs/common@10.3.2)(@nestjs/platform-express@10.3.2)(@nestjs/websockets@10.3.7)(reflect-metadata@0.2.1)(rxjs@7.8.1) '@nestjs/platform-express': 10.3.2(@nestjs/common@10.3.2)(@nestjs/core@10.3.2) tslib: 2.6.2 dev: true + /@nestjs/websockets@10.3.7(@nestjs/common@10.3.2)(@nestjs/core@10.3.2)(@nestjs/platform-socket.io@10.3.7)(reflect-metadata@0.2.1)(rxjs@7.8.1): + resolution: {integrity: sha512-iYdsWiRNPUy0XzPoW44bx2MW1griuraTr5fNhoe2rUSNO0mEW1aeXp4v56KeZDLAss31WbeckC5P3N223Fys5g==} + peerDependencies: + '@nestjs/common': ^10.0.0 + '@nestjs/core': ^10.0.0 + '@nestjs/platform-socket.io': ^10.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/platform-socket.io': + optional: true + dependencies: + '@nestjs/common': 10.3.2(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.1)(rxjs@7.8.1) + '@nestjs/core': 10.3.2(@nestjs/common@10.3.2)(@nestjs/platform-express@10.3.2)(@nestjs/websockets@10.3.7)(reflect-metadata@0.2.1)(rxjs@7.8.1) + '@nestjs/platform-socket.io': 10.3.7(@nestjs/common@10.3.2)(@nestjs/websockets@10.3.7)(rxjs@7.8.1) + iterare: 1.2.1 + object-hash: 3.0.0 + reflect-metadata: 0.2.1 + rxjs: 7.8.1 + tslib: 2.6.2 + /@next/env@13.5.6: resolution: {integrity: sha512-Yac/bV5sBGkkEXmAX5FWPS9Mmo2rthrOPRQQNfycJPkjUAUclomCPH7QFVCDQ4Mp2k2K1SSM6m0zrxYrOwtFQw==} dev: false @@ -3659,6 +3713,55 @@ packages: '@babel/runtime': 7.24.1 dev: false + /@redis/bloom@1.2.0(@redis/client@1.5.14): + resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} + peerDependencies: + '@redis/client': ^1.0.0 + dependencies: + '@redis/client': 1.5.14 + dev: false + + /@redis/client@1.5.14: + resolution: {integrity: sha512-YGn0GqsRBFUQxklhY7v562VMOP0DcmlrHHs3IV1mFE3cbxe31IITUkqhBcIhVSI/2JqtWAJXg5mjV4aU+zD0HA==} + engines: {node: '>=14'} + dependencies: + cluster-key-slot: 1.1.2 + generic-pool: 3.9.0 + yallist: 4.0.0 + dev: false + + /@redis/graph@1.1.1(@redis/client@1.5.14): + resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==} + peerDependencies: + '@redis/client': ^1.0.0 + dependencies: + '@redis/client': 1.5.14 + dev: false + + /@redis/json@1.0.6(@redis/client@1.5.14): + resolution: {integrity: sha512-rcZO3bfQbm2zPRpqo82XbW8zg4G/w4W3tI7X8Mqleq9goQjAGLL7q/1n1ZX4dXEAmORVZ4s1+uKLaUOg7LrUhw==} + peerDependencies: + '@redis/client': ^1.0.0 + dependencies: + '@redis/client': 1.5.14 + dev: false + + /@redis/search@1.1.6(@redis/client@1.5.14): + resolution: {integrity: sha512-mZXCxbTYKBQ3M2lZnEddwEAks0Kc7nauire8q20oA0oA/LoA+E/b5Y5KZn232ztPb1FkIGqo12vh3Lf+Vw5iTw==} + peerDependencies: + '@redis/client': ^1.0.0 + dependencies: + '@redis/client': 1.5.14 + dev: false + + /@redis/time-series@1.0.5(@redis/client@1.5.14): + resolution: {integrity: sha512-IFjIgTusQym2B5IZJG3XKr5llka7ey84fw/NOYqESP5WUfQs9zz1ww/9+qoz4ka/S6KcGBodzlCeZ5UImKbscg==} + peerDependencies: + '@redis/client': ^1.0.0 + dependencies: + '@redis/client': 1.5.14 + dev: false + /@rollup/pluginutils@5.1.0: resolution: {integrity: sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==} engines: {node: '>=14.0.0'} @@ -3865,6 +3968,23 @@ packages: '@sinonjs/commons': 3.0.1 dev: true + /@socket.io/component-emitter@3.1.0: + resolution: {integrity: sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==} + + /@socket.io/redis-adapter@8.3.0(socket.io-adapter@2.5.4): + resolution: {integrity: sha512-ly0cra+48hDmChxmIpnESKrc94LjRL80TEmZVscuQ/WWkRP81nNj8W8cCGMqbI4L6NCuAaPRSzZF1a9GlAxxnA==} + engines: {node: '>=10.0.0'} + peerDependencies: + socket.io-adapter: ^2.5.4 + dependencies: + debug: 4.3.4 + notepack.io: 3.0.1 + socket.io-adapter: 2.5.4 + uid2: 1.0.0 + transitivePeerDependencies: + - supports-color + dev: false + /@supabase/functions-js@2.1.5: resolution: {integrity: sha512-BNzC5XhCzzCaggJ8s53DP+WeHHGT/NfTsx2wUSSGKR2/ikLFQTBCDzMvGz/PxYMqRko/LwncQtKXGOYp1PkPaw==} dependencies: @@ -4397,10 +4517,18 @@ packages: '@types/node': 20.11.19 dev: true + /@types/cookie@0.4.1: + resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} + /@types/cookiejar@2.1.5: resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} dev: true + /@types/cors@2.8.17: + resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==} + dependencies: + '@types/node': 20.11.19 + /@types/eccrypto@1.1.6: resolution: {integrity: sha512-rsmcX5LdDZ3xN2W3al6+YR+XNmiQWlXSwVhsU184QOwNQNJ83YpwvAt8a7cT7y3RpVWkKWmXoIFdanI/z38rNQ==} dependencies: @@ -5548,6 +5676,10 @@ packages: /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + /base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + /base64url@3.0.1: resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==} engines: {node: '>=6.0.0'} @@ -5911,6 +6043,11 @@ packages: engines: {node: '>=6'} dev: false + /cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + dev: false + /cmdk@0.2.1(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-U6//9lQ6JvT47+6OF6Gi8BvkxYQ8SCRRSKIJkthIMsFsLZRG0cKvTtuTaefyIKMQb8rvvXy0wGdpTNq/jPtm+g==} peerDependencies: @@ -6035,6 +6172,10 @@ packages: /cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + /cookie@0.4.2: + resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==} + engines: {node: '>= 0.6'} + /cookie@0.5.0: resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} engines: {node: '>= 0.6'} @@ -6487,6 +6628,29 @@ packages: resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} engines: {node: '>= 0.8'} + /engine.io-parser@5.2.2: + resolution: {integrity: sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==} + engines: {node: '>=10.0.0'} + + /engine.io@6.5.4: + resolution: {integrity: sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==} + engines: {node: '>=10.2.0'} + dependencies: + '@types/cookie': 0.4.1 + '@types/cors': 2.8.17 + '@types/node': 20.11.19 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.4.2 + cors: 2.8.5 + debug: 4.3.4 + engine.io-parser: 5.2.2 + ws: 8.11.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + /enhanced-resolve@5.15.0: resolution: {integrity: sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==} engines: {node: '>=10.13.0'} @@ -7560,6 +7724,11 @@ packages: next: 13.5.6(@babel/core@7.23.9)(react-dom@18.2.0)(react@18.2.0) dev: false + /generic-pool@3.9.0: + resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} + engines: {node: '>= 4'} + dev: false + /gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -9356,6 +9525,10 @@ packages: engines: {node: '>=0.10.0'} dev: true + /notepack.io@3.0.1: + resolution: {integrity: sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==} + dev: false + /npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -10170,6 +10343,17 @@ packages: resolve: 1.22.8 dev: true + /redis@4.6.13: + resolution: {integrity: sha512-MHgkS4B+sPjCXpf+HfdetBwbRz6vCtsceTmw1pHNYJAsYxrfpOP6dz+piJWGos8wqG7qb3vj/Rrc5qOlmInUuA==} + dependencies: + '@redis/bloom': 1.2.0(@redis/client@1.5.14) + '@redis/client': 1.5.14 + '@redis/graph': 1.1.1(@redis/client@1.5.14) + '@redis/json': 1.0.6(@redis/client@1.5.14) + '@redis/search': 1.1.6(@redis/client@1.5.14) + '@redis/time-series': 1.0.5(@redis/client@1.5.14) + dev: false + /reflect-metadata@0.2.1: resolution: {integrity: sha512-i5lLI6iw9AU3Uu4szRNPPEkomnkjRTaVt9hy/bn5g/oSzekBSMeLZblcjP74AW0vBabqERLLIrz+gR8QYR54Tw==} @@ -10644,6 +10828,41 @@ packages: tslib: 2.6.2 dev: true + /socket.io-adapter@2.5.4: + resolution: {integrity: sha512-wDNHGXGewWAjQPt3pyeYBtpWSq9cLE5UW1ZUPL/2eGK9jtse/FpXib7epSTsz0Q0m+6sg6Y4KtcFTlah1bdOVg==} + dependencies: + debug: 4.3.4 + ws: 8.11.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + /socket.io-parser@4.2.4: + resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==} + engines: {node: '>=10.0.0'} + dependencies: + '@socket.io/component-emitter': 3.1.0 + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + + /socket.io@4.7.5: + resolution: {integrity: sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA==} + engines: {node: '>=10.2.0'} + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.5 + debug: 4.3.4 + engine.io: 6.5.4 + socket.io-adapter: 2.5.4 + socket.io-parser: 4.2.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + /sonic-boom@3.8.0: resolution: {integrity: sha512-ybz6OYOUjoQQCQ/i4LU8kaToD8ACtYP+Cj5qd2AO36bwbdewxWJ3ArmJ2cr6AvxlL2o0PqnCcPGUgkILbfkaCA==} dependencies: @@ -11454,6 +11673,11 @@ packages: resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==} dev: false + /uid2@1.0.0: + resolution: {integrity: sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==} + engines: {node: '>= 4.0.0'} + dev: false + /uid@2.0.2: resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} engines: {node: '>=8'} @@ -11800,6 +12024,18 @@ packages: signal-exit: 3.0.7 dev: true + /ws@8.11.0: + resolution: {integrity: sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + /ws@8.16.0: resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==} engines: {node: '>=10.0.0'}