diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index b0879dc6de..45cc6fb1e5 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -35,6 +35,7 @@ "@calcom/prisma": "*", "@golevelup/ts-jest": "^0.4.0", "@microsoft/microsoft-graph-types-beta": "^0.42.0-preview", + "@nest-lab/throttler-storage-redis": "1.0.0", "@nestjs/bull": "^10.1.1", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.1.1", @@ -43,7 +44,7 @@ "@nestjs/passport": "^10.0.2", "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^7.3.0", - "@nestjs/throttler": "^5.1.2", + "@nestjs/throttler": "6.2.1", "@sentry/node": "^8.8.0", "body-parser": "^1.20.2", "bull": "^4.12.4", @@ -57,7 +58,6 @@ "ioredis": "^5.3.2", "luxon": "^3.4.4", "nest-winston": "^1.9.4", - "nestjs-throttler-storage-redis": "^0.4.1", "next-auth": "^4.22.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", diff --git a/apps/api/v2/src/app.e2e-spec.ts b/apps/api/v2/src/app.e2e-spec.ts index 41bc985250..5e8a0c8b22 100644 --- a/apps/api/v2/src/app.e2e-spec.ts +++ b/apps/api/v2/src/app.e2e-spec.ts @@ -1,26 +1,385 @@ import { AppModule } from "@/app.module"; +import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module"; +import { CustomThrottlerGuard } from "@/lib/throttler-guard"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; import { INestApplication } from "@nestjs/common"; import { TestingModule } from "@nestjs/testing"; import { Test } from "@nestjs/testing"; import * as request from "supertest"; +import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { RateLimitRepositoryFixture } from "test/fixtures/repository/rate-limit.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; -describe("AppController (e2e)", () => { - let app: INestApplication; +import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants"; +import { User, PlatformOAuthClient, Team, RateLimit } from "@calcom/prisma/client"; - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); +describe("AppController", () => { + describe("Rate limiting", () => { + let app: INestApplication; + let userRepositoryFixture: UserRepositoryFixture; + let apiKeysRepositoryFixture: ApiKeysRepositoryFixture; + let rateLimitRepositoryFixture: RateLimitRepositoryFixture; + const userEmail = "app-rate-limits-e2e@api.com"; + let user: User; - app = moduleFixture.createNestApplication(); - await app.init(); - }); + let organization: Team; + let oAuthClient: PlatformOAuthClient; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let profilesRepositoryFixture: ProfileRepositoryFixture; - it("/ (GET)", () => { - return request(app.getHttpServer()).get("/health").expect("OK"); - }); + let apiKeyString: string; - afterAll(async () => { - await app.close(); + let rateLimit: RateLimit; + let apiKeyStringWithRateLimit: string; + + let apiKeyStringWithMultipleLimits: string; + let firstRateLimitWithMultipleLimits: RateLimit; + let secondRateLimitWithMultipleLimits: RateLimit; + + const mockDefaultLimit = 5; + const mockDefaultTtl = 2500; + const mockDefaultBlockDuration = 5000; + + beforeEach(async () => { + const moduleRef: TestingModule = await Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_04_15], + }).compile(); + + jest.spyOn(CustomThrottlerGuard.prototype, "getDefaultLimit").mockReturnValue(mockDefaultLimit); + jest.spyOn(CustomThrottlerGuard.prototype, "getDefaultTtl").mockReturnValue(mockDefaultTtl); + jest + .spyOn(CustomThrottlerGuard.prototype, "getDefaultBlockDuration") + .mockReturnValue(mockDefaultBlockDuration); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + + apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef); + const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null); + apiKeyString = `cal_test_${keyString}`; + + rateLimitRepositoryFixture = new RateLimitRepositoryFixture(moduleRef); + const { apiKey, keyString: keyStringWithRateLimit } = await apiKeysRepositoryFixture.createApiKey( + user.id, + null + ); + apiKeyStringWithRateLimit = `cal_test_${keyStringWithRateLimit}`; + rateLimit = await rateLimitRepositoryFixture.createRateLimit("long", apiKey.id, 2000, 3, 4000); + + const { apiKey: apiKeyWithMultipleLimits, keyString: keyStringWithMultipleLimits } = + await apiKeysRepositoryFixture.createApiKey(user.id, null); + apiKeyStringWithMultipleLimits = `cal_test_${keyStringWithMultipleLimits}`; + firstRateLimitWithMultipleLimits = await rateLimitRepositoryFixture.createRateLimit( + "short", + apiKeyWithMultipleLimits.id, + 1000, + 2, + 2000 + ); + secondRateLimitWithMultipleLimits = await rateLimitRepositoryFixture.createRateLimit( + "long", + apiKeyWithMultipleLimits.id, + 2000, + 3, + 4000 + ); + + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + organization = await organizationsRepositoryFixture.create({ name: "ecorp" }); + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + oAuthClient = await createOAuthClient(organization.id); + profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + await profilesRepositoryFixture.create({ + uid: "asd-asd", + username: userEmail, + user: { connect: { id: user.id } }, + organization: { connect: { id: organization.id } }, + }); + + app = moduleRef.createNestApplication(); + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:5555"], + permissions: 1023, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it( + "api key with default rate limit - should enforce rate limits and reset after block duration", + async () => { + const limit = mockDefaultLimit; + const blockDuration = mockDefaultBlockDuration; + + for (let i = 1; i <= limit; i++) { + const response = await request(app.getHttpServer()) + .get("/v2/me") + .set({ Authorization: `Bearer ${apiKeyString}` }) + .expect(200); + + expect(response.headers["x-ratelimit-limit-default"]).toBe(limit.toString()); + expect(response.headers["x-ratelimit-remaining-default"]).toBe((limit - i).toString()); + expect(Number(response.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0); + } + + const blockedResponse = await request(app.getHttpServer()) + .get("/v2/me") + .set("Authorization", `Bearer ${apiKeyString}`) + .expect(429); + + expect(blockedResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString()); + expect(blockedResponse.headers["x-ratelimit-remaining-default"]).toBe("0"); + expect(Number(blockedResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThanOrEqual( + blockDuration / 1000 + ); + + await new Promise((resolve) => setTimeout(resolve, blockDuration)); + + const afterBlockResponse = await request(app.getHttpServer()) + .get("/v2/me") + .set("Authorization", `Bearer ${apiKeyString}`) + .expect(200); + + expect(afterBlockResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString()); + expect(afterBlockResponse.headers["x-ratelimit-remaining-default"]).toBe((limit - 1).toString()); + expect(Number(afterBlockResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0); + }, + 15 * 1000 + ); + + it( + "api key with custom rate limit - should enforce rate limits and reset after block duration", + async () => { + const limit = rateLimit.limit; + const blockDuration = rateLimit.blockDuration; + const name = rateLimit.name; + + for (let i = 1; i <= limit; i++) { + const response = await request(app.getHttpServer()) + .get("/v2/me") + .set({ Authorization: `Bearer ${apiKeyStringWithRateLimit}` }) + .expect(200); + + expect(response.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString()); + expect(response.headers[`x-ratelimit-remaining-${name}`]).toBe((limit - i).toString()); + expect(Number(response.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThan(0); + } + + const blockedResponse = await request(app.getHttpServer()) + .get("/v2/me") + .set("Authorization", `Bearer ${apiKeyStringWithRateLimit}`) + .expect(429); + + expect(blockedResponse.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString()); + expect(blockedResponse.headers[`x-ratelimit-remaining-${name}`]).toBe("0"); + expect(Number(blockedResponse.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThanOrEqual( + blockDuration / 1000 + ); + + await new Promise((resolve) => setTimeout(resolve, blockDuration)); + + const afterBlockResponse = await request(app.getHttpServer()) + .get("/v2/me") + .set("Authorization", `Bearer ${apiKeyStringWithRateLimit}`) + .expect(200); + + expect(afterBlockResponse.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString()); + expect(afterBlockResponse.headers[`x-ratelimit-remaining-${name}`]).toBe((limit - 1).toString()); + expect(Number(afterBlockResponse.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThan(0); + }, + 15 * 1000 + ); + + it( + "api key with multiple rate limits - should enforce both short and long rate limits", + async () => { + const shortLimit = firstRateLimitWithMultipleLimits.limit; + const longLimit = secondRateLimitWithMultipleLimits.limit; + const shortName = firstRateLimitWithMultipleLimits.name; + const longName = secondRateLimitWithMultipleLimits.name; + const shortBlock = firstRateLimitWithMultipleLimits.blockDuration; + const longBlock = secondRateLimitWithMultipleLimits.blockDuration; + + let requestsMade = 0; + // note(Lauris): exhaust short limit to have remaining 0 for it + for (let i = 1; i <= shortLimit; i++) { + const response = await request(app.getHttpServer()) + .get("/v2/me") + .set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` }) + .expect(200); + + requestsMade++; + + expect(response.headers[`x-ratelimit-limit-${shortName}`]).toBe(shortLimit.toString()); + expect(response.headers[`x-ratelimit-remaining-${shortName}`]).toBe((shortLimit - i).toString()); + expect(Number(response.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThan(0); + + expect(response.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString()); + expect(response.headers[`x-ratelimit-remaining-${longName}`]).toBe((longLimit - i).toString()); + expect(Number(response.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan(0); + } + + // note(Lauris): short limit exhausted, now exhaust long limit to have remaining 0 for it + for (let i = requestsMade; i < longLimit; i++) { + const responseAfterShortLimit = await request(app.getHttpServer()) + .get("/v2/me") + .set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` }) + .expect(200); + + requestsMade++; + + expect(responseAfterShortLimit.headers[`x-ratelimit-limit-${shortName}`]).toBe( + shortLimit.toString() + ); + expect(responseAfterShortLimit.headers[`x-ratelimit-remaining-${shortName}`]).toBe("0"); + expect(Number(responseAfterShortLimit.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThan( + 0 + ); + + expect(responseAfterShortLimit.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString()); + expect(responseAfterShortLimit.headers[`x-ratelimit-remaining-${longName}`]).toBe( + (longLimit - requestsMade).toString() + ); + expect(Number(responseAfterShortLimit.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan(0); + } + + // note(Lauris): both have remaining 0 so now exceed both + const blockedResponseLong = await request(app.getHttpServer()) + .get("/v2/me") + .set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` }) + .expect(429); + + expect(blockedResponseLong.headers[`x-ratelimit-limit-${shortName}`]).toBe(shortLimit.toString()); + expect(blockedResponseLong.headers[`x-ratelimit-remaining-${shortName}`]).toBe("0"); + expect(Number(blockedResponseLong.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThanOrEqual( + firstRateLimitWithMultipleLimits.blockDuration / 1000 + ); + + expect(blockedResponseLong.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString()); + expect(blockedResponseLong.headers[`x-ratelimit-remaining-${longName}`]).toBe("0"); + expect(Number(blockedResponseLong.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThanOrEqual( + secondRateLimitWithMultipleLimits.blockDuration / 1000 + ); + + // note(Lauris): wait for short limit to reset + await new Promise((resolve) => setTimeout(resolve, shortBlock)); + const responseAfterShortLimitReload = await request(app.getHttpServer()) + .get("/v2/me") + .set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` }) + .expect(200); + expect(responseAfterShortLimitReload.headers[`x-ratelimit-limit-${shortName}`]).toBe( + shortLimit.toString() + ); + expect(responseAfterShortLimitReload.headers[`x-ratelimit-remaining-${shortName}`]).toBe( + (shortLimit - 1).toString() + ); + expect( + Number(responseAfterShortLimitReload.headers[`x-ratelimit-reset-${shortName}`]) + ).toBeGreaterThan(0); + expect(responseAfterShortLimitReload.headers[`x-ratelimit-limit-${longName}`]).toBe( + longLimit.toString() + ); + expect(responseAfterShortLimitReload.headers[`x-ratelimit-remaining-${longName}`]).toBe( + (longLimit - requestsMade).toString() + ); + expect( + Number(responseAfterShortLimitReload.headers[`x-ratelimit-reset-${longName}`]) + ).toBeGreaterThan(0); + + // note(Lauris): wait for long limit to reset + await new Promise((resolve) => setTimeout(resolve, longBlock)); + const responseAfterLongLimitReload = await request(app.getHttpServer()) + .get("/v2/me") + .set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` }) + .expect(200); + expect(responseAfterLongLimitReload.headers[`x-ratelimit-limit-${shortName}`]).toBe( + shortLimit.toString() + ); + expect(responseAfterLongLimitReload.headers[`x-ratelimit-remaining-${shortName}`]).toBe( + (shortLimit - 1).toString() + ); + expect( + Number(responseAfterLongLimitReload.headers[`x-ratelimit-reset-${shortName}`]) + ).toBeGreaterThan(0); + expect(responseAfterLongLimitReload.headers[`x-ratelimit-limit-${longName}`]).toBe( + longLimit.toString() + ); + expect(responseAfterLongLimitReload.headers[`x-ratelimit-remaining-${longName}`]).toBe( + (longLimit - 1).toString() + ); + expect(Number(responseAfterLongLimitReload.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan( + 0 + ); + }, + 30 * 1000 + ); + + it( + "non api key with default rate limit - should enforce rate limits and reset after block duration", + async () => { + const limit = mockDefaultLimit; + const blockDuration = mockDefaultBlockDuration; + + for (let i = 1; i <= limit; i++) { + const response = await request(app.getHttpServer()) + .get("/v2/me") + .set(X_CAL_CLIENT_ID, oAuthClient.id) + .set(X_CAL_SECRET_KEY, oAuthClient.secret) + .expect(200); + + expect(response.headers["x-ratelimit-limit-default"]).toBe(limit.toString()); + expect(response.headers["x-ratelimit-remaining-default"]).toBe((limit - i).toString()); + expect(Number(response.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0); + } + + const blockedResponse = await request(app.getHttpServer()) + .get("/v2/me") + .set(X_CAL_CLIENT_ID, oAuthClient.id) + .set(X_CAL_SECRET_KEY, oAuthClient.secret) + .expect(429); + + expect(blockedResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString()); + expect(blockedResponse.headers["x-ratelimit-remaining-default"]).toBe("0"); + expect(Number(blockedResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThanOrEqual( + blockDuration / 1000 + ); + + await new Promise((resolve) => setTimeout(resolve, blockDuration)); + + const afterBlockResponse = await request(app.getHttpServer()) + .get("/v2/me") + .set(X_CAL_CLIENT_ID, oAuthClient.id) + .set(X_CAL_SECRET_KEY, oAuthClient.secret) + .expect(200); + + expect(afterBlockResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString()); + expect(afterBlockResponse.headers["x-ratelimit-remaining-default"]).toBe((limit - 1).toString()); + expect(Number(afterBlockResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0); + }, + 15 * 1000 + ); + + afterAll(async () => { + await userRepositoryFixture.deleteByEmail(userEmail); + await organizationsRepositoryFixture.delete(organization.id); + await app.close(); + }); }); }); diff --git a/apps/api/v2/src/app.module.ts b/apps/api/v2/src/app.module.ts index 77533de5cd..e4c1693753 100644 --- a/apps/api/v2/src/app.module.ts +++ b/apps/api/v2/src/app.module.ts @@ -13,12 +13,12 @@ import { JwtModule } from "@/modules/jwt/jwt.module"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { RedisModule } from "@/modules/redis/redis.module"; import { RedisService } from "@/modules/redis/redis.service"; +import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis"; import { BullModule } from "@nestjs/bull"; import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core"; import { seconds, ThrottlerModule } from "@nestjs/throttler"; -import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis"; import { AppController } from "./app.controller"; @@ -34,16 +34,19 @@ import { AppController } from "./app.controller"; BullModule.forRoot({ redis: `${process.env.REDIS_URL}${process.env.NODE_ENV === "production" ? "?tls=true" : ""}`, }), - // Rate limiting here is handled by the CustomThrottlerGuard ThrottlerModule.forRootAsync({ imports: [RedisModule], inject: [RedisService], useFactory: (redisService: RedisService) => ({ + // note(Lauris): IMPORTANT: rate limiting is enforced by CustomThrottlerGuard, but we need to have at least one + // entry in the throttlers array otherwise CustomThrottlerGuard is not invoked at all. If we specify only ThrottlerModule + // without .forRootAsync then throttler options are not passed to CustomThrottlerGuard containing redis connection etc. + // So we need to specify at least one dummy throttler here and CustomThrottlerGuard is actually handling the default and custom rate limits. throttlers: [ { - name: "long", - ttl: seconds(60), // Time to live for the long period in seconds - limit: 120, // Maximum number of requests within the long ttl + name: "dummy", + ttl: seconds(60), + limit: 120, }, ], storage: new ThrottlerStorageRedisService(redisService.redis), @@ -56,6 +59,13 @@ import { AppController } from "./app.controller"; ], controllers: [AppController], providers: [ + { + provide: ThrottlerStorageRedisService, + useFactory: (redisService: RedisService) => { + return new ThrottlerStorageRedisService(redisService.redis); + }, + inject: [RedisService], + }, { provide: APP_INTERCEPTOR, useClass: ResponseInterceptor, diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_04_15/controllers/schedules.controller.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/controllers/schedules.controller.ts index 803fb9c880..ad6c3dffc9 100644 --- a/apps/api/v2/src/ee/schedules/schedules_2024_04_15/controllers/schedules.controller.ts +++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/controllers/schedules.controller.ts @@ -23,8 +23,7 @@ import { Patch, UseGuards, } from "@nestjs/common"; -import { ApiResponse, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger"; -import { Throttle } from "@nestjs/throttler"; +import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger"; import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types"; @@ -73,7 +72,6 @@ export class SchedulesController_2024_04_15 { @Get("/:scheduleId") @Permissions([SCHEDULE_READ]) - @Throttle({ default: { limit: 10, ttl: 60000 } }) // allow 10 requests per minute (for :scheduleId) async getSchedule( @GetUser() user: UserWithProfile, @Param("scheduleId") scheduleId: number diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.ts b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.ts index a76bc40bdf..a194754919 100644 --- a/apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.ts +++ b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.ts @@ -18,7 +18,6 @@ import { UseGuards, } from "@nestjs/common"; import { ApiHeader, ApiOperation, ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger"; -import { Throttle } from "@nestjs/throttler"; import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants"; import { @@ -105,7 +104,6 @@ export class SchedulesController_2024_06_11 { @Get("/:scheduleId") @Permissions([SCHEDULE_READ]) - @Throttle({ default: { limit: 10, ttl: 60000 } }) // allow 10 requests per minute (for :scheduleId) @ApiOperation({ summary: "Get a schedule" }) async getSchedule( @GetUser() user: UserWithProfile, diff --git a/apps/api/v2/src/env.ts b/apps/api/v2/src/env.ts index e2431dd549..af0fc72f62 100644 --- a/apps/api/v2/src/env.ts +++ b/apps/api/v2/src/env.ts @@ -20,6 +20,9 @@ export type Environment = { GET_LICENSE_KEY_URL: string; API_KEY_PREFIX: string; DOCS_URL: string; + RATE_LIMIT_DEFAULT_TTL_MS: number; + RATE_LIMIT_DEFAULT_LIMIT: number; + RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS: number; }; export const getEnv = (key: K, fallback?: Environment[K]): Environment[K] => { diff --git a/apps/api/v2/src/lib/throttler-guard.ts b/apps/api/v2/src/lib/throttler-guard.ts index a54d3a0c85..09249a7366 100644 --- a/apps/api/v2/src/lib/throttler-guard.ts +++ b/apps/api/v2/src/lib/throttler-guard.ts @@ -1,42 +1,191 @@ -import { isApiKey } from "@/lib/api-key"; -import { Injectable, Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; +import { getEnv } from "@/env"; +import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis"; +import { Inject, Injectable, Logger, UnauthorizedException } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; -import { ThrottlerGuard, ThrottlerModuleOptions, ThrottlerStorage } from "@nestjs/throttler"; -import { Request } from "express"; +import { + ThrottlerGuard, + ThrottlerException, + ThrottlerRequest, + ThrottlerModuleOptions, + seconds, +} from "@nestjs/throttler"; +import { Request, Response } from "express"; +import { z } from "zod"; import { X_CAL_CLIENT_ID } from "@calcom/platform-constants"; +const rateLimitSchema = z.object({ + name: z.string(), + limit: z.number(), + ttl: z.number(), + blockDuration: z.number(), +}); + +type RateLimitType = z.infer; + +const rateLimitsSchema = z.array(rateLimitSchema); + @Injectable() export class CustomThrottlerGuard extends ThrottlerGuard { private logger = new Logger("CustomThrottlerGuard"); + private defaultTttl = Number(getEnv("RATE_LIMIT_DEFAULT_TTL_MS", 60 * 1000)); + private defaultLimit = Number(getEnv("RATE_LIMIT_DEFAULT_LIMIT", 120)); + private defaultBlockDuration = Number(getEnv("RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS", 60 * 1000)); + constructor( options: ThrottlerModuleOptions, - storageService: ThrottlerStorage, + @Inject(ThrottlerStorageRedisService) protected readonly storageService: ThrottlerStorageRedisService, reflector: Reflector, - private readonly config: ConfigService + private readonly dbRead: PrismaReadService ) { super(options, storageService, reflector); + this.storageService = storageService; + } + + protected async handleRequest(requestProps: ThrottlerRequest): Promise { + const { context } = requestProps; + + const request = context.switchToHttp().getRequest(); + const response = context.switchToHttp().getResponse(); + const tracker = await this.getTracker(request); + + if (tracker.startsWith("api_key_")) { + return this.handleApiKeyRequest(tracker, response); + } else { + return this.handleNonApiKeyRequest(tracker, response); + } + } + + private async handleApiKeyRequest(tracker: string, response: Response): Promise { + const rateLimits = await this.getRateLimitsForApiKeyTracker(tracker); + + let allLimitsBlocked = true; + for (const rateLimit of rateLimits) { + const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response); + if (!isBlocked) { + allLimitsBlocked = false; + } + } + + if (allLimitsBlocked) { + throw new ThrottlerException("Too many requests. Please try again later."); + } + + return true; + } + + private async handleNonApiKeyRequest(tracker: string, response: Response): Promise { + const rateLimit = this.getDefaultRateLimit(); + + const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response); + if (isBlocked) { + throw new ThrottlerException("Too many requests. Please try again later."); + } + + return true; + } + + private getDefaultRateLimit() { + return { + name: "default", + limit: this.getDefaultLimit(), + ttl: this.getDefaultTtl(), + blockDuration: this.getDefaultBlockDuration(), + }; + } + + getDefaultLimit() { + return this.defaultLimit; + } + + getDefaultTtl() { + return this.defaultTttl; + } + + getDefaultBlockDuration() { + return this.defaultBlockDuration; + } + + private async getRateLimitsForApiKeyTracker(tracker: string) { + const cacheKey = `rate_limit:${tracker}`; + + const cachedRateLimits = await this.storageService.redis.get(cacheKey); + if (cachedRateLimits) { + return rateLimitsSchema.parse(JSON.parse(cachedRateLimits)); + } + + const apiKey = tracker.replace("api_key_", ""); + let rateLimits: RateLimitType[]; + const apiKeyRecord = await this.dbRead.prisma.apiKey.findUnique({ + where: { hashedKey: apiKey }, + select: { id: true }, + }); + + if (!apiKeyRecord) { + throw new UnauthorizedException("Invalid API Key"); + } + + rateLimits = await this.dbRead.prisma.rateLimit.findMany({ + where: { apiKeyId: apiKeyRecord.id }, + select: { name: true, limit: true, ttl: true, blockDuration: true }, + }); + + if (!rateLimits || rateLimits.length === 0) { + rateLimits = [this.getDefaultRateLimit()]; + } + + await this.storageService.redis.setex(cacheKey, 3600, JSON.stringify(rateLimits)); + + return rateLimits; + } + + private async incrementRateLimit(tracker: string, rateLimit: RateLimitType, response: Response) { + const { name, limit, ttl, blockDuration } = rateLimit; + + const key = `${tracker}:${limit}:${ttl}`; + + const { isBlocked, totalHits, timeToExpire, timeToBlockExpire } = await this.storageService.increment( + key, + ttl, + limit, + blockDuration, + name + ); + + const nameFirstUpper = name.charAt(0).toUpperCase() + name.slice(1); + response.setHeader(`X-RateLimit-Limit-${nameFirstUpper}`, limit); + response.setHeader( + `X-RateLimit-Remaining-${nameFirstUpper}`, + timeToBlockExpire ? 0 : Math.max(0, limit - totalHits) + ); + response.setHeader(`X-RateLimit-Reset-${nameFirstUpper}`, timeToBlockExpire || timeToExpire); + + this.logger.log(`Rate limit for ${tracker} incremented. Remaining: ${limit - totalHits}`); + + return { isBlocked }; } protected async getTracker(request: Request): Promise { const authorizationHeader = request.get("Authorization")?.replace("Bearer ", ""); if (authorizationHeader) { - return isApiKey(authorizationHeader, this.config.get("api.apiKeyPrefix") ?? "cal_") - ? `api_key_${authorizationHeader}` + const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_"); + return isApiKey(authorizationHeader, apiKeyPrefix) + ? `api_key_${hashAPIKey(stripApiKey(authorizationHeader, apiKeyPrefix))}` : `access_token_${authorizationHeader}`; } const oauthClientId = request.get(X_CAL_CLIENT_ID); if (oauthClientId) { - return oauthClientId; + return `oauth_client_${oauthClientId}`; } if (request.ip) { - return request.ip; + return `ip_${request.ip}`; } this.logger.log(`no tracker found: ${request.url}`); diff --git a/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.spec.ts b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.spec.ts index a8869436ce..88efa362c1 100644 --- a/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.spec.ts +++ b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.spec.ts @@ -1,3 +1,4 @@ +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { createMock } from "@golevelup/ts-jest"; import { ExecutionContext } from "@nestjs/common"; @@ -26,7 +27,8 @@ describe("PermissionsGuard", () => { return null; } }), - }) + }), + createMock() ); }); @@ -38,7 +40,7 @@ describe("PermissionsGuard", () => { it("should return false", async () => { const mockContext = createMockExecutionContext({}); jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]); - jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(0); + jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(0); await expect(guard.canActivate(mockContext)).resolves.toBe(false); }); @@ -51,7 +53,7 @@ describe("PermissionsGuard", () => { let oAuthClientPermissions = 0; oAuthClientPermissions |= SCHEDULE_WRITE; - jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions); + jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions); await expect(guard.canActivate(mockContext)).resolves.toBe(true); }); @@ -62,7 +64,7 @@ describe("PermissionsGuard", () => { let oAuthClientPermissions = 0; oAuthClientPermissions |= SCHEDULE_WRITE; oAuthClientPermissions |= SCHEDULE_READ; - jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions); + jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions); await expect(guard.canActivate(mockContext)).resolves.toBe(true); }); @@ -73,7 +75,7 @@ describe("PermissionsGuard", () => { let oAuthClientPermissions = 0; oAuthClientPermissions |= SCHEDULE_WRITE; - jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions); + jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions); await expect(guard.canActivate(mockContext)).resolves.toBe(true); }); @@ -83,7 +85,7 @@ describe("PermissionsGuard", () => { let oAuthClientPermissions = 0; oAuthClientPermissions |= APPS_WRITE; - jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions); + jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions); await expect(guard.canActivate(mockContext)).resolves.toBe(false); }); @@ -94,7 +96,63 @@ describe("PermissionsGuard", () => { let oAuthClientPermissions = 0; oAuthClientPermissions |= SCHEDULE_WRITE; - jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions); + jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions); + + await expect(guard.canActivate(mockContext)).resolves.toBe(false); + }); + }); + + describe("when oauth id is provided", () => { + it("should return true for valid permissions", async () => { + const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" }); + jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]); + + let oAuthClientPermissions = 0; + oAuthClientPermissions |= SCHEDULE_WRITE; + jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions); + await expect(guard.canActivate(mockContext)).resolves.toBe(true); + }); + + it("should return true for multiple valid permissions", async () => { + const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" }); + jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE, SCHEDULE_READ]); + + let oAuthClientPermissions = 0; + oAuthClientPermissions |= SCHEDULE_WRITE; + oAuthClientPermissions |= SCHEDULE_READ; + jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions); + + await expect(guard.canActivate(mockContext)).resolves.toBe(true); + }); + + it("should return true for empty Permissions decorator", async () => { + const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" }); + jest.spyOn(reflector, "get").mockReturnValue([]); + + let oAuthClientPermissions = 0; + oAuthClientPermissions |= SCHEDULE_WRITE; + jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions); + await expect(guard.canActivate(mockContext)).resolves.toBe(true); + }); + + it("should return false for invalid permissions", async () => { + const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" }); + jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]); + + let oAuthClientPermissions = 0; + oAuthClientPermissions |= APPS_WRITE; + jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions); + + await expect(guard.canActivate(mockContext)).resolves.toBe(false); + }); + + it("should return false for a missing permission", async () => { + const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" }); + jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE, SCHEDULE_READ]); + + let oAuthClientPermissions = 0; + oAuthClientPermissions |= SCHEDULE_WRITE; + jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions); await expect(guard.canActivate(mockContext)).resolves.toBe(false); }); diff --git a/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts index 2a427d08df..176bab52a8 100644 --- a/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts +++ b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts @@ -1,11 +1,13 @@ import { isApiKey } from "@/lib/api-key"; import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Reflector } from "@nestjs/core"; import { getToken } from "next-auth/jwt"; +import { X_CAL_CLIENT_ID } from "@calcom/platform-constants"; import { hasPermissions } from "@calcom/platform-utils"; @Injectable() @@ -13,7 +15,8 @@ export class PermissionsGuard implements CanActivate { constructor( private reflector: Reflector, private tokensRepository: TokensRepository, - private readonly config: ConfigService + private readonly config: ConfigService, + private readonly oAuthClientRepository: OAuthClientRepository ) {} async canActivate(context: ExecutionContext): Promise { @@ -27,12 +30,13 @@ export class PermissionsGuard implements CanActivate { const authString = request.get("Authorization")?.replace("Bearer ", ""); const nextAuthSecret = this.config.get("next.authSecret", { infer: true }); const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret }); + const oAuthClientId = request.params?.clientId || request.get(X_CAL_CLIENT_ID); if (nextAuthToken) { return true; } - if (!authString) { + if (!authString && !oAuthClientId) { return false; } @@ -41,7 +45,9 @@ export class PermissionsGuard implements CanActivate { return true; } - const oAuthClientPermissions = await this.getOAuthClientPermissions(authString); + const oAuthClientPermissions = authString + ? await this.getOAuthClientPermissionsByAccessToken(authString) + : await this.getOAuthClientPermissionsById(oAuthClientId); if (!oAuthClientPermissions) { return false; @@ -50,8 +56,13 @@ export class PermissionsGuard implements CanActivate { return hasPermissions(oAuthClientPermissions, [...requiredPermissions]); } - async getOAuthClientPermissions(accessToken: string) { + async getOAuthClientPermissionsByAccessToken(accessToken: string) { const oAuthClient = await this.tokensRepository.getAccessTokenClient(accessToken); return oAuthClient?.permissions; } + + async getOAuthClientPermissionsById(id: string) { + const oAuthClient = await this.oAuthClientRepository.getOAuthClient(id); + return oAuthClient?.permissions; + } } diff --git a/apps/api/v2/src/modules/slots/controllers/slots.controller.ts b/apps/api/v2/src/modules/slots/controllers/slots.controller.ts index dbee660a2f..fa0cca5341 100644 --- a/apps/api/v2/src/modules/slots/controllers/slots.controller.ts +++ b/apps/api/v2/src/modules/slots/controllers/slots.controller.ts @@ -2,7 +2,6 @@ import { API_VERSIONS_VALUES } from "@/lib/api-versions"; import { SlotsService } from "@/modules/slots/services/slots.service"; import { Query, Body, Controller, Get, Delete, Post, Req, Res } from "@nestjs/common"; import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; -import { Throttle, seconds } from "@nestjs/throttler"; import { Response as ExpressResponse, Request as ExpressRequest } from "express"; import { SUCCESS_STATUS } from "@calcom/platform-constants"; @@ -52,7 +51,6 @@ export class SlotsController { @Get("/available") @ApiOperation({ summary: "Get available slots" }) - @Throttle({ default: { limit: 300, ttl: seconds(60) } }) // allow 300 requests per minute async getAvailableSlots( @Query() query: GetAvailableSlotsInput, @Req() req: ExpressRequest diff --git a/apps/api/v2/test/fixtures/repository/rate-limit.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/rate-limit.repository.fixture.ts new file mode 100644 index 0000000000..2c0672b5d8 --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/rate-limit.repository.fixture.ts @@ -0,0 +1,22 @@ +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; + +export class RateLimitRepositoryFixture { + private dbWrite: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.dbWrite = module.get(PrismaWriteService).prisma; + } + + async createRateLimit(name: string, apiKeyId: string, ttl: number, limit: number, blockDuration: number) { + return await this.dbWrite.rateLimit.create({ + data: { + name, + apiKeyId, + ttl, + limit, + blockDuration, + }, + }); + } +} diff --git a/apps/api/v2/test/setEnvVars.ts b/apps/api/v2/test/setEnvVars.ts index 1f50272d84..2aa6f8672c 100644 --- a/apps/api/v2/test/setEnvVars.ts +++ b/apps/api/v2/test/setEnvVars.ts @@ -16,6 +16,10 @@ const env: Partial> = { API_KEY_PREFIX: "cal_test_", GET_LICENSE_KEY_URL: " https://console.cal.com/api/license", CALCOM_LICENSE_KEY: "c4234812-12ab-42s6-a1e3-55bedd4a5bb7", + RATE_LIMIT_DEFAULT_TTL_MS: 60000, + // note(Lauris): setting high limit so that e2e tests themselves are not rate limited + RATE_LIMIT_DEFAULT_LIMIT: 10000, + RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS: 60000, }; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore diff --git a/packages/prisma/migrations/20241001091544_add_api_key_rate_limit/migration.sql b/packages/prisma/migrations/20241001091544_add_api_key_rate_limit/migration.sql new file mode 100644 index 0000000000..31da587190 --- /dev/null +++ b/packages/prisma/migrations/20241001091544_add_api_key_rate_limit/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "RateLimit" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "apiKeyId" TEXT NOT NULL, + "ttl" INTEGER NOT NULL, + "limit" INTEGER NOT NULL, + "blockDuration" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "RateLimit_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "RateLimit_apiKeyId_idx" ON "RateLimit"("apiKeyId"); + +-- AddForeignKey +ALTER TABLE "RateLimit" ADD CONSTRAINT "RateLimit_apiKeyId_fkey" FOREIGN KEY ("apiKeyId") REFERENCES "ApiKey"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 0fa9000395..fb36379a41 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -803,22 +803,38 @@ model Impersonations { } model ApiKey { - id String @id @unique @default(cuid()) + id String @id @unique @default(cuid()) userId Int teamId Int? note String? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) expiresAt DateTime? lastUsedAt DateTime? - hashedKey String @unique() - user User? @relation(fields: [userId], references: [id], onDelete: Cascade) - team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) - app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) + hashedKey String @unique() + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + app App? @relation(fields: [appId], references: [slug], onDelete: Cascade) appId String? + rateLimits RateLimit[] @@index([userId]) } +model RateLimit { + id String @id @default(uuid()) + name String + apiKeyId String + ttl Int + limit Int + blockDuration Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade) + + @@index([apiKeyId]) +} + model HashedLink { id Int @id @default(autoincrement()) link String @unique() diff --git a/yarn.lock b/yarn.lock index fab6280dd6..7879990320 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4134,13 +4134,14 @@ __metadata: dependencies: "@calcom/platform-constants": "*" "@calcom/platform-enums": "*" - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.41" + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.43" "@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2" "@calcom/platform-types": "*" "@calcom/platform-utils": "*" "@calcom/prisma": "*" "@golevelup/ts-jest": ^0.4.0 "@microsoft/microsoft-graph-types-beta": ^0.42.0-preview + "@nest-lab/throttler-storage-redis": 1.0.0 "@nestjs/bull": ^10.1.1 "@nestjs/cli": ^10.0.0 "@nestjs/common": ^10.0.0 @@ -4152,7 +4153,7 @@ __metadata: "@nestjs/schematics": ^10.0.0 "@nestjs/swagger": ^7.3.0 "@nestjs/testing": ^10.0.0 - "@nestjs/throttler": ^5.1.2 + "@nestjs/throttler": 6.2.1 "@sentry/node": ^8.8.0 "@types/cookie-parser": ^1.4.6 "@types/express": ^4.17.21 @@ -4175,7 +4176,6 @@ __metadata: jest: ^29.7.0 luxon: ^3.4.4 nest-winston: ^1.9.4 - nestjs-throttler-storage-redis: ^0.4.1 next-auth: ^4.22.1 passport: ^0.7.0 passport-jwt: ^4.0.1 @@ -4376,6 +4376,15 @@ __metadata: languageName: unknown linkType: soft +"@calcom/bolna@workspace:packages/app-store/bolna": + version: 0.0.0-use.local + resolution: "@calcom/bolna@workspace:packages/app-store/bolna" + dependencies: + "@calcom/lib": "*" + "@calcom/types": "*" + languageName: unknown + linkType: soft + "@calcom/caldavcalendar@workspace:packages/app-store/caldavcalendar": version: 0.0.0-use.local resolution: "@calcom/caldavcalendar@workspace:packages/app-store/caldavcalendar" @@ -5121,14 +5130,14 @@ __metadata: languageName: node linkType: hard -"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.41": - version: 0.0.41 - resolution: "@calcom/platform-libraries@npm:0.0.41" +"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.43": + version: 0.0.43 + resolution: "@calcom/platform-libraries@npm:0.0.43" dependencies: "@calcom/core": "*" "@calcom/features": "*" "@calcom/lib": "*" - checksum: a4052828549f0705d585647a0962a82cd09643161c71c066e66f6ce71dd26f9a5912ce09270256e8c99d5b5fc49b8f812ac903c623ff9840c0ebf0295632b261 + checksum: c9a6ede11ddd5ec413aacc6e8f6624446d964a60a303469fce329c9a925d8a436931ce91b3d1dfa943c8e8740d7b041e5b0522dfc387c8309008cb255d391b5c languageName: node linkType: hard @@ -9483,6 +9492,32 @@ __metadata: languageName: node linkType: hard +"@nest-lab/throttler-storage-redis@npm:1.0.0": + version: 1.0.0 + resolution: "@nest-lab/throttler-storage-redis@npm:1.0.0" + dependencies: + tslib: ^2.3.0 + peerDependencies: + "@nestjs/common": ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + "@nestjs/core": ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + "@nestjs/throttler": ">=6.0.0" + ioredis: ">=5.0.0" + reflect-metadata: ^0.2.1 + peerDependenciesMeta: + "@nestjs/common": + optional: false + "@nestjs/core": + optional: false + "@nestjs/throttler": + optional: false + ioredis: + optional: false + reflect-metadata: + optional: false + checksum: ed6913f789e73a94370547c6fc1336d582e56b635dda951f18be297508cf732849cf45190e19df081c3849aede7cc5fe3940bd43373eb33201e91363651e19c8 + languageName: node + linkType: hard + "@nestjs/bull-shared@npm:^10.1.1": version: 10.1.1 resolution: "@nestjs/bull-shared@npm:10.1.1" @@ -9730,14 +9765,14 @@ __metadata: languageName: node linkType: hard -"@nestjs/throttler@npm:^5.1.2": - version: 5.1.2 - resolution: "@nestjs/throttler@npm:5.1.2" +"@nestjs/throttler@npm:6.2.1": + version: 6.2.1 + resolution: "@nestjs/throttler@npm:6.2.1" peerDependencies: "@nestjs/common": ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 "@nestjs/core": ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 reflect-metadata: ^0.1.13 || ^0.2.0 - checksum: 181c99fca712be09ce9264e0b3f505f450d07d19cdacd58ca2e66c9f29e143ac3a8051a3cdc0b928f162ae0c0b14d40fc68752434d46c9b25e555974a1c8c1a1 + checksum: 6634812aaba2db9fd7edd1200d051447d2dc33dca8318a14d517f02b103fd2a3e4a41842c006f8f5218b757da20eff0d6f472b0455d3a25d3a8758eed96552ae languageName: node linkType: hard @@ -36532,19 +36567,6 @@ __metadata: languageName: node linkType: hard -"nestjs-throttler-storage-redis@npm:^0.4.1": - version: 0.4.3 - resolution: "nestjs-throttler-storage-redis@npm:0.4.3" - peerDependencies: - "@nestjs/common": ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 - "@nestjs/core": ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 - "@nestjs/throttler": ">=5.0.0" - ioredis: ">=5.0.0" - reflect-metadata: ^0.2.1 - checksum: 72178c70aff73a554701f3d92e5d80dc67f50eedb92200bacfad9542d067d3bbdb4583210f7010a4fd18c77864a5d58bea8103d45f0b0c0c53c93bf9630b49f8 - languageName: node - linkType: hard - "netmask@npm:^2.0.2": version: 2.0.2 resolution: "netmask@npm:2.0.2"