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