mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
feat(ui): separate PostHog hosts and enable Toolbar in development (#12582)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,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
@@ -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, "");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Vendored
+1
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user