From 654d92f97343053a51f854b888d358fd85e497c5 Mon Sep 17 00:00:00 2001 From: Shannon Anahata Date: Wed, 12 Aug 2026 14:35:38 -0700 Subject: [PATCH 1/3] feat(middleware): redirect production build URLs to canonical host Option B for contractor preview access: instead of gating previews with Vercel Deployment Protection, keep previews publicly accessible and lock down only the generated *production* build URLs in middleware. With Deployment Protection off, generated production URLs (e.g. sentry-docs-.vercel.app) would be publicly accessible and indexable as a separate copy of the site. This adds an early check in the existing middleware that, on production deployments only, 308-redirects any request whose host isn't the canonical domain (docs.sentry.io / develop.sentry.dev) to that domain, preserving path and query. - Preview deployments are untouched and stay publicly accessible, so contractors open PR previews with zero extra steps (no share links). - Only GET requests are redirected; API/cron/webhook traffic is unaffected. - VERCEL_ENV is inlined into the edge bundle via next.config.ts `env` (edge runtime can't read server env vars at request time). - Complements the existing X-Robots-Tag: noindex on non-canonical hosts. Requires turning OFF Vercel Standard Protection so previews are reachable. Adds 7 middleware tests (21 total passing). --- middleware.test.ts | 94 +++++++++++++++++++++++++++++++++++++++++++--- middleware.ts | 46 +++++++++++++++++++++++ next.config.ts | 4 ++ 3 files changed, 138 insertions(+), 6 deletions(-) diff --git a/middleware.test.ts b/middleware.test.ts index 8e96b9d2a5163..0689bce024c9c 100644 --- a/middleware.test.ts +++ b/middleware.test.ts @@ -100,6 +100,82 @@ describe('middleware redirect set selection', () => { }); }); +describe('production build-url redirect to canonical (Deployment Protection off)', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + function makeHostRequest(url: string, method = 'GET'): NextRequest { + return new NextRequest(new URL(url), {method}); + } + + it('redirects a non-canonical production host to the canonical host', async () => { + const {middleware} = await importMiddleware({}); + vi.stubEnv('VERCEL_ENV', 'production'); + const res = middleware( + makeHostRequest('https://sentry-docs-abc123.vercel.app/platforms/javascript/') + ); + expect(res.status).toBe(308); + expect(res.headers.get('location')).toBe( + 'https://docs.sentry.io/platforms/javascript/' + ); + }); + + it('preserves query strings when redirecting', async () => { + const {middleware} = await importMiddleware({}); + vi.stubEnv('VERCEL_ENV', 'production'); + const res = middleware( + makeHostRequest('https://sentry-docs-git-master-getsentry.vercel.app/search/?q=x') + ); + expect(res.status).toBe(308); + expect(res.headers.get('location')).toBe('https://docs.sentry.io/search/?q=x'); + }); + + it('redirects to develop.sentry.dev in developer docs mode', async () => { + const {middleware} = await importMiddleware({NEXT_PUBLIC_DEVELOPER_DOCS: '1'}); + vi.stubEnv('VERCEL_ENV', 'production'); + const res = middleware( + makeHostRequest('https://develop-docs-abc123.vercel.app/getting-started/') + ); + expect(res.status).toBe(308); + expect(res.headers.get('location')).toBe( + 'https://develop.sentry.dev/getting-started/' + ); + }); + + it('does not redirect requests already on the canonical host', async () => { + const {middleware} = await importMiddleware({}); + vi.stubEnv('VERCEL_ENV', 'production'); + const res = middleware(makeHostRequest('https://docs.sentry.io/platforms/')); + expect(res.status).not.toBe(308); + }); + + it('leaves preview deployments accessible (no redirect)', async () => { + const {middleware} = await importMiddleware({}); + vi.stubEnv('VERCEL_ENV', 'preview'); + const res = middleware( + makeHostRequest('https://sentry-docs-git-my-branch.sentry.dev/getting-started/') + ); + expect(res.status).not.toBe(308); + }); + + it('does nothing locally (VERCEL_ENV unset)', async () => { + const {middleware} = await importMiddleware({}); + vi.stubEnv('VERCEL_ENV', ''); + const res = middleware(makeHostRequest('https://sentry-docs-abc123.vercel.app/foo/')); + expect(res.status).not.toBe(308); + }); + + it('does not redirect non-GET requests', async () => { + const {middleware} = await importMiddleware({}); + vi.stubEnv('VERCEL_ENV', 'production'); + const res = middleware( + makeHostRequest('https://sentry-docs-abc123.vercel.app/foo/', 'POST') + ); + expect(res.status).not.toBe(308); + }); +}); + describe('canonical Link header on .md responses', () => { afterEach(() => { vi.unstubAllEnvs(); @@ -145,9 +221,12 @@ describe('canonical Link header on .md responses', () => { describe('wantsMarkdownViaAccept: text/plain no longer triggers markdown', () => { it('does not serve markdown for Accept: application/json, text/plain, */*', async () => { const {middleware} = await importMiddleware({}); - const req = new NextRequest(new URL('/platforms/apple/cocoa/', 'http://localhost:3000'), { - headers: {'Accept': 'application/json, text/plain, */*'}, - }); + const req = new NextRequest( + new URL('/platforms/apple/cocoa/', 'http://localhost:3000'), + { + headers: {Accept: 'application/json, text/plain, */*'}, + } + ); const res = middleware(req); // Should not rewrite to .md — Next rewrite changes the URL; a plain next() keeps it expect(res.headers.get('x-middleware-rewrite')).toBeNull(); @@ -155,9 +234,12 @@ describe('wantsMarkdownViaAccept: text/plain no longer triggers markdown', () => it('still serves markdown for explicit text/markdown Accept header', async () => { const {middleware} = await importMiddleware({}); - const req = new NextRequest(new URL('/platforms/apple/cocoa/', 'http://localhost:3000'), { - headers: {'Accept': 'text/markdown'}, - }); + const req = new NextRequest( + new URL('/platforms/apple/cocoa/', 'http://localhost:3000'), + { + headers: {Accept: 'text/markdown'}, + } + ); const res = middleware(req); // A rewrite to .md will set x-middleware-rewrite expect(res.headers.get('x-middleware-rewrite')).toContain('.md'); diff --git a/middleware.ts b/middleware.ts index 5fcf32699f2c0..127238d9ecea1 100644 --- a/middleware.ts +++ b/middleware.ts @@ -16,6 +16,11 @@ const BASE_URL = isDeveloperDocs ? 'https://develop.sentry.dev' : 'https://docs.sentry.io'; +// The canonical, publicly-served production hostname for this docs site. On +// production deployments, any other host is a Vercel-generated build URL +// (e.g. sentry-docs-.vercel.app) that we redirect to this host. +const CANONICAL_HOST = new URL(BASE_URL).hostname; + // Production domains whose content should be indexable by search engines. // All other hostnames (Vercel preview/deployment URLs, old production deployments) // get X-Robots-Tag: noindex to prevent search engines from indexing stale content. @@ -35,6 +40,16 @@ export const config = { // This function can be marked `async` if using `await` inside export function middleware(request: NextRequest) { + // Lock down Vercel-generated *production* build URLs. Deployment Protection is + // off, so generated production URLs (e.g. sentry-docs-.vercel.app) would + // otherwise be publicly accessible and indexable as a separate copy of the + // site. Redirect them to the canonical domain. Preview deployments are left + // untouched and publicly accessible, so contractors can open PR previews. + const buildUrlRedirect = redirectProductionBuildUrlToCanonical(request); + if (buildUrlRedirect) { + return buildUrlRedirect; + } + // Classify once per request and record it as a counter. This metric — not // trace sampling — is the source of truth for agent/bot/user traffic: the // middleware root span is created by Next.js before any request data reaches @@ -55,6 +70,37 @@ export function middleware(request: NextRequest) { return response; } +/** + * On production deployments, redirects any request whose host isn't the + * canonical domain to the canonical domain (preserving path + query). This + * keeps Vercel-generated production build URLs from being crawled, indexed, or + * used as a separate copy of the site. + * + * Returns null (no redirect) when: + * - not a production deployment (previews stay publicly accessible), + * - not a GET request (avoid interfering with API/cron/webhook traffic), or + * - the request is already on the canonical host. + * + * VERCEL_ENV is inlined at build time (see next.config.ts `env`) because the + * edge runtime can't read server env vars at request time; it's constant per + * deployment, so build-time inlining is correct. + */ +function redirectProductionBuildUrlToCanonical( + request: NextRequest +): NextResponse | null { + if (process.env.VERCEL_ENV !== 'production' || request.method !== 'GET') { + return null; + } + if (request.nextUrl.hostname === CANONICAL_HOST) { + return null; + } + const url = request.nextUrl.clone(); + url.protocol = 'https:'; + url.host = CANONICAL_HOST; + url.port = ''; + return NextResponse.redirect(url, 308); +} + /** * Adds X-Robots-Tag: noindex to responses served from non-production domains. * This prevents search engines from indexing stale Vercel deployment URLs diff --git a/next.config.ts b/next.config.ts index 4662d206569fe..8ff73d7c99fcd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -141,6 +141,10 @@ const nextConfig = { // Inline NEXT_PUBLIC_DEVELOPER_DOCS into edge middleware at build time. // Edge runtime doesn't have access to server env vars at request time. DEVELOPER_DOCS: process.env.NEXT_PUBLIC_DEVELOPER_DOCS, + // Inline VERCEL_ENV for the same reason: middleware.ts uses it to decide + // whether a deployment is production. It is constant per deployment, so + // build-time inlining is correct. + VERCEL_ENV: process.env.VERCEL_ENV, }, redirects, rewrites: () => [ From 9a835dce698e4f0a7d4c18a3f217577886cad2cb Mon Sep 17 00:00:00 2001 From: Shannon Anahata Date: Wed, 12 Aug 2026 15:01:55 -0700 Subject: [PATCH 2/3] docs(middleware): clarify VERCEL_ENV is an intentional build-time freeze Reword the next.config.ts and middleware.ts comments to describe the VERCEL_ENV `env` entry as deliberate build-time freezing of a per-deployment-constant value (following the DEVELOPER_DOCS convention), rather than claiming the Edge runtime can't read server env vars. Vercel exposes VERCEL_ENV at build and runtime when "Automatically expose System Environment Variables" is enabled (the default); the freeze is a stability choice, not a workaround. No behavior change. --- middleware.ts | 7 ++++--- next.config.ts | 10 +++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/middleware.ts b/middleware.ts index 127238d9ecea1..d970e858da4b5 100644 --- a/middleware.ts +++ b/middleware.ts @@ -81,9 +81,10 @@ export function middleware(request: NextRequest) { * - not a GET request (avoid interfering with API/cron/webhook traffic), or * - the request is already on the canonical host. * - * VERCEL_ENV is inlined at build time (see next.config.ts `env`) because the - * edge runtime can't read server env vars at request time; it's constant per - * deployment, so build-time inlining is correct. + * VERCEL_ENV is frozen into the bundle at build time (see next.config.ts + * `env`) so this has a stable value; it is constant per deployment, so that is + * correct. Requires "Automatically expose System Environment Variables" to be + * enabled on the Vercel project (the default). */ function redirectProductionBuildUrlToCanonical( request: NextRequest diff --git a/next.config.ts b/next.config.ts index 8ff73d7c99fcd..c448e367b9c97 100644 --- a/next.config.ts +++ b/next.config.ts @@ -141,9 +141,13 @@ const nextConfig = { // Inline NEXT_PUBLIC_DEVELOPER_DOCS into edge middleware at build time. // Edge runtime doesn't have access to server env vars at request time. DEVELOPER_DOCS: process.env.NEXT_PUBLIC_DEVELOPER_DOCS, - // Inline VERCEL_ENV for the same reason: middleware.ts uses it to decide - // whether a deployment is production. It is constant per deployment, so - // build-time inlining is correct. + // Freeze VERCEL_ENV into the bundle at build time so middleware.ts has a + // stable value for detecting production deployments. VERCEL_ENV is constant + // per deployment, so build-time freezing is intentional and correct; it + // follows the DEVELOPER_DOCS pattern above. (Vercel also exposes VERCEL_ENV + // at runtime when "Automatically expose System Environment Variables" is + // enabled — the default — so this entry is a deliberate freeze, not a + // workaround for missing runtime access.) VERCEL_ENV: process.env.VERCEL_ENV, }, redirects, From 126f7395f05a24f27670fec850481001b867efb7 Mon Sep 17 00:00:00 2001 From: Shannon Anahata Date: Wed, 12 Aug 2026 15:10:25 -0700 Subject: [PATCH 3/3] fix(middleware): redirect HEAD requests to canonical host --- middleware.test.ts | 12 +++++++++++- middleware.ts | 30 +++++------------------------- next.config.ts | 8 +------- 3 files changed, 17 insertions(+), 33 deletions(-) diff --git a/middleware.test.ts b/middleware.test.ts index 0689bce024c9c..6e8f6e7763517 100644 --- a/middleware.test.ts +++ b/middleware.test.ts @@ -166,7 +166,17 @@ describe('production build-url redirect to canonical (Deployment Protection off) expect(res.status).not.toBe(308); }); - it('does not redirect non-GET requests', async () => { + it('redirects HEAD requests', async () => { + const {middleware} = await importMiddleware({}); + vi.stubEnv('VERCEL_ENV', 'production'); + const res = middleware( + makeHostRequest('https://sentry-docs-abc123.vercel.app/foo/', 'HEAD') + ); + expect(res.status).toBe(308); + expect(res.headers.get('location')).toBe('https://docs.sentry.io/foo/'); + }); + + it('does not redirect non-page requests', async () => { const {middleware} = await importMiddleware({}); vi.stubEnv('VERCEL_ENV', 'production'); const res = middleware( diff --git a/middleware.ts b/middleware.ts index d970e858da4b5..222e1a94ef5ac 100644 --- a/middleware.ts +++ b/middleware.ts @@ -16,9 +16,6 @@ const BASE_URL = isDeveloperDocs ? 'https://develop.sentry.dev' : 'https://docs.sentry.io'; -// The canonical, publicly-served production hostname for this docs site. On -// production deployments, any other host is a Vercel-generated build URL -// (e.g. sentry-docs-.vercel.app) that we redirect to this host. const CANONICAL_HOST = new URL(BASE_URL).hostname; // Production domains whose content should be indexable by search engines. @@ -40,11 +37,6 @@ export const config = { // This function can be marked `async` if using `await` inside export function middleware(request: NextRequest) { - // Lock down Vercel-generated *production* build URLs. Deployment Protection is - // off, so generated production URLs (e.g. sentry-docs-.vercel.app) would - // otherwise be publicly accessible and indexable as a separate copy of the - // site. Redirect them to the canonical domain. Preview deployments are left - // untouched and publicly accessible, so contractors can open PR previews. const buildUrlRedirect = redirectProductionBuildUrlToCanonical(request); if (buildUrlRedirect) { return buildUrlRedirect; @@ -70,26 +62,14 @@ export function middleware(request: NextRequest) { return response; } -/** - * On production deployments, redirects any request whose host isn't the - * canonical domain to the canonical domain (preserving path + query). This - * keeps Vercel-generated production build URLs from being crawled, indexed, or - * used as a separate copy of the site. - * - * Returns null (no redirect) when: - * - not a production deployment (previews stay publicly accessible), - * - not a GET request (avoid interfering with API/cron/webhook traffic), or - * - the request is already on the canonical host. - * - * VERCEL_ENV is frozen into the bundle at build time (see next.config.ts - * `env`) so this has a stable value; it is constant per deployment, so that is - * correct. Requires "Automatically expose System Environment Variables" to be - * enabled on the Vercel project (the default). - */ +/** Redirects page requests on noncanonical production hosts, leaving previews public. */ function redirectProductionBuildUrlToCanonical( request: NextRequest ): NextResponse | null { - if (process.env.VERCEL_ENV !== 'production' || request.method !== 'GET') { + if ( + process.env.VERCEL_ENV !== 'production' || + (request.method !== 'GET' && request.method !== 'HEAD') + ) { return null; } if (request.nextUrl.hostname === CANONICAL_HOST) { diff --git a/next.config.ts b/next.config.ts index c448e367b9c97..71bc6673facde 100644 --- a/next.config.ts +++ b/next.config.ts @@ -141,13 +141,7 @@ const nextConfig = { // Inline NEXT_PUBLIC_DEVELOPER_DOCS into edge middleware at build time. // Edge runtime doesn't have access to server env vars at request time. DEVELOPER_DOCS: process.env.NEXT_PUBLIC_DEVELOPER_DOCS, - // Freeze VERCEL_ENV into the bundle at build time so middleware.ts has a - // stable value for detecting production deployments. VERCEL_ENV is constant - // per deployment, so build-time freezing is intentional and correct; it - // follows the DEVELOPER_DOCS pattern above. (Vercel also exposes VERCEL_ENV - // at runtime when "Automatically expose System Environment Variables" is - // enabled — the default — so this entry is a deliberate freeze, not a - // workaround for missing runtime access.) + // Freeze this per-deployment value for middleware, matching the pattern above. VERCEL_ENV: process.env.VERCEL_ENV, }, redirects,