Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 20 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,26 @@ auto-links. No ticket? Say why. Closing a GitHub issue? Add "Fixes #123". -->

<!-- REQUIRED for any UI change: attach before/after screenshots or a recording. No visual proof → sent back. -->

## Breaking changes

<!-- REQUIRED. Does this PR change a public contract in a way that could break existing integrations or
self-hosted setups? Count as breaking: API/SDK request or response shape (renamed, removed, or retyped
fields, or changed values like `EN` → `en-US`), removed/renamed endpoints or routes, changed webhook
payloads, changed defaults, new/removed/renamed env vars or config, and DB migrations that need manual
action. If YES:
1. Add the `breaking-change` label to this PR.
2. Fill the table below — one row per change. This text feeds the GitHub release notes and the
self-hoster migration guide, so write it for an external integrator, not for the team.
If there are no breaking changes, leave "None" below. -->

None

<!-- Delete "None" above and use this table when there IS a breaking change:
| Change | Before | After | Who's affected | Action required |
| --- | --- | --- | --- | --- |
| `language` field on responses | `EN`, `DE` | `en-US`, `de-DE` | API v1 consumers | Map the new BCP-47 locale codes in your integration |
-->

## QA / Test Plan

<!--
Expand Down
96 changes: 96 additions & 0 deletions .github/workflows/pr-label-sync.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
name: PR Label Sync

# Keeps PR labels in sync with declared sections in the PR body (see .github/pull_request_template.md).
# Generic engine: to drive a new label from a new "## <heading>" section, add one entry to RULES
# below — no other changes needed.
#
# Currently configured:
# - "Breaking changes" section (a filled table / any content other than "None") -> `breaking-change`
# The Release QA audit filters on this label to compile release notes + the migration guide.
#
# Uses pull_request_target because adding a label needs a write-scoped token (incl. fork PRs). This is
# safe here: the job only reads the PR *body* (data) and never checks out or runs PR code.
# Note: pull_request_target always runs the workflow definition from the base branch, so changes here
# take effect once merged to main — they do not self-run on their own introducing PR.

on:
pull_request_target:
types: [opened, edited, reopened]

permissions:
contents: read
pull-requests: write

concurrency:
group: pr-label-sync-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
sync-labels:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit

- name: Sync labels from PR body sections
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
// A section is "declared" when it holds real content — a filled table or prose —
// i.e. it is neither empty nor a bare "None". Reusable default rule predicate.
const isDeclared = (content) => !(content === "" || /^none\.?$/i.test(content));

// Label rules. Add an entry to sync a new label from a new "## <heading>" PR-body section.
// `isActive(content)` decides whether the label should be present for the given section text.
const RULES = [
{ heading: "Breaking changes", label: "breaking-change", isActive: isDeclared },
];

const pr = context.payload.pull_request;
// Strip HTML comments so template guidance and example rows never count as content.
const body = (pr.body || "").replace(/<!--[\s\S]*?-->/g, "");

// Content under a "## <heading>" 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.`);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
];
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -52,7 +53,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
<div className="h-[75vh] w-full">
<AirtableWrapper
isEnabled={isEnabled}
airtableIntegration={airtableIntegration}
airtableIntegration={redactIntegrationCredentials(airtableIntegration)}
airtableArray={airtableArray}
workspaceId={workspace.id}
surveys={surveys}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
GOOGLE_SHEETS_REDIRECT_URL,
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";
Expand Down Expand Up @@ -46,7 +47,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}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
});
});
Expand Down
4 changes: 2 additions & 2 deletions apps/web/app/api/google-sheet/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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();
}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/app/api/google-sheet/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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();
}
Expand Down
18 changes: 15 additions & 3 deletions apps/web/app/api/mcp/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"'
);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading