From 925636e8cdb19b9424685ba1901b5493a3cc650d Mon Sep 17 00:00:00 2001 From: TurtleWolfe Date: Mon, 8 Jun 2026 18:14:17 +0000 Subject: [PATCH] feat(monitoring): #45 consent-gated Sentry error monitoring (client-only, PII-scrubbed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolds Sentry error monitoring for the static-export app. Client-only (@sentry/react, not @sentry/nextjs — withSentryConfig needs a Next server), gated on analytics consent via ConsentContext (mirrors GoogleAnalytics), and inert until NEXT_PUBLIC_SENTRY_DSN is set (empty DSN = no-op, so it ships now and goes live with zero code change once a DSN is provided). - src/lib/monitoring/scrub.ts — pure PII scrubber (emails, JWTs, Bearer/token k-v, sb--auth-token keys; whole-value redaction of message bodies content/plaintext_content/new_content/encrypted_content/password). Used as beforeSend. 15 unit tests. - src/lib/monitoring/sentry.ts — idempotent init (no-op without DSN/on SSR), closeSentry on consent withdrawal, guarded captureAppError. Replay + tracing disabled so the encrypted messaging UI is never recorded. 6 tests. - src/lib/monitoring/SentryMonitor/ — 5-file consent-gated client component. - src/utils/error-handler.ts — single capture chokepoint in sendToService (ErrorBoundary already routes here → exactly one captureException per error). - layout.tsx mounts + adds Sentry ingest hosts to CSP connect-src (static export has no header CSP; envelopes would be blocked). - .env.example / deploy.yml / README document NEXT_PUBLIC_SENTRY_DSN. Closes #45. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 4 + .github/workflows/deploy.yml | 1 + README.md | 3 + package.json | 1 + pnpm-lock.yaml | 75 +++++++++ src/app/layout.tsx | 4 +- .../SentryMonitor.accessibility.test.tsx | 59 +++++++ .../SentryMonitor/SentryMonitor.stories.tsx | 86 ++++++++++ .../SentryMonitor/SentryMonitor.test.tsx | 72 ++++++++ .../SentryMonitor/SentryMonitor.tsx | 33 ++++ src/lib/monitoring/SentryMonitor/index.tsx | 1 + src/lib/monitoring/scrub.test.ts | 155 ++++++++++++++++++ src/lib/monitoring/scrub.ts | 123 ++++++++++++++ src/lib/monitoring/sentry.test.ts | 102 ++++++++++++ src/lib/monitoring/sentry.ts | 79 +++++++++ src/utils/error-handler.test.ts | 53 ++++++ src/utils/error-handler.ts | 11 ++ 17 files changed, 861 insertions(+), 1 deletion(-) create mode 100644 src/lib/monitoring/SentryMonitor/SentryMonitor.accessibility.test.tsx create mode 100644 src/lib/monitoring/SentryMonitor/SentryMonitor.stories.tsx create mode 100644 src/lib/monitoring/SentryMonitor/SentryMonitor.test.tsx create mode 100644 src/lib/monitoring/SentryMonitor/SentryMonitor.tsx create mode 100644 src/lib/monitoring/SentryMonitor/index.tsx create mode 100644 src/lib/monitoring/scrub.test.ts create mode 100644 src/lib/monitoring/scrub.ts create mode 100644 src/lib/monitoring/sentry.test.ts create mode 100644 src/lib/monitoring/sentry.ts create mode 100644 src/utils/error-handler.test.ts diff --git a/.env.example b/.env.example index 44988864..398fe8b3 100644 --- a/.env.example +++ b/.env.example @@ -84,6 +84,10 @@ SH_SUPABASE_MAIL_PORT=54324 # NEXT_PUBLIC_PAYPAL_CLIENT_ID=your-paypal-client-id # NEXT_PUBLIC_PROJECT_NAME=MyProject # NEXT_PUBLIC_PROJECT_OWNER=MyUsername +# Sentry error monitoring (optional). Public client DSN from sentry.io → your +# project → Client Keys (DSN). Empty = monitoring disabled (no-op). Only sends +# after the user grants analytics consent. +# NEXT_PUBLIC_SENTRY_DSN=https://examplePublicKey@o0.ingest.us.sentry.io/0 # NEXT_PUBLIC_SITE_TWITTER_HANDLE=TwitterHandle # NEXT_PUBLIC_SITE_URL=https://yourdomain.com # NEXT_PUBLIC_SOCIAL_PLATFORMS=github,linkedin,twitch,twitter diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a3388042..39f6471a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -72,6 +72,7 @@ jobs: NEXT_PUBLIC_PAGESPEED_API_KEY: ${{ secrets.NEXT_PUBLIC_PAGESPEED_API_KEY }} NEXT_PUBLIC_PROJECT_NAME: ${{ vars.NEXT_PUBLIC_PROJECT_NAME }} NEXT_PUBLIC_PROJECT_OWNER: ${{ vars.NEXT_PUBLIC_PROJECT_OWNER }} + NEXT_PUBLIC_SENTRY_DSN: ${{ secrets.NEXT_PUBLIC_SENTRY_DSN }} NEXT_PUBLIC_SITE_URL: ${{ vars.NEXT_PUBLIC_SITE_URL }} NEXT_PUBLIC_SOCIAL_PLATFORMS: ${{ vars.NEXT_PUBLIC_SOCIAL_PLATFORMS }} NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY: ${{ secrets.NEXT_PUBLIC_WEB3FORMS_ACCESS_KEY }} diff --git a/README.md b/README.md index 43aa0288..0672ef81 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,9 @@ These customize the site appearance but aren't required for builds: | `NEXT_PUBLIC_DISQUS_SHORTNAME` | Disqus comments integration | | `NEXT_PUBLIC_PAGESPEED_API_KEY` | Google PageSpeed API key | | `NEXT_PUBLIC_GA_MEASUREMENT_ID` | Google Analytics measurement ID | +| `NEXT_PUBLIC_SENTRY_DSN` | Sentry error-monitoring client DSN | + +Sentry monitoring is **disabled until `NEXT_PUBLIC_SENTRY_DSN` is set** (empty = no-op) and only sends events **after the user grants analytics consent**. Get the DSN from sentry.io → your project → **Client Keys (DSN)**. All events are PII-scrubbed (emails, tokens, message bodies) before leaving the browser; session replay and tracing are disabled. ### 📝 Optional - Supabase Admin (for migrations) diff --git a/package.json b/package.json index 243ec759..3aa929b6 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "@noble/curves": "^2.0.1", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.6.1", + "@sentry/react": "9.47.1", "@stripe/stripe-js": "^8.0.0", "@supabase/ssr": "^0.5.2", "@supabase/supabase-js": "^2.58.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 103542c3..5b1fa18f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@react-three/fiber': specifier: ^9.6.1 version: 9.6.1(@types/react@19.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(three@0.184.0) + '@sentry/react': + specifier: 9.47.1 + version: 9.47.1(react@19.1.0) '@stripe/stripe-js': specifier: ^8.0.0 version: 8.0.0 @@ -1528,10 +1531,34 @@ packages: '@rushstack/eslint-patch@1.12.0': resolution: {integrity: sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==} + '@sentry-internal/browser-utils@9.47.1': + resolution: {integrity: sha512-twv6YhrUlPkvKz4/iQDH4KHgcv9t4cMjmZPf4/dCSCXn4/GOjzjx2d74c1w+1KOdS7lcsQzI+MtbK6SeYLiGfQ==} + engines: {node: '>=18'} + + '@sentry-internal/feedback@9.47.1': + resolution: {integrity: sha512-xJ4vKvIpAT8e+Sz80YrsNinPU0XV7jPxPjdZ4ex8R2mMvx7pM0gq8JiR/sIVmNiOE0WiUDr6VwLDE8j2APSRMA==} + engines: {node: '>=18'} + + '@sentry-internal/replay-canvas@9.47.1': + resolution: {integrity: sha512-r9nve+l5+elGB9NXSN1+PUgJy790tXN1e8lZNH2ziveoU91jW4yYYt34mHZ30fU9tOz58OpaRMj3H3GJ/jYZVA==} + engines: {node: '>=18'} + + '@sentry-internal/replay@9.47.1': + resolution: {integrity: sha512-O9ZEfySpstGtX1f73m3NbdbS2utwPikaFt6sgp74RG4ZX4LlXe99VAjKR464xKECpYsLmj2bYpiK4opURF0pBA==} + engines: {node: '>=18'} + + '@sentry/browser@9.47.1': + resolution: {integrity: sha512-at5JOLziw5QpVYytxTDU6xijdV6lDQ/Rxp/qXJaHXud3gIK4suv2cXW+tupJfwoUoHFCnDNfccjCmPmP0yRqiA==} + engines: {node: '>=18'} + '@sentry/core@9.46.0': resolution: {integrity: sha512-it7JMFqxVproAgEtbLgCVBYtQ9fIb+Bu0JD+cEplTN/Ukpe6GaolyYib5geZqslVxhp2sQgT+58aGvfd/k0N8Q==} engines: {node: '>=18'} + '@sentry/core@9.47.1': + resolution: {integrity: sha512-KX62+qIt4xgy8eHKHiikfhz2p5fOciXd0Cl+dNzhgPFq8klq4MGMNaf148GB3M/vBqP4nw/eFvRMAayFCgdRQw==} + engines: {node: '>=18'} + '@sentry/node-core@9.46.0': resolution: {integrity: sha512-XRVu5pqoklZeh4wqhxCLZkz/ipoKhitctgEFXX9Yh1e1BoHM2pIxT52wf+W6hHM676TFmFXW3uKBjsmRM3AjgA==} engines: {node: '>=18'} @@ -1558,6 +1585,12 @@ packages: '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.0.0 '@opentelemetry/semantic-conventions': ^1.34.0 + '@sentry/react@9.47.1': + resolution: {integrity: sha512-Anqt0hG1R+nktlwEiDc2FmD+6DUGMJOLuArgr7q1cSCdPbK2Gb1eZ2rF57Ui+CDo9XLvlX9QP2is/M08rrVe3w==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.14.0 || 17.x || 18.x || 19.x + '@sideway/address@4.1.5': resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} @@ -3938,6 +3971,9 @@ packages: hls.js@1.6.16: resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==} + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + homedir-polyfill@1.0.3: resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} engines: {node: '>=0.10.0'} @@ -8073,8 +8109,36 @@ snapshots: '@rushstack/eslint-patch@1.12.0': {} + '@sentry-internal/browser-utils@9.47.1': + dependencies: + '@sentry/core': 9.47.1 + + '@sentry-internal/feedback@9.47.1': + dependencies: + '@sentry/core': 9.47.1 + + '@sentry-internal/replay-canvas@9.47.1': + dependencies: + '@sentry-internal/replay': 9.47.1 + '@sentry/core': 9.47.1 + + '@sentry-internal/replay@9.47.1': + dependencies: + '@sentry-internal/browser-utils': 9.47.1 + '@sentry/core': 9.47.1 + + '@sentry/browser@9.47.1': + dependencies: + '@sentry-internal/browser-utils': 9.47.1 + '@sentry-internal/feedback': 9.47.1 + '@sentry-internal/replay': 9.47.1 + '@sentry-internal/replay-canvas': 9.47.1 + '@sentry/core': 9.47.1 + '@sentry/core@9.46.0': {} + '@sentry/core@9.47.1': {} + '@sentry/node-core@9.46.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.37.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -8137,6 +8201,13 @@ snapshots: '@opentelemetry/semantic-conventions': 1.37.0 '@sentry/core': 9.46.0 + '@sentry/react@9.47.1(react@19.1.0)': + dependencies: + '@sentry/browser': 9.47.1 + '@sentry/core': 9.47.1 + hoist-non-react-statics: 3.3.2 + react: 19.1.0 + '@sideway/address@4.1.5': dependencies: '@hapi/hoek': 9.3.0 @@ -10931,6 +11002,10 @@ snapshots: hls.js@1.6.16: {} + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + homedir-polyfill@1.0.3: dependencies: parse-passwd: 1.0.0 diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2fdfc52c..f2e7d784 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -10,6 +10,7 @@ import { ConsentProvider } from '@/contexts/ConsentContext'; import { CookieConsent } from '@/components/privacy/CookieConsent'; import { ConsentModal } from '@/components/privacy/ConsentModal'; import GoogleAnalytics from '@/lib/analytics/GoogleAnalytics'; +import SentryMonitor from '@/lib/monitoring/SentryMonitor'; import ErrorBoundary from '@/components/ErrorBoundary'; import { AuthProvider } from '@/contexts/AuthContext'; import { projectConfig } from '@/config/project.config'; @@ -101,7 +102,7 @@ export const metadata: Metadata = { "style-src 'self' 'unsafe-inline' https://unpkg.com", "img-src 'self' data: https: blob:", "font-src 'self' data:", - "connect-src 'self' https://www.googleapis.com https://*.google-analytics.com https://tile.openstreetmap.org https://*.tile.openstreetmap.org https://*.supabase.co wss://*.supabase.co https://*.basemaps.cartocdn.com https://api.web3forms.com", + "connect-src 'self' https://www.googleapis.com https://*.google-analytics.com https://tile.openstreetmap.org https://*.tile.openstreetmap.org https://*.supabase.co wss://*.supabase.co https://*.basemaps.cartocdn.com https://api.web3forms.com https://*.ingest.sentry.io https://*.ingest.us.sentry.io", "frame-src 'self' https://www.google.com", "object-src 'none'", "base-uri 'self'", @@ -127,6 +128,7 @@ export default function RootLayout({ + diff --git a/src/lib/monitoring/SentryMonitor/SentryMonitor.accessibility.test.tsx b/src/lib/monitoring/SentryMonitor/SentryMonitor.accessibility.test.tsx new file mode 100644 index 00000000..d4398625 --- /dev/null +++ b/src/lib/monitoring/SentryMonitor/SentryMonitor.accessibility.test.tsx @@ -0,0 +1,59 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import { axe, toHaveNoViolations } from 'jest-axe'; +import SentryMonitor from './SentryMonitor'; +import { useConsent } from '@/contexts/ConsentContext'; +import { + createMockConsentAllRejected, + createMockConsentWithAnalytics, +} from '@/test-utils/consent-mocks'; + +vi.mock('@/lib/monitoring/sentry', () => ({ + SENTRY_DSN: 'https://k@o1.ingest.us.sentry.io/1', + initSentry: vi.fn(), + closeSentry: vi.fn(() => Promise.resolve()), +})); + +vi.mock('@/contexts/ConsentContext', () => ({ + useConsent: vi.fn(), +})); + +expect.extend(toHaveNoViolations); + +describe('SentryMonitor Accessibility', () => { + it('has no accessibility violations when consent is granted', async () => { + vi.mocked(useConsent).mockReturnValue(createMockConsentWithAnalytics()); + const { container } = render(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('has no accessibility violations when consent is denied', async () => { + vi.mocked(useConsent).mockReturnValue(createMockConsentAllRejected()); + const { container } = render(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('adds no focusable elements', () => { + vi.mocked(useConsent).mockReturnValue(createMockConsentWithAnalytics()); + const { container } = render(); + const focusable = container.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + expect(focusable).toHaveLength(0); + }); + + it('adds no visible content', () => { + vi.mocked(useConsent).mockReturnValue(createMockConsentWithAnalytics()); + const { container } = render(); + expect(container.textContent).toBe(''); + }); + + it('adds no ARIA live regions or roles', () => { + vi.mocked(useConsent).mockReturnValue(createMockConsentWithAnalytics()); + const { container } = render(); + expect(container.querySelectorAll('[aria-live]')).toHaveLength(0); + expect(container.querySelectorAll('[role]')).toHaveLength(0); + }); +}); diff --git a/src/lib/monitoring/SentryMonitor/SentryMonitor.stories.tsx b/src/lib/monitoring/SentryMonitor/SentryMonitor.stories.tsx new file mode 100644 index 00000000..a8881e1b --- /dev/null +++ b/src/lib/monitoring/SentryMonitor/SentryMonitor.stories.tsx @@ -0,0 +1,86 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import React from 'react'; + +/** + * SentryMonitor renders nothing — it initializes Sentry as a side effect when + * analytics consent is granted and a DSN is configured. These stories document + * that behavior visually rather than rendering the (invisible) component, which + * depends on ConsentProvider + the Sentry SDK at runtime. + */ +const SentryMonitorDoc = ({ + analyticsConsent = true, + dsnConfigured = true, +}: { + analyticsConsent?: boolean; + dsnConfigured?: boolean; +}) => { + const active = analyticsConsent && dsnConfigured; + return ( +
+

