diff --git a/.github/workflows/formbricks-release.yml b/.github/workflows/formbricks-release.yml
index ff4d1ca71992..30f611f284fc 100644
--- a/.github/workflows/formbricks-release.yml
+++ b/.github/workflows/formbricks-release.yml
@@ -269,12 +269,20 @@ jobs:
name: Mark Linear release as complete
runs-on: ubuntu-latest
timeout-minutes: 5
+ # Published artifacts only, and only jobs that always run for a stable release.
+ #
+ # update-helm-app-version opens a follow-up PR against main rather than publishing
+ # anything for this tag, so gating completion on it left Linear stuck on every stable
+ # release its appVersion PR job failed.
+ #
+ # move-stable-tag is excluded for a subtler reason: its called job is gated on
+ # `!is_prerelease && make_latest`, so it is legitimately skipped for any stable release
+ # that is not the latest - every patch on an older line. A skipped dependency skips this
+ # job too, so depending on it would keep Linear stuck on exactly those releases.
needs:
- docker-build-community
- docker-build-cloud
- helm-chart-release
- - move-stable-tag
- - update-helm-app-version
if: ${{ !github.event.release.prerelease }}
steps:
- name: Harden the runner
@@ -287,18 +295,23 @@ jobs:
with:
fetch-depth: 0
- # Stamp the in-progress release with the tag version before completing it.
- # complete only looks up a release by version, it never assigns one, so a
- # versioned sync must run first (Linear's recommended scheduled-pipeline flow).
+ # A versioned sync targets the release carrying this exact version, or creates it when
+ # none exists - which is what makes patch releases work, since 5.3.4 never had a
+ # planned train of its own. complete then looks that exact version up; it never
+ # assigns one, so the sync has to run first.
+ #
+ # VERSION is this release's tag with any v-prefix stripped and validated as SemVer by
+ # release-docker-github.yml. Using the raw tag_name instead would silently create a
+ # second, v-prefixed Linear release the first time somebody tags v5.6.0.
- name: Stamp Linear release version
- uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
+ uses: linear/linear-release-action@17b8c24f8ceb2b98cabaf1965ff83c55dd596fac # v0.15.1
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
- version: ${{ github.event.release.tag_name }}
+ version: ${{ needs.docker-build-community.outputs.VERSION }}
- name: Complete Linear release
- uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
+ uses: linear/linear-release-action@17b8c24f8ceb2b98cabaf1965ff83c55dd596fac # v0.15.1
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
command: complete
- version: ${{ github.event.release.tag_name }}
+ version: ${{ needs.docker-build-community.outputs.VERSION }}
diff --git a/.github/workflows/linear-release-smoke.yml b/.github/workflows/linear-release-smoke.yml
new file mode 100644
index 000000000000..096d0f1bcf2d
--- /dev/null
+++ b/.github/workflows/linear-release-smoke.yml
@@ -0,0 +1,72 @@
+name: Linear Release Smoke
+
+# Dry-run canary for the Linear release flow. dry_run makes the action scan commits and call
+# read-only Linear APIs, then log the action it would have taken; it never mutates the pipeline.
+#
+# It exists because the real flow only runs on a published release, so a stale action pin or a
+# broken install stayed invisible until release day.
+#
+# WHAT THIS DOES NOT PROVE. The pinned CLI returns from its complete command before resolving a
+# release when dry_run is set, so a green run here says nothing about whether an exact version
+# resolves or completes - a nonexistent version passes too. Treat it as "the action installs,
+# authenticates and scans", not as validation of the release flow. Exact-version completion is
+# only exercised by a real published release.
+#
+# TRIGGERS ARE DELIBERATELY TRUSTED-ONLY. This job holds a Linear key that can mutate the
+# pipeline, so it must never run code from outside main. `pull_request` would run the PR's own
+# copy of this file, and `workflow_dispatch` runs the copy on whichever ref the caller picks,
+# so either would let a pushed branch drop dry_run or add an exfiltration step. Hence: no
+# pull_request trigger, push restricted to main, and the ref check on the job below.
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: "Version to target (blank targets the active started release)"
+ required: false
+ type: string
+ push:
+ branches:
+ - main
+ paths:
+ - .github/workflows/linear-release.yml
+ - .github/workflows/formbricks-release.yml
+ - .github/workflows/linear-release-smoke.yml
+
+permissions:
+ contents: read
+
+jobs:
+ linear-release-smoke:
+ name: Dry-run the Linear release flow
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ # Belt to the trigger's braces: a workflow_dispatch runs the file as it exists on the ref
+ # the caller chose, so the key must not be handed to a branch's copy of these steps.
+ if: github.ref == 'refs/heads/main'
+ steps:
+ - name: Harden the runner
+ uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
+ with:
+ egress-policy: audit
+
+ - name: Checkout repository
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+ with:
+ fetch-depth: 0
+
+ - name: Dry-run the release sync
+ uses: linear/linear-release-action@17b8c24f8ceb2b98cabaf1965ff83c55dd596fac # v0.15.1
+ with:
+ access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
+ version: ${{ inputs.version }}
+ dry_run: "true"
+ log_level: verbose
+
+ - name: Dry-run the release completion
+ uses: linear/linear-release-action@17b8c24f8ceb2b98cabaf1965ff83c55dd596fac # v0.15.1
+ with:
+ access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
+ command: complete
+ version: ${{ inputs.version }}
+ dry_run: "true"
+ log_level: verbose
diff --git a/.github/workflows/linear-release.yml b/.github/workflows/linear-release.yml
index d1650fc25944..6e87b1fe2f72 100644
--- a/.github/workflows/linear-release.yml
+++ b/.github/workflows/linear-release.yml
@@ -25,6 +25,6 @@ jobs:
fetch-depth: 0
- name: Sync Linear release
- uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
+ uses: linear/linear-release-action@17b8c24f8ceb2b98cabaf1965ff83c55dd596fac # v0.15.1
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx
index a23407b9cf3c..ff8e3816cb34 100644
--- a/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx
+++ b/apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx
@@ -72,6 +72,25 @@ interface NavigationProps {
isFormbricksSurveysConfigured: boolean;
}
+/**
+ * A nav section header carrying a Beta badge.
+ *
+ * Analyze and Act are both pre-1.0 surfaces, and the badge is what tells someone the difference
+ * between "this is finished" and "this is early". Extracted rather than duplicated so the two
+ * sections cannot drift into looking subtly different from each other.
+ */
+const sectionLabelWithBeta = (label: React.ReactNode) => (
+
+ {label}
+
+
+);
+
export const MainNavigation = ({
organization,
user,
@@ -151,18 +170,9 @@ export const MainNavigation = ({
},
{
id: "unify-feedback",
- name: (
-
- {/* Product section (IA) label — intentionally not localized (kept in English across all locales) */}
- Unify
-
-
- ),
+ // Same policy as "Ask" above: product section labels stay English in every locale.
+ // Was "Unify" until ENG-2742 settled on Ask / Analyze / Act as the three pillars.
+ name: sectionLabelWithBeta("Analyze"),
items: [
{
name: t("workspace.unify.feedback_data"),
@@ -184,7 +194,11 @@ export const MainNavigation = ({
},
{
id: "act",
- name: t("common.act"),
+ // Kept translated, unlike "Ask" and "Analyze" above. Those two are deliberately English in
+ // every locale; this one has been going through t() since it was added. Making the three
+ // consistent means dropping a string 15 locales already translate, which is a naming
+ // decision rather than a side effect of adding a badge — see ENG-2742.
+ name: sectionLabelWithBeta(t("common.act")),
items: [
{
name: t("common.workflows"),
diff --git a/apps/web/app/api/v1/management/responses/lib/response.test.ts b/apps/web/app/api/v1/management/responses/lib/response.test.ts
index 459f8596f41b..45417d1fae68 100644
--- a/apps/web/app/api/v1/management/responses/lib/response.test.ts
+++ b/apps/web/app/api/v1/management/responses/lib/response.test.ts
@@ -104,6 +104,8 @@ vi.mock("@/lib/constants", () => ({
STRIPE_API_VERSION: "2026-01-28.clover",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
+ COMMUNITY_WORKSPACE_LIMIT: 1,
+ CLOUD_HOBBY_WORKSPACE_LIMIT: 1,
}));
vi.mock("@/lib/utils/helper");
vi.mock("@/lib/response/service");
diff --git a/apps/web/app/api/v2/client/[workspaceId]/responses/lib/response.test.ts b/apps/web/app/api/v2/client/[workspaceId]/responses/lib/response.test.ts
index 80e8905de3c1..aa597878fbce 100644
--- a/apps/web/app/api/v2/client/[workspaceId]/responses/lib/response.test.ts
+++ b/apps/web/app/api/v2/client/[workspaceId]/responses/lib/response.test.ts
@@ -48,6 +48,8 @@ vi.mock("@/lib/constants", () => ({
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
STRIPE_API_VERSION: "2026-01-28.clover",
+ COMMUNITY_WORKSPACE_LIMIT: 1,
+ CLOUD_HOBBY_WORKSPACE_LIMIT: 1,
}));
vi.mock("@/lib/organization/service");
diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock
index 6da374bf295b..886ad51eec06 100644
--- a/apps/web/i18n.lock
+++ b/apps/web/i18n.lock
@@ -552,7 +552,7 @@ checksums:
common/yes: ec580fd11a45779b039466f1e35eed2a
common/you_are_downgraded_to_the_community_edition: e3ae56502ff787109cae0997519f628e
common/you_are_not_authorized_to_perform_this_action: 1b3255ab740582ddff016a399f8bf302
- common/you_have_reached_your_limit_of_workspace_limit: 506a6ee315d9754da7ea26929bc40f52
+ common/you_have_reached_your_limit_of_workspace_limit: 444293496a4029ea608ec12266bb2e94
common/you_have_reached_your_monthly_response_limit_of_count: d1e427dbc5ce704bd6b9e188d8407e07
common/you_will_be_downgraded_to_the_community_edition_on_date: bff35b54c13e2c205dc4c19056261cc0
common/your_license_has_expired_please_renew: 3f21ae4a7deab351b143b407ece58254
diff --git a/apps/web/lib/constants.ts b/apps/web/lib/constants.ts
index af21a9506f46..36e34bef5b6a 100644
--- a/apps/web/lib/constants.ts
+++ b/apps/web/lib/constants.ts
@@ -109,6 +109,21 @@ export const TEXT_RESPONSES_PER_PAGE = 5;
export const MAX_RESPONSES_FOR_INSIGHT_GENERATION = 500;
export const MAX_OTHER_OPTION_LENGTH = 250;
+/**
+ * Workspaces an organization gets on a self-hosted instance with no active enterprise license
+ * (Community Edition). Mirrors docs/self-hosting/advanced/license.mdx.
+ */
+export const COMMUNITY_WORKSPACE_LIMIT = 1;
+
+/**
+ * Workspaces a cloud organization falls back to when the license server cannot confirm the instance
+ * license (expired, invalid_license, instance_mismatch, unreachable). Deliberately the Hobby (free
+ * tier) allowance: an entitlement we cannot verify is treated as no entitlement. The create gate is
+ * `count >= limit`, so an org already above it keeps every workspace it has and only pauses creating
+ * new ones until the license resolves.
+ */
+export const CLOUD_HOBBY_WORKSPACE_LIMIT = 1;
+
export const SKIP_INVITE_FOR_SSO = env.AUTH_SKIP_INVITE_FOR_SSO === "1";
export const DEFAULT_TEAM_ID = env.AUTH_DEFAULT_TEAM_ID;
diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json
index 2a15582b689d..1ff2a5b9072c 100644
--- a/apps/web/locales/de-DE.json
+++ b/apps/web/locales/de-DE.json
@@ -581,7 +581,7 @@
"yes": "Ja",
"you_are_downgraded_to_the_community_edition": "Du wurdest auf die Community Edition herabgestuft.",
"you_are_not_authorized_to_perform_this_action": "Du bist nicht berechtigt, diese Aktion durchzuführen.",
- "you_have_reached_your_limit_of_workspace_limit": "Du hast dein Limit von {workspaceLimit} Workspaces erreicht.",
+ "you_have_reached_your_limit_of_workspace_limit": "Du hast dein Workspace-Limit ({workspaceLimit}) erreicht.",
"you_have_reached_your_monthly_response_limit_of_count": "Du hast dein monatliches Antwortlimit von {count} erreicht.",
"you_will_be_downgraded_to_the_community_edition_on_date": "Du wirst am {date} auf die Community Edition herabgestuft.",
"your_license_has_expired_please_renew": "Deine Enterprise-Lizenz ist abgelaufen. Bitte erneuere sie, um weiterhin Enterprise-Funktionen nutzen zu können.",
diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json
index 2cf95fbd8f4b..f4c1f01fc1ee 100644
--- a/apps/web/locales/en-US.json
+++ b/apps/web/locales/en-US.json
@@ -581,7 +581,7 @@
"yes": "Yes",
"you_are_downgraded_to_the_community_edition": "You are downgraded to the Community Edition.",
"you_are_not_authorized_to_perform_this_action": "You are not authorized to perform this action.",
- "you_have_reached_your_limit_of_workspace_limit": "You have reached your limit of {workspaceLimit} workspaces.",
+ "you_have_reached_your_limit_of_workspace_limit": "You have reached your workspace limit ({workspaceLimit}).",
"you_have_reached_your_monthly_response_limit_of_count": "You have reached your monthly response limit of {count}.",
"you_will_be_downgraded_to_the_community_edition_on_date": "You will be downgraded to the Community Edition on {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features.",
diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json
index 89d673464727..833071275e62 100644
--- a/apps/web/locales/es-ES.json
+++ b/apps/web/locales/es-ES.json
@@ -581,7 +581,7 @@
"yes": "Sí",
"you_are_downgraded_to_the_community_edition": "Has sido degradado a la edición Community.",
"you_are_not_authorized_to_perform_this_action": "No tienes autorización para realizar esta acción.",
- "you_have_reached_your_limit_of_workspace_limit": "Has alcanzado tu límite de {workspaceLimit} espacios de trabajo.",
+ "you_have_reached_your_limit_of_workspace_limit": "Has alcanzado el límite de tu espacio de trabajo ({workspaceLimit}).",
"you_have_reached_your_monthly_response_limit_of_count": "Has alcanzado tu límite mensual de respuestas de {count}.",
"you_will_be_downgraded_to_the_community_edition_on_date": "Serás degradado a la edición Community el {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features.",
diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json
index 59a1127d9bd7..15f225bb8708 100644
--- a/apps/web/locales/fr-FR.json
+++ b/apps/web/locales/fr-FR.json
@@ -581,7 +581,7 @@
"yes": "Oui",
"you_are_downgraded_to_the_community_edition": "Vous êtes rétrogradé à l'édition communautaire.",
"you_are_not_authorized_to_perform_this_action": "Vous n'êtes pas autorisé à effectuer cette action.",
- "you_have_reached_your_limit_of_workspace_limit": "Vous avez atteint votre limite de {workspaceLimit} espaces de travail.",
+ "you_have_reached_your_limit_of_workspace_limit": "Tu as atteint la limite de ton espace de travail ({workspaceLimit}).",
"you_have_reached_your_monthly_response_limit_of_count": "Vous avez atteint votre limite mensuelle de {count} réponses.",
"you_will_be_downgraded_to_the_community_edition_on_date": "Vous serez rétrogradé à l'édition communautaire le {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features.",
diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json
index 0e051f4762bf..cac88fa2b6ea 100644
--- a/apps/web/locales/hu-HU.json
+++ b/apps/web/locales/hu-HU.json
@@ -581,7 +581,7 @@
"yes": "Igen",
"you_are_downgraded_to_the_community_edition": "Visszaváltott a közösségi kiadásra.",
"you_are_not_authorized_to_perform_this_action": "Nincs felhatalmazva ennek a műveletnek a végrehajtásához.",
- "you_have_reached_your_limit_of_workspace_limit": "Elérte a(z) {workspaceLimit} munkaterületből álló korlátját.",
+ "you_have_reached_your_limit_of_workspace_limit": "Elérte a munkaterület korlátját ({workspaceLimit}).",
"you_have_reached_your_monthly_response_limit_of_count": "Elérte a havi {count} értékű válaszkorlátját.",
"you_will_be_downgraded_to_the_community_edition_on_date": "Vissza lesz állítva a közösségi kiadásra ekkor: {date}.",
"your_license_has_expired_please_renew": "A vállalati licence lejárt. Újítsa meg, hogy továbbra is használhassa a vállalati funkciókat.",
diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json
index d6cc2715caf6..b1ed215d3ed8 100644
--- a/apps/web/locales/ja-JP.json
+++ b/apps/web/locales/ja-JP.json
@@ -581,7 +581,7 @@
"yes": "はい",
"you_are_downgraded_to_the_community_edition": "コミュニティ版にダウングレードされました。",
"you_are_not_authorized_to_perform_this_action": "このアクションを実行する権限がありません。",
- "you_have_reached_your_limit_of_workspace_limit": "ワークスペースの上限である{workspaceLimit}件に達しました。",
+ "you_have_reached_your_limit_of_workspace_limit": "ワークスペースの上限({workspaceLimit})に達しました。",
"you_have_reached_your_monthly_response_limit_of_count": "月間の回答制限{count}件に達しました。",
"you_will_be_downgraded_to_the_community_edition_on_date": "コミュニティ版へのダウングレードは {date} に行われます。",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features.",
diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json
index 1802dd175701..eaa0c8ae672e 100644
--- a/apps/web/locales/nl-NL.json
+++ b/apps/web/locales/nl-NL.json
@@ -581,7 +581,7 @@
"yes": "Ja",
"you_are_downgraded_to_the_community_edition": "Je bent gedowngraded naar de Community-editie.",
"you_are_not_authorized_to_perform_this_action": "U bent niet geautoriseerd om deze actie uit te voeren.",
- "you_have_reached_your_limit_of_workspace_limit": "Je hebt je limiet van {workspaceLimit} workspaces bereikt.",
+ "you_have_reached_your_limit_of_workspace_limit": "Je hebt je limiet van werkruimtes bereikt ({workspaceLimit}).",
"you_have_reached_your_monthly_response_limit_of_count": "Je hebt je maandelijkse responslimiet van {count} bereikt.",
"you_will_be_downgraded_to_the_community_edition_on_date": "Je wordt gedowngraded naar de Community-editie op {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features.",
diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json
index 0a22f2bab72a..d0c1103e7c57 100644
--- a/apps/web/locales/pt-BR.json
+++ b/apps/web/locales/pt-BR.json
@@ -581,7 +581,7 @@
"yes": "Sim",
"you_are_downgraded_to_the_community_edition": "Você foi rebaixado para a Edição Comunitária.",
"you_are_not_authorized_to_perform_this_action": "Você não tem autorização para realizar essa ação.",
- "you_have_reached_your_limit_of_workspace_limit": "Você atingiu o limite de {workspaceLimit} espaços de trabalho.",
+ "you_have_reached_your_limit_of_workspace_limit": "Você atingiu o limite do seu workspace ({workspaceLimit}).",
"you_have_reached_your_monthly_response_limit_of_count": "Você atingiu seu limite mensal de respostas de {count}.",
"you_will_be_downgraded_to_the_community_edition_on_date": "Você será rebaixado para a Edição Comunitária em {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features.",
diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json
index bbaa3bf8ad98..1961bebbebfd 100644
--- a/apps/web/locales/pt-PT.json
+++ b/apps/web/locales/pt-PT.json
@@ -581,7 +581,7 @@
"yes": "Sim",
"you_are_downgraded_to_the_community_edition": "Foi rebaixado para a Edição Comunitária.",
"you_are_not_authorized_to_perform_this_action": "Não está autorizado a realizar esta ação.",
- "you_have_reached_your_limit_of_workspace_limit": "Atingiste o teu limite de {workspaceLimit} espaços de trabalho.",
+ "you_have_reached_your_limit_of_workspace_limit": "Atingiste o teu limite de espaço de trabalho ({workspaceLimit}).",
"you_have_reached_your_monthly_response_limit_of_count": "Atingiste o teu limite mensal de respostas de {count}.",
"you_will_be_downgraded_to_the_community_edition_on_date": "Será rebaixado para a Edição Comunitária em {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features.",
diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json
index 290a58a54db5..a88d7817e1b9 100644
--- a/apps/web/locales/ro-RO.json
+++ b/apps/web/locales/ro-RO.json
@@ -581,7 +581,7 @@
"yes": "Da",
"you_are_downgraded_to_the_community_edition": "Ai fost retrogradat la ediția Community.",
"you_are_not_authorized_to_perform_this_action": "Nu sunteți autorizat să efectuați această acțiune.",
- "you_have_reached_your_limit_of_workspace_limit": "Ai atins limita de {workspaceLimit} spații de lucru.",
+ "you_have_reached_your_limit_of_workspace_limit": "Ai atins limita spațiului de lucru ({workspaceLimit}).",
"you_have_reached_your_monthly_response_limit_of_count": "Ai atins limita lunară de răspunsuri de {count}.",
"you_will_be_downgraded_to_the_community_edition_on_date": "Vei fi retrogradat la ediția Community pe {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features.",
diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json
index 49b064804e51..f5eb038d38a6 100644
--- a/apps/web/locales/ru-RU.json
+++ b/apps/web/locales/ru-RU.json
@@ -581,7 +581,7 @@
"yes": "Да",
"you_are_downgraded_to_the_community_edition": "Ваша версия понижена до Community Edition.",
"you_are_not_authorized_to_perform_this_action": "У вас нет прав для выполнения этого действия.",
- "you_have_reached_your_limit_of_workspace_limit": "Вы достигли лимита в {workspaceLimit} рабочих пространств.",
+ "you_have_reached_your_limit_of_workspace_limit": "Вы достигли лимита рабочих пространств ({workspaceLimit}).",
"you_have_reached_your_monthly_response_limit_of_count": "Вы достигли месячного лимита ответов: {count}.",
"you_will_be_downgraded_to_the_community_edition_on_date": "Ваша версия будет понижена до Community Edition {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features.",
diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json
index ff468cd256cc..c8dd40cf1133 100644
--- a/apps/web/locales/sv-SE.json
+++ b/apps/web/locales/sv-SE.json
@@ -581,7 +581,7 @@
"yes": "Ja",
"you_are_downgraded_to_the_community_edition": "Du har nedgraderats till Community Edition.",
"you_are_not_authorized_to_perform_this_action": "Du har inte behörighet att utföra denna åtgärd.",
- "you_have_reached_your_limit_of_workspace_limit": "Du har nått din gräns på {workspaceLimit} arbetsytor.",
+ "you_have_reached_your_limit_of_workspace_limit": "Du har nått din gräns för arbetsytor ({workspaceLimit}).",
"you_have_reached_your_monthly_response_limit_of_count": "Du har nått din månatliga svarsgräns på {count}.",
"you_will_be_downgraded_to_the_community_edition_on_date": "Du kommer att nedgraderas till Community Edition den {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features.",
diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json
index 714cabd35631..9d9fd3291c84 100644
--- a/apps/web/locales/tr-TR.json
+++ b/apps/web/locales/tr-TR.json
@@ -581,7 +581,7 @@
"yes": "Evet",
"you_are_downgraded_to_the_community_edition": "Topluluk Sürümüne düşürüldünüz.",
"you_are_not_authorized_to_perform_this_action": "Bu işlemi gerçekleştirme yetkiniz yok.",
- "you_have_reached_your_limit_of_workspace_limit": "{workspaceLimit} çalışma alanı limitine ulaştınız.",
+ "you_have_reached_your_limit_of_workspace_limit": "Çalışma alanı limitine ulaştın ({workspaceLimit}).",
"you_have_reached_your_monthly_response_limit_of_count": "Aylık {count} yanıt limitinize ulaştınız.",
"you_will_be_downgraded_to_the_community_edition_on_date": "{date} tarihinde Topluluk Sürümüne düşürüleceksiniz.",
"your_license_has_expired_please_renew": "Kurumsal lisansınızın süresi doldu. Kurumsal özellikleri kullanmaya devam etmek için lütfen yenileyin.",
diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json
index 26106dc8562c..229cbdefec65 100644
--- a/apps/web/locales/zh-Hans-CN.json
+++ b/apps/web/locales/zh-Hans-CN.json
@@ -581,7 +581,7 @@
"yes": "是",
"you_are_downgraded_to_the_community_edition": "您已降级到社区版。",
"you_are_not_authorized_to_perform_this_action": "您无权执行此操作。",
- "you_have_reached_your_limit_of_workspace_limit": "您已达到 {workspaceLimit} 个工作区的上限。",
+ "you_have_reached_your_limit_of_workspace_limit": "你已达到工作区限制 ({workspaceLimit})。",
"you_have_reached_your_monthly_response_limit_of_count": "您已达到每月响应限制 {count} 次。",
"you_will_be_downgraded_to_the_community_edition_on_date": "您将在 {date} 降级到社区版。",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features.",
diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json
index 3c71dbd5abdf..4ad4794c1b68 100644
--- a/apps/web/locales/zh-Hant-TW.json
+++ b/apps/web/locales/zh-Hant-TW.json
@@ -581,7 +581,7 @@
"yes": "是",
"you_are_downgraded_to_the_community_edition": "您已降級至社群版。",
"you_are_not_authorized_to_perform_this_action": "您沒有執行此操作的權限。",
- "you_have_reached_your_limit_of_workspace_limit": "您已達到 {workspaceLimit} 個工作區的上限。",
+ "you_have_reached_your_limit_of_workspace_limit": "你已達到工作區上限({workspaceLimit})。",
"you_have_reached_your_monthly_response_limit_of_count": "您已達到每月回應上限 {count} 次。",
"you_will_be_downgraded_to_the_community_edition_on_date": "您將於 {date} 降級至社群版。",
"your_license_has_expired_please_renew": "您的企業授權已過期。請續約以繼續使用企業功能。",
diff --git a/apps/web/modules/ee/contacts/lib/attributes.test.ts b/apps/web/modules/ee/contacts/lib/attributes.test.ts
index e771e79e8aa4..b4b2f414a02c 100644
--- a/apps/web/modules/ee/contacts/lib/attributes.test.ts
+++ b/apps/web/modules/ee/contacts/lib/attributes.test.ts
@@ -7,7 +7,7 @@ import {
hasEmailAttribute,
hasUserIdAttribute,
} from "@/modules/ee/contacts/lib/contact-attributes";
-import { updateAttributes } from "./attributes";
+import { formatAttributeMessage, updateAttributes } from "./attributes";
vi.mock("@/lib/constants", () => ({
MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT: 2,
@@ -491,3 +491,30 @@ describe("updateAttributes", () => {
expect(transactionCall).toHaveLength(2);
});
});
+
+describe("formatAttributeMessage", () => {
+ test("describes the duplicate email/userId checks as workspace-scoped", () => {
+ // The checks behind these two codes call hasEmailAttribute/hasUserIdAttribute with workspaceId,
+ // and the UI renders the same conditions via workspace.contacts.attributes_msg_* — so the
+ // English templates must not describe the scope as an environment.
+ expect(formatAttributeMessage({ code: "email_already_exists", params: {} })).toBe(
+ "The email already exists for this workspace and was not updated."
+ );
+ expect(formatAttributeMessage({ code: "userid_already_exists", params: {} })).toBe(
+ "The userId already exists for this workspace and was not updated."
+ );
+ });
+
+ test("interpolates every occurrence of a param", () => {
+ expect(
+ formatAttributeMessage({
+ code: "attribute_type_validation_error",
+ params: { error: "Not a number", key: "age", dataType: "number" },
+ })
+ ).toBe("Not a number (attribute 'age' has dataType: number)");
+ });
+
+ test("falls back to the raw code when no template exists", () => {
+ expect(formatAttributeMessage({ code: "some_unmapped_code", params: {} })).toBe("some_unmapped_code");
+ });
+});
diff --git a/apps/web/modules/ee/contacts/lib/attributes.ts b/apps/web/modules/ee/contacts/lib/attributes.ts
index f2d83e9b7b4b..37c2c6776279 100644
--- a/apps/web/modules/ee/contacts/lib/attributes.ts
+++ b/apps/web/modules/ee/contacts/lib/attributes.ts
@@ -38,8 +38,8 @@ export interface TAttributeUpdateMessage {
const MESSAGE_TEMPLATES: Record = {
email_or_userid_required: "Either email or userId is required. The existing values were preserved.",
attribute_type_validation_error: "{error} (attribute '{key}' has dataType: {dataType})",
- email_already_exists: "The email already exists for this environment and was not updated.",
- userid_already_exists: "The userId already exists for this environment and was not updated.",
+ email_already_exists: "The email already exists for this workspace and was not updated.",
+ userid_already_exists: "The userId already exists for this workspace and was not updated.",
invalid_attribute_keys:
"Skipped creating attribute(s) with invalid key(s): {keys}. Keys must only contain lowercase letters, numbers, and underscores, and must start with a letter.",
reserved_attribute_keys: "{issue}",
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 c46cc78eb732..47974ea02905 100644
--- a/apps/web/modules/ee/license-check/lib/license.test.ts
+++ b/apps/web/modules/ee/license-check/lib/license.test.ts
@@ -332,7 +332,7 @@ describe("License Core Logic", () => {
active: false,
features: {
isMultiOrgEnabled: false,
- workspaces: 3,
+ workspaces: 1,
twoFactorAuth: false,
sso: false,
whitelabel: false,
@@ -356,7 +356,7 @@ describe("License Core Logic", () => {
active: false,
features: {
isMultiOrgEnabled: false,
- workspaces: 3,
+ workspaces: 1,
twoFactorAuth: false,
sso: false,
whitelabel: false,
@@ -389,7 +389,7 @@ describe("License Core Logic", () => {
const license = await getEnterpriseLicense();
const expectedFeatures: TEnterpriseLicenseFeatures = {
isMultiOrgEnabled: false,
- workspaces: 3,
+ workspaces: 1,
twoFactorAuth: false,
sso: false,
whitelabel: false,
@@ -489,7 +489,7 @@ describe("License Core Logic", () => {
active: false,
features: expect.objectContaining({
isMultiOrgEnabled: false,
- workspaces: 3,
+ workspaces: 1,
removeBranding: false,
}),
lastChecked: expect.any(Date),
@@ -522,7 +522,7 @@ describe("License Core Logic", () => {
expect(license).toEqual({
active: false,
- features: expect.objectContaining({ workspaces: 3 }),
+ features: expect.objectContaining({ workspaces: 1 }),
lastChecked: expect.any(Date),
isPendingDowngrade: false,
fallbackLevel: "default" as const,
@@ -553,7 +553,7 @@ describe("License Core Logic", () => {
expect(license).toEqual({
active: false,
- features: expect.objectContaining({ workspaces: 3 }),
+ features: expect.objectContaining({ workspaces: 1 }),
lastChecked: expect.any(Date),
isPendingDowngrade: false,
fallbackLevel: "default" as const,
@@ -1304,7 +1304,7 @@ describe("License Core Logic", () => {
active: false,
features: expect.objectContaining({
isMultiOrgEnabled: false,
- workspaces: 3,
+ workspaces: 1,
}),
lastChecked: expect.any(Date),
isPendingDowngrade: false,
@@ -1328,7 +1328,7 @@ describe("License Core Logic", () => {
active: false,
features: expect.objectContaining({
isMultiOrgEnabled: false,
- workspaces: 3,
+ workspaces: 1,
}),
lastChecked: expect.any(Date),
isPendingDowngrade: false,
diff --git a/apps/web/modules/ee/license-check/lib/license.ts b/apps/web/modules/ee/license-check/lib/license.ts
index 5da16aedc6e2..25028475518c 100644
--- a/apps/web/modules/ee/license-check/lib/license.ts
+++ b/apps/web/modules/ee/license-check/lib/license.ts
@@ -6,7 +6,7 @@ import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { cache } from "@/lib/cache";
-import { E2E_TESTING } from "@/lib/constants";
+import { COMMUNITY_WORKSPACE_LIMIT, E2E_TESTING } from "@/lib/constants";
import { env } from "@/lib/env";
import { hashString } from "@/lib/hash-string";
import { getInstanceId } from "@/lib/instance";
@@ -146,7 +146,7 @@ export const getCacheKeys = () => {
// Default features
const DEFAULT_FEATURES: TEnterpriseLicenseFeatures = {
isMultiOrgEnabled: false,
- workspaces: 3,
+ workspaces: COMMUNITY_WORKSPACE_LIMIT,
twoFactorAuth: false,
sso: false,
whitelabel: false,
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 1cfe7b29c830..5ed08f09d570 100644
--- a/apps/web/modules/ee/license-check/lib/utils.test.ts
+++ b/apps/web/modules/ee/license-check/lib/utils.test.ts
@@ -552,7 +552,7 @@ describe("License Utils", () => {
expect(result).toBe(Infinity);
});
- test("returns 3 when cloud license status does not allow usage", async () => {
+ test("falls back to the cloud Hobby limit when the license status does not allow usage", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(getOrganizationEntitlementsContext).mockResolvedValue({
...defaultEntitlementsContext,
@@ -562,7 +562,9 @@ describe("License Utils", () => {
const result = await getOrganizationWorkspacesLimit("org_1");
- expect(result).toBe(3);
+ // The org's own entitlement (10) is deliberately ignored: an entitlement we cannot verify is
+ // treated as no entitlement, so the free-tier allowance applies until the license resolves.
+ expect(result).toBe(1);
});
test("returns self-hosted workspace limit from active license feature", async () => {
@@ -593,7 +595,7 @@ describe("License Utils", () => {
expect(result).toBe(Infinity);
});
- test("returns 3 for self-hosted when the license is not active", async () => {
+ test("returns the community limit for self-hosted when the license is not active", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(getOrganizationEntitlementsContext).mockResolvedValue({
...defaultEntitlementsContext,
@@ -604,10 +606,10 @@ describe("License Utils", () => {
const result = await getOrganizationWorkspacesLimit("org_1");
- expect(result).toBe(3);
+ expect(result).toBe(1);
});
- test("returns 3 for self-hosted when there are no license features", async () => {
+ test("returns the community limit for self-hosted when there are no license features", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(getOrganizationEntitlementsContext).mockResolvedValue({
...defaultEntitlementsContext,
@@ -618,7 +620,21 @@ describe("License Utils", () => {
const result = await getOrganizationWorkspacesLimit("org_1");
- expect(result).toBe(3);
+ expect(result).toBe(1);
+ });
+
+ test("returns the community limit for self-hosted with no license key at all", async () => {
+ vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
+ vi.mocked(getOrganizationEntitlementsContext).mockResolvedValue({
+ ...defaultEntitlementsContext,
+ source: "self_hosted_license",
+ licenseStatus: "no-license",
+ licenseFeatures: null,
+ });
+
+ const result = await getOrganizationWorkspacesLimit("org_1");
+
+ expect(result).toBe(1);
});
});
diff --git a/apps/web/modules/ee/license-check/lib/utils.ts b/apps/web/modules/ee/license-check/lib/utils.ts
index e4ab67264a14..aca5ff23da13 100644
--- a/apps/web/modules/ee/license-check/lib/utils.ts
+++ b/apps/web/modules/ee/license-check/lib/utils.ts
@@ -1,5 +1,11 @@
import "server-only";
-import { AUDIT_LOG_ENABLED, IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED } from "@/lib/constants";
+import {
+ AUDIT_LOG_ENABLED,
+ CLOUD_HOBBY_WORKSPACE_LIMIT,
+ COMMUNITY_WORKSPACE_LIMIT,
+ IS_FORMBRICKS_CLOUD,
+ IS_RECAPTCHA_CONFIGURED,
+} from "@/lib/constants";
import { CLOUD_STRIPE_FEATURE_LOOKUP_KEYS } from "@/modules/billing/lib/stripe-catalog";
import type { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
import { hasOrganizationEntitlementWithLicenseGuard } from "@/modules/entitlements/lib/checks";
@@ -189,7 +195,7 @@ export const getOrganizationWorkspacesLimit = async (organizationId: string): Pr
if (IS_FORMBRICKS_CLOUD) {
const cloudLicenseAllowsLimits =
entitlementsContext.licenseStatus === "active" || entitlementsContext.licenseStatus === "no-license";
- if (!cloudLicenseAllowsLimits) return 3;
+ if (!cloudLicenseAllowsLimits) return CLOUD_HOBBY_WORKSPACE_LIMIT;
return entitlementsContext.limits.workspaces ?? Infinity;
}
@@ -198,5 +204,6 @@ export const getOrganizationWorkspacesLimit = async (organizationId: string): Pr
return entitlementsContext.licenseFeatures.workspaces ?? Infinity;
}
- return 3;
+ // No active license (no-license / expired / invalid / unreachable) — Community Edition.
+ return COMMUNITY_WORKSPACE_LIMIT;
};
diff --git a/apps/web/modules/ee/sso/lib/better-auth-providers.test.ts b/apps/web/modules/ee/sso/lib/better-auth-providers.test.ts
index 585423be38d6..109889ed0280 100644
--- a/apps/web/modules/ee/sso/lib/better-auth-providers.test.ts
+++ b/apps/web/modules/ee/sso/lib/better-auth-providers.test.ts
@@ -6,6 +6,10 @@ import { PINNED_SSO_PROVIDER_IDS } from "@/modules/auth/lib/legacy-sso-callback"
const { captureSsoIdentity } = vi.hoisted(() => ({ captureSsoIdentity: vi.fn() }));
vi.mock("./sso-request-context", () => ({ captureSsoIdentity }));
+// The module warns at import time when a pseudo-tenant is configured (ENG-2750); capture it.
+const { loggerWarn } = vi.hoisted(() => ({ loggerWarn: vi.fn() }));
+vi.mock("@formbricks/logger", () => ({ logger: { warn: loggerWarn } }));
+
// The pinned SSO callback URL is built from `getAuthIssuerUrl()`, which reads `@/lib/env` directly rather
// than the constants mocked below — it has to, because that helper encodes Better Auth's own base-URL
// precedence (`BETTER_AUTH_URL ?? NEXTAUTH_URL ?? WEBAPP_URL`). Spread the real env so `@/lib/constants`
@@ -85,6 +89,7 @@ const callMapper = (mapper: unknown, profile: Record): { email?
beforeEach(() => {
captureSsoIdentity.mockClear();
+ loggerWarn.mockClear();
});
afterEach(() => {
@@ -323,6 +328,123 @@ describe("better-auth SSO providers", () => {
expect(azure?.tokenUrl).toBeUndefined();
});
+ /**
+ * ENG-2750: `common` and `organizations` must NOT take the discovery branch. Their discovery
+ * documents advertise the literal `{tenantid}` placeholder as `issuer`, which 1.7's literal `iss`
+ * comparison can never match — with `AZUREAD_TENANT_ID=common` in the env (Cloud prod's config),
+ * every Microsoft sign-in failed verification and landed on `?error=unable_to_get_user_info`.
+ * The authority is preserved in the endpoint URLs: `organizations` still restricts which account
+ * types Microsoft accepts at the authorize endpoint.
+ *
+ * `consumers` is deliberately absent — it advertises a real issuer, so it belongs with the
+ * discovery cases below.
+ */
+ test.each([
+ ["common", "common"],
+ ["organizations", "organizations"],
+ // Case-insensitive and trimmed (an operator-typed env var), and emitted in canonical lower case.
+ ["Common", "common"],
+ [" common ", "common"],
+ ["ORGANIZATIONS", "organizations"],
+ ])(
+ "Azure treats the template-issuer authority %j like unset: explicit endpoints, no discovery",
+ async (value, inUrl) => {
+ const m = await loadProviders({
+ ENTERPRISE_LICENSE_KEY: "lic",
+ AZURE_OAUTH_ENABLED: true,
+ AZUREAD_TENANT_ID: value,
+ });
+ const azure = m.ssoGenericOAuthConfig.find((c) => c.providerId === "azuread");
+
+ expect(azure?.discoveryUrl).toBeUndefined();
+ expect(azure?.authorizationUrl).toBe(
+ `https://login.microsoftonline.com/${inUrl}/oauth2/v2.0/authorize`
+ );
+ expect(azure?.tokenUrl).toBe(`https://login.microsoftonline.com/${inUrl}/oauth2/v2.0/token`);
+ expect(azure?.userInfoUrl).toBe("https://graph.microsoft.com/oidc/userinfo");
+ // The operator set a value and is getting the weaker multi-tenant mode — that must be visible.
+ expect(loggerWarn).toHaveBeenCalledTimes(1);
+ expect(loggerWarn.mock.calls[0][0]).toContain("placeholder issuer");
+ }
+ );
+
+ /**
+ * The warning must not describe this as "treating it like unset". Unset resolves to `common`,
+ * which accepts personal accounts, so an operator who chose `organizations` to allow only
+ * work/school accounts would read that as having silently lost the restriction — while in fact
+ * only id_token verification is given up and the authority still applies at the authorize
+ * endpoint. Pinned because it is a deliberate wording decision, not incidental phrasing.
+ */
+ test("the tenant warning says the configured authority still applies, not that it is ignored", async () => {
+ await loadProviders({
+ ENTERPRISE_LICENSE_KEY: "lic",
+ AZURE_OAUTH_ENABLED: true,
+ AZUREAD_TENANT_ID: "organizations",
+ });
+
+ expect(loggerWarn).toHaveBeenCalledTimes(1);
+ const message = loggerWarn.mock.calls[0][0] as string;
+ expect(message).toContain("still applies");
+ expect(message).not.toMatch(/like unset|treated as unset/i);
+ });
+
+ /**
+ * Every tenant whose discovery document carries a real issuer keeps the stronger discovery path.
+ * `consumers` is the one that is easy to get wrong: it looks like a sibling of `common` and
+ * `organizations`, but all personal Microsoft accounts live in one well-known MSA tenant, so its
+ * discovery document names that tenant as the issuer and its id_tokens verify. Treating it as a
+ * placeholder authority would silently drop a check that works today.
+ */
+ test.each([
+ ["a verified domain", "contoso.onmicrosoft.com", "contoso.onmicrosoft.com"],
+ ["the personal-accounts authority", "consumers", "consumers"],
+ ["a mixed-case value, passed through unchanged", "Contoso.OnMicrosoft.com", "Contoso.OnMicrosoft.com"],
+ ])("Azure uses discovery for %s", async (_label, value, inUrl) => {
+ const m = await loadProviders({
+ ENTERPRISE_LICENSE_KEY: "lic",
+ AZURE_OAUTH_ENABLED: true,
+ AZUREAD_TENANT_ID: value,
+ });
+ const azure = m.ssoGenericOAuthConfig.find((c) => c.providerId === "azuread");
+
+ expect(azure?.discoveryUrl).toBe(
+ `https://login.microsoftonline.com/${inUrl}/v2.0/.well-known/openid-configuration`
+ );
+ expect(azure?.authorizationUrl).toBeUndefined();
+ expect(azure?.tokenUrl).toBeUndefined();
+ });
+
+ test.each([
+ ["unset", undefined],
+ ["whitespace only", " "],
+ ["a concrete tenant", "00000000-1111-2222-3333-444444444444"],
+ ["the personal-accounts authority", "consumers"],
+ ])("Azure does not warn when the tenant is %s", async (_label, value) => {
+ await loadProviders({
+ ENTERPRISE_LICENSE_KEY: "lic",
+ AZURE_OAUTH_ENABLED: true,
+ AZUREAD_TENANT_ID: value,
+ });
+
+ expect(loggerWarn).not.toHaveBeenCalled();
+ });
+
+ /**
+ * The warning describes how Azure sign-in will behave, so it is pointless — and misleading — on an
+ * instance that registers no Azure provider. Both cases below reach that state, and registration
+ * needs BOTH gates, so the warning has to check both too: an unlicensed instance with Azure
+ * credentials configured is just as provider-less as a licensed one with none.
+ */
+ test.each([
+ ["Azure SSO is disabled", { ENTERPRISE_LICENSE_KEY: "lic", AZURE_OAUTH_ENABLED: false }],
+ ["the instance is unlicensed", { ENTERPRISE_LICENSE_KEY: undefined, AZURE_OAUTH_ENABLED: true }],
+ ])("Azure does not warn about a template-issuer authority when %s", async (_label, overrides) => {
+ const m = await loadProviders({ ...overrides, AZUREAD_TENANT_ID: "common" });
+
+ expect(m.ssoGenericOAuthConfig.find((c) => c.providerId === "azuread")).toBeUndefined();
+ expect(loggerWarn).not.toHaveBeenCalled();
+ });
+
test("Azure mapProfileToUser resolves the display name through its fallback chain", async () => {
const m = await loadProviders({ ENTERPRISE_LICENSE_KEY: "lic", AZURE_OAUTH_ENABLED: true });
const azure = m.ssoGenericOAuthConfig.find((c) => c.providerId === "azuread");
@@ -424,3 +546,117 @@ describe("better-auth SSO providers", () => {
});
});
});
+
+/**
+ * Raised in review on #9017: setting `userInfoUrl` does not guarantee Better Auth calls it.
+ *
+ * `fetchUserInfo` opens with `decodeJwt(tokens.idToken)` — decode, not verify — and returns those
+ * claims whenever the token carries `sub` and `email`, never touching the userinfo endpoint. The
+ * explicit-endpoint branch deliberately has no `idToken` config, so nothing validates that token's
+ * signature, issuer or nonce, and we request the `email` scope, so a real Microsoft token takes the
+ * shortcut every time.
+ *
+ * These tests drive the provider Better Auth actually initialises rather than the config object,
+ * because the config object cannot show which of the two paths runs — asserting `userInfoUrl` is
+ * exactly the check that passed while the shortcut was live.
+ */
+describe("Azure identity comes from Graph, not an unverified id_token (#9017 review)", () => {
+ // An UNSIGNED token carrying the claims the shortcut looks for. If it is ever accepted, an
+ // attacker-supplied token would be too.
+ const forgedIdToken = `${Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url")}.${Buffer.from(
+ JSON.stringify({ sub: "forged-subject", email: "attacker@evil.test", name: "Forged" })
+ ).toString("base64url")}.`;
+
+ const initializedAzureProvider = async (tenant?: string) => {
+ const m = await loadProviders({
+ ENTERPRISE_LICENSE_KEY: "lic",
+ AZURE_OAUTH_ENABLED: true,
+ AZUREAD_CLIENT_ID: "az-id",
+ AZUREAD_CLIENT_SECRET: "az-secret",
+ AZUREAD_TENANT_ID: tenant,
+ });
+ const { betterAuth } = await import("better-auth");
+ const { memoryAdapter } = await import("better-auth/adapters/memory");
+ const { genericOAuth } = await import("better-auth/plugins");
+ const auth = betterAuth({
+ baseURL: "https://app.formbricks.test",
+ secret: "sso-provider-contract-secret-0123456789",
+ database: memoryAdapter({ user: [], session: [], account: [], verification: [] }),
+ plugins: [genericOAuth({ config: m.ssoGenericOAuthConfig })],
+ });
+ const ctx = await auth.$context;
+ return ctx.socialProviders.find((p) => p.id === "azuread");
+ };
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ test.each([["common"], ["organizations"], [undefined]])(
+ "tenant %s: a forged id_token is never accepted as the identity",
+ async (tenant) => {
+ // Graph is unreachable, so the ONLY way to produce a profile is the unverified shortcut.
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => {
+ throw new Error("graph unreachable");
+ })
+ );
+
+ const provider = await initializedAzureProvider(tenant);
+ const result = await provider?.getUserInfo?.({
+ accessToken: "access-token",
+ idToken: forgedIdToken,
+ } as never);
+
+ expect(result).toBeNull();
+ }
+ );
+
+ test("the identity is the Graph response, and Graph is actually called", async () => {
+ const graph = vi.fn(async () => ({
+ ok: true,
+ json: async () => ({ sub: "graph-subject", email: "real@corp.test", name: "Real User" }),
+ }));
+ vi.stubGlobal("fetch", graph);
+
+ const provider = await initializedAzureProvider("common");
+ const result = await provider?.getUserInfo?.({
+ accessToken: "access-token",
+ idToken: forgedIdToken,
+ } as never);
+
+ expect(graph).toHaveBeenCalledWith(
+ "https://graph.microsoft.com/oidc/userinfo",
+ expect.objectContaining({ headers: { Authorization: "Bearer access-token" } })
+ );
+ // The forged subject must not appear anywhere in the resolved identity.
+ expect(result?.user).toMatchObject({ email: "real@corp.test" });
+ expect(JSON.stringify(result)).not.toContain("forged-subject");
+ expect(JSON.stringify(result)).not.toContain("attacker@evil.test");
+ });
+
+ test("a Graph error fails the sign-in closed rather than falling back to the token", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => ({ ok: false, status: 401, json: async () => ({}) }))
+ );
+
+ const provider = await initializedAzureProvider("common");
+ expect(await provider?.getUserInfo?.({ accessToken: "t", idToken: forgedIdToken } as never)).toBeNull();
+ });
+
+ // The concrete-tenant branch keeps discovery, where Better Auth verifies the id_token before any
+ // profile is resolved — so it is intentionally left on the default path.
+ test("a concrete tenant still uses discovery, not the Graph override", async () => {
+ const m = await loadProviders({
+ ENTERPRISE_LICENSE_KEY: "lic",
+ AZURE_OAUTH_ENABLED: true,
+ AZUREAD_TENANT_ID: "00000000-1111-2222-3333-444444444444",
+ });
+ const azure = m.ssoGenericOAuthConfig.find((c) => c.providerId === "azuread");
+
+ expect(azure?.getUserInfo).toBeUndefined();
+ expect(azure?.discoveryUrl).toBeDefined();
+ });
+});
diff --git a/apps/web/modules/ee/sso/lib/better-auth-providers.ts b/apps/web/modules/ee/sso/lib/better-auth-providers.ts
index b66467df4477..18ad8baf89dc 100644
--- a/apps/web/modules/ee/sso/lib/better-auth-providers.ts
+++ b/apps/web/modules/ee/sso/lib/better-auth-providers.ts
@@ -1,6 +1,7 @@
import "server-only";
import type { BetterAuthOptions } from "better-auth";
import type { GenericOAuthConfig, GenericOAuthUserInfo } from "better-auth/plugins";
+import { logger } from "@formbricks/logger";
import {
AZUREAD_CLIENT_ID,
AZUREAD_CLIENT_SECRET,
@@ -174,27 +175,132 @@ const ssoAccountSubject =
* has not set it, and drop support for genuinely multi-tenant app registrations, which have no single
* issuer by construction — the tenant decides the mechanism:
*
- * - **Concrete tenant**: keep `discoveryUrl`. The discovered issuer is a real value, so the id_token is
- * fully verified. Strictly stronger than 1.6.
- * - **`common`**: configure the endpoints explicitly and skip discovery, so no `idTokenConfig` is built
+ * - **A tenant whose discovery document carries a real issuer** — a directory GUID, a verified domain
+ * like `contoso.onmicrosoft.com`, **or `consumers`** (see the table below): keep `discoveryUrl`. The
+ * discovered issuer is a real value, so the id_token is fully verified. Strictly stronger than 1.6.
+ * - **Unset, or a template-issuer authority (`common` / `organizations`, ENG-2750)**: configure the
+ * endpoints explicitly and skip discovery, so no `idTokenConfig` is built
* (`generic-oauth/index.mjs` only constructs it inside the discovery branch) and identity comes from
* UserInfo — the 1.6 behaviour, over a client-authenticated back-channel call to Microsoft. Note this
* is not where the code flow's security lives: that is `state` + PKCE and the authenticated code
* exchange, and RFC 9207 mix-up defence still applies via `iss` on the authorization response when a
* provider sends one.
*
- * Deliberately NOT setting `requireIdTokenVerification`: on the `common` path it would throw at init and
- * take Azure sign-in down, which is the outcome this split exists to avoid.
+ * Which of Microsoft's three multi-tenant authorities can be verified is NOT uniform, and guessing it
+ * wrong costs either an outage or a silently weakened check. Read live from each
+ * `/{authority}/v2.0/.well-known/openid-configuration`:
+ *
+ * | authority | advertised `issuer` | verifiable? |
+ * | --- | --- | --- |
+ * | `common` | `https://login.microsoftonline.com/{tenantid}/v2.0` | no — placeholder |
+ * | `organizations` | `https://login.microsoftonline.com/{tenantid}/v2.0` | no — placeholder |
+ * | `consumers` | `https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0` | **yes** |
+ *
+ * `consumers` is the odd one out: personal Microsoft accounts all live in that one well-known MSA tenant,
+ * so its discovery document names a real issuer and every id_token it mints matches. It therefore stays
+ * on the discovery branch and keeps full verification — moving it here would drop a check that works.
+ *
+ * `common` and `organizations` must not take the discovery branch: Microsoft's guidance is to substitute
+ * the token's `tid` into that placeholder, which a literal `iss` comparison can never satisfy, so every
+ * sign-in fails verification and lands on `?error=unable_to_get_user_info`. That is exactly how ENG-2750
+ * took Microsoft SSO down on Cloud — prod had `AZUREAD_TENANT_ID=common`, harmless on 1.6, a full outage
+ * on 1.7. The authority is still kept in the endpoint URLs, because `organizations` meaningfully
+ * restricts which account types Microsoft accepts at the authorize endpoint.
+ *
+ * Deliberately NOT setting `requireIdTokenVerification`: on the multi-tenant path it would throw at init
+ * and take Azure sign-in down, which is the outcome this split exists to avoid.
*/
-const azureTenant = AZUREAD_TENANT_ID || "common";
-const azureEndpoints = AZUREAD_TENANT_ID
+const AZURE_TEMPLATE_ISSUER_TENANTS = new Set(["common", "organizations"]);
+
+const MICROSOFT_GRAPH_USERINFO_URL = "https://graph.microsoft.com/oidc/userinfo";
+
+/**
+ * Resolve the signed-in identity from Microsoft Graph, always (raised in review on #9017).
+ *
+ * Setting `userInfoUrl` is NOT enough to guarantee Graph is called. Better Auth's default
+ * `fetchUserInfo` opens with an unverified shortcut
+ * (`better-auth/dist/plugins/generic-oauth/index.mjs`):
+ *
+ * ```js
+ * if (tokens.idToken) try {
+ * const decoded = decodeJwt(tokens.idToken); // decode, NOT verify
+ * if (decoded?.sub && decoded?.email) return { id: decoded.sub, ...decoded };
+ * } catch {}
+ * if (!userInfoUrl) return null; // only reached when the shortcut misses
+ * ```
+ *
+ * On the explicit-endpoint branch no `idToken` config exists — that is the whole point of skipping
+ * discovery — so the verification step in `getUserInfo` is a no-op and nothing checks that token's
+ * signature, issuer or nonce. We request the `email` scope, so a real Microsoft id_token carries both
+ * `sub` and `email` and takes the shortcut every time. Identity would come from an unverified JWT
+ * while the comments, the startup warning and the docs all promised it came from Graph.
+ *
+ * `c.getUserInfo` takes precedence over `fetchUserInfo` in that same file, so supplying it is how the
+ * promise is kept. The access token authenticates the call, and it reached us over the
+ * client-authenticated token exchange.
+ *
+ * Fails CLOSED: a non-OK response, an unparseable body, or a missing `sub` returns null, which Better
+ * Auth turns into a failed sign-in rather than a partially-trusted identity. `sub` specifically,
+ * because `accountSubject` pins the account key to it — inventing a fallback is how the wrong account
+ * gets linked.
+ */
+const microsoftGraphUserInfo = async (tokens: {
+ accessToken?: string;
+}): Promise => {
+ if (!tokens.accessToken) return null;
+ try {
+ const response = await fetch(MICROSOFT_GRAPH_USERINFO_URL, {
+ method: "GET",
+ headers: { Authorization: `Bearer ${tokens.accessToken}` },
+ });
+ if (!response.ok) return null;
+ const profile = (await response.json()) as GenericOAuthUserInfo;
+ if (!profile?.sub) return null;
+ return {
+ ...profile,
+ // Strictly `=== true`, not Better Auth's `?? false`: this flag is a claim from the provider that
+ // feeds provisioning, so anything that is not an explicit boolean true is treated as unverified.
+ emailVerified: profile.email_verified === true,
+ image: typeof profile.picture === "string" ? profile.picture : undefined,
+ };
+ } catch {
+ return null;
+ }
+};
+// Unset behaves exactly like `common`: Microsoft's multi-tenant authority, and the documented default.
+const azureTenant = AZUREAD_TENANT_ID?.trim() || "common";
+const isAzureTemplateIssuerTenant = AZURE_TEMPLATE_ISSUER_TENANTS.has(azureTenant.toLowerCase());
+// A template-issuer authority is one of two known literals, so emit its canonical lower-case form; a
+// concrete tenant is passed through exactly as the operator configured it.
+const azureAuthority = isAzureTemplateIssuerTenant ? azureTenant.toLowerCase() : azureTenant;
+// Only worth saying when Azure SSO is actually registered — which needs BOTH gates below, mirroring
+// `ssoGenericOAuthConfig`'s own conditions — and only when the operator set the value themselves, since
+// an unset var takes this same path by design and needs no warning.
+//
+// The wording deliberately avoids "treating it like unset": an operator who set `organizations` would
+// read that as having lost their work/school-only restriction, which still applies at the authorize
+// endpoint. Only id_token verification is given up.
+if (
+ ENTERPRISE_LICENSE_KEY &&
+ AZURE_OAUTH_ENABLED &&
+ AZUREAD_TENANT_ID?.trim() &&
+ isAzureTemplateIssuerTenant
+) {
+ logger.warn(
+ `AZUREAD_TENANT_ID="${azureTenant}" names a Microsoft multi-tenant authority whose discovery document advertises a placeholder issuer, so id_tokens cannot be verified against it. Skipping discovery for this provider and taking identity from the userinfo endpoint; the authority you configured still applies at sign-in. Set a Directory (tenant) ID for full id_token verification.`
+ );
+}
+const azureEndpoints = isAzureTemplateIssuerTenant
? {
- discoveryUrl: `https://login.microsoftonline.com/${azureTenant}/v2.0/.well-known/openid-configuration`,
+ authorizationUrl: `https://login.microsoftonline.com/${azureAuthority}/oauth2/v2.0/authorize`,
+ tokenUrl: `https://login.microsoftonline.com/${azureAuthority}/oauth2/v2.0/token`,
+ userInfoUrl: MICROSOFT_GRAPH_USERINFO_URL,
+ // Not redundant with `userInfoUrl` — see microsoftGraphUserInfo. The URL alone leaves Better
+ // Auth's unverified-id_token shortcut in play; this is what actually forces the Graph call.
+ getUserInfo: microsoftGraphUserInfo,
}
: {
- authorizationUrl: `https://login.microsoftonline.com/${azureTenant}/oauth2/v2.0/authorize`,
- tokenUrl: `https://login.microsoftonline.com/${azureTenant}/oauth2/v2.0/token`,
- userInfoUrl: "https://graph.microsoft.com/oidc/userinfo",
+ discoveryUrl: `https://login.microsoftonline.com/${azureAuthority}/v2.0/.well-known/openid-configuration`,
};
export const ssoGenericOAuthConfig: GenericOAuthConfig[] = ENTERPRISE_LICENSE_KEY
diff --git a/apps/web/modules/entitlements/lib/self-hosted-provider.test.ts b/apps/web/modules/entitlements/lib/self-hosted-provider.test.ts
index be2108906641..97119a23373a 100644
--- a/apps/web/modules/entitlements/lib/self-hosted-provider.test.ts
+++ b/apps/web/modules/entitlements/lib/self-hosted-provider.test.ts
@@ -114,7 +114,7 @@ describe("getSelfHostedOrganizationEntitlementsContext", () => {
organizationId: "org1",
source: "self_hosted_license",
features: [],
- limits: { workspaces: 3, monthlyResponses: null, monthlyWorkflowRuns: null },
+ limits: { workspaces: 1, monthlyResponses: null, monthlyWorkflowRuns: null },
licenseStatus: "no-license",
licenseFeatures: null,
stripeCustomerId: null,
@@ -155,7 +155,7 @@ describe("getSelfHostedOrganizationEntitlementsContext", () => {
expect(result.limits.workspaces).toBeNull();
});
- test("defaults workspaces to 3 when license is inactive", async () => {
+ test("defaults workspaces to the community limit when license is inactive", async () => {
mockGetOrg.mockResolvedValue(organization);
mockGetLicense.mockResolvedValue(
expiredLicense({ workspaces: 10, contacts: true, spamProtection: true })
@@ -164,7 +164,7 @@ describe("getSelfHostedOrganizationEntitlementsContext", () => {
const result = await getSelfHostedOrganizationEntitlementsContext("org1");
expect(result.features).toEqual([]);
- expect(result.limits.workspaces).toBe(3);
+ expect(result.limits.workspaces).toBe(1);
});
test("maps whitelabel feature to hide-branding", async () => {
diff --git a/apps/web/modules/entitlements/lib/self-hosted-provider.ts b/apps/web/modules/entitlements/lib/self-hosted-provider.ts
index adaed940ff92..9c04b22ab14f 100644
--- a/apps/web/modules/entitlements/lib/self-hosted-provider.ts
+++ b/apps/web/modules/entitlements/lib/self-hosted-provider.ts
@@ -1,5 +1,6 @@
import "server-only";
import { ResourceNotFoundError } from "@formbricks/types/errors";
+import { COMMUNITY_WORKSPACE_LIMIT } from "@/lib/constants";
import { getOrganization } from "@/lib/organization/service";
import { CLOUD_STRIPE_FEATURE_LOOKUP_KEYS } from "@/modules/billing/lib/stripe-catalog";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
@@ -61,7 +62,8 @@ export const getSelfHostedOrganizationEntitlementsContext = async (
features: license.active ? mapLicenseFeaturesToEntitlements(license.features) : [],
limits: {
// null = unlimited; only an inactive or feature-less license falls back to the community default.
- workspaces: license.active && license.features ? license.features.workspaces : 3,
+ workspaces:
+ license.active && license.features ? license.features.workspaces : COMMUNITY_WORKSPACE_LIMIT,
// Self-hosted response limits are not license-server-managed today.
monthlyResponses: null,
// Self-hosted workflows are gated by the boolean license feature, not metered (ENG-1936).
diff --git a/docker/__tests__/release-workflows.test.ts b/docker/__tests__/release-workflows.test.ts
new file mode 100644
index 000000000000..3de56708b54f
--- /dev/null
+++ b/docker/__tests__/release-workflows.test.ts
@@ -0,0 +1,152 @@
+import { load } from "js-yaml";
+import { readFileSync, readdirSync } from "node:fs";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, test } from "vitest";
+
+const repositoryRoot = fileURLToPath(new URL("../../", import.meta.url));
+const workflowsDirectory = ".github/workflows";
+const linearSyncWorkflow = `${workflowsDirectory}/linear-release.yml`;
+const formbricksReleaseWorkflow = `${workflowsDirectory}/formbricks-release.yml`;
+const linearSmokeWorkflow = `${workflowsDirectory}/linear-release-smoke.yml`;
+const releaseWorkflows = [linearSyncWorkflow, formbricksReleaseWorkflow, linearSmokeWorkflow];
+
+const linearAction = "linear/linear-release-action";
+const linearActionSha = "17b8c24f8ceb2b98cabaf1965ff83c55dd596fac";
+const linearActionVersion = "v0.15.1";
+const releasedVersion = "${{ needs.docker-build-community.outputs.VERSION }}";
+
+type WorkflowStep = {
+ uses?: string;
+ with?: {
+ "fetch-depth"?: number;
+ access_key?: string;
+ command?: string;
+ dry_run?: string;
+ version?: string;
+ };
+};
+
+type WorkflowTriggers = {
+ push?: { branches?: string[] };
+ workflow_dispatch?: unknown;
+ pull_request?: unknown;
+ pull_request_target?: unknown;
+};
+
+type Workflow = {
+ jobs?: Record;
+ on?: WorkflowTriggers;
+ // js-yaml 3 resolved the YAML 1.1 truthy key `on:` to boolean `true`; 4.x keeps it a string.
+ true?: WorkflowTriggers;
+};
+
+const readText = (relativePath: string): string => readFileSync(join(repositoryRoot, relativePath), "utf8");
+
+const readWorkflow = (relativePath: string): Workflow => load(readText(relativePath)) as Workflow;
+
+const linearSteps = (workflow: Workflow, jobId: string): WorkflowStep[] =>
+ (workflow.jobs?.[jobId]?.steps ?? []).filter((step) => step.uses?.startsWith(`${linearAction}@`));
+
+const linearUses = (workflow: Workflow): string[] =>
+ Object.values(workflow.jobs ?? {})
+ .flatMap((job) => job?.steps ?? [])
+ .map((step) => step.uses)
+ .filter((uses): uses is string => uses?.startsWith(`${linearAction}@`) ?? false);
+
+describe("release workflows", () => {
+ test.each(releaseWorkflows)("%s parses as YAML and declares jobs", (path) => {
+ expect(Object.keys(readWorkflow(path).jobs ?? {})).not.toHaveLength(0);
+ });
+
+ // Every use is checked, not just the first: formbricks-release.yml calls the action twice, so a
+ // `toContain` on the file text would let one correct use mask a second that had drifted.
+ test.each(releaseWorkflows)("pins every Linear release action use by commit SHA in %s", (path) => {
+ const uses = linearUses(readWorkflow(path));
+
+ expect(uses).not.toHaveLength(0);
+ expect(uses).toEqual(uses.map(() => `${linearAction}@${linearActionSha}`));
+ });
+
+ // Separate from the pin above so a drifted annotation and a drifted pin fail distinguishably, and
+ // counted so one annotated line cannot vouch for an unannotated sibling. The annotation is worth
+ // asserting at all because this repo ran a v0.7.0 pin under a comment describing v0.15.1
+ // behaviour for months, which is the drift that hid the bug these tests guard.
+ test.each(releaseWorkflows)("annotates every pin with its release tag in %s", (path) => {
+ const annotated = readText(path).split(`${linearAction}@${linearActionSha} # ${linearActionVersion}`);
+
+ expect(annotated).toHaveLength(linearUses(readWorkflow(path)).length + 1);
+ });
+
+ test("uses no other ref of the Linear release action across the workflows", () => {
+ const directory = join(repositoryRoot, workflowsDirectory);
+ const pattern = new RegExp(`${linearAction}@(\\S+)`, "g");
+ const refs = readdirSync(directory)
+ .filter((entry) => entry.endsWith(".yml") || entry.endsWith(".yaml"))
+ .flatMap((entry) => [...readFileSync(join(directory, entry), "utf8").matchAll(pattern)])
+ .map((match) => match[1]);
+
+ expect([...new Set(refs)]).toEqual([linearActionSha]);
+ });
+
+ test("completes the Linear release once the published artifacts are out", () => {
+ const needs = readWorkflow(formbricksReleaseWorkflow).jobs?.["linear-release-complete"]?.needs;
+
+ expect(needs).toEqual(
+ expect.arrayContaining(["docker-build-community", "docker-build-cloud", "helm-chart-release"])
+ );
+ // Exactly three, so a future non-publishing dependency cannot slip in and reintroduce the
+ // bug from a direction the named exclusions below do not anticipate.
+ expect(needs).toHaveLength(3);
+ // Neither of these publishes anything for the released tag, and a skipped or failed
+ // dependency skips this job, so either one gates Linear completion on unrelated work:
+ // update-helm-app-version opens a follow-up PR against main and fails without its
+ // credentials, and move-stable-tag is skipped by design for any stable release that is
+ // not the latest - i.e. every patch on an older line.
+ expect(needs).not.toContain("update-helm-app-version");
+ expect(needs).not.toContain("move-stable-tag");
+ });
+
+ // The smoke job holds a pipeline-mutating Linear key, so it must only ever run main's copy of
+ // itself. Any path that executes a branch's copy - a pull request, or a dispatch aimed at that
+ // ref - would let whoever pushed it drop dry_run or add an exfiltration step.
+ test("only ever runs the credentialed smoke dry-run from main", () => {
+ const workflow = readWorkflow(linearSmokeWorkflow);
+ const triggers = workflow.on ?? workflow.true;
+
+ expect(triggers).not.toHaveProperty("pull_request");
+ expect(triggers).not.toHaveProperty("pull_request_target");
+ expect(triggers?.push?.branches).toEqual(["main"]);
+ // Required, not incidental: dispatch is the only way to check the pipeline between release
+ // flow changes, and dropping it would quietly remove that without failing anything else.
+ expect(triggers).toHaveProperty("workflow_dispatch");
+ // workflow_dispatch runs the file as it exists on the caller's chosen ref, so the trigger
+ // list alone is not enough - the job itself has to refuse any ref but main.
+ expect(workflow.jobs?.["linear-release-smoke"]?.if).toBe("github.ref == 'refs/heads/main'");
+ });
+
+ test("stamps the released version on Linear before completing the release", () => {
+ const steps = linearSteps(readWorkflow(formbricksReleaseWorkflow), "linear-release-complete");
+
+ expect(steps.map((step) => step.with?.version)).toEqual([releasedVersion, releasedVersion]);
+ expect(steps.map((step) => step.with?.command)).toEqual([undefined, "complete"]);
+ });
+
+ test("skips the Linear completion for prereleases", () => {
+ expect(readWorkflow(formbricksReleaseWorkflow).jobs?.["linear-release-complete"]?.if).toBe(
+ "${{ !github.event.release.prerelease }}"
+ );
+ });
+
+ test("keeps the unversioned Linear sync on pushes to main", () => {
+ const workflow = readWorkflow(linearSyncWorkflow);
+ const checkout = workflow.jobs?.["linear-release"]?.steps?.find((step) =>
+ step.uses?.startsWith("actions/checkout@")
+ );
+
+ expect((workflow.on ?? workflow.true)?.push?.branches).toContain("main");
+ expect(checkout?.with?.["fetch-depth"]).toBe(0);
+ // No version input: this train is the started release that `command: complete` later looks up.
+ expect(linearSteps(workflow, "linear-release").map((step) => step.with?.version)).toEqual([undefined]);
+ });
+});
diff --git a/docs/images/surveys/question-type/address/editor.webp b/docs/images/surveys/question-type/address/editor.webp
new file mode 100644
index 000000000000..46fc7729f76e
Binary files /dev/null and b/docs/images/surveys/question-type/address/editor.webp differ
diff --git a/docs/images/surveys/question-type/consent/editor.webp b/docs/images/surveys/question-type/consent/editor.webp
new file mode 100644
index 000000000000..8f42edb547e6
Binary files /dev/null and b/docs/images/surveys/question-type/consent/editor.webp differ
diff --git a/docs/images/surveys/question-type/contact-info/editor.webp b/docs/images/surveys/question-type/contact-info/editor.webp
new file mode 100644
index 000000000000..83c224fd7dc2
Binary files /dev/null and b/docs/images/surveys/question-type/contact-info/editor.webp differ
diff --git a/docs/images/surveys/question-type/date/editor.webp b/docs/images/surveys/question-type/date/editor.webp
new file mode 100644
index 000000000000..c32d76ce9622
Binary files /dev/null and b/docs/images/surveys/question-type/date/editor.webp differ
diff --git a/docs/images/surveys/question-type/file-upload/editor.webp b/docs/images/surveys/question-type/file-upload/editor.webp
new file mode 100644
index 000000000000..1880a59d7dfe
Binary files /dev/null and b/docs/images/surveys/question-type/file-upload/editor.webp differ
diff --git a/docs/images/surveys/question-type/free-text/editor.webp b/docs/images/surveys/question-type/free-text/editor.webp
new file mode 100644
index 000000000000..2f01eccecb32
Binary files /dev/null and b/docs/images/surveys/question-type/free-text/editor.webp differ
diff --git a/docs/images/surveys/question-type/matrix/editor.webp b/docs/images/surveys/question-type/matrix/editor.webp
new file mode 100644
index 000000000000..bd09919e2369
Binary files /dev/null and b/docs/images/surveys/question-type/matrix/editor.webp differ
diff --git a/docs/images/surveys/question-type/net-promoter-score/editor.webp b/docs/images/surveys/question-type/net-promoter-score/editor.webp
new file mode 100644
index 000000000000..c0c3927879ef
Binary files /dev/null and b/docs/images/surveys/question-type/net-promoter-score/editor.webp differ
diff --git a/docs/images/surveys/question-type/ranking/editor.webp b/docs/images/surveys/question-type/ranking/editor.webp
new file mode 100644
index 000000000000..927ca43780fc
Binary files /dev/null and b/docs/images/surveys/question-type/ranking/editor.webp differ
diff --git a/docs/images/surveys/question-type/rating/editor.webp b/docs/images/surveys/question-type/rating/editor.webp
new file mode 100644
index 000000000000..37e52283238d
Binary files /dev/null and b/docs/images/surveys/question-type/rating/editor.webp differ
diff --git a/docs/images/surveys/question-type/schedule-a-meeting/editor.webp b/docs/images/surveys/question-type/schedule-a-meeting/editor.webp
new file mode 100644
index 000000000000..a96bb4d32853
Binary files /dev/null and b/docs/images/surveys/question-type/schedule-a-meeting/editor.webp differ
diff --git a/docs/images/surveys/question-type/select-multiple/editor.webp b/docs/images/surveys/question-type/select-multiple/editor.webp
new file mode 100644
index 000000000000..7ec2c63b6c8a
Binary files /dev/null and b/docs/images/surveys/question-type/select-multiple/editor.webp differ
diff --git a/docs/images/surveys/question-type/select-single/editor.webp b/docs/images/surveys/question-type/select-single/editor.webp
new file mode 100644
index 000000000000..84a0d590ad94
Binary files /dev/null and b/docs/images/surveys/question-type/select-single/editor.webp differ
diff --git a/docs/images/surveys/question-type/statement-cta/editor.webp b/docs/images/surveys/question-type/statement-cta/editor.webp
new file mode 100644
index 000000000000..455e36ef0ec4
Binary files /dev/null and b/docs/images/surveys/question-type/statement-cta/editor.webp differ
diff --git a/docs/self-hosting/advanced/migration.mdx b/docs/self-hosting/advanced/migration.mdx
index 79df0b38c167..ad5c44255992 100644
--- a/docs/self-hosting/advanced/migration.mdx
+++ b/docs/self-hosting/advanced/migration.mdx
@@ -236,6 +236,25 @@ instance's JWT signing keys. Keys created before the upgrade keep working untouc
null `alg` as the default algorithm — so there is nothing to backfill, but the columns must exist before
the new version mints its next key.
+#### AZUREAD_TENANT_ID: set a concrete tenant for full id_token verification
+
+Better Auth 1.7 verifies Microsoft id_tokens against the issuer advertised by the tenant's OpenID
+discovery document whenever `AZUREAD_TENANT_ID` names a concrete tenant — a Directory (tenant) ID GUID or
+a verified domain. That is strictly stronger than v5.3 and needs no action.
+
+Two of Microsoft's multi-tenant authorities are different. `common` and `organizations` advertise the
+literal placeholder `https://login.microsoftonline.com/{tenantid}/v2.0` as their issuer, so verifying
+real tokens against it fails every Microsoft sign-in with `?error=unable_to_get_user_info`. Formbricks
+therefore handles those two the way it already handles an unset `AZUREAD_TENANT_ID`: sign-in identity
+comes from Microsoft's userinfo endpoint over a client-authenticated call (the v5.3 behavior) and
+id_tokens are not verified. The authority you configure still applies at sign-in, so `organizations`
+keeps restricting sign-in to work/school accounts, and an unset value behaves as `common`. Formbricks
+logs a warning at startup when either is set and Microsoft SSO is enabled. To get full id_token
+verification, set your Directory (tenant) ID instead.
+
+`consumers` needs no action and is not affected: every personal Microsoft account lives in one
+well-known tenant, so that authority advertises a real issuer and keeps full id_token verification.
+
#### SSO callback URLs are unchanged
Your SSO callback URLs stay exactly as they are:
diff --git a/docs/self-hosting/configuration/auth-sso/azure-ad-oauth.mdx b/docs/self-hosting/configuration/auth-sso/azure-ad-oauth.mdx
index acad7767429e..47528c6360ce 100644
--- a/docs/self-hosting/configuration/auth-sso/azure-ad-oauth.mdx
+++ b/docs/self-hosting/configuration/auth-sso/azure-ad-oauth.mdx
@@ -68,6 +68,20 @@ Do you have a Microsoft Entra ID Tenant? Integrate it with your Formbricks insta
- Copy the entry for **Application (client) ID** to populate the `AZUREAD_CLIENT_ID` variable.
- Copy the entry for **Directory (tenant) ID** to populate the `AZUREAD_TENANT_ID` variable.
+
+ Use the **Directory (tenant) ID** GUID (or a verified domain) — with it set, Formbricks fully
+ verifies Microsoft id_tokens against your tenant's issuer.
+
+ For a multi-tenant app registration, either leave `AZUREAD_TENANT_ID` unset, which is equivalent
+ to `common` and accepts both work/school and personal accounts, or set it to `organizations` to
+ accept work/school accounts only. Those two authorities advertise a placeholder issuer rather
+ than a real one, so Formbricks skips id_token verification for them and takes the signed-in
+ identity from Microsoft's userinfo endpoint — the authority you configure still applies at
+ sign-in, so `organizations` keeps restricting which accounts Microsoft will accept. `consumers`
+ is unaffected: personal Microsoft accounts share one well-known tenant, so its id_tokens are
+ verified normally.
+
+

diff --git a/docs/surveys/best-practices/research-panel.mdx b/docs/surveys/best-practices/research-panel.mdx
index c8dc6ebe4c1b..997a3f034d42 100644
--- a/docs/surveys/best-practices/research-panel.mdx
+++ b/docs/surveys/best-practices/research-panel.mdx
@@ -49,7 +49,7 @@ Building a research panel with Formbricks involves these key steps:
3. Add questions to collect the data you need for segmentation
4. Include a **Contact Info** question type to capture email addresses
- 
+ 
diff --git a/docs/surveys/question-type/address.mdx b/docs/surveys/question-type/address.mdx
index 4e9b45ea5ef7..e0131f704ae9 100644
--- a/docs/surveys/question-type/address.mdx
+++ b/docs/surveys/question-type/address.mdx
@@ -20,7 +20,7 @@ icon: "map-pin"
## Elements
-
+
### Question
diff --git a/docs/surveys/question-type/consent.mdx b/docs/surveys/question-type/consent.mdx
index 8da121a3d3f0..34329f327912 100644
--- a/docs/surveys/question-type/consent.mdx
+++ b/docs/surveys/question-type/consent.mdx
@@ -20,7 +20,7 @@ icon: "check"
## Elements
-
+
### Title
diff --git a/docs/surveys/question-type/contact-info.mdx b/docs/surveys/question-type/contact-info.mdx
index 70d2d5280e8f..96c61897fde3 100644
--- a/docs/surveys/question-type/contact-info.mdx
+++ b/docs/surveys/question-type/contact-info.mdx
@@ -20,7 +20,7 @@ icon: "address-book"
## Elements
-
+
### Title
diff --git a/docs/surveys/question-type/date.mdx b/docs/surveys/question-type/date.mdx
index bb8a0767b661..1834e951e211 100644
--- a/docs/surveys/question-type/date.mdx
+++ b/docs/surveys/question-type/date.mdx
@@ -20,7 +20,7 @@ icon: "calendar"
## Elements
-
+
### Title
diff --git a/docs/surveys/question-type/file-upload.mdx b/docs/surveys/question-type/file-upload.mdx
index 76b18db8814f..19368818b704 100644
--- a/docs/surveys/question-type/file-upload.mdx
+++ b/docs/surveys/question-type/file-upload.mdx
@@ -26,7 +26,7 @@ icon: "upload"
## Elements
-
+
### Title
diff --git a/docs/surveys/question-type/free-text.mdx b/docs/surveys/question-type/free-text.mdx
index 213507c12641..b8e8ba64816c 100644
--- a/docs/surveys/question-type/free-text.mdx
+++ b/docs/surveys/question-type/free-text.mdx
@@ -23,7 +23,7 @@ Free text questions allow respondents to enter a custom answer. Displays a title
## Elements
-
+
### Title
diff --git a/docs/surveys/question-type/matrix.mdx b/docs/surveys/question-type/matrix.mdx
index defd51634835..4ad49f27d14c 100644
--- a/docs/surveys/question-type/matrix.mdx
+++ b/docs/surveys/question-type/matrix.mdx
@@ -22,7 +22,7 @@ The values range from 0 to a user-defined maximum (e.g., 0 to X). The selection
## Elements
-
+
### Title
diff --git a/docs/surveys/question-type/net-promoter-score.mdx b/docs/surveys/question-type/net-promoter-score.mdx
index f6e997b47775..637388f5d920 100644
--- a/docs/surveys/question-type/net-promoter-score.mdx
+++ b/docs/surveys/question-type/net-promoter-score.mdx
@@ -21,7 +21,7 @@ icon: "presentation-screen"
## Elements
-
+
### Title
diff --git a/docs/surveys/question-type/ranking.mdx b/docs/surveys/question-type/ranking.mdx
index cf5556a26e43..084a007fab32 100644
--- a/docs/surveys/question-type/ranking.mdx
+++ b/docs/surveys/question-type/ranking.mdx
@@ -20,7 +20,7 @@ icon: "ranking-star"
## Elements
-
+
### Title
diff --git a/docs/surveys/question-type/rating.mdx b/docs/surveys/question-type/rating.mdx
index 9538dc53c994..c0ab0b783aef 100644
--- a/docs/surveys/question-type/rating.mdx
+++ b/docs/surveys/question-type/rating.mdx
@@ -22,7 +22,7 @@ Rating questions allow respondents to rate questions on a scale. Displays a titl
## Elements
-
+
### Title
diff --git a/docs/surveys/question-type/schedule-a-meeting.mdx b/docs/surveys/question-type/schedule-a-meeting.mdx
index d68cb43fa65b..d91a2af24065 100644
--- a/docs/surveys/question-type/schedule-a-meeting.mdx
+++ b/docs/surveys/question-type/schedule-a-meeting.mdx
@@ -20,7 +20,7 @@ icon: "calendar-check"
## Elements
-
+
### Title
diff --git a/docs/surveys/question-type/select-multiple.mdx b/docs/surveys/question-type/select-multiple.mdx
index b731cea0dc41..462d8117c186 100644
--- a/docs/surveys/question-type/select-multiple.mdx
+++ b/docs/surveys/question-type/select-multiple.mdx
@@ -23,7 +23,7 @@ Multi select questions allow respondents to select several answers from a list.
## Elements
-
+
### Title
diff --git a/docs/surveys/question-type/select-single.mdx b/docs/surveys/question-type/select-single.mdx
index f30dd39af90f..1ddb76a2abe9 100644
--- a/docs/surveys/question-type/select-single.mdx
+++ b/docs/surveys/question-type/select-single.mdx
@@ -23,7 +23,7 @@ Single select questions allow respondents to select one answer from a list. Disp
## Elements
-
+
### Title
diff --git a/docs/surveys/question-type/statement-cta.mdx b/docs/surveys/question-type/statement-cta.mdx
index c6622a900c57..51cf3c7a4344 100644
--- a/docs/surveys/question-type/statement-cta.mdx
+++ b/docs/surveys/question-type/statement-cta.mdx
@@ -22,7 +22,7 @@ It consists of a title (can be Question or Short Note) and a description, which
## Elements
-
+
### Title
diff --git a/docs/unify-feedback/dashboards-charts.mdx b/docs/unify-feedback/dashboards-charts.mdx
index 8dda3e8c290f..2da39ebc8987 100644
--- a/docs/unify-feedback/dashboards-charts.mdx
+++ b/docs/unify-feedback/dashboards-charts.mdx
@@ -8,7 +8,7 @@ Dashboards & Charts let you turn Feedback Records into visual analytics. A **Cha
## Charts
-Charts live under **Workspace → Charts**. Each chart is a query plus a visualization config.
+Charts live under **Analyze → Analysis**. Each chart is a query plus a visualization config.
### Available chart types
@@ -34,7 +34,7 @@ The AI builder requires **Smart functionality (AI)** to be enabled at the organi
## Dashboards
-Dashboards live under **Workspace → Dashboards**. Each dashboard is a grid you can resize and arrange.
+Dashboards live under **Analyze → Analysis**, alongside charts. Each dashboard is a grid you can resize and arrange.
From a dashboard you can:
diff --git a/docs/unify-feedback/feedback-datasets.mdx b/docs/unify-feedback/feedback-datasets.mdx
index 037119ef8373..516a7f567727 100644
--- a/docs/unify-feedback/feedback-datasets.mdx
+++ b/docs/unify-feedback/feedback-datasets.mdx
@@ -24,7 +24,7 @@ Manage dataset access from **Settings → Organization → Feedback Datasets**:
- Rename or archive datasets
- Add or remove workspace access
-Only **Owners** and **Managers** can manage datasets. Workspace members see the datasets their workspace has access to inside the Unify section.
+Only **Owners** and **Managers** can manage datasets. Workspace members see the datasets their workspace has access to under **Analyze**.
## Archiving
diff --git a/docs/unify-feedback/overview.mdx b/docs/unify-feedback/overview.mdx
index 5a801b64981a..1f429f686957 100644
--- a/docs/unify-feedback/overview.mdx
+++ b/docs/unify-feedback/overview.mdx
@@ -37,6 +37,11 @@ Most companies collect feedback in many places: surveys, support tickets, app st
+
+ Unify Feedback is in **Beta**. It appears under **Analyze** in the app, badged Beta. Expect the
+ feature set to keep moving; we will note breaking changes here.
+
+
Unify Feedback is an enterprise feature. Enable it on Formbricks Cloud with a paid plan, or self-host with a license.
diff --git a/docs/workflows/overview.mdx b/docs/workflows/overview.mdx
index 41bfc042e069..fa6b335681a0 100644
--- a/docs/workflows/overview.mdx
+++ b/docs/workflows/overview.mdx
@@ -8,6 +8,11 @@ icon: "https://d3gk2c5xim1je2.cloudfront.net/lucide/v1.16.0/workflow.svg"
Workflows automate tasks in response to events in Formbricks. You define the event that starts the workflow,
narrow down which events qualify, and choose the action Formbricks should perform.
+
+ Workflows are in **Beta**. They appear under **Act** in the app, badged Beta. Expect the feature set
+ to keep moving; we will note breaking changes here.
+
+
Workflows are part of the Formbricks [Enterprise Edition](/self-hosting/advanced/license).
## How Workflows work