Compare commits

..

2 Commits

Author SHA1 Message Date
Matti Nannt b9dcc5dd4b docs: restore Vercel reference in hubspot webhook guide
Vercel mention is about user-built webhook servers, not Formbricks
deployment — keeping it is correct.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:56:05 +02:00
Matti Nannt d7fbb439e5 chore: remove all Vercel deployment references
Formbricks no longer deploys to Vercel and does not support it for
self-hosters. This removes all Vercel-specific deployment config,
environment variables, and infrastructure references from the codebase.

- Delete vercel.json and .vercelignore
- Remove VERCEL_URL env var from env.ts, constants.ts, getPublicUrl.ts
- Remove X-Vercel-IP-Country header from v1 and v2 response API routes
- Remove VERCEL / VERCEL_URL from turbo.json build env passthrough
- Update .env.example and docker-compose.yml comments
- Update sentry.edge.config.ts comment
- Remove VERCEL_URL from license.test.ts and getPublicUrl.test.ts mocks
- Update docs to remove Vercel deployment references

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:46:40 +02:00
23 changed files with 1528 additions and 1167 deletions
+1 -1
View File
@@ -70,7 +70,7 @@ SMTP_PASSWORD=smtpPassword
# S3 STORAGE #
##############
# S3 Storage is required for the file upload in serverless environments like Vercel
# S3 Storage is required for the file upload in serverless environments
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_REGION=
-1
View File
@@ -1 +0,0 @@
apps/web/.env
@@ -96,10 +96,7 @@ export const POST = withV1ApiWrapper({
const agent = new UAParser(userAgent);
const country =
requestHeaders.get("CF-IPCountry") ||
requestHeaders.get("X-Vercel-IP-Country") ||
requestHeaders.get("CloudFront-Viewer-Country") ||
undefined;
requestHeaders.get("CF-IPCountry") || requestHeaders.get("CloudFront-Viewer-Country") || undefined;
const responseInputData = responseInputValidation.data;
@@ -1,201 +0,0 @@
import { type NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
const mocks = vi.hoisted(() => ({
applyIPRateLimit: vi.fn(),
applyRateLimit: vi.fn(),
getSurvey: vi.fn(),
getOrganizationByEnvironmentId: vi.fn(),
getBiggerUploadFileSizePermission: vi.fn(),
getSignedUrlForUpload: vi.fn(),
getErrorResponseFromStorageError: vi.fn(),
reportApiError: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: mocks.applyIPRateLimit,
applyRateLimit: mocks.applyRateLimit,
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: mocks.getSurvey,
}));
vi.mock("@/lib/organization/service", () => ({
getOrganizationByEnvironmentId: mocks.getOrganizationByEnvironmentId,
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getBiggerUploadFileSizePermission: mocks.getBiggerUploadFileSizePermission,
}));
vi.mock("@/modules/storage/service", () => ({
getSignedUrlForUpload: mocks.getSignedUrlForUpload,
}));
vi.mock("@/modules/storage/utils", () => ({
getErrorResponseFromStorageError: mocks.getErrorResponseFromStorageError,
}));
vi.mock("@/app/lib/api/api-error-reporter", () => ({
reportApiError: mocks.reportApiError,
}));
vi.mock("@/app/api/v1/auth", () => ({
authenticateRequest: vi.fn(),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEvent: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: false,
MAX_FILE_UPLOAD_SIZES: {
standard: 1024 * 1024 * 10,
big: 1024 * 1024 * 1024,
},
SENTRY_DSN: undefined,
}));
const ENVIRONMENT_ID = "cm1ubebtj000614kqe4hs3c67";
const OTHER_ENVIRONMENT_ID = "cm1ubebtj000714kqe4hs3c68";
const SURVEY_ID = "cm1ubebtj000814kqe4hs3c69";
const ORGANIZATION_ID = "cm1ubebtj000914kqe4hs3c70";
const createMockRequest = ({
apiVersion = "v1",
body = {
fileName: "upload.png",
fileType: "image/png",
surveyId: SURVEY_ID,
},
environmentId = ENVIRONMENT_ID,
}: {
apiVersion?: "v1" | "v2";
body?: unknown;
environmentId?: string;
} = {}): NextRequest => {
const pathname = `/api/${apiVersion}/client/${environmentId}/storage`;
return {
method: "POST",
url: `https://api.test${pathname}`,
headers: {
get: vi.fn(() => null),
},
nextUrl: {
pathname,
},
json: vi.fn().mockResolvedValue(body),
} as unknown as NextRequest;
};
const createRouteProps = (environmentId = ENVIRONMENT_ID) => ({
params: Promise.resolve({ environmentId }),
});
describe("api/v1 client storage route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.applyIPRateLimit.mockResolvedValue({ allowed: true });
mocks.applyRateLimit.mockResolvedValue({ allowed: true });
mocks.getSurvey.mockResolvedValue({ id: SURVEY_ID, environmentId: ENVIRONMENT_ID });
mocks.getOrganizationByEnvironmentId.mockResolvedValue({ id: ORGANIZATION_ID });
mocks.getBiggerUploadFileSizePermission.mockResolvedValue(false);
mocks.getSignedUrlForUpload.mockResolvedValue({
ok: true,
data: {
signedUrl: "https://s3.example.com/upload",
presignedFields: { key: "value" },
fileUrl: `/storage/${ENVIRONMENT_ID}/private/upload--fid--uuid.png`,
},
});
});
test("applies IP and environment rate limits before signing the upload", async () => {
const { POST } = await import("./route");
const response = await POST(createMockRequest(), createRouteProps());
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
data: {
signedUrl: "https://s3.example.com/upload",
presignedFields: { key: "value" },
fileUrl: `/storage/${ENVIRONMENT_ID}/private/upload--fid--uuid.png`,
},
});
expect(mocks.applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.storage.upload);
expect(mocks.applyRateLimit).toHaveBeenCalledWith(
rateLimitConfigs.storage.uploadPerEnvironment,
ENVIRONMENT_ID
);
expect(mocks.getSignedUrlForUpload).toHaveBeenCalledWith(
"upload.png",
ENVIRONMENT_ID,
"image/png",
"private",
1024 * 1024 * 10
);
});
test("returns 429 with CORS when the environment rate limit is exceeded", async () => {
const { POST } = await import("./route");
mocks.applyRateLimit.mockRejectedValueOnce(
new Error("Maximum number of requests reached. Please try again later.")
);
const response = await POST(createMockRequest(), createRouteProps());
expect(response.status).toBe(429);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
expect(await response.json()).toEqual({
code: "too_many_requests",
message: "Maximum number of requests reached. Please try again later.",
details: {},
});
expect(mocks.getSignedUrlForUpload).not.toHaveBeenCalled();
});
test("does not burn environment quota when the survey belongs to another environment", async () => {
const { POST } = await import("./route");
mocks.getSurvey.mockResolvedValueOnce({ id: SURVEY_ID, environmentId: OTHER_ENVIRONMENT_ID });
const response = await POST(createMockRequest(), createRouteProps());
expect(response.status).toBe(400);
expect(mocks.applyRateLimit).not.toHaveBeenCalled();
expect(mocks.getSignedUrlForUpload).not.toHaveBeenCalled();
});
test("applies the same environment rate limit through the v2 storage re-export", async () => {
const { POST } = await import("@/app/api/v2/client/[environmentId]/storage/route");
const response = await POST(createMockRequest({ apiVersion: "v2" }), createRouteProps());
expect(response.status).toBe(200);
expect(mocks.applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.storage.upload);
expect(mocks.applyRateLimit).toHaveBeenCalledWith(
rateLimitConfigs.storage.uploadPerEnvironment,
ENVIRONMENT_ID
);
});
});
@@ -6,7 +6,6 @@ import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging
import { MAX_FILE_UPLOAD_SIZES } from "@/lib/constants";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey } from "@/lib/survey/service";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils";
import { getSignedUrlForUpload } from "@/modules/storage/service";
@@ -80,17 +79,6 @@ export const POST = withV1ApiWrapper({
};
}
try {
await applyRateLimit(rateLimitConfigs.storage.uploadPerEnvironment, environmentId);
} catch (error) {
return {
response: responses.tooManyRequestsResponse(
error instanceof Error ? error.message : "Rate limit exceeded",
true
),
};
}
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.id);
const maxFileUploadSize = isBiggerFileUploadAllowed
? MAX_FILE_UPLOAD_SIZES.big
@@ -35,10 +35,7 @@ type TValidatedResponseInputResult =
| { response: Response };
const getCountry = (requestHeaders: Headers): string | undefined =>
requestHeaders.get("CF-IPCountry") ||
requestHeaders.get("X-Vercel-IP-Country") ||
requestHeaders.get("CloudFront-Viewer-Country") ||
undefined;
requestHeaders.get("CF-IPCountry") || requestHeaders.get("CloudFront-Viewer-Country") || undefined;
const getUnexpectedPublicErrorResponse = (): Response =>
responses.internalServerErrorResponse("Something went wrong. Please try again.", true);
+1 -2
View File
@@ -10,8 +10,7 @@ export const IS_DEVELOPMENT = env.NODE_ENV === "development";
export const E2E_TESTING = env.E2E_TESTING === "1";
// URLs
export const WEBAPP_URL =
env.WEBAPP_URL || (env.VERCEL_URL ? `https://${env.VERCEL_URL}` : false) || "http://localhost:3000";
export const WEBAPP_URL = env.WEBAPP_URL || "http://localhost:3000";
// encryption keys
export const ENCRYPTION_KEY = env.ENCRYPTION_KEY;
-2
View File
@@ -235,7 +235,6 @@ const parsedEnv = createEnv({
TURNSTILE_SITE_KEY: z.string().optional(),
RECAPTCHA_SITE_KEY: z.string().optional(),
RECAPTCHA_SECRET_KEY: z.string().optional(),
VERCEL_URL: z.string().optional(),
WEBAPP_URL: z.url().optional(),
UNSPLASH_ACCESS_KEY: z.string().optional(),
@@ -354,7 +353,6 @@ const parsedEnv = createEnv({
RECAPTCHA_SITE_KEY: process.env.RECAPTCHA_SITE_KEY,
RECAPTCHA_SECRET_KEY: process.env.RECAPTCHA_SECRET_KEY,
TERMS_URL: process.env.TERMS_URL,
VERCEL_URL: process.env.VERCEL_URL,
WEBAPP_URL: process.env.WEBAPP_URL,
UNSPLASH_ACCESS_KEY: process.env.UNSPLASH_ACCESS_KEY,
NODE_ENV: process.env.NODE_ENV,
+1 -12
View File
@@ -2,7 +2,6 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
const envMock = {
WEBAPP_URL: undefined as string | undefined,
VERCEL_URL: undefined as string | undefined,
PUBLIC_URL: undefined as string | undefined,
};
@@ -19,7 +18,6 @@ const loadGetPublicDomain = async () => {
describe("getPublicDomain", () => {
beforeEach(() => {
envMock.WEBAPP_URL = undefined;
envMock.VERCEL_URL = undefined;
envMock.PUBLIC_URL = undefined;
});
@@ -31,16 +29,7 @@ describe("getPublicDomain", () => {
expect(getPublicDomain()).toBe("https://app.formbricks.com");
});
test("falls back to VERCEL_URL when WEBAPP_URL is empty", async () => {
envMock.WEBAPP_URL = " ";
envMock.VERCEL_URL = "preview.formbricks.com";
const getPublicDomain = await loadGetPublicDomain();
expect(getPublicDomain()).toBe("https://preview.formbricks.com");
});
test("falls back to localhost when WEBAPP_URL and VERCEL_URL are not set", async () => {
test("falls back to localhost when WEBAPP_URL is not set", async () => {
const getPublicDomain = await loadGetPublicDomain();
expect(getPublicDomain()).toBe("http://localhost:3000");
+1 -11
View File
@@ -2,17 +2,7 @@ import "server-only";
import { env } from "./env";
const configuredWebappUrl = env.WEBAPP_URL?.trim() ?? "";
const WEBAPP_URL = (() => {
if (configuredWebappUrl !== "") {
return configuredWebappUrl;
}
if (env.VERCEL_URL) {
return `https://${env.VERCEL_URL}`;
}
return "http://localhost:3000";
})();
const WEBAPP_URL = configuredWebappUrl !== "" ? configuredWebappUrl : "http://localhost:3000";
/**
* Returns the public domain URL
@@ -59,7 +59,6 @@ describe("rateLimitConfigs", () => {
expect(rateLimitConfigs).toHaveProperty("auth");
expect(rateLimitConfigs).toHaveProperty("api");
expect(rateLimitConfigs).toHaveProperty("actions");
expect(rateLimitConfigs).toHaveProperty("storage");
});
test("should have all auth configurations", () => {
@@ -82,11 +81,6 @@ describe("rateLimitConfigs", () => {
"licenseRecheck",
]);
});
test("should have all storage configurations", () => {
const storageConfigs = Object.keys(rateLimitConfigs.storage);
expect(storageConfigs).toEqual(["upload", "uploadPerEnvironment", "delete"]);
});
});
describe("Zod Validation", () => {
@@ -95,7 +89,6 @@ describe("rateLimitConfigs", () => {
...Object.values(rateLimitConfigs.auth),
...Object.values(rateLimitConfigs.api),
...Object.values(rateLimitConfigs.actions),
...Object.values(rateLimitConfigs.storage),
];
for (const config of allConfigs) {
@@ -112,7 +105,6 @@ describe("rateLimitConfigs", () => {
Object.values(rateLimitConfigs.auth).forEach((config) => allNamespaces.push(config.namespace));
Object.values(rateLimitConfigs.api).forEach((config) => allNamespaces.push(config.namespace));
Object.values(rateLimitConfigs.actions).forEach((config) => allNamespaces.push(config.namespace));
Object.values(rateLimitConfigs.storage).forEach((config) => allNamespaces.push(config.namespace));
const uniqueNamespaces = new Set(allNamespaces);
expect(uniqueNamespaces.size).toBe(allNamespaces.length);
@@ -150,7 +142,6 @@ describe("rateLimitConfigs", () => {
{ config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" },
{ config: rateLimitConfigs.actions.accountDeletion, identifier: "user-account-delete" },
{ config: rateLimitConfigs.storage.upload, identifier: "storage-upload" },
{ config: rateLimitConfigs.storage.uploadPerEnvironment, identifier: "storage-upload-env" },
{ config: rateLimitConfigs.storage.delete, identifier: "storage-delete" },
];
@@ -180,15 +171,6 @@ describe("rateLimitConfigs", () => {
expect(config.namespace).toBe("storage:upload");
});
test("should properly configure storage upload per environment rate limit", async () => {
const config = rateLimitConfigs.storage.uploadPerEnvironment;
// Verify configuration values
expect(config.interval).toBe(60); // 1 minute
expect(config.allowedPerInterval).toBe(100); // 100 requests per minute
expect(config.namespace).toBe("storage:upload:environment");
});
test("should properly configure storage delete rate limit", async () => {
const config = rateLimitConfigs.storage.delete;
@@ -30,11 +30,6 @@ export const rateLimitConfigs = {
storage: {
upload: { interval: 60, allowedPerInterval: 5, namespace: "storage:upload" }, // 5 per minute
uploadPerEnvironment: {
interval: 60,
allowedPerInterval: 100,
namespace: "storage:upload:environment",
}, // 100 per minute per environment
delete: { interval: 60, allowedPerInterval: 5, namespace: "storage:delete" }, // 5 per minute
},
} as const;
@@ -12,7 +12,7 @@ vi.mock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -378,7 +378,7 @@ describe("License Core Logic", () => {
vi.doMock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: "",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -410,7 +410,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -444,7 +444,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -475,7 +475,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -506,7 +506,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -571,7 +571,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -627,7 +627,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -683,7 +683,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -722,7 +722,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -748,7 +748,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -899,7 +899,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: "test-license-key",
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -946,7 +946,7 @@ describe("License Core Logic", () => {
vi.doMock("@/lib/env", () => ({
env: {
ENTERPRISE_LICENSE_KEY: undefined,
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
@@ -969,7 +969,7 @@ describe("License Core Logic", () => {
env: {
ENTERPRISE_LICENSE_KEY: testLicenseKey,
ENVIRONMENT: "production",
VERCEL_URL: "some.vercel.url",
FORMBRICKS_COM_URL: "https://app.formbricks.com",
HTTPS_PROXY: undefined,
HTTP_PROXY: undefined,
+6 -6
View File
@@ -46,13 +46,13 @@
"@lexical/table": "0.41.0",
"@next-auth/prisma-adapter": "1.0.7",
"@opentelemetry/auto-instrumentations-node": "0.75.0",
"@opentelemetry/exporter-metrics-otlp-http": "0.217.0",
"@opentelemetry/exporter-metrics-otlp-http": "0.213.0",
"@opentelemetry/exporter-prometheus": "0.217.0",
"@opentelemetry/exporter-trace-otlp-http": "0.217.0",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/sdk-metrics": "2.7.1",
"@opentelemetry/sdk-node": "0.217.0",
"@opentelemetry/sdk-trace-base": "2.7.1",
"@opentelemetry/exporter-trace-otlp-http": "0.213.0",
"@opentelemetry/resources": "2.6.1",
"@opentelemetry/sdk-metrics": "2.6.1",
"@opentelemetry/sdk-node": "0.213.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/semantic-conventions": "1.40.0",
"@paralleldrive/cuid2": "2.3.1",
"@prisma/client": "6.19.3",
+1 -1
View File
@@ -1,6 +1,6 @@
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
// The config you add here will be used whenever one of the edge features is loaded.
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
// Note that this config is also required when running locally.
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
import { logger } from "@formbricks/logger";
-16
View File
@@ -1,16 +0,0 @@
{
"functions": {
"app/**/*.ts": {
"maxDuration": 10,
"memory": 512
},
"app/api/cron/**/*.ts": {
"maxDuration": 180,
"memory": 512
},
"app/api/v1/client/**/*.ts": {
"maxDuration": 10,
"memory": 200
}
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ x-environment: &environment
################################################### OPTIONAL (STORAGE) ###################################################
# Set S3 Storage configuration (required for the file upload in serverless environments like Vercel)
# Set S3 Storage configuration (required for the file upload in serverless environments)
# S3_ACCESS_KEY:
# S3_SECRET_KEY:
# S3_REGION:
@@ -6,7 +6,7 @@ icon: code
## TypeScript
Our codebase follows the Vercel Engineering Style Guide conventions.
Our codebase uses the `@vercel/style-guide` ESLint configurations for consistent code quality.
### ESLint Configuration
+1 -1
View File
@@ -1323,7 +1323,7 @@ Please note that their values and the logic remains exactly the same. Only the p
### Deprecated Environment Variables
- **`NEXT_PUBLIC_VERCEL_URL`**: Was used as Vercel URL (used instead of `WEBAPP_URL)`, but from v1.1, you can just set the `WEBAPP_URL` environment variable to your Vercel URL.
- **`NEXT_PUBLIC_VERCEL_URL`**: Was used as deployment URL fallback (used instead of `WEBAPP_URL`), but from v1.1, you can just set the `WEBAPP_URL` environment variable.
- **`RAILWAY_STATIC_URL`**: Was used as Railway Static URL (used instead of `WEBAPP_URL`), but from v1.1, you can just set the `WEBAPP_URL` environment variable.
+22 -25
View File
@@ -9,7 +9,6 @@ Formbricks applies request rate limits to protect against abuse and keep API usa
Rate limits are scoped by identifier, depending on the endpoint:
- IP hash (for unauthenticated/client-side routes and public actions)
- Environment ID (for public client storage upload abuse protection)
- API key ID (for authenticated API calls)
- User ID (for authenticated session-based calls and server actions)
- Organization ID (for follow-up email dispatch)
@@ -20,30 +19,29 @@ When a limit is exceeded, the API returns `429 Too Many Requests`.
These are the current limits for Management APIs:
| **Route Group** | **Limit** | **Window** | **Identifier** |
| ------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ---------- | ----------------------------- |
| `/api/v1/management/*` (except `/api/v1/management/storage`), `/api/v1/webhooks/*`, `/api/v1/integrations/*`, `/api/v1/management/me` | 100 requests | 1 minute | API key ID or session user ID |
| `/api/v2/management/*` (and other v2 authenticated routes that use `authenticatedApiClient`) | 100 requests | 1 minute | API key ID |
| `POST /api/v1/management/storage` | 5 requests | 1 minute | API key ID or session user ID |
| **Route Group** | **Limit** | **Window** | **Identifier** |
| --- | --- | --- | --- |
| `/api/v1/management/*` (except `/api/v1/management/storage`), `/api/v1/webhooks/*`, `/api/v1/integrations/*`, `/api/v1/management/me` | 100 requests | 1 minute | API key ID or session user ID |
| `/api/v2/management/*` (and other v2 authenticated routes that use `authenticatedApiClient`) | 100 requests | 1 minute | API key ID |
| `POST /api/v1/management/storage` | 5 requests | 1 minute | API key ID or session user ID |
## All Enforced Limits
| **Config** | **Limit** | **Window** | **Identifier** | **Used For** |
| ------------------------------ | ------------ | ---------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `auth.login` | 10 requests | 15 minutes | IP hash | Email/password login flow (`/api/auth/callback/credentials`) |
| `auth.signup` | 30 requests | 60 minutes | IP hash | Signup server action |
| `auth.forgotPassword` | 5 requests | 60 minutes | IP hash | Forgot password server action |
| `auth.verifyEmail` | 10 requests | 60 minutes | IP hash | Email verification callback + resend verification action |
| `api.v1` | 100 requests | 1 minute | API key ID or session user ID | v1 management, webhooks, integrations, and `/api/v1/management/me` |
| `api.v2` | 100 requests | 1 minute | API key ID | v2 authenticated API wrapper (`authenticatedApiClient`) |
| `api.client` | 100 requests | 1 minute | IP hash | v1 client API routes (except `/api/v1/client/og` and storage upload override), plus v2 routes that re-use those v1 handlers |
| `storage.upload` | 5 requests | 1 minute | IP hash or authenticated ID | Client storage upload and management storage upload |
| `storage.uploadPerEnvironment` | 100 requests | 1 minute | Environment ID | Client storage upload only (`/api/v1/client/[environmentId]/storage` and the v2 re-export) |
| `storage.delete` | 5 requests | 1 minute | API key ID or session user ID | `DELETE /storage/[environmentId]/[accessType]/[fileName]` |
| `actions.emailUpdate` | 3 requests | 60 minutes | User ID | Profile email update action |
| `actions.surveyFollowUp` | 50 requests | 60 minutes | Organization ID | Survey follow-up email processing |
| `actions.sendLinkSurveyEmail` | 10 requests | 60 minutes | IP hash | Link survey email send action |
| `actions.licenseRecheck` | 5 requests | 1 minute | User ID | Enterprise license recheck action |
| **Config** | **Limit** | **Window** | **Identifier** | **Used For** |
| --- | --- | --- | --- | --- |
| `auth.login` | 10 requests | 15 minutes | IP hash | Email/password login flow (`/api/auth/callback/credentials`) |
| `auth.signup` | 30 requests | 60 minutes | IP hash | Signup server action |
| `auth.forgotPassword` | 5 requests | 60 minutes | IP hash | Forgot password server action |
| `auth.verifyEmail` | 10 requests | 60 minutes | IP hash | Email verification callback + resend verification action |
| `api.v1` | 100 requests | 1 minute | API key ID or session user ID | v1 management, webhooks, integrations, and `/api/v1/management/me` |
| `api.v2` | 100 requests | 1 minute | API key ID | v2 authenticated API wrapper (`authenticatedApiClient`) |
| `api.client` | 100 requests | 1 minute | IP hash | v1 client API routes (except `/api/v1/client/og` and storage upload override), plus v2 routes that re-use those v1 handlers |
| `storage.upload` | 5 requests | 1 minute | IP hash or authenticated ID | Client storage upload and management storage upload |
| `storage.delete` | 5 requests | 1 minute | API key ID or session user ID | `DELETE /storage/[environmentId]/[accessType]/[fileName]` |
| `actions.emailUpdate` | 3 requests | 60 minutes | User ID | Profile email update action |
| `actions.surveyFollowUp` | 50 requests | 60 minutes | Organization ID | Survey follow-up email processing |
| `actions.sendLinkSurveyEmail` | 10 requests | 60 minutes | IP hash | Link survey email send action |
| `actions.licenseRecheck` | 5 requests | 1 minute | User ID | Enterprise license recheck action |
## Current Endpoint Exceptions
@@ -61,8 +59,8 @@ v1-style endpoints return:
```json
{
"code": "too_many_requests",
"details": {},
"message": "Maximum number of requests reached. Please try again later."
"message": "Maximum number of requests reached. Please try again later.",
"details": {}
}
```
@@ -93,5 +91,4 @@ After changing this value, restart the server.
- Redis/Valkey is required for robust rate limiting (`REDIS_URL`).
- If Redis is unavailable at runtime, rate-limiter checks currently fail open (requests are allowed through without enforcement).
- Client storage upload rate limits count signed upload URL issuance, not successful object creation in S3-compatible storage.
- Authentication failure audit logging uses a separate throttle (`shouldLogAuthFailure()`) and is intentionally **fail-closed**: when Redis is unavailable or errors occur, audit log entries are **skipped entirely** rather than written without throttle control. This prevents spam while preserving the hash-integrity chain required for compliance. In other words, if Redis is down, no authentication-failure audit logs will be recorded—requests themselves are still allowed (fail-open rate limiting above), but the audit trail for those failures will not be written.
+5 -11
View File
@@ -84,26 +84,20 @@
"pnpm": {
"overrides": {
"@hono/node-server": "1.19.13",
"@protobufjs/utf8": "1.1.1",
"@tootallnate/once": "3.0.1",
"@xmldom/xmldom": "0.9.10",
"ajv@6": "6.14.0",
"axios": "1.15.2",
"effect": "3.20.0",
"fast-uri": "3.1.2",
"fast-xml-parser": "5.7.0",
"hono": "4.12.18",
"ip-address": "10.1.1",
"fast-xml-parser": "5.5.7",
"hono": "4.12.14",
"lodash": "4.18.1",
"node-forge": "1.4.0",
"postcss": "8.5.14",
"protobufjs@7": "7.5.8",
"protobufjs@8": "8.2.0",
"tar": "7.5.15",
"uuid@11": "11.1.1"
"@opentelemetry/otlp-transformer>protobufjs": "8.0.1",
"tar": "7.5.13"
},
"comments": {
"overrides": "Security fixes for transitive dependencies that still fail a no-override audit. Remove each override when its upstream chain adopts a patched version: @hono/node-server/hono via Prisma dev tooling | @protobufjs/utf8 (CVE overlong UTF-8) - awaiting @opentelemetry/otlp-transformer update | @tootallnate/once and tar via sqlite3/node-gyp chain | @xmldom/xmldom (XML injection/DoS CVEs) - awaiting @boxyhq/saml20 to pin to >=0.9.10 | axios, lodash, and node-forge via @boxyhq/saml-jackson | ajv@6 via webpack/eslint | effect (GHSA-38f7-945m-qr2g) - awaiting @prisma/config update | fast-uri (CVE-2025-48944/48945) - awaiting ajv/schema-utils update | fast-xml-parser via AWS SDK XML builder | ip-address (XSS in Address6) - awaiting mongodb/socks update | postcss (CVE-2025-62695) - awaiting next.js to unpin postcss | protobufjs@7/8 (GHSA-xq3m-2v4x-88gg et al.) - awaiting @grpc/proto-loader/otlp-transformer update | uuid@11 (CVE-2025-61475) - awaiting typeorm update"
"overrides": "Security fixes for transitive dependencies that still fail a no-override audit. Remove each override when its upstream chain adopts a patched version: @hono/node-server/hono/effect via Prisma dev tooling | @tootallnate/once and tar via sqlite3/BoxyHQ SAML Jackson database tooling | @xmldom/xmldom, axios, lodash, and node-forge via @boxyhq/saml-jackson | ajv via @vercel/style-guide/eslint-plugin-tsdoc | protobufjs via BoxyHQ/OpenTelemetry metrics | fast-xml-parser via AWS SDK XML builder."
},
"patchedDependencies": {
"next-auth@4.24.13": "patches/next-auth@4.24.13.patch"
+1471 -816
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -289,8 +289,6 @@
"RECAPTCHA_SECRET_KEY",
"TELEMETRY_DISABLED",
"TERMS_URL",
"VERCEL",
"VERCEL_URL",
"VERSION",
"WEBAPP_URL",
"UNSPLASH_ACCESS_KEY",