feat(ui): add PostHog-backed in-app feedback survey (#12116)

Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
This commit is contained in:
Alan Buscaglia
2026-07-29 11:12:38 +02:00
committed by GitHub
co-authored by Pablo F.G
parent 1bb3fc9bda
commit 7f1cdb82ae
20 changed files with 1022 additions and 34 deletions
+1 -1
View File
@@ -87,7 +87,7 @@ ENV HOSTNAME="0.0.0.0"
# NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID, POSTHOG_KEY/HOST) still work:
# UI_SENTRY_ENABLED + UI_SENTRY_DSN (+ optional UI_SENTRY_ENVIRONMENT)
# UI_GOOGLE_TAG_MANAGER_ENABLED + UI_GOOGLE_TAG_MANAGER_ID
# UI_POSTHOG_ENABLED + UI_POSTHOG_KEY + UI_POSTHOG_HOST (no consumer yet)
# UI_POSTHOG_ENABLED + UI_POSTHOG_KEY + UI_POSTHOG_HOST (feedback survey)
# - 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
+6 -4
View File
@@ -17,6 +17,7 @@ import { NavigationProgress } from "@/components/shadcn/navigation-progress";
import { Toaster } from "@/components/shadcn/toast";
import { TaskPollingWatcher } from "@/components/shared/task-polling-watcher";
import { GlobalSidePanel } from "@/components/side-panel";
import { FeedbackSurvey } from "@/components/survey/feedback-survey";
import { fontMono, fontSans } from "@/config/fonts";
import { siteConfig } from "@/config/site";
import { isCloud } from "@/lib/shared/env";
@@ -52,8 +53,8 @@ export default async function RootLayout({
}: {
children: ReactNode;
}) {
// Onboarding is Cloud-only; skip its fetches and orchestrators in OSS.
const onboardingEnabled = isCloud();
// Skip Cloud-only onboarding fetches and orchestrators in OSS.
const cloudEnabled = isCloud();
// Fail-open: unknown scan state is treated as "has data" so the banner never blocks
// progression on a fetch error.
@@ -61,7 +62,7 @@ export default async function RootLayout({
// Tri-state: true = has providers, false = zero providers, undefined = fetch failed (gate fails open).
let hasProviders: boolean | undefined = false;
if (onboardingEnabled) {
if (cloudEnabled) {
const [providersData, scansByState] = await Promise.all([
getProviders({ page: 1, pageSize: 1 }),
getScansByState(),
@@ -98,7 +99,7 @@ export default async function RootLayout({
</Suspense>
{/* Store uses boolean; gate receives tri-state to fail open on fetch errors. */}
<StoreInitializer values={{ hasProviders: hasProviders ?? false }} />
{onboardingEnabled && (
{cloudEnabled && (
<>
<OnboardingGate hasProviders={hasProviders} />
{/* Single mount point so the watcher survives post-connect navigation. */}
@@ -108,6 +109,7 @@ export default async function RootLayout({
</>
)}
<MainLayout>{children}</MainLayout>
{cloudEnabled && <FeedbackSurvey />}
{/* Always mounted: it hosts the detail (finding/resource) views in
every deployment; the AI tab inside is cloud-gated on its own. */}
<GlobalSidePanel />
@@ -0,0 +1 @@
In Prowler Cloud, authenticated users can send product feedback through a persistent widget backed by a PostHog headless survey, rendered with native Prowler components and editable from the PostHog dashboard
@@ -0,0 +1,448 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Survey } from "posthog-js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type SurveyCallback = (surveys: Survey[]) => void;
const mocks = vi.hoisted(() => ({
isCloud: vi.fn(),
useRuntimeConfig: vi.fn(),
init: vi.fn(),
onSurveysLoaded: vi.fn(),
capture: vi.fn(),
moduleLoaded: vi.fn(),
// Mirrors the singleton's `__loaded` flag: true when a PostHog instance
// already exists (as on Prowler Cloud, initialized in app/providers.tsx).
loaded: false,
}));
vi.mock("@/lib/shared/env", () => ({ isCloud: mocks.isCloud }));
vi.mock("@/hooks/use-runtime-config", () => ({
useRuntimeConfig: mocks.useRuntimeConfig,
}));
vi.mock("posthog-js", () => {
mocks.moduleLoaded();
return {
default: {
get __loaded() {
return mocks.loaded;
},
init: mocks.init,
onSurveysLoaded: mocks.onSurveysLoaded,
capture: mocks.capture,
},
};
});
const POSTHOG_KEY = "phc_key";
const SURVEY_FIXTURE = {
id: "survey-123",
name: "Prowler Feedback",
type: "api",
questions: [
{
id: "q-1",
type: "open",
question: "What could we do better?",
description:
"Is there anything we could do to make your experience better?",
},
],
appearance: {
placeholder: "Type your answer here",
submitButtonText: "Submit answer",
thankYouMessageHeader: "Thanks for the feedback!",
thankYouMessageDescription: "The Prowler team reads every response.",
},
} as unknown as Survey;
// Feed the given surveys to the component through the posthog callback the way
// the real SDK does once the definitions have loaded.
const provideSurveys = (surveys: Survey[]): void => {
mocks.onSurveysLoaded.mockImplementation((callback: SurveyCallback) => {
callback(surveys);
return () => {};
});
};
const renderSurvey = async () => {
const { FeedbackSurvey } = await import("./feedback-survey");
return render(<FeedbackSurvey />);
};
describe("FeedbackSurvey", () => {
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
mocks.loaded = false;
mocks.isCloud.mockReturnValue(true);
mocks.useRuntimeConfig.mockReturnValue({
cloudEnabled: true,
posthogEnabled: true,
posthogKey: POSTHOG_KEY,
posthogHost: "https://eu.posthog.com",
});
provideSurveys([SURVEY_FIXTURE]);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
it("renders the feedback trigger when Cloud and the survey is available", async () => {
// When
await renderSurvey();
// Then
const trigger = await screen.findByRole("button", {
name: "Give feedback",
});
expect(trigger).toBeVisible();
expect(trigger).toHaveTextContent("Feedback");
});
it("ignores a same-name non-API survey and selects the API survey", async () => {
// Given - PostHog can return multiple survey types with the same name.
const popoverSurvey = {
...SURVEY_FIXTURE,
id: "survey-popover",
type: "popover",
questions: [
{
id: "q-popover",
type: "open",
question: "This popover must not render",
},
],
} as unknown as Survey;
provideSurveys([popoverSurvey, SURVEY_FIXTURE]);
const user = userEvent.setup();
// When
await renderSurvey();
await user.click(
await screen.findByRole("button", { name: "Give feedback" }),
);
// Then
expect(
await screen.findByRole("heading", { name: "What could we do better?" }),
).toBeVisible();
expect(
screen.queryByRole("heading", { name: "This popover must not render" }),
).not.toBeInTheDocument();
});
it("opens a visible feedback form with the fetched question, description, placeholder, and submit copy", async () => {
// Given
const user = userEvent.setup();
await renderSurvey();
const trigger = await screen.findByRole("button", {
name: "Give feedback",
});
// Then - the form is not present until the user opens it
expect(
screen.queryByRole("heading", { name: "What could we do better?" }),
).not.toBeInTheDocument();
// When
await user.click(trigger);
// Then - the user actually sees the survey copy from the definition
expect(
await screen.findByRole("heading", { name: "What could we do better?" }),
).toBeVisible();
expect(
screen.getByText(
"Is there anything we could do to make your experience better?",
),
).toBeVisible();
const input = screen.getByPlaceholderText("Type your answer here");
expect(input).toBeVisible();
expect(screen.getByRole("button", { name: "Submit answer" })).toBeVisible();
});
it("captures 'survey shown' when the form opens", async () => {
// Given
const user = userEvent.setup();
await renderSurvey();
const trigger = await screen.findByRole("button", {
name: "Give feedback",
});
// When
await user.click(trigger);
// Then
await waitFor(() =>
expect(mocks.capture).toHaveBeenCalledWith("survey shown", {
$survey_id: "survey-123",
$survey_name: "Prowler Feedback",
}),
);
});
it("captures 'survey sent' with modern and legacy response keys and then thanks the user", async () => {
// Given
const user = userEvent.setup();
await renderSurvey();
await user.click(
await screen.findByRole("button", { name: "Give feedback" }),
);
await user.type(
screen.getByPlaceholderText("Type your answer here"),
"Add dark mode",
);
// When
await user.click(screen.getByRole("button", { name: "Submit answer" }));
// Then - the capture carries the survey identity, per-question payload, and
// BOTH the modern per-question key and the legacy first-question key.
await waitFor(() =>
expect(mocks.capture).toHaveBeenCalledWith("survey sent", {
$survey_id: "survey-123",
$survey_name: "Prowler Feedback",
$survey_questions: [
{
id: "q-1",
question: "What could we do better?",
response: "Add dark mode",
},
],
"$survey_response_q-1": "Add dark mode",
$survey_response: "Add dark mode",
}),
);
// Then - the user sees the thank-you copy from the definition
expect(await screen.findByText("Thanks for the feedback!")).toBeVisible();
expect(
screen.getByText("The Prowler team reads every response."),
).toBeVisible();
});
it("captures 'survey dismissed' when the form is closed without submitting", async () => {
// Given
const user = userEvent.setup();
await renderSurvey();
await user.click(
await screen.findByRole("button", { name: "Give feedback" }),
);
await screen.findByRole("heading", { name: "What could we do better?" });
// When - the user closes the popover without answering
await user.keyboard("{Escape}");
// Then
await waitFor(() =>
expect(mocks.capture).toHaveBeenCalledWith("survey dismissed", {
$survey_id: "survey-123",
$survey_name: "Prowler Feedback",
}),
);
});
it("does not dismiss after a successful submit when the form is closed", async () => {
// Given
const user = userEvent.setup();
await renderSurvey();
await user.click(
await screen.findByRole("button", { name: "Give feedback" }),
);
await user.type(
screen.getByPlaceholderText("Type your answer here"),
"Great tool",
);
await user.click(screen.getByRole("button", { name: "Submit answer" }));
await screen.findByText("Thanks for the feedback!");
// When - the user closes the thank-you popover
await user.keyboard("{Escape}");
// Then - a submitted survey is never also reported as dismissed
await waitFor(() =>
expect(mocks.capture).toHaveBeenCalledWith(
"survey sent",
expect.anything(),
),
);
expect(mocks.capture).not.toHaveBeenCalledWith(
"survey dismissed",
expect.anything(),
);
});
it("initializes PostHog once with the runtime config, then loads surveys, when Cloud with a key and host and no existing instance", async () => {
// Given - no PostHog instance exists yet (self-hosted opt-in path). Init is
// deferred to this component effect (post-hydration) to avoid a hydration
// mismatch, using the runtime public config island.
mocks.loaded = false;
// When
await renderSurvey();
// Then
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",
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
});
await waitFor(() => expect(mocks.onSurveysLoaded).toHaveBeenCalled());
});
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.
mocks.loaded = true;
// When
await renderSurvey();
// Then - it reuses Cloud's instance: no re-init, but surveys still load and
// the trigger renders.
await waitFor(() => expect(mocks.onSurveysLoaded).toHaveBeenCalled());
expect(mocks.init).not.toHaveBeenCalled();
expect(
await screen.findByRole("button", { name: "Give feedback" }),
).toBeVisible();
});
it("does not initialize or load surveys when the runtime config is empty", async () => {
// Given - the runtime config island has not provided PostHog settings
mocks.loaded = false;
mocks.useRuntimeConfig.mockReturnValue({
cloudEnabled: false,
posthogEnabled: false,
posthogKey: null,
posthogHost: null,
});
// When
const view = await renderSurvey();
// Then - no init, no survey fetch, nothing rendered
expect(mocks.init).not.toHaveBeenCalled();
expect(mocks.onSurveysLoaded).not.toHaveBeenCalled();
expect(mocks.moduleLoaded).not.toHaveBeenCalled();
expect(view.container).toBeEmptyDOMElement();
});
it("renders nothing and touches no PostHog surface when PostHog is disabled, even with a key and host present", async () => {
// Given - the integration is off with a stale key/host still present
mocks.loaded = false;
mocks.useRuntimeConfig.mockReturnValue({
cloudEnabled: true,
posthogEnabled: false,
posthogKey: POSTHOG_KEY,
posthogHost: "https://eu.posthog.com",
});
// When
const view = await renderSurvey();
// Then
expect(view.container).toBeEmptyDOMElement();
expect(
screen.queryByRole("button", { name: "Give feedback" }),
).not.toBeInTheDocument();
expect(mocks.init).not.toHaveBeenCalled();
expect(mocks.onSurveysLoaded).not.toHaveBeenCalled();
expect(mocks.capture).not.toHaveBeenCalled();
expect(mocks.moduleLoaded).not.toHaveBeenCalled();
});
it("renders nothing and touches no PostHog surface when not Cloud, even with a survey and key present", async () => {
// Given - OSS: isCloud() is the primary guard, even with a key and instance
mocks.isCloud.mockReturnValue(false);
mocks.loaded = true;
provideSurveys([SURVEY_FIXTURE]);
// When
const view = await renderSurvey();
// Then - no init, no trigger, no survey fetch, no capture, regardless of config
expect(view.container).toBeEmptyDOMElement();
expect(
screen.queryByRole("button", { name: "Give feedback" }),
).not.toBeInTheDocument();
expect(mocks.init).not.toHaveBeenCalled();
expect(mocks.onSurveysLoaded).not.toHaveBeenCalled();
expect(mocks.capture).not.toHaveBeenCalled();
expect(mocks.moduleLoaded).not.toHaveBeenCalled();
});
it("renders nothing when Cloud but the named survey is not available", async () => {
// Given
provideSurveys([]);
// When
const view = await renderSurvey();
// Then
await waitFor(() => expect(mocks.onSurveysLoaded).toHaveBeenCalled());
expect(view.container).toBeEmptyDOMElement();
expect(mocks.capture).not.toHaveBeenCalled();
});
it("renders nothing and never crashes when the matched survey has no questions", async () => {
// Given - a definition with an empty questions array (regression: submit
// used to read questions[0].question unguarded and could crash).
const questionlessSurvey = {
...SURVEY_FIXTURE,
questions: [],
} as unknown as Survey;
provideSurveys([questionlessSurvey]);
// When
const view = await renderSurvey();
// Then - no trigger renders, so the form (and its question access) is
// unreachable: nothing to interact with, nothing throws.
await waitFor(() => expect(mocks.onSurveysLoaded).toHaveBeenCalled());
expect(view.container).toBeEmptyDOMElement();
expect(
screen.queryByRole("button", { name: "Give feedback" }),
).not.toBeInTheDocument();
expect(mocks.capture).not.toHaveBeenCalled();
});
it("renders nothing when the first question is not an open-text question", async () => {
// Given - an editor changed the question type in the PostHog dashboard to a
// non-open kind; the free-text form only matches `open`.
const ratingSurvey = {
...SURVEY_FIXTURE,
questions: [
{
id: "q-1",
type: "rating",
question: "How would you rate Prowler?",
display: "number",
scale: 5,
lowerBoundLabel: "Bad",
upperBoundLabel: "Great",
},
],
} as unknown as Survey;
provideSurveys([ratingSurvey]);
// When
const view = await renderSurvey();
// Then - feature safely off: no trigger, no malformed response path
await waitFor(() => expect(mocks.onSurveysLoaded).toHaveBeenCalled());
expect(view.container).toBeEmptyDOMElement();
expect(
screen.queryByRole("button", { name: "Give feedback" }),
).not.toBeInTheDocument();
expect(mocks.capture).not.toHaveBeenCalled();
});
});
+24
View File
@@ -0,0 +1,24 @@
"use client";
import { lazy, Suspense } from "react";
import { useRuntimeConfig } from "@/hooks/use-runtime-config";
import { isCloud } from "@/lib/shared/env";
const RuntimeFeedbackSurvey = lazy(() => import("./runtime-feedback-survey"));
export function FeedbackSurvey() {
const { posthogEnabled, posthogKey, posthogHost } = useRuntimeConfig();
if (!isCloud() || !posthogEnabled || !posthogKey || !posthogHost) return null;
return (
<Suspense fallback={null}>
<RuntimeFeedbackSurvey
key={`${posthogKey}:${posthogHost}`}
posthogKey={posthogKey}
posthogHost={posthogHost}
/>
</Suspense>
);
}
@@ -0,0 +1,161 @@
"use client";
import { MessageSquareText } from "lucide-react";
import posthogClient from "posthog-js";
import type { Survey } from "posthog-js";
import { useState } from "react";
import { Button } from "@/components/shadcn/button/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/shadcn/popover";
import { Textarea } from "@/components/shadcn/textarea/textarea";
import { useMountEffect } from "@/hooks/use-mount-effect";
const SURVEY_NAME = "Prowler Feedback";
const SURVEY_EVENT = {
SHOWN: "survey shown",
SENT: "survey sent",
DISMISSED: "survey dismissed",
} as const;
interface RuntimeFeedbackSurveyProps {
posthogKey: string;
posthogHost: string;
}
export default function RuntimeFeedbackSurvey({
posthogKey,
posthogHost,
}: RuntimeFeedbackSurveyProps) {
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) {
posthogClient.init(posthogKey, {
api_host: posthogHost,
ui_host: posthogHost,
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
});
}
return posthogClient.onSurveysLoaded((surveys) => {
setSurvey(
surveys.find(
(item) => item.name === SURVEY_NAME && item.type === "api",
) ?? null,
);
});
});
const question = survey?.questions?.[0];
if (!survey || question?.type !== "open") return null;
const questionId = question.id ?? "";
const appearance = survey.appearance;
const trimmedResponse = response.trim();
const identity = { $survey_id: survey.id, $survey_name: survey.name };
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen);
if (nextOpen) {
setSubmitted(false);
setResponse("");
posthogClient.capture(SURVEY_EVENT.SHOWN, identity);
return;
}
if (!submitted) posthogClient.capture(SURVEY_EVENT.DISMISSED, identity);
};
const handleSubmit = () => {
if (!trimmedResponse) return;
posthogClient.capture(SURVEY_EVENT.SENT, {
...identity,
$survey_questions: [
{
id: questionId,
question: question.question,
response: trimmedResponse,
},
],
[`$survey_response_${questionId}`]: trimmedResponse,
$survey_response: trimmedResponse,
});
setSubmitted(true);
};
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
type="button"
aria-label="Give feedback"
size="xl"
className="group fixed right-6 bottom-20 z-50 transition-all duration-200 hover:-translate-y-0.5 active:translate-y-0 active:scale-[0.98] motion-reduce:transform-none motion-reduce:transition-none"
>
<MessageSquareText
aria-hidden="true"
className="transition-transform duration-200 group-hover:scale-110 group-hover:-rotate-6 motion-reduce:transform-none motion-reduce:transition-none"
/>
<span>Feedback</span>
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
side="top"
className="w-[min(92vw,26rem)] p-5"
>
{submitted ? (
<div role="status" aria-live="polite" className="flex flex-col gap-1">
<h2 className="text-text-neutral-primary text-base font-semibold">
{appearance?.thankYouMessageHeader ?? "Thanks for the feedback!"}
</h2>
{appearance?.thankYouMessageDescription ? (
<p className="text-text-neutral-secondary text-sm">
{appearance.thankYouMessageDescription}
</p>
) : null}
</div>
) : (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
handleSubmit();
}}
>
<div className="flex flex-col gap-1">
<h2 className="text-text-neutral-primary text-base font-semibold">
{question.question}
</h2>
{question.description ? (
<p className="text-text-neutral-secondary text-sm">
{question.description}
</p>
) : null}
</div>
<Textarea
aria-label={question.question}
placeholder={appearance?.placeholder ?? ""}
value={response}
onChange={(event) => setResponse(event.target.value)}
textareaSize="lg"
className="min-h-32"
/>
<Button type="submit" disabled={!trimmedResponse}>
{appearance?.submitButtonText ?? "Submit"}
</Button>
</form>
)}
</PopoverContent>
</Popover>
);
}
+8
View File
@@ -519,6 +519,14 @@
"strategy": "installed",
"generatedAt": "2025-10-22T12:36:37.962Z"
},
{
"section": "dependencies",
"name": "posthog-js",
"from": "1.260.1",
"to": "1.407.2",
"strategy": "installed",
"generatedAt": "2026-07-27T08:35:41.397Z"
},
{
"section": "dependencies",
"name": "react",
+30
View File
@@ -0,0 +1,30 @@
const POSTHOG_CSP_SOURCE = "https://*.posthog.com";
interface CspOptions {
cloudEnabled: boolean;
posthogEnabled: boolean;
posthogKey: string | null;
posthogHost: string | null;
}
export function getCspHeader({
cloudEnabled,
posthogEnabled,
posthogKey,
posthogHost,
}: CspOptions): string {
const allowPosthog =
cloudEnabled && posthogEnabled && Boolean(posthogKey && posthogHost);
const posthogSource = allowPosthog ? ` ${POSTHOG_CSP_SOURCE}` : "";
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';
`.replace(/\n/g, "");
}
+24
View File
@@ -45,6 +45,29 @@ describe("getRuntimeConfigClient", () => {
// Keys not present in the island fall back to null.
expect(config.googleTagManagerId).toBeNull();
expect(config.posthogKey).toBeNull();
// Booleans fall back to false, not undefined.
expect(config.posthogEnabled).toBe(false);
});
it("carries the PostHog enable flag through the island", async () => {
// Given
writeIsland(
JSON.stringify({
posthogEnabled: true,
posthogKey: "phc_key",
posthogHost: "https://eu.i.posthog.com",
}),
);
const { getRuntimeConfigClient } = await import(
"./get-runtime-config.client"
);
// When
const config = getRuntimeConfigClient();
// Then
expect(config.posthogEnabled).toBe(true);
expect(config.posthogKey).toBe("phc_key");
});
it("falls back to an all-null config when the island is absent", async () => {
@@ -115,6 +138,7 @@ describe("getRuntimeConfigClient", () => {
"cloudBillingEnabled",
"cloudEnabled",
"googleTagManagerId",
"posthogEnabled",
"posthogHost",
"posthogKey",
"reoDevClientId",
+60
View File
@@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
assertGatedIntegrations,
GATED_INTEGRATIONS,
isGatedIntegrationEnabled,
readGatedEnv,
warnGatedIntegrationsMisconfig,
} from "./integrations";
@@ -213,6 +215,64 @@ describe("assertGatedIntegrations", () => {
});
});
describe("isGatedIntegrationEnabled", () => {
it("is false when neither the enable flag nor any legacy name is set", () => {
// Given no PostHog env at all
// When / Then
expect(isGatedIntegrationEnabled(GATED_INTEGRATIONS.posthog)).toBe(false);
});
it("is true when the enable flag is 'true'", () => {
// Given
vi.stubEnv("UI_POSTHOG_ENABLED", "true");
vi.stubEnv("UI_POSTHOG_KEY", "phc_key");
vi.stubEnv("UI_POSTHOG_HOST", "https://eu.i.posthog.com");
// When / Then
expect(isGatedIntegrationEnabled(GATED_INTEGRATIONS.posthog)).toBe(true);
});
it("is true for a complete legacy config without the enable flag", () => {
// Given - legacy presence activates without the flag
vi.stubEnv("POSTHOG_KEY", "phc_key");
vi.stubEnv("POSTHOG_HOST", "https://eu.i.posthog.com");
// When / Then
expect(isGatedIntegrationEnabled(GATED_INTEGRATIONS.posthog)).toBe(true);
});
it("is false for a partial legacy config without the enable flag", () => {
// Given - incomplete legacy set (host missing)
vi.stubEnv("POSTHOG_KEY", "phc_key");
// When / Then
expect(isGatedIntegrationEnabled(GATED_INTEGRATIONS.posthog)).toBe(false);
});
it("is false when the enable flag is explicitly 'false'", () => {
// Given
vi.stubEnv("UI_POSTHOG_ENABLED", "false");
vi.stubEnv("UI_POSTHOG_KEY", "phc_key");
// When / Then
expect(isGatedIntegrationEnabled(GATED_INTEGRATIONS.posthog)).toBe(false);
});
it("resolves each integration independently", () => {
// Given - only Sentry is enabled
vi.stubEnv("UI_SENTRY_ENABLED", "true");
vi.stubEnv("UI_SENTRY_DSN", "https://dsn.example");
// When / Then
expect(isGatedIntegrationEnabled(GATED_INTEGRATIONS.sentry)).toBe(true);
expect(isGatedIntegrationEnabled(GATED_INTEGRATIONS.posthog)).toBe(false);
expect(isGatedIntegrationEnabled(GATED_INTEGRATIONS.googleTagManager)).toBe(
false,
);
});
});
describe("warnGatedIntegrationsMisconfig", () => {
it("warns when a config value is set but its enable flag is not 'true'", () => {
// Given
+11 -1
View File
@@ -17,7 +17,7 @@ interface IntegrationEnvVar {
legacy?: keyof NodeJS.ProcessEnv;
}
interface GatedIntegration {
export interface GatedIntegration {
name: string;
enableKey: keyof NodeJS.ProcessEnv;
required: ReadonlyArray<IntegrationEnvVar>;
@@ -82,6 +82,16 @@ function hasCompleteLegacyConfig(integration: GatedIntegration): boolean {
);
}
// True when an integration is active through either path: the enable flag is
// "true", or a complete legacy config is present.
export function isGatedIntegrationEnabled(
integration: GatedIntegration,
): boolean {
return (
readBoolEnv(integration.enableKey) || hasCompleteLegacyConfig(integration)
);
}
// True when any required legacy name is set, i.e. the deployment is attempting
// legacy activation (possibly half-configured).
function hasAnyLegacyConfig(integration: GatedIntegration): boolean {
+5 -2
View File
@@ -6,8 +6,9 @@ export interface RuntimePublicConfig {
googleTagManagerId: string | null;
apiBaseUrl: string | null;
apiDocsUrl: string | null;
posthogKey: string | null; // reserved
posthogHost: string | null; // reserved
posthogEnabled: boolean;
posthogKey: string | null;
posthogHost: string | null;
reoDevClientId: string | null; // reserved
cloudEnabled: boolean;
cloudBillingEnabled: boolean;
@@ -24,6 +25,7 @@ export const EMPTY_RUNTIME_PUBLIC_CONFIG: RuntimePublicConfig = {
googleTagManagerId: null,
apiBaseUrl: null,
apiDocsUrl: null,
posthogEnabled: false,
posthogKey: null,
posthogHost: null,
reoDevClientId: null,
@@ -42,6 +44,7 @@ const pickConfig = (
googleTagManagerId: parsed.googleTagManagerId ?? null,
apiBaseUrl: parsed.apiBaseUrl ?? null,
apiDocsUrl: parsed.apiDocsUrl ?? null,
posthogEnabled: parsed.posthogEnabled ?? false,
posthogKey: parsed.posthogKey ?? null,
posthogHost: parsed.posthogHost ?? null,
reoDevClientId: parsed.reoDevClientId ?? null,
+6 -1
View File
@@ -2,7 +2,11 @@ import "server-only";
import { connection } from "next/server";
import { readGatedEnv } from "@/lib/integrations";
import {
GATED_INTEGRATIONS,
isGatedIntegrationEnabled,
readGatedEnv,
} from "@/lib/integrations";
import { type RuntimePublicConfig } from "@/lib/runtime-config.shared";
import { readBoolEnv, readEnv } from "@/lib/runtime-env";
@@ -33,6 +37,7 @@ export async function getRuntimePublicConfig(): Promise<RuntimePublicConfig> {
),
apiBaseUrl: readEnv("UI_API_BASE_URL", "NEXT_PUBLIC_API_BASE_URL"),
apiDocsUrl: readEnv("UI_API_DOCS_URL", "NEXT_PUBLIC_API_DOCS_URL"),
posthogEnabled: isGatedIntegrationEnabled(GATED_INTEGRATIONS.posthog),
posthogKey: readGatedEnv(
"UI_POSTHOG_ENABLED",
"UI_POSTHOG_KEY",
-20
View File
@@ -6,22 +6,6 @@ const { withSentryConfig } = require("@sentry/nextjs");
/** @type {import('next').NextConfig} */
// HTTP Security Headers
// 'unsafe-eval' is configured under `script-src` because it is required by NextJS for development mode.
//
// CSP is static; the JSON config island is inert (no nonce needed). A runtime
// Sentry DSN must be in `connect-src` below — `*.sentry.io` covers Sentry Cloud,
// but a self-hosted/region host is blocked until per-request CSP (middleware) lands.
const cspHeader = `
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com https://www.googletagmanager.com https://browser.sentry-cdn.com;
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;
img-src 'self' https://www.google-analytics.com https://www.googletagmanager.com;
font-src 'self';
style-src 'self' 'unsafe-inline';
frame-src 'self' https://js.stripe.com https://www.googletagmanager.com;
frame-ancestors 'none';
`;
const nextConfig = {
poweredByHeader: false,
// Use standalone only in production deployments, not for CI/testing
@@ -37,10 +21,6 @@ const nextConfig = {
},
async headers() {
const headers = [
{
key: "Content-Security-Policy",
value: cspHeader.replace(/\n/g, ""),
},
{
key: "X-Content-Type-Options",
value: "nosniff",
+125
View File
@@ -0,0 +1,125 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
import { getCspHeader } from "@/lib/csp";
const require = createRequire(import.meta.url);
const config = require("./next.config.js") as {
headers: () => Promise<
Array<{ headers: Array<{ key: string; value: string }> }>
>;
};
const POSTHOG_WILDCARD = "https://*.posthog.com";
const ENABLED_POSTHOG_CONFIG = {
cloudEnabled: true,
posthogEnabled: true,
posthogKey: "phc_key",
posthogHost: "https://eu.posthog.com",
};
const BASELINE_CSP = {
"default-src": ["'self'"],
"script-src": [
"'self'",
"'unsafe-inline'",
"'unsafe-eval'",
"https://js.stripe.com",
"https://www.googletagmanager.com",
"https://browser.sentry-cdn.com",
],
"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",
],
"img-src": [
"'self'",
"https://www.google-analytics.com",
"https://www.googletagmanager.com",
],
"font-src": ["'self'"],
"style-src": ["'self'", "'unsafe-inline'"],
"frame-src": [
"'self'",
"https://js.stripe.com",
"https://www.googletagmanager.com",
],
"frame-ancestors": ["'none'"],
} as const;
const getStaticCsp = async () => {
const rules = await config.headers();
return rules[0]?.headers.find(({ key }) => key === "Content-Security-Policy");
};
const parseCsp = (value: string) => {
return Object.fromEntries(
value
.split(";")
.map((entry) => entry.trim().split(/\s+/))
.filter(([name]) => name)
.map(([name, ...sources]) => [name, sources]),
) as Record<string, string[]>;
};
describe("PostHog Content Security Policy", () => {
it("does not configure CSP through static Next headers", async () => {
// When
const staticCsp = await getStaticCsp();
// Then
expect(staticCsp).toBeUndefined();
});
it("omits PostHog permissions from the baseline request CSP", () => {
// When
const csp = parseCsp(
getCspHeader({
cloudEnabled: false,
posthogEnabled: false,
posthogKey: null,
posthogHost: null,
}),
);
// Then
expect(csp).toEqual(BASELINE_CSP);
expect(Object.values(csp).flat()).not.toContain(POSTHOG_WILDCARD);
});
it("adds PostHog permissions only for a fully enabled Cloud request", () => {
// When
const csp = parseCsp(getCspHeader(ENABLED_POSTHOG_CONFIG));
// Then
expect(csp["script-src"]).toContain(POSTHOG_WILDCARD);
expect(csp["connect-src"]).toContain(POSTHOG_WILDCARD);
expect(csp["img-src"]).toContain(POSTHOG_WILDCARD);
expect(csp["frame-src"]).toContain(POSTHOG_WILDCARD);
expect(csp["font-src"]).not.toContain(POSTHOG_WILDCARD);
expect(csp["default-src"]).not.toContain(POSTHOG_WILDCARD);
});
it.each([
["Cloud is disabled", { cloudEnabled: false }],
["PostHog is disabled", { posthogEnabled: false }],
["the key is missing", { posthogKey: null }],
["the host is missing", { posthogHost: null }],
])("omits PostHog permissions when %s", (_case, override) => {
// Given
const config = { ...ENABLED_POSTHOG_CONFIG, ...override };
// When
const csp = parseCsp(getCspHeader(config));
// Then
expect(Object.values(csp).flat()).not.toContain(POSTHOG_WILDCARD);
});
});
+1
View File
@@ -100,6 +100,7 @@
"next": "16.2.11",
"next-auth": "5.0.0-beta.32",
"next-themes": "0.2.1",
"posthog-js": "1.407.2",
"react": "19.2.7",
"react-day-picker": "9.13.0",
"react-dom": "19.2.7",
+72
View File
@@ -237,6 +237,9 @@ importers:
next-themes:
specifier: 0.2.1
version: 0.2.1(next@16.2.11(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.56.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
posthog-js:
specifier: 1.407.2
version: 1.407.2(preact-render-to-string@6.5.11(preact@10.24.3))
react:
specifier: 19.2.7
version: 19.2.7
@@ -1896,6 +1899,15 @@ packages:
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@posthog/browser-common@0.2.1':
resolution: {integrity: sha512-FTVPsRw6GBKWvFwN26TNIQNNYPR9FsUj+B+CKhk/ht63IRzn4yYFtFOjcmW+bfXvNHuRADs6APbZ6Haz1kpoAw==}
'@posthog/core@1.45.1':
resolution: {integrity: sha512-tLtvzomavb2PPWdGYKsusyIzIeL2Px47v348Smibkay7sMy/83TyPk+Ptsp2NdeOgJsbuwSxWkR2+XA0aSCAaA==}
'@posthog/types@1.398.0':
resolution: {integrity: sha512-sJMkl4k+u8yS/0fjHsKqE9xTdsAh30a2WvgChiptellnVoE0e8QJKFgqOMD2sk8FaEArPdeFklAhXvmENAt3Sg==}
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -4158,6 +4170,9 @@ packages:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
core-js@3.49.0:
resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==}
cors@2.8.5:
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
engines: {node: '>= 0.10'}
@@ -4790,6 +4805,9 @@ packages:
picomatch:
optional: true
fflate@0.4.9:
resolution: {integrity: sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==}
file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
@@ -6131,6 +6149,9 @@ packages:
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
engines: {node: ^10 || ^12 || >=14}
posthog-js@1.407.2:
resolution: {integrity: sha512-5deTvXopn+NJWEmCUw8Ix/ms3cv2+60WJ73Vgbvu2wSkZY/FIj2uqAgCnq7IewGpGC+tH7CAbPYFzH6hw8eW1w==}
preact-render-to-string@6.5.11:
resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==}
peerDependencies:
@@ -6139,6 +6160,14 @@ packages:
preact@10.24.3:
resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==}
preact@10.29.7:
resolution: {integrity: sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==}
peerDependencies:
preact-render-to-string: '>=5'
peerDependenciesMeta:
preact-render-to-string:
optional: true
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -6246,6 +6275,9 @@ packages:
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
engines: {node: '>=0.6'}
query-selector-shadow-dom@1.0.1:
resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -7154,6 +7186,9 @@ packages:
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
web-vitals@5.3.0:
resolution: {integrity: sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -8976,6 +9011,17 @@ snapshots:
'@polka/url@1.0.0-next.29': {}
'@posthog/browser-common@0.2.1':
dependencies:
'@posthog/core': 1.45.1
'@posthog/types': 1.398.0
'@posthog/core@1.45.1':
dependencies:
'@posthog/types': 1.398.0
'@posthog/types@1.398.0': {}
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.2': {}
@@ -11363,6 +11409,8 @@ snapshots:
cookie@1.1.1: {}
core-js@3.49.0: {}
cors@2.8.5:
dependencies:
object-assign: 4.1.1
@@ -12175,6 +12223,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.5
fflate@0.4.9: {}
file-entry-cache@8.0.0:
dependencies:
flat-cache: 4.0.1
@@ -13824,12 +13874,30 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
posthog-js@1.407.2(preact-render-to-string@6.5.11(preact@10.24.3)):
dependencies:
'@posthog/browser-common': 0.2.1
'@posthog/core': 1.45.1
'@posthog/types': 1.398.0
core-js: 3.49.0
dompurify: 3.4.11
fflate: 0.4.9
preact: 10.29.7(preact-render-to-string@6.5.11(preact@10.24.3))
query-selector-shadow-dom: 1.0.1
web-vitals: 5.3.0
transitivePeerDependencies:
- preact-render-to-string
preact-render-to-string@6.5.11(preact@10.24.3):
dependencies:
preact: 10.24.3
preact@10.24.3: {}
preact@10.29.7(preact-render-to-string@6.5.11(preact@10.24.3)):
optionalDependencies:
preact-render-to-string: 6.5.11(preact@10.24.3)
prelude-ls@1.2.1: {}
prettier-plugin-packagejson@2.5.22(prettier@3.6.2):
@@ -13873,6 +13941,8 @@ snapshots:
dependencies:
side-channel: 1.1.0
query-selector-shadow-dom@1.0.1: {}
queue-microtask@1.2.3: {}
range-parser@1.2.1: {}
@@ -14970,6 +15040,8 @@ snapshots:
web-namespaces@2.0.1: {}
web-vitals@5.3.0: {}
webidl-conversions@3.0.1: {}
webidl-conversions@8.0.1: {}
+3
View File
@@ -100,6 +100,9 @@ allowBuilds:
unrs-resolver: true
# msw: Copies mockServiceWorker.js into the directories listed in package.json's `msw.workerDirectory` (here: `public/`) so the runtime worker stays in sync with the installed msw version. Pure file copy — no native binary, no network access. Required for vitest browser tests to intercept fetches via the service worker.
msw: true
# core-js: transitive dep of posthog-js. Its postinstall only prints a funding
# banner — no native binary is needed for the feedback survey. Deny the script.
core-js: false
# --- Level 3: Trust Policy + Exotic Subdeps ---
# Fail when a package's trust evidence is downgraded (e.g., new publisher).
+35 -5
View File
@@ -2,6 +2,12 @@ import { NextResponse } from "next/server";
import type { NextAuthRequest } from "next-auth";
import { auth } from "@/auth.config";
import { getCspHeader } from "@/lib/csp";
import {
GATED_INTEGRATIONS,
isGatedIntegrationEnabled,
readGatedEnv,
} from "@/lib/integrations";
import { readEnv } from "@/lib/runtime-env";
import { isCloud } from "@/lib/shared/env";
@@ -19,6 +25,30 @@ const isPublicRoute = (pathname: string): boolean => {
return publicRoutes.some((route) => pathname.startsWith(route));
};
const withSecurityHeaders = (response: NextResponse): NextResponse => {
response.headers.set(
"Content-Security-Policy",
getCspHeader({
cloudEnabled: isCloud(),
posthogEnabled: isGatedIntegrationEnabled(GATED_INTEGRATIONS.posthog),
posthogKey: readGatedEnv(
"UI_POSTHOG_ENABLED",
"UI_POSTHOG_KEY",
"POSTHOG_KEY",
),
posthogHost: readGatedEnv(
"UI_POSTHOG_ENABLED",
"UI_POSTHOG_HOST",
"POSTHOG_HOST",
),
}),
);
return response;
};
const redirect = (url: URL): NextResponse =>
withSecurityHeaders(NextResponse.redirect(url));
// NextAuth's auth() wrapper - renamed from middleware to proxy
export default auth((req: NextAuthRequest) => {
const { pathname } = req.nextUrl;
@@ -33,13 +63,13 @@ export default auth((req: NextAuthRequest) => {
const signInUrl = new URL("/sign-in", req.url);
signInUrl.searchParams.set("error", sessionError);
signInUrl.searchParams.set("callbackUrl", pathname + req.nextUrl.search);
return NextResponse.redirect(signInUrl);
return redirect(signInUrl);
}
if (!user && !isPublicRoute(pathname)) {
const signInUrl = new URL("/sign-in", req.url);
signInUrl.searchParams.set("callbackUrl", pathname + req.nextUrl.search);
return NextResponse.redirect(signInUrl);
return redirect(signInUrl);
}
if (
@@ -48,7 +78,7 @@ export default auth((req: NextAuthRequest) => {
!cloudBillingEnabled ||
user?.permissions?.manage_billing !== true)
) {
return NextResponse.redirect(new URL("/profile", req.url));
return redirect(new URL("/profile", req.url));
}
if (user?.permissions) {
@@ -58,11 +88,11 @@ export default auth((req: NextAuthRequest) => {
pathname.startsWith("/integrations") &&
!permissions.manage_integrations
) {
return NextResponse.redirect(new URL("/profile", req.url));
return redirect(new URL("/profile", req.url));
}
}
return NextResponse.next();
return withSecurityHeaders(NextResponse.next());
});
export const config = {
@@ -16,6 +16,7 @@ export const RUNTIME_CONFIG_KEYS = [
"googleTagManagerId",
"apiBaseUrl",
"apiDocsUrl",
"posthogEnabled",
"posthogKey",
"posthogHost",
"reoDevClientId",