Compare commits

..

1 Commits

Author SHA1 Message Date
Matti Nannt
046c56b9dd fix: sync legacy stripe payment methods 2026-03-16 17:02:01 +01:00
23 changed files with 107 additions and 1685 deletions

View File

@@ -1,246 +0,0 @@
import { ApiKeyPermission, EnvironmentType } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { getEnvironment } from "@/lib/utils/services";
import { requireSessionWorkspaceAccess, requireV3WorkspaceAccess } from "./auth";
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromProjectId: vi.fn(),
}));
vi.mock("@/lib/utils/services", () => ({
getEnvironment: vi.fn(),
}));
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
checkAuthorizationUpdated: vi.fn(),
}));
const requestId = "req-123";
describe("requireSessionWorkspaceAccess", () => {
test("returns 401 when authentication is null", async () => {
const result = await requireSessionWorkspaceAccess(null, "proj_abc", "read", requestId);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(401);
expect((result as Response).headers.get("Content-Type")).toBe("application/problem+json");
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.status).toBe(401);
expect(body.code).toBe("not_authenticated");
expect(getEnvironment).not.toHaveBeenCalled();
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
});
test("returns 401 when authentication is API key (no user)", async () => {
const result = await requireSessionWorkspaceAccess(
{ apiKeyId: "key_1", organizationId: "org_1", environmentPermissions: [] } as any,
"proj_abc",
"read",
requestId
);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(401);
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("not_authenticated");
expect(getEnvironment).not.toHaveBeenCalled();
});
test("returns 403 when workspace (environment) is not found (avoid leaking existence)", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_nonexistent",
"read",
requestId
);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(403);
expect((result as Response).headers.get("Content-Type")).toBe("application/problem+json");
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("forbidden");
expect(getEnvironment).toHaveBeenCalledWith("env_nonexistent");
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
});
test("returns 403 when user has no access to workspace", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_abc",
projectId: "proj_abc",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_1");
vi.mocked(checkAuthorizationUpdated).mockRejectedValueOnce(new AuthorizationError("Not authorized"));
const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_abc",
"read",
requestId
);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(403);
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("forbidden");
expect(checkAuthorizationUpdated).toHaveBeenCalledWith({
userId: "user_1",
organizationId: "org_1",
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", projectId: "proj_abc", minPermission: "read" },
],
});
});
test("returns workspace context when session is valid and user has access", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_abc",
projectId: "proj_abc",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_1");
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_abc",
"readWrite",
requestId
);
expect(result).not.toBeInstanceOf(Response);
expect(result).toEqual({
environmentId: "env_abc",
projectId: "proj_abc",
organizationId: "org_1",
});
expect(checkAuthorizationUpdated).toHaveBeenCalledWith({
userId: "user_1",
organizationId: "org_1",
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", projectId: "proj_abc", minPermission: "readWrite" },
],
});
});
});
const keyBase = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_k",
organizationAccess: { accessControl: { read: true, write: false } },
};
function envPerm(environmentId: string, permission: ApiKeyPermission = ApiKeyPermission.read) {
return {
environmentId,
environmentType: EnvironmentType.development,
projectId: "proj_k",
projectName: "K",
permission,
};
}
describe("requireV3WorkspaceAccess", () => {
test("401 when authentication is null", async () => {
const r = await requireV3WorkspaceAccess(null, "env_x", "read", requestId);
expect((r as Response).status).toBe(401);
});
test("delegates to session flow when user is present", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_s",
projectId: "proj_s",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_s");
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const r = await requireV3WorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_s",
"read",
requestId
);
expect(r).toEqual({
environmentId: "env_s",
projectId: "proj_s",
organizationId: "org_s",
});
});
test("returns context for API key with read on workspace", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_a", ApiKeyPermission.read)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_a", "read", requestId);
expect(r).toEqual({
environmentId: "ws_a",
projectId: "proj_k",
organizationId: "org_k",
});
});
test("returns context for API key with write on workspace", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_b", ApiKeyPermission.write)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_b", "read", requestId);
expect(r).toEqual({
environmentId: "ws_b",
projectId: "proj_k",
organizationId: "org_k",
});
});
test("403 when API key has no matching environment", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("other_env")],
};
const r = await requireV3WorkspaceAccess(auth as any, "wanted", "read", requestId);
expect((r as Response).status).toBe(403);
});
test("403 when API key permission is not list-eligible (runtime value)", async () => {
const auth = {
...keyBase,
environmentPermissions: [
{
...envPerm("ws_c"),
permission: "invalid" as unknown as ApiKeyPermission,
},
],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_c", "read", requestId);
expect((r as Response).status).toBe(403);
});
test("returns context for API key with manage on workspace", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_m", ApiKeyPermission.manage)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_m", "read", requestId);
expect(r).toEqual({
environmentId: "ws_m",
projectId: "proj_k",
organizationId: "org_k",
});
});
test("401 when auth is neither session nor valid API key payload", async () => {
const r = await requireV3WorkspaceAccess({ user: {} } as any, "env", "read", requestId);
expect((r as Response).status).toBe(401);
});
});

View File

