feat(ui): separate PostHog hosts and enable Toolbar in development (#12582)

This commit is contained in:
Alejandro Bailo
2026-09-03 10:36:47 +02:00
committed by GitHub
parent 8007501574
commit 9ed07de610
20 changed files with 432 additions and 47 deletions
@@ -0,0 +1 @@
PostHog Toolbar support in development with separate ingestion and app hosts
@@ -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,
+52 -5
View File
@@ -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
+8 -4
View File
@@ -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 (
<Suspense fallback={null}>
<RuntimeFeedbackSurvey
key={`${posthogKey}:${posthogHost}`}
key={`${posthogKey}:${posthogIngestionHost}:${posthogUiHost}`}
posthogKey={posthogKey}
posthogHost={posthogHost}
posthogIngestionHost={posthogIngestionHost}
posthogUiHost={posthogUiHost}
/>
</Suspense>
);
@@ -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<Survey | null>(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 (
<VisibleFeedbackSurvey
posthogKey={posthogKey}
posthogIngestionHost={posthogIngestionHost}
posthogUiHost={posthogUiHost}
isPushViewport={isPushViewport}
sidePanelResizing={sidePanelResizing}
sidePanelVisible={sidePanelVisible}
sidePanelWidth={sidePanelWidth}
/>
);
}
function VisibleFeedbackSurvey({
posthogKey,
posthogIngestionHost,
posthogUiHost,
isPushViewport,
sidePanelResizing,
sidePanelVisible,
sidePanelWidth,
}: VisibleFeedbackSurveyProps) {
const [survey, setSurvey] = useState<Survey | null>(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;
+57 -10
View File
@@ -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, "");
}
+2
View File
@@ -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",
+6 -2
View File
@@ -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",
+15
View File
@@ -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(() => {});
+1 -1
View File
@@ -54,7 +54,7 @@ export const GATED_INTEGRATIONS: Record<string, GatedIntegration> = {
{ 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<string, GatedIntegration>;
+29
View File
@@ -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");
});
});
+22
View File
@@ -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;
}
+11
View File
@@ -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;
}
+6 -3
View File
@@ -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,
+74
View File
@@ -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();
});
});
+2 -1
View File
@@ -43,11 +43,12 @@ export async function getRuntimePublicConfig(): Promise<RuntimePublicConfig> {
"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
+88 -3
View File
@@ -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 };
+3 -1
View File
@@ -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;
@@ -18,7 +18,8 @@ export const RUNTIME_CONFIG_KEYS = [
"apiDocsUrl",
"posthogEnabled",
"posthogKey",
"posthogHost",
"posthogIngestionHost",
"posthogUiHost",
"reoDevClientId",
"cloudBillingEnabled",
"cloudEnabled",
+1
View File
@@ -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;