mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-16 23:53:36 -05:00
Compare commits
1 Commits
release/4.
...
feat/v3-ge
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d96cc1d005 |
205
apps/web/app/api/v3/app/surveys/route.test.ts
Normal file
205
apps/web/app/api/v3/app/surveys/route.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { requireSessionWorkspaceAccess } from "@/app/api/v3/lib/auth";
|
||||
import { getSurveyCount, getSurveys } from "@/modules/survey/list/lib/survey";
|
||||
import { GET } from "./route";
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
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", () => ({
|
||||
requireSessionWorkspaceAccess: 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";
|
||||
|
||||
function createRequest(url: string, requestId?: string): NextRequest {
|
||||
const headers: Record<string, string> = {};
|
||||
if (requestId) headers["x-request-id"] = requestId;
|
||||
return new NextRequest(url, { headers });
|
||||
}
|
||||
|
||||
describe("GET /api/v3/app/surveys", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
getServerSession.mockResolvedValue({
|
||||
user: { id: "user_1", name: "User", email: "u@example.com" },
|
||||
expires: "2026-01-01",
|
||||
} as any);
|
||||
vi.mocked(requireSessionWorkspaceAccess).mockResolvedValue({
|
||||
environmentId: validWorkspaceId,
|
||||
projectId: "proj_1",
|
||||
organizationId: "org_1",
|
||||
});
|
||||
vi.mocked(getSurveys).mockResolvedValue([]);
|
||||
vi.mocked(getSurveyCount).mockResolvedValue(0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns 401 when no session", async () => {
|
||||
getServerSession.mockResolvedValue(null);
|
||||
const req = createRequest(`http://localhost/api/v3/app/surveys?workspaceId=${validWorkspaceId}`);
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(401);
|
||||
expect(requireSessionWorkspaceAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 200 with list envelope when session and valid workspaceId", async () => {
|
||||
const req = createRequest(
|
||||
`http://localhost/api/v3/app/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(res.headers.get("Cache-Control")).toContain("no-store");
|
||||
const body = await res.json();
|
||||
expect(body).toHaveProperty("data");
|
||||
expect(body).toHaveProperty("meta");
|
||||
expect(Array.isArray(body.data)).toBe(true);
|
||||
expect(body.meta).toEqual({ limit: 20, offset: 0, total: 0 });
|
||||
expect(requireSessionWorkspaceAccess).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
validWorkspaceId,
|
||||
"read",
|
||||
"req-456",
|
||||
"/api/v3/app/surveys"
|
||||
);
|
||||
expect(getSurveys).toHaveBeenCalledWith(validWorkspaceId, 20, 0, undefined);
|
||||
});
|
||||
|
||||
test("returns 400 when workspaceId is missing", async () => {
|
||||
const req = createRequest("http://localhost/api/v3/app/surveys");
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.headers.get("Content-Type")).toBe("application/problem+json");
|
||||
const body = await res.json();
|
||||
expect(body.requestId).toBeDefined();
|
||||
expect(body.status).toBe(400);
|
||||
expect(body.invalid_params).toBeDefined();
|
||||
expect(requireSessionWorkspaceAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 400 when workspaceId is not cuid2", async () => {
|
||||
const req = createRequest("http://localhost/api/v3/app/surveys?workspaceId=not-a-cuid");
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.status).toBe(400);
|
||||
expect(body.invalid_params).toBeDefined();
|
||||
});
|
||||
|
||||
test("returns 400 when limit exceeds max", async () => {
|
||||
const req = createRequest(
|
||||
`http://localhost/api/v3/app/surveys?workspaceId=${validWorkspaceId}&limit=101`
|
||||
);
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.status).toBe(400);
|
||||
});
|
||||
|
||||
test("reflects limit, offset and total in meta and passes to getSurveys", async () => {
|
||||
vi.mocked(getSurveyCount).mockResolvedValue(42);
|
||||
const req = createRequest(
|
||||
`http://localhost/api/v3/app/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(validWorkspaceId, 10, 5, undefined);
|
||||
expect(getSurveyCount).toHaveBeenCalledWith(validWorkspaceId, undefined);
|
||||
});
|
||||
|
||||
test("passes filterCriteria to getSurveys and getSurveyCount so total matches filter", async () => {
|
||||
const filterCriteria = { status: ["inProgress"], sortBy: "updatedAt" as const };
|
||||
vi.mocked(getSurveys).mockResolvedValue([]);
|
||||
vi.mocked(getSurveyCount).mockResolvedValue(7);
|
||||
const req = createRequest(
|
||||
`http://localhost/api/v3/app/surveys?workspaceId=${validWorkspaceId}&filterCriteria=${encodeURIComponent(JSON.stringify(filterCriteria))}`
|
||||
);
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.meta.total).toBe(7);
|
||||
expect(getSurveys).toHaveBeenCalledWith(validWorkspaceId, 20, 0, filterCriteria);
|
||||
expect(getSurveyCount).toHaveBeenCalledWith(validWorkspaceId, filterCriteria);
|
||||
});
|
||||
|
||||
test("returns 403 when auth helper returns 403", async () => {
|
||||
const forbiddenResponse = 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" } }
|
||||
);
|
||||
vi.mocked(requireSessionWorkspaceAccess).mockResolvedValueOnce(forbiddenResponse);
|
||||
const req = createRequest(`http://localhost/api/v3/app/surveys?workspaceId=${validWorkspaceId}`);
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.headers.get("Content-Type")).toBe("application/problem+json");
|
||||
const body = await res.json();
|
||||
expect(body.requestId).toBeDefined();
|
||||
expect(body.status).toBe(403);
|
||||
});
|
||||
|
||||
test("success response does not include blocks/questions in list items", async () => {
|
||||
const minimalSurvey = {
|
||||
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,
|
||||
};
|
||||
vi.mocked(getSurveys).mockResolvedValue([minimalSurvey as any]);
|
||||
const req = createRequest(`http://localhost/api/v3/app/surveys?workspaceId=${validWorkspaceId}`);
|
||||
const res = await GET(req, {} as any);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0]).not.toHaveProperty("blocks");
|
||||
expect(body.data[0]).not.toHaveProperty("questions");
|
||||
expect(body.data[0].id).toBe("s1");
|
||||
expect(body.data[0].responseCount).toBe(0);
|
||||
});
|
||||
});
|
||||
150
apps/web/app/api/v3/app/surveys/route.ts
Normal file
150
apps/web/app/api/v3/app/surveys/route.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* GET /api/v3/app/surveys — list surveys for a workspace.
|
||||
* Session auth; scope by workspaceId only (no environmentId in the API).
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZSurveyFilterCriteria } from "@formbricks/types/surveys/types";
|
||||
import { requireSessionWorkspaceAccess } from "@/app/api/v3/lib/auth";
|
||||
import {
|
||||
problem400,
|
||||
problem401,
|
||||
problem403,
|
||||
problem500,
|
||||
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";
|
||||
|
||||
const DEFAULT_LIMIT = 20;
|
||||
const MAX_LIMIT = 100;
|
||||
|
||||
/** Query schema: workspaceId required; limit/offset/filterCriteria optional. */
|
||||
const ZQuery = z.object({
|
||||
workspaceId: z.string().cuid2(),
|
||||
limit: z.coerce.number().int().min(1).max(MAX_LIMIT).default(DEFAULT_LIMIT),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
filterCriteria: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Parse optional JSON filterCriteria from query; invalid JSON is treated as missing. */
|
||||
function parseFilterCriteria(value: string | undefined): z.infer<typeof ZSurveyFilterCriteria> | undefined {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
const result = ZSurveyFilterCriteria.safeParse(parsed);
|
||||
return result.success ? result.data : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ req, authentication }) => {
|
||||
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
|
||||
const log = logger.withContext({ requestId });
|
||||
// Instance for problem responses; path only, we do not read query until after auth.
|
||||
const instance = new URL(req.url).pathname;
|
||||
|
||||
// --- Auth first (security): never read or validate query params before confirming session.
|
||||
// Unauthenticated requests must get 401 without leaking anything about the request (e.g. missing workspaceId).
|
||||
if (!authentication || !("user" in authentication) || !authentication.user?.id) {
|
||||
return { response: problem401(requestId, "Not authenticated", instance) };
|
||||
}
|
||||
|
||||
// --- Parse and validate query (only after auth) ---
|
||||
const searchParams = new URL(req.url).searchParams;
|
||||
const workspaceId = searchParams.get("workspaceId");
|
||||
const limitParam = searchParams.get("limit");
|
||||
const offsetParam = searchParams.get("offset");
|
||||
const filterCriteriaParam = searchParams.get("filterCriteria");
|
||||
|
||||
const queryResult = ZQuery.safeParse({
|
||||
workspaceId,
|
||||
limit: limitParam ?? undefined,
|
||||
offset: offsetParam ?? undefined,
|
||||
filterCriteria: filterCriteriaParam ?? undefined,
|
||||
});
|
||||
|
||||
if (!queryResult.success) {
|
||||
const invalidParams = queryResult.error.issues.map((issue) => ({
|
||||
name: issue.path.join(".") || "query",
|
||||
reason: issue.message,
|
||||
}));
|
||||
log.warn({ statusCode: 400, invalidParams }, "Validation failed");
|
||||
const res = problem400(requestId, "Invalid query parameters", {
|
||||
invalid_params: invalidParams,
|
||||
instance,
|
||||
});
|
||||
return { response: res };
|
||||
}
|
||||
|
||||
const filterCriteria = parseFilterCriteria(queryResult.data.filterCriteria);
|
||||
// Client sent filterCriteria but it was invalid JSON or didn't match schema → 400
|
||||
if (
|
||||
filterCriteriaParam !== null &&
|
||||
filterCriteriaParam !== undefined &&
|
||||
filterCriteriaParam !== "" &&
|
||||
filterCriteria === undefined
|
||||
) {
|
||||
log.warn({ statusCode: 400 }, "Invalid filterCriteria JSON");
|
||||
const res = problem400(requestId, "Invalid filterCriteria", {
|
||||
invalid_params: [
|
||||
{ name: "filterCriteria", reason: "Must be valid JSON matching filter criteria schema" },
|
||||
],
|
||||
instance,
|
||||
});
|
||||
return { response: res };
|
||||
}
|
||||
|
||||
// --- Auth: session + workspace access; returns context (environmentId, projectId, organizationId) or error Response
|
||||
const authResult = await requireSessionWorkspaceAccess(
|
||||
authentication ?? null,
|
||||
queryResult.data.workspaceId,
|
||||
"read",
|
||||
requestId,
|
||||
instance
|
||||
);
|
||||
if (authResult instanceof Response) {
|
||||
return { response: authResult };
|
||||
}
|
||||
|
||||
const { environmentId } = authResult;
|
||||
|
||||
// --- Load surveys and total count in parallel (same filter so total matches list)
|
||||
try {
|
||||
const [surveys, total] = await Promise.all([
|
||||
getSurveys(environmentId, queryResult.data.limit, queryResult.data.offset, filterCriteria),
|
||||
getSurveyCount(environmentId, filterCriteria),
|
||||
]);
|
||||
|
||||
return {
|
||||
response: successListResponse(
|
||||
surveys,
|
||||
{
|
||||
limit: queryResult.data.limit,
|
||||
offset: queryResult.data.offset,
|
||||
total,
|
||||
},
|
||||
{ requestId, cache: "private, no-store" }
|
||||
),
|
||||
};
|
||||
} catch (err) {
|
||||
// Map known errors to problem responses; rethrow the rest.
|
||||
// Use 403 (not 404) for ResourceNotFoundError to avoid leaking resource type/id existence.
|
||||
if (err instanceof ResourceNotFoundError) {
|
||||
log.warn({ statusCode: 403, errorCode: err.name }, "Resource not found");
|
||||
return {
|
||||
response: problem403(requestId, "You are not authorized to access this resource", instance),
|
||||
};
|
||||
}
|
||||
if (err instanceof DatabaseError) {
|
||||
log.error({ error: err, statusCode: 500 }, "Database error");
|
||||
return { response: problem500(requestId, "An unexpected error occurred.", instance) };
|
||||
}
|
||||
log.error({ error: err, statusCode: 500 }, "Unexpected error");
|
||||
return { response: problem500(requestId, "An unexpected error occurred.", instance) };
|
||||
}
|
||||
},
|
||||
});
|
||||
125
apps/web/app/api/v3/lib/auth.test.ts
Normal file
125
apps/web/app/api/v3/lib/auth.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
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 } from "./auth";
|
||||
|
||||
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" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
65
apps/web/app/api/v3/lib/auth.ts
Normal file
65
apps/web/app/api/v3/lib/auth.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* V3 app API session auth — require session + workspace access.
|
||||
* workspaceId is resolved via workspace-context (today: workspaceId = environmentId).
|
||||
* No environmentId in the API contract; callers only pass workspaceId.
|
||||
*/
|
||||
import { logger } from "@formbricks/logger";
|
||||
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 { problem401, problem403 } from "./response";
|
||||
import { type V3WorkspaceContext, resolveV3WorkspaceContext } from "./workspace-context";
|
||||
|
||||
/**
|
||||
* 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 problem401(requestId, "Not authenticated", instance);
|
||||
}
|
||||
if (!("user" in authentication) || !authentication.user?.id) {
|
||||
return problem401(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 problem403(requestId, "You are not authorized to access this resource", instance);
|
||||
}
|
||||
if (err instanceof AuthorizationError) {
|
||||
log.warn({ statusCode: 403, errorCode: err.name }, "Forbidden");
|
||||
return problem403(requestId, "You are not authorized to access this resource", instance);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
153
apps/web/app/api/v3/lib/response.ts
Normal file
153
apps/web/app/api/v3/lib/response.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 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,
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
return Response.json(body, { status, headers });
|
||||
}
|
||||
|
||||
export function problem400(
|
||||
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 problem401(
|
||||
requestId: string,
|
||||
detail: string = "Not authenticated",
|
||||
instance?: string
|
||||
): Response {
|
||||
return problemResponse(401, "Unauthorized", detail, requestId, {
|
||||
code: "not_authenticated",
|
||||
instance,
|
||||
});
|
||||
}
|
||||
|
||||
export function problem403(
|
||||
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 problem403 with a generic message for those cases.
|
||||
*/
|
||||
export function problem404(
|
||||
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 problem500(
|
||||
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 problem429(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 });
|
||||
}
|
||||
38
apps/web/app/api/v3/lib/workspace-context.test.ts
Normal file
38
apps/web/app/api/v3/lib/workspace-context.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
50
apps/web/app/api/v3/lib/workspace-context.ts
Normal file
50
apps/web/app/api/v3/lib/workspace-context.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@@ -90,6 +90,17 @@ describe("endpoint-validator", () => {
|
||||
});
|
||||
|
||||
describe("isManagementApiRoute", () => {
|
||||
test("should return Session for v3 app routes", () => {
|
||||
expect(isManagementApiRoute("/api/v3/app/surveys")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.Session,
|
||||
});
|
||||
expect(isManagementApiRoute("/api/v3/app/workspaces/abc/surveys")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.Session,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return correct object for management API routes with API key authentication", () => {
|
||||
expect(isManagementApiRoute("/api/v1/management/something")).toEqual({
|
||||
isManagementApi: true,
|
||||
|
||||
@@ -22,6 +22,8 @@ export const isClientSideApiRoute = (url: string): { isClientSideApi: boolean; i
|
||||
export const isManagementApiRoute = (
|
||||
url: string
|
||||
): { isManagementApi: boolean; authenticationMethod: AuthenticationMethod } => {
|
||||
if (url.includes("/api/v3/app/"))
|
||||
return { isManagementApi: true, authenticationMethod: AuthenticationMethod.Session };
|
||||
if (url.includes("/api/v1/management/storage"))
|
||||
return { isManagementApi: true, authenticationMethod: AuthenticationMethod.Both };
|
||||
if (url.includes("/api/v1/webhooks"))
|
||||
|
||||
@@ -605,22 +605,26 @@ export const copySurveyToOtherEnvironment = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const getSurveyCount = reactCache(async (environmentId: string): Promise<number> => {
|
||||
validateInputs([environmentId, z.cuid2()]);
|
||||
try {
|
||||
const surveyCount = await prisma.survey.count({
|
||||
where: {
|
||||
environmentId: environmentId,
|
||||
},
|
||||
});
|
||||
/** 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),
|
||||
},
|
||||
});
|
||||
|
||||
return surveyCount;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting survey count");
|
||||
throw new DatabaseError(error.message);
|
||||
return surveyCount;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting survey count");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import { type z } from "zod";
|
||||
import type { TI18nString } from "../i18n";
|
||||
import type { TSurveyLanguage } from "./types";
|
||||
import { getTextContent } from "./validation";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { parse } from "node-html-parser";
|
||||
import { z } from "zod";
|
||||
import { type z } from "zod";
|
||||
import type { TI18nString } from "../i18n";
|
||||
import type { TConditionGroup, TSingleCondition } from "./logic";
|
||||
import type {
|
||||
|
||||
Reference in New Issue
Block a user