diff --git a/.env.example b/.env.example index a2f103f73c..ef5649f1ae 100644 --- a/.env.example +++ b/.env.example @@ -68,7 +68,9 @@ HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres?sslmode=d ########################### # CUBE ANALYTICS (XM V5) # ########################### -# XM Suite v5 analysis features require Cube.js. The local dev stack exposes Cube on port 4000. +# XM Suite v5 analysis features require Cube.js. The optional xm dev profile exposes Cube on port 4000. +# Uncomment COMPOSE_PROFILES=xm to run the optional Cube analytics service. +# COMPOSE_PROFILES=xm CUBEJS_API_URL=http://localhost:4000 # Generate with: openssl rand -hex 32. `pnpm dev:setup` will create/preserve this automatically. CUBEJS_API_SECRET= diff --git a/apps/web/lib/env.test.ts b/apps/web/lib/env.test.ts index d7d7f932ae..90f598a741 100644 --- a/apps/web/lib/env.test.ts +++ b/apps/web/lib/env.test.ts @@ -109,6 +109,16 @@ describe("env", () => { expect(env.CUBEJS_API_SECRET).toBeUndefined(); }); + test("treats an empty Cube API secret from Docker Compose as omitted", async () => { + setTestEnv({ + CUBEJS_API_SECRET: "", + }); + + const { env } = await import("./env"); + + expect(env.CUBEJS_API_SECRET).toBeUndefined(); + }); + test("allows the Cube API URL to be omitted until analytics is used", async () => { setTestEnv({ CUBEJS_API_URL: undefined, @@ -119,6 +129,16 @@ describe("env", () => { expect(env.CUBEJS_API_URL).toBeUndefined(); }); + test("treats an empty Cube API URL as omitted", async () => { + setTestEnv({ + CUBEJS_API_URL: "", + }); + + const { env } = await import("./env"); + + expect(env.CUBEJS_API_URL).toBeUndefined(); + }); + test("fails to load when the Cube API URL is invalid", async () => { setTestEnv({ CUBEJS_API_URL: "not-a-url", diff --git a/apps/web/lib/env.ts b/apps/web/lib/env.ts index d2d447c9b0..97c69dcc86 100644 --- a/apps/web/lib/env.ts +++ b/apps/web/lib/env.ts @@ -141,6 +141,10 @@ const ZSurveySchedulingTimeZone = z.string().trim().min(1).refine(isValidIanaTim const ZSurveySchedulingLocalHour = z.coerce.number().int().min(0).max(23); const ZSurveySchedulingLocalMinute = z.coerce.number().int().min(0).max(59); +const emptyStringToUndefined = (value: unknown) => + typeof value === "string" && value.trim() === "" ? undefined : value; +const ZOptionalNonEmptyString = z.preprocess(emptyStringToUndefined, z.string().trim().min(1).optional()); +const ZOptionalUrl = z.preprocess(emptyStringToUndefined, z.url().optional()); const parsedEnv = createEnv({ /* @@ -194,10 +198,10 @@ const parsedEnv = createEnv({ AI_AZURE_API_KEY: z.string().optional(), AI_AZURE_API_VERSION: z.string().optional(), AI_AZURE_RESOURCE_NAME: z.string().optional(), - CUBEJS_API_SECRET: z.string().trim().min(1).optional(), - CUBEJS_API_URL: z.url().optional(), - CUBEJS_JWT_AUDIENCE: z.string().trim().min(1).optional(), - CUBEJS_JWT_ISSUER: z.string().trim().min(1).optional(), + CUBEJS_API_SECRET: ZOptionalNonEmptyString, + CUBEJS_API_URL: ZOptionalUrl, + CUBEJS_JWT_AUDIENCE: ZOptionalNonEmptyString, + CUBEJS_JWT_ISSUER: ZOptionalNonEmptyString, HTTP_PROXY: z.url().optional(), HTTPS_PROXY: z.url().optional(), HUB_API_URL: z.url(), diff --git a/apps/web/modules/ee/analysis/api/lib/cube-query-rewrite.test.ts b/apps/web/modules/ee/analysis/api/lib/cube-query-rewrite.test.ts index 78cb1a1d0d..1aba09ccd2 100644 --- a/apps/web/modules/ee/analysis/api/lib/cube-query-rewrite.test.ts +++ b/apps/web/modules/ee/analysis/api/lib/cube-query-rewrite.test.ts @@ -2,7 +2,10 @@ import { createRequire } from "node:module"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; const require = createRequire(import.meta.url); -const { queryRewrite } = require("../../../../../../../docker/cube/cube.js") as { +const cubeConfigPath = require.resolve("../../../../../../../docker/cube/cube.js"); +process.env.CUBEJS_API_SECRET = process.env.CUBEJS_API_SECRET || "cube-secret"; + +const { queryRewrite } = require(cubeConfigPath) as { queryRewrite: ( query: Record, context: { securityContext?: Record } @@ -34,6 +37,17 @@ describe("cube queryRewrite", () => { ); }); + test("rejects Cube startup without an API secret", () => { + const originalSecret = process.env.CUBEJS_API_SECRET; + delete process.env.CUBEJS_API_SECRET; + delete require.cache[cubeConfigPath]; + + expect(() => require(cubeConfigPath)).toThrow(/CUBEJS_API_SECRET is required to run Cube/); + + process.env.CUBEJS_API_SECRET = originalSecret || "cube-secret"; + delete require.cache[cubeConfigPath]; + }); + test("rejects queries without a rewrite context", () => { expect(() => queryRewrite( diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index b6525cecb6..a05efdd9ed 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -81,6 +81,7 @@ services: PGSSLMODE: disable cube: + profiles: ["xm"] image: cubejs/cube:v1.6.6 env_file: - apps/web/.env @@ -100,7 +101,7 @@ services: CUBEJS_DB_PASS: ${CUBEJS_DB_PASS:-postgres} CUBEJS_DB_PORT: ${CUBEJS_DB_PORT:-5432} CUBEJS_DEV_MODE: "true" - CUBEJS_API_SECRET: ${CUBEJS_API_SECRET:?CUBEJS_API_SECRET is required to run Cube} + CUBEJS_API_SECRET: ${CUBEJS_API_SECRET:-} CUBEJS_JWT_ISSUER: ${CUBEJS_JWT_ISSUER:-formbricks-web} CUBEJS_JWT_AUDIENCE: ${CUBEJS_JWT_AUDIENCE:-formbricks-cube} CUBEJS_DEFAULT_API_SCOPES: meta,data diff --git a/docker/README.md b/docker/README.md index c42205edf4..68ad7165b5 100644 --- a/docker/README.md +++ b/docker/README.md @@ -30,10 +30,10 @@ That's it! After running the command and providing the required information, vis ## Formbricks Hub and Cube -The stack includes the [Formbricks Hub](https://github.com/formbricks/hub) API (`ghcr.io/formbricks/hub`) and a bundled Cube.js service for XM Suite v5 analytics. Hub and Cube share the same database as Formbricks by default. +The stack includes the [Formbricks Hub](https://github.com/formbricks/hub) API (`ghcr.io/formbricks/hub`) and can also run a bundled Cube.js service for XM Suite v5 analytics. Hub and Cube share the same database as Formbricks by default, and Cube is enabled through the optional Docker Compose `xm` profile. - **Migrations**: A `hub-migrate` service runs Hub's database migrations (goose + river) before the Hub API starts. It runs on every `docker compose up` and is idempotent. -- **Production** (`docker/docker-compose.yml`): Set `HUB_API_KEY` and `CUBEJS_API_SECRET` (required). `HUB_API_URL` defaults to `http://hub:8080` and `CUBEJS_API_URL` defaults to `http://cube:4000` so the Formbricks app can reach both services 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. Override `HUB_DATABASE_URL` and `CUBEJS_DB_*` only if Hub or Cube should use a separate database. -- **Development** (`docker-compose.dev.yml`): Hub and Cube use the same local Postgres database. `HUB_API_KEY` defaults to `dev-api-key`, `CUBEJS_API_URL` defaults to `http://localhost:4000`, and `pnpm dev:setup` generates `CUBEJS_API_SECRET` in the repo root `.env`. +- **Production** (`docker/docker-compose.yml`): Set `HUB_API_KEY` (required). `HUB_API_URL` defaults to `http://hub:8080` so the Formbricks app can reach Hub inside the compose network. To enable XM Suite v5 analytics, set `COMPOSE_PROFILES=xm` and `CUBEJS_API_SECRET`; `CUBEJS_API_URL` defaults to `http://cube:4000`. Cube JWT issuer/audience default to `formbricks-web` and `formbricks-cube`, and the bundled Cube service exposes only `meta,data` API scopes. Override `HUB_DATABASE_URL` and `CUBEJS_DB_*` only if Hub or Cube should use a separate database. +- **Development** (`docker-compose.dev.yml`): Hub uses the same local Postgres database and `HUB_API_KEY` defaults to `dev-api-key`. Cube is behind the `xm` profile, `CUBEJS_API_URL` defaults to `http://localhost:4000`, and `pnpm dev:setup` generates `CUBEJS_API_SECRET` in the repo root `.env`. -In development, Hub is exposed locally on port **8080** and Cube on **4000** (with the Cube playground on **4001**). In production Docker Compose, Hub and Cube stay internal to the compose network and are reached via `http://hub:8080` and `http://cube:4000`. +In development, Hub is exposed locally on port **8080**. When the `xm` profile is enabled, Cube is exposed on **4000** (with the Cube playground on **4001**). In production Docker Compose, Hub stays internal to the compose network at `http://hub:8080`; Cube also stays internal at `http://cube:4000` when enabled. diff --git a/docker/cube/cube.js b/docker/cube/cube.js index 43c9961379..cc286a9c56 100644 --- a/docker/cube/cube.js +++ b/docker/cube/cube.js @@ -3,6 +3,16 @@ const TENANT_MEMBER = "FeedbackRecords.tenantId"; const REQUIRED_SCOPE = "xm:cube:query"; +function assertRequiredEnvironmentVariable(name) { + const value = process.env[name]; + + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`${name} is required to run Cube`); + } +} + +assertRequiredEnvironmentVariable("CUBEJS_API_SECRET"); + function getStringClaim(securityContext, claim) { const value = securityContext?.[claim]; if (typeof value !== "string") { diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 6330e54412..6a92313f37 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -38,9 +38,9 @@ x-environment: &environment # Hub database URL (optional). Default: same Postgres as Formbricks. Set only if Hub uses a separate DB. # HUB_DATABASE_URL: - # Cube.js analytics for XM Suite v5. Cube runs inside this compose stack by default. + # Cube.js analytics for XM Suite v5. Enable the optional xm profile and set CUBEJS_API_SECRET to run Cube. CUBEJS_API_URL: ${CUBEJS_API_URL:-http://cube:4000} - CUBEJS_API_SECRET: ${CUBEJS_API_SECRET:?CUBEJS_API_SECRET is required to run XM Suite v5 analytics} + CUBEJS_API_SECRET: ${CUBEJS_API_SECRET:-} CUBEJS_JWT_ISSUER: ${CUBEJS_JWT_ISSUER:-formbricks-web} CUBEJS_JWT_AUDIENCE: ${CUBEJS_JWT_AUDIENCE:-formbricks-cube} @@ -291,8 +291,9 @@ services: API_KEY: ${HUB_API_KEY:?HUB_API_KEY is required to run Hub} DATABASE_URL: ${HUB_DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/formbricks?sslmode=disable} - # Cube.js analytics service for XM Suite v5. Shares the Hub database by default. + # Optional Cube.js analytics service for XM Suite v5. Enable with COMPOSE_PROFILES=xm and set CUBEJS_API_SECRET. cube: + profiles: ["xm"] restart: always image: cubejs/cube:v1.6.6 depends_on: @@ -307,7 +308,7 @@ services: CUBEJS_DB_USER: ${CUBEJS_DB_USER:-postgres} CUBEJS_DB_PASS: ${CUBEJS_DB_PASS:-postgres} CUBEJS_DB_PORT: ${CUBEJS_DB_PORT:-5432} - CUBEJS_API_SECRET: ${CUBEJS_API_SECRET:?CUBEJS_API_SECRET is required to run Cube} + CUBEJS_API_SECRET: ${CUBEJS_API_SECRET:-} CUBEJS_JWT_ISSUER: ${CUBEJS_JWT_ISSUER:-formbricks-web} CUBEJS_JWT_AUDIENCE: ${CUBEJS_JWT_AUDIENCE:-formbricks-cube} CUBEJS_DEFAULT_API_SCOPES: meta,data diff --git a/docker/formbricks.sh b/docker/formbricks.sh index 6fe5aed065..0172743203 100755 --- a/docker/formbricks.sh +++ b/docker/formbricks.sh @@ -334,6 +334,7 @@ EOT hub_api_key=$(openssl rand -hex 32) cubejs_api_secret=$(openssl rand -hex 32) cat < .env +COMPOSE_PROFILES=xm HUB_API_KEY=$hub_api_key CUBEJS_API_SECRET=$cubejs_api_secret CUBEJS_JWT_ISSUER=formbricks-web diff --git a/docs/development/technical-handbook/cube-tenant-isolation.mdx b/docs/development/technical-handbook/cube-tenant-isolation.mdx index 5afbf36d93..06b8ffca9f 100644 --- a/docs/development/technical-handbook/cube-tenant-isolation.mdx +++ b/docs/development/technical-handbook/cube-tenant-isolation.mdx @@ -37,7 +37,7 @@ The controls assume query bodies are attacker-influenced. Tenant identity is nev Cube `queryRewrite` rejects missing tenant context, rejects caller-supplied tenant member usage, and - appends `FeedbackRecords.tenantId = securityContext.tenantId` to every query. + appends `FeedbackRecords.tenantId = securityContext.workspaceId` to every query. diff --git a/docs/self-hosting/configuration/environment-variables.mdx b/docs/self-hosting/configuration/environment-variables.mdx index b9b7afec5a..d376656294 100644 --- a/docs/self-hosting/configuration/environment-variables.mdx +++ b/docs/self-hosting/configuration/environment-variables.mdx @@ -99,6 +99,7 @@ For `AI_PROVIDER=google`, use a Gemini model ID such as `gemini-2.5-flash` toget #### Formbricks Hub When running the stack with [Formbricks Hub](https://github.com/formbricks/hub) (for example via Docker Compose or Helm), the following variables apply: +The bundled Docker Compose stack starts Hub by default. | Variable | Description | Required | Default | | ---------------- | ---------------------------------------------------------------------------------- | -------- | --------------------------------------------------- | @@ -110,6 +111,7 @@ When running the stack with [Formbricks Hub](https://github.com/formbricks/hub) XM Suite v5 dashboard and analysis features require a reachable Cube.js instance. Formbricks generates the backend Cube JWT from `CUBEJS_API_SECRET`, so `CUBEJS_API_TOKEN` is not part of the supported setup contract. +If you do not use XM Suite v5 analytics, omit the Cube variables and leave the bundled Docker `xm` profile disabled. | Variable | Description | Required | Default | | ------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------- | ------------------------------------ | diff --git a/docs/self-hosting/setup/docker.mdx b/docs/self-hosting/setup/docker.mdx index 35835a8a36..b4b5488767 100644 --- a/docs/self-hosting/setup/docker.mdx +++ b/docs/self-hosting/setup/docker.mdx @@ -36,12 +36,15 @@ Make sure Docker and Docker Compose are installed on your system. These are usua curl -o cube/schema/FeedbackRecords.js https://raw.githubusercontent.com/formbricks/formbricks/stable/docker/cube/schema/FeedbackRecords.js ``` -1. **Generate Hub and Cube Secrets** +1. **Generate Hub Secret and Optional Cube Secret** - XM Suite v5 analytics requires Formbricks Hub and Cube.js. Create a local `.env` file for the required shared secrets: + Formbricks Hub requires an API key. XM Suite v5 analytics also requires Cube.js; set the optional `xm` + Compose profile and Cube secret when you want to run the bundled Cube service. For a Hub-only stack, create + `.env` with just `HUB_API_KEY` and omit `COMPOSE_PROFILES` and `CUBEJS_API_SECRET`. ```bash cat < .env + COMPOSE_PROFILES=xm HUB_API_KEY=$(openssl rand -hex 32) CUBEJS_API_SECRET=$(openssl rand -hex 32) CUBEJS_JWT_ISSUER=formbricks-web @@ -100,7 +103,9 @@ Make sure Docker and Docker Compose are installed on your system. These are usua 1. **Start the Docker Setup** - Now, you're ready to run Formbricks with Docker. Use the command below to start Formbricks together with PostgreSQL, Redis, Formbricks Hub, and Cube.js: + Now, you're ready to run Formbricks with Docker. Use the command below to start Formbricks together with + PostgreSQL, Redis, and Formbricks Hub. If the `xm` profile is set in `.env`, Docker Compose also starts Cube.js + for XM Suite v5 analytics. ```bash docker compose up -d @@ -113,8 +118,8 @@ Make sure Docker and Docker Compose are installed on your system. These are usua Once the setup is running, open [**http://localhost:3000**](http://localhost:3000) in your browser to access Formbricks. The first time you visit, you'll see a setup wizard. Follow the steps to create your first user and start using Formbricks. - The bundled Docker stack keeps Formbricks Hub and Cube.js internal to the compose network. The app reaches - them through `http://hub:8080` and `http://cube:4000`. + The bundled Docker stack keeps Formbricks Hub internal to the compose network. When the `xm` profile is + enabled, Cube.js is internal too. The app reaches them through `http://hub:8080` and `http://cube:4000`. ## Update