@@ -1,112 +0,0 @@
/**
* V3 API auth — session (browser) or API key with environment-scoped access.
*/
import { ApiKeyPermission } from "@prisma/client";
import { logger } from "@formbricks/logger";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import type { TApiV1Authentication } from "@/app/lib/api/with-api-logging";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import type { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { problemForbidden, problemUnauthorized } from "./response";
import { type V3WorkspaceContext, resolveV3WorkspaceContext } from "./workspace-context";
/** read/write/manage on an API key env all allow read-only list operations. */
function apiKeyPermissionAllowsList(permission: ApiKeyPermission): boolean {
return (
permission === ApiKeyPermission.read ||
permission === ApiKeyPermission.write ||
permission === ApiKeyPermission.manage
);
}
/**
* Require session and workspace access. workspaceId is resolved via the V3 workspace-context layer.
* Returns a Response (401 or 403) on failure, or the resolved workspace context on success so callers
* use internal IDs (environmentId, projectId, organizationId) without resolving again.
* We use 403 (not 404) when the workspace is not found to avoid leaking resource existence.
*/
export async function requireSessionWorkspaceAccess(
authentication: TApiV1Authentication,
workspaceId: string,
minPermission: TTeamPermission,
requestId: string,
instance?: string
): Promise<Response | V3WorkspaceContext> {
// --- Session checks ---
if (!authentication) {
return problemUnauthorized(requestId, "Not authenticated", instance);
}
if (!("user" in authentication) || !authentication.user?.id) {
return problemUnauthorized(requestId, "Session required", instance);
}
const userId = authentication.user.id;
const log = logger.withContext({ requestId, workspaceId });
try {
// Resolve workspaceId → environmentId, projectId, organizationId (single place to change when Workspace exists).
const context = await resolveV3WorkspaceContext(workspaceId);
// Org + project-team access; we use internal IDs from context.
await checkAuthorizationUpdated({
userId,
organizationId: context.organizationId,
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", projectId: context.projectId, minPermission },
],
});
return context;
} catch (err) {
if (err instanceof ResourceNotFoundError) {
// Return 403 (not 404) so we don't leak whether the workspace exists to unauthenticated or unauthorized callers.
log.warn({ statusCode: 403, errorCode: err.name }, "Workspace not found");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
if (err instanceof AuthorizationError) {
log.warn({ statusCode: 403, errorCode: err.name }, "Forbidden");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
throw err;
}
}
/**
* Session or API key: authorize `workspaceId` for survey list (read).
* API keys must list the workspace environment with read-equivalent permission.
*/
export async function requireV3WorkspaceAccess(
authentication: TApiV1Authentication,
workspaceId: string,
minPermission: TTeamPermission,
requestId: string,
instance?: string
): Promise<Response | V3WorkspaceContext> {
if (!authentication) {
return problemUnauthorized(requestId, "Not authenticated", instance);
}
if ("user" in authentication && authentication.user?.id) {
return requireSessionWorkspaceAccess(authentication, workspaceId, minPermission, requestId, instance);
}
const keyAuth = authentication as TAuthenticationApiKey;
if (keyAuth.apiKeyId && Array.isArray(keyAuth.environmentPermissions)) {
const perm = keyAuth.environmentPermissions.find((e) => e.environmentId === workspaceId);
if (!perm || !apiKeyPermissionAllowsList(perm.permission)) {
logger
.withContext({ requestId, workspaceId })
.warn({ statusCode: 403 }, "API key not allowed for workspace");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
return {
environmentId: workspaceId,
projectId: perm.projectId,
organizationId: keyAuth.organizationId,
};
}
return problemUnauthorized(requestId, "Not authenticated", instance);
}

View File

@@ -1,95 +0,0 @@
import { describe, expect, test } from "vitest";
import {
problemBadRequest,
problemForbidden,
problemInternalError,
problemNotFound,
problemTooManyRequests,
problemUnauthorized,
successListResponse,
} from "./response";
describe("v3 problem responses", () => {
test("problemBadRequest includes invalid_params", async () => {
const res = problemBadRequest("rid", "bad", {
invalid_params: [{ name: "x", reason: "y" }],
instance: "/p",
});
expect(res.status).toBe(400);
expect(res.headers.get("X-Request-Id")).toBe("rid");
const body = await res.json();
expect(body.code).toBe("bad_request");
expect(body.requestId).toBe("rid");
expect(body.invalid_params).toEqual([{ name: "x", reason: "y" }]);
expect(body.instance).toBe("/p");
});
test("problemUnauthorized default detail", async () => {
const res = problemUnauthorized("r1");
expect(res.status).toBe(401);
const body = await res.json();
expect(body.detail).toBe("Not authenticated");
expect(body.code).toBe("not_authenticated");
});
test("problemForbidden", async () => {
const res = problemForbidden("r2", undefined, "/api/x");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
expect(body.instance).toBe("/api/x");
});
test("problemInternalError", async () => {
const res = problemInternalError("r3", "oops", "/i");
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
expect(body.detail).toBe("oops");
});
test("problemNotFound includes details", async () => {
const res = problemNotFound("r4", "Survey", "s1", "/s");
expect(res.status).toBe(404);
const body = await res.json();
expect(body.code).toBe("not_found");
expect(body.details).toEqual({ resource_type: "Survey", resource_id: "s1" });
});
test("problemTooManyRequests with Retry-After", async () => {
const res = problemTooManyRequests("r5", "slow down", 60);
expect(res.status).toBe(429);
expect(res.headers.get("Retry-After")).toBe("60");
const body = await res.json();
expect(body.code).toBe("too_many_requests");
});
test("problemTooManyRequests without Retry-After", async () => {
const res = problemTooManyRequests("r6", "nope");
expect(res.headers.get("Retry-After")).toBeNull();
});
});
describe("successListResponse", () => {
test("sets X-Request-Id and default cache", async () => {
const res = successListResponse(
[{ a: 1 }],
{ limit: 10, offset: 0, total: 1 },
{
requestId: "req-x",
}
);
expect(res.status).toBe(200);
expect(res.headers.get("X-Request-Id")).toBe("req-x");
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.json()).toEqual({
data: [{ a: 1 }],
meta: { limit: 10, offset: 0, total: 1 },
});
});
test("custom Cache-Control", async () => {
const res = successListResponse([], { limit: 5, offset: 0 }, { cache: "private, max-age=0" });
expect(res.headers.get("Cache-Control")).toBe("private, max-age=0");
});
});

View File

@@ -1,154 +0,0 @@
/**
* V3 API response helpers — RFC 9457 Problem Details (application/problem+json)
* and list envelope for success responses.
*/
const PROBLEM_JSON = "application/problem+json" as const;
const CACHE_NO_STORE = "private, no-store";
export type ProblemExtension = {
code?: string;
requestId: string;
details?: Record<string, unknown>;
invalid_params?: Array<{ name: string; reason: string }>;
};
export type ProblemBody = {
type?: string;
title: string;
status: number;
detail: string;
instance?: string;
} & ProblemExtension;
function problemResponse(
status: number,
title: string,
detail: string,
requestId: string,
options?: {
type?: string;
instance?: string;
code?: string;
details?: Record<string, unknown>;
invalid_params?: Array<{ name: string; reason: string }>;
headers?: Record<string, string>;
}
): Response {
const body: ProblemBody = {
title,
status,
detail,
requestId,
...(options?.type && { type: options.type }),
...(options?.instance && { instance: options.instance }),
...(options?.code && { code: options.code }),
...(options?.details && { details: options.details }),
...(options?.invalid_params && { invalid_params: options.invalid_params }),
};
const headers: Record<string, string> = {
"Content-Type": PROBLEM_JSON,
"Cache-Control": CACHE_NO_STORE,
"X-Request-Id": requestId,
...options?.headers,
};
return Response.json(body, { status, headers });
}
export function problemBadRequest(
requestId: string,
detail: string,
options?: { invalid_params?: Array<{ name: string; reason: string }>; instance?: string }
): Response {
return problemResponse(400, "Bad Request", detail, requestId, {
code: "bad_request",
instance: options?.instance,
invalid_params: options?.invalid_params,
});
}
export function problemUnauthorized(
requestId: string,
detail: string = "Not authenticated",
instance?: string
): Response {
return problemResponse(401, "Unauthorized", detail, requestId, {
code: "not_authenticated",
instance,
});
}
export function problemForbidden(
requestId: string,
detail: string = "You are not authorized to access this resource",
instance?: string
): Response {
return problemResponse(403, "Forbidden", detail, requestId, {
code: "forbidden",
instance,
});
}
/**
* 404 with resource details. Do not use for auth-sensitive or existence-sensitive resources:
* the body includes resource_type and resource_id, which can leak existence to unauthenticated or unauthorized callers.
* Prefer problemForbidden with a generic message for those cases.
*/
export function problemNotFound(
requestId: string,
resourceType: string,
resourceId: string | null,
instance?: string
): Response {
return problemResponse(404, "Not Found", `${resourceType} not found`, requestId, {
code: "not_found",
details: { resource_type: resourceType, resource_id: resourceId },
instance,
});
}
export function problemInternalError(
requestId: string,
detail: string = "An unexpected error occurred.",
instance?: string
): Response {
return problemResponse(500, "Internal Server Error", detail, requestId, {
code: "internal_server_error",
instance,
});
}
export function problemTooManyRequests(requestId: string, detail: string, retryAfter?: number): Response {
const headers: Record<string, string> = {};
if (retryAfter !== undefined) {
headers["Retry-After"] = String(retryAfter);
}
return problemResponse(429, "Too Many Requests", detail, requestId, {
code: "too_many_requests",
headers,
});
}
/** List envelope for GET list endpoints */
export type ListMeta = {
limit: number;
offset: number;
total?: number;
};
export function successListResponse<T>(
data: T[],
meta: ListMeta,
options?: { requestId?: string; cache?: string }
): Response {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
};
if (options?.requestId) {
headers["X-Request-Id"] = options.requestId;
}
return Response.json({ data, meta }, { status: 200, headers });
}

View File

@@ -1,38 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { getEnvironment } from "@/lib/utils/services";
import { resolveV3WorkspaceContext } from "./workspace-context";
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromProjectId: vi.fn(),
}));
vi.mock("@/lib/utils/services", () => ({
getEnvironment: vi.fn(),
}));
describe("resolveV3WorkspaceContext", () => {
test("returns environmentId, projectId and organizationId when workspace exists (today: workspaceId === environmentId)", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_abc",
projectId: "proj_xyz",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_123");
const result = await resolveV3WorkspaceContext("env_abc");
expect(result).toEqual({
environmentId: "env_abc",
projectId: "proj_xyz",
organizationId: "org_123",
});
expect(getEnvironment).toHaveBeenCalledWith("env_abc");
expect(getOrganizationIdFromProjectId).toHaveBeenCalledWith("proj_xyz");
});
test("throws when workspace (environment) does not exist", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
await expect(resolveV3WorkspaceContext("env_nonexistent")).rejects.toThrow(ResourceNotFoundError);
expect(getEnvironment).toHaveBeenCalledWith("env_nonexistent");
expect(getOrganizationIdFromProjectId).not.toHaveBeenCalled();
});
});

