diff --git a/.env b/.env
index c042b96342..bb20af048c 100644
--- a/.env
+++ b/.env
@@ -145,15 +145,16 @@ DJANGO_BROKER_VISIBILITY_TIMEOUT=86400
DJANGO_SENTRY_DSN=
DJANGO_THROTTLE_TOKEN_OBTAIN=50/minute
-# Sentry for the web app (server + browser). Empty/unset UI_SENTRY_DSN ⇒
-# Sentry disabled, zero egress. SENTRY_RELEASE (unprefixed) feeds the web app's
-# server/edge SDKs.
+# Sentry for the web app (server + browser). The UI_SENTRY_* values load only
+# when UI_SENTRY_ENABLE="true"; without it they are ignored (default off, zero
+# egress). The deprecated NEXT_PUBLIC_SENTRY_DSN still activates Sentry without
+# the flag. SENTRY_RELEASE (unprefixed) feeds the web app's server/edge SDKs.
UI_SENTRY_DSN=
UI_SENTRY_ENVIRONMENT=local
SENTRY_RELEASE=local
# Reserved runtime public config (registered now; no UI consumer yet)
-# POSTHOG_KEY=
-# POSTHOG_HOST=
+# UI_POSTHOG_KEY=
+# UI_POSTHOG_HOST=
# REO_DEV_CLIENT_ID=
#### Prowler release version ####
diff --git a/docs/developer-guide/environment-variables.mdx b/docs/developer-guide/environment-variables.mdx
index 1f4d17ee2e..ce51e06bbb 100644
--- a/docs/developer-guide/environment-variables.mdx
+++ b/docs/developer-guide/environment-variables.mdx
@@ -39,6 +39,24 @@ The former build-time variables map to the new runtime variables as follows:
The build-time-only Sentry variables used for source-map upload — `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN`, and `SENTRY_RELEASE` — keep their names, as they are not part of the App's runtime configuration.
+## Enabling Third-Party Integrations
+
+Prowler App gates each optional third-party integration behind an explicit enable flag. When an integration is configured through its new `UI_*` variables, it loads only when its flag is set to the exact string `"true"`; any other value, including unset, leaves it off. This default-off behavior keeps a deployment free of third-party egress unless it opts in. Deployments still using the deprecated legacy variable names keep loading without the flag, for backward compatibility (see [Deprecated Names](#deprecated-names)).
+
+| Integration | Enable flag | Required configuration when enabled |
+|-------------|-------------|-------------------------------------|
+| Sentry (error monitoring) | `UI_SENTRY_ENABLE` | `UI_SENTRY_DSN` |
+| Google Tag Manager | `UI_GOOGLE_TAG_MANAGER_ENABLE` | `UI_GOOGLE_TAG_MANAGER_ID` |
+| PostHog (product analytics) | `UI_POSTHOG_ENABLE` | `UI_POSTHOG_KEY` and `UI_POSTHOG_HOST` |
+
+When an integration is enabled but its required configuration is missing, Prowler App fails fast at server startup with a clear error, so a misconfigured container never starts silently. A new `UI_*` value set while its enable flag is not `"true"` is ignored, and the server logs a one-time startup warning noting that the integration will not load. Legacy names follow the backward-compatible rule described in [Deprecated Names](#deprecated-names).
+
+PostHog support is currently limited to configuration validation: Prowler App reads and validates the PostHog variables but does not yet load a PostHog client.
+
+
+Configuring an integration through the new `UI_*` variables now requires its enable flag. A deployment that adopted `UI_SENTRY_DSN` or `UI_GOOGLE_TAG_MANAGER_ID` must also set `UI_SENTRY_ENABLE=true` or `UI_GOOGLE_TAG_MANAGER_ENABLE=true` to keep the integration active. Deployments still using the legacy names (`NEXT_PUBLIC_*`, or `POSTHOG_KEY` and `POSTHOG_HOST`) keep working without the flag.
+
+
## Upcoming Breaking Change
@@ -49,5 +67,5 @@ Prowler API, Prowler SDK, and Prowler MCP Server have not yet adopted the conven
## Deprecated Names
-- **Prowler App:** The bare server-side `SENTRY_DSN` and `SENTRY_ENVIRONMENT` are no longer read; the server and edge runtimes now read `UI_SENTRY_DSN` and `UI_SENTRY_ENVIRONMENT`. The former `NEXT_PUBLIC_*` build-time variables are deprecated but still read at runtime as a fallback when the matching `UI_*` variable is unset. This fallback will be removed in a future release, so set the `UI_*` runtime variables on the running container.
+- **Prowler App:** The bare server-side `SENTRY_DSN` and `SENTRY_ENVIRONMENT` are no longer read; the server and edge runtimes now read `UI_SENTRY_DSN` and `UI_SENTRY_ENVIRONMENT`. The former `NEXT_PUBLIC_*` names — and, for PostHog, the unprefixed `POSTHOG_KEY` and `POSTHOG_HOST` — are deprecated but stay backward compatible: they are read at runtime regardless of the enable flag, so an existing deployment keeps its integration active without opting in. The new `UI_*` names, by contrast, load only when the matching enable flag is set to `"true"`. These legacy names will be removed in a future release, so migrate to the `UI_*` runtime variables — and set the enable flag — on the running container.
- **Prowler API, Prowler SDK, and Prowler MCP Server:** The current, unprefixed variable names are deprecated. They continue to work today and will be removed once the prefixed convention is adopted for each component, as described in [Upcoming Breaking Change](#upcoming-breaking-change).
diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md
index bab2b9cde2..115ef1e2b5 100644
--- a/ui/CHANGELOG.md
+++ b/ui/CHANGELOG.md
@@ -11,6 +11,7 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🔄 Changed
- Organization row actions in the profile page are aligned in fixed columns and the Active indicator now sits next to the organization name [(#11864)](https://github.com/prowler-cloud/prowler/pull/11864)
+- Sentry, Google Tag Manager, and PostHog now load their `UI_*` config only when the matching enable flag (`UI_SENTRY_ENABLE` / `UI_GOOGLE_TAG_MANAGER_ENABLE` / `UI_POSTHOG_ENABLE`) is `"true"` (default off); the deprecated legacy names (`NEXT_PUBLIC_*`, `POSTHOG_KEY`/`POSTHOG_HOST`) still activate without the flag [(#11682)](https://github.com/prowler-cloud/prowler/pull/11682)
---
diff --git a/ui/Dockerfile b/ui/Dockerfile
index 86673ba046..03d50be231 100644
--- a/ui/Dockerfile
+++ b/ui/Dockerfile
@@ -78,8 +78,14 @@ ENV HOSTNAME="0.0.0.0"
# NOT baked into the image. Supply it via your orchestrator (docker-compose,
# Helm/K8s):
# - required: UI_API_BASE_URL, AUTH_URL, AUTH_SECRET (missing ⇒ fail fast at boot)
-# - optional: UI_API_DOCS_URL, UI_GOOGLE_TAG_MANAGER_ID, UI_SENTRY_DSN, UI_SENTRY_ENVIRONMENT
-# - reserved: POSTHOG_KEY, POSTHOG_HOST, REO_DEV_CLIENT_ID (no consumer yet)
+# - optional: UI_API_DOCS_URL
+# - gated integrations (load only when *_ENABLE="true"; the value is then
+# required or boot fails). Legacy names (NEXT_PUBLIC_*, POSTHOG_KEY/HOST)
+# still activate without the flag:
+# UI_SENTRY_ENABLE + UI_SENTRY_DSN (+ optional UI_SENTRY_ENVIRONMENT)
+# UI_GOOGLE_TAG_MANAGER_ENABLE + UI_GOOGLE_TAG_MANAGER_ID
+# UI_POSTHOG_ENABLE + UI_POSTHOG_KEY + UI_POSTHOG_HOST (no consumer yet)
+# - reserved: REO_DEV_CLIENT_ID (no consumer yet)
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD ["node", "server.js"]
diff --git a/ui/app/(auth)/layout.tsx b/ui/app/(auth)/layout.tsx
index 79a4ce4e89..bf1e82db8f 100644
--- a/ui/app/(auth)/layout.tsx
+++ b/ui/app/(auth)/layout.tsx
@@ -10,7 +10,7 @@ import { NavigationProgress, Toaster } from "@/components/ui";
import { fontSans } from "@/config/fonts";
import { siteConfig } from "@/config/site";
import { cn } from "@/lib";
-import { readEnv } from "@/lib/runtime-env";
+import { readGatedEnv } from "@/lib/integrations";
import { Providers } from "../providers";
@@ -42,8 +42,8 @@ export default async function AuthLayout({
// island's own connection() call).
await connection();
- // Server-side runtime read. Empty/unset id ⇒ GoogleTagManager is not mounted
- const gtmId = readEnv(
+ const gtmId = readGatedEnv(
+ "UI_GOOGLE_TAG_MANAGER_ENABLE",
"UI_GOOGLE_TAG_MANAGER_ID",
"NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID",
);
diff --git a/ui/instrumentation.ts b/ui/instrumentation.ts
index 6716ffeb4e..be157251e5 100644
--- a/ui/instrumentation.ts
+++ b/ui/instrumentation.ts
@@ -19,9 +19,13 @@ import "@/lib/env";
import * as Sentry from "@sentry/nextjs";
-import { readEnv } from "@/lib/runtime-env";
+import { readGatedEnv } from "@/lib/integrations";
-const sentryDsn = readEnv("UI_SENTRY_DSN", "NEXT_PUBLIC_SENTRY_DSN");
+const sentryDsn = readGatedEnv(
+ "UI_SENTRY_ENABLE",
+ "UI_SENTRY_DSN",
+ "NEXT_PUBLIC_SENTRY_DSN",
+);
export async function register() {
// Skip Sentry initialization if DSN is not configured
diff --git a/ui/lib/env.test.ts b/ui/lib/env.test.ts
index 5824bbeab0..1b4bb8df12 100644
--- a/ui/lib/env.test.ts
+++ b/ui/lib/env.test.ts
@@ -44,3 +44,101 @@ describe("lib/env boot assertion", () => {
await expect(import("@/lib/env")).resolves.toBeDefined();
});
});
+
+describe("lib/env gated integration validation", () => {
+ // Clear every gated flag/config so ambient shell env cannot affect assertions,
+ // then satisfy the unconditional REQUIRED vars (they are checked first).
+ const GATED_ENV_VARS = [
+ "UI_SENTRY_ENABLE",
+ "UI_SENTRY_DSN",
+ "NEXT_PUBLIC_SENTRY_DSN",
+ "UI_SENTRY_ENVIRONMENT",
+ "NEXT_PUBLIC_SENTRY_ENVIRONMENT",
+ "UI_GOOGLE_TAG_MANAGER_ENABLE",
+ "UI_GOOGLE_TAG_MANAGER_ID",
+ "NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID",
+ "UI_POSTHOG_ENABLE",
+ "UI_POSTHOG_KEY",
+ "POSTHOG_KEY",
+ "UI_POSTHOG_HOST",
+ "POSTHOG_HOST",
+ ] as const;
+
+ beforeEach(() => {
+ vi.resetModules();
+ for (const key of GATED_ENV_VARS) {
+ vi.stubEnv(key, undefined);
+ }
+ vi.stubEnv("UI_API_BASE_URL", "https://api.example.com/api/v1");
+ vi.stubEnv("AUTH_URL", "http://localhost:3000");
+ vi.stubEnv("AUTH_SECRET", "secret");
+ vi.spyOn(console, "warn").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+ });
+
+ it("throws when UI_SENTRY_ENABLE is true but the DSN is unset", async () => {
+ vi.stubEnv("UI_SENTRY_ENABLE", "true");
+
+ await expect(import("@/lib/env")).rejects.toThrow("UI_SENTRY_DSN");
+ });
+
+ it("resolves when UI_SENTRY_ENABLE is true and the DSN is present", async () => {
+ vi.stubEnv("UI_SENTRY_ENABLE", "true");
+ vi.stubEnv("UI_SENTRY_DSN", "https://key@o0.ingest.sentry.io/1");
+
+ await expect(import("@/lib/env")).resolves.toBeDefined();
+ });
+
+ it("does not throw when an integration is disabled and its config is unset", async () => {
+ // No enable flags set — the highest-value non-regression: a default
+ // deployment that never opted into any integration must still boot.
+ await expect(import("@/lib/env")).resolves.toBeDefined();
+ });
+
+ it("throws when UI_GOOGLE_TAG_MANAGER_ENABLE is true without an id", async () => {
+ vi.stubEnv("UI_GOOGLE_TAG_MANAGER_ENABLE", "true");
+
+ await expect(import("@/lib/env")).rejects.toThrow(
+ "UI_GOOGLE_TAG_MANAGER_ID",
+ );
+ });
+
+ it("throws on UI_POSTHOG_HOST when PostHog is enabled with only the key", async () => {
+ vi.stubEnv("UI_POSTHOG_ENABLE", "true");
+ vi.stubEnv("UI_POSTHOG_KEY", "phc_key");
+
+ await expect(import("@/lib/env")).rejects.toThrow("UI_POSTHOG_HOST");
+ });
+
+ it("resolves when PostHog is enabled with both key and host", async () => {
+ vi.stubEnv("UI_POSTHOG_ENABLE", "true");
+ vi.stubEnv("UI_POSTHOG_KEY", "phc_key");
+ vi.stubEnv("UI_POSTHOG_HOST", "https://eu.i.posthog.com");
+
+ await expect(import("@/lib/env")).resolves.toBeDefined();
+ });
+
+ it("resolves when the legacy Sentry DSN is set without the enable flag", async () => {
+ // Legacy names stay backward compatible: they activate without the flag.
+ vi.stubEnv("NEXT_PUBLIC_SENTRY_DSN", "https://key@o0.ingest.sentry.io/1");
+
+ await expect(import("@/lib/env")).resolves.toBeDefined();
+ });
+
+ it("resolves when the legacy PostHog names are set without the enable flag", async () => {
+ vi.stubEnv("POSTHOG_KEY", "phc_key");
+ vi.stubEnv("POSTHOG_HOST", "https://eu.i.posthog.com");
+
+ await expect(import("@/lib/env")).resolves.toBeDefined();
+ });
+
+ it("throws on a partial legacy PostHog config set without the enable flag", async () => {
+ vi.stubEnv("POSTHOG_KEY", "phc_key");
+
+ await expect(import("@/lib/env")).rejects.toThrow("POSTHOG_HOST");
+ });
+});
diff --git a/ui/lib/env.ts b/ui/lib/env.ts
index 33057e60d4..e32828ebf2 100644
--- a/ui/lib/env.ts
+++ b/ui/lib/env.ts
@@ -1,3 +1,7 @@
+import {
+ assertGatedIntegrations,
+ warnGatedIntegrationsMisconfig,
+} from "@/lib/integrations";
import { readEnv } from "@/lib/runtime-env";
// Boot-time required-env assertion so a misconfigured container fails fast
@@ -18,4 +22,7 @@ for (const { key, legacy } of REQUIRED) {
}
}
+assertGatedIntegrations();
+warnGatedIntegrationsMisconfig();
+
export {};
diff --git a/ui/lib/get-runtime-config.client.test.ts b/ui/lib/get-runtime-config.client.test.ts
index 553c3be421..d4c91cf7e3 100644
--- a/ui/lib/get-runtime-config.client.test.ts
+++ b/ui/lib/get-runtime-config.client.test.ts
@@ -93,11 +93,12 @@ describe("getRuntimeConfigClient", () => {
// When
const config = getRuntimeConfigClient();
- // Then - exactly the eight allowlisted keys, nothing else
+ // Then - exactly the allowlisted keys, nothing else
expect(Object.keys(config).sort()).toEqual(
[
"apiBaseUrl",
"apiDocsUrl",
+ "billingCloudEnable",
"googleTagManagerId",
"posthogHost",
"posthogKey",
@@ -107,6 +108,9 @@ describe("getRuntimeConfigClient", () => {
].sort(),
);
expect(config.apiBaseUrl).toBe("https://api.example.com/api/v1");
+ // billingCloudEnable is a boolean flag, so it defaults to false (not null)
+ // when absent from the island.
+ expect(config.billingCloudEnable).toBe(false);
expect(
(config as unknown as Record).notAllowlisted,
).toBeUndefined();
diff --git a/ui/lib/get-runtime-config.client.ts b/ui/lib/get-runtime-config.client.ts
index c27378d6f9..0ae7d94de8 100644
--- a/ui/lib/get-runtime-config.client.ts
+++ b/ui/lib/get-runtime-config.client.ts
@@ -20,6 +20,7 @@ const pickConfig = (
posthogKey: parsed.posthogKey ?? null,
posthogHost: parsed.posthogHost ?? null,
reoDevClientId: parsed.reoDevClientId ?? null,
+ billingCloudEnable: parsed.billingCloudEnable ?? false,
});
// Reads the island once (memoized); all-null during SSR or if it's
diff --git a/ui/lib/integrations.test.ts b/ui/lib/integrations.test.ts
new file mode 100644
index 0000000000..78fd59b67f
--- /dev/null
+++ b/ui/lib/integrations.test.ts
@@ -0,0 +1,265 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ assertGatedIntegrations,
+ readGatedEnv,
+ warnGatedIntegrationsMisconfig,
+} from "./integrations";
+
+// Every env var any gated integration reads. Cleared before each test so the
+// assertions never depend on ambient shell/CI env.
+const GATED_ENV_VARS = [
+ "UI_SENTRY_ENABLE",
+ "UI_SENTRY_DSN",
+ "NEXT_PUBLIC_SENTRY_DSN",
+ "UI_SENTRY_ENVIRONMENT",
+ "NEXT_PUBLIC_SENTRY_ENVIRONMENT",
+ "UI_GOOGLE_TAG_MANAGER_ENABLE",
+ "UI_GOOGLE_TAG_MANAGER_ID",
+ "NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID",
+ "UI_POSTHOG_ENABLE",
+ "UI_POSTHOG_KEY",
+ "POSTHOG_KEY",
+ "UI_POSTHOG_HOST",
+ "POSTHOG_HOST",
+] as const;
+
+beforeEach(() => {
+ for (const key of GATED_ENV_VARS) {
+ vi.stubEnv(key, undefined);
+ }
+});
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+});
+
+describe("readGatedEnv", () => {
+ it("returns the primary value when the integration is enabled", () => {
+ // Given
+ vi.stubEnv("UI_SENTRY_ENABLE", "true");
+ vi.stubEnv("UI_SENTRY_DSN", "https://dsn.example");
+
+ // When / Then
+ expect(
+ readGatedEnv(
+ "UI_SENTRY_ENABLE",
+ "UI_SENTRY_DSN",
+ "NEXT_PUBLIC_SENTRY_DSN",
+ ),
+ ).toBe("https://dsn.example");
+ });
+
+ it("falls back to the legacy value when enabled and the primary is unset", () => {
+ // Given
+ vi.stubEnv("UI_SENTRY_ENABLE", "true");
+ vi.stubEnv("NEXT_PUBLIC_SENTRY_DSN", "https://legacy.example");
+
+ // When / Then
+ expect(
+ readGatedEnv(
+ "UI_SENTRY_ENABLE",
+ "UI_SENTRY_DSN",
+ "NEXT_PUBLIC_SENTRY_DSN",
+ ),
+ ).toBe("https://legacy.example");
+ });
+
+ it("ignores the primary (new) value when disabled and no legacy is set", () => {
+ // Given - the new UI_* name only counts when the enable flag is "true"
+ vi.stubEnv("UI_SENTRY_ENABLE", "false");
+ vi.stubEnv("UI_SENTRY_DSN", "https://dsn.example");
+
+ // When / Then
+ expect(
+ readGatedEnv(
+ "UI_SENTRY_ENABLE",
+ "UI_SENTRY_DSN",
+ "NEXT_PUBLIC_SENTRY_DSN",
+ ),
+ ).toBeNull();
+ });
+
+ it("returns the legacy value when disabled (legacy ignores the enable flag)", () => {
+ // Given - legacy names stay backward compatible: they work without the flag
+ vi.stubEnv("UI_SENTRY_ENABLE", "false");
+ vi.stubEnv("NEXT_PUBLIC_SENTRY_DSN", "https://legacy.example");
+
+ // When / Then
+ expect(
+ readGatedEnv(
+ "UI_SENTRY_ENABLE",
+ "UI_SENTRY_DSN",
+ "NEXT_PUBLIC_SENTRY_DSN",
+ ),
+ ).toBe("https://legacy.example");
+ });
+
+ it("returns the legacy value when disabled even if the new value is also set", () => {
+ // Given - new value is ignored without the flag; legacy still activates
+ vi.stubEnv("UI_SENTRY_ENABLE", "false");
+ vi.stubEnv("UI_SENTRY_DSN", "https://dsn.example");
+ vi.stubEnv("NEXT_PUBLIC_SENTRY_DSN", "https://legacy.example");
+
+ // When / Then
+ expect(
+ readGatedEnv(
+ "UI_SENTRY_ENABLE",
+ "UI_SENTRY_DSN",
+ "NEXT_PUBLIC_SENTRY_DSN",
+ ),
+ ).toBe("https://legacy.example");
+ });
+
+ it("returns null when the enable flag is unset and only the new name is set", () => {
+ // Given
+ vi.stubEnv("UI_SENTRY_DSN", "https://dsn.example");
+
+ // When / Then
+ expect(readGatedEnv("UI_SENTRY_ENABLE", "UI_SENTRY_DSN")).toBeNull();
+ });
+});
+
+describe("assertGatedIntegrations", () => {
+ it("does not throw when every integration is disabled (the default)", () => {
+ expect(() => assertGatedIntegrations()).not.toThrow();
+ });
+
+ it("throws when Sentry is enabled but the DSN is unset", () => {
+ // Given
+ vi.stubEnv("UI_SENTRY_ENABLE", "true");
+
+ // When / Then
+ expect(() => assertGatedIntegrations()).toThrow("UI_SENTRY_DSN");
+ });
+
+ it("does not throw when Sentry is enabled and the DSN is present", () => {
+ // Given
+ vi.stubEnv("UI_SENTRY_ENABLE", "true");
+ vi.stubEnv("UI_SENTRY_DSN", "https://dsn.example");
+
+ // When / Then
+ expect(() => assertGatedIntegrations()).not.toThrow();
+ });
+
+ it("accepts the legacy DSN name when Sentry is enabled", () => {
+ // Given
+ vi.stubEnv("UI_SENTRY_ENABLE", "true");
+ vi.stubEnv("NEXT_PUBLIC_SENTRY_DSN", "https://legacy.example");
+
+ // When / Then
+ expect(() => assertGatedIntegrations()).not.toThrow();
+ });
+
+ it("does not require the optional Sentry environment when enabled", () => {
+ // Given - DSN present, UI_SENTRY_ENVIRONMENT intentionally unset
+ vi.stubEnv("UI_SENTRY_ENABLE", "true");
+ vi.stubEnv("UI_SENTRY_DSN", "https://dsn.example");
+
+ // When / Then
+ expect(() => assertGatedIntegrations()).not.toThrow();
+ });
+
+ it("throws when GTM is enabled without an id", () => {
+ // Given
+ vi.stubEnv("UI_GOOGLE_TAG_MANAGER_ENABLE", "true");
+
+ // When / Then
+ expect(() => assertGatedIntegrations()).toThrow("UI_GOOGLE_TAG_MANAGER_ID");
+ });
+
+ it("requires BOTH UI_POSTHOG_KEY and UI_POSTHOG_HOST when PostHog is enabled", () => {
+ // Given - key set, host missing
+ vi.stubEnv("UI_POSTHOG_ENABLE", "true");
+ vi.stubEnv("UI_POSTHOG_KEY", "phc_key");
+
+ // When / Then
+ expect(() => assertGatedIntegrations()).toThrow("UI_POSTHOG_HOST");
+ });
+
+ it("does not throw when PostHog is enabled with both key and host", () => {
+ // Given
+ vi.stubEnv("UI_POSTHOG_ENABLE", "true");
+ vi.stubEnv("UI_POSTHOG_KEY", "phc_key");
+ vi.stubEnv("UI_POSTHOG_HOST", "https://eu.i.posthog.com");
+
+ // When / Then
+ expect(() => assertGatedIntegrations()).not.toThrow();
+ });
+
+ it("accepts the legacy Sentry DSN without the enable flag", () => {
+ // Given - backward compat: legacy presence activates without the flag
+ vi.stubEnv("NEXT_PUBLIC_SENTRY_DSN", "https://legacy.example");
+
+ // When / Then
+ expect(() => assertGatedIntegrations()).not.toThrow();
+ });
+
+ it("accepts the legacy PostHog names without the enable flag", () => {
+ // Given - both legacy names present, no UI_POSTHOG_ENABLE
+ vi.stubEnv("POSTHOG_KEY", "phc_key");
+ vi.stubEnv("POSTHOG_HOST", "https://eu.i.posthog.com");
+
+ // When / Then
+ expect(() => assertGatedIntegrations()).not.toThrow();
+ });
+
+ it("throws when a partial legacy PostHog config is set without the enable flag", () => {
+ // Given - one legacy name present; the full legacy set is then required
+ vi.stubEnv("POSTHOG_KEY", "phc_key");
+
+ // When / Then
+ expect(() => assertGatedIntegrations()).toThrow("POSTHOG_HOST");
+ });
+});
+
+describe("warnGatedIntegrationsMisconfig", () => {
+ it("warns when a config value is set but its enable flag is not 'true'", () => {
+ // Given
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ vi.stubEnv("UI_SENTRY_DSN", "https://dsn.example");
+
+ // When
+ warnGatedIntegrationsMisconfig();
+
+ // Then
+ expect(warn).toHaveBeenCalledTimes(1);
+ expect(warn.mock.calls[0][0]).toContain("UI_SENTRY_ENABLE");
+ });
+
+ it("does not warn when the integration is enabled", () => {
+ // Given
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ vi.stubEnv("UI_SENTRY_ENABLE", "true");
+ vi.stubEnv("UI_SENTRY_DSN", "https://dsn.example");
+
+ // When
+ warnGatedIntegrationsMisconfig();
+
+ // Then
+ expect(warn).not.toHaveBeenCalled();
+ });
+
+ it("does not warn when nothing is configured", () => {
+ // Given
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+
+ // When
+ warnGatedIntegrationsMisconfig();
+
+ // Then
+ expect(warn).not.toHaveBeenCalled();
+ });
+
+ it("does not warn when only a legacy name is set without the enable flag", () => {
+ // Given - legacy stays backward compatible, so it loads and is not a misconfig
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ vi.stubEnv("NEXT_PUBLIC_SENTRY_DSN", "https://legacy.example");
+
+ // When
+ warnGatedIntegrationsMisconfig();
+
+ // Then
+ expect(warn).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui/lib/integrations.ts b/ui/lib/integrations.ts
new file mode 100644
index 0000000000..a5441a2e7b
--- /dev/null
+++ b/ui/lib/integrations.ts
@@ -0,0 +1,135 @@
+import { readBoolEnv, readEnv } from "@/lib/runtime-env";
+
+// Single source of truth for the third-party integrations gated behind a
+// UI_*_ENABLE flag. The same map drives boot validation (lib/env.ts) and the
+// runtime-config gating (lib/runtime-config.ts) so the two cannot drift.
+//
+// Two activation paths, applied uniformly to every integration:
+// - New `UI_*` names count only when the enable flag is "true" (explicit
+// opt-in; default off ⇒ no third-party egress).
+// - Legacy names stay backward compatible: their presence activates the
+// integration regardless of the flag, matching the pre-enable-flag
+// behavior, so an existing deployment keeps working untouched. A partial
+// legacy config fails fast (all required legacy names must be set), just
+// like an enabled one.
+interface IntegrationEnvVar {
+ key: keyof NodeJS.ProcessEnv;
+ legacy?: keyof NodeJS.ProcessEnv;
+}
+
+interface GatedIntegration {
+ name: string;
+ enableKey: keyof NodeJS.ProcessEnv;
+ required: ReadonlyArray;
+ optional: ReadonlyArray;
+}
+
+export const GATED_INTEGRATIONS: Record = {
+ sentry: {
+ name: "Sentry",
+ enableKey: "UI_SENTRY_ENABLE",
+ required: [{ key: "UI_SENTRY_DSN", legacy: "NEXT_PUBLIC_SENTRY_DSN" }],
+ optional: [
+ {
+ key: "UI_SENTRY_ENVIRONMENT",
+ legacy: "NEXT_PUBLIC_SENTRY_ENVIRONMENT",
+ },
+ ],
+ },
+ googleTagManager: {
+ name: "Google Tag Manager",
+ enableKey: "UI_GOOGLE_TAG_MANAGER_ENABLE",
+ required: [
+ {
+ key: "UI_GOOGLE_TAG_MANAGER_ID",
+ legacy: "NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID",
+ },
+ ],
+ optional: [],
+ },
+ posthog: {
+ name: "PostHog",
+ enableKey: "UI_POSTHOG_ENABLE",
+ required: [
+ { key: "UI_POSTHOG_KEY", legacy: "POSTHOG_KEY" },
+ { key: "UI_POSTHOG_HOST", legacy: "POSTHOG_HOST" },
+ ],
+ optional: [],
+ },
+} as const satisfies Record;
+
+// Resolve a config value honoring the gate. The new `primary` (UI_*) name is
+// read only when the enable flag is "true"; the `legacy` name is read
+// regardless of the flag so a pre-existing deployment keeps working. When the
+// flag is "true" the new name wins, falling back to the legacy name.
+export function readGatedEnv(
+ enableKey: keyof NodeJS.ProcessEnv,
+ primary: keyof NodeJS.ProcessEnv,
+ legacy?: keyof NodeJS.ProcessEnv,
+): string | null {
+ const legacyValue = legacy ? readEnv(legacy) : null;
+ return readBoolEnv(enableKey)
+ ? (readEnv(primary) ?? legacyValue)
+ : legacyValue;
+}
+
+// True when every required var has a legacy name and all are set — the
+// backward-compatible activation path that works without the enable flag.
+function hasCompleteLegacyConfig(integration: GatedIntegration): boolean {
+ return (
+ integration.required.length > 0 &&
+ integration.required.every(({ legacy }) => legacy && readEnv(legacy))
+ );
+}
+
+// True when any required legacy name is set, i.e. the deployment is attempting
+// legacy activation (possibly half-configured).
+function hasAnyLegacyConfig(integration: GatedIntegration): boolean {
+ return integration.required.some(({ legacy }) => legacy && readEnv(legacy));
+}
+
+// Boot-time fail-fast for an incomplete required config. When the flag is
+// "true", each required var must resolve via its UI_* name or legacy fallback.
+// When the flag is not set but a legacy name is present, the deployment is on
+// the legacy path, so the full legacy set is required. Optional vars are never
+// checked.
+export function assertGatedIntegrations(): void {
+ for (const integration of Object.values(GATED_INTEGRATIONS)) {
+ if (readBoolEnv(integration.enableKey)) {
+ for (const { key, legacy } of integration.required) {
+ if (!readEnv(key, legacy)) {
+ throw new Error(
+ `Missing required env: ${key} (${integration.enableKey} is "true")`,
+ );
+ }
+ }
+ continue;
+ }
+ if (!hasAnyLegacyConfig(integration)) continue;
+ for (const { legacy } of integration.required) {
+ if (!legacy || !readEnv(legacy)) {
+ throw new Error(
+ `Missing required env: ${legacy ?? "legacy name"} (legacy ${integration.name} configuration is incomplete)`,
+ );
+ }
+ }
+ }
+}
+
+// Non-fatal nudge: a new UI_* value is set but the integration will not load
+// because its enable flag is not "true" and it is not activated through a
+// complete legacy config. Legacy-only deployments load and are not warned.
+export function warnGatedIntegrationsMisconfig(): void {
+ for (const integration of Object.values(GATED_INTEGRATIONS)) {
+ if (readBoolEnv(integration.enableKey)) continue;
+ if (hasCompleteLegacyConfig(integration)) continue;
+ for (const { key } of [...integration.required, ...integration.optional]) {
+ if (readEnv(key)) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `${key} is set but ${integration.enableKey} is not "true"; ${integration.name} will not load.`,
+ );
+ }
+ }
+ }
+}
diff --git a/ui/lib/runtime-config.shared.ts b/ui/lib/runtime-config.shared.ts
index 98a76f8c3a..22f53002a7 100644
--- a/ui/lib/runtime-config.shared.ts
+++ b/ui/lib/runtime-config.shared.ts
@@ -9,6 +9,7 @@ export interface RuntimePublicConfig {
posthogKey: string | null; // reserved
posthogHost: string | null; // reserved
reoDevClientId: string | null; // reserved
+ billingCloudEnable: boolean;
}
export const RUNTIME_CONFIG_SCRIPT_ID = "__PROWLER_RUNTIME_CONFIG__";
@@ -23,4 +24,5 @@ export const EMPTY_RUNTIME_PUBLIC_CONFIG: RuntimePublicConfig = {
posthogKey: null,
posthogHost: null,
reoDevClientId: null,
+ billingCloudEnable: false,
};
diff --git a/ui/lib/runtime-config.ts b/ui/lib/runtime-config.ts
index c799a6fced..fca2b088b1 100644
--- a/ui/lib/runtime-config.ts
+++ b/ui/lib/runtime-config.ts
@@ -2,29 +2,48 @@ import "server-only";
import { connection } from "next/server";
+import { readGatedEnv } from "@/lib/integrations";
import type { RuntimePublicConfig } from "@/lib/runtime-config.shared";
-import { readEnv } from "@/lib/runtime-env";
+import { readBoolEnv, readEnv } from "@/lib/runtime-env";
// `connection()` forces a per-request runtime read (never build-snapshotted);
// only this allowlist reaches the client. Each migrated key falls back to its
-// deprecated NEXT_PUBLIC_* name during migration (see readEnv).
+// deprecated NEXT_PUBLIC_* name during migration (see readEnv). Gated
+// integrations (Sentry/GTM/PostHog) resolve to their value only when the
+// matching UI_*_ENABLE flag is "true", else null — boot validation
+// (lib/env.ts) guarantees the value is present whenever the flag is set.
export async function getRuntimePublicConfig(): Promise {
await connection();
return {
- sentryDsn: readEnv("UI_SENTRY_DSN", "NEXT_PUBLIC_SENTRY_DSN"),
- sentryEnvironment: readEnv(
+ sentryDsn: readGatedEnv(
+ "UI_SENTRY_ENABLE",
+ "UI_SENTRY_DSN",
+ "NEXT_PUBLIC_SENTRY_DSN",
+ ),
+ sentryEnvironment: readGatedEnv(
+ "UI_SENTRY_ENABLE",
"UI_SENTRY_ENVIRONMENT",
"NEXT_PUBLIC_SENTRY_ENVIRONMENT",
),
- googleTagManagerId: readEnv(
+ googleTagManagerId: readGatedEnv(
+ "UI_GOOGLE_TAG_MANAGER_ENABLE",
"UI_GOOGLE_TAG_MANAGER_ID",
"NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID",
),
apiBaseUrl: readEnv("UI_API_BASE_URL", "NEXT_PUBLIC_API_BASE_URL"),
apiDocsUrl: readEnv("UI_API_DOCS_URL", "NEXT_PUBLIC_API_DOCS_URL"),
- posthogKey: readEnv("POSTHOG_KEY"),
- posthogHost: readEnv("POSTHOG_HOST"),
+ posthogKey: readGatedEnv(
+ "UI_POSTHOG_ENABLE",
+ "UI_POSTHOG_KEY",
+ "POSTHOG_KEY",
+ ),
+ posthogHost: readGatedEnv(
+ "UI_POSTHOG_ENABLE",
+ "UI_POSTHOG_HOST",
+ "POSTHOG_HOST",
+ ),
reoDevClientId: readEnv("REO_DEV_CLIENT_ID"),
+ billingCloudEnable: readBoolEnv("BILLING_CLOUD_ENABLE"),
};
}
diff --git a/ui/lib/runtime-env.test.ts b/ui/lib/runtime-env.test.ts
index b353b0ba11..723ecfdeb0 100644
--- a/ui/lib/runtime-env.test.ts
+++ b/ui/lib/runtime-env.test.ts
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
-import { readEnv } from "./runtime-env";
+import { readBoolEnv, readEnv } from "./runtime-env";
describe("readEnv", () => {
afterEach(() => {
@@ -73,3 +73,51 @@ describe("readEnv", () => {
expect(readEnv("UI_API_BASE_URL", "NEXT_PUBLIC_API_BASE_URL")).toBeNull();
});
});
+
+describe("readBoolEnv", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('is true only for the exact string "true"', () => {
+ // Given
+ vi.stubEnv("UI_SENTRY_ENABLE", "true");
+
+ // When / Then
+ expect(readBoolEnv("UI_SENTRY_ENABLE")).toBe(true);
+ });
+
+ it("trims surrounding whitespace before comparing", () => {
+ // Given - clean() does not trim a non-empty value, so readBoolEnv must
+ vi.stubEnv("UI_SENTRY_ENABLE", " true ");
+
+ // When / Then
+ expect(readBoolEnv("UI_SENTRY_ENABLE")).toBe(true);
+ });
+
+ it("is false when unset", () => {
+ // Given
+ vi.stubEnv("UI_SENTRY_ENABLE", undefined);
+
+ // When / Then
+ expect(readBoolEnv("UI_SENTRY_ENABLE")).toBe(false);
+ });
+
+ it('is false for "false"', () => {
+ // Given
+ vi.stubEnv("UI_SENTRY_ENABLE", "false");
+
+ // When / Then
+ expect(readBoolEnv("UI_SENTRY_ENABLE")).toBe(false);
+ });
+
+ it('is false for other truthy-looking values ("TRUE", "1", "yes")', () => {
+ for (const value of ["TRUE", "1", "yes"]) {
+ // Given
+ vi.stubEnv("UI_SENTRY_ENABLE", value);
+
+ // When / Then
+ expect(readBoolEnv("UI_SENTRY_ENABLE")).toBe(false);
+ }
+ });
+});
diff --git a/ui/lib/runtime-env.ts b/ui/lib/runtime-env.ts
index 7426a2e357..430d4f298d 100644
--- a/ui/lib/runtime-env.ts
+++ b/ui/lib/runtime-env.ts
@@ -19,3 +19,8 @@ export function readEnv(
return clean(env[primary]) ?? (legacy ? clean(env[legacy]) : null);
}
+
+// Reads a runtime boolean flag.
+export function readBoolEnv(key: keyof NodeJS.ProcessEnv): boolean {
+ return (readEnv(key) ?? "").trim() === "true";
+}
diff --git a/ui/sentry/sentry.edge.config.test.ts b/ui/sentry/sentry.edge.config.test.ts
index 918e686eb0..c0ac2625a3 100644
--- a/ui/sentry/sentry.edge.config.test.ts
+++ b/ui/sentry/sentry.edge.config.test.ts
@@ -20,6 +20,7 @@ describe("sentry.edge.config", () => {
it("should initialize with the resolved environment and reduced edge sampling", async () => {
// Given
+ vi.stubEnv("UI_SENTRY_ENABLE", "true");
vi.stubEnv("UI_SENTRY_DSN", "https://key@o0.ingest.sentry.io/1");
vi.stubEnv("UI_SENTRY_ENVIRONMENT", "pro");
@@ -36,7 +37,18 @@ describe("sentry.edge.config", () => {
});
it("should not initialize when the DSN is absent", async () => {
- // Given no DSN
+ // Given no DSN (and no enable flag)
+
+ // When
+ await import("./sentry.edge.config");
+
+ // Then
+ expect(initMock).not.toHaveBeenCalled();
+ });
+
+ it("should not initialize when UI_SENTRY_ENABLE is unset even if a DSN is set", async () => {
+ // Given - DSN configured but the enable flag is not "true"
+ vi.stubEnv("UI_SENTRY_DSN", "https://key@o0.ingest.sentry.io/1");
// When
await import("./sentry.edge.config");
@@ -46,7 +58,8 @@ describe("sentry.edge.config", () => {
});
it("should default to a non-dev environment so an unset UI_SENTRY_ENVIRONMENT does not enable dev sampling", async () => {
- // Given - DSN set but environment unset
+ // Given - enabled with a DSN but environment unset
+ vi.stubEnv("UI_SENTRY_ENABLE", "true");
vi.stubEnv("UI_SENTRY_DSN", "https://key@o0.ingest.sentry.io/1");
// When
diff --git a/ui/sentry/sentry.edge.config.ts b/ui/sentry/sentry.edge.config.ts
index 933c97822a..665b5b57b8 100644
--- a/ui/sentry/sentry.edge.config.ts
+++ b/ui/sentry/sentry.edge.config.ts
@@ -1,9 +1,14 @@
import * as Sentry from "@sentry/nextjs";
-import { readEnv } from "@/lib/runtime-env";
+import { readGatedEnv } from "@/lib/integrations";
-const sentryDsn = readEnv("UI_SENTRY_DSN", "NEXT_PUBLIC_SENTRY_DSN");
-const sentryEnvironment = readEnv(
+const sentryDsn = readGatedEnv(
+ "UI_SENTRY_ENABLE",
+ "UI_SENTRY_DSN",
+ "NEXT_PUBLIC_SENTRY_DSN",
+);
+const sentryEnvironment = readGatedEnv(
+ "UI_SENTRY_ENABLE",
"UI_SENTRY_ENVIRONMENT",
"NEXT_PUBLIC_SENTRY_ENVIRONMENT",
);
diff --git a/ui/sentry/sentry.server.config.test.ts b/ui/sentry/sentry.server.config.test.ts
index a3d75cc856..77e572827c 100644
--- a/ui/sentry/sentry.server.config.test.ts
+++ b/ui/sentry/sentry.server.config.test.ts
@@ -21,6 +21,7 @@ describe("sentry.server.config", () => {
it("should initialize with the resolved environment and production sampling", async () => {
// Given
+ vi.stubEnv("UI_SENTRY_ENABLE", "true");
vi.stubEnv("UI_SENTRY_DSN", "https://key@o0.ingest.sentry.io/1");
vi.stubEnv("UI_SENTRY_ENVIRONMENT", "pro");
@@ -38,7 +39,18 @@ describe("sentry.server.config", () => {
});
it("should not initialize when the DSN is absent", async () => {
- // Given no DSN
+ // Given no DSN (and no enable flag)
+
+ // When
+ await import("./sentry.server.config");
+
+ // Then
+ expect(initMock).not.toHaveBeenCalled();
+ });
+
+ it("should not initialize when UI_SENTRY_ENABLE is unset even if a DSN is set", async () => {
+ // Given - DSN configured but the enable flag is not "true"
+ vi.stubEnv("UI_SENTRY_DSN", "https://key@o0.ingest.sentry.io/1");
// When
await import("./sentry.server.config");
@@ -48,7 +60,8 @@ describe("sentry.server.config", () => {
});
it("should default to a non-dev environment so an unset UI_SENTRY_ENVIRONMENT does not enable dev sampling", async () => {
- // Given - DSN set but environment unset
+ // Given - enabled with a DSN but environment unset
+ vi.stubEnv("UI_SENTRY_ENABLE", "true");
vi.stubEnv("UI_SENTRY_DSN", "https://key@o0.ingest.sentry.io/1");
// When
diff --git a/ui/sentry/sentry.server.config.ts b/ui/sentry/sentry.server.config.ts
index 361365f2ec..8ea3b54615 100644
--- a/ui/sentry/sentry.server.config.ts
+++ b/ui/sentry/sentry.server.config.ts
@@ -1,9 +1,14 @@
import * as Sentry from "@sentry/nextjs";
-import { readEnv } from "@/lib/runtime-env";
+import { readGatedEnv } from "@/lib/integrations";
-const sentryDsn = readEnv("UI_SENTRY_DSN", "NEXT_PUBLIC_SENTRY_DSN");
-const sentryEnvironment = readEnv(
+const sentryDsn = readGatedEnv(
+ "UI_SENTRY_ENABLE",
+ "UI_SENTRY_DSN",
+ "NEXT_PUBLIC_SENTRY_DSN",
+);
+const sentryEnvironment = readGatedEnv(
+ "UI_SENTRY_ENABLE",
"UI_SENTRY_ENVIRONMENT",
"NEXT_PUBLIC_SENTRY_ENVIRONMENT",
);
diff --git a/ui/tests/runtime-config/runtime-config-page.ts b/ui/tests/runtime-config/runtime-config-page.ts
index 28c9ad6065..41a415ed9a 100644
--- a/ui/tests/runtime-config/runtime-config-page.ts
+++ b/ui/tests/runtime-config/runtime-config-page.ts
@@ -19,6 +19,7 @@ export const RUNTIME_CONFIG_KEYS = [
"posthogKey",
"posthogHost",
"reoDevClientId",
+ "billingCloudEnable",
] as const satisfies ReadonlyArray;
/**
diff --git a/ui/tests/runtime-config/runtime-config.md b/ui/tests/runtime-config/runtime-config.md
index 18aedaa1b3..6b4246056c 100644
--- a/ui/tests/runtime-config/runtime-config.md
+++ b/ui/tests/runtime-config/runtime-config.md
@@ -2,7 +2,9 @@
**Suite ID:** `RUNTIME-CONFIG-E2E`
**Feature:** Runtime resolution of public client config via an inert JSON data
-island injected into `` (Sentry, GTM, API base/docs URL, reserved keys).
+island injected into `` (Sentry, GTM, API base/docs URL, and reserved keys). Each third-party integration
+resolves to a value only when its `UI_*_ENABLE` flag is `"true"`, so a disabled
+integration is exposed as `null`.
---
@@ -40,7 +42,7 @@ island injected into `` (Sentry, GTM, API base/docs URL, reserved keys).
**Preconditions:**
-- UI server running. `UI_SENTRY_DSN` may be set or unset.
+- UI server running. Sentry may be enabled (`UI_SENTRY_ENABLE=true` with `UI_SENTRY_DSN`) or off.
### Flow Steps
@@ -68,7 +70,7 @@ island injected into `` (Sentry, GTM, API base/docs URL, reserved keys).
**Preconditions:**
-- UI server running with `UI_SENTRY_DSN` and `UI_GOOGLE_TAG_MANAGER_ID` unset (the Enterprise default; the test skips when either is configured).
+- UI server running with Sentry and Google Tag Manager disabled — their `UI_SENTRY_ENABLE` and `UI_GOOGLE_TAG_MANAGER_ENABLE` flags unset, the Enterprise default — so the island exposes both as `null` (the test skips when either is enabled and configured).
### Flow Steps
diff --git a/ui/types/env.d.ts b/ui/types/env.d.ts
index bfb995d5d1..28b0865455 100644
--- a/ui/types/env.d.ts
+++ b/ui/types/env.d.ts
@@ -15,12 +15,12 @@ declare global {
NEXT_PUBLIC_API_DOCS_URL?: string;
UI_API_DOCS_URL?: string;
- // GTM
+ UI_GOOGLE_TAG_MANAGER_ENABLE?: "true" | "false";
/** @deprecated use UI_GOOGLE_TAG_MANAGER_ID */
NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID?: string;
UI_GOOGLE_TAG_MANAGER_ID?: string;
- // SENTRY
+ UI_SENTRY_ENABLE?: "true" | "false";
/** @deprecated use UI_SENTRY_DSN */
NEXT_PUBLIC_SENTRY_DSN?: string;
UI_SENTRY_DSN?: string;
@@ -28,6 +28,8 @@ declare global {
NEXT_PUBLIC_SENTRY_ENVIRONMENT?: string;
UI_SENTRY_ENVIRONMENT?: string;
+ BILLING_CLOUD_ENABLE?: "true" | "false";
+
// Build-time public config
NEXT_PUBLIC_IS_CLOUD_ENV?: "true" | "false";
NEXT_PUBLIC_PROWLER_RELEASE_VERSION?: string;
@@ -44,9 +46,14 @@ declare global {
SENTRY_PROJECT?: string;
SENTRY_AUTH_TOKEN?: string;
- // TODO Reserved runtime public config (registered now; no UI consumer yet)
+ UI_POSTHOG_ENABLE?: "true" | "false";
+ /** @deprecated use UI_POSTHOG_KEY */
POSTHOG_KEY?: string;
+ UI_POSTHOG_KEY?: string;
+ /** @deprecated use UI_POSTHOG_HOST */
POSTHOG_HOST?: string;
+ UI_POSTHOG_HOST?: string;
+ // TODO Reserved runtime public config (registered now; no UI consumer yet)
REO_DEV_CLIENT_ID?: string;
// Social OAuth