From d348cb917927bee38a913fef0cb9a2e0767ac67b Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Thu, 7 May 2026 20:14:56 +0200 Subject: [PATCH] feat(cms): Sentry server instrumentation + withSentryConfig + R38 PII test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds apps/cms/instrumentation.ts (server-only — Payload admin client DSN is out-of-scope per spec §8). Wraps the Payload-wrapped next config with withSentryConfig. Adds the R38 PII scrubber test. Required adding @repo/core-shared as a direct dep of cms (was only transitive before). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cms/instrumentation.ts | 20 +++++++ apps/cms/next.config.mjs | 10 +++- apps/cms/package.json | 2 + .../src/__tests__/sentry-pii-scrubber.test.ts | 56 +++++++++++++++++++ pnpm-lock.yaml | 6 ++ 5 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 apps/cms/instrumentation.ts create mode 100644 apps/cms/src/__tests__/sentry-pii-scrubber.test.ts diff --git a/apps/cms/instrumentation.ts b/apps/cms/instrumentation.ts new file mode 100644 index 0000000..9702a61 --- /dev/null +++ b/apps/cms/instrumentation.ts @@ -0,0 +1,20 @@ +// apps/cms/instrumentation.ts +// CMS is server-only (Payload admin UI). No instrumentation-client.ts here — +// Payload admin UI bundling is opinionated and the public DSN flow is +// out-of-scope per spec §8. + +export async function register() { + if ( + process.env["NEXT_RUNTIME"] === "nodejs" || + process.env["NEXT_RUNTIME"] === "edge" + ) { + const { initSentryServer } = await import( + "@repo/core-shared/instrumentation/sentry/init-server" + ); + initSentryServer({ + dsn: process.env["CMS_SENTRY_DSN"], + app: "cms", + release: process.env["VERCEL_GIT_COMMIT_SHA"], + }); + } +} diff --git a/apps/cms/next.config.mjs b/apps/cms/next.config.mjs index abee3e0..6054f5d 100644 --- a/apps/cms/next.config.mjs +++ b/apps/cms/next.config.mjs @@ -1,6 +1,14 @@ import { withPayload } from "@payloadcms/next/withPayload"; +import { withSentryConfig } from "@sentry/nextjs"; /** @type {import('next').NextConfig} */ const nextConfig = {}; -export default withPayload(nextConfig); +export default withSentryConfig(withPayload(nextConfig), { + silent: process.env.CI !== "true", + authToken: process.env.SENTRY_AUTH_TOKEN, + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT_CMS, + hideSourceMaps: true, + disableLogger: true, +}); diff --git a/apps/cms/package.json b/apps/cms/package.json index 1acc65e..7c71b17 100644 --- a/apps/cms/package.json +++ b/apps/cms/package.json @@ -16,6 +16,8 @@ "@payloadcms/richtext-lexical": "^3.14.0", "@payloadcms/ui": "^3.14.0", "@repo/core-cms": "workspace:*", + "@repo/core-shared": "workspace:*", + "@sentry/nextjs": "^10.51.0", "next": "^15.3.0", "payload": "^3.14.0", "react": "^19.0.0", diff --git a/apps/cms/src/__tests__/sentry-pii-scrubber.test.ts b/apps/cms/src/__tests__/sentry-pii-scrubber.test.ts new file mode 100644 index 0000000..e8e45ef --- /dev/null +++ b/apps/cms/src/__tests__/sentry-pii-scrubber.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { + beforeSend, + beforeSendTransaction, +} from "@repo/core-shared/instrumentation/sentry/scrub"; + +describe("R38 — apps/cms PII scrubber", () => { + it("strips email/password/cookie/auth/IP from event payload", () => { + const event = { + extra: { + userEmail: "alice@example.com", + password: "p4$$w0rd", + ipAddress: "192.168.1.10", + note: "request from 10.0.0.1", + }, + request: { + headers: { + Authorization: "Bearer secret", + "Set-Cookie": "session=abc", + "User-Agent": "Mozilla", + }, + }, + } as Parameters[0]; + const result = beforeSend(event, {}) as { + extra: Record; + request: { headers: Record }; + }; + expect(result.extra["userEmail"]).toBe("[redacted]"); + expect(result.extra["password"]).toBe("[redacted]"); + expect(result.extra["ipAddress"]).toBe("[redacted]"); + expect(result.extra["note"]).toContain("[redacted-ip]"); + expect(result.request.headers["Authorization"]).toBe("[redacted]"); + expect(result.request.headers["Set-Cookie"]).toBe("[redacted]"); + expect(result.request.headers["User-Agent"]).toBe("Mozilla"); + }); + + it("strips ?token / ?email / ?password / ?secret / ?signature from URLs", () => { + const event = { + request: { + url: "https://app/api/x?token=abc&email=a@b.c&password=p&secret=z&signature=s&safe=1", + }, + transaction: "/foo?accessToken=t", + } as Parameters[0]; + const result = beforeSendTransaction(event, {}) as { + request: { url: string }; + transaction: string; + }; + const url = decodeURIComponent(result.request.url); + const txn = decodeURIComponent(result.transaction); + for (const key of ["token", "email", "password", "secret", "signature"]) { + expect(url).toContain(`${key}=[redacted]`); + } + expect(url).toContain("safe=1"); + expect(txn).toContain("accessToken=[redacted]"); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49f2a45..b13090f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,12 @@ importers: '@repo/core-cms': specifier: workspace:* version: link:../../packages/core-cms + '@repo/core-shared': + specifier: workspace:* + version: link:../../packages/core-shared + '@sentry/nextjs': + specifier: ^10.51.0 + version: 10.51.0(@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.1))(next@15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0))(react@19.2.4)(webpack@5.106.2) next: specifier: ^15.3.0 version: 15.5.14(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.99.0)