View File

@@ -1,50 +0,0 @@
/**
* V3 API workspace → internal IDs translation layer (retro-compatibility / future-proofing).
*
* Workspace is the default container for surveys. We are deprecating Environment and making
* Workspace that container. In the API, workspaceId refers to that container.
*
* Today: workspaceId is mapped to environmentId (Environment is the current container for surveys).
* When Environment is deprecated and Workspace exists: resolve workspaceId to the Workspace entity
* (and derive environmentId or equivalent from it). Change only this file.
*/
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { getEnvironment } from "@/lib/utils/services";
/**
* Internal IDs derived from a V3 workspace identifier.
* Today: environmentId is the workspace (Environment = container for surveys until Workspace exists).
*/
export type V3WorkspaceContext = {
/** Environment ID — the container for surveys today. Replaced by workspace when Environment is deprecated. */
environmentId: string;
/** Project ID used for projectTeam auth. */
projectId: string;
/** Organization ID used for org-level auth. */
organizationId: string;
};
/**
* Resolves a V3 API workspaceId to internal environmentId, projectId, and organizationId.
* Today: workspaceId is treated as environmentId (workspace = container for surveys = Environment).
*
* @throws ResourceNotFoundError if the workspace (environment) does not exist.
*/
export async function resolveV3WorkspaceContext(workspaceId: string): Promise<V3WorkspaceContext> {
// Today: workspaceId is the environment id (survey container). Look it up.
const environment = await getEnvironment(workspaceId);
if (!environment) {
throw new ResourceNotFoundError("environment", workspaceId);
}
// Derive org for auth; project comes from the environment.
const organizationId = await getOrganizationIdFromProjectId(environment.projectId);
// We looked up by workspaceId (as environment id), so the resolved environment id is workspaceId.
return {
environmentId: workspaceId,
projectId: environment.projectId,
organizationId,
};
}

View File

@@ -1,74 +0,0 @@
import { describe, expect, test } from "vitest";
import { collectMultiValueQueryParam, parseV3SurveysListQuery } from "./parse-v3-surveys-list-query";
const wid = "clxx1234567890123456789012";
function params(qs: string): URLSearchParams {
return new URLSearchParams(qs);
}
describe("collectMultiValueQueryParam", () => {
test("merges repeated keys and comma-separated values", () => {
const sp = params("status=draft&status=inProgress&type=link,app");
expect(collectMultiValueQueryParam(sp, "status")).toEqual(["draft", "inProgress"]);
expect(collectMultiValueQueryParam(sp, "type")).toEqual(["link", "app"]);
});
test("dedupes", () => {
const sp = params("status=draft&status=draft");
expect(collectMultiValueQueryParam(sp, "status")).toEqual(["draft"]);
});
});
describe("parseV3SurveysListQuery", () => {
test("rejects filterCriteria", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&filterCriteria={}`), {
sessionUserId: "u1",
});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.invalid_params[0].name).toBe("filterCriteria");
});
test("parses minimal query", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}`), { sessionUserId: "u1" });
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.limit).toBe(20);
expect(r.filterCriteria).toBeUndefined();
}
});
test("builds filter from flat params and sets createdBy.userId from session", () => {
const r = parseV3SurveysListQuery(
params(
`workspaceId=${wid}&name=Foo&status=inProgress&status=draft&type=link&createdBy=you&sortBy=updatedAt`
),
{ sessionUserId: "session_user" }
);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.filterCriteria).toEqual({
name: "Foo",
status: ["inProgress", "draft"],
type: ["link"],
createdBy: { userId: "session_user", value: ["you"] },
sortBy: "updatedAt",
});
}
});
test("invalid status", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&status=notastatus`), {
sessionUserId: "u1",
});
expect(r.ok).toBe(false);
});
test("createdBy not allowed without session user", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&createdBy=you`), {
sessionUserId: null,
});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.invalid_params[0].name).toBe("createdBy");
});
});

