Makes the application say what it is doing and when it fails

U4. Console logging plus Sentry, a same-origin tunnel so ad blockers cannot
silence browser errors, Umami on the admin SPA, and six security events that
alert rules can actually be built on.

The correlation id is the W3C trace id from the ambient Activity, enabled by
one line of ActivityTrackingOptions so every entry from every category carries
it without touching a call site. It propagates across the master/slave
boundary via traceparent, which TraceIdentifier cannot do at all, and it is
the same value ProblemDetails already returns to the browser.

The security events use source-generated LoggerMessage with constant
templates. Sentry groups log events by message, so interpolating an email
address would give every address its own issue and "more than 20 failed
logins in five minutes" could never fire — the events would arrive, be
visible, be tagged, and the alerting would silently be impossible. A test
asserts the rendered message is identical across argument values.

Scrubbing happens in-process, before transmission, and covers Set-Cookie as
well as Cookie: the login response issues the refreshToken there, so
scrubbing only the request side would protect nothing. Transactions are
scrubbed too, because they carry request data and are the channel nobody
thinks of.

The tunnel derives its destination from the DSN once at startup and reads
nothing from the request, which is what separates a tunnel from a
server-side request forgery primitive. Size is capped by a bounded read
rather than by trusting Content-Length, and the endpoint is rate limited.

Two things found along the way. Zod 4's url() hands the value to the URL
constructor, which accepts any scheme — so the existing frontend validation
would have accepted the exact "htp://" typo BR-U4-24 names, and the SPA
would have called a nonexistent origin. Now constrained to http(s). And the
new appsettings comments are verified against the real configuration
provider, because the failure mode if it rejected them is both hosts
refusing to start after a release switch.

One deviation. IAdminTokenValidator was meant to gain a reason-reporting
overload; implemented that way, a substitute returning false by default
silently inverted the access decision while both methods compiled. Two
methods whose difference is invisible at the call site is the defect, so it
is now a single Validate returning AdminTokenResult.

Touches two files from already-committed units: DatabaseMigrationExtensions
(U2) gains a flush before the rethrow, or the one Critical event in the
system dies with the process; AdminTokenValidator (U1) classifies why a
bypass was refused.

