mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-14 03:04:00 -05:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b61e3b9bd |
@@ -0,0 +1,14 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { authorizeTraefikRequest } from "@/modules/traefik-auth/service";
|
||||
|
||||
const handler = async (request: NextRequest): Promise<Response> => {
|
||||
return await authorizeTraefikRequest(request);
|
||||
};
|
||||
|
||||
export const GET = handler;
|
||||
export const POST = handler;
|
||||
export const PUT = handler;
|
||||
export const PATCH = handler;
|
||||
export const DELETE = handler;
|
||||
export const HEAD = handler;
|
||||
export const OPTIONS = handler;
|
||||
@@ -1,14 +1,8 @@
|
||||
import "server-only";
|
||||
import { NextRequest } from "next/server";
|
||||
import { feedbackRecordsEnvoyAuthorizer } from "@/modules/hub/feedback-records-gateway";
|
||||
import {
|
||||
TEnvoyRequestAuthorizer,
|
||||
authenticateEnvoyRequest,
|
||||
buildStatusResponse,
|
||||
parseEnvoyRequestMetadata,
|
||||
} from "./shared";
|
||||
|
||||
const envoyAuthorizers: TEnvoyRequestAuthorizer[] = [feedbackRecordsEnvoyAuthorizer];
|
||||
import { gatewayRequestAuthorizers } from "@/modules/gateway-auth/lib/authorizers";
|
||||
import { authorizeGatewayRequest } from "@/modules/gateway-auth/lib/request";
|
||||
import { buildEnvoyAllowResponse, parseEnvoyRequestMetadata } from "./shared";
|
||||
|
||||
export const authorizeEnvoyRequest = async (request: NextRequest): Promise<Response> => {
|
||||
const requestMetadata = parseEnvoyRequestMetadata(request);
|
||||
@@ -16,20 +10,12 @@ export const authorizeEnvoyRequest = async (request: NextRequest): Promise<Respo
|
||||
return requestMetadata.errorResponse;
|
||||
}
|
||||
|
||||
const authorizer = envoyAuthorizers.find((candidate) => candidate.matches(requestMetadata.originalRequest));
|
||||
if (!authorizer) {
|
||||
return buildStatusResponse(400, "Unsupported Envoy auth route");
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateEnvoyRequest(request, authorizer.gatewayToken);
|
||||
if (authenticationResult.status === "missing" || authenticationResult.status === "invalid") {
|
||||
return buildStatusResponse(401, "Unauthorized");
|
||||
}
|
||||
|
||||
return await authorizer.authorize({
|
||||
return await authorizeGatewayRequest({
|
||||
request,
|
||||
originalRequest: requestMetadata.originalRequest,
|
||||
principal: authenticationResult.principal,
|
||||
authorizers: gatewayRequestAuthorizers,
|
||||
requestId: request.headers.get("x-request-id") ?? "unknown",
|
||||
buildAllowResponse: buildEnvoyAllowResponse,
|
||||
unsupportedRouteMessage: "Unsupported Envoy auth route",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,51 +1,11 @@
|
||||
import "server-only";
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { authenticateApiKeyFromHeaders, getApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth";
|
||||
import { getProxySession } from "@/modules/auth/lib/proxy-session";
|
||||
import { TGatewayOriginalRequest, buildGatewayStatusResponse } from "@/modules/gateway-auth/lib/request";
|
||||
|
||||
const ENVOY_AUTH_PREFIX = "/api/envoy-auth";
|
||||
const HEADERS_TO_REMOVE_ON_ALLOW = "x-api-key,authorization,cookie";
|
||||
|
||||
export type TEnvoyOriginalRequest = {
|
||||
method: string;
|
||||
url: URL;
|
||||
};
|
||||
|
||||
export type TEnvoyAuthenticatedPrincipal =
|
||||
| {
|
||||
type: "apiKey";
|
||||
authentication: TAuthenticationApiKey;
|
||||
}
|
||||
| {
|
||||
type: "user";
|
||||
userId: string;
|
||||
source: "session" | "jwt";
|
||||
};
|
||||
|
||||
export type TEnvoyGatewayTokenHandler = {
|
||||
getTokenFromHeaders: (headers: Headers) => string | null;
|
||||
verifyToken: (token: string) => { userId: string };
|
||||
};
|
||||
|
||||
export type TEnvoyAuthenticationResult =
|
||||
| { status: "authenticated"; principal: TEnvoyAuthenticatedPrincipal }
|
||||
| { status: "invalid" }
|
||||
| { status: "missing" };
|
||||
|
||||
export type TEnvoyRequestAuthorizer = {
|
||||
matches: (originalRequest: TEnvoyOriginalRequest) => boolean;
|
||||
gatewayToken?: TEnvoyGatewayTokenHandler;
|
||||
authorize: (params: {
|
||||
request: NextRequest;
|
||||
originalRequest: TEnvoyOriginalRequest;
|
||||
principal: TEnvoyAuthenticatedPrincipal;
|
||||
requestId: string;
|
||||
}) => Promise<Response>;
|
||||
};
|
||||
|
||||
export const buildAllowResponse = (): Response =>
|
||||
export const buildEnvoyAllowResponse = (): Response =>
|
||||
new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
@@ -53,20 +13,12 @@ export const buildAllowResponse = (): Response =>
|
||||
},
|
||||
});
|
||||
|
||||
export const buildStatusResponse = (status: number, message: string): Response =>
|
||||
new Response(message, {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
},
|
||||
});
|
||||
|
||||
export const parseEnvoyRequestMetadata = (
|
||||
request: NextRequest
|
||||
): { originalRequest: TEnvoyOriginalRequest } | { errorResponse: Response } => {
|
||||
): { originalRequest: TGatewayOriginalRequest } | { errorResponse: Response } => {
|
||||
if (!request.nextUrl.pathname.startsWith(`${ENVOY_AUTH_PREFIX}/`)) {
|
||||
return {
|
||||
errorResponse: buildStatusResponse(400, "Invalid Envoy auth request path"),
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid Envoy auth request path"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,7 +29,7 @@ export const parseEnvoyRequestMetadata = (
|
||||
|
||||
if (originalPathSegments.length === 0) {
|
||||
return {
|
||||
errorResponse: buildStatusResponse(400, "Missing original request path"),
|
||||
errorResponse: buildGatewayStatusResponse(400, "Missing original request path"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,69 +45,7 @@ export const parseEnvoyRequestMetadata = (
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
errorResponse: buildStatusResponse(400, "Invalid original request path"),
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid original request path"),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const authenticateEnvoyRequest = async (
|
||||
request: NextRequest,
|
||||
gatewayToken?: TEnvoyGatewayTokenHandler
|
||||
): Promise<TEnvoyAuthenticationResult> => {
|
||||
if (getApiKeyFromHeaders(request.headers)) {
|
||||
const apiKeyAuthentication = await authenticateApiKeyFromHeaders(request.headers);
|
||||
if (!apiKeyAuthentication) {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "apiKey",
|
||||
authentication: apiKeyAuthentication,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (gatewayToken) {
|
||||
const token = gatewayToken.getTokenFromHeaders(request.headers);
|
||||
if (token) {
|
||||
try {
|
||||
const { userId } = gatewayToken.verifyToken(token);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, isActive: true },
|
||||
});
|
||||
|
||||
if (!user || user.isActive === false) {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "user",
|
||||
userId: user.id,
|
||||
source: "jwt",
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const proxySession = await getProxySession(request);
|
||||
if (!proxySession) {
|
||||
return { status: "missing" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "user",
|
||||
userId: proxySession.userId,
|
||||
source: "session",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import "server-only";
|
||||
import { feedbackRecordsGatewayAuthorizer } from "@/modules/hub/feedback-records-gateway";
|
||||
import { TGatewayRequestAuthorizer } from "./request";
|
||||
|
||||
export const gatewayRequestAuthorizers: TGatewayRequestAuthorizer[] = [feedbackRecordsGatewayAuthorizer];
|
||||
@@ -0,0 +1,152 @@
|
||||
import "server-only";
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { authenticateApiKeyFromHeaders, getApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth";
|
||||
import { getProxySession } from "@/modules/auth/lib/proxy-session";
|
||||
|
||||
export type TGatewayOriginalRequest = {
|
||||
method: string;
|
||||
url: URL;
|
||||
};
|
||||
|
||||
export type TGatewayAuthenticatedPrincipal =
|
||||
| {
|
||||
type: "apiKey";
|
||||
authentication: TAuthenticationApiKey;
|
||||
}
|
||||
| {
|
||||
type: "user";
|
||||
userId: string;
|
||||
source: "session" | "jwt";
|
||||
};
|
||||
|
||||
export type TGatewayTokenHandler = {
|
||||
getTokenFromHeaders: (headers: Headers) => string | null;
|
||||
verifyToken: (token: string) => { userId: string };
|
||||
};
|
||||
|
||||
export type TGatewayAuthenticationResult =
|
||||
| { status: "authenticated"; principal: TGatewayAuthenticatedPrincipal }
|
||||
| { status: "invalid" }
|
||||
| { status: "missing" };
|
||||
|
||||
export type TGatewayAuthorizationDecision = { status: "allow" } | { status: "deny"; response: Response };
|
||||
|
||||
export type TGatewayRequestAuthorizer = {
|
||||
matches: (originalRequest: TGatewayOriginalRequest) => boolean;
|
||||
gatewayToken?: TGatewayTokenHandler;
|
||||
authorize: (params: {
|
||||
request: NextRequest;
|
||||
originalRequest: TGatewayOriginalRequest;
|
||||
principal: TGatewayAuthenticatedPrincipal;
|
||||
requestId: string;
|
||||
}) => Promise<TGatewayAuthorizationDecision>;
|
||||
};
|
||||
|
||||
export const buildGatewayStatusResponse = (status: number, message: string): Response =>
|
||||
new Response(message, {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
},
|
||||
});
|
||||
|
||||
export const allowGatewayRequest = (): TGatewayAuthorizationDecision => ({ status: "allow" });
|
||||
|
||||
export const authenticateGatewayRequest = async (
|
||||
request: NextRequest,
|
||||
gatewayToken?: TGatewayTokenHandler
|
||||
): Promise<TGatewayAuthenticationResult> => {
|
||||
if (getApiKeyFromHeaders(request.headers)) {
|
||||
const apiKeyAuthentication = await authenticateApiKeyFromHeaders(request.headers);
|
||||
if (!apiKeyAuthentication) {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "apiKey",
|
||||
authentication: apiKeyAuthentication,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (gatewayToken) {
|
||||
const token = gatewayToken.getTokenFromHeaders(request.headers);
|
||||
if (token) {
|
||||
try {
|
||||
const { userId } = gatewayToken.verifyToken(token);
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, isActive: true },
|
||||
});
|
||||
|
||||
if (!user || user.isActive === false) {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "user",
|
||||
userId: user.id,
|
||||
source: "jwt",
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const proxySession = await getProxySession(request);
|
||||
if (!proxySession) {
|
||||
return { status: "missing" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
principal: {
|
||||
type: "user",
|
||||
userId: proxySession.userId,
|
||||
source: "session",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const authorizeGatewayRequest = async ({
|
||||
request,
|
||||
originalRequest,
|
||||
authorizers,
|
||||
requestId,
|
||||
buildAllowResponse,
|
||||
unsupportedRouteMessage,
|
||||
}: {
|
||||
request: NextRequest;
|
||||
originalRequest: TGatewayOriginalRequest;
|
||||
authorizers: TGatewayRequestAuthorizer[];
|
||||
requestId: string;
|
||||
buildAllowResponse: () => Response;
|
||||
unsupportedRouteMessage: string;
|
||||
}): Promise<Response> => {
|
||||
const authorizer = authorizers.find((candidate) => candidate.matches(originalRequest));
|
||||
if (!authorizer) {
|
||||
return buildGatewayStatusResponse(400, unsupportedRouteMessage);
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateGatewayRequest(request, authorizer.gatewayToken);
|
||||
if (authenticationResult.status === "missing" || authenticationResult.status === "invalid") {
|
||||
return buildGatewayStatusResponse(401, "Unauthorized");
|
||||
}
|
||||
|
||||
const authorizationDecision = await authorizer.authorize({
|
||||
request,
|
||||
originalRequest,
|
||||
principal: authenticationResult.principal,
|
||||
requestId,
|
||||
});
|
||||
|
||||
return authorizationDecision.status === "allow" ? buildAllowResponse() : authorizationDecision.response;
|
||||
};
|
||||
@@ -12,11 +12,11 @@ import { getBearerTokenFromHeaders } from "@/modules/api/lib/api-key-auth";
|
||||
import { getFeedbackDirectoryAuthContext } from "@/modules/ee/feedback-directory/lib/feedback-directory";
|
||||
import { getIsUnifyFeedbackEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import {
|
||||
TEnvoyAuthenticatedPrincipal,
|
||||
TEnvoyRequestAuthorizer,
|
||||
buildAllowResponse,
|
||||
buildStatusResponse,
|
||||
} from "@/modules/envoy-auth/shared";
|
||||
TGatewayAuthenticatedPrincipal,
|
||||
TGatewayRequestAuthorizer,
|
||||
allowGatewayRequest,
|
||||
buildGatewayStatusResponse,
|
||||
} from "@/modules/gateway-auth/lib/request";
|
||||
import { getFeedbackRecordTenant } from "@/modules/hub/service";
|
||||
|
||||
const FEEDBACK_RECORDS_V3_PREFIX = "/api/v3/feedbackRecords";
|
||||
@@ -137,7 +137,7 @@ const parseFeedbackRecordsGatewayRoute = (method: string, pathname: string): TPa
|
||||
return null;
|
||||
};
|
||||
|
||||
type TAuthenticatedGatewayPrincipal = TEnvoyAuthenticatedPrincipal;
|
||||
type TAuthenticatedGatewayPrincipal = TGatewayAuthenticatedPrincipal;
|
||||
|
||||
const parseTenantId = (tenantId: string | null): string | null => {
|
||||
if (!tenantId) {
|
||||
@@ -200,7 +200,7 @@ const resolveTenantId = async (
|
||||
const tenantId = parseTenantId(originalUrl.searchParams.get("tenant_id"));
|
||||
if (!tenantId) {
|
||||
return {
|
||||
errorResponse: buildStatusResponse(400, "Invalid or missing tenant_id"),
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid or missing tenant_id"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ const resolveTenantId = async (
|
||||
const tenantId = parseTenantId(typeof body?.tenant_id === "string" ? body.tenant_id : null);
|
||||
if (!tenantId) {
|
||||
return {
|
||||
errorResponse: buildStatusResponse(400, "Invalid or missing tenant_id"),
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid or missing tenant_id"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@ const resolveTenantId = async (
|
||||
"Feedback record tenant lookup returned not found"
|
||||
);
|
||||
return {
|
||||
errorResponse: buildStatusResponse(403, "Forbidden"),
|
||||
errorResponse: buildGatewayStatusResponse(403, "Forbidden"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ const resolveTenantId = async (
|
||||
"Feedback record tenant lookup failed"
|
||||
);
|
||||
return {
|
||||
errorResponse: buildStatusResponse(503, "Feedback record lookup failed"),
|
||||
errorResponse: buildGatewayStatusResponse(503, "Feedback record lookup failed"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -247,14 +247,14 @@ const resolveTenantId = async (
|
||||
"Feedback record tenant lookup returned invalid tenant"
|
||||
);
|
||||
return {
|
||||
errorResponse: buildStatusResponse(503, "Feedback record lookup failed"),
|
||||
errorResponse: buildGatewayStatusResponse(503, "Feedback record lookup failed"),
|
||||
};
|
||||
}
|
||||
|
||||
return { tenantId };
|
||||
};
|
||||
|
||||
const authorizeGatewayRequest = async (
|
||||
const authorizeFeedbackRecordsGatewayRequest = async (
|
||||
principal: TAuthenticatedGatewayPrincipal,
|
||||
feedbackDirectoryId: string,
|
||||
requiredPermission: TFeedbackRecordsGatewayPermission
|
||||
@@ -308,7 +308,7 @@ const authorizeGatewayRequest = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const feedbackRecordsEnvoyAuthorizer: TEnvoyRequestAuthorizer = {
|
||||
export const feedbackRecordsGatewayAuthorizer: TGatewayRequestAuthorizer = {
|
||||
matches: (originalRequest) => normalizeFeedbackRecordsPath(originalRequest.url.pathname) !== null,
|
||||
gatewayToken: {
|
||||
getTokenFromHeaders: getFeedbackRecordsGatewayJwtFromHeaders,
|
||||
@@ -317,15 +317,21 @@ export const feedbackRecordsEnvoyAuthorizer: TEnvoyRequestAuthorizer = {
|
||||
authorize: async ({ request, originalRequest, principal, requestId }) => {
|
||||
const route = parseFeedbackRecordsGatewayRoute(originalRequest.method, originalRequest.url.pathname);
|
||||
if (!route) {
|
||||
return buildStatusResponse(400, "Unsupported FeedbackRecords route");
|
||||
return {
|
||||
status: "deny",
|
||||
response: buildGatewayStatusResponse(400, "Unsupported FeedbackRecords route"),
|
||||
};
|
||||
}
|
||||
|
||||
const tenantResolution = await resolveTenantId(request, route, originalRequest.url, requestId);
|
||||
if ("errorResponse" in tenantResolution) {
|
||||
return tenantResolution.errorResponse;
|
||||
return {
|
||||
status: "deny",
|
||||
response: tenantResolution.errorResponse,
|
||||
};
|
||||
}
|
||||
|
||||
const authorizationResult = await authorizeGatewayRequest(
|
||||
const authorizationResult = await authorizeFeedbackRecordsGatewayRequest(
|
||||
principal,
|
||||
tenantResolution.tenantId,
|
||||
route.requiredPermission
|
||||
@@ -341,7 +347,10 @@ export const feedbackRecordsEnvoyAuthorizer: TEnvoyRequestAuthorizer = {
|
||||
},
|
||||
"Feedback records gateway authorization denied"
|
||||
);
|
||||
return buildStatusResponse(403, "Forbidden");
|
||||
return {
|
||||
status: "deny",
|
||||
response: buildGatewayStatusResponse(403, "Forbidden"),
|
||||
};
|
||||
}
|
||||
|
||||
logger.info(
|
||||
@@ -355,6 +364,6 @@ export const feedbackRecordsEnvoyAuthorizer: TEnvoyRequestAuthorizer = {
|
||||
"Feedback records gateway authorization allowed"
|
||||
);
|
||||
|
||||
return buildAllowResponse();
|
||||
return allowGatewayRequest();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { authorizeTraefikRequest } from "./service";
|
||||
|
||||
const {
|
||||
mockAuthenticateApiKeyFromHeaders,
|
||||
mockGetApiKeyFromHeaders,
|
||||
mockGetBearerTokenFromHeaders,
|
||||
mockGetProxySession,
|
||||
mockVerifyFeedbackRecordsGatewayToken,
|
||||
mockGetFeedbackDirectoryAuthContext,
|
||||
mockGetFeedbackRecordTenant,
|
||||
mockCheckAuthorizationUpdated,
|
||||
mockUserFindUnique,
|
||||
mockGetIsUnifyFeedbackEnabled,
|
||||
} = vi.hoisted(() => ({
|
||||
mockAuthenticateApiKeyFromHeaders: vi.fn(),
|
||||
mockGetApiKeyFromHeaders: vi.fn(),
|
||||
mockGetBearerTokenFromHeaders: vi.fn(),
|
||||
mockGetProxySession: vi.fn(),
|
||||
mockVerifyFeedbackRecordsGatewayToken: vi.fn(),
|
||||
mockGetFeedbackDirectoryAuthContext: vi.fn(),
|
||||
mockGetFeedbackRecordTenant: vi.fn(),
|
||||
mockCheckAuthorizationUpdated: vi.fn(),
|
||||
mockUserFindUnique: vi.fn(),
|
||||
mockGetIsUnifyFeedbackEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/lib/api-key-auth", () => ({
|
||||
authenticateApiKeyFromHeaders: mockAuthenticateApiKeyFromHeaders,
|
||||
getApiKeyFromHeaders: mockGetApiKeyFromHeaders,
|
||||
getBearerTokenFromHeaders: mockGetBearerTokenFromHeaders,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/proxy-session", () => ({
|
||||
getProxySession: mockGetProxySession,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jwt", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/jwt")>();
|
||||
return {
|
||||
...actual,
|
||||
verifyFeedbackRecordsGatewayToken: mockVerifyFeedbackRecordsGatewayToken,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: mockUserFindUnique,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/feedback-directory/lib/feedback-directory", () => ({
|
||||
getFeedbackDirectoryAuthContext: mockGetFeedbackDirectoryAuthContext,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getIsUnifyFeedbackEnabled: mockGetIsUnifyFeedbackEnabled,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/hub/service", () => ({
|
||||
getFeedbackRecordTenant: mockGetFeedbackRecordTenant,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
|
||||
checkAuthorizationUpdated: mockCheckAuthorizationUpdated,
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const feedbackDirectoryId = "clxx1234567890123456789012";
|
||||
const feedbackRecordId = "0194d8a0-3d55-7ff4-9f62-8d02c3fbcfe8";
|
||||
|
||||
const createRequest = ({
|
||||
method = "GET",
|
||||
forwardedMethod = "GET",
|
||||
forwardedUri,
|
||||
headers = {},
|
||||
body,
|
||||
adapterUrl = "http://localhost/api/traefik-auth/feedback-records",
|
||||
}: {
|
||||
method?: string;
|
||||
forwardedMethod?: string;
|
||||
forwardedUri?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: BodyInit;
|
||||
adapterUrl?: string;
|
||||
} = {}) =>
|
||||
new NextRequest(adapterUrl, {
|
||||
method,
|
||||
headers: {
|
||||
"x-forwarded-method": forwardedMethod,
|
||||
...(forwardedUri ? { "x-forwarded-uri": forwardedUri } : {}),
|
||||
"x-forwarded-host": "app.example.com",
|
||||
"x-forwarded-proto": "https",
|
||||
...headers,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
describe("authorizeTraefikRequest", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockGetApiKeyFromHeaders.mockReturnValue(null);
|
||||
mockGetBearerTokenFromHeaders.mockReturnValue(null);
|
||||
mockAuthenticateApiKeyFromHeaders.mockResolvedValue(null);
|
||||
mockGetProxySession.mockResolvedValue(null);
|
||||
mockVerifyFeedbackRecordsGatewayToken.mockImplementation(() => {
|
||||
throw new Error("invalid token");
|
||||
});
|
||||
mockGetFeedbackDirectoryAuthContext.mockResolvedValue({
|
||||
organizationId: "org_1",
|
||||
workspaceIds: ["workspace_1"],
|
||||
isArchived: false,
|
||||
});
|
||||
mockGetFeedbackRecordTenant.mockResolvedValue({
|
||||
data: { tenantId: feedbackDirectoryId },
|
||||
error: null,
|
||||
});
|
||||
mockCheckAuthorizationUpdated.mockResolvedValue(true);
|
||||
mockUserFindUnique.mockResolvedValue({ id: "user_1", isActive: true });
|
||||
mockGetIsUnifyFeedbackEnabled.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
test("allows requests using Traefik forwarded method and URI metadata", async () => {
|
||||
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
|
||||
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
|
||||
type: "apiKey",
|
||||
apiKeyId: "key_1",
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: true, write: true } },
|
||||
workspacePermissions: [],
|
||||
});
|
||||
|
||||
const response = await authorizeTraefikRequest(
|
||||
createRequest({
|
||||
method: "POST",
|
||||
forwardedMethod: "POST",
|
||||
forwardedUri: "/api/v3/feedbackRecords",
|
||||
headers: {
|
||||
authorization: "Bearer fbk_test",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ tenant_id: feedbackDirectoryId }),
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("x-envoy-auth-headers-to-remove")).toBeNull();
|
||||
});
|
||||
|
||||
test("uses the forwarded URI instead of the Traefik auth endpoint URL", async () => {
|
||||
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
|
||||
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
|
||||
type: "apiKey",
|
||||
apiKeyId: "key_1",
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: true, write: true } },
|
||||
workspacePermissions: [],
|
||||
});
|
||||
|
||||
const response = await authorizeTraefikRequest(
|
||||
createRequest({
|
||||
adapterUrl: "http://localhost/api/traefik-auth/not-the-original-route",
|
||||
forwardedUri: `/v1/feedback-records?tenant_id=${feedbackDirectoryId}`,
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test("returns 400 when Traefik forwarded metadata is missing", async () => {
|
||||
const response = await authorizeTraefikRequest(
|
||||
new NextRequest("http://localhost/api/traefik-auth/v1/feedback-records", {
|
||||
method: "GET",
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
test("authorizes record lookups through the shared FeedbackRecords authorizer", async () => {
|
||||
mockGetBearerTokenFromHeaders.mockReturnValue("header.payload.signature");
|
||||
mockVerifyFeedbackRecordsGatewayToken.mockReturnValue({ userId: "user_1" });
|
||||
|
||||
const response = await authorizeTraefikRequest(
|
||||
createRequest({
|
||||
forwardedMethod: "PATCH",
|
||||
forwardedUri: `/v1/feedback-records/${feedbackRecordId}`,
|
||||
headers: {
|
||||
authorization: "Bearer header.payload.signature",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockGetFeedbackRecordTenant).toHaveBeenCalledWith(feedbackRecordId);
|
||||
expect(mockCheckAuthorizationUpdated).toHaveBeenCalledWith({
|
||||
userId: "user_1",
|
||||
organizationId: "org_1",
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
workspaceId: "workspace_1",
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 401 for invalid explicit JWT instead of falling back to session cookies", async () => {
|
||||
mockGetBearerTokenFromHeaders.mockReturnValue("header.payload.signature");
|
||||
mockGetProxySession.mockResolvedValue({
|
||||
userId: "user_1",
|
||||
});
|
||||
|
||||
const response = await authorizeTraefikRequest(
|
||||
createRequest({
|
||||
forwardedUri: `/v1/feedback-records?tenant_id=${feedbackDirectoryId}`,
|
||||
headers: {
|
||||
authorization: "Bearer header.payload.signature",
|
||||
cookie: "next-auth.session-token=still-present",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(mockGetProxySession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import "server-only";
|
||||
import { NextRequest } from "next/server";
|
||||
import { gatewayRequestAuthorizers } from "@/modules/gateway-auth/lib/authorizers";
|
||||
import { authorizeGatewayRequest } from "@/modules/gateway-auth/lib/request";
|
||||
import { buildTraefikAllowResponse, parseTraefikRequestMetadata } from "./shared";
|
||||
|
||||
export const authorizeTraefikRequest = async (request: NextRequest): Promise<Response> => {
|
||||
const requestMetadata = parseTraefikRequestMetadata(request);
|
||||
if ("errorResponse" in requestMetadata) {
|
||||
return requestMetadata.errorResponse;
|
||||
}
|
||||
|
||||
return await authorizeGatewayRequest({
|
||||
request,
|
||||
originalRequest: requestMetadata.originalRequest,
|
||||
authorizers: gatewayRequestAuthorizers,
|
||||
requestId: request.headers.get("x-request-id") ?? request.headers.get("x-forwarded-for") ?? "unknown",
|
||||
buildAllowResponse: buildTraefikAllowResponse,
|
||||
unsupportedRouteMessage: "Unsupported Traefik auth route",
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import "server-only";
|
||||
import { NextRequest } from "next/server";
|
||||
import { TGatewayOriginalRequest, buildGatewayStatusResponse } from "@/modules/gateway-auth/lib/request";
|
||||
|
||||
const TRAEFIK_AUTH_PREFIX = "/api/traefik-auth";
|
||||
|
||||
const isTraefikAuthPath = (pathname: string): boolean =>
|
||||
pathname === TRAEFIK_AUTH_PREFIX || pathname.startsWith(`${TRAEFIK_AUTH_PREFIX}/`);
|
||||
|
||||
const buildForwardedRequestUrl = (request: NextRequest, forwardedUri: string): URL => {
|
||||
if (forwardedUri.startsWith("http://") || forwardedUri.startsWith("https://")) {
|
||||
return new URL(forwardedUri);
|
||||
}
|
||||
|
||||
const proto = request.headers.get("x-forwarded-proto") || "https";
|
||||
const host = request.headers.get("x-forwarded-host") || request.headers.get("host") || "traefik-auth.local";
|
||||
const normalizedUri = forwardedUri.startsWith("/") ? forwardedUri : `/${forwardedUri}`;
|
||||
|
||||
return new URL(normalizedUri, `${proto}://${host}`);
|
||||
};
|
||||
|
||||
export const buildTraefikAllowResponse = (): Response => new Response(null, { status: 200 });
|
||||
|
||||
export const parseTraefikRequestMetadata = (
|
||||
request: NextRequest
|
||||
): { originalRequest: TGatewayOriginalRequest } | { errorResponse: Response } => {
|
||||
if (!isTraefikAuthPath(request.nextUrl.pathname)) {
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid Traefik auth request path"),
|
||||
};
|
||||
}
|
||||
|
||||
const forwardedMethod = request.headers.get("x-forwarded-method")?.trim();
|
||||
const forwardedUri = request.headers.get("x-forwarded-uri")?.trim();
|
||||
|
||||
if (!forwardedMethod || !forwardedUri) {
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(400, "Missing original request metadata"),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
originalRequest: {
|
||||
method: forwardedMethod.toUpperCase(),
|
||||
url: buildForwardedRequestUrl(request, forwardedUri),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
errorResponse: buildGatewayStatusResponse(400, "Invalid original request URI"),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -37,3 +37,8 @@ The stack includes the [Formbricks Hub](https://github.com/formbricks/hub) API (
|
||||
- **Development** (`docker-compose.dev.yml`): Hub uses the same local Postgres database and `HUB_API_KEY` defaults to `dev-api-key`. Cube is behind the `xm` profile, `CUBEJS_API_URL` defaults to `http://localhost:4000`, and `pnpm dev:setup` generates `CUBEJS_API_SECRET` in the repo root `.env`. The Hub image is pinned to a semver tag (`hub` and `hub-migrate` share the same value); override `HUB_IMAGE_TAG` in the repo root `.env` to test a specific Hub release.
|
||||
|
||||
In development, Hub is exposed locally on port **8080**. When the `xm` profile is enabled, Cube is exposed on **4000** (with the Cube playground on **4001**). In production Docker Compose, Hub stays internal to the compose network at `http://hub:8080`; Cube also stays internal at `http://cube:4000` when enabled.
|
||||
|
||||
The one-click Traefik installer exposes Hub-backed FeedbackRecords on the Formbricks origin at
|
||||
`/api/v3/feedbackRecords` and `/v1/feedback-records`. Traefik uses Formbricks gateway auth, rewrites the v3
|
||||
path to Hub's `/v1/feedback-records`, injects `Authorization: Bearer ${HUB_API_KEY}` for Hub, and strips client
|
||||
API key/cookie headers before the Hub hop.
|
||||
|
||||
+65
-2
@@ -397,6 +397,12 @@ EOF
|
||||
print " - \"traefik.http.routers.formbricks.tls=true\""
|
||||
print " - \"traefik.http.routers.formbricks.tls.certresolver=default\""
|
||||
print " - \"traefik.http.services.formbricks.loadbalancer.server.port=3000\""
|
||||
print " - \"traefik.http.routers.feedback-records-token.rule=Host(`" domain_name "`) && Path(`/api/v3/feedbackRecords/token`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-token.entrypoints=websecure\""
|
||||
print " - \"traefik.http.routers.feedback-records-token.tls=true\""
|
||||
print " - \"traefik.http.routers.feedback-records-token.tls.certresolver=default\""
|
||||
print " - \"traefik.http.routers.feedback-records-token.service=formbricks\""
|
||||
print " - \"traefik.http.routers.feedback-records-token.priority=200\""
|
||||
if (hsts_enabled == "y") {
|
||||
print " - \"traefik.http.middlewares.hstsHeader.headers.stsSeconds=31536000\""
|
||||
print " - \"traefik.http.middlewares.hstsHeader.headers.forceSTSHeader=true\""
|
||||
@@ -405,6 +411,10 @@ EOF
|
||||
} else {
|
||||
print " - \"traefik.http.routers.formbricks_http.entrypoints=web\""
|
||||
print " - \"traefik.http.routers.formbricks_http.rule=Host(`" domain_name "`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-token-http.rule=Host(`" domain_name "`) && Path(`/api/v3/feedbackRecords/token`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-token-http.entrypoints=web\""
|
||||
print " - \"traefik.http.routers.feedback-records-token-http.service=formbricks\""
|
||||
print " - \"traefik.http.routers.feedback-records-token-http.priority=200\""
|
||||
}
|
||||
print $0
|
||||
} else {
|
||||
@@ -413,6 +423,57 @@ EOF
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' docker-compose.yml >tmp.yml && mv tmp.yml docker-compose.yml
|
||||
|
||||
# Step 1b: Add FeedbackRecords gateway labels to the Hub service.
|
||||
awk -v domain_name="$domain_name" -v hsts_enabled="$hsts_enabled" '
|
||||
BEGIN { in_hub = 0; inserted = 0 }
|
||||
/^ hub:/ { in_hub = 1 }
|
||||
in_hub && /^ [A-Za-z0-9_-]+:/ && !/^ hub:/ { in_hub = 0 }
|
||||
{
|
||||
if (in_hub && !inserted && $0 ~ /^ environment:/) {
|
||||
print " labels:"
|
||||
print " - \"traefik.enable=true\""
|
||||
print " - \"traefik.http.services.feedback-records-hub.loadbalancer.server.port=8080\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.rule=Host(`" domain_name "`) && PathPrefix(`/api/v3/feedbackRecords`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.entrypoints=websecure\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.tls=true\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.tls.certresolver=default\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.service=feedback-records-hub\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.priority=100\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3.middlewares=feedback-records-auth,feedback-records-v3-rewrite,feedback-records-hub-headers\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.rule=Host(`" domain_name "`) && PathPrefix(`/v1/feedback-records`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.entrypoints=websecure\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.tls=true\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.tls.certresolver=default\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.service=feedback-records-hub\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.priority=100\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk.middlewares=feedback-records-auth,feedback-records-hub-headers\""
|
||||
if (hsts_enabled != "y") {
|
||||
print " - \"traefik.http.routers.feedback-records-v3-http.rule=Host(`" domain_name "`) && PathPrefix(`/api/v3/feedbackRecords`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3-http.entrypoints=web\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3-http.service=feedback-records-hub\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3-http.priority=100\""
|
||||
print " - \"traefik.http.routers.feedback-records-v3-http.middlewares=feedback-records-auth,feedback-records-v3-rewrite,feedback-records-hub-headers\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk-http.rule=Host(`" domain_name "`) && PathPrefix(`/v1/feedback-records`)\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk-http.entrypoints=web\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk-http.service=feedback-records-hub\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk-http.priority=100\""
|
||||
print " - \"traefik.http.routers.feedback-records-sdk-http.middlewares=feedback-records-auth,feedback-records-hub-headers\""
|
||||
}
|
||||
print " - \"traefik.http.middlewares.feedback-records-auth.forwardauth.address=http://formbricks:3000/api/traefik-auth/feedback-records\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-auth.forwardauth.forwardbody=true\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-auth.forwardauth.maxbodysize=1048576\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-auth.forwardauth.preserverequestmethod=true\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-v3-rewrite.replacepathregex.regex=^/api/v3/feedbackRecords(.*)\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-v3-rewrite.replacepathregex.replacement=/v1/feedback-records$${1}\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-hub-headers.headers.customrequestheaders.Authorization=Bearer ${HUB_API_KEY}\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-hub-headers.headers.customrequestheaders.X-API-Key=\""
|
||||
print " - \"traefik.http.middlewares.feedback-records-hub-headers.headers.customrequestheaders.Cookie=\""
|
||||
inserted = 1
|
||||
}
|
||||
print
|
||||
}
|
||||
' docker-compose.yml >tmp.yml && mv tmp.yml docker-compose.yml
|
||||
|
||||
# Step 2: Ensure formbricks waits for minio-init to complete successfully (mapping depends_on)
|
||||
@@ -511,11 +572,12 @@ EOF
|
||||
if [[ $insert_traefik == "y" ]]; then
|
||||
cat >> "$services_snippet_file" << EOF
|
||||
traefik:
|
||||
image: "traefik:v2.11.31"
|
||||
image: "traefik:v3.6.4"
|
||||
restart: always
|
||||
container_name: "traefik"
|
||||
depends_on:
|
||||
- formbricks
|
||||
- hub
|
||||
- minio
|
||||
ports:
|
||||
- "80:80"
|
||||
@@ -540,11 +602,12 @@ EOF
|
||||
cat > "$services_snippet_file" << EOF
|
||||
|
||||
traefik:
|
||||
image: "traefik:v2.11.31"
|
||||
image: "traefik:v3.6.4"
|
||||
restart: always
|
||||
container_name: "traefik"
|
||||
depends_on:
|
||||
- formbricks
|
||||
- hub
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: "API Gateway"
|
||||
description: "Gateway auth architecture for proxied service APIs"
|
||||
icon: "route"
|
||||
---
|
||||
|
||||
### Gateway Model
|
||||
|
||||
Formbricks gateway auth is split into three layers:
|
||||
|
||||
- A shared gateway-auth core authenticates the caller, normalizes the original request, and dispatches to a
|
||||
service authorizer.
|
||||
- Provider adapters translate ingress-specific auth requests into the shared shape. Envoy uses
|
||||
`/api/envoy-auth/[...path]`; Traefik uses `/api/traefik-auth/[...path]`.
|
||||
- Service authorizers own service-specific authorization. FeedbackRecords is the first registered service authorizer.
|
||||
|
||||
### Tokens
|
||||
|
||||
Session-authenticated browser callers should use `/api/v3/gateway/token` with
|
||||
`{ "service": "feedbackRecords" }`. The token identifies the user for the gateway only; every proxied request
|
||||
still runs through gateway authorization. `/api/v3/feedbackRecords/token` remains a compatibility alias.
|
||||
|
||||
### Provider Adapters
|
||||
|
||||
Envoy and Traefik do not send auth subrequests in the same format, so they stay as thin adapters. Envoy derives the
|
||||
original path from the auth request path. Traefik derives it from `X-Forwarded-Method` and `X-Forwarded-Uri`.
|
||||
Both adapters reuse the same gateway-auth core and FeedbackRecords authorizer.
|
||||
@@ -309,6 +309,7 @@
|
||||
"group": "Technical Handbook",
|
||||
"pages": [
|
||||
"development/technical-handbook/overview",
|
||||
"development/technical-handbook/api-gateway",
|
||||
"development/technical-handbook/background-job-processing",
|
||||
"development/technical-handbook/cube-tenant-isolation",
|
||||
"development/technical-handbook/database-model",
|
||||
|
||||
@@ -109,7 +109,7 @@ Modify the configuration to enforce SSL. The rest of the configuration should re
|
||||
<<: *environment
|
||||
|
||||
traefik:
|
||||
image: "traefik:v2.11.31"
|
||||
image: "traefik:v3.6.4"
|
||||
restart: always
|
||||
container_name: "traefik"
|
||||
depends_on:
|
||||
@@ -146,4 +146,4 @@ Modify the configuration to enforce SSL. The rest of the configuration should re
|
||||
This setup ensures that Formbricks securely communicates using your own SSL certificate. 🚀
|
||||
|
||||
If you have any questions or require help, feel free to reach out to us on [**GitHub Discussions**](https://github.com/formbricks/formbricks/discussions). 😃[
|
||||
](https://formbricks.com/docs/developer-docs/rest-api)
|
||||
](https://formbricks.com/docs/developer-docs/rest-api)
|
||||
|
||||
@@ -151,6 +151,13 @@ Make sure Docker and Docker Compose are installed on your system. These are usua
|
||||
enabled, Cube.js is internal too. The app reaches them through `http://hub:8080` and `http://cube:4000`.
|
||||
</Note>
|
||||
|
||||
<Info>
|
||||
If you use the one-click Traefik setup, FeedbackRecords are available on the Formbricks origin at
|
||||
`/api/v3/feedbackRecords` and `/v1/feedback-records`. Custom Docker reverse proxies need equivalent wiring:
|
||||
run gateway auth against the Formbricks app, rewrite `/api/v3/feedbackRecords` to Hub's
|
||||
`/v1/feedback-records`, and inject `Authorization: Bearer <HUB_API_KEY>` only on the Hub-bound hop.
|
||||
</Info>
|
||||
|
||||
## Update
|
||||
|
||||
Please take a look at our [migration guide](/self-hosting/advanced/migration) for version specific steps to update Formbricks.
|
||||
|
||||
@@ -40,6 +40,13 @@ curl -fsSL https://raw.githubusercontent.com/formbricks/formbricks/stable/docker
|
||||
finishes, add it manually. `HUB_API_URL` should normally stay at `http://hub:8080`.
|
||||
</Info>
|
||||
|
||||
<Info>
|
||||
The v5 one-click Traefik setup also exposes Hub-backed FeedbackRecords through Formbricks at
|
||||
`/api/v3/feedbackRecords` and `/v1/feedback-records`. Traefik calls the internal Formbricks gateway auth
|
||||
endpoint first, then forwards allowed requests to Hub with the generated `HUB_API_KEY`. Browser callers should
|
||||
request short-lived gateway tokens from `/api/v3/gateway/token` with `{ "service": "feedbackRecords" }`.
|
||||
</Info>
|
||||
|
||||
### Script Prompts
|
||||
|
||||
During installation, the script will prompt you to provide some details:
|
||||
|
||||
Reference in New Issue
Block a user