chore: system wide ratelimit per path (#25080)

* chore: Add system wide rate limiting

* Handle and convert HttpError to 429

* Make algo 32-bit to prevent <ES2020 error + fix sms manager unit test

* Change core to common to go from 10 requests per minute to 200

* Remove redundant function

* Fix integration tests

* Make sure we allow all legal POST routes

* Allow tRPC post calls

* Add matcher tests on middleware

* Add matcher tests on middleware

* Fix matcher to not use regex

* Fix missing POST allow rule for /api/auth/callback/credentials

* Missed the api/book/event endpoints

* Add missing pages/api routes

* Remove POST middleware for now, very risky

* Remove tests for POST protection

---------

Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
This commit is contained in:
Alex van Andel
2025-11-12 22:01:59 +00:00
committed by GitHub
co-authored by Volnei Munhoz
parent a3fc17bc75
commit 1d6959c4ac
11 changed files with 251 additions and 160 deletions
+72 -48
View File
@@ -9,6 +9,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
import { checkPostMethod } from "./middleware";
// We'll test the wrapped middleware as it would be used in production
import middleware from "./middleware";
import { config } from "./middleware";
// Mock dependencies at module level
vi.mock("@vercel/edge-config", () => ({
@@ -16,6 +17,7 @@ vi.mock("@vercel/edge-config", () => ({
}));
vi.mock("next-collect/server", () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
collectEvents: vi.fn((config: any) => config.middleware),
}));
@@ -29,11 +31,13 @@ vi.mock("next/server", async () => {
const actual = await vi.importActual<typeof import("next/server")>("next/server");
// Create a NextResponse constructor that returns Response objects
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const NextResponse = function (body: any, init?: ResponseInit) {
return new Response(body, init);
};
// Add static methods
// eslint-disable-next-line @typescript-eslint/no-explicit-any
NextResponse.json = (body: any, init?: ResponseInit) => {
return new Response(JSON.stringify(body), {
...init,
@@ -55,6 +59,7 @@ vi.mock("next/server", async () => {
});
// Add cookies property
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(response as any).cookies = {
delete: vi.fn(),
set: vi.fn(),
@@ -91,6 +96,7 @@ vi.mock("next/server", async () => {
});
// Add cookies property
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(response as any).cookies = {
delete: vi.fn(),
set: vi.fn(),
@@ -128,6 +134,7 @@ const createTestRequest = (overrides?: {
return req;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createEdgeConfigMock = (config: Record<string, any>) => {
return (key: string) => {
if (key in config) return Promise.resolve(config[key]);
@@ -147,6 +154,7 @@ const expectStatus = (res: Response, status: number) => {
// Wrapper for middleware calls to handle type casting
const callMiddleware = async (req: NextRequest): Promise<Response> => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (await (middleware as any)(req)) as Response;
};
@@ -165,15 +173,6 @@ describe("Middleware - POST requests restriction", () => {
expect(res1).toBeNull();
});
it("should block POST requests to not-allowed app routes", async () => {
const req = createRequest("/team/xyz", "POST");
const res = checkPostMethod(req);
expect(res).not.toBeNull();
expect(res?.status).toBe(405);
expect(res?.statusText).toBe("Method Not Allowed");
expect(res?.headers.get("Allow")).toBe("GET");
});
it("should allow GET requests to app routes", async () => {
const req = createRequest("/team/xyz", "GET");
const res = checkPostMethod(req);
@@ -213,29 +212,6 @@ describe("Middleware Integration Tests", () => {
});
});
describe("POST Method Protection", () => {
it("should allow POST to /api/auth/signup", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/api/auth/signup`,
method: "POST",
});
const res = await callMiddleware(req);
expectStatus(res, 200);
});
it("should block POST to regular routes", async () => {
const req = createTestRequest({
url: `${WEBAPP_URL}/team/test`,
method: "POST",
});
const res = await callMiddleware(req);
expectStatus(res, 405);
expect(getHeader(res, "Allow")).toBe("GET");
});
});
describe("Maintenance Mode", () => {
it("should redirect to maintenance when enabled", async () => {
(edgeConfigGet as Mock).mockImplementation(
@@ -430,22 +406,6 @@ describe("Middleware Integration Tests", () => {
});
describe("Multiple Features", () => {
it("should handle POST protection before maintenance mode", async () => {
(edgeConfigGet as Mock).mockImplementation(
createEdgeConfigMock({
isInMaintenanceMode: true,
})
);
const req = createTestRequest({
url: `${WEBAPP_URL}/team/test`,
method: "POST",
});
const res = await callMiddleware(req);
// POST protection should trigger first
expectStatus(res, 405);
});
it("should handle embed route with routing forms rewrite", async () => {
const req = createTestRequest({
@@ -483,3 +443,67 @@ describe("Middleware Integration Tests", () => {
});
});
});
describe("Middleware Matcher - Comprehensive Coverage", () => {
const matcher = config.matcher[0];
const pattern = matcher.replace(/^\/|\/$/g, "");
const regex = new RegExp(`^/${pattern}`);
const cases = [
// pages & apis
{ path: "/", expected: true, reason: "Root page" },
{ path: "/home", expected: true, reason: "Regular page" },
{ path: "/team/abc", expected: true, reason: "Nested page" },
{ path: "/api/auth/login", expected: true, reason: "API route" },
{ path: "/api/bookings", expected: true, reason: "Top-level API" },
{ path: "/dashboard/settings", expected: true, reason: "Deep nested page" },
{ path: "/user/john/profile", expected: true, reason: "Multiple nested path" },
{ path: "/apps/routing_forms/form", expected: true, reason: "App page under /apps" },
{ path: "/embed?ui.color-scheme=dark", expected: true, reason: "Embed query param" },
// should be ignored (internal / static / public)
{ path: "/_next/static/chunks/app.js", expected: false, reason: "Internal static asset" },
{ path: "/_next/image?url=%2Flogo.png&w=256&q=75", expected: false, reason: "Internal image handler" },
{ path: "/_next/data/build-id/page.json", expected: false, reason: "Next.js data route" },
{ path: "/favicon.ico", expected: false, reason: "Favicon asset" },
{ path: "/robots.txt", expected: false, reason: "Robots file" },
{ path: "/sitemap.xml", expected: false, reason: "Sitemap file" },
{ path: "/public/images/logo.png", expected: false, reason: "Public folder asset" },
{ path: "/public/fonts/inter.woff2", expected: false, reason: "Public folder font" },
{ path: "/static/js/main.js", expected: false, reason: "Static folder JavaScript" },
{ path: "/static/css/app.css", expected: false, reason: "Static folder stylesheet" },
// edge cases
{ path: "/manifest.json", expected: true, reason: "Manifest is a public page, not ignored" },
{ path: "/_nextsomething", expected: true, reason: "Looks like _next but not reserved" },
{ path: "/nextconfig", expected: true, reason: "Normal route with 'next' in name" },
{ path: "/_NEXT/image", expected: true, reason: "Case-sensitive test (should match)" },
{ path: "/favicon-abc.ico", expected: true, reason: "Favicon variant should still match" },
{ path: "/robots-custom.txt", expected: true, reason: "Custom robots file should match" },
{ path: "/sitemap-other.xml", expected: true, reason: "Custom sitemap file should match" },
{ path: "/api_", expected: true, reason: "Partial match with api underscore" },
{ path: "//double-slash", expected: true, reason: "Double slash URL" },
{ path: "/_next", expected: false, reason: "Bare _next path" },
];
it("should match only the intended routes", () => {
for (const { path, expected, reason } of cases) {
const result = regex.test(path);
expect(result, `${path}${reason}`).toBe(expected);
}
});
it("should not accidentally match internal Next.js routes", () => {
const internalPaths = ["/_next/static", "/_next/image", "/_next/data"];
for (const path of internalPaths) {
expect(regex.test(path)).toBe(false);
}
});
it("should match all user-facing routes and APIs", () => {
const publicPaths = ["/", "/api/user", "/settings", "/dashboard"];
for (const path of publicPaths) {
expect(regex.test(path)).toBe(true);
}
});
});
+72 -30
View File
@@ -3,19 +3,74 @@ import { collectEvents } from "next-collect/server";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import getIP from "@calcom/lib/getIP";
import { HttpError } from "@calcom/lib/http-error";
import { piiHasher } from "@calcom/lib/server/PiiHasher";
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry";
import { getCspHeader, getCspNonce } from "@lib/csp";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const safeGet = async <T = any>(key: string): Promise<T | undefined> => {
try {
return get<T>(key);
} catch (error) {
} catch {
// Don't crash if EDGE_CONFIG env var is missing
}
};
export const POST_METHODS_ALLOWED_API_ROUTES = ["/api/auth/signup"];
export const POST_METHODS_ALLOWED_API_ROUTES = [
"/api/auth/forgot-password",
"/api/auth/oauth/me",
"/api/auth/oauth/refreshToken",
"/api/auth/oauth/token",
"/api/auth/reset-password",
"/api/auth/saml/callback",
"/api/auth/saml/token",
"/api/auth/setup",
"/api/auth/signup",
"/api/auth/two-factor/totp/disable",
"/api/auth/two-factor/totp/enable",
"/api/auth/two-factor/totp/setup",
"/api/auth/session",
"/api/availability/calendar",
"/api/cancel",
"/api/cron/bookingReminder",
"/api/cron/calendar-cache-cleanup",
"/api/cron/changeTimeZone",
"/api/cron/checkSmsPrices",
"/api/cron/downgradeUsers",
"/api/cron/monthlyDigestEmail",
"/api/cron/syncAppMeta",
"/api/cron/webhookTriggers",
"/api/cron/workflows/scheduleEmailReminders",
"/api/cron/workflows/scheduleSMSReminders",
"/api/cron/workflows/scheduleWhatsappReminders",
"/api/get-inbound-dynamic-variables",
"/api/integrations/", // for /api/integrations/[...args] and webhooks
"/api/recorded-daily-video",
"/api/router",
"/api/routing-forms/queued-response",
"/api/scim/v2.0/", // /api/scim/v2.0/[...directory]
"/api/support/conversation",
"/api/sync/helpscout",
"/api/twilio/webhook",
"/api/username",
"/api/verify-booking-token",
"/api/video/guest-session",
"/api/webhook/app-credential",
"/api/webhooks/calendar-subscription/", // /api/webhooks/calendar-subscription/[provider]
"/api/webhooks/retell-ai",
"/api/workflows/sms/user-response",
"/api/trpc/", // for tRPC
"/api/auth/callback/", // for NextAuth
"/api/book/event",
"/api/book/instant-event",
"/api/book/recurring-event",
"/availability",
];
export function checkPostMethod(req: NextRequest) {
const pathname = req.nextUrl.pathname;
if (!POST_METHODS_ALLOWED_API_ROUTES.some((route) => pathname.startsWith(route)) && req.method === "POST") {
@@ -30,14 +85,6 @@ export function checkPostMethod(req: NextRequest) {
return null;
}
export function checkStaticFiles(pathname: string) {
const hasFileExtension = /\.(svg|png|jpg|jpeg|gif|webp|ico)$/.test(pathname);
// Skip Next.js internal paths (_next) and static assets
if (pathname.startsWith("/_next") || hasFileExtension) {
return NextResponse.next();
}
}
const isPagePathRequest = (url: URL) => {
const isNonPagePathPrefix = /^\/(?:_next|api)\//;
const isFile = /\..*$/;
@@ -50,11 +97,21 @@ const shouldEnforceCsp = (url: URL) => {
};
const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
const postCheckResult = checkPostMethod(req);
if (postCheckResult) return postCheckResult;
const requestorIp = getIP(req);
try {
await checkRateLimitAndThrowError({
rateLimitingType: "common",
identifier: `${req.nextUrl.pathname}-${piiHasher.hash(requestorIp)}`,
});
} catch (error) {
if (error instanceof HttpError) {
return new NextResponse(error.message, { status: error.statusCode });
}
throw error;
}
const isStaticFile = checkStaticFiles(req.nextUrl.pathname);
if (isStaticFile) return isStaticFile;
// const postCheckResult = checkPostMethod(req);
// if (postCheckResult) return postCheckResult;
const url = req.nextUrl;
const reqWithEnrichedHeaders = enrichRequestWithHeaders({ req });
@@ -168,22 +225,7 @@ function enrichRequestWithHeaders({ req }: { req: NextRequest }) {
}
export const config = {
// Next.js Doesn't support spread operator in config matcher, so, we must list all paths explicitly here.
// https://github.com/vercel/next.js/discussions/42458
// WARNING: DO NOT ADD AN ENDING SLASH "/" TO THE PATHS BELOW
// THIS WILL MAKE THEM NOT MATCH AND HENCE NOT HIT MIDDLEWARE
matcher: [
// Routes to enforce CSP
"/auth/login",
"/login",
// Routes to set cookies
"/apps/installed",
"/auth/logout",
// Embed Routes,
"/:path*/embed",
// API routes
"/api/auth/signup",
],
matcher: ["/((?!_next(?:/|$)|static(?:/|$)|public(?:/|$)|favicon\\.ico$|robots\\.txt$|sitemap\\.xml$).*)"],
};
export default collectEvents({
@@ -2,7 +2,7 @@ import type { NextRequest } from "next/server";
import TwilioClient from "twilio";
import { v4 as uuidv4 } from "uuid";
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
import { checkSMSRateLimit } from "@calcom/lib/smsLockState";
import { WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { setTestSMS } from "@calcom/lib/testSMS";
@@ -85,6 +85,7 @@ export const sendSMS = async ({
}
if (isWhatsapp) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const messageOptions: any = {
contentSid: contentSid,
to: getSMSNumber(phoneNumber, isWhatsapp),
@@ -172,6 +173,7 @@ export const scheduleSMS = async ({
}
if (isWhatsapp) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const messageOptions: any = {
contentSid: contentSid,
to: getSMSNumber(phoneNumber, isWhatsapp),
@@ -225,7 +227,7 @@ export const verifyNumber = async (phoneNumber: string, code: string) => {
.services(process.env.TWILIO_VERIFY_SID)
.verificationChecks.create({ to: phoneNumber, code: code });
return verification_check.status;
} catch (e) {
} catch {
return "failed";
}
}
@@ -265,7 +267,7 @@ async function isLockedForSMSSending(userId?: number | null, teamId?: number | n
(membership) => membership.team.smsLockState === SMSLockState.LOCKED
);
if (!!memberOfLockedTeam) {
if (memberOfLockedTeam) {
return true;
}
@@ -11,7 +11,7 @@ import * as twilio from "@calcom/features/ee/workflows/lib/reminders/providers/t
import type { Workflow, WorkflowStep } from "@calcom/features/ee/workflows/lib/types";
import { getSubmitterEmail } from "@calcom/features/tasker/tasks/triggerFormSubmittedNoEvent/formSubmissionValidation";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
import { checkSMSRateLimit } from "@calcom/lib/smsLockState";
import { SENDER_NAME } from "@calcom/lib/constants";
import { formatCalEventExtended } from "@calcom/lib/formatCalendarEvent";
import { withReporting } from "@calcom/lib/sentryWrapper";
+1 -66
View File
@@ -1,6 +1,3 @@
import { prisma } from "@calcom/prisma";
import { SMSLockState } from "@calcom/prisma/enums";
import { HttpError } from "./http-error";
import type { RateLimitHelper } from "./rateLimit";
import { rateLimiter } from "./rateLimit";
@@ -12,10 +9,8 @@ export async function checkRateLimitAndThrowError({
opts,
}: RateLimitHelper) {
const response = await rateLimiter()({ rateLimitingType, identifier, opts });
const { success, reset } = response;
if (onRateLimiterResponse) onRateLimiterResponse(response);
const { success, reset } = response;
if (!success) {
const convertToSeconds = (ms: number) => Math.floor(ms / 1000);
const secondsToWait = convertToSeconds(reset - Date.now());
@@ -26,63 +21,3 @@ export async function checkRateLimitAndThrowError({
}
return response;
}
export async function checkSMSRateLimit({
rateLimitingType = "sms",
identifier,
onRateLimiterResponse,
opts,
}: RateLimitHelper) {
const response = await rateLimiter()({ rateLimitingType, identifier, opts });
const { success } = response;
if (onRateLimiterResponse) onRateLimiterResponse(response);
if (!success) {
await changeSMSLockState(
identifier,
rateLimitingType === "sms" ? SMSLockState.LOCKED : SMSLockState.REVIEW_NEEDED
);
}
}
async function changeSMSLockState(identifier: string, status: SMSLockState) {
let userId, teamId;
if (identifier.startsWith("sms:user:")) {
userId = Number(identifier.slice(9));
} else if (identifier.startsWith("sms:team:")) {
teamId = Number(identifier.slice(9));
}
if (userId) {
const user = await prisma.user.findUnique({ where: { id: userId, profiles: { none: {} } } });
if (user?.smsLockReviewedByAdmin) return;
await prisma.user.update({
where: {
id: userId,
profiles: { none: {} },
},
data: {
smsLockState: status,
},
});
} else {
const team = await prisma.team.findUnique({
where: { id: teamId, parentId: null, isOrganization: false },
});
if (team?.smsLockReviewedByAdmin) return;
await prisma.team.update({
where: {
id: teamId,
parentId: null,
isOrganization: false,
},
data: {
smsLockState: status,
},
});
}
}
+12 -4
View File
@@ -5,16 +5,24 @@ describe("PII Hasher Test Suite", () => {
const hasher = new Md5PiiHasher("test-salt");
it("can hash email addresses", async () => {
it("can hash email addresses deterministically and preserve domain", async () => {
const email = "sensitive_data@example.com";
const hashedEmail = hashEmail(email, hasher);
expect(hashedEmail).toBe("2e74ca9edc8add1709b0d049aa2a0959@example.com");
// Domain must be preserved
expect(hashedEmail.endsWith("@example.com")).toBe(true);
// Local part should change
expect(hashedEmail.split("@")[0]).not.toBe("sensitive_data");
// Deterministic
expect(hashEmail(email, hasher)).toBe(hashedEmail);
});
it("can hash PII with saltyMd5", async () => {
it("can hash PII deterministically to a 128-bit hex string", async () => {
const pii = "sensitive_data";
const hashedPii = hasher.hash(pii);
expect(hashedPii).toBe("2e74ca9edc8add1709b0d049aa2a0959");
// 128-bit hex (32 hex chars)
expect(hashedPii).toMatch(/^[0-9a-f]{32}$/);
// Deterministic
expect(hasher.hash(pii)).toBe(hashedPii);
});
it("handles hashing with different salt", () => {
+19 -4
View File
@@ -1,4 +1,4 @@
import { createHash } from "crypto";
// Note: avoid Node's crypto to support runtimes where it's unavailable (e.g., edge/browsers)
export interface PiiHasher {
hash(input: string): string;
@@ -7,9 +7,24 @@ export interface PiiHasher {
export class Md5PiiHasher implements PiiHasher {
constructor(private readonly salt: string) {}
hash(input: string) {
return createHash("md5")
.update(this.salt + input)
.digest("hex");
// FNV-1a 32-bit using Math.imul, repeated 4 times with variant salt to get 128-bit hex
const fnv1a32 = (str: string) => {
// Convert to UTF-8 bytes without relying on TextEncoder/Buffer
const data = unescape(encodeURIComponent(str));
let hash = 0x811c9dc5; // offset basis (2166136261)
for (let i = 0; i < data.length; i++) {
hash = Math.imul(hash ^ data.charCodeAt(i), 0x01000193); // 16777619
}
return hash >>> 0; // unsigned 32-bit
};
// Four independent 32-bit hashes to produce a stable 128-bit hex string
const h1 = fnv1a32(`${this.salt}::1::${input}`);
const h2 = fnv1a32(`${this.salt}::2::${input}`);
const h3 = fnv1a32(`${this.salt}::3::${input}`);
const h4 = fnv1a32(`${this.salt}::4::${input}`);
const toHex8 = (n: number) => n.toString(16).padStart(8, "0");
return `${toHex8(h1)}${toHex8(h2)}${toHex8(h3)}${toHex8(h4)}`;
}
}
+64
View File
@@ -0,0 +1,64 @@
import { prisma } from "@calcom/prisma";
import { SMSLockState } from "@calcom/prisma/enums";
import type { RateLimitHelper } from "./rateLimit";
import { rateLimiter } from "./rateLimit";
export async function checkSMSRateLimit({
rateLimitingType = "sms",
identifier,
onRateLimiterResponse,
opts,
}: RateLimitHelper) {
const response = await rateLimiter()({ rateLimitingType, identifier, opts });
const { success } = response;
if (onRateLimiterResponse) onRateLimiterResponse(response);
if (!success) {
await changeSMSLockState(
identifier,
rateLimitingType === "sms" ? SMSLockState.LOCKED : SMSLockState.REVIEW_NEEDED
);
}
}
async function changeSMSLockState(identifier: string, status: SMSLockState) {
let userId, teamId;
if (identifier.startsWith("sms:user:")) {
userId = Number(identifier.slice(9));
} else if (identifier.startsWith("sms:team:")) {
teamId = Number(identifier.slice(9));
}
if (userId) {
const user = await prisma.user.findUnique({ where: { id: userId, profiles: { none: {} } } });
if (user?.smsLockReviewedByAdmin) return;
await prisma.user.update({
where: {
id: userId,
profiles: { none: {} },
},
data: {
smsLockState: status,
},
});
} else {
const team = await prisma.team.findUnique({
where: { id: teamId, parentId: null, isOrganization: false },
});
if (team?.smsLockReviewedByAdmin) return;
await prisma.team.update({
where: {
id: teamId,
parentId: null,
isOrganization: false,
},
data: {
smsLockState: status,
},
});
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
import dayjs from "@calcom/dayjs";
import { getSenderId } from "@calcom/features/ee/workflows/lib/alphanumericSenderIdSupport";
import { sendSmsOrFallbackEmail } from "@calcom/features/ee/workflows/lib/reminders/messageDispatcher";
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
import { checkSMSRateLimit } from "@calcom/lib/smsLockState";
import { SENDER_ID } from "@calcom/lib/constants";
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
import { piiHasher } from "@calcom/lib/server/PiiHasher";
@@ -42,7 +42,7 @@ const handleSendingSMS = async ({
phoneNumber: reminderPhone,
body: smsMessage,
sender: senderID,
...(!!teamId ? { teamId } : { userId: organizerUserId }),
...(teamId ? { teamId } : { userId: organizerUserId }),
bookingUid,
},
});
+2 -2
View File
@@ -2,13 +2,13 @@ import type { TFunction } from "i18next";
import { describe, expect, test, vi, beforeEach } from "vitest";
import { sendSmsOrFallbackEmail } from "@calcom/features/ee/workflows/lib/reminders/messageDispatcher";
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
import { checkSMSRateLimit } from "@calcom/lib/smsLockState";
import prisma from "@calcom/prisma";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import SMSManager from "../sms-manager";
vi.mock("@calcom/lib/checkRateLimitAndThrowError");
vi.mock("@calcom/lib/smsLockState");
vi.mock("@calcom/features/ee/workflows/lib/reminders/messageDispatcher");
vi.mock("@calcom/prisma", () => ({
default: {
+1
View File
@@ -105,6 +105,7 @@
"NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID",
"NEXT_PUBLIC_HOSTED_CAL_FEATURES",
"NEXT_PUBLIC_IS_E2E",
"IS_E2E",
"NEXT_PUBLIC_MINUTES_TO_BOOK",
"NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED",
"NEXT_PUBLIC_SENDER_ID",