View File

@@ -1,140 +0,0 @@
/**
* Validates GET /api/v3/surveys query string and builds {@link TSurveyFilterCriteria} for list/count.
* Keeps HTTP parsing separate from the route handler and shared survey list service.
*/
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import type { TSurveyFilterCriteria } from "@formbricks/types/surveys/types";
export const V3_SURVEYS_DEFAULT_LIMIT = 20;
export const V3_SURVEYS_MAX_LIMIT = 100;
const ZStatus = z.enum(["draft", "inProgress", "paused", "completed"]);
const ZType = z.enum(["link", "app"]);
const ZCreatedBy = z.enum(["you", "others"]);
const ZSortBy = z.enum(["createdAt", "updatedAt", "name", "relevance"]);
/** Collect repeated query keys and comma-separated values: `status=a&status=b` or `status=a,b`. */
export function collectMultiValueQueryParam(searchParams: URLSearchParams, key: string): string[] {
const acc: string[] = [];
for (const raw of searchParams.getAll(key)) {
for (const part of raw.split(",")) {
const t = part.trim();
if (t) acc.push(t);
}
}
return [...new Set(acc)];
}
const ZV3SurveysListQuery = z.object({
workspaceId: ZId,
limit: z.coerce.number().int().min(1).max(V3_SURVEYS_MAX_LIMIT).default(V3_SURVEYS_DEFAULT_LIMIT),
offset: z.coerce.number().int().min(0).default(0),
name: z
.string()
.max(512)
.optional()
.transform((s) => (s === undefined || s.trim() === "" ? undefined : s.trim())),
status: z.array(ZStatus).optional(),
type: z.array(ZType).optional(),
createdBy: z.array(ZCreatedBy).optional(),
sortBy: ZSortBy.optional(),
});
export type TV3SurveysListQuery = z.infer<typeof ZV3SurveysListQuery>;
export type TV3SurveysListQueryParseResult =
| {
ok: true;
workspaceId: string;
limit: number;
offset: number;
filterCriteria: TSurveyFilterCriteria | undefined;
}
| { ok: false; invalid_params: Array<{ name: string; reason: string }> };
function buildFilterCriteria(
q: TV3SurveysListQuery,
sessionUserId: string | null
): TSurveyFilterCriteria | undefined {
const f: TSurveyFilterCriteria = {};
if (q.name) f.name = q.name;
if (q.status?.length) f.status = q.status;
if (q.type?.length) f.type = q.type;
if (q.createdBy?.length && sessionUserId) {
f.createdBy = { userId: sessionUserId, value: q.createdBy };
}
if (q.sortBy) f.sortBy = q.sortBy;
return Object.keys(f).length > 0 ? f : undefined;
}
export type TV3SurveysListQueryParseOptions = {
sessionUserId: string | null;
};
export function parseV3SurveysListQuery(
searchParams: URLSearchParams,
options: TV3SurveysListQueryParseOptions
): TV3SurveysListQueryParseResult {
const { sessionUserId } = options;
if (searchParams.has("filterCriteria")) {
return {
ok: false,
invalid_params: [
{
name: "filterCriteria",
reason:
"Not supported. Use name, status, type, createdBy, and sortBy as query parameters (see OpenAPI).",
},
],
};
}
const statusVals = collectMultiValueQueryParam(searchParams, "status");
const typeVals = collectMultiValueQueryParam(searchParams, "type");
const createdByVals = collectMultiValueQueryParam(searchParams, "createdBy");
if (createdByVals.length > 0 && sessionUserId === null) {
return {
ok: false,
invalid_params: [
{
name: "createdBy",
reason: "The createdBy filter is only supported with session authentication (not API keys).",
},
],
};
}
const raw = {
workspaceId: searchParams.get("workspaceId"),
limit: searchParams.get("limit") ?? undefined,
offset: searchParams.get("offset") ?? undefined,
name: searchParams.get("name") ?? undefined,
status: statusVals.length > 0 ? statusVals : undefined,
type: typeVals.length > 0 ? typeVals : undefined,
createdBy: createdByVals.length > 0 ? createdByVals : undefined,
sortBy: searchParams.get("sortBy")?.trim() || undefined,
};
const result = ZV3SurveysListQuery.safeParse(raw);
if (!result.success) {
return {
ok: false,
invalid_params: result.error.issues.map((issue) => ({
name: issue.path.join(".") || "query",
reason: issue.message,
})),
};
}
const q = result.data;
return {
ok: true,
workspaceId: q.workspaceId,
limit: q.limit,
offset: q.offset,
filterCriteria: buildFilterCriteria(q, sessionUserId),
};
}

View File

