diff --git a/.github/workflows/docker-build-validation.yml b/.github/workflows/docker-build-validation.yml index 582f5a02a886..61776d67c9ce 100644 --- a/.github/workflows/docker-build-validation.yml +++ b/.github/workflows/docker-build-validation.yml @@ -21,6 +21,7 @@ jobs: contents: read env: DATABASE_URL: postgresql://test:test@127.0.0.1:5432/formbricks + POSTGRES_PASSWORD: build-time-placeholder ENCRYPTION_KEY: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef REDIS_URL: redis://127.0.0.1:6379 CUBEJS_API_URL: http://127.0.0.1:4000 @@ -110,6 +111,7 @@ jobs: env: AUTHZED_DATABASE_PASSWORD: 0000000000000000000000000000000000000000000000000000000000000002 AUTHZED_TOKEN: 0000000000000000000000000000000000000000000000000000000000000001 + POSTGRES_PASSWORD: build-time-placeholder run: | set -euo pipefail @@ -322,6 +324,7 @@ jobs: printf '%s\n' \ 'WEBAPP_URL=http://localhost:3000' \ 'NEXTAUTH_URL=http://localhost:3000' \ + 'POSTGRES_PASSWORD=compose-postgres-placeholder' \ "ENCRYPTION_KEY=$DUMMY_ENCRYPTION_KEY" \ "BETTER_AUTH_SECRET=$DUMMY_ENCRYPTION_KEY" \ 'HUB_API_KEY=build-time-placeholder' \ diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2337640a91bb..9cf38a4fac24 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -11,6 +11,21 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 + services: + postgres: + image: pgvector/pgvector@sha256:2ba9ca5f2e7daa0f0e7723cba1ee9167bab54efd3640516a44ac1a928dd67e7a + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: formbricks_migration_shadow + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 @@ -42,5 +57,51 @@ jobs: - name: Check formatting run: pnpm format:check + - name: Lint changed database migrations + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || '' }} + shell: bash + run: | + set -euo pipefail + + base_sha="${BASE_SHA}" + if [[ -z "${base_sha}" ]]; then + base_sha="$(git rev-parse HEAD^)" + echo "::notice::No event base SHA was provided; comparing against ${base_sha}." + fi + + if [[ ! "${base_sha}" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Invalid base SHA: ${base_sha}" + exit 1 + fi + + if ! git cat-file -e "${base_sha}^{commit}" 2>/dev/null; then + git fetch --no-tags --depth=1 origin "${base_sha}" + fi + + mapfile -d '' migration_files < <( + git diff --diff-filter=ACMR --name-only -z "${base_sha}" HEAD -- \ + 'packages/database/migration/*/migration.sql' + ) + + if (( ${#migration_files[@]} == 0 )); then + echo "::notice::No changed schema migrations to lint." + exit 0 + fi + + absolute_migration_files=() + for migration_file in "${migration_files[@]}"; do + absolute_migration_files+=("${GITHUB_WORKSPACE}/${migration_file}") + done + + printf 'Linting migration: %s\n' "${migration_files[@]}" + pnpm --filter @formbricks/database lint:migrations -- "${absolute_migration_files[@]}" + + - name: Check database migration drift + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres?schema=public + SHADOW_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/formbricks_migration_shadow?schema=public + run: pnpm --filter @formbricks/database check:migration-drift + - name: Lint run: pnpm lint diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/account/notifications/components/EditAlerts.tsx b/apps/web/app/(app)/workspaces/[workspaceId]/settings/account/notifications/components/EditAlerts.tsx index 6bc32a22d62c..8f001208eafd 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/settings/account/notifications/components/EditAlerts.tsx +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/account/notifications/components/EditAlerts.tsx @@ -9,6 +9,7 @@ import { organizationSettingsPath } from "@/modules/settings/lib/routes"; import { EmptyState } from "@/modules/ui/components/empty-state"; import { SettingsTable, type TSettingsTableColumn } from "@/modules/ui/components/settings-table"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip"; +import { type TAlertRow, getAlertRows } from "../lib/alert-rows"; import { Membership } from "../types"; import { NotificationSwitch } from "./NotificationSwitch"; @@ -19,9 +20,6 @@ interface EditAlertsProps { autoDisableNotificationElementId: string; } -/** A survey to alert on, carrying the workspace it belongs to for the row's sub-line. */ -type TAlertRow = { surveyId: string; surveyName: string; workspaceName: string }; - /** * Defined at module level rather than inside the component: an inline `cell` that returns JSX reads as a * nested component definition to Sonar (typescript:S6478). One array serves every organization's table. @@ -39,15 +37,22 @@ const getAlertColumns = ({ }>): TSettingsTableColumn[] => [ { id: "survey", - header: t("common.surveys"), - headerClassName: "w-[70%]", + header: t("common.survey"), + headerClassName: "w-[45%]", + cellClassName: "font-medium text-slate-900", skeletonWidth: "w-48", - cell: (row) => ( - <> -
{row.surveyName}
-
{row.workspaceName}
- - ), + cell: (row) => row.surveyName, + }, + { + // A column of its own rather than a sub-line under the survey name: the surveys of an organization + // are listed together here, so the same name can appear once per workspace, and an unlabelled second + // line left the reader to guess what it named. + id: "workspace", + header: t("common.workspace"), + headerClassName: "w-[30%]", + cellClassName: "text-slate-500", + skeletonWidth: "w-32", + cell: (row) => row.workspaceName, }, { id: "alert", @@ -64,7 +69,7 @@ const getAlertColumns = ({ ), - headerClassName: "w-[30%]", + headerClassName: "w-[25%]", align: "center", skeletonWidth: "w-10", cell: (row) => ( @@ -103,15 +108,9 @@ export const EditAlerts = ({ return ( <> {memberships.map((membership) => { - // One row list per organization: the surveys were nested one level deeper, under workspaces, and - // the workspace only contributes a sub-line to each row. - const rows: TAlertRow[] = membership.organization.workspaces.flatMap((workspace) => - workspace.surveys.map((survey) => ({ - surveyId: survey.id, - surveyName: survey.name, - workspaceName: workspace.name, - })) - ); + // One row list per organization: the surveys are nested one level deeper, under workspaces, and + // each row names the workspace it came from. + const rows: TAlertRow[] = getAlertRows(membership.organization.workspaces); return (
diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/account/notifications/lib/alert-rows.test.ts b/apps/web/app/(app)/workspaces/[workspaceId]/settings/account/notifications/lib/alert-rows.test.ts new file mode 100644 index 000000000000..327f1206c0dc --- /dev/null +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/account/notifications/lib/alert-rows.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "vitest"; +import { getAlertRows } from "./alert-rows"; + +const workspace = (name: string, surveys: { id: string; name: string }[]) => ({ id: name, name, surveys }); + +describe("getAlertRows", () => { + test("names the workspace on every survey row", () => { + const rows = getAlertRows([ + workspace("Website", [{ id: "s1", name: "NPS Survey" }]), + workspace("Mobile App", [{ id: "s2", name: "Churn Survey" }]), + ]); + + expect(rows).toEqual([ + { surveyId: "s2", surveyName: "Churn Survey", workspaceName: "Mobile App" }, + { surveyId: "s1", surveyName: "NPS Survey", workspaceName: "Website" }, + ]); + }); + + test("keeps a workspace's surveys together when the input interleaves them", () => { + // What the page's own query returns: no `orderBy` on either level, so a second workspace can sit + // between two surveys that belong to the same one. + const rows = getAlertRows([ + workspace("Website", [{ id: "s1", name: "NPS Survey" }]), + workspace("Docs Portal", [{ id: "s2", name: "Onboarding Feedback" }]), + workspace("Website", [{ id: "s3", name: "Churn Survey" }]), + ]); + + expect(rows.map((row) => [row.workspaceName, row.surveyName])).toEqual([ + ["Docs Portal", "Onboarding Feedback"], + ["Website", "Churn Survey"], + ["Website", "NPS Survey"], + ]); + }); + + test("orders same-named surveys in one workspace by id, so their rows cannot swap", () => { + const duplicates = [ + { id: "s2", name: "NPS Survey" }, + { id: "s1", name: "NPS Survey" }, + ]; + + expect(getAlertRows([workspace("Website", duplicates)]).map((row) => row.surveyId)).toEqual(["s1", "s2"]); + expect( + getAlertRows([workspace("Website", [...duplicates].reverse())]).map((row) => row.surveyId) + ).toEqual(["s1", "s2"]); + }); + + test("orders numbered workspaces the way a reader counts them", () => { + const rows = getAlertRows([ + workspace("Workspace 10", [{ id: "s1", name: "NPS Survey" }]), + workspace("Workspace 2", [{ id: "s2", name: "NPS Survey" }]), + ]); + + expect(rows.map((row) => row.workspaceName)).toEqual(["Workspace 2", "Workspace 10"]); + }); + + test("returns no rows for an organization whose workspaces hold no surveys", () => { + expect(getAlertRows([workspace("Website", []), workspace("Mobile App", [])])).toEqual([]); + }); +}); diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/settings/account/notifications/lib/alert-rows.ts b/apps/web/app/(app)/workspaces/[workspaceId]/settings/account/notifications/lib/alert-rows.ts new file mode 100644 index 000000000000..f0c0f4709dfa --- /dev/null +++ b/apps/web/app/(app)/workspaces/[workspaceId]/settings/account/notifications/lib/alert-rows.ts @@ -0,0 +1,41 @@ +import type { Membership } from "../types"; + +/** A survey to alert on, carrying the workspace it belongs to so the list can name it. */ +export type TAlertRow = { surveyId: string; surveyName: string; workspaceName: string }; + +type TWorkspaces = Membership["organization"]["workspaces"]; + +/** + * Pinned to one locale rather than left to `localeCompare`'s default, because the list is rendered by a + * client component: the server sorts under Node's locale and the browser re-sorts under the visitor's, + * and a collation that disagrees between the two puts the rows in a different order on each side, which + * is a hydration mismatch. `numeric` is what keeps "Workspace 2" ahead of "Workspace 10". + */ +const collator = new Intl.Collator("en", { numeric: true }); + +/** + * Flattens one organization's workspaces into a row per survey, ordered by workspace, then by survey + * name, then by id. + * + * The order carries the workspace column: the query returns surveys grouped by workspace but with no + * order inside or between those groups, so a workspace's surveys can arrive interleaved with another's + * and two same-named surveys from different workspaces can land rows apart. Sorting keeps each + * workspace's surveys together, so the column reads as a group label rather than a value repeated at + * random. The id breaks the remaining tie, because nothing stops two surveys in one workspace sharing a + * name — without it their rows could swap places between renders. + */ +export const getAlertRows = (workspaces: TWorkspaces): TAlertRow[] => + workspaces + .flatMap((workspace) => + workspace.surveys.map((survey) => ({ + surveyId: survey.id, + surveyName: survey.name, + workspaceName: workspace.name, + })) + ) + .sort( + (a, b) => + collator.compare(a.workspaceName, b.workspaceName) || + collator.compare(a.surveyName, b.surveyName) || + collator.compare(a.surveyId, b.surveyId) + ); diff --git a/apps/web/instrumentation-jobs.test.ts b/apps/web/instrumentation-jobs.test.ts index 9df0feb2bc51..57320b4a7051 100644 --- a/apps/web/instrumentation-jobs.test.ts +++ b/apps/web/instrumentation-jobs.test.ts @@ -5,6 +5,8 @@ const mockRemoveSurveyScheduling = vi.fn(); const mockUpsertSurveyScheduling = vi.fn(); const mockRemoveSurveyArchivePurge = vi.fn(); const mockUpsertSurveyArchivePurge = vi.fn(); +const mockRemoveUsageTelemetry = vi.fn(); +const mockUpsertUsageTelemetry = vi.fn(); const mockRemoveWorkflowRunReconcile = vi.fn(); const mockUpsertWorkflowRunReconcile = vi.fn(); const mockUpsertAuthzedProjectionDelivery = vi.fn(); @@ -17,6 +19,7 @@ const mockGetJobsWorkerBootstrapConfig = vi.fn(); const mockProcessResponsePipelineJob = vi.fn(); const mockProcessSurveySchedulingJob = vi.fn(); const mockProcessSurveyArchivePurgeJob = vi.fn(); +const mockProcessUsageTelemetryJob = vi.fn(); const mockProcessWorkflowRunJob = vi.fn(); const mockProcessWorkflowRunReconcileJob = vi.fn(); const mockProcessAuthzedProjectionDeliveryJob = vi.fn(); @@ -61,6 +64,13 @@ vi.mock("@formbricks/jobs", () => ({ scope: "global", upsert: mockUpsertSurveyScheduling, }, + usageTelemetry: { + name: "usage-telemetry.process", + remove: mockRemoveUsageTelemetry, + scheduleId: "daily-usage-telemetry", + scope: "global", + upsert: mockUpsertUsageTelemetry, + }, workflowRunReconcile: { name: "workflow-run.reconcile", remove: mockRemoveWorkflowRunReconcile, @@ -98,6 +108,10 @@ vi.mock("@/modules/survey/archive/lib/process-survey-archive-purge-job", () => ( processSurveyArchivePurgeJob: mockProcessSurveyArchivePurgeJob, })); +vi.mock("@/lib/telemetry/process-usage-telemetry-job", () => ({ + processUsageTelemetryJob: mockProcessUsageTelemetryJob, +})); + vi.mock("@/modules/ee/workflows/lib/runner/process-workflow-run-job", () => ({ processWorkflowRunJob: mockProcessWorkflowRunJob, })); @@ -126,6 +140,12 @@ describe("instrumentation-jobs", () => { name: "survey-archive-purge.process", queueName: "background-jobs", }); + mockRemoveUsageTelemetry.mockResolvedValue(true); + mockUpsertUsageTelemetry.mockResolvedValue({ + id: "usage-telemetry-schedule-1", + name: "usage-telemetry.process", + queueName: "background-jobs", + }); mockRemoveWorkflowRunReconcile.mockResolvedValue(true); mockGetJobsQueueingConfig.mockReturnValue({ enabled: false, @@ -207,6 +227,7 @@ describe("instrumentation-jobs", () => { "survey-archive-purge.process": expect.any(Function), "workflow-run.process": expect.any(Function), "test-log.process": mockExistingOverride, + "usage-telemetry.process": expect.any(Function), "workflow-run.reconcile": expect.any(Function), }, redisUrl: "redis://localhost:6379", @@ -287,6 +308,28 @@ describe("instrumentation-jobs", () => { } ); + const usageTelemetryOverride = overrides?.["usage-telemetry.process"]; + await usageTelemetryOverride?.( + { scope: "global" }, + { + attempt: 1, + jobId: "job_101", + jobName: "usage-telemetry.process", + maxAttempts: 3, + queueName: "background-jobs", + } + ); + expect(mockProcessUsageTelemetryJob).toHaveBeenCalledWith( + { scope: "global" }, + { + attempt: 1, + jobId: "job_101", + jobName: "usage-telemetry.process", + maxAttempts: 3, + queueName: "background-jobs", + } + ); + const workflowRunReconcileOverride = overrides?.["workflow-run.reconcile"]; await workflowRunReconcileOverride?.( { scope: "global" }, @@ -433,6 +476,8 @@ describe("instrumentation-jobs", () => { await import("@/modules/survey/scheduling/lib/constants"); const { SURVEY_ARCHIVE_PURGE_DAILY_CRON_PATTERN, SURVEY_ARCHIVE_PURGE_TIME_ZONE } = await import("@/modules/survey/archive/lib/constants"); + const { USAGE_TELEMETRY_DAILY_CRON_PATTERN, USAGE_TELEMETRY_TIME_ZONE } = + await import("@/lib/telemetry/constants"); const { WORKFLOW_RUN_RECONCILE_INTERVAL_MS } = await import("@/modules/ee/workflows/lib/runner/reconcile-constants"); @@ -468,6 +513,15 @@ describe("instrumentation-jobs", () => { // NEXT_PUBLIC_ var that ENG-1665 renamed away, pinning it to the Europe/Berlin fallback // regardless of configuration (ENG-2244). expect(SURVEY_ARCHIVE_PURGE_TIME_ZONE).toBe(SURVEY_SCHEDULING_TIME_ZONE); + expect(mockUpsertUsageTelemetry).toHaveBeenCalledTimes(1); + // `immediately` is the point of this schedule, not an incidental option: without it an instance + // that has just been identified sends no usage update until the next daily slot (ENG-2107). + expect(mockUpsertUsageTelemetry).toHaveBeenCalledWith({ + cronPattern: USAGE_TELEMETRY_DAILY_CRON_PATTERN, + immediately: true, + kind: "cron", + timeZone: USAGE_TELEMETRY_TIME_ZONE, + }); expect(mockUpsertWorkflowRunReconcile).toHaveBeenCalledTimes(1); expect(mockUpsertWorkflowRunReconcile).toHaveBeenCalledWith({ everyMs: WORKFLOW_RUN_RECONCILE_INTERVAL_MS, @@ -477,6 +531,7 @@ describe("instrumentation-jobs", () => { // scheduler with no delayed job (bullmq#3063). expect(mockRemoveSurveyScheduling).not.toHaveBeenCalled(); expect(mockRemoveSurveyArchivePurge).not.toHaveBeenCalled(); + expect(mockRemoveUsageTelemetry).not.toHaveBeenCalled(); expect(mockRemoveWorkflowRunReconcile).not.toHaveBeenCalled(); } ); diff --git a/apps/web/lib/jobs/recurring-registrations.ts b/apps/web/lib/jobs/recurring-registrations.ts index b0550f4eb495..31e6f9398d93 100644 --- a/apps/web/lib/jobs/recurring-registrations.ts +++ b/apps/web/lib/jobs/recurring-registrations.ts @@ -12,6 +12,8 @@ import { } from "@formbricks/jobs"; import { processAuthzedProjectionDeliveryJob } from "@/lib/authzed/outbox-processor"; import { processAuthzedScheduledReconciliationJob } from "@/lib/authzed/scheduled-reconciliation"; +import { USAGE_TELEMETRY_DAILY_CRON_PATTERN, USAGE_TELEMETRY_TIME_ZONE } from "@/lib/telemetry/constants"; +import { processUsageTelemetryJob } from "@/lib/telemetry/process-usage-telemetry-job"; import { processWorkflowRunJob } from "@/modules/ee/workflows/lib/runner/process-workflow-run-job"; import { processWorkflowRunReconcileJob } from "@/modules/ee/workflows/lib/runner/process-workflow-run-reconcile-job"; import { WORKFLOW_RUN_RECONCILE_INTERVAL_MS } from "@/modules/ee/workflows/lib/runner/reconcile-constants"; @@ -92,6 +94,30 @@ export const RECURRING_JOB_REGISTRATIONS_BY_KEY: Record ({ + sendTelemetryEvents: vi.fn(), +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + }, +})); + +const context: JobExecutionContext = { + attempt: 1, + jobId: "job-1", + jobName: "usage-telemetry.process", + maxAttempts: 3, + queueName: "background-jobs", +}; + +describe("processUsageTelemetryJob", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("sends the instance usage update", async () => { + vi.mocked(sendTelemetryEvents).mockResolvedValue(undefined); + + await processUsageTelemetryJob({ scope: "global" }, context); + + expect(sendTelemetryEvents).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ jobId: "job-1", scope: "global" }), + "Usage telemetry job completed" + ); + }); + + test("propagates a dispatch failure so the job is retried", async () => { + const error = new Error("telemetry offline"); + vi.mocked(sendTelemetryEvents).mockRejectedValue(error); + + await expect(processUsageTelemetryJob({ scope: "global" }, context)).rejects.toThrow("telemetry offline"); + + expect(logger.info).not.toHaveBeenCalledWith(expect.anything(), "Usage telemetry job completed"); + }); +}); diff --git a/apps/web/lib/telemetry/process-usage-telemetry-job.ts b/apps/web/lib/telemetry/process-usage-telemetry-job.ts new file mode 100644 index 000000000000..669168c50d2d --- /dev/null +++ b/apps/web/lib/telemetry/process-usage-telemetry-job.ts @@ -0,0 +1,37 @@ +import "server-only"; +import type { JobHandler, TUsageTelemetryJobData } from "@formbricks/jobs"; +import { logger } from "@formbricks/logger"; +import { sendTelemetryEvents } from "@/lib/telemetry/usage-update"; + +/** + * Sends the instance's usage update on a daily cron, independently of any survey traffic. + * + * Before this job the usage update was only ever sent from the response pipeline, so an instance that + * identified itself against the license server but never collected a response reported no usage at all + * (ENG-2107). An instance that is not up at 02:15 UTC still reports: a missed tick is re-added with + * its original timestamp and runs at the next boot — see `lib/jobs/recurring-registrations.ts`, which + * also explains why `immediately: true` fires once per scheduler rather than on every boot. + * + * Safe to overlap, as recurring handlers must be: `sendTelemetryEvents` is guarded by an in-memory + * check, a shared 24h timestamp in Redis and a distributed lock, so a second tick — or a response + * pipeline run in the same window — is a no-op rather than a duplicate report. It also handles its own + * failures rather than throwing, so a rejected update leaves this job successful; the 1h cooldown it + * sets is a floor on the next attempt, not a scheduled retry, so the retry is whichever trigger calls + * in next (a response, the next daily tick, or that tick overdue at boot) rather than a BullMQ attempt. + */ +export const processUsageTelemetryJob: JobHandler = async (data, context) => { + const logContext = { + attempt: context.attempt, + jobId: context.jobId, + jobName: context.jobName, + maxAttempts: context.maxAttempts, + queueName: context.queueName, + scope: data.scope, + }; + + logger.info(logContext, "Usage telemetry job started"); + + await sendTelemetryEvents(); + + logger.info(logContext, "Usage telemetry job completed"); +}; diff --git a/apps/web/modules/response-pipeline/lib/telemetry.test.ts b/apps/web/lib/telemetry/usage-update.test.ts similarity index 81% rename from apps/web/modules/response-pipeline/lib/telemetry.test.ts rename to apps/web/lib/telemetry/usage-update.test.ts index c4d9cd39449b..282f31c1148c 100644 --- a/apps/web/modules/response-pipeline/lib/telemetry.test.ts +++ b/apps/web/lib/telemetry/usage-update.test.ts @@ -3,7 +3,7 @@ import { getCacheService } from "@formbricks/cache"; import { prisma } from "@formbricks/database"; import { IntegrationType } from "@formbricks/database/prisma"; import { logger } from "@formbricks/logger"; -import { sendTelemetryEvents } from "./telemetry"; +import { sendTelemetryEvents } from "./usage-update"; // Mock dependencies vi.mock("@formbricks/cache", () => ({ @@ -219,7 +219,7 @@ describe("sendTelemetryEvents", () => { vi.doMock("@/modules/ee/license-check/lib/license", () => ({ getEnterpriseLicense: vi.fn().mockResolvedValue({ active: false }), })); - const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry"); + const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./usage-update"); // Ensure we can acquire lock by setting last sent time far in the past const oldTime = Date.now() - 25 * 60 * 60 * 1000; // 25 hours ago @@ -271,7 +271,7 @@ describe("sendTelemetryEvents", () => { vi.doMock("@/modules/ee/license-check/lib/license", () => ({ getEnterpriseLicense: vi.fn().mockResolvedValue({ active: false }), })); - const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry"); + const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./usage-update"); // Ensure we can acquire lock by setting last sent time far in the past const oldTime = Date.now() - 25 * 60 * 60 * 1000; // 25 hours ago @@ -290,9 +290,7 @@ describe("sendTelemetryEvents", () => { await freshSendTelemetryEvents(); - // sendTelemetry returns early when no org exists - // Since it returns (not throws), the try block completes successfully - // Then cache.set is called, and finally block executes + // sendTelemetry returns early when no org exists, so nothing is reported expect(fetchMock).not.toHaveBeenCalled(); // Verify lock was acquired (prerequisite for finally block to execute) @@ -301,9 +299,73 @@ describe("sendTelemetryEvents", () => { // Lock should be released in finally block expect(mockCacheService.del).toHaveBeenCalledWith(["telemetry_lock"]); - // Note: The current implementation calls cache.set even when no org exists - // This might be a bug, but we test the actual behavior - expect(mockCacheService.set).toHaveBeenCalled(); + // The 24h window must not be consumed when nothing was reported: recording it would delay this + // instance's *first* usage update by a day (ENG-2107). + expect(mockCacheService.set).not.toHaveBeenCalled(); + + // A 1h cooldown applies instead of the full 24h, so the retry lands once an org exists. + vi.clearAllMocks(); + vi.mocked(getCacheService).mockResolvedValue({ ok: true, data: mockCacheService as any }); + vi.setSystemTime(new Date(Date.now() + 61 * 60 * 1000)); + mockCacheService.get.mockResolvedValue({ ok: true, data: String(oldTime) }); + mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true }); + mockCacheService.del.mockResolvedValue({ ok: true, data: undefined }); + mockCacheService.set.mockResolvedValue({ ok: true, data: undefined }); + vi.mocked(prisma.organization.findFirst).mockResolvedValue({ + id: "org-123", + createdAt: new Date("2023-01-01"), + } as any); + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { + organizationCount: BigInt(1), + userCount: BigInt(1), + teamCount: BigInt(1), + workspaceCount: BigInt(1), + surveyCount: BigInt(0), + inProgressSurveyCount: BigInt(0), + completedSurveyCount: BigInt(0), + responseCountAllTime: BigInt(0), + responseCountSinceLastUpdate: BigInt(0), + displayCount: BigInt(0), + contactCount: BigInt(0), + segmentCount: BigInt(0), + newestResponseAt: null, + }, + ] as any); + vi.mocked(prisma.integration.findMany).mockResolvedValue([] as any); + vi.mocked(prisma.account.findMany).mockResolvedValue([] as any); + fetchMock.mockResolvedValue({ ok: true }); + + await freshSendTelemetryEvents(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(mockCacheService.set).toHaveBeenCalledWith("telemetry_last_sent_ts", expect.any(String)); + }); + + test("should not record a send when the usage update endpoint rejects it", async () => { + vi.resetModules(); + vi.doMock("@/lib/constants", () => ({ + E2E_TESTING: false, + IS_DEVELOPMENT: false, + TELEMETRY_DISABLED: false, + })); + vi.doMock("@/modules/ee/license-check/lib/license", () => ({ + getEnterpriseLicense: vi.fn().mockResolvedValue({ active: false }), + })); + const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./usage-update"); + + fetchMock.mockResolvedValue({ ok: false, status: 503 }); + + await freshSendTelemetryEvents(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + // A rejected update is a failure, not a send: the timestamp stays put so the next run retries. + expect(mockCacheService.set).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ message: "Usage update endpoint responded with status 503" }), + "Failed to send telemetry - applying 1h cooldown" + ); + expect(mockCacheService.del).toHaveBeenCalledWith(["telemetry_lock"]); }); test("should skip telemetry when TELEMETRY_DISABLED is true and no active EE license", async () => { @@ -316,7 +378,7 @@ describe("sendTelemetryEvents", () => { vi.doMock("@/modules/ee/license-check/lib/license", () => ({ getEnterpriseLicense: vi.fn().mockResolvedValue({ active: false }), })); - const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry"); + const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./usage-update"); await freshSendTelemetryEvents(); @@ -335,7 +397,7 @@ describe("sendTelemetryEvents", () => { vi.doMock("@/modules/ee/license-check/lib/license", () => ({ getEnterpriseLicense: vi.fn().mockResolvedValue({ active: true }), })); - const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry"); + const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./usage-update"); // Re-setup mocks after resetModules vi.mocked(getCacheService).mockResolvedValue({ @@ -388,7 +450,7 @@ describe("sendTelemetryEvents", () => { vi.doMock("@/modules/ee/license-check/lib/license", () => ({ getEnterpriseLicense: vi.fn().mockResolvedValue({ active: true }), })); - const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry"); + const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./usage-update"); await freshSendTelemetryEvents(); @@ -407,7 +469,7 @@ describe("sendTelemetryEvents", () => { vi.doMock("@/modules/ee/license-check/lib/license", () => ({ getEnterpriseLicense: vi.fn().mockResolvedValue({ active: true }), })); - const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry"); + const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./usage-update"); await freshSendTelemetryEvents(); diff --git a/apps/web/modules/response-pipeline/lib/telemetry.ts b/apps/web/lib/telemetry/usage-update.ts similarity index 76% rename from apps/web/modules/response-pipeline/lib/telemetry.ts rename to apps/web/lib/telemetry/usage-update.ts index 6357f8f40b93..534ec810f48e 100644 --- a/apps/web/modules/response-pipeline/lib/telemetry.ts +++ b/apps/web/lib/telemetry/usage-update.ts @@ -1,4 +1,4 @@ -import { createCacheKey, getCacheService } from "@formbricks/cache"; +import { type CacheService, createCacheKey, getCacheService } from "@formbricks/cache"; import { prisma } from "@formbricks/database"; import { IntegrationType } from "@formbricks/database/prisma"; import { logger } from "@formbricks/logger"; @@ -26,6 +26,10 @@ let nextTelemetryCheck = 0; * 1. In-memory check (fast, process-local) * 2. Redis check (shared across instances, persists across restarts) * 3. Distributed lock (prevents concurrent execution in multi-instance deployments) + * + * Called from two places, both of which rely on those checks for idempotency: the response pipeline + * (so an active instance reports as it is used) and the daily usage telemetry job (so an instance that + * collects no responses still reports at least once — see ENG-2107). */ // Hashed license key for log context — allows correlating log entries to a specific license // without exposing the raw key. Computed once at module load. @@ -41,6 +45,58 @@ const isTelemetryDisabledForCE = async (): Promise => { return !license.active; }; +/** + * Runs the actual send once every check has passed and the lock is held. Pulled out of + * `sendTelemetryEvents` so its try/catch/finally isn't nested inside that function's own try block — + * nesting is what pushed the caller over the cognitive-complexity threshold, not the branch count. + */ +const executeTelemetrySend = async (cache: CacheService, lastSent: number, now: number): Promise => { + try { + const sent = await sendTelemetry(lastSent); + + if (!sent) { + // Nothing was reported (no organization exists yet), so the 24h window must not be consumed: + // recording it here would delay the instance's *first* usage update by a day. + // + // The 1h value is a floor on the next attempt, not a scheduled retry — nothing re-invokes this + // hourly. It exists so the next real trigger is not blocked for 24h; those triggers are a + // processed response, the next 02:15 UTC tick, or that tick running overdue at the next boot. + // So on an instance with no response traffic the first usage update after an organization is + // created lands within a day, not within an hour. + logger.info( + { hashedLicenseKey }, + "Telemetry skipped - no organization to report on yet, not consuming the 24h window" + ); + nextTelemetryCheck = now + 60 * 60 * 1000; + return; + } + + // Success: Update Redis with current timestamp so other instances know telemetry was sent. + // No TTL - persists indefinitely to support low-volume instances (responses every few days/weeks). + await cache.set(TELEMETRY_LAST_SENT_KEY, now.toString()); + + // Update in-memory check to prevent this instance from checking again for 24h. + nextTelemetryCheck = now + TELEMETRY_INTERVAL_MS; + } catch (e) { + // Log as warning since telemetry is non-essential + const errorMessage = e instanceof Error ? e.message : String(e); + logger.warn( + { error: e, message: errorMessage, lastSent, now, hashedLicenseKey }, + "Failed to send telemetry - applying 1h cooldown" + ); + + // Failure cooldown: Prevent retrying immediately to avoid hammering the endpoint. + // Wait 1 hour before allowing this instance to try again. Like the no-organization case above + // this is a floor rather than a retry — the next attempt is whenever a trigger next calls in. + // Note: Other instances can still try (they'll hit the lock or Redis check). + nextTelemetryCheck = now + 60 * 60 * 1000; + } finally { + // Always release the lock, even if telemetry failed. + // This allows other instances to retry if this one failed. + await cache.del([TELEMETRY_LOCK_KEY]); + } +}; + export const sendTelemetryEvents = async () => { try { // ============================================================ @@ -124,32 +180,7 @@ export const sendTelemetryEvents = async () => { // EXECUTION: Send Telemetry // ============================================================ // We've passed all checks and acquired the lock. Now execute telemetry. - try { - await sendTelemetry(lastSent); - - // Success: Update Redis with current timestamp so other instances know telemetry was sent. - // No TTL - persists indefinitely to support low-volume instances (responses every few days/weeks). - await cache.set(TELEMETRY_LAST_SENT_KEY, now.toString()); - - // Update in-memory check to prevent this instance from checking again for 24h. - nextTelemetryCheck = now + TELEMETRY_INTERVAL_MS; - } catch (e) { - // Log as warning since telemetry is non-essential - const errorMessage = e instanceof Error ? e.message : String(e); - logger.warn( - { error: e, message: errorMessage, lastSent, now, hashedLicenseKey }, - "Failed to send telemetry - applying 1h cooldown" - ); - - // Failure cooldown: Prevent retrying immediately to avoid hammering the endpoint. - // Wait 1 hour before allowing this instance to try again. - // Note: Other instances can still try (they'll hit the lock or Redis check). - nextTelemetryCheck = now + 60 * 60 * 1000; - } finally { - // Always release the lock, even if telemetry failed. - // This allows other instances to retry if this one failed. - await cache.del([TELEMETRY_LOCK_KEY]); - } + await executeTelemetrySend(cache, lastSent, now); } catch (error) { // Catch-all for any unexpected errors in the wrapper logic (cache failures, lock issues, etc.) // Log as warning since telemetry is non-essential functionality @@ -164,12 +195,15 @@ export const sendTelemetryEvents = async () => { /** * Gathers telemetry data and sends it to Formbricks Enterprise endpoint. * @param lastSent - Timestamp of last telemetry send (used to calculate incremental metrics) + * @returns `true` when a usage update was accepted by the endpoint, `false` when there was nothing to + * report. Throws when the update could not be delivered, so the caller applies its failure cooldown + * instead of recording a send that never happened. */ -const sendTelemetry = async (lastSent: number) => { +const sendTelemetry = async (lastSent: number): Promise => { // Get the instance info (hashed oldest organization ID and creation date). // Using the oldest org ensures the ID doesn't change over time. const instanceInfo = await getInstanceInfo(); - if (!instanceInfo) return; // No organization exists, nothing to report + if (!instanceInfo) return false; // No organization exists, nothing to report const { instanceId, createdAt: instanceCreatedAt } = instanceInfo; @@ -295,14 +329,24 @@ const sendTelemetry = async (lastSent: number) => { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout - await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - signal: controller.signal, - }); + try { + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + + // A rejected update must not be recorded as sent, or the instance stays silent for another 24h + // while the license server still has no usage for it. + if (!res.ok) { + throw new Error(`Usage update endpoint responded with status ${res.status}`); + } + } finally { + clearTimeout(timeout); + } - clearTimeout(timeout); + return true; }; diff --git a/apps/web/modules/response-pipeline/lib/process-response-pipeline-job.test.ts b/apps/web/modules/response-pipeline/lib/process-response-pipeline-job.test.ts index 2c62bf7e0e39..ec9903e5dbe5 100644 --- a/apps/web/modules/response-pipeline/lib/process-response-pipeline-job.test.ts +++ b/apps/web/modules/response-pipeline/lib/process-response-pipeline-job.test.ts @@ -97,7 +97,7 @@ vi.mock("./handle-integrations", () => ({ handleIntegrations: mockHandleIntegrations, })); -vi.mock("./telemetry", () => ({ +vi.mock("@/lib/telemetry/usage-update", () => ({ sendTelemetryEvents: mockSendTelemetryEvents, })); diff --git a/apps/web/modules/response-pipeline/lib/process-response-pipeline-job.ts b/apps/web/modules/response-pipeline/lib/process-response-pipeline-job.ts index d1e85d395412..678be7a626c6 100644 --- a/apps/web/modules/response-pipeline/lib/process-response-pipeline-job.ts +++ b/apps/web/modules/response-pipeline/lib/process-response-pipeline-job.ts @@ -11,6 +11,7 @@ import { handleFeedbackSourcePipeline } from "@/lib/feedback-source/pipeline-han import { getIntegrations } from "@/lib/integration/service"; import { isDatabasePoolExhaustionError } from "@/lib/jobs/pool-exhaustion"; import { getResponseCountBySurveyId } from "@/lib/response/service"; +import { sendTelemetryEvents } from "@/lib/telemetry/usage-update"; import { createPinnedDispatcher, validateAndResolveWebhookUrl } from "@/lib/utils/validate-webhook-url"; import { queueAuditEventWithoutRequest } from "@/modules/ee/audit-logs/lib/handler"; import { type TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; @@ -24,7 +25,6 @@ import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up"; import { getFinishedResponseCountBySurveyId } from "@/modules/survey/lib/response"; import { handleIntegrations } from "./handle-integrations"; -import { sendTelemetryEvents } from "./telemetry"; const WEBHOOK_TIMEOUT_MS = 5_000; const DEFAULT_NOTIFICATION_LOCALE: TUserLocale = "en-US"; diff --git a/docker/README.md b/docker/README.md index 517b8e274f81..bd4adb0e5dc9 100644 --- a/docker/README.md +++ b/docker/README.md @@ -33,20 +33,24 @@ That's it! After running the command and providing the required information, vis The stack includes the [Formbricks Hub](https://github.com/formbricks/hub) API (`ghcr.io/formbricks/hub`) and the bundled Cube service. Hub and Cube share the same database as Formbricks by default and both start as part of the baseline `docker compose up`. - **Migrations**: A `formbricks-migrate` service runs Formbricks Prisma migrations before `hub-migrate` writes Hub tables to the shared database. `hub-migrate` then runs Hub's database migrations (goose + river) before the Hub API starts. Both migration services run on every `docker compose up` and are idempotent. -- **Production** (`docker/docker-compose.yml`): Set non-empty `HUB_API_KEY` and - `CUBEJS_API_SECRET` in `.env` before starting the stack. `docker compose config >/dev/null` validates - Compose syntax, but missing secrets are reported by the service that needs them at startup. - `HUB_API_URL` defaults to `http://hub:8080` and `CUBEJS_API_URL` defaults to `http://cube:4000` so the - Formbricks app reaches Hub and Cube inside the Compose network. Cube JWT issuer/audience default to - `formbricks-web` and `formbricks-cube`, and the bundled Cube service exposes only `meta,data` API - scopes. The bundled single-replica Cube uses in-memory cache and queue storage and defaults - `CUBEJS_EXTERNAL_DEFAULT` to `false`, so it does not require Cube Store. If you add external - pre-aggregations, configure Cube Store before overriding `CUBEJS_EXTERNAL_DEFAULT=true`. Override - `HUB_DATABASE_URL` and `CUBEJS_DB_*` only if Hub or Cube should use a separate database. The Hub image - tracks `:latest` by default so `formbricks.sh update` advances Hub in lockstep with the app. `hub` and - `hub-migrate` always resolve to the same image. To pin to an immutable reference, set `HUB_IMAGE_REF` - in `docker/.env` to either a tag (e.g. `:0.3.0`) or a digest - (e.g. `@sha256:14db7b3d...`). +- **Production** (`docker/docker-compose.yml`): Set `POSTGRES_PASSWORD` to a unique random value and set + non-empty `HUB_API_KEY`, `CUBEJS_API_SECRET`, `AUTHZED_TOKEN`, and `AUTHZED_DATABASE_PASSWORD` values in + `.env` before starting the stack. Keep + `POSTGRES_PASSWORD` unchanged after the database volume has been initialized. The installer also writes a + URL-encoded companion for connection strings. Manual installs only need to set + `POSTGRES_PASSWORD_URL_ENCODED` when the raw password contains URI-reserved characters; existing URL-safe + passwords continue to work through the raw-value fallback. The `docker compose config >/dev/null` command + validates Compose syntax and fails when `POSTGRES_PASSWORD` is missing; other missing secrets are reported by + the service that needs them at startup. `HUB_API_URL` defaults to `http://hub:8080` and `CUBEJS_API_URL` + defaults to `http://cube:4000` so the Formbricks app reaches Hub and Cube inside the Compose network. Cube JWT + issuer/audience default to `formbricks-web` and `formbricks-cube`, and the bundled Cube service exposes only + `meta,data` API scopes. The bundled single-replica Cube uses in-memory cache and queue storage and defaults + `CUBEJS_EXTERNAL_DEFAULT` to `false`, so it does not require Cube Store. If you add external pre-aggregations, + configure Cube Store before overriding `CUBEJS_EXTERNAL_DEFAULT=true`. Override `HUB_DATABASE_URL` and + `CUBEJS_DB_*` only if Hub or Cube should use a separate database. The Hub image tracks `:latest` by default so + `formbricks.sh update` advances Hub in lockstep with the app. `hub` and `hub-migrate` always resolve to the same + image. To pin to an immutable reference, set `HUB_IMAGE_REF` in `docker/.env` to either a tag (e.g. `:0.3.0`) + or a digest (e.g. `@sha256:14db7b3d...`). - **Existing production installs**: Pulling new images does not replace an existing `docker-compose.yml`. Add `CUBEJS_EXTERNAL_DEFAULT: ${CUBEJS_EXTERNAL_DEFAULT:-false}` to the Cube service's `environment` block, then run `docker compose up -d --no-deps --force-recreate cube`. diff --git a/docker/__tests__/formbricks-script.test.ts b/docker/__tests__/formbricks-script.test.ts index 685c9a40dab4..a10dd8c916b6 100644 --- a/docker/__tests__/formbricks-script.test.ts +++ b/docker/__tests__/formbricks-script.test.ts @@ -11,7 +11,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, test } from "vitest"; @@ -23,6 +23,24 @@ const multiArchValkeyImage = "valkey/valkey@sha256:e0eb7c480958d32bdc4357a74bdd70653ae15f2f9b4c93c4a5a9fad1dc471c84"; const tempDirs: string[] = []; +const dockerComposeOverrideKeys = [ + "POSTGRES_PASSWORD", + "POSTGRES_PASSWORD_URL_ENCODED", + "HUB_DATABASE_URL", + "CUBEJS_DB_PASS", + "AUTHZED_TOKEN", + "AUTHZED_DATABASE_PASSWORD", + "FORMBRICKS_UNSET_PASSWORD_SENTINEL", +]; +const dockerComposeTestTimeout = 30_000; + +const dockerComposeTest = (name: string, testFunction: () => void): void => { + test(name, testFunction, dockerComposeTestTimeout); +}; + +type RenderedDockerComposeConfig = { + services: Record }>; +}; const createTempDir = (): string => { const tempDir = mkdtempSync(join(tmpdir(), "formbricks-script-")); @@ -70,6 +88,167 @@ const writeDockerComposeTemplate = (): string => { return composePath; }; +const getDockerComposeProcessEnv = (overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv => { + const environment = { ...process.env }; + + for (const key of dockerComposeOverrideKeys) { + delete environment[key]; + } + + return { ...environment, ...overrides }; +}; + +const runDockerCompose = (args: string[], environment: NodeJS.ProcessEnv = {}): string => { + const result = spawnSync("docker", ["compose", ...args], { + encoding: "utf8", + env: getDockerComposeProcessEnv(environment), + timeout: 20_000, + }); + + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(result.stderr.trim() || `Docker Compose exited with status ${result.status}`); + } + + return result.stdout; +}; + +const withRequiredComposeEnv = (envContents: string): string => { + const requiredValues = [ + ["AUTHZED_TOKEN", "test-authzed-token"], + ["AUTHZED_DATABASE_PASSWORD", "test-authzed-database-password"], + ]; + const missingValues = requiredValues + .filter(([key]) => !new RegExp(`^${key}=`, "m").test(envContents)) + .map(([key, value]) => `${key}=${value}`) + .join("\n"); + + return [envContents.trimEnd(), missingValues].filter(Boolean).join("\n") + "\n"; +}; + +const writeDockerComposeFixture = (envContents: string): { composePath: string; envPath: string } => { + const composePath = writeDockerComposeTemplate(); + const envPath = join(dirname(composePath), ".env"); + + writeFileSync(envPath, withRequiredComposeEnv(envContents)); + + return { composePath, envPath }; +}; + +const renderDockerCompose = ( + envContents: string, + processEnvironment: NodeJS.ProcessEnv = {} +): RenderedDockerComposeConfig => { + const { composePath, envPath } = writeDockerComposeFixture(envContents); + + return JSON.parse( + runDockerCompose( + [ + "--env-file", + envPath, + "--profile", + "authzed-ops", + "-f", + composePath, + "--project-directory", + dirname(composePath), + "config", + "--format", + "json", + ], + processEnvironment + ) + ) as RenderedDockerComposeConfig; +}; + +const getRenderedServiceEnvironment = ( + config: RenderedDockerComposeConfig, + serviceName: string +): Record => { + const environment = config.services[serviceName]?.environment; + + expect(environment).toBeDefined(); + return environment ?? {}; +}; + +const getRenderedDockerComposeEnvironment = ( + composePath: string, + envPath: string, + processEnvironment: NodeJS.ProcessEnv = {} +): string => + runDockerCompose( + [ + "--env-file", + envPath, + "-f", + composePath, + "--project-directory", + dirname(composePath), + "config", + "--environment", + ], + processEnvironment + ); + +const writeGeneratedEnvFile = (envPath: string, postgresPassword = ""): void => { + execFileSync( + "bash", + [ + "-lc", + 'source "$1"; write_generated_env_file "$2" "$3"', + "bash", + formbricksScriptPath, + envPath, + postgresPassword, + ], + { encoding: "utf8" } + ); +}; + +const readExistingPostgresPassword = ( + envPath: string, + composePath: string, + processEnvironment: NodeJS.ProcessEnv = {} +): string => { + const result = spawnSync( + "bash", + [ + "-c", + 'source "$1"; read_existing_postgres_password "$2" "$3"', + "bash", + formbricksScriptPath, + envPath, + composePath, + ], + { + encoding: "utf8", + env: getDockerComposeProcessEnv(processEnvironment), + timeout: 20_000, + } + ); + + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(result.stderr.trim() || `Password discovery exited with status ${result.status}`); + } + + return result.stdout; +}; + +const getDotenvValue = (envContents: string, key: string): string => { + const value = envContents + .split("\n") + .find((line) => line.startsWith(`${key}=`)) + ?.slice(key.length + 1); + + expect(value).toBeDefined(); + return value ?? ""; +}; + const migrateLegacyValkeyImage = (composePath: string, validationResult: "success" | "failure"): string => { const validationLogPath = join(createTempDir(), "validation.log"); @@ -126,6 +305,379 @@ describe("docker/docker-compose.yml Cube configuration", () => { }); }); +describe("Docker self-hosting credentials", () => { + dockerComposeTest("rejects a missing PostgreSQL password", () => { + expect(() => renderDockerCompose("")).toThrow(/POSTGRES_PASSWORD.*missing a value/); + }); + + dockerComposeTest("renders a URL-safe PostgreSQL password into every bundled database client", () => { + const password = "url-safe-password"; + const config = renderDockerCompose(`POSTGRES_PASSWORD=${password}\n`); + const formbricksDatabaseUrl = `postgresql://postgres:${password}@postgres:5432/formbricks?schema=public`; + const hubDatabaseUrl = `postgresql://postgres:${password}@postgres:5432/formbricks?sslmode=disable`; + const postgresAdminUrl = `postgresql://postgres:${password}@postgres:5432/postgres?sslmode=disable`; + + expect(getRenderedServiceEnvironment(config, "postgres").POSTGRES_PASSWORD).toBe(password); + expect(getRenderedServiceEnvironment(config, "authzed-db-bootstrap").POSTGRES_ADMIN_URL).toBe( + postgresAdminUrl + ); + expect(getRenderedServiceEnvironment(config, "formbricks-migrate").DATABASE_URL).toBe( + formbricksDatabaseUrl + ); + expect(getRenderedServiceEnvironment(config, "formbricks").DATABASE_URL).toBe(formbricksDatabaseUrl); + expect(getRenderedServiceEnvironment(config, "authzed-ops").DATABASE_URL).toBe(formbricksDatabaseUrl); + expect(getRenderedServiceEnvironment(config, "authzed-initialize").DATABASE_URL).toBe( + formbricksDatabaseUrl + ); + expect(getRenderedServiceEnvironment(config, "hub-migrate").DATABASE_URL).toBe(hubDatabaseUrl); + expect(getRenderedServiceEnvironment(config, "hub").DATABASE_URL).toBe(hubDatabaseUrl); + expect(getRenderedServiceEnvironment(config, "cube").CUBEJS_DB_PASS).toBe(password); + }); + + dockerComposeTest("renders encoded connection URLs while retaining the raw PostgreSQL password", () => { + const rawPassword = "legacy:p@ss/word?#"; + const encodedPassword = "legacy%3Ap%40ss%2Fword%3F%23"; + const config = renderDockerCompose( + `POSTGRES_PASSWORD=${rawPassword}\nPOSTGRES_PASSWORD_URL_ENCODED=${encodedPassword}\n` + ); + const formbricksDatabaseUrl = `postgresql://postgres:${encodedPassword}@postgres:5432/formbricks?schema=public`; + const hubDatabaseUrl = `postgresql://postgres:${encodedPassword}@postgres:5432/formbricks?sslmode=disable`; + const postgresAdminUrl = `postgresql://postgres:${encodedPassword}@postgres:5432/postgres?sslmode=disable`; + + expect(getRenderedServiceEnvironment(config, "postgres").POSTGRES_PASSWORD).toBe(rawPassword); + expect(getRenderedServiceEnvironment(config, "authzed-db-bootstrap").POSTGRES_ADMIN_URL).toBe( + postgresAdminUrl + ); + expect(getRenderedServiceEnvironment(config, "formbricks-migrate").DATABASE_URL).toBe( + formbricksDatabaseUrl + ); + expect(getRenderedServiceEnvironment(config, "formbricks").DATABASE_URL).toBe(formbricksDatabaseUrl); + expect(getRenderedServiceEnvironment(config, "authzed-ops").DATABASE_URL).toBe(formbricksDatabaseUrl); + expect(getRenderedServiceEnvironment(config, "authzed-initialize").DATABASE_URL).toBe( + formbricksDatabaseUrl + ); + expect(getRenderedServiceEnvironment(config, "hub-migrate").DATABASE_URL).toBe(hubDatabaseUrl); + expect(getRenderedServiceEnvironment(config, "hub").DATABASE_URL).toBe(hubDatabaseUrl); + expect(getRenderedServiceEnvironment(config, "cube").CUBEJS_DB_PASS).toBe(rawPassword); + }); + + test("writes generated credentials to a private local environment file", () => { + const tempDir = createTempDir(); + const envPath = join(tempDir, ".env"); + const secondEnvPath = join(tempDir, ".env.second"); + + writeGeneratedEnvFile(envPath); + writeGeneratedEnvFile(secondEnvPath); + + const envContents = readFileSync(envPath, "utf8"); + const secondEnvContents = readFileSync(secondEnvPath, "utf8"); + const serializedPostgresPassword = getDotenvValue(envContents, "POSTGRES_PASSWORD"); + const serializedSecondPostgresPassword = getDotenvValue(secondEnvContents, "POSTGRES_PASSWORD"); + const postgresPassword = serializedPostgresPassword.slice(1, -1); + const secondPostgresPassword = serializedSecondPostgresPassword.slice(1, -1); + + expect(serializedPostgresPassword).toMatch(/^"[a-f0-9]{64}"$/); + expect(serializedSecondPostgresPassword).toMatch(/^"[a-f0-9]{64}"$/); + expect(envContents).toContain(`POSTGRES_PASSWORD_URL_ENCODED=${postgresPassword}`); + expect(envContents).toMatch(/^HUB_API_KEY=[a-f0-9]{64}$/m); + expect(envContents).toMatch(/^CUBEJS_API_SECRET=[a-f0-9]{64}$/m); + expect(envContents).toMatch(/^AUTHZED_TOKEN=[a-f0-9]{64}$/m); + expect(envContents).toMatch(/^AUTHZED_DATABASE_PASSWORD=[a-f0-9]{64}$/m); + expect(envContents).toContain(` +CUBEJS_JWT_ISSUER=formbricks-web +CUBEJS_JWT_AUDIENCE=formbricks-cube +`); + expect(secondPostgresPassword).not.toBe(postgresPassword); + expect(statSync(envPath).mode & 0o777).toBe(0o600); + expect(statSync(secondEnvPath).mode & 0o777).toBe(0o600); + }); + + test("leaves the existing password empty when no deployment artifacts exist", () => { + const tempDir = createTempDir(); + + expect(readExistingPostgresPassword(join(tempDir, ".env"), join(tempDir, "docker-compose.yml"))).toBe(""); + }); + + test("reuses sudo Docker Compose access selected by installer preflight", () => { + const tempDir = createTempDir(); + const binPath = join(tempDir, "bin"); + const dockerPath = join(binPath, "docker"); + const sudoPath = join(binPath, "sudo"); + const callLogPath = join(tempDir, "docker-calls.log"); + const envPath = join(tempDir, ".env"); + const composePath = join(tempDir, "docker-compose.yml"); + + mkdirSync(binPath); + writeFileSync(envPath, "POSTGRES_PASSWORD=legacy-password\n"); + writeFileSync(composePath, "services:\n postgres:\n image: pgvector/pgvector:pg18\n"); + writeFileSync( + dockerPath, + `#!/bin/sh +printf 'docker %s\\n' "$*" >> "$FORMBRICKS_DOCKER_CALL_LOG" +exit 1 +` + ); + writeFileSync( + sudoPath, + `#!/bin/sh +printf 'sudo %s\\n' "$*" >> "$FORMBRICKS_DOCKER_CALL_LOG" +if [ "$1 $2" = "docker info" ]; then + exit 0 +fi +if [ "$1 $2 $3" = "docker compose version" ]; then + exit 0 +fi +if [ "$1 $2" = "docker compose" ]; then + printf '%s' '{"services":{"postgres":{"environment":{"POSTGRES_PASSWORD":"legacy-password"}}}}' + exit 0 +fi +exit 1 +` + ); + chmodSync(dockerPath, 0o755); + chmodSync(sudoPath, 0o755); + + const recoveredPassword = execFileSync( + "bash", + [ + "-c", + 'source "$1"; configure_formbricks_docker_command; run_formbricks_docker_compose version >/dev/null; read_existing_postgres_password "$2" "$3"', + "bash", + formbricksScriptPath, + envPath, + composePath, + ], + { + encoding: "utf8", + env: getDockerComposeProcessEnv({ + FORMBRICKS_DOCKER_CALL_LOG: callLogPath, + PATH: `${binPath}:${process.env.PATH ?? ""}`, + }), + } + ); + const callLog = readFileSync(callLogPath, "utf8"); + + expect(recoveredPassword).toBe("legacy-password"); + expect(callLog).toContain("docker info"); + expect(callLog).toContain("sudo docker info"); + expect(callLog).toContain("sudo docker compose version"); + expect(callLog).toContain("sudo docker compose --env-file"); + }); + + dockerComposeTest("preserves and URL-encodes the password from an existing one-click installation", () => { + const tempDir = createTempDir(); + const envPath = join(tempDir, ".env"); + const composePath = join(tempDir, "docker-compose.yml"); + + writeFileSync( + composePath, + `services: + postgres: + image: pgvector/pgvector:pg18 + environment: + - POSTGRES_PASSWORD=legacy:p@ss/word?# +` + ); + writeFileSync(envPath, "HUB_API_KEY=legacy-hub-key\nCUSTOM_SETTING=preserved\n"); + + const existingPassword = readExistingPostgresPassword(envPath, composePath); + writeGeneratedEnvFile(envPath, existingPassword); + + const envContents = readFileSync(envPath, "utf8"); + + expect(existingPassword).toBe("legacy:p@ss/word?#"); + expect(envContents).toContain('POSTGRES_PASSWORD="legacy:p@ss/word?#"'); + expect(envContents).toContain("POSTGRES_PASSWORD_URL_ENCODED=legacy%3Ap%40ss%2Fword%3F%23"); + expect(envContents).toContain("CUSTOM_SETTING=preserved"); + }); + + dockerComposeTest("uses Docker Compose semantics to resolve existing dotenv passwords", () => { + const cases = [ + { + envContents: "POSTGRES_PASSWORD: 'legacy$$value'\n", + expectedPassword: "legacy$$value", + }, + { + envContents: 'POSTGRES_PASSWORD="legacy\\\\path"\n', + expectedPassword: "legacy\\path", + }, + { + envContents: "POSTGRES_PASSWORD=legacy-password # operator note\n", + expectedPassword: "legacy-password", + }, + { + envContents: "PASSWORD_SUFFIX=word\nPOSTGRES_PASSWORD=prefix-${PASSWORD_SUFFIX}\n", + expectedPassword: "prefix-word", + }, + ]; + + for (const { envContents, expectedPassword } of cases) { + const composePath = writeDockerComposeTemplate(); + const envPath = join(dirname(composePath), ".env"); + + writeFileSync(envPath, withRequiredComposeEnv(envContents)); + + expect(readExistingPostgresPassword(envPath, composePath)).toBe(expectedPassword); + } + }); + + dockerComposeTest("reads legacy Compose passwords through the rendered configuration", () => { + const tempDir = createTempDir(); + const envPath = join(tempDir, ".env"); + const composePath = join(tempDir, "docker-compose.yml"); + const missingComposePath = join(tempDir, "missing-compose.yml"); + const writeLegacyComposePassword = (passwordLine: string): void => { + writeFileSync( + composePath, + `services: + postgres: + image: pgvector/pgvector:pg18 + environment: + ${passwordLine} +` + ); + }; + + writeFileSync(envPath, "POSTGRES_PASSWORD='legacy-password'\n"); + expect(() => readExistingPostgresPassword(envPath, missingComposePath)).toThrow( + /Could not safely resolve/ + ); + rmSync(envPath); + + writeLegacyComposePassword("- POSTGRES_PASSWORD=legacy:p@ss/word?#!&'()*;[]"); + expect(readExistingPostgresPassword(envPath, composePath)).toBe("legacy:p@ss/word?#!&'()*;[]"); + + const quotedPassword = `legacy:p@ss/word?#!&'()*;[]\\path"quoted" `; + writeLegacyComposePassword(`POSTGRES_PASSWORD: ${JSON.stringify(quotedPassword)}`); + expect(readExistingPostgresPassword(envPath, composePath)).toBe(quotedPassword); + + writeLegacyComposePassword("- POSTGRES_PASSWORD=legacy$$PASSWORD_SENTINEL"); + expect(readExistingPostgresPassword(envPath, composePath)).toBe("legacy$PASSWORD_SENTINEL"); + + writeLegacyComposePassword("- POSTGRES_PASSWORD=$FORMBRICKS_UNSET_PASSWORD_SENTINEL"); + const unresolvedConfig = JSON.parse( + runDockerCompose([ + "-f", + composePath, + "--project-directory", + dirname(composePath), + "config", + "--format", + "json", + ]) + ) as RenderedDockerComposeConfig; + + expect(getRenderedServiceEnvironment(unresolvedConfig, "postgres").POSTGRES_PASSWORD).toBe(""); + expect(() => readExistingPostgresPassword(envPath, composePath)).toThrow(/Could not safely resolve/); + + writeLegacyComposePassword("- POSTGRES_PASSWORD=legacy-password # operator note"); + expect(readExistingPostgresPassword(envPath, composePath)).toBe("legacy-password"); + + writeLegacyComposePassword(`POSTGRES_PASSWORD: ${JSON.stringify("legacy\npassword")}`); + expect(() => readExistingPostgresPassword(envPath, composePath)).toThrow(/Could not safely resolve/); + }); + + dockerComposeTest("preserves unrelated environment entries and literal dollar signs across reruns", () => { + const composePath = writeDockerComposeTemplate(); + const envPath = join(dirname(composePath), ".env"); + const password = "legacy$PASSWORD_SENTINEL"; + + writeFileSync( + envPath, + withRequiredComposeEnv(`# Operator-managed settings +PUBLIC_URL=https://surveys.example.com +CUSTOM_SECRET=keep-me +POSTGRES_PASSWORD='legacy$PASSWORD_SENTINEL' +export HUB_API_KEY=replace-me +`) + ); + + const quotedExistingPassword = readExistingPostgresPassword(envPath, composePath, { + PASSWORD_SENTINEL: "rewritten", + }); + writeGeneratedEnvFile(envPath, quotedExistingPassword); + + const firstEnvContents = readFileSync(envPath, "utf8"); + const renderedEnvironment = getRenderedDockerComposeEnvironment(composePath, envPath, { + PASSWORD_SENTINEL: "rewritten", + }); + const renderedConfig = renderDockerCompose(firstEnvContents, { + PASSWORD_SENTINEL: "rewritten", + }); + const renderedPostgresPassword = getRenderedServiceEnvironment( + renderedConfig, + "postgres" + ).POSTGRES_PASSWORD.replaceAll("$$", "$"); + const renderedCubePassword = getRenderedServiceEnvironment( + renderedConfig, + "cube" + ).CUBEJS_DB_PASS.replaceAll("$$", "$"); + const encodedPassword = "legacy%24PASSWORD_SENTINEL"; + + expect(quotedExistingPassword).toBe(password); + expect(firstEnvContents).toContain("# Operator-managed settings"); + expect(firstEnvContents).toContain("PUBLIC_URL=https://surveys.example.com"); + expect(firstEnvContents).toContain("CUSTOM_SECRET=keep-me"); + expect(firstEnvContents).not.toContain("HUB_API_KEY=replace-me"); + expect(firstEnvContents).toContain('POSTGRES_PASSWORD="legacy$$PASSWORD_SENTINEL"'); + expect(firstEnvContents).toContain(`POSTGRES_PASSWORD_URL_ENCODED=${encodedPassword}`); + expect(firstEnvContents).toContain("AUTHZED_TOKEN=test-authzed-token"); + expect(firstEnvContents).toContain("AUTHZED_DATABASE_PASSWORD=test-authzed-database-password"); + expect(getDotenvValue(renderedEnvironment, "POSTGRES_PASSWORD")).toBe(password); + expect(renderedPostgresPassword).toBe(password); + expect(renderedCubePassword).toBe(password); + expect(getRenderedServiceEnvironment(renderedConfig, "formbricks").DATABASE_URL).toBe( + `postgresql://postgres:${encodedPassword}@postgres:5432/formbricks?schema=public` + ); + expect(getRenderedServiceEnvironment(renderedConfig, "hub").DATABASE_URL).toBe( + `postgresql://postgres:${encodedPassword}@postgres:5432/formbricks?sslmode=disable` + ); + + const existingPassword = readExistingPostgresPassword(envPath, composePath, { + PASSWORD_SENTINEL: "rewritten", + }); + writeGeneratedEnvFile(envPath, existingPassword); + + const rerunEnvContents = readFileSync(envPath, "utf8"); + + expect(existingPassword).toBe(password); + expect(rerunEnvContents).toContain("# Operator-managed settings"); + expect(rerunEnvContents).toContain("PUBLIC_URL=https://surveys.example.com"); + expect(rerunEnvContents).toContain("CUSTOM_SECRET=keep-me"); + expect(rerunEnvContents.match(/^POSTGRES_PASSWORD=/gm)).toHaveLength(1); + expect(rerunEnvContents.match(/^HUB_API_KEY=/gm)).toHaveLength(1); + expect(rerunEnvContents.match(/^AUTHZED_TOKEN=/gm)).toHaveLength(1); + expect(rerunEnvContents.match(/^AUTHZED_DATABASE_PASSWORD=/gm)).toHaveLength(1); + expect(statSync(envPath).mode & 0o777).toBe(0o600); + }); + + dockerComposeTest("serializes preserved passwords without dotenv reinterpretation", () => { + const composePath = writeDockerComposeTemplate(); + const envPath = join(dirname(composePath), ".env"); + const password = 'my secret #1 a\\\'b "quoted" $PASSWORD_SENTINEL '; + const encodedPassword = "my%20secret%20%231%20a%5C%27b%20%22quoted%22%20%24PASSWORD_SENTINEL%20"; + + writeGeneratedEnvFile(envPath, password); + + const firstEnvContents = readFileSync(envPath, "utf8"); + const firstReadPassword = readExistingPostgresPassword(envPath, composePath, { + PASSWORD_SENTINEL: "rewritten", + }); + + expect(firstReadPassword).toBe(password); + expect(getDotenvValue(firstEnvContents, "POSTGRES_PASSWORD")).toMatch(/^".*"$/); + expect(getDotenvValue(firstEnvContents, "POSTGRES_PASSWORD_URL_ENCODED")).toBe(encodedPassword); + + writeGeneratedEnvFile(envPath, firstReadPassword); + + expect( + readExistingPostgresPassword(envPath, composePath, { + PASSWORD_SENTINEL: "rewritten", + }) + ).toBe(password); + }); +}); + describe("docker/formbricks.sh AuthZed setup", () => { test("writes AuthZed secrets without printing them", () => { const envPath = join(createTempDir(), ".env"); diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 043fac007a6e..7e700856dc96 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,5 +1,5 @@ x-formbricks-database: &formbricks-database - DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/formbricks?schema=public" + DATABASE_URL: "postgresql://postgres:${POSTGRES_PASSWORD_URL_ENCODED:-${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}}@postgres:5432/formbricks?schema=public" x-environment: &environment environment: &app-environment @@ -165,7 +165,7 @@ x-environment: &environment # OIDC_SIGNING_ALGORITHM: # Set the below to SAML Provider if you want to enable SAML - # SAML_DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/formbricks-saml?sslmode=disable" + # SAML_DATABASE_URL: "postgresql://:@postgres:5432/formbricks-saml?sslmode=disable" ########################################## OPTIONAL (THIRD PARTY INTEGRATIONS) ########################################### @@ -246,8 +246,8 @@ services: volumes: - postgres:/var/lib/postgresql environment: - - POSTGRES_PASSWORD=postgres - - POSTGRES_DB=formbricks + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} + POSTGRES_DB: formbricks healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres || exit 1"] interval: 5s @@ -260,7 +260,7 @@ services: restart: "no" entrypoint: ["/bin/sh", "/usr/local/bin/authzed-postgres-bootstrap.sh"] environment: - POSTGRES_ADMIN_URL: postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable + POSTGRES_ADMIN_URL: postgresql://postgres:${POSTGRES_PASSWORD_URL_ENCODED:-${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}}@postgres:5432/postgres?sslmode=disable AUTHZED_DATABASE_NAME: spicedb AUTHZED_DATABASE_USERNAME: spicedb AUTHZED_DATABASE_PASSWORD: ${AUTHZED_DATABASE_PASSWORD:?AUTHZED_DATABASE_PASSWORD is required} @@ -438,7 +438,7 @@ services: 'if [ -x /usr/local/bin/goose ] && [ -x /usr/local/bin/river ]; then /usr/local/bin/goose -dir /app/migrations postgres "$$DATABASE_URL" up && /usr/local/bin/river migrate-up --database-url "$$DATABASE_URL"; else echo ''Migration tools (goose/river) not in image.''; exit 1; fi', ] environment: - DATABASE_URL: ${HUB_DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/formbricks?sslmode=disable} + DATABASE_URL: ${HUB_DATABASE_URL:-postgresql://postgres:${POSTGRES_PASSWORD_URL_ENCODED:-${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}}@postgres:5432/formbricks?sslmode=disable} depends_on: formbricks-migrate: condition: service_completed_successfully @@ -454,7 +454,7 @@ services: condition: service_healthy environment: API_KEY: ${HUB_API_KEY:-} - DATABASE_URL: ${HUB_DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/formbricks?sslmode=disable} + DATABASE_URL: ${HUB_DATABASE_URL:-postgresql://postgres:${POSTGRES_PASSWORD_URL_ENCODED:-${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}}@postgres:5432/formbricks?sslmode=disable} TAXONOMY_SERVICE_URL: ${TAXONOMY_SERVICE_URL:-} TAXONOMY_SERVICE_TOKEN: ${TAXONOMY_SERVICE_TOKEN:-} HUB_INTERNAL_API_TOKEN: ${HUB_INTERNAL_API_TOKEN:-} @@ -571,7 +571,7 @@ services: CUBEJS_DB_HOST: ${CUBEJS_DB_HOST:-postgres} CUBEJS_DB_NAME: ${CUBEJS_DB_NAME:-formbricks} CUBEJS_DB_USER: ${CUBEJS_DB_USER:-postgres} - CUBEJS_DB_PASS: ${CUBEJS_DB_PASS:-postgres} + CUBEJS_DB_PASS: ${CUBEJS_DB_PASS:-${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}} CUBEJS_DB_PORT: ${CUBEJS_DB_PORT:-5432} CUBEJS_API_SECRET: ${CUBEJS_API_SECRET:-} CUBEJS_JWT_ISSUER: ${CUBEJS_JWT_ISSUER:-formbricks-web} diff --git a/docker/formbricks.sh b/docker/formbricks.sh index eb385c897bc8..f5ae4b2a7807 100755 --- a/docker/formbricks.sh +++ b/docker/formbricks.sh @@ -165,6 +165,197 @@ write_rustfs_env_file() { upsert_dotenv_var "FORMBRICKS_RUSTFS_REGION" "us-east-1" "$env_file" } +formbricks_docker_command=(docker) + +configure_formbricks_docker_command() { + if docker info >/dev/null 2>&1; then + formbricks_docker_command=(docker) + return + fi + + if command -v sudo >/dev/null 2>&1 && sudo docker info >/dev/null 2>&1; then + formbricks_docker_command=(sudo docker) + return + fi + + return 1 +} + +run_formbricks_docker_compose() { + "${formbricks_docker_command[@]}" compose "$@" +} + +read_rendered_compose_password() { + local compose_file="$1" + local env_file="${2:-}" + local encoded_password + local rendered_config + local rendered_password + local without_escaped_dollars + local compose_args=(-f "$compose_file") + + if [ -n "$env_file" ]; then + compose_args=(--env-file "$env_file" "${compose_args[@]}") + fi + + if ! command -v docker >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then + return 1 + fi + + rendered_config=$( + unset POSTGRES_PASSWORD POSTGRES_PASSWORD_URL_ENCODED + run_formbricks_docker_compose "${compose_args[@]}" config --format json 2>/dev/null + ) || return 1 + encoded_password=$(printf '%s' "$rendered_config" | jq -er ' + .services.postgres.environment.POSTGRES_PASSWORD + | select(type == "string" and length > 0) + | select((contains("\n") or contains("\r")) | not) + | @base64 + ') || return 1 + + rendered_password=$(printf '%s' "$encoded_password" | base64 --decode) || return 1 + # Compose doubles literal dollar signs in its rendered model so that the model can be parsed again. + without_escaped_dollars=${rendered_password//\$\$/} + if [[ "$without_escaped_dollars" == *\$* ]]; then + return 1 + fi + + printf '%s' "${rendered_password//\$\$/\$}" +} + +read_existing_postgres_password() { + local env_file="${1:-.env}" + local compose_file="${2:-docker-compose.yml}" + local existing_password + + if [ -f "$env_file" ]; then + if [ ! -f "$compose_file" ]; then + echo "❌ Could not safely resolve the existing PostgreSQL password. Refusing to rewrite $env_file." >&2 + return 1 + fi + + if existing_password=$(read_rendered_compose_password "$compose_file" "$env_file"); then + printf '%s' "$existing_password" + return + fi + + echo "❌ Could not safely resolve the existing PostgreSQL password. Refusing to rewrite $env_file." >&2 + return 1 + fi + + if [ -f "$compose_file" ]; then + if existing_password=$(read_rendered_compose_password "$compose_file"); then + printf '%s' "$existing_password" + return + fi + + echo "❌ Could not safely resolve the existing PostgreSQL password. Refusing to rewrite .env." >&2 + return 1 + fi + + return 0 +} + +url_encode() { + local LC_ALL=C + local value="$1" + local encoded="" + local char + local byte + local i + + for ((i = 0; i < ${#value}; i++)); do + char=${value:i:1} + case "$char" in + [a-zA-Z0-9.~_-]) encoded+="$char" ;; + *) + printf -v byte '%d' "'$char" + printf -v encoded '%s%%%02X' "$encoded" "$((byte & 255))" + ;; + esac + done + + printf '%s' "$encoded" +} + +serialize_dotenv_value() { + local value="$1" + + value=${value//\\/\\\\} + value=${value//\"/\\\"} + value=${value//\$/\$\$} + + printf '"%s"' "$value" +} + +write_generated_env_file() ( + local env_file="${1:-.env}" + local postgres_password="${2:-}" + local hub_api_key="${3:-}" + local cubejs_api_secret="${4:-}" + local authzed_token="${5:-}" + local authzed_database_password="${6:-}" + local serialized_postgres_password + local postgres_password_url_encoded + local tmp_file + + append_if_missing() { + local key="$1" + local value="$2" + + if ! grep -Eq "^[[:space:]]*(export[[:space:]]+)?${key}[[:space:]]*=" "$tmp_file"; then + printf '%s=%s\n' "$key" "$value" >>"$tmp_file" + fi + } + + umask 077 + if [ -z "$postgres_password" ]; then + postgres_password=$(openssl rand -hex 32) + fi + if [ -z "$hub_api_key" ]; then + hub_api_key=$(openssl rand -hex 32) + fi + if [ -z "$cubejs_api_secret" ]; then + cubejs_api_secret=$(openssl rand -hex 32) + fi + if [ -z "$authzed_token" ]; then + authzed_token=$(openssl rand -hex 32) + fi + if [ -z "$authzed_database_password" ]; then + authzed_database_password=$(openssl rand -hex 32) + fi + serialized_postgres_password=$(serialize_dotenv_value "$postgres_password") + postgres_password_url_encoded=$(url_encode "$postgres_password") + + tmp_file=$(mktemp "${env_file}.tmp.XXXXXX") + trap 'rm -f "$tmp_file"' EXIT + + if [ -f "$env_file" ]; then + awk ' + !/^[[:space:]]*(export[[:space:]]+)?(POSTGRES_PASSWORD|POSTGRES_PASSWORD_URL_ENCODED|HUB_API_KEY|CUBEJS_API_SECRET|CUBEJS_JWT_ISSUER|CUBEJS_JWT_AUDIENCE)[[:space:]]*=/ + ' "$env_file" >"$tmp_file" + fi + + cat <>"$tmp_file" +POSTGRES_PASSWORD=$serialized_postgres_password +POSTGRES_PASSWORD_URL_ENCODED=$postgres_password_url_encoded +HUB_API_KEY=$hub_api_key +CUBEJS_API_SECRET=$cubejs_api_secret +CUBEJS_JWT_ISSUER=formbricks-web +CUBEJS_JWT_AUDIENCE=formbricks-cube +EOF + + append_if_missing "AUTHZED_TOKEN" "$authzed_token" + append_if_missing "AUTHZED_DATABASE_PASSWORD" "$authzed_database_password" + append_if_missing "AUTHZED_ENABLED" "true" + append_if_missing "AUTHZED_CONSISTENCY" "fully_consistent" + append_if_missing "FORMBRICKS_AUTHZED_V6_MIGRATION_ACKNOWLEDGED" "true" + + chmod 600 "$tmp_file" + mv "$tmp_file" "$env_file" + trap - EXIT +) + write_base_env_file() { local env_file="${1:-.env}" local hub_key="$2" @@ -174,16 +365,7 @@ write_base_env_file() { umask 077 : >"$env_file" - upsert_dotenv_var "HUB_API_KEY" "$hub_key" "$env_file" - upsert_dotenv_var "CUBEJS_API_SECRET" "$cube_secret" "$env_file" - upsert_dotenv_var "CUBEJS_JWT_ISSUER" "formbricks-web" "$env_file" - upsert_dotenv_var "CUBEJS_JWT_AUDIENCE" "formbricks-cube" "$env_file" - upsert_dotenv_var "AUTHZED_TOKEN" "$authzed_token" "$env_file" - upsert_dotenv_var "AUTHZED_DATABASE_PASSWORD" "$authzed_database_password" "$env_file" - upsert_dotenv_var "AUTHZED_ENABLED" "true" "$env_file" - upsert_dotenv_var "AUTHZED_CONSISTENCY" "fully_consistent" "$env_file" - upsert_dotenv_var "FORMBRICKS_AUTHZED_V6_MIGRATION_ACKNOWLEDGED" "true" "$env_file" - chmod 600 "$env_file" + write_generated_env_file "$env_file" "" "$hub_key" "$cube_secret" "$authzed_token" "$authzed_database_password" } add_formbricks_traefik_labels() { @@ -263,28 +445,12 @@ install_formbricks() { sudo apt-get install -y \ ca-certificates \ curl \ + jq \ lsb-release >/dev/null 2>&1 # Reuse an existing Docker installation instead of replacing it implicitly. if command -v docker >/dev/null 2>&1; then echo "✅ Docker is already installed." - - if docker info >/dev/null 2>&1 || sudo docker info >/dev/null 2>&1; then - echo "✅ Docker daemon is reachable. Reusing the existing Docker installation." - - if docker compose version >/dev/null 2>&1 || sudo docker compose version >/dev/null 2>&1; then - echo "✅ Docker Compose is available." - else - echo "❌ Docker Compose is not available on this system." - echo "Please install Docker Compose or upgrade Docker so 'docker compose' works, then rerun this script." - exit 1 - fi - else - echo "❌ Docker is installed, but the daemon is not reachable." - echo "Please start or fix Docker and rerun this script." - echo "To avoid modifying an existing Docker setup without your consent, this script will not remove or reinstall Docker automatically." - exit 1 - fi else # Remove old Docker packages only when Docker is not installed at all. echo "⚠️ Legacy Docker-related packages may conflict with Docker CE." @@ -328,6 +494,21 @@ install_formbricks() { fi fi + if ! configure_formbricks_docker_command; then + echo "❌ Docker is installed, but the daemon is not reachable." + echo "Please start or fix Docker and rerun this script." + echo "To avoid modifying an existing Docker setup without your consent, this script will not remove or reinstall Docker automatically." + exit 1 + fi + echo "✅ Docker daemon is reachable. Reusing the existing Docker installation." + + if ! run_formbricks_docker_compose version >/dev/null 2>&1; then + echo "❌ Docker Compose is not available on this system." + echo "Please install Docker Compose or upgrade Docker so 'docker compose' works, then rerun this script." + exit 1 + fi + echo "✅ Docker Compose is available." + # Adding your user to the Docker group echo "🐳 Adding your user to the Docker group to avoid using sudo with docker commands." sudo groupadd docker >/dev/null 2>&1 || true @@ -338,6 +519,14 @@ install_formbricks() { mkdir -p formbricks && cd formbricks echo "📁 Created Formbricks Quickstart directory at ./formbricks." + existing_postgres_password="" + if [ -f ".env" ] || [ -f "docker-compose.yml" ]; then + if ! existing_postgres_password=$(read_existing_postgres_password ".env" "docker-compose.yml"); then + echo "Set POSTGRES_PASSWORD in .env manually, then rerun this script." + exit 1 + fi + fi + # Ask the user for their domain name (recommend surveys subdomain) echo "🔗 Please enter your app domain (e.g., surveys.example.com). 🚨 Do NOT enter the protocol (http/https):" read domain_name @@ -619,8 +808,18 @@ EOT cubejs_api_secret=$(openssl rand -hex 32) authzed_token=$(openssl rand -hex 32) authzed_database_password=$(openssl rand -hex 32) - write_base_env_file ".env" "$hub_api_key" "$cubejs_api_secret" "$authzed_token" "$authzed_database_password" - echo "🚗 Generated Hub, Cube, and AuthZed secrets in .env successfully!" + write_generated_env_file \ + ".env" \ + "$existing_postgres_password" \ + "$hub_api_key" \ + "$cubejs_api_secret" \ + "$authzed_token" \ + "$authzed_database_password" + if [ -n "$existing_postgres_password" ]; then + echo "🚗 Preserved the existing PostgreSQL password and AuthZed credentials while refreshing .env." + else + echo "🚗 Generated PostgreSQL, Hub, Cube, and AuthZed secrets in .env successfully!" + fi if [[ -n $mail_from ]]; then sed -i "s|# MAIL_FROM:|MAIL_FROM: \"$mail_from\"|" docker-compose.yml diff --git a/docs/self-hosting/advanced/migration.mdx b/docs/self-hosting/advanced/migration.mdx index 478f7c064fe5..69bf8ffc4ae6 100644 --- a/docs/self-hosting/advanced/migration.mdx +++ b/docs/self-hosting/advanced/migration.mdx @@ -699,6 +699,9 @@ Cube is part of the baseline Formbricks v5 stack. Then compare `formbricks/docker-compose.v5.yml` with your existing `formbricks/docker-compose.yml` and merge the v5 additions: + - set `POSTGRES_PASSWORD` in `formbricks/.env` to the exact password used by the existing PostgreSQL + volume; do not generate a new value during the upgrade. If it contains URI-reserved characters, also set + `POSTGRES_PASSWORD_URL_ENCODED` to its percent-encoded form - add a non-empty `HUB_API_KEY` and reuse the same value wherever your deployment resolves Hub auth - keep `HUB_API_URL` at `http://hub:8080` unless Hub runs elsewhere - include the bundled `formbricks-migrate`, `hub-migrate`, and `hub` services @@ -728,6 +731,9 @@ Cube is part of the baseline Formbricks v5 stack. At minimum, confirm: + - `POSTGRES_PASSWORD` contains the exact password used by the existing PostgreSQL volume; replacing the + Compose file does not rotate the database password. If it contains URI-reserved characters, also set + `POSTGRES_PASSWORD_URL_ENCODED` to its percent-encoded form - `HUB_API_KEY` is configured and the same value is available wherever your deployment resolves Hub auth - `HUB_API_URL` points to the Hub service the app can reach - the compose stack includes `formbricks-migrate`, `hub-migrate`, and `hub` diff --git a/docs/self-hosting/configuration/job-runner.mdx b/docs/self-hosting/configuration/job-runner.mdx index 1a01412b6a06..9485df45234a 100644 --- a/docs/self-hosting/configuration/job-runner.mdx +++ b/docs/self-hosting/configuration/job-runner.mdx @@ -43,6 +43,7 @@ Current workloads on the shared queue include: | Response pipeline | `response-pipeline.process` | Processes asynchronous response pipeline events. | | Survey scheduling | `survey-scheduling.reconcile` | Reconciles scheduled survey state. | | Survey archive | `survey-archive-purge.process` | Permanently deletes surveys past the archive retention window. | +| Usage telemetry | `usage-telemetry.process` | Sends the instance's daily usage update. | | Workflows | `workflow-run.process`, `workflow-run.reconcile` | Executes workflow runs and recovers stalled runs. | These are current examples rather than a fixed list. Future Formbricks features can use the same Job Runner for diff --git a/docs/self-hosting/setup/docker.mdx b/docs/self-hosting/setup/docker.mdx index 431416e8f7e1..532204392d2d 100644 --- a/docs/self-hosting/setup/docker.mdx +++ b/docs/self-hosting/setup/docker.mdx @@ -77,9 +77,12 @@ Docker and Docker Compose are usually included in tools like Docker Desktop and install, replace both URL values with your public HTTPS URL before starting the stack. ```bash + POSTGRES_PASSWORD=$(openssl rand -hex 32) cat < .env WEBAPP_URL=http://localhost:3000 NEXTAUTH_URL=http://localhost:3000 + POSTGRES_PASSWORD=$POSTGRES_PASSWORD + POSTGRES_PASSWORD_URL_ENCODED=$POSTGRES_PASSWORD NEXTAUTH_SECRET=$(openssl rand -hex 32) ENCRYPTION_KEY=$(openssl rand -hex 32) CRON_SECRET=$(openssl rand -hex 32) @@ -93,6 +96,13 @@ Docker and Docker Compose are usually included in tools like Docker Desktop and chmod 600 .env ``` + Keep `POSTGRES_PASSWORD` unchanged after PostgreSQL initializes the `postgres` volume. The generated + hexadecimal value is already URL-safe, so `POSTGRES_PASSWORD_URL_ENCODED` uses the same value. If you replace + an existing `docker-compose.yml`, copy the exact password from that deployment instead of generating a new + one. If it contains URI-reserved characters, set `POSTGRES_PASSWORD_URL_ENCODED` to its percent-encoded form; + otherwise you can use the same value or omit the encoded variable. The PostgreSQL image only applies the raw + password when it creates a new database volume. + 1. **Validate the Docker Compose Configuration** Validate the Compose file after `.env` contains the required values: diff --git a/docs/self-hosting/setup/one-click.mdx b/docs/self-hosting/setup/one-click.mdx index 1b65ac1ad0ec..ff67b1908c17 100644 --- a/docs/self-hosting/setup/one-click.mdx +++ b/docs/self-hosting/setup/one-click.mdx @@ -23,9 +23,11 @@ If you’re looking to quickly set up a production instance of Formbricks on an - When bundled RustFS is enabled, the installer stores the generated RustFS credentials in `./formbricks/.env` - and restricts the file to `0600`. Keep that file private, include it in your server backup plan, and avoid - checking it into source control or copying it to shared locations. + The installer stores generated PostgreSQL, Hub, Cube, and AuthZed credentials in `./formbricks/.env` and + restricts the file to `0600`. PostgreSQL has a raw password for the database plus a URL-encoded companion for + connection strings. When bundled RustFS is enabled, its generated credentials are stored there too. Keep that + file private, include it in your server backup plan, and avoid checking it into source control or copying it to + shared locations. @@ -340,6 +342,9 @@ Compose file, replaces only that exact image reference with the native amd64/arm result before pulling. An already-updated or custom Valkey image is left unchanged. The named `redis` volume and its AOF data are preserved. +Existing one-click installations keep the database credentials in their local `docker-compose.yml` or `.env`. +The update command does not regenerate or rotate them. + Apart from the exact v5.4 Valkey pin migration described above, `./formbricks.sh update` does **not** rewrite your existing `formbricks/docker-compose.yml`. For a major migration such as Formbricks 4.x to 5.0, follow diff --git a/packages/database/.squawk.toml b/packages/database/.squawk.toml new file mode 100644 index 000000000000..9cc8a465a136 --- /dev/null +++ b/packages/database/.squawk.toml @@ -0,0 +1,14 @@ +# PostgreSQL 15 is the lowest supported self-hosted major; CI replays the history on PostgreSQL 18. +pg_version = "15.0" + +# Prisma 7.8 does not add a transaction wrapper around migration SQL. +assume_in_transaction = false + +excluded_rules = [ + # Prisma Int fields generate INTEGER columns by design. + "prefer-bigint-over-int", + # Prisma DateTime fields generate TIMESTAMP(3) columns by design. + "prefer-timestamp-tz", + # Safe statement timeouts depend on the operation and table size; a global value can abort valid migrations. + "require-statement-timeout", +] diff --git a/packages/database/README.md b/packages/database/README.md index 9810423d1fc6..0353cdc80174 100644 --- a/packages/database/README.md +++ b/packages/database/README.md @@ -123,12 +123,71 @@ Run these commands from the `packages/database` directory: - **Note**: Only use Prisma raw queries in data migrations for better performance and to avoid type errors - **`pnpm db:seed`**: Run the seeding script - **`pnpm db:seed:clear`**: Clear data and run the seeding script +- **`pnpm lint:migrations migration//migration.sql`**: Lint one or more new schema + migrations with Squawk +- **`pnpm check:migration-drift`**: Compare the complete checked-in SQL migration history with the + Prisma schema (requires a disposable `SHADOW_DATABASE_URL`) + +### Migration safety checks + +CI runs Squawk only on schema migrations added, copied, renamed, or modified by the pull request. Existing +migrations are the baseline and are not linted again. To check a migration locally from this package, run: + +```bash +pnpm lint:migrations migration//migration.sql +``` + +Squawk checks PostgreSQL 15 syntax, which remains relevant to older one-click installations in the +[self-hosted migration guide](../../docs/self-hosting/advanced/migration.mdx#v27), while CI replays the complete +history on PostgreSQL 18. Prisma 7.8 does not add a transaction wrapper around migration SQL, so concurrent index +checks remain enabled and migrations must add `BEGIN` and `COMMIT` explicitly when atomic execution is required. + +`pnpm create-migration` copies Prisma's generated SQL unchanged. Before committing every generated migration: + +1. Add `SET lock_timeout = '1s';` at the top. +2. Add explicit transaction boundaries when the statements must be atomic. +3. Change eligible index builds to `CREATE INDEX CONCURRENTLY`, which cannot run inside a transaction. +4. Run `pnpm lint:migrations migration//migration.sql` and document targeted exceptions. + +Squawk cannot inspect statements hidden inside a `DO $$ ... $$` block. Use such blocks only when PostgreSQL +procedural logic is required, not to bypass the migration safety checks. + +If a warning is intentional, place a statement-level ignore immediately before the affected statement and +document why it is safe. Package-level exclusions are reserved for rules that conflict with Prisma's generated +SQL or deployment model; do not add one for a migration-specific exception or use historical path allowlists. +For example: + +```sql +SET lock_timeout = '1s'; +-- A preceding data migration guarantees that Example has no rows. +-- squawk-ignore adding-required-field +ALTER TABLE "Example" ADD COLUMN IF NOT EXISTS "slug" TEXT NOT NULL; +``` + +Squawk enforces a short lock timeout, but intentionally does not require a statement timeout. If an operation +needs one, size it for that operation and table; a blanket value can abort legitimate large-table index builds. + +The drift check replays only checked-in `migration.sql` files, so interleaved TypeScript data migrations are +excluded. Data migrations must remain data-only: DDL in a `migration.ts` file is invisible to the replay and +causes false drift. Prisma resets the shadow database while evaluating migration history. The helper requires +the database name to contain `shadow` and rejects the primary database identity, but that marker is only a +fail-safe: still use a dedicated disposable database and never point this variable at a development, staging, or +production database: + +```bash +SHADOW_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/formbricks_migration_shadow?schema=public" \ + pnpm check:migration-drift +``` + +Pass `SHADOW_DATABASE_URL` inline or through ephemeral CI configuration. Do not persist it in `.env`, because +`prisma.config.ts` would then activate that shadow database for other Prisma migration commands as well. ### Available Scripts ```json { "build": "pnpm generate && vite build", + "check:migration-drift": "Compare SQL migration history with the Prisma schema", "create-migration": "Create new schema migration", "db:migrate:deploy": "Apply migrations in production", "db:migrate:dev": "Apply migrations in development", @@ -138,7 +197,8 @@ Run these commands from the `packages/database` directory: "db:setup": "pnpm db:migrate:dev && pnpm db:create-saml-database:dev && pnpm db:seed", "dev": "vite build --watch", "generate": "prisma generate --config ./prisma.config.ts", - "generate-data-migration": "Create new data migration" + "generate-data-migration": "Create new data migration", + "lint:migrations": "Lint one or more schema migration SQL files" } ``` diff --git a/packages/database/package.json b/packages/database/package.json index b1487dbb3240..ecc573a5f31e 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -58,10 +58,12 @@ "db:seed:contract": "dotenv -e ../../.env -- tsx src/scripts/seed-contract-fixtures.ts", "db:setup": "pnpm db:migrate:dev && pnpm db:create-saml-database:dev", "db:start": "pnpm db:setup", + "check:migration-drift": "dotenv -e ../../.env -- tsx ./src/scripts/check-migration-drift.ts", "format": "prisma format --config ./prisma.config.ts", "generate": "node ./src/scripts/clean-generated-prisma.mjs && prisma generate --config ./prisma.config.ts", "lint": "eslint ./src", "lint:fix": "eslint ./src --fix", + "lint:migrations": "squawk --config ./.squawk.toml", "generate-data-migration": "tsx ./src/scripts/generate-data-migration.ts", "test": "vitest run", "test:coverage": "vitest run --coverage", @@ -93,6 +95,7 @@ "glob": "catalog:", "prisma": "7.8.0", "prisma-json-types-generator": "4.1.1", + "squawk-cli": "2.63.0", "tsx": "catalog:", "typescript": "catalog:", "vite": "catalog:", diff --git a/packages/database/schema/main.prisma b/packages/database/schema/main.prisma index 4599167535cb..98045c104a30 100644 --- a/packages/database/schema/main.prisma +++ b/packages/database/schema/main.prisma @@ -1163,12 +1163,14 @@ model PasswordResetToken { /// @property memberships - Organizations the user belongs to /// @property notificationSettings - User's notification preferences model User { - id String @id @default(cuid()) - createdAt DateTime @default(now()) @map(name: "created_at") - updatedAt DateTime @updatedAt @map(name: "updated_at") - name String - email String @unique - emailVerified Boolean @default(false) @map(name: "email_verified") + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + name String + email String @unique + emailVerified Boolean @default(false) @map(name: "email_verified") + // Temporary representation of the orphaned rollback column until ENG-1826 removes it; keep it out of Prisma Client. + legacyEmailVerifiedAt DateTime? @map(name: "email_verified_at") @ignore twoFactorSecret String? twoFactorEnabled Boolean @default(false) @@ -1535,14 +1537,14 @@ model FeedbackSourceFormbricksMapping { createdAt DateTime @default(now()) @map(name: "created_at") feedbackSourceId String @map(name: "feedback_source_id") workspaceId String - feedbackSource FeedbackSource @relation(fields: [feedbackSourceId, workspaceId], references: [id, workspaceId], onDelete: Cascade) + feedbackSource FeedbackSource @relation(fields: [feedbackSourceId, workspaceId], references: [id, workspaceId], onDelete: Cascade, map: "FeedbackSourceFormbricksMapping_feedbackSourceId_workspaceId_fk") surveyId String survey Survey @relation(fields: [surveyId, workspaceId], references: [id, workspaceId], onDelete: Cascade) elementId String hubFieldType HubFieldType customFieldLabel String? @map(name: "custom_field_label") - @@unique([workspaceId, feedbackSourceId, surveyId, elementId]) + @@unique([workspaceId, feedbackSourceId, surveyId, elementId], map: "FeedbackSourceFormbricksMapping_workspaceId_feedbackSourceId_su") @@index([workspaceId, surveyId]) @@index([surveyId]) } @@ -1560,12 +1562,12 @@ model FeedbackSourceFieldMapping { createdAt DateTime @default(now()) @map(name: "created_at") feedbackSourceId String @map(name: "feedback_source_id") workspaceId String - feedbackSource FeedbackSource @relation(fields: [feedbackSourceId, workspaceId], references: [id, workspaceId], onDelete: Cascade) + feedbackSource FeedbackSource @relation(fields: [feedbackSourceId, workspaceId], references: [id, workspaceId], onDelete: Cascade, map: "FeedbackSourceFieldMapping_feedbackSourceId_workspaceId_fkey") sourceFieldId String @map(name: "source_field_id") targetFieldId String @map(name: "target_field_id") staticValue String? @map(name: "static_value") - @@unique([workspaceId, feedbackSourceId, sourceFieldId, targetFieldId]) + @@unique([workspaceId, feedbackSourceId, sourceFieldId, targetFieldId], map: "FeedbackSourceFieldMapping_workspaceId_feedbackSourceId_sourceF") } /// Represents a feedback directory (Hub tenant) owned by an organization. diff --git a/packages/database/src/scripts/check-migration-drift.test.ts b/packages/database/src/scripts/check-migration-drift.test.ts new file mode 100644 index 000000000000..37ac623a8ec9 --- /dev/null +++ b/packages/database/src/scripts/check-migration-drift.test.ts @@ -0,0 +1,249 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + checkMigrationDrift, + runPrismaDiff, + sortMigrationDirectoryNames, + stagePrismaMigrationHistory, + validateShadowDatabaseEnvironment, +} from "./check-migration-drift"; + +const temporaryPaths: string[] = []; +const MIGRATION_LOCK_CONTENT = '# Migration lock fixture\nprovider = "postgresql"\n'; + +const createTemporaryDirectory = async (prefix: string): Promise => { + const temporaryPath = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + temporaryPaths.push(temporaryPath); + return temporaryPath; +}; + +const createMigration = async ( + migrationsDir: string, + migrationName: string, + fileName: "migration.sql" | "migration.ts" +): Promise => { + const migrationDir = path.join(migrationsDir, migrationName); + await fs.mkdir(migrationDir, { recursive: true }); + await fs.writeFile( + path.join(migrationDir, fileName), + fileName === "migration.sql" ? "SELECT 1;\n" : "export {};\n" + ); +}; + +const createMigrationHistory = async (): Promise => { + const migrationsDir = await createTemporaryDirectory("formbricks-migration-source-"); + await fs.writeFile(path.join(migrationsDir, "migration_lock.toml"), MIGRATION_LOCK_CONTENT); + return migrationsDir; +}; + +afterEach(async () => { + await Promise.all( + temporaryPaths.splice(0).map((temporaryPath) => fs.rm(temporaryPath, { force: true, recursive: true })) + ); +}); + +describe("stagePrismaMigrationHistory", () => { + test("sorts migration directory names chronologically", () => { + expect(sortMigrationDirectoryNames(["20260103000000_third", "20260101000000_first"])).toEqual([ + "20260101000000_first", + "20260103000000_third", + ]); + }); + + test("copies schema migrations in order and ignores data migrations", async () => { + const migrationsDir = await createMigrationHistory(); + const destinationDir = await createTemporaryDirectory("formbricks-migration-destination-"); + + await createMigration(migrationsDir, "20260103000000_second_schema", "migration.sql"); + await createMigration(migrationsDir, "20260102000000_data_only", "migration.ts"); + await createMigration(migrationsDir, "20260101000000_first_schema", "migration.sql"); + + const stagedMigrations = await stagePrismaMigrationHistory(migrationsDir, destinationDir); + + expect(stagedMigrations).toEqual(["20260101000000_first_schema", "20260103000000_second_schema"]); + expect(await fs.readFile(path.join(destinationDir, "migration_lock.toml"), "utf8")).toBe( + MIGRATION_LOCK_CONTENT + ); + expect((await fs.readdir(destinationDir)).sort()).toEqual([ + "20260101000000_first_schema", + "20260103000000_second_schema", + "migration_lock.toml", + ]); + }); +}); + +describe("runPrismaDiff", () => { + test("invokes Prisma migrate diff with exit-code enabled", async () => { + const environment = { SHADOW_DATABASE_URL: "postgresql://postgres:postgres@localhost/shadow" }; + const executeCommand = vi.fn(() => Promise.resolve(2)); + + await expect( + runPrismaDiff({ + environment, + executeCommand, + migrationsPath: "/tmp/migrations", + prismaBin: "/repo/node_modules/.bin/prisma", + prismaConfigPath: "/repo/packages/database/prisma.config.ts", + repoRoot: "/repo", + schemaPath: "/repo/packages/database/schema", + }) + ).resolves.toBe(2); + + expect(executeCommand).toHaveBeenCalledWith({ + args: [ + "migrate", + "diff", + "--config", + "/repo/packages/database/prisma.config.ts", + "--from-migrations", + "/tmp/migrations", + "--to-schema", + "/repo/packages/database/schema", + "--exit-code", + ], + command: "/repo/node_modules/.bin/prisma", + cwd: "/repo", + environment, + }); + }); +}); + +describe("checkMigrationDrift", () => { + const databaseEnvironment = { + DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks", + SHADOW_DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks_shadow", + }; + + test("accepts distinct primary and explicitly marked shadow databases", () => { + expect(() => validateShadowDatabaseEnvironment(databaseEnvironment)).not.toThrow(); + }); + + test.each([ + { + environment: { SHADOW_DATABASE_URL: databaseEnvironment.SHADOW_DATABASE_URL }, + expectedError: "DATABASE_URL must be set so shadow database isolation can be verified", + name: "a missing primary URL", + }, + { + environment: { DATABASE_URL: databaseEnvironment.DATABASE_URL }, + expectedError: "SHADOW_DATABASE_URL must point to a dedicated disposable database", + name: "a missing shadow URL", + }, + { + environment: { + DATABASE_URL: databaseEnvironment.DATABASE_URL, + SHADOW_DATABASE_URL: " ", + }, + expectedError: "SHADOW_DATABASE_URL must point to a dedicated disposable database", + name: "a blank shadow URL", + }, + { + environment: { + DATABASE_URL: databaseEnvironment.DATABASE_URL, + SHADOW_DATABASE_URL: "not-a-url", + }, + expectedError: "SHADOW_DATABASE_URL must be a valid PostgreSQL URL", + name: "an invalid shadow URL", + }, + { + environment: { + DATABASE_URL: databaseEnvironment.DATABASE_URL, + SHADOW_DATABASE_URL: "http://localhost:5432/formbricks_shadow", + }, + expectedError: "SHADOW_DATABASE_URL must be a valid PostgreSQL URL", + name: "a non-PostgreSQL shadow URL", + }, + { + environment: { + DATABASE_URL: databaseEnvironment.DATABASE_URL, + SHADOW_DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks_scratch", + }, + expectedError: 'SHADOW_DATABASE_URL database name must contain the marker "shadow"', + name: "an unmarked shadow database", + }, + { + environment: { + DATABASE_URL: "postgresql://postgres:postgres@localhost/formbricks_shadow?schema=main", + SHADOW_DATABASE_URL: "postgres://postgres:postgres@localhost:5432/formbricks_shadow?schema=public", + }, + expectedError: "SHADOW_DATABASE_URL must not target the DATABASE_URL database", + name: "the primary database through an equivalent URL", + }, + ])("rejects $name", ({ environment, expectedError }) => { + expect(() => validateShadowDatabaseEnvironment(environment)).toThrow(expectedError); + }); + + test("validates shadow database isolation before staging migrations", async () => { + const executePrismaDiff = vi.fn(() => Promise.resolve(0)); + + await expect( + checkMigrationDrift({ + environment: { + DATABASE_URL: databaseEnvironment.DATABASE_URL, + SHADOW_DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks_scratch", + }, + migrationsDir: "/migrations", + prismaBin: "prisma", + prismaConfigPath: "prisma.config.ts", + repoRoot: "/repo", + runPrismaDiff: executePrismaDiff, + schemaPath: "schema", + }) + ).rejects.toThrow('SHADOW_DATABASE_URL database name must contain the marker "shadow"'); + + expect(executePrismaDiff).not.toHaveBeenCalled(); + }); + + test.each([1, 2])("propagates Prisma exit code %i and removes the staged history", async (exitCode) => { + const migrationsDir = await createMigrationHistory(); + await createMigration(migrationsDir, "20260101000000_schema", "migration.sql"); + let stagedMigrationsPath = ""; + const executePrismaDiff = vi.fn(async ({ migrationsPath }: { migrationsPath: string }) => { + stagedMigrationsPath = migrationsPath; + expect(await fs.readFile(path.join(migrationsPath, "migration_lock.toml"), "utf8")).toBe( + MIGRATION_LOCK_CONTENT + ); + return exitCode; + }); + + await expect( + checkMigrationDrift({ + environment: databaseEnvironment, + migrationsDir, + prismaBin: "prisma", + prismaConfigPath: "prisma.config.ts", + repoRoot: "/repo", + runPrismaDiff: executePrismaDiff, + schemaPath: "schema", + }) + ).resolves.toBe(exitCode); + + await expect(fs.access(stagedMigrationsPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + test("propagates process errors and still removes the staged history", async () => { + const migrationsDir = await createMigrationHistory(); + await createMigration(migrationsDir, "20260101000000_schema", "migration.sql"); + let stagedMigrationsPath = ""; + const executePrismaDiff = vi.fn(({ migrationsPath }: { migrationsPath: string }) => { + stagedMigrationsPath = migrationsPath; + return Promise.reject(new Error("Prisma process failed to start")); + }); + + await expect( + checkMigrationDrift({ + environment: databaseEnvironment, + migrationsDir, + prismaBin: "prisma", + prismaConfigPath: "prisma.config.ts", + repoRoot: "/repo", + runPrismaDiff: executePrismaDiff, + schemaPath: "schema", + }) + ).rejects.toThrow("Prisma process failed to start"); + + await expect(fs.access(stagedMigrationsPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); diff --git a/packages/database/src/scripts/check-migration-drift.ts b/packages/database/src/scripts/check-migration-drift.ts new file mode 100644 index 000000000000..9b9e52f913e2 --- /dev/null +++ b/packages/database/src/scripts/check-migration-drift.ts @@ -0,0 +1,256 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DATABASE_PACKAGE_DIR = path.resolve(__dirname, "../.."); +const REPO_ROOT_DIR = path.resolve(DATABASE_PACKAGE_DIR, "../.."); +const MIGRATIONS_DIR = path.join(DATABASE_PACKAGE_DIR, "migration"); +const SCHEMA_DIR = path.join(DATABASE_PACKAGE_DIR, "schema"); +const PRISMA_CONFIG_PATH = path.join(DATABASE_PACKAGE_DIR, "prisma.config.ts"); +const PRISMA_BIN = path.join( + REPO_ROOT_DIR, + "node_modules", + ".bin", + process.platform === "win32" ? "prisma.cmd" : "prisma" +); +const MIGRATION_LOCK_FILE_NAME = "migration_lock.toml"; +const POSTGRESQL_PROTOCOLS = new Set(["postgres:", "postgresql:"]); +const SHADOW_DATABASE_MARKER = "shadow"; + +export interface CommandInput { + args: string[]; + command: string; + cwd: string; + environment: NodeJS.ProcessEnv; +} + +export type ExecuteCommand = (input: CommandInput) => Promise; + +export interface PrismaDiffInput { + environment: NodeJS.ProcessEnv; + executeCommand?: ExecuteCommand; + migrationsPath: string; + prismaBin: string; + prismaConfigPath: string; + repoRoot: string; + schemaPath: string; +} + +export type RunPrismaDiff = (input: PrismaDiffInput) => Promise; + +export interface MigrationDriftCheckOptions { + environment?: NodeJS.ProcessEnv; + migrationsDir: string; + prismaBin: string; + prismaConfigPath: string; + repoRoot: string; + runPrismaDiff?: RunPrismaDiff; + schemaPath: string; +} + +const isMissingFileError = (error: unknown): boolean => + error instanceof Error && "code" in error && error.code === "ENOENT"; + +interface DatabaseIdentity { + databaseName: string; + hostname: string; + port: string; +} + +const parseDatabaseIdentity = (databaseUrl: string, variableName: string): DatabaseIdentity => { + let parsedUrl: URL; + + try { + parsedUrl = new URL(databaseUrl); + } catch { + throw new Error(`${variableName} must be a valid PostgreSQL URL`); + } + + if (!POSTGRESQL_PROTOCOLS.has(parsedUrl.protocol)) { + throw new Error(`${variableName} must be a valid PostgreSQL URL`); + } + + const databaseName = decodeURIComponent(parsedUrl.pathname.replace(/^\//, "")); + if (!parsedUrl.hostname || !databaseName) { + throw new Error(`${variableName} must be a valid PostgreSQL URL`); + } + + return { + databaseName: databaseName.toLowerCase(), + hostname: parsedUrl.hostname.toLowerCase(), + port: parsedUrl.port || "5432", + }; +}; + +export const validateShadowDatabaseEnvironment = (environment: NodeJS.ProcessEnv): void => { + const databaseUrl = environment.DATABASE_URL?.trim(); + const shadowDatabaseUrl = environment.SHADOW_DATABASE_URL?.trim(); + + if (!databaseUrl) { + throw new Error("DATABASE_URL must be set so shadow database isolation can be verified"); + } + + if (!shadowDatabaseUrl) { + throw new Error("SHADOW_DATABASE_URL must point to a dedicated disposable database"); + } + + const databaseIdentity = parseDatabaseIdentity(databaseUrl, "DATABASE_URL"); + const shadowDatabaseIdentity = parseDatabaseIdentity(shadowDatabaseUrl, "SHADOW_DATABASE_URL"); + + if (!shadowDatabaseIdentity.databaseName.includes(SHADOW_DATABASE_MARKER)) { + throw new Error('SHADOW_DATABASE_URL database name must contain the marker "shadow"'); + } + + if ( + databaseIdentity.hostname === shadowDatabaseIdentity.hostname && + databaseIdentity.port === shadowDatabaseIdentity.port && + databaseIdentity.databaseName === shadowDatabaseIdentity.databaseName + ) { + throw new Error("SHADOW_DATABASE_URL must not target the DATABASE_URL database"); + } +}; + +export const sortMigrationDirectoryNames = (migrationNames: string[]): string[] => + [...migrationNames].sort((a, b) => a.localeCompare(b)); + +export const stagePrismaMigrationHistory = async ( + migrationsDir: string, + destinationDir: string +): Promise => { + const entries = await fs.readdir(migrationsDir, { withFileTypes: true }); + const schemaMigrationNames: string[] = []; + + const migrationDirectoryNames = sortMigrationDirectoryNames( + entries.filter((candidate) => candidate.isDirectory()).map((entry) => entry.name) + ); + + for (const migrationName of migrationDirectoryNames) { + const sourceSqlPath = path.join(migrationsDir, migrationName, "migration.sql"); + + try { + await fs.access(sourceSqlPath); + } catch (error: unknown) { + if (isMissingFileError(error)) { + continue; + } + + throw error; + } + + const destinationMigrationDir = path.join(destinationDir, migrationName); + await fs.mkdir(destinationMigrationDir, { recursive: true }); + await fs.copyFile(sourceSqlPath, path.join(destinationMigrationDir, "migration.sql")); + schemaMigrationNames.push(migrationName); + } + + if (schemaMigrationNames.length === 0) { + throw new Error(`No schema migrations found in ${migrationsDir}`); + } + + await fs.copyFile( + path.join(migrationsDir, MIGRATION_LOCK_FILE_NAME), + path.join(destinationDir, MIGRATION_LOCK_FILE_NAME) + ); + + return schemaMigrationNames; +}; + +const spawnCommand: ExecuteCommand = async ({ args, command, cwd, environment }) => + new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + env: environment, + stdio: "inherit", + }); + + child.once("error", reject); + child.once("close", (exitCode) => { + resolve(exitCode ?? 1); + }); + }); + +export const runPrismaDiff: RunPrismaDiff = async ({ + environment, + executeCommand = spawnCommand, + migrationsPath, + prismaBin, + prismaConfigPath, + repoRoot, + schemaPath, +}) => + executeCommand({ + args: [ + "migrate", + "diff", + "--config", + prismaConfigPath, + "--from-migrations", + migrationsPath, + "--to-schema", + schemaPath, + "--exit-code", + ], + command: prismaBin, + cwd: repoRoot, + environment, + }); + +export const checkMigrationDrift = async ({ + environment = process.env, + migrationsDir, + prismaBin, + prismaConfigPath, + repoRoot, + runPrismaDiff: executePrismaDiff = runPrismaDiff, + schemaPath, +}: MigrationDriftCheckOptions): Promise => { + validateShadowDatabaseEnvironment(environment); + + const temporaryMigrationsDir = await fs.mkdtemp(path.join(os.tmpdir(), "formbricks-prisma-migrations-")); + + try { + await stagePrismaMigrationHistory(migrationsDir, temporaryMigrationsDir); + + return await executePrismaDiff({ + environment, + migrationsPath: temporaryMigrationsDir, + prismaBin, + prismaConfigPath, + repoRoot, + schemaPath, + }); + } finally { + await fs.rm(temporaryMigrationsDir, { force: true, recursive: true }); + } +}; + +const main = async (): Promise => { + const exitCode = await checkMigrationDrift({ + migrationsDir: MIGRATIONS_DIR, + prismaBin: PRISMA_BIN, + prismaConfigPath: PRISMA_CONFIG_PATH, + repoRoot: REPO_ROOT_DIR, + schemaPath: SCHEMA_DIR, + }); + + if (exitCode === 2) { + process.stderr.write("Migration history and Prisma schema have drifted.\n"); + } else if (exitCode !== 0) { + process.stderr.write(`Prisma migrate diff failed with exit code ${exitCode.toString()}.\n`); + } + + process.exitCode = exitCode; +}; + +const entryPoint = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : undefined; + +if (entryPoint === import.meta.url) { + main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : "Unknown migration drift check error"; + process.stderr.write(`${message}\n`); + process.exitCode = 1; + }); +} diff --git a/packages/jobs/src/constants.ts b/packages/jobs/src/constants.ts index b0671916d6ad..7031109f84c3 100644 --- a/packages/jobs/src/constants.ts +++ b/packages/jobs/src/constants.ts @@ -10,6 +10,7 @@ export const JOB_NAMES = { responsePipeline: "response-pipeline.process", surveyScheduling: "survey-scheduling.reconcile", surveyArchivePurge: "survey-archive-purge.process", + usageTelemetry: "usage-telemetry.process", workflowRun: "workflow-run.process", workflowRunReconcile: "workflow-run.reconcile", } as const; diff --git a/packages/jobs/src/index.ts b/packages/jobs/src/index.ts index 4c212bf01454..86e7d609c72e 100644 --- a/packages/jobs/src/index.ts +++ b/packages/jobs/src/index.ts @@ -32,6 +32,7 @@ export { ZSurveyArchivePurgeJobData, ZSurveySchedulingJobData, ZTestLogJobData, + ZUsageTelemetryJobData, ZWorkflowRunJobData, ZWorkflowRunReconcileJobData, } from "./types"; @@ -42,6 +43,7 @@ export type { TSurveyArchivePurgeJobData, TSurveySchedulingJobData, TTestLogJobData, + TUsageTelemetryJobData, TWorkflowRunJobData, TWorkflowRunReconcileJobData, } from "./types"; diff --git a/packages/jobs/src/queue.test.ts b/packages/jobs/src/queue.test.ts index cfa6842dcf1b..ffdc3efa1347 100644 --- a/packages/jobs/src/queue.test.ts +++ b/packages/jobs/src/queue.test.ts @@ -344,6 +344,7 @@ describe("@formbricks/jobs queue helpers", () => { ["authzedReconciliationAudit", "authzed-reconciliation.audit:global:authzed-reconciliation-audit"], ["surveyArchivePurge", "survey-archive-purge.process:global:daily-survey-archive-purge"], ["surveyScheduling", "survey-scheduling.reconcile:global:daily-survey-scheduling"], + ["usageTelemetry", "usage-telemetry.process:global:daily-usage-telemetry"], ["workflowRunReconcile", "workflow-run.reconcile:global:workflow-run-reconcile"], ] as const)("keeps the %s scheduler id stable", async (key, expectedSchedulerId) => { mockQueueUpsertJobScheduler.mockResolvedValue({ diff --git a/packages/jobs/src/recurring.ts b/packages/jobs/src/recurring.ts index 18733371baa7..422c38b8e326 100644 --- a/packages/jobs/src/recurring.ts +++ b/packages/jobs/src/recurring.ts @@ -78,6 +78,11 @@ export const recurringJobDescriptors = { name: JOB_NAMES.surveyScheduling, scheduleId: "daily-survey-scheduling", }), + usageTelemetry: defineRecurringJob({ + label: "usage telemetry", + name: JOB_NAMES.usageTelemetry, + scheduleId: "daily-usage-telemetry", + }), workflowRunReconcile: defineRecurringJob({ label: "workflow run reconcile", name: JOB_NAMES.workflowRunReconcile, diff --git a/packages/jobs/src/types.ts b/packages/jobs/src/types.ts index 1f9ef17361c0..b5f55beaa6e6 100644 --- a/packages/jobs/src/types.ts +++ b/packages/jobs/src/types.ts @@ -60,6 +60,10 @@ export const ZSurveyArchivePurgeJobData = ZGlobalScopeJobData; export type TSurveyArchivePurgeJobData = TGlobalScopeJobData; +export const ZUsageTelemetryJobData = ZGlobalScopeJobData; + +export type TUsageTelemetryJobData = TGlobalScopeJobData; + export const ZWorkflowRunJobData = z.object({ workflowRunId: z.cuid2(), workflowId: z.cuid2(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0305dfda743f..517996530500 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1116,6 +1116,9 @@ importers: prisma-json-types-generator: specifier: 4.1.1 version: 4.1.1(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3))(prisma@7.8.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3))(typescript@5.9.3) + squawk-cli: + specifier: 2.63.0 + version: 2.63.0 tsx: specifier: 'catalog:' version: 4.21.0 @@ -5971,6 +5974,31 @@ packages: '@sqltools/formatter@1.2.5': resolution: {integrity: sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==} + '@squawk-cli/darwin-arm64@2.63.0': + resolution: {integrity: sha512-YaSfsQ8c0bb93thcBmJWLOte/1MExUFn6mVWfSqjgaFHip0UGHa3X4oZJiCdk75f/Tw2GrGmAiLV2/eQqnYfUA==} + cpu: [arm64] + os: [darwin] + + '@squawk-cli/darwin-x64@2.63.0': + resolution: {integrity: sha512-y23BkUAzD5VtxjCr2JGZz3j+gMVueyad6t4f8AfXV/cNp2CEY+5EmO7QAjXD9z8ro8Oye4F9aId9A5gCNsbh9A==} + cpu: [x64] + os: [darwin] + + '@squawk-cli/linux-arm64@2.63.0': + resolution: {integrity: sha512-ino85Bg5vL3XIHCBLadoQdaTIZlTg+oh9LriyejIGur7s15fhavDkwhCjqnEUKznGnqsx7x92yW2uDUbJM3fYw==} + cpu: [arm64] + os: [linux] + + '@squawk-cli/linux-x64@2.63.0': + resolution: {integrity: sha512-dh1IiQGs/7M9cQEHaC2JkV6lYrLoR5ytAyznRLxcBAfzExSfqEz/gPwc11dewzFZDq3G6TmKKVbMNhcWIhAsww==} + cpu: [x64] + os: [linux] + + '@squawk-cli/win32-x64@2.63.0': + resolution: {integrity: sha512-7939Y/zW6ni0X/Zp19Mml8wS35eOsszgFT+RuZH5zYZwWvQxjEa00pc3baPugb9VIAIdWVMnJTjBcmFA31sKfg==} + cpu: [x64] + os: [win32] + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -11364,6 +11392,10 @@ packages: resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} engines: {node: '>= 0.6'} + squawk-cli@2.63.0: + resolution: {integrity: sha512-OR9UHzY3kSLoZCB4qk3iSHeEm9pwVccHSWnRggfvRKo/QAddkWJEyoXlIsz0gPAPpzsyk60WcaY4SkswZTXPVA==} + hasBin: true + ssri@8.0.1: resolution: {integrity: sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==} engines: {node: '>= 8'} @@ -18274,6 +18306,21 @@ snapshots: '@sqltools/formatter@1.2.5': {} + '@squawk-cli/darwin-arm64@2.63.0': + optional: true + + '@squawk-cli/darwin-x64@2.63.0': + optional: true + + '@squawk-cli/linux-arm64@2.63.0': + optional: true + + '@squawk-cli/linux-x64@2.63.0': + optional: true + + '@squawk-cli/win32-x64@2.63.0': + optional: true + '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -24333,6 +24380,14 @@ snapshots: sqlstring@2.3.3: {} + squawk-cli@2.63.0: + optionalDependencies: + '@squawk-cli/darwin-arm64': 2.63.0 + '@squawk-cli/darwin-x64': 2.63.0 + '@squawk-cli/linux-arm64': 2.63.0 + '@squawk-cli/linux-x64': 2.63.0 + '@squawk-cli/win32-x64': 2.63.0 + ssri@8.0.1: dependencies: minipass: 3.3.6