Build 0 errors; 366 backend tests pass, up from 315, and 237 frontend tests,
up from 213. tsc clean, eslint clean on every changed file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
This commit is contained in:
2026-07-28 11:25:54 +02:00
co-authored by Claude Opus 5
parent a122548454
commit 8e79a72340
54 changed files with 2598 additions and 66 deletions
+1
View File
@@ -23,6 +23,7 @@
"@radix-ui/react-label": "^2.1.10",
"@radix-ui/react-select": "^2.3.1",
"@radix-ui/react-slot": "^1.3.0",
"@sentry/react": "^10.68.0",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-router": "^1.170.16",
"class-variance-authority": "^0.7.1",
+78
View File
@@ -26,6 +26,9 @@ importers:
'@radix-ui/react-slot':
specifier: ^1.3.0
version: 1.3.0(@types/react@19.2.17)(react@19.2.7)
'@sentry/react':
specifier: ^10.68.0
version: 10.68.0(react@19.2.7)
'@tanstack/react-query':
specifier: ^5.101.0
version: 5.101.0(react@19.2.7)
@@ -875,6 +878,40 @@ packages:
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@sentry/browser-utils@10.68.0':
resolution: {integrity: sha512-be8VtdjCngKc77cstJeV+gO15iH+blyXpBDk8yOehmtX4BkFO33mfTMNCWVR2LA0oOxjIHWRAhf77fIUEhzxPg==}
engines: {node: '>=18'}
'@sentry/browser@10.68.0':
resolution: {integrity: sha512-8xVgk7oG2lajXnbXF6a7H1xMZ/U6icqSldHGzQu1+bajfrK8Gan9ULG/Xsj1VM1LlNeK6/7znDJ3u1jgvIwznw==}
engines: {node: '>=18'}
'@sentry/conventions@0.16.0':
resolution: {integrity: sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==}
engines: {node: '>=14'}
'@sentry/core@10.68.0':
resolution: {integrity: sha512-5Amhx8ltVz7vb1bRGyf3c4J69/iHW8R/H+SJxTRILHlsSOBrnVVc/IQEYDC6PTRdRdZ3x2u7RVjxZi2Mhe525g==}
engines: {node: '>=18'}
'@sentry/feedback@10.68.0':
resolution: {integrity: sha512-XbdcXiBnpC3vgw46eHOPeD/ZQ+XzluP75ubdUcaPDW02hCh2nsdXiwjZ2DBImbpvIpTbJgjHf/sIlHWvcZJ2Mg==}
engines: {node: '>=18'}
'@sentry/react@10.68.0':
resolution: {integrity: sha512-rIq4QR4ScMHHx9JJZv7Jgw31bMdUVJMx+ykHIJb7htjY6mj78sjKs+KpCsMDnvJxDhSmvftGM1KfKD4BggL7OQ==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.14.0 || 17.x || 18.x || 19.x
'@sentry/replay-canvas@10.68.0':
resolution: {integrity: sha512-HusYcr+He+ohnUDHunYrc5St6vdDnBXpUAndnT5ReyUMVSCiWKfY3paXowU/0787HwYfxdcpZgwC5u79+XbEIg==}
engines: {node: '>=18'}
'@sentry/replay@10.68.0':
resolution: {integrity: sha512-ZoG2n16vbkx4GWSCnLIqUUN9xlUmccQFbQ2US2rhruQeHTUnHl/ukr8NHOQXZaEbwKyMkX9bEMwfmZHJm+wSTQ==}
engines: {node: '>=18'}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -2997,6 +3034,47 @@ snapshots:
'@rolldown/pluginutils@1.0.1': {}
'@sentry/browser-utils@10.68.0':
dependencies:
'@sentry/conventions': 0.16.0
'@sentry/core': 10.68.0
'@sentry/browser@10.68.0':
dependencies:
'@sentry/browser-utils': 10.68.0
'@sentry/conventions': 0.16.0
'@sentry/core': 10.68.0
'@sentry/feedback': 10.68.0
'@sentry/replay': 10.68.0
'@sentry/replay-canvas': 10.68.0
'@sentry/conventions@0.16.0': {}
'@sentry/core@10.68.0':
dependencies:
'@sentry/conventions': 0.16.0
'@sentry/feedback@10.68.0':
dependencies:
'@sentry/core': 10.68.0
'@sentry/react@10.68.0(react@19.2.7)':
dependencies:
'@sentry/browser': 10.68.0
'@sentry/conventions': 0.16.0
'@sentry/core': 10.68.0
react: 19.2.7
'@sentry/replay-canvas@10.68.0':
dependencies:
'@sentry/core': 10.68.0
'@sentry/replay': 10.68.0
'@sentry/replay@10.68.0':
dependencies:
'@sentry/browser-utils': 10.68.0
'@sentry/core': 10.68.0
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
@@ -0,0 +1,90 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SentryErrorBoundary } from '@/components/SentryErrorBoundary';
function Boom(): never {
throw new Error('Internal detail: connection string Server=db;Password=hunter2');
}
describe('SentryErrorBoundary', () => {
beforeEach(() => {
// React logs caught render errors to the console; silence the expected noise.
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('shows a fallback instead of blanking the screen', () => {
render(
<SentryErrorBoundary>
<Boom />
</SentryErrorBoundary>,
);
expect(screen.getByTestId('error-boundary-fallback')).toBeInTheDocument();
expect(screen.getByRole('alert')).toBeInTheDocument();
});
/**
* Exception text frequently contains internal detail, so showing it to an operator is both
* unhelpful and a small information leak. This asserts the leak cannot reappear.
*/
it('shows no exception message and no stack trace', () => {
render(
<SentryErrorBoundary>
<Boom />
</SentryErrorBoundary>,
);
const fallback = screen.getByTestId('error-boundary-fallback');
expect(fallback.textContent).not.toContain('Internal detail');
expect(fallback.textContent).not.toContain('hunter2');
expect(fallback.textContent).not.toContain('Server=');
});
it('offers a retry that remounts the subtree', async () => {
let shouldThrow = true;
function Flaky() {
if (shouldThrow) {
throw new Error('transient');
}
return <p data-testid="recovered">recovered</p>;
}
render(
<SentryErrorBoundary>
<Flaky />
</SentryErrorBoundary>,
);
expect(screen.getByTestId('error-boundary-fallback')).toBeInTheDocument();
shouldThrow = false;
await userEvent.click(screen.getByTestId('error-boundary-retry-button'));
expect(screen.getByTestId('recovered')).toBeInTheDocument();
});
it('renders its children untouched when nothing throws', () => {
render(
<SentryErrorBoundary>
<p data-testid="child">fine</p>
</SentryErrorBoundary>,
);
expect(screen.getByTestId('child')).toBeInTheDocument();
expect(screen.queryByTestId('error-boundary-fallback')).not.toBeInTheDocument();
});
/**
* No DSN is configured in the test environment, so this run also covers the without-Sentry
* case: the boundary still catches and still shows the fallback, it simply reports nothing.
*/
it('works without a Sentry DSN configured', () => {
expect(import.meta.env.VITE_SENTRY_DSN).toBeUndefined();
});
});
@@ -0,0 +1,59 @@
import type { ReactNode } from 'react';
import * as Sentry from '@sentry/react';
import { AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
interface SentryErrorBoundaryProps {
children: ReactNode;
}
interface FallbackProps {
resetError: () => void;
}
/**
* Shown when a render error was caught.
*
* Deliberately shows no exception message and no stack trace: exception text frequently contains
* internal detail, and showing it to an operator is both unhelpful and a small information leak.
*/
function ErrorFallback({ resetError }: FallbackProps) {
const { t } = useTranslation();
return (
<div
className="flex flex-col items-center justify-center py-16 text-center space-y-4"
role="alert"
data-testid="error-boundary-fallback"
>
<AlertTriangle className="size-12 text-destructive" />
<h1 className="text-2xl font-semibold">{t('error.unexpected.title')}</h1>
<p className="text-muted-foreground max-w-sm">{t('error.unexpected.message')}</p>
<Button onClick={resetError} data-testid="error-boundary-retry-button">
{t('common.retry')}
</Button>
</div>
);
}
/**
* Catches render-time React errors that would otherwise blank the screen, reports them, and shows
* a recoverable fallback.
*
* Placed inside AuthProvider rather than outermost: the fallback has to be reachable for a
* logged-in user, and an error inside a page must not tear down the session context otherwise
* recovering from a render error would also log the user out.
*
* Works without a DSN too: it still catches and still shows the fallback, it simply reports
* nothing.
*/
export function SentryErrorBoundary({ children }: SentryErrorBoundaryProps) {
return (
<Sentry.ErrorBoundary
fallback={({ resetError }) => <ErrorFallback resetError={resetError} />}
>
{children}
</Sentry.ErrorBoundary>
);
}
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render } from '@testing-library/react';
import { UmamiAnalytics } from '@/components/UmamiAnalytics';
import { resetAppConfigCache } from '@/lib/config';
const SCRIPT_SELECTOR = '#umami-analytics-script';
function scripts(): NodeListOf<HTMLScriptElement> {
return document.head.querySelectorAll<HTMLScriptElement>(SCRIPT_SELECTOR);
}
describe('UmamiAnalytics', () => {
beforeEach(() => {
resetAppConfigCache();
document.head.querySelectorAll(SCRIPT_SELECTOR).forEach((node) => node.remove());
});
afterEach(() => {
vi.unstubAllEnvs();
resetAppConfigCache();
});
function configure(): void {
vi.stubEnv('VITE_UMAMI_SCRIPT_URL', 'https://analytics.example.com/script.js');
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', 'site-id');
}
it('injects nothing in development, even when fully configured', () => {
vi.stubEnv('DEV', true);
configure();
render(<UmamiAnalytics />);
expect(scripts()).toHaveLength(0);
});
it('injects nothing when the website ID is missing', () => {
vi.stubEnv('DEV', false);
vi.stubEnv('VITE_UMAMI_SCRIPT_URL', 'https://analytics.example.com/script.js');
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', undefined as unknown as string);
render(<UmamiAnalytics />);
expect(scripts()).toHaveLength(0);
});
it('injects nothing when the script URL is missing', () => {
vi.stubEnv('DEV', false);
vi.stubEnv('VITE_UMAMI_SCRIPT_URL', undefined as unknown as string);
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', 'site-id');
render(<UmamiAnalytics />);
expect(scripts()).toHaveLength(0);
});
it('injects the script once when configured outside development', () => {
vi.stubEnv('DEV', false);
configure();
render(<UmamiAnalytics />);
const injected = scripts();
expect(injected).toHaveLength(1);
expect(injected[0].src).toBe('https://analytics.example.com/script.js');
expect(injected[0].getAttribute('data-website-id')).toBe('site-id');
expect(injected[0].defer).toBe(true);
});
/**
* Guards against reinstating a cleanup that removes the script: under StrictMode the
* double-invocation would become inject -> remove -> inject, and removing the element does not
* unregister the listeners Umami already installed, so the first page view can be counted
* twice.
*/
it('injects the script only once across re-renders', () => {
vi.stubEnv('DEV', false);
configure();
const { rerender } = render(<UmamiAnalytics />);
rerender(<UmamiAnalytics />);
render(<UmamiAnalytics />);
expect(scripts()).toHaveLength(1);
});
it('renders nothing visible', () => {
vi.stubEnv('DEV', false);
configure();
const { container } = render(<UmamiAnalytics />);
expect(container.firstChild).toBeNull();
});
});
@@ -0,0 +1,47 @@
import { useEffect } from 'react';
import { getAppConfig } from '@/lib/config';
const SCRIPT_ELEMENT_ID = 'umami-analytics-script';
/**
* Injects the self-hosted Umami tracking script when both the script URL and the website ID are
* configured at build time. Renders nothing.
*
* A component rather than a tag in index.html, because the website ID is a build-time variable
* and index.html cannot read import.meta.env. It also makes the "never in development" and "once
* only" rules testable.
*
* `Do Not Track` is deliberately not consulted: Umami sets no cookies and collects no personal
* data, and this SPA's audience is a known set of operators, so honouring DNT would reduce data
* without protecting anyone. A conscious choice rather than an omission.
*/
export function UmamiAnalytics() {
useEffect(() => {
const { umamiScriptUrl, umamiWebsiteId } = getAppConfig();
// Never in local development, regardless of configuration, so local testing does not
// pollute the real visitor analytics.
if (import.meta.env.DEV || !umamiScriptUrl || !umamiWebsiteId) {
return;
}
if (document.getElementById(SCRIPT_ELEMENT_ID) !== null) {
return;
}
const script = document.createElement('script');
script.id = SCRIPT_ELEMENT_ID;
script.src = umamiScriptUrl;
script.defer = true;
script.setAttribute('data-website-id', umamiWebsiteId);
document.head.appendChild(script);
// Deliberately no cleanup removing the script. Under StrictMode the double-invocation
// would become inject -> remove -> inject, and removing the element does not unregister
// the listeners Umami already installed — so the first page view can be counted twice.
// This component lives for the application's lifetime and has nothing to clean up; the
// duplicate guard above handles re-invocation on its own.
}, []);
return null;
}
@@ -239,7 +239,11 @@
"title": "Page Not Found",
"message": "The page you're looking for doesn't exist."
},
"backToDashboard": "Back to Dashboard"
"backToDashboard": "Back to Dashboard",
"unexpected": {
"title": "Something went wrong",
"message": "This page could not be displayed. Try again, or go back to the dashboard."
}
},
"errors": {
"network": "Unable to reach the server. Check your connection and try again.",
@@ -239,7 +239,11 @@
"title": "Pagina niet gevonden",
"message": "De pagina die je zoekt bestaat niet."
},
"backToDashboard": "Terug naar dashboard"
"backToDashboard": "Terug naar dashboard",
"unexpected": {
"title": "Er is iets misgegaan",
"message": "Deze pagina kon niet worden weergegeven. Probeer het opnieuw of ga terug naar het dashboard."
}
},
"errors": {
"network": "Kan de server niet bereiken. Controleer je verbinding en probeer opnieuw.",
+27
View File
@@ -183,4 +183,31 @@ describe('ApiClient', () => {
const client = makeClient();
await expect(client.get('/network-fail')).rejects.toThrow(NetworkError);
});
/**
* Same-origin is the production configuration, and the one nobody runs locally so it is
* only ever exercised here. Note the URL must come out as a plain relative path: any code
* building it with `new URL(path, base)` or an interpolated slash would break on an empty
* base, and would break only in production.
*/
describe('with an empty base URL (same-origin)', () => {
it('issues requests against relative paths', async () => {
let seenUrl: string | null = null;
server.use(
http.get('/api/v1/System/capabilities', ({ request }) => {
seenUrl = request.url;
return HttpResponse.json({ modules: [] });
}),
);
const client = new ApiClient('');
const result = await client.get<{ modules: string[] }>(
'/api/v1/System/capabilities',
);
expect(result).toEqual({ modules: [] });
expect(seenUrl).toContain('/api/v1/System/capabilities');
expect(seenUrl).not.toContain('//api/v1');
});
});
});
+83
View File
@@ -0,0 +1,83 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getAppConfig, resetAppConfigCache } from '@/lib/config';
/**
* The empty-base case is the production configuration, and the one nobody runs locally so it is
* only ever exercised here. A malformed value must still be rejected: relaxing validation to
* permit "" is not the same as removing it.
*/
describe('getAppConfig', () => {
const originalEnv = { ...import.meta.env };
beforeEach(() => {
resetAppConfigCache();
});
afterEach(() => {
vi.unstubAllEnvs();
resetAppConfigCache();
Object.assign(import.meta.env, originalEnv);
});
it('resolves an absent API base URL to same-origin', () => {
vi.stubEnv('VITE_API_BASE_URL', undefined as unknown as string);
expect(getAppConfig().apiBaseUrl).toBe('');
});
it('resolves an empty API base URL to same-origin', () => {
vi.stubEnv('VITE_API_BASE_URL', '');
expect(getAppConfig().apiBaseUrl).toBe('');
});
it('keeps an explicit absolute URL, so local master/slave development is unaffected', () => {
vi.stubEnv('VITE_API_BASE_URL', 'https://localhost:7221');
expect(getAppConfig().apiBaseUrl).toBe('https://localhost:7221');
});
it('warns in development about a malformed API base URL rather than accepting it silently', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.stubEnv('VITE_API_BASE_URL', 'htp://localhost:7221');
getAppConfig();
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it('does not warn about an empty API base URL', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.stubEnv('VITE_API_BASE_URL', '');
getAppConfig();
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
it('exposes the observability values', () => {
vi.stubEnv('VITE_SENTRY_DSN', 'https://abc@host.sentry.io/42');
vi.stubEnv('VITE_APP_ENV', 'test');
vi.stubEnv('VITE_UMAMI_SCRIPT_URL', 'https://analytics.example.com/script.js');
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', 'site-id');
const config = getAppConfig();
expect(config.sentryDsn).toBe('https://abc@host.sentry.io/42');
expect(config.appEnv).toBe('test');
expect(config.umamiScriptUrl).toBe('https://analytics.example.com/script.js');
expect(config.umamiWebsiteId).toBe('site-id');
});
it('leaves the observability values undefined when nothing is configured', () => {
vi.stubEnv('VITE_SENTRY_DSN', undefined as unknown as string);
vi.stubEnv('VITE_UMAMI_WEBSITE_ID', undefined as unknown as string);
const config = getAppConfig();
expect(config.sentryDsn).toBeUndefined();
expect(config.umamiWebsiteId).toBeUndefined();
});
});
+28 -2
View File
@@ -6,8 +6,21 @@ import { z } from 'zod';
* once and only warns in development production trusts the build-time env.
*/
const configSchema = z.object({
apiBaseUrl: z.string().url(),
// Relaxed by exactly one case, not loosened: the empty string means same-origin, and
// everything else must still be a valid absolute http(s) URL. A typo such as
// 'htp://localhost:7221' has to stay a failure, or the SPA silently issues requests to a
// nonexistent origin — which looks exactly like the API being down.
//
// The protocol constraint is not decoration. Zod 4's url() validates by handing the value to
// the URL constructor, which happily accepts ANY scheme — 'htp://localhost:7221' and
// 'ftp://x.nl' both pass a bare .url(). The scheme typo this rule exists to catch was
// therefore never actually caught before this constraint was added.
apiBaseUrl: z.union([z.literal(''), z.url({ protocol: /^https?$/ })]),
appTitle: z.string(),
sentryDsn: z.string().optional(),
appEnv: z.string().optional(),
umamiScriptUrl: z.string().optional(),
umamiWebsiteId: z.string().optional(),
});
export type AppConfig = z.infer<typeof configSchema>;
@@ -20,8 +33,16 @@ export function getAppConfig(): AppConfig {
}
const raw: AppConfig = {
apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
// Absent or empty means same-origin: requests use relative paths, which is the intended
// production configuration now that the API and this SPA are served by one process. An
// explicit absolute URL is still honoured, so local development against
// https://localhost:7221 (master) and :7222 (slave) keeps working unchanged.
apiBaseUrl: import.meta.env.VITE_API_BASE_URL ?? '',
appTitle: import.meta.env.VITE_APP_TITLE ?? 'SlpModularCms',
sentryDsn: import.meta.env.VITE_SENTRY_DSN,
appEnv: import.meta.env.VITE_APP_ENV,
umamiScriptUrl: import.meta.env.VITE_UMAMI_SCRIPT_URL,
umamiWebsiteId: import.meta.env.VITE_UMAMI_WEBSITE_ID,
};
if (import.meta.env.DEV) {
@@ -35,3 +56,8 @@ export function getAppConfig(): AppConfig {
cached = raw;
return cached;
}
/** Test-only: clears the memoised configuration so a different env can be observed. */
export function resetAppConfigCache(): void {
cached = null;
}
+72
View File
@@ -0,0 +1,72 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as Sentry from '@sentry/react';
import { SENTRY_TUNNEL_PATH, initSentry } from '@/lib/sentry';
import { resetAppConfigCache } from '@/lib/config';
// ESM namespace objects are not configurable, so Sentry.init cannot be spied on in place.
vi.mock('@sentry/react', () => ({ init: vi.fn() }));
const init = vi.mocked(Sentry.init);
describe('initSentry', () => {
beforeEach(() => {
resetAppConfigCache();
init.mockClear();
});
afterEach(() => {
vi.unstubAllEnvs();
resetAppConfigCache();
});
it('does nothing without a DSN, so local development is unaffected', () => {
vi.stubEnv('VITE_SENTRY_DSN', undefined as unknown as string);
initSentry();
expect(init).not.toHaveBeenCalled();
});
it('does nothing when the DSN is an empty string', () => {
vi.stubEnv('VITE_SENTRY_DSN', '');
initSentry();
expect(init).not.toHaveBeenCalled();
});
/**
* The tunnel is the whole reason browser reporting survives an ad blocker: a direct ingest URL
* is blocked with ERR_BLOCKED_BY_CLIENT, losing errors precisely for the users who have one.
*/
it('routes through the same-origin tunnel rather than a Sentry ingest URL', () => {
vi.stubEnv('VITE_SENTRY_DSN', 'https://abc@o1.ingest.de.sentry.io/42');
initSentry();
expect(init).toHaveBeenCalledOnce();
const options = init.mock.calls[0][0]!;
expect(options.tunnel).toBe(SENTRY_TUNNEL_PATH);
expect(options.tunnel).not.toContain('sentry.io');
});
it('tags the environment and the release, and keeps PII off', () => {
vi.stubEnv('VITE_SENTRY_DSN', 'https://abc@o1.ingest.de.sentry.io/42');
vi.stubEnv('VITE_APP_ENV', 'test');
initSentry();
const options = init.mock.calls[0][0]!;
expect(options.environment).toBe('test');
expect(options.release).toBeTruthy();
// False on the frontend even though the backend enables it with scrubbing: the browser
// offers no equivalent in-process guarantee, and there is nothing here the backend cannot
// already report.
expect(options.sendDefaultPii).toBe(false);
});
it('uses the same tunnel path the API serves and the dev proxy mirrors', () => {
expect(SENTRY_TUNNEL_PATH).toBe('/sentry-tunnel');
expect(SENTRY_TUNNEL_PATH).not.toMatch(/^\/api\//);
});
});
+40
View File
@@ -0,0 +1,40 @@
import * as Sentry from '@sentry/react';
import { getAppConfig } from '@/lib/config';
/** Same-origin path served by the API (see SentryTunnelExtensions). */
export const SENTRY_TUNNEL_PATH = '/sentry-tunnel';
/**
* Initialises Sentry, or does nothing when no DSN is configured.
*
* Called first from main.tsx, before the query client and before render, so an error during
* startup is still captured.
*/
export function initSentry(): void {
const { sentryDsn, appEnv } = getAppConfig();
// Local development and any deployment without Sentry must work unchanged.
if (!sentryDsn) {
return;
}
Sentry.init({
dsn: sentryDsn,
environment: appEnv,
release: __APP_VERSION__,
// Sentry's free plan counts transactions against the same quota as errors, and this
// setup's value is in errors rather than performance traces.
tracesSampleRate: 0.1,
// False here even though the backend enables it with scrubbing: the backend can scrub
// in-process before transmission because it controls the send, and in the browser there
// is no equivalent guarantee. The frontend has nothing to add that the backend cannot
// already report, so there is no reason to accept the risk.
sendDefaultPii: false,
// Ad blockers block requests to *.ingest.sentry.io outright (ERR_BLOCKED_BY_CLIENT),
// because they look like third-party tracking. Without this, errors are lost precisely
// for the users who have an ad blocker — a silently biased sample of exactly the group
// most likely to have browser oddities. The API forwards the envelope onward; the
// destination is derived from its own DSN, so nothing here needs a project id.
tunnel: SENTRY_TUNNEL_PATH,
});
}
+16 -1
View File
@@ -9,6 +9,13 @@ import { useAuth } from '@/contexts/auth-context';
import { Toaster } from '@/components/ui/sonner';
import { router } from '@/router';
import { getAppConfig } from '@/lib/config';
import { initSentry } from '@/lib/sentry';
import { SentryErrorBoundary } from '@/components/SentryErrorBoundary';
import { UmamiAnalytics } from '@/components/UmamiAnalytics';
// First, before the query client and before render, so an error during startup is still captured.
// A no-op when no DSN is configured.
initSentry();
document.title = getAppConfig().appTitle;
@@ -55,7 +62,15 @@ void enableMocking().then(() => {
<StrictMode>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<InnerApp />
{/*
* Inside AuthProvider, not outermost: the fallback has to be reachable for a
* logged-in user, and a render error inside a page must not tear down the
* session context otherwise recovering would also log the user out.
*/}
<SentryErrorBoundary>
<InnerApp />
<UmamiAnalytics />
</SentryErrorBoundary>
<Toaster />
</AuthProvider>
</QueryClientProvider>
+17 -2
View File
@@ -1,15 +1,30 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** Base URL of the SlpModularCms .NET API (e.g. http://localhost:5000). */
readonly VITE_API_BASE_URL: string;
/**
* Base URL of the SlpModularCms .NET API. Absent or empty means same-origin, which is the
* production configuration: the API and this SPA are served by one process. Set explicitly
* for local development (e.g. https://localhost:7221 for master, :7222 for slave).
*/
readonly VITE_API_BASE_URL?: string;
/** Set to 'true' to run the MSW mock backend in the browser during dev. */
readonly VITE_ENABLE_MSW?: string;
/** Browser tab title; lets local master/slave dev instances be told apart. Defaults to "SlpModularCms". */
readonly VITE_APP_TITLE?: string;
/** Sentry DSN for client-side error reporting. Not a secret — it ships in the bundle. Absent means Sentry is skipped. */
readonly VITE_SENTRY_DSN?: string;
/** Build-time environment tag ('test' | 'production'). Both use `vite build`, so MODE alone cannot tell them apart. */
readonly VITE_APP_ENV?: string;
/** Self-hosted Umami tracking script URL, e.g. https://analytics.example.com/script.js */
readonly VITE_UMAMI_SCRIPT_URL?: string;
/** Umami website ID, created per environment in the Umami dashboard. */
readonly VITE_UMAMI_WEBSITE_ID?: string;
// Add future typed env flags here.
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
/** App version at build time (from package.json), injected via vite.config.ts `define`. Used as the Sentry `release` tag. */
declare const __APP_VERSION__: string;
+24
View File
@@ -3,12 +3,17 @@ import path from 'node:path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { version } from './package.json';
// https://vite.dev/config/
export default defineConfig(({ command }) => ({
// Production builds are deployed under /admin (see Program.cs); the dev server keeps serving from '/'.
base: command === 'build' ? '/admin/' : '/',
plugins: [react(), tailwindcss()],
define: {
// Injected at build time, used as the Sentry `release` tag (see src/lib/sentry.ts).
__APP_VERSION__: JSON.stringify(version),
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
@@ -16,6 +21,22 @@ export default defineConfig(({ command }) => ({
},
server: {
port: 5173,
proxy: {
// Mirrors the tunnel path the API serves in test and production, so the same
// Sentry `tunnel` option also works during `pnpm dev` — where this SPA runs on
// 5173 and the API on 7221.
//
// Note this targets our own API rather than Sentry's ingest host directly. The
// reference project proxies straight to Sentry, which forces the DSN's project id
// into this file and requires keeping it in sync by hand. The API derives the
// destination from its own DSN, so there is nothing to synchronise and no project
// id in a committed file.
'/sentry-tunnel': {
target: 'https://localhost:7221',
changeOrigin: true,
secure: false,
},
},
},
test: {
globals: true,
@@ -33,6 +54,9 @@ export default defineConfig(({ command }) => ({
'src/mocks/**',
'src/main.tsx',
'src/vite-env.d.ts',
// Calls Sentry.init, which cannot run meaningfully under jsdom; its skip-without-
// a-DSN behaviour is covered by a test that asserts init was not called.
'src/lib/sentry.ts',
],
},
},