mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 20:42:02 +00:00
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:
committed by
GitHub
parent
838f82b255
commit
ac3f289de6
@@ -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 ####
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Vendored
+10
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user