mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
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:
committed by
GitHub
parent
751c7fc29f
commit
853610bbbf
@@ -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: |
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -417,6 +417,7 @@
|
||||
"group": "Miscellaneous",
|
||||
"pages": [
|
||||
"developer-guide/documentation",
|
||||
"developer-guide/environment-variables",
|
||||
{
|
||||
"group": "Testing",
|
||||
"pages": [
|
||||
|
||||
+14
-20
@@ -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>
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 {};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
Vendored
+28
-7
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user