chore: Replaces Unkey and Update rate limiting in the management API v2. (#6273)

This commit is contained in:
Piyush Gupta
2025-07-22 15:03:29 +05:30
committed by GitHub
parent ed89f12af8
commit eee9ee8995
11 changed files with 67 additions and 311 deletions

View File

@@ -194,9 +194,6 @@ REDIS_URL=redis://localhost:6379
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL:
# The below is used for Rate Limiting for management API
UNKEY_ROOT_KEY=
# INTERCOM_APP_ID=
# INTERCOM_SECRET_KEY=

View File

@@ -175,7 +175,6 @@ export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;
export const REDIS_URL = env.REDIS_URL;
export const REDIS_HTTP_URL = env.REDIS_HTTP_URL;
export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1";
export const UNKEY_ROOT_KEY = env.UNKEY_ROOT_KEY;
export const BREVO_API_KEY = env.BREVO_API_KEY;
export const BREVO_LIST_ID = env.BREVO_LIST_ID;

View File

@@ -116,7 +116,7 @@ export const env = createEnv({
VERCEL_URL: z.string().optional(),
WEBAPP_URL: z.string().url().optional(),
UNSPLASH_ACCESS_KEY: z.string().optional(),
UNKEY_ROOT_KEY: z.string().optional(),
NODE_ENV: z.enum(["development", "production", "test"]).optional(),
PROMETHEUS_EXPORTER_PORT: z.string().optional(),
PROMETHEUS_ENABLED: z.enum(["1", "0"]).optional(),
@@ -218,7 +218,6 @@ export const env = createEnv({
VERCEL_URL: process.env.VERCEL_URL,
WEBAPP_URL: process.env.WEBAPP_URL,
UNSPLASH_ACCESS_KEY: process.env.UNSPLASH_ACCESS_KEY,
UNKEY_ROOT_KEY: process.env.UNKEY_ROOT_KEY,
NODE_ENV: process.env.NODE_ENV,
PROMETHEUS_ENABLED: process.env.PROMETHEUS_ENABLED,
PROMETHEUS_EXPORTER_PORT: process.env.PROMETHEUS_EXPORTER_PORT,

View File

@@ -1,6 +1,7 @@
import { ApiAuditLog } from "@/app/lib/api/with-api-logging";
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { ZodRawShape, z } from "zod";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { authenticateRequest } from "./authenticate-request";
@@ -104,11 +105,10 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
}
if (rateLimit) {
const rateLimitResponse = await checkRateLimitAndThrowError({
identifier: authentication.data.hashedApiKey,
});
if (!rateLimitResponse.ok) {
return handleApiError(request, rateLimitResponse.error);
try {
await applyRateLimit(rateLimitConfigs.api.v2, authentication.data.hashedApiKey);
} catch (error) {
return handleApiError(request, { type: "too_many_requests", details: error.message });
}
}

View File

@@ -1,22 +1,26 @@
import { apiWrapper } from "@/modules/api/v2/auth/api-wrapper";
import { authenticateRequest } from "@/modules/api/v2/auth/authenticate-request";
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { checkRateLimit } from "@/modules/core/rate-limit/rate-limit";
import { describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { err, ok, okVoid } from "@formbricks/types/error-handlers";
import { err, ok } from "@formbricks/types/error-handlers";
vi.mock("../authenticate-request", () => ({
authenticateRequest: vi.fn(),
}));
vi.mock("@/modules/api/v2/lib/rate-limit", () => ({
checkRateLimitAndThrowError: vi.fn(),
vi.mock("@/modules/core/rate-limit/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/modules/api/v2/lib/utils", () => ({
handleApiError: vi.fn(),
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
api: {
v2: { interval: 60, allowedPerInterval: 100, namespace: "api:v2" },
},
},
}));
vi.mock("@/modules/api/v2/lib/utils", () => ({
@@ -24,20 +28,31 @@ vi.mock("@/modules/api/v2/lib/utils", () => ({
handleApiError: vi.fn(),
}));
const mockAuthentication = {
type: "apiKey" as const,
environmentPermissions: [
{
environmentId: "env-id",
environmentType: "development" as const,
projectId: "project-id",
projectName: "Project Name",
permission: "manage" as const,
},
],
hashedApiKey: "hashed-api-key",
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: {} as any,
} as any;
describe("apiWrapper", () => {
test("should handle request and return response", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(checkRateLimitAndThrowError).mockResolvedValue(okVoid());
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(checkRateLimit).mockResolvedValue(ok({ allowed: true }));
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
const response = await apiWrapper({
@@ -74,13 +89,7 @@ describe("apiWrapper", () => {
headers: { "Content-Type": "application/json" },
});
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
const bodySchema = z.object({ key: z.string() });
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
@@ -107,14 +116,7 @@ describe("apiWrapper", () => {
headers: { "Content-Type": "application/json" },
});
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
const bodySchema = z.object({ key: z.string() });
@@ -134,13 +136,7 @@ describe("apiWrapper", () => {
test("should parse query schema correctly", async () => {
const request = new Request("http://localhost?key=value");
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
const querySchema = z.object({ key: z.string() });
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
@@ -163,14 +159,7 @@ describe("apiWrapper", () => {
test("should handle query schema errors", async () => {
const request = new Request("http://localhost?foo%ZZ=abc");
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
const querySchema = z.object({ key: z.string() });
@@ -190,13 +179,7 @@ describe("apiWrapper", () => {
test("should parse params schema correctly", async () => {
const request = new Request("http://localhost");
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
const paramsSchema = z.object({ key: z.string() });
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
@@ -220,14 +203,7 @@ describe("apiWrapper", () => {
test("should handle no external params", async () => {
const request = new Request("http://localhost");
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
const paramsSchema = z.object({ key: z.string() });
@@ -248,14 +224,7 @@ describe("apiWrapper", () => {
test("should handle params schema errors", async () => {
const request = new Request("http://localhost");
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
const paramsSchema = z.object({ key: z.string() });
@@ -273,21 +242,13 @@ describe("apiWrapper", () => {
expect(handler).not.toHaveBeenCalled();
});
test("should handle rate limit errors", async () => {
test("should handle rate limit exceeded", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
vi.mocked(authenticateRequest).mockResolvedValue(
ok({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
})
);
vi.mocked(checkRateLimitAndThrowError).mockResolvedValue(
err({ type: "rateLimitExceeded" } as unknown as ApiErrorResponseV2)
);
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
vi.mocked(checkRateLimit).mockResolvedValue(ok({ allowed: false }));
vi.mocked(handleApiError).mockImplementation(
(_request: Request, _error: ApiErrorResponseV2): Response =>
new Response("rate limit exceeded", { status: 429 })
@@ -302,4 +263,24 @@ describe("apiWrapper", () => {
expect(response.status).toBe(429);
expect(handler).not.toHaveBeenCalled();
});
test("should handle rate limit check failure gracefully", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
// When rate limiting fails (e.g., Redis connection issues), checkRateLimit fails open by returning allowed: true
vi.mocked(checkRateLimit).mockResolvedValue(ok({ allowed: true }));
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
const response = await apiWrapper({
request,
handler,
});
// Should fail open for availability
expect(response.status).toBe(200);
expect(handler).toHaveBeenCalled();
});
});

View File

@@ -1,71 +0,0 @@
import { MANAGEMENT_API_RATE_LIMIT, RATE_LIMITING_DISABLED, UNKEY_ROOT_KEY } from "@/lib/constants";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { type LimitOptions, Ratelimit, type RatelimitResponse } from "@unkey/ratelimit";
import { logger } from "@formbricks/logger";
import { Result, err, okVoid } from "@formbricks/types/error-handlers";
export type RateLimitHelper = {
identifier: string;
opts?: LimitOptions;
/**
* Using a callback instead of a regular return to provide headers even
* when the rate limit is reached and an error is thrown.
**/
onRateLimiterResponse?: (response: RatelimitResponse) => void;
};
let warningDisplayed = false;
/** Prevent flooding the logs while testing/building */
function logOnce(message: string) {
if (warningDisplayed) return;
logger.warn(message);
warningDisplayed = true;
}
export function rateLimiter() {
if (RATE_LIMITING_DISABLED) {
logOnce("Rate limiting disabled");
return () => ({ success: true, limit: 10, remaining: 999, reset: 0 }) as RatelimitResponse;
}
if (!UNKEY_ROOT_KEY) {
logOnce("Disabled due to not finding UNKEY_ROOT_KEY env variable");
return () => ({ success: true, limit: 10, remaining: 999, reset: 0 }) as RatelimitResponse;
}
const timeout = {
fallback: { success: true, limit: 10, remaining: 999, reset: 0 },
ms: 5000,
};
const limiter = {
api: new Ratelimit({
rootKey: UNKEY_ROOT_KEY,
namespace: "api",
limit: MANAGEMENT_API_RATE_LIMIT.allowedPerInterval,
duration: MANAGEMENT_API_RATE_LIMIT.interval * 1000,
timeout,
}),
};
async function rateLimit({ identifier, opts }: RateLimitHelper) {
return await limiter.api.limit(identifier, opts);
}
return rateLimit;
}
export const checkRateLimitAndThrowError = async ({
identifier,
opts,
}: RateLimitHelper): Promise<Result<void, ApiErrorResponseV2>> => {
const response = await rateLimiter()({ identifier, opts });
const { success } = response;
if (!success) {
return err({
type: "too_many_requests",
});
}
return okVoid();
};

View File

@@ -1,114 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
vi.mock("@formbricks/logger", () => ({
logger: {
warn: vi.fn(),
},
}));
vi.mock("@unkey/ratelimit", () => ({
Ratelimit: vi.fn(),
}));
describe("when rate limiting is disabled", () => {
beforeEach(async () => {
vi.resetModules();
const constants = await vi.importActual("@/lib/constants");
vi.doMock("@/lib/constants", () => ({
...constants,
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
RATE_LIMITING_DISABLED: true,
}));
});
test("should log a warning once and return a stubbed response", async () => {
const loggerSpy = vi.spyOn(logger, "warn");
const { rateLimiter } = await import("@/modules/api/v2/lib/rate-limit");
const res1 = await rateLimiter()({ identifier: "test-id" });
expect(res1).toEqual({ success: true, limit: 10, remaining: 999, reset: 0 });
expect(loggerSpy).toHaveBeenCalled();
// Subsequent calls won't log again.
await rateLimiter()({ identifier: "another-id" });
expect(loggerSpy).toHaveBeenCalledTimes(1);
loggerSpy.mockRestore();
});
});
describe("when UNKEY_ROOT_KEY is missing", () => {
beforeEach(async () => {
vi.resetModules();
const constants = await vi.importActual("@/lib/constants");
vi.doMock("@/lib/constants", () => ({
...constants,
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
RATE_LIMITING_DISABLED: false,
UNKEY_ROOT_KEY: "",
}));
});
test("should log a warning about missing UNKEY_ROOT_KEY and return stub response", async () => {
const loggerSpy = vi.spyOn(logger, "warn");
const { rateLimiter } = await import("@/modules/api/v2/lib/rate-limit");
const limiterFunc = rateLimiter();
const res = await limiterFunc({ identifier: "test-id" });
expect(res).toEqual({ success: true, limit: 10, remaining: 999, reset: 0 });
expect(loggerSpy).toHaveBeenCalled();
loggerSpy.mockRestore();
});
});
describe("when rate limiting is active (enabled)", () => {
const mockResponse = { success: true, limit: 5, remaining: 2, reset: 1000 };
let limitMock: ReturnType<typeof vi.fn>;
beforeEach(async () => {
vi.resetModules();
const constants = await vi.importActual("@/lib/constants");
vi.doMock("@/lib/constants", () => ({
...constants,
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
RATE_LIMITING_DISABLED: false,
UNKEY_ROOT_KEY: "valid-key",
}));
limitMock = vi.fn().mockResolvedValue(mockResponse);
const RatelimitMock = vi.fn().mockImplementation(() => {
return { limit: limitMock };
});
vi.doMock("@unkey/ratelimit", () => ({
Ratelimit: RatelimitMock,
}));
});
test("should create a rate limiter that calls the limit method with the proper arguments", async () => {
const { rateLimiter } = await import("../rate-limit");
const limiterFunc = rateLimiter();
const res = await limiterFunc({ identifier: "abc", opts: { cost: 1 } });
expect(limitMock).toHaveBeenCalledWith("abc", { cost: 1 });
expect(res).toEqual(mockResponse);
});
test("checkRateLimitAndThrowError returns okVoid when rate limit is not exceeded", async () => {
limitMock.mockResolvedValueOnce({ success: true, limit: 5, remaining: 3, reset: 1000 });
const { checkRateLimitAndThrowError } = await import("../rate-limit");
const result = await checkRateLimitAndThrowError({ identifier: "abc" });
expect(result.ok).toBe(true);
});
test("checkRateLimitAndThrowError returns an error when the rate limit is exceeded", async () => {
limitMock.mockResolvedValueOnce({ success: false, limit: 5, remaining: 0, reset: 1000 });
const { checkRateLimitAndThrowError } = await import("../rate-limit");
const result = await checkRateLimitAndThrowError({ identifier: "abc" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({ type: "too_many_requests" });
}
});
});

View File

@@ -79,7 +79,6 @@
"@tolgee/format-icu": "6.2.5",
"@tolgee/react": "6.2.5",
"@ungap/structured-clone": "1.3.0",
"@unkey/ratelimit": "0.5.5",
"@vercel/functions": "2.0.2",
"@vercel/og": "0.6.8",
"bcryptjs": "3.0.2",

View File

@@ -54,7 +54,7 @@ These variables are present inside your machine's docker-compose file. Restart t
| STRIPE_SECRET_KEY | Secret key for Stripe integration. | optional | |
| STRIPE_WEBHOOK_SECRET | Webhook secret for Stripe integration. | optional | |
| TELEMETRY_DISABLED | Disables telemetry if set to 1. | optional | |
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b |
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b |
| DEFAULT_ORGANIZATION_ID | Automatically assign new users to a specific organization when joining | optional | |
| OIDC_DISPLAY_NAME | Display name for Custom OpenID Connect Provider | optional | |
| OIDC_CLIENT_ID | Client ID for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
@@ -62,7 +62,6 @@ These variables are present inside your machine's docker-compose file. Restart t
| OIDC_ISSUER | Issuer URL for Custom OpenID Connect Provider (should have .well-known configured at this) | optional (required if OIDC auth is enabled) | |
| OIDC_SIGNING_ALGORITHM | Signing Algorithm for Custom OpenID Connect Provider | optional | RS256 |
| OPENTELEMETRY_LISTENER_URL | URL for OpenTelemetry listener inside Formbricks. | optional | |
| UNKEY_ROOT_KEY | Key for the [Unkey](https://www.unkey.com/) service. This is used for Rate Limiting for management API. | optional | |
| PROMETHEUS_ENABLED | Enables Prometheus metrics if set to 1. | optional | |
| PROMETHEUS_EXPORTER_PORT | Port for Prometheus metrics. | optional | 9090 |
| DOCKER_CRON_ENABLED | Controls whether cron jobs run in the Docker image. Set to 0 to disable (useful for cluster setups). | optional | 1 |

32
pnpm-lock.yaml generated
View File

@@ -277,9 +277,6 @@ importers:
'@ungap/structured-clone':
specifier: 1.3.0
version: 1.3.0
'@unkey/ratelimit':
specifier: 0.5.5
version: 0.5.5
'@vercel/functions':
specifier: 2.0.2
version: 2.0.2(@aws-sdk/credential-provider-web-identity@3.804.0)
@@ -4339,18 +4336,6 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
'@unkey/api@0.33.1':
resolution: {integrity: sha512-HXFQOGjO3S0+N6GMBOz9lkn2Fp41rdgNrafBkjRg8IjOsNv6SNdWrXZSELONinZYycr7Wxc/t7Vhx7xUdb0cdA==}
'@unkey/error@0.2.0':
resolution: {integrity: sha512-DFGb4A7SrusZPP0FYuRIF0CO+Gi4etLUAEJ6EKc+TKYmscL0nEJ2Pr38FyX9MvjI4Wx5l35Wc9KsBjMm9Ybh7w==}
'@unkey/ratelimit@0.5.5':
resolution: {integrity: sha512-79Xv7lZFHqScGzBQpO3hh2SZBhujXDDkyH/izWzeXcJ5sFYO+PsC+VaAFagEinbHbrx116RkIRIqbDrfZfRpxA==}
'@unkey/rbac@0.3.1':
resolution: {integrity: sha512-Hj+52XRIlBBl3/qOUq9K71Fwy3PWExBQOpOClVYHdrcmbgqNL6L4EdW/BzliLhqPCdwZTPVSJTnZ3Hw4ZYixsQ==}
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}
cpu: [arm]
@@ -14435,23 +14420,6 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
'@unkey/api@0.33.1':
dependencies:
'@unkey/rbac': 0.3.1
'@unkey/error@0.2.0':
dependencies:
zod: 3.24.4
'@unkey/ratelimit@0.5.5':
dependencies:
'@unkey/api': 0.33.1
'@unkey/rbac@0.3.1':
dependencies:
'@unkey/error': 0.2.0
zod: 3.24.4
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
optional: true

View File

@@ -174,7 +174,6 @@
"VERSION",
"WEBAPP_URL",
"UNSPLASH_ACCESS_KEY",
"UNKEY_ROOT_KEY",
"PROMETHEUS_ENABLED",
"PROMETHEUS_EXPORTER_PORT",
"USER_MANAGEMENT_MINIMUM_ROLE"