fix: make bundled cube optional

This commit is contained in:
Tiago Farto
2026-05-04 11:03:37 +00:00
parent 2abf8e1d8c
commit dd757394af
12 changed files with 81 additions and 21 deletions
+3 -1
View File
@@ -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=
+20
View File
@@ -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",
+8 -4
View File
@@ -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(),
@@ -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<string, unknown>,
context: { securityContext?: Record<string, unknown> }
@@ -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(
+2 -1
View File
@@ -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
+4 -4
View File
@@ -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.
+10
View File
@@ -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") {
+5 -4
View File
@@ -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
+1
View File
@@ -334,6 +334,7 @@ EOT
hub_api_key=$(openssl rand -hex 32)
cubejs_api_secret=$(openssl rand -hex 32)
cat <<EOF > .env
COMPOSE_PROFILES=xm
HUB_API_KEY=$hub_api_key
CUBEJS_API_SECRET=$cubejs_api_secret
CUBEJS_JWT_ISSUER=formbricks-web
@@ -37,7 +37,7 @@ The controls assume query bodies are attacker-influenced. Tenant identity is nev
</Step>
<Step title="Enforce the tenant filter">
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.
</Step>
</Steps>
@@ -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 |
| ------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------- | ------------------------------------ |
+10 -5
View File
@@ -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 <<EOF > .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.
<Note>
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`.
</Note>
## Update