feat(ui): resolve public SaaS config at container runtime (#11500)

Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Pablo Fernandez Guerra (PFE)
2026-06-18 15:12:18 +02:00
committed by GitHub
parent 751c7fc29f
commit 853610bbbf
54 changed files with 1590 additions and 259 deletions
+16 -4
View File
@@ -6,14 +6,20 @@
PROWLER_UI_VERSION="stable"
AUTH_URL=http://localhost:3000
API_BASE_URL=http://prowler-api:8080/api/v1
# deprecated, use UI_API_BASE_URL
NEXT_PUBLIC_API_BASE_URL=${API_BASE_URL}
UI_API_BASE_URL=${API_BASE_URL}
# deprecated, use UI_API_DOCS_URL
NEXT_PUBLIC_API_DOCS_URL=http://prowler-api:8080/api/v1/docs
UI_API_DOCS_URL=http://prowler-api:8080/api/v1/docs
AUTH_TRUST_HOST=true
UI_PORT=3000
# openssl rand -base64 32
AUTH_SECRET="N/c6mnaS5+SWq81+819OrzQZlmx1Vxtp/orjttJSmw8="
# Google Tag Manager ID
# Google Tag Manager ID (empty/unset ⇒ GTM not loaded, zero egress)
# deprecated, use UI_GOOGLE_TAG_MANAGER_ID
NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID=""
UI_GOOGLE_TAG_MANAGER_ID=""
#### MCP Server ####
PROWLER_MCP_VERSION=stable
@@ -139,10 +145,16 @@ DJANGO_BROKER_VISIBILITY_TIMEOUT=86400
DJANGO_SENTRY_DSN=
DJANGO_THROTTLE_TOKEN_OBTAIN=50/minute
# Sentry settings
SENTRY_ENVIRONMENT=local
# Sentry for the web app (server + browser). Empty/unset UI_SENTRY_DSN ⇒
# Sentry disabled, zero egress. SENTRY_RELEASE (unprefixed) feeds the web app's
# server/edge SDKs.
UI_SENTRY_DSN=
UI_SENTRY_ENVIRONMENT=local
SENTRY_RELEASE=local
NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT}
# Reserved runtime public config (registered now; no UI consumer yet)
# POSTHOG_KEY=
# POSTHOG_HOST=
# REO_DEV_CLIENT_ID=
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.31.0
@@ -32,9 +32,6 @@ env:
PROWLERCLOUD_DOCKERHUB_REPOSITORY: prowlercloud
PROWLERCLOUD_DOCKERHUB_IMAGE: prowler-ui
# Build args
NEXT_PUBLIC_API_BASE_URL: http://prowler-api:8080/api/v1
permissions: {}
jobs:
@@ -146,7 +143,6 @@ jobs:
context: ${{ env.WORKING_DIRECTORY }}
build-args: |
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('v{0}', env.RELEASE_TAG) || needs.setup.outputs.short-sha }}
NEXT_PUBLIC_API_BASE_URL=${{ env.NEXT_PUBLIC_API_BASE_URL }}
push: true
platforms: ${{ matrix.platform }}
tags: |
+3 -2
View File
@@ -40,7 +40,8 @@ jobs:
AUTH_SECRET: 'fallback-ci-secret-for-testing'
AUTH_TRUST_HOST: true
NEXTAUTH_URL: 'http://localhost:3000'
NEXT_PUBLIC_API_BASE_URL: 'http://localhost:8080/api/v1'
AUTH_URL: 'http://localhost:3000'
UI_API_BASE_URL: 'http://localhost:8080/api/v1'
E2E_ADMIN_USER: ${{ secrets.E2E_ADMIN_USER }}
E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }}
E2E_AWS_PROVIDER_ACCOUNT_ID: ${{ secrets.E2E_AWS_PROVIDER_ACCOUNT_ID }}
@@ -165,7 +166,7 @@ jobs:
timeout=150
elapsed=0
while [ $elapsed -lt $timeout ]; do
if curl -s ${NEXT_PUBLIC_API_BASE_URL}/docs >/dev/null 2>&1; then
if curl -s ${UI_API_BASE_URL}/docs >/dev/null 2>&1; then
echo "Prowler API is ready!"
exit 0
fi
@@ -11,8 +11,7 @@ data:
{{- else }}
AUTH_URL: {{ .Values.ui.authUrl | quote }}
{{- end }}
API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1"
NEXT_PUBLIC_API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1"
NEXT_PUBLIC_API_DOCS_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1/docs"
UI_API_BASE_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1"
UI_API_DOCS_URL: "http://{{ include "prowler.fullname" . }}-api:{{ .Values.api.service.port }}/api/v1/docs"
AUTH_TRUST_HOST: "true"
UI_PORT: {{ .Values.ui.service.port | quote }}
+2 -2
View File
@@ -21,8 +21,8 @@ fullnameOverride: ""
secrets:
SITE_URL: http://localhost:3000
API_BASE_URL: http://prowler-api:8080/api/v1
NEXT_PUBLIC_API_DOCS_URL: http://prowler-api:8080/api/v1/docs
UI_API_BASE_URL: http://prowler-api:8080/api/v1
UI_API_DOCS_URL: http://prowler-api:8080/api/v1/docs
AUTH_TRUST_HOST: True
UI_PORT: 3000
# openssl rand -base64 32
+2 -2
View File
@@ -221,9 +221,9 @@ Before running E2E tests:
```
- **Ensure Prowler API is available**
- By default, Playwright uses `NEXT_PUBLIC_API_BASE_URL=http://localhost:8080/api/v1` (configured in `playwright.config.ts`).
- By default, Playwright uses `UI_API_BASE_URL=http://localhost:8080/api/v1` (configured in `playwright.config.ts`).
- Start Prowler API so it is reachable on that URL (for example, via `docker-compose-dev.yml` or the development orchestration used locally).
- If a different API URL is required, set `NEXT_PUBLIC_API_BASE_URL` accordingly before running the tests.
- If a different API URL is required, set `UI_API_BASE_URL` accordingly before running the tests.
- **Ensure Prowler App UI is available**
- Playwright automatically starts the Next.js server through the `webServer` block in `playwright.config.ts` (`pnpm run dev` by default).
@@ -0,0 +1,53 @@
---
title: 'Environment Variable Naming Convention'
---
Prowler is a monorepo composed of several runtime components — Prowler App (the web user interface), Prowler API (the backend), Prowler SDK, and Prowler MCP Server (Model Context Protocol) — that frequently share a single `.env` file. To keep that shared configuration unambiguous, each component namespaces its environment variables with a component-specific prefix.
## Component Prefixes
Each component owns a dedicated prefix for the environment variables it reads:
| Component | Prefix | Status |
|-----------|--------|--------|
| Prowler App (web UI) | `UI_` | Adopted |
| Prowler API (backend) | `API_` | Planned |
| Prowler SDK | `SDK_` | Planned |
| Prowler MCP Server | `MCP_` | Planned |
## Why Component Prefixes Matter
Component prefixes solve three concrete problems in a shared configuration file:
- **Collisions in a shared `.env`:** Several components historically read identically named variables. The API base URL, for example, is consumed by more than one component, so a single unprefixed name is ambiguous. A component prefix removes that ambiguity.
- **Explicit ownership:** A prefix states, at a glance, which component consumes a variable.
- **Reduced accidental exposure:** For Prowler App, scoping browser-facing configuration under one intentional prefix prevents server-only values from leaking into the client bundle.
## Prowler App
Prowler App has adopted the `UI_` prefix. Its public configuration is resolved from the container environment at runtime rather than inlined at build time, so a single pre-built image serves any deployment. For the operational details on changing these values without rebuilding the image, see [Troubleshooting](/troubleshooting).
The former build-time variables map to the new runtime variables as follows:
| Former variable | New variable |
|-----------------|--------------|
| `NEXT_PUBLIC_API_BASE_URL` | `UI_API_BASE_URL` |
| `NEXT_PUBLIC_API_DOCS_URL` | `UI_API_DOCS_URL` |
| `NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID` | `UI_GOOGLE_TAG_MANAGER_ID` |
| `NEXT_PUBLIC_SENTRY_DSN`, `SENTRY_DSN` | `UI_SENTRY_DSN` |
| `NEXT_PUBLIC_SENTRY_ENVIRONMENT`, `SENTRY_ENVIRONMENT` | `UI_SENTRY_ENVIRONMENT` |
The build-time-only Sentry variables used for source-map upload — `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN`, and `SENTRY_RELEASE` — keep their names, as they are not part of the App's runtime configuration.
## Upcoming Breaking Change
<Warning>
Adopting the `API_`, `SDK_`, and `MCP_` prefixes for Prowler API, Prowler SDK, and Prowler MCP Server is a planned breaking change in a future release. Migrate environment configuration to the new names when upgrading.
</Warning>
Prowler API, Prowler SDK, and Prowler MCP Server have not yet adopted the convention. In a future release, the variables each of these components reads will be namespaced under `API_`, `SDK_`, and `MCP_` respectively. The per-component mapping from current to prefixed names will be documented when each change is released.
## Deprecated Names
- **Prowler App:** The bare server-side `SENTRY_DSN` and `SENTRY_ENVIRONMENT` are no longer read; the server and edge runtimes now read `UI_SENTRY_DSN` and `UI_SENTRY_ENVIRONMENT`. The former `NEXT_PUBLIC_*` build-time variables are deprecated but still read at runtime as a fallback when the matching `UI_*` variable is unset. This fallback will be removed in a future release, so set the `UI_*` runtime variables on the running container.
- **Prowler API, Prowler SDK, and Prowler MCP Server:** The current, unprefixed variable names are deprecated. They continue to work today and will be removed once the prefixed convention is adopted for each component, as described in [Upcoming Breaking Change](#upcoming-breaking-change).
+1
View File
@@ -417,6 +417,7 @@
"group": "Miscellaneous",
"pages": [
"developer-guide/documentation",
"developer-guide/environment-variables",
{
"group": "Testing",
"pages": [
+14 -20
View File
@@ -201,35 +201,29 @@ When running Prowler behind a reverse proxy (nginx, Traefik, etc.) or load balan
**Root Cause:**
Next.js environment variables prefixed with `NEXT_PUBLIC_` are **bundled at build time**, not runtime. The pre-built Docker images from Docker Hub (`prowlercloud/prowler-ui:stable`) are built with default internal URLs. Simply setting `NEXT_PUBLIC_API_BASE_URL` in your `.env` file or environment variables and restarting the container will **NOT** work because these values are already compiled into the JavaScript bundle.
The API base and docs URLs are resolved from the container environment **at runtime**. A single pre-built Docker image (`prowlercloud/prowler-ui:stable`) therefore serves any environment: point the URLs at your external domain and restart the container — no rebuild is required.
**Solution:**
You must **rebuild** the UI Docker image with your external URL:
```bash
# Clone the repository (if you haven't already)
git clone https://github.com/prowler-cloud/prowler.git
cd prowler/ui
# Build with your external URL as a build argument
docker build \
--build-arg NEXT_PUBLIC_API_BASE_URL=https://prowler.example.com/api/v1 \
--build-arg NEXT_PUBLIC_API_DOCS_URL=https://prowler.example.com/api/v1/docs \
-t prowler-ui-custom:latest \
--target prod \
.
```
Then update your `docker-compose.yml` to use your custom image instead of the pre-built one:
Set the runtime environment variables to your external URL and restart the UI container:
```yaml
services:
ui:
image: prowler-ui-custom:latest # Use your custom-built image
image: prowlercloud/prowler-ui:stable
environment:
UI_API_BASE_URL: https://prowler.example.com/api/v1
UI_API_DOCS_URL: https://prowler.example.com/api/v1/docs
# ... rest of configuration
```
The same values can be supplied through your `.env` file:
```bash
UI_API_BASE_URL=https://prowler.example.com/api/v1
UI_API_DOCS_URL=https://prowler.example.com/api/v1/docs
```
<Note>
The `NEXT_PUBLIC_` prefix is a Next.js convention that exposes environment variables to the browser. Since the browser bundle is compiled during `docker build`, these variables must be provided as build arguments, not runtime environment variables.
Earlier releases inlined these values into the JavaScript bundle at build time (via the `NEXT_PUBLIC_` prefix) and required a rebuild with `--build-arg`. That is no longer necessary: `UI_API_BASE_URL` and `UI_API_DOCS_URL` are read at container start, so updating them and restarting is sufficient.
</Note>
+8
View File
@@ -28,6 +28,14 @@ All notable changes to the **Prowler UI** are documented in this file.
- Threat Map no longer shows an empty map for accounts that only have Okta or Google Workspace scans [(#11542)](https://github.com/prowler-cloud/prowler/pull/11542)
- Compliance attributes requests now pass the selected scan, so multi-provider universal frameworks (e.g. CSA CCM) load the check IDs of the scan's provider and Azure/GCP requirement details show their findings instead of appearing empty [(#11546)](https://github.com/prowler-cloud/prowler/pull/11546)
### 🔄 Changed
- Public SaaS config (Sentry, Google Tag Manager, API base/docs URL) now resolves at container runtime instead of build time; self-hosted deployments set the UI config through the new `UI_`-prefixed env vars (`UI_API_BASE_URL`, `UI_API_DOCS_URL`, `UI_GOOGLE_TAG_MANAGER_ID`, `UI_SENTRY_DSN`, `UI_SENTRY_ENVIRONMENT`), with the previous `NEXT_PUBLIC_*` names still honored as a deprecated fallback [(#11500)](https://github.com/prowler-cloud/prowler/pull/11500)
### 🐞 Fixed
- `ui/.env` template now lists only the canonical `UI_SENTRY_DSN` and `UI_SENTRY_ENVIRONMENT` names; the deprecated `NEXT_PUBLIC_SENTRY_DSN` and `NEXT_PUBLIC_SENTRY_ENVIRONMENT` entries have been removed [(#11500)](https://github.com/prowler-cloud/prowler/pull/11500)
---
## [1.30.0] (Prowler v5.30.0)
+8 -6
View File
@@ -36,12 +36,8 @@ RUN corepack install
ENV NEXT_TELEMETRY_DISABLED=1
ARG NEXT_PUBLIC_PROWLER_RELEASE_VERSION
ENV NEXT_PUBLIC_PROWLER_RELEASE_VERSION=${NEXT_PUBLIC_PROWLER_RELEASE_VERSION}
ARG NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID
ENV NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID=${NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID}
ARG NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL}
ARG NEXT_PUBLIC_API_DOCS_URL
ENV NEXT_PUBLIC_API_DOCS_URL=${NEXT_PUBLIC_API_DOCS_URL}
# GTM / API base+docs URLs are runtime container env (prod stage), not build ARGs.
RUN pnpm run build
@@ -78,6 +74,12 @@ EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Runtime configuration is read by `node server.js` at container start and is
# NOT baked into the image. Supply it via your orchestrator (docker-compose,
# Helm/K8s):
# - required: UI_API_BASE_URL, AUTH_URL, AUTH_SECRET (missing ⇒ fail fast at boot)
# - optional: UI_API_DOCS_URL, UI_GOOGLE_TAG_MANAGER_ID, UI_SENTRY_DSN, UI_SENTRY_ENVIRONMENT
# - reserved: POSTHOG_KEY, POSTHOG_HOST, REO_DEV_CLIENT_ID (no consumer yet)
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD ["node", "server.js"]
+1 -1
View File
@@ -10,7 +10,7 @@ import type {
QueryResultAttributes,
} from "@/types/attack-paths";
const API = process.env.NEXT_PUBLIC_API_BASE_URL;
const API = process.env.UI_API_BASE_URL;
type JsonApiErrorBody = {
errors: Array<{ detail: string; status: string }>;
@@ -14,7 +14,7 @@ const lastFetchCall = (): { url: string; init: RequestInit } => {
describe("confirmAlertRecipient", () => {
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://api.example.com/api/v1");
vi.stubEnv("UI_API_BASE_URL", "https://api.example.com/api/v1");
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
@@ -104,7 +104,8 @@ describe("confirmAlertRecipient", () => {
});
it("returns the fallback message when the API base URL is missing", async () => {
// Given
// Given - neither the new name nor its legacy fallback is set
vi.stubEnv("UI_API_BASE_URL", "");
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "");
// When
@@ -120,6 +121,21 @@ describe("confirmAlertRecipient", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
it("falls back to the deprecated NEXT_PUBLIC_API_BASE_URL when UI_API_BASE_URL is unset", async () => {
// Given - only the legacy name is configured
vi.stubEnv("UI_API_BASE_URL", undefined);
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://legacy.example.com/api/v1");
// When
const result = await confirmAlertRecipient("token-1");
// Then
expect(result.ok).toBe(true);
expect(lastFetchCall().url).toBe(
"https://legacy.example.com/api/v1/alerts/recipients/confirm?token=token-1",
);
});
it("returns the fallback message when the request fails", async () => {
// Given
fetchMock.mockRejectedValueOnce(new Error("network down"));
@@ -1,3 +1,5 @@
import { readEnv } from "@/lib/runtime-env";
interface AlertConfirmApiResponse {
state?: string;
message?: string;
@@ -41,7 +43,7 @@ const toState = (payload: unknown): string => {
export const confirmAlertRecipient = async (
token?: string,
): Promise<AlertConfirmResult> => {
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
const apiBaseUrl = readEnv("UI_API_BASE_URL", "NEXT_PUBLIC_API_BASE_URL");
if (!apiBaseUrl) {
return {
ok: false,
@@ -14,7 +14,7 @@ const lastFetchCall = (): { url: string; init: RequestInit } => {
describe("unsubscribeAlertRecipient", () => {
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://api.example.com/api/v1");
vi.stubEnv("UI_API_BASE_URL", "https://api.example.com/api/v1");
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
@@ -102,4 +102,37 @@ describe("unsubscribeAlertRecipient", () => {
"https://api.example.com/api/v1/alerts/recipients/unsubscribe",
);
});
it("returns the fallback message when the API base URL is missing", async () => {
// Given - neither the new name nor its legacy fallback is set
vi.stubEnv("UI_API_BASE_URL", "");
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "");
// When
const result = await unsubscribeAlertRecipient("token-1");
// Then
expect(result).toEqual({
ok: false,
state: "missing_api_base_url",
message:
"We could not process this unsubscribe link. Please try again later.",
});
expect(fetchMock).not.toHaveBeenCalled();
});
it("falls back to the deprecated NEXT_PUBLIC_API_BASE_URL when UI_API_BASE_URL is unset", async () => {
// Given - only the legacy name is configured
vi.stubEnv("UI_API_BASE_URL", undefined);
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://legacy.example.com/api/v1");
// When
const result = await unsubscribeAlertRecipient("token-1");
// Then
expect(result.ok).toBe(true);
expect(lastFetchCall().url).toBe(
"https://legacy.example.com/api/v1/alerts/recipients/unsubscribe?token=token-1",
);
});
});
@@ -1,3 +1,5 @@
import { readEnv } from "@/lib/runtime-env";
interface AlertUnsubscribeApiResponse {
state?: string;
message?: string;
@@ -41,7 +43,7 @@ const toState = (payload: unknown): string => {
export const unsubscribeAlertRecipient = async (
token?: string,
): Promise<AlertUnsubscribeResult> => {
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
const apiBaseUrl = readEnv("UI_API_BASE_URL", "NEXT_PUBLIC_API_BASE_URL");
if (!apiBaseUrl) {
return {
ok: false,
+23 -5
View File
@@ -2,12 +2,15 @@ import "@/styles/globals.css";
import { GoogleTagManager } from "@next/third-parties/google";
import { Metadata, Viewport } from "next";
import { connection } from "next/server";
import { ReactNode, Suspense } from "react";
import { RuntimePublicConfig } from "@/components/runtime-config/runtime-public-config";
import { NavigationProgress, Toaster } from "@/components/ui";
import { fontSans } from "@/config/fonts";
import { siteConfig } from "@/config/site";
import { cn } from "@/lib";
import { readEnv } from "@/lib/runtime-env";
import { Providers } from "../providers";
@@ -29,10 +32,27 @@ export const viewport: Viewport = {
],
};
export default function AuthLayout({ children }: { children: ReactNode }) {
export default async function AuthLayout({
children,
}: {
children: ReactNode;
}) {
// Force dynamic rendering so the read below resolves from the container env
// at request time rather than being snapshotted at build (independent of the
// <RuntimePublicConfig/> island's own connection() call).
await connection();
// Server-side runtime read. Empty/unset id ⇒ GoogleTagManager is not mounted
const gtmId = readEnv(
"UI_GOOGLE_TAG_MANAGER_ID",
"NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID",
);
return (
<html suppressHydrationWarning lang="en">
<head />
<head>
<RuntimePublicConfig />
</head>
<body
suppressHydrationWarning
className={cn(
@@ -46,9 +66,7 @@ export default function AuthLayout({ children }: { children: ReactNode }) {
</Suspense>
{children}
<Toaster />
<GoogleTagManager
gtmId={process.env.NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID || ""}
/>
{gtmId && <GoogleTagManager gtmId={gtmId} />}
</Providers>
</body>
</html>
+4 -1
View File
@@ -11,6 +11,7 @@ import {
OnboardingGate,
OnboardingSequenceBanner,
} from "@/components/onboarding";
import { RuntimePublicConfig } from "@/components/runtime-config/runtime-public-config";
import MainLayout from "@/components/ui/main-layout/main-layout";
import { NavigationProgress } from "@/components/ui/navigation-progress";
import { Toaster } from "@/components/ui/toast";
@@ -76,7 +77,9 @@ export default async function RootLayout({
return (
<html suppressHydrationWarning lang="en">
<head />
<head>
<RuntimePublicConfig />
</head>
<body
suppressHydrationWarning
className={cn(
-121
View File
@@ -1,121 +0,0 @@
"use client";
/**
* Client-side Sentry instrumentation
*
* This file is automatically loaded by Next.js in the browser via the instrumentation hook.
* It configures Sentry for client-side error tracking and performance monitoring.
*
* For server-side configuration, see: instrumentation.ts
* For runtime-specific configs, see: sentry/sentry.server.config.ts and sentry/sentry.edge.config.ts
*/
import * as Sentry from "@sentry/nextjs";
const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN;
// Only initialize Sentry in the browser (not during SSR)
if (typeof window !== "undefined" && SENTRY_DSN) {
const isDevelopment = process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT === "local";
/**
* Initialize Sentry error tracking and performance monitoring
*
* This setup includes:
* - Performance monitoring with Web Vitals tracking (LCP, FID, CLS, INP)
* - Long task detection for UI-blocking operations
* - beforeSend hook to filter noise
*/
Sentry.init({
// 📍 DSN - Data Source Name (identifies your Sentry project)
dsn: SENTRY_DSN,
// 🌍 Environment - Separate dev errors from production
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT || "local",
// 📦 Release - Track which version has the error
release: process.env.NEXT_PUBLIC_PROWLER_RELEASE_VERSION,
// 🐛 Debug - Detailed logs in development console
debug: isDevelopment,
// 📊 Sample Rates - Performance monitoring
// 100% in dev (test everything), 50% in production (balance visibility with costs)
tracesSampleRate: isDevelopment ? 1.0 : 0.5,
profilesSampleRate: isDevelopment ? 1.0 : 0.5,
// 🔌 Integrations - browserTracingIntegration is client-only
integrations: [
// 📊 Performance Monitoring: Core Web Vitals + RUM
// Tracks LCP, FID, CLS, INP
// Real User Monitoring captures actual user experience, not synthetic tests
Sentry.browserTracingIntegration({
enableLongTask: true, // Detect tasks that block UI (>50ms)
enableInp: true, // Interaction to Next Paint (Core Web Vital)
}),
],
// 🎣 beforeSend Hook - Filter or modify events before sending to Sentry
ignoreErrors: [
// Browser extensions
"top.GLOBALS",
// Random network errors
"Network request failed",
"NetworkError",
"Failed to fetch",
// User canceled actions
"AbortError",
"Non-Error promise rejection captured",
// NextAuth expected errors
"NEXT_REDIRECT",
// ResizeObserver errors (common browser quirk, not real bugs)
"ResizeObserver",
],
beforeSend(event, hint) {
// Filter out noise: ResizeObserver errors (common browser quirk, not real bugs)
if (event.message?.includes("ResizeObserver")) {
return null; // Don't send to Sentry
}
// Filter out non-actionable errors
if (event.exception) {
const error = hint.originalException;
// Don't send cancelled requests
if (
error &&
typeof error === "object" &&
"name" in error &&
error.name === "AbortError"
) {
return null;
}
// Add additional context for API errors
if (
error &&
typeof error === "object" &&
"message" in error &&
typeof error.message === "string" &&
error.message.includes("Request failed")
) {
event.tags = {
...event.tags,
error_type: "api_error",
};
}
}
return event; // Send to Sentry
},
});
// 👤 Set user context (identifies who experienced the error)
// In production, this will be updated after authentication
if (isDevelopment) {
Sentry.setUser({
id: "dev-user",
});
}
}
-3
View File
@@ -1,8 +1,5 @@
"use client";
// Import Sentry client-side initialization
import "@/app/instrumentation.client";
import { HeroUIProvider } from "@heroui/system";
import { useRouter } from "next/navigation";
import { SessionProvider } from "next-auth/react";
@@ -18,7 +18,7 @@ import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet";
import { CustomServerInput } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { FormButtons } from "@/components/ui/form";
import { apiBaseUrl } from "@/lib";
import { useRuntimeConfig } from "@/hooks/use-runtime-config";
const validateXMLContent = (
xmlContent: string,
@@ -253,8 +253,10 @@ export const SamlConfigForm = ({
reader.readAsText(file);
};
const { apiBaseUrl } = useRuntimeConfig();
const trimmedEmailDomain = emailDomain.trim();
const acsUrl = trimmedEmailDomain
const acsUrl =
trimmedEmailDomain && apiBaseUrl
? `${apiBaseUrl}/accounts/saml/${trimmedEmailDomain}/acs/`
: "";
@@ -0,0 +1,17 @@
import { getRuntimePublicConfig } from "@/lib/runtime-config";
import { RUNTIME_CONFIG_SCRIPT_ID } from "@/lib/runtime-config.shared";
import { serializeForScript } from "@/lib/safe-json";
// Inert JSON config island (type="application/json") rendered in <head> before
// the client bundle, so module-load consumers (Sentry init) read it race-free.
export async function RuntimePublicConfig() {
const config = await getRuntimePublicConfig();
return (
<script
id={RUNTIME_CONFIG_SCRIPT_ID}
type="application/json"
dangerouslySetInnerHTML={{ __html: serializeForScript(config) }}
/>
);
}
+3
View File
@@ -15,6 +15,7 @@ import { ScrollArea } from "@/components/ui/scroll-area/scroll-area";
import { CollapsibleMenu } from "@/components/ui/sidebar/collapsible-menu";
import { MenuItem } from "@/components/ui/sidebar/menu-item";
import { useAuth } from "@/hooks";
import { useRuntimeConfig } from "@/hooks/use-runtime-config";
import { getMenuList } from "@/lib/menu-list";
import { LAUNCH_SCAN_HREF } from "@/lib/scans-navigation";
import { cn } from "@/lib/utils";
@@ -61,9 +62,11 @@ export const Menu = ({ isOpen }: { isOpen: boolean }) => {
(state) => state.openLaunchScanModal,
);
const isScansPage = pathname.startsWith("/scans");
const { apiDocsUrl } = useRuntimeConfig();
const menuList = getMenuList({
pathname,
apiDocsUrl,
});
const labelsToHide = MENU_HIDE_RULES.filter((rule) =>
+52
View File
@@ -0,0 +1,52 @@
import { renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RUNTIME_CONFIG_SCRIPT_ID } from "@/lib/runtime-config.shared";
const writeIsland = (content: string) => {
const el = document.createElement("script");
el.id = RUNTIME_CONFIG_SCRIPT_ID;
el.type = "application/json";
el.textContent = content;
document.head.appendChild(el);
};
describe("useRuntimeConfig", () => {
beforeEach(() => {
// Reset modules so the underlying reader's memoization cache starts empty.
vi.resetModules();
document.head.innerHTML = "";
});
afterEach(() => {
document.head.innerHTML = "";
});
it("should expose the runtime config island to client components", async () => {
// Given
writeIsland(
JSON.stringify({ apiDocsUrl: "https://self-hosted.example/api/v1/docs" }),
);
const { useRuntimeConfig } = await import("./use-runtime-config");
// When
const { result } = renderHook(() => useRuntimeConfig());
// Then
expect(result.current.apiDocsUrl).toBe(
"https://self-hosted.example/api/v1/docs",
);
});
it("should return an all-null config when the island is absent", async () => {
// Given no island in the document
const { useRuntimeConfig } = await import("./use-runtime-config");
// When
const { result } = renderHook(() => useRuntimeConfig());
// Then
expect(result.current.apiDocsUrl).toBeNull();
expect(result.current.apiBaseUrl).toBeNull();
});
});
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { useSyncExternalStore } from "react";
import { getRuntimeConfigClient } from "@/lib/get-runtime-config.client";
import {
EMPTY_RUNTIME_PUBLIC_CONFIG,
type RuntimePublicConfig,
} from "@/lib/runtime-config.shared";
// The island is browser-only, so SSR and the first hydration render must see
// the empty config to avoid a mismatch; useSyncExternalStore swaps to the
// island value afterwards. Both snapshots must be referentially stable.
const subscribe = () => () => {}; // config is immutable after load — never notifies
const getServerSnapshot = (): RuntimePublicConfig =>
EMPTY_RUNTIME_PUBLIC_CONFIG;
export function useRuntimeConfig(): RuntimePublicConfig {
return useSyncExternalStore(
subscribe,
getRuntimeConfigClient,
getServerSnapshot,
);
}
+97
View File
@@ -0,0 +1,97 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EMPTY_RUNTIME_PUBLIC_CONFIG } from "@/lib/runtime-config.shared";
// Stable mock fns shared across module re-evaluations (resetModules clears the
// module registry but these hoisted fns survive, so assertions stay reliable).
const { initMock, setUserMock, captureMock, getConfigMock } = vi.hoisted(
() => ({
initMock: vi.fn(),
setUserMock: vi.fn(),
captureMock: vi.fn(),
getConfigMock: vi.fn(),
}),
);
vi.mock("@sentry/nextjs", () => ({
init: initMock,
setUser: setUserMock,
captureRouterTransitionStart: captureMock,
browserTracingIntegration: vi.fn(() => ({})),
}));
vi.mock("@/lib/get-runtime-config.client", () => ({
getRuntimeConfigClient: getConfigMock,
}));
vi.mock("@/components/ui/navigation-progress/use-navigation-progress", () => ({
startProgress: vi.fn(),
cancelProgress: vi.fn(),
}));
describe("instrumentation-client Sentry init", () => {
beforeEach(() => {
// Re-evaluate the module per case so its top-level init guard re-runs.
vi.resetModules();
initMock.mockClear();
setUserMock.mockClear();
captureMock.mockClear();
getConfigMock.mockReset();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("initializes Sentry with the runtime DSN and the build-time release when a DSN is present", async () => {
// Given
getConfigMock.mockReturnValue({
...EMPTY_RUNTIME_PUBLIC_CONFIG,
sentryDsn: "https://key@o0.ingest.sentry.io/1",
sentryEnvironment: "pro",
});
// The browser release comes from the build-time public version (D10);
// SENTRY_RELEASE is non-public and never reaches the client.
vi.stubEnv("NEXT_PUBLIC_PROWLER_RELEASE_VERSION", "1.30.0");
// When
await import("@/instrumentation-client");
// Then
expect(initMock).toHaveBeenCalledTimes(1);
expect(initMock.mock.calls[0][0]).toMatchObject({
dsn: "https://key@o0.ingest.sentry.io/1",
environment: "pro",
release: "1.30.0",
});
});
it("does not initialize Sentry when the DSN is absent", async () => {
// Given
getConfigMock.mockReturnValue({ ...EMPTY_RUNTIME_PUBLIC_CONFIG });
// When
await import("@/instrumentation-client");
// Then
expect(initMock).not.toHaveBeenCalled();
});
it("defaults to a non-dev environment so an unset UI_SENTRY_ENVIRONMENT does not enable dev mode", async () => {
// Given - DSN set but environment unset
getConfigMock.mockReturnValue({
...EMPTY_RUNTIME_PUBLIC_CONFIG,
sentryDsn: "https://key@o0.ingest.sentry.io/1",
});
// When
await import("@/instrumentation-client");
// Then
const options = initMock.mock.calls[0][0];
expect(options.environment).toBe("production");
expect(options.debug).toBe(false);
expect(options.tracesSampleRate).toBe(0.5);
expect(setUserMock).not.toHaveBeenCalled();
});
});
+132 -4
View File
@@ -1,16 +1,26 @@
/**
* Next.js Client Instrumentation
*
* This file runs on the client before React hydration.
* Used to set up navigation progress tracking.
* This file runs on the client BEFORE React hydration. It is responsible for:
* - Initializing browser Sentry from the runtime data island, so a single
* prebuilt image enables/disables Sentry per deployment via `UI_SENTRY_DSN`
* (unset DSN ⇒ `Sentry.init` is never called ⇒ zero egress).
* - Driving the navigation progress bar on router transitions.
*
* Running here (rather than in a React effect) means Sentry is configured
* before hydration, so browserTracing's performance observers attach early and
* the error boundary in `global-error.tsx` is covered.
*
* @see https://nextjs.org/docs/app/api-reference/file-conventions/instrumentation-client
*/
import * as Sentry from "@sentry/nextjs";
import {
cancelProgress,
startProgress,
} from "@/components/ui/navigation-progress/use-navigation-progress";
import { getRuntimeConfigClient } from "@/lib/get-runtime-config.client";
export const NAVIGATION_TYPE = {
PUSH: "push",
@@ -20,17 +30,133 @@ export const NAVIGATION_TYPE = {
type NavigationType = (typeof NAVIGATION_TYPE)[keyof typeof NAVIGATION_TYPE];
const { sentryDsn, sentryEnvironment } = getRuntimeConfigClient();
// Only initialize Sentry in the browser when a runtime DSN is configured.
if (typeof window !== "undefined" && sentryDsn) {
// Default to a non-dev environment so an unset UI_SENTRY_ENVIRONMENT never
// runs the browser SDK in dev mode (debug logging, 100% sampling, synthetic
// dev user); only an explicit "local" enables it.
const environment = sentryEnvironment ?? "production";
const isDevelopment = environment === "local";
/**
* Initialize Sentry error tracking and performance monitoring.
*
* This setup includes:
* - Performance monitoring with Web Vitals tracking (LCP, FID, CLS, INP)
* - Long task detection for UI-blocking operations
* - beforeSend hook to filter noise
*/
Sentry.init({
// 📍 DSN - resolved at runtime from the data island
dsn: sentryDsn,
// 🌍 Environment - separate dev errors from production
environment,
// 📦 Release - the browser can only read the build-time public release
// version; SENTRY_RELEASE is non-public and never reaches the client. The
// server/edge SDKs read SENTRY_RELEASE for parity (D10). No new release var.
release: process.env.NEXT_PUBLIC_PROWLER_RELEASE_VERSION,
// 🐛 Debug - detailed logs in development console
debug: isDevelopment,
// 📊 Sample Rates - Performance monitoring
// 100% in dev (test everything), 50% in production (balance visibility with costs)
tracesSampleRate: isDevelopment ? 1.0 : 0.5,
profilesSampleRate: isDevelopment ? 1.0 : 0.5,
// 🔌 Integrations - browserTracingIntegration is client-only
integrations: [
// 📊 Performance Monitoring: Core Web Vitals + RUM
// Tracks LCP, FID, CLS, INP
Sentry.browserTracingIntegration({
enableLongTask: true, // Detect tasks that block UI (>50ms)
enableInp: true, // Interaction to Next Paint (Core Web Vital)
}),
],
// 🎣 Filter expected errors / noise before sending to Sentry
ignoreErrors: [
// Browser extensions
"top.GLOBALS",
// Random network errors
"Network request failed",
"NetworkError",
"Failed to fetch",
// User canceled actions
"AbortError",
"Non-Error promise rejection captured",
// NextAuth expected errors
"NEXT_REDIRECT",
// ResizeObserver errors (common browser quirk, not real bugs)
"ResizeObserver",
],
beforeSend(event, hint) {
// Filter out noise: ResizeObserver errors (common browser quirk, not real bugs)
if (event.message?.includes("ResizeObserver")) {
return null; // Don't send to Sentry
}
// Filter out non-actionable errors
if (event.exception) {
const error = hint.originalException;
// Don't send cancelled requests
if (
error &&
typeof error === "object" &&
"name" in error &&
error.name === "AbortError"
) {
return null;
}
// Add additional context for API errors
if (
error &&
typeof error === "object" &&
"message" in error &&
typeof error.message === "string" &&
error.message.includes("Request failed")
) {
event.tags = {
...event.tags,
error_type: "api_error",
};
}
}
return event; // Send to Sentry
},
});
// 👤 Set user context (identifies who experienced the error)
// In production, this will be updated after authentication
if (isDevelopment) {
Sentry.setUser({
id: "dev-user",
});
}
}
function getCurrentUrl(): string {
return window.location.pathname + window.location.search;
}
/**
* Called by Next.js when router navigation begins.
* Triggers the navigation progress bar.
*
* Triggers the navigation progress bar AND forwards the transition to Sentry
* (`captureRouterTransitionStart` is a safe no-op when Sentry is
* uninitialized).
*/
export function onRouterTransitionStart(
url: string,
_navigationType: NavigationType,
navigationType: NavigationType,
) {
const currentUrl = getCurrentUrl();
@@ -41,4 +167,6 @@ export function onRouterTransitionStart(
// Different URL - start progress
startProgress();
}
Sentry.captureRouterTransitionStart(url, navigationType);
}
+9 -4
View File
@@ -9,18 +9,23 @@
* - sentry/sentry.server.config.ts (Node.js runtime)
* - sentry/sentry.edge.config.ts (Edge runtime)
* 3. Client-side init:
* - app/instrumentation.client.ts (Browser/Client)
* - instrumentation-client.ts (Browser/Client; reads the runtime data island)
*
* @see https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
*/
// Fail fast at server boot if required runtime env is missing.
import "@/lib/env";
import * as Sentry from "@sentry/nextjs";
const SENTRY_DSN = process.env.SENTRY_DSN;
import { readEnv } from "@/lib/runtime-env";
const sentryDsn = readEnv("UI_SENTRY_DSN", "NEXT_PUBLIC_SENTRY_DSN");
export async function register() {
// Skip Sentry initialization if DSN is not configured
if (!SENTRY_DSN) {
if (!sentryDsn) {
return;
}
@@ -35,6 +40,6 @@ export async function register() {
}
// Only capture request errors if Sentry is configured
export const onRequestError = SENTRY_DSN
export const onRequestError = sentryDsn
? Sentry.captureRequestError
: undefined;
+46
View File
@@ -0,0 +1,46 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("lib/env boot assertion", () => {
beforeEach(() => {
// Re-evaluate the module per case so its top-level assertion re-runs.
vi.resetModules();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("throws a clear error when a required env var is missing", async () => {
// Given - UI_API_BASE_URL and its legacy empty, the others present
vi.stubEnv("UI_API_BASE_URL", "");
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "");
vi.stubEnv("AUTH_URL", "http://localhost:3000");
vi.stubEnv("AUTH_SECRET", "secret");
// When / Then
await expect(import("@/lib/env")).rejects.toThrow(
"Missing required env: UI_API_BASE_URL",
);
});
it("does not throw when every required env var is present", async () => {
// Given
vi.stubEnv("UI_API_BASE_URL", "https://api.example.com/api/v1");
vi.stubEnv("AUTH_URL", "http://localhost:3000");
vi.stubEnv("AUTH_SECRET", "secret");
// When / Then
await expect(import("@/lib/env")).resolves.toBeDefined();
});
it("accepts the deprecated NEXT_PUBLIC_API_BASE_URL as a fallback", async () => {
// Given - the new name is unset but the legacy name is configured
vi.stubEnv("UI_API_BASE_URL", undefined);
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://api.example.com/api/v1");
vi.stubEnv("AUTH_URL", "http://localhost:3000");
vi.stubEnv("AUTH_SECRET", "secret");
// When / Then
await expect(import("@/lib/env")).resolves.toBeDefined();
});
});
+21
View File
@@ -0,0 +1,21 @@
import { readEnv } from "@/lib/runtime-env";
// Boot-time required-env assertion so a misconfigured container fails fast
// with a clear message. A key with a deprecated legacy name is satisfied by
// either name (see readEnv).
const REQUIRED: ReadonlyArray<{
key: keyof NodeJS.ProcessEnv;
legacy?: keyof NodeJS.ProcessEnv;
}> = [
{ key: "UI_API_BASE_URL", legacy: "NEXT_PUBLIC_API_BASE_URL" },
{ key: "AUTH_URL" },
{ key: "AUTH_SECRET" },
];
for (const { key, legacy } of REQUIRED) {
if (!readEnv(key, legacy)) {
throw new Error(`Missing required env: ${key}`);
}
}
export {};
+115
View File
@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RUNTIME_CONFIG_SCRIPT_ID } from "./runtime-config.shared";
const writeIsland = (content: string) => {
const el = document.createElement("script");
el.id = RUNTIME_CONFIG_SCRIPT_ID;
el.type = "application/json";
// textContent (not innerHTML) mirrors how the browser parses an
// application/json island: the escaped < sequences are already decoded.
el.textContent = content;
document.head.appendChild(el);
};
describe("getRuntimeConfigClient", () => {
beforeEach(() => {
// Reset modules so each dynamic import re-evaluates the file and its
// module-level memoization cache starts empty.
vi.resetModules();
document.head.innerHTML = "";
});
afterEach(() => {
document.head.innerHTML = "";
});
it("parses the data island when present", async () => {
// Given
writeIsland(
JSON.stringify({
sentryDsn: "https://key@o0.ingest.sentry.io/1",
apiBaseUrl: "https://api.example.com/api/v1",
}),
);
const { getRuntimeConfigClient } = await import(
"./get-runtime-config.client"
);
// When
const config = getRuntimeConfigClient();
// Then
expect(config.sentryDsn).toBe("https://key@o0.ingest.sentry.io/1");
expect(config.apiBaseUrl).toBe("https://api.example.com/api/v1");
// Keys not present in the island fall back to null.
expect(config.googleTagManagerId).toBeNull();
expect(config.posthogKey).toBeNull();
});
it("falls back to an all-null config when the island is absent", async () => {
// Given no island in the document
const { getRuntimeConfigClient } = await import(
"./get-runtime-config.client"
);
// When
const config = getRuntimeConfigClient();
// Then
expect(config.sentryDsn).toBeNull();
expect(config.apiBaseUrl).toBeNull();
expect(config.reoDevClientId).toBeNull();
});
it("falls back to an all-null config when the island is malformed JSON", async () => {
// Given
writeIsland("{ not valid json");
const { getRuntimeConfigClient } = await import(
"./get-runtime-config.client"
);
// When
const config = getRuntimeConfigClient();
// Then
expect(config.sentryDsn).toBeNull();
expect(config.apiBaseUrl).toBeNull();
});
it("exposes only the allowlisted keys and ignores anything extra", async () => {
// Given an island carrying an unexpected key and a __proto__ payload
writeIsland(
JSON.stringify({
apiBaseUrl: "https://api.example.com/api/v1",
notAllowlisted: "should-not-survive",
__proto__: { polluted: true },
}),
);
const { getRuntimeConfigClient } = await import(
"./get-runtime-config.client"
);
// When
const config = getRuntimeConfigClient();
// Then - exactly the eight allowlisted keys, nothing else
expect(Object.keys(config).sort()).toEqual(
[
"apiBaseUrl",
"apiDocsUrl",
"googleTagManagerId",
"posthogHost",
"posthogKey",
"reoDevClientId",
"sentryDsn",
"sentryEnvironment",
].sort(),
);
expect(config.apiBaseUrl).toBe("https://api.example.com/api/v1");
expect(
(config as unknown as Record<string, unknown>).notAllowlisted,
).toBeUndefined();
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
});
});
+43
View File
@@ -0,0 +1,43 @@
"use client";
import {
EMPTY_RUNTIME_PUBLIC_CONFIG,
RUNTIME_CONFIG_SCRIPT_ID,
type RuntimePublicConfig,
} from "@/lib/runtime-config.shared";
let cached: RuntimePublicConfig | null = null;
// Explicit per-key copy (not a spread) so unexpected island keys can't leak through.
const pickConfig = (
parsed: Partial<RuntimePublicConfig>,
): RuntimePublicConfig => ({
sentryDsn: parsed.sentryDsn ?? null,
sentryEnvironment: parsed.sentryEnvironment ?? null,
googleTagManagerId: parsed.googleTagManagerId ?? null,
apiBaseUrl: parsed.apiBaseUrl ?? null,
apiDocsUrl: parsed.apiDocsUrl ?? null,
posthogKey: parsed.posthogKey ?? null,
posthogHost: parsed.posthogHost ?? null,
reoDevClientId: parsed.reoDevClientId ?? null,
});
// Reads the <head> island once (memoized); all-null during SSR or if it's
// missing/malformed, so callers can treat every integration as disabled.
export function getRuntimeConfigClient(): RuntimePublicConfig {
if (cached) return cached;
if (typeof document === "undefined") return EMPTY_RUNTIME_PUBLIC_CONFIG;
const el = document.getElementById(RUNTIME_CONFIG_SCRIPT_ID);
let resolved: RuntimePublicConfig;
try {
resolved = el?.textContent
? pickConfig(JSON.parse(el.textContent) as Partial<RuntimePublicConfig>)
: EMPTY_RUNTIME_PUBLIC_CONFIG;
} catch {
resolved = EMPTY_RUNTIME_PUBLIC_CONFIG;
}
cached = resolved;
return resolved;
}
+6 -2
View File
@@ -11,10 +11,14 @@ import {
COMPLIANCE_REPORT_DISPLAY_NAMES,
type ComplianceReportType,
} from "@/lib/compliance/compliance-report-types";
import { readEnv } from "@/lib/runtime-env";
import { AuthSocialProvider, MetaDataProps, PermissionInfo } from "@/types";
export const baseUrl = process.env.AUTH_URL || "http://localhost:3000";
export const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
export const baseUrl = process.env.AUTH_URL;
export const apiBaseUrl = readEnv(
"UI_API_BASE_URL",
"NEXT_PUBLIC_API_BASE_URL",
);
/**
* Extracts a form value from a FormData object
+41
View File
@@ -13,11 +13,52 @@ const findSubmenu = (label: string) =>
.flatMap((menu) => menu.submenus ?? [])
.find((submenu) => submenu.label === label);
const findApiReference = (options: Parameters<typeof getMenuList>[0]) =>
getMenuList(options)
.flatMap((group) => group.menus)
.flatMap((menu) => menu.submenus ?? [])
.find((submenu) => submenu.label === "API reference");
describe("getMenuList", () => {
afterEach(() => {
delete process.env.NEXT_PUBLIC_IS_CLOUD_ENV;
});
describe("API reference link", () => {
it("should use the apiDocsUrl provided by the caller in OSS", () => {
// Given / When — the caller resolves the runtime value (hydration-safe)
const apiRef = findApiReference({
pathname: "/",
apiDocsUrl: "https://self-hosted.example/api/v1/docs",
});
// Then
expect(apiRef?.href).toBe("https://self-hosted.example/api/v1/docs");
});
it("should default to an empty href when no apiDocsUrl is provided", () => {
// Given / When — no island read here, so SSR and client agree
const apiRef = findApiReference({ pathname: "/" });
// Then
expect(apiRef?.href).toBe("");
});
it("should use the Cloud docs URL and ignore apiDocsUrl when Cloud is enabled", () => {
// Given
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
// When
const apiRef = findApiReference({
pathname: "/",
apiDocsUrl: "https://ignored.example/docs",
});
// Then
expect(apiRef?.href).toBe("https://api.prowler.com/api/v1/docs");
});
});
it("should show Alerts as disabled Cloud-only in OSS when Cloud is disabled", () => {
// Given / When
const alerts = findSubmenu("Alerts");
+9 -4
View File
@@ -30,9 +30,15 @@ import { GroupProps } from "@/types";
interface MenuListOptions {
pathname: string;
// Passed in (not read here) so the island isn't read during SSR — that would
// cause a hydration mismatch. See useRuntimeConfig.
apiDocsUrl?: string | null;
}
export const getMenuList = ({ pathname }: MenuListOptions): GroupProps[] => {
export const getMenuList = ({
pathname,
apiDocsUrl = null,
}: MenuListOptions): GroupProps[] => {
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
return [
@@ -164,10 +170,9 @@ export const getMenuList = ({ pathname }: MenuListOptions): GroupProps[] => {
icon: DocIcon,
},
{
href:
process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"
href: isCloudEnv
? "https://api.prowler.com/api/v1/docs"
: `${process.env.NEXT_PUBLIC_API_DOCS_URL}`,
: (apiDocsUrl ?? ""),
target: "_blank",
label: "API reference",
icon: APIdocIcon,
+26
View File
@@ -0,0 +1,26 @@
// Side-effect-free shape shared by the server and client readers (no
// server-only code, so it's safe to import from the client bundle).
export interface RuntimePublicConfig {
sentryDsn: string | null;
sentryEnvironment: string | null;
googleTagManagerId: string | null;
apiBaseUrl: string | null;
apiDocsUrl: string | null;
posthogKey: string | null; // reserved
posthogHost: string | null; // reserved
reoDevClientId: string | null; // reserved
}
export const RUNTIME_CONFIG_SCRIPT_ID = "__PROWLER_RUNTIME_CONFIG__";
// All-null fallback (SSR or parse failure).
export const EMPTY_RUNTIME_PUBLIC_CONFIG: RuntimePublicConfig = {
sentryDsn: null,
sentryEnvironment: null,
googleTagManagerId: null,
apiBaseUrl: null,
apiDocsUrl: null,
posthogKey: null,
posthogHost: null,
reoDevClientId: null,
};
+30
View File
@@ -0,0 +1,30 @@
import "server-only";
import { connection } from "next/server";
import type { RuntimePublicConfig } from "@/lib/runtime-config.shared";
import { readEnv } from "@/lib/runtime-env";
// `connection()` forces a per-request runtime read (never build-snapshotted);
// only this allowlist reaches the client. Each migrated key falls back to its
// deprecated NEXT_PUBLIC_* name during migration (see readEnv).
export async function getRuntimePublicConfig(): Promise<RuntimePublicConfig> {
await connection();
return {
sentryDsn: readEnv("UI_SENTRY_DSN", "NEXT_PUBLIC_SENTRY_DSN"),
sentryEnvironment: readEnv(
"UI_SENTRY_ENVIRONMENT",
"NEXT_PUBLIC_SENTRY_ENVIRONMENT",
),
googleTagManagerId: readEnv(
"UI_GOOGLE_TAG_MANAGER_ID",
"NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID",
),
apiBaseUrl: readEnv("UI_API_BASE_URL", "NEXT_PUBLIC_API_BASE_URL"),
apiDocsUrl: readEnv("UI_API_DOCS_URL", "NEXT_PUBLIC_API_DOCS_URL"),
posthogKey: readEnv("POSTHOG_KEY"),
posthogHost: readEnv("POSTHOG_HOST"),
reoDevClientId: readEnv("REO_DEV_CLIENT_ID"),
};
}
+75
View File
@@ -0,0 +1,75 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { readEnv } from "./runtime-env";
describe("readEnv", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("returns the primary value when it is set", () => {
// Given
vi.stubEnv("UI_API_BASE_URL", "https://primary.example.com");
// When / Then
expect(readEnv("UI_API_BASE_URL")).toBe("https://primary.example.com");
});
it("returns null when the primary is unset and no legacy is given", () => {
// Given
vi.stubEnv("UI_API_BASE_URL", undefined);
// When / Then
expect(readEnv("UI_API_BASE_URL")).toBeNull();
});
it("treats an empty or whitespace-only primary as unset", () => {
// Given
vi.stubEnv("UI_API_BASE_URL", " ");
// When / Then
expect(readEnv("UI_API_BASE_URL")).toBeNull();
});
it("falls back to the legacy var when the primary is unset", () => {
// Given
vi.stubEnv("UI_API_BASE_URL", undefined);
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://legacy.example.com");
// When / Then
expect(readEnv("UI_API_BASE_URL", "NEXT_PUBLIC_API_BASE_URL")).toBe(
"https://legacy.example.com",
);
});
it("falls back to the legacy var when the primary is empty", () => {
// Given
vi.stubEnv("UI_API_BASE_URL", "");
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://legacy.example.com");
// When / Then
expect(readEnv("UI_API_BASE_URL", "NEXT_PUBLIC_API_BASE_URL")).toBe(
"https://legacy.example.com",
);
});
it("prefers the primary over the legacy when both are set", () => {
// Given
vi.stubEnv("UI_API_BASE_URL", "https://primary.example.com");
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", "https://legacy.example.com");
// When / Then
expect(readEnv("UI_API_BASE_URL", "NEXT_PUBLIC_API_BASE_URL")).toBe(
"https://primary.example.com",
);
});
it("returns null when neither the primary nor the legacy is set", () => {
// Given
vi.stubEnv("UI_API_BASE_URL", undefined);
vi.stubEnv("NEXT_PUBLIC_API_BASE_URL", undefined);
// When / Then
expect(readEnv("UI_API_BASE_URL", "NEXT_PUBLIC_API_BASE_URL")).toBeNull();
});
});
+21
View File
@@ -0,0 +1,21 @@
// Reads a runtime env var, with an optional deprecated-name fallback.
//
// Both names are read through a computed key (never a literal `process.env.X`
// member access) so Next.js/Turbopack does NOT inline them at build time. This
// is essential for the legacy `NEXT_PUBLIC_*` names: a literal read would be
// replaced with the build-time snapshot, defeating the runtime fallback. The
// new `UI_*` names are not `NEXT_PUBLIC_`-prefixed, so they are runtime reads
// regardless. Empty/whitespace values are treated as unset so a leftover empty
// `UI_*` still falls through to a configured legacy var.
const clean = (value?: string): string | null =>
value && value.trim() !== "" ? value : null;
export function readEnv(
primary: keyof NodeJS.ProcessEnv,
legacy?: keyof NodeJS.ProcessEnv,
): string | null {
const env = typeof process === "undefined" ? undefined : process.env;
if (!env) return null;
return clean(env[primary]) ?? (legacy ? clean(env[legacy]) : null);
}
+83
View File
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { serializeForScript } from "./safe-json";
const LINE_SEPARATOR = String.fromCharCode(0x2028); // U+2028
const PARAGRAPH_SEPARATOR = String.fromCharCode(0x2029); // U+2029
describe("serializeForScript", () => {
it("neutralizes a </script> breakout so the script tag is not terminated early", () => {
// Given
const value = { sentryDsn: "</script><script>alert(1)</script>" };
// When
const serialized = serializeForScript(value);
// Then
expect(serialized).not.toContain("</script>");
expect(serialized).not.toContain("<");
expect(serialized).not.toContain(">");
});
it("neutralizes the HTML comment opener <!--", () => {
// Given
const value = { sentryDsn: "<!-- not a comment -->" };
// When
const serialized = serializeForScript(value);
// Then
expect(serialized).not.toContain("<!--");
expect(serialized).not.toContain("<");
});
it("escapes ampersands", () => {
// When
const serialized = serializeForScript({ apiBaseUrl: "https://x?a=1&b=2" });
// Then
expect(serialized).not.toContain("&");
expect(serialized).toContain("\\u0026");
});
it("escapes U+2028 and U+2029 so output stays safe in an executed-JS context", () => {
// Given - JSON.stringify leaves these line terminators raw, which would
// break a JS string literal if the island were ever inlined as executed JS.
const value = { sentryDsn: `a${LINE_SEPARATOR}b${PARAGRAPH_SEPARATOR}c` };
// When
const serialized = serializeForScript(value);
// Then
expect(serialized).not.toContain(LINE_SEPARATOR);
expect(serialized).not.toContain(PARAGRAPH_SEPARATOR);
expect(serialized).toContain("\\u2028");
expect(serialized).toContain("\\u2029");
});
it("round-trips back to the original value via JSON.parse", () => {
// Given
const value = {
sentryDsn: "</script>",
apiBaseUrl: "https://api.example.com?a=1&b=2",
googleTagManagerId: null,
};
// When
const parsed = JSON.parse(serializeForScript(value));
// Then
expect(parsed).toEqual(value);
});
it("round-trips line terminators back to their original characters", () => {
// Given
const value = { sentryDsn: `a${LINE_SEPARATOR}b${PARAGRAPH_SEPARATOR}c` };
// When
const parsed = JSON.parse(serializeForScript(value));
// Then
expect(parsed).toEqual(value);
});
});
+16
View File
@@ -0,0 +1,16 @@
// Escape a value for an inline <script>. Neutralizes < > & to block the
// </script>/<!-- breakout (the only vector when read as inert JSON), plus the
// U+2028/U+2029 line terminators that JSON.stringify leaves raw, so the output
// is also safe if ever inlined into an executed-JS context. JSON.parse decodes
// all of these back to the original characters.
const LINE_SEPARATOR = new RegExp(String.fromCharCode(0x2028), "g");
const PARAGRAPH_SEPARATOR = new RegExp(String.fromCharCode(0x2029), "g");
export function serializeForScript(value: unknown): string {
return JSON.stringify(value)
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026")
.replace(LINE_SEPARATOR, "\\u2028")
.replace(PARAGRAPH_SEPARATOR, "\\u2029");
}
+12 -26
View File
@@ -6,7 +6,11 @@ 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
// '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;
@@ -16,23 +20,8 @@ const cspHeader = `
style-src 'self' 'unsafe-inline';
frame-src 'self' https://js.stripe.com https://www.googletagmanager.com;
frame-ancestors 'none';
report-to csp-endpoint;
`;
// Get Sentry CSP report endpoint if DSN is configured
const getSentryReportEndpoint = () => {
if (!process.env.NEXT_PUBLIC_SENTRY_DSN) return null;
try {
const sentryKey =
process.env.NEXT_PUBLIC_SENTRY_DSN.split("@")[0]?.split("//")[1];
return sentryKey
? `https://o0.ingest.sentry.io/api/0/security/?sentry_key=${sentryKey}`
: null;
} catch {
return null;
}
};
const nextConfig = {
poweredByHeader: false,
// Use standalone only in production deployments, not for CI/testing
@@ -47,7 +36,6 @@ const nextConfig = {
root: __dirname,
},
async headers() {
const sentryEndpoint = getSentryReportEndpoint();
const headers = [
{
key: "Content-Security-Policy",
@@ -63,14 +51,6 @@ const nextConfig = {
},
];
// Add Reporting-Endpoints header if Sentry is configured
if (sentryEndpoint) {
headers.push({
key: "Reporting-Endpoints",
value: `csp-endpoint="${sentryEndpoint}"`,
});
}
return [
{
source: "/(.*)",
@@ -92,6 +72,12 @@ const sentryWebpackPluginOptions = {
};
// Export with Sentry only if configuration is available
module.exports = process.env.SENTRY_DSN
const hasSentryBuildCredentials = Boolean(
process.env.SENTRY_AUTH_TOKEN &&
process.env.SENTRY_ORG &&
process.env.SENTRY_PROJECT,
);
module.exports = hasSentryBuildCredentials
? withSentryConfig(nextConfig, sentryWebpackPluginOptions)
: nextConfig;
+4 -4
View File
@@ -23,12 +23,12 @@
"test:browser": "vitest run --project browser",
"test:browser:watch": "vitest --project browser",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans",
"test:e2e:debug": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --debug",
"test:e2e:headed": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --headed",
"test:e2e": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --project=runtime-config",
"test:e2e:debug": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --project=runtime-config --debug",
"test:e2e:headed": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --project=runtime-config --headed",
"test:e2e:install": "playwright install",
"test:e2e:report": "playwright show-report",
"test:e2e:ui": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --ui",
"test:e2e:ui": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --project=runtime-config --ui",
"test:unit": "vitest run --project unit",
"test:watch": "vitest",
"tour:check": "node scripts/check-tour-alignment.mjs",
+10 -2
View File
@@ -120,6 +120,13 @@ export default defineConfig({
use: { ...devices["Desktop Chrome"] },
testMatch: /invitation-accept\/.*\.spec\.ts/,
},
// This project runs the runtime public-config data island test suite
// Tests run unauthenticated (no auth setup dependency)
{
name: "runtime-config",
use: { ...devices["Desktop Chrome"] },
testMatch: /runtime-config\/.*\.spec\.ts/,
},
// This project runs the scans test suite
{
name: "scans",
@@ -146,8 +153,9 @@ export default defineConfig({
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
env: {
NEXT_PUBLIC_API_BASE_URL:
process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8080/api/v1",
UI_API_BASE_URL:
process.env.UI_API_BASE_URL || "http://localhost:8080/api/v1",
AUTH_URL: process.env.AUTH_URL || "http://localhost:3000",
AUTH_SECRET: process.env.AUTH_SECRET || "fallback-ci-secret-for-testing",
AUTH_TRUST_HOST: process.env.AUTH_TRUST_HOST || "true",
NEXTAUTH_URL: process.env.NEXTAUTH_URL || "http://localhost:3000",
+15 -6
View File
@@ -11,7 +11,11 @@ This folder contains all Sentry-related configuration and utilities for the Prow
## Client Configuration
The client-side configuration is located in `app/instrumentation.client.ts` following Next.js conventions.
The client-side configuration lives in the Next.js convention file
`instrumentation-client.ts` (repo root). It runs before hydration and reads the
DSN/environment from the runtime data island injected into `<head>` (see
`lib/runtime-config.ts`), so the browser SDK is configured per deployment from
the container environment rather than from build-time `NEXT_PUBLIC_*` vars.
## Usage
@@ -30,16 +34,21 @@ Sentry.captureException(error, {
## Environment Variables
Required environment variables (add to `.env`):
Runtime environment variables (supplied to the running container; a single
`UI_SENTRY_DSN` / `UI_SENTRY_ENVIRONMENT` now serves both server and
browser):
```env
UI_SENTRY_DSN=https://YOUR_KEY@o0.ingest.sentry.io/0
UI_SENTRY_ENVIRONMENT=production
```
Build-time only (for source-map upload via `withSentryConfig`):
```env
SENTRY_DSN=https://YOUR_KEY@o0.ingest.sentry.io/0
NEXT_PUBLIC_SENTRY_DSN=https://YOUR_KEY@o0.ingest.sentry.io/0
SENTRY_ORG=your-org-slug
SENTRY_PROJECT=your-project-slug
SENTRY_AUTH_TOKEN=sntrys_YOUR_AUTH_TOKEN
SENTRY_ENVIRONMENT=development
NEXT_PUBLIC_SENTRY_ENVIRONMENT=development
```
## Ignored Errors
+60
View File
@@ -0,0 +1,60 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Stable mock that survives resetModules (which clears the module registry).
const { initMock } = vi.hoisted(() => ({ initMock: vi.fn() }));
vi.mock("@sentry/nextjs", () => ({
init: initMock,
}));
describe("sentry.edge.config", () => {
beforeEach(() => {
// Re-evaluate the module per case so its top-level init guard re-runs.
vi.resetModules();
initMock.mockClear();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("should initialize with the resolved environment and reduced edge sampling", async () => {
// Given
vi.stubEnv("UI_SENTRY_DSN", "https://key@o0.ingest.sentry.io/1");
vi.stubEnv("UI_SENTRY_ENVIRONMENT", "pro");
// When
await import("./sentry.edge.config");
// Then
expect(initMock).toHaveBeenCalledTimes(1);
expect(initMock.mock.calls[0][0]).toMatchObject({
dsn: "https://key@o0.ingest.sentry.io/1",
environment: "pro",
tracesSampleRate: 0.25,
});
});
it("should not initialize when the DSN is absent", async () => {
// Given no DSN
// When
await import("./sentry.edge.config");
// Then
expect(initMock).not.toHaveBeenCalled();
});
it("should default to a non-dev environment so an unset UI_SENTRY_ENVIRONMENT does not enable dev sampling", async () => {
// Given - DSN set but environment unset
vi.stubEnv("UI_SENTRY_DSN", "https://key@o0.ingest.sentry.io/1");
// When
await import("./sentry.edge.config");
// Then
const options = initMock.mock.calls[0][0];
expect(options.environment).toBe("production");
expect(options.tracesSampleRate).toBe(0.25);
});
});
+16 -6
View File
@@ -1,10 +1,20 @@
import * as Sentry from "@sentry/nextjs";
const SENTRY_DSN = process.env.SENTRY_DSN;
import { readEnv } from "@/lib/runtime-env";
const sentryDsn = readEnv("UI_SENTRY_DSN", "NEXT_PUBLIC_SENTRY_DSN");
const sentryEnvironment = readEnv(
"UI_SENTRY_ENVIRONMENT",
"NEXT_PUBLIC_SENTRY_ENVIRONMENT",
);
// Only initialize Sentry if DSN is configured
if (SENTRY_DSN) {
const isProduction = process.env.SENTRY_ENVIRONMENT === "pro";
if (sentryDsn) {
// Default to a non-dev environment so an unset UI_SENTRY_ENVIRONMENT never
// runs in dev mode; only an explicit "local" enables it. Mirrors the browser
// SDK (instrumentation-client.ts) so all runtimes resolve the env identically.
const environment = sentryEnvironment ?? "production";
const isDevelopment = environment === "local";
/**
* Edge runtime Sentry configuration
@@ -17,17 +27,17 @@ if (SENTRY_DSN) {
*/
Sentry.init({
// 📍 DSN - Data Source Name (identifies your Sentry project)
dsn: SENTRY_DSN,
dsn: sentryDsn,
// 🌍 Environment configuration
environment: process.env.SENTRY_ENVIRONMENT || "local",
environment,
// 📦 Release tracking
release: process.env.SENTRY_RELEASE,
// 📊 Sample Rates - Reduced for edge runtime constraints
// 50% in dev, 25% in production (edge has lower overhead limits than server)
tracesSampleRate: isProduction ? 0.25 : 0.5,
tracesSampleRate: isDevelopment ? 0.5 : 0.25,
// 🔌 Integrations - Edge runtime doesn't support all integrations
integrations: [],
+63
View File
@@ -0,0 +1,63 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Stable mock that survives resetModules (which clears the module registry).
const { initMock } = vi.hoisted(() => ({ initMock: vi.fn() }));
vi.mock("@sentry/nextjs", () => ({
init: initMock,
extraErrorDataIntegration: vi.fn(() => ({})),
}));
describe("sentry.server.config", () => {
beforeEach(() => {
// Re-evaluate the module per case so its top-level init guard re-runs.
vi.resetModules();
initMock.mockClear();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("should initialize with the resolved environment and production sampling", async () => {
// Given
vi.stubEnv("UI_SENTRY_DSN", "https://key@o0.ingest.sentry.io/1");
vi.stubEnv("UI_SENTRY_ENVIRONMENT", "pro");
// When
await import("./sentry.server.config");
// Then
expect(initMock).toHaveBeenCalledTimes(1);
expect(initMock.mock.calls[0][0]).toMatchObject({
dsn: "https://key@o0.ingest.sentry.io/1",
environment: "pro",
tracesSampleRate: 0.5,
profilesSampleRate: 0.5,
});
});
it("should not initialize when the DSN is absent", async () => {
// Given no DSN
// When
await import("./sentry.server.config");
// Then
expect(initMock).not.toHaveBeenCalled();
});
it("should default to a non-dev environment so an unset UI_SENTRY_ENVIRONMENT does not enable dev sampling", async () => {
// Given - DSN set but environment unset
vi.stubEnv("UI_SENTRY_DSN", "https://key@o0.ingest.sentry.io/1");
// When
await import("./sentry.server.config");
// Then
const options = initMock.mock.calls[0][0];
expect(options.environment).toBe("production");
expect(options.tracesSampleRate).toBe(0.5);
expect(options.profilesSampleRate).toBe(0.5);
});
});
+18 -7
View File
@@ -1,10 +1,21 @@
import * as Sentry from "@sentry/nextjs";
const SENTRY_DSN = process.env.SENTRY_DSN;
import { readEnv } from "@/lib/runtime-env";
const sentryDsn = readEnv("UI_SENTRY_DSN", "NEXT_PUBLIC_SENTRY_DSN");
const sentryEnvironment = readEnv(
"UI_SENTRY_ENVIRONMENT",
"NEXT_PUBLIC_SENTRY_ENVIRONMENT",
);
// Only initialize Sentry if DSN is configured
if (SENTRY_DSN) {
const isProduction = process.env.SENTRY_ENVIRONMENT === "pro";
if (sentryDsn) {
// Default to a non-dev environment so an unset UI_SENTRY_ENVIRONMENT never
// runs in dev mode (100% sampling); only an explicit "local" enables it.
// Mirrors the browser SDK (instrumentation-client.ts) so all runtimes resolve
// the env identically.
const environment = sentryEnvironment ?? "production";
const isDevelopment = environment === "local";
/**
* Server-side Sentry configuration
@@ -16,18 +27,18 @@ if (SENTRY_DSN) {
*/
Sentry.init({
// 📍 DSN - Data Source Name (identifies your Sentry project)
dsn: SENTRY_DSN,
dsn: sentryDsn,
// 🌍 Environment configuration
environment: process.env.SENTRY_ENVIRONMENT || "local",
environment,
// 📦 Release tracking
release: process.env.SENTRY_RELEASE,
// 📊 Sample Rates - Performance monitoring
// 100% in dev (test everything), 50% in production (balance visibility with costs)
tracesSampleRate: isProduction ? 0.5 : 1.0,
profilesSampleRate: isProduction ? 0.5 : 1.0,
tracesSampleRate: isDevelopment ? 1.0 : 0.5,
profilesSampleRate: isDevelopment ? 1.0 : 0.5,
// 🔌 Integrations
integrations: [
@@ -0,0 +1,124 @@
import { expect, Locator, Page, Request } from "@playwright/test";
import {
RUNTIME_CONFIG_SCRIPT_ID,
type RuntimePublicConfig,
} from "@/lib/runtime-config.shared";
import { BasePage } from "../base-page";
export { RUNTIME_CONFIG_SCRIPT_ID };
/** Keys the runtime data island is expected to expose (the allowlist). */
export const RUNTIME_CONFIG_KEYS = [
"sentryDsn",
"sentryEnvironment",
"googleTagManagerId",
"apiBaseUrl",
"apiDocsUrl",
"posthogKey",
"posthogHost",
"reoDevClientId",
] as const satisfies ReadonlyArray<keyof RuntimePublicConfig>;
/**
* Page object for the runtime public-config data island. The island is rendered
* into `<head>` by both root layouts, so any unauthenticated route works; the
* sign-in page is used because it needs no session.
*/
export class RuntimeConfigPage extends BasePage {
readonly island: Locator;
constructor(page: Page) {
super(page);
this.island = page.locator(`script#${RUNTIME_CONFIG_SCRIPT_ID}`);
}
async goto(): Promise<void> {
await super.goto("/sign-in");
}
/** Parsed island JSON, or null when the island is missing/malformed. */
async readConfig(): Promise<Record<string, unknown> | null> {
return this.page.evaluate((id) => {
const el = document.getElementById(id);
if (!el?.textContent) return null;
try {
return JSON.parse(el.textContent) as Record<string, unknown>;
} catch {
return null;
}
}, RUNTIME_CONFIG_SCRIPT_ID);
}
/** DSN the browser Sentry client initialized with, or null if uninitialized. */
async sentryInitializedDsn(): Promise<string | null> {
return this.page.evaluate(() => {
const sentry = (window as unknown as { __SENTRY__?: unknown }).__SENTRY__;
if (!sentry || typeof sentry !== "object") return null;
const hub = sentry as {
getClient?: () => unknown;
hub?: { getClient?: () => unknown };
};
const client = (hub.getClient?.() ?? hub.hub?.getClient?.()) as
| { getOptions?: () => { dsn?: string } }
| undefined;
return client?.getOptions?.().dsn ?? null;
});
}
async verifyIslandInHead(): Promise<void> {
// type="application/json" ⇒ inert, not governed by CSP script-src.
await expect(this.island).toHaveAttribute("type", "application/json");
const parentTag = await this.island.evaluate(
(el) => el.parentElement?.tagName.toLowerCase() ?? "",
);
expect(parentTag).toBe("head");
}
/**
* The island must precede the first ordered (non-`async`) bundle
* `<script src>` — that bundle is the client entry calling
* getRuntimeConfigClient(), so the island must exist before it runs. Next.js's
* `async` chunk-preloads load out of order and don't read the config, so they
* are excluded.
*/
async verifyIslandPrecedesClientBundle(): Promise<void> {
const precedes = await this.page.evaluate((id) => {
const scripts = Array.from(document.querySelectorAll("script"));
const islandIndex = scripts.findIndex((s) => s.id === id);
if (islandIndex === -1) return false;
const firstOrderedBundleIndex = scripts.findIndex(
(s) => s.src && !s.async,
);
return (
firstOrderedBundleIndex === -1 || islandIndex < firstOrderedBundleIndex
);
}, RUNTIME_CONFIG_SCRIPT_ID);
expect(precedes).toBe(true);
}
/**
* Reload the page while recording any request whose URL contains one of the
* given host fragments. Returns the matching URLs (empty ⇒ zero egress).
*/
async thirdPartyRequestsOnReload(hostFragments: string[]): Promise<string[]> {
const hits: string[] = [];
const listener = (req: Request) => {
const url = req.url();
if (hostFragments.some((fragment) => url.includes(fragment))) {
hits.push(url);
}
};
this.page.on("request", listener);
await this.page.reload({ waitUntil: "load" });
this.page.off("request", listener);
return hits;
}
async verifyGoogleTagManagerNotRendered(): Promise<void> {
await expect(
this.page.locator('script[src*="googletagmanager.com"]'),
).toHaveCount(0);
}
}
+87
View File
@@ -0,0 +1,87 @@
### E2E Tests: Runtime Public-Config Data Island
**Suite ID:** `RUNTIME-CONFIG-E2E`
**Feature:** Runtime resolution of public client config via an inert JSON data
island injected into `<head>` (Sentry, GTM, API base/docs URL, reserved keys).
---
## Test Case: `RUNTIME-CONFIG-E2E-001` - Island rendered in `<head>` before the client bundle
**Priority:** `critical`
**Tags:** @e2e, @runtime-config
**Preconditions:**
- UI server running with `UI_API_BASE_URL` set (the playwright `webServer` provides it).
### Flow Steps
1. Navigate to `/sign-in` (unauthenticated; the island renders on every route).
2. Locate `script#__PROWLER_RUNTIME_CONFIG__`.
### Expected Result
- The island exists, is `type="application/json"` (inert), and lives in `<head>`.
- The island appears before the first ordered (non-`async`) bundle `<script src>` — Next.js's `async` chunk-preloads in `<head>` don't execute in order and are excluded.
- It parses as JSON exposing exactly the allowlisted keys and a truthy `apiBaseUrl`.
### Key Verification Points
- In-`<head>`-before-bundle ordering guarantee (not provable in jsdom).
- Only the allowlisted shape is exposed (no other env leaks).
---
## Test Case: `RUNTIME-CONFIG-E2E-002` - Browser Sentry init matches the island DSN
**Priority:** `high`
**Tags:** @e2e, @runtime-config
**Preconditions:**
- UI server running. `UI_SENTRY_DSN` may be set or unset.
### Flow Steps
1. Navigate to `/sign-in`.
2. Read `sentryDsn` from the island.
3. Read the DSN the browser Sentry client initialized with.
### Expected Result
- If the island carries a DSN, the browser Sentry client initialized with that
exact runtime DSN (proving the island feeds `Sentry.init` race-free).
- If the island has no DSN, Sentry is not initialized (zero egress — the default).
### Key Verification Points
- The runtime DSN reaches module-load Sentry init via the island.
- Unset DSN ⇒ no Sentry initialization (privacy guarantee).
---
## Test Case: `RUNTIME-CONFIG-E2E-003` - Zero third-party telemetry when Sentry and GTM are unset
**Priority:** `critical`
**Tags:** @e2e, @runtime-config
**Preconditions:**
- UI server running with `UI_SENTRY_DSN` and `UI_GOOGLE_TAG_MANAGER_ID` unset (the Enterprise default; the test skips when either is configured).
### Flow Steps
1. Navigate to `/sign-in` and read the island config.
2. Reload while recording requests to `googletagmanager.com`, `google-analytics.com`, and `sentry.io`.
3. Inspect the DOM for a Google Tag Manager script.
### Expected Result
- No request is sent to any Google or Sentry host.
- The `GoogleTagManager` component is not rendered (no `gtm.js` script).
### Key Verification Points
- Enterprise default sends zero error/analytics telemetry to any third party.
- Empty/unset GTM id ⇒ component not mounted (an empty id is NOT inert).
@@ -0,0 +1,82 @@
import { expect, test } from "@playwright/test";
import { RUNTIME_CONFIG_KEYS, RuntimeConfigPage } from "./runtime-config-page";
test.describe("Runtime public-config data island", () => {
let runtimeConfigPage: RuntimeConfigPage;
test.beforeEach(async ({ page }) => {
runtimeConfigPage = new RuntimeConfigPage(page);
await runtimeConfigPage.goto();
});
test(
"renders an inert JSON island in <head> before the client bundle",
{
tag: ["@critical", "@e2e", "@runtime-config", "@RUNTIME-CONFIG-E2E-001"],
},
async () => {
// The island exists, is inert (application/json), lives in <head>, and
// precedes the first ordered (non-async) bundle script (the ordering
// guarantee a jsdom unit test cannot prove).
await expect(runtimeConfigPage.island).toBeAttached();
await runtimeConfigPage.verifyIslandInHead();
await runtimeConfigPage.verifyIslandPrecedesClientBundle();
// It carries exactly the allowlisted shape and parses as JSON.
const config = await runtimeConfigPage.readConfig();
expect(config).not.toBeNull();
expect(Object.keys(config ?? {}).sort()).toEqual(
[...RUNTIME_CONFIG_KEYS].sort(),
);
// API base URL is the required runtime value supplied to the server.
expect(config?.apiBaseUrl).toBeTruthy();
},
);
test(
"browser Sentry init is consistent with the island DSN",
{ tag: ["@high", "@e2e", "@runtime-config", "@RUNTIME-CONFIG-E2E-002"] },
async () => {
// The island feeds instrumentation-client's Sentry.init race-free. Assert
// the wiring regardless of whether this deployment configures a DSN:
// - DSN set ⇒ Sentry initialized with that exact runtime DSN
// - DSN unset ⇒ Sentry not initialized (zero egress, the default)
const config = await runtimeConfigPage.readConfig();
const islandDsn = (config?.sentryDsn as string | null) ?? null;
const initializedDsn = await runtimeConfigPage.sentryInitializedDsn();
if (islandDsn) {
expect(initializedDsn).toBe(islandDsn);
} else {
expect(initializedDsn).toBeNull();
}
},
);
test(
"sends zero third-party telemetry when Sentry and GTM are unset",
{
tag: ["@critical", "@e2e", "@runtime-config", "@RUNTIME-CONFIG-E2E-003"],
},
async () => {
// The Enterprise default: with neither integration configured, the page
// must not contact Sentry or Google. Only meaningful when this deployment
// actually leaves them unset.
const config = await runtimeConfigPage.readConfig();
test.skip(
Boolean(config?.sentryDsn) || Boolean(config?.googleTagManagerId),
"Sentry or GTM is configured in this environment",
);
const hits = await runtimeConfigPage.thirdPartyRequestsOnReload([
"googletagmanager.com",
"google-analytics.com",
"sentry.io",
]);
expect(hits).toEqual([]);
await runtimeConfigPage.verifyGoogleTagManagerNotRendered();
},
);
});
+28 -7
View File
@@ -5,14 +5,32 @@ declare global {
NODE_ENV: "development" | "production" | "test";
NEXT_RUNTIME?: "nodejs" | "edge";
// Public client config
NEXT_PUBLIC_API_BASE_URL: string;
// UI prefix — runtime public config, read server-side per request and
// exposed to the browser via the runtime data island. Each var keeps its
// deprecated NEXT_PUBLIC_* name as a runtime fallback (see readEnv).
/** @deprecated use UI_API_BASE_URL */
NEXT_PUBLIC_API_BASE_URL?: string;
UI_API_BASE_URL: string;
/** @deprecated use UI_API_DOCS_URL */
NEXT_PUBLIC_API_DOCS_URL?: string;
UI_API_DOCS_URL?: string;
// GTM
/** @deprecated use UI_GOOGLE_TAG_MANAGER_ID */
NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID?: string;
UI_GOOGLE_TAG_MANAGER_ID?: string;
// SENTRY
/** @deprecated use UI_SENTRY_DSN */
NEXT_PUBLIC_SENTRY_DSN?: string;
UI_SENTRY_DSN?: string;
/** @deprecated use UI_SENTRY_ENVIRONMENT */
NEXT_PUBLIC_SENTRY_ENVIRONMENT?: string;
UI_SENTRY_ENVIRONMENT?: string;
// Build-time public config
NEXT_PUBLIC_IS_CLOUD_ENV?: "true" | "false";
NEXT_PUBLIC_PROWLER_RELEASE_VERSION?: string;
NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID?: string;
NEXT_PUBLIC_SENTRY_DSN?: string;
NEXT_PUBLIC_SENTRY_ENVIRONMENT?: string;
// Auth (NextAuth)
AUTH_URL: string;
@@ -21,13 +39,16 @@ declare global {
NEXTAUTH_URL?: string;
// Sentry (server / build)
SENTRY_DSN?: string;
SENTRY_ENVIRONMENT?: string;
SENTRY_RELEASE?: string;
SENTRY_ORG?: string;
SENTRY_PROJECT?: string;
SENTRY_AUTH_TOKEN?: string;
// TODO Reserved runtime public config (registered now; no UI consumer yet)
POSTHOG_KEY?: string;
POSTHOG_HOST?: string;
REO_DEV_CLIENT_ID?: string;
// Social OAuth
SOCIAL_GOOGLE_OAUTH_CLIENT_ID?: string;
SOCIAL_GOOGLE_OAUTH_CLIENT_SECRET?: string;
+2 -3
View File
@@ -5,8 +5,7 @@ import type { TestProjectConfiguration } from "vitest/config";
import { defineConfig } from "vitest/config";
export default defineConfig(() => {
const apiBaseUrl =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost/api/v1";
const apiBaseUrl = process.env.UI_API_BASE_URL ?? "http://localhost/api/v1";
return {
plugins: [react()],
@@ -75,7 +74,7 @@ export default defineConfig(() => {
] as TestProjectConfiguration[],
},
define: {
"process.env.NEXT_PUBLIC_API_BASE_URL": JSON.stringify(apiBaseUrl),
"process.env.UI_API_BASE_URL": JSON.stringify(apiBaseUrl),
// `next/dist/server/web/spec-extension/user-agent.js` references
// `__dirname` directly and is pulled in transitively via `next-auth`.
// Vite serves it to the browser where that global doesn't exist, so we