feat(ui): gate Sentry, GTM and PostHog behind runtime enable flags (#11682)

Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Pablo Fernandez Guerra (PFE)
2026-07-07 13:43:34 +02:00
committed by GitHub
parent 838f82b255
commit ac3f289de6
23 changed files with 698 additions and 38 deletions
+6 -5
View File
@@ -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 ####
+19 -1
View File
@@ -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.
<Warning>
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.
</Warning>
## Upcoming Breaking Change
<Warning>
@@ -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).
+1
View File
@@ -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)
---
+8 -2
View File
@@ -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"]
+3 -3
View File
@@ -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({
// <RuntimePublicConfig/> 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",
);
+6 -2
View File
@@ -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
+98
View File
@@ -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");
});
});
+7
View File
@@ -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 {};
+5 -1
View File
@@ -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<string, unknown>).notAllowlisted,
).toBeUndefined();
+1
View File
@@ -20,6 +20,7 @@ const pickConfig = (
posthogKey: parsed.posthogKey ?? null,
posthogHost: parsed.posthogHost ?? null,
reoDevClientId: parsed.reoDevClientId ?? null,
billingCloudEnable: parsed.billingCloudEnable ?? false,
});
// Reads the <head> island once (memoized); all-null during SSR or if it's
+265
View File
@@ -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();
});
});
+135
View File
@@ -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<IntegrationEnvVar>;
optional: ReadonlyArray<IntegrationEnvVar>;
}
export const GATED_INTEGRATIONS: Record<string, GatedIntegration> = {
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<string, GatedIntegration>;
// 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.`,
);
}
}
}
}
+2
View File
@@ -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,
};
+26 -7
View File
@@ -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<RuntimePublicConfig> {
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"),
};
}
+49 -1
View File
@@ -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);
}
});
});
+5
View File
@@ -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";
}
+15 -2
View File
@@ -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
+8 -3
View File
@@ -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",
);
+15 -2
View File
@@ -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
+8 -3
View File
@@ -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",
);
@@ -19,6 +19,7 @@ export const RUNTIME_CONFIG_KEYS = [
"posthogKey",
"posthogHost",
"reoDevClientId",
"billingCloudEnable",
] as const satisfies ReadonlyArray<keyof RuntimePublicConfig>;
/**
+5 -3
View File
@@ -2,7 +2,9 @@
**Suite ID:** `RUNTIME-CONFIG-E2E`
**Feature:** Runtime resolution of public client config via an inert JSON data
island injected into `<head>` (Sentry, GTM, API base/docs URL, reserved keys).
island injected into `<head>` (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 `<head>` (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 `<head>` (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
+10 -3
View File
@@ -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