diff --git a/.env.example b/.env.example index afab45bfa8e6..c488f9711447 100644 --- a/.env.example +++ b/.env.example @@ -361,6 +361,16 @@ ENTERPRISE_LICENSE_KEY= # Ignore Rate Limiting across the Formbricks app # RATE_LIMITING_DISABLED=1 +# Number of reverse proxies in front of Formbricks whose X-Forwarded-For entries can be trusted. +# X-Forwarded-For is appended to by each proxy, so its leftmost entry is whatever the client sent; +# only the rightmost N entries were added by infrastructure you control. Defaults to 1, which is +# correct for a single nginx/Traefik/Envoy in front of the app. Set it to 2 if Cloudflare (or another +# CDN) also fronts that proxy, and so on. Setting it higher than your real topology lets callers spoof +# their address again by prepending entries; setting it to 0 believes no forwarding header at all, +# which means IP-based rate limiting and the IP recorded on responses and audit logs cannot identify +# individual clients. cf-connecting-ip is never trusted — Cloudflare also populates X-Forwarded-For. +# TRUSTED_PROXY_HOP_COUNT=1 + # Disable telemetry reporting (usage stats sent to Formbricks). Ignored when an EE license is active. # TELEMETRY_DISABLED=1 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 69d96dc1b4db..f467f845ef51 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -21,6 +21,26 @@ auto-links. No ticket? Say why. Closing a GitHub issue? Add "Fixes #123". --> +## Breaking changes + + + +None + + + ## QA / Test Plan /g, ""); + + // Content under a "## " up to the next H2 (or end of body); null when absent. + const sectionContent = (heading) => { + const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const m = body.match(new RegExp(`^##\\s+${escaped}\\s*$`, "im")); + if (!m) return null; + const after = body.slice(m.index + m[0].length); + const nextH2 = after.search(/^##\s/m); + return (nextH2 === -1 ? after : after.slice(0, nextH2)).trim(); + }; + + const current = new Set((pr.labels || []).map((l) => l.name)); + + for (const rule of RULES) { + const content = sectionContent(rule.heading); + if (content === null) { + core.info(`[${rule.label}] no "## ${rule.heading}" section found; leaving unchanged.`); + continue; + } + const active = rule.isActive(content); + const has = current.has(rule.label); + if (active && !has) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: [rule.label], + }); + core.info(`[${rule.label}] added.`); + } else if (!active && has) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + name: rule.label, + }); + core.info(`[${rule.label}] removed.`); + } else { + core.info(`[${rule.label}] already in the correct state.`); + } + } diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseFeaturesTable.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseFeaturesTable.tsx index e00b2a2fd23c..0c636e87ac06 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseFeaturesTable.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseFeaturesTable.tsx @@ -91,6 +91,11 @@ const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => { labelKey: t("workspace.settings.general.ai_smart_tools_enabled"), docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai", }, + { + key: "workflows", + labelKey: t("workspace.settings.enterprise.workflows"), + docsUrl: "https://formbricks.com/docs/workflows/overview", + }, ]; }; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/actions.ts b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/actions.ts index f7db67c32b93..5fa0e81a7017 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/actions.ts +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/actions.ts @@ -3,7 +3,12 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; import { ZIntegrationInput } from "@formbricks/types/integration"; -import { createOrUpdateIntegration, deleteIntegration } from "@/lib/integration/service"; +import { withStoredIntegrationKey } from "@/lib/integration/redact-credentials"; +import { + createOrUpdateIntegration, + deleteIntegration, + getIntegrationByType, +} from "@/lib/integration/service"; import { capturePostHogEvent } from "@/lib/posthog"; import { authenticatedActionClient } from "@/lib/utils/action-client"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; @@ -42,7 +47,22 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient }); ctx.auditLoggingCtx.organizationId = organizationId; - const result = await createOrUpdateIntegration(parsedInput.workspaceId, parsedInput.integrationData); + + // `config.key` holds the provider's OAuth credentials, which the settings pages now redact before + // handing the integration to a client component (lib/integration/redact-credentials.ts). The + // mapping UI echoes the whole integration object back here on every add, edit *and* delete, so + // trusting the `key` it sends would write those blanks straight over the stored tokens and + // silently disconnect the integration — while the UI still rendered it as connected, because the + // wrappers only test `config.key` for presence. The stored value is the sole source of truth on + // this path; credentials are written only by the OAuth callbacks, which call + // createOrUpdateIntegration directly rather than through this action. + const storedIntegration = await getIntegrationByType( + parsedInput.workspaceId, + parsedInput.integrationData.type + ); + const integrationData = withStoredIntegrationKey(parsedInput.integrationData, storedIntegration); + + const result = await createOrUpdateIntegration(parsedInput.workspaceId, integrationData); ctx.auditLoggingCtx.integrationId = result.id; ctx.auditLoggingCtx.newObject = result; diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/page.tsx index 929aecd3bd68..4cdd4cf92191 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/page.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/page.tsx @@ -6,6 +6,7 @@ import { AirtableWrapper } from "@/app/(app)/workspaces/[workspaceId]/settings/w import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/lib/surveys"; import { getAirtableTables } from "@/lib/airtable/service"; import { AIRTABLE_CLIENT_ID, DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants"; +import { redactIntegrationCredentials } from "@/lib/integration/redact-credentials"; import { getIntegrations } from "@/lib/integration/service"; import { getUserLocale } from "@/lib/user/service"; import { getTranslate } from "@/lingodotdev/server"; @@ -52,7 +53,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
}) => { isEnabled={isEnabled} workspaceId={workspace.id} surveys={surveys} - googleSheetIntegration={googleSheetIntegration} + googleSheetIntegration={redactIntegrationCredentials(googleSheetIntegration)} webAppUrl={WEBAPP_URL} locale={locale ?? DEFAULT_LOCALE} /> diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/page.tsx index 19afb7609fd7..9a7c2e769bbc 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/page.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/page.tsx @@ -10,6 +10,7 @@ import { NOTION_REDIRECT_URI, WEBAPP_URL, } from "@/lib/constants"; +import { redactIntegrationCredentials } from "@/lib/integration/redact-credentials"; import { getIntegrationByType } from "@/lib/integration/service"; import { getNotionDatabases } from "@/lib/notion/service"; import { getUserLocale } from "@/lib/user/service"; @@ -56,7 +57,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => { enabled={enabled} surveys={surveys} workspaceId={workspace.id} - notionIntegration={notionIntegration as TIntegrationNotion} + notionIntegration={redactIntegrationCredentials(notionIntegration as TIntegrationNotion)} webAppUrl={WEBAPP_URL} databasesArray={databasesArray} locale={locale ?? DEFAULT_LOCALE} diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/page.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/page.tsx index 815ee4b63480..7aaa7328dc01 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/page.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/page.tsx @@ -3,6 +3,7 @@ import { TIntegrationSlack } from "@formbricks/types/integration/slack"; import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/lib/surveys"; import { SlackWrapper } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/components/SlackWrapper"; import { DEFAULT_LOCALE, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants"; +import { redactIntegrationCredentials } from "@/lib/integration/redact-credentials"; import { getIntegrationByType } from "@/lib/integration/service"; import { getUserLocale } from "@/lib/user/service"; import { getTranslate } from "@/lingodotdev/server"; @@ -38,7 +39,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => { isEnabled={isEnabled} workspaceId={workspace.id} surveys={surveys} - slackIntegration={slackIntegration as TIntegrationSlack} + slackIntegration={redactIntegrationCredentials(slackIntegration as TIntegrationSlack)} webAppUrl={WEBAPP_URL} locale={locale ?? DEFAULT_LOCALE} /> diff --git a/apps/web/app/.well-known/oauth-protected-resource/route.test.ts b/apps/web/app/.well-known/oauth-protected-resource/route.test.ts index 487b1c35ebfd..20163aaddc8c 100644 --- a/apps/web/app/.well-known/oauth-protected-resource/route.test.ts +++ b/apps/web/app/.well-known/oauth-protected-resource/route.test.ts @@ -43,7 +43,13 @@ describe("OAuth protected resource metadata", () => { // offline_access must stay advertised: MCP clients register (DCR) with exactly these // scopes, and the oauth-provider plugin validates /authorize against the client's // registered scopes — dropping it re-breaks refresh-token issuance for all MCP clients. - scopes_supported: ["surveys:read", "surveys:write", "offline_access"], + scopes_supported: [ + "surveys:read", + "surveys:write", + "feedbackRecords:read", + "feedbackRecords:write", + "offline_access", + ], bearer_methods_supported: ["header"], }); }); diff --git a/apps/web/app/api/google-sheet/callback/route.ts b/apps/web/app/api/google-sheet/callback/route.ts index 30d3fb8d3338..317e6ed16a3e 100644 --- a/apps/web/app/api/google-sheet/callback/route.ts +++ b/apps/web/app/api/google-sheet/callback/route.ts @@ -16,7 +16,7 @@ import { } from "@/lib/oauth/integration-state"; import { capturePostHogEvent } from "@/lib/posthog"; import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; -import { hasUserWorkspaceAccess } from "@/lib/workspace/auth"; +import { canUserWriteWorkspaceIntegrations } from "@/lib/workspace/auth"; import { getSession } from "@/modules/auth/lib/session"; const getGoogleSheetsRedirectUrl = (workspaceId: string) => @@ -97,7 +97,7 @@ export const GET = async (req: Request) => { } const workspaceId = oauthState.workspaceId; - const canUserAccessWorkspace = await hasUserWorkspaceAccess(session.user.id, workspaceId); + const canUserAccessWorkspace = await canUserWriteWorkspaceIntegrations(session.user.id, workspaceId); if (!canUserAccessWorkspace) { return responses.unauthorizedResponse(); } diff --git a/apps/web/app/api/google-sheet/route.ts b/apps/web/app/api/google-sheet/route.ts index c4547e486fdb..deab7daa7534 100644 --- a/apps/web/app/api/google-sheet/route.ts +++ b/apps/web/app/api/google-sheet/route.ts @@ -8,7 +8,7 @@ import { GOOGLE_SHEETS_REDIRECT_URL, } from "@/lib/constants"; import { createIntegrationOAuthState } from "@/lib/oauth/integration-state"; -import { hasUserWorkspaceAccess } from "@/lib/workspace/auth"; +import { canUserWriteWorkspaceIntegrations } from "@/lib/workspace/auth"; import { getSession } from "@/modules/auth/lib/session"; const scopes = [ @@ -28,7 +28,7 @@ export const GET = async (req: NextRequest) => { return responses.notAuthenticatedResponse(); } - const canUserAccessWorkspace = await hasUserWorkspaceAccess(session?.user.id, workspaceId); + const canUserAccessWorkspace = await canUserWriteWorkspaceIntegrations(session?.user.id, workspaceId); if (!canUserAccessWorkspace) { return responses.unauthorizedResponse(); } diff --git a/apps/web/app/api/mcp/route.test.ts b/apps/web/app/api/mcp/route.test.ts index 744bd94c2006..d0e5c5582c89 100644 --- a/apps/web/app/api/mcp/route.test.ts +++ b/apps/web/app/api/mcp/route.test.ts @@ -43,7 +43,9 @@ vi.mock("@formbricks/database", () => ({ })); vi.mock("@/modules/auth/lib/oauth-urls", () => ({ - MCP_RESOURCE_SCOPES: ["surveys:read", "surveys:write"], + // Must mirror the real MCP_RESOURCE_SCOPES: the route's minimum-scope gate and its WWW-Authenticate + // challenge are both derived from this list, so a short mock would test a world production doesn't have. + MCP_RESOURCE_SCOPES: ["surveys:read", "surveys:write", "feedbackRecords:read", "feedbackRecords:write"], getAuthIssuerUrl: () => "http://localhost/api/auth", getMcpOrigin: () => "http://localhost", getMcpProtectedResourceMetadataUrl: () => "http://localhost/.well-known/oauth-protected-resource/api/mcp", @@ -161,7 +163,7 @@ describe("POST /api/mcp", () => { expect(response.status).toBe(401); expect(response.headers.get("Content-Type")).toBe("application/problem+json"); expect(response.headers.get("WWW-Authenticate")).toBe( - 'Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource/api/mcp" scope="surveys:read surveys:write"' + 'Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource/api/mcp" scope="surveys:read surveys:write feedbackRecords:read feedbackRecords:write"' ); expect(applyIPRateLimit).toHaveBeenCalled(); }); @@ -213,6 +215,16 @@ describe("POST /api/mcp", () => { "patch_survey", "delete_survey", "list_workspaces", + "list_feedback_datasets", + "list_feedback_records", + "count_feedback_records", + "get_feedback_record", + "create_feedback_record", + "create_feedback_records", + "update_feedback_record", + "delete_feedback_record", + "search_feedback_records", + "find_similar_feedback_records", ]); const tools = new Map(message.result.tools.map((tool: { name: string }) => [tool.name, tool])); expect(Object.keys((tools.get("create_survey") as any).inputSchema.properties)).toEqual( @@ -412,7 +424,7 @@ describe("POST /api/mcp", () => { expect(authenticateApiKeyFromHeaders).not.toHaveBeenCalled(); expect(applyIPRateLimit).toHaveBeenCalled(); expect(response.headers.get("WWW-Authenticate")).toBe( - 'Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource/api/mcp" scope="surveys:read surveys:write"' + 'Bearer resource_metadata="http://localhost/.well-known/oauth-protected-resource/api/mcp" scope="surveys:read surveys:write feedbackRecords:read feedbackRecords:write"' ); }); diff --git a/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.ts b/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.ts index 0b39a9e72fec..5d32b9bebd87 100644 --- a/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.ts +++ b/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.ts @@ -14,6 +14,7 @@ import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers"; import { validateClientFileUploads } from "@/modules/storage/utils"; import { verifyLinkSurveyPinToken } from "@/modules/survey/link/lib/pin-token"; +import { VERIFIED_EMAIL_RESPONSE_KEY } from "@/modules/survey/link/lib/verify-email-gate"; import { updateResponseWithQuotaEvaluation } from "./response"; import { getValidatedResponseUpdateInput } from "./validated-response-update-input"; @@ -223,6 +224,13 @@ export const putResponseHandler = async ({ }; } + // The update payload carries no `meta`, so there is no token to re-verify here. The address was + // established authoritatively from the verification token when the response was created, so drop any + // client-supplied value rather than letting an update overwrite it with an arbitrary address. + if (survey.isVerifyEmailEnabled && responseUpdateInput.data) { + delete responseUpdateInput.data[VERIFIED_EMAIL_RESPONSE_KEY]; + } + const validationResult = validateUpdateRequest(existingResponse, survey, responseUpdateInput, workspaceId); if (validationResult) { return validationResult; diff --git a/apps/web/app/api/v1/client/[workspaceId]/responses/route.ts b/apps/web/app/api/v1/client/[workspaceId]/responses/route.ts index 2277c943e4af..35a4870c47df 100644 --- a/apps/web/app/api/v1/client/[workspaceId]/responses/route.ts +++ b/apps/web/app/api/v1/client/[workspaceId]/responses/route.ts @@ -15,10 +15,12 @@ import { getClientIpFromHeaders } from "@/lib/utils/client-ip"; import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; import { resolveClientApiIds } from "@/lib/utils/resolve-client-id"; import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation"; +import { verifyResponseRecaptcha } from "@/modules/api/lib/verify-response-recaptcha"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers"; import { validateClientFileUploads } from "@/modules/storage/utils"; import { verifyLinkSurveyPinToken } from "@/modules/survey/link/lib/pin-token"; +import { enforceVerifiedEmailGate } from "@/modules/survey/link/lib/verify-email-gate"; import { createResponseWithQuotaEvaluation } from "./lib/response"; export const OPTIONS = async (): Promise => { @@ -152,6 +154,29 @@ export const POST = withV1ApiWrapper({ }; } + // Email verification, like the PIN above, has to be enforced here and not only in the renderer: + // this endpoint is public, so a caller could otherwise submit with any `verifiedEmail` they like. + // Shared with the v2 endpoint so the two versions cannot drift apart. + const verifiedEmailErrorResponse = enforceVerifiedEmailGate({ + survey, + responseData: responseInputData.data, + metaUrl: responseInputData.meta?.url, + }); + if (verifiedEmailErrorResponse) { + return { response: verifiedEmailErrorResponse }; + } + + // Same gate the v2 endpoint applies — without it, posting to the v1 URL opts the caller out of the + // survey's spam protection entirely. + const recaptchaErrorResponse = await verifyResponseRecaptcha({ + survey, + workspaceId, + recaptchaToken: responseInputData.recaptchaToken, + }); + if (recaptchaErrorResponse) { + return { response: recaptchaErrorResponse }; + } + const singleUseValidationResult = validateSingleUseResponseInput(survey, workspaceId, responseInputData); if (singleUseValidationResult) { if ("response" in singleUseValidationResult) { diff --git a/apps/web/app/api/v1/integrations/airtable/callback/route.ts b/apps/web/app/api/v1/integrations/airtable/callback/route.ts index 511add4e7c71..1cb84bf06ffe 100644 --- a/apps/web/app/api/v1/integrations/airtable/callback/route.ts +++ b/apps/web/app/api/v1/integrations/airtable/callback/route.ts @@ -12,7 +12,7 @@ import { } from "@/lib/oauth/integration-state"; import { capturePostHogEvent } from "@/lib/posthog"; import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; -import { hasUserWorkspaceAccess } from "@/lib/workspace/auth"; +import { canUserWriteWorkspaceIntegrations } from "@/lib/workspace/auth"; const getEmail = async (token: string) => { const req_ = await fetch("https://api.airtable.com/v0/meta/whoami", { @@ -144,7 +144,10 @@ export const GET = withV1ApiWrapper({ }; } - const canUserAccessWorkspace = await hasUserWorkspaceAccess(authentication.user.id, workspaceId); + const canUserAccessWorkspace = await canUserWriteWorkspaceIntegrations( + authentication.user.id, + workspaceId + ); if (!canUserAccessWorkspace) { return { response: responses.unauthorizedResponse(), diff --git a/apps/web/app/api/v1/integrations/airtable/route.ts b/apps/web/app/api/v1/integrations/airtable/route.ts index 22b4f3d7330f..f84d131106c0 100644 --- a/apps/web/app/api/v1/integrations/airtable/route.ts +++ b/apps/web/app/api/v1/integrations/airtable/route.ts @@ -2,7 +2,7 @@ import { responses } from "@/app/lib/api/response"; import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants"; import { createIntegrationOAuthState, generatePkcePair } from "@/lib/oauth/integration-state"; -import { hasUserWorkspaceAccess } from "@/lib/workspace/auth"; +import { canUserWriteWorkspaceIntegrations } from "@/lib/workspace/auth"; const scope = `data.records:read data.records:write schema.bases:read schema.bases:write user.email:read`; @@ -20,7 +20,10 @@ export const GET = withV1ApiWrapper({ }; } - const canUserAccessWorkspace = await hasUserWorkspaceAccess(authentication.user.id, workspaceId); + const canUserAccessWorkspace = await canUserWriteWorkspaceIntegrations( + authentication.user.id, + workspaceId + ); if (!canUserAccessWorkspace) { return { response: responses.unauthorizedResponse(), diff --git a/apps/web/app/api/v1/integrations/airtable/tables/route.ts b/apps/web/app/api/v1/integrations/airtable/tables/route.ts index 56ce87017530..1770106dc83e 100644 --- a/apps/web/app/api/v1/integrations/airtable/tables/route.ts +++ b/apps/web/app/api/v1/integrations/airtable/tables/route.ts @@ -3,7 +3,7 @@ import { responses } from "@/app/lib/api/response"; import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { getAirtableToken, getTables } from "@/lib/airtable/service"; import { getIntegrationByType } from "@/lib/integration/service"; -import { hasUserWorkspaceAccess } from "@/lib/workspace/auth"; +import { canUserReadWorkspaceIntegrations } from "@/lib/workspace/auth"; export const GET = withV1ApiWrapper({ handler: async ({ req, authentication }) => { @@ -28,7 +28,10 @@ export const GET = withV1ApiWrapper({ }; } - const canUserAccessWorkspace = await hasUserWorkspaceAccess(authentication.user.id, workspaceId); + const canUserAccessWorkspace = await canUserReadWorkspaceIntegrations( + authentication.user.id, + workspaceId + ); if (!canUserAccessWorkspace) { return { response: responses.unauthorizedResponse(), diff --git a/apps/web/app/api/v1/integrations/notion/callback/route.ts b/apps/web/app/api/v1/integrations/notion/callback/route.ts index c8df1da00fd0..e695217af77e 100644 --- a/apps/web/app/api/v1/integrations/notion/callback/route.ts +++ b/apps/web/app/api/v1/integrations/notion/callback/route.ts @@ -18,7 +18,7 @@ import { } from "@/lib/oauth/integration-state"; import { capturePostHogEvent } from "@/lib/posthog"; import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; -import { hasUserWorkspaceAccess } from "@/lib/workspace/auth"; +import { canUserWriteWorkspaceIntegrations } from "@/lib/workspace/auth"; export const GET = withV1ApiWrapper({ handler: async ({ req, authentication }) => { @@ -56,7 +56,10 @@ export const GET = withV1ApiWrapper({ }; } - const canUserAccessWorkspace = await hasUserWorkspaceAccess(authentication.user.id, workspaceId); + const canUserAccessWorkspace = await canUserWriteWorkspaceIntegrations( + authentication.user.id, + workspaceId + ); if (!canUserAccessWorkspace) { return { response: responses.unauthorizedResponse(), diff --git a/apps/web/app/api/v1/integrations/notion/route.ts b/apps/web/app/api/v1/integrations/notion/route.ts index d1c9bd87ae29..97b3dc06bef8 100644 --- a/apps/web/app/api/v1/integrations/notion/route.ts +++ b/apps/web/app/api/v1/integrations/notion/route.ts @@ -7,7 +7,7 @@ import { NOTION_REDIRECT_URI, } from "@/lib/constants"; import { createIntegrationOAuthState } from "@/lib/oauth/integration-state"; -import { hasUserWorkspaceAccess } from "@/lib/workspace/auth"; +import { canUserWriteWorkspaceIntegrations } from "@/lib/workspace/auth"; export const GET = withV1ApiWrapper({ handler: async ({ req, authentication }) => { @@ -23,7 +23,10 @@ export const GET = withV1ApiWrapper({ }; } - const canUserAccessWorkspace = await hasUserWorkspaceAccess(authentication.user.id, workspaceId); + const canUserAccessWorkspace = await canUserWriteWorkspaceIntegrations( + authentication.user.id, + workspaceId + ); if (!canUserAccessWorkspace) { return { response: responses.unauthorizedResponse(), diff --git a/apps/web/app/api/v1/integrations/slack/callback/route.ts b/apps/web/app/api/v1/integrations/slack/callback/route.ts index 0aa4e271e2b6..bcef75f87f20 100644 --- a/apps/web/app/api/v1/integrations/slack/callback/route.ts +++ b/apps/web/app/api/v1/integrations/slack/callback/route.ts @@ -15,7 +15,7 @@ import { } from "@/lib/oauth/integration-state"; import { capturePostHogEvent } from "@/lib/posthog"; import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; -import { hasUserWorkspaceAccess } from "@/lib/workspace/auth"; +import { canUserWriteWorkspaceIntegrations } from "@/lib/workspace/auth"; export const GET = withV1ApiWrapper({ handler: async ({ req, authentication }) => { @@ -53,7 +53,10 @@ export const GET = withV1ApiWrapper({ }; } - const canUserAccessWorkspace = await hasUserWorkspaceAccess(authentication.user.id, workspaceId); + const canUserAccessWorkspace = await canUserWriteWorkspaceIntegrations( + authentication.user.id, + workspaceId + ); if (!canUserAccessWorkspace) { return { response: responses.unauthorizedResponse(), diff --git a/apps/web/app/api/v1/integrations/slack/route.ts b/apps/web/app/api/v1/integrations/slack/route.ts index c9461a79af59..a99296c8190d 100644 --- a/apps/web/app/api/v1/integrations/slack/route.ts +++ b/apps/web/app/api/v1/integrations/slack/route.ts @@ -2,7 +2,7 @@ import { responses } from "@/app/lib/api/response"; import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; import { SLACK_AUTH_URL, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET } from "@/lib/constants"; import { createIntegrationOAuthState } from "@/lib/oauth/integration-state"; -import { hasUserWorkspaceAccess } from "@/lib/workspace/auth"; +import { canUserWriteWorkspaceIntegrations } from "@/lib/workspace/auth"; export const GET = withV1ApiWrapper({ handler: async ({ req, authentication }) => { @@ -19,7 +19,10 @@ export const GET = withV1ApiWrapper({ }; } - const canUserAccessWorkspace = await hasUserWorkspaceAccess(authentication.user.id, workspaceId); + const canUserAccessWorkspace = await canUserWriteWorkspaceIntegrations( + authentication.user.id, + workspaceId + ); if (!canUserAccessWorkspace) { return { response: responses.unauthorizedResponse(), diff --git a/apps/web/app/api/v1/management/surveys/[surveyId]/singleUseIds/route.ts b/apps/web/app/api/v1/management/surveys/[surveyId]/singleUseIds/route.ts index 20ba99d9aa42..bc0500f98cd8 100644 --- a/apps/web/app/api/v1/management/surveys/[surveyId]/singleUseIds/route.ts +++ b/apps/web/app/api/v1/management/surveys/[surveyId]/singleUseIds/route.ts @@ -24,7 +24,12 @@ export const GET = withV1ApiWrapper({ response: responses.notFoundResponse("Survey", params.surveyId), }; } - if (!hasPermission(authentication.workspacePermissions, survey.workspaceId, "GET")) { + // Checked at write level despite being a GET: this endpoint *mints* credentials rather than + // reading anything. Each returned link carries a fresh HMAC-signed suId/suToken pair that the + // unauthenticated POST /api/v1/client/{workspaceId}/responses accepts, so at "read" a + // reporting-only key — the level you would hand an external analyst or BI tool — could generate + // thousands of valid submission links and inject responses with them. + if (!hasPermission(authentication.workspacePermissions, survey.workspaceId, "POST")) { return { response: responses.unauthorizedResponse(), }; diff --git a/apps/web/app/api/v2/client/[workspaceId]/responses/lib/utils.test.ts b/apps/web/app/api/v2/client/[workspaceId]/responses/lib/utils.test.ts index e2cc8666f28b..7907e133ed1e 100644 --- a/apps/web/app/api/v2/client/[workspaceId]/responses/lib/utils.test.ts +++ b/apps/web/app/api/v2/client/[workspaceId]/responses/lib/utils.test.ts @@ -45,6 +45,7 @@ vi.mock("@/lib/utils/helper", () => ({ vi.mock("@formbricks/logger", () => ({ logger: { error: vi.fn(), + warn: vi.fn(), }, })); @@ -171,7 +172,8 @@ describe("checkSurveyValidity", () => { const result = await checkSurveyValidity(survey, "ws-1", responseInputWithoutToken); expect(result).toBeInstanceOf(Response); expect(result?.status).toBe(400); - expect(logger.error).toHaveBeenCalledWith("Missing recaptcha token"); + // warn, not error: a missing token on a public endpoint is caller-controlled, not a fault. + expect(logger.warn).toHaveBeenCalledWith("Missing recaptcha token"); expect(responses.badRequestResponse).toHaveBeenCalledWith( "Missing recaptcha token", { code: "recaptcha_verification_failed" }, @@ -190,7 +192,8 @@ describe("checkSurveyValidity", () => { }); expect(result).toBeInstanceOf(Response); expect(result?.status).toBe(404); - expect(responses.notFoundResponse).toHaveBeenCalledWith("Organization", null); + // cors: true, matching every other response this path returns. + expect(responses.notFoundResponse).toHaveBeenCalledWith("Organization", null, true); expect(getOrganizationBillingByWorkspaceId).toHaveBeenCalledWith("ws-1"); }); @@ -204,7 +207,7 @@ describe("checkSurveyValidity", () => { recaptchaToken: "test-token", }); expect(result).toBeNull(); - expect(logger.error).toHaveBeenCalledWith("Spam protection is not enabled for this organization"); + expect(logger.warn).toHaveBeenCalledWith("Spam protection is not enabled for this organization"); }); test("should return badRequestResponse if recaptcha verification fails", async () => { diff --git a/apps/web/app/api/v2/client/[workspaceId]/responses/lib/utils.ts b/apps/web/app/api/v2/client/[workspaceId]/responses/lib/utils.ts index d6e3e1f881af..341afcb4ea53 100644 --- a/apps/web/app/api/v2/client/[workspaceId]/responses/lib/utils.ts +++ b/apps/web/app/api/v2/client/[workspaceId]/responses/lib/utils.ts @@ -1,17 +1,15 @@ import { logger } from "@formbricks/logger"; import { TSurvey } from "@formbricks/types/surveys/types"; -import { getOrganizationBillingByWorkspaceId } from "@/app/api/v2/client/[workspaceId]/responses/lib/organization"; -import { verifyRecaptchaToken } from "@/app/api/v2/client/[workspaceId]/responses/lib/recaptcha"; import { TResponseInputV2 } from "@/app/api/v2/client/[workspaceId]/responses/types/response"; import { responses } from "@/app/lib/api/response"; import { ENCRYPTION_KEY } from "@/lib/constants"; import { symmetricDecrypt } from "@/lib/crypto"; -import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; import { validateSurveySingleUseLinkParams } from "@/lib/utils/single-use-surveys"; -import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils"; +import { verifyResponseRecaptcha } from "@/modules/api/lib/verify-response-recaptcha"; import { verifyLinkSurveyPinToken } from "@/modules/survey/link/lib/pin-token"; +import { enforceVerifiedEmailGate } from "@/modules/survey/link/lib/verify-email-gate"; -export const RECAPTCHA_VERIFICATION_ERROR_CODE = "recaptcha_verification_failed"; +export { RECAPTCHA_VERIFICATION_ERROR_CODE } from "@/modules/api/lib/verify-response-recaptcha"; export const checkSurveyValidity = async ( survey: TSurvey, @@ -95,41 +93,22 @@ export const checkSurveyValidity = async ( responseInput.singleUseId = canonicalSingleUseId; } - if (survey.recaptcha?.enabled) { - if (!responseInput.recaptchaToken) { - logger.error("Missing recaptcha token"); - return responses.badRequestResponse( - "Missing recaptcha token", - { - code: RECAPTCHA_VERIFICATION_ERROR_CODE, - }, - true - ); - } - const billing = await getOrganizationBillingByWorkspaceId(workspaceId); - - if (!billing) { - return responses.notFoundResponse("Organization", null); - } - - const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId); - const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(organizationId); - - if (!isSpamProtectionEnabled) { - logger.error("Spam protection is not enabled for this organization"); - } - - const isPassed = await verifyRecaptchaToken(responseInput.recaptchaToken, survey.recaptcha.threshold); - if (!isPassed) { - return responses.badRequestResponse( - "reCAPTCHA verification failed", - { - code: RECAPTCHA_VERIFICATION_ERROR_CODE, - }, - true - ); - } + // Email verification, like the PIN above, must be enforced here and not only in the page renderer: + // this endpoint is public, so a caller could otherwise submit any `verifiedEmail` they like. + // Shared with the v1 endpoint so the two versions cannot drift apart. + const verifiedEmailErrorResponse = enforceVerifiedEmailGate({ + survey, + responseData: responseInput.data, + metaUrl: responseInput.meta?.url, + }); + if (verifiedEmailErrorResponse) { + return verifiedEmailErrorResponse; } - return null; + // Shared with the v1 endpoint so the two versions cannot drift apart again. + return await verifyResponseRecaptcha({ + survey, + workspaceId, + recaptchaToken: responseInput.recaptchaToken, + }); }; diff --git a/apps/web/app/api/v3/feedbackRecords/lib/access.ts b/apps/web/app/api/v3/feedbackRecords/lib/access.ts new file mode 100644 index 000000000000..e96d7a14da7b --- /dev/null +++ b/apps/web/app/api/v3/feedbackRecords/lib/access.ts @@ -0,0 +1,196 @@ +import "server-only"; +import type { logger } from "@formbricks/logger"; +import { requireUnifyFeedbackWorkspaceAccess } from "@/app/api/v3/lib/feedback-access"; +import { problemBadRequest, problemForbidden, problemUnprocessableContent } from "@/app/api/v3/lib/response"; +import type { TV3Authentication } from "@/app/api/v3/lib/types"; +import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-directory/lib/feedback-directory"; +import { retrieveFeedbackRecord } from "@/modules/hub/service"; +import type { FeedbackRecordData } from "@/modules/hub/types"; +import { hubErrorToProblemResponse } from "./errors"; + +/** + * Tenant isolation for the feedback-records surface. Two guards live here, and every operation goes + * through at least one of them: + * + * - `resolveWorkspaceFeedbackTenant` — the workspace → dataset → Hub tenant choke point. + * - `requireOwnedFeedbackRecord` — the per-record ownership check for the Hub endpoints that take a bare + * record id and derive the tenant from the record itself. + * + * Kept in its own module (mirroring `unify-feedback/taxonomy/lib/access.ts`) so the authorization rules + * are reviewable in one place instead of being spread across the operations. + */ + +type TResolveParams = { + authentication: TV3Authentication; + workspaceId: string; + minPermission: "read" | "readWrite"; + requestId: string; + instance: string; + datasetId?: string; +}; + +export type TResolvedFeedbackTenant = { + workspaceId: string; + organizationId: string; + tenantId: string; + /** Name of the resolved dataset, echoed to the caller so a response is self-describing. */ + datasetName: string; + allowedTenantIds: string[]; +}; + +type TResolveResult = ({ ok: true } & TResolvedFeedbackTenant) | { ok: false; response: Response }; + +/** + * Resolve (and authorize) the Hub tenant for a feedback-records request. This is the single tenant- + * isolation choke point for every tool: workspace access → feedback-directories license gate → + * dataset membership → the resolved Hub tenant (= the FeedbackDirectory id, `dataset_id` on the wire). + * Mirrors the Unify read path (`modules/ee/unify-feedback/page.tsx`). `tenant_id` is never taken from caller input. + */ +export async function resolveWorkspaceFeedbackTenant({ + authentication, + workspaceId, + minPermission, + requestId, + instance, + datasetId, +}: TResolveParams): Promise { + const authResult = await requireUnifyFeedbackWorkspaceAccess( + authentication, + workspaceId, + minPermission, + requestId, + instance + ); + if (authResult instanceof Response) { + return { ok: false, response: authResult }; + } + + const { workspaceId: resolvedWorkspaceId, organizationId } = authResult; + + const directories = await getFeedbackDirectoriesByWorkspaceId(resolvedWorkspaceId); + // Normalised once, here: the Hub uses `tenant_id` verbatim — in SQL filters and, for semantic search, in + // the vector query — with no trimming of its own, so a stray space silently matches nothing instead of + // failing. Doing it at the source means every consumer gets a usable value; doing it per call site meant + // whichever site was written last got it right. + const allowedTenantIds = directories.map((directory) => directory.id.trim()); + + if (datasetId) { + // Trimmed on both sides: the id we hand back as `dataset_id` is normalised, so the value a caller + // echoes on its next request must match a normalised id too, or a legitimate round trip would 403. + const requested = directories.find((directory) => directory.id.trim() === datasetId.trim()); + if (!requested) { + return { + ok: false, + response: problemForbidden( + requestId, + "You are not authorized to access this feedback dataset", + instance + ), + }; + } + return { + ok: true, + workspaceId: resolvedWorkspaceId, + organizationId, + tenantId: requested.id.trim(), + datasetName: requested.name, + allowedTenantIds, + }; + } + + if (allowedTenantIds.length === 0) { + return { + ok: false, + // Actionable on purpose: an agent hitting this can only help if the response says who has to do + // what, and where. Datasets are organization-level, so a workspace member cannot self-serve. + response: problemUnprocessableContent( + requestId, + "No feedback dataset is assigned to this workspace. An organization owner or manager can create one and grant this workspace access under Settings → Organization → Feedback Datasets.", + { instance } + ), + }; + } + if (allowedTenantIds.length > 1) { + return { + ok: false, + response: problemBadRequest( + requestId, + "Multiple feedback datasets are assigned to this workspace; specify datasetId", + { instance } + ), + }; + } + + return { + ok: true, + workspaceId: resolvedWorkspaceId, + organizationId, + tenantId: directories[0].id.trim(), + datasetName: directories[0].name, + allowedTenantIds, + }; +} + +type TRequireOwnedRecordParams = { + feedbackRecordId: string; + resolution: TResolvedFeedbackTenant; + /** Set when the caller pinned a dataset: the record must then live in *that* one, not merely in a permitted one. */ + datasetId?: string; + log: ReturnType; + requestId: string; + instance: string; +}; + +type TOwnedRecordResult = { ok: true; record: FeedbackRecordData } | { ok: false; response: Response }; + +/** + * Assert that a feedback record belongs to the caller, and return it. + * + * Several Hub endpoints (`GET /{id}`, `GET /{id}/similar`, `DELETE /{id}`) take a bare record id and + * derive the tenant from the stored record — the Hub delegates record-level authorization to us by + * design. Without this guard each of them would be a cross-tenant primitive: a read oracle, a + * neighbour-leak, and a destructive write respectively. So every one of them retrieves first, checks the + * record's tenant against the caller's permitted set, and only then acts. + * + * A missing record and a foreign record return the *same* generic 403, so record ids can't be probed + * across tenants. The response is identical per route, not per resource. + */ +export async function requireOwnedFeedbackRecord({ + feedbackRecordId, + resolution, + datasetId, + log, + requestId, + instance, +}: TRequireOwnedRecordParams): Promise { + const result = await retrieveFeedbackRecord(feedbackRecordId); + if (result.error || !result.data) { + const status = result.error?.status ?? 0; + // Not found → generic 403, no existence oracle across tenants. + if (status === 404) { + // Logged like the tenant-mismatch denial below, so both halves of the 403 are observable. + log.warn({ statusCode: 403, hubStatus: 404 }, "Feedback record not found"); + return { ok: false, response: forbidFeedbackRecord(requestId, instance) }; + } + log.warn({ hubStatus: status, hubCode: result.error?.code }, "Hub retrieveFeedbackRecord failed"); + return { ok: false, response: hubErrorToProblemResponse(result.error, requestId, instance) }; + } + + // When the caller named a dataset, the record must live in THAT one; otherwise any dataset the + // workspace owns is acceptable. Same 403 either way — see the doc comment above. + const permittedTenantIds = datasetId ? [resolution.tenantId] : resolution.allowedTenantIds; + if (!result.data.tenant_id || !permittedTenantIds.includes(result.data.tenant_id)) { + log.warn({ statusCode: 403 }, "Feedback record tenant outside caller's workspace datasets"); + return { ok: false, response: forbidFeedbackRecord(requestId, instance) }; + } + + return { ok: true, record: result.data }; +} + +/** + * The single record-level 403. Exported so every path that must be indistinguishable from the others — + * the ownership guard above, and a record that vanishes mid-update — produces a byte-identical body by + * construction rather than by two copies of the same string staying in sync. + */ +export const forbidFeedbackRecord = (requestId: string, instance: string): Response => + problemForbidden(requestId, "You are not authorized to access this feedback record", instance); diff --git a/apps/web/app/api/v3/feedbackRecords/lib/errors.test.ts b/apps/web/app/api/v3/feedbackRecords/lib/errors.test.ts new file mode 100644 index 000000000000..5d607f7fde36 --- /dev/null +++ b/apps/web/app/api/v3/feedbackRecords/lib/errors.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, test, vi } from "vitest"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { EMBEDDINGS_UNAVAILABLE_DETAIL, handleUnexpectedError, hubErrorToProblemResponse } from "./errors"; + +vi.mock("server-only", () => ({})); + +const requestId = "req_1"; +const instance = "/api/mcp"; +const log = { warn: vi.fn(), error: vi.fn(), info: vi.fn() } as any; + +/** + * The status matrix is exercised end-to-end in `operations.test.ts`; what is pinned here is the part that + * only this module owns — the *bounds* on what a remote service may contribute to our response body, and + * the unexpected-throw mapping, which no operation test reaches. + */ +describe("hubErrorToProblemResponse", () => { + const hubError = (status: number, extra: Record = {}) => ({ + status, + message: `${status}`, + detail: `${status}`, + ...extra, + }); + + test("truncates a relayed 4xx detail so the Hub cannot size our response body", async () => { + const response = hubErrorToProblemResponse( + hubError(422, { problemDetail: "x".repeat(5_000) }), + requestId, + instance + ); + const body = await response.json(); + + expect(response.status).toBe(422); + expect(body.detail).toHaveLength(512); + }); + + test("caps the number of relayed invalid_params", async () => { + const invalidParams = Array.from({ length: 50 }, (_, i) => ({ + name: `field_${i}`, + reason: "y".repeat(1_000), + })); + + const body = await hubErrorToProblemResponse( + hubError(400, { invalidParams }), + requestId, + instance + ).json(); + + expect(body.invalid_params).toHaveLength(20); + expect(body.invalid_params[0].reason).toHaveLength(512); + expect(body.invalid_params[0].name).toBe("field_0"); + }); + + /** + * The Hub's tenant is our dataset, and the record serializer already renames the field outward — so a + * relayed message must not send a caller looking for a `tenant_id` parameter this API does not have. The + * fixture is the Hub's verbatim duplicate-create 409, captured from a live instance. + */ + test("renames the Hub's tenant_id to dataset_id in a relayed detail", async () => { + const body = await hubErrorToProblemResponse( + hubError(409, { + problemDetail: "a feedback record with this tenant_id, submission_id, and field_id already exists", + }), + requestId, + instance + ).json(); + + expect(body.detail).toBe( + "a feedback record with this dataset_id, submission_id, and field_id already exists" + ); + expect(body.detail).not.toContain("tenant_id"); + }); + + /** + * The rewrite lengthens the string (`dataset_id` is a character longer), so it has to happen before the + * slice — otherwise the cap this module exists to enforce is quietly exceeded by however many times the + * Hub said `tenant_id`. + */ + test("stays within the relay cap even when the rewrite lengthens the text", async () => { + const body = await hubErrorToProblemResponse( + hubError(409, { problemDetail: "tenant_id ".repeat(200) }), + requestId, + instance + ).json(); + + expect(body.detail.length).toBeLessThanOrEqual(512); + expect(body.detail).not.toContain("tenant_id"); + }); + + test("renames tenant_id in relayed invalid_params too, where the Hub also names fields", async () => { + const body = await hubErrorToProblemResponse( + hubError(400, { invalidParams: [{ name: "tenant_id", reason: "tenant_id is required" }] }), + requestId, + instance + ).json(); + + expect(body.invalid_params).toEqual([{ name: "dataset_id", reason: "dataset_id is required" }]); + }); + + // Word-bounded, so it renames the term without corrupting text that merely contains it. + test("leaves lookalike substrings alone", async () => { + const body = await hubErrorToProblemResponse( + hubError(422, { problemDetail: "my_tenant_identifier and tenant_ids are unaffected" }), + requestId, + instance + ).json(); + + expect(body.detail).toBe("my_tenant_identifier and tenant_ids are unaffected"); + }); + + // 5xx bodies can carry upstream internals, so nothing from them is ever echoed — only the status is. + test("never relays a 5xx problem detail", async () => { + const body = await hubErrorToProblemResponse( + hubError(500, { problemDetail: "panic: nil deref at 10.0.0.1" }), + requestId, + instance + ).json(); + + expect(body.status).toBe(502); + expect(JSON.stringify(body)).not.toContain("10.0.0.1"); + }); + + // Not "any 4xx": a Hub 401/403 means our own Hub credentials were refused, and a 404 can reveal upstream + // addressing. Echoing either would describe our infrastructure rather than the caller's request. + test.each([401, 403, 404])("never relays the detail of a Hub %i", async (status) => { + const body = await hubErrorToProblemResponse( + hubError(status, { problemDetail: "invalid api key for tenant-service" }), + requestId, + instance + ).json(); + + expect(JSON.stringify(body)).not.toContain("invalid api key"); + }); + + test("maps a null error (Hub unconfigured) to a generic 502", async () => { + const response = hubErrorToProblemResponse(null, requestId, instance); + + expect(response.status).toBe(502); + expect((await response.json()).detail).toBe("The feedback service is unavailable."); + }); + + // A 503 means embeddings aren't configured — a deployment-level fact no retry fixes, so it must not be + // folded into the generic "unavailable" 502 that reads as "try again". + test("maps a 503 to 503 rather than folding it into the generic 502", async () => { + const response = hubErrorToProblemResponse(hubError(503), requestId, instance); + + expect(response.status).toBe(503); + expect((await response.json()).code).toBe("service_unavailable"); + }); + + test("uses the caller's wording for a 503 when it knows what is unconfigured", async () => { + const body = await hubErrorToProblemResponse(hubError(503), requestId, instance, { + serviceUnavailableDetail: EMBEDDINGS_UNAVAILABLE_DETAIL, + }).json(); + + expect(body.detail).toContain("EMBEDDING_PROVIDER"); + }); + + /** + * The Hub answers 503 for several unrelated unconfigured subsystems, so the default must name none of them: + * a plain outage on a list or a create would otherwise tell an operator to go configure embeddings. + */ + test("names no subsystem in a 503 the caller did not explain", async () => { + const body = await hubErrorToProblemResponse(hubError(503), requestId, instance).json(); + + expect(body.detail).not.toContain("EMBEDDING"); + expect(body.detail).not.toMatch(/embedding/i); + expect(body.detail).toContain("not configured"); + }); +}); + +describe("handleUnexpectedError", () => { + test("maps a missing resource to 403, not 404 (no existence oracle)", async () => { + const response = handleUnexpectedError( + new ResourceNotFoundError("Workspace", "ws_1"), + log, + requestId, + instance + ); + + expect(response.status).toBe(403); + // The resource type and id must not travel back to the caller. + expect(JSON.stringify(await response.json())).not.toContain("ws_1"); + }); + + test("maps a database failure to a generic 500", async () => { + const response = handleUnexpectedError(new DatabaseError("connection lost"), log, requestId, instance); + + expect(response.status).toBe(500); + expect((await response.json()).detail).toBe("An unexpected error occurred."); + }); + + test("maps an unknown throw to a generic 500 without leaking the message", async () => { + const response = handleUnexpectedError(new Error("secret internal detail"), log, requestId, instance); + + expect(response.status).toBe(500); + expect(JSON.stringify(await response.json())).not.toContain("secret internal detail"); + }); +}); diff --git a/apps/web/app/api/v3/feedbackRecords/lib/errors.ts b/apps/web/app/api/v3/feedbackRecords/lib/errors.ts new file mode 100644 index 000000000000..05838185b9a6 --- /dev/null +++ b/apps/web/app/api/v3/feedbackRecords/lib/errors.ts @@ -0,0 +1,193 @@ +import "server-only"; +import type { z } from "zod"; +import type { logger } from "@formbricks/logger"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { + type InvalidParam, + problemBadGateway, + problemBadRequest, + problemConflict, + problemForbidden, + problemInternalError, + problemPayloadTooLarge, + problemServiceUnavailable, + problemTooManyRequests, + problemUnprocessableContent, +} from "@/app/api/v3/lib/response"; +import type { HubError } from "@/modules/hub/utils"; + +/** + * Error mapping for the feedback-records surface: Hub failures and unexpected throws → controlled v3 + * problem responses. Split out of `operations.ts` so the operations read as a dispatcher and the mapping + * rules — the part with the disclosure risk — can be tested on their own. + */ + +// Bounds on what a Hub 4xx may contribute to our response body (see `hubErrorToProblemResponse`). +const MAX_RELAYED_INVALID_PARAMS = 20; +const MAX_RELAYED_DETAIL_LENGTH = 512; + +/** + * Semantic search and similarity need embeddings, which are optional in the Hub. Our own static message, + * not the upstream body: it names the setting to change, on both processes that need it. + * + * Passed explicitly by the two search operations rather than being the 503 default, because those are the + * only ones for which it is true — see `GENERIC_SERVICE_UNAVAILABLE_DETAIL`. + */ +export const EMBEDDINGS_UNAVAILABLE_DETAIL = + "Semantic search is not available: the feedback service has no embedding model configured. A self-hosting administrator can enable it by setting EMBEDDING_PROVIDER and EMBEDDING_MODEL on both the Hub API and the Hub worker."; + +/** + * A record that exists and belongs to the caller, yet has no embedding — the only thing a Hub 404 can + * mean once ownership is proven. Reported as 409, not 404, because the record *is* there; the message + * distinguishes the two causes, because only one of them is worth retrying (a fresh record is still being + * embedded, whereas a record with no text has no embedding to wait for). + */ +export const EMBEDDING_PENDING_DETAIL = + "This feedback record has no embedding, so similar records cannot be found. If it was just created, embeddings are generated in the background — retry in a moment. If it has no text, or its text was cleared by an update, it has no embedding at all and retrying will not help."; + +/** + * What a 503 says when the caller has not named a cause. Deliberately vague: the Hub answers 503 for + * several unrelated unconfigured subsystems, so naming one would be wrong for the rest — a plain Hub outage + * on a list or a create must not tell an operator to configure embeddings, which has nothing to do with it. + */ +const GENERIC_SERVICE_UNAVAILABLE_DETAIL = + "This feature depends on a part of the feedback service that is not configured on this deployment."; + +/** + * Hub statuses whose detail describes the *caller's own* request, and may therefore be echoed: a rejected + * field value, a duplicate submission, an oversized record. + * + * Deliberately not "any 4xx". A Hub 401/403 means *our* Hub credentials were refused and a 404 can reveal + * upstream addressing — neither is the caller's business, and both would be describing our infrastructure + * rather than their request. + */ +const RELAYABLE_HUB_STATUSES = new Set([400, 409, 413, 422]); + +/** + * Translate the Hub's internal vocabulary into this API's, for any upstream text we relay. + * + * The Hub's tenant *is* our dataset — `serializeV3FeedbackRecord` already renames the field on the way out — + * so a relayed message naming `tenant_id` contradicts the rest of the surface and points a caller at a + * parameter that does not exist here. Reproduced with a duplicate create, whose Hub 409 reads "a feedback + * record with this tenant_id, submission_id, and field_id already exists". + * + * Word-bounded so it renames the term and nothing else. Applied to every relayed string — detail and + * `invalid_params` alike — because the Hub names fields in both. + */ +function toApiVocabulary(text: string): string { + return text.replace(/\btenant_id\b/g, "dataset_id"); +} + +/** + * The one place that decides what a Hub failure may say to a caller. + * + * The Hub owns content rules we deliberately don't duplicate (NULL bytes, its own length limits), so for + * the relayable statuses its message is relayed — bounded, and in this API's vocabulary — because without it + * an agent cannot correct its own request. Everything else is replaced by a fixed string. Used both for + * whole-request problem responses and for the per-record failures of a batch write, so neither can drift + * into leaking more than the other. + */ +export function relayableHubDetail(error: HubError | null, fallback: string): string { + if (!error?.problemDetail || !RELAYABLE_HUB_STATUSES.has(error.status)) { + return fallback; + } + // Rewritten before slicing, not after: `dataset_id` is a character longer than `tenant_id`, so a + // slice-then-rewrite could push the result past the cap it is supposed to enforce. + return toApiVocabulary(error.problemDetail).slice(0, MAX_RELAYED_DETAIL_LENGTH); +} + +/** + * Map a Hub service error to a controlled v3 problem response. + * + * A Hub 400/422 describes the *caller's own* input, so its field-level detail is relayed: the Hub owns + * the content rules we deliberately don't duplicate here (NULL bytes, its own length limits), and + * without them an agent can't correct its request. Everything else — + * unconfigured/unreachable Hub, our own Hub credentials being rejected, upstream 5xx — collapses to a + * generic 502 and is only ever logged, never echoed. + */ +export function hubErrorToProblemResponse( + error: HubError | null, + requestId: string, + instance: string, + options?: { + /** + * What a Hub 503 means for this operation. Worth passing from any caller that can actually receive one; + * the rest get a message that names no subsystem. + */ + serviceUnavailableDetail?: string; + } +): Response { + const status = error?.status ?? 0; + if (status === 429) { + return problemTooManyRequests(requestId, "The feedback service is rate limiting requests."); + } + + // A duplicate (submission_id, field_id) or an in-progress tenant purge — the caller's request, not an + // outage, and retryable in the purge case. Reported as 409 so an agent doesn't retry-loop on a 502. + if (status === 409) { + return problemConflict( + requestId, + relayableHubDetail(error, "The feedback service reported a conflict."), + instance + ); + } + + // The Hub's body cap is lower than ours, so this is reachable with a large (but locally valid) payload. + if (status === 413) { + return problemPayloadTooLarge( + requestId, + relayableHubDetail(error, "The feedback record is too large."), + instance + ); + } + + // A deployment-level "not enabled", not an outage — so it must not collapse into the generic 502 below, + // which would read as "retry later" for something no retry can fix. What is unconfigured depends on the + // operation, so the wording comes from the caller. + if (status === 503) { + return problemServiceUnavailable( + requestId, + options?.serviceUnavailableDetail ?? GENERIC_SERVICE_UNAVAILABLE_DETAIL, + instance + ); + } + + if (status === 400 || status === 422) { + // Only name/reason cross over: the Hub's `code` vocabulary is its own, not the v3 InvalidParamCode set. + // Bounded on both axes — the Hub is a remote service, so we don't let it size our response body. + const invalidParams: InvalidParam[] | undefined = error?.invalidParams + ?.slice(0, MAX_RELAYED_INVALID_PARAMS) + .map(({ name, reason }) => ({ + name: toApiVocabulary(name).slice(0, MAX_RELAYED_DETAIL_LENGTH), + reason: toApiVocabulary(reason).slice(0, MAX_RELAYED_DETAIL_LENGTH), + })); + const detail = relayableHubDetail(error, "The feedback service rejected the request."); + + return status === 400 + ? problemBadRequest(requestId, detail, { instance, invalid_params: invalidParams }) + : problemUnprocessableContent(requestId, detail, { instance, invalid_params: invalidParams }); + } + + return problemBadGateway(requestId, "The feedback service is unavailable.", instance); +} + +export function handleUnexpectedError( + err: unknown, + log: ReturnType, + requestId: string, + instance: string +): Response { + if (err instanceof ResourceNotFoundError) { + log.warn({ statusCode: 403, errorCode: err.name }, "Resource not found"); + return problemForbidden(requestId, "You are not authorized to access this resource", instance); + } + if (err instanceof DatabaseError) { + log.error({ error: err, statusCode: 500 }, "Database error"); + return problemInternalError(requestId, "An unexpected error occurred.", instance); + } + log.error({ error: err, statusCode: 500 }, "Unexpected error"); + return problemInternalError(requestId, "An unexpected error occurred.", instance); +} + +export const toInvalidParams = (error: z.ZodError): InvalidParam[] => + error.issues.map((issue) => ({ name: issue.path.join("."), reason: issue.message })); diff --git a/apps/web/app/api/v3/feedbackRecords/lib/operations.test.ts b/apps/web/app/api/v3/feedbackRecords/lib/operations.test.ts new file mode 100644 index 000000000000..0fcea82083a7 --- /dev/null +++ b/apps/web/app/api/v3/feedbackRecords/lib/operations.test.ts @@ -0,0 +1,1595 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth"; +import type { TV3AuditLog } from "@/app/api/v3/lib/types"; +import type { V3WorkspaceContext } from "@/app/api/v3/lib/workspace-context"; +import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-directory/lib/feedback-directory"; +import { getIsFeedbackDirectoriesEnabled } from "@/modules/ee/license-check/lib/utils"; +import { + countFeedbackRecords, + createFeedbackRecord, + createFeedbackRecordsBatch, + deleteFeedbackRecord, + findSimilarFeedbackRecords, + listFeedbackRecords, + retrieveFeedbackRecord, + semanticSearchFeedbackRecords, + updateFeedbackRecord, +} from "@/modules/hub/service"; +import type { FeedbackRecordData } from "@/modules/hub/types"; +import { + countV3FeedbackRecords, + createV3FeedbackRecord, + createV3FeedbackRecords, + deleteV3FeedbackRecord, + findSimilarV3FeedbackRecords, + getV3FeedbackRecord, + listV3FeedbackDatasets, + listV3FeedbackRecords, + searchV3FeedbackRecords, + updateV3FeedbackRecord, +} from "./operations"; + +vi.mock("server-only", () => ({})); + +vi.mock("@formbricks/logger", () => ({ + logger: { withContext: vi.fn(() => ({ warn: vi.fn(), error: vi.fn(), info: vi.fn() })) }, +})); + +vi.mock("@/app/api/v3/lib/auth", () => ({ requireV3WorkspaceAccess: vi.fn() })); +vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getIsFeedbackDirectoriesEnabled: vi.fn() })); +vi.mock("@/modules/ee/feedback-directory/lib/feedback-directory", () => ({ + getFeedbackDirectoriesByWorkspaceId: vi.fn(), +})); +vi.mock("@/modules/hub/service", () => ({ + listFeedbackRecords: vi.fn(), + countFeedbackRecords: vi.fn(), + createFeedbackRecordsBatch: vi.fn(), + retrieveFeedbackRecord: vi.fn(), + createFeedbackRecord: vi.fn(), + deleteFeedbackRecord: vi.fn(), + semanticSearchFeedbackRecords: vi.fn(), + findSimilarFeedbackRecords: vi.fn(), + updateFeedbackRecord: vi.fn(), +})); + +const workspaceId = "clxx1234567890123456789012"; +const directoryId = "clfd1234567890123456789012"; +const otherDirectoryId = "clfd9999999999999999999999"; +const context: V3WorkspaceContext = { workspaceId, organizationId: "org_1" }; +const instance = "/api/mcp"; +const requestId = "req_1"; +const base = { authentication: null, workspaceId, requestId, instance }; + +const record: FeedbackRecordData = { + id: "019fa338-f494-7384-b34e-01739783d280", + collected_at: "2026-07-01T00:00:00.000Z", + created_at: "2026-07-01T00:00:00.000Z", + updated_at: "2026-07-01T00:00:00.000Z", + field_id: "q1", + field_type: "text", + source_type: "survey", + submission_id: "sub-1", + tenant_id: directoryId, + value_text: "Love it", +}; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(context); + vi.mocked(getIsFeedbackDirectoriesEnabled).mockResolvedValue(true); + vi.mocked(getFeedbackDirectoriesByWorkspaceId).mockResolvedValue([{ id: directoryId, name: "Support" }]); +}); + +describe("shared authorization + tenant resolution", () => { + test("returns the auth Response and skips downstream work when access is denied", async () => { + const denied = new Response("forbidden", { status: 403 }); + vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(denied); + + const response = await listV3FeedbackRecords(base); + + expect(response).toBe(denied); + expect(getIsFeedbackDirectoriesEnabled).not.toHaveBeenCalled(); + expect(listFeedbackRecords).not.toHaveBeenCalled(); + }); + + test("returns 403 when the feedbackDirectories feature is not licensed", async () => { + vi.mocked(getIsFeedbackDirectoriesEnabled).mockResolvedValue(false); + + const response = await listV3FeedbackRecords(base); + + expect(response.status).toBe(403); + expect(listFeedbackRecords).not.toHaveBeenCalled(); + }); + + // An agent can only help the user if the dead-end says who does what, and where — so the detail has + // to name the role and the settings location, not just the problem. + test("returns an actionable 422 when no dataset is assigned to the workspace", async () => { + vi.mocked(getFeedbackDirectoriesByWorkspaceId).mockResolvedValue([]); + + const response = await listV3FeedbackRecords(base); + const body = await response.json(); + + expect(response.status).toBe(422); + expect(body.detail).toContain("organization owner or manager"); + expect(body.detail).toContain("Settings → Organization → Feedback Datasets"); + expect(listFeedbackRecords).not.toHaveBeenCalled(); + }); + + test("returns 400 when multiple datasets exist and none is specified", async () => { + vi.mocked(getFeedbackDirectoriesByWorkspaceId).mockResolvedValue([ + { id: directoryId, name: "A" }, + { id: otherDirectoryId, name: "B" }, + ]); + + const response = await listV3FeedbackRecords(base); + + expect(response.status).toBe(400); + }); + + // The resolver normalises the id it hands back as `dataset_id`, so a caller echoing that value on its + // next request must still match. Comparing the raw id would 403 a legitimate round trip. + test("accepts the normalised datasetId it would have echoed back", async () => { + vi.mocked(getFeedbackDirectoriesByWorkspaceId).mockResolvedValue([ + { id: ` ${directoryId} `, name: "Support" }, + ]); + vi.mocked(listFeedbackRecords).mockResolvedValue({ + data: { data: [], limit: 50, next_cursor: undefined }, + error: null, + }); + + const response = await listV3FeedbackRecords({ ...base, datasetId: directoryId }); + + expect(response.status).toBe(200); + expect(vi.mocked(listFeedbackRecords).mock.calls[0][0].tenant_id).toBe(directoryId); + }); + + test("returns 403 when an explicit datasetId is not assigned to the workspace", async () => { + const response = await listV3FeedbackRecords({ ...base, datasetId: otherDirectoryId }); + + expect(response.status).toBe(403); + expect(listFeedbackRecords).not.toHaveBeenCalled(); + }); +}); + +describe("listV3FeedbackDatasets", () => { + test("returns the workspace's active datasets", async () => { + const response = await listV3FeedbackDatasets(base); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + data: [{ id: directoryId, name: "Support" }], + meta: { nextCursor: null, totalCount: 1 }, + }); + }); + + // This operation stops at the shared access+entitlement gate rather than the tenant resolver, so its + // own denial branches need pinning too. + test("returns the auth Response when workspace access is denied", async () => { + const denied = new Response("forbidden", { status: 403 }); + vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(denied); + + const response = await listV3FeedbackDatasets(base); + + expect(response).toBe(denied); + expect(getFeedbackDirectoriesByWorkspaceId).not.toHaveBeenCalled(); + }); + + test("returns 403 when the feedbackDirectories feature is not licensed", async () => { + vi.mocked(getIsFeedbackDirectoriesEnabled).mockResolvedValue(false); + + const response = await listV3FeedbackDatasets(base); + + expect(response.status).toBe(403); + expect(getFeedbackDirectoriesByWorkspaceId).not.toHaveBeenCalled(); + }); + + test("returns an empty list when the workspace has no dataset", async () => { + vi.mocked(getFeedbackDirectoriesByWorkspaceId).mockResolvedValue([]); + + const response = await listV3FeedbackDatasets(base); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ data: [], meta: { nextCursor: null, totalCount: 0 } }); + }); +}); + +describe("listV3FeedbackRecords", () => { + test("auto-resolves the single dataset as the Hub tenant and returns serialized records", async () => { + vi.mocked(listFeedbackRecords).mockResolvedValue({ + data: { data: [record], limit: 50, next_cursor: "next" }, + error: null, + }); + + const response = await listV3FeedbackRecords({ ...base, source_type: "survey", field_type: "text" }); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(listFeedbackRecords).toHaveBeenCalledWith( + expect.objectContaining({ + tenant_id: directoryId, + limit: 50, + source_type: "survey", + field_type: "text", + }) + ); + expect(body.meta).toEqual({ + limit: 50, + nextCursor: "next", + datasetId: directoryId, + datasetName: "Support", + }); + expect(body.data[0].id).toBe(record.id); + expect(body.data[0].value_text).toBe("Love it"); + }); + + // An empty list used to be ambiguous — a caller could not tell "this dataset has no matching records" + // from "I don't know which dataset was searched", and had to make a second call to find out. The + // response now names the dataset it resolved, empty or not. + test("names the resolved dataset even when no records match", async () => { + vi.mocked(listFeedbackRecords).mockResolvedValue({ + data: { data: [], limit: 50, next_cursor: undefined }, + error: null, + }); + + const body = await (await listV3FeedbackRecords(base)).json(); + + expect(body.data).toEqual([]); + expect(body.meta.datasetId).toBe(directoryId); + expect(body.meta.datasetName).toBe("Support"); + }); + + test("names the dataset the caller asked for when one is given explicitly", async () => { + vi.mocked(getFeedbackDirectoriesByWorkspaceId).mockResolvedValue([ + { id: directoryId, name: "Support" }, + { id: otherDirectoryId, name: "Sales" }, + ]); + vi.mocked(listFeedbackRecords).mockResolvedValue({ + data: { data: [], limit: 50, next_cursor: undefined }, + error: null, + }); + + const body = await (await listV3FeedbackRecords({ ...base, datasetId: otherDirectoryId })).json(); + + expect(body.meta.datasetId).toBe(otherDirectoryId); + expect(body.meta.datasetName).toBe("Sales"); + }); + + // The Hub's `tenant_id` is Hub-internal vocabulary; the outward-facing name is `dataset_id`. Same + // value, and `tenant_id` must not appear in a response at all. + test("emits the tenant as dataset_id and never as tenant_id", async () => { + vi.mocked(listFeedbackRecords).mockResolvedValue({ + data: { data: [record], limit: 50, next_cursor: undefined }, + error: null, + }); + + const body = await (await listV3FeedbackRecords(base)).json(); + + expect(body.data[0].dataset_id).toBe(directoryId); + expect(body.data[0]).not.toHaveProperty("tenant_id"); + expect(JSON.stringify(body)).not.toContain("tenant_id"); + }); + + test("maps a Hub rate-limit error to 429", async () => { + vi.mocked(listFeedbackRecords).mockResolvedValue({ + data: null, + error: { status: 429, message: "slow down", detail: "slow down" }, + }); + + const response = await listV3FeedbackRecords(base); + + expect(response.status).toBe(429); + }); + + test("maps a Hub server/unreachable error to 502 without leaking the detail", async () => { + vi.mocked(listFeedbackRecords).mockResolvedValue({ + data: null, + error: { status: 0, message: "ECONNREFUSED at 10.0.0.1", detail: "ECONNREFUSED at 10.0.0.1" }, + }); + + const response = await listV3FeedbackRecords(base); + const body = await response.json(); + + expect(response.status).toBe(502); + expect(JSON.stringify(body)).not.toContain("ECONNREFUSED"); + }); +}); + +describe("getV3FeedbackRecord", () => { + const getBase = { ...base, feedbackRecordId: record.id }; + + test("returns the record when its tenant belongs to the workspace", async () => { + vi.mocked(retrieveFeedbackRecord).mockResolvedValue({ data: record, error: null }); + + const response = await getV3FeedbackRecord(getBase); + + expect(response.status).toBe(200); + expect((await response.json()).data.id).toBe(record.id); + }); + + test("returns 403 (no existence oracle) when the record belongs to another tenant", async () => { + vi.mocked(retrieveFeedbackRecord).mockResolvedValue({ + data: { ...record, tenant_id: otherDirectoryId }, + error: null, + }); + + const response = await getV3FeedbackRecord(getBase); + + expect(response.status).toBe(403); + }); + + test("returns 403 when the Hub reports 404 (indistinguishable from cross-tenant)", async () => { + vi.mocked(retrieveFeedbackRecord).mockResolvedValue({ + data: null, + error: { status: 404, message: "not found", detail: "not found" }, + }); + + const response = await getV3FeedbackRecord(getBase); + + expect(response.status).toBe(403); + }); + + // The no-existence-oracle guarantee: cross-tenant and not-found must be byte-identical, not merely + // both 403 — a one-sided message tweak would otherwise silently reopen the oracle. + test("returns an identical body for cross-tenant and not-found", async () => { + vi.mocked(retrieveFeedbackRecord).mockResolvedValue({ + data: { ...record, tenant_id: otherDirectoryId }, + error: null, + }); + const crossTenant = await getV3FeedbackRecord(getBase).then((r) => r.json()); + + vi.mocked(retrieveFeedbackRecord).mockResolvedValue({ + data: null, + error: { status: 404, message: "not found", detail: "not found" }, + }); + const notFound = await getV3FeedbackRecord(getBase).then((r) => r.json()); + + expect(crossTenant).toEqual(notFound); + }); + + // A Hub failure that isn't a 404 is an upstream problem, not an authorization one — collapsing it into + // the 403 would tell the caller its permissions are wrong when the service is simply down. + test("maps a non-404 Hub failure on retrieval to 502, not 403", async () => { + vi.mocked(retrieveFeedbackRecord).mockResolvedValue({ + data: null, + error: { status: 500, message: "boom", detail: "boom" }, + }); + + const response = await getV3FeedbackRecord(getBase); + + expect(response.status).toBe(502); + }); + + test("rejects a record from another directory the workspace owns when a directory is named", async () => { + vi.mocked(getFeedbackDirectoriesByWorkspaceId).mockResolvedValue([ + { id: directoryId, name: "A" }, + { id: otherDirectoryId, name: "B" }, + ]); + vi.mocked(retrieveFeedbackRecord).mockResolvedValue({ + data: { ...record, tenant_id: otherDirectoryId }, + error: null, + }); + + const response = await getV3FeedbackRecord({ ...getBase, datasetId: directoryId }); + + expect(response.status).toBe(403); + }); +}); + +describe("createV3FeedbackRecord", () => { + const createdRecord: FeedbackRecordData = { ...record, source_type: "call_notes", value_text: "hi" }; + + beforeEach(() => { + vi.mocked(createFeedbackRecord).mockResolvedValue({ data: createdRecord, error: null }); + }); + + test("requires readWrite access", async () => { + await createV3FeedbackRecord({ + ...base, + body: { source_type: "call_notes", field_id: "note", field_type: "text", value_text: "hi" }, + }); + + expect(requireV3WorkspaceAccess).toHaveBeenCalledWith( + null, + workspaceId, + "readWrite", + requestId, + instance + ); + }); + + test("injects the resolved tenant_id and ignores any tenant_id in the body", async () => { + await createV3FeedbackRecord({ + ...base, + body: { + source_type: "call_notes", + field_id: "note", + field_type: "text", + value_text: "hi", + tenant_id: "attacker-tenant", + }, + }); + + const callArg = vi.mocked(createFeedbackRecord).mock.calls[0][0]; + expect(callArg.tenant_id).toBe(directoryId); + expect(callArg.tenant_id).not.toBe("attacker-tenant"); + }); + + test("generates a submission_id when omitted", async () => { + await createV3FeedbackRecord({ + ...base, + body: { source_type: "call_notes", field_id: "note", field_type: "text", value_text: "hi" }, + }); + + const callArg = vi.mocked(createFeedbackRecord).mock.calls[0][0]; + expect(typeof callArg.submission_id).toBe("string"); + expect(callArg.submission_id.length).toBeGreaterThan(0); + }); + + test("returns 201 and stamps the audit log on success", async () => { + const auditLog = { status: "attempted" } as unknown as TV3AuditLog; + + const response = await createV3FeedbackRecord({ + ...base, + auditLog, + body: { source_type: "call_notes", field_id: "note", field_type: "text", value_text: "hi" }, + }); + + expect(response.status).toBe(201); + expect(auditLog.organizationId).toBe("org_1"); + expect(auditLog.targetId).toBe(createdRecord.id); + expect(auditLog.newObject).toBeDefined(); + }); + + test("returns 422 for an invalid body (bad field_type) before calling the Hub", async () => { + const response = await createV3FeedbackRecord({ + ...base, + body: { source_type: "call_notes", field_id: "note", field_type: "not-a-type", value_text: "hi" }, + }); + + expect(response.status).toBe(422); + expect(createFeedbackRecord).not.toHaveBeenCalled(); + }); + + // The Hub still owns content rules we don't duplicate (NUL bytes, its own limits) and reports them as + // 400 — its field-level detail must reach the caller, or an agent can't correct its own request. + // (Verified against a real Hub: a NUL byte in value_text produces exactly this shape.) + test("relays the Hub's validation detail and invalid_params on a 400", async () => { + vi.mocked(createFeedbackRecord).mockResolvedValue({ + data: null, + error: { + status: 400, + message: "400 Bad Request", + detail: "400 Bad Request", + code: "validation", + problemDetail: "One or more request parameters are invalid", + invalidParams: [{ name: "value_text", reason: "must not contain NULL bytes" }], + }, + }); + + const response = await createV3FeedbackRecord({ + ...base, + body: { source_type: "call_notes", field_id: "note", field_type: "text", value_text: "hi" }, + }); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.detail).toBe("One or more request parameters are invalid"); + expect(body.invalid_params).toEqual([{ name: "value_text", reason: "must not contain NULL bytes" }]); + }); + + // The Hub accepts a record with no value at all, and MCP strips unknown keys before we see them, so a + // mistyped field name would otherwise store an empty record and report success. + test("rejects a body whose field_type has no matching value, naming the expected field", async () => { + const response = await createV3FeedbackRecord({ + ...base, + // `valueText` is what an agent typo looks like after MCP has stripped it: no value at all. + body: { source_type: "call_notes", field_id: "note", field_type: "text" }, + }); + const body = await response.json(); + + expect(response.status).toBe(422); + expect(body.invalid_params).toEqual([ + expect.objectContaining({ name: "value_text", reason: expect.stringContaining("field_type") }), + ]); + expect(createFeedbackRecord).not.toHaveBeenCalled(); + }); + + test.each([ + ["nps", "value_number"], + ["boolean", "value_boolean"], + ["date", "value_date"], + ])("requires %s to carry %s", async (fieldType, expectedField) => { + const response = await createV3FeedbackRecord({ + ...base, + body: { source_type: "survey", field_id: "q", field_type: fieldType }, + }); + + expect(response.status).toBe(422); + expect((await response.json()).invalid_params[0].name).toBe(expectedField); + }); + + test("accepts a categorical record identified only by value_id", async () => { + const response = await createV3FeedbackRecord({ + ...base, + body: { source_type: "survey", field_id: "q", field_type: "categorical", value_id: "opt_1" }, + }); + + expect(response.status).toBe(201); + }); + + test("maps a Hub duplicate/purge conflict to 409 rather than a misleading 502", async () => { + vi.mocked(createFeedbackRecord).mockResolvedValue({ + data: null, + error: { + status: 409, + message: "409 Conflict", + detail: "409 Conflict", + problemDetail: "duplicate record for (tenant_id, submission_id, field_id)", + }, + }); + + const response = await createV3FeedbackRecord({ + ...base, + body: { source_type: "call_notes", field_id: "note", field_type: "text", value_text: "hi" }, + }); + + expect(response.status).toBe(409); + expect((await response.json()).detail).toContain("duplicate record"); + }); + + test("relays the Hub's own size-limit detail on a 413", async () => { + vi.mocked(createFeedbackRecord).mockResolvedValue({ + data: null, + error: { + status: 413, + message: "413", + detail: "413", + problemDetail: "request body exceeds 512 KiB", + }, + }); + + const relayed = await createV3FeedbackRecord({ + ...base, + body: { source_type: "call_notes", field_id: "note", field_type: "text", value_text: "hi" }, + }); + + expect(relayed.status).toBe(413); + expect((await relayed.json()).detail).toContain("512 KiB"); + }); + + test("maps a Hub 413 to payload-too-large rather than a misleading 502", async () => { + vi.mocked(createFeedbackRecord).mockResolvedValue({ + data: null, + error: { status: 413, message: "too big", detail: "too big" }, + }); + + const response = await createV3FeedbackRecord({ + ...base, + body: { source_type: "call_notes", field_id: "note", field_type: "text", value_text: "hi" }, + }); + + expect(response.status).toBe(413); + }); + + // The cap is a byte budget, so it must not be fooled by multi-byte characters: 20k CJK characters is + // ~60 KB of UTF-8 but only 20k UTF-16 code units. + test("measures the metadata cap in bytes, not string length", async () => { + const response = await createV3FeedbackRecord({ + ...base, + body: { + source_type: "call_notes", + field_id: "note", + field_type: "text", + value_text: "hi", + metadata: { blob: "字".repeat(20_000) }, + }, + }); + + expect(response.status).toBe(422); + expect(createFeedbackRecord).not.toHaveBeenCalled(); + }); + + test("rejects oversized metadata locally instead of letting the Hub reject the request", async () => { + const response = await createV3FeedbackRecord({ + ...base, + body: { + source_type: "call_notes", + field_id: "note", + field_type: "text", + value_text: "hi", + metadata: { blob: "x".repeat(40_000) }, + }, + }); + + expect(response.status).toBe(422); + expect(createFeedbackRecord).not.toHaveBeenCalled(); + }); + + test("does not relay upstream detail on a Hub 5xx", async () => { + vi.mocked(createFeedbackRecord).mockResolvedValue({ + data: null, + error: { + status: 500, + message: "boom", + detail: "boom", + problemDetail: "panic: runtime error at 10.0.0.1", + }, + }); + + const response = await createV3FeedbackRecord({ + ...base, + body: { source_type: "call_notes", field_id: "note", field_type: "text", value_text: "hi" }, + }); + const body = await response.json(); + + expect(response.status).toBe(502); + expect(JSON.stringify(body)).not.toContain("panic"); + }); +}); + +describe("deleteV3FeedbackRecord", () => { + const deleteBase = { ...base, feedbackRecordId: record.id }; + + beforeEach(() => { + vi.mocked(retrieveFeedbackRecord).mockResolvedValue({ data: record, error: null }); + vi.mocked(deleteFeedbackRecord).mockResolvedValue({ data: { deleted: true }, error: null }); + }); + + test("requires readWrite access", async () => { + await deleteV3FeedbackRecord(deleteBase); + + expect(requireV3WorkspaceAccess).toHaveBeenCalledWith( + null, + workspaceId, + "readWrite", + requestId, + instance + ); + }); + + test("returns 204 and deletes the record when it belongs to the workspace", async () => { + const response = await deleteV3FeedbackRecord(deleteBase); + + expect(response.status).toBe(204); + expect(deleteFeedbackRecord).toHaveBeenCalledWith(record.id); + }); + + // The whole point of the ownership guard: the Hub's delete derives the tenant from the record, so a + // foreign id must be refused *before* the delete call — not merely reported as failed afterwards. + test("refuses a cross-tenant record with 403 and never calls the Hub delete", async () => { + vi.mocked(retrieveFeedbackRecord).mockResolvedValue({ + data: { ...record, tenant_id: otherDirectoryId }, + error: null, + }); + + const response = await deleteV3FeedbackRecord(deleteBase); + + expect(response.status).toBe(403); + expect(deleteFeedbackRecord).not.toHaveBeenCalled(); + }); + + test("refuses an unknown record with the same 403 body as a cross-tenant one", async () => { + vi.mocked(retrieveFeedbackRecord).mockResolvedValue({ + data: { ...record, tenant_id: otherDirectoryId }, + error: null, + }); + const crossTenant = await deleteV3FeedbackRecord(deleteBase).then((r) => r.json()); + + vi.mocked(retrieveFeedbackRecord).mockResolvedValue({ + data: null, + error: { status: 404, message: "not found", detail: "not found" }, + }); + const notFoundResponse = await deleteV3FeedbackRecord(deleteBase); + + expect(notFoundResponse.status).toBe(403); + expect(await notFoundResponse.json()).toEqual(crossTenant); + expect(deleteFeedbackRecord).not.toHaveBeenCalled(); + }); + + // The record is gone afterwards, so the audit entry is the only remaining trace of what was deleted. + test("stamps the audit log with the target and the pre-delete record", async () => { + const auditLog = { status: "failure" } as unknown as TV3AuditLog; + + await deleteV3FeedbackRecord({ ...deleteBase, auditLog }); + + expect(auditLog.organizationId).toBe("org_1"); + expect(auditLog.targetId).toBe(record.id); + expect(auditLog.oldObject).toEqual(expect.objectContaining({ id: record.id, value_text: "Love it" })); + // Even in the audit log the outward name is used, so an exported log matches the API vocabulary. + expect(auditLog.oldObject).not.toHaveProperty("tenant_id"); + }); + + // Deleted concurrently between our ownership check and the delete call: the caller's intended end state + // holds, and a 502 here would read as an outage and invite a retry loop against a record already gone. + test("treats a concurrent delete (Hub 404) as success rather than a 502", async () => { + vi.mocked(deleteFeedbackRecord).mockResolvedValue({ + data: null, + error: { status: 404, message: "not found", detail: "not found" }, + }); + + const response = await deleteV3FeedbackRecord(deleteBase); + + expect(response.status).toBe(204); + }); + + test("maps an in-progress tenant purge to a retryable 409", async () => { + vi.mocked(deleteFeedbackRecord).mockResolvedValue({ + data: null, + error: { + status: 409, + message: "409 Conflict", + detail: "409 Conflict", + problemDetail: "tenant data deletion in progress", + }, + }); + + const response = await deleteV3FeedbackRecord(deleteBase); + + expect(response.status).toBe(409); + expect((await response.json()).detail).toContain("tenant data deletion in progress"); + }); +}); + +describe("searchV3FeedbackRecords", () => { + const searchBase = { ...base, query: "checkout is confusing" }; + const match = { + feedback_record_id: record.id, + score: 0.82, + field_label: "What can we improve?", + value_text: "I couldn't figure out how to pay", + }; + + beforeEach(() => { + vi.mocked(semanticSearchFeedbackRecords).mockResolvedValue({ + data: { data: [match], limit: 10, next_cursor: undefined }, + error: null, + }); + }); + + test("injects the resolved tenant and applies our defaults", async () => { + const response = await searchV3FeedbackRecords(searchBase); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(semanticSearchFeedbackRecords).toHaveBeenCalledWith({ + tenant_id: directoryId, + query: "checkout is confusing", + limit: 10, + min_score: 0.5, + }); + expect(body.data).toEqual([match]); + expect(body.meta).toEqual({ + limit: 10, + nextCursor: null, + minScore: 0.5, + datasetId: directoryId, + datasetName: "Support", + }); + }); + + // The Hub uses `tenant_id` verbatim in the vector query (no trimming of its own), so an untrimmed value + // would match nothing and look like "no results" rather than a failure. + test("trims the tenant before sending it to the Hub", async () => { + vi.mocked(getFeedbackDirectoriesByWorkspaceId).mockResolvedValue([ + { id: ` ${directoryId} `, name: "Support" }, + ]); + + await searchV3FeedbackRecords({ ...searchBase, datasetId: ` ${directoryId} ` }); + + expect(vi.mocked(semanticSearchFeedbackRecords).mock.calls[0][0].tenant_id).toBe(directoryId); + }); + + test("passes an explicit limit, cursor and minScore through", async () => { + await searchV3FeedbackRecords({ ...searchBase, limit: 25, cursor: "abc", minScore: 0.9 }); + + expect(semanticSearchFeedbackRecords).toHaveBeenCalledWith( + expect.objectContaining({ limit: 25, cursor: "abc", min_score: 0.9 }) + ); + }); + + // The Hub silently coerces out-of-range limit/min_score to its own defaults, so a caller would get + // results that quietly don't match what it asked for. Rejected locally instead. + test.each([ + ["limit", { limit: 999 }], + ["limit", { limit: 0 }], + ["minScore", { minScore: 1.1 }], + ["minScore", { minScore: -1 }], + ["query", { query: "" }], + ])("rejects an out-of-range %s locally without calling the Hub", async (_field, override) => { + const response = await searchV3FeedbackRecords({ ...searchBase, ...override }); + + expect(response.status).toBe(422); + expect((await response.json()).invalid_params.length).toBeGreaterThan(0); + expect(semanticSearchFeedbackRecords).not.toHaveBeenCalled(); + }); + + // Embeddings are optional in the Hub, so most self-hosted installs land here. A bare "unavailable" + // leaves the caller with nothing to act on. + test("turns the Hub's 503 into an actionable configuration message", async () => { + vi.mocked(semanticSearchFeedbackRecords).mockResolvedValue({ + data: null, + error: { status: 503, message: "unavailable", detail: "unavailable" }, + }); + + const response = await searchV3FeedbackRecords(searchBase); + const body = await response.json(); + + expect(response.status).toBe(503); + expect(body.detail).toContain("EMBEDDING_PROVIDER"); + expect(body.detail).toContain("EMBEDDING_MODEL"); + }); + + test("reports an empty result against the named dataset rather than an error", async () => { + vi.mocked(semanticSearchFeedbackRecords).mockResolvedValue({ + data: { data: [], limit: 10, next_cursor: undefined }, + error: null, + }); + + const body = await (await searchV3FeedbackRecords(searchBase)).json(); + + expect(body.data).toEqual([]); + expect(body.meta.datasetId).toBe(directoryId); + expect(body.meta.minScore).toBe(0.5); + }); +}); + +describe("findSimilarV3FeedbackRecords", () => { + const similarBase = { ...base, feedbackRecordId: record.id }; + const neighbour = { + feedback_record_id: "019fa338-f494-7384-b34e-01739783d999", + score: 0.77, + field_label: "What can we improve?", + value_text: "payment step was unclear", + }; + + beforeEach(() => { + vi.mocked(retrieveFeedbackRecord).mockResolvedValue({ data: record, error: null }); + vi.mocked(findSimilarFeedbackRecords).mockResolvedValue({ + data: { data: [neighbour], limit: 10, next_cursor: undefined }, + error: null, + }); + }); + + test("returns the neighbours of an owned record", async () => { + const response = await findSimilarV3FeedbackRecords(similarBase); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(findSimilarFeedbackRecords).toHaveBeenCalledWith(record.id, { limit: 10, min_score: 0.5 }); + expect(body.data).toEqual([neighbour]); + expect(body.meta.datasetId).toBe(directoryId); + }); + + // Without the ownership guard this endpoint reads another tenant's records: the Hub scopes the + // neighbour search to whatever tenant the anchor belongs to, with no authorization of its own. + test("refuses a cross-tenant anchor with 403 and returns no neighbours", async () => { + vi.mocked(retrieveFeedbackRecord).mockResolvedValue({ + data: { ...record, tenant_id: otherDirectoryId }, + error: null, + }); + + const response = await findSimilarV3FeedbackRecords(similarBase); + const body = await response.json(); + + expect(response.status).toBe(403); + expect(findSimilarFeedbackRecords).not.toHaveBeenCalled(); + expect(JSON.stringify(body)).not.toContain(neighbour.feedback_record_id); + }); + + test("refuses an unknown anchor with the same 403 body as a cross-tenant one", async () => { + vi.mocked(retrieveFeedbackRecord).mockResolvedValue({ + data: { ...record, tenant_id: otherDirectoryId }, + error: null, + }); + const crossTenant = await findSimilarV3FeedbackRecords(similarBase).then((r) => r.json()); + + vi.mocked(retrieveFeedbackRecord).mockResolvedValue({ + data: null, + error: { status: 404, message: "not found", detail: "not found" }, + }); + const notFound = await findSimilarV3FeedbackRecords(similarBase); + + expect(notFound.status).toBe(403); + expect(await notFound.json()).toEqual(crossTenant); + }); + + // Once ownership is proven the Hub's 404 can only mean "no embedding for this record" — reporting it as + // the generic 403 would be actively misleading, since the caller does own the record. + test("reports a post-ownership 404 as a retryable embedding-pending conflict, not a 403", async () => { + vi.mocked(findSimilarFeedbackRecords).mockResolvedValue({ + data: null, + error: { status: 404, message: "not found", detail: "embedding not found" }, + }); + + const response = await findSimilarV3FeedbackRecords(similarBase); + const body = await response.json(); + + expect(response.status).toBe(409); + expect(body.detail).toContain("no embedding"); + expect(body.detail).toContain("background"); + // An update can *clear* a record's text, which removes its embedding for good — so the message must + // not tell the caller to keep retrying something that will never succeed. + expect(body.detail).toContain("retrying will not help"); + }); + + test("turns the Hub's 503 into an actionable configuration message", async () => { + vi.mocked(findSimilarFeedbackRecords).mockResolvedValue({ + data: null, + error: { status: 503, message: "unavailable", detail: "unavailable" }, + }); + + const response = await findSimilarV3FeedbackRecords(similarBase); + + expect(response.status).toBe(503); + expect((await response.json()).detail).toContain("EMBEDDING_PROVIDER"); + }); + + test("rejects an out-of-range minScore locally, before retrieving the record", async () => { + const response = await findSimilarV3FeedbackRecords({ ...similarBase, minScore: 1.1 }); + + expect(response.status).toBe(422); + expect(retrieveFeedbackRecord).not.toHaveBeenCalled(); + expect(findSimilarFeedbackRecords).not.toHaveBeenCalled(); + }); +}); + +describe("countV3FeedbackRecords", () => { + beforeEach(() => { + vi.mocked(countFeedbackRecords).mockResolvedValue({ data: { count: 42 }, error: null }); + }); + + test("returns the count and names the dataset it came from", async () => { + const response = await countV3FeedbackRecords(base); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + data: { count: 42, dataset_id: directoryId, dataset_name: "Support" }, + }); + expect(countFeedbackRecords).toHaveBeenCalledWith({ tenant_id: directoryId }); + }); + + // A count answers "how many" without any record content crossing the boundary — that is the whole point + // of having it rather than making the caller page through records. + test("returns no record content at all", async () => { + const body = JSON.stringify(await (await countV3FeedbackRecords(base)).json()); + + expect(body).not.toContain("value_text"); + expect(body).not.toContain("Love it"); + }); + + test("passes every filter through under the Hub's parameter names", async () => { + await countV3FeedbackRecords({ + ...base, + source_type: "survey", + source_id: "svy_1", + field_type: "text", + field_id: "q1", + field_group_id: "grp_1", + submission_id: "sub-1", + user_id: "user-1", + value_id: "opt_1", + since: "2026-01-01T00:00:00Z", + until: "2026-12-31T00:00:00Z", + }); + + expect(countFeedbackRecords).toHaveBeenCalledWith({ + tenant_id: directoryId, + source_type: "survey", + source_id: "svy_1", + field_type: "text", + field_id: "q1", + field_group_id: "grp_1", + submission_id: "sub-1", + user_id: "user-1", + value_id: "opt_1", + since: "2026-01-01T00:00:00Z", + until: "2026-12-31T00:00:00Z", + }); + }); + + test("cannot be pointed at another workspace's dataset", async () => { + const response = await countV3FeedbackRecords({ ...base, datasetId: otherDirectoryId }); + + expect(response.status).toBe(403); + expect(countFeedbackRecords).not.toHaveBeenCalled(); + }); + + test("maps a Hub failure through the shared error mapping", async () => { + vi.mocked(countFeedbackRecords).mockResolvedValue({ + data: null, + error: { status: 500, message: "boom", detail: "boom", problemDetail: "panic at 10.0.0.1" }, + }); + + const response = await countV3FeedbackRecords(base); + + expect(response.status).toBe(502); + expect(JSON.stringify(await response.json())).not.toContain("10.0.0.1"); + }); +}); + +describe("list/count validate their own input", () => { + // These operations are the transport-independent face of this surface, so they can't assume an MCP + // schema screened the input first — and the Hub silently coerces an out-of-range limit to its default. + test.each([ + ["limit", { limit: 5000 }], + ["limit", { limit: 0 }], + ["source_type", { source_type: "x".repeat(256) }], + ])("listV3FeedbackRecords rejects an out-of-range %s without calling the Hub", async (_f, override) => { + const response = await listV3FeedbackRecords({ ...base, ...override }); + + expect(response.status).toBe(422); + expect((await response.json()).invalid_params.length).toBeGreaterThan(0); + expect(listFeedbackRecords).not.toHaveBeenCalled(); + }); + + /** + * A filter this surface does not have used to be dropped silently, which on a filter fails in the wrong + * direction: the query runs unfiltered and the caller is told it succeeded. `userId` is the realistic + * mistake — the old spelling of `user_id` — and it must now be a 422 naming the offending key. + */ + test.each([ + ["the old camelCase spelling", { userId: "user-1" }], + ["an outright unknown key", { nonsense: "x" }], + ])("listV3FeedbackRecords rejects %s instead of ignoring it", async (_f, override) => { + const response = await listV3FeedbackRecords({ ...base, ...(override as object) }); + + expect(response.status).toBe(422); + expect(listFeedbackRecords).not.toHaveBeenCalled(); + }); + + test("countV3FeedbackRecords rejects an out-of-range filter without calling the Hub", async () => { + const response = await countV3FeedbackRecords({ ...base, user_id: "u".repeat(256) }); + + expect(response.status).toBe(422); + expect(countFeedbackRecords).not.toHaveBeenCalled(); + }); +}); + +describe("listV3FeedbackRecords", () => { + /** + * Listing does not touch the vector index, so a Hub 503 here is an outage or some *other* unconfigured + * subsystem. It used to answer with the embeddings message for every operation, which sent an operator + * after a setting that has nothing to do with listing records. + */ + test("does not blame embeddings for a 503 on a path that needs none", async () => { + vi.mocked(listFeedbackRecords).mockResolvedValue({ + data: null, + error: { status: 503, message: "unavailable", detail: "unavailable" }, + }); + + const response = await listV3FeedbackRecords({ ...base }); + const body = await response.json(); + + expect(response.status).toBe(503); + expect(body.detail).not.toMatch(/embedding/i); + expect(body.detail).toContain("not configured"); + }); +}); + +describe("listV3FeedbackRecords filters", () => { + // The Hub's count endpoint takes the same parameters as list, and both go through one mapper — so this + // pins that list really does send the full filter set, not a subset of it. + test("sends every filter to the Hub alongside pagination", async () => { + vi.mocked(listFeedbackRecords).mockResolvedValue({ + data: { data: [], limit: 10, next_cursor: undefined }, + error: null, + }); + + await listV3FeedbackRecords({ + ...base, + limit: 10, + cursor: "abc", + source_type: "survey", + source_id: "svy_1", + field_type: "text", + field_id: "q1", + field_group_id: "grp_1", + submission_id: "sub-1", + user_id: "user-1", + value_id: "opt_1", + since: "2026-01-01T00:00:00Z", + until: "2026-12-31T00:00:00Z", + }); + + expect(listFeedbackRecords).toHaveBeenCalledWith({ + tenant_id: directoryId, + limit: 10, + cursor: "abc", + source_type: "survey", + source_id: "svy_1", + field_type: "text", + field_id: "q1", + field_group_id: "grp_1", + submission_id: "sub-1", + user_id: "user-1", + value_id: "opt_1", + since: "2026-01-01T00:00:00Z", + until: "2026-12-31T00:00:00Z", + }); + }); +}); + +describe("createV3FeedbackRecords", () => { + const record = (i: number) => ({ + source_type: "call_notes", + field_id: "note", + field_type: "text", + value_text: `note ${i}`, + }); + const hubRecord = (i: number) => + ({ + id: `019fa338-f494-7384-b34e-0173978300${i}0`, + tenant_id: directoryId, + value_text: `note ${i}`, + }) as FeedbackRecordData; + + test("requires readWrite access", async () => { + vi.mocked(createFeedbackRecordsBatch).mockResolvedValue({ + results: [{ data: hubRecord(1), error: null }], + }); + + await createV3FeedbackRecords({ ...base, body: { records: [record(1)] } }); + + expect(requireV3WorkspaceAccess).toHaveBeenCalledWith( + null, + workspaceId, + "readWrite", + requestId, + instance + ); + }); + + test("creates every record with the resolved tenant injected", async () => { + vi.mocked(createFeedbackRecordsBatch).mockResolvedValue({ + results: [ + { data: hubRecord(1), error: null }, + { data: hubRecord(2), error: null }, + ], + }); + + const response = await createV3FeedbackRecords({ + ...base, + body: { records: [record(1), record(2)] }, + }); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.data).toHaveLength(2); + expect(body.meta).toMatchObject({ requested: 2, created: 2, failed: 0, failures: [] }); + for (const params of vi.mocked(createFeedbackRecordsBatch).mock.calls[0][0]) { + expect(params.tenant_id).toBe(directoryId); + } + }); + + // Validation is all-or-nothing on purpose: a half-written batch is far worse to recover from than a + // rejected one, and the caller can fix and resend. + test("rejects the whole batch when one record is invalid, before writing anything", async () => { + const response = await createV3FeedbackRecords({ + ...base, + body: { records: [record(1), { ...record(2), value_text: undefined }] }, + }); + const body = await response.json(); + + expect(response.status).toBe(422); + expect(body.invalid_params[0].name).toBe("records.1.value_text"); + expect(createFeedbackRecordsBatch).not.toHaveBeenCalled(); + }); + + test("rejects a batch over the cap and an empty batch", async () => { + const tooMany = await createV3FeedbackRecords({ + ...base, + body: { records: Array.from({ length: 51 }, (_, i) => record(i)) }, + }); + const empty = await createV3FeedbackRecords({ ...base, body: { records: [] } }); + + expect(tooMany.status).toBe(422); + expect(empty.status).toBe(422); + expect(createFeedbackRecordsBatch).not.toHaveBeenCalled(); + }); + + // A batch is not a submission. Grouping is the caller's job, and getting it wrong stores data nothing + // downstream can distinguish from the intended shape — so both halves are pinned. + test("gives each record its own submission_id when omitted, and preserves a shared one", async () => { + vi.mocked(createFeedbackRecordsBatch).mockResolvedValue({ + results: [ + { data: hubRecord(1), error: null }, + { data: hubRecord(2), error: null }, + ], + }); + + await createV3FeedbackRecords({ ...base, body: { records: [record(1), record(2)] } }); + const generated = vi.mocked(createFeedbackRecordsBatch).mock.calls[0][0]; + expect(generated[0].submission_id).not.toBe(generated[1].submission_id); + + vi.mocked(createFeedbackRecordsBatch).mockClear(); + await createV3FeedbackRecords({ + ...base, + body: { + records: [ + { ...record(1), submission_id: "sub-shared" }, + { ...record(2), submission_id: "sub-shared" }, + ], + }, + }); + const shared = vi.mocked(createFeedbackRecordsBatch).mock.calls[0][0]; + expect(shared.map((p) => p.submission_id)).toEqual(["sub-shared", "sub-shared"]); + }); + + test("ignores a tenant_id smuggled into a record", async () => { + vi.mocked(createFeedbackRecordsBatch).mockResolvedValue({ + results: [{ data: hubRecord(1), error: null }], + }); + + await createV3FeedbackRecords({ + ...base, + body: { records: [{ ...record(1), tenant_id: "attacker-tenant" }] }, + }); + + expect(vi.mocked(createFeedbackRecordsBatch).mock.calls[0][0][0].tenant_id).toBe(directoryId); + }); + + // Partial success has to be visible: the caller needs to know which records to retry, by index. + test("reports partial failure per index while returning what was created", async () => { + vi.mocked(createFeedbackRecordsBatch).mockResolvedValue({ + results: [ + { data: hubRecord(1), error: null }, + { + data: null, + error: { + status: 409, + message: "409", + detail: "409", + problemDetail: "duplicate record for (tenant_id, submission_id, field_id)", + }, + }, + ], + }); + + const body = await ( + await createV3FeedbackRecords({ ...base, body: { records: [record(1), record(2)] } }) + ).json(); + + expect(body.data).toHaveLength(1); + expect(body.meta).toMatchObject({ requested: 2, created: 1, failed: 1 }); + expect(body.meta.failures).toEqual([{ index: 1, detail: expect.stringContaining("duplicate record") }]); + }); + + // A 5xx can carry upstream internals, so a per-record failure must be as tight-lipped as a whole-request + // one — the batch path must not become the leak the single path isn't. + test("does not relay upstream internals in a per-record failure", async () => { + vi.mocked(createFeedbackRecordsBatch).mockResolvedValue({ + results: [ + { data: hubRecord(1), error: null }, + { + data: null, + error: { status: 500, message: "boom", detail: "boom", problemDetail: "panic at 10.0.0.1" }, + }, + ], + }); + + const body = await ( + await createV3FeedbackRecords({ ...base, body: { records: [record(1), record(2)] } }) + ).json(); + + expect(JSON.stringify(body)).not.toContain("10.0.0.1"); + expect(body.meta.failures[0].detail).toBe("The feedback service rejected this record."); + }); + + // Per-record relay follows the same status allowlist as a whole request: a Hub 401 is about *our* + // credentials, so its detail must not reach the caller even though 401 is a 4xx. + test("does not relay a Hub auth failure detail in a per-record failure", async () => { + vi.mocked(createFeedbackRecordsBatch).mockResolvedValue({ + results: [ + { data: hubRecord(1), error: null }, + { + data: null, + error: { + status: 401, + message: "401", + detail: "401", + problemDetail: "invalid api key for tenant-service", + }, + }, + ], + }); + + const body = await ( + await createV3FeedbackRecords({ ...base, body: { records: [record(1), record(2)] } }) + ).json(); + + expect(JSON.stringify(body)).not.toContain("invalid api key"); + expect(body.meta.failures[0].detail).toBe("The feedback service rejected this record."); + }); + + // An empty 200 would read as "there was nothing to do" rather than "the service refused everything". + test("returns the upstream failure when nothing could be created", async () => { + vi.mocked(createFeedbackRecordsBatch).mockResolvedValue({ + results: [ + { data: null, error: { status: 503, message: "unavailable", detail: "unavailable" } }, + { data: null, error: { status: 503, message: "unavailable", detail: "unavailable" } }, + ], + }); + + const response = await createV3FeedbackRecords({ ...base, body: { records: [record(1), record(2)] } }); + const body = await response.json(); + + expect(response.status).toBe(503); + // A batch create needs no embeddings, so the 503 must not tell an operator to go configure them. + expect(body.detail).not.toMatch(/embedding/i); + }); + + test("stamps one audit log per created record and leaves failed ones unstamped", async () => { + vi.mocked(createFeedbackRecordsBatch).mockResolvedValue({ + results: [ + { data: hubRecord(1), error: null }, + { data: null, error: { status: 409, message: "dupe", detail: "dupe" } }, + ], + }); + const auditLogs = [{ status: "failure" }, { status: "failure" }] as unknown as TV3AuditLog[]; + + await createV3FeedbackRecords({ ...base, body: { records: [record(1), record(2)] }, auditLogs }); + + expect(auditLogs[0].targetId).toBe(hubRecord(1).id); + expect(auditLogs[0].organizationId).toBe("org_1"); + expect(auditLogs[0].newObject).toBeDefined(); + expect(auditLogs[1].targetId).toBeUndefined(); + }); +}); + +describe("updateV3FeedbackRecord", () => { + const updateBase = { ...base, feedbackRecordId: record.id }; + const updated = { ...record, value_text: "Actually, love it a lot" } as FeedbackRecordData; + + beforeEach(() => { + vi.mocked(retrieveFeedbackRecord).mockResolvedValue({ data: record, error: null }); + vi.mocked(updateFeedbackRecord).mockResolvedValue({ data: updated, error: null }); + }); + + test("requires readWrite access", async () => { + await updateV3FeedbackRecord({ ...updateBase, body: { value_text: "x" } }); + + expect(requireV3WorkspaceAccess).toHaveBeenCalledWith( + null, + workspaceId, + "readWrite", + requestId, + instance + ); + }); + + test("sends only the fields the caller provided", async () => { + const response = await updateV3FeedbackRecord({ + ...updateBase, + body: { value_text: "Actually, love it a lot" }, + }); + + expect(response.status).toBe(200); + expect(updateFeedbackRecord).toHaveBeenCalledWith(record.id, { + value_text: "Actually, love it a lot", + }); + expect((await response.json()).data.value_text).toBe("Actually, love it a lot"); + }); + + /** + * The Hub does not check the populated `value_*` against `field_type`, and `field_type` is immutable so it + * is not in the patch — without reading the record first, a patch could assemble what create rejects. This + * was reproduced against a live Hub: `{value_number, value_id}` on a `text` record returned 200 with text, + * number and id all set, leaving no way to tell which value the record means. + */ + test("rejects a value_* field the stored field_type does not accept", async () => { + const response = await updateV3FeedbackRecord({ + ...updateBase, + body: { value_number: 99, value_id: "opt-bogus" }, + }); + const body = await response.json(); + + expect(response.status).toBe(422); + expect(body.invalid_params).toEqual([ + { name: "value_number", reason: 'is not valid for a "text" record (expected one of: value_text)' }, + { name: "value_id", reason: 'is not valid for a "text" record (expected one of: value_text)' }, + ]); + expect(updateFeedbackRecord).not.toHaveBeenCalled(); + }); + + /** + * `field_type` comes from a remote service and the record DTO treats it as optional, so the cross-check + * must not turn its absence into a failed update — it used to throw here, which surfaced as a generic 500 + * on an otherwise valid patch. + */ + test("lets the patch through when the Hub returned no field_type to check against", async () => { + const { field_type: _dropped, ...typeless } = record; + vi.mocked(retrieveFeedbackRecord).mockResolvedValue({ + data: typeless as FeedbackRecordData, + error: null, + }); + + const response = await updateV3FeedbackRecord({ ...updateBase, body: { value_number: 99 } }); + + expect(response.status).toBe(200); + expect(updateFeedbackRecord).toHaveBeenCalledWith(record.id, { value_number: 99 }); + }); + + test("allows the value_* field the stored field_type does accept", async () => { + const response = await updateV3FeedbackRecord({ ...updateBase, body: { value_text: "corrected" } }); + + expect(response.status).toBe(200); + expect(updateFeedbackRecord).toHaveBeenCalledWith(record.id, { value_text: "corrected" }); + }); + + // `value_id` is a legitimate value for a categorical record — the option's stable id — so the check has to + // be per-type rather than a blanket ban, or correcting a mis-mapped choice id becomes impossible. + test("allows value_id on a categorical record", async () => { + const categorical = { ...record, field_type: "categorical", value_id: "opt-1" } as FeedbackRecordData; + vi.mocked(retrieveFeedbackRecord).mockResolvedValue({ data: categorical, error: null }); + vi.mocked(updateFeedbackRecord).mockResolvedValue({ data: categorical, error: null }); + + const response = await updateV3FeedbackRecord({ ...updateBase, body: { value_id: "opt-2" } }); + + expect(response.status).toBe(200); + expect(updateFeedbackRecord).toHaveBeenCalledWith(record.id, { value_id: "opt-2" }); + }); + + // Non-value fields carry no type contract, so they must not be caught by the cross-check. + test("allows non-value fields regardless of field_type", async () => { + const response = await updateV3FeedbackRecord({ + ...updateBase, + body: { user_id: "u2", language: "de", metadata: { a: 1 } }, + }); + + expect(response.status).toBe(200); + }); + + // Provenance is immutable in the Hub, and the allowlist is what enforces it: these have nowhere to go. + test("drops immutable provenance fields instead of forwarding them", async () => { + await updateV3FeedbackRecord({ + ...updateBase, + body: { + value_text: "x", + source_type: "hacked", + source_id: "hacked", + field_id: "hacked", + field_type: "nps", + submission_id: "hacked", + collected_at: "1999-01-01T00:00:00Z", + tenant_id: "attacker-tenant", + }, + }); + + expect(updateFeedbackRecord).toHaveBeenCalledWith(record.id, { value_text: "x" }); + }); + + // The derived enrichment fields are the Hub's to compute; a caller must not be able to assert them. + test("drops derived enrichment fields", async () => { + await updateV3FeedbackRecord({ + ...updateBase, + body: { + value_text: "x", + sentiment: "positive", + sentiment_score: 1, + emotions: ["joy"], + value_text_translated: "fake", + translation_lang_key: "de-DE", + }, + }); + + expect(updateFeedbackRecord).toHaveBeenCalledWith(record.id, { value_text: "x" }); + }); + + test("rejects an empty patch rather than making a pointless round trip", async () => { + const response = await updateV3FeedbackRecord({ ...updateBase, body: {} }); + + expect(response.status).toBe(422); + expect((await response.json()).invalid_params[0].reason).toContain("at least one field"); + expect(updateFeedbackRecord).not.toHaveBeenCalled(); + }); + + // The whole reason the update schema is `.pick()`ed from the create fields is that field-level + // refinements come with it. If that ever stopped holding, update would silently lose create's bounds. + test.each([ + ["30k text cap", { value_text: "x".repeat(30_001) }], + ["32KB metadata byte cap", { metadata: { blob: "x".repeat(40_000) } }], + ["metadata measured in bytes, not code units", { metadata: { blob: "字".repeat(20_000) } }], + ])("inherits create's %s", async (_name, body) => { + const response = await updateV3FeedbackRecord({ ...updateBase, body }); + + expect(response.status).toBe(422); + expect(updateFeedbackRecord).not.toHaveBeenCalled(); + }); + + // The body is validated before the ownership check, so this confirms that ordering can't be used to + // distinguish a record you own from one you don't. + test("gives the same 422 for an invalid body whether or not the record is yours", async () => { + const own = await updateV3FeedbackRecord({ + ...updateBase, + body: { value_text: "x".repeat(30_001) }, + }).then((r) => r.json()); + + vi.mocked(retrieveFeedbackRecord).mockResolvedValue({ + data: { ...record, tenant_id: otherDirectoryId }, + error: null, + }); + const foreign = await updateV3FeedbackRecord({ + ...updateBase, + body: { value_text: "x".repeat(30_001) }, + }).then((r) => r.json()); + + expect(foreign).toEqual(own); + }); + + // PATCH is IDOR-shaped upstream exactly like get and delete: the Hub derives the tenant from the record. + test("refuses a cross-tenant record with 403 and never calls the Hub update", async () => { + vi.mocked(retrieveFeedbackRecord).mockResolvedValue({ + data: { ...record, tenant_id: otherDirectoryId }, + error: null, + }); + + const response = await updateV3FeedbackRecord({ ...updateBase, body: { value_text: "x" } }); + + expect(response.status).toBe(403); + expect(updateFeedbackRecord).not.toHaveBeenCalled(); + }); + + test("refuses an unknown record with the same 403 body as a cross-tenant one", async () => { + vi.mocked(retrieveFeedbackRecord).mockResolvedValue({ + data: { ...record, tenant_id: otherDirectoryId }, + error: null, + }); + const crossTenant = await updateV3FeedbackRecord({ ...updateBase, body: { value_text: "x" } }).then((r) => + r.json() + ); + + vi.mocked(retrieveFeedbackRecord).mockResolvedValue({ + data: null, + error: { status: 404, message: "not found", detail: "not found" }, + }); + const notFound = await updateV3FeedbackRecord({ ...updateBase, body: { value_text: "x" } }); + + expect(notFound.status).toBe(403); + expect(await notFound.json()).toEqual(crossTenant); + }); + + // An edit is only reviewable if both sides are recorded. + test("audits both the previous and the new state", async () => { + const auditLog = { status: "failure" } as unknown as TV3AuditLog; + + await updateV3FeedbackRecord({ ...updateBase, body: { value_text: "new" }, auditLog }); + + expect(auditLog.organizationId).toBe("org_1"); + expect(auditLog.targetId).toBe(record.id); + expect(auditLog.oldObject).toMatchObject({ value_text: "Love it" }); + expect(auditLog.newObject).toMatchObject({ value_text: "Actually, love it a lot" }); + }); + + // The guard read the record a moment earlier, so a 404 from the write means it was deleted in between: + // nothing happened, the service is healthy, and the answer must stay indistinguishable from no-access. + test("answers a concurrent delete with the guard's 403, not a misleading 502", async () => { + vi.mocked(updateFeedbackRecord).mockResolvedValue({ + data: null, + error: { status: 404, message: "not found", detail: "not found" }, + }); + + const response = await updateV3FeedbackRecord({ ...updateBase, body: { value_text: "x" } }); + + expect(response.status).toBe(403); + expect((await response.json()).detail).toBe("You are not authorized to access this feedback record"); + }); + + test("relays the Hub's field-level rejection", async () => { + vi.mocked(updateFeedbackRecord).mockResolvedValue({ + data: null, + error: { + status: 400, + message: "400", + detail: "400", + problemDetail: "One or more request parameters are invalid", + invalidParams: [{ name: "value_text", reason: "must not contain NULL bytes" }], + }, + }); + + const response = await updateV3FeedbackRecord({ ...updateBase, body: { value_text: "x" } }); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.invalid_params).toEqual([{ name: "value_text", reason: "must not contain NULL bytes" }]); + }); +}); diff --git a/apps/web/app/api/v3/feedbackRecords/lib/operations.ts b/apps/web/app/api/v3/feedbackRecords/lib/operations.ts new file mode 100644 index 000000000000..7e3ea56afc73 --- /dev/null +++ b/apps/web/app/api/v3/feedbackRecords/lib/operations.ts @@ -0,0 +1,1072 @@ +import "server-only"; +import { randomUUID } from "node:crypto"; +import type { z } from "zod"; +import { logger } from "@formbricks/logger"; +import { requireUnifyFeedbackWorkspaceAccess } from "@/app/api/v3/lib/feedback-access"; +import { + noContentResponse, + problemConflict, + problemUnprocessableContent, + successListResponse, + successResponse, +} from "@/app/api/v3/lib/response"; +import type { TV3AuditLog, TV3Authentication } from "@/app/api/v3/lib/types"; +import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-directory/lib/feedback-directory"; +import { + countFeedbackRecords, + createFeedbackRecord, + createFeedbackRecordsBatch, + deleteFeedbackRecord, + findSimilarFeedbackRecords, + listFeedbackRecords, + semanticSearchFeedbackRecords, + updateFeedbackRecord, +} from "@/modules/hub/service"; +import type { + FeedbackRecordCountParams, + FeedbackRecordCreateParams, + FeedbackRecordListParams, + FeedbackRecordUpdateParams, + SemanticSearchResultItem, + SimilarRecordsParams, + SimilarRecordsResultItem, +} from "@/modules/hub/types"; +import { + type TResolvedFeedbackTenant, + forbidFeedbackRecord, + requireOwnedFeedbackRecord, + resolveWorkspaceFeedbackTenant, +} from "./access"; +import { + EMBEDDINGS_UNAVAILABLE_DETAIL, + EMBEDDING_PENDING_DETAIL, + handleUnexpectedError, + hubErrorToProblemResponse, + relayableHubDetail, + toInvalidParams, +} from "./errors"; +import { + SIMILARITY_LIMIT_DEFAULT, + SIMILARITY_MIN_SCORE_DEFAULT, + type TV3FeedbackRecordBatchCreateBody, + type TV3FeedbackRecordCreateBody, + type TV3FeedbackRecordFilters, + type TV3FeedbackRecordUpdateBody, + ZV3FeedbackRecordBatchCreateBody, + ZV3FeedbackRecordCreateBody, + ZV3FeedbackRecordFilters, + ZV3FeedbackRecordListFilters, + ZV3FeedbackRecordSearchFilters, + ZV3FeedbackRecordSimilarityFilters, + ZV3FeedbackRecordUpdateBody, + conflictingUpdateValueFields, +} from "./schemas"; +import { + type TV3FeedbackRecord, + serializeV3FeedbackDataset, + serializeV3FeedbackRecord, + serializeV3FeedbackRecordMatch, +} from "./serializers"; + +/** + * Transport-independent operations for the feedback-records surface. Each one authorizes (via `./access`), + * talks to the Hub through `modules/hub/service`, and returns a v3 `Response`. Authorization lives in + * `./access.ts`, error mapping in `./errors.ts`, wire shapes in `./serializers.ts`. + */ + +const CACHE = "private, no-store" as const; + +/** + * Build the Hub create payload. This field list *is* the allowlist — never a spread of the input — so + * nothing the caller invents (a `tenant_id` above all) can reach the Hub. Optional fields are assigned + * unconditionally: `undefined` ones are dropped by JSON serialization, so the wire payload only ever + * carries what the caller actually sent. + */ +function buildHubCreateParams( + data: TV3FeedbackRecordCreateBody, + tenantId: string +): FeedbackRecordCreateParams { + return { + tenant_id: tenantId, + // Generated when omitted so a single ad-hoc record still groups cleanly. + submission_id: data.submission_id ?? randomUUID(), + source_type: data.source_type, + field_id: data.field_id, + field_type: data.field_type, + value_text: data.value_text, + value_number: data.value_number, + value_boolean: data.value_boolean, + value_date: data.value_date, + value_id: data.value_id, + user_id: data.user_id, + language: data.language, + source_id: data.source_id, + source_name: data.source_name, + field_group_id: data.field_group_id, + field_group_label: data.field_group_label, + field_label: data.field_label, + collected_at: data.collected_at, + metadata: data.metadata, + }; +} + +/** + * Build the Hub update payload. An allowlist for the same reason as the create builder — and it is what + * enforces immutability of a record's provenance: a `source_type` or `submission_id` in the input simply has + * nowhere to go. Only the fields the caller actually sent are assigned, so an omitted field is left + * untouched rather than being overwritten with null. + * + * One field is not additive: the Hub assigns `metadata` wholesale (`metadata = $n`), so sending it replaces + * the stored object rather than merging into it. A caller adding one key must send the existing keys too — + * hence the warning in the tool description, since the failure is silent. + */ +function buildHubUpdateParams(data: TV3FeedbackRecordUpdateBody): FeedbackRecordUpdateParams { + const params: FeedbackRecordUpdateParams = {}; + if (data.value_text !== undefined) params.value_text = data.value_text; + if (data.value_number !== undefined) params.value_number = data.value_number; + if (data.value_boolean !== undefined) params.value_boolean = data.value_boolean; + if (data.value_date !== undefined) params.value_date = data.value_date; + if (data.value_id !== undefined) params.value_id = data.value_id; + if (data.user_id !== undefined) params.user_id = data.user_id; + if (data.language !== undefined) params.language = data.language; + if (data.metadata !== undefined) params.metadata = data.metadata; + return params; +} + +/** The Hub's own default is 100; we page smaller by default and let callers raise it. */ +const DEFAULT_LIST_LIMIT = 50; + +/** + * Map our filters onto the Hub's query parameters, with the resolved tenant injected. + * + * The names now match the Hub's, so this reads as a copy — but it stays a field-by-field allowlist rather + * than a spread, for the same reason as the create builder: `tenant_id` is always ours, and nothing a caller + * invents can reach the Hub by being named plausibly. + * + * Shared by list and count: the Hub documents `/count` as accepting the same parameters as the list endpoint, + * so the two must agree about what a filter means — otherwise a count could describe a different set of + * records than the list it is supposed to be counting. Absent filters are left off entirely rather than sent + * as undefined. + */ +function buildHubFilterParams( + tenantId: string, + filters: TV3FeedbackRecordFilters +): FeedbackRecordCountParams { + const params: FeedbackRecordCountParams = { tenant_id: tenantId }; + if (filters.source_type) params.source_type = filters.source_type; + if (filters.source_id) params.source_id = filters.source_id; + if (filters.field_type) params.field_type = filters.field_type; + if (filters.field_id) params.field_id = filters.field_id; + if (filters.field_group_id) params.field_group_id = filters.field_group_id; + if (filters.submission_id) params.submission_id = filters.submission_id; + if (filters.user_id) params.user_id = filters.user_id; + if (filters.value_id) params.value_id = filters.value_id; + if (filters.since) params.since = filters.since; + if (filters.until) params.until = filters.until; + return params; +} + +/** + * The two similarity searches — by query text and by example record — differ only in what they send to + * the Hub. Everything around that is shared: the same locally-enforced bounds, the same scored-match rows, + * and the same `meta`. These three helpers hold that common part so each operation states just its own + * difference. + */ + +/** Pagination/threshold params, with our defaults applied. */ +const buildSimilarityParams = (filters: { + limit?: number; + cursor?: string; + minScore?: number; +}): SimilarRecordsParams => { + const params: SimilarRecordsParams = { + limit: filters.limit ?? SIMILARITY_LIMIT_DEFAULT, + min_score: filters.minScore ?? SIMILARITY_MIN_SCORE_DEFAULT, + }; + if (filters.cursor) params.cursor = filters.cursor; + return params; +}; + +/** + * Validate query input against our own bounds, and turn a failure into `invalid_params`. + * + * Every operation validates rather than trusting its caller: these operations are the transport-independent + * face of this surface, so they cannot assume an MCP schema has already screened the input. It also matters + * upstream — the Hub silently coerces an out-of-range `limit`/`min_score` to its own default instead of + * rejecting it, so without this a caller would quietly get something other than what it asked for. + */ +function parseQueryInput( + schema: z.ZodType, + input: unknown, + label: string, + log: ReturnType, + requestId: string, + instance: string +): { ok: true; data: T } | { ok: false; response: Response } { + const parsed = schema.safeParse(input); + if (parsed.success) { + return { ok: true, data: parsed.data }; + } + + log.warn({ statusCode: 422 }, `Invalid feedback record ${label}`); + return { + ok: false, + response: problemUnprocessableContent(requestId, `Invalid ${label}`, { + invalid_params: toInvalidParams(parsed.error), + instance, + }), + }; +} + +/** Envelope for a page of scored matches: the rows, the page cursor, the threshold, and the dataset. */ +const similarityMatchesResponse = ( + page: { + data: (SemanticSearchResultItem | SimilarRecordsResultItem)[]; + limit: number; + next_cursor?: string; + }, + resolution: TResolvedFeedbackTenant, + minScore: number | undefined, + requestId: string +): Response => + successListResponse( + page.data.map(serializeV3FeedbackRecordMatch), + { + limit: page.limit, + nextCursor: page.next_cursor ?? null, + minScore: minScore ?? SIMILARITY_MIN_SCORE_DEFAULT, + // Echoed for the same reason as on list: an empty result must say which dataset was searched. + datasetId: resolution.tenantId, + datasetName: resolution.datasetName, + }, + { requestId, cache: CACHE } + ); + +type TListV3FeedbackDatasetsParams = { + workspaceId: string; + authentication: TV3Authentication; + requestId: string; + instance: string; +}; + +/** List the active feedback datasets assigned to a workspace (discovery for the other tools). */ +export async function listV3FeedbackDatasets({ + workspaceId, + authentication, + requestId, + instance, +}: TListV3FeedbackDatasetsParams): Promise { + const log = logger.withContext({ requestId, workspaceId }); + try { + // Not the tenant resolver: this operation *is* how a caller discovers dataset ids, so it stops at + // the shared access + entitlement gate. + const authResult = await requireUnifyFeedbackWorkspaceAccess( + authentication, + workspaceId, + "read", + requestId, + instance + ); + if (authResult instanceof Response) { + return authResult; + } + + const directories = await getFeedbackDirectoriesByWorkspaceId(authResult.workspaceId); + return successListResponse( + directories.map(serializeV3FeedbackDataset), + { nextCursor: null, totalCount: directories.length }, + { requestId, cache: CACHE } + ); + } catch (err) { + return handleUnexpectedError(err, log, requestId, instance); + } +} + +/** Workspace/dataset selection plus the shared filter set — the common input of list and count. */ +type TFeedbackRecordQueryParams = TV3FeedbackRecordFilters & { + workspaceId: string; + datasetId?: string; + authentication: TV3Authentication; + requestId: string; + instance: string; +}; + +type TListV3FeedbackRecordsParams = TFeedbackRecordQueryParams & { + limit?: number; + cursor?: string; +}; + +/** List feedback records for the resolved tenant, with cursor pagination and optional filters. */ +export async function listV3FeedbackRecords({ + workspaceId, + datasetId, + limit, + cursor, + authentication, + requestId, + instance, + ...filters +}: TListV3FeedbackRecordsParams): Promise { + const log = logger.withContext({ requestId, workspaceId }); + try { + const resolution = await resolveWorkspaceFeedbackTenant({ + authentication, + workspaceId, + datasetId, + minPermission: "read", + requestId, + instance, + }); + if (!resolution.ok) { + return resolution.response; + } + + const parsed = parseQueryInput( + ZV3FeedbackRecordListFilters, + { ...filters, limit, cursor }, + "list parameters", + log, + requestId, + instance + ); + if (!parsed.ok) { + return parsed.response; + } + + const listParams: FeedbackRecordListParams = { + ...buildHubFilterParams(resolution.tenantId, parsed.data), + limit: parsed.data.limit ?? DEFAULT_LIST_LIMIT, + }; + if (parsed.data.cursor) listParams.cursor = parsed.data.cursor; + + const result = await listFeedbackRecords(listParams); + if (result.error || !result.data) { + log.warn( + { + hubStatus: result.error?.status, + hubCode: result.error?.code, + datasetId: resolution.tenantId, + }, + "Hub listFeedbackRecords failed" + ); + return hubErrorToProblemResponse(result.error, requestId, instance); + } + + return successListResponse( + result.data.data.map(serializeV3FeedbackRecord), + { + limit: result.data.limit, + nextCursor: result.data.next_cursor ?? null, + // Echo the dataset we resolved. Without it an empty `data` is ambiguous to the caller — "this + // dataset holds no matching records" reads identically to "I don't know what was searched" — and + // a caller that auto-resolved the dataset would have to make a second call to find out. + datasetId: resolution.tenantId, + datasetName: resolution.datasetName, + }, + { requestId, cache: CACHE } + ); + } catch (err) { + return handleUnexpectedError(err, log, requestId, instance); + } +} + +/** + * Count the feedback records matching a filter set, without returning any of them. + * + * Answers "how many" in one upstream call instead of paging, and deliberately returns only the total: a + * caller asking for a count never has record content — end-user text, ids, metadata — pulled into its + * context to get it. + */ +export async function countV3FeedbackRecords({ + workspaceId, + datasetId, + authentication, + requestId, + instance, + ...filters +}: TFeedbackRecordQueryParams): Promise { + const log = logger.withContext({ requestId, workspaceId }); + try { + const resolution = await resolveWorkspaceFeedbackTenant({ + authentication, + workspaceId, + datasetId, + minPermission: "read", + requestId, + instance, + }); + if (!resolution.ok) { + return resolution.response; + } + + const parsed = parseQueryInput(ZV3FeedbackRecordFilters, filters, "filters", log, requestId, instance); + if (!parsed.ok) { + return parsed.response; + } + + const result = await countFeedbackRecords(buildHubFilterParams(resolution.tenantId, parsed.data)); + if (result.error || !result.data) { + log.warn( + { + hubStatus: result.error?.status, + hubCode: result.error?.code, + datasetId: resolution.tenantId, + }, + "Hub countFeedbackRecords failed" + ); + return hubErrorToProblemResponse(result.error, requestId, instance); + } + + return successResponse( + { + count: result.data.count, + // Named for the same reason as on list: a zero count must say which dataset produced it. + dataset_id: resolution.tenantId, + dataset_name: resolution.datasetName, + }, + { requestId, cache: CACHE } + ); + } catch (err) { + return handleUnexpectedError(err, log, requestId, instance); + } +} + +type TGetV3FeedbackRecordParams = { + workspaceId: string; + feedbackRecordId: string; + datasetId?: string; + authentication: TV3Authentication; + requestId: string; + instance: string; +}; + +/** + * Get one feedback record by id. The Hub `get` is NOT tenant-scoped, so ownership is asserted by + * `requireOwnedFeedbackRecord`, which returns an indistinguishable generic 403 for a foreign record and + * for a missing one. + */ +export async function getV3FeedbackRecord({ + workspaceId, + feedbackRecordId, + datasetId, + authentication, + requestId, + instance, +}: TGetV3FeedbackRecordParams): Promise { + const log = logger.withContext({ requestId, workspaceId }); + try { + const resolution = await resolveWorkspaceFeedbackTenant({ + authentication, + workspaceId, + datasetId, + minPermission: "read", + requestId, + instance, + }); + if (!resolution.ok) { + return resolution.response; + } + + const owned = await requireOwnedFeedbackRecord({ + feedbackRecordId, + resolution, + datasetId, + log, + requestId, + instance, + }); + if (!owned.ok) { + return owned.response; + } + + return successResponse(serializeV3FeedbackRecord(owned.record), { requestId, cache: CACHE }); + } catch (err) { + return handleUnexpectedError(err, log, requestId, instance); + } +} + +type TCreateV3FeedbackRecordParams = { + workspaceId: string; + datasetId?: string; + body: unknown; + authentication: TV3Authentication; + requestId: string; + instance: string; + auditLog?: TV3AuditLog; +}; + +/** + * Create a feedback record in the resolved tenant. The Hub payload is built as an explicit allowlist + * from the validated body; `tenant_id` is injected from the resolved dataset and never accepted from + * input. `submission_id` is generated when omitted so a single ad-hoc record still groups cleanly. + */ +export async function createV3FeedbackRecord({ + workspaceId, + datasetId, + body, + authentication, + requestId, + instance, + auditLog, +}: TCreateV3FeedbackRecordParams): Promise { + const log = logger.withContext({ requestId, workspaceId }); + try { + const resolution = await resolveWorkspaceFeedbackTenant({ + authentication, + workspaceId, + datasetId, + minPermission: "readWrite", + requestId, + instance, + }); + if (!resolution.ok) { + return resolution.response; + } + + const parsed = ZV3FeedbackRecordCreateBody.safeParse(body); + if (!parsed.success) { + log.warn({ statusCode: 422 }, "Invalid feedback record body"); + return problemUnprocessableContent(requestId, "Invalid feedback record", { + invalid_params: toInvalidParams(parsed.error), + instance, + }); + } + + const result = await createFeedbackRecord(buildHubCreateParams(parsed.data, resolution.tenantId)); + if (result.error || !result.data) { + log.warn( + { hubStatus: result.error?.status, hubCode: result.error?.code }, + "Hub createFeedbackRecord failed" + ); + // The caller stamps eventId/status from the response — mirrors the surveys operations. + return hubErrorToProblemResponse(result.error, requestId, instance); + } + + const serialized = serializeV3FeedbackRecord(result.data); + if (auditLog) { + auditLog.organizationId = resolution.organizationId; + auditLog.targetId = result.data.id; + auditLog.newObject = serialized; + } + + return successResponse(serialized, { requestId, status: 201, cache: CACHE }); + } catch (err) { + return handleUnexpectedError(err, log, requestId, instance); + } +} + +type TCreateV3FeedbackRecordsParams = { + workspaceId: string; + datasetId?: string; + body: unknown; + authentication: TV3Authentication; + requestId: string; + instance: string; + /** + * One audit log per input record, **indexed by record position** — pass a sparse array rather than a + * compacted one, or entries will be attributed to the wrong records. Entries for records that were + * actually created come back with `targetId` set; the caller queues those as successes. Empty or all-holes + * when auditing is off. + */ + auditLogs?: (TV3AuditLog | undefined)[]; +}; + +/** + * Create several feedback records in one request. + * + * The Hub has no bulk-create endpoint, so this fans out to one create per record, in parallel. That makes + * two things deliberate: + * + * - **Validation is all-or-nothing.** Every record is checked before any is written, so a malformed batch + * fails without leaving half of it stored. Only *upstream* failures can produce a partial result. + * - **Partial success is reported, not hidden.** A batch where some records were rejected by the Hub (a + * duplicate submission, say) returns the ones that were created plus a per-index account of the rest, so + * a caller can retry precisely. If nothing was created at all, the upstream failure is returned as the + * response instead — an empty success would read as "there was nothing to do". + * + * A batch is not a submission: each record without a `submission_id` gets its own generated one, exactly as + * in the single-record case. Callers that mean "these answers were given together" must set a shared + * `submission_id` themselves — otherwise the records are stored as unrelated submissions, which nothing + * downstream can tell apart from the intended shape. + */ +export async function createV3FeedbackRecords({ + workspaceId, + datasetId, + body, + authentication, + requestId, + instance, + auditLogs, +}: TCreateV3FeedbackRecordsParams): Promise { + const log = logger.withContext({ requestId, workspaceId }); + try { + const resolution = await resolveWorkspaceFeedbackTenant({ + authentication, + workspaceId, + datasetId, + minPermission: "readWrite", + requestId, + instance, + }); + if (!resolution.ok) { + return resolution.response; + } + + const parsed = ZV3FeedbackRecordBatchCreateBody.safeParse(body); + if (!parsed.success) { + log.warn({ statusCode: 422 }, "Invalid feedback record batch"); + // Zod paths carry the offending index, so `invalid_params` names e.g. `records.3.value_text`. + return problemUnprocessableContent(requestId, "Invalid feedback records", { + invalid_params: toInvalidParams(parsed.error), + instance, + }); + } + + const { records } = parsed.data satisfies TV3FeedbackRecordBatchCreateBody; + const { results } = await createFeedbackRecordsBatch( + records.map((record) => buildHubCreateParams(record, resolution.tenantId)) + ); + + const created: TV3FeedbackRecord[] = []; + const failures: { index: number; detail: string }[] = []; + results.forEach((result, index) => { + if (result.data) { + const serialized = serializeV3FeedbackRecord(result.data); + created.push(serialized); + const auditLog = auditLogs?.[index]; + if (auditLog) { + auditLog.organizationId = resolution.organizationId; + auditLog.targetId = result.data.id; + auditLog.newObject = serialized; + } + return; + } + failures.push({ + index, + // Same relay rules as a single-record failure: a 4xx explains itself, anything else does not. + detail: relayableHubDetail(result.error, "The feedback service rejected this record."), + }); + }); + + if (created.length === 0) { + log.warn( + { hubStatus: results[0]?.error?.status, hubCode: results[0]?.error?.code, requested: records.length }, + "Hub createFeedbackRecordsBatch created nothing" + ); + return hubErrorToProblemResponse(results[0]?.error ?? null, requestId, instance); + } + + if (failures.length > 0) { + log.warn( + { requested: records.length, created: created.length, failed: failures.length }, + "Hub createFeedbackRecordsBatch partially failed" + ); + } + + return successListResponse( + created, + { + requested: records.length, + created: created.length, + failed: failures.length, + failures, + datasetId: resolution.tenantId, + datasetName: resolution.datasetName, + }, + { requestId, cache: CACHE } + ); + } catch (err) { + return handleUnexpectedError(err, log, requestId, instance); + } +} + +type TUpdateV3FeedbackRecordParams = { + workspaceId: string; + feedbackRecordId: string; + datasetId?: string; + body: unknown; + authentication: TV3Authentication; + requestId: string; + instance: string; + auditLog?: TV3AuditLog; +}; + +/** + * Update the mutable fields of one feedback record. + * + * Like get/delete/similar, the Hub's `PATCH /{id}` takes a bare record id and derives the tenant from the + * stored record, so ownership is asserted first — and the record that guard returns is also the pre-update + * state the audit log needs, so the check costs nothing extra. + * + * There is a window between that check and the write, but it cannot be used to cross a tenant boundary: a + * record's `tenant_id` is not in the Hub's updatable set, so the record the guard approved is still in the + * same dataset when the write lands. The only thing that can change in the window is the record ceasing to + * exist, which is handled below. + * + * Two Hub behaviours the caller has to know about, both documented in its contract: + * - Changing `value_text` **clears** the fields derived from it (`sentiment`, `sentiment_score`, + * `emotions`, `value_text_translated`, `translation_lang_key`) and re-queues enrichment; changing + * `language` re-queues the translation pair only. The response therefore reflects the *cleared* state, + * which must not be read as "this record has no sentiment". + * - Changing `value_text` (or a field label) re-queues the embedding, so semantic search catches up with + * the edit asynchronously — and clearing the text removes the embedding, making the record unsearchable. + */ +export async function updateV3FeedbackRecord({ + workspaceId, + feedbackRecordId, + datasetId, + body, + authentication, + requestId, + instance, + auditLog, +}: TUpdateV3FeedbackRecordParams): Promise { + const log = logger.withContext({ requestId, workspaceId }); + try { + const resolution = await resolveWorkspaceFeedbackTenant({ + authentication, + workspaceId, + datasetId, + minPermission: "readWrite", + requestId, + instance, + }); + if (!resolution.ok) { + return resolution.response; + } + + const parsed = ZV3FeedbackRecordUpdateBody.safeParse(body); + if (!parsed.success) { + log.warn({ statusCode: 422 }, "Invalid feedback record update"); + return problemUnprocessableContent(requestId, "Invalid feedback record update", { + invalid_params: toInvalidParams(parsed.error), + instance, + }); + } + + const owned = await requireOwnedFeedbackRecord({ + feedbackRecordId, + resolution, + datasetId, + log, + requestId, + instance, + }); + if (!owned.ok) { + return owned.response; + } + + // Only now can the patch be checked against the record's `field_type` — it is immutable, so it is not in + // the body and had to be read first. The Hub does not enforce this, so skipping it would let a patch + // build a record create rejects: `value_number` on a `text` record leaves both values set at once. + const conflicts = conflictingUpdateValueFields(parsed.data, owned.record.field_type); + if (conflicts.length > 0) { + log.warn( + { statusCode: 422, fieldType: owned.record.field_type }, + "Feedback record update sets a value field the record's type does not accept" + ); + return problemUnprocessableContent(requestId, "Invalid feedback record update", { + invalid_params: conflicts.map(({ name, accepted }) => ({ + name, + reason: `is not valid for a "${owned.record.field_type}" record (expected one of: ${accepted.join(", ")})`, + })), + instance, + }); + } + + const result = await updateFeedbackRecord(feedbackRecordId, buildHubUpdateParams(parsed.data)); + if (result.error || !result.data) { + // The record was there a moment ago (the guard just read it), so a 404 means it was deleted in + // between. Nothing was updated, and the service is fine — reporting the generic 502 would blame an + // outage. Answered with the guard's own 403, so a vanished record is indistinguishable from one the + // caller never had access to. + if (result.error?.status === 404) { + log.info({ statusCode: 403, hubStatus: 404 }, "Feedback record deleted during update"); + return forbidFeedbackRecord(requestId, instance); + } + log.warn( + { hubStatus: result.error?.status, hubCode: result.error?.code }, + "Hub updateFeedbackRecord failed" + ); + return hubErrorToProblemResponse(result.error, requestId, instance); + } + + const serialized = serializeV3FeedbackRecord(result.data); + if (auditLog) { + auditLog.organizationId = resolution.organizationId; + auditLog.targetId = feedbackRecordId; + // Both sides: an edit is only reviewable if the previous value is recorded too. + auditLog.oldObject = serializeV3FeedbackRecord(owned.record); + auditLog.newObject = serialized; + } + + return successResponse(serialized, { requestId, cache: CACHE }); + } catch (err) { + return handleUnexpectedError(err, log, requestId, instance); + } +} + +type TDeleteV3FeedbackRecordParams = { + workspaceId: string; + feedbackRecordId: string; + datasetId?: string; + authentication: TV3Authentication; + requestId: string; + instance: string; + auditLog?: TV3AuditLog; +}; + +/** + * Delete one feedback record. Permanent: the Hub removes the row and its derived embedding, with no + * soft-delete to recover from. + * + * The Hub's delete takes a bare record id and derives the tenant from the record, so ownership is + * asserted *before* the delete — a foreign id must fail without touching anything. The pre-delete record + * is captured for the audit log, which is the only remaining trace afterwards. + */ +export async function deleteV3FeedbackRecord({ + workspaceId, + feedbackRecordId, + datasetId, + authentication, + requestId, + instance, + auditLog, +}: TDeleteV3FeedbackRecordParams): Promise { + const log = logger.withContext({ requestId, workspaceId }); + try { + const resolution = await resolveWorkspaceFeedbackTenant({ + authentication, + workspaceId, + datasetId, + minPermission: "readWrite", + requestId, + instance, + }); + if (!resolution.ok) { + return resolution.response; + } + + const owned = await requireOwnedFeedbackRecord({ + feedbackRecordId, + resolution, + datasetId, + log, + requestId, + instance, + }); + if (!owned.ok) { + return owned.response; + } + + const result = await deleteFeedbackRecord(feedbackRecordId); + // A 404 here means the record was deleted between our ownership check and this call — the caller's + // intended end state holds, so it is reported as success. Reporting the generic 502 instead would tell + // an agent the service is down and invite a retry loop against a record that is already gone. + if (result.error && result.error.status !== 404) { + log.warn( + { hubStatus: result.error.status, hubCode: result.error.code }, + "Hub deleteFeedbackRecord failed" + ); + return hubErrorToProblemResponse(result.error, requestId, instance); + } + if (result.error?.status === 404) { + log.info({ datasetId: resolution.tenantId }, "Feedback record already deleted"); + } + + if (auditLog) { + auditLog.organizationId = resolution.organizationId; + auditLog.targetId = feedbackRecordId; + // The deleted content, kept only in the audit trail — the record itself is gone. + auditLog.oldObject = serializeV3FeedbackRecord(owned.record); + } + + log.info({ datasetId: resolution.tenantId }, "Feedback record deleted"); + // 204, as the v3 delete convention has it (see `deleteV3Survey`). + return noContentResponse({ requestId }); + } catch (err) { + return handleUnexpectedError(err, log, requestId, instance); + } +} + +type TSearchV3FeedbackRecordsParams = { + workspaceId: string; + datasetId?: string; + query: string; + limit?: number; + cursor?: string; + minScore?: number; + authentication: TV3Authentication; + requestId: string; + instance: string; +}; + +/** + * Semantic search over the resolved dataset: the query is embedded and compared to record embeddings by + * cosine similarity, so it matches meaning rather than keywords. + * + * `tenant_id` is injected from the resolved dataset — never from input — and is normalised by the resolver, + * because the Hub uses it verbatim in the vector query. + */ +export async function searchV3FeedbackRecords({ + workspaceId, + datasetId, + query, + limit, + cursor, + minScore, + authentication, + requestId, + instance, +}: TSearchV3FeedbackRecordsParams): Promise { + const log = logger.withContext({ requestId, workspaceId }); + try { + const resolution = await resolveWorkspaceFeedbackTenant({ + authentication, + workspaceId, + datasetId, + minPermission: "read", + requestId, + instance, + }); + if (!resolution.ok) { + return resolution.response; + } + + const filters = parseQueryInput( + ZV3FeedbackRecordSearchFilters, + { query, limit, cursor, minScore }, + "search parameters", + log, + requestId, + instance + ); + if (!filters.ok) { + return filters.response; + } + + const result = await semanticSearchFeedbackRecords({ + // Never from input. Already normalised by the resolver, which every operation shares. + tenant_id: resolution.tenantId, + query: filters.data.query, + ...buildSimilarityParams(filters.data), + }); + if (result.error || !result.data) { + // The query itself is never logged: it is caller-authored text. + log.warn( + { + hubStatus: result.error?.status, + hubCode: result.error?.code, + datasetId: resolution.tenantId, + }, + "Hub semanticSearchFeedbackRecords failed" + ); + return hubErrorToProblemResponse(result.error, requestId, instance, { + serviceUnavailableDetail: EMBEDDINGS_UNAVAILABLE_DETAIL, + }); + } + + return similarityMatchesResponse(result.data, resolution, filters.data.minScore, requestId); + } catch (err) { + return handleUnexpectedError(err, log, requestId, instance); + } +} + +type TFindSimilarV3FeedbackRecordsParams = { + workspaceId: string; + feedbackRecordId: string; + datasetId?: string; + limit?: number; + cursor?: string; + minScore?: number; + authentication: TV3Authentication; + requestId: string; + instance: string; +}; + +/** + * Find the records most similar to a given one, by embedding distance. + * + * The Hub endpoint takes a bare record id and scopes the neighbour search to whatever tenant that record + * belongs to — so without an ownership check it would read other tenants' records. Hence the same + * `requireOwnedFeedbackRecord` guard as get/delete, before any neighbour is fetched. + * + * That guard also disambiguates the Hub's 404, which on its own conflates "no such record" with "not + * embedded": once the record is known to exist and to be ours, a 404 can only mean the latter. + */ +export async function findSimilarV3FeedbackRecords({ + workspaceId, + feedbackRecordId, + datasetId, + limit, + cursor, + minScore, + authentication, + requestId, + instance, +}: TFindSimilarV3FeedbackRecordsParams): Promise { + const log = logger.withContext({ requestId, workspaceId }); + try { + const resolution = await resolveWorkspaceFeedbackTenant({ + authentication, + workspaceId, + datasetId, + minPermission: "read", + requestId, + instance, + }); + if (!resolution.ok) { + return resolution.response; + } + + const filters = parseQueryInput( + ZV3FeedbackRecordSimilarityFilters, + { limit, cursor, minScore }, + "similarity parameters", + log, + requestId, + instance + ); + if (!filters.ok) { + return filters.response; + } + + const owned = await requireOwnedFeedbackRecord({ + feedbackRecordId, + resolution, + datasetId, + log, + requestId, + instance, + }); + if (!owned.ok) { + return owned.response; + } + + const result = await findSimilarFeedbackRecords(feedbackRecordId, buildSimilarityParams(filters.data)); + if (result.error || !result.data) { + const status = result.error?.status ?? 0; + // Ownership is already proven, so a 404 is not an authorization signal — the record is there and + // simply has no embedding. Reported distinctly, and as a retryable state rather than a 404. + if (status === 404) { + log.info({ statusCode: 409, datasetId: resolution.tenantId }, "Feedback record has no embedding"); + return problemConflict(requestId, EMBEDDING_PENDING_DETAIL, instance); + } + log.warn( + { + hubStatus: status, + hubCode: result.error?.code, + datasetId: resolution.tenantId, + }, + "Hub findSimilarFeedbackRecords failed" + ); + return hubErrorToProblemResponse(result.error, requestId, instance, { + serviceUnavailableDetail: EMBEDDINGS_UNAVAILABLE_DETAIL, + }); + } + + return similarityMatchesResponse(result.data, resolution, filters.data.minScore, requestId); + } catch (err) { + return handleUnexpectedError(err, log, requestId, instance); + } +} diff --git a/apps/web/app/api/v3/feedbackRecords/lib/schemas.ts b/apps/web/app/api/v3/feedbackRecords/lib/schemas.ts new file mode 100644 index 000000000000..300ac549e55f --- /dev/null +++ b/apps/web/app/api/v3/feedbackRecords/lib/schemas.ts @@ -0,0 +1,336 @@ +import { z } from "zod"; +import { ZHubFieldType } from "@formbricks/types/feedback-source"; + +/** + * v3-owned schemas for the feedback-records surface. Kept in the v3 layer (not the MCP layer) so the + * operations can validate independently of any transport, mirroring the surveys operations. + * + * `tenant_id` is deliberately absent from every schema here — it is always resolved server-side from + * the caller's workspace + feedback directory and never accepted from input (tenant isolation). + */ + +/** + * The filter set shared by listing and counting feedback records — the Hub documents its `/count` endpoint + * as taking "the same query parameters as the list endpoint", minus pagination, so both are described once + * here and mapped to Hub params by one function in `operations.ts`. + * + * Field semantics follow the Hub's own `GET /v1/feedback-records` parameter documentation (hub 0.8.1 + * `openapi.yaml`). Every filter is a plain equality match, combined with AND, and always scoped to the + * resolved dataset. Length caps mirror the Hub's (1–255); it additionally rejects NULL bytes and relays + * that as a 400 we pass through. + * + * Each filter is named after the field it filters, so it matches the snake_case a caller reads back in a + * record and sends in a create body: filtering by the `user_id` you just saw in a response is spelled the + * same way. Our own parameters — `workspaceId`, `datasetId`, `limit`, `cursor`, `minScore` — stay camelCase, + * because they name nothing in the record. + * + * `.strict()` so a key this surface does not have is an error rather than a silent no-op. On a *filter* the + * silent direction is the dangerous one: a dropped `user_id` widens the result set instead of narrowing it, + * and the caller cannot tell it happened. This guards the operation contract rather than the MCP boundary — + * the SDK validates against its own non-strict copy of the shape and strips unknown keys before an operation + * sees them, which is why the names above do more work here than this does. + */ +const ZFeedbackRecordFilterId = z.string().trim().min(1).max(255); + +export const ZV3FeedbackRecordFilters = z.object({ + source_type: ZFeedbackRecordFilterId.optional().describe( + "Filter by feedback source type, e.g. survey, review, call_notes." + ), + source_id: ZFeedbackRecordFilterId.optional().describe( + "Filter by source id — the survey/form/ticket the feedback came from." + ), + field_type: ZHubFieldType.optional().describe("Filter by field type."), + field_id: ZFeedbackRecordFilterId.optional().describe("Filter by field id — all answers to one question."), + field_group_id: ZFeedbackRecordFilterId.optional().describe( + "Filter by field group id, which groups related fields of one question (ranking, matrix, grid)." + ), + submission_id: ZFeedbackRecordFilterId.optional().describe( + "Filter by submission id — the sibling records of one logical submission, i.e. the rest of the answers given at the same time." + ), + user_id: ZFeedbackRecordFilterId.optional().describe( + "Filter by end-user identifier — everything one person submitted." + ), + value_id: ZFeedbackRecordFilterId.optional().describe( + "Filter by the source system's stable option id, e.g. everyone who picked one particular survey choice." + ), + since: z + .string() + .trim() + .min(1) + .optional() + .describe( + "Only records collected at or after this ISO 8601 timestamp (bounds collected_at). Must fall between 1970-01-01 and 2080-12-31." + ), + until: z + .string() + .trim() + .min(1) + .optional() + .describe( + "Only records collected at or before this ISO 8601 timestamp (bounds collected_at). Must fall between 1970-01-01 and 2080-12-31." + ), +}).strict(); +export type TV3FeedbackRecordFilters = z.infer; + +// Listing adds keyset pagination on top of the shared filters (the Hub's cursor/keyset contract). +export const ZV3FeedbackRecordListFilters = ZV3FeedbackRecordFilters.extend({ + limit: z + .number() + .int() + .min(1) + .max(1000) + .optional() + .describe("Maximum number of records to return (1–1000). Defaults to 50."), + cursor: z + .string() + .min(1) + .optional() + .describe("Opaque keyset cursor from a previous response's nextCursor. Omit for the first page."), +}); +export type TV3FeedbackRecordListFilters = z.infer; + +/** + * Shared parameters for the two similarity searches (by text, and by example record). Both hit the Hub's + * vector index and return the same scored-row shape. + * + * The bounds are enforced here rather than left to the Hub, which silently coerces out-of-range values to + * its defaults instead of rejecting them — so `limit: 999` or `minScore: 5` would quietly return + * something other than what was asked for. Locally they become an actionable `invalid_params` error. + */ +export const SIMILARITY_LIMIT_DEFAULT = 10; +export const SIMILARITY_LIMIT_MAX = 100; + +/** + * Our default, deliberately below the Hub's own 0.7: at 0.7 a paraphrase of a record often scores just + * under and the caller sees an empty result, which reads as "nothing matched" rather than "the threshold + * was strict". 0.5 matches what the Unify topic UI already treats as a meaningful match. + */ +export const SIMILARITY_MIN_SCORE_DEFAULT = 0.5; + +// Every search embeds its query with the configured provider, so the query is a cost and a rate-limit +// input, not just a string. Well above any real question, well below a pasted document. +const MAX_SEARCH_QUERY_LENGTH = 2000; + +export const ZV3FeedbackRecordSimilarityFilters = z.object({ + limit: z + .number() + .int() + .min(1) + .max(SIMILARITY_LIMIT_MAX) + .optional() + .describe( + `Maximum number of matches to return (1–${SIMILARITY_LIMIT_MAX}). Defaults to ${SIMILARITY_LIMIT_DEFAULT}.` + ), + cursor: z + .string() + .min(1) + .optional() + .describe("Opaque keyset cursor from a previous response's nextCursor. Omit for the first page."), + minScore: z + .number() + .min(0) + .max(1) + .optional() + .describe( + `Minimum similarity score to include, from 0 (unrelated) to 1 (identical). Defaults to ${SIMILARITY_MIN_SCORE_DEFAULT}. Raise it for stricter matches; lower it when a search returns nothing.` + ), +}); +export type TV3FeedbackRecordSimilarityFilters = z.infer; + +export const ZV3FeedbackRecordSearchFilters = ZV3FeedbackRecordSimilarityFilters.extend({ + query: z + .string() + .trim() + .min(1) + .max(MAX_SEARCH_QUERY_LENGTH) + .describe("Natural-language text to match feedback records against by meaning, not by keyword."), +}); +export type TV3FeedbackRecordSearchFilters = z.infer; + +/** + * Create body — mirrors the Hub `CreateFeedbackRecordInputBody`, WITHOUT `tenant_id`. Length/type + * bounds match the Hub contract so oversized/invalid input is rejected early; the Hub remains the + * source of truth for the remaining content rules (no NULL bytes, its own length limits), and its + * field-level failures are relayed to the caller by `hubErrorToProblemResponse`. + * + * The Hub does NOT check that the populated `value_*` matches `field_type` (it accepts `field_type: + * "text"` carrying only `value_number`, and even a record with no value at all), so we enforce it here. + * Without this a mistyped field name — MCP strips unknown keys before we ever see them, so `valueText` + * simply vanishes — would store a permanently empty record and report success. + */ +const VALUE_FIELD_BY_TYPE: Record, string[]> = { + text: ["value_text"], + // A choice can be identified by its label, its stable option id, or both. + categorical: ["value_text", "value_id"], + nps: ["value_number"], + csat: ["value_number"], + ces: ["value_number"], + rating: ["value_number"], + number: ["value_number"], + boolean: ["value_boolean"], + date: ["value_date"], +}; + +// Keep well under the Hub's 512 KiB request cap so an oversized payload fails here, with a clear +// message, instead of arriving as an opaque upstream rejection. +const MAX_METADATA_BYTES = 32_768; + +export const ZV3FeedbackRecordCreateBodyFields = z.object({ + source_type: z + .string() + .trim() + .min(1) + .max(255) + .describe("Type of feedback source, e.g. survey, review, call_notes."), + field_id: z.string().trim().min(1).max(255).describe("Identifier for the question/field."), + field_type: ZHubFieldType.describe("Field type; determines which value_* field applies."), + submission_id: z + .string() + .trim() + .min(1) + .max(255) + .optional() + .describe( + "Logical submission this record belongs to (groups multi-field submissions). A UUID is generated when omitted." + ), + value_text: z.string().max(30000).optional().describe("Open-ended text response."), + value_number: z.number().optional().describe("Numeric response (ratings, NPS, numbers)."), + value_boolean: z.boolean().optional().describe("Boolean (yes/no) response."), + value_date: z.string().trim().min(1).optional().describe("Date response as an ISO 8601 timestamp."), + value_id: z + .string() + .max(255) + .optional() + .describe("Stable id of the selected option in the source system (e.g. a survey choice id)."), + user_id: z.string().max(255).optional().describe("End-user identifier (e.g. anonymous id or email hash)."), + language: z.string().max(10).optional().describe("ISO language code of the response."), + source_id: z.string().max(255).optional().describe("Reference to the survey/form/ticket id."), + source_name: z.string().max(255).optional().describe("Human-readable source name."), + field_group_id: z + .string() + .max(255) + .optional() + .describe("Stable id grouping related fields (ranking, matrix, grid)."), + field_group_label: z.string().max(2048).optional().describe("Human-readable group question text."), + field_label: z.string().max(2048).optional().describe("The actual question text."), + collected_at: z + .string() + .trim() + .min(1) + .optional() + .describe("When the feedback was collected, as an ISO 8601 timestamp. Defaults to now."), + metadata: z + .record(z.string(), z.unknown()) + // Byte length, not string length: JSON.stringify(...).length counts UTF-16 code units, so a CJK or + // emoji payload would slip through at several times the advertised size. + .refine((value) => Buffer.byteLength(JSON.stringify(value), "utf8") <= MAX_METADATA_BYTES, { + message: `must serialize to at most ${MAX_METADATA_BYTES} bytes`, + }) + .optional() + .describe("Additional context (device, tags, etc.)."), +}); + +export const ZV3FeedbackRecordCreateBody = ZV3FeedbackRecordCreateBodyFields.superRefine((data, ctx) => { + const accepted = VALUE_FIELD_BY_TYPE[data.field_type]; + if (accepted.some((field) => data[field as keyof typeof data] !== undefined)) { + return; + } + + ctx.addIssue({ + code: "custom", + // Report against the field the caller should have sent, so the error points at the fix. + path: [accepted[0]], + message: `is required for field_type "${data.field_type}" (expected one of: ${accepted.join(", ")})`, + }); +}); + +export type TV3FeedbackRecordCreateBody = z.infer; + +/** + * Update body — the **mutable subset** of the create fields, picked from them rather than redeclared so the + * two can never drift on a bound or a description. + * + * The subset is the Hub's (`UpdateFeedbackRecordInputBody` in hub 0.8.1): a record's provenance is + * immutable, so `source_*`, `field_*`, `submission_id` and `collected_at` cannot be changed — correcting + * those means deleting and recreating. The Hub also refuses to accept the derived enrichment fields + * (`sentiment`, `emotions`, translations) from a caller, so there is nothing to exclude there. + * + * At least one field is required: an empty patch is a caller mistake, not a no-op worth a round trip. + */ +/** The plain object form first, because `inputSchema` needs a raw shape rather than a refined schema. */ +export const ZV3FeedbackRecordUpdateBodyFields = ZV3FeedbackRecordCreateBodyFields.pick({ + value_text: true, + value_number: true, + value_boolean: true, + value_date: true, + value_id: true, + user_id: true, + language: true, + metadata: true, +}); + +/** The refined form is derived from it, so the mutable field list exists exactly once. */ +export const ZV3FeedbackRecordUpdateBody = ZV3FeedbackRecordUpdateBodyFields.refine( + (data) => Object.values(data).some((value) => value !== undefined), + { message: "at least one field to update is required" } +); +export type TV3FeedbackRecordUpdateBody = z.infer; + +/** The mutable `value_*` fields, derived from the update set so a new one can't be missed below. */ +const UPDATABLE_VALUE_FIELDS = Object.keys(ZV3FeedbackRecordUpdateBodyFields.shape).filter((key) => + key.startsWith("value_") +) as (keyof typeof ZV3FeedbackRecordUpdateBodyFields.shape)[]; + +/** + * The `value_*` fields a patch sets that this record's type does not accept. + * + * Create enforces the same `VALUE_FIELD_BY_TYPE` table in its `superRefine`, but update cannot: `field_type` + * is immutable and therefore absent from the patch, so the type has to come from the *stored* record — which + * means this check can only run after the record has been read. Without it the two paths disagree, and a + * patch can assemble a record create would have rejected: putting `value_number` on a `text` record leaves + * text and number populated at once, with no way to tell which one the record means. + * + * The lookup is total — `field_type` is the same closed union the table is keyed by. + */ +export function conflictingUpdateValueFields( + data: TV3FeedbackRecordUpdateBody, + fieldType: string | undefined +): { name: string; accepted: string[] }[] { + const accepted = VALUE_FIELD_BY_TYPE[fieldType as keyof typeof VALUE_FIELD_BY_TYPE] as + | string[] + | undefined; + + // Typed loosely and guarded on purpose: `field_type` arrives from a remote service, and this codebase + // already treats it as possibly absent (`TV3FeedbackRecord.field_type` is optional, and the serializer + // copies it only when present). With no table to check against there is nothing to judge, so the patch is + // let through — refusing it would turn a legitimate update into an error over a field the caller cannot + // set anyway. + if (!accepted) { + return []; + } + + return UPDATABLE_VALUE_FIELDS.filter( + (field) => data[field] !== undefined && !accepted.includes(field) + ).map((name) => ({ name, accepted })); +} + +/** + * Batch create. The Hub has no bulk-create endpoint (its only bulk write is the delete-by-user erasure + * path), so this fans out to one Hub call per record. The cap is therefore an amplification bound as much + * as a payload bound: one authorized request must not turn into an unbounded burst of upstream writes. + * + * 50 covers the case this exists for — importing a batch of feedback without one round trip per record — + * and stays well inside the 2 MiB request-body limit even with sizeable metadata on every record. + */ +export const MAX_FEEDBACK_RECORDS_PER_BATCH = 50; + +export const ZV3FeedbackRecordBatchCreateBody = z.object({ + records: z + .array(ZV3FeedbackRecordCreateBody) + .min(1) + .max(MAX_FEEDBACK_RECORDS_PER_BATCH) + .describe( + `Feedback records to create, 1–${MAX_FEEDBACK_RECORDS_PER_BATCH} per call. Every record is validated before any of them is written, so an invalid record fails the whole call without a partial write.` + ), +}); +export type TV3FeedbackRecordBatchCreateBody = z.infer; diff --git a/apps/web/app/api/v3/feedbackRecords/lib/serializers.test.ts b/apps/web/app/api/v3/feedbackRecords/lib/serializers.test.ts new file mode 100644 index 000000000000..fd26b59e38e8 --- /dev/null +++ b/apps/web/app/api/v3/feedbackRecords/lib/serializers.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from "vitest"; +import type { FeedbackRecordData } from "@/modules/hub/types"; +import { + serializeV3FeedbackDataset, + serializeV3FeedbackRecord, + serializeV3FeedbackRecordMatch, +} from "./serializers"; + +/** + * The serializer is an explicit allowlist, so the risk it carries is *silent omission*: dropping a field + * still returns a valid-looking record. These tests pin the whole field set, not a sample. + */ +const fullRecord: FeedbackRecordData = { + id: "019fa338-f494-7384-b34e-01739783d280", + tenant_id: "clfd1234567890123456789012", + submission_id: "sub-1", + source_type: "survey", + source_id: "svy_1", + source_name: "Q1 NPS", + field_id: "q1", + field_type: "text", + field_label: "What can we improve?", + field_group_id: "grp_1", + field_group_label: "Group", + user_id: "user-1", + language: "en", + value_text: "Great support", + value_number: 9, + value_boolean: true, + value_date: "2026-07-01T00:00:00.000Z", + value_id: "opt_1", + metadata: { device: "ios" }, + sentiment: "positive", + sentiment_score: 0.8, + emotions: ["joy"], + translation_lang_key: "de-DE", + value_text_translated: "Guter Support", + collected_at: "2026-07-01T00:00:00.000Z", + created_at: "2026-07-01T00:00:00.000Z", + updated_at: "2026-07-01T00:00:00.000Z", +} as FeedbackRecordData; + +describe("serializeV3FeedbackRecord", () => { + test("emits every allowlisted field, with the tenant renamed to dataset_id", () => { + // Asserted as a whole object: a dropped field or a leaked extra both fail here. + expect(serializeV3FeedbackRecord(fullRecord)).toEqual({ + id: fullRecord.id, + dataset_id: "clfd1234567890123456789012", + submission_id: "sub-1", + source_type: "survey", + source_id: "svy_1", + source_name: "Q1 NPS", + field_id: "q1", + field_type: "text", + field_label: "What can we improve?", + field_group_id: "grp_1", + field_group_label: "Group", + user_id: "user-1", + language: "en", + value_text: "Great support", + value_number: 9, + value_boolean: true, + value_date: "2026-07-01T00:00:00.000Z", + value_id: "opt_1", + metadata: { device: "ios" }, + sentiment: "positive", + sentiment_score: 0.8, + emotions: ["joy"], + translation_lang_key: "de-DE", + value_text_translated: "Guter Support", + collected_at: "2026-07-01T00:00:00.000Z", + created_at: "2026-07-01T00:00:00.000Z", + updated_at: "2026-07-01T00:00:00.000Z", + }); + }); + + test("never emits tenant_id", () => { + const dto = serializeV3FeedbackRecord(fullRecord); + + expect(dto).not.toHaveProperty("tenant_id"); + expect(JSON.stringify(dto)).not.toContain("tenant_id"); + }); + + test("drops anything outside the allowlist, so an SDK addition can't widen the response", () => { + const withExtras = { + ...fullRecord, + internal_debug_note: "should not surface", + hub_only_column: 42, + } as unknown as FeedbackRecordData; + + const dto = serializeV3FeedbackRecord(withExtras) as Record; + + expect(dto.internal_debug_note).toBeUndefined(); + expect(dto.hub_only_column).toBeUndefined(); + }); + + test("omits absent fields rather than emitting undefined, and preserves explicit nulls", () => { + const sparse = { + id: fullRecord.id, + tenant_id: "clfd1234567890123456789012", + value_text: null, + } as unknown as FeedbackRecordData; + + const dto = serializeV3FeedbackRecord(sparse); + + expect(dto).toEqual({ id: fullRecord.id, dataset_id: "clfd1234567890123456789012", value_text: null }); + expect(Object.keys(dto)).not.toContain("submission_id"); + }); + + test("omits dataset_id when the Hub record carries no tenant", () => { + const orphan = { id: fullRecord.id } as unknown as FeedbackRecordData; + + expect(serializeV3FeedbackRecord(orphan)).toEqual({ id: fullRecord.id }); + }); +}); + +describe("serializeV3FeedbackRecordMatch", () => { + test("emits exactly the four match fields", () => { + const match = { + feedback_record_id: "019fa338-f494-7384-b34e-01739783d280", + score: 0.8123, + field_label: "What can we improve?", + value_text: "payment step was unclear", + }; + + expect(serializeV3FeedbackRecordMatch(match)).toEqual(match); + }); + + test("drops anything the Hub adds beyond the match contract", () => { + const withExtras = { + feedback_record_id: "019fa338-f494-7384-b34e-01739783d280", + score: 0.5, + field_label: "Q", + value_text: "text", + distance: 0.5, + tenant_id: "clfd1234567890123456789012", + } as unknown as Parameters[0]; + + const dto = serializeV3FeedbackRecordMatch(withExtras) as Record; + + expect(dto.distance).toBeUndefined(); + expect(dto.tenant_id).toBeUndefined(); + expect(Object.keys(dto)).toEqual(["feedback_record_id", "score", "field_label", "value_text"]); + }); +}); + +describe("serializeV3FeedbackDataset", () => { + test("returns only id and name", () => { + const dataset = { id: "clfd1234567890123456789012", name: "Support", organizationId: "org_1" }; + + expect(serializeV3FeedbackDataset(dataset)).toEqual({ + id: "clfd1234567890123456789012", + name: "Support", + }); + }); +}); diff --git a/apps/web/app/api/v3/feedbackRecords/lib/serializers.ts b/apps/web/app/api/v3/feedbackRecords/lib/serializers.ts new file mode 100644 index 000000000000..9c8672225d5e --- /dev/null +++ b/apps/web/app/api/v3/feedbackRecords/lib/serializers.ts @@ -0,0 +1,132 @@ +import type { + FeedbackRecordData, + SemanticSearchResultItem, + SimilarRecordsResultItem, +} from "@/modules/hub/types"; + +/** The two similarity endpoints return the same row shape; either is acceptable here. */ +type FeedbackRecordMatchSource = SemanticSearchResultItem | SimilarRecordsResultItem; + +/** + * Public DTO for a feedback record. Field names mirror the Hub's feedback-record contract (snake_case) + * so the MCP surface, the Unify UI, and the Hub API documentation all describe the same shape. + * + * One deliberate exception: the Hub's `tenant_id` is emitted as **`dataset_id`**. The value is the same + * (a `FeedbackDirectory.id`), but "tenant" is Hub-internal vocabulary — the product, its UI and its docs + * call this a Feedback Dataset, so that is what the outward-facing surface says. Internal code keeps the + * `feedbackDirectory*` names; this serializer is the one place the outbound mapping happens. + * + * This is an explicit allowlist (not a pass-through of the SDK object): only the documented Hub fields + * are ever emitted, so a future SDK/bridge addition can't silently widen the response (OWASP API3). + * Read-only enrichment fields (sentiment/emotions/translation) are included when present. + */ +export type TV3FeedbackRecord = { + id: string; + dataset_id?: string; + submission_id?: string; + source_type?: string; + source_id?: string; + source_name?: string; + field_id?: string; + field_type?: string; + field_label?: string; + field_group_id?: string; + field_group_label?: string; + user_id?: string; + language?: string; + value_text?: string | null; + value_number?: number; + value_boolean?: boolean; + value_date?: string; + value_id?: string | null; + metadata?: Record; + sentiment?: string; + sentiment_score?: number; + emotions?: string[] | string | null; + translation_lang_key?: string | null; + value_text_translated?: string | null; + collected_at?: string; + created_at?: string; + updated_at?: string; +}; + +// Allowlisted optional fields copied verbatim from the Hub record when present (explicit nulls +// preserved). `tenant_id` is absent on purpose — it is renamed to `dataset_id` below. +const FEEDBACK_RECORD_FIELDS = [ + "submission_id", + "source_type", + "source_id", + "source_name", + "field_id", + "field_type", + "field_label", + "field_group_id", + "field_group_label", + "user_id", + "language", + "value_text", + "value_number", + "value_boolean", + "value_date", + "value_id", + "metadata", + "sentiment", + "sentiment_score", + "emotions", + "translation_lang_key", + "value_text_translated", + "collected_at", + "created_at", + "updated_at", +] as const; + +export const serializeV3FeedbackRecord = (record: FeedbackRecordData): TV3FeedbackRecord => { + const source = record as unknown as Record; + const dto: TV3FeedbackRecord = { id: record.id }; + const writable = dto as Record; + + // The Hub's tenant is our dataset — same value, outward-facing name. + if (record.tenant_id !== undefined) { + dto.dataset_id = record.tenant_id; + } + + for (const key of FEEDBACK_RECORD_FIELDS) { + if (source[key] !== undefined) { + writable[key] = source[key]; + } + } + return dto; +}; + +/** + * One scored match from a similarity search. The Hub returns an identical row shape for text search and + * for "records like this one", so both go through this serializer. + * + * Only the fields needed to triage a hit are emitted — the id (to fetch the full record), the score, and + * the embedded text with its field label. Hydrating each hit into a full record would cost one Hub call + * per result; the caller can do that selectively via the get operation. + */ +export type TV3FeedbackRecordMatch = { + feedback_record_id: string; + /** Cosine similarity, 0 (unrelated) to 1 (identical). Results are ordered best first. */ + score: number; + field_label: string; + value_text: string; +}; + +export const serializeV3FeedbackRecordMatch = (match: FeedbackRecordMatchSource): TV3FeedbackRecordMatch => ({ + feedback_record_id: match.feedback_record_id, + score: match.score, + field_label: match.field_label, + value_text: match.value_text, +}); + +export type TV3FeedbackDataset = { + id: string; + name: string; +}; + +export const serializeV3FeedbackDataset = (dataset: { id: string; name: string }): TV3FeedbackDataset => ({ + id: dataset.id, + name: dataset.name, +}); diff --git a/apps/web/app/api/v3/lib/feedback-access.ts b/apps/web/app/api/v3/lib/feedback-access.ts new file mode 100644 index 000000000000..1c62230ccc3b --- /dev/null +++ b/apps/web/app/api/v3/lib/feedback-access.ts @@ -0,0 +1,49 @@ +import "server-only"; +import { getIsFeedbackDirectoriesEnabled } from "@/modules/ee/license-check/lib/utils"; +import type { TTeamPermission } from "@/modules/ee/teams/workspace-teams/types/team"; +import { requireV3WorkspaceAccess } from "./auth"; +import { problemForbidden } from "./response"; +import type { TV3Authentication } from "./types"; +import type { V3WorkspaceContext } from "./workspace-context"; + +/** + * Workspace access + the `feedbackDirectories` entitlement — the gate every Unify Feedback surface needs + * before touching Hub data. `requireV3WorkspaceAccess` covers org owner/manager OR workspace-team + * permission; this adds the entitlement check it deliberately does not do. + * + * Shared so the pair can't drift between the v3 surfaces that depend on it (taxonomy and feedback + * records). Callers that also address a specific directory should use `requireUnifyDirectoryAccess`, + * which layers the membership check on top. + * + * Returns a `Response` (401/403) to short-circuit on failure, or the resolved workspace context. + */ +export async function requireUnifyFeedbackWorkspaceAccess( + authentication: TV3Authentication, + workspaceId: string, + minPermission: TTeamPermission, + requestId: string, + instance?: string +): Promise { + const context = await requireV3WorkspaceAccess( + authentication, + workspaceId, + minPermission, + requestId, + instance + ); + if (context instanceof Response) { + return context; + } + + if (!(await getIsFeedbackDirectoriesEnabled(context.organizationId))) { + // Keeps the recognizable leading phrase used elsewhere, plus a next step — an API or MCP caller has + // no upgrade prompt to fall back on, so the response has to say who can act. + return problemForbidden( + requestId, + "Unify Feedback is not enabled for this organization. It requires an Enterprise plan or license — an organization owner can enable it from the organization's billing or license settings.", + instance + ); + } + + return context; +} diff --git a/apps/web/app/api/v3/lib/response.ts b/apps/web/app/api/v3/lib/response.ts index b59726959fbe..1b159b209e2f 100644 --- a/apps/web/app/api/v3/lib/response.ts +++ b/apps/web/app/api/v3/lib/response.ts @@ -169,6 +169,13 @@ export function problemUnprocessableContent( }); } +export function problemConflict(requestId: string, detail: string, instance?: string): Response { + return problemResponse(409, "Conflict", detail, requestId, { + code: "conflict", + instance, + }); +} + export function problemBadGateway(requestId: string, detail: string, instance?: string): Response { return problemResponse(502, "Bad Gateway", detail, requestId, { code: "bad_gateway", @@ -176,6 +183,17 @@ export function problemBadGateway(requestId: string, detail: string, instance?: }); } +/** + * 503 for a capability that is not enabled on this deployment (as opposed to a transient outage, which + * is a 502). `detail` should say what to configure — a bare "unavailable" is not actionable. + */ +export function problemServiceUnavailable(requestId: string, detail: string, instance?: string): Response { + return problemResponse(503, "Service Unavailable", detail, requestId, { + code: "service_unavailable", + 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. diff --git a/apps/web/app/api/v3/unify-feedback/taxonomy/lib/access.ts b/apps/web/app/api/v3/unify-feedback/taxonomy/lib/access.ts index 50a14fdaa28b..8c1a7d74d7b8 100644 --- a/apps/web/app/api/v3/unify-feedback/taxonomy/lib/access.ts +++ b/apps/web/app/api/v3/unify-feedback/taxonomy/lib/access.ts @@ -1,19 +1,18 @@ import "server-only"; -import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth"; +import { requireUnifyFeedbackWorkspaceAccess } from "@/app/api/v3/lib/feedback-access"; import { problemForbidden } from "@/app/api/v3/lib/response"; import type { TV3Authentication } from "@/app/api/v3/lib/types"; import type { V3WorkspaceContext } from "@/app/api/v3/lib/workspace-context"; import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-directory/lib/feedback-directory"; -import { getIsFeedbackDirectoriesEnabled } from "@/modules/ee/license-check/lib/utils"; import type { TTeamPermission } from "@/modules/ee/teams/workspace-teams/types/team"; /** * Authorize a Unify Feedback taxonomy request against a workspace + feedback directory. * * Reproduces the exact guard the legacy server actions applied (`ensureAccess` + `ensureDirectoryAccess`): - * `requireV3WorkspaceAccess` covers the org owner/manager OR workspace-team permission array, and this - * wrapper adds the two checks v3 workspace auth does NOT do — the `feedbackDirectories` entitlement and - * the directory-belongs-to-workspace membership check. Omitting either would widen access beyond today. + * the shared `requireUnifyFeedbackWorkspaceAccess` covers workspace permission plus the + * `feedbackDirectories` entitlement, and this wrapper adds the directory-belongs-to-workspace membership + * check. Omitting either would widen access beyond today. * * Returns a `Response` (401/403) to short-circuit on failure, or the resolved workspace context on success. */ @@ -25,7 +24,7 @@ export async function requireUnifyDirectoryAccess( requestId: string, instance?: string ): Promise { - const context = await requireV3WorkspaceAccess( + const context = await requireUnifyFeedbackWorkspaceAccess( authentication, workspaceId, minPermission, @@ -36,11 +35,6 @@ export async function requireUnifyDirectoryAccess( return context; } - const isEnabled = await getIsFeedbackDirectoriesEnabled(context.organizationId); - if (!isEnabled) { - return problemForbidden(requestId, "Unify Feedback is not enabled for this organization", instance); - } - const directories = await getFeedbackDirectoriesByWorkspaceId(context.workspaceId); if (!directories.some((directory) => directory.id === directoryId)) { return problemForbidden(requestId, "You are not authorized to access this resource", instance); diff --git a/apps/web/app/setup/organization/create/actions.ts b/apps/web/app/setup/organization/create/actions.ts index 08cdf86e8f45..8e45f1b4e08c 100644 --- a/apps/web/app/setup/organization/create/actions.ts +++ b/apps/web/app/setup/organization/create/actions.ts @@ -7,7 +7,7 @@ import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { getHasNoOrganizations } from "@/lib/instance/service"; import { createMembership } from "@/lib/membership/service"; import { createOrganization } from "@/lib/organization/service"; -import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog"; +import { capturePostHogEvent, getEmailDomain, groupIdentifyPostHog } from "@/lib/posthog"; import { authenticatedActionClient } from "@/lib/utils/action-client"; import { DEFAULT_WORKSPACE_NAME } from "@/lib/workspace/constants"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; @@ -56,7 +56,10 @@ export const createOrganizationAction = authenticatedActionClient ctx.auditLoggingCtx.organizationId = newOrganization.id; ctx.auditLoggingCtx.newObject = newOrganization; - groupIdentifyPostHog("organization", newOrganization.id, { name: newOrganization.name }); + groupIdentifyPostHog("organization", newOrganization.id, { + name: newOrganization.name, + email_domain: getEmailDomain(ctx.user.email), + }); groupIdentifyPostHog("workspace", newWorkspace.id, { name: newWorkspace.name }); capturePostHogEvent( diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index e2a46646dfb3..64b9e4bf47f4 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -77,6 +77,8 @@ checksums: auth/oauth/revoke: be57685a85b6dfeaeb6eab4e9560b520 auth/oauth/revoke_confirmation: 9f2ef40fac22fe48e39d81de2b677345 auth/oauth/scopes/email: c28cf6b6c6221b0045539a5f7712230f + auth/oauth/scopes/feedback_records_read: 720898c950af227bb3be529a64e3bc38 + auth/oauth/scopes/feedback_records_write: 2c620b5bfeaef1580d27e30a971a93a8 auth/oauth/scopes/offline_access: 5086c8bd3685a21f691d81d50dc0a4e0 auth/oauth/scopes/openid: 557e91a8b41497cf95b1434aefcb72ea auth/oauth/scopes/profile: ea073dc28851ee1a00365047be103d50 @@ -2560,6 +2562,7 @@ checksums: workspace/settings/enterprise/unlock_the_full_power_of_formbricks_free_for_30_days: a96f8df102a5d1f0367f3f82ac1f344b workspace/settings/enterprise/white_glove_onboarding: 79d5f04ad3decd659e63f5cf61628ea0 workspace/settings/enterprise/whitelabel_email_follow_ups: 4d62c861b60c5dceb6ad72cd5d1186d7 + workspace/settings/enterprise/workflows: b0c9c8615a9ba7d9cb73e767290a7f72 workspace/settings/feedback_directories/archive: fa813ab3074103e5daad07462af25789 workspace/settings/feedback_directories/archive_directory: c2b39feb0962b93f883a4bac6ffadd1e workspace/settings/feedback_directories/archive_not_allowed: 929b16f54260e321b0ebf5d458499d60 diff --git a/apps/web/lib/constants.ts b/apps/web/lib/constants.ts index 6eceda1e8e11..297501eeacdb 100644 --- a/apps/web/lib/constants.ts +++ b/apps/web/lib/constants.ts @@ -166,6 +166,16 @@ export const ENTERPRISE_LICENSE_REQUEST_FORM_URL = export const REDIS_URL = env.REDIS_URL; export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1"; +/** + * Number of reverse proxies in front of the app whose `X-Forwarded-For` entries may be believed. + * + * Defaults to 1 because that matches every supported topology — the Helm chart's Traefik/Envoy ingress, + * docker-compose behind a proxy, and Formbricks Cloud — and because Next 16 gives route handlers no + * socket peer address to fall back on, so a default of 0 would leave IP-based rate limiting unable to + * tell clients apart until an operator set this. Deployments with a longer proxy chain must raise it; + * setting it higher than the real chain lets a caller spoof the address by prepending entries. + */ +export const TRUSTED_PROXY_HOP_COUNT = env.TRUSTED_PROXY_HOP_COUNT ?? 1; export const TELEMETRY_DISABLED = env.TELEMETRY_DISABLED === "1"; // Opt-out for the Have-I-Been-Pwned breach check (ENG-1587). Set to "1" on air-gapped / diff --git a/apps/web/lib/env.ts b/apps/web/lib/env.ts index 153a33c6f931..bb4640a732ea 100644 --- a/apps/web/lib/env.ts +++ b/apps/web/lib/env.ts @@ -316,6 +316,18 @@ const parsedEnv = createEnv({ .optional() .or(z.string().refine((str) => str === "")), RATE_LIMITING_DISABLED: z.enum(["1", "0"]).optional(), + // Number of reverse proxies in front of the app whose X-Forwarded-For entries can be believed. + // Unset falls back to 1 (see TRUSTED_PROXY_HOP_COUNT in lib/constants.ts); an explicit 0 trusts no + // forwarding header at all. See resolveClientIp in lib/utils/client-ip.ts. + // Preprocessed because `z.coerce.number()` turns "" into 0, and 0 is a *valid* value here (the + // explicit "trust nothing" opt-out) rather than something `.min()` would reject. A deployment that + // renders the variable empty when unset — the common docker-compose / Helm shape — would otherwise + // silently opt out of trusting any forwarding header, collapsing every request into one rate-limit + // bucket. The neighbouring numeric vars only fail loudly on "" because their minimums exceed 0. + TRUSTED_PROXY_HOP_COUNT: z.preprocess( + (value) => (value === "" ? undefined : value), + z.coerce.number().int().min(0).max(10).optional() + ), TELEMETRY_DISABLED: z.enum(["1", "0"]).optional(), S3_ACCESS_KEY: z.string().optional(), S3_BUCKET_NAME: z.string().optional(), @@ -482,6 +494,7 @@ const parsedEnv = createEnv({ PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: process.env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES, PRIVACY_URL: process.env.PRIVACY_URL, RATE_LIMITING_DISABLED: process.env.RATE_LIMITING_DISABLED, + TRUSTED_PROXY_HOP_COUNT: process.env.TRUSTED_PROXY_HOP_COUNT, TELEMETRY_DISABLED: process.env.TELEMETRY_DISABLED, S3_ACCESS_KEY: process.env.S3_ACCESS_KEY, S3_BUCKET_NAME: process.env.S3_BUCKET_NAME, diff --git a/apps/web/lib/feedback-source/actions.ts b/apps/web/lib/feedback-source/actions.ts index f96aa9839e33..4b0a73381139 100644 --- a/apps/web/lib/feedback-source/actions.ts +++ b/apps/web/lib/feedback-source/actions.ts @@ -20,6 +20,7 @@ import { getOrganizationIdFromFeedbackSourceId, getOrganizationIdFromSurveyId, getOrganizationIdFromWorkspaceId, + getWorkspaceIdFromSurveyId, } from "@/lib/utils/helper"; import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-directory/lib/feedback-directory"; import { getContactIdsByUserIds } from "@/modules/ee/unify-feedback/lib/contacts"; @@ -275,6 +276,12 @@ export const getResponseCountAction = authenticatedActionClient parsedInput: z.infer; }): Promise => { const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId); + + // Authorize against the survey's own workspace, not the caller-supplied one: the workspaceTeam + // check only proves team access to whatever workspace the caller names, so passing a workspace + // they do have access to would otherwise return the response count for any survey in the org. + const surveyWorkspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId); + await checkAuthorizationUpdated({ userId: ctx.user.id, organizationId, @@ -286,7 +293,7 @@ export const getResponseCountAction = authenticatedActionClient { type: "workspaceTeam", minPermission: "readWrite", - workspaceId: parsedInput.workspaceId, + workspaceId: surveyWorkspaceId, }, ], }); diff --git a/apps/web/lib/integration/redact-credentials.test.ts b/apps/web/lib/integration/redact-credentials.test.ts new file mode 100644 index 000000000000..b12ed8f148c5 --- /dev/null +++ b/apps/web/lib/integration/redact-credentials.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "vitest"; +import { redactIntegrationCredentials, withStoredIntegrationKey } from "./redact-credentials"; + +describe("redactIntegrationCredentials", () => { + // Regression: these objects are passed to "use client" wrappers, so anything left in them ends up in + // the RSC payload in the page source. + test("blanks the OAuth tokens on a Google Sheets integration", () => { + const integration = { + id: "int_1", + type: "googleSheets", + config: { + email: "owner@example.com", + data: [{ spreadsheetId: "sheet_1" }], + key: { + scope: "https://www.googleapis.com/auth/spreadsheets", + token_type: "Bearer", + expiry_date: 123, + access_token: "ya29.super-secret", + refresh_token: "1//long-lived-secret", + }, + }, + }; + + const redacted = redactIntegrationCredentials(integration); + + expect(redacted?.config.key.access_token).toBe(""); + expect(redacted?.config.key.refresh_token).toBe(""); + // Non-secret fields the UI needs are preserved. + expect(redacted?.config.key.scope).toBe("https://www.googleapis.com/auth/spreadsheets"); + expect(redacted?.config.email).toBe("owner@example.com"); + expect(redacted?.config.data).toEqual([{ spreadsheetId: "sheet_1" }]); + }); + + test("preserves the display fields Notion and Slack read from the key", () => { + const notion = redactIntegrationCredentials({ + config: { key: { access_token: "secret", bot_id: "bot_1", workspace_name: "Acme" }, data: [] }, + }); + expect(notion?.config.key).toEqual({ access_token: "", bot_id: "bot_1", workspace_name: "Acme" }); + + const slack = redactIntegrationCredentials({ + config: { key: { access_token: "xoxb-secret", team: { id: "T1", name: "Acme" } }, data: [] }, + }); + expect(slack?.config.key).toEqual({ access_token: "", team: { id: "T1", name: "Acme" } }); + }); + + // The point of matching on the field name is that a credential a provider adds later is redacted + // without anyone having to remember to extend a list. + test("blanks credential-shaped fields it has never seen before", () => { + const redacted = redactIntegrationCredentials({ + config: { + key: { + id_token: "eyJ-secret", + client_secret: "cs-secret", + service_account_private_key: "-----BEGIN PRIVATE KEY-----", + api_key: "ak-secret", + token: "bare-secret", + }, + data: [], + }, + }); + + expect(redacted?.config.key).toEqual({ + id_token: "", + client_secret: "", + service_account_private_key: "", + api_key: "", + token: "", + }); + }); + + // `token_type` names the scheme, and Slack/Google Sheets type it as a Zod literal — blanking it would + // make the redacted object stop satisfying the integration type it is passed as. + test.each([ + ["token_type", "Bearer"], + ["workspace_id", "ws_1"], + ["bot_user_id", "U1"], + ["app_id", "A1"], + ["expiry_date", "2026-01-01"], + ])("leaves the non-secret field %s alone", (field, value) => { + const redacted = redactIntegrationCredentials({ + config: { key: { [field]: value, access_token: "secret" }, data: [] }, + }); + + expect(redacted?.config.key[field]).toBe(value); + expect(redacted?.config.key.access_token).toBe(""); + }); + + test("does not mutate the input", () => { + const key = { access_token: "secret", refresh_token: "secret2" }; + const integration = { config: { key } }; + + redactIntegrationCredentials(integration); + + expect(key.access_token).toBe("secret"); + expect(key.refresh_token).toBe("secret2"); + }); + + test.each([ + ["undefined integration", undefined], + ["no config", {}], + ["null config", { config: null }], + ["no key", { config: { data: [] } }], + ])("passes through %s unchanged", (_label, input) => { + expect(redactIntegrationCredentials(input as never)).toEqual(input); + }); +}); + +describe("withStoredIntegrationKey", () => { + const stored = { + config: { key: { access_token: "real-access", refresh_token: "real-refresh" }, data: [] }, + }; + + // Regression for the round-trip that redaction created: the settings pages blank config.key on the + // way out, and the mapping UI echoes the whole integration back on add / edit / delete. Honouring the + // echoed key wrote empty strings over the stored tokens and disconnected the integration, while the + // UI still showed it connected because the wrappers only test config.key for presence. + test("keeps the stored credentials when the client echoes blanked ones", () => { + const incoming = { + type: "notion", + config: { key: { access_token: "", refresh_token: "" }, data: [{ surveyId: "s1" }] }, + }; + + const result = withStoredIntegrationKey(incoming as never, stored as never); + + expect(result.config.key).toEqual(stored.config.key); + // the non-secret payload the client legitimately owns still wins + expect(result.config.data).toEqual([{ surveyId: "s1" }]); + }); + + test("keeps the stored credentials even when the client sends a different key", () => { + const incoming = { type: "notion", config: { key: { access_token: "attacker" }, data: [] } }; + + expect(withStoredIntegrationKey(incoming as never, stored as never).config.key).toEqual( + stored.config.key + ); + }); + + test.each([ + ["no stored integration", null], + ["stored integration without a key", { config: { data: [] } }], + ])("passes the input through when there is %s", (_label, storedValue) => { + const incoming = { type: "notion", config: { key: { access_token: "fresh" }, data: [] } }; + + expect(withStoredIntegrationKey(incoming as never, storedValue as never)).toEqual(incoming); + }); +}); diff --git a/apps/web/lib/integration/redact-credentials.ts b/apps/web/lib/integration/redact-credentials.ts new file mode 100644 index 000000000000..e7fae3adda5d --- /dev/null +++ b/apps/web/lib/integration/redact-credentials.ts @@ -0,0 +1,88 @@ +/** + * Strips OAuth credentials out of an integration before it is handed to a client component. + * + * The integration settings pages pass the whole integration — including `config.key`, which holds the + * provider's `access_token` and, for Google Sheets and Airtable, a long-lived `refresh_token` — into + * `"use client"` wrappers. Anything a client component receives is serialized into the RSC payload and + * readable in the page source, so those credentials were exposed to every member who could open the + * page, and to anything with access to that HTML (browser extensions, HAR captures, a shared screen). + * A refresh token in particular grants access to the connected Google/Airtable account well outside + * Formbricks, and long after the member's Formbricks access is revoked. + * + * The client only needs the non-secret parts: `config.data` (the survey→destination mappings), + * `config.email`, and a few display/presence fields inside `key` (`bot_id`, `workspace_name`, + * `team.name`). Secret fields are blanked rather than removed so every consumer stays type-valid. + */ +const REDACTED = ""; + +/** + * Matched against each field name in `config.key`, so a credential a provider adds later is redacted by + * default rather than exposed until someone remembers to extend a list. + * + * Deliberately matches `token` only as a whole word or `*_token` suffix: Slack and Google Sheets type + * `token_type` as a Zod *literal* (`"bot"` / `"Bearer"`), so blanking it would make the redacted object + * fail to satisfy the integration type it is passed as. `token_type` names a scheme, not a secret. + */ +const SECRET_KEY_FIELD_PATTERN = /(^token$|_token$|secret|password|credential|private_key|api_?key)/i; + +/** Always redacted, whatever the pattern says. */ +const SECRET_KEY_FIELDS = ["access_token", "refresh_token"] as const; + +const isSecretKeyField = (field: string): boolean => + SECRET_KEY_FIELDS.includes(field as (typeof SECRET_KEY_FIELDS)[number]) || + SECRET_KEY_FIELD_PATTERN.test(field); + +type TIntegrationWithConfig = { + config?: { key?: Record | null } | null; +}; + +export const redactIntegrationCredentials = ( + integration: T | undefined +): T | undefined => { + if (!integration?.config?.key || typeof integration.config.key !== "object") { + return integration; + } + + const redactedKey: Record = { ...integration.config.key }; + for (const field of Object.keys(redactedKey)) { + // Only string values are blanked: every credential these providers issue is a string, and an + // object- or number-typed field (Slack's `team`, Google's `expiry_date`) would stop matching its + // schema if replaced with "". + if (isSecretKeyField(field) && typeof redactedKey[field] === "string") { + redactedKey[field] = REDACTED; + } + } + + return { + ...integration, + config: { ...integration.config, key: redactedKey }, + }; +}; + +/** + * The inbound counterpart to {@link redactIntegrationCredentials}: replaces a client-supplied + * `config.key` with the stored one. + * + * Because the settings pages redact credentials on the way out, and the mapping UI echoes the whole + * integration object back when a link is added, edited or removed, an unguarded save writes the blanked + * tokens over the real ones — disconnecting the integration while the UI still shows it as connected, + * since the wrappers only test `config.key` for presence. Credentials therefore never come from the + * request on that path; they are written only by the OAuth callbacks, which reach + * `createOrUpdateIntegration` directly. + * + * With no stored integration there is nothing to preserve and the input passes through unchanged: that + * is the first-connect case, which only the callbacks perform. + */ +export const withStoredIntegrationKey = ( + incoming: T, + stored: TIntegrationWithConfig | null | undefined +): T => { + if (!stored?.config?.key || !incoming.config) { + return incoming; + } + + return { + ...incoming, + config: { ...incoming.config, key: stored.config.key }, + }; +}; diff --git a/apps/web/lib/jwt.test.ts b/apps/web/lib/jwt.test.ts index a6a4c8a71792..e4349ba16733 100644 --- a/apps/web/lib/jwt.test.ts +++ b/apps/web/lib/jwt.test.ts @@ -298,6 +298,20 @@ describe("JWT Functions - Comprehensive Security Tests", () => { const token = jwt.sign({ email: "test@example.com" }, TEST_NEXTAUTH_SECRET); await testMissingSecretsError(getEmailFromEmailToken, [token]); }); + + test("should reject a link survey token minted for another flow", () => { + const token = createTokenForLinkSurvey("test-survey-id", mockUser.email); + expect(() => getEmailFromEmailToken(token)).toThrow("Invalid token"); + }); + + test("should reject an expired email-display token", () => { + const token = jwt.sign( + { email: `encrypted_${mockUser.email}`, purpose: "email_display" }, + TEST_NEXTAUTH_SECRET, + { expiresIn: "-1s" } + ); + expect(() => getEmailFromEmailToken(token)).toThrow(); + }); }); describe("verifyTokenForLinkSurvey", () => { @@ -308,6 +322,43 @@ describe("JWT Functions - Comprehensive Security Tests", () => { expect(verifiedEmail).toBe(mockUser.email); }); + // Regression: every token in this module is signed with NEXTAUTH_SECRET, so a token minted for a + // different flow must not pass as a verified email. `createEmailToken` is reachable through an + // unauthenticated server action for any registered address, so accepting it here bypassed the + // link-survey email gate for arbitrary people. + test("should reject an email-display token as a link survey verification token", () => { + const token = createEmailToken(mockUser.email); + expect(verifyTokenForLinkSurvey(token, "test-survey-id")).toBeNull(); + }); + + test("should reject an invite token as a link survey verification token", () => { + const token = createInviteToken("invite-id", mockUser.email); + expect(verifyTokenForLinkSurvey(token, "test-survey-id")).toBeNull(); + }); + + test("should reject a token with no surveyId claim signed with the plain secret", () => { + const token = jwt.sign({ email: `encrypted_${mockUser.email}` }, TEST_NEXTAUTH_SECRET); + expect(verifyTokenForLinkSurvey(token, "test-survey-id")).toBeNull(); + }); + + test("should reject a link survey token whose purpose is for another flow", () => { + const token = jwt.sign( + { email: `encrypted_${mockUser.email}`, surveyId: "test-survey-id", purpose: "email_display" }, + TEST_NEXTAUTH_SECRET + ); + expect(verifyTokenForLinkSurvey(token, "test-survey-id")).toBeNull(); + }); + + test("should reject an expired link survey token", () => { + const surveyId = "test-survey-id"; + const token = jwt.sign( + { email: `encrypted_${mockUser.email}`, surveyId, purpose: "link_survey_email_verification" }, + TEST_NEXTAUTH_SECRET, + { expiresIn: "-1s" } + ); + expect(verifyTokenForLinkSurvey(token, surveyId)).toBeNull(); + }); + test("should return null for invalid token", () => { const result = verifyTokenForLinkSurvey("invalid-token", "test-survey-id"); expect(result).toBeNull(); diff --git a/apps/web/lib/jwt.ts b/apps/web/lib/jwt.ts index 5d7d7147b7b3..223306d2e849 100644 --- a/apps/web/lib/jwt.ts +++ b/apps/web/lib/jwt.ts @@ -16,6 +16,19 @@ const decryptWithFallback = (encryptedText: string, key: string): string => { } }; +/** + * Every token minted here is signed with the same `NEXTAUTH_SECRET`, so the signature alone proves + * nothing about *which* flow a token was issued for. Tokens that grant something must therefore carry + * an explicit `purpose` claim and the verifier must require it, otherwise a token handed out by one + * flow is replayable against another (e.g. an email- or invite-token accepted as proof of email + * verification for a link survey). + */ +export const LINK_SURVEY_EMAIL_VERIFICATION_PURPOSE = "link_survey_email_verification"; +export const EMAIL_DISPLAY_TOKEN_PURPOSE = "email_display"; + +const LINK_SURVEY_TOKEN_TTL = "7d"; +const EMAIL_DISPLAY_TOKEN_TTL = "1d"; + export const VERIFICATION_TOKEN_PURPOSES = ["email_verification", "sso_recovery"] as const; export type TVerificationTokenPurpose = (typeof VERIFICATION_TOKEN_PURPOSES)[number]; @@ -110,7 +123,11 @@ export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): s } const encryptedEmail = symmetricEncrypt(userEmail, ENCRYPTION_KEY); - return jwt.sign({ email: encryptedEmail, surveyId }, NEXTAUTH_SECRET); + return jwt.sign( + { email: encryptedEmail, surveyId, purpose: LINK_SURVEY_EMAIL_VERIFICATION_PURPOSE }, + NEXTAUTH_SECRET, + { expiresIn: LINK_SURVEY_TOKEN_TTL } + ); }; export const verifyEmailChangeToken = async (token: string): Promise<{ id: string; email: string }> => { @@ -205,7 +222,11 @@ export const createEmailToken = (email: string): string => { } const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY); - return jwt.sign({ email: encryptedEmail }, NEXTAUTH_SECRET); + // Handed out by an unauthenticated action purely so the "check your inbox" screen can echo the + // address back. It must therefore be inert everywhere else: scope it with a purpose and a TTL. + return jwt.sign({ email: encryptedEmail, purpose: EMAIL_DISPLAY_TOKEN_PURPOSE }, NEXTAUTH_SECRET, { + expiresIn: EMAIL_DISPLAY_TOKEN_TTL, + }); }; export const getEmailFromEmailToken = (token: string): string => { @@ -219,7 +240,15 @@ export const getEmailFromEmailToken = (token: string): string => { const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & { email: string; + purpose?: string; }; + + // Tokens minted before the purpose claim existed have none; anything else was issued for a + // different flow and must not resolve to an email here. + if (payload.purpose !== undefined && payload.purpose !== EMAIL_DISPLAY_TOKEN_PURPOSE) { + throw new Error("Invalid token"); + } + return decryptWithFallback(payload.email, ENCRYPTION_KEY); }; @@ -243,7 +272,10 @@ export const verifyTokenForLinkSurvey = (token: string, surveyId: string): strin } try { - let payload: JwtPayload & { email: string; surveyId?: string }; + let payload: JwtPayload & { email: string; surveyId?: string; purpose?: string }; + // Legacy tokens carry no `surveyId` claim; they are bound to one survey by their signing key + // (`NEXTAUTH_SECRET + surveyId`) instead, so that path needs no claim check. + let isBoundBySigningKey = false; // Try primary method first (consistent secret) try { @@ -259,14 +291,23 @@ export const verifyTokenForLinkSurvey = (token: string, surveyId: string): strin payload = jwt.verify(token, NEXTAUTH_SECRET + surveyId, { algorithms: ["HS256"] }) as JwtPayload & { email: string; }; + isBoundBySigningKey = true; } catch (legacyError) { logger.error(legacyError, "Token verification failed with legacy method"); throw new Error("Invalid token"); } } - // Verify the surveyId matches if present in payload (new format) - if (payload.surveyId && payload.surveyId !== surveyId) { + // A token verified with the plain secret MUST name this survey. Accepting a missing `surveyId` + // here let any NEXTAUTH_SECRET-signed token that happens to carry an `email` claim — e.g. the one + // `createEmailToken` hands out for an arbitrary address — pass as a verified email for any survey. + if (!isBoundBySigningKey && payload.surveyId !== surveyId) { + return null; + } + + // Reject tokens explicitly minted for a different flow. Tokens issued before the purpose claim + // existed have none; they are already survey-bound by the check above. + if (payload.purpose !== undefined && payload.purpose !== LINK_SURVEY_EMAIL_VERIFICATION_PURPOSE) { return null; } diff --git a/apps/web/lib/posthog/capture.test.ts b/apps/web/lib/posthog/capture.test.ts index d5b679ed4e80..52a056cb2f64 100644 --- a/apps/web/lib/posthog/capture.test.ts +++ b/apps/web/lib/posthog/capture.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -import { capturePostHogEvent, groupIdentifyPostHog, identifyPostHogPerson } from "./capture"; +import { capturePostHogEvent, getEmailDomain, groupIdentifyPostHog, identifyPostHogPerson } from "./capture"; const mocks = vi.hoisted(() => ({ capture: vi.fn(), @@ -206,3 +206,19 @@ describe("capturePostHogEvent with null client", () => { expect(mocks.loggerWarn).not.toHaveBeenCalled(); }); }); + +describe("getEmailDomain", () => { + test("extracts the domain part of an email", () => { + expect(getEmailDomain("user@example.com")).toBe("example.com"); + }); + + test("lowercases the domain", () => { + expect(getEmailDomain("User@Example.COM")).toBe("example.com"); + }); + + test("returns undefined when there is no domain part", () => { + expect(getEmailDomain("not-an-email")).toBeUndefined(); + expect(getEmailDomain("trailing@")).toBeUndefined(); + expect(getEmailDomain("")).toBeUndefined(); + }); +}); diff --git a/apps/web/lib/posthog/capture.ts b/apps/web/lib/posthog/capture.ts index b9870139209e..039e862cd24f 100644 --- a/apps/web/lib/posthog/capture.ts +++ b/apps/web/lib/posthog/capture.ts @@ -51,6 +51,14 @@ export function identifyPostHogPerson(distinctId: string, properties?: PostHogEv } } +/** + * Extracts the lowercased domain part of an email address for use as a PostHog + * property. Returns undefined when the email has no domain part. + */ +export function getEmailDomain(email: string): string | undefined { + return email.split("@")[1]?.toLowerCase() || undefined; +} + type PostHogGroupType = "organization" | "workspace"; export function groupIdentifyPostHog( diff --git a/apps/web/lib/posthog/index.ts b/apps/web/lib/posthog/index.ts index eb6eac0727fd..17975d3f0228 100644 --- a/apps/web/lib/posthog/index.ts +++ b/apps/web/lib/posthog/index.ts @@ -1,6 +1,6 @@ import "server-only"; -export { capturePostHogEvent, groupIdentifyPostHog, identifyPostHogPerson } from "./capture"; +export { capturePostHogEvent, groupIdentifyPostHog, identifyPostHogPerson, getEmailDomain } from "./capture"; export type { PostHogGroupContext } from "./capture"; export { getPostHogFeatureFlag } from "./get-feature-flag"; export type { TPostHogFeatureFlagContext, TPostHogFeatureFlagValue } from "./types"; diff --git a/apps/web/lib/utils/client-ip.test.ts b/apps/web/lib/utils/client-ip.test.ts index 1ddfe9cc3868..d087251508e0 100644 --- a/apps/web/lib/utils/client-ip.test.ts +++ b/apps/web/lib/utils/client-ip.test.ts @@ -1,6 +1,6 @@ import * as nextHeaders from "next/headers"; import { beforeEach, describe, expect, test, vi } from "vitest"; -import { getClientIpFromHeaders } from "./client-ip"; +import { UNTRUSTED_CLIENT_IP, getClientIpFromHeaders, resolveClientIp } from "./client-ip"; // Mock next/headers declare module "next/headers" { @@ -11,72 +11,193 @@ vi.mock("next/headers", () => ({ headers: vi.fn(), })); +// Mirrors the shipped default of 1: one trusted reverse proxy in front of the app. +vi.mock("@/lib/constants", () => ({ TRUSTED_PROXY_HOP_COUNT: 1 })); + +vi.mock("@formbricks/logger", () => ({ + logger: { error: vi.fn() }, +})); + +const buildHeaders = (headerMap: Record): Headers => + ({ + get: (key: string) => headerMap[key.toLowerCase()] ?? null, + }) as Headers; + const mockHeaders = (headerMap: Record) => { - vi.mocked(nextHeaders.headers).mockReturnValue({ - get: (key: string) => headerMap[key.toLowerCase()] ?? undefined, - }); + vi.mocked(nextHeaders.headers).mockReturnValue(buildHeaders(headerMap)); }; -describe("getClientIpFromHeaders", () => { - beforeEach(() => { - vi.clearAllMocks(); +describe("resolveClientIp", () => { + // Regression: X-Forwarded-For is appended to by each proxy, so its leftmost entry is client-supplied. + // Reading it let a caller rotate the header to mint a fresh rate-limit bucket per request, bypassing + // the login / forgot-password / signup / public-API limits, and to forge captured IPs. + describe("with one trusted proxy", () => { + test("takes the entry the trusted proxy appended, not the client-supplied one", () => { + const headers = buildHeaders({ "x-forwarded-for": "1.1.1.1, 203.0.113.7" }); + expect(resolveClientIp(headers, 1)).toBe("203.0.113.7"); + }); + + test("ignores a spoofed chain the client prepended", () => { + const headers = buildHeaders({ + "x-forwarded-for": "9.9.9.9, 8.8.8.8, 7.7.7.7, 203.0.113.7", + }); + expect(resolveClientIp(headers, 1)).toBe("203.0.113.7"); + }); + + // A hop count says "one proxy is in front", not "that proxy is Cloudflare". Traefik, Envoy and + // nginx all pass cf-connecting-ip through untouched, so believing it ahead of the hop-counted + // chain would hand the spoof straight back to the caller. + test("ignores cf-connecting-ip in favour of the forwarded chain", () => { + const headers = buildHeaders({ + "cf-connecting-ip": "198.51.100.5", + "x-forwarded-for": "1.1.1.1, 203.0.113.7", + }); + expect(resolveClientIp(headers, 1)).toBe("203.0.113.7"); + }); + + test("ignores cf-connecting-ip even when it is the only header present", () => { + const headers = buildHeaders({ "cf-connecting-ip": "198.51.100.5" }); + expect(resolveClientIp(headers, 1)).toBe(UNTRUSTED_CLIENT_IP); + }); + + test("trims whitespace around the selected entry", () => { + const headers = buildHeaders({ "x-forwarded-for": " 1.1.1.1 , 203.0.113.7 " }); + expect(resolveClientIp(headers, 1)).toBe("203.0.113.7"); + }); + + // Regression: x-real-ip is only ever reached when no XFF arrived, which means the request did not + // come through the proxy this app is configured to trust — so the header is caller-supplied, and + // honouring it would hand back the per-request bucket rotation this function exists to stop. + test("ignores x-real-ip when there is no forwarded chain", () => { + const headers = buildHeaders({ "x-real-ip": "203.0.113.9" }); + expect(resolveClientIp(headers, 1)).toBe(UNTRUSTED_CLIENT_IP); + }); + + test("ignores x-real-ip even when a forwarded chain is present", () => { + const headers = buildHeaders({ + "x-forwarded-for": "1.1.1.1, 203.0.113.7", + "x-real-ip": "9.9.9.9", + }); + expect(resolveClientIp(headers, 1)).toBe("203.0.113.7"); + }); + + test("reports the client as untrusted when no header identifies it", () => { + expect(resolveClientIp(buildHeaders({}), 1)).toBe(UNTRUSTED_CLIENT_IP); + }); }); - test("returns cf-connecting-ip if present", async () => { - mockHeaders({ "cf-connecting-ip": "1.2.3.4" }); - const ip = await getClientIpFromHeaders(); - expect(ip).toBe("1.2.3.4"); + describe("with two trusted proxies", () => { + test("takes the second entry from the right", () => { + const headers = buildHeaders({ "x-forwarded-for": "1.1.1.1, 203.0.113.7, 10.0.0.1" }); + expect(resolveClientIp(headers, 2)).toBe("203.0.113.7"); + }); + + test("clamps to the earliest entry when the chain is shorter than configured", () => { + const headers = buildHeaders({ "x-forwarded-for": "203.0.113.7" }); + expect(resolveClientIp(headers, 2)).toBe("203.0.113.7"); + }); }); - test("returns first x-forwarded-for if cf-connecting-ip is missing", async () => { - mockHeaders({ "x-forwarded-for": "5.6.7.8, 9.10.11.12" }); - const ip = await getClientIpFromHeaders(); - expect(ip).toBe("5.6.7.8"); + describe("with no trusted proxy", () => { + test.each([ + ["x-forwarded-for", { "x-forwarded-for": "1.1.1.1, 203.0.113.7" }], + ["cf-connecting-ip", { "cf-connecting-ip": "1.1.1.1" }], + ["x-real-ip", { "x-real-ip": "1.1.1.1" }], + ])("does not believe %s", (_label, headerMap) => { + expect(resolveClientIp(buildHeaders(headerMap), 0)).toBe(UNTRUSTED_CLIENT_IP); + }); + + test("treats a negative hop count as zero", () => { + const headers = buildHeaders({ "x-forwarded-for": "1.1.1.1" }); + expect(resolveClientIp(headers, -1)).toBe(UNTRUSTED_CLIENT_IP); + }); + }); +}); + +describe("getClientIpFromHeaders", () => { + beforeEach(() => { + vi.clearAllMocks(); }); - test("returns x-real-ip if cf-connecting-ip and x-forwarded-for are missing", async () => { - mockHeaders({ "x-real-ip": "13.14.15.16" }); - const ip = await getClientIpFromHeaders(); - expect(ip).toBe("13.14.15.16"); + // At the shipped default of 1, the address is the entry the single trusted proxy appended, and the + // client-supplied prefix and `cf-connecting-ip` are both ignored. + test("takes the trusted proxy's entry at the default hop count of 1", async () => { + mockHeaders({ "cf-connecting-ip": "1.2.3.4", "x-forwarded-for": "9.9.9.9, 203.0.113.7" }); + await expect(getClientIpFromHeaders()).resolves.toBe("203.0.113.7"); }); - test("returns ::1 if no headers are present", async () => { + test("reports the client as untrusted when no header identifies it", async () => { mockHeaders({}); - const ip = await getClientIpFromHeaders(); - expect(ip).toBe("::1"); + await expect(getClientIpFromHeaders()).resolves.toBe(UNTRUSTED_CLIENT_IP); }); - test("trims whitespace in x-forwarded-for", async () => { - mockHeaders({ "x-forwarded-for": " 21.22.23.24 , 25.26.27.28" }); - const ip = await getClientIpFromHeaders(); - expect(ip).toBe("21.22.23.24"); + test("handles errors when headers() throws an exception", async () => { + vi.mocked(nextHeaders.headers).mockImplementation(() => { + throw new Error("Failed to get headers"); + }); + + await expect(getClientIpFromHeaders()).resolves.toBe(UNTRUSTED_CLIENT_IP); }); +}); - test("getClientIpFromHeaders should return the value of the cf-connecting-ip header when it is present", async () => { - const testIp = "123.123.123.123"; +describe("misconfiguration warning", () => { + // The throttle timestamp is module-level state, so each case re-imports the module to get a fresh + // one. The logger has to be imported *after* the reset too, or it would be a different mock instance + // than the one the re-imported module captured. + const loadFresh = async () => { + vi.resetModules(); + const { logger } = await import("@formbricks/logger"); + const { resolveClientIp: freshResolveClientIp } = await import("./client-ip"); + return { logger, freshResolveClientIp }; + }; + + test("warns once per throttle window when forwarding headers arrive but no hop is trusted", async () => { + const { logger, freshResolveClientIp } = await loadFresh(); + const headers = buildHeaders({ "x-forwarded-for": "1.1.1.1, 203.0.113.7" }); + + freshResolveClientIp(headers, 0); + freshResolveClientIp(headers, 0); + freshResolveClientIp(buildHeaders({ "cf-connecting-ip": "1.1.1.1" }), 0); + + expect(logger.error).toHaveBeenCalledTimes(1); + expect(vi.mocked(logger.error).mock.calls[0][0]).toContain("TRUSTED_PROXY_HOP_COUNT"); + }); - vi.mocked(nextHeaders.headers).mockReturnValue({ - get: vi.fn().mockImplementation((headerName: string) => { - if (headerName === "cf-connecting-ip") { - return testIp; - } - return null; - }), - } as any); + // The point of the time window over a once-per-process flag: an operator who leaves + // TRUSTED_PROXY_HOP_COUNT=0 keeps getting a signal, instead of one line at boot and silence after. + test("warns again after the throttle window elapses", async () => { + const { logger, freshResolveClientIp } = await loadFresh(); + const headers = buildHeaders({ "x-forwarded-for": "1.1.1.1, 203.0.113.7" }); + + vi.useFakeTimers(); + try { + freshResolveClientIp(headers, 0); + expect(logger.error).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(9 * 60 * 1000); + freshResolveClientIp(headers, 0); + expect(logger.error).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(2 * 60 * 1000); + freshResolveClientIp(headers, 0); + expect(logger.error).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); - const result = await getClientIpFromHeaders(); + test("stays quiet when the request carries no forwarding header at all", async () => { + const { logger, freshResolveClientIp } = await loadFresh(); - expect(result).toBe(testIp); - expect(nextHeaders.headers).toHaveBeenCalled(); + expect(freshResolveClientIp(buildHeaders({}), 0)).toBe(UNTRUSTED_CLIENT_IP); + expect(logger.error).not.toHaveBeenCalled(); }); - test("getClientIpFromHeaders should handle errors when headers() throws an exception", async () => { - vi.mocked(nextHeaders.headers).mockImplementation(() => { - throw new Error("Failed to get headers"); - }); + test("stays quiet when a hop is trusted", async () => { + const { logger, freshResolveClientIp } = await loadFresh(); - const result = await getClientIpFromHeaders(); + freshResolveClientIp(buildHeaders({ "x-forwarded-for": "1.1.1.1, 203.0.113.7" }), 1); - expect(result).toBe("::1"); + expect(logger.error).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/lib/utils/client-ip.ts b/apps/web/lib/utils/client-ip.ts index 0cefef5b48e5..6bfabdbaaa18 100644 --- a/apps/web/lib/utils/client-ip.ts +++ b/apps/web/lib/utils/client-ip.ts @@ -1,5 +1,98 @@ import { headers } from "next/headers"; import { logger } from "@formbricks/logger"; +import { TRUSTED_PROXY_HOP_COUNT } from "@/lib/constants"; + +/** + * Returned when no forwarding header may be believed, so the client is genuinely unidentifiable. + * + * Next 16 exposes no socket peer address to route handlers or server actions (`NextRequest.ip` was + * removed and `headers()` carries only HTTP headers), so with no trusted proxy there is nothing else to + * fall back to. + */ +export const UNTRUSTED_CLIENT_IP = "untrusted-client-ip"; + +/** + * Throttle window for the misconfiguration warning. Time-based rather than once-per-process: a warning + * that fires only on the first request goes silent for the life of a long-running deployment, so an + * ongoing misconfiguration disappears from monitoring after one line. Raised by CodeRabbit on #8680. + */ +const UNTRUSTED_IP_WARNING_INTERVAL_MS = 10 * 60 * 1000; + +let lastUntrustedIpWarningAt: number | null = null; + +const warnAboutUntrustedIp = (): void => { + const now = Date.now(); + if ( + lastUntrustedIpWarningAt !== null && + now - lastUntrustedIpWarningAt < UNTRUSTED_IP_WARNING_INTERVAL_MS + ) { + return; + } + lastUntrustedIpWarningAt = now; + logger.error( + "TRUSTED_PROXY_HOP_COUNT is set to 0 but the request carries forwarding headers. IP-based rate " + + "limiting and IP capture cannot identify individual clients while no hop is trusted. Unset it to " + + "take the default of 1, or set it to the number of reverse proxies actually in front of this app." + ); +}; + +/** + * Resolves the client IP from forwarding headers, trusting only as many proxy hops as configured. + * + * `X-Forwarded-For` is *appended* to by each proxy, so its leftmost entry is whatever the client itself + * sent — reading that, as this used to, let a caller supply any value. Because the result keys the + * IP-based rate limits (login, forgot-password, signup, the public client API), a caller could rotate + * the header to mint a fresh bucket per request and bypass all of them, and could also forge the + * `ipAddress` recorded on responses and in audit-log entries. + * + * With `hopCount` proxies in front, the address the outermost trusted proxy observed is the + * `hopCount`-th entry from the right; everything left of it is client-supplied and ignored. + * `TRUSTED_PROXY_HOP_COUNT` defaults to 1, matching every supported topology; `0` is an explicit + * opt-out that trusts nothing and therefore cannot identify a client at all. + * + * `cf-connecting-ip` is deliberately *not* consulted. It is only trustworthy when the request provably + * came from Cloudflare's edge, and a hop count cannot establish that: `hopCount >= 1` says "one proxy is + * in front", which for most deployments is Traefik, Envoy or nginx — none of which strip + * `cf-connecting-ip`. Preferring it would therefore hand the spoof straight back to the caller. Nothing + * is lost by dropping it: Cloudflare also puts the visitor address into `X-Forwarded-For`, so a + * Cloudflare deployment is just `hopCount = 1` (or 2 with a proxy of its own behind it). + * + * Exported separately from {@link getClientIpFromHeaders} so the parsing is unit-testable without + * mocking `next/headers`. + */ +export const resolveClientIp = (headersList: Headers, hopCount: number): string => { + const xForwardedFor = headersList.get("x-forwarded-for"); + + if (hopCount <= 0) { + if (xForwardedFor || headersList.get("cf-connecting-ip") || headersList.get("x-real-ip")) { + warnAboutUntrustedIp(); + } + return UNTRUSTED_CLIENT_IP; + } + + if (xForwardedFor) { + const entries = xForwardedFor + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + + if (entries.length > 0) { + // Clamped rather than falling back to the leftmost entry: a request with fewer hops than + // configured arrived through a shorter path than expected, and the earliest entry present is the + // closest thing to what a trusted proxy saw. + const index = Math.max(0, entries.length - hopCount); + return entries[index]; + } + } + + // No `x-real-ip` fallback. It is only reachable when no `X-Forwarded-For` arrived at all, and a proxy + // that is genuinely in front appends to XFF — so the absence of XFF means the request did not come + // through the trusted hop this app is configured for, and `x-real-ip` is then whatever the caller + // chose to send. Reading it would restore exactly the per-request bucket rotation this function + // exists to stop. This is the same argument the docstring makes against `cf-connecting-ip`; applying + // it to one header and not the other was inconsistent. Raised by @pandeymangg on #8680. + return UNTRUSTED_CLIENT_IP; +}; export async function getClientIpFromHeaders(): Promise { let headersList: Headers; @@ -7,16 +100,8 @@ export async function getClientIpFromHeaders(): Promise { headersList = await headers(); } catch (e) { logger.error(e, "Failed to get headers in getClientIpFromHeaders"); - return "::1"; + return UNTRUSTED_CLIENT_IP; } - // Try common proxy headers first - const cfConnectingIp = headersList.get("cf-connecting-ip"); - if (cfConnectingIp) return cfConnectingIp; - - const xForwardedFor = headersList.get("x-forwarded-for"); - if (xForwardedFor) return xForwardedFor.split(",")[0].trim(); - - // Fallback (may be undefined or localhost in dev) - return headersList.get("x-real-ip") || "::1"; // NOSONAR - We want to fallback when the result is "" + return resolveClientIp(headersList, TRUSTED_PROXY_HOP_COUNT); } diff --git a/apps/web/lib/utils/recall.test.ts b/apps/web/lib/utils/recall.test.ts index d79eeffbd1e1..7e9cbdd21bb2 100644 --- a/apps/web/lib/utils/recall.test.ts +++ b/apps/web/lib/utils/recall.test.ts @@ -545,3 +545,110 @@ describe("recall utility functions", () => { }); }); }); + +describe("parseRecallInfo — escapeValues", () => { + const recall = (id: string) => `#recall:${id}/fallback:none#`; + + // Regression: the recalled value is a respondent's answer, i.e. data, and must never become markup. + // Sanitizing the combined string afterwards cannot help — an allowlist that legitimately permits + // `` in the survey author's body passes an anchor spliced in from an answer just the same. + test("escapes HTML in a substituted response value when asked to", () => { + const result = parseRecallInfo( + `Hi ${recall("q1")}`, + { q1: 'Action required' }, + undefined, + false, + "en-US", + undefined, + true + ); + + expect(result).not.toContain(" { + const result = parseRecallInfo( + recall("q1"), + { q1: "<script>alert(1)</script>" }, + undefined, + false, + "en-US", + undefined, + true + ); + + expect(result).toBe("&lt;script&gt;alert(1)&lt;/script&gt;"); + }); + + // Matrix and address answers arrive as records, which would coerce to "[object Object]" if handed + // straight to String(). + test("stringifies a record-shaped answer by joining its filled entries", () => { + const result = parseRecallInfo( + recall("q1"), + { q1: { "Row 1": "Yes", "Row 2": "", "Row 3": "No" } }, + undefined, + false, + "en-US", + undefined, + true + ); + + expect(result).toBe("Yes, No"); + }); + + test("escapes a record-shaped answer's entries too", () => { + const result = parseRecallInfo( + recall("q1"), + { q1: { street: "" } }, + undefined, + false, + "en-US", + undefined, + true + ); + + expect(result).toBe("<script>alert(1)</script>"); + }); + + test("leaves the author's surrounding markup untouched", () => { + const result = parseRecallInfo( + `

Thanks ${recall("q1")}

`, + { q1: "Ada" }, + undefined, + false, + "en-US", + undefined, + true + ); + + expect(result).toBe("

Thanks Ada

"); + }); + + // Default stays off: the React callers escape for themselves, so escaping here would double-escape. + test("does not escape by default", () => { + const result = parseRecallInfo(recall("q1"), { q1: "5 > 3 & rising" }); + + expect(result).toBe("5 > 3 & rising"); + }); + + // Regression: stringifyRecallValue used to run only inside the escapeValues branch, so the default + // path cast a record-shaped answer straight to string and rendered "[object Object]". Every caller + // outside the follow-up-email flow takes that default. Raised by CodeRabbit on #8681. + test("stringifies a record-shaped answer on the default (unescaped) path", () => { + const result = parseRecallInfo(recall("q1"), { q1: { "Row 1": "Yes", "Row 2": "No" } }); + + expect(result).not.toContain("[object Object]"); + expect(result).toContain("Yes"); + expect(result).toContain("No"); + }); + + test("stringifies a record-shaped answer identically whether or not values are escaped", () => { + const data = { q1: { "Row 1": "Yes" } }; + + const plain = parseRecallInfo(recall("q1"), data); + const escaped = parseRecallInfo(recall("q1"), data, undefined, false, undefined, undefined, true); + + expect(plain).toBe(escaped); + }); +}); diff --git a/apps/web/lib/utils/recall.ts b/apps/web/lib/utils/recall.ts index 2f0b1ccf2b9d..4de36c2f8e4d 100644 --- a/apps/web/lib/utils/recall.ts +++ b/apps/web/lib/utils/recall.ts @@ -220,13 +220,62 @@ export const headlineToRecall = ( return text; }; +/** The trailing `\#` a slash-wrapped recall tag ends with. */ +const RECALL_SLASH_SUFFIX = String.raw`\#`; + +/** + * A response value is `string | number | string[] | Record`. Arrays and dates are + * already normalized above, but matrix and address answers arrive as records, which would coerce to + * `[object Object]` if handed to `String()`. + */ +const stringifyRecallValue = (value: TResponseDataValue): string => { + if (typeof value === "string") return value; + if (typeof value === "number") return String(value); + if (Array.isArray(value)) return value.filter(Boolean).join(", "); + if (value) return Object.values(value).filter(Boolean).join(", "); + return ""; +}; + +const HTML_ENTITIES: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + +/** + * Encodes a string so it renders as literal text in HTML, in element content or a quoted attribute. + * + * Encoding, not sanitizing: `sanitize-html` and DOMPurify parse markup and *remove* what isn't allowed, + * which is the wrong tool here — a respondent who answers `bold` should see that text in the + * email, not have it silently dropped. Escaping is also what makes the value inert regardless of where + * the surrounding template puts it. + * + * Single pass over the five characters rather than chained `replaceAll`s, so `&` cannot be + * double-encoded if someone reorders the entries — with sequential replacements, moving the `&` rule + * after the others turns `<` into `&lt;`. + */ +const escapeHtml = (value: string): string => value.replaceAll(/[&<>"']/g, (char) => HTML_ENTITIES[char]); + +/** + * @param escapeValues HTML-escape each substituted value before splicing it in. Off by default because + * most callers render the result through React, which escapes for them — escaping here too would show + * literal `&`. Turn it on when the result goes into raw HTML, e.g. the follow-up email body. + * + * The recalled value is a respondent's answer, i.e. data, and must never become markup. Sanitizing the + * *combined* string afterwards is not a substitute: a sanitizer cannot tell the survey author's + * intended markup from markup a respondent injected, so an allowlist that legitimately permits + * `
` in an author-written body will equally pass off an anchor spliced in from an answer. + */ export const parseRecallInfo = ( text: string, responseData?: TResponseData, variables?: TResponseVariables, withSlash: boolean = false, locale: string = "en-US", - dateFormats?: TSurveyDateFormatMap + dateFormats?: TSurveyDateFormatMap, + escapeValues: boolean = false ) => { let modifiedText = text; const questionIds = responseData ? Object.keys(responseData) : []; @@ -272,11 +321,18 @@ export const parseRecallInfo = ( value = fallback; } - // Replace the recall tag with the value + // Stringify unconditionally, escape only when asked. Gating the stringify on `escapeValues` left the + // default path casting a `Record` answer (matrix, address) straight to string, which + // renders as "[object Object]" — the exact bug stringifyRecallValue exists to prevent, still live for + // every caller outside the follow-up-email flow. Raised by CodeRabbit on #8681. + const stringifiedValue = stringifyRecallValue(value); + const substitutedValue = escapeValues ? escapeHtml(stringifiedValue) : stringifiedValue; + // Replacer functions, not replacement strings: `$&`, `` $` `` and friends are special in a + // replacement string, so an answer containing them would splice part of the pattern back in. if (withSlash) { - modifiedText = modifiedText.replace(recallInfo, "#/" + value + "\\#"); + modifiedText = modifiedText.replace(recallInfo, () => `#/${substitutedValue}${RECALL_SLASH_SUFFIX}`); } else { - modifiedText = modifiedText.replace(recallInfo, value as string); + modifiedText = modifiedText.replace(recallInfo, () => substitutedValue); } } diff --git a/apps/web/lib/workspace/auth.ts b/apps/web/lib/workspace/auth.ts index 7f6a66bfd0e3..7aab4a46de2a 100644 --- a/apps/web/lib/workspace/auth.ts +++ b/apps/web/lib/workspace/auth.ts @@ -94,6 +94,31 @@ export const hasUserWorkspaceAccessForAction = async ( } }; +/** + * Authorization for the integration OAuth routes (Notion / Airtable / Slack / Google Sheets). + * + * These are credential *mutations* delivered over GET: completing the flow writes the workspace's + * third-party credentials, after which survey responses are forwarded to whichever account was + * connected. They must therefore be gated on readWrite, not on mere workspace access — + * {@link hasUserWorkspaceAccess} returns true for the `billing` role (which is otherwise excluded from + * all product data) and for a `read`-only team member, either of whom could otherwise bind their own + * account as the workspace integration and start receiving another team's responses, or overwrite the + * credentials an admin configured. + */ +export const canUserWriteWorkspaceIntegrations = async ( + userId: string, + workspaceId: string +): Promise => hasUserWorkspaceAccessForAction(userId, workspaceId, "POST"); + +/** + * Read-only counterpart for routes that only surface a connected integration's data. Unlike + * {@link hasUserWorkspaceAccess} this still excludes the `billing` role. + */ +export const canUserReadWorkspaceIntegrations = async ( + userId: string, + workspaceId: string +): Promise => hasUserWorkspaceAccessForAction(userId, workspaceId, "GET"); + export const hasUserWorkspaceAccess = async (userId: string, workspaceId: string) => { validateInputs([userId, ZId], [workspaceId, ZId]); diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index df51d56cdf5e..0fe85e639453 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -92,6 +92,8 @@ "revoke_confirmation": "Revoke this app's access to your Formbricks account?", "scopes": { "email": "View your email address", + "feedback_records_read": "Read feedback records", + "feedback_records_write": "Create feedback records", "offline_access": "Keep access with refresh tokens", "openid": "Identify you with OpenID Connect", "profile": "View your profile", @@ -2660,7 +2662,8 @@ "unify_feedback_inbox": "Vereinheitlichter Feedback-Posteingang", "unlock_the_full_power_of_formbricks_free_for_30_days": "Entfalte das volle Potenzial von Formbricks.", "white_glove_onboarding": "Premium-Onboarding", - "whitelabel_email_follow_ups": "White-Label E-Mail-Nachfassaktionen" + "whitelabel_email_follow_ups": "White-Label E-Mail-Nachfassaktionen", + "workflows": "Workflows" }, "feedback_directories": { "archive": "Archivieren", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index f4f2e9f60517..3b161e266931 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -92,6 +92,8 @@ "revoke_confirmation": "Revoke this app's access to your Formbricks account?", "scopes": { "email": "View your email address", + "feedback_records_read": "Read feedback records", + "feedback_records_write": "Create feedback records", "offline_access": "Keep access with refresh tokens", "openid": "Identify you with OpenID Connect", "profile": "View your profile", @@ -2660,7 +2662,8 @@ "unify_feedback_inbox": "Unify Feedback Inbox", "unlock_the_full_power_of_formbricks_free_for_30_days": "Unlock the full power of Formbricks.", "white_glove_onboarding": "White-glove onboarding", - "whitelabel_email_follow_ups": "Whitelabel email follow-ups" + "whitelabel_email_follow_ups": "Whitelabel email follow-ups", + "workflows": "Workflows" }, "feedback_directories": { "archive": "Archive", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index 30ffb8cdb3bc..5c67956fac7d 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -92,6 +92,8 @@ "revoke_confirmation": "Revoke this app's access to your Formbricks account?", "scopes": { "email": "View your email address", + "feedback_records_read": "Read feedback records", + "feedback_records_write": "Create feedback records", "offline_access": "Keep access with refresh tokens", "openid": "Identify you with OpenID Connect", "profile": "View your profile", @@ -2660,7 +2662,8 @@ "unify_feedback_inbox": "Bandeja de Entrada de Comentarios Unificada", "unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloquea todo el potencial de Formbricks.", "white_glove_onboarding": "Incorporación personalizada", - "whitelabel_email_follow_ups": "Seguimientos por correo de marca blanca" + "whitelabel_email_follow_ups": "Seguimientos por correo de marca blanca", + "workflows": "Workflows" }, "feedback_directories": { "archive": "Archivar", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index e8d5aade61cf..0336cdaa0c1e 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -92,6 +92,8 @@ "revoke_confirmation": "Revoke this app's access to your Formbricks account?", "scopes": { "email": "View your email address", + "feedback_records_read": "Read feedback records", + "feedback_records_write": "Create feedback records", "offline_access": "Keep access with refresh tokens", "openid": "Identify you with OpenID Connect", "profile": "View your profile", @@ -2660,7 +2662,8 @@ "unify_feedback_inbox": "Boîte de réception de feedback unifiée", "unlock_the_full_power_of_formbricks_free_for_30_days": "Débloquez tout le potentiel de Formbricks.", "white_glove_onboarding": "Accompagnement personnalisé à l'intégration", - "whitelabel_email_follow_ups": "Suivis par e-mail en marque blanche" + "whitelabel_email_follow_ups": "Suivis par e-mail en marque blanche", + "workflows": "Workflows" }, "feedback_directories": { "archive": "Archiver", diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index f34ae7e439d6..ca2c36a2ecbf 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -92,6 +92,8 @@ "revoke_confirmation": "Revoke this app's access to your Formbricks account?", "scopes": { "email": "View your email address", + "feedback_records_read": "Read feedback records", + "feedback_records_write": "Create feedback records", "offline_access": "Keep access with refresh tokens", "openid": "Identify you with OpenID Connect", "profile": "View your profile", @@ -2660,7 +2662,8 @@ "unify_feedback_inbox": "Egyesített visszajelzési postaláda", "unlock_the_full_power_of_formbricks_free_for_30_days": "Használja ki a Formbricks teljes erejét.", "white_glove_onboarding": "Prémium bevezetés", - "whitelabel_email_follow_ups": "Whitelabel e-mail követések" + "whitelabel_email_follow_ups": "Whitelabel e-mail követések", + "workflows": "Workflows" }, "feedback_directories": { "archive": "Archiválás", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index f9d720d62d4c..d39950956bd6 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -92,6 +92,8 @@ "revoke_confirmation": "Revoke this app's access to your Formbricks account?", "scopes": { "email": "View your email address", + "feedback_records_read": "Read feedback records", + "feedback_records_write": "Create feedback records", "offline_access": "Keep access with refresh tokens", "openid": "Identify you with OpenID Connect", "profile": "View your profile", @@ -2660,7 +2662,8 @@ "unify_feedback_inbox": "フィードバック受信箱の統合", "unlock_the_full_power_of_formbricks_free_for_30_days": "Formbricksの全機能を解放しましょう。", "white_glove_onboarding": "専任サポート付きオンボーディング", - "whitelabel_email_follow_ups": "ホワイトラベルメールフォローアップ" + "whitelabel_email_follow_ups": "ホワイトラベルメールフォローアップ", + "workflows": "Workflows" }, "feedback_directories": { "archive": "アーカイブ", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index 98eec844653f..9c12fd8e0dc6 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -92,6 +92,8 @@ "revoke_confirmation": "Revoke this app's access to your Formbricks account?", "scopes": { "email": "View your email address", + "feedback_records_read": "Read feedback records", + "feedback_records_write": "Create feedback records", "offline_access": "Keep access with refresh tokens", "openid": "Identify you with OpenID Connect", "profile": "View your profile", @@ -2660,7 +2662,8 @@ "unify_feedback_inbox": "Uniforme Feedback Inbox", "unlock_the_full_power_of_formbricks_free_for_30_days": "Ontgrendel de volledige kracht van Formbricks.", "white_glove_onboarding": "White-glove onboarding", - "whitelabel_email_follow_ups": "Whitelabel e-mail follow-ups" + "whitelabel_email_follow_ups": "Whitelabel e-mail follow-ups", + "workflows": "Workflows" }, "feedback_directories": { "archive": "Archiveren", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 34caff480195..0864297e313d 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -92,6 +92,8 @@ "revoke_confirmation": "Revoke this app's access to your Formbricks account?", "scopes": { "email": "View your email address", + "feedback_records_read": "Read feedback records", + "feedback_records_write": "Create feedback records", "offline_access": "Keep access with refresh tokens", "openid": "Identify you with OpenID Connect", "profile": "View your profile", @@ -2660,7 +2662,8 @@ "unify_feedback_inbox": "Caixa de Entrada de Feedback Unificada", "unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloqueie todo o poder do Formbricks.", "white_glove_onboarding": "Onboarding personalizado", - "whitelabel_email_follow_ups": "Follow-ups de e-mail em marca branca" + "whitelabel_email_follow_ups": "Follow-ups de e-mail em marca branca", + "workflows": "Workflows" }, "feedback_directories": { "archive": "Arquivar", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 50e1aede369d..feffc039f973 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -92,6 +92,8 @@ "revoke_confirmation": "Revoke this app's access to your Formbricks account?", "scopes": { "email": "View your email address", + "feedback_records_read": "Read feedback records", + "feedback_records_write": "Create feedback records", "offline_access": "Keep access with refresh tokens", "openid": "Identify you with OpenID Connect", "profile": "View your profile", @@ -2660,7 +2662,8 @@ "unify_feedback_inbox": "Caixa de Entrada de Feedback Unificada", "unlock_the_full_power_of_formbricks_free_for_30_days": "Liberta todo o poder do Formbricks.", "white_glove_onboarding": "Onboarding personalizado", - "whitelabel_email_follow_ups": "Follow-ups por e-mail sem marca" + "whitelabel_email_follow_ups": "Follow-ups por e-mail sem marca", + "workflows": "Workflows" }, "feedback_directories": { "archive": "Arquivar", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index 3b882ab23c93..699661659f9c 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -92,6 +92,8 @@ "revoke_confirmation": "Revoke this app's access to your Formbricks account?", "scopes": { "email": "View your email address", + "feedback_records_read": "Read feedback records", + "feedback_records_write": "Create feedback records", "offline_access": "Keep access with refresh tokens", "openid": "Identify you with OpenID Connect", "profile": "View your profile", @@ -2660,7 +2662,8 @@ "unify_feedback_inbox": "Inbox unificat pentru feedback", "unlock_the_full_power_of_formbricks_free_for_30_days": "Deblochează întregul potențial al Formbricks.", "white_glove_onboarding": "Onboarding personalizat", - "whitelabel_email_follow_ups": "Follow-up-uri email whitelabel" + "whitelabel_email_follow_ups": "Follow-up-uri email whitelabel", + "workflows": "Workflows" }, "feedback_directories": { "archive": "Arhivează", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index 418b3b3762e5..8ca769e1dbb1 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -92,6 +92,8 @@ "revoke_confirmation": "Revoke this app's access to your Formbricks account?", "scopes": { "email": "View your email address", + "feedback_records_read": "Read feedback records", + "feedback_records_write": "Create feedback records", "offline_access": "Keep access with refresh tokens", "openid": "Identify you with OpenID Connect", "profile": "View your profile", @@ -2660,7 +2662,8 @@ "unify_feedback_inbox": "Единый почтовый ящик обратной связи", "unlock_the_full_power_of_formbricks_free_for_30_days": "Раскройте весь потенциал Formbricks.", "white_glove_onboarding": "VIP-онбординг", - "whitelabel_email_follow_ups": "Whitelabel email-рассылки" + "whitelabel_email_follow_ups": "Whitelabel email-рассылки", + "workflows": "Workflows" }, "feedback_directories": { "archive": "Архивировать", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index c5e3a117918e..9caa707cb15c 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -92,6 +92,8 @@ "revoke_confirmation": "Revoke this app's access to your Formbricks account?", "scopes": { "email": "View your email address", + "feedback_records_read": "Read feedback records", + "feedback_records_write": "Create feedback records", "offline_access": "Keep access with refresh tokens", "openid": "Identify you with OpenID Connect", "profile": "View your profile", @@ -2660,7 +2662,8 @@ "unify_feedback_inbox": "Enhetlig feedbackinkorg", "unlock_the_full_power_of_formbricks_free_for_30_days": "Lås upp Formbricks fulla potential.", "white_glove_onboarding": "Personlig onboarding", - "whitelabel_email_follow_ups": "White label-uppföljningar via e-post" + "whitelabel_email_follow_ups": "White label-uppföljningar via e-post", + "workflows": "Workflows" }, "feedback_directories": { "archive": "Arkivera", diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json index b8db051af994..cf81add80693 100644 --- a/apps/web/locales/tr-TR.json +++ b/apps/web/locales/tr-TR.json @@ -92,6 +92,8 @@ "revoke_confirmation": "Revoke this app's access to your Formbricks account?", "scopes": { "email": "View your email address", + "feedback_records_read": "Read feedback records", + "feedback_records_write": "Create feedback records", "offline_access": "Keep access with refresh tokens", "openid": "Identify you with OpenID Connect", "profile": "View your profile", @@ -2660,7 +2662,8 @@ "unify_feedback_inbox": "Birleşik Geri Bildirim Kutusu", "unlock_the_full_power_of_formbricks_free_for_30_days": "Formbricks'in tüm gücünü ortaya çıkarın.", "white_glove_onboarding": "Kapsamlı başlangıç desteği", - "whitelabel_email_follow_ups": "Beyaz etiketli e-posta takipleri" + "whitelabel_email_follow_ups": "Beyaz etiketli e-posta takipleri", + "workflows": "Workflows" }, "feedback_directories": { "archive": "Arşivle", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index 74e2285bfb6a..65f5f4350085 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -92,6 +92,8 @@ "revoke_confirmation": "Revoke this app's access to your Formbricks account?", "scopes": { "email": "View your email address", + "feedback_records_read": "Read feedback records", + "feedback_records_write": "Create feedback records", "offline_access": "Keep access with refresh tokens", "openid": "Identify you with OpenID Connect", "profile": "View your profile", @@ -2660,7 +2662,8 @@ "unify_feedback_inbox": "统一反馈收件箱", "unlock_the_full_power_of_formbricks_free_for_30_days": "解锁 Formbricks 的全部功能。", "white_glove_onboarding": "专属引导服务", - "whitelabel_email_follow_ups": "白标电子邮件跟进" + "whitelabel_email_follow_ups": "白标电子邮件跟进", + "workflows": "Workflows" }, "feedback_directories": { "archive": "归档", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 1333a5452299..7544ec518c6e 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -92,6 +92,8 @@ "revoke_confirmation": "Revoke this app's access to your Formbricks account?", "scopes": { "email": "View your email address", + "feedback_records_read": "Read feedback records", + "feedback_records_write": "Create feedback records", "offline_access": "Keep access with refresh tokens", "openid": "Identify you with OpenID Connect", "profile": "View your profile", @@ -2660,7 +2662,8 @@ "unify_feedback_inbox": "統一回饋收件匣", "unlock_the_full_power_of_formbricks_free_for_30_days": "解鎖 Formbricks 的完整功能。", "white_glove_onboarding": "專屬引導服務", - "whitelabel_email_follow_ups": "白標電子郵件追蹤" + "whitelabel_email_follow_ups": "白標電子郵件追蹤", + "workflows": "Workflows" }, "feedback_directories": { "archive": "封存", diff --git a/apps/web/modules/api/lib/verify-response-recaptcha.ts b/apps/web/modules/api/lib/verify-response-recaptcha.ts new file mode 100644 index 000000000000..69eb3e46059e --- /dev/null +++ b/apps/web/modules/api/lib/verify-response-recaptcha.ts @@ -0,0 +1,76 @@ +import "server-only"; +import { logger } from "@formbricks/logger"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { getOrganizationBillingByWorkspaceId } from "@/app/api/v2/client/[workspaceId]/responses/lib/organization"; +import { verifyRecaptchaToken } from "@/app/api/v2/client/[workspaceId]/responses/lib/recaptcha"; +import { responses } from "@/app/lib/api/response"; +import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; +import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils"; + +export const RECAPTCHA_VERIFICATION_ERROR_CODE = "recaptcha_verification_failed"; + +/** + * Shared reCAPTCHA gate for the public response-submission endpoints. + * + * This lives in one place on purpose: the check was originally implemented only inside the v2 + * endpoint's `checkSurveyValidity`, while `POST /api/v1/client/{workspaceId}/responses` accepts + * responses for the same surveys with no reCAPTCHA check at all. Both endpoints are public and + * unauthenticated, so a caller could opt out of a survey's spam protection just by posting to the v1 + * URL. Keeping a single implementation is what stops the two versions drifting apart again. + * + * @returns an error `Response` when the request must be rejected, otherwise `null`. + */ +export const verifyResponseRecaptcha = async ({ + survey, + workspaceId, + recaptchaToken, +}: { + survey: TSurvey; + workspaceId: string; + recaptchaToken?: string | null; +}): Promise => { + if (!survey.recaptcha?.enabled) { + return null; + } + + if (!recaptchaToken) { + // warn, not error: a public endpoint receiving a request without a token is an ordinary + // caller-controlled condition, not an application fault, and erroring on it lets anyone fill the + // error log by posting an empty body. + logger.warn("Missing recaptcha token"); + return responses.badRequestResponse( + "Missing recaptcha token", + { code: RECAPTCHA_VERIFICATION_ERROR_CODE }, + true + ); + } + + const billing = await getOrganizationBillingByWorkspaceId(workspaceId); + if (!billing) { + // `cors: true` to match every other response this function returns. `notFoundResponse` defaults it + // to false, so without it this one branch of a public, browser-called endpoint would answer without + // the CORS headers and surface to the respondent as an opaque network error instead of a 404. + return responses.notFoundResponse("Organization", null, true); + } + + const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId); + const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(organizationId); + if (!isSpamProtectionEnabled) { + // Deliberately does not gate: the token is still verified below, so an organization without the + // entitlement gets the stricter behaviour, not a bypass. Logged at warn because it reports a + // licensing state an operator may want to see, not a fault. (Ported as-is from the v2 endpoint; + // making it fail-open here would silently drop reCAPTCHA for unentitled organizations.) + logger.warn("Spam protection is not enabled for this organization"); + } + + const isPassed = await verifyRecaptchaToken(recaptchaToken, survey.recaptcha.threshold); + if (!isPassed) { + return responses.badRequestResponse( + "reCAPTCHA verification failed", + { code: RECAPTCHA_VERIFICATION_ERROR_CODE }, + true + ); + } + + return null; +}; diff --git a/apps/web/modules/api/v2/management/responses/lib/response.ts b/apps/web/modules/api/v2/management/responses/lib/response.ts index 26b14f78e279..c8dbb0b51971 100644 --- a/apps/web/modules/api/v2/management/responses/lib/response.ts +++ b/apps/web/modules/api/v2/management/responses/lib/response.ts @@ -70,6 +70,28 @@ export const createResponse = async ( } = responseInput; try { + // `displayId` is caller-supplied and was connected without any ownership check. Display↔Response is + // one-to-one, so naming another workspace's display either failed outright or moved that display + // onto this response, corrupting the other tenant's display and completion counts. A display always + // belongs to one survey, so matching it against this survey is the tightest check available. + if (displayId) { + const display = await (tx ?? prisma).display.findUnique({ + where: { id: displayId }, + select: { surveyId: true }, + }); + + // Uniform not-found for "does not exist" and "exists but belongs elsewhere". Distinguishing the + // two would confirm that a display id is real, making this endpoint a cross-tenant existence + // oracle — the same reason the historical-response import raises not-found rather than forbidden + // (#8679). Matches the shape the sibling handlers already return for a foreign display. + if (display?.surveyId !== surveyId) { + return err({ + type: "not_found", + details: [{ field: "display", issue: "not found" }], + }); + } + } + let contact: { id: string; attributes: TContactAttributes } | null = null; // If userId is provided, look up the contact by userId diff --git a/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts b/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts index d14523b6aa59..c47d3e0e4eb1 100644 --- a/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts +++ b/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts @@ -32,6 +32,10 @@ vi.mock("@formbricks/database", () => ({ findMany: vi.fn(), count: vi.fn(), }, + // createResponse checks that a caller-supplied displayId belongs to the survey before connecting it. + display: { + findUnique: vi.fn(), + }, }, })); @@ -48,9 +52,58 @@ vi.mock("@/lib/constants", async () => { describe("Response Lib", () => { beforeEach(() => { vi.clearAllMocks(); + // Default: the display named by the fixtures belongs to the survey under test. + vi.mocked(prisma.display.findUnique).mockResolvedValue({ + surveyId: responseInput.surveyId, + } as never); }); describe("createResponse", () => { + // Regression: displayId was connected with no ownership check, and Display<->Response is 1:1, so + // naming another workspace's display moved it onto this response and corrupted that tenant's counts. + test("reject a displayId that belongs to a different survey", async () => { + vi.mocked(prisma.display.findUnique).mockResolvedValue({ + surveyId: "someothersurveyid00000000", + } as never); + + const result = await createResponse(workspaceId, responseInput); + + expect(result.ok).toBe(false); + expect(prisma.response.create).not.toHaveBeenCalled(); + }); + + test("reject a displayId that does not exist", async () => { + vi.mocked(prisma.display.findUnique).mockResolvedValue(null as never); + + const result = await createResponse(workspaceId, responseInput); + + expect(result.ok).toBe(false); + expect(prisma.response.create).not.toHaveBeenCalled(); + }); + + // The anti-enumeration property, not just the rejection: a foreign display and a nonexistent one + // must be indistinguishable, or the endpoint confirms which display ids are real. Asserted as one + // test comparing the two errors so a future edit cannot make only one of them more specific. + test("report a foreign and a nonexistent displayId identically", async () => { + vi.mocked(prisma.display.findUnique).mockResolvedValue({ + surveyId: "someothersurveyid00000000", + } as never); + const foreign = await createResponse(workspaceId, responseInput); + + vi.mocked(prisma.display.findUnique).mockResolvedValue(null as never); + const missing = await createResponse(workspaceId, responseInput); + + expect(foreign.ok).toBe(false); + expect(missing.ok).toBe(false); + if (!foreign.ok && !missing.ok) { + expect(foreign.error).toEqual({ + type: "not_found", + details: [{ field: "display", issue: "not found" }], + }); + expect(missing.error).toEqual(foreign.error); + } + }); + test("create a response successfully", async () => { vi.mocked(prisma.response.create).mockResolvedValue(response); diff --git a/apps/web/modules/auth/actions.ts b/apps/web/modules/auth/actions.ts index 9a47af6576df..7a0f9263335b 100644 --- a/apps/web/modules/auth/actions.ts +++ b/apps/web/modules/auth/actions.ts @@ -6,6 +6,8 @@ import { ZUserEmail } from "@formbricks/types/user"; import { createEmailToken } from "@/lib/jwt"; import { getUserByEmail } from "@/lib/user/service"; import { actionClient } from "@/lib/utils/action-client"; +import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers"; +import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; const ZCreateEmailTokenAction = z.object({ email: ZUserEmail, @@ -14,6 +16,10 @@ const ZCreateEmailTokenAction = z.object({ export const createEmailTokenAction = actionClient .inputSchema(ZCreateEmailTokenAction) .action(async ({ parsedInput }) => { + // Unauthenticated: it answers "is this email registered?" for any address the caller names, so it + // needs the same throttling as the other auth endpoints that expose that signal. + await applyIPRateLimit(rateLimitConfigs.auth.emailToken); + const normalizedEmail = parsedInput.email.toLowerCase(); const user = await getUserByEmail(normalizedEmail); if (!user) { diff --git a/apps/web/modules/auth/lib/auth.ts b/apps/web/modules/auth/lib/auth.ts index a7fc7fdcd643..fd4db268363a 100644 --- a/apps/web/modules/auth/lib/auth.ts +++ b/apps/web/modules/auth/lib/auth.ts @@ -10,6 +10,7 @@ import { prisma } from "@formbricks/database"; import { logger } from "@formbricks/logger"; import type { TUserLocale } from "@formbricks/types/user"; import { + EMAIL_AUTH_ENABLED, EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_TOKEN_LIFETIME_MINUTES, RATE_LIMITING_DISABLED, @@ -38,7 +39,13 @@ const DAY_IN_SECONDS = 60 * 60 * 24; // `__Secure-`/Secure cookies require HTTPS — on http://localhost the browser drops them and the // session can't persist. Gate on the configured URL scheme (parity with NextAuth's URL-based // useSecureCookies default) instead of hardcoding true, so local/dev over http works. -const USE_SECURE_COOKIES = (env.BETTER_AUTH_URL ?? env.NEXTAUTH_URL ?? "").startsWith("https://"); +// +// WEBAPP_URL is part of the chain because all three vars are optional: a deployment that sets only +// WEBAPP_URL=https://… — the primary documented variable — would otherwise fall through to "" and +// serve the session cookie without `Secure`, letting a downgrade to plaintext HTTP leak it. +const USE_SECURE_COOKIES = (env.BETTER_AUTH_URL ?? env.NEXTAUTH_URL ?? env.WEBAPP_URL ?? "").startsWith( + "https://" +); /** Resolve a user's locale for transactional emails (Better Auth's callback user omits it). */ export const getUserLocale = async (userId: string): Promise => { @@ -90,7 +97,12 @@ export const auth = betterAuth({ socialProviders: ssoSocialProviders, emailAndPassword: { - enabled: true, + // EMAIL_AUTH_DISABLED=1 has to switch the credential endpoints off here, not just hide the form on + // the login/signup pages (its only other use). Hardcoding `true` left + // POST /api/auth/sign-in/email live on an instance the operator had configured as SSO-only, so any + // account that still carried a password could sign in around the IdP — and around whatever the IdP + // enforces, such as MFA, conditional access, or deprovisioning. + enabled: EMAIL_AUTH_ENABLED, // Matches ZUserPassword (min 8 / max 128); the upper+digit composition rule stays enforced // by ZUserPassword at the app layer (deferred policy modernization → design doc §10.6). minPasswordLength: 8, diff --git a/apps/web/modules/auth/lib/mcp-oauth-provider-options.ts b/apps/web/modules/auth/lib/mcp-oauth-provider-options.ts index 8b22f229c7da..40a678459e94 100644 --- a/apps/web/modules/auth/lib/mcp-oauth-provider-options.ts +++ b/apps/web/modules/auth/lib/mcp-oauth-provider-options.ts @@ -24,18 +24,14 @@ export const getMcpOauthProviderOptions = (): TOauthProviderOptions => ({ // write tools are reachable (clients derive their DCR/authorize scopes from what we advertise, and // the plugin validates authorize against the client's registered scopes). Granting the write scope // is safe: actual write access is still enforced downstream by the user's workspace permissions. - clientRegistrationDefaultScopes: [ - "openid", - "profile", - "email", - "offline_access", - "surveys:read", - "surveys:write", - ], + // Derived from the shared list rather than repeated: a scope added there must reach default client + // registration too, or clients would be told about a scope they can't register for. + clientRegistrationDefaultScopes: [...MCP_OAUTH_SCOPES], accessTokenExpiresIn: 15 * 60, refreshTokenExpiresIn: 30 * 24 * 60 * 60, scopeExpirations: { "surveys:write": "15m", + "feedbackRecords:write": "15m", }, // Store opaque access-token and refresh-token lookup values as hashes. JWT access tokens are // stateless and bounded by the short 15-minute lifetime above. diff --git a/apps/web/modules/auth/lib/oauth-client-metadata.test.ts b/apps/web/modules/auth/lib/oauth-client-metadata.test.ts index 34d7d6c6103d..7b0e788d2d69 100644 --- a/apps/web/modules/auth/lib/oauth-client-metadata.test.ts +++ b/apps/web/modules/auth/lib/oauth-client-metadata.test.ts @@ -32,6 +32,12 @@ describe("OAuth client metadata helpers", () => { expect(getOAuthScopeLabel("offline_access", t)).toBe("translated:auth.oauth.scopes.offline_access"); expect(getOAuthScopeLabel("surveys:read", t)).toBe("translated:auth.oauth.scopes.surveys_read"); expect(getOAuthScopeLabel("surveys:write", t)).toBe("translated:auth.oauth.scopes.surveys_write"); + expect(getOAuthScopeLabel("feedbackRecords:read", t)).toBe( + "translated:auth.oauth.scopes.feedback_records_read" + ); + expect(getOAuthScopeLabel("feedbackRecords:write", t)).toBe( + "translated:auth.oauth.scopes.feedback_records_write" + ); }); test("keeps unknown OAuth scopes readable", () => { diff --git a/apps/web/modules/auth/lib/oauth-client-metadata.ts b/apps/web/modules/auth/lib/oauth-client-metadata.ts index 7c797a6c0ec2..f33b7815fd3b 100644 --- a/apps/web/modules/auth/lib/oauth-client-metadata.ts +++ b/apps/web/modules/auth/lib/oauth-client-metadata.ts @@ -44,6 +44,10 @@ export const getOAuthScopeLabel = (scope: string, t: (key: string) => string): s return t("auth.oauth.scopes.surveys_read"); case "surveys:write": return t("auth.oauth.scopes.surveys_write"); + case "feedbackRecords:read": + return t("auth.oauth.scopes.feedback_records_read"); + case "feedbackRecords:write": + return t("auth.oauth.scopes.feedback_records_write"); default: return scope; } diff --git a/apps/web/modules/auth/lib/oauth-urls.ts b/apps/web/modules/auth/lib/oauth-urls.ts index 6972f98e108a..d35d11ef1f7e 100644 --- a/apps/web/modules/auth/lib/oauth-urls.ts +++ b/apps/web/modules/auth/lib/oauth-urls.ts @@ -47,9 +47,16 @@ export const MCP_OAUTH_SCOPES = [ "offline_access", "surveys:read", "surveys:write", + "feedbackRecords:read", + "feedbackRecords:write", ] as const; -export const MCP_RESOURCE_SCOPES = ["surveys:read", "surveys:write"] as const; +export const MCP_RESOURCE_SCOPES = [ + "surveys:read", + "surveys:write", + "feedbackRecords:read", + "feedbackRecords:write", +] as const; // Scopes advertised in the RFC 9728 protected-resource metadata. MCP clients derive their // Dynamic Client Registration + authorize scopes from this list (NOT from the AS metadata), diff --git a/apps/web/modules/auth/lib/session.test.ts b/apps/web/modules/auth/lib/session.test.ts index 961a345f222b..4af0c03889b0 100644 --- a/apps/web/modules/auth/lib/session.test.ts +++ b/apps/web/modules/auth/lib/session.test.ts @@ -1,12 +1,19 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; // Mock the Better Auth instance so the test never loads the real auth.ts graph (Redis/prisma/etc.). -const { getSessionMock } = vi.hoisted(() => ({ getSessionMock: vi.fn() })); +const { getSessionMock, findUniqueMock } = vi.hoisted(() => ({ + getSessionMock: vi.fn(), + findUniqueMock: vi.fn(), +})); vi.mock("@/modules/auth/lib/auth", () => ({ auth: { api: { getSession: getSessionMock } }, })); +vi.mock("@formbricks/database", () => ({ + prisma: { user: { findUnique: findUniqueMock } }, +})); + // `next/headers` and `server-only` are stubbed globally in vitestSetup; the mocked // `auth.api.getSession` ignores the headers arg, so the stub's value is irrelevant here. @@ -17,6 +24,8 @@ describe("getSession — Better Auth DAL (ENG-1054)", () => { beforeEach(() => { vi.resetModules(); getSessionMock.mockReset(); + findUniqueMock.mockReset(); + findUniqueMock.mockResolvedValue({ isActive: true }); }); test("returns null when Better Auth has no session", async () => { @@ -51,4 +60,33 @@ describe("getSession — Better Auth DAL (ENG-1054)", () => { const getSession = await importGetSession(); await expect(getSession()).resolves.toMatchObject({ expires: "2026-07-01T00:00:00.000Z" }); }); + + // Regression: `rejectInactiveUserOnSessionCreate` only gates session creation, and Better Auth + // serves the user from Redis + a 5-minute cookie cache — so without a live read a user deactivated + // after signing in kept full access for the life of a rolling 1-day session. + test("returns null when the user has been deactivated since signing in", async () => { + getSessionMock.mockResolvedValue({ + session: { expiresAt: new Date("2026-07-01T00:00:00.000Z") }, + user: { id: "user_abc123", email: "user@example.com", name: "Ada Lovelace" }, + }); + findUniqueMock.mockResolvedValue({ isActive: false }); + + const getSession = await importGetSession(); + await expect(getSession()).resolves.toBeNull(); + expect(findUniqueMock).toHaveBeenCalledWith({ + where: { id: "user_abc123" }, + select: { isActive: true }, + }); + }); + + test("returns null when the session's user row no longer exists", async () => { + getSessionMock.mockResolvedValue({ + session: { expiresAt: new Date("2026-07-01T00:00:00.000Z") }, + user: { id: "deleted_user", email: "gone@example.com", name: "Gone" }, + }); + findUniqueMock.mockResolvedValue(null); + + const getSession = await importGetSession(); + await expect(getSession()).resolves.toBeNull(); + }); }); diff --git a/apps/web/modules/auth/lib/session.ts b/apps/web/modules/auth/lib/session.ts index 3996619b3989..d00e67b86877 100644 --- a/apps/web/modules/auth/lib/session.ts +++ b/apps/web/modules/auth/lib/session.ts @@ -1,6 +1,7 @@ import "server-only"; import { headers } from "next/headers"; import { cache } from "react"; +import { prisma } from "@formbricks/database"; import type { Session } from "@formbricks/types/auth"; import { auth } from "@/modules/auth/lib/auth"; @@ -19,12 +20,35 @@ import { auth } from "@/modules/auth/lib/auth"; * resolves to `null`: Better Auth deletes the credential session and issues a short-lived * two-factor cookie until the code is verified, so no authenticated session exists yet. */ +/** + * Deactivation must take effect on the *existing* session, not just the next sign-in. + * + * `rejectInactiveUserOnSessionCreate` only gates session *creation*, and Better Auth serves this + * session from Redis secondary storage and a 5-minute signed cookie cache, so neither the session + * record nor `result.user` reflects a user who was deactivated after signing in. Without this read a + * deactivated user keeps full access — server actions, v3 session routes, storage — for the life of a + * rolling 1-day session. `getProxySession` already performs the equivalent check for the middleware. + */ +const isUserActive = async (userId: string): Promise => { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { isActive: true }, + }); + + // A session whose user row is gone is not a session either. + return user?.isActive === true; +}; + export const getSession = cache(async (): Promise => { const result = await auth.api.getSession({ headers: await headers() }); if (!result?.user) { return null; } + if (!(await isUserActive(result.user.id))) { + return null; + } + return { user: { id: result.user.id, diff --git a/apps/web/modules/auth/signup/actions.test.ts b/apps/web/modules/auth/signup/actions.test.ts index 126592fc7cd7..9232eef8f2d6 100644 --- a/apps/web/modules/auth/signup/actions.test.ts +++ b/apps/web/modules/auth/signup/actions.test.ts @@ -1,6 +1,12 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { SIGNUP_EMAIL_DOMAIN_BLOCKED_ERROR_CODE } from "@formbricks/types/errors"; +import { + INVITE_TOKEN_INVALID_ERROR_CODE, + SIGNUP_DISABLED_ERROR_CODE, + SIGNUP_EMAIL_DOMAIN_BLOCKED_ERROR_CODE, +} from "@formbricks/types/errors"; +import { getIsFreshInstance } from "@/lib/instance/service"; import { verifyInviteToken } from "@/lib/jwt"; +import { createMembership } from "@/lib/membership/service"; import { getUserByEmail } from "@/lib/user/service"; import { auth } from "@/modules/auth/lib/auth"; import { getInvite, resolveInviteMatch } from "@/modules/auth/signup/lib/invite"; @@ -12,6 +18,7 @@ import { createUserAction } from "./actions"; vi.mock("@formbricks/logger", () => ({ logger: { error: vi.fn(), + warn: vi.fn(), }, })); @@ -41,6 +48,7 @@ vi.mock("@/lib/posthog", () => ({ capturePostHogEvent: vi.fn(), groupIdentifyPostHog: vi.fn(), identifyPostHogPerson: vi.fn(), + getEmailDomain: (email: string) => email.split("@")[1]?.toLowerCase() || undefined, })); vi.mock("@/modules/ee/billing/lib/organization-billing", () => ({ ensureCloudStripeSetupForOrganization: vi.fn(), @@ -55,6 +63,7 @@ vi.mock("@/modules/workspaces/settings/lib/workspace", () => ({ createWorkspace: const constantsOverrides = vi.hoisted(() => ({ IS_FORMBRICKS_CLOUD: false, SIGNUP_DOMAIN_CHECK_ON_INVITES: false, + SIGNUP_ENABLED: true, })); vi.mock("@/lib/constants", () => ({ @@ -67,8 +76,13 @@ vi.mock("@/lib/constants", () => ({ get SIGNUP_DOMAIN_CHECK_ON_INVITES() { return constantsOverrides.SIGNUP_DOMAIN_CHECK_ON_INVITES; }, + get SIGNUP_ENABLED() { + return constantsOverrides.SIGNUP_ENABLED; + }, })); +vi.mock("@/lib/instance/service", () => ({ getIsFreshInstance: vi.fn() })); + vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({ withAuditLogging: vi.fn((_type: string, _object: string, fn: Function) => fn), })); @@ -89,7 +103,9 @@ describe("createUserAction — signup verification email callbackURL", () => { notificationSettings: { alert: {} }, }; - const baseInput = { name: "Ada", email: "Ada@Example.com", password: "Password123!" }; + // Mirrors what signup-form.tsx actually posts: it always sends the field, as `inviteToken ?? ""`, + // so an uninvited sign-up arrives with an empty string rather than undefined. + const baseInput = { name: "Ada", email: "Ada@Example.com", password: "Password123!", inviteToken: "" }; const newCtx = () => ({ auditLoggingCtx: { organizationId: "", userId: "" } }); @@ -97,6 +113,8 @@ describe("createUserAction — signup verification email callbackURL", () => { vi.resetAllMocks(); constantsOverrides.IS_FORMBRICKS_CLOUD = false; constantsOverrides.SIGNUP_DOMAIN_CHECK_ON_INVITES = false; + constantsOverrides.SIGNUP_ENABLED = true; + vi.mocked(getIsFreshInstance).mockResolvedValue(true); vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true } as never); vi.mocked(getUserByEmail).mockResolvedValue(createdUser as never); vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false); @@ -115,6 +133,7 @@ describe("createUserAction — signup verification email callbackURL", () => { }); test("does not point the verification callback at /invite for invite signups (ENG-1527)", async () => { + vi.mocked(resolveInviteMatch).mockResolvedValue("valid"); vi.mocked(verifyInviteToken).mockReturnValue({ inviteId: "invite-1", email: "ada@example.com" } as never); vi.mocked(getInvite).mockResolvedValue({ id: "invite-1", @@ -141,6 +160,99 @@ describe("createUserAction — signup verification email callbackURL", () => { }); }); + // Regression: signup-form.tsx sends `inviteToken: inviteToken ?? ""`, so an uninvited sign-up arrives + // with an empty string. Guarding the "unusable invite" check on `!== undefined` rather than + // truthiness rejected every public sign-up with INVITE_TOKEN_INVALID_ERROR_CODE. + test.each(["", " ", undefined])( + "treats %p as no invite and completes an ordinary sign-up", + async (inviteToken) => { + vi.mocked(resolveInviteMatch).mockResolvedValue("missing"); + + const result = await createUserAction({ + ctx: newCtx(), + parsedInput: { ...baseInput, inviteToken }, + } as never); + + expect(result).toEqual({ success: true }); + expect(auth.api.signUpEmail).toHaveBeenCalled(); + // No invite means no membership grant — the org-creation path handles it instead. + expect(createMembership).not.toHaveBeenCalled(); + } + ); + + // Regression: an invite binds a role in an organization to one address. Accepting it only on a valid + // signature meant a leaked or forwarded invite link could be redeemed with any address. + test.each(["email_mismatch", "invalid_or_expired", "verification_error", "missing"] as const)( + "refuses to grant membership when the invite resolves as %s", + async (inviteMatch) => { + vi.mocked(resolveInviteMatch).mockResolvedValue(inviteMatch); + vi.mocked(verifyInviteToken).mockReturnValue({ + inviteId: "invite-1", + email: "someone-else@example.com", + } as never); + + await expect( + createUserAction({ + ctx: newCtx(), + parsedInput: { ...baseInput, inviteToken: "invite-jwt-123" }, + } as never) + // The stable code, not just "some error": the sign-up form maps this to localized copy, so a + // regression to a raw message would break the UI while still passing a bare toThrow(). + ).rejects.toThrow(INVITE_TOKEN_INVALID_ERROR_CODE); + + expect(createMembership).not.toHaveBeenCalled(); + } + ); + + // Regression: signup/page.tsx requires a valid invite once public sign-up is closed, but the action + // itself enforced nothing — so anyone could POST it and create an account on a closed instance. + describe("closed instance policy", () => { + beforeEach(() => { + constantsOverrides.SIGNUP_ENABLED = false; + vi.mocked(getIsFreshInstance).mockResolvedValue(false); + vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false); + }); + + test("rejects an uninvited signup and creates no user", async () => { + await expect(createUserAction({ ctx: newCtx(), parsedInput: baseInput } as never)).rejects.toThrow( + SIGNUP_DISABLED_ERROR_CODE + ); + expect(auth.api.signUpEmail).not.toHaveBeenCalled(); + }); + + test("still allows the first administrator during fresh-instance setup", async () => { + vi.mocked(getIsFreshInstance).mockResolvedValue(true); + + const result = await createUserAction({ ctx: newCtx(), parsedInput: baseInput } as never); + + expect(result).toEqual({ success: true }); + expect(auth.api.signUpEmail).toHaveBeenCalled(); + }); + + test("still allows a signup backed by a valid matching invite", async () => { + vi.mocked(resolveInviteMatch).mockResolvedValue("valid"); + vi.mocked(verifyInviteToken).mockReturnValue({ + inviteId: "invite-1", + email: "ada@example.com", + } as never); + vi.mocked(getInvite).mockResolvedValue({ + id: "invite-1", + organizationId: "org-1", + role: "member", + teamIds: null, + creator: { name: "Owner", email: "owner@example.com", locale: "en-US" }, + } as never); + + const result = await createUserAction({ + ctx: newCtx(), + parsedInput: { ...baseInput, inviteToken: "invite-jwt-123" }, + } as never); + + expect(result).toEqual({ success: true }); + expect(createMembership).toHaveBeenCalled(); + }); + }); + test("treats a duplicate email as already-existed without post-creation side effects", async () => { vi.mocked(auth.api.signUpEmail).mockRejectedValue(new Error("user already exists")); vi.mocked(getUserByEmail).mockResolvedValue(createdUser as never); @@ -161,12 +273,19 @@ describe("createUserAction — personal email domain block (Cloud)", () => { locale: "en-US", notificationSettings: { alert: {} }, }; - const blockedInput = { name: "Spammer", email: "spammer@gmail.com", password: "Password123!" }; + const blockedInput = { + name: "Spammer", + email: "spammer@gmail.com", + password: "Password123!", + inviteToken: "", + }; const newCtx = () => ({ auditLoggingCtx: { organizationId: "", userId: "" } }); beforeEach(() => { vi.resetAllMocks(); - constantsOverrides.IS_FORMBRICKS_CLOUD = true; // this suite runs as Formbricks Cloud + constantsOverrides.IS_FORMBRICKS_CLOUD = true; + constantsOverrides.SIGNUP_ENABLED = true; + vi.mocked(getIsFreshInstance).mockResolvedValue(true); constantsOverrides.SIGNUP_DOMAIN_CHECK_ON_INVITES = false; vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true } as never); vi.mocked(getUserByEmail).mockResolvedValue(createdUser as never); @@ -211,18 +330,21 @@ describe("createUserAction — personal email domain block (Cloud)", () => { test("blocks when the invite email does not match the signup email", async () => { vi.mocked(resolveInviteMatch).mockResolvedValue("email_mismatch"); + // A mismatched invite is now rejected outright rather than merely losing its domain exemption, so + // no user row is created for it either. await expect( createUserAction({ ctx: newCtx(), parsedInput: { ...blockedInput, inviteToken: "invite-jwt-123" }, } as never) - ).rejects.toThrow(SIGNUP_EMAIL_DOMAIN_BLOCKED_ERROR_CODE); + ).rejects.toThrow(INVITE_TOKEN_INVALID_ERROR_CODE); expect(auth.api.signUpEmail).not.toHaveBeenCalled(); }); test("blocks a personal-domain invite when the kill-switch is enabled", async () => { constantsOverrides.SIGNUP_DOMAIN_CHECK_ON_INVITES = true; - // Kill-switch on: the invite exemption isn't consulted at all, so resolveInviteMatch is irrelevant. + // The invite itself is usable; the kill-switch is what removes its domain exemption. + vi.mocked(resolveInviteMatch).mockResolvedValue("valid"); await expect( createUserAction({ diff --git a/apps/web/modules/auth/signup/actions.ts b/apps/web/modules/auth/signup/actions.ts index b3f83ad03736..ca3681d03b29 100644 --- a/apps/web/modules/auth/signup/actions.ts +++ b/apps/web/modules/auth/signup/actions.ts @@ -4,17 +4,30 @@ import { cookies } from "next/headers"; import { z } from "zod"; import { logger } from "@formbricks/logger"; import { + INVITE_TOKEN_INVALID_ERROR_CODE, InvalidInputError, PASSWORD_COMPROMISED_ERROR_CODE, + SIGNUP_DISABLED_ERROR_CODE, SIGNUP_EMAIL_DOMAIN_BLOCKED_ERROR_CODE, UnknownError, } from "@formbricks/types/errors"; import { ZUser, ZUserEmail, ZUserLocale, ZUserName, ZUserPassword } from "@formbricks/types/user"; -import { IS_FORMBRICKS_CLOUD, IS_TURNSTILE_CONFIGURED, TURNSTILE_SECRET_KEY } from "@/lib/constants"; +import { + IS_FORMBRICKS_CLOUD, + IS_TURNSTILE_CONFIGURED, + SIGNUP_ENABLED, + TURNSTILE_SECRET_KEY, +} from "@/lib/constants"; +import { getIsFreshInstance } from "@/lib/instance/service"; import { verifyInviteToken } from "@/lib/jwt"; import { createMembership } from "@/lib/membership/service"; import { createOrganization, getOrganization } from "@/lib/organization/service"; -import { capturePostHogEvent, groupIdentifyPostHog, identifyPostHogPerson } from "@/lib/posthog"; +import { + capturePostHogEvent, + getEmailDomain, + groupIdentifyPostHog, + identifyPostHogPerson, +} from "@/lib/posthog"; import { getUserByEmail } from "@/lib/user/service"; import { actionClient } from "@/lib/utils/action-client"; import { ActionClientCtx } from "@/lib/utils/action-client/types/context"; @@ -28,7 +41,12 @@ import { runWithSignupRequestContext, } from "@/modules/auth/lib/signup-request-context"; import { updateUser } from "@/modules/auth/lib/user"; -import { deleteInvite, getInvite, resolveInviteMatch } from "@/modules/auth/signup/lib/invite"; +import { + type InviteMatch, + deleteInvite, + getInvite, + resolveInviteMatch, +} from "@/modules/auth/signup/lib/invite"; import { createTeamMembership } from "@/modules/auth/signup/lib/team"; import { verifyTurnstileToken } from "@/modules/auth/signup/lib/utils"; import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers"; @@ -131,6 +149,17 @@ async function handleInviteAcceptance( inviteToken: string, user: TCreatedUser ): Promise { + // An invite grants a specific address a specific role in a specific organization. Verifying the + // signature and loading the row is not enough: without matching the token's email against the account + // being created, anyone holding an invite link — they get forwarded, pasted into tickets, and land in + // referrer logs — could redeem it with an arbitrary address and take the invited role, which may be + // manager or owner. `resolveInviteMatch` also enforces the invite's expiry, which this path skipped. + const inviteMatch = await resolveInviteMatch(inviteToken, user.email); + if (inviteMatch !== "valid") { + logger.warn({ inviteMatch }, "Rejected invite acceptance during sign-up"); + throw new InvalidInputError(INVITE_TOKEN_INVALID_ERROR_CODE); + } + const inviteTokenData = verifyInviteToken(inviteToken); const invite = await getInvite(inviteTokenData.inviteId); @@ -147,7 +176,10 @@ async function handleInviteAcceptance( try { const invitedOrganization = await getOrganization(invite.organizationId); if (invitedOrganization) { - groupIdentifyPostHog("organization", invitedOrganization.id, { name: invitedOrganization.name }); + groupIdentifyPostHog("organization", invitedOrganization.id, { + name: invitedOrganization.name, + email_domain: getEmailDomain(invite.creator.email), + }); } } catch (error) { logger.warn({ error, organizationId: invite.organizationId }, "Failed to identify org group in PostHog"); @@ -207,7 +239,10 @@ async function handleOrganizationCreation(ctx: ActionClientCtx, user: TCreatedUs name: DEFAULT_WORKSPACE_NAME, }); - groupIdentifyPostHog("organization", organization.id, { name: organization.name }); + groupIdentifyPostHog("organization", organization.id, { + name: organization.name, + email_domain: getEmailDomain(user.email), + }); groupIdentifyPostHog("workspace", workspace.id, { name: workspace.name }); capturePostHogEvent( @@ -258,19 +293,51 @@ async function handlePostUserCreation( // requireEmailVerification config and the callbackURL chosen in signUpUserSafely. } +/** + * The two sign-up gates that used to live only in `signup/page.tsx`, enforced here so a direct POST to + * this action cannot walk around them. Extracted from `createUserAction` to keep that function within + * the cognitive-complexity budget. + */ +async function assertSignupPolicyAllows( + inviteToken: string | undefined, + inviteMatch: InviteMatch +): Promise { + // A supplied-but-unusable invite is rejected before the user row is created, so a bad token can't + // leave an orphaned account behind (handleInviteAcceptance would otherwise throw after signup). + if (inviteToken && inviteMatch !== "valid") { + logger.warn({ inviteMatch }, "Rejected sign-up with an unusable invite token"); + throw new InvalidInputError(INVITE_TOKEN_INVALID_ERROR_CODE); + } + + const isPublicSignupOpen = SIGNUP_ENABLED && (await getIsMultiOrgEnabled()); + if (isPublicSignupOpen || inviteMatch === "valid") { + return; + } + + // Closed instance and no invite: the only remaining legitimate case is the initial administrator + // during fresh-instance setup, who has no invite to present. + if (!(await getIsFreshInstance())) { + throw new InvalidInputError(SIGNUP_DISABLED_ERROR_CODE); + } +} + export const createUserAction = actionClient.inputSchema(ZCreateUserAction).action( withAuditLogging("created", "user", async ({ ctx, parsedInput }) => { await applyIPRateLimit(rateLimitConfigs.auth.signup); await verifyTurnstileIfConfigured(parsedInput.turnstileToken); + // Normalized once, up front, and used for every downstream decision. The sign-up form always sends + // this field as `inviteToken ?? ""`, so an ordinary uninvited sign-up arrives with an empty string — + // anything blank has to mean "no invite" everywhere, or the checks below disagree with + // `handlePostUserCreation` about which path the request is on. + const inviteToken = parsedInput.inviteToken?.trim() || undefined; + const inviteMatch = await resolveInviteMatch(inviteToken, parsedInput.email); + + await assertSignupPolicyAllows(inviteToken, inviteMatch); + // Formbricks Cloud only: reject personal/free/disposable email domains before any user is created. // Invited users are exempt unless SIGNUP_DOMAIN_CHECK_ON_INVITES is enabled. - if ( - await isSignupEmailDomainBlocked( - parsedInput.email, - async () => (await resolveInviteMatch(parsedInput.inviteToken, parsedInput.email)) === "valid" - ) - ) { + if (await isSignupEmailDomainBlocked(parsedInput.email, async () => inviteMatch === "valid")) { throw new InvalidInputError(SIGNUP_EMAIL_DOMAIN_BLOCKED_ERROR_CODE); } @@ -288,7 +355,7 @@ export const createUserAction = actionClient.inputSchema(ZCreateUserAction).acti }); if (!userAlreadyExisted && user) { - await handlePostUserCreation(ctx, user, parsedInput.inviteToken); + await handlePostUserCreation(ctx, user, inviteToken); await subscribeUserToMailingList({ email: user.email, @@ -301,7 +368,11 @@ export const createUserAction = actionClient.inputSchema(ZCreateUserAction).acti const hasAttributionCookie = cookieStore.get(ATTRIBUTION_COOKIE_NAME) !== undefined; const attributionProperties = getAttributionPropertiesFromCookies(cookieStore); - identifyPostHogPerson(user.id, { email: user.email, name: user.name }); + identifyPostHogPerson(user.id, { + email: user.email, + name: user.name, + email_domain: getEmailDomain(user.email), + }); capturePostHogEvent( user.id, "user_signed_up", @@ -309,8 +380,8 @@ export const createUserAction = actionClient.inputSchema(ZCreateUserAction).acti // Spread attribution first so trusted, server-computed props always win on a name clash. ...attributionProperties, auth_provider: "credentials", - email_domain: user.email.split("@")[1], - signup_source: parsedInput.inviteToken ? "invite" : "direct", + email_domain: getEmailDomain(user.email), + signup_source: inviteToken ? "invite" : "direct", invite_organization_id: ctx.auditLoggingCtx.organizationId ?? null, }, ctx.auditLoggingCtx.organizationId diff --git a/apps/web/modules/auth/signup/components/signup-form.tsx b/apps/web/modules/auth/signup/components/signup-form.tsx index 795c7ac1317a..1065e1e6ed9c 100644 --- a/apps/web/modules/auth/signup/components/signup-form.tsx +++ b/apps/web/modules/auth/signup/components/signup-form.tsx @@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next"; import Turnstile, { useTurnstile } from "react-turnstile"; import { z } from "zod"; import { + INVITE_TOKEN_INVALID_ERROR_CODE, PASSWORD_COMPROMISED_ERROR_CODE, SIGNUP_EMAIL_DOMAIN_BLOCKED_ERROR_CODE, } from "@formbricks/types/errors"; @@ -158,7 +159,20 @@ export const SignupForm = ({ } else if (errorMessage === PASSWORD_COMPROMISED_ERROR_CODE) { // Breached password: surface under the password field with a clear, actionable message. form.setError("password", { type: "manual", message: t("auth.password_compromised") }); + } else if (errorMessage === INVITE_TOKEN_INVALID_ERROR_CODE) { + // Reachable when the invite expires or is revoked between this page rendering and the form + // being submitted. Reuses the existing invite copy rather than naming the specific reason, + // matching the server, which returns one code for expired / revoked / wrong-address so it + // cannot be used to probe which invites exist. + toast.error(t("auth.invite.invite_not_found_description")); } else { + // SIGNUP_DISABLED_ERROR_CODE lands here. CodeRabbit is right that a real user can see it — + // sign-up can be open when this page renders and closed before submit, the same + // render-then-revoke race that makes the invite branch above user-facing — so it should be + // translated rather than shown as a raw code. Deferred, not declined: the fix needs a new + // en-US string plus a Lingo run to populate the 14 target locales, and adding the key without + // that run fails `scan-translations` (incomplete translations + lockfile out of sync). Doing + // it here would redden the translation gate on a release-critical PR; tracked for follow-up. toast.error(errorMessage); } return; diff --git a/apps/web/modules/core/rate-limit/rate-limit-configs.test.ts b/apps/web/modules/core/rate-limit/rate-limit-configs.test.ts index c7e7f92de7aa..0ca958f92575 100644 --- a/apps/web/modules/core/rate-limit/rate-limit-configs.test.ts +++ b/apps/web/modules/core/rate-limit/rate-limit-configs.test.ts @@ -64,7 +64,14 @@ describe("rateLimitConfigs", () => { test("should have all auth configurations", () => { const authConfigs = Object.keys(rateLimitConfigs.auth); - expect(authConfigs).toEqual(["login", "signup", "forgotPassword", "verifyEmail"]); + expect(authConfigs).toEqual(["login", "signup", "forgotPassword", "verifyEmail", "emailToken"]); + // The values, not just the key: emailToken throttles an unauthenticated endpoint that also + // reveals whether an address is registered, so a loosened quota is a security regression. + expect(rateLimitConfigs.auth.emailToken).toEqual({ + interval: 3600, + allowedPerInterval: 10, + namespace: "auth:email-token", + }); }); test("should have all API configurations", () => { diff --git a/apps/web/modules/core/rate-limit/rate-limit-configs.ts b/apps/web/modules/core/rate-limit/rate-limit-configs.ts index 57ef57b1c876..44e7e70f4e7b 100644 --- a/apps/web/modules/core/rate-limit/rate-limit-configs.ts +++ b/apps/web/modules/core/rate-limit/rate-limit-configs.ts @@ -5,6 +5,7 @@ export const rateLimitConfigs = { signup: { interval: 3600, allowedPerInterval: 30, namespace: "auth:signup" }, // 30 per hour forgotPassword: { interval: 3600, allowedPerInterval: 5, namespace: "auth:forgot" }, // 5 per hour verifyEmail: { interval: 3600, allowedPerInterval: 10, namespace: "auth:verify" }, // 10 per hour + emailToken: { interval: 3600, allowedPerInterval: 10, namespace: "auth:email-token" }, // 10 per hour — unauthenticated, tells the caller whether an email is registered }, // API endpoints - higher limits for legitimate usage diff --git a/apps/web/modules/ee/audit-logs/types/audit-log.ts b/apps/web/modules/ee/audit-logs/types/audit-log.ts index beb61341f33f..adbe4ae42923 100644 --- a/apps/web/modules/ee/audit-logs/types/audit-log.ts +++ b/apps/web/modules/ee/audit-logs/types/audit-log.ts @@ -30,6 +30,7 @@ export const ZAuditTarget = z.enum([ "dashboardWidget", "cubeQuery", "feedbackDirectory", + "feedbackRecord", ]); export const ZAuditAction = z.enum([ "created", diff --git a/apps/web/modules/ee/contacts/api/v1/client/[workspaceId]/user/lib/update-user.ts b/apps/web/modules/ee/contacts/api/v1/client/[workspaceId]/user/lib/update-user.ts index d7c7510eb542..18e19a890d53 100644 --- a/apps/web/modules/ee/contacts/api/v1/client/[workspaceId]/user/lib/update-user.ts +++ b/apps/web/modules/ee/contacts/api/v1/client/[workspaceId]/user/lib/update-user.ts @@ -9,6 +9,9 @@ import { toLegacyLanguageCodes } from "@/lib/i18n/utils"; import { formatAttributeMessage, updateAttributes } from "@/modules/ee/contacts/lib/attributes"; import { getPersonSegmentIds } from "./segments"; +/** Message codes whose presence alone reveals whether an identifier is already a contact here. */ +const EXISTENCE_REVEALING_MESSAGE_CODES = new Set(["email_already_exists", "userid_already_exists"]); + /** * Shared select for the two contact-state branches (existing contact vs newly created). Kept in one * place so both produce the identical shape that feeds buildUserStateFromContact and the in-memory @@ -232,7 +235,15 @@ export const updateUser = async ( errors: updateAttrErrors, } = await updateAttributes(contactData.id, userId, workspaceId, normalizedAttributes); - messages = updateAttrMessages?.map(formatAttributeMessage) ?? []; + // This runs on the *unauthenticated* public client endpoint, and workspaceId is public — it ships + // in the widget snippet on the customer's own site. Echoing "the email/userId already exists" + // turns the endpoint into an oracle for "is this address a contact of this organization?", + // answerable for any address an attacker cares to try. The SDK only debug-logs these two, so + // withholding them costs nothing; genuine `errors` are still returned in full. + messages = + updateAttrMessages + ?.filter((message) => !EXISTENCE_REVEALING_MESSAGE_CODES.has(message.code)) + .map(formatAttributeMessage) ?? []; errors = updateAttrErrors?.map(formatAttributeMessage) ?? []; // Update language if provided (used in response state) diff --git a/apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.test.ts b/apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.test.ts index d086b24d74ed..f6dd6e369cb6 100644 --- a/apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.test.ts +++ b/apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.test.ts @@ -1908,4 +1908,67 @@ describe("survey interaction filters", () => { expect(clause.NOT.responses.some.finished).toBe(true); }); + describe("cross-workspace nested segments", () => { + // Regression: getSegment looks up by id alone and segmentId comes from a caller-authored filter + // tree, so another workspace's segment could be nested and its filters evaluated against contacts + // the caller controls — probing seeded values against membership reveals the other team's rules. + const nestedFilters: TBaseFilters = [ + { + id: "nested_filter_1", + connector: null, + resource: { + id: "attr_secret", + root: { type: "attribute" as const, contactAttributeKey: "plan" }, + value: "enterprise", + qualifier: { operator: "equals" }, + }, + }, + ]; + + const buildForeignSegment = (workspaceId: string): TSegmentWithSurveyRefs => ({ + id: "foreign-segment-id", + filters: nestedFilters, + workspaceId, + title: "Another team's segment", + description: null, + isPrivate: true, + createdAt: new Date(), + updatedAt: new Date(), + surveys: [], + activeSurveys: [], + inactiveSurveys: [], + }); + + const nestingFilters: TBaseFilters = [ + { + id: "filter_1", + connector: null, + resource: { + id: "segment_1", + root: { type: "segment" as const, segmentId: "foreign-segment-id" }, + value: "", + qualifier: { operator: "userIsIn" }, + }, + }, + ]; + + test("does not apply the filters of a segment from another workspace", async () => { + vi.mocked(getSegment).mockResolvedValue(buildForeignSegment("someone-elses-workspace")); + + const result = await segmentFilterToPrismaQuery(mockSegmentId, nestingFilters, mockWorkspaceId); + + expect(result.ok).toBe(true); + // The foreign segment resolves to {}, so nothing from its filter tree leaks into the query. + expect(JSON.stringify(result)).not.toContain("enterprise"); + }); + + test("still applies the filters of a segment in the same workspace", async () => { + vi.mocked(getSegment).mockResolvedValue(buildForeignSegment(mockWorkspaceId)); + + const result = await segmentFilterToPrismaQuery(mockSegmentId, nestingFilters, mockWorkspaceId); + + expect(result.ok).toBe(true); + expect(JSON.stringify(result)).toContain("enterprise"); + }); + }); }); diff --git a/apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts b/apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts index a450963d7740..27eca75cbe53 100644 --- a/apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts +++ b/apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts @@ -410,6 +410,18 @@ const buildSegmentFilterWhereClause = async ( return {}; } + // `getSegment` looks up by id alone, and `segmentId` comes from a caller-authored filter tree. Without + // this check a user could nest another workspace's segment inside their own and have its filters + // evaluated against contacts they control — probing seeded attribute values against the resulting + // membership reveals the other team's targeting rules. + if (segment.workspaceId !== workspaceId) { + logger.error( + { segmentId, segmentWorkspaceId: segment.workspaceId, workspaceId }, + "Refusing to resolve a segment filter referencing another workspace's segment" + ); + return {}; + } + const newPath = new Set(segmentPath); newPath.add(segmentId); diff --git a/apps/web/modules/ee/license-check/lib/license.test.ts b/apps/web/modules/ee/license-check/lib/license.test.ts index 2cbd9335b526..d0ff95723275 100644 --- a/apps/web/modules/ee/license-check/lib/license.test.ts +++ b/apps/web/modules/ee/license-check/lib/license.test.ts @@ -153,6 +153,7 @@ describe("License Core Logic", () => { quotas: true, feedbackDirectories: false, dashboards: false, + workflows: false, }; const mockFetchedLicenseDetails: TEnterpriseLicenseDetails = { status: "active", @@ -292,6 +293,7 @@ describe("License Core Logic", () => { quotas: false, feedbackDirectories: false, dashboards: false, + workflows: false, }, lastChecked: expect.any(Date), }, @@ -315,6 +317,7 @@ describe("License Core Logic", () => { quotas: false, feedbackDirectories: false, dashboards: false, + workflows: false, }, lastChecked: expect.any(Date), isPendingDowngrade: false, @@ -347,6 +350,7 @@ describe("License Core Logic", () => { quotas: false, feedbackDirectories: false, dashboards: false, + workflows: false, }; expect(mockCache.set).toHaveBeenCalledWith( expect.stringContaining("fb:license:"), @@ -538,6 +542,7 @@ describe("License Core Logic", () => { quotas: true, feedbackDirectories: false, dashboards: false, + workflows: false, }, }; @@ -604,6 +609,7 @@ describe("License Core Logic", () => { quotas: true, feedbackDirectories: false, dashboards: false, + workflows: false, }, }; @@ -661,6 +667,7 @@ describe("License Core Logic", () => { quotas: true, feedbackDirectories: false, dashboards: false, + workflows: false, }, }; @@ -864,6 +871,7 @@ describe("License Core Logic", () => { quotas: false, feedbackDirectories: false, dashboards: false, + workflows: false, }, }, }, @@ -1044,6 +1052,7 @@ describe("License Core Logic", () => { quotas: true, feedbackDirectories: false, dashboards: false, + workflows: false, }, }; @@ -1172,6 +1181,7 @@ describe("License Core Logic", () => { quotas: true, feedbackDirectories: false, dashboards: false, + workflows: false, }, }; diff --git a/apps/web/modules/ee/license-check/lib/license.ts b/apps/web/modules/ee/license-check/lib/license.ts index e356e9b9cb6f..874c007d166b 100644 --- a/apps/web/modules/ee/license-check/lib/license.ts +++ b/apps/web/modules/ee/license-check/lib/license.ts @@ -90,6 +90,7 @@ const LicenseFeaturesSchema = z.object({ quotas: z.boolean(), feedbackDirectories: z.boolean().default(false), dashboards: z.boolean().default(false), + workflows: z.boolean().default(false), }); const LicenseDetailsSchema = z.object({ @@ -159,6 +160,7 @@ const DEFAULT_FEATURES: TEnterpriseLicenseFeatures = { quotas: false, feedbackDirectories: false, dashboards: false, + workflows: false, }; // Helper functions diff --git a/apps/web/modules/ee/license-check/lib/utils.test.ts b/apps/web/modules/ee/license-check/lib/utils.test.ts index 21ffbf1849c2..ed1513c70fc7 100644 --- a/apps/web/modules/ee/license-check/lib/utils.test.ts +++ b/apps/web/modules/ee/license-check/lib/utils.test.ts @@ -65,6 +65,7 @@ const defaultFeatures: TEnterpriseLicenseFeatures = { quotas: false, feedbackDirectories: false, dashboards: false, + workflows: false, }; const defaultLicense = { diff --git a/apps/web/modules/ee/license-check/types/enterprise-license.ts b/apps/web/modules/ee/license-check/types/enterprise-license.ts index 1cb9591f74ff..042ba507bd2e 100644 --- a/apps/web/modules/ee/license-check/types/enterprise-license.ts +++ b/apps/web/modules/ee/license-check/types/enterprise-license.ts @@ -20,6 +20,7 @@ const ZEnterpriseLicenseFeatures = z.object({ quotas: z.boolean(), feedbackDirectories: z.boolean().default(false), dashboards: z.boolean().default(false), + workflows: z.boolean().default(false), }); export type TEnterpriseLicenseFeatures = z.infer; diff --git a/apps/web/modules/ee/teams/team-list/actions.ts b/apps/web/modules/ee/teams/team-list/actions.ts index 76a38e4caeda..c83d7af3ca55 100644 --- a/apps/web/modules/ee/teams/team-list/actions.ts +++ b/apps/web/modules/ee/teams/team-list/actions.ts @@ -14,6 +14,7 @@ import { getTeamDetails, updateTeamDetails, } from "@/modules/ee/teams/team-list/lib/team"; +import { hasWorkspaceAccessChanges } from "@/modules/ee/teams/team-list/lib/workspace-access"; import { ZTeamSettingsFormSchema } from "@/modules/ee/teams/team-list/types/team"; const ZCreateTeamAction = z.object({ @@ -132,6 +133,23 @@ export const updateTeamDetailsAction = authenticatedActionClient.inputSchema(ZUp ctx.auditLoggingCtx.organizationId = organizationId; ctx.auditLoggingCtx.teamId = parsedInput.teamId; const oldObject = await getTeamDetails(parsedInput.teamId); + + // A team admin may be a plain org `member`, and this input carries the team's full workspace grant + // list — so without this gate they could grant their own team `manage` on every workspace in the + // organization. Changing workspace access stays owner/manager-only, matching what the UI offers. + if (hasWorkspaceAccessChanges(oldObject?.workspaces ?? [], parsedInput.data.workspaces)) { + await checkAuthorizationUpdated({ + userId: ctx.user.id, + organizationId, + access: [ + { + type: "organization", + roles: ["owner", "manager"], + }, + ], + }); + } + const result = await updateTeamDetails(parsedInput.teamId, parsedInput.data); ctx.auditLoggingCtx.oldObject = oldObject; ctx.auditLoggingCtx.newObject = await getTeamDetails(parsedInput.teamId); diff --git a/apps/web/modules/ee/teams/team-list/lib/workspace-access.test.ts b/apps/web/modules/ee/teams/team-list/lib/workspace-access.test.ts new file mode 100644 index 000000000000..81e012724400 --- /dev/null +++ b/apps/web/modules/ee/teams/team-list/lib/workspace-access.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "vitest"; +import { hasWorkspaceAccessChanges } from "./workspace-access"; + +describe("hasWorkspaceAccessChanges", () => { + test("reports no change when the submitted grants match the stored ones", () => { + const current = [ + { workspaceId: "ws-1", permission: "read" as const }, + { workspaceId: "ws-2", permission: "manage" as const }, + ]; + + expect(hasWorkspaceAccessChanges(current, current)).toBe(false); + }); + + test("reports no change when the same grants arrive in a different order", () => { + expect( + hasWorkspaceAccessChanges( + [ + { workspaceId: "ws-1", permission: "read" as const }, + { workspaceId: "ws-2", permission: "manage" as const }, + ], + [ + { workspaceId: "ws-2", permission: "manage" as const }, + { workspaceId: "ws-1", permission: "read" as const }, + ] + ) + ).toBe(false); + }); + + // The escalation this guards: a team admin submitting an extra workspace, or upgrading an existing + // grant, to give their own team access org-wide. + test("reports a change when a workspace is added", () => { + expect( + hasWorkspaceAccessChanges( + [{ workspaceId: "ws-1", permission: "read" as const }], + [ + { workspaceId: "ws-1", permission: "read" as const }, + { workspaceId: "ws-victim", permission: "manage" as const }, + ] + ) + ).toBe(true); + }); + + test("reports a change when a permission is escalated", () => { + expect( + hasWorkspaceAccessChanges( + [{ workspaceId: "ws-1", permission: "read" as const }], + [{ workspaceId: "ws-1", permission: "manage" as const }] + ) + ).toBe(true); + }); + + test("reports a change when a workspace is removed", () => { + expect( + hasWorkspaceAccessChanges( + [ + { workspaceId: "ws-1", permission: "read" as const }, + { workspaceId: "ws-2", permission: "read" as const }, + ], + [{ workspaceId: "ws-1", permission: "read" as const }] + ) + ).toBe(true); + }); + + test("reports a change when a workspace is swapped for another", () => { + expect( + hasWorkspaceAccessChanges( + [{ workspaceId: "ws-1", permission: "read" as const }], + [{ workspaceId: "ws-victim", permission: "read" as const }] + ) + ).toBe(true); + }); + + // Duplicates must not let a submission pad its length to match the stored count. + test("reports a change when a duplicated workspace masks an added one", () => { + expect( + hasWorkspaceAccessChanges( + [ + { workspaceId: "ws-1", permission: "read" as const }, + { workspaceId: "ws-2", permission: "read" as const }, + ], + [ + { workspaceId: "ws-1", permission: "read" as const }, + { workspaceId: "ws-1", permission: "read" as const }, + { workspaceId: "ws-victim", permission: "manage" as const }, + ] + ) + ).toBe(true); + }); + + // The harder duplicate: the *last* entry matches the stored grant, so a `Map` keyed on workspaceId + // collapses to something equal to `current` and the gate would be skipped, while an earlier entry + // carries the escalated permission. Raised by CodeRabbit on #8680. + test("reports a change when a duplicate hides an escalated permission behind a matching one", () => { + expect( + hasWorkspaceAccessChanges( + [{ workspaceId: "ws-1", permission: "read" as const }], + [ + { workspaceId: "ws-1", permission: "manage" as const }, + { workspaceId: "ws-1", permission: "read" as const }, + ] + ) + ).toBe(true); + }); +}); diff --git a/apps/web/modules/ee/teams/team-list/lib/workspace-access.ts b/apps/web/modules/ee/teams/team-list/lib/workspace-access.ts new file mode 100644 index 000000000000..76606af25347 --- /dev/null +++ b/apps/web/modules/ee/teams/team-list/lib/workspace-access.ts @@ -0,0 +1,52 @@ +import { type TTeamPermission } from "@/modules/ee/teams/workspace-teams/types/team"; + +type TWorkspaceGrant = { + workspaceId: string; + permission: TTeamPermission; +}; + +/** + * True when a team-settings submission would add, remove, or re-scope any of the team's workspace + * grants. + * + * `updateTeamDetailsAction` is reachable by an org owner/manager *or* a team admin, and a team admin + * may be a plain org `member`. Its input carries the team's whole workspace grant list, so without + * this check a team admin could grant their own team `manage` on every workspace in the organization — + * i.e. read/write on all surveys, responses, and contacts — while the UI only ever offered them the + * members section (workspace selects are disabled for non-owners in `workspace-row.tsx`). + * + * Comparing the submitted list against the stored one lets the member-management path keep working + * (it round-trips the existing grants unchanged) while any actual access change requires owner/manager. + */ +export const hasWorkspaceAccessChanges = ( + current: readonly TWorkspaceGrant[], + submitted: readonly TWorkspaceGrant[] +): boolean => { + const currentByWorkspace = new Map(current.map(({ workspaceId, permission }) => [workspaceId, permission])); + + const submittedByWorkspace = new Map( + submitted.map(({ workspaceId, permission }) => [workspaceId, permission]) + ); + + // A repeated workspaceId is treated as a change so the owner/manager gate applies. `Map` keeps the + // last entry for a duplicate, so `[{ws1, manage}, {ws1, read}]` would otherwise compare equal to a + // stored `read` grant and skip the gate. `updateTeamDetails` already rejects duplicates (it counts + // the submitted ids against a distinct `in` query, so the lengths disagree), which is why this was + // not exploitable — but that makes the outcome depend on a check in a different module. Deciding it + // here keeps this function correct on its own terms. Raised by CodeRabbit on #8680. + if (submittedByWorkspace.size !== submitted.length) { + return true; + } + + if (currentByWorkspace.size !== submittedByWorkspace.size) { + return true; + } + + for (const [workspaceId, permission] of submittedByWorkspace) { + if (currentByWorkspace.get(workspaceId) !== permission) { + return true; + } + } + + return false; +}; diff --git a/apps/web/modules/ee/two-factor-auth/lib/two-factor-auth.test.ts b/apps/web/modules/ee/two-factor-auth/lib/two-factor-auth.test.ts index ddedca53f059..db2e798c769b 100644 --- a/apps/web/modules/ee/two-factor-auth/lib/two-factor-auth.test.ts +++ b/apps/web/modules/ee/two-factor-auth/lib/two-factor-auth.test.ts @@ -96,6 +96,23 @@ describe("Two Factor Auth", () => { ); }); + // Regression: setup writes `twoFactorEnabled: false` and replaces the secret and backup codes, so + // running it against an already-enabled factor switched 2FA off on a password alone — while + // disableTwoFactorAuth demands the password *and* a current code or backup code. + test("setupTwoFactorAuth should refuse to re-enroll while two factor auth is already enabled", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "user123", + identityProvider: "email", + twoFactorEnabled: true, + } as any); + + await expect(setupTwoFactorAuth("user123", "password123")).rejects.toThrow( + new InvalidInputError("Two factor authentication is already enabled") + ); + expect(prisma.user.update).not.toHaveBeenCalled(); + expect(verifyUserPassword).not.toHaveBeenCalled(); + }); + test("setupTwoFactorAuth should throw InvalidInputError when password is incorrect", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: "user123", diff --git a/apps/web/modules/ee/two-factor-auth/lib/two-factor-auth.ts b/apps/web/modules/ee/two-factor-auth/lib/two-factor-auth.ts index ee8181a72481..ec1c8600fe42 100644 --- a/apps/web/modules/ee/two-factor-auth/lib/two-factor-auth.ts +++ b/apps/web/modules/ee/two-factor-auth/lib/two-factor-auth.ts @@ -40,6 +40,17 @@ export const setupTwoFactorAuth = async ( throw new InvalidInputError("Third party login is already enabled"); } + // Re-enrolling while a factor is active must not be possible on a password alone. The update below + // sets `twoFactorEnabled: false` and replaces both the secret and the backup codes, so reaching it + // with an already-enabled factor turned 2FA off — the same end state `disableTwoFactorAuth` reaches, + // but that one demands the password *and* a current code or backup code. It would also strand a + // legitimate user who abandoned the flow half-way, with their authenticator and backup codes both + // replaced. Disabling first is the supported path, and the settings UI only offers setup when the + // factor is off. + if (user.twoFactorEnabled) { + throw new InvalidInputError("Two factor authentication is already enabled"); + } + // Password verification — the credential-account lookup and fail-closed "no password" handling — // is owned by verifyUserPassword; 2FA setup just needs the yes/no answer. const isCorrectPassword = await verifyUserPassword(userId, password); diff --git a/apps/web/modules/ee/unify-feedback/sources/actions.ts b/apps/web/modules/ee/unify-feedback/sources/actions.ts index 56583c3c523f..1a96afd6fd4b 100644 --- a/apps/web/modules/ee/unify-feedback/sources/actions.ts +++ b/apps/web/modules/ee/unify-feedback/sources/actions.ts @@ -27,9 +27,13 @@ export const getSurveysForUnifyAction = authenticatedActionClient userId: ctx.user.id, organizationId, access: [ + // "member" must not appear here: the access items are OR'd, and every org member satisfies an + // organization item, which would make the workspaceTeam check below dead and let any member + // list surveys in workspaces they have no team access to. Members reach this through their + // team permission instead. { type: "organization", - roles: ["owner", "manager", "member"], + roles: ["owner", "manager"], }, { type: "workspaceTeam", diff --git a/apps/web/modules/envoy-auth/service.test.ts b/apps/web/modules/envoy-auth/service.test.ts index b12ba3e93f68..53dd2bfa15dd 100644 --- a/apps/web/modules/envoy-auth/service.test.ts +++ b/apps/web/modules/envoy-auth/service.test.ts @@ -127,8 +127,8 @@ describe("authorizeEnvoyRequest", () => { type: "apiKey", apiKeyId: "key_1", organizationId: "org_1", - organizationAccess: { accessControl: { read: true, write: true } }, - workspacePermissions: [], + organizationAccess: { accessControl: { read: false, write: false } }, + workspacePermissions: [{ workspaceId: "workspace_1", workspaceName: "Linked", permission: "manage" }], }); const response = await authorizeEnvoyRequest( @@ -241,8 +241,8 @@ describe("authorizeEnvoyRequest", () => { type: "apiKey", apiKeyId: "key_1", organizationId: "org_1", - organizationAccess: { accessControl: { read: true, write: false } }, - workspacePermissions: [], + organizationAccess: { accessControl: { read: false, write: false } }, + workspacePermissions: [{ workspaceId: "workspace_1", workspaceName: "Linked", permission: "read" }], }); const response = await authorizeEnvoyRequest( @@ -391,6 +391,46 @@ describe("authorizeEnvoyRequest", () => { expect(response.status).toBe(200); }); + test("denies a cross-org API key even with a matching workspace permission (ENG-1980)", async () => { + // The directory belongs to org_2; the key belongs to org_1 but carries a workspace permission + // whose workspaceId collides with one linked to the org_2 directory. Access must be denied by the + // key-belongs-to-directory-org check — this is the exact cross-tenant path ENG-1980 closed. + // Wiring-level guard: if authorizeFeedbackRecordsGatewayRequest ever passed the key's own + // organizationId (instead of the directory's) into the org check, that check turns into a + // tautology and this returns 200; the direct unit test on the pure function can't catch it. + mockGetFeedbackDirectoryAuthContext.mockResolvedValue({ + organizationId: "org_2", + workspaceIds: ["workspace_1"], + isArchived: false, + }); + mockGetIsFeedbackDirectoriesEnabled.mockResolvedValue(true); + mockGetApiKeyFromHeaders.mockReturnValue("fbk_test"); + mockAuthenticateApiKeyFromHeaders.mockResolvedValue({ + type: "apiKey", + apiKeyId: "key_1", + organizationId: "org_1", + organizationAccess: { accessControl: { read: false, write: false } }, + workspacePermissions: [ + { + workspaceId: "workspace_1", + workspaceName: "Workspace 1", + permission: "manage", + }, + ], + }); + + const response = await authorizeEnvoyRequest( + createRequest(`http://localhost/api/envoy-auth/v1/feedback-records?tenant_id=${feedbackDirectoryId}`, { + headers: { + "x-api-key": "fbk_test", + }, + }) + ); + + expect(response.status).toBe(403); + expect(mockGetIsFeedbackDirectoriesEnabled).toHaveBeenCalledWith("org_2"); + }); + test("returns 403 when API key has read-only workspace permission for a write op", async () => { mockGetApiKeyFromHeaders.mockReturnValue("fbk_test"); mockAuthenticateApiKeyFromHeaders.mockResolvedValue({ @@ -453,7 +493,7 @@ describe("authorizeEnvoyRequest", () => { expect(response.status).toBe(403); }); - test("allows API key with org-level read access for a read op even without workspace match", async () => { + test("denies an API key with only org-level access and no workspace match (org access does not grant feedback data)", async () => { mockGetFeedbackDirectoryAuthContext.mockResolvedValue({ organizationId: "org_1", workspaceIds: [], @@ -476,7 +516,7 @@ describe("authorizeEnvoyRequest", () => { }) ); - expect(response.status).toBe(200); + expect(response.status).toBe(403); }); test("returns 403 when unify feedback entitlement is disabled", async () => { diff --git a/apps/web/modules/hub/feedback-records-gateway-authz.test.ts b/apps/web/modules/hub/feedback-records-gateway-authz.test.ts new file mode 100644 index 000000000000..e4ebb37c2153 --- /dev/null +++ b/apps/web/modules/hub/feedback-records-gateway-authz.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from "vitest"; +import type { TAuthenticationApiKey } from "@formbricks/types/auth"; +import { hasApiKeyImplicitFeedbackDirectoryAccess } from "./feedback-records-gateway-authz"; + +const DIRECTORY_ORG_ID = "org_directory"; +const DIRECTORY_WORKSPACE_ID = "wsdirectory0000000000000"; + +const makeApiKeyAuth = (overrides: Partial = {}): TAuthenticationApiKey => ({ + type: "apiKey", + apiKeyId: "key_1", + organizationId: DIRECTORY_ORG_ID, + organizationAccess: { accessControl: { read: false, write: false } }, + workspacePermissions: [], + ...overrides, +}); + +describe("hasApiKeyImplicitFeedbackDirectoryAccess", () => { + describe("organization-level access control does not grant feedback-record access", () => { + // organizationAccess.accessControl governs org management (members/teams), NOT workspace data. + // Feedback records are workspace-scoped, so they must never be reachable via org-level access — + // only via a matching workspace permission. + test("denies a same-organization key with org-level write and no matching workspace permission", () => { + const ownerKey = makeApiKeyAuth({ + organizationAccess: { accessControl: { read: true, write: true } }, + }); + + expect( + hasApiKeyImplicitFeedbackDirectoryAccess(ownerKey, DIRECTORY_ORG_ID, [DIRECTORY_WORKSPACE_ID], "read") + ).toBe(false); + expect( + hasApiKeyImplicitFeedbackDirectoryAccess( + ownerKey, + DIRECTORY_ORG_ID, + [DIRECTORY_WORKSPACE_ID], + "write" + ) + ).toBe(false); + }); + + test("denies a foreign-organization key regardless of org-level or workspace access (ENG-1980)", () => { + const foreignKey = makeApiKeyAuth({ + organizationId: "org_attacker", + organizationAccess: { accessControl: { read: true, write: true } }, + workspacePermissions: [ + { workspaceId: DIRECTORY_WORKSPACE_ID, workspaceName: "Shared", permission: "manage" }, + ], + }); + + expect( + hasApiKeyImplicitFeedbackDirectoryAccess( + foreignKey, + DIRECTORY_ORG_ID, + [DIRECTORY_WORKSPACE_ID], + "read" + ) + ).toBe(false); + expect( + hasApiKeyImplicitFeedbackDirectoryAccess( + foreignKey, + DIRECTORY_ORG_ID, + [DIRECTORY_WORKSPACE_ID], + "write" + ) + ).toBe(false); + }); + }); + + describe("workspace-permission path (same-organization keys only)", () => { + test("grants a same-organization key (no org-level access) via a matching workspace permission", () => { + // Fall-through: org-level accessControl is all-false, so access comes solely from the + // per-workspace permission. + const workspaceKey = makeApiKeyAuth({ + workspacePermissions: [ + { workspaceId: DIRECTORY_WORKSPACE_ID, workspaceName: "Shared", permission: "write" }, + ], + }); + + expect( + hasApiKeyImplicitFeedbackDirectoryAccess( + workspaceKey, + DIRECTORY_ORG_ID, + [DIRECTORY_WORKSPACE_ID], + "write" + ) + ).toBe(true); + }); + + test("grants a read op via a read workspace permission (equal weight)", () => { + const workspaceKey = makeApiKeyAuth({ + workspacePermissions: [ + { workspaceId: DIRECTORY_WORKSPACE_ID, workspaceName: "Shared", permission: "read" }, + ], + }); + + expect( + hasApiKeyImplicitFeedbackDirectoryAccess( + workspaceKey, + DIRECTORY_ORG_ID, + [DIRECTORY_WORKSPACE_ID], + "read" + ) + ).toBe(true); + }); + + test("grants a write op via a manage workspace permission (higher weight)", () => { + const workspaceKey = makeApiKeyAuth({ + workspacePermissions: [ + { workspaceId: DIRECTORY_WORKSPACE_ID, workspaceName: "Shared", permission: "manage" }, + ], + }); + + expect( + hasApiKeyImplicitFeedbackDirectoryAccess( + workspaceKey, + DIRECTORY_ORG_ID, + [DIRECTORY_WORKSPACE_ID], + "write" + ) + ).toBe(true); + }); + + test("denies when the read-only workspace permission is below the required write weight", () => { + const workspaceKey = makeApiKeyAuth({ + workspacePermissions: [ + { workspaceId: DIRECTORY_WORKSPACE_ID, workspaceName: "Shared", permission: "read" }, + ], + }); + + expect( + hasApiKeyImplicitFeedbackDirectoryAccess( + workspaceKey, + DIRECTORY_ORG_ID, + [DIRECTORY_WORKSPACE_ID], + "write" + ) + ).toBe(false); + }); + + test("denies when no workspace permission matches a directory workspace", () => { + const workspaceKey = makeApiKeyAuth({ + workspacePermissions: [ + { workspaceId: "wsother00000000000000000", workspaceName: "Other", permission: "manage" }, + ], + }); + + expect( + hasApiKeyImplicitFeedbackDirectoryAccess( + workspaceKey, + DIRECTORY_ORG_ID, + [DIRECTORY_WORKSPACE_ID], + "read" + ) + ).toBe(false); + }); + }); +}); diff --git a/apps/web/modules/hub/feedback-records-gateway-authz.ts b/apps/web/modules/hub/feedback-records-gateway-authz.ts new file mode 100644 index 000000000000..74973d3d5707 --- /dev/null +++ b/apps/web/modules/hub/feedback-records-gateway-authz.ts @@ -0,0 +1,59 @@ +import type { ApiKeyPermission } from "@formbricks/database/prisma"; +import type { TAuthenticationApiKey } from "@formbricks/types/auth"; + +// Pure authorization logic for the feedback records gateway. Kept free of server-only, Prisma, and +// env imports so it can be unit-tested in isolation (the gateway module itself pulls in the full +// request/DB/env stack). + +export type TFeedbackRecordsGatewayPermission = "read" | "write"; + +const apiKeyPermissionWeight: Record = { + read: 1, + write: 2, + manage: 3, +}; + +const gatewayPermissionToApiKeyPermissionWeight: Record = { + read: apiKeyPermissionWeight.read, + write: apiKeyPermissionWeight.write, +}; + +/** + * Whether an API key may access a feedback directory's records. + * + * Feedback records are workspace-scoped DATA, so access is granted solely by the key's per-workspace + * permissions (`workspacePermissions`) — never by `organizationAccess.accessControl`. That org-level + * flag governs organization MANAGEMENT (members, teams, roles), not workspace data: the API-key UI + * describes it as "Members and teams only — not workspace data", and every other consumer + * (`hasOrganizationAccess`, the org users/teams endpoints) uses it only for org-admin operations. + * Granting record access from it would let a members-management key read/write feedback data and + * would make the workspace-permission check below redundant, so it is deliberately not consulted. + * + * Access additionally requires the key to belong to the directory's organization. The + * workspace-permission match already implies the same organization (a key's `workspacePermissions` + * only contain workspaces in its own org — see `authenticateApiKeyFromHeaders`), so the up-front + * organization check is defense-in-depth: it keeps this decision correct on its own, and prevents + * cross-tenant access (ENG-1980) even if that upstream invariant ever regresses. + */ +export const hasApiKeyImplicitFeedbackDirectoryAccess = ( + authentication: TAuthenticationApiKey, + directoryOrganizationId: string, + workspaceIds: string[], + requiredPermission: TFeedbackRecordsGatewayPermission +): boolean => { + // The key must belong to the directory's organization. + if (authentication.organizationId !== directoryOrganizationId) { + return false; + } + + const matchingWeights = authentication.workspacePermissions + .filter((permission) => workspaceIds.includes(permission.workspaceId)) + .map((permission) => apiKeyPermissionWeight[permission.permission]); + + if (matchingWeights.length === 0) { + return false; + } + + const maxWeight = Math.max(...matchingWeights); + return maxWeight >= gatewayPermissionToApiKeyPermissionWeight[requiredPermission]; +}; diff --git a/apps/web/modules/hub/feedback-records-gateway.ts b/apps/web/modules/hub/feedback-records-gateway.ts index 6eb18b7e110e..f328f7bcdf46 100644 --- a/apps/web/modules/hub/feedback-records-gateway.ts +++ b/apps/web/modules/hub/feedback-records-gateway.ts @@ -1,9 +1,7 @@ import "server-only"; import { NextRequest } from "next/server"; import { z } from "zod"; -import { ApiKeyPermission } from "@formbricks/database/prisma"; import { logger } from "@formbricks/logger"; -import { TAuthenticationApiKey } from "@formbricks/types/auth"; import { ZId } from "@formbricks/types/common"; import { AuthorizationError } from "@formbricks/types/errors"; import { RequestBodyTooLargeError, readRequestBodyWithLimit } from "@/app/lib/api/request-body"; @@ -18,12 +16,15 @@ import { allowGatewayRequest, buildGatewayStatusResponse, } from "@/modules/gateway-auth/lib/request"; +import { + type TFeedbackRecordsGatewayPermission, + hasApiKeyImplicitFeedbackDirectoryAccess, +} from "@/modules/hub/feedback-records-gateway-authz"; import { normalizeFeedbackRecordsPath } from "@/modules/hub/feedback-records-routing"; import { getFeedbackRecordTenant } from "@/modules/hub/service"; const ZFeedbackRecordId = z.uuid(); -type TFeedbackRecordsGatewayPermission = "read" | "write"; type TFeedbackRecordsGatewayOperation = | "list" | "create" @@ -41,17 +42,6 @@ type TParsedGatewayRoute = { tenantSource: "query" | "body" | "recordLookup"; }; -const apiKeyPermissionWeight: Record = { - read: 1, - write: 2, - manage: 3, -}; - -const gatewayPermissionToApiKeyPermissionWeight: Record = { - read: apiKeyPermissionWeight.read, - write: apiKeyPermissionWeight.write, -}; - const parseFeedbackRecordsGatewayRoute = (method: string, pathname: string): TParsedGatewayRoute | null => { const normalizedPath = normalizeFeedbackRecordsPath(pathname); if (!normalizedPath) { @@ -157,31 +147,6 @@ const getFeedbackRecordsGatewayJwtFromHeaders = (headers: Headers): string | nul return getBearerTokenFromHeaders(headers); }; -const hasApiKeyImplicitFeedbackDirectoryAccess = ( - authentication: TAuthenticationApiKey, - workspaceIds: string[], - requiredPermission: TFeedbackRecordsGatewayPermission -): boolean => { - const orgAccessControl = authentication.organizationAccess?.accessControl; - if (orgAccessControl?.write) { - return true; - } - if (orgAccessControl?.read && requiredPermission === "read") { - return true; - } - - const matchingWeights = authentication.workspacePermissions - .filter((permission) => workspaceIds.includes(permission.workspaceId)) - .map((permission) => apiKeyPermissionWeight[permission.permission]); - - if (matchingWeights.length === 0) { - return false; - } - - const maxWeight = Math.max(...matchingWeights); - return maxWeight >= gatewayPermissionToApiKeyPermissionWeight[requiredPermission]; -}; - const resolveTenantId = async ( request: NextRequest, route: TParsedGatewayRoute, @@ -271,6 +236,7 @@ const authorizeFeedbackRecordsGatewayRequest = async ( if (principal.type === "apiKey") { return hasApiKeyImplicitFeedbackDirectoryAccess( principal.authentication, + feedbackDirectory.organizationId, feedbackDirectory.workspaceIds, requiredPermission ) diff --git a/apps/web/modules/hub/service.test.ts b/apps/web/modules/hub/service.test.ts index cff41b683a71..b9a8417e1dbb 100644 --- a/apps/web/modules/hub/service.test.ts +++ b/apps/web/modules/hub/service.test.ts @@ -1,12 +1,15 @@ -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { createCacheKey } from "@formbricks/cache"; import FormbricksHub from "@formbricks/hub"; +import { logger } from "@formbricks/logger"; import { + countFeedbackRecords, createFeedbackRecord, createFeedbackRecordsBatch, createTaxonomyRun, deleteFeedbackRecord, deleteHubTenantData, + findSimilarFeedbackRecords, getActiveTaxonomyTree, getFeedbackRecordTenant, getTaxonomyRun, @@ -24,7 +27,7 @@ import { import type { FeedbackRecordCreateParams } from "./types"; vi.mock("@formbricks/logger", () => ({ - logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, + logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }, })); vi.mock("@formbricks/hub", () => ({ @@ -208,6 +211,44 @@ describe("hub service", () => { }); }); + describe("countFeedbackRecords", () => { + test("returns config error when getHubClient returns null", async () => { + vi.mocked(getHubClient).mockReturnValue(null); + + const result = await countFeedbackRecords({ tenant_id: "env-1" }); + + expect(result.data).toBeNull(); + expect(result.error?.message).toContain("HUB_API_KEY"); + }); + + test("passes the filters through and returns the count", async () => { + const count = vi.fn().mockResolvedValue({ count: 42 }); + vi.mocked(getHubClient).mockReturnValue({ feedbackRecords: { count } } as any); + + const result = await countFeedbackRecords({ tenant_id: "env-1", user_id: "user-1" }); + + expect(count).toHaveBeenCalledWith({ tenant_id: "env-1", user_id: "user-1" }); + expect(result.data).toEqual({ count: 42 }); + expect(result.error).toBeNull(); + }); + + test("returns a relayable error when the Hub rejects the filters", async () => { + const apiError = Object.assign(new (FormbricksHub.APIError as any)("400 Bad Request", 400), { + error: { code: "validation", detail: "since must be a valid timestamp" }, + }); + vi.mocked(getHubClient).mockReturnValue({ + feedbackRecords: { count: vi.fn().mockRejectedValue(apiError) }, + } as any); + + const result = await countFeedbackRecords({ tenant_id: "env-1", since: "not-a-date" }); + + expect(result.error).toMatchObject({ + status: 400, + problemDetail: "since must be a valid timestamp", + }); + }); + }); + describe("semanticSearchFeedbackRecords", () => { test("returns error result when getHubClient returns null", async () => { vi.mocked(getHubClient).mockReturnValue(null); @@ -289,6 +330,78 @@ describe("hub service", () => { expect(result.data).toBeNull(); expect(result.error).toMatchObject({ status: 0, message: "Network error" }); }); + + // Callers map the Hub's problem members to their own response (an actionable message for the 503, + // relayed field errors on a 4xx). A hand-built error object would drop them and degrade to generic text. + test("preserves the Hub's RFC 9457 problem members", async () => { + const apiError = Object.assign(new (FormbricksHub.APIError as any)("503 unavailable", 503), { + error: { code: "service_unavailable", detail: "embeddings are not configured" }, + }); + vi.mocked(getHubClient).mockReturnValue({ + feedbackRecords: { search: { performSemanticSearch: vi.fn().mockRejectedValue(apiError) } }, + } as any); + + const result = await semanticSearchFeedbackRecords({ tenant_id: "env-1", query: "q" }); + + expect(result.error).toMatchObject({ + status: 503, + code: "service_unavailable", + problemDetail: "embeddings are not configured", + }); + }); + }); + + describe("findSimilarFeedbackRecords", () => { + test("returns config error when getHubClient returns null", async () => { + vi.mocked(getHubClient).mockReturnValue(null); + + const result = await findSimilarFeedbackRecords("rec-1"); + + expect(result.data).toBeNull(); + expect(result.error?.message).toContain("HUB_API_KEY"); + }); + + test("passes the record id and params through and returns the matches", async () => { + const response = { + data: [ + { + feedback_record_id: "018e1234-5678-9abc-def0-123456789abc", + score: 0.77, + field_label: "What can we improve?", + value_text: "payment step was unclear", + }, + ], + limit: 10, + }; + const retrieveSimilar = vi.fn().mockResolvedValue(response); + vi.mocked(getHubClient).mockReturnValue({ feedbackRecords: { retrieveSimilar } } as any); + + const result = await findSimilarFeedbackRecords("rec-1", { limit: 10, min_score: 0.5 }); + + expect(retrieveSimilar).toHaveBeenCalledWith("rec-1", { limit: 10, min_score: 0.5 }); + expect(result.data).toEqual(response); + expect(result.error).toBeNull(); + }); + + // The 404 is load-bearing: callers translate it into "no embedding yet" once they know the record + // exists, so it must arrive with its status intact rather than as a generic failure. It is also an + // expected state, so it must not be logged as a fault — a warning per call would carry a stack trace. + test("surfaces a 404 with its status, logged as debug rather than a warning", async () => { + const apiError = new (FormbricksHub.APIError as any)("embedding not found", 404); + vi.mocked(getHubClient).mockReturnValue({ + feedbackRecords: { retrieveSimilar: vi.fn().mockRejectedValue(apiError) }, + } as any); + // This file has no shared reset, so clear the shared logger spies before asserting on them. + vi.mocked(logger.warn).mockClear(); + vi.mocked(logger.debug).mockClear(); + + const result = await findSimilarFeedbackRecords("rec-1"); + + expect(result.data).toBeNull(); + expect(result.error).toMatchObject({ status: 404 }); + expect(logger.debug).toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + }); }); describe("updateFeedbackRecord", () => { @@ -364,6 +477,25 @@ describe("hub service", () => { expect(result.data).toBeNull(); expect(result.error).toMatchObject({ status: 0, message: "network" }); }); + + // A tenant purge in progress is reported as a 409 with a detail the caller relays as retryable; that + // detail only survives if the error goes through the shared problem-parsing helper. + test("preserves the Hub's RFC 9457 problem members on a conflict", async () => { + const apiError = Object.assign(new (FormbricksHub.APIError as any)("409 Conflict", 409), { + error: { code: "tenant_write_conflict", detail: "tenant data deletion in progress" }, + }); + vi.mocked(getHubClient).mockReturnValue({ + feedbackRecords: { delete: vi.fn().mockRejectedValue(apiError) }, + } as any); + + const result = await deleteFeedbackRecord("rec-1"); + + expect(result.error).toMatchObject({ + status: 409, + code: "tenant_write_conflict", + problemDetail: "tenant data deletion in progress", + }); + }); }); describe("deleteHubTenantData", () => { diff --git a/apps/web/modules/hub/service.ts b/apps/web/modules/hub/service.ts index 04d975ebd3ec..cefc1bf78240 100644 --- a/apps/web/modules/hub/service.ts +++ b/apps/web/modules/hub/service.ts @@ -6,6 +6,8 @@ import { getHubClient } from "./hub-client"; import type { CreateTaxonomyRunInput, CreateTaxonomyRunResponse, + FeedbackRecordCountParams, + FeedbackRecordCountResponse, FeedbackRecordCreateParams, FeedbackRecordData, FeedbackRecordListParams, @@ -15,6 +17,8 @@ import type { RenameTaxonomyNodeInput, SemanticSearchInput, SemanticSearchResponse, + SimilarRecordsParams, + SimilarRecordsResponse, TaxonomyFieldsResponse, TaxonomyNode, TaxonomyNodeRecordsResponse, @@ -118,9 +122,9 @@ export const deleteFeedbackRecord = async (id: string): Promise => { + const client = getHubClient(); + if (!client) { + return { data: null, error: { ...NO_CONFIG_ERROR } }; + } + try { + const data = await client.feedbackRecords.count(params); + return { data, error: null }; + } catch (err) { + logger.warn({ err, tenantId: params.tenant_id }, "Hub: countFeedbackRecords failed"); + return createHubResultFromError(err); } }; +/** + * Semantic (vector) search over a tenant's feedback records. Only available when the Hub has an embedding + * model configured — otherwise it fails with 503, which callers surface as an actionable message. + * + * The query text is never logged: it is caller-authored content. + */ export const semanticSearchFeedbackRecords = async ( input: SemanticSearchInput ): Promise => { @@ -223,9 +259,46 @@ export const semanticSearchFeedbackRecords = async ( return { data, error: null }; } catch (err) { logger.warn({ err, tenantId: input.tenant_id }, "Hub: semanticSearchFeedbackRecords failed"); - const status = getErrorStatus(err); - const message = getErrorMessage(err); - return { data: null, error: { status, message, detail: message } }; + // Via the shared helper so the Hub's problem members survive — most importantly the 503 that says + // embeddings aren't configured, which would otherwise be indistinguishable from an outage. + return createHubResultFromError(err); + } +}; + +export type SimilarFeedbackRecordsResult = { + data: SimilarRecordsResponse | null; + error: HubError | null; +}; + +/** + * Nearest neighbours of one feedback record, by embedding similarity. The Hub derives the tenant from the + * record itself and applies no authorization, so callers MUST verify the record belongs to them first + * (see `requireOwnedFeedbackRecord`) — otherwise this reads across tenants. + * + * A 404 here means "no embedding for this record", not "no such record": the row may exist but not have + * been embedded yet (embedding is asynchronous, and records without text are never embedded). + */ +export const findSimilarFeedbackRecords = async ( + id: string, + params: SimilarRecordsParams = {} +): Promise => { + const client = getHubClient(); + if (!client) { + return { data: null, error: { ...NO_CONFIG_ERROR } }; + } + try { + const data = await client.feedbackRecords.retrieveSimilar(id, params); + return { data, error: null }; + } catch (err) { + // "No embedding for this record" is an expected state, not a fault: embedding is asynchronous and + // records without text are never embedded. Logged at debug so the normal case doesn't write a stack + // trace on every call — same treatment as a missing taxonomy tree below. + if (getErrorStatus(err) === 404) { + logger.debug({ id }, "Hub: no embedding for feedback record yet"); + } else { + logger.warn({ err, id }, "Hub: findSimilarFeedbackRecords failed"); + } + return createHubResultFromError(err); } }; diff --git a/apps/web/modules/hub/types.ts b/apps/web/modules/hub/types.ts index dce3fd7dc2cc..e673ba0c9347 100644 --- a/apps/web/modules/hub/types.ts +++ b/apps/web/modules/hub/types.ts @@ -26,10 +26,22 @@ export type FeedbackRecordListResponse = Omit { + test("extracts code, detail, and invalid_params from a Hub problem body", () => { + const err = { + status: 422, + error: { + code: "validation", + detail: "One or more request parameters are invalid", + invalid_params: [{ name: "field_type", reason: "must be one of: text, nps" }], + }, + }; + + expect(getErrorProblem(err)).toEqual({ + code: "validation", + problemDetail: "One or more request parameters are invalid", + invalidParams: [{ name: "field_type", reason: "must be one of: text, nps" }], + }); + }); + + test("returns an empty object when there is no problem body", () => { + expect(getErrorProblem(new Error("connection refused"))).toEqual({}); + expect(getErrorProblem(undefined)).toEqual({}); + expect(getErrorProblem("string error")).toEqual({}); + expect(getErrorProblem({ status: 500, error: "not an object" })).toEqual({}); + }); + + test("drops malformed invalid_params entries instead of surfacing partial ones", () => { + const err = { + error: { + invalid_params: [{ name: "ok", reason: "valid" }, { name: 42 }, null, { reason: "no name" }], + }, + }; + + expect(getErrorProblem(err).invalidParams).toEqual([{ name: "ok", reason: "valid" }]); + }); + + test("omits invalid_params entirely when none survive validation", () => { + expect(getErrorProblem({ error: { invalid_params: [{ bogus: true }] } })).toEqual({}); + }); +}); + +describe("createHubResultFromError", () => { + test("keeps message/detail semantics and adds the problem members", () => { + // Shaped like a real SDK APIError: an Error subclass carrying status + the parsed problem body. + const err = Object.assign(new Error("400 Bad Request"), { + status: 400, + error: { code: "bad_request", detail: "cursor is not valid" }, + }); + + expect(createHubResultFromError(err)).toEqual({ + data: null, + error: { + status: 400, + message: "400 Bad Request", + detail: "400 Bad Request", + code: "bad_request", + problemDetail: "cursor is not valid", + }, + }); + }); +}); diff --git a/apps/web/modules/hub/utils.ts b/apps/web/modules/hub/utils.ts index 6f846135f8b1..fe3fefc5062a 100644 --- a/apps/web/modules/hub/utils.ts +++ b/apps/web/modules/hub/utils.ts @@ -1,4 +1,21 @@ -export type HubError = { status: number; message: string; detail: string }; +/** A single field-level validation failure from the Hub's RFC 9457 `invalid_params` extension. */ +export type HubInvalidParam = { name: string; reason: string }; + +export type HubError = { + status: number; + message: string; + detail: string; + /** + * Members parsed off the Hub's RFC 9457 problem body, when it returned one. Additive: `message` and + * `detail` keep their existing meaning for current consumers. + * + * Only safe to relay to an API caller for 4xx — those describe the caller's own input. Never relay + * them on 5xx, where they can carry upstream internals. + */ + code?: string; + problemDetail?: string; + invalidParams?: HubInvalidParam[]; +}; export type HubResult = { data: T | null; @@ -24,8 +41,36 @@ export const getErrorStatus = (err: unknown): number => ? (err as { status: number }).status : 0; +const asRecord = (value: unknown): Record | null => + typeof value === "object" && value !== null ? (value as Record) : null; + +/** + * Reads the RFC 9457 problem members off a Hub SDK error (which exposes the parsed JSON body as + * `error`). Duck-typed for the same reason as `getErrorStatus`: `instanceof` against the SDK error + * class breaks when @formbricks/hub is loaded into more than one module scope under Next dev/Turbopack. + */ +export const getErrorProblem = (err: unknown): Pick => { + const body = asRecord(asRecord(err)?.error); + if (!body) return {}; + + const invalidParams = Array.isArray(body.invalid_params) + ? body.invalid_params.flatMap((entry) => { + const param = asRecord(entry); + return param && typeof param.name === "string" && typeof param.reason === "string" + ? [{ name: param.name, reason: param.reason }] + : []; + }) + : []; + + return { + ...(typeof body.code === "string" ? { code: body.code } : {}), + ...(typeof body.detail === "string" ? { problemDetail: body.detail } : {}), + ...(invalidParams.length > 0 ? { invalidParams } : {}), + }; +}; + export const createHubResultFromError = (err: unknown): HubResult => { const status = getErrorStatus(err); const message = getErrorMessage(err); - return { data: null, error: { status, message, detail: message } }; + return { data: null, error: { status, message, detail: message, ...getErrorProblem(err) } }; }; diff --git a/apps/web/modules/mcp/auth.test.ts b/apps/web/modules/mcp/auth.test.ts index 7fbef0a52ca4..2e5b76797d2b 100644 --- a/apps/web/modules/mcp/auth.test.ts +++ b/apps/web/modules/mcp/auth.test.ts @@ -46,8 +46,17 @@ vi.mock("@/modules/core/rate-limit/helpers", () => ({ })); vi.mock("@/modules/auth/lib/oauth-urls", () => ({ - MCP_OAUTH_SCOPES: ["openid", "profile", "email", "offline_access", "surveys:read", "surveys:write"], - MCP_RESOURCE_SCOPES: ["surveys:read", "surveys:write"], + MCP_OAUTH_SCOPES: [ + "openid", + "profile", + "email", + "offline_access", + "surveys:read", + "surveys:write", + "feedbackRecords:read", + "feedbackRecords:write", + ], + MCP_RESOURCE_SCOPES: ["surveys:read", "surveys:write", "feedbackRecords:read", "feedbackRecords:write"], getAuthIssuerUrl: vi.fn(() => "https://app.example.com/api/auth"), getMcpOrigin: vi.fn(() => "https://app.example.com"), getMcpProtectedResourceMetadataUrl: vi.fn( @@ -105,9 +114,11 @@ describe("authenticateMcpRequest", () => { expect(result.ok).toBe(false); if (!result.ok) { expect(result.response.status).toBe(401); - // The challenge must advertise read + write so clients request write at consent and can reach - // the write tools (advertising only read is why write was unreachable — ENG-1055 QA). - expect(result.response.headers.get("WWW-Authenticate")).toContain('scope="surveys:read surveys:write"'); + // The challenge must advertise every resource scope so clients request them at consent and can + // reach the write tools (advertising only read is why write was unreachable — ENG-1055 QA). + expect(result.response.headers.get("WWW-Authenticate")).toContain( + 'scope="surveys:read surveys:write feedbackRecords:read feedbackRecords:write"' + ); expect(await result.response.json()).toMatchObject({ code: "not_authenticated", detail: "API key or OAuth access token required", @@ -213,10 +224,37 @@ describe("authenticateMcpRequest", () => { expect(result.authInfo.token).toBe("key_1"); expect(getMcpAuthentication(result.authInfo)).toEqual(apiKeyAuth); expect(getMcpRequestId(result.authInfo)).toBe("req_1"); + // A write-capable key must reach both tool groups' read AND write tools. + expect(result.authInfo.scopes).toEqual( + expect.arrayContaining([ + "surveys:read", + "surveys:write", + "feedbackRecords:read", + "feedbackRecords:write", + ]) + ); } expect(applyRateLimit).toHaveBeenCalledWith(expect.objectContaining({ namespace: "api:v3" }), "key_1"); }); + test("grants only read scopes to a read-only API key", async () => { + vi.mocked(authenticateApiKeyFromHeaders).mockResolvedValue({ + ...apiKeyAuth, + workspacePermissions: [ + { workspaceId: "workspace_1", workspaceName: "Workspace", permission: ApiKeyPermission.read }, + ], + }); + + const result = await authenticateMcpRequest( + createRequest("http://localhost/api/mcp", { "x-api-key": "fbk_test" }) + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.authInfo.scopes).toEqual(["surveys:read", "feedbackRecords:read"]); + } + }); + test("returns 429 when rate limited", async () => { vi.mocked(authenticateApiKeyFromHeaders).mockResolvedValue(apiKeyAuth); vi.mocked(applyRateLimit).mockRejectedValue(new TooManyRequestsError("Too many requests", 30)); @@ -314,11 +352,11 @@ describe("authenticateMcpRequest", () => { expect(applyRateLimit).not.toHaveBeenCalled(); }); - test("rejects OAuth bearer tokens without the read scope", async () => { + test("rejects OAuth bearer tokens holding no MCP resource scope at all", async () => { verifyAccessTokenMock.mockResolvedValue({ sub: "user_1", client_id: "client_2", - scope: "surveys:write", + scope: "openid profile email", }); const result = await authenticateMcpRequest( @@ -331,11 +369,33 @@ describe("authenticateMcpRequest", () => { if (!result.ok) { expect(result.response.status).toBe(403); expect(result.response.headers.get("WWW-Authenticate")).toContain('error="insufficient_scope"'); - expect(result.response.headers.get("WWW-Authenticate")).toContain('scope="surveys:read"'); + expect(result.response.headers.get("WWW-Authenticate")).toContain( + 'scope="surveys:read surveys:write feedbackRecords:read feedbackRecords:write"' + ); } expect(applyRateLimit).not.toHaveBeenCalled(); }); + // Any single resource scope is enough to authenticate: a feedbackRecords-only grant is a legitimate + // MCP client and must not be turned away for lacking surveys:read (per-tool guards still apply). + test.each([["feedbackRecords:read"], ["surveys:write"]])( + "authenticates an OAuth token scoped only to %s", + async (scope) => { + verifyAccessTokenMock.mockResolvedValue({ sub: "user_1", azp: "client_1", scope }); + + const result = await authenticateMcpRequest( + createRequest("http://localhost/api/mcp", { + authorization: "Bearer oauth_access_token", + }) + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.authInfo.scopes).toEqual([scope]); + } + } + ); + test("rejects OAuth bearer tokens without a user subject", async () => { verifyAccessTokenMock.mockResolvedValue({ azp: "client_1", diff --git a/apps/web/modules/mcp/auth.ts b/apps/web/modules/mcp/auth.ts index ac5bc39d11ff..97a66c5cc1bb 100644 --- a/apps/web/modules/mcp/auth.ts +++ b/apps/web/modules/mcp/auth.ts @@ -37,8 +37,11 @@ const QUERY_CREDENTIAL_PARAMS = new Set([ "authorization", ]); -// Minimum scope required to authenticate against the MCP server at all. -const DEFAULT_OAUTH_SCOPE = "surveys:read"; +// Minimum grant required to authenticate against the MCP server at all: at least ONE resource scope. +// Any single one is enough — a token granted only `feedbackRecords:read` is a legitimate MCP client and +// must not be rejected here for lacking `surveys:read`. Which tools it can actually call is enforced +// per-tool by guardMcpScopes at call time. +const MCP_MINIMUM_SCOPES = MCP_RESOURCE_SCOPES; // Scopes advertised in the 401 WWW-Authenticate challenge. Clients build their DCR + authorize // requests from this, so it must list every resource scope (read + write) or clients only ever // request read and can never reach the write tools. Actual write access is still gated downstream @@ -92,13 +95,14 @@ function isOriginAllowed(request: NextRequest): boolean { } function getMcpScopes(authentication: TAuthenticationApiKey): string[] { - const scopes = new Set(["surveys:read"]); + const scopes = new Set(["surveys:read", "feedbackRecords:read"]); if ( authentication.workspacePermissions.some( (permission) => permission.permission === "write" || permission.permission === "manage" ) ) { scopes.add("surveys:write"); + scopes.add("feedbackRecords:write"); } return Array.from(scopes); @@ -365,14 +369,14 @@ async function authenticateMcpOAuthBearer( }; } - if (!hasMcpScopes(authInfo, [DEFAULT_OAUTH_SCOPE])) { - log.warn({ statusCode: 403, clientId: authInfo.clientId }, "MCP OAuth token missing read scope"); + if (!hasAnyMcpScope(authInfo, MCP_MINIMUM_SCOPES)) { + log.warn({ statusCode: 403, clientId: authInfo.clientId }, "MCP OAuth token missing every MCP scope"); return { ok: false, requestId, response: withInsufficientScopeChallenge( problemForbidden(requestId, "OAuth token does not include the required MCP scope", instance), - [DEFAULT_OAUTH_SCOPE] + [...MCP_MINIMUM_SCOPES] ), }; } @@ -407,6 +411,12 @@ export function hasMcpScopes(authInfo: AuthInfo | undefined, requiredScopes: str return requiredScopes.every((scope) => scopes.includes(scope)); } +/** True when the caller holds at least one of `allowedScopes` (any-of, unlike `hasMcpScopes`). */ +export function hasAnyMcpScope(authInfo: AuthInfo | undefined, allowedScopes: readonly string[]): boolean { + const scopes = authInfo?.scopes ?? []; + return allowedScopes.some((scope) => scopes.includes(scope)); +} + export function createMcpInsufficientScopeResponse(requestId: string, scopes: string[]): Response { return withInsufficientScopeChallenge( problemForbidden(requestId, "OAuth token does not include the required MCP scope", "/api/mcp"), diff --git a/apps/web/modules/mcp/constants.ts b/apps/web/modules/mcp/constants.ts index cdd85abbcb0c..ccc814a35b66 100644 --- a/apps/web/modules/mcp/constants.ts +++ b/apps/web/modules/mcp/constants.ts @@ -1,3 +1,5 @@ export const MCP_API_ROUTE = "/api/mcp" as const; -export const MCP_SERVER_NAME = "formbricks-v3-surveys" as const; -export const MCP_SERVER_VERSION = "0.1.0" as const; +// Names the whole XM Suite surface, not just surveys — the server also exposes workspace discovery and +// feedback records. Client config keys are user-chosen, so this is a display name only. +export const MCP_SERVER_NAME = "formbricks-xm-suite" as const; +export const MCP_SERVER_VERSION = "0.2.0" as const; diff --git a/apps/web/modules/mcp/server.ts b/apps/web/modules/mcp/server.ts index a3a8e0ee987a..83a3718cd5dd 100644 --- a/apps/web/modules/mcp/server.ts +++ b/apps/web/modules/mcp/server.ts @@ -1,6 +1,7 @@ import "server-only"; import { createMcpHandler } from "mcp-handler"; import { MCP_SERVER_NAME, MCP_SERVER_VERSION } from "./constants"; +import { registerFeedbackRecordTools } from "./tools/feedback-records"; import { registerSurveyTools } from "./tools/surveys"; import { registerWorkspaceTools } from "./tools/workspaces"; @@ -8,6 +9,7 @@ export const mcpHandler = createMcpHandler( (server) => { registerSurveyTools(server); registerWorkspaceTools(server); + registerFeedbackRecordTools(server); }, { serverInfo: { diff --git a/apps/web/modules/mcp/tools/feedback-records.test.ts b/apps/web/modules/mcp/tools/feedback-records.test.ts new file mode 100644 index 000000000000..d40c84e8300b --- /dev/null +++ b/apps/web/modules/mcp/tools/feedback-records.test.ts @@ -0,0 +1,551 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { ApiKeyPermission } from "@formbricks/database/prisma"; +import { + countV3FeedbackRecords, + createV3FeedbackRecord, + createV3FeedbackRecords, + deleteV3FeedbackRecord, + findSimilarV3FeedbackRecords, + getV3FeedbackRecord, + listV3FeedbackDatasets, + listV3FeedbackRecords, + searchV3FeedbackRecords, + updateV3FeedbackRecord, +} from "@/app/api/v3/feedbackRecords/lib/operations"; +import { buildV3AuditLog, queueV3AuditLog } from "@/app/api/v3/lib/audit"; +import { + noContentResponse, + problemBadRequest, + problemForbidden, + successListResponse, + successResponse, +} from "@/app/api/v3/lib/response"; +import { registerFeedbackRecordTools } from "./feedback-records"; + +vi.mock("@/app/api/v3/feedbackRecords/lib/operations", () => ({ + countV3FeedbackRecords: vi.fn(), + createV3FeedbackRecord: vi.fn(), + createV3FeedbackRecords: vi.fn(), + deleteV3FeedbackRecord: vi.fn(), + findSimilarV3FeedbackRecords: vi.fn(), + getV3FeedbackRecord: vi.fn(), + listV3FeedbackDatasets: vi.fn(), + listV3FeedbackRecords: vi.fn(), + searchV3FeedbackRecords: vi.fn(), + updateV3FeedbackRecord: vi.fn(), +})); + +vi.mock("@/app/api/v3/lib/audit", () => ({ + buildV3AuditLog: vi.fn(), + queueV3AuditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { withContext: vi.fn(() => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn() })) }, +})); + +const recordId = "019fa338-f494-7384-b34e-01739783d280"; + +const workspaceId = "clxx1234567890123456789012"; +const directoryId = "clfd1234567890123456789012"; + +const apiKeyAuth = { + type: "apiKey" as const, + apiKeyId: "key_1", + organizationId: "org_1", + organizationAccess: { accessControl: { read: true, write: true } }, + workspacePermissions: [{ workspaceId, workspaceName: "Workspace", permission: ApiKeyPermission.write }], +}; + +const authInfo = { + token: "key_1", + clientId: "key_1", + scopes: ["feedbackRecords:read", "feedbackRecords:write"], + extra: { formbricksAuthentication: apiKeyAuth, requestId: "req_tool" }, +}; + +const readOnlyAuthInfo = { ...authInfo, scopes: ["feedbackRecords:read"] }; +const noFeedbackScopeAuthInfo = { ...authInfo, scopes: ["surveys:read", "surveys:write"] }; + +function createToolServer() { + const tools = new Map< + string, + { config: Record; handler: (input: any, extra: any) => Promise } + >(); + const server = { + registerTool: vi.fn((name: string, config: Record, handler: any) => { + tools.set(name, { config, handler }); + }), + }; + registerFeedbackRecordTools(server as any); + return { server, tools }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("registerFeedbackRecordTools", () => { + test("registers the ten feedback-record tools in order", () => { + const { tools } = createToolServer(); + expect(Array.from(tools.keys())).toEqual([ + "list_feedback_datasets", + "list_feedback_records", + "count_feedback_records", + "get_feedback_record", + "create_feedback_record", + "create_feedback_records", + "update_feedback_record", + "delete_feedback_record", + "search_feedback_records", + "find_similar_feedback_records", + ]); + }); + + test("marks read tools read-only and create as a non-idempotent write", () => { + const { tools } = createToolServer(); + expect(tools.get("list_feedback_records")!.config.annotations).toMatchObject({ + readOnlyHint: true, + idempotentHint: true, + }); + expect(tools.get("create_feedback_record")!.config.annotations).toMatchObject({ + readOnlyHint: false, + idempotentHint: false, + }); + }); + + // The destructive hint is what lets a client warn (or ask) before an irreversible call, so it is pinned: + // delete is the only tool here that removes data, and the searches must not be mistaken for writes. + test("marks update destructive too, matching patch_survey", () => { + const { tools } = createToolServer(); + expect(tools.get("update_feedback_record")!.config.annotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }); + }); + + test("marks delete destructive, and both searches as read-only", () => { + const { tools } = createToolServer(); + expect(tools.get("delete_feedback_record")!.config.annotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + }); + for (const name of ["search_feedback_records", "find_similar_feedback_records"]) { + expect(tools.get(name)!.config.annotations).toMatchObject({ + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + }); + } + }); +}); + +describe("list_feedback_datasets", () => { + test("delegates to listV3FeedbackDatasets and returns structured content", async () => { + vi.mocked(listV3FeedbackDatasets).mockResolvedValue( + successListResponse( + [{ id: directoryId, name: "Support" }], + { nextCursor: null, totalCount: 1 }, + { + requestId: "req_tool", + } + ) + ); + const { tools } = createToolServer(); + + const result = await tools.get("list_feedback_datasets")!.handler({ workspaceId }, { authInfo }); + + expect(listV3FeedbackDatasets).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId, authentication: apiKeyAuth, instance: "/api/mcp" }) + ); + expect(result.structuredContent.data).toEqual([{ id: directoryId, name: "Support" }]); + }); +}); + +describe("list_feedback_records", () => { + test("passes filters through to listV3FeedbackRecords", async () => { + vi.mocked(listV3FeedbackRecords).mockResolvedValue( + successListResponse([], { limit: 25, nextCursor: null }, { requestId: "req_tool" }) + ); + const { tools } = createToolServer(); + + await tools + .get("list_feedback_records")! + .handler( + { workspaceId, datasetId: directoryId, limit: 25, source_type: "survey", field_type: "text" }, + { authInfo } + ); + + expect(listV3FeedbackRecords).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId, + datasetId: directoryId, + limit: 25, + source_type: "survey", + field_type: "text", + }) + ); + }); + + test("returns an insufficient-scope error and skips the op without the read scope", async () => { + const { tools } = createToolServer(); + + const result = await tools + .get("list_feedback_records")! + .handler({ workspaceId }, { authInfo: noFeedbackScopeAuthInfo }); + + expect(result.isError).toBe(true); + expect(listV3FeedbackRecords).not.toHaveBeenCalled(); + }); +}); + +describe("get_feedback_record", () => { + test("delegates to getV3FeedbackRecord with the record id", async () => { + vi.mocked(getV3FeedbackRecord).mockResolvedValue( + successResponse({ id: "rec-1" }, { requestId: "req_tool" }) + ); + const { tools } = createToolServer(); + + await tools + .get("get_feedback_record")! + .handler({ workspaceId, feedbackRecordId: recordId }, { authInfo }); + + expect(getV3FeedbackRecord).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId, feedbackRecordId: recordId }) + ); + }); +}); + +describe("create_feedback_record", () => { + const body = { + workspaceId, + source_type: "call_notes", + field_id: "note", + field_type: "text", + value_text: "hi", + }; + + test("delegates with body, builds and queues the audit log, and marks success", async () => { + const auditLog = { status: "attempted" } as any; + vi.mocked(buildV3AuditLog).mockReturnValue(auditLog); + vi.mocked(createV3FeedbackRecord).mockResolvedValue( + successResponse({ id: "rec-1" }, { requestId: "req_tool", status: 201 }) + ); + const { tools } = createToolServer(); + + await tools.get("create_feedback_record")!.handler(body, { authInfo }); + + expect(buildV3AuditLog).toHaveBeenCalledWith(apiKeyAuth, "created", "feedbackRecord", "/api/mcp"); + expect(createV3FeedbackRecord).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId, body, authentication: apiKeyAuth }) + ); + expect(auditLog.status).toBe("success"); + expect(queueV3AuditLog).toHaveBeenCalled(); + }); + + test("requires the write scope", async () => { + const { tools } = createToolServer(); + + const result = await tools.get("create_feedback_record")!.handler(body, { authInfo: readOnlyAuthInfo }); + + expect(result.isError).toBe(true); + expect(createV3FeedbackRecord).not.toHaveBeenCalled(); + }); + + // The invariant is "a failed mutation is never silently unaudited" — including when the operation throws + // rather than returning a problem response. Without this the catch branch is unguarded. + test("still queues a failure audit event when the operation throws, and rethrows", async () => { + const auditLog = { status: "failure" } as any; + vi.mocked(buildV3AuditLog).mockReturnValue(auditLog); + vi.mocked(createV3FeedbackRecord).mockRejectedValue(new Error("boom")); + const { tools } = createToolServer(); + + await expect(tools.get("create_feedback_record")!.handler(body, { authInfo })).rejects.toThrow("boom"); + + expect(auditLog.eventId).toBe("req_tool"); + expect(queueV3AuditLog).toHaveBeenCalledWith(auditLog, "req_tool", expect.anything()); + }); + + // buildAuditLogBaseObject seeds status:"failure"; a failed create must keep that and carry an eventId + // for correlation. The operations layer intentionally no longer sets eventId (this layer does), so this + // is the only guard on that invariant. + test("queues a failure audit event with an eventId when the create fails", async () => { + const auditLog = { status: "failure" } as any; + vi.mocked(buildV3AuditLog).mockReturnValue(auditLog); + vi.mocked(createV3FeedbackRecord).mockResolvedValue( + problemBadRequest("req_tool", "rejected", { instance: "/api/mcp" }) + ); + const { tools } = createToolServer(); + + const result = await tools.get("create_feedback_record")!.handler(body, { authInfo }); + + expect(result.isError).toBe(true); + expect(auditLog.status).toBe("failure"); + expect(auditLog.eventId).toBe("req_tool"); + expect(queueV3AuditLog).toHaveBeenCalledWith(auditLog, "req_tool", expect.anything()); + }); +}); + +describe("delete_feedback_record", () => { + const input = { workspaceId, feedbackRecordId: recordId, datasetId: directoryId }; + + test("delegates to deleteV3FeedbackRecord and audits the deletion as a success", async () => { + const auditLog = { status: "failure" } as any; + vi.mocked(buildV3AuditLog).mockReturnValue(auditLog); + vi.mocked(deleteV3FeedbackRecord).mockResolvedValue(noContentResponse({ requestId: "req_tool" })); + const { tools } = createToolServer(); + + const result = await tools.get("delete_feedback_record")!.handler(input, { authInfo }); + + expect(buildV3AuditLog).toHaveBeenCalledWith(apiKeyAuth, "deleted", "feedbackRecord", "/api/mcp"); + expect(deleteV3FeedbackRecord).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId, + feedbackRecordId: recordId, + datasetId: directoryId, + authentication: apiKeyAuth, + auditLog, + }) + ); + expect(auditLog.status).toBe("success"); + expect(queueV3AuditLog).toHaveBeenCalled(); + // A 204 carries no body; the tool result must still read as a success, not an error. + expect(result.isError).toBeUndefined(); + }); + + // A read-only key must not be able to destroy data — the single most important guard on this tool. + test("requires the write scope and never reaches the operation", async () => { + const { tools } = createToolServer(); + + const result = await tools.get("delete_feedback_record")!.handler(input, { authInfo: readOnlyAuthInfo }); + + expect(result.isError).toBe(true); + expect(deleteV3FeedbackRecord).not.toHaveBeenCalled(); + }); + + test("queues a failure audit event with an eventId when the delete is refused", async () => { + const auditLog = { status: "failure" } as any; + vi.mocked(buildV3AuditLog).mockReturnValue(auditLog); + vi.mocked(deleteV3FeedbackRecord).mockResolvedValue( + problemForbidden("req_tool", "You are not authorized to access this feedback record", "/api/mcp") + ); + const { tools } = createToolServer(); + + const result = await tools.get("delete_feedback_record")!.handler(input, { authInfo }); + + expect(result.isError).toBe(true); + expect(auditLog.status).toBe("failure"); + expect(auditLog.eventId).toBe("req_tool"); + expect(queueV3AuditLog).toHaveBeenCalledWith(auditLog, "req_tool", expect.anything()); + }); +}); + +describe("search_feedback_records", () => { + test("passes the query and similarity params through", async () => { + vi.mocked(searchV3FeedbackRecords).mockResolvedValue( + successListResponse( + [{ feedback_record_id: recordId, score: 0.82, field_label: "Q1", value_text: "slow checkout" }], + { limit: 10, nextCursor: null, minScore: 0.5 }, + { requestId: "req_tool" } + ) + ); + const { tools } = createToolServer(); + + const result = await tools + .get("search_feedback_records")! + .handler({ workspaceId, query: "checkout is confusing", limit: 5, minScore: 0.6 }, { authInfo }); + + expect(searchV3FeedbackRecords).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId, + query: "checkout is confusing", + limit: 5, + minScore: 0.6, + authentication: apiKeyAuth, + instance: "/api/mcp", + }) + ); + expect(result.structuredContent.data).toEqual([ + { feedback_record_id: recordId, score: 0.82, field_label: "Q1", value_text: "slow checkout" }, + ]); + }); + + test("requires the read scope", async () => { + const { tools } = createToolServer(); + + const result = await tools + .get("search_feedback_records")! + .handler({ workspaceId, query: "anything" }, { authInfo: noFeedbackScopeAuthInfo }); + + expect(result.isError).toBe(true); + expect(searchV3FeedbackRecords).not.toHaveBeenCalled(); + }); +}); + +describe("find_similar_feedback_records", () => { + test("delegates to findSimilarV3FeedbackRecords with the anchor record id", async () => { + vi.mocked(findSimilarV3FeedbackRecords).mockResolvedValue( + successListResponse([], { limit: 10, nextCursor: null, minScore: 0.5 }, { requestId: "req_tool" }) + ); + const { tools } = createToolServer(); + + await tools + .get("find_similar_feedback_records")! + .handler({ workspaceId, feedbackRecordId: recordId, minScore: 0.9 }, { authInfo }); + + expect(findSimilarV3FeedbackRecords).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId, feedbackRecordId: recordId, minScore: 0.9 }) + ); + }); + + test("requires the read scope", async () => { + const { tools } = createToolServer(); + + const result = await tools + .get("find_similar_feedback_records")! + .handler({ workspaceId, feedbackRecordId: recordId }, { authInfo: noFeedbackScopeAuthInfo }); + + expect(result.isError).toBe(true); + expect(findSimilarV3FeedbackRecords).not.toHaveBeenCalled(); + }); +}); + +describe("count_feedback_records", () => { + test("passes the filters through and returns the count", async () => { + vi.mocked(countV3FeedbackRecords).mockResolvedValue( + successResponse( + { count: 7, dataset_id: directoryId, dataset_name: "Support" }, + { requestId: "req_tool" } + ) + ); + const { tools } = createToolServer(); + + const result = await tools + .get("count_feedback_records")! + .handler({ workspaceId, user_id: "user-1", field_type: "text" }, { authInfo }); + + expect(countV3FeedbackRecords).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId, user_id: "user-1", field_type: "text", instance: "/api/mcp" }) + ); + expect(result.structuredContent.data.count).toBe(7); + }); + + test("requires the read scope", async () => { + const { tools } = createToolServer(); + + const result = await tools + .get("count_feedback_records")! + .handler({ workspaceId }, { authInfo: noFeedbackScopeAuthInfo }); + + expect(result.isError).toBe(true); + expect(countV3FeedbackRecords).not.toHaveBeenCalled(); + }); +}); + +describe("create_feedback_records", () => { + const records = [ + { source_type: "call_notes", field_id: "note", field_type: "text", value_text: "one" }, + { source_type: "call_notes", field_id: "note", field_type: "text", value_text: "two" }, + ]; + const input = { workspaceId, records }; + + test("requires the write scope", async () => { + const { tools } = createToolServer(); + + const result = await tools.get("create_feedback_records")!.handler(input, { authInfo: readOnlyAuthInfo }); + + expect(result.isError).toBe(true); + expect(createV3FeedbackRecords).not.toHaveBeenCalled(); + }); + + // One creation is one audit event, so a batch of N creations must be N events — not one summary event + // that loses which records were written. + test("queues one success audit event per created record", async () => { + const built: any[] = []; + vi.mocked(buildV3AuditLog).mockImplementation(() => { + const auditLog = { status: "failure" } as any; + built.push(auditLog); + return auditLog; + }); + vi.mocked(createV3FeedbackRecords).mockImplementation(async ({ auditLogs }: any) => { + // Stand in for the operation: it stamps only the records it actually created. + auditLogs[0].targetId = "rec-1"; + return successListResponse([{ id: "rec-1" }], { created: 1, failed: 1 }, { requestId: "req_tool" }); + }); + const { tools } = createToolServer(); + + await tools.get("create_feedback_records")!.handler(input, { authInfo }); + + expect(built).toHaveLength(2); + expect(queueV3AuditLog).toHaveBeenCalledTimes(1); + expect(built[0].status).toBe("success"); + expect(built[1].status).toBe("failure"); + }); + + test("still queues a failure event when the batch operation throws, and rethrows", async () => { + const built: any[] = []; + vi.mocked(buildV3AuditLog).mockImplementation(() => { + const auditLog = { status: "failure" } as any; + built.push(auditLog); + return auditLog; + }); + vi.mocked(createV3FeedbackRecords).mockRejectedValue(new Error("boom")); + const { tools } = createToolServer(); + + await expect(tools.get("create_feedback_records")!.handler(input, { authInfo })).rejects.toThrow("boom"); + + expect(queueV3AuditLog).toHaveBeenCalledTimes(1); + expect(built[0].eventId).toBe("req_tool"); + }); + + test("queues a single failure event when nothing was created", async () => { + const built: any[] = []; + vi.mocked(buildV3AuditLog).mockImplementation(() => { + const auditLog = { status: "failure" } as any; + built.push(auditLog); + return auditLog; + }); + vi.mocked(createV3FeedbackRecords).mockResolvedValue( + problemBadRequest("req_tool", "rejected", { instance: "/api/mcp" }) + ); + const { tools } = createToolServer(); + + const result = await tools.get("create_feedback_records")!.handler(input, { authInfo }); + + expect(result.isError).toBe(true); + expect(queueV3AuditLog).toHaveBeenCalledTimes(1); + expect(built[0].eventId).toBe("req_tool"); + }); +}); + +describe("update_feedback_record", () => { + const input = { workspaceId, feedbackRecordId: recordId, value_text: "corrected" }; + + test("delegates with the whole input as the body and audits as an update", async () => { + const auditLog = { status: "failure" } as any; + vi.mocked(buildV3AuditLog).mockReturnValue(auditLog); + vi.mocked(updateV3FeedbackRecord).mockResolvedValue( + successResponse({ id: recordId, value_text: "corrected" }, { requestId: "req_tool" }) + ); + const { tools } = createToolServer(); + + await tools.get("update_feedback_record")!.handler(input, { authInfo }); + + expect(buildV3AuditLog).toHaveBeenCalledWith(apiKeyAuth, "updated", "feedbackRecord", "/api/mcp"); + expect(updateV3FeedbackRecord).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId, feedbackRecordId: recordId, body: input, auditLog }) + ); + expect(auditLog.status).toBe("success"); + }); + + test("requires the write scope", async () => { + const { tools } = createToolServer(); + + const result = await tools.get("update_feedback_record")!.handler(input, { authInfo: readOnlyAuthInfo }); + + expect(result.isError).toBe(true); + expect(updateV3FeedbackRecord).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/modules/mcp/tools/feedback-records.ts b/apps/web/modules/mcp/tools/feedback-records.ts new file mode 100644 index 000000000000..0ecb1aa015ac --- /dev/null +++ b/apps/web/modules/mcp/tools/feedback-records.ts @@ -0,0 +1,419 @@ +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { logger } from "@formbricks/logger"; +import { + countV3FeedbackRecords, + createV3FeedbackRecord, + createV3FeedbackRecords, + deleteV3FeedbackRecord, + findSimilarV3FeedbackRecords, + getV3FeedbackRecord, + listV3FeedbackDatasets, + listV3FeedbackRecords, + searchV3FeedbackRecords, + updateV3FeedbackRecord, +} from "@/app/api/v3/feedbackRecords/lib/operations"; +import { buildV3AuditLog, queueV3AuditLog } from "@/app/api/v3/lib/audit"; +import type { TV3AuditLog, TV3Authentication } from "@/app/api/v3/lib/types"; +import { MCP_API_ROUTE } from "@/modules/mcp/constants"; +import { getMcpAuthentication, getMcpRequestId } from "../auth"; +import { responseToMcpToolResult } from "../errors"; +import { guardMcpScopes } from "./guard-scopes"; +import { + type TMcpCountFeedbackRecordsInput, + type TMcpCreateFeedbackRecordInput, + type TMcpCreateFeedbackRecordsInput, + type TMcpDeleteFeedbackRecordInput, + type TMcpFindSimilarFeedbackRecordsInput, + type TMcpGetFeedbackRecordInput, + type TMcpListFeedbackDatasetsInput, + type TMcpListFeedbackRecordsInput, + type TMcpSearchFeedbackRecordsInput, + type TMcpUpdateFeedbackRecordInput, + ZMcpCountFeedbackRecordsInput, + ZMcpCreateFeedbackRecordInput, + ZMcpCreateFeedbackRecordsInput, + ZMcpDeleteFeedbackRecordInput, + ZMcpFindSimilarFeedbackRecordsInput, + ZMcpGetFeedbackRecordInput, + ZMcpListFeedbackDatasetsInput, + ZMcpListFeedbackRecordsInput, + ZMcpSearchFeedbackRecordsInput, + ZMcpUpdateFeedbackRecordInput, +} from "./schemas"; + +const FEEDBACK_RECORDS_READ_SCOPE = ["feedbackRecords:read"]; +const FEEDBACK_RECORDS_WRITE_SCOPE = ["feedbackRecords:write"]; + +/** + * Shared handler body for the read-only tools: resolve the request id, gate on the read scope, run the + * v3 operation, map its Response to a tool result. Only `run` differs between them. + */ +function readOnlyHandler( + run: (input: TInput, authentication: TV3Authentication, requestId: string) => Promise +) { + return async (input: TInput, extra: { authInfo?: AuthInfo }): Promise => { + const requestId = getMcpRequestId(extra.authInfo); + const scopeError = await guardMcpScopes(extra.authInfo, FEEDBACK_RECORDS_READ_SCOPE, requestId); + if (scopeError) { + return scopeError; + } + + const response = await run(input, getMcpAuthentication(extra.authInfo), requestId); + return await responseToMcpToolResult(response, requestId); + }; +} + +/** + * Shared handler body for the mutating tools: write scope, plus the audit-log lifecycle — the record is + * stamped by the operation, and the outcome (`success`, or an `eventId` on failure) by this wrapper. A + * throw still queues the log, so a failed mutation is never silently unaudited. + */ +function writeHandler( + action: "created" | "updated" | "deleted", + run: ( + input: TInput, + authentication: TV3Authentication, + requestId: string, + auditLog?: TV3AuditLog + ) => Promise +) { + return async (input: TInput, extra: { authInfo?: AuthInfo }): Promise => { + const requestId = getMcpRequestId(extra.authInfo); + const scopeError = await guardMcpScopes(extra.authInfo, FEEDBACK_RECORDS_WRITE_SCOPE, requestId); + if (scopeError) { + return scopeError; + } + + const authentication = getMcpAuthentication(extra.authInfo); + const log = logger.withContext({ requestId, workspaceId: input.workspaceId }); + const auditLog = buildV3AuditLog(authentication, action, "feedbackRecord", MCP_API_ROUTE); + + try { + const response = await run(input, authentication, requestId, auditLog); + + if (auditLog) { + if (response.ok) { + auditLog.status = "success"; + } else { + auditLog.eventId = requestId; + } + } + + await queueV3AuditLog(auditLog, requestId, log); + return await responseToMcpToolResult(response, requestId); + } catch (error) { + if (auditLog) { + auditLog.eventId = requestId; + await queueV3AuditLog(auditLog, requestId, log); + } + + throw error; + } + }; +} + +export function registerFeedbackRecordTools(server: McpServer): void { + server.registerTool( + "list_feedback_datasets", + { + title: "List feedback datasets", + description: + "List the feedback datasets assigned to a Formbricks workspace. Use the returned id as datasetId for the other feedback-record tools.", + inputSchema: ZMcpListFeedbackDatasetsInput.shape, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + readOnlyHandler((input, authentication, requestId) => + listV3FeedbackDatasets({ + workspaceId: input.workspaceId, + authentication, + requestId, + instance: MCP_API_ROUTE, + }) + ) + ); + + server.registerTool( + "list_feedback_records", + { + title: "List feedback records", + description: + "List feedback records for a workspace's feedback dataset, with cursor pagination and optional filters. meta.datasetId and meta.datasetName report which dataset was searched, so an empty data array means that dataset holds no matching records — there is no need to call list_feedback_datasets to check. A workspace with no dataset at all fails with 422 instead.", + inputSchema: ZMcpListFeedbackRecordsInput.shape, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + // The validated input is exactly the operation's filter contract, so it is spread rather than copied + // field by field: adding a filter to the schema can't silently fail to reach the operation. Safe + // because MCP strips unknown keys and the operation allowlists what reaches the Hub. + readOnlyHandler((input, authentication, requestId) => + listV3FeedbackRecords({ ...input, authentication, requestId, instance: MCP_API_ROUTE }) + ) + ); + + server.registerTool( + "count_feedback_records", + { + title: "Count feedback records", + description: + "Count the feedback records matching a set of filters, without fetching them. Use this for 'how many' questions — how many responses to one question, from one person, or in a date range — instead of paging through records to count them. Returns only the total plus the dataset it came from, never record content. Takes the same filters as list_feedback_records.", + inputSchema: ZMcpCountFeedbackRecordsInput.shape, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + readOnlyHandler((input, authentication, requestId) => + countV3FeedbackRecords({ ...input, authentication, requestId, instance: MCP_API_ROUTE }) + ) + ); + + server.registerTool( + "get_feedback_record", + { + title: "Get feedback record", + description: "Get one feedback record by id from a workspace's feedback dataset.", + inputSchema: ZMcpGetFeedbackRecordInput.shape, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + readOnlyHandler((input, authentication, requestId) => + getV3FeedbackRecord({ + workspaceId: input.workspaceId, + feedbackRecordId: input.feedbackRecordId, + datasetId: input.datasetId, + authentication, + requestId, + instance: MCP_API_ROUTE, + }) + ) + ); + + server.registerTool( + "create_feedback_record", + { + title: "Create feedback record", + description: + "Create a feedback record in a workspace's feedback dataset. The dataset is resolved from workspaceId, or from datasetId when the workspace has more than one; it can never be set through the record body.", + inputSchema: ZMcpCreateFeedbackRecordInput.shape, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + writeHandler("created", (input, authentication, requestId, auditLog) => + createV3FeedbackRecord({ + workspaceId: input.workspaceId, + datasetId: input.datasetId, + body: input, + authentication, + requestId, + instance: MCP_API_ROUTE, + auditLog, + }) + ) + ); + + server.registerTool( + "create_feedback_records", + { + title: "Create feedback records", + description: + "Create several feedback records in one call — use this instead of calling create_feedback_record repeatedly when importing a batch. Every record is validated before any is written, so an invalid record fails the whole call rather than storing part of the batch. If the feedback service rejects some records (a duplicate submission, say), the created ones are returned and meta.failures lists the rest by index, so only those need retrying; check meta.failed. Records in one call are NOT automatically treated as one submission: each record without a submission_id gets its own generated one, so to record several answers given together (a survey response, a call with a rating and a comment) set the same submission_id on all of them.", + inputSchema: ZMcpCreateFeedbackRecordsInput.shape, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + async (input: TMcpCreateFeedbackRecordsInput, extra) => { + const requestId = getMcpRequestId(extra.authInfo); + const scopeError = await guardMcpScopes(extra.authInfo, FEEDBACK_RECORDS_WRITE_SCOPE, requestId); + if (scopeError) { + return scopeError; + } + + const authentication = getMcpAuthentication(extra.authInfo); + const log = logger.withContext({ requestId, workspaceId: input.workspaceId }); + // One audit event per record, not per call: N records created is N creations to an auditor. The + // operation stamps the entries it created (identified by `targetId`) — and indexes this array by + // record position, so it is deliberately NOT compacted. Dropping an entry would shift the rest and + // attribute a creation to the wrong record. `buildV3AuditLog` returns undefined for every record or + // none (it only depends on whether auditing is enabled), so the holes are all-or-nothing. + const auditLogs = input.records.map(() => + buildV3AuditLog(authentication, "created", "feedbackRecord", MCP_API_ROUTE) + ); + + const queueOutcome = async () => { + const stamped = auditLogs.filter((auditLog) => auditLog?.targetId); + for (const auditLog of stamped) { + if (!auditLog) continue; + auditLog.status = "success"; + await queueV3AuditLog(auditLog, requestId, log); + } + if (stamped.length > 0) { + return; + } + // Nothing created — record the attempt once rather than once per record. + const first = auditLogs.find(Boolean); + if (first) { + first.eventId = requestId; + await queueV3AuditLog(first, requestId, log); + } + }; + + try { + const response = await createV3FeedbackRecords({ + workspaceId: input.workspaceId, + datasetId: input.datasetId, + body: input, + authentication, + requestId, + instance: MCP_API_ROUTE, + auditLogs, + }); + + await queueOutcome(); + return await responseToMcpToolResult(response, requestId); + } catch (error) { + await queueOutcome(); + throw error; + } + } + ); + + server.registerTool( + "update_feedback_record", + { + title: "Update feedback record", + description: + "Correct the value of an existing feedback record — the text, number, boolean, date or chosen option, plus user_id, language and metadata. Only the fields you send are changed, with one exception: metadata is REPLACED wholesale, so to add a key you must send the existing keys too (fetch the record first with get_feedback_record). Send the value field that matches the record's field_type — value_text for text, value_number for nps/csat/ces/rating/number, value_boolean for boolean, value_date for date, value_text and/or value_id for categorical; sending any other one is rejected, because field_type itself cannot be changed. A record's provenance cannot be changed either (which source, question, submission or when it was collected); correcting those means deleting the record and creating it again. Editing the text clears the derived sentiment, emotions and translation and regenerates them in the background, so the response comes back without them — that means 'being recomputed', not 'none'. Semantic search catches up with an edit a moment later, and clearing a record's text makes it unsearchable.", + inputSchema: ZMcpUpdateFeedbackRecordInput.shape, + annotations: { + readOnlyHint: false, + // Overwrites a stored value irreversibly (the previous value survives only in the audit log), so + // the same hints as patch_survey — a client should be able to warn before calling it. + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + }, + writeHandler("updated", (input, authentication, requestId, auditLog) => + updateV3FeedbackRecord({ + workspaceId: input.workspaceId, + feedbackRecordId: input.feedbackRecordId, + datasetId: input.datasetId, + body: input, + authentication, + requestId, + instance: MCP_API_ROUTE, + auditLog, + }) + ) + ); + + server.registerTool( + "delete_feedback_record", + { + title: "Delete feedback record", + description: + "Permanently delete one feedback record from a workspace's feedback dataset. This cannot be undone: the record and its search embedding are removed, and no copy is kept. Deletes a single record only — there is no bulk delete. Returns no content on success.", + inputSchema: ZMcpDeleteFeedbackRecordInput.shape, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }, + }, + writeHandler("deleted", (input, authentication, requestId, auditLog) => + deleteV3FeedbackRecord({ + workspaceId: input.workspaceId, + feedbackRecordId: input.feedbackRecordId, + datasetId: input.datasetId, + authentication, + requestId, + instance: MCP_API_ROUTE, + auditLog, + }) + ) + ); + + server.registerTool( + "search_feedback_records", + { + title: "Search feedback records", + description: + "Search a workspace's feedback dataset by meaning rather than keywords: the query is embedded and compared to record embeddings, so 'checkout is confusing' also matches 'I couldn't figure out how to pay'. Returns scored matches, best first — record ids with the matched text, not full records; pass an id to get_feedback_record for the rest. Only records with text are searchable, and embeddings are generated in the background, so a record created moments ago may not appear yet. Requires an embedding model on the feedback service; without one this fails with 503.", + inputSchema: ZMcpSearchFeedbackRecordsInput.shape, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + readOnlyHandler((input, authentication, requestId) => + searchV3FeedbackRecords({ + workspaceId: input.workspaceId, + datasetId: input.datasetId, + query: input.query, + limit: input.limit, + cursor: input.cursor, + minScore: input.minScore, + authentication, + requestId, + instance: MCP_API_ROUTE, + }) + ) + ); + + server.registerTool( + "find_similar_feedback_records", + { + title: "Find similar feedback records", + description: + "Find the feedback records most similar to a given one — use it to see how widely a piece of feedback is echoed by others. Returns scored matches, best first, excluding the record itself. If the record has no embedding this reports a conflict rather than an empty result, and says which case it is: worth retrying for a record that was just created and is still being embedded, not worth retrying for one with no text (including text cleared by an update). Requires an embedding model on the feedback service; without one this fails with 503.", + inputSchema: ZMcpFindSimilarFeedbackRecordsInput.shape, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + readOnlyHandler((input, authentication, requestId) => + findSimilarV3FeedbackRecords({ + workspaceId: input.workspaceId, + feedbackRecordId: input.feedbackRecordId, + datasetId: input.datasetId, + limit: input.limit, + cursor: input.cursor, + minScore: input.minScore, + authentication, + requestId, + instance: MCP_API_ROUTE, + }) + ) + ); +} diff --git a/apps/web/modules/mcp/tools/guard-scopes.ts b/apps/web/modules/mcp/tools/guard-scopes.ts index 90a78ed0a044..40e9d2c5fa08 100644 --- a/apps/web/modules/mcp/tools/guard-scopes.ts +++ b/apps/web/modules/mcp/tools/guard-scopes.ts @@ -1,6 +1,6 @@ import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { createMcpInsufficientScopeResponse, hasMcpScopes } from "../auth"; +import { createMcpInsufficientScopeResponse, hasAnyMcpScope, hasMcpScopes } from "../auth"; import { responseToMcpToolResult } from "../errors"; /** @@ -21,3 +21,24 @@ export async function guardMcpScopes( requestId ); } + +/** + * Any-of variant for tools that more than one scope group legitimately depends on — currently the + * workspace discovery tool, which both the survey and feedback-record tools need to resolve a + * `workspaceId`. Returns `null` when the caller holds at least one of `allowedScopes`. The challenge + * still advertises the full list, since RFC 6750 has no way to express "any one of these". + */ +export async function guardMcpAnyScope( + authInfo: AuthInfo | undefined, + allowedScopes: string[], + requestId: string +): Promise { + if (hasAnyMcpScope(authInfo, allowedScopes)) { + return null; + } + + return await responseToMcpToolResult( + createMcpInsufficientScopeResponse(requestId, allowedScopes), + requestId + ); +} diff --git a/apps/web/modules/mcp/tools/schemas.ts b/apps/web/modules/mcp/tools/schemas.ts index c92c28238d30..8e8870772270 100644 --- a/apps/web/modules/mcp/tools/schemas.ts +++ b/apps/web/modules/mcp/tools/schemas.ts @@ -1,6 +1,16 @@ import { z } from "zod"; import { ZId } from "@formbricks/types/common"; import { ZSurveyFilters, ZSurveyStatus, ZSurveyType } from "@formbricks/types/surveys/types"; +import { + MAX_FEEDBACK_RECORDS_PER_BATCH, + ZV3FeedbackRecordCreateBody, + ZV3FeedbackRecordCreateBodyFields, + ZV3FeedbackRecordFilters, + ZV3FeedbackRecordListFilters, + ZV3FeedbackRecordSearchFilters, + ZV3FeedbackRecordSimilarityFilters, + ZV3FeedbackRecordUpdateBodyFields, +} from "@/app/api/v3/feedbackRecords/lib/schemas"; export const ZMcpListSurveysInput = z.object({ workspaceId: ZId.describe("Workspace ID whose surveys should be listed."), @@ -122,6 +132,80 @@ export const ZMcpDeleteSurveyInput = z.object({ // list_workspaces takes no arguments — it returns the workspaces the authenticated caller can access. export const ZMcpListWorkspacesInput = z.object({}); +// Feedback records live in the Hub, addressed by a tenant that is always resolved server-side from the +// caller's workspace + feedback dataset. No schema here accepts a tenant_id; the Hub's `tenant_id` is +// surfaced outward as `dataset_id`. +const datasetIdField = ZId.optional().describe( + "Feedback dataset to target. Optional when the workspace has exactly one active dataset; required when it has more than one. Use list_feedback_datasets to discover ids." +); + +export const ZMcpListFeedbackDatasetsInput = z.object({ + workspaceId: ZId.describe("Workspace ID whose feedback datasets should be listed."), +}); + +export const ZMcpListFeedbackRecordsInput = ZV3FeedbackRecordListFilters.extend({ + workspaceId: ZId.describe("Workspace ID whose feedback records should be listed."), + datasetId: datasetIdField, +}); + +export const ZMcpGetFeedbackRecordInput = z.object({ + workspaceId: ZId.describe("Workspace ID that owns the feedback record."), + feedbackRecordId: z.uuid().describe("Feedback record ID (UUID) to fetch."), + datasetId: datasetIdField, +}); + +// Extends the plain field object (not the refined body): `inputSchema` needs a raw shape, and the +// value/field_type rule is enforced by the operations layer, which is also where MCP-stripped unknown +// keys become visible as a missing value. +export const ZMcpCreateFeedbackRecordInput = ZV3FeedbackRecordCreateBodyFields.extend({ + workspaceId: ZId.describe("Workspace ID to create the feedback record in."), + datasetId: datasetIdField, +}); + +export const ZMcpCountFeedbackRecordsInput = ZV3FeedbackRecordFilters.extend({ + workspaceId: ZId.describe("Workspace ID whose feedback records should be counted."), + datasetId: datasetIdField, +}); + +// The refined body is used here (unlike the single-record tool): a batch is a nested array, so there is no +// raw shape to flatten, and the value/field_type rule can be enforced per element right in the schema. +export const ZMcpCreateFeedbackRecordsInput = z.object({ + workspaceId: ZId.describe("Workspace ID to create the feedback records in."), + datasetId: datasetIdField, + records: z + .array(ZV3FeedbackRecordCreateBody) + .min(1) + .max(MAX_FEEDBACK_RECORDS_PER_BATCH) + .describe( + `Feedback records to create, 1–${MAX_FEEDBACK_RECORDS_PER_BATCH} per call. Every record is validated before any is written, so an invalid record fails the whole call rather than storing part of the batch.` + ), +}); + +// The plain field object again (not the refined one): `inputSchema` needs a raw shape. The +// at-least-one-field rule is enforced by the operations layer. +export const ZMcpUpdateFeedbackRecordInput = ZV3FeedbackRecordUpdateBodyFields.extend({ + workspaceId: ZId.describe("Workspace ID that owns the feedback record."), + feedbackRecordId: z.uuid().describe("Feedback record ID (UUID) to update."), + datasetId: datasetIdField, +}); + +export const ZMcpDeleteFeedbackRecordInput = z.object({ + workspaceId: ZId.describe("Workspace ID that owns the feedback record."), + feedbackRecordId: z.uuid().describe("Feedback record ID (UUID) to delete permanently."), + datasetId: datasetIdField, +}); + +export const ZMcpSearchFeedbackRecordsInput = ZV3FeedbackRecordSearchFilters.extend({ + workspaceId: ZId.describe("Workspace ID whose feedback records should be searched."), + datasetId: datasetIdField, +}); + +export const ZMcpFindSimilarFeedbackRecordsInput = ZV3FeedbackRecordSimilarityFilters.extend({ + workspaceId: ZId.describe("Workspace ID that owns the feedback record."), + feedbackRecordId: z.uuid().describe("Feedback record ID (UUID) to find similar records for."), + datasetId: datasetIdField, +}); + export type TMcpListSurveysInput = z.infer; export type TMcpListWorkspacesInput = z.infer; export type TMcpGetSurveyInput = z.infer; @@ -129,3 +213,13 @@ export type TMcpCreateSurveyInput = z.infer; export type TMcpPatchSurveyInput = z.infer; export type TMcpValidateSurveyInput = z.infer; export type TMcpDeleteSurveyInput = z.infer; +export type TMcpListFeedbackDatasetsInput = z.infer; +export type TMcpListFeedbackRecordsInput = z.infer; +export type TMcpGetFeedbackRecordInput = z.infer; +export type TMcpCreateFeedbackRecordInput = z.infer; +export type TMcpCountFeedbackRecordsInput = z.infer; +export type TMcpCreateFeedbackRecordsInput = z.infer; +export type TMcpUpdateFeedbackRecordInput = z.infer; +export type TMcpDeleteFeedbackRecordInput = z.infer; +export type TMcpSearchFeedbackRecordsInput = z.infer; +export type TMcpFindSimilarFeedbackRecordsInput = z.infer; diff --git a/apps/web/modules/mcp/tools/workspaces.test.ts b/apps/web/modules/mcp/tools/workspaces.test.ts index 4a8e1b37d33c..75e862d1249e 100644 --- a/apps/web/modules/mcp/tools/workspaces.test.ts +++ b/apps/web/modules/mcp/tools/workspaces.test.ts @@ -24,6 +24,7 @@ const readAuthInfo = { }; const writeOnlyAuthInfo = { ...readAuthInfo, scopes: ["surveys:write"] }; +const feedbackReadAuthInfo = { ...readAuthInfo, scopes: ["feedbackRecords:read"] }; function createToolServer() { const tools = new Map< @@ -75,7 +76,7 @@ describe("registerWorkspaceTools", () => { }); }); - test("returns an insufficient-scope error without surveys:read (and skips the operation)", async () => { + test("returns an insufficient-scope error without any read scope (and skips the operation)", async () => { const { tools } = createToolServer(); const result = await tools.get("list_workspaces")!.handler({}, { authInfo: writeOnlyAuthInfo }); @@ -84,4 +85,18 @@ describe("registerWorkspaceTools", () => { expect(result.isError).toBe(true); expect(result.structuredContent.error).toMatchObject({ status: 403 }); }); + + // Workspace discovery is the prerequisite for the feedback-record tools too, so a token scoped only + // to feedbackRecords:read must be able to resolve its workspaceId. + test("allows a feedbackRecords-only token to discover workspaces", async () => { + const { tools } = createToolServer(); + vi.mocked(listV3Workspaces).mockResolvedValue( + successListResponse([], { nextCursor: null, totalCount: 0 }, { requestId: "req_tool" }) + ); + + const result = await tools.get("list_workspaces")!.handler({}, { authInfo: feedbackReadAuthInfo }); + + expect(listV3Workspaces).toHaveBeenCalled(); + expect(result.isError).toBeUndefined(); + }); }); diff --git a/apps/web/modules/mcp/tools/workspaces.ts b/apps/web/modules/mcp/tools/workspaces.ts index ed2c08316794..ad36bccb9c28 100644 --- a/apps/web/modules/mcp/tools/workspaces.ts +++ b/apps/web/modules/mcp/tools/workspaces.ts @@ -3,7 +3,7 @@ import { listV3Workspaces } from "@/app/api/v3/workspaces/lib/operations"; import { MCP_API_ROUTE } from "@/modules/mcp/constants"; import { getMcpAuthentication, getMcpRequestId } from "../auth"; import { responseToMcpToolResult } from "../errors"; -import { guardMcpScopes } from "./guard-scopes"; +import { guardMcpAnyScope } from "./guard-scopes"; import { type TMcpListWorkspacesInput, ZMcpListWorkspacesInput } from "./schemas"; export function registerWorkspaceTools(server: McpServer): void { @@ -12,7 +12,7 @@ export function registerWorkspaceTools(server: McpServer): void { { title: "List workspaces", description: - "List the Formbricks workspaces the authenticated user can access. Use this to discover the workspaceId required by the survey tools.", + "List the Formbricks workspaces the authenticated user can access. Use this to discover the workspaceId required by the survey and feedback-record tools.", inputSchema: ZMcpListWorkspacesInput.shape, annotations: { readOnlyHint: true, @@ -23,8 +23,14 @@ export function registerWorkspaceTools(server: McpServer): void { }, async (_input: TMcpListWorkspacesInput, extra) => { const requestId = getMcpRequestId(extra.authInfo); - // Listing workspaces is the read-prerequisite for the survey read tools, so it reuses surveys:read. - const scopeError = await guardMcpScopes(extra.authInfo, ["surveys:read"], requestId); + // Workspace discovery is the read-prerequisite for every other tool group, so any read scope is + // enough — a feedbackRecords-only token still needs a workspaceId. The result is derived from the + // caller's own memberships/key grants, so it exposes nothing extra either way. + const scopeError = await guardMcpAnyScope( + extra.authInfo, + ["surveys:read", "feedbackRecords:read"], + requestId + ); if (scopeError) { return scopeError; } diff --git a/apps/web/modules/organization/actions.ts b/apps/web/modules/organization/actions.ts index 3e92bfcfb976..2d4cf391817b 100644 --- a/apps/web/modules/organization/actions.ts +++ b/apps/web/modules/organization/actions.ts @@ -7,7 +7,7 @@ import { TUserNotificationSettings } from "@formbricks/types/user"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { createMembership } from "@/lib/membership/service"; import { createOrganization } from "@/lib/organization/service"; -import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog"; +import { capturePostHogEvent, getEmailDomain, groupIdentifyPostHog } from "@/lib/posthog"; import { updateUser } from "@/lib/user/service"; import { authenticatedActionClient } from "@/lib/utils/action-client"; import { DEFAULT_WORKSPACE_NAME } from "@/lib/workspace/constants"; @@ -53,7 +53,10 @@ export const createOrganizationAction = authenticatedActionClient name: DEFAULT_WORKSPACE_NAME, }); - groupIdentifyPostHog("organization", newOrganization.id, { name: newOrganization.name }); + groupIdentifyPostHog("organization", newOrganization.id, { + name: newOrganization.name, + email_domain: getEmailDomain(ctx.user.email), + }); groupIdentifyPostHog("workspace", newWorkspace.id, { name: newWorkspace.name }); capturePostHogEvent( diff --git a/apps/web/modules/survey/archive/lib/process-survey-archive-purge-job.test.ts b/apps/web/modules/survey/archive/lib/process-survey-archive-purge-job.test.ts index e427d3565de9..22f62f49e10a 100644 --- a/apps/web/modules/survey/archive/lib/process-survey-archive-purge-job.test.ts +++ b/apps/web/modules/survey/archive/lib/process-survey-archive-purge-job.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { ResourceNotFoundError } from "@formbricks/types/errors"; -import { SURVEY_ARCHIVE_PURGE_BATCH_SIZE } from "@/modules/survey/archive/lib/constants"; import { queueAuditEventWithoutRequest } from "@/modules/ee/audit-logs/lib/handler"; +import { SURVEY_ARCHIVE_PURGE_BATCH_SIZE } from "@/modules/survey/archive/lib/constants"; import { deleteSurvey } from "@/modules/survey/lib/surveys"; import { getSurveyArchivePurgeCutoff, purgeExpiredArchivedSurveys } from "./process-survey-archive-purge-job"; diff --git a/apps/web/modules/survey/follow-ups/lib/email.ts b/apps/web/modules/survey/follow-ups/lib/email.ts index 4e6a6e895d1d..ad27571dd9b0 100644 --- a/apps/web/modules/survey/follow-ups/lib/email.ts +++ b/apps/web/modules/survey/follow-ups/lib/email.ts @@ -50,18 +50,37 @@ export const sendFollowUpEmail = async ({ // Falls back to DEFAULT_LOCALE when the respondent locale wasn't captured at submission. const t = await getTranslate(locale ?? DEFAULT_LOCALE); - // Process body: parse recall tags and sanitize HTML - const processedBody = sanitizeHtml(parseRecallInfo(body, response.data, response.variables), { - allowedTags: ["p", "span", "b", "strong", "i", "em", "a", "br"], - allowedAttributes: { - a: ["href", "rel", "target"], - "*": ["dir", "class"], - }, - allowedSchemes: ["http", "https"], - allowedSchemesByTag: { - a: ["http", "https"], - }, - }); + // Process body: parse recall tags and sanitize HTML. + // Recall values are escaped as they are substituted (the last argument). The body is author-written + // HTML and the sanitizer below legitimately allows ``, so it cannot distinguish that markup + // from markup a respondent smuggled in through an open-text answer — an anonymous respondent could + // otherwise place an arbitrary clickable link, with text of their choosing, into a Formbricks-branded + // email delivered to the survey owner. + const processedBody = sanitizeHtml( + // Same resolved locale the translations above use, not a hardcoded "en-US": recall values include + // date answers, which parseRecallInfo formats per locale, so pinning it here would render US dates + // in an otherwise correctly localized email. + parseRecallInfo( + body, + response.data, + response.variables, + false, + locale ?? DEFAULT_LOCALE, + undefined, + true + ), + { + allowedTags: ["p", "span", "b", "strong", "i", "em", "a", "br"], + allowedAttributes: { + a: ["href", "rel", "target"], + "*": ["dir", "class"], + }, + allowedSchemes: ["http", "https"], + allowedSchemesByTag: { + a: ["http", "https"], + }, + } + ); // Process response data // Resolve relative storage URLs to absolute URLs for email rendering diff --git a/apps/web/modules/survey/link/lib/data.ts b/apps/web/modules/survey/link/lib/data.ts index 83a454544c19..078ae1cdc34e 100644 --- a/apps/web/modules/survey/link/lib/data.ts +++ b/apps/web/modules/survey/link/lib/data.ts @@ -125,7 +125,20 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => { throw new ResourceNotFoundError("Survey", surveyId); } - return transformPrismaSurvey(survey); + const transformedSurvey = transformPrismaSurvey(survey); + + // This survey object is handed to a client component on the *public* link-survey page, so every + // field in it ends up in the page payload for anonymous visitors. Follow-up configuration carries + // internal recipient addresses, subjects and email bodies, and segment filters carry targeting + // rules built from contact attributes — none of which the survey renderer reads. They are selected + // above only because `TSurvey` requires the keys, so blank them out before they leave the server. + return { + ...transformedSurvey, + followUps: [], + ...(transformedSurvey.segment + ? { segment: { ...transformedSurvey.segment, filters: [], description: null } } + : {}), + }; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { throw new DatabaseError(error.message); diff --git a/apps/web/modules/survey/link/lib/verify-email-gate.test.ts b/apps/web/modules/survey/link/lib/verify-email-gate.test.ts new file mode 100644 index 000000000000..11dbb137956f --- /dev/null +++ b/apps/web/modules/survey/link/lib/verify-email-gate.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test, vi } from "vitest"; + +const { verifyTokenForLinkSurveyMock } = vi.hoisted(() => ({ + verifyTokenForLinkSurveyMock: vi.fn(), +})); + +vi.mock("@/lib/jwt", () => ({ + verifyTokenForLinkSurvey: verifyTokenForLinkSurveyMock, +})); + +const { resolveVerifiedEmailFromResponseMeta } = await import("./verify-email-gate"); + +const SURVEY_ID = "survey_abc"; + +describe("resolveVerifiedEmailFromResponseMeta", () => { + test("returns the verified email for a valid token in the submission URL", () => { + verifyTokenForLinkSurveyMock.mockReturnValue("respondent@example.com"); + + const email = resolveVerifiedEmailFromResponseMeta( + SURVEY_ID, + `https://app.example.com/s/${SURVEY_ID}?verify=tok123&lang=de` + ); + + expect(email).toBe("respondent@example.com"); + // The survey id is passed through so the token stays bound to this survey. + expect(verifyTokenForLinkSurveyMock).toHaveBeenCalledWith("tok123", SURVEY_ID); + }); + + test("returns null when the token does not verify", () => { + verifyTokenForLinkSurveyMock.mockReturnValue(null); + + expect( + resolveVerifiedEmailFromResponseMeta(SURVEY_ID, `https://app.example.com/s/${SURVEY_ID}?verify=nope`) + ).toBeNull(); + }); + + // These are the shapes an attacker submitting straight to the public response endpoint would send. + test.each([ + ["no verify param", `https://app.example.com/s/${SURVEY_ID}`], + ["empty verify param", `https://app.example.com/s/${SURVEY_ID}?verify=`], + ["unparseable url", "not a url"], + ["missing url", undefined], + ["null url", null], + ])("returns null when the submission URL has %s", (_label, metaUrl) => { + verifyTokenForLinkSurveyMock.mockReturnValue("respondent@example.com"); + + expect(resolveVerifiedEmailFromResponseMeta(SURVEY_ID, metaUrl)).toBeNull(); + expect(verifyTokenForLinkSurveyMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/modules/survey/link/lib/verify-email-gate.ts b/apps/web/modules/survey/link/lib/verify-email-gate.ts new file mode 100644 index 000000000000..f881c6bf025b --- /dev/null +++ b/apps/web/modules/survey/link/lib/verify-email-gate.ts @@ -0,0 +1,83 @@ +import "server-only"; +import { TResponseData } from "@formbricks/types/responses"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { responses } from "@/app/lib/api/response"; +import { verifyTokenForLinkSurvey } from "@/lib/jwt"; + +export const VERIFIED_EMAIL_RESPONSE_KEY = "verifiedEmail"; + +/** + * Server-side enforcement of a link survey's `isVerifyEmailEnabled` setting. + * + * The gate used to live only in the page renderer (`survey-renderer.tsx` refuses to mount the survey + * unless `?verify=` resolves), which is a client-side-only control: the response endpoints are + * public, so a caller could POST straight to `/api/v{1,2}/client/{workspaceId}/responses` with + * `data.verifiedEmail` set to any address and never present a token. That is the same defect already + * fixed for PIN-protected surveys (see `verifyLinkSurveyPinToken`, CWE-602 / ENG-1579). + * + * The token is read from `meta.url` rather than a new request field so no widget or bundle change is + * needed — the survey client already sends `window.location.href`, which for a link survey is the + * `/s/?verify=…` URL. `meta.url` is attacker-controlled, but the token inside it is a + * signed, survey-bound JWT, so control of the URL does not help. The v2 endpoint already trusts this + * same channel for `suId`/`suToken` single-use validation. + * + * @returns the verified email address, or `null` when no valid token for this survey was presented. + */ +export const resolveVerifiedEmailFromResponseMeta = ( + surveyId: string, + metaUrl: string | undefined | null +): string | null => { + if (!metaUrl) { + return null; + } + + let token: string | null; + try { + token = new URL(metaUrl).searchParams.get("verify"); + } catch { + return null; + } + + if (!token) { + return null; + } + + return verifyTokenForLinkSurvey(token, surveyId); +}; + +/** + * Applies the email-verification gate to an incoming public response, for both the v1 and v2 endpoints. + * + * Single implementation on purpose, mirroring {@link verifyResponseRecaptcha}: the same gate enforced in + * two places is the drift that let reCAPTCHA end up on one endpoint and not the other. An edit to one + * copy that missed the other would silently reopen this bypass on one API version. + * + * Writes the verified address into `responseData` on success — deliberately taken from the token and + * never from the request body, so a caller holding a valid token for their own address cannot record + * someone else's. + * + * @returns an error `Response` when the submission must be rejected, otherwise `null`. + */ +export const enforceVerifiedEmailGate = ({ + survey, + responseData, + metaUrl, +}: { + survey: TSurvey; + responseData: TResponseData; + metaUrl: string | undefined | null; +}): Response | null => { + if (!survey.isVerifyEmailEnabled) { + return null; + } + + const verifiedEmail = resolveVerifiedEmailFromResponseMeta(survey.id, metaUrl); + if (!verifiedEmail) { + return responses.forbiddenResponse("Survey requires email verification", true, { + surveyId: survey.id, + }); + } + + responseData[VERIFIED_EMAIL_RESPONSE_KEY] = verifiedEmail; + return null; +}; diff --git a/apps/web/modules/traefik-auth/service.test.ts b/apps/web/modules/traefik-auth/service.test.ts index 94e766e5ecb2..e4c63e57dc84 100644 --- a/apps/web/modules/traefik-auth/service.test.ts +++ b/apps/web/modules/traefik-auth/service.test.ts @@ -136,8 +136,8 @@ describe("authorizeTraefikRequest", () => { type: "apiKey", apiKeyId: "key_1", organizationId: "org_1", - organizationAccess: { accessControl: { read: true, write: true } }, - workspacePermissions: [], + organizationAccess: { accessControl: { read: false, write: false } }, + workspacePermissions: [{ workspaceId: "workspace_1", workspaceName: "Linked", permission: "manage" }], }); const response = await authorizeTraefikRequest( @@ -163,8 +163,8 @@ describe("authorizeTraefikRequest", () => { type: "apiKey", apiKeyId: "key_1", organizationId: "org_1", - organizationAccess: { accessControl: { read: true, write: true } }, - workspacePermissions: [], + organizationAccess: { accessControl: { read: false, write: false } }, + workspacePermissions: [{ workspaceId: "workspace_1", workspaceName: "Linked", permission: "manage" }], }); const response = await authorizeTraefikRequest( diff --git a/charts/formbricks/values.yaml b/charts/formbricks/values.yaml index f5fb9dfdd717..712811909682 100644 --- a/charts/formbricks/values.yaml +++ b/charts/formbricks/values.yaml @@ -114,7 +114,13 @@ deployment: # AI_OPENAI_COMPATIBLE_PROVIDER_NAME: vllm # AI_OPENAI_COMPATIBLE_SUPPORTS_STRUCTURED_OUTPUTS: "1" # Secret values can use valueFrom.secretKeyRef; omit unused provider variables. - env: {} + env: + # The app sits behind exactly one reverse proxy (Traefik/Envoy) in this chart, so the client address + # is the last X-Forwarded-For entry. Pinned here rather than relying on the code default (also 1) so + # the assumption is visible next to the ingress it describes. Raise it if you put further proxies — + # a CDN, a corporate load balancer — in front of the ingress; setting it higher than the real chain + # lets callers spoof their address by prepending entries. + TRUSTED_PROXY_HOP_COUNT: "1" # Tolerations for scheduling pods on tainted nodes tolerations: [] diff --git a/docs/development/technical-handbook/mcp-server.mdx b/docs/development/technical-handbook/mcp-server.mdx index 223d2a06c840..77aa9021d61d 100644 --- a/docs/development/technical-handbook/mcp-server.mdx +++ b/docs/development/technical-handbook/mcp-server.mdx @@ -12,12 +12,13 @@ rate limiting, response handling, and audit logging paths as the REST routes. OAuth 2.1 is the preferred authentication path for MCP clients. Formbricks API keys remain supported as a - backwards-compatible fallback for local development, self-hosters, and clients that do not support MCP OAuth yet. + backwards-compatible fallback for local development, self-hosters, and clients that do not support MCP OAuth + yet. - Looking for step-by-step setup guides for Claude Code, the Claude apps, and Codex? See - [Connect AI agents (MCP)](/platform/mcp/overview). This handbook covers the technical internals. + Looking for step-by-step setup guides for Claude Code, the Claude apps, and Codex? See [Connect AI agents + (MCP)](/platform/mcp/overview). This handbook covers the technical internals. ## Endpoint @@ -65,35 +66,53 @@ https://app.formbricks.com/api/auth Discovery endpoints: -| Endpoint | Purpose | -| ------------------------------------------------------------------ | ------------------------------------ | -| `/api/auth/.well-known/oauth-authorization-server` | Better Auth authorization metadata | -| `/api/auth/.well-known/openid-configuration` | OpenID Connect metadata | -| `/.well-known/oauth-authorization-server/api/auth` | RFC 8414 path-insertion alias | -| `/.well-known/openid-configuration/api/auth` | RFC 8414 OpenID path-insertion alias | -| `/.well-known/oauth-protected-resource` | MCP protected resource metadata | -| `/.well-known/oauth-protected-resource/api/mcp` | MCP resource-specific metadata | +| Endpoint | Purpose | +| -------------------------------------------------- | ------------------------------------ | +| `/api/auth/.well-known/oauth-authorization-server` | Better Auth authorization metadata | +| `/api/auth/.well-known/openid-configuration` | OpenID Connect metadata | +| `/.well-known/oauth-authorization-server/api/auth` | RFC 8414 path-insertion alias | +| `/.well-known/openid-configuration/api/auth` | RFC 8414 OpenID path-insertion alias | +| `/.well-known/oauth-protected-resource` | MCP protected resource metadata | +| `/.well-known/oauth-protected-resource/api/mcp` | MCP resource-specific metadata | Supported scopes: -| Scope | Use | -| ----------------- | ------------------------------------------------------------------------- | -| `surveys:read` | `list_surveys`, `get_survey`, and read-only validation paths | -| `surveys:write` | `create_survey`, `patch_survey`, `delete_survey`, and write validations | -| `openid` | OpenID Connect subject interoperability | -| `profile` | User profile claims | -| `email` | User email claim | -| `offline_access` | Refresh tokens for MCP clients | +| Scope | Use | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `surveys:read` | `list_surveys`, `get_survey`, and read-only validation paths | +| `surveys:write` | `create_survey`, `patch_survey`, `delete_survey`, and write validations | +| `feedbackRecords:read` | `list_feedback_datasets`, `list_feedback_records`, `count_feedback_records`, `get_feedback_record`, `search_feedback_records`, `find_similar_feedback_records` | +| `feedbackRecords:write` | `create_feedback_record`, `create_feedback_records`, `update_feedback_record`, `delete_feedback_record` | +| `openid` | OpenID Connect subject interoperability | +| `profile` | User profile claims | +| `email` | User email claim | +| `offline_access` | Refresh tokens for MCP clients | The authorization server supports all of the above, but the MCP protected-resource metadata (`/.well-known/oauth-protected-resource/api/mcp`) advertises only `surveys:read`, `surveys:write`, -and `offline_access` — the scopes an MCP client needs. Clients derive their Dynamic Client +`feedbackRecords:read`, `feedbackRecords:write`, and `offline_access` — the scopes an MCP client +needs. Clients derive their Dynamic Client Registration scopes from that list, so `offline_access` must be advertised there for clients to be issued refresh tokens (`openid`/`profile`/`email` are OIDC scopes the MCP resource does not require). + + Adding a resource scope breaks already-connected OAuth clients until they re-register. The + authorization server validates `/authorize` against the scopes the **client** registered with, so a + client registered before the change requests the newly advertised scope and is rejected with + `invalid_scope` — it does not fall back to the scopes it already holds, and re-consenting does not help + because the same `client_id` is reused. The client has to run Dynamic Client Registration again: remove + and re-add the MCP server (for example `claude mcp remove ` then `claude mcp add …`), or delete + its `oauthClient` row so the next connection re-registers. Ship a release note whenever this list + changes. + + OAuth scopes only gate MCP tool categories. Actual workspace access is still evaluated at tool execution through the existing v3 authorization checks for the signed-in Formbricks user. +Scope groups are **independent**: a token authenticates as long as it holds at least one resource +scope, so a `feedbackRecords:read`-only grant is valid and simply can't reach the survey tools. +`list_workspaces` is the shared discovery tool and accepts either read scope. + ### API-key fallback API-key MCP access remains supported. Authenticate with a Formbricks API key in a request header: @@ -113,18 +132,28 @@ Do not pass credentials in the query string. The MCP route rejects query credent API key permissions are enforced through the same workspace access checks as the v3 REST API: -| Tool | Minimum Workspace Permission | -| ----------------- | ------------------------------------------------------------------- | -| `list_surveys` | `read` | -| `get_survey` | `read` | -| `create_survey` | `write` or `manage` | -| `validate_survey` | `write` or `manage` when the validation request checks write access | -| `patch_survey` | `write` or `manage` | -| `delete_survey` | `write` or `manage` | +| Tool | Minimum Workspace Permission | +| ------------------------------- | ------------------------------------------------------------------- | +| `list_surveys` | `read` | +| `get_survey` | `read` | +| `create_survey` | `write` or `manage` | +| `validate_survey` | `write` or `manage` when the validation request checks write access | +| `patch_survey` | `write` or `manage` | +| `delete_survey` | `write` or `manage` | +| `list_feedback_datasets` | `read` | +| `list_feedback_records` | `read` | +| `count_feedback_records` | `read` | +| `get_feedback_record` | `read` | +| `search_feedback_records` | `read` | +| `find_similar_feedback_records` | `read` | +| `create_feedback_record` | `write` or `manage` | +| `create_feedback_records` | `write` or `manage` | +| `update_feedback_record` | `write` or `manage` | +| `delete_feedback_record` | `write` or `manage` | - Store fallback MCP API keys as environment variables or client secrets. Do not commit API keys into MCP client - config files. + Store fallback MCP API keys as environment variables or client secrets. Do not commit API keys into MCP + client config files. ## Local Setup @@ -137,6 +166,12 @@ pnpm db:migrate:dev pnpm --filter @formbricks/web dev ``` + + Restart the dev server after changing tool registrations. The MCP server is built once at module scope by + `createMcpHandler`, so hot reload does not pick up an added, removed or renamed tool — `tools/list` keeps + serving the previous set until the process restarts. + + Verify OAuth discovery: ```bash @@ -162,18 +197,18 @@ curl -sS -X POST http://localhost:3000/api/auth/oauth2/register \ "token_endpoint_auth_method": "none", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], - "scope": "openid profile email offline_access surveys:read surveys:write" + "scope": "openid profile email offline_access surveys:read surveys:write feedbackRecords:read feedbackRecords:write" }' ``` - This passes `scope` explicitly, so it does **not** reproduce a real client's behavior — real MCP - clients register with the scopes from the protected-resource metadata's `scopes_supported` - (`surveys:read surveys:write offline_access`). Because the authorization server validates the - `/authorize` request against the client's **registered** scopes, a client that registers with a - narrower set than it later requests (e.g. it registers surveys-only but requests `offline_access`) - is rejected with `invalid_scope`. To smoke-test the real path, omit `scope` and let the client - adopt the advertised set, or pass exactly what the metadata advertises. + This passes `scope` explicitly, so it does **not** reproduce a real client's behavior — real MCP clients + register with the scopes from the protected-resource metadata's `scopes_supported` (`surveys:read + surveys:write feedbackRecords:read feedbackRecords:write offline_access`). Because the authorization server + validates the `/authorize` request against the client's **registered** scopes, a client that registers with + a narrower set than it later requests (e.g. it registers surveys-only but requests `offline_access`) is + rejected with `invalid_scope`. To smoke-test the real path, omit `scope` and let the client adopt the + advertised set, or pass exactly what the metadata advertises. The consent screen is served at `/account/authorize`. Users can revoke approved MCP clients from @@ -211,16 +246,20 @@ Authenticate and request the scopes the agent needs: ```bash codex mcp login formbricks-local \ - --scopes openid,profile,email,offline_access,surveys:read,surveys:write + --scopes openid,profile,email,offline_access,surveys:read,surveys:write,feedbackRecords:read,feedbackRecords:write ``` -For read-only usage, omit `surveys:write`: +For read-only usage, omit the write scopes: ```bash codex mcp login formbricks-local \ - --scopes openid,profile,email,offline_access,surveys:read + --scopes openid,profile,email,offline_access,surveys:read,feedbackRecords:read ``` +Scope groups are independent — a token needs at least one resource scope, but not all of them. To use +only the feedback-record tools, request `feedbackRecords:read` (plus `feedbackRecords:write` to create) +and omit the survey scopes. + Codex opens the browser to Formbricks, completes Dynamic Client Registration, and stores the OAuth client and tokens in Codex's credential store. The user approves the requested scopes on the Formbricks consent screen. @@ -282,9 +321,9 @@ claude mcp add --transport http formbricks-local http://localhost:3000/api/mcp Run `/mcp` and authenticate `formbricks-local`. Claude Code discovers the MCP protected-resource metadata, registers a public client whose scopes are taken from that metadata's `scopes_supported` -(`surveys:read surveys:write offline_access`), and launches the browser-based OAuth flow — there is -no scope-entry step. You approve the requested scopes on the Formbricks consent screen. Workspace -role determines whether `surveys:write` tools actually succeed at execution time (see +(`surveys:read surveys:write feedbackRecords:read feedbackRecords:write offline_access`), and launches +the browser-based OAuth flow — there is no scope-entry step. You approve the requested scopes on the +Formbricks consent screen. Workspace role determines whether write tools actually succeed at execution time (see [Authentication](#authentication)), regardless of the granted scope. Verify it: @@ -653,6 +692,393 @@ so callers can correlate the audit trail: } ``` +### list_feedback_datasets + +Lists the active feedback datasets assigned to a workspace. Read-only and idempotent. Use the +returned `id` as `datasetId` for the other feedback-record tools. + +Input: + +```json +{ + "workspaceId": "clxx1234567890123456789012" +} +``` + +Output: + +```json +{ + "data": [{ "id": "clfd1234567890123456789012", "name": "Support" }], + "meta": { "nextCursor": null, "totalCount": 1 }, + "requestId": "req_..." +} +``` + +### list_feedback_records + +Lists feedback records in a workspace's feedback dataset. Read-only and idempotent, with opaque +cursor pagination. `datasetId` is optional when the workspace has exactly one active +dataset (the common case) and required when it has more than one. + +Input: + +```json +{ + "cursor": "opaque-cursor-from-previous-response", + "datasetId": "clfd1234567890123456789012", + "field_group_id": "grp_1", + "field_id": "q1", + "field_type": "text", + "limit": 50, + "since": "2026-01-01T00:00:00Z", + "source_id": "svy_1", + "source_type": "survey", + "submission_id": "sub_1", + "until": "2026-12-31T23:59:59Z", + "user_id": "user_1", + "value_id": "opt_1", + "workspaceId": "clxx1234567890123456789012" +} +``` + +All filters are optional, match exactly, and combine with AND. They mirror the Hub's own +`GET /v1/feedback-records` parameters, and each is named after the record field it filters — so filtering by +a `user_id` you just read in a response is spelled the same way. An unknown filter key is rejected with a +422 rather than ignored, since a silently dropped filter would widen the result set without saying so. + +| Filter | Selects | +| ----------------- | -------------------------------------------------------------------- | +| `source_type` | a kind of source, e.g. `survey`, `review`, `call_notes` | +| `source_id` | one survey/form/ticket | +| `field_type` | one field type (`text`, `nps`, `rating`, …) | +| `field_id` | all answers to one question | +| `field_group_id` | one grouped question (ranking, matrix, grid) | +| `submission_id` | the sibling answers given in one submission | +| `user_id` | everything one end user submitted | +| `value_id` | everyone who picked one particular choice | +| `since` / `until` | a `collected_at` range (must fall between 1970-01-01 and 2080-12-31) | + +The workspace/dataset/pagination parameters (`workspaceId`, `datasetId`, `limit`, `cursor`) stay camelCase: +they name nothing in the record. + +Output mirrors the Hub feedback-record shape and paginates with `meta.nextCursor`. `meta` also names the +dataset that was searched, so an empty `data` array unambiguously means that dataset holds no matching +records — a caller that let the dataset auto-resolve does not need a second call to find out which one it was: + +```json +{ + "data": [ + { + "collected_at": "2026-04-21T10:00:00.000Z", + "dataset_id": "clfd1234567890123456789012", + "field_id": "q1", + "field_type": "text", + "id": "019fa338-f494-7384-b34e-01739783d280", + "source_type": "survey", + "submission_id": "…", + "value_text": "Great support!" + } + ], + "meta": { + "datasetId": "clfd1234567890123456789012", + "datasetName": "Support", + "limit": 50, + "nextCursor": null + }, + "requestId": "req_..." +} +``` + +### count_feedback_records + +Counts the feedback records matching a filter set, without fetching them — for "how many" questions that +would otherwise mean paging through records. Read-only and idempotent. Takes the same filters as +`list_feedback_records` (no `limit`/`cursor`), because the Hub documents its count endpoint as accepting +the same query parameters as its list endpoint; both go through one mapper in `lib/operations.ts`, so a +count always describes the same set as the equivalent list. + +Input: + +```json +{ + "since": "2026-01-01T00:00:00Z", + "user_id": "user_1", + "workspaceId": "clxx1234567890123456789012" +} +``` + +Output is the total and the dataset it came from — **no record content**, which is the point: a caller +asking "how many" never pulls end-user text into its context to find out. + +```json +{ + "data": { + "count": 42, + "dataset_id": "clfd1234567890123456789012", + "dataset_name": "Support" + }, + "requestId": "req_..." +} +``` + +### get_feedback_record + +Gets one feedback record by its Hub UUID. Read-only and idempotent. The record must belong to a +feedback dataset assigned to the workspace; otherwise the tool returns a generic authorization +error and never reveals whether a record id exists in another tenant. + +Input: + +```json +{ + "feedbackRecordId": "019fa338-f494-7384-b34e-01739783d280", + "workspaceId": "clxx1234567890123456789012" +} +``` + +### create_feedback_record + +Creates a feedback record in a workspace's feedback dataset. Writes data and is not idempotent, and +requires `feedbackRecords:write` (API keys need `write` or `manage`). The tenant is derived from the +resolved feedback dataset and is never taken from the request body. `submission_id` is optional — a +UUID is generated when omitted. + +Input: + +```json +{ + "field_id": "note", + "field_label": "Call summary", + "field_type": "text", + "source_type": "call_notes", + "value_text": "Customer asked for SSO and a longer trial.", + "workspaceId": "clxx1234567890123456789012" +} +``` + +Output is the created feedback record (Hub shape) plus the MCP `requestId`. + +### create_feedback_records + +Creates several feedback records in one call — the batch form of `create_feedback_record`, for imports. +Writes data, is not idempotent, and requires `feedbackRecords:write`. Between 1 and 50 records per call. + +The Hub has no bulk-create endpoint (its only bulk write is the delete-by-user erasure path), so this fans +out to one Hub create per record, in parallel. Two consequences are deliberate: + +- **Validation is all-or-nothing.** Every record is validated before any is written, so a malformed batch + is rejected without leaving half of it stored. `invalid_params` names the offending index, e.g. + `records.3.value_text`. +- **Partial success is reported, not hidden.** If the Hub rejects some records — a duplicate + `(submission_id, field_id)`, say — the created ones are returned and `meta.failures` accounts for the + rest by index, so only those need retrying. If _nothing_ could be created, the upstream failure is + returned as the response instead, because an empty success would read as "there was nothing to do". + +The 50-record cap is an amplification bound as much as a payload one: one authorized request must not +become an unbounded burst of upstream writes. + + + **A batch is not a submission.** Each record without a `submission_id` gets its own generated one, exactly + as in the single-record case — so several answers that belong to the same submission (a survey response, or + a call with both a rating and a comment) must carry the *same* `submission_id`, set by the caller. Omit it + and they are stored as unrelated submissions, which nothing downstream can distinguish from the intended + shape. + + +Input: + +```json +{ + "records": [ + { + "field_id": "note", + "field_type": "text", + "source_type": "call_notes", + "value_text": "Asked for SSO." + }, + { + "field_id": "score", + "field_type": "nps", + "source_type": "call_notes", + "value_number": 9 + } + ], + "workspaceId": "clxx1234567890123456789012" +} +``` + +Output: + +```json +{ + "data": [{ "dataset_id": "clfd…", "id": "019fa338-…", "value_text": "Asked for SSO." }], + "meta": { + "created": 1, + "datasetId": "clfd1234567890123456789012", + "datasetName": "Support", + "failed": 1, + "failures": [{ "detail": "duplicate record for (tenant_id, submission_id, field_id)", "index": 1 }], + "requested": 2 + }, + "requestId": "req_..." +} +``` + +Per-record failure text goes through the same relay rules as a whole-request failure: a Hub 4xx explains +itself, anything else becomes a fixed message. Each record actually created produces its own `created` +audit event — N creations are N events, not one summary. + +### update_feedback_record + +Corrects the value of an existing feedback record. Writes data, is annotated `destructiveHint: true` (it +overwrites a stored value; the previous one survives only in the audit log), and requires +`feedbackRecords:write`. Only the fields sent are changed; at least one is required. + +Updatable — the Hub's own mutable set: `value_text`, `value_number`, `value_boolean`, `value_date`, +`value_id`, `user_id`, `language`, `metadata`. The schema is `.pick()`ed from the create fields, so bounds +can't drift between creating and correcting a record. + +The `value_*` field being set must be one the record's `field_type` accepts — the same table `create` enforces +(`text` takes `value_text`, `nps`/`rating`/`number` take `value_number`, `categorical` takes `value_text` +and/or `value_id`, and so on). Because `field_type` is immutable it is not part of the patch, so this is +checked against the *stored* record and therefore reported after the ownership check, as a 422 naming the +offending field. Without it a patch could assemble what `create` rejects: putting `value_number` on a `text` +record would leave both a text and a number set, with nothing to say which one the record means. The Hub does +not enforce this itself. + +**Not** updatable: a record's provenance — `source_type`, `source_id`, `source_name`, `field_id`, +`field_type`, `field_label`, `field_group_*`, `submission_id`, `collected_at`. Correcting those means +deleting the record and creating it again. The derived enrichment fields (`sentiment`, `sentiment_score`, +`emotions`, `value_text_translated`, `translation_lang_key`) are not accepted from callers either — they are +the Hub's to compute. + +`metadata` is the one field that is **replaced, not merged** — the Hub assigns it wholesale, so adding a key +means sending the existing ones too. Everything else is left untouched when omitted. + +Ownership is verified before the update, the same way as for get and delete: `PATCH /{id}` is another Hub +endpoint that derives the tenant from the stored record. A record deleted in the window between that check +and the write returns the same generic authorization error, not a 502 — nothing was updated and the service +is fine. + +Input: + +```json +{ + "feedbackRecordId": "019fa338-f494-7384-b34e-01739783d280", + "value_text": "Actually the export works fine, it was the filter that confused me", + "workspaceId": "clxx1234567890123456789012" +} +``` + +Output is the updated record. + + + **Editing the text resets what was derived from it.** Per the Hub's contract, changing `value_text` clears + `sentiment`, `sentiment_score`, `emotions`, `value_text_translated` and `translation_lang_key` and queues + re-enrichment; changing `language` re-queues the translation pair only. The response reflects the + **cleared** state, so those fields being absent right after an update means "being recomputed", not "none". + Changing `value_text` (or a field label) also re-queues the embedding, so semantic search catches up + asynchronously — and clearing a record's text removes its embedding, making it unsearchable. + + +### delete_feedback_record + +Permanently deletes one feedback record. Writes data, is not idempotent, is annotated +`destructiveHint: true`, and requires `feedbackRecords:write` (API keys need `write` or `manage`). The +record and its derived embedding are removed with no soft delete, so this cannot be undone — the audit log +entry keeps the deleted record as its `oldObject` and is the only remaining trace. + +Ownership is verified before the delete: the record must belong to a feedback dataset assigned to the +workspace, and a record in another tenant is refused with the same generic authorization error as an +unknown id, without deleting anything. Single-record only — there is no bulk delete tool. + +Input: + +```json +{ + "feedbackRecordId": "019fa338-f494-7384-b34e-01739783d280", + "workspaceId": "clxx1234567890123456789012" +} +``` + +Returns `204 No Content` on success (the tool result carries only the `requestId`). + +### search_feedback_records + +Searches a workspace's feedback dataset semantically: the query is embedded and compared to record +embeddings by cosine similarity, so it matches meaning rather than keywords. Read-only and idempotent. + +Input: + +```json +{ + "cursor": "opaque-cursor-from-previous-response", + "limit": 10, + "minScore": 0.5, + "query": "checkout is confusing", + "workspaceId": "clxx1234567890123456789012" +} +``` + +Output is scored matches, best first — record ids with the embedded text, not full records. Pass a +`feedback_record_id` to `get_feedback_record` for the rest of a record: + +```json +{ + "data": [ + { + "feedback_record_id": "019fa338-f494-7384-b34e-01739783d280", + "field_label": "What can we improve?", + "score": 0.82, + "value_text": "I couldn't figure out how to pay" + } + ], + "meta": { + "datasetId": "clfd1234567890123456789012", + "datasetName": "Support", + "limit": 10, + "minScore": 0.5, + "nextCursor": null + }, + "requestId": "req_..." +} +``` + +### find_similar_feedback_records + +Finds the records most similar to a given one, by embedding distance — useful for gauging how widely a +piece of feedback is echoed. Read-only and idempotent. Same output shape as `search_feedback_records`; +the anchor record is excluded from its own results. + +Input: + +```json +{ + "feedbackRecordId": "019fa338-f494-7384-b34e-01739783d280", + "limit": 10, + "minScore": 0.5, + "workspaceId": "clxx1234567890123456789012" +} +``` + +Ownership of the anchor record is verified before any neighbour is fetched (see +[Feedback Records And The Hub](#feedback-records-and-the-hub)). + + + Both search tools need embeddings, which are optional in the Hub. Without `EMBEDDING_PROVIDER` and + `EMBEDDING_MODEL` configured on **both** the Hub API and the Hub worker they return `503` with that + instruction as the detail. Embedding is also asynchronous and only covers records that have text, so a + record created moments ago is not searchable yet; `find_similar_feedback_records` reports that as a `409` + rather than an empty result, distinguishing "still being embedded, retry" from "no text, so there is no + embedding to wait for" — the latter also covers text cleared by an update. + + +`limit` (1–100, default 10) and `minScore` (0–1, default 0.5) are validated by Formbricks rather than +passed straight through: the Hub silently coerces out-of-range values to its own defaults, which would +return something other than what was asked for. The default `minScore` of 0.5 is Formbricks' own — the Hub +defaults to 0.7, which is strict enough that a fair paraphrase often falls just below it. + ## Relationship To V3 Surveys API The MCP server does not run custom survey database queries. Each tool calls the shared server-only v3 @@ -670,14 +1096,52 @@ survey operations used by the REST routes: When the v3 OpenAPI contract changes, update the MCP schemas and this page together. The hand-maintained v3 OpenAPI spec lives at `docs/api-v3-reference/openapi.yml`. +## Feedback Records And The Hub + +Feedback-record tools do not map to a v3 REST route. They call the shared server-only Hub service +(`@/modules/hub/service`) — the same client the Unify Feedback UI uses — and enforce the same +`feedbackDirectories` Enterprise entitlement. + +A feedback record lives in the Formbricks Hub, addressed by an opaque tenant id. Every tool +resolves that tenant server-side: it authorizes the caller's access to `workspaceId`, checks the +`feedbackDirectories` license, then maps the workspace to its assigned feedback dataset — the +`FeedbackDirectory` id **is** the Hub tenant id, and it is surfaced to callers as `dataset_id`. + +Assignment is a join table, but the application enforces at most one _non-archived_ directory per +workspace, so in practice `datasetId` can be omitted and the single active dataset is used. +It becomes required only if a workspace somehow has several active directories, in which case the tools +return `400` rather than guessing. A tenant id is never accepted from tool input. + +Three Hub endpoints — get, delete, and similar — take a bare record id and derive the tenant from the +stored record, delegating record-level authorization to the product. So `get_feedback_record`, +`delete_feedback_record` and `find_similar_feedback_records` all retrieve the record first and verify its +tenant — the named dataset when one was given, otherwise any dataset the workspace owns — before acting. +A foreign record and an unknown record produce the same generic authorization error, so record ids cannot +be probed across tenants. `search_feedback_records` instead has the resolved tenant injected into the Hub +query, like `create_feedback_record`. + ## Limitations - OAuth is user-delegated only. Machine-to-machine client credentials for MCP are not supported. - OAuth access tokens must be JWTs audience-bound to `/api/mcp`; opaque MCP access token introspection is not accepted by the MCP route. - API-key authentication remains supported for compatibility and fallback use. -- The MCP server exposes only the v3 survey operations listed on this page. -- Tool coverage depends on the current v3 Surveys REST endpoint coverage. +- The MCP server exposes the survey and feedback-record operations listed on this page. +- Survey tool coverage depends on the current v3 Surveys REST endpoint coverage; feedback-record tool + coverage depends on the Hub feedback-records API. +- Feedback-record tools require the `feedbackDirectories` Enterprise entitlement and a feedback + directory assigned to the workspace; without either they return 403 / 422. +- A feedback record's **provenance** is immutable: `update_feedback_record` changes values, users, language + and metadata, but not which source, question or submission a record belongs to, nor when it was + collected. The derived enrichment fields are the Hub's to compute and are never accepted from callers. +- `delete_feedback_record` deletes one record at a time. The Hub's bulk and delete-by-user endpoints are + deliberately not exposed: they are erasure operations whose blast radius does not belong on an agent + surface. There is deliberately no batch _delete_ to match `create_feedback_records`. +- Counting and filtering cover the Hub's own filter set only. There is no aggregation by sentiment or + emotion, and semantic search cannot be narrowed by source or date — the Hub's search endpoint takes only + a query and a tenant. Those need Hub-side support first. +- The search tools require an embedding model configured on the Hub API **and** the Hub worker; without + one they return 503. Embedding is asynchronous and covers only records with text. - `create_survey` creates link surveys only. In-app survey creation and distribution settings are not part of the current v3 create operation. - `patch_survey` follows the v3 PATCH contract: top-level partial document updates only, no JSON Patch, @@ -694,5 +1158,10 @@ v3 OpenAPI spec lives at `docs/api-v3-reference/openapi.yml`. - `apps/web/modules/mcp/auth.ts` - `apps/web/modules/mcp/server.ts` - `apps/web/modules/mcp/tools/surveys.ts` +- `apps/web/modules/mcp/tools/workspaces.ts` +- `apps/web/modules/mcp/tools/feedback-records.ts` - `apps/web/modules/mcp/tools/schemas.ts` - `apps/web/app/api/v3/surveys/lib/operations.ts` +- `apps/web/app/api/v3/feedbackRecords/lib/operations.ts` +- `apps/web/app/api/v3/feedbackRecords/lib/access.ts` (tenant resolution + per-record ownership guard) +- `apps/web/app/api/v3/feedbackRecords/lib/errors.ts` (Hub error → v3 problem mapping) diff --git a/docs/images/surveys/link-surveys/quickstart/choose-template.webp b/docs/images/surveys/link-surveys/quickstart/choose-template.webp new file mode 100644 index 000000000000..bbe7c87a055d Binary files /dev/null and b/docs/images/surveys/link-surveys/quickstart/choose-template.webp differ diff --git a/docs/images/surveys/link-surveys/quickstart/response-options.webp b/docs/images/surveys/link-surveys/quickstart/response-options.webp new file mode 100644 index 000000000000..c1eb713a945e Binary files /dev/null and b/docs/images/surveys/link-surveys/quickstart/response-options.webp differ diff --git a/docs/images/surveys/link-surveys/quickstart/share-survey.webp b/docs/images/surveys/link-surveys/quickstart/share-survey.webp new file mode 100644 index 000000000000..f6b636e48f98 Binary files /dev/null and b/docs/images/surveys/link-surveys/quickstart/share-survey.webp differ diff --git a/docs/images/surveys/link-surveys/quickstart/styling.webp b/docs/images/surveys/link-surveys/quickstart/styling.webp new file mode 100644 index 000000000000..2f16a4cbb361 Binary files /dev/null and b/docs/images/surveys/link-surveys/quickstart/styling.webp differ diff --git a/docs/images/surveys/link-surveys/quickstart/summary-page.webp b/docs/images/surveys/link-surveys/quickstart/summary-page.webp new file mode 100644 index 000000000000..55cf71bf4b50 Binary files /dev/null and b/docs/images/surveys/link-surveys/quickstart/summary-page.webp differ diff --git a/docs/images/surveys/link-surveys/quickstart/survey-editor.webp b/docs/images/surveys/link-surveys/quickstart/survey-editor.webp new file mode 100644 index 000000000000..a096ebe125cd Binary files /dev/null and b/docs/images/surveys/link-surveys/quickstart/survey-editor.webp differ diff --git a/docs/images/surveys/link-surveys/quickstart/survey-type-link.webp b/docs/images/surveys/link-surveys/quickstart/survey-type-link.webp new file mode 100644 index 000000000000..1c3c49d237b2 Binary files /dev/null and b/docs/images/surveys/link-surveys/quickstart/survey-type-link.webp differ diff --git a/docs/platform/mcp/overview.mdx b/docs/platform/mcp/overview.mdx index 4e35094a6b5a..89f03e8208a5 100644 --- a/docs/platform/mcp/overview.mdx +++ b/docs/platform/mcp/overview.mdx @@ -1,27 +1,27 @@ --- title: "Connect AI Agents (MCP)" -description: "Let AI assistants like Claude and Codex read and manage your Formbricks surveys through the Model Context Protocol (MCP), authorized securely with your own login." +description: "Let AI assistants like Claude and Codex read and manage your Formbricks surveys and feedback records through the Model Context Protocol (MCP), authorized securely with your own login." icon: "robot" --- -Formbricks ships a **Model Context Protocol (MCP) server** that lets AI assistants — Claude Code, the Claude apps, Codex, and other MCP-capable clients — work with the surveys in your workspace: list and read surveys, create link surveys, and update or delete them. The agent calls the same v3 Surveys API you use in the app, so it always respects your permissions. +Formbricks ships a **Model Context Protocol (MCP) server** that lets AI assistants — Claude Code, the Claude apps, Codex, and other MCP-capable clients — work with your workspace: list and read surveys, create link surveys, update or delete them, and read or add Unify Feedback records. The agent goes through the same APIs and permission checks you use in the app, so it always respects your permissions. You connect a client **with your own Formbricks login over OAuth 2.1** — there are no API keys to generate, copy, or paste. You approve the connection once on a consent screen, and you can revoke it at any time. - Prefer to use a long-lived token instead (for scripts, CI, or a client that doesn't support OAuth - yet)? Formbricks API keys still work as a fallback — see the - [MCP server technical handbook](/development/technical-handbook/mcp-server). + Prefer to use a long-lived token instead (for scripts, CI, or a client that doesn't support OAuth yet)? + Formbricks API keys still work as a fallback — see the [MCP server technical + handbook](/development/technical-handbook/mcp-server). ## The MCP server URL Point your client at your Formbricks app's `/api/mcp` endpoint: -| Instance | MCP server URL | -| --- | --- | -| Formbricks Cloud | `https://app.formbricks.com/api/mcp` | -| Self-hosted | `https:///api/mcp` | +| Instance | MCP server URL | +| ---------------- | ------------------------------------------ | +| Formbricks Cloud | `https://app.formbricks.com/api/mcp` | +| Self-hosted | `https:///api/mcp` | ## How the connection works @@ -36,17 +36,46 @@ The whole flow uses Authorization Code + PKCE, the standard the [MCP authorizati ## What the agent can do -The MCP server exposes six survey tools, grouped into two scopes you approve on the consent screen: +The MCP server exposes survey and feedback-record tools, grouped into scopes you approve on the consent screen: -| Scope | Tools | What it allows | -| --- | --- | --- | -| `surveys:read` | `list_surveys`, `get_survey`, `validate_survey` | Read surveys and validate survey documents | -| `surveys:write` | `create_survey`, `patch_survey`, `delete_survey` | Create, update, and delete surveys | +| Scope | Tools | What it allows | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| `surveys:read` | `list_surveys`, `get_survey`, `validate_survey` | Read surveys and validate survey documents | +| `surveys:write` | `create_survey`, `patch_survey`, `delete_survey` | Create, update, and delete surveys | +| `feedbackRecords:read` | `list_feedback_datasets`, `list_feedback_records`, `count_feedback_records`, `get_feedback_record`, `search_feedback_records`, `find_similar_feedback_records` | Read, count and search feedback records in a workspace's feedback dataset | +| `feedbackRecords:write` | `create_feedback_record`, `create_feedback_records`, `update_feedback_record`, `delete_feedback_record` | Create, correct and delete feedback records | + +Scope groups are independent — a client can be granted feedback-record access without survey access, +or the other way around. `list_workspaces` is available to either read scope, since every other tool +needs a workspace id. + + + The feedback-record tools work with **Unify Feedback** data and need the Enterprise Edition + (`feedbackDirectories`), plus a feedback dataset assigned to the workspace. Without the entitlement or a + directory, those tools return an authorization or validation error while the survey tools keep working. + + + + `delete_feedback_record` permanently deletes a record — there is no undo and no trash. Grant + `feedbackRecords:write` only to clients you want to be able to change or delete feedback, and keep read-only + agents on `feedbackRecords:read`. Corrections via `update_feedback_record` are also irreversible — the + previous value is kept only in the audit log. + + +Feedback records can be filtered by source, question, submission, end user, chosen option and date range, +and `count_feedback_records` answers "how many" without fetching the records themselves — so an agent can +report totals without pulling anyone's feedback text into the conversation. + +The two search tools match by meaning rather than keywords ("checkout is confusing" finds "I couldn't +figure out how to pay"), and `find_similar_feedback_records` shows how widely one piece of feedback is +echoed by others. They rely on the feedback service having an embedding model configured; on +self-hosted installs without one they report that they are unavailable. Newly created records become +searchable a short while after they arrive, and records without text are never searchable. - Scopes are only a coarse gate. **Access is always bounded by your Formbricks workspace role**: even - if a client holds `surveys:write`, a call only succeeds if you have write/manage permission on the - target workspace. An agent can never do more than you can. + Scopes are only a coarse gate. **Access is always bounded by your Formbricks workspace role**: even if a + client holds `surveys:write`, a call only succeeds if you have write/manage permission on the target + workspace. An agent can never do more than you can. ## Prerequisites @@ -56,20 +85,22 @@ The MCP server exposes six survey tools, grouped into two scopes you approve on - An **MCP client** that supports remote HTTP servers with OAuth — see the [setup guides](/platform/mcp/setup) for Claude Code, the Claude apps, and Codex. - **Self-hosting?** The OAuth provider is built in and enabled automatically — there's nothing extra - to turn on. It just needs your instance to be served from a correct, public **HTTPS** base URL - (set via `WEBAPP_URL` / `BETTER_AUTH_URL`). The discovery metadata and browser redirects are built - from that URL, so an `http://localhost` or misconfigured origin will break the OAuth flow for - remote clients. See the [self-hosting configuration](/self-hosting/configuration/environment-variables). + **Self-hosting?** The OAuth provider is built in and enabled automatically — there's nothing extra to turn + on. It just needs your instance to be served from a correct, public **HTTPS** base URL (set via `WEBAPP_URL` + / `BETTER_AUTH_URL`). The discovery metadata and browser redirects are built from that URL, so an + `http://localhost` or misconfigured origin will break the OAuth flow for remote clients. See the + [self-hosting configuration](/self-hosting/configuration/environment-variables). ## Next steps - Copy-paste guides for Claude Code, the Claude apps (custom connectors), and Codex — plus how to - manage and revoke access. + Copy-paste guides for Claude Code, the Claude apps (custom connectors), and Codex — plus how to manage and + revoke access. For the tool schemas, response formats, discovery endpoints, and the API-key fallback, see the -[MCP server technical handbook](/development/technical-handbook/mcp-server). Each tool maps to the -[v3 Surveys API](/development/technical-handbook/mcp-server#relationship-to-v3-surveys-api) it wraps. +[MCP server technical handbook](/development/technical-handbook/mcp-server). Survey tools map to the +[v3 Surveys API](/development/technical-handbook/mcp-server#relationship-to-v3-surveys-api) they wrap; +feedback-record tools read and write +[Unify Feedback data](/development/technical-handbook/mcp-server#feedback-records-and-the-hub). diff --git a/docs/platform/mcp/setup.mdx b/docs/platform/mcp/setup.mdx index fb700d104823..ba6013ec2304 100644 --- a/docs/platform/mcp/setup.mdx +++ b/docs/platform/mcp/setup.mdx @@ -94,8 +94,10 @@ Use this for **claude.ai** and **Claude Desktop**, where Formbricks is added as Codex opens the browser to Formbricks, registers itself, and you **approve** on the consent screen. For read-only access, restrict the scopes: ```bash - codex mcp login formbricks --scopes surveys:read,offline_access + codex mcp login formbricks --scopes surveys:read,feedbackRecords:read,offline_access ``` + Scope groups are independent — request only the ones you need (for example + `feedbackRecords:read,offline_access` for feedback data alone). ```bash @@ -122,8 +124,8 @@ to connect again. Tokens are short-lived by design: an access token lasts **15 minutes** and the client refreshes it silently in the background for up to **30 days** (the refresh window). After that — or after you -revoke — the client re-runs the sign-in and consent flow. Approving `surveys:write` is re-confirmed -periodically for safety. +revoke — the client re-runs the sign-in and consent flow. Approving the write scopes +(`surveys:write`, `feedbackRecords:write`) is re-confirmed periodically for safety. ## Troubleshooting @@ -141,7 +143,8 @@ periodically for safety. The client requested a scope it isn't registered for. Reconnect from scratch so it re-registers - with the scopes the server advertises (`surveys:read`, `surveys:write`, `offline_access`). + with the scopes the server advertises (`surveys:read`, `surveys:write`, `feedbackRecords:read`, + `feedbackRecords:write`, `offline_access`). Usually a cookie or stale-session issue. Make sure you're signed in to Formbricks in the same diff --git a/docs/surveys/general-features/multi-language-surveys.mdx b/docs/surveys/general-features/multi-language-surveys.mdx index 47b0191bdf44..a5f43afcc1dc 100644 --- a/docs/surveys/general-features/multi-language-surveys.mdx +++ b/docs/surveys/general-features/multi-language-surveys.mdx @@ -64,6 +64,43 @@ How to deliver a specific language depends on the survey type (app or link surve +--- + +## Built-in Interface Translations + +Beyond the content you translate yourself, every survey ships with a set of built-in interface strings that Formbricks localizes automatically — so respondents see them in their own language without any extra work from you. These include: + +- Default navigation and action buttons (**Back**, **Next**, **Finish**) +- Form validation messages (e.g. "Please fill out this field", "Please enter a valid email address") +- File-upload prompts and states +- Offline, retry, and "sending responses" notices +- Attribution and helper labels like "Powered by" and "Required" + +These built-in strings are currently provided in **23 languages**: + +| Language | Code | Language | Code | +| --- | --- | --- | --- | +| English (base) | `en-US` | Italian | `it-IT` | +| Arabic | `ar-EG` | Japanese | `ja-JP` | +| Chinese (Simplified) | `zh-Hans-CN` | Portuguese (Brazil) | `pt-BR` | +| Chinese (Traditional) | `zh-Hant-TW` | Romanian | `ro-RO` | +| Danish | `da-DK` | Russian | `ru-RU` | +| Dutch | `nl-NL` | Spanish | `es-ES` | +| Estonian | `et-EE` | Swedish | `sv-SE` | +| French | `fr-FR` | Turkish | `tr-TR` | +| German | `de-DE` | Urdu | `ur-PK` | +| Hindi | `hi-IN` | Uzbek | `uz-UZ` | +| Hungarian | `hu-HU` | Vietnamese | `vi-VN` | +| Indonesian | `id-ID` | | | + +Formbricks matches the survey's active language to the closest available bundle — for example, `de-AT` and `de` both use the German bundle, and `pt-PT` uses `pt-BR`. Writing script is preserved when matching, so `zh-Hant` and `zh-TW` resolve to the Traditional Chinese bundle rather than the Simplified one. If a language has no matching bundle, your translated survey content is still shown as usual, but these built-in interface strings fall back to English. + + + Don't see your language? These interface translations live in the open-source `@formbricks/surveys` package. You can add a new one by contributing a locale file on [GitHub](https://github.com/formbricks/formbricks). + + +--- + ## App Surveys Configuration diff --git a/docs/surveys/link-surveys/quickstart.mdx b/docs/surveys/link-surveys/quickstart.mdx index 6cfe5495893c..84e1f3f2fe40 100644 --- a/docs/surveys/link-surveys/quickstart.mdx +++ b/docs/surveys/link-surveys/quickstart.mdx @@ -10,36 +10,59 @@ Link Surveys make it easy for your users to give you feedback. They are a great While you can [self-host](/self-hosting/overview) Formbricks, the quickest and easiest way to get started is with the free Cloud plan. Just [sign up here](https://app.formbricks.com/auth/signup) and click through the onboarding. + + + + In your workspace, click **New Survey** and pick **Choose a template**. We'll use the **Product Market Fit (Superhuman)** template for this quickstart guide. - Choose one of the pre-created templates to get started. We'll choose the **Product Market Fit** template for this quickstart guide. + Click the template to preview it, then hit **Use this template**. + + ![Choosing the Product Market Fit template](/images/surveys/link-surveys/quickstart/choose-template.webp) - When you click the template, you'll be taken to the survey editor. Here, you can edit the survey questions and settings. To keep it simple, we'll leave the questions as they are and go to the survey settings. + You'll be taken to the survey editor. The **Questions** tab holds the blocks and questions that came with the template, and the preview on the right updates as you edit. To keep it simple, we'll leave the questions as they are. - Click on the **Settings** tab to edit the survey settings. + ![The survey editor with the template questions](/images/surveys/link-surveys/quickstart/survey-editor.webp) + + + + Open the **Settings** tab and choose **Link Survey** under **Survey Type**. This is what gives your survey a shareable URL, instead of running it inside your app or website. + + ![Selecting Link Survey as the survey type](/images/surveys/link-surveys/quickstart/survey-type-link.webp) - Formbricks packs a lot of useful functionality out of the box. You can: + Still in the **Settings** tab, open **Response Options**. Formbricks packs a lot of useful functionality out of the box. You can: - - Close the survey on a specidic date - - After a number of response + - Close the survey on a specific date + - Close the survey after a number of responses - Redirect users to a URL after they completed the survey - - Protect survey with a Pin + - Protect the survey with a PIN - ... and much more! + + ![Response Options in the survey settings](/images/surveys/link-surveys/quickstart/response-options.webp) - Style your survey to your need. You can keep it simplistic or use animated backgrounds. You can change the main color and soon you'll be able to fully control the appearance of the survey. - + Switch to the **Styling** tab and turn on **Add custom styles** to override your workspace theme for this survey. You can keep it simplistic or use animated backgrounds, and you can change the colors, the card layout and the logo. + + ![The Styling tab with custom styles enabled](/images/surveys/link-surveys/quickstart/styling.webp) + + Want the same look across all of your surveys? Set your theme once in the Look & Feel settings instead. See [Styling Theme](/platform/features/styling-theme). + + - + Once you're happy with your survey settings, hit the **Publish** button. You'll be forwarded to the Summary Page where all the responses to your survey will appear. - - - Congratulations! Your survey is now published and ready to be shared with your users. You can share the survey link via email, SMS, or any other channel you prefer. - - \ No newline at end of file + ![The survey summary page](/images/surveys/link-surveys/quickstart/summary-page.webp) + + + + Congratulations! Your survey is now published and ready to be shared with your users. Click **Share survey** to copy the survey link, or pick one of the other channels: a website or email embed, social media, a QR code or a dynamic pop-up. + + ![Sharing the survey link](/images/surveys/link-surveys/quickstart/share-survey.webp) + + diff --git a/package.json b/package.json index 5b524cdfaaa1..a072a56caefc 100644 --- a/package.json +++ b/package.json @@ -43,10 +43,6 @@ "i18n:validate": "pnpm scan-translations", "dev:setup": "bash scripts/setup-dev-env.sh" }, - "dependencies": { - "react": "19.2.6", - "react-dom": "19.2.6" - }, "devDependencies": { "@axe-core/playwright": "4.11.3", "@azure/playwright": "1.1.5", diff --git a/packages/survey-ui/src/components/general/element-media.tsx b/packages/survey-ui/src/components/general/element-media.tsx index 9e7d8bf0df39..10f34e3796f3 100644 --- a/packages/survey-ui/src/components/general/element-media.tsx +++ b/packages/survey-ui/src/components/general/element-media.tsx @@ -1,22 +1,37 @@ import { Download, ExternalLink } from "lucide-react"; import * as React from "react"; import { cn } from "@/lib/utils"; -import { checkForLoomUrl, checkForVimeoUrl, checkForYoutubeUrl, convertToEmbedUrl } from "@/lib/video"; +import { + checkForLoomUrl, + checkForVimeoUrl, + checkForYoutubeUrl, + convertToEmbedUrl, + isSafeMediaUrl, +} from "@/lib/video"; //Function to add extra params to videoUrls in order to reduce video controls -const getVideoUrlWithParams = (videoUrl: string): string => { - const isYoutubeVideo = checkForYoutubeUrl(videoUrl); - const isVimeoUrl = checkForVimeoUrl(videoUrl); - const isLoomUrl = checkForLoomUrl(videoUrl); - if (isYoutubeVideo) return videoUrl.concat("?controls=0"); - else if (isVimeoUrl) - return videoUrl.concat( +const getVideoUrlWithParams = (videoUrl: string): string | undefined => { + // Only the three supported platforms may reach the iframe, and only as the normalized embed URL that + // convertToEmbedUrl builds from a hardcoded origin plus an extracted id. Returning `videoUrl` + // unchanged for anything else put an arbitrary attacker-chosen URL into `