@@ -1,335 +0,0 @@
import { ApiKeyPermission, EnvironmentType } from "@prisma/client";
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurveyCount, getSurveys } from "@/modules/survey/list/lib/survey";
import { GET } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
return { ...actual, authenticateRequest: mockAuthenticateRequest };
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return { ...actual, AUDIT_LOG_ENABLED: false };
});
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/modules/survey/list/lib/survey", () => ({
getSurveys: vi.fn(),
getSurveyCount: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
const validWorkspaceId = "clxx1234567890123456789012";
const resolvedEnvironmentId = "clzz9876543210987654321098";
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
const headers: Record<string, string> = { ...extraHeaders };
if (requestId) headers["x-request-id"] = requestId;
return new NextRequest(url, { headers });
}
const apiKeyAuth = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: {
accessControl: { read: true, write: false },
},
environmentPermissions: [
{
environmentId: validWorkspaceId,
environmentType: EnvironmentType.development,
projectId: "proj_1",
projectName: "P",
permission: ApiKeyPermission.read,
},
],
};
describe("GET /api/v3/surveys", () => {
beforeEach(() => {
vi.resetAllMocks();
getServerSession.mockResolvedValue({
user: { id: "user_1", name: "User", email: "u@example.com" },
expires: "2026-01-01",
} as any);
mockAuthenticateRequest.mockResolvedValue(null);
vi.mocked(requireV3WorkspaceAccess).mockImplementation(async (auth, workspaceId) => {
if (auth && "apiKeyId" in auth) {
const p = auth.environmentPermissions.find((e) => e.environmentId === workspaceId);
if (!p) {
return new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
);
}
return {
environmentId: workspaceId,
projectId: p.projectId,
organizationId: auth.organizationId,
};
}
return {
environmentId: resolvedEnvironmentId,
projectId: "proj_1",
organizationId: "org_1",
};
});
vi.mocked(getSurveys).mockResolvedValue([]);
vi.mocked(getSurveyCount).mockResolvedValue(0);
});
afterEach(() => {
vi.clearAllMocks();
});
test("returns 401 when no session and no API key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
const res = await GET(req, {} as any);
expect(res.status).toBe(401);
expect(res.headers.get("Content-Type")).toBe("application/problem+json");
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 200 with session and valid workspaceId", async () => {
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-456");
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("application/json");
expect(res.headers.get("X-Request-Id")).toBe("req-456");
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
validWorkspaceId,
"read",
"req-456",
"/api/v3/surveys"
);
expect(getSurveys).toHaveBeenCalledWith(resolvedEnvironmentId, 20, 0, undefined);
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, undefined);
});
test("returns 200 with x-api-key when workspace is on the key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-k", {
"x-api-key": "fbk_test",
});
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ apiKeyId: "key_1" }),
validWorkspaceId,
"read",
"req-k",
"/api/v3/surveys"
);
expect(getSurveys).toHaveBeenCalledWith(validWorkspaceId, 20, 0, undefined);
expect(getSurveyCount).toHaveBeenCalledWith(validWorkspaceId, undefined);
});
test("returns 403 when API key does not include workspace", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue({
...apiKeyAuth,
environmentPermissions: [
{
environmentId: "claa1111111111111111111111",
environmentType: EnvironmentType.development,
projectId: "proj_x",
projectName: "X",
permission: ApiKeyPermission.read,
},
],
} as any);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, undefined, {
"x-api-key": "fbk_test",
});
const res = await GET(req, {} as any);
expect(res.status).toBe(403);
});
test("returns 400 when API key and createdBy filter", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&createdBy=you`,
undefined,
{ "x-api-key": "fbk_test" }
);
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.invalid_params?.some((p: { name: string }) => p.name === "createdBy")).toBe(true);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 400 when workspaceId is missing", async () => {
const req = createRequest("http://localhost/api/v3/surveys");
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 400 when workspaceId is not cuid2", async () => {
const req = createRequest("http://localhost/api/v3/surveys?workspaceId=not-a-cuid");
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
});
test("returns 400 when limit exceeds max", async () => {
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&limit=101`);
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
});
test("reflects limit, offset and total in meta", async () => {
vi.mocked(getSurveyCount).mockResolvedValue(42);
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&limit=10&offset=5`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.meta).toEqual({ limit: 10, offset: 5, total: 42 });
expect(getSurveys).toHaveBeenCalledWith(resolvedEnvironmentId, 10, 5, undefined);
});
test("passes filter query to getSurveys and getSurveyCount", async () => {
const filterCriteria = { status: ["inProgress"], sortBy: "updatedAt" as const };
vi.mocked(getSurveyCount).mockResolvedValue(7);
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&status=inProgress&sortBy=updatedAt`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
expect(getSurveys).toHaveBeenCalledWith(resolvedEnvironmentId, 20, 0, filterCriteria);
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, filterCriteria);
});
test("createdBy uses session user id", async () => {
const expectedForDb = {
createdBy: { userId: "user_1", value: ["you" as const] },
sortBy: "updatedAt" as const,
};
vi.mocked(getSurveyCount).mockResolvedValue(1);
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&createdBy=you&sortBy=updatedAt`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
expect(getSurveys).toHaveBeenCalledWith(resolvedEnvironmentId, 20, 0, expectedForDb);
});
test("returns 400 when filterCriteria is used", async () => {
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filterCriteria=${encodeURIComponent("{}")}`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 403 when auth returns 403", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce(
new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req-789",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
)
);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
const res = await GET(req, {} as any);
expect(res.status).toBe(403);
});
test("list items omit blocks and questions", async () => {
vi.mocked(getSurveys).mockResolvedValue([
{
id: "s1",
name: "Survey 1",
environmentId: "env_1",
type: "link",
status: "draft",
createdAt: new Date(),
updatedAt: new Date(),
responseCount: 0,
creator: { name: "Test" },
singleUse: null,
} as any,
]);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
const res = await GET(req, {} as any);
const body = await res.json();
expect(body.data[0]).not.toHaveProperty("blocks");
expect(body.data[0]).not.toHaveProperty("singleUse");
expect(body.data[0].id).toBe("s1");
});
test("returns 403 when getSurveys throws ResourceNotFoundError", async () => {
vi.mocked(getSurveys).mockRejectedValueOnce(new ResourceNotFoundError("survey", "s1"));
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-nf");
const res = await GET(req, {} as any);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
});
test("returns 500 when getSurveys throws DatabaseError", async () => {
vi.mocked(getSurveys).mockRejectedValueOnce(new DatabaseError("db down"));
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-db");
const res = await GET(req, {} as any);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
test("returns 500 on unexpected error from getSurveys", async () => {
vi.mocked(getSurveys).mockRejectedValueOnce(new Error("boom"));
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-err");
const res = await GET(req, {} as any);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
});

View File

@@ -1,100 +0,0 @@
/**
* GET /api/v3/surveys — list surveys for a workspace.
* Session cookie or x-api-key; scope by workspaceId only.
*/
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import {
problemBadRequest,
problemForbidden,
problemInternalError,
problemUnauthorized,
successListResponse,
} from "@/app/api/v3/lib/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getSurveyCount, getSurveys } from "@/modules/survey/list/lib/survey";
import type { TSurvey } from "@/modules/survey/list/types/surveys";
import { parseV3SurveysListQuery } from "./parse-v3-surveys-list-query";
/** V3 list payload omits `singleUse` (app overview still loads it via server actions). */
function toV3SurveyListItem(survey: TSurvey): Omit<TSurvey, "singleUse"> {
const { singleUse: _omit, ...rest } = survey;
return rest;
}
export const GET = withV1ApiWrapper({
unauthenticatedResponse: (req) => {
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
return problemUnauthorized(requestId, "Not authenticated", req.nextUrl.pathname);
},
handler: async ({ req, authentication }) => {
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
const log = logger.withContext({ requestId });
const instance = new URL(req.url).pathname;
try {
if (!authentication) {
return { response: problemUnauthorized(requestId, "Not authenticated", instance) };
}
const sessionUserId =
"user" in authentication && authentication.user?.id ? authentication.user.id : null;
const searchParams = new URL(req.url).searchParams;
const parsed = parseV3SurveysListQuery(searchParams, { sessionUserId });
if (!parsed.ok) {
log.warn({ statusCode: 400, invalidParams: parsed.invalid_params }, "Validation failed");
return {
response: problemBadRequest(requestId, "Invalid query parameters", {
invalid_params: parsed.invalid_params,
instance,
}),
};
}
const authResult = await requireV3WorkspaceAccess(
authentication,
parsed.workspaceId,
"read",
requestId,
instance
);
if (authResult instanceof Response) {
return { response: authResult };
}
const { environmentId } = authResult;
const [surveys, total] = await Promise.all([
getSurveys(environmentId, parsed.limit, parsed.offset, parsed.filterCriteria),
getSurveyCount(environmentId, parsed.filterCriteria),
]);
return {
response: successListResponse(
surveys.map(toV3SurveyListItem),
{
limit: parsed.limit,
offset: parsed.offset,
total,
},
{ requestId, cache: "private, no-store" }
),
};
} catch (err) {
if (err instanceof ResourceNotFoundError) {
log.warn({ statusCode: 403, errorCode: err.name }, "Resource not found");
return {
response: problemForbidden(requestId, "You are not authorized to access this resource", instance),
};
}
if (err instanceof DatabaseError) {
log.error({ error: err, statusCode: 500 }, "Database error");
return { response: problemInternalError(requestId, "An unexpected error occurred.", instance) };
}
log.error({ error: err, statusCode: 500 }, "V3 surveys list unexpected error");
return { response: problemInternalError(requestId, "An unexpected error occurred.", instance) };
}
},
});

View File

@@ -421,38 +421,6 @@ describe("withV1ApiWrapper", () => {
expect(handler).not.toHaveBeenCalled();
});
test("uses unauthenticatedResponse when provided instead of default 401", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { getServerSession } = await import("next-auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Session,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(getServerSession).mockResolvedValue(null);
const custom401 = new Response(JSON.stringify({ title: "Custom", status: 401 }), {
status: 401,
headers: { "Content-Type": "application/problem+json" },
});
const handler = vi.fn();
const req = createMockRequest({ url: "https://api.test/api/v3/surveys" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({
handler,
unauthenticatedResponse: () => custom401,
});
const res = await wrapped(req, undefined);
expect(res).toBe(custom401);
expect(handler).not.toHaveBeenCalled();
expect(mockContextualLoggerError).toHaveBeenCalled();
});
test("handles rate limiting errors", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =

View File

@@ -38,11 +38,6 @@ export interface TWithV1ApiWrapperParams<TResult extends { response: Response },
action?: TAuditAction;
targetType?: TAuditTarget;
customRateLimitConfig?: TRateLimitConfig;
/**
* When the route requires auth but the client is unauthenticated, the wrapper normally returns
* the legacy JSON 401. Use this to return a custom response (e.g. RFC 9457 problem+json for V3).
*/
unauthenticatedResponse?: (req: NextRequest) => Response;
}
enum ApiV1RouteTypeEnum {
@@ -270,7 +265,7 @@ const getRouteType = (
export const withV1ApiWrapper = <TResult extends { response: Response }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const { handler, action, targetType, customRateLimitConfig, unauthenticatedResponse } = params;
const { handler, action, targetType, customRateLimitConfig } = params;
return async (req: NextRequest, props: TProps): Promise<Response> => {
// === Audit Log Setup ===
const saveAuditLog = action && targetType;
@@ -292,11 +287,6 @@ export const withV1ApiWrapper = <TResult extends { response: Response }, TProps
const authentication = await handleAuthentication(authenticationMethod, req);
if (!authentication && routeType !== ApiV1RouteTypeEnum.Client) {
if (unauthenticatedResponse) {
const res = unauthenticatedResponse(req);
await processResponse(res, req, auditLog);
return res;
}
return responses.notAuthenticatedResponse();
}

View File

@@ -90,17 +90,6 @@ describe("endpoint-validator", () => {
});
describe("isManagementApiRoute", () => {
test("should return Both for v3 surveys routes", () => {
expect(isManagementApiRoute("/api/v3/surveys")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Both,
});
expect(isManagementApiRoute("/api/v3/surveys/clxxxxxxxxxxxxxxxxxxxxxxxx")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Both,
});
});
test("should return correct object for management API routes with API key authentication", () => {
expect(isManagementApiRoute("/api/v1/management/something")).toEqual({
isManagementApi: true,

View File

@@ -22,9 +22,6 @@ export const isClientSideApiRoute = (url: string): { isClientSideApi: boolean; i
export const isManagementApiRoute = (
url: string
): { isManagementApi: boolean; authenticationMethod: AuthenticationMethod } => {
// V3 surveys: session cookie or x-api-key (same pattern as management storage)
if (/^\/api\/v3\/surveys(?:\/|$)/.test(url))
return { isManagementApi: true, authenticationMethod: AuthenticationMethod.Both };
if (url.includes("/api/v1/management/storage"))
return { isManagementApi: true, authenticationMethod: AuthenticationMethod.Both };
if (url.includes("/api/v1/webhooks"))

View File

@@ -33,14 +33,14 @@ describe("Password Management", () => {
const hashedPassword = await hashPassword(password);
const isValid = await verifyPassword(password, hashedPassword);
expect(isValid).toBe(true);
});
}, 15000);
test("verifyPassword should reject an incorrect password", async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
const isValid = await verifyPassword("wrongPassword", hashedPassword);
expect(isValid).toBe(false);
});
}, 15000);
});
describe("Organization Access", () => {

View File

@@ -34,7 +34,7 @@ describe("Crypto Utils", () => {
const isValid = await verifySecret(secret, hash);
expect(isValid).toBe(true);
});
}, 15000);
test("should reject wrong secrets", async () => {
const secret = "test-secret-123";
@@ -43,7 +43,7 @@ describe("Crypto Utils", () => {
const isValid = await verifySecret(wrongSecret, hash);
expect(isValid).toBe(false);
});
}, 15000);
test("should generate different hashes for the same secret (due to salt)", async () => {
const secret = "test-secret-123";
@@ -64,7 +64,7 @@ describe("Crypto Utils", () => {
// Verify the cost factor is in the hash
expect(hash).toMatch(/^\$2[aby]\$10\$/);
expect(await verifySecret(secret, hash)).toBe(true);
});
}, 15000);
test("should return false for invalid hash format", async () => {
const secret = "test-secret-123";

View File

@@ -1021,6 +1021,6 @@ describe("updateSurveyDraftAction", () => {
// Expect validation error (skipValidation = false)
await expect(updateSurveyInternal(incompleteSurvey, false)).rejects.toThrow();
});
}, 15000);
});
});

View File

@@ -159,6 +159,12 @@ describe("organization-billing", () => {
mocks.getCloudPlanFromProduct.mockReturnValue("pro");
mocks.subscriptionsList.mockResolvedValue({ data: [] });
mocks.customersList.mockResolvedValue({ data: [] });
mocks.customersRetrieve.mockResolvedValue({
id: "cus_1",
deleted: false,
invoice_settings: { default_payment_method: null },
default_source: null,
});
mocks.prismaMembershipFindFirst.mockResolvedValue(null);
mocks.productsList.mockResolvedValue({
data: [
@@ -639,6 +645,64 @@ describe("organization-billing", () => {
expect(mocks.cacheDel).toHaveBeenCalledWith(["billing-cache-key"]);
});
test("syncOrganizationBillingFromStripe marks migrated customers with customer-level payment methods", async () => {
mocks.prismaOrganizationBillingFindUnique.mockResolvedValue({
stripeCustomerId: "cus_1",
limits: {
projects: 3,
monthly: {
responses: 1500,
},
},
usageCycleAnchor: new Date(),
stripe: { lastSyncedEventId: null },
});
mocks.subscriptionsList.mockResolvedValue({
data: [
{
id: "sub_1",
status: "active",
default_payment_method: null,
billing_cycle_anchor: 1739923200,
items: {
data: [
{
price: {
metadata: {},
product: { id: "prod_pro", metadata: { formbricks_plan: "pro" } },
recurring: { usage_type: "licensed", interval: "month" },
},
},
],
},
},
],
});
mocks.customersRetrieve.mockResolvedValue({
id: "cus_1",
deleted: false,
invoice_settings: { default_payment_method: "pm_legacy_default" },
default_source: null,
});
mocks.entitlementsList.mockResolvedValue({
data: [],
has_more: false,
});
const result = await syncOrganizationBillingFromStripe("org_1");
expect(result?.stripe?.hasPaymentMethod).toBe(true);
expect(mocks.prismaOrganizationBillingUpdate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
stripe: expect.objectContaining({
hasPaymentMethod: true,
}),
}),
})
);
});
test("createPaidPlanCheckoutSession rejects mixed-interval yearly checkout", async () => {
await expect(
createPaidPlanCheckoutSession({

View File

@@ -1107,6 +1107,21 @@ const resolvePendingPlanChange = async (subscription: Stripe.Subscription | null
return null;
};
const resolveHasPaymentMethod = (
subscription: Stripe.Subscription | null,
customer: Stripe.Customer | Stripe.DeletedCustomer
) => {
if (subscription?.default_payment_method != null) {
return true;
}
if (customer.deleted) {
return false;
}
return customer.invoice_settings.default_payment_method != null || customer.default_source != null;
};
export const syncOrganizationBillingFromStripe = async (
organizationId: string,
event?: { id: string; created: number }
@@ -1132,9 +1147,10 @@ export const syncOrganizationBillingFromStripe = async (
return billing;
}
const [subscription, featureLookupKeys] = await Promise.all([
const [subscription, featureLookupKeys, customer] = await Promise.all([
resolveCurrentSubscription(customerId),
listAllActiveEntitlements(customerId),
stripeClient.customers.retrieve(customerId),
]);
const cloudPlan = resolveCloudPlanFromSubscription(subscription);
@@ -1160,7 +1176,7 @@ export const syncOrganizationBillingFromStripe = async (
interval: billingInterval,
subscriptionStatus,
subscriptionId: subscription?.id ?? null,
hasPaymentMethod: subscription?.default_payment_method != null,
hasPaymentMethod: resolveHasPaymentMethod(subscription, customer),
features: featureLookupKeys,
pendingChange,
lastStripeEventCreatedAt: toIsoStringOrNull(incomingEventDate ?? previousEventDate),

View File

@@ -605,26 +605,22 @@ export const copySurveyToOtherEnvironment = async (
}
};
/** Count surveys in an environment, optionally with the same filter as getSurveys (so total matches list). */
export const getSurveyCount = reactCache(
async (environmentId: string, filterCriteria?: TSurveyFilterCriteria): Promise<number> => {
validateInputs([environmentId, z.cuid2()]);
try {
const surveyCount = await prisma.survey.count({
where: {
environmentId,
...buildWhereClause(filterCriteria),
},
});
export const getSurveyCount = reactCache(async (environmentId: string): Promise<number> => {
validateInputs([environmentId, z.cuid2()]);
try {
const surveyCount = await prisma.survey.count({
where: {
environmentId: environmentId,
},
});
return surveyCount;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error getting survey count");
throw new DatabaseError(error.message);
}
throw error;
return surveyCount;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error getting survey count");
throw new DatabaseError(error.message);
}
throw error;
}
);
});

View File

@@ -1,254 +0,0 @@
# V3 API — GET Surveys (hand-maintained; not generated by generate-api-specs).
# Implementation: apps/web/app/api/v3/surveys/route.ts
# See apps/web/app/api/v3/README.md and docs/Survey-Server-Actions.md (Part III) for full context.
openapi: 3.1.0
info:
title: Formbricks API v3 (Surveys)
description: |
**GET /api/v3/surveys** — authenticate with **session cookie** or **`x-api-key`** (management key with access to the workspace environment).
**Spec location:** `docs/api-v3-reference/openapi-surveys.yml` (alongside v2 at `docs/api-v2-reference/openapi.yml`).
**workspaceId (today)**
Query param `workspaceId` is the **environment id** (survey container in the DB). The API uses the name *workspace* because the product is moving toward **Workspace** as the default container; until that exists, resolution is implemented in `workspace-context.ts` (single place to change when Environment is deprecated).
**Auth order**
Session is tried first, then API key. Unauthenticated callers get **401** before query validation. **`createdBy` is session-only** (API key + `createdBy` → **400**).
**Pagination**
**limit** + **offset** (not cursor). Suited to page-based UIs (page 1/2/3, previous/next). Cursor-based pagination is intentionally **not** offered here (one-directional “next” only); revisit only for a high-volume feed-style endpoint.
**Filtering**
Filters and sort are **flat query parameters** (`name`, `status`, `type`, `createdBy`, `sortBy`). Arrays use repeated keys or comma-separated values (e.g. `status=draft&status=inProgress`).
**Security**
Missing/forbidden workspace returns **403** with a generic message (not **404**) so resource existence is not leaked. List responses use `private, no-store`.
**OpenAPI**
This YAML is **not** produced by `pnpm generate-api-specs` (that script only builds v2 → `docs/api-v2-reference/openapi.yml`). Update this file when the route contract changes.
**Next steps (out of scope for this spec)**
Additional v3 survey endpoints (single survey, CRUD), frontend cutover from `getSurveysAction`, optional ETag/304, field selection — see Survey-Server-Actions.md Part III.
version: 0.1.0
x-implementation-notes:
route: apps/web/app/api/v3/surveys/route.ts
query-parser: apps/web/app/api/v3/surveys/parse-v3-surveys-list-query.ts
auth: apps/web/app/api/v3/lib/auth.ts
workspace-resolution: apps/web/app/api/v3/lib/workspace-context.ts
openapi-generated: false
pagination-model: offset
cursor-pagination: not-supported
paths:
/api/v3/surveys:
get:
operationId: getSurveysV3
summary: List surveys
description: Returns surveys for the workspace. Session cookie or x-api-key.
tags:
- V3 Surveys
parameters:
- in: query
name: workspaceId
required: true
schema:
type: string
format: cuid2
description: |
Workspace identifier. **Today:** pass the **environment id** (the environment that contains the surveys). When Workspace replaces Environment in the data model, clients may pass workspace ids instead; resolution is centralized in workspace-context.
- in: query
name: limit
schema:
type: integer
minimum: 1
maximum: 100
default: 20
description: Page size (max 100)
- in: query
name: offset
schema:
type: integer
minimum: 0
default: 0
description: Pagination offset (0-based). No cursor/`after` token — see API info for rationale.
- in: query
name: name
schema:
type: string
maxLength: 512
description: Case-insensitive substring match on survey name (same as in-app list filters).
- in: query
name: status
schema:
type: array
items:
type: string
enum: [draft, inProgress, paused, completed]
style: form
explode: true
description: |
Survey status filter. Repeat the parameter (`status=draft&status=inProgress`) or use comma-separated values (`status=draft,inProgress`). Invalid values → **400**.
- in: query
name: type
schema:
type: array
items:
type: string
enum: [link, app]
style: form
explode: true
description: Survey type filter (`link` / `app`). Same repeat-or-comma rules as `status`.
- in: query
name: createdBy
schema:
type: array
items:
type: string
enum: [you, others]
style: form
explode: true
description: |
**Session only:** creator scope (`you` / `others`); server applies the signed-in user. **Not supported with API keys** (400).
- in: query
name: sortBy
schema:
type: string
enum: [createdAt, updatedAt, name, relevance]
description: Sort order. Default is service-defined when omitted.
responses:
"200":
description: Surveys retrieved successfully
headers:
X-Request-Id:
schema: { type: string }
description: Request correlation ID
Cache-Control:
schema: { type: string }
example: "private, no-store"
content:
application/json:
schema:
type: object
required: [data, meta]
properties:
data:
type: array
items:
$ref: "#/components/schemas/SurveyListItem"
meta:
type: object
required: [limit, offset, total]
properties:
limit: { type: integer }
offset: { type: integer }
total:
type: integer
description: Count of surveys matching the same filters as this list request
"400":
description: Bad Request
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"401":
description: Not authenticated (no valid session or API key)
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"403":
description: Forbidden — no access, or workspace/environment does not exist (404 not used; avoids existence leak)
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"429":
description: |
Rate limit exceeded (wrapper). Uses the **legacy v1 JSON** shape from `responses.tooManyRequestsResponse`.
content:
application/json:
schema:
$ref: "#/components/schemas/LegacyTooManyRequests"
"500":
description: |
Handler errors return RFC 9457 Problem Details. Uncaught errors from the API wrapper may return legacy v1 JSON (`code` / `message` / `details`).
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
application/json:
schema:
$ref: "#/components/schemas/LegacyInternalError"
security:
- sessionAuth: []
- apiKeyAuth: []
components:
securitySchemes:
sessionAuth:
type: apiKey
in: cookie
name: next-auth.session-token
description: |
NextAuth session JWT cookie. **Development:** often `next-auth.session-token`.
**Production (HTTPS):** often `__Secure-next-auth.session-token`. Send the cookie your browser receives after sign-in.
apiKeyAuth:
type: apiKey
in: header
name: x-api-key
description: |
Management API key; must include **workspaceId** as an allowed environment with read, write, or manage permission.
schemas:
SurveyListItem:
type: object
description: |
Shape from `getSurveys` (`surveySelect` + `responseCount`). Serialized dates are ISO 8601 strings.
Legacy DB rows may include survey **type** values `website` or `web` (see Prisma); filter **type** only accepts `link` | `app`.
properties:
id: { type: string }
name: { type: string }
environmentId: { type: string }
type: { type: string, enum: [link, app, website, web] }
status:
type: string
enum: [draft, inProgress, paused, completed]
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
responseCount: { type: integer }
creator: { type: object, nullable: true, properties: { name: { type: string } } }
Problem:
type: object
description: RFC 9457 Problem Details for HTTP APIs (`application/problem+json`). Responses typically include a machine-readable `code` field alongside `title`, `status`, `detail`, and `requestId`.
required: [title, status, detail, requestId]
properties:
type: { type: string, format: uri }
title: { type: string }
status: { type: integer }
detail: { type: string }
instance: { type: string }
code:
type: string
enum: [bad_request, not_authenticated, forbidden, internal_server_error]
requestId: { type: string }
details: { type: object }
invalid_params:
type: array
items:
type: object
properties:
name: { type: string }
reason: { type: string }
LegacyTooManyRequests:
type: object
required: [code, message, details]
properties:
code: { type: string, enum: [too_many_requests] }
message: { type: string }
details: { type: object }
LegacyInternalError:
type: object
required: [code, message, details]
properties:
code: { type: string, enum: [internal_server_error] }
message: { type: string }
details: { type: object }

View File

@@ -1,4 +1,4 @@
import { type z } from "zod";
import { z } from "zod";
import type { TI18nString } from "../i18n";
import type { TSurveyLanguage } from "./types";
import { getTextContent } from "./validation";

View File

@@ -1,5 +1,5 @@
import { parse } from "node-html-parser";
import { type z } from "zod";
import { z } from "zod";
import type { TI18nString } from "../i18n";
import type { TConditionGroup, TSingleCondition } from "./logic";
import type {