diff --git a/ui/changelog.d/posthog-toolbar-localhost.added.md b/ui/changelog.d/posthog-toolbar-localhost.added.md
new file mode 100644
index 0000000000..a2aa815a1c
--- /dev/null
+++ b/ui/changelog.d/posthog-toolbar-localhost.added.md
@@ -0,0 +1 @@
+PostHog Toolbar support in development with separate ingestion and app hosts
diff --git a/ui/components/integrations/saml/saml-config-form.test.tsx b/ui/components/integrations/saml/saml-config-form.test.tsx
index a067d8b2d2..4949a9b255 100644
--- a/ui/components/integrations/saml/saml-config-form.test.tsx
+++ b/ui/components/integrations/saml/saml-config-form.test.tsx
@@ -53,7 +53,8 @@ const runtimeConfig: RuntimePublicConfig = {
apiDocsUrl: null,
posthogEnabled: false,
posthogKey: null,
- posthogHost: null,
+ posthogIngestionHost: null,
+ posthogUiHost: null,
reoDevClientId: null,
cloudEnabled: false,
cloudBillingEnabled: false,
diff --git a/ui/components/survey/feedback-survey.test.tsx b/ui/components/survey/feedback-survey.test.tsx
index 436307e0e5..b837beb8b4 100644
--- a/ui/components/survey/feedback-survey.test.tsx
+++ b/ui/components/survey/feedback-survey.test.tsx
@@ -10,6 +10,12 @@ import { clampSidePanelWidth } from "@/lib/ui-layout";
type SurveyCallback = (surveys: Survey[]) => void;
+interface PostHogInitConfig {
+ api_host?: string;
+ loaded?: (client: unknown) => void;
+ ui_host?: string;
+}
+
const mocks = vi.hoisted(() => ({
isCloud: vi.fn(),
useRuntimeConfig: vi.fn(),
@@ -100,13 +106,15 @@ describe("FeedbackSurvey", () => {
mocks.loaded = false;
mocks.pathname = "/";
mocks.isPushViewport = false;
+ vi.stubEnv("NODE_ENV", "development");
localStorage.clear();
mocks.isCloud.mockReturnValue(true);
mocks.useRuntimeConfig.mockReturnValue({
cloudEnabled: true,
posthogEnabled: true,
posthogKey: POSTHOG_KEY,
- posthogHost: "https://eu.posthog.com",
+ posthogIngestionHost: "https://us.i.posthog.com",
+ posthogUiHost: "https://us.posthog.com",
});
provideSurveys([SURVEY_FIXTURE]);
});
@@ -115,6 +123,7 @@ describe("FeedbackSurvey", () => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
+ Reflect.deleteProperty(window, "posthog");
});
it("renders an accessible icon-only circular feedback trigger", async () => {
@@ -443,8 +452,8 @@ describe("FeedbackSurvey", () => {
await waitFor(() => expect(mocks.init).toHaveBeenCalledTimes(1));
expect(mocks.init.mock.calls[0][0]).toBe(POSTHOG_KEY);
expect(mocks.init.mock.calls[0][1]).toMatchObject({
- api_host: "https://eu.posthog.com",
- ui_host: "https://eu.posthog.com",
+ api_host: "https://us.i.posthog.com",
+ ui_host: "https://us.posthog.com",
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
@@ -452,6 +461,40 @@ describe("FeedbackSurvey", () => {
await waitFor(() => expect(mocks.onSurveysLoaded).toHaveBeenCalled());
});
+ it("exposes the initialized PostHog client for the development Toolbar", async () => {
+ // Given
+ await renderSurvey();
+ await waitFor(() => expect(mocks.init).toHaveBeenCalledOnce());
+ const config = mocks.init.mock.calls[0]?.[1] as
+ | PostHogInitConfig
+ | undefined;
+ const { default: posthogClient } = await import("posthog-js");
+
+ // When
+ config?.loaded?.(posthogClient);
+
+ // Then
+ expect(config?.loaded).toBeTypeOf("function");
+ expect(Reflect.get(window, "posthog")).toBe(posthogClient);
+ });
+
+ it("keeps the initialized PostHog client off window in production", async () => {
+ // Given
+ vi.stubEnv("NODE_ENV", "production");
+ await renderSurvey();
+ await waitFor(() => expect(mocks.init).toHaveBeenCalledOnce());
+ const config = mocks.init.mock.calls[0]?.[1] as
+ | PostHogInitConfig
+ | undefined;
+ const { default: posthogClient } = await import("posthog-js");
+
+ // When
+ config?.loaded?.(posthogClient);
+
+ // Then
+ expect(Reflect.has(window, "posthog")).toBe(false);
+ });
+
it("consumes the already-initialized Cloud instance without re-initializing", async () => {
// Given - on Prowler Cloud the singleton is already initialized
// (app/providers.tsx), so __loaded is true.
@@ -464,6 +507,8 @@ describe("FeedbackSurvey", () => {
// the trigger renders.
await waitFor(() => expect(mocks.onSurveysLoaded).toHaveBeenCalled());
expect(mocks.init).not.toHaveBeenCalled();
+ const { default: posthogClient } = await import("posthog-js");
+ expect(Reflect.get(window, "posthog")).toBe(posthogClient);
expect(
await screen.findByRole("button", { name: "Give feedback" }),
).toBeVisible();
@@ -476,7 +521,8 @@ describe("FeedbackSurvey", () => {
cloudEnabled: false,
posthogEnabled: false,
posthogKey: null,
- posthogHost: null,
+ posthogIngestionHost: null,
+ posthogUiHost: null,
});
// When
@@ -496,7 +542,8 @@ describe("FeedbackSurvey", () => {
cloudEnabled: true,
posthogEnabled: false,
posthogKey: POSTHOG_KEY,
- posthogHost: "https://eu.posthog.com",
+ posthogIngestionHost: "https://eu.i.posthog.com",
+ posthogUiHost: "https://eu.posthog.com",
});
// When
diff --git a/ui/components/survey/feedback-survey.tsx b/ui/components/survey/feedback-survey.tsx
index eff7cd1b56..645c266d3b 100644
--- a/ui/components/survey/feedback-survey.tsx
+++ b/ui/components/survey/feedback-survey.tsx
@@ -8,16 +8,20 @@ import { isCloud } from "@/lib/shared/env";
const RuntimeFeedbackSurvey = lazy(() => import("./runtime-feedback-survey"));
export function FeedbackSurvey() {
- const { posthogEnabled, posthogKey, posthogHost } = useRuntimeConfig();
+ const { posthogEnabled, posthogKey, posthogIngestionHost, posthogUiHost } =
+ useRuntimeConfig();
- if (!isCloud() || !posthogEnabled || !posthogKey || !posthogHost) return null;
+ if (!isCloud() || !posthogEnabled || !posthogKey || !posthogIngestionHost) {
+ return null;
+ }
return (
);
diff --git a/ui/components/survey/runtime-feedback-survey.tsx b/ui/components/survey/runtime-feedback-survey.tsx
index 49c9d0ceb6..3e7db575af 100644
--- a/ui/components/survey/runtime-feedback-survey.tsx
+++ b/ui/components/survey/runtime-feedback-survey.tsx
@@ -4,7 +4,7 @@ import { MessageSquareText } from "lucide-react";
import { usePathname } from "next/navigation";
import posthogClient from "posthog-js";
import type { Survey } from "posthog-js";
-import { useEffect, useState } from "react";
+import { useState } from "react";
import { Button } from "@/components/shadcn/button/button";
import {
@@ -16,6 +16,8 @@ import { useMediaQuery } from "@/hooks/use-media-query";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { useStore } from "@/hooks/use-store";
import { isLighthouseChatRoute } from "@/lib/lighthouse-routes";
+import { resolvePosthogUiHost } from "@/lib/posthog-hosts";
+import { exposePosthogForToolbar } from "@/lib/posthog-toolbar";
import {
clampSidePanelWidth,
SIDE_PANEL_PUSH_MEDIA_QUERY,
@@ -36,18 +38,23 @@ const SURVEY_EVENT = {
interface RuntimeFeedbackSurveyProps {
posthogKey: string;
- posthogHost: string;
+ posthogIngestionHost: string;
+ posthogUiHost: string | null;
+}
+
+interface VisibleFeedbackSurveyProps extends RuntimeFeedbackSurveyProps {
+ isPushViewport: boolean;
+ sidePanelResizing: boolean | undefined;
+ sidePanelVisible: boolean;
+ sidePanelWidth: number | undefined;
}
export default function RuntimeFeedbackSurvey({
posthogKey,
- posthogHost,
+ posthogIngestionHost,
+ posthogUiHost,
}: RuntimeFeedbackSurveyProps) {
const pathname = usePathname();
- const [survey, setSurvey] = useState(null);
- const [open, setOpen] = useState(false);
- const [response, setResponse] = useState("");
- const [submitted, setSubmitted] = useState(false);
const sidePanelOpen = useStore(useSidePanelStore, (state) => state.isOpen);
const sidePanelWidth = useStore(useSidePanelStore, (state) => state.width);
const sidePanelResizing = useStore(
@@ -58,14 +65,47 @@ export default function RuntimeFeedbackSurvey({
const sidePanelVisible =
Boolean(sidePanelOpen) && !isLighthouseChatRoute(pathname);
+ // Reset the open survey by unmounting it while an overlay panel is visible.
+ if (sidePanelVisible && !isPushViewport) return null;
+
+ return (
+
+ );
+}
+
+function VisibleFeedbackSurvey({
+ posthogKey,
+ posthogIngestionHost,
+ posthogUiHost,
+ isPushViewport,
+ sidePanelResizing,
+ sidePanelVisible,
+ sidePanelWidth,
+}: VisibleFeedbackSurveyProps) {
+ const [survey, setSurvey] = useState(null);
+ const [open, setOpen] = useState(false);
+ const [response, setResponse] = useState("");
+ const [submitted, setSubmitted] = useState(false);
+
useMountEffect(() => {
- if (!posthogClient.__loaded) {
+ if (posthogClient.__loaded) {
+ exposePosthogForToolbar(posthogClient);
+ } else {
posthogClient.init(posthogKey, {
- api_host: posthogHost,
- ui_host: posthogHost,
+ api_host: posthogIngestionHost,
+ ui_host: resolvePosthogUiHost(posthogIngestionHost, posthogUiHost),
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
+ loaded: exposePosthogForToolbar,
});
}
@@ -78,13 +118,8 @@ export default function RuntimeFeedbackSurvey({
});
});
- useEffect(() => {
- if (sidePanelVisible && !isPushViewport) setOpen(false);
- }, [sidePanelVisible, isPushViewport]);
-
const question = survey?.questions?.[0];
if (!survey || question?.type !== "open") return null;
- if (sidePanelVisible && !isPushViewport) return null;
const questionId = question.id ?? "";
const appearance = survey.appearance;
diff --git a/ui/lib/csp.ts b/ui/lib/csp.ts
index 617fe913e3..77a41c2e3c 100644
--- a/ui/lib/csp.ts
+++ b/ui/lib/csp.ts
@@ -1,30 +1,77 @@
+import { resolvePosthogUiHost } from "@/lib/posthog-hosts";
+
const POSTHOG_CSP_SOURCE = "https://*.posthog.com";
interface CspOptions {
cloudEnabled: boolean;
posthogEnabled: boolean;
posthogKey: string | null;
- posthogHost: string | null;
+ posthogIngestionHost: string | null;
+ posthogUiHost: string | null;
+ posthogToolbarEnabled: boolean;
}
+const getPosthogToolbarUiSource = (
+ ingestionHost: string,
+ configuredUiHost: string | null,
+): string => {
+ try {
+ const resolvedUiHost = resolvePosthogUiHost(
+ ingestionHost,
+ configuredUiHost,
+ );
+ const url = new URL(resolvedUiHost);
+ const isHttp = url.protocol === "http:" || url.protocol === "https:";
+ const coveredByCloudWildcard =
+ url.protocol === "https:" && url.hostname.endsWith(".posthog.com");
+
+ return isHttp && !coveredByCloudWildcard ? ` ${url.origin}` : "";
+ } catch {
+ return "";
+ }
+};
+
export function getCspHeader({
cloudEnabled,
posthogEnabled,
posthogKey,
- posthogHost,
+ posthogIngestionHost,
+ posthogUiHost,
+ posthogToolbarEnabled,
}: CspOptions): string {
const allowPosthog =
- cloudEnabled && posthogEnabled && Boolean(posthogKey && posthogHost);
+ cloudEnabled &&
+ posthogEnabled &&
+ Boolean(posthogKey && posthogIngestionHost);
const posthogSource = allowPosthog ? ` ${POSTHOG_CSP_SOURCE}` : "";
+ const allowPosthogToolbar = allowPosthog && posthogToolbarEnabled;
+ const toolbarUiSource =
+ allowPosthogToolbar && posthogIngestionHost
+ ? getPosthogToolbarUiSource(posthogIngestionHost, posthogUiHost)
+ : "";
+ const toolbarPosthogSource = allowPosthogToolbar
+ ? ` ${POSTHOG_CSP_SOURCE}${toolbarUiSource}`
+ : "";
+ const toolbarWorkerSource = allowPosthogToolbar
+ ? "worker-src 'self' blob: data:;"
+ : "";
+ const toolbarMediaSource = allowPosthogToolbar
+ ? `media-src 'self' ${POSTHOG_CSP_SOURCE}${toolbarUiSource};`
+ : "";
+ const frameAncestors = allowPosthogToolbar
+ ? `'self' ${POSTHOG_CSP_SOURCE}${toolbarUiSource}`
+ : "'none'";
return `
default-src 'self';
- script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com https://www.googletagmanager.com https://browser.sentry-cdn.com${posthogSource};
- connect-src 'self' https://api.iconify.design https://api.simplesvg.com https://api.unisvg.com https://js.stripe.com https://www.googletagmanager.com https://*.sentry.io https://*.ingest.sentry.io${posthogSource};
- img-src 'self' https://www.google-analytics.com https://www.googletagmanager.com${posthogSource};
- font-src 'self';
- style-src 'self' 'unsafe-inline';
- frame-src 'self' https://js.stripe.com https://www.googletagmanager.com${posthogSource};
- frame-ancestors 'none';
+ script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com https://www.googletagmanager.com https://browser.sentry-cdn.com${posthogSource}${toolbarUiSource};
+ connect-src 'self' https://api.iconify.design https://api.simplesvg.com https://api.unisvg.com https://js.stripe.com https://www.googletagmanager.com https://*.sentry.io https://*.ingest.sentry.io${posthogSource}${toolbarUiSource};
+ img-src 'self' https://www.google-analytics.com https://www.googletagmanager.com${posthogSource}${toolbarUiSource};
+ font-src 'self'${toolbarPosthogSource};
+ style-src 'self' 'unsafe-inline'${toolbarPosthogSource};
+ ${toolbarMediaSource}
+ ${toolbarWorkerSource}
+ frame-src 'self' https://js.stripe.com https://www.googletagmanager.com${posthogSource}${toolbarUiSource};
+ frame-ancestors ${frameAncestors};
`.replace(/\n/g, "");
}
diff --git a/ui/lib/env.test.ts b/ui/lib/env.test.ts
index ee916e6b12..32465d81a7 100644
--- a/ui/lib/env.test.ts
+++ b/ui/lib/env.test.ts
@@ -62,6 +62,7 @@ describe("lib/env gated integration validation", () => {
"POSTHOG_KEY",
"UI_POSTHOG_HOST",
"POSTHOG_HOST",
+ "UI_POSTHOG_UI_HOST",
"CLOUD_BILLING_ENABLED",
] as const;
@@ -189,6 +190,7 @@ describe("lib/env billing and Stripe boot warnings", () => {
"POSTHOG_KEY",
"UI_POSTHOG_HOST",
"POSTHOG_HOST",
+ "UI_POSTHOG_UI_HOST",
"UI_CLOUD_STRIPE_PUBLISHABLE_KEY",
"NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY",
"UI_CLOUD_STRIPE_PUBLISHABLE_KEY_V2",
diff --git a/ui/lib/get-runtime-config.client.test.ts b/ui/lib/get-runtime-config.client.test.ts
index 7da677b1d5..2c0362cb40 100644
--- a/ui/lib/get-runtime-config.client.test.ts
+++ b/ui/lib/get-runtime-config.client.test.ts
@@ -55,7 +55,8 @@ describe("getRuntimeConfigClient", () => {
JSON.stringify({
posthogEnabled: true,
posthogKey: "phc_key",
- posthogHost: "https://eu.i.posthog.com",
+ posthogIngestionHost: "https://eu.i.posthog.com",
+ posthogUiHost: "https://eu.posthog.com",
}),
);
const { getRuntimeConfigClient } = await import(
@@ -68,6 +69,8 @@ describe("getRuntimeConfigClient", () => {
// Then
expect(config.posthogEnabled).toBe(true);
expect(config.posthogKey).toBe("phc_key");
+ expect(config.posthogIngestionHost).toBe("https://eu.i.posthog.com");
+ expect(config.posthogUiHost).toBe("https://eu.posthog.com");
});
it("falls back to an all-null config when the island is absent", async () => {
@@ -139,8 +142,9 @@ describe("getRuntimeConfigClient", () => {
"cloudEnabled",
"googleTagManagerId",
"posthogEnabled",
- "posthogHost",
+ "posthogIngestionHost",
"posthogKey",
+ "posthogUiHost",
"reoDevClientId",
"sentryDsn",
"sentryEnvironment",
diff --git a/ui/lib/integrations.test.ts b/ui/lib/integrations.test.ts
index 0832b418e3..fd25289139 100644
--- a/ui/lib/integrations.test.ts
+++ b/ui/lib/integrations.test.ts
@@ -24,6 +24,7 @@ const GATED_ENV_VARS = [
"POSTHOG_KEY",
"UI_POSTHOG_HOST",
"POSTHOG_HOST",
+ "UI_POSTHOG_UI_HOST",
] as const;
beforeEach(() => {
@@ -274,6 +275,20 @@ describe("isGatedIntegrationEnabled", () => {
});
describe("warnGatedIntegrationsMisconfig", () => {
+ it("warns when the optional PostHog app host is set while disabled", () => {
+ // Given
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ vi.stubEnv("UI_POSTHOG_UI_HOST", "https://eu.posthog.com");
+
+ // When
+ warnGatedIntegrationsMisconfig();
+
+ // Then
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining("UI_POSTHOG_UI_HOST"),
+ );
+ });
+
it("warns when a config value is set but its enable flag is not 'true'", () => {
// Given
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
diff --git a/ui/lib/integrations.ts b/ui/lib/integrations.ts
index 07d3886bad..a7408e9ea2 100644
--- a/ui/lib/integrations.ts
+++ b/ui/lib/integrations.ts
@@ -54,7 +54,7 @@ export const GATED_INTEGRATIONS: Record = {
{ key: "UI_POSTHOG_KEY", legacy: "POSTHOG_KEY" },
{ key: "UI_POSTHOG_HOST", legacy: "POSTHOG_HOST" },
],
- optional: [],
+ optional: [{ key: "UI_POSTHOG_UI_HOST" }],
},
} as const satisfies Record;
diff --git a/ui/lib/posthog-hosts.test.ts b/ui/lib/posthog-hosts.test.ts
new file mode 100644
index 0000000000..036ab80a81
--- /dev/null
+++ b/ui/lib/posthog-hosts.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+
+import { resolvePosthogUiHost } from "./posthog-hosts";
+
+describe("resolvePosthogUiHost", () => {
+ it("prefers an explicitly configured app host", () => {
+ // Given
+ const ingestionHost = "https://eu.i.posthog.com";
+ const uiHost = "https://posthog.example.com/";
+
+ // When / Then
+ expect(resolvePosthogUiHost(ingestionHost, uiHost)).toBe(
+ "https://posthog.example.com",
+ );
+ });
+
+ it.each([
+ ["https://eu.i.posthog.com", "https://eu.posthog.com"],
+ ["https://us.i.posthog.com/", "https://us.posthog.com"],
+ ])("derives the Cloud app host from %s", (ingestionHost, expected) => {
+ expect(resolvePosthogUiHost(ingestionHost, null)).toBe(expected);
+ });
+
+ it("uses the ingestion host for a self-hosted instance", () => {
+ expect(
+ resolvePosthogUiHost("https://posthog.internal.example/", null),
+ ).toBe("https://posthog.internal.example");
+ });
+});
diff --git a/ui/lib/posthog-hosts.ts b/ui/lib/posthog-hosts.ts
new file mode 100644
index 0000000000..2da15a5b5d
--- /dev/null
+++ b/ui/lib/posthog-hosts.ts
@@ -0,0 +1,22 @@
+const POSTHOG_CLOUD_INGESTION_HOST = /^(eu|us)\.i\.posthog\.com$/i;
+
+const stripTrailingSlash = (host: string): string => host.replace(/\/+$/, "");
+
+export function resolvePosthogUiHost(
+ ingestionHost: string,
+ configuredUiHost: string | null,
+): string {
+ if (configuredUiHost) return stripTrailingSlash(configuredUiHost);
+
+ const normalizedIngestionHost = stripTrailingSlash(ingestionHost);
+
+ try {
+ const url = new URL(normalizedIngestionHost);
+ const match = url.hostname.match(POSTHOG_CLOUD_INGESTION_HOST);
+ if (match) return `${url.protocol}//${match[1]}.posthog.com`;
+ } catch {
+ // Keep the configured value so posthog-js reports the invalid host.
+ }
+
+ return normalizedIngestionHost;
+}
diff --git a/ui/lib/posthog-toolbar.ts b/ui/lib/posthog-toolbar.ts
new file mode 100644
index 0000000000..e112a77c36
--- /dev/null
+++ b/ui/lib/posthog-toolbar.ts
@@ -0,0 +1,11 @@
+import type { PostHogInterface } from "posthog-js";
+
+declare global {
+ interface Window {
+ posthog?: PostHogInterface;
+ }
+}
+
+export function exposePosthogForToolbar(client: PostHogInterface): void {
+ if (process.env.NODE_ENV === "development") window.posthog = client;
+}
diff --git a/ui/lib/runtime-config.shared.ts b/ui/lib/runtime-config.shared.ts
index 3a36534bc0..db1318fb21 100644
--- a/ui/lib/runtime-config.shared.ts
+++ b/ui/lib/runtime-config.shared.ts
@@ -8,7 +8,8 @@ export interface RuntimePublicConfig {
apiDocsUrl: string | null;
posthogEnabled: boolean;
posthogKey: string | null;
- posthogHost: string | null;
+ posthogIngestionHost: string | null;
+ posthogUiHost: string | null;
reoDevClientId: string | null; // reserved
cloudEnabled: boolean;
cloudBillingEnabled: boolean;
@@ -27,7 +28,8 @@ export const EMPTY_RUNTIME_PUBLIC_CONFIG: RuntimePublicConfig = {
apiDocsUrl: null,
posthogEnabled: false,
posthogKey: null,
- posthogHost: null,
+ posthogIngestionHost: null,
+ posthogUiHost: null,
reoDevClientId: null,
cloudEnabled: false,
cloudBillingEnabled: false,
@@ -46,7 +48,8 @@ const pickConfig = (
apiDocsUrl: parsed.apiDocsUrl ?? null,
posthogEnabled: parsed.posthogEnabled ?? false,
posthogKey: parsed.posthogKey ?? null,
- posthogHost: parsed.posthogHost ?? null,
+ posthogIngestionHost: parsed.posthogIngestionHost ?? null,
+ posthogUiHost: parsed.posthogUiHost ?? null,
reoDevClientId: parsed.reoDevClientId ?? null,
cloudEnabled: parsed.cloudEnabled ?? false,
cloudBillingEnabled: parsed.cloudBillingEnabled ?? false,
diff --git a/ui/lib/runtime-config.test.ts b/ui/lib/runtime-config.test.ts
new file mode 100644
index 0000000000..412cc1e185
--- /dev/null
+++ b/ui/lib/runtime-config.test.ts
@@ -0,0 +1,74 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("server-only", () => ({}));
+vi.mock("next/server", () => ({ connection: () => Promise.resolve() }));
+
+const POSTHOG_ENV_VARS = [
+ "UI_POSTHOG_ENABLED",
+ "UI_POSTHOG_KEY",
+ "POSTHOG_KEY",
+ "UI_POSTHOG_HOST",
+ "POSTHOG_HOST",
+ "UI_POSTHOG_UI_HOST",
+] as const;
+
+const importFresh = async () => {
+ vi.resetModules();
+ return import("./runtime-config");
+};
+
+describe("getRuntimePublicConfig PostHog hosts", () => {
+ beforeEach(() => {
+ for (const key of POSTHOG_ENV_VARS) vi.stubEnv(key, undefined);
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("returns separate ingestion and app hosts when PostHog is enabled", async () => {
+ // Given
+ vi.stubEnv("UI_POSTHOG_ENABLED", "true");
+ vi.stubEnv("UI_POSTHOG_KEY", "phc_key");
+ vi.stubEnv("UI_POSTHOG_HOST", "https://us.i.posthog.com");
+ vi.stubEnv("UI_POSTHOG_UI_HOST", "https://us.posthog.com");
+
+ // When
+ const { getRuntimePublicConfig } = await importFresh();
+ const config = await getRuntimePublicConfig();
+
+ // Then
+ expect(config.posthogIngestionHost).toBe("https://us.i.posthog.com");
+ expect(config.posthogUiHost).toBe("https://us.posthog.com");
+ });
+
+ it("keeps legacy PostHog config working without an explicit app host", async () => {
+ // Given
+ vi.stubEnv("POSTHOG_KEY", "phc_key");
+ vi.stubEnv("POSTHOG_HOST", "https://posthog.internal.example");
+
+ // When
+ const { getRuntimePublicConfig } = await importFresh();
+ const config = await getRuntimePublicConfig();
+
+ // Then
+ expect(config.posthogIngestionHost).toBe(
+ "https://posthog.internal.example",
+ );
+ expect(config.posthogUiHost).toBeNull();
+ });
+
+ it("returns null hosts when PostHog is disabled", async () => {
+ // Given
+ vi.stubEnv("UI_POSTHOG_HOST", "https://eu.i.posthog.com");
+ vi.stubEnv("UI_POSTHOG_UI_HOST", "https://eu.posthog.com");
+
+ // When
+ const { getRuntimePublicConfig } = await importFresh();
+ const config = await getRuntimePublicConfig();
+
+ // Then
+ expect(config.posthogIngestionHost).toBeNull();
+ expect(config.posthogUiHost).toBeNull();
+ });
+});
diff --git a/ui/lib/runtime-config.ts b/ui/lib/runtime-config.ts
index 52635c44c8..29d38ca952 100644
--- a/ui/lib/runtime-config.ts
+++ b/ui/lib/runtime-config.ts
@@ -43,11 +43,12 @@ export async function getRuntimePublicConfig(): Promise {
"UI_POSTHOG_KEY",
"POSTHOG_KEY",
),
- posthogHost: readGatedEnv(
+ posthogIngestionHost: readGatedEnv(
"UI_POSTHOG_ENABLED",
"UI_POSTHOG_HOST",
"POSTHOG_HOST",
),
+ posthogUiHost: readGatedEnv("UI_POSTHOG_ENABLED", "UI_POSTHOG_UI_HOST"),
reoDevClientId: readEnv("REO_DEV_CLIENT_ID"),
cloudEnabled: readBoolEnv("UI_CLOUD_ENABLED"),
// Install-level selector "legacy" | "metronome" | "false"; the client only
diff --git a/ui/next.config.test.ts b/ui/next.config.test.ts
index a0f39bbdcb..2154ea88f9 100644
--- a/ui/next.config.test.ts
+++ b/ui/next.config.test.ts
@@ -16,7 +16,9 @@ const ENABLED_POSTHOG_CONFIG = {
cloudEnabled: true,
posthogEnabled: true,
posthogKey: "phc_key",
- posthogHost: "https://eu.posthog.com",
+ posthogIngestionHost: "https://eu.i.posthog.com",
+ posthogUiHost: null,
+ posthogToolbarEnabled: false,
};
const BASELINE_CSP = {
@@ -85,7 +87,9 @@ describe("PostHog Content Security Policy", () => {
cloudEnabled: false,
posthogEnabled: false,
posthogKey: null,
- posthogHost: null,
+ posthogIngestionHost: null,
+ posthogUiHost: null,
+ posthogToolbarEnabled: false,
}),
);
@@ -104,14 +108,95 @@ describe("PostHog Content Security Policy", () => {
expect(csp["img-src"]).toContain(POSTHOG_WILDCARD);
expect(csp["frame-src"]).toContain(POSTHOG_WILDCARD);
expect(csp["font-src"]).not.toContain(POSTHOG_WILDCARD);
+ expect(csp["style-src"]).not.toContain(POSTHOG_WILDCARD);
+ expect(csp["media-src"]).toBeUndefined();
+ expect(csp["worker-src"]).toBeUndefined();
+ expect(csp["frame-ancestors"]).toEqual(["'none'"]);
expect(csp["default-src"]).not.toContain(POSTHOG_WILDCARD);
});
+ it("adds Toolbar permissions for a fully enabled Cloud development request", () => {
+ // Given
+ const toolbarConfig = {
+ ...ENABLED_POSTHOG_CONFIG,
+ posthogToolbarEnabled: true,
+ };
+
+ // When
+ const csp = parseCsp(getCspHeader(toolbarConfig));
+
+ // Then
+ expect(csp["style-src"]).toContain(POSTHOG_WILDCARD);
+ expect(csp["font-src"]).toContain(POSTHOG_WILDCARD);
+ expect(csp["media-src"]).toContain(POSTHOG_WILDCARD);
+ expect(csp["worker-src"]).toEqual(["'self'", "blob:", "data:"]);
+ expect(csp["frame-ancestors"]).toEqual(["'self'", POSTHOG_WILDCARD]);
+ });
+
+ it("allows the resolved UI origin for a self-hosted Toolbar", () => {
+ // Given
+ const selfHostedUiOrigin = "https://posthog.internal.example";
+ const toolbarConfig = {
+ ...ENABLED_POSTHOG_CONFIG,
+ posthogIngestionHost: `${selfHostedUiOrigin}/ingest`,
+ posthogUiHost: `${selfHostedUiOrigin}/app/`,
+ posthogToolbarEnabled: true,
+ };
+
+ // When
+ const csp = parseCsp(getCspHeader(toolbarConfig));
+
+ // Then
+ expect(csp["script-src"]).toContain(selfHostedUiOrigin);
+ expect(csp["connect-src"]).toContain(selfHostedUiOrigin);
+ expect(csp["img-src"]).toContain(selfHostedUiOrigin);
+ expect(csp["style-src"]).toContain(selfHostedUiOrigin);
+ expect(csp["font-src"]).toContain(selfHostedUiOrigin);
+ expect(csp["media-src"]).toContain(selfHostedUiOrigin);
+ expect(csp["frame-src"]).toContain(selfHostedUiOrigin);
+ expect(csp["frame-ancestors"]).toContain(selfHostedUiOrigin);
+ });
+
+ it("does not add a non-HTTP PostHog UI host to the CSP", () => {
+ // Given
+ const unsafeUiHost = "data:text/plain,toolbar";
+ const toolbarConfig = {
+ ...ENABLED_POSTHOG_CONFIG,
+ posthogUiHost: unsafeUiHost,
+ posthogToolbarEnabled: true,
+ };
+
+ // When
+ const csp = parseCsp(getCspHeader(toolbarConfig));
+
+ // Then
+ expect(Object.values(csp).flat()).not.toContain(unsafeUiHost);
+ expect(Object.values(csp).flat()).not.toContain("null");
+ });
+
+ it("keeps Toolbar permissions closed when PostHog is not fully enabled", () => {
+ // Given
+ const toolbarConfig = {
+ ...ENABLED_POSTHOG_CONFIG,
+ posthogEnabled: false,
+ posthogToolbarEnabled: true,
+ };
+
+ // When
+ const csp = parseCsp(getCspHeader(toolbarConfig));
+
+ // Then
+ expect(Object.values(csp).flat()).not.toContain(POSTHOG_WILDCARD);
+ expect(csp["media-src"]).toBeUndefined();
+ expect(csp["worker-src"]).toBeUndefined();
+ expect(csp["frame-ancestors"]).toEqual(["'none'"]);
+ });
+
it.each([
["Cloud is disabled", { cloudEnabled: false }],
["PostHog is disabled", { posthogEnabled: false }],
["the key is missing", { posthogKey: null }],
- ["the host is missing", { posthogHost: null }],
+ ["the host is missing", { posthogIngestionHost: null }],
])("omits PostHog permissions when %s", (_case, override) => {
// Given
const config = { ...ENABLED_POSTHOG_CONFIG, ...override };
diff --git a/ui/proxy.ts b/ui/proxy.ts
index 2e6b330b17..137d108579 100644
--- a/ui/proxy.ts
+++ b/ui/proxy.ts
@@ -41,11 +41,13 @@ const withSecurityHeaders = (response: NextResponse): NextResponse => {
"UI_POSTHOG_KEY",
"POSTHOG_KEY",
),
- posthogHost: readGatedEnv(
+ posthogIngestionHost: readGatedEnv(
"UI_POSTHOG_ENABLED",
"UI_POSTHOG_HOST",
"POSTHOG_HOST",
),
+ posthogUiHost: readGatedEnv("UI_POSTHOG_ENABLED", "UI_POSTHOG_UI_HOST"),
+ posthogToolbarEnabled: process.env.NODE_ENV === "development",
}),
);
return response;
diff --git a/ui/tests/runtime-config/runtime-config-page.ts b/ui/tests/runtime-config/runtime-config-page.ts
index b05f754ee0..35d66672c3 100644
--- a/ui/tests/runtime-config/runtime-config-page.ts
+++ b/ui/tests/runtime-config/runtime-config-page.ts
@@ -18,7 +18,8 @@ export const RUNTIME_CONFIG_KEYS = [
"apiDocsUrl",
"posthogEnabled",
"posthogKey",
- "posthogHost",
+ "posthogIngestionHost",
+ "posthogUiHost",
"reoDevClientId",
"cloudBillingEnabled",
"cloudEnabled",
diff --git a/ui/types/env.d.ts b/ui/types/env.d.ts
index 8b5a1ce039..6815cb962d 100644
--- a/ui/types/env.d.ts
+++ b/ui/types/env.d.ts
@@ -64,6 +64,7 @@ declare global {
/** @deprecated use UI_POSTHOG_HOST */
POSTHOG_HOST?: string;
UI_POSTHOG_HOST?: string;
+ UI_POSTHOG_UI_HOST?: string;
// TODO Reserved runtime public config (registered now; no UI consumer yet)
REO_DEV_CLIENT_ID?: string;