diff --git a/.github/actions/cache-build-web/action.yml b/.github/actions/cache-build-web/action.yml index 162f300d86b6..edec0b94bdac 100644 --- a/.github/actions/cache-build-web/action.yml +++ b/.github/actions/cache-build-web/action.yml @@ -38,10 +38,10 @@ runs: run: echo "cache-hit=${{ steps.cache-build.outputs.cache-hit }}" >> "$GITHUB_OUTPUT" shell: bash - - name: Setup Node.js 22.x + - name: Setup Node.js (version from .nvmrc) uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: 22.x + node-version-file: ".nvmrc" if: steps.cache-build.outputs.cache-hit != 'true' - name: Install pnpm diff --git a/.github/workflows/api-v3-spec.yml b/.github/workflows/api-v3-spec.yml index 10ca53ba8e18..ec59780b2912 100644 --- a/.github/workflows/api-v3-spec.yml +++ b/.github/workflows/api-v3-spec.yml @@ -29,10 +29,10 @@ jobs: with: persist-credentials: false - - name: Setup Node.js 22.x + - name: Setup Node.js (version from .nvmrc) uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: 22.x + node-version-file: ".nvmrc" - name: Install pnpm uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index c4e2db9c51fb..89bbf39fc0bb 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -30,7 +30,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: 22.x + node-version-file: ".nvmrc" - name: Install pnpm uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 diff --git a/.github/workflows/dependabot-to-linear.yml b/.github/workflows/dependabot-to-linear.yml index dd3960af9bfe..27aced112687 100644 --- a/.github/workflows/dependabot-to-linear.yml +++ b/.github/workflows/dependabot-to-linear.yml @@ -36,7 +36,7 @@ jobs: - name: Setup Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: 24 + node-version-file: ".nvmrc" - name: Sync alerts to Linear env: diff --git a/.github/workflows/docker-build-validation.yml b/.github/workflows/docker-build-validation.yml index 987c3d897d19..5717d6394f62 100644 --- a/.github/workflows/docker-build-validation.yml +++ b/.github/workflows/docker-build-validation.yml @@ -78,6 +78,197 @@ jobs: cubejs_api_url=http://localhost:4000 cubejs_api_secret=build-time-placeholder + - name: Reject Invalid Environment Before Database Setup + shell: bash + env: + GITHUB_SHA: ${{ github.sha }} + DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }} + run: | + set -euo pipefail + + IMAGE="formbricks-test:$GITHUB_SHA" + COMMON_ENV=( + -e DATABASE_URL="postgresql://test:test@192.0.2.1:5432/formbricks" + -e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" + -e BETTER_AUTH_SECRET="$DUMMY_ENCRYPTION_KEY" + -e REDIS_URL="redis://192.0.2.1:6379" + -e HUB_API_URL="http://192.0.2.1:4000" + -e HUB_API_KEY="build-time-placeholder" + -e CUBEJS_API_URL="http://192.0.2.1:4000" + -e CUBEJS_API_SECRET="build-time-placeholder" + ) + + run_invalid_case() { + local case_name="$1" + local expected_field="$2" + local secret_value="$3" + shift 3 + + set +e + local output + output="$(docker run --rm "${COMMON_ENV[@]}" "$@" "$IMAGE" 2>&1)" + local status=$? + set -e + + if [ "$status" -eq 0 ]; then + echo "Expected $case_name to fail environment validation" + exit 1 + fi + + if ! grep -Fq "$expected_field" <<<"$output"; then + echo "Expected $case_name output to name $expected_field" + echo "$output" + exit 1 + fi + + if [ -n "$secret_value" ] && grep -Fq "$secret_value" <<<"$output"; then + echo "Environment validation leaked the configured value for $case_name" + exit 1 + fi + + if grep -Eq "Running database migrations|Running SAML database setup|Prisma|Can't reach database" <<<"$output"; then + echo "Database work started before $case_name environment validation failed" + echo "$output" + exit 1 + fi + } + + run_invalid_case \ + "invalid provider" \ + "AI_PROVIDER" \ + "unsupported-provider-sentinel" \ + -e AI_PROVIDER="unsupported-provider-sentinel" + + run_invalid_case \ + "missing model" \ + "AI_MODEL" \ + "" \ + -e AI_PROVIDER="google" \ + -e AI_GOOGLE_CLOUD_PROJECT="test-project" \ + -e AI_GOOGLE_CLOUD_LOCATION="us-central1" + + run_invalid_case \ + "invalid credentials JSON" \ + "AI_GOOGLE_CLOUD_CREDENTIALS_JSON" \ + "credentials-secret-sentinel" \ + -e AI_PROVIDER="google" \ + -e AI_MODEL="gemini-2.5-flash" \ + -e AI_GOOGLE_CLOUD_PROJECT="test-project" \ + -e AI_GOOGLE_CLOUD_LOCATION="us-central1" \ + -e AI_GOOGLE_CLOUD_CREDENTIALS_JSON="credentials-secret-sentinel" + + run_invalid_case \ + "invalid provider base URL" \ + "AI_OPENAI_COMPATIBLE_BASE_URL" \ + "base-url-secret-sentinel" \ + -e AI_PROVIDER="openai-compatible" \ + -e AI_MODEL="test-model" \ + -e AI_OPENAI_COMPATIBLE_BASE_URL="base-url-secret-sentinel" + + run_invalid_case \ + "invalid Azure base URL" \ + "AI_AZURE_BASE_URL" \ + "azure-base-url-secret-sentinel" \ + -e AI_PROVIDER="azure" \ + -e AI_MODEL="test-model" \ + -e AI_AZURE_API_KEY="azure-base-url-secret-sentinel" \ + -e AI_AZURE_BASE_URL="azure-base-url-secret-sentinel" + + - name: Validate Docker Compose Migration Environment + shell: bash + env: + GITHUB_SHA: ${{ github.sha }} + DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }} + run: | + set -euo pipefail + + docker tag "formbricks-test:$GITHUB_SHA" ghcr.io/formbricks/formbricks:latest + trap 'rm -f docker/.env' EXIT + printf '%s\n' \ + 'WEBAPP_URL=http://localhost:3000' \ + 'NEXTAUTH_URL=http://localhost:3000' \ + "ENCRYPTION_KEY=$DUMMY_ENCRYPTION_KEY" \ + "BETTER_AUTH_SECRET=$DUMMY_ENCRYPTION_KEY" \ + 'HUB_API_KEY=build-time-placeholder' \ + 'CUBEJS_API_SECRET=build-time-placeholder' \ + 'AI_PROVIDER=compose-provider-secret-sentinel' \ + > docker/.env + + set +e + output="$(docker compose -f docker/docker-compose.yml run --rm --no-deps formbricks-migrate 2>&1)" + status=$? + set -e + + if [ "$status" -eq 0 ]; then + echo "Expected the Compose migration service to reject its invalid environment" + exit 1 + fi + + if ! grep -Fq "AI_PROVIDER" <<<"$output"; then + echo "Expected the Compose migration output to name AI_PROVIDER" + echo "$output" + exit 1 + fi + + if grep -Fq "compose-provider-secret-sentinel" <<<"$output"; then + echo "Compose migration validation leaked the configured provider value" + exit 1 + fi + + if grep -Eq "prisma migrate|Prisma|Can't reach database" <<<"$output"; then + echo "Compose started migrations before environment validation failed" + echo "$output" + exit 1 + fi + + - name: Preserve Startup Migration Skip Behavior + shell: bash + env: + GITHUB_SHA: ${{ github.sha }} + DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }} + run: | + set -euo pipefail + + docker run --name formbricks-skip-test \ + -e DATABASE_URL="postgresql://test:test@192.0.2.1:5432/formbricks" \ + -e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \ + -e BETTER_AUTH_SECRET="$DUMMY_ENCRYPTION_KEY" \ + -e REDIS_URL="redis://192.0.2.1:6379" \ + -e HUB_API_URL="http://192.0.2.1:4000" \ + -e HUB_API_KEY="build-time-placeholder" \ + -e CUBEJS_API_URL="http://192.0.2.1:4000" \ + -e CUBEJS_API_SECRET="build-time-placeholder" \ + -e SKIP_STARTUP_MIGRATION="true" \ + -d "formbricks-test:$GITHUB_SHA" + + trap 'docker rm -f formbricks-skip-test >/dev/null 2>&1 || true' EXIT + + for _ in $(seq 1 30); do + if docker logs formbricks-skip-test 2>&1 | grep -Fq "Starting Next.js server"; then + break + fi + + if [ "$(docker inspect -f '{{.State.Running}}' formbricks-skip-test 2>/dev/null)" != "true" ]; then + echo "Container exited before exercising the startup migration skip" + docker logs formbricks-skip-test + exit 1 + fi + + sleep 1 + done + + logs="$(docker logs formbricks-skip-test 2>&1)" + grep -Fq "Environment variables validated successfully" <<<"$logs" + grep -Fq "Skipping startup migrations" <<<"$logs" + grep -Fq "Running SAML database setup" <<<"$logs" + grep -Fq "Starting Next.js server" <<<"$logs" + + if grep -Fq "Running database migrations" <<<"$logs"; then + echo "Startup migrations ran despite SKIP_STARTUP_MIGRATION=true" + echo "$logs" + exit 1 + fi + - name: Verify and Initialize PostgreSQL run: | echo "Verifying PostgreSQL connection..." @@ -146,6 +337,7 @@ jobs: -p 3000:3000 \ -e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \ -e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \ + -e BETTER_AUTH_SECRET="$DUMMY_ENCRYPTION_KEY" \ -e REDIS_URL="redis://host.docker.internal:6379" \ -e HUB_API_URL="http://localhost:4000" \ -e HUB_API_KEY="build-time-placeholder" \ @@ -191,9 +383,24 @@ jobs: sleep 5 done - # Show full container logs for debugging + # Show full container logs for debugging and verify startup work ran exactly once echo "๐Ÿ“‹ Full container logs:" - docker logs formbricks-test + CONTAINER_LOGS="$(docker logs formbricks-test 2>&1)" + echo "$CONTAINER_LOGS" + + assert_log_once() { + local message="$1" + local count + count="$(grep -Fc "$message" <<<"$CONTAINER_LOGS" || true)" + if [ "$count" -ne 1 ]; then + echo "Expected '$message' exactly once, found $count occurrences" + exit 1 + fi + } + + assert_log_once "Environment variables validated successfully" + assert_log_once "Running database migrations" + assert_log_once "Running SAML database setup" # Clean up the container echo "๐Ÿงน Cleaning up..." diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index c55811d3062f..332f3837d0ef 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -56,10 +56,10 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: ./.github/actions/dangerous-git-checkout - - name: Setup Node.js 22.x + - name: Setup Node.js (version from .nvmrc) uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: 22.x + node-version-file: ".nvmrc" - name: Install pnpm uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 8cf4671d9995..a7452fe2c7b0 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -88,11 +88,11 @@ jobs: fi shell: bash - - name: Setup Node.js 22.x + - name: Setup Node.js (version from .nvmrc) if: steps.harness.outputs.present == 'true' uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: 22.x + node-version-file: ".nvmrc" - name: Install pnpm if: steps.harness.outputs.present == 'true' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2ece12688df2..d4f1f965690f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -20,10 +20,10 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: ./.github/actions/dangerous-git-checkout - - name: Setup Node.js 22.x + - name: Setup Node.js (version from .nvmrc) uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: 22.x + node-version-file: ".nvmrc" - name: Install pnpm uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index d02eab9e6faa..40999be1b443 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -24,10 +24,10 @@ jobs: with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - - name: Setup Node.js 22.x + - name: Setup Node.js (version from .nvmrc) uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: 22.x + node-version-file: ".nvmrc" - name: Setup Java 21 uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 86b4a656575a..fdebfff01ed9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,10 +21,10 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: ./.github/actions/dangerous-git-checkout - - name: Setup Node.js 22.x + - name: Setup Node.js (version from .nvmrc) uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: 22.x + node-version-file: ".nvmrc" - name: Install pnpm uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 diff --git a/.github/workflows/translation-check.yml b/.github/workflows/translation-check.yml index 6028565c942b..7fa29d71d667 100644 --- a/.github/workflows/translation-check.yml +++ b/.github/workflows/translation-check.yml @@ -40,11 +40,11 @@ jobs: - 'packages/i18n-utils/**' - '**/i18n.lock' - - name: Setup Node.js 22.x + - name: Setup Node.js (version from .nvmrc) if: steps.changes.outputs.translations == 'true' uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: 22.x + node-version-file: ".nvmrc" - name: Install pnpm if: steps.changes.outputs.translations == 'true' diff --git a/.nvmrc b/.nvmrc index 1d9b7831ba9d..d845d9d88db7 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.12.0 +24.14.0 diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 14e256ce46d6..e7467d29cb05 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -1,3 +1,4 @@ +# Node major must stay in sync with .nvmrc (single source of truth for dev + CI + prod). FROM node:24-alpine3.23 AS base # @@ -98,6 +99,9 @@ RUN chmod 644 ./next.config.mjs COPY --from=installer /app/apps/web/package.json . RUN chmod 644 ./package.json +COPY --from=installer /app/apps/web/dist/docker/validate-env.mjs ./validate-env.mjs +RUN chown nextjs:nextjs ./validate-env.mjs && chmod 444 ./validate-env.mjs + COPY --from=installer /app/prisma.config.mjs ./prisma.config.mjs RUN chmod 644 ./prisma.config.mjs diff --git a/apps/web/lib/env.test.ts b/apps/web/lib/env.test.ts index eec5e5ba99e2..f66eda209f0a 100644 --- a/apps/web/lib/env.test.ts +++ b/apps/web/lib/env.test.ts @@ -105,6 +105,25 @@ describe("env", () => { expect(env.AI_GOOGLE_CLOUD_LOCATION).toBe("us-central1"); }); + test("fails to load when the AI provider is invalid", async () => { + setTestEnv({ + AI_PROVIDER: "unsupported-provider", + }); + + await expect(import("./env")).rejects.toThrow("AI_PROVIDER"); + }); + + test("fails to load when an AI provider is set without a model", async () => { + setTestEnv({ + AI_PROVIDER: "google", + AI_MODEL: undefined, + AI_GOOGLE_CLOUD_PROJECT: "test-project", + AI_GOOGLE_CLOUD_LOCATION: "us-central1", + }); + + await expect(import("./env")).rejects.toThrow("AI_MODEL is required when AI_PROVIDER is set"); + }); + test("fails to load when Google Cloud credentials JSON is invalid", async () => { setTestEnv({ AI_PROVIDER: "google", @@ -117,6 +136,17 @@ describe("env", () => { await expect(import("./env")).rejects.toThrow("AI_GOOGLE_CLOUD_CREDENTIALS_JSON"); }); + test("fails to load when the Azure base URL is invalid", async () => { + setTestEnv({ + AI_PROVIDER: "azure", + AI_MODEL: "gpt-4o-mini", + AI_AZURE_API_KEY: "test-api-key", + AI_AZURE_BASE_URL: "not-a-url", + }); + + await expect(import("./env")).rejects.toThrow("AI_AZURE_BASE_URL"); + }); + test("loads OpenAI-compatible AI configuration with the base URL and model", async () => { setTestEnv({ AI_PROVIDER: "openai-compatible", diff --git a/apps/web/modules/auth/lib/after-auth-hooks.ts b/apps/web/modules/auth/lib/after-auth-hooks.ts index 434c815260cd..830543404add 100644 --- a/apps/web/modules/auth/lib/after-auth-hooks.ts +++ b/apps/web/modules/auth/lib/after-auth-hooks.ts @@ -5,6 +5,7 @@ import { ssoRecoveryAfterHandler, } from "@/modules/ee/sso/lib/better-auth-hooks"; import { auditFailedAuthAfter } from "./better-auth-observability"; +import { twoFactorBackfillAfterHandler } from "./better-auth-two-factor-backfill"; /** * Composed Better Auth `hooks.after` chain. Ordering rationale: `auditFailedAuthAfter` runs before @@ -19,5 +20,8 @@ import { auditFailedAuthAfter } from "./better-auth-observability"; export const runAfterAuthHooks = async (ctx: AuthHookContext): Promise => { await ssoRecoveryAfterHandler(ctx); await auditFailedAuthAfter(ctx); + // ENG-1824: heal legacy 2FA enrollments (no `TwoFactor` row) on successful password sign-in, before + // the 2FA challenge. Only touches `/sign-in/email`, so it's unaffected by the SSO-only redirect below. + await twoFactorBackfillAfterHandler(ctx); await blockedSignupDomainRedirectAfterHandler(ctx); }; diff --git a/apps/web/modules/auth/lib/better-auth-two-factor-backfill.test.ts b/apps/web/modules/auth/lib/better-auth-two-factor-backfill.test.ts new file mode 100644 index 000000000000..baa0628ba964 --- /dev/null +++ b/apps/web/modules/auth/lib/better-auth-two-factor-backfill.test.ts @@ -0,0 +1,103 @@ +import { isAPIError } from "better-auth/api"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { buildReencodedTwoFactorData } from "@/modules/auth/lib/cutover/reencode-two-factor"; +import type { AuthHookContext } from "@/modules/ee/sso/lib/better-auth-hooks"; +import { twoFactorBackfillAfterHandler } from "./better-auth-two-factor-backfill"; + +vi.mock("@formbricks/database", () => ({ + prisma: { + user: { findFirst: vi.fn() }, + twoFactor: { findUnique: vi.fn(), upsert: vi.fn() }, + }, +})); + +vi.mock("better-auth/api", () => ({ isAPIError: vi.fn() })); + +vi.mock("@formbricks/logger", () => ({ logger: { warn: vi.fn(), error: vi.fn() } })); + +vi.mock("@/modules/auth/lib/auth", () => ({ + auth: { $context: Promise.resolve({ secretConfig: "ba-secret-config" }) }, +})); + +vi.mock("@/modules/auth/lib/cutover/reencode-two-factor", () => ({ + buildReencodedTwoFactorData: vi.fn(), +})); + +// Minimal AuthHookContext for a successful /sign-in/email of a legacy-2FA user. +const ctx = (overrides: Record = {}) => + ({ + path: "/sign-in/email", + body: { email: "user@example.com" }, + context: { returned: { twoFactorRedirect: true } }, + ...overrides, + }) as unknown as AuthHookContext; + +describe("twoFactorBackfillAfterHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isAPIError).mockReturnValue(false); // success by default + vi.mocked(buildReencodedTwoFactorData).mockResolvedValue({ + secret: "ba-secret", + backupCodes: "ba-codes", + }); + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + id: "user123", + twoFactorEnabled: true, + twoFactorSecret: "encrypted_secret", + backupCodes: "encrypted_codes", + } as any); + vi.mocked(prisma.twoFactor.findUnique).mockResolvedValue(null); // no BA row yet + }); + + test("backfills the TwoFactor row for a legacy-enrolled user on successful sign-in", async () => { + await twoFactorBackfillAfterHandler(ctx()); + + expect(buildReencodedTwoFactorData).toHaveBeenCalledWith( + "encrypted_secret", + "encrypted_codes", + "ba-secret-config" + ); + expect(prisma.twoFactor.upsert).toHaveBeenCalledWith({ + where: { userId: "user123" }, + update: { secret: "ba-secret", backupCodes: "ba-codes", verified: true }, + create: { userId: "user123", secret: "ba-secret", backupCodes: "ba-codes", verified: true }, + }); + }); + + test("no-op on a non-sign-in path", async () => { + await twoFactorBackfillAfterHandler(ctx({ path: "/two-factor/verify-totp" })); + expect(prisma.user.findFirst).not.toHaveBeenCalled(); + expect(prisma.twoFactor.upsert).not.toHaveBeenCalled(); + }); + + test("no-op on a failed sign-in (APIError) โ€” never a pre-auth write", async () => { + vi.mocked(isAPIError).mockReturnValue(true); + await twoFactorBackfillAfterHandler(ctx({ context: { returned: { message: "bad password" } } })); + expect(prisma.user.findFirst).not.toHaveBeenCalled(); + expect(prisma.twoFactor.upsert).not.toHaveBeenCalled(); + }); + + test("no-op when the user already has a TwoFactor row", async () => { + vi.mocked(prisma.twoFactor.findUnique).mockResolvedValue({ id: "tf1" } as any); + await twoFactorBackfillAfterHandler(ctx()); + expect(prisma.twoFactor.upsert).not.toHaveBeenCalled(); + }); + + test("no-op for a non-2FA / SSO user (no legacy secret or flag off)", async () => { + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + id: "sso1", + twoFactorEnabled: false, + twoFactorSecret: null, + backupCodes: null, + } as any); + await twoFactorBackfillAfterHandler(ctx()); + expect(prisma.twoFactor.upsert).not.toHaveBeenCalled(); + }); + + test("a heal failure is swallowed and never blocks sign-in", async () => { + vi.mocked(prisma.twoFactor.upsert).mockRejectedValue(new Error("ON CONFLICT constraint missing")); + // Must resolve, not reject โ€” the sign-in it runs inside must not 500. + await expect(twoFactorBackfillAfterHandler(ctx())).resolves.toBeUndefined(); + }); +}); diff --git a/apps/web/modules/auth/lib/better-auth-two-factor-backfill.ts b/apps/web/modules/auth/lib/better-auth-two-factor-backfill.ts new file mode 100644 index 000000000000..562ddad22c49 --- /dev/null +++ b/apps/web/modules/auth/lib/better-auth-two-factor-backfill.ts @@ -0,0 +1,79 @@ +import "server-only"; +import { isAPIError } from "better-auth/api"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { buildReencodedTwoFactorData } from "@/modules/auth/lib/cutover/reencode-two-factor"; +import type { AuthHookContext } from "@/modules/ee/sso/lib/better-auth-hooks"; + +/** + * ENG-1824 self-heal. The custom 2FA enable flow (`modules/ee/two-factor-auth`) historically wrote the + * secret only to the legacy `User.twoFactorSecret` column, but login verifies via Better Auth's + * `twoFactor` plugin, which reads the `TwoFactor` table. Users who enabled 2FA before the enable-path + * bridge landed have the legacy columns but no `TwoFactor` row, so login throws "TOTP not enabled". + * + * This lazily re-encodes their existing secret + backup codes into the `TwoFactor` table on their next + * successful password sign-in โ€” no data migration, no re-enrollment โ€” so the `/two-factor/verify-*` + * step that follows finds a row and succeeds. Idempotent (`TwoFactor.userId` is `@@unique`). + * + * Runs as a `hooks.after` on `/sign-in/email` and ONLY when that sign-in succeeded (password verified): + * a wrong-password attempt is a handled `APIError` on `ctx.context.returned`, which we skip. This keeps + * it from being an unauthenticated, email-guessable write. It materializes only the user's OWN existing + * secret, returns nothing to the client, and no-ops for SSO users, non-2FA users, and users who already + * have a `TwoFactor` row. + */ +export const twoFactorBackfillAfterHandler = async (ctx: AuthHookContext): Promise => { + if (ctx.path !== "/sign-in/email") return; + + // Only heal after a successful password step; a failed sign-in surfaces as an APIError response. + const returned = (ctx.context as { returned?: unknown }).returned; + if (isAPIError(returned)) return; + + const body = ctx.body as { email?: unknown } | undefined; + const email = typeof body?.email === "string" ? body.email.trim() : undefined; + if (!email) return; + + // Best-effort: this heal is a convenience, so a failure here (transient DB error, drift, etc.) must + // never break the sign-in it runs inside. Swallow and log; the user still reaches the 2FA prompt, and + // a persistent failure surfaces as the pre-existing "TOTP not enabled" rather than a 500. + try { + // One indexed lookup on each successful password sign-in (a small, deliberate cost for this + // temporary heal shim โ€” it can be removed together with the legacy `User.twoFactor*` columns once + // all enrollments are migrated). Case-insensitive so we match the same account the sign-in did, + // whatever the stored email case; emails are unique, so this resolves to exactly one user. + const user = await prisma.user.findFirst({ + where: { email: { equals: email, mode: "insensitive" } }, + select: { id: true, twoFactorEnabled: true, twoFactorSecret: true, backupCodes: true }, + }); + // Only a legacy-enrolled user (2FA on + legacy secret present) can be missing a BA row. + if (!user?.twoFactorEnabled || !user.twoFactorSecret) return; + + const existing = await prisma.twoFactor.findUnique({ + where: { userId: user.id }, + select: { id: true }, + }); + if (existing) return; + + // Lazy import: this module is loaded eagerly as part of the `hooks.after` chain in auth.ts, so a + // top-level `auth` import would be circular. We only need it once we're actually healing a row. + const { auth } = await import("@/modules/auth/lib/auth"); + const { secretConfig } = await auth.$context; + const twoFactorRow = await buildReencodedTwoFactorData( + user.twoFactorSecret, + user.backupCodes, + secretConfig + ); + await prisma.twoFactor.upsert({ + where: { userId: user.id }, + update: { ...twoFactorRow, verified: true }, + create: { userId: user.id, ...twoFactorRow, verified: true }, + }); + } catch (error) { + // Error level, not warn: the sign-in still succeeds (we swallow so login never 500s), but a failure + // here means the user is left in the exact broken state this heal exists to fix ("TOTP not + // enabled"). It must be loud enough to catch in QA/monitoring rather than hide in the noise. + logger.error( + { error: error instanceof Error ? error.message : error }, + "Two-factor credential backfill failed; user will still see 'TOTP not enabled' until resolved" + ); + } +}; diff --git a/apps/web/modules/auth/lib/cutover/reencode-two-factor.integration.test.ts b/apps/web/modules/auth/lib/cutover/reencode-two-factor.integration.test.ts index 0d5b10407392..a8de5269c859 100644 --- a/apps/web/modules/auth/lib/cutover/reencode-two-factor.integration.test.ts +++ b/apps/web/modules/auth/lib/cutover/reencode-two-factor.integration.test.ts @@ -164,4 +164,111 @@ describe("2FA secret re-encode (real Postgres)", () => { expect(second.skipped).toBe(1); expect(await prisma.twoFactor.count({ where: { userId: user.id } })).toBe(1); }); + + // ENG-1824: a user enrolled on the legacy flow has 2FA + a legacy backup code but NO `TwoFactor` + // row. On their next password sign-in the self-heal must materialize the row from their EXISTING + // (older) secret + backup codes โ€” no migration run, no re-enrollment โ€” so one of the codes they + // saved back then still verifies. Drives the real `hooks.after` heal (not a hand-written row) and + // the real BA verify-backup-code, end to end. + test("a legacy 2FA user's OLD backup code verifies after the sign-in self-heal (no pre-existing row)", async () => { + authenticator.options = { digits: 6, step: 30 }; + const password = "Legacy-Heal1!"; + const fbSecret = authenticator.generateSecret(20); + // Codes as the legacy setup stored them: bare 10-char hex, encrypted with ENCRYPTION_KEY. + const bareCodes = ["a1b2c3d4e5", "0f1e2d3c4b"]; + const user = await prisma.user.create({ + data: { + email: "legacy-heal-backup@example.com", + name: "LegacyHealBackup", + emailVerified: true, + password: await hashSecret(password), + twoFactorEnabled: true, + twoFactorSecret: symmetricEncrypt(fbSecret, ENCRYPTION_KEY), + backupCodes: symmetricEncrypt(JSON.stringify(bareCodes), ENCRYPTION_KEY), + }, + }); + await prisma.account.create({ + data: { + userId: user.id, + type: "credential", + provider: "credential", + providerAccountId: user.id, + password: user.password!, + }, + }); + + // No TwoFactor row yet โ€” the heal is the only thing that can create it. + expect(await prisma.twoFactor.count({ where: { userId: user.id } })).toBe(0); + + // Password step: BA returns the 2FA challenge (no session yet) and the after-hook heal runs. + const challenge = await auth.api.signInEmail({ + body: { email: "legacy-heal-backup@example.com", password }, + asResponse: true, + }); + expect(challenge.status).toBe(200); + expect(await prisma.session.count()).toBe(0); + + // The heal materialized a verified row from the legacy columns. + const healed = await prisma.twoFactor.findUnique({ where: { userId: user.id } }); + expect(healed?.verified).toBe(true); + + // The user enters an OLD backup code in the displayed hyphenated form; BA exact-matches it and + // promotes the partial session to a full one. + await auth.api.verifyBackupCode({ + body: { code: "a1b2c-3d4e5" }, + headers: { cookie: allCookies(challenge) }, + }); + expect(await prisma.session.count()).toBe(1); + }); + + // ENG-1824 regression: the legacy login consumed a used backup code by nulling its slot in place + // (`backupCodes[i] = null`), so a real upgraded user who ever used one has `null` entries. The heal's + // re-encode must skip those (not crash on `null.slice(...)`), materialize a row from the REMAINING + // valid codes, and let one of them verify. Reproduces the actual upgrade failure that "TOTP not + // enabled" masked. + test("a legacy 2FA user with a CONSUMED (null) backup code still heals and a remaining code verifies", async () => { + authenticator.options = { digits: 6, step: 30 }; + const password = "Legacy-Heal2!"; + const fbSecret = authenticator.generateSecret(20); + // First slot nulled = the code the user already spent on the legacy app; second is still valid. + const storedCodes = [null, "0f1e2d3c4b"]; + const user = await prisma.user.create({ + data: { + email: "legacy-consumed-backup@example.com", + name: "LegacyConsumed", + emailVerified: true, + password: await hashSecret(password), + twoFactorEnabled: true, + twoFactorSecret: symmetricEncrypt(fbSecret, ENCRYPTION_KEY), + backupCodes: symmetricEncrypt(JSON.stringify(storedCodes), ENCRYPTION_KEY), + }, + }); + await prisma.account.create({ + data: { + userId: user.id, + type: "credential", + provider: "credential", + providerAccountId: user.id, + password: user.password!, + }, + }); + + expect(await prisma.twoFactor.count({ where: { userId: user.id } })).toBe(0); + + // Sign-in heal must NOT throw on the null slot; it materializes a verified row from the survivors. + const challenge = await auth.api.signInEmail({ + body: { email: "legacy-consumed-backup@example.com", password }, + asResponse: true, + }); + expect(challenge.status).toBe(200); + const healed = await prisma.twoFactor.findUnique({ where: { userId: user.id } }); + expect(healed?.verified).toBe(true); + + // The still-valid code verifies; the consumed one was dropped (not carried over as a broken entry). + await auth.api.verifyBackupCode({ + body: { code: "0f1e2-d3c4b" }, + headers: { cookie: allCookies(challenge) }, + }); + expect(await prisma.session.count()).toBe(1); + }); }); diff --git a/apps/web/modules/auth/lib/cutover/reencode-two-factor.ts b/apps/web/modules/auth/lib/cutover/reencode-two-factor.ts index 4a6d879040f1..daa17ca04717 100644 --- a/apps/web/modules/auth/lib/cutover/reencode-two-factor.ts +++ b/apps/web/modules/auth/lib/cutover/reencode-two-factor.ts @@ -42,16 +42,44 @@ export const reencodeTwoFactorSecret = async ( * the hyphenated `XXXXX-XXXXX` form (`display-backup-codes.tsx` formatBackupCode), and BA's * verify-backup-code does an EXACT match with no hyphen-stripping. So we store the displayed form โ€” the * string the user will actually enter. + * + * The legacy login CONSUMED a used backup code by nulling its slot in place (`backupCodes[i] = null`), + * so a real user who ever used one has `null` entries in the stored array. We drop those โ€” a consumed + * code is spent, and BA's model is that the stored array is just the still-valid codes. Without this + * filter the re-encode throws on `null.slice(...)`, which the sign-in heal swallows โ†’ no `TwoFactor` + * row โ†’ "TOTP not enabled" (ENG-1824). */ export const reencodeTwoFactorBackupCodes = async ( encryptedFormbricksBackupCodes: string, secretConfig: string | SecretConfig ): Promise => { - const bareCodes = JSON.parse(symmetricDecrypt(encryptedFormbricksBackupCodes, ENCRYPTION_KEY)) as string[]; - const displayedCodes = bareCodes.map((code) => `${code.slice(0, 5)}-${code.slice(5, 10)}`); + const storedCodes = JSON.parse(symmetricDecrypt(encryptedFormbricksBackupCodes, ENCRYPTION_KEY)) as ( + | string + | null + )[]; + const displayedCodes = storedCodes + .filter((code): code is string => typeof code === "string") + .map((code) => `${code.slice(0, 5)}-${code.slice(5, 10)}`); return symmetricEncrypt({ key: secretConfig, data: JSON.stringify(displayedCodes) }); }; +/** + * Build a Better Auth `TwoFactor`-row payload (`{ secret, backupCodes }`) from a user's legacy + * `User.twoFactorSecret` / `User.backupCodes` (both `ENCRYPTION_KEY`-encrypted). Shared by the one-shot + * cutover batch below and the live enable / sign-in bridge (ENG-1824). A user with a secret but no + * stored backup codes gets an empty (encrypted) code list. + */ +export const buildReencodedTwoFactorData = async ( + encryptedFormbricksSecret: string, + encryptedFormbricksBackupCodes: string | null, + secretConfig: string | SecretConfig +): Promise<{ secret: string; backupCodes: string }> => ({ + secret: await reencodeTwoFactorSecret(encryptedFormbricksSecret, secretConfig), + backupCodes: encryptedFormbricksBackupCodes + ? await reencodeTwoFactorBackupCodes(encryptedFormbricksBackupCodes, secretConfig) + : await symmetricEncrypt({ key: secretConfig, data: "[]" }), +}); + interface TwoFactorUserRow { id: string; twoFactorSecret: string; @@ -102,10 +130,7 @@ export const reencodeAllTwoFactorSecrets = async ( await prisma.twoFactor.create({ data: { userId: user.id, - secret: await reencodeTwoFactorSecret(user.twoFactorSecret, secretConfig), - backupCodes: user.backupCodes - ? await reencodeTwoFactorBackupCodes(user.backupCodes, secretConfig) - : await symmetricEncrypt({ key: secretConfig, data: "[]" }), + ...(await buildReencodedTwoFactorData(user.twoFactorSecret, user.backupCodes, secretConfig)), }, }); stats.migrated += 1; diff --git a/apps/web/modules/ee/two-factor-auth/lib/two-factor-auth.test.ts b/apps/web/modules/ee/two-factor-auth/lib/two-factor-auth.test.ts index 9707e596b854..ddedca53f059 100644 --- a/apps/web/modules/ee/two-factor-auth/lib/two-factor-auth.test.ts +++ b/apps/web/modules/ee/two-factor-auth/lib/two-factor-auth.test.ts @@ -3,6 +3,7 @@ import { prisma } from "@formbricks/database"; import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto"; import { getCredentialPasswordHash, verifyUserPassword } from "@/lib/user/password"; +import { buildReencodedTwoFactorData } from "@/modules/auth/lib/cutover/reencode-two-factor"; import { totpAuthenticatorCheck } from "@/modules/auth/lib/totp"; import { disableTwoFactorAuth, enableTwoFactorAuth, setupTwoFactorAuth } from "./two-factor-auth"; @@ -12,6 +13,12 @@ vi.mock("@formbricks/database", () => ({ findUnique: vi.fn(), update: vi.fn(), }, + twoFactor: { + upsert: vi.fn(), + deleteMany: vi.fn(), + }, + // Runs the passed prisma operations; the individual mocks above record their own calls. + $transaction: vi.fn((ops) => (Array.isArray(ops) ? Promise.all(ops) : Promise.resolve(ops))), }, })); @@ -25,6 +32,14 @@ vi.mock("@/lib/user/password", () => ({ verifyUserPassword: vi.fn(), })); +vi.mock("@/modules/auth/lib/auth", () => ({ + auth: { $context: Promise.resolve({ secretConfig: "ba-secret-config" }) }, +})); + +vi.mock("@/modules/auth/lib/cutover/reencode-two-factor", () => ({ + buildReencodedTwoFactorData: vi.fn(), +})); + vi.mock("@/modules/auth/lib/totp", () => ({ totpAuthenticatorCheck: vi.fn(), })); @@ -36,6 +51,10 @@ describe("Two Factor Auth", () => { // password" tests override these. vi.mocked(getCredentialPasswordHash).mockResolvedValue("hashedPassword"); vi.mocked(verifyUserPassword).mockResolvedValue(true); + vi.mocked(buildReencodedTwoFactorData).mockResolvedValue({ + secret: "ba-secret", + backupCodes: "ba-codes", + }); }); afterEach(() => { @@ -223,6 +242,18 @@ describe("Two Factor Auth", () => { where: { id: "user123" }, data: { twoFactorEnabled: true }, }); + // ENG-1824: also materialize the Better Auth TwoFactor row (which login verifies against). + expect(buildReencodedTwoFactorData).toHaveBeenCalledWith( + "encrypted_secret", + undefined, + "ba-secret-config" + ); + expect(prisma.twoFactor.upsert).toHaveBeenCalledWith({ + where: { userId: "user123" }, + update: { secret: "ba-secret", backupCodes: "ba-codes", verified: true }, + create: { userId: "user123", secret: "ba-secret", backupCodes: "ba-codes", verified: true }, + }); + expect(prisma.$transaction).toHaveBeenCalled(); }); test("disableTwoFactorAuth should throw ResourceNotFoundError when user not found", async () => { @@ -351,6 +382,9 @@ describe("Two Factor Auth", () => { twoFactorSecret: null, }, }); + // ENG-1824: also remove the Better Auth TwoFactor row so 2FA is fully off. + expect(prisma.twoFactor.deleteMany).toHaveBeenCalledWith({ where: { userId: "user123" } }); + expect(prisma.$transaction).toHaveBeenCalled(); }); test("disableTwoFactorAuth should successfully disable 2FA with 2FA code", async () => { @@ -378,5 +412,8 @@ describe("Two Factor Auth", () => { twoFactorSecret: null, }, }); + // ENG-1824: also remove the Better Auth TwoFactor row so 2FA is fully off. + expect(prisma.twoFactor.deleteMany).toHaveBeenCalledWith({ where: { userId: "user123" } }); + expect(prisma.$transaction).toHaveBeenCalled(); }); }); diff --git a/apps/web/modules/ee/two-factor-auth/lib/two-factor-auth.ts b/apps/web/modules/ee/two-factor-auth/lib/two-factor-auth.ts index 13f3988c689e..ee8181a72481 100644 --- a/apps/web/modules/ee/two-factor-auth/lib/two-factor-auth.ts +++ b/apps/web/modules/ee/two-factor-auth/lib/two-factor-auth.ts @@ -6,6 +6,8 @@ import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/erro import { ENCRYPTION_KEY } from "@/lib/constants"; import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto"; import { getCredentialPasswordHash, verifyUserPassword } from "@/lib/user/password"; +import { auth } from "@/modules/auth/lib/auth"; +import { buildReencodedTwoFactorData } from "@/modules/auth/lib/cutover/reencode-two-factor"; import { totpAuthenticatorCheck } from "@/modules/auth/lib/totp"; export const setupTwoFactorAuth = async ( @@ -110,14 +112,25 @@ export const enableTwoFactorAuth = async (id: string, code: string) => { throw new InvalidInputError("Invalid code"); } - await prisma.user.update({ - where: { - id, - }, - data: { - twoFactorEnabled: true, - }, - }); + // Better Auth's login-time TOTP/backup verification reads the `TwoFactor` table, not the legacy + // `User.twoFactorSecret` column this flow writes โ€” so we re-encode the same (already TOTP-verified) + // secret + backup codes into that table, or login fails with "TOTP not enabled" (ENG-1824). `verified` + // defaults to true, which is correct: we only reach here after checking a live TOTP code. Both writes + // run in one transaction so the enabled flag and the BA row can't diverge. + const { secretConfig } = await auth.$context; + const twoFactorRow = await buildReencodedTwoFactorData( + user.twoFactorSecret, + user.backupCodes, + secretConfig + ); + await prisma.$transaction([ + prisma.user.update({ where: { id }, data: { twoFactorEnabled: true } }), + prisma.twoFactor.upsert({ + where: { userId: id }, + update: { ...twoFactorRow, verified: true }, + create: { userId: id, ...twoFactorRow, verified: true }, + }), + ]); return { message: "Two factor authentication enabled", @@ -201,16 +214,15 @@ export const disableTwoFactorAuth = async (id: string, params: TDisableTwoFactor } } - await prisma.user.update({ - where: { - id, - }, - data: { - backupCodes: null, - twoFactorEnabled: false, - twoFactorSecret: null, - }, - }); + // Clear both stores together: the legacy User columns and Better Auth's `TwoFactor` row (which login + // reads). Leaving the BA row behind would keep 2FA effectively on at login (ENG-1824). + await prisma.$transaction([ + prisma.user.update({ + where: { id }, + data: { backupCodes: null, twoFactorEnabled: false, twoFactorSecret: null }, + }), + prisma.twoFactor.deleteMany({ where: { userId: id } }), + ]); return { message: "Two factor authentication disabled", diff --git a/apps/web/package.json b/apps/web/package.json index 09dfea0c4fc3..d1dbfc01e549 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,7 +7,8 @@ "clean": "rimraf .turbo node_modules .next coverage", "dev": "next dev -p 3000 --turbopack", "go": "next dev -p 3000 --turbopack", - "build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 next build", + "build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 next build && pnpm build:env-validator", + "build:env-validator": "vite build --config vite.env-validation.config.mts", "build:dev": "pnpm run build", "start": "next start", "typecheck": "pnpm typegen && tsc --noEmit --project tsconfig.typecheck.json", diff --git a/apps/web/scripts/docker/next-start.sh b/apps/web/scripts/docker/next-start.sh index 75a16f4c3e73..27024ff19e39 100755 --- a/apps/web/scripts/docker/next-start.sh +++ b/apps/web/scripts/docker/next-start.sh @@ -3,6 +3,9 @@ set -eu export NODE_ENV=production +echo "Validating environment variables..." +node /home/nextjs/validate-env.mjs + # Function to run command with timeout if available, or without timeout as fallback run_with_timeout() { _timeout_duration="$1" diff --git a/apps/web/scripts/docker/validate-env.ts b/apps/web/scripts/docker/validate-env.ts new file mode 100644 index 000000000000..e6501523d8c2 --- /dev/null +++ b/apps/web/scripts/docker/validate-env.ts @@ -0,0 +1,3 @@ +import "../../lib/env"; + +console.log("Environment variables validated successfully"); diff --git a/apps/web/vite.env-validation.config.mts b/apps/web/vite.env-validation.config.mts new file mode 100644 index 000000000000..789d3c67d395 --- /dev/null +++ b/apps/web/vite.env-validation.config.mts @@ -0,0 +1,21 @@ +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [tsconfigPaths()], + build: { + copyPublicDir: false, + emptyOutDir: true, + outDir: "dist/docker", + ssr: "scripts/docker/validate-env.ts", + target: "node24", + rollupOptions: { + output: { + entryFileNames: "validate-env.mjs", + }, + }, + }, + ssr: { + noExternal: true, + }, +}); diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index e30c6eafb0a8..0be96e1f0495 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -272,10 +272,13 @@ services: formbricks-migrate: image: ghcr.io/formbricks/formbricks:latest restart: "no" + env_file: + - path: .env + required: false entrypoint: ["sh", "-c"] command: [ - 'if command -v timeout >/dev/null 2>&1; then timeout 300 node packages/database/dist/scripts/apply-migrations.js; else node packages/database/dist/scripts/apply-migrations.js; fi', + "node /home/nextjs/validate-env.mjs && if command -v timeout >/dev/null 2>&1; then timeout 300 node packages/database/dist/scripts/apply-migrations.js; else node packages/database/dist/scripts/apply-migrations.js; fi", ] depends_on: postgres: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 79738db76fd3..aa9e4a8f27ce 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -86,6 +86,16 @@ enablePrePostScripts: true legacyPeerDeps: true nodeLinker: hoisted saveExact: true +# Supply-chain release cooldown: refuse to install package versions published less than +# 3 days (4320 minutes) ago, so compromised releases are usually detected/yanked before we +# can pull them. pnpm 11 already defaults to 1 day; we raise it to 3 days and pin it +# explicitly. Note: with trustLockfile disabled (below), `pnpm install` re-verifies entries +# already in the lockfile against this policy too โ€” a lockfile bump younger than 3 days +# (e.g. written by a bot) fails install until it ages or is excluded below. Intended. +minimumReleaseAge: 4320 +# Keep the install-time re-verification of lockfile entries active (this is pnpm's default; +# pinned explicitly so a future default flip can't silently weaken the cooldown). +trustLockfile: false minimumReleaseAgeExclude: # First-party SDK (published by Formbricks' own CI) โ€” exempt all versions from the release-age gate so # a fresh hub bump isn't blocked. Version-pinned scoped entries (e.g. '@formbricks/hub@0.9.0') are not