Sentry Monitoring

+

DSN configured: {dsnConfigured ? '✅ Yes' : '❌ No (inert)'}

+

Analytics consent: {analyticsConsent ? '✅ Granted' : '❌ Denied'}

+

+ {active + ? 'Sentry is initialized; handled errors report (PII-scrubbed).' + : 'Sentry is NOT initialized — no events are sent.'} +

+
+ ); +}; + +const meta: Meta = { + title: 'Utilities/Monitoring/SentryMonitor', + component: SentryMonitorDoc, + parameters: { + layout: 'padded', + docs: { + description: { + component: ` +The SentryMonitor component initializes client-only Sentry error monitoring, +gated on analytics consent (via ConsentContext) and a configured +\`NEXT_PUBLIC_SENTRY_DSN\`. It renders nothing. + +## Key Features +- **Privacy-first**: no events until analytics consent is granted +- **Inert without a DSN**: ships disabled until \`NEXT_PUBLIC_SENTRY_DSN\` is set +- **PII scrubbing**: every event is run through a \`beforeSend\` scrubber +- **No session replay / tracing**: never records the encrypted messaging UI +- **Static-export safe**: uses \`@sentry/react\` (client-only), not \`@sentry/nextjs\` + +## Usage +\`\`\`tsx +// In app/layout.tsx, beside +import SentryMonitor from '@/lib/monitoring/SentryMonitor'; + + + + + {children} + +\`\`\` + `, + }, + }, + }, + tags: ['autodocs'], + argTypes: { + analyticsConsent: { control: 'boolean' }, + dsnConfigured: { control: 'boolean' }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Active: Story = { + args: { analyticsConsent: true, dsnConfigured: true }, +}; + +export const ConsentDenied: Story = { + args: { analyticsConsent: false, dsnConfigured: true }, +}; + +export const NoDsnConfigured: Story = { + args: { analyticsConsent: true, dsnConfigured: false }, +}; diff --git a/src/lib/monitoring/SentryMonitor/SentryMonitor.test.tsx b/src/lib/monitoring/SentryMonitor/SentryMonitor.test.tsx new file mode 100644 index 00000000..396d3ce2 --- /dev/null +++ b/src/lib/monitoring/SentryMonitor/SentryMonitor.test.tsx @@ -0,0 +1,72 @@ +import { render, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import SentryMonitor from './SentryMonitor'; +import { useConsent } from '@/contexts/ConsentContext'; +import { + createMockConsentAllRejected, + createMockConsentWithAnalytics, +} from '@/test-utils/consent-mocks'; + +const initSentry = vi.fn(); +const closeSentry = vi.fn(() => Promise.resolve()); + +// Mutable DSN so we can test the unset case. +let mockDsn: string | undefined = 'https://k@o1.ingest.us.sentry.io/1'; +vi.mock('@/lib/monitoring/sentry', () => ({ + get SENTRY_DSN() { + return mockDsn; + }, + initSentry: () => initSentry(), + closeSentry: () => closeSentry(), +})); + +vi.mock('@/contexts/ConsentContext', () => ({ + useConsent: vi.fn(), +})); + +describe('SentryMonitor', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDsn = 'https://k@o1.ingest.us.sentry.io/1'; + }); + + it('initializes Sentry when analytics consent is granted', async () => { + vi.mocked(useConsent).mockReturnValue(createMockConsentWithAnalytics()); + render(); + await waitFor(() => expect(initSentry).toHaveBeenCalledTimes(1)); + expect(closeSentry).not.toHaveBeenCalled(); + }); + + it('closes Sentry when analytics consent is denied', async () => { + vi.mocked(useConsent).mockReturnValue(createMockConsentAllRejected()); + render(); + await waitFor(() => expect(closeSentry).toHaveBeenCalledTimes(1)); + expect(initSentry).not.toHaveBeenCalled(); + }); + + it('re-initializes when consent transitions denied → granted', async () => { + vi.mocked(useConsent).mockReturnValue(createMockConsentAllRejected()); + const { rerender } = render(); + await waitFor(() => expect(closeSentry).toHaveBeenCalled()); + + vi.mocked(useConsent).mockReturnValue(createMockConsentWithAnalytics()); + rerender(); + await waitFor(() => expect(initSentry).toHaveBeenCalledTimes(1)); + }); + + it('does nothing when no DSN is configured, even with consent', async () => { + mockDsn = undefined; + vi.mocked(useConsent).mockReturnValue(createMockConsentWithAnalytics()); + render(); + await waitFor(() => { + expect(initSentry).not.toHaveBeenCalled(); + expect(closeSentry).not.toHaveBeenCalled(); + }); + }); + + it('renders nothing', () => { + vi.mocked(useConsent).mockReturnValue(createMockConsentWithAnalytics()); + const { container } = render(); + expect(container.textContent).toBe(''); + }); +}); diff --git a/src/lib/monitoring/SentryMonitor/SentryMonitor.tsx b/src/lib/monitoring/SentryMonitor/SentryMonitor.tsx new file mode 100644 index 00000000..13f0764f --- /dev/null +++ b/src/lib/monitoring/SentryMonitor/SentryMonitor.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { useEffect } from 'react'; +import { useConsent } from '@/contexts/ConsentContext'; +import { SENTRY_DSN, initSentry, closeSentry } from '@/lib/monitoring/sentry'; + +/** + * SentryMonitor + * + * Consent-gated, client-only initializer for Sentry error monitoring. Mirrors + * the GoogleAnalytics pattern: initializes Sentry only after analytics consent + * is granted, and shuts it down if consent is withdrawn. Renders nothing. + * + * No-ops entirely when `NEXT_PUBLIC_SENTRY_DSN` is unset, so the integration + * ships inert until a DSN is configured. + * + * @category atomic + */ +export default function SentryMonitor() { + const { consent } = useConsent(); + + useEffect(() => { + if (!SENTRY_DSN) return; + + if (consent.analytics) { + initSentry(); + } else { + void closeSentry(); + } + }, [consent.analytics]); + + return null; +} diff --git a/src/lib/monitoring/SentryMonitor/index.tsx b/src/lib/monitoring/SentryMonitor/index.tsx new file mode 100644 index 00000000..220bb8a5 --- /dev/null +++ b/src/lib/monitoring/SentryMonitor/index.tsx @@ -0,0 +1 @@ +export { default } from './SentryMonitor'; diff --git a/src/lib/monitoring/scrub.test.ts b/src/lib/monitoring/scrub.test.ts new file mode 100644 index 00000000..56efbcb3 --- /dev/null +++ b/src/lib/monitoring/scrub.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from 'vitest'; +import type { Event, Breadcrumb } from '@sentry/react'; +import { scrubString, scrubEvent, scrubBreadcrumb } from './scrub'; + +const REDACTED = '[REDACTED]'; + +describe('scrubString', () => { + it('redacts email addresses', () => { + expect(scrubString('contact user@example.com now')).toBe( + `contact ${REDACTED} now` + ); + expect(scrubString('first.last+tag@sub.domain.co.uk')).toBe(REDACTED); + }); + + it('redacts JWT-shaped tokens', () => { + const jwt = + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; + expect(scrubString(`token=${jwt} end`)).toBe(`token=${REDACTED} end`); + }); + + it('redacts Bearer tokens but keeps the scheme word', () => { + expect(scrubString('Authorization: Bearer abc.def-ghi_123')).toBe( + `Authorization: Bearer ${REDACTED}` + ); + }); + + it('redacts access_token / refresh_token key-value pairs, keeping the key', () => { + expect(scrubString('access_token=secretvalue123&x=1')).toBe( + `access_token=${REDACTED}&x=1` + ); + expect(scrubString('"refresh_token":"abc-def-ghi"')).toBe( + `"refresh_token":"${REDACTED}"` + ); + }); + + it('redacts Supabase auth localStorage keys', () => { + expect(scrubString('key sb-huvitqubafsrazpjxsax-auth-token here')).toBe( + `key ${REDACTED} here` + ); + }); + + it('leaves non-sensitive strings untouched', () => { + expect(scrubString('a perfectly normal error message')).toBe( + 'a perfectly normal error message' + ); + expect(scrubString('')).toBe(''); + }); +}); + +describe('scrubEvent', () => { + it('scrubs event.message', () => { + const ev: Event = { message: 'failed for user@example.com' }; + expect(scrubEvent(ev)?.message).toBe(`failed for ${REDACTED}`); + }); + + it('scrubs exception values', () => { + const ev: Event = { + exception: { + values: [{ type: 'Error', value: 'login failed: admin@corp.com' }], + }, + }; + expect(scrubEvent(ev)?.exception?.values?.[0].value).toBe( + `login failed: ${REDACTED}` + ); + }); + + it('whole-value-redacts sensitive object keys regardless of content', () => { + const ev: Event = { + extra: { + content: 'this is a decrypted private message', + plaintext_content: 'another secret', + new_content: 'edited secret', + encrypted_content: 'base64ciphertext==', + password: 'hunter2', + harmless: 'keep me', + }, + }; + const out = scrubEvent(ev)!; + expect(out.extra?.content).toBe(REDACTED); + expect(out.extra?.plaintext_content).toBe(REDACTED); + expect(out.extra?.new_content).toBe(REDACTED); + expect(out.extra?.encrypted_content).toBe(REDACTED); + expect(out.extra?.password).toBe(REDACTED); + expect(out.extra?.harmless).toBe('keep me'); + }); + + it('recurses into nested objects and arrays', () => { + const ev: Event = { + extra: { + nested: { deep: { email: 'x@y.com', list: ['a@b.com', 'fine'] } }, + }, + }; + const out = scrubEvent(ev)!; + const nested = out.extra?.nested as Record; + const deep = nested.deep as Record; + expect(deep.email).toBe(REDACTED); + expect(deep.list).toEqual([REDACTED, 'fine']); + }); + + it('scrubs request fields', () => { + const ev: Event = { + request: { + url: 'https://app/path?email=a@b.com', + headers: { Authorization: 'Bearer secrettoken' }, + }, + }; + const out = scrubEvent(ev)!; + expect(out.request?.url).toContain(REDACTED); + expect((out.request?.headers as Record).Authorization).toBe( + `Bearer ${REDACTED}` + ); + }); + + it('scrubs breadcrumbs', () => { + const ev: Event = { + breadcrumbs: [ + { message: 'navigated as user@example.com', data: { password: 'p' } }, + ], + }; + const out = scrubEvent(ev)!; + expect(out.breadcrumbs?.[0].message).toBe(`navigated as ${REDACTED}`); + expect( + (out.breadcrumbs?.[0].data as Record).password + ).toBe(REDACTED); + }); + + it('preserves primitive/null values and never throws on empty events', () => { + expect(() => scrubEvent({})).not.toThrow(); + const ev: Event = { + extra: { n: 42, b: true, z: null, u: undefined }, + }; + const out = scrubEvent(ev)!; + expect(out.extra?.n).toBe(42); + expect(out.extra?.b).toBe(true); + expect(out.extra?.z).toBeNull(); + }); + + it('returns the same event reference (mutated in place)', () => { + const ev: Event = { message: 'fine' }; + expect(scrubEvent(ev)).toBe(ev); + }); +}); + +describe('scrubBreadcrumb', () => { + it('scrubs message and data', () => { + const b: Breadcrumb = { + message: 'token=Bearer xyz.abc', + data: { content: 'private', ok: 'visible' }, + }; + const out = scrubBreadcrumb(b); + expect(out.message).toContain(REDACTED); + expect((out.data as Record).content).toBe(REDACTED); + expect((out.data as Record).ok).toBe('visible'); + }); +}); diff --git a/src/lib/monitoring/scrub.ts b/src/lib/monitoring/scrub.ts new file mode 100644 index 00000000..77500c2f --- /dev/null +++ b/src/lib/monitoring/scrub.ts @@ -0,0 +1,123 @@ +/** + * PII scrubber for Sentry events. + * + * Pure, SDK-free transforms (only Sentry *types* are imported, which erase at + * build time) so the logic is exhaustively unit-testable without initializing + * the SDK. Used as Sentry's `beforeSend` / `beforeBreadcrumb` so no sensitive + * data ever leaves the browser: + * - emails, JWTs, Bearer tokens, access/refresh-token pairs, Supabase + * `sb--auth-token` localStorage keys (string-level regex) + * - decrypted message bodies + secrets, by whole-value redaction of known + * sensitive object keys (`content`, `plaintext_content`, `new_content`, + * `encrypted_content`, `password`) — these may not match any regex, so the + * whole value is dropped. + * + * Every transform is defensive: a throw inside `beforeSend` silently drops the + * event, so this must never throw on a missing/oddly-shaped field. + */ + +import type { Event, Breadcrumb } from '@sentry/react'; + +const REDACTED = '[REDACTED]'; + +// Run key/token patterns BEFORE the broad email pattern so partial matches in +// tokens don't get half-redacted by the email regex first. +const SB_AUTH_KEY_RE = /sb-[a-z0-9]+-auth-token/gi; +const JWT_RE = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g; +const BEARER_RE = /\bBearer\s+[A-Za-z0-9._-]+/gi; +const TOKEN_KV_RE = + /("?(?:access_token|refresh_token)"?\s*[:=]\s*"?)[^"\s,&]+/gi; +const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g; + +// Object keys whose VALUE is always sensitive (plaintext message bodies, +// ciphertext, secrets) — redacted wholesale regardless of value shape. +const SENSITIVE_KEYS = new Set([ + 'content', + 'plaintext_content', + 'new_content', + 'encrypted_content', + 'password', +]); + +/** Redact PII patterns from a single string. */ +export function scrubString(input: string): string { + if (typeof input !== 'string' || input.length === 0) return input; + return input + .replace(SB_AUTH_KEY_RE, REDACTED) + .replace(JWT_RE, REDACTED) + .replace(BEARER_RE, `Bearer ${REDACTED}`) + .replace(TOKEN_KV_RE, `$1${REDACTED}`) + .replace(EMAIL_RE, REDACTED); +} + +function scrubValue(value: unknown): unknown { + if (typeof value === 'string') return scrubString(value); + if (Array.isArray(value)) return value.map(scrubValue); + if (value && typeof value === 'object') { + return scrubObject(value as Record); + } + return value; +} + +function scrubObject(obj: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(obj)) { + out[key] = SENSITIVE_KEYS.has(key) ? REDACTED : scrubValue(value); + } + return out; +} + +/** Scrub a Sentry breadcrumb's message and data in place. */ +export function scrubBreadcrumb(crumb: Breadcrumb): Breadcrumb { + if (typeof crumb.message === 'string') { + crumb.message = scrubString(crumb.message); + } + if (crumb.data && typeof crumb.data === 'object') { + crumb.data = scrubObject(crumb.data as Record); + } + return crumb; +} + +/** + * Sentry `beforeSend` hook. Scrubs every field that can carry user data, then + * returns the (mutated) event. Returns `null` only if Sentry passes null. + */ +export function scrubEvent(event: Event): Event | null { + if (!event) return event; + + if (typeof event.message === 'string') { + event.message = scrubString(event.message); + } + + event.exception?.values?.forEach((ex) => { + if (typeof ex.value === 'string') ex.value = scrubString(ex.value); + }); + + if (event.request && typeof event.request === 'object') { + event.request = scrubObject( + event.request as unknown as Record + ) as unknown as Event['request']; + } + + if (event.extra && typeof event.extra === 'object') { + event.extra = scrubObject(event.extra); + } + + if (event.contexts && typeof event.contexts === 'object') { + event.contexts = scrubObject( + event.contexts as Record + ) as Event['contexts']; + } + + if (event.tags && typeof event.tags === 'object') { + event.tags = scrubObject( + event.tags as Record + ) as Event['tags']; + } + + if (Array.isArray(event.breadcrumbs)) { + event.breadcrumbs = event.breadcrumbs.map(scrubBreadcrumb); + } + + return event; +} diff --git a/src/lib/monitoring/sentry.test.ts b/src/lib/monitoring/sentry.test.ts new file mode 100644 index 00000000..9bc897f4 --- /dev/null +++ b/src/lib/monitoring/sentry.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock the SDK; the scrub fn is real (covered by scrub.test.ts). +const init = vi.fn(); +const captureException = vi.fn(); +const close = vi.fn(() => Promise.resolve(true)); +const getClient = vi.fn(() => ({ close })); + +vi.mock('@sentry/react', () => ({ + init: (...a: unknown[]) => init(...a), + captureException: (...a: unknown[]) => captureException(...a), + getClient: () => getClient(), +})); + +// `SENTRY_DSN` is read at module load, so each test re-imports after setting env. +async function freshModule() { + vi.resetModules(); + return import('./sentry'); +} + +const DSN = 'https://abc123@o1.ingest.us.sentry.io/42'; + +describe('sentry module', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.NEXT_PUBLIC_SENTRY_DSN; + }); + + afterEach(() => { + delete process.env.NEXT_PUBLIC_SENTRY_DSN; + }); + + it('does NOT init and reports disabled when DSN is empty', async () => { + const m = await freshModule(); + m.initSentry(); + expect(init).not.toHaveBeenCalled(); + expect(m.isSentryEnabled()).toBe(false); + }); + + it('captureAppError is a no-op when not enabled', async () => { + const m = await freshModule(); + m.captureAppError(new Error('boom')); + expect(captureException).not.toHaveBeenCalled(); + }); + + it('inits exactly once when DSN is set (idempotent)', async () => { + process.env.NEXT_PUBLIC_SENTRY_DSN = DSN; + const m = await freshModule(); + m.initSentry(); + m.initSentry(); + expect(init).toHaveBeenCalledTimes(1); + expect(m.isSentryEnabled()).toBe(true); + // Replay/tracing must be disabled and beforeSend wired. + const cfg = init.mock.calls[0][0] as Record; + expect(cfg.dsn).toBe(DSN); + expect(cfg.tracesSampleRate).toBe(0); + expect(cfg.replaysSessionSampleRate).toBe(0); + expect(cfg.sendDefaultPii).toBe(false); + expect(typeof cfg.beforeSend).toBe('function'); + expect(cfg.integrations).toEqual([]); + }); + + it('captureAppError forwards to Sentry when enabled, with extra context', async () => { + process.env.NEXT_PUBLIC_SENTRY_DSN = DSN; + const m = await freshModule(); + m.initSentry(); + const err = new Error('kaboom'); + m.captureAppError(err, { category: 'system' }); + expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException.mock.calls[0][0]).toBe(err); + expect(captureException.mock.calls[0][1]).toEqual({ + extra: { category: 'system' }, + }); + }); + + it('closeSentry closes the client and flips enabled to false; re-init works', async () => { + process.env.NEXT_PUBLIC_SENTRY_DSN = DSN; + const m = await freshModule(); + m.initSentry(); + expect(m.isSentryEnabled()).toBe(true); + await m.closeSentry(); + expect(close).toHaveBeenCalled(); + expect(m.isSentryEnabled()).toBe(false); + // Re-consent → re-init creates a fresh client. + m.initSentry(); + expect(init).toHaveBeenCalledTimes(2); + expect(m.isSentryEnabled()).toBe(true); + }); + + it('beforeSend scrubs PII out of the event', async () => { + process.env.NEXT_PUBLIC_SENTRY_DSN = DSN; + const m = await freshModule(); + m.initSentry(); + const cfg = init.mock.calls[0][0] as { + beforeSend: (e: unknown) => unknown; + }; + const scrubbed = cfg.beforeSend({ + message: 'failure for user@example.com', + }) as { message: string }; + expect(scrubbed.message).toBe('failure for [REDACTED]'); + }); +}); diff --git a/src/lib/monitoring/sentry.ts b/src/lib/monitoring/sentry.ts new file mode 100644 index 00000000..600e8671 --- /dev/null +++ b/src/lib/monitoring/sentry.ts @@ -0,0 +1,79 @@ +/** + * Sentry client-only error monitoring. + * + * The app is a Next.js static export (GitHub Pages) — `@sentry/nextjs`, + * `withSentryConfig`, and `instrumentation.ts` all require a Next server, so we + * use the browser SDK directly (`@sentry/react` re-exports `@sentry/browser`) + * and initialize purely on the client, mirroring the consent-gated + * GoogleAnalytics integration. + * + * Initialization is: + * - a no-op when `NEXT_PUBLIC_SENTRY_DSN` is empty (so the integration ships + * inert until a DSN is provided — zero code change to go live); + * - gated on analytics consent by the `SentryMonitor` component; + * - idempotent, and reversible via `closeSentry()` on consent withdrawal. + * + * Replay and performance tracing are explicitly disabled so the SDK never + * records the E2E-encrypted messaging UI, and every outgoing event is run + * through `scrubEvent` (see scrub.ts) to strip PII. + */ + +import * as Sentry from '@sentry/react'; +import { scrubEvent } from './scrub'; + +/** Sentry DSN — public client key, safe in the browser. Empty = disabled. */ +export const SENTRY_DSN = process.env.NEXT_PUBLIC_SENTRY_DSN; + +let initialized = false; + +/** True once Sentry has been initialized with a usable DSN. */ +export function isSentryEnabled(): boolean { + return initialized && !!SENTRY_DSN; +} + +/** Initialize Sentry. Safe to call repeatedly; no-ops without a DSN or on SSR. */ +export function initSentry(): void { + if (typeof window === 'undefined') return; // never on the server/SSG + if (initialized) return; // idempotent + if (!SENTRY_DSN) return; // ship inert until a DSN exists + + Sentry.init({ + dsn: SENTRY_DSN, + environment: process.env.NODE_ENV, + // Static export on GitHub Pages: no source maps, no server. Opt OUT of all + // default integrations (Replay, BrowserTracing) — Replay would record the + // encrypted messaging UI, which we must never capture. + integrations: [], + tracesSampleRate: 0, + replaysSessionSampleRate: 0, + replaysOnErrorSampleRate: 0, + sendDefaultPii: false, + // scrubEvent operates on the broader Event type and mutates in place, + // returning the same reference — safe to hand back as the ErrorEvent. + beforeSend: (event) => scrubEvent(event) as typeof event | null, + }); + initialized = true; +} + +/** + * Flush and shut down Sentry (called when analytics consent is withdrawn). + * Resets state so a later re-consent re-initializes a fresh client. + */ +export async function closeSentry(): Promise { + if (!initialized) return; + const client = Sentry.getClient(); + if (client) await client.close(2000); + initialized = false; +} + +/** + * Capture an error. No-ops unless Sentry is initialized (consent + DSN), so it + * is safe to call unconditionally from the central error handler. + */ +export function captureAppError( + error: Error, + context?: Record +): void { + if (!isSentryEnabled()) return; + Sentry.captureException(error, context ? { extra: context } : undefined); +} diff --git a/src/utils/error-handler.test.ts b/src/utils/error-handler.test.ts new file mode 100644 index 00000000..b8643d3d --- /dev/null +++ b/src/utils/error-handler.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock the external sinks so we can assert the Sentry chokepoint behavior. +const captureAppError = vi.fn(); +vi.mock('@/lib/monitoring/sentry', () => ({ + captureAppError: (...a: unknown[]) => captureAppError(...a), +})); +vi.mock('@/utils/analytics', () => ({ trackError: vi.fn() })); + +import errorHandler, { + AppError, + ErrorCategory, + ErrorSeverity, +} from './error-handler'; + +describe('errorHandler → Sentry chokepoint', () => { + beforeEach(() => { + vi.clearAllMocks(); + errorHandler.clearQueue(); + }); + + it('captures exactly once per handled error, preferring the original Error', () => { + const original = new Error('db down'); + const appError = new AppError('wrapped', { + category: ErrorCategory.SYSTEM, + severity: ErrorSeverity.HIGH, + context: { route: '/x' }, + originalError: original, + }); + + errorHandler.handle(appError); + + expect(captureAppError).toHaveBeenCalledTimes(1); + expect(captureAppError.mock.calls[0][0]).toBe(original); + expect(captureAppError.mock.calls[0][1]).toMatchObject({ + category: ErrorCategory.SYSTEM, + severity: ErrorSeverity.HIGH, + route: '/x', + }); + }); + + it('falls back to the AppError itself when there is no originalError', () => { + const appError = new AppError('no original', { + category: ErrorCategory.VALIDATION, + severity: ErrorSeverity.LOW, + }); + + errorHandler.handle(appError); + + expect(captureAppError).toHaveBeenCalledTimes(1); + expect(captureAppError.mock.calls[0][0]).toBe(appError); + }); +}); diff --git a/src/utils/error-handler.ts b/src/utils/error-handler.ts index 7bb12072..65249df4 100644 --- a/src/utils/error-handler.ts +++ b/src/utils/error-handler.ts @@ -4,6 +4,7 @@ */ import { trackError } from '@/utils/analytics'; +import { captureAppError } from '@/lib/monitoring/sentry'; import { createLogger } from '@/lib/logger'; const logger = createLogger('utils:errorHandler'); @@ -233,6 +234,16 @@ class ErrorHandler { // Track to analytics trackError(errorMessage, isFatal); + // Capture to Sentry (no-op unless initialized — i.e. analytics consent + + // a configured DSN). This is the single capture chokepoint; every handled + // error (including those routed here by ErrorBoundary) reports exactly + // once. Prefer the original Error for a meaningful stack/fingerprint. + captureAppError(error.originalError ?? error, { + category: error.category, + severity: error.severity, + ...error.context, + }); + if (this.config.isDevelopment) { logger.debug('Error tracked to analytics', { message: error.message,