mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
## Summary The `ChatGPT-User` live-fetch agent's user-facing reader hard-fails (`(400) OK`) on pages we serve it as markdown via user-agent matching, which made supabase.com blog and product pages unreadable in that assistant. I root-caused this with a controlled fetch diagnostic cross-checked against our request logs: the failing fetches never reach our origin (the failure is cached on their side), pages served as plain HTML read fine everywhere we tested, and the same failure reproduces on other major sites that serve UA-matched markdown, so the reader bug is upstream. This PR removes user-agent-based markdown serving entirely rather than special-casing one agent: UA sniffing is a guess about contractless clients whose fetchers change without notice, and this incident showed the failure mode is silent (we keep serving 200s while the user-facing agent breaks). Markdown remains available on every explicit signal — `Accept: text/markdown` q-value negotiation, explicit `.md` URLs, and llms.txt — which is the same contract-driven model the Claude fetcher already uses successfully (it sends `Accept: text/markdown, text/html, */*` and keeps receiving markdown after this change). ## Changes - Remove the `LLM_USER_AGENT` regex and the `userAgent` parameter from `negotiateMarkdown` in `packages/common/markdown-negotiation.ts`; decisions now depend only on `Accept`, the `.md` suffix, and the markdown-variant manifest - Update both consuming middlewares (`apps/www`, `apps/docs`) to the new signature; no behavior change for Accept-negotiated or `.md` requests - Add the missing `Vary: Accept` header to docs guides-md 200 responses (the www `api-v2/md` route already declares it) - Fix a pre-existing www bug surfaced in review: explicit changelog `.md` URLs rewrote to a doubled `.md.md` path (404) under a markdown-preferring `Accept`, and 406'd on a non-matching `Accept`. The www middleware now strips the `.md` suffix before slug lookup and passes `isMarkdownSuffix` into `negotiateMarkdown`, folding the separate `MD_PAGES` `.md` block into the single negotiation path (same shape as the docs middleware) - Rework tests: UA-independence suites replace the per-agent rewrite tests; a probe Accept header now 406s regardless of user agent (previously agent UAs were exempt); new changelog `.md` negotiation coverage ## Testing Tested locally: - [x] www middleware suite 36/36, docs middleware suite 17/17 - [x] typecheck green for common, www, docs Verified on the Vercel previews (www + docs) with curl: - [x] `ChatGPT-User` and `Claude-User` UA GETs on blog/pricing/guide pages return `text/html` with a default Accept - [x] Claude's real Accept (`text/markdown, text/html, */*`) still returns `text/markdown`; `Accept: text/markdown` and `.md` URLs return `text/markdown`; probe Accept returns 406 - [x] `/changelog/<slug>.md` with `Accept: text/markdown` returns the entry markdown as a direct 200 (production today detours through a 308 to the bare URL); changelog index `.md` and bare-entry Accept negotiation also verified - [x] docs guides markdown 200s carry `Vary: Accept` The intermediate commit (ChatGPT-User-only exclusion) was already verified on the preview: `ChatGPT-User` got HTML while `Accept`/`.md`/other-UA markdown was unaffected. Expected effects post-merge: UA-driven markdown volume in the request logs (~92% of md traffic) collapses to the Accept + `.md` baseline; named-agent page requests return to prerendered/static serving, reversing the extra Vercel function invocations the UA rewrite introduced; user-facing readability in the affected assistant recovers within ~24h as its fetch cache revalidates. The md-share dashboard gets a dated annotation; the ratio is not comparable across this change. ## Linear - fixes GROWTH-973 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Markdown and HTML routing now depends on the request’s `Accept` header and `.md` links, making content negotiation more predictable. * Requests that don’t accept available content now consistently return `406 Not Acceptable`, even for bot-like user agents. * Guide markdown responses now include an `Accept`-based cache variation header to improve correct caching behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
import { FIRST_REFERRER_COOKIE_NAME } from 'common/first-referrer-cookie'
|
|
import { NextRequest } from 'next/server'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
|
|
import { middleware } from './middleware'
|
|
|
|
// content.generated.ts is produced by scripts/generateMdContent.mjs at
|
|
// content:build time and gitignored, so it isn't on disk in CI before tests
|
|
// run. The mock seeds a representative allowlist so the .md-routing branches
|
|
// are actually exercised below.
|
|
vi.mock('./app/api-v2/md/content.generated', () => ({
|
|
MD_CONTENT: new Map<string, string>(),
|
|
MD_PAGES: new Set<string>(['homepage', 'auth', 'pricing']),
|
|
}))
|
|
|
|
function makeRequest(
|
|
url: string,
|
|
{
|
|
referer,
|
|
hasCookie,
|
|
accept,
|
|
userAgent,
|
|
}: { referer?: string; hasCookie?: boolean; accept?: string; userAgent?: string } = {}
|
|
): NextRequest {
|
|
const headers: Record<string, string> = {}
|
|
if (referer) headers.referer = referer
|
|
if (accept) headers.accept = accept
|
|
if (userAgent) headers['user-agent'] = userAgent
|
|
const req = new NextRequest(new URL(url, 'https://supabase.com'), { headers })
|
|
if (hasCookie) {
|
|
req.cookies.set(FIRST_REFERRER_COOKIE_NAME, 'existing')
|
|
}
|
|
return req
|
|
}
|
|
|
|
describe('www middleware', () => {
|
|
describe('cookie stamping on www paths', () => {
|
|
it('stamps cookie for external referrer on www path', () => {
|
|
const req = makeRequest('/pricing', { referer: 'https://google.com' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.cookies.get(FIRST_REFERRER_COOKIE_NAME)).toBeDefined()
|
|
})
|
|
|
|
it('does not stamp cookie for internal referrer', () => {
|
|
const req = makeRequest('/pricing', { referer: 'https://supabase.com/docs' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.cookies.get(FIRST_REFERRER_COOKIE_NAME)).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('cookie stamping on /dashboard paths', () => {
|
|
it('stamps cookie for external referrer', () => {
|
|
const req = makeRequest('/dashboard/project/123', { referer: 'https://google.com' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.cookies.get(FIRST_REFERRER_COOKIE_NAME)).toBeDefined()
|
|
})
|
|
|
|
it('does not stamp cookie for internal referrer', () => {
|
|
const req = makeRequest('/dashboard/project/123', {
|
|
referer: 'https://supabase.com/pricing',
|
|
})
|
|
const res = middleware(req)
|
|
|
|
expect(res.cookies.get(FIRST_REFERRER_COOKIE_NAME)).toBeUndefined()
|
|
})
|
|
|
|
it('does not stamp cookie for direct navigation (no referrer)', () => {
|
|
const req = makeRequest('/dashboard/project/123')
|
|
const res = middleware(req)
|
|
|
|
expect(res.cookies.get(FIRST_REFERRER_COOKIE_NAME)).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('cookie stamping on /docs paths', () => {
|
|
it('stamps cookie for external referrer', () => {
|
|
const req = makeRequest('/docs/guides/auth', { referer: 'https://google.com' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.cookies.get(FIRST_REFERRER_COOKIE_NAME)).toBeDefined()
|
|
})
|
|
|
|
it('does not stamp cookie for internal referrer', () => {
|
|
const req = makeRequest('/docs/guides/auth', {
|
|
referer: 'https://supabase.com/pricing',
|
|
})
|
|
const res = middleware(req)
|
|
|
|
expect(res.cookies.get(FIRST_REFERRER_COOKIE_NAME)).toBeUndefined()
|
|
})
|
|
|
|
it('does not stamp cookie for direct navigation (no referrer)', () => {
|
|
const req = makeRequest('/docs/guides/auth')
|
|
const res = middleware(req)
|
|
|
|
expect(res.cookies.get(FIRST_REFERRER_COOKIE_NAME)).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('.md suffix routing', () => {
|
|
it('rewrites /<slug>.md for allowlisted slugs', () => {
|
|
const req = makeRequest('/auth.md')
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/api-v2/md/auth')
|
|
})
|
|
|
|
it('falls through for non-allowlisted .md slugs', () => {
|
|
const req = makeRequest('/not-a-page.md')
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBeNull()
|
|
})
|
|
|
|
it('rewrites changelog entry .md requests without doubling the suffix', () => {
|
|
const req = makeRequest('/changelog/100.md', { accept: 'text/markdown' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/changelog/100.md')
|
|
})
|
|
|
|
it('serves markdown for explicit changelog .md requests even when Accept excludes it', () => {
|
|
const req = makeRequest('/changelog/100.md', {
|
|
accept: 'application/x-content-negotiation-probe',
|
|
})
|
|
const res = middleware(req)
|
|
|
|
expect(res.status).not.toBe(406)
|
|
expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/changelog/100.md')
|
|
})
|
|
|
|
it('rewrites the changelog index .md request without doubling the suffix', () => {
|
|
const req = makeRequest('/changelog.md', { accept: 'text/markdown' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/changelog.md')
|
|
})
|
|
})
|
|
|
|
describe('Accept: text/markdown content negotiation', () => {
|
|
it('rewrites / to homepage when Accept: text/markdown', () => {
|
|
const req = makeRequest('/', { accept: 'text/markdown' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBe(
|
|
'https://supabase.com/api-v2/md/homepage'
|
|
)
|
|
})
|
|
|
|
it('rewrites /<slug> when Accept: text/markdown matches the allowlist', () => {
|
|
const req = makeRequest('/auth', { accept: 'text/markdown' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/api-v2/md/auth')
|
|
})
|
|
|
|
it('rewrites /<slug>/ (trailing slash) the same as /<slug>', () => {
|
|
const req = makeRequest('/auth/', { accept: 'text/markdown' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/api-v2/md/auth')
|
|
})
|
|
|
|
it('falls through when Accept does not include text/markdown', () => {
|
|
const req = makeRequest('/auth', { accept: 'text/html' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBeNull()
|
|
})
|
|
|
|
it('falls through when slug is not in the allowlist', () => {
|
|
const req = makeRequest('/not-a-page', { accept: 'text/markdown' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBeNull()
|
|
})
|
|
|
|
it('rewrites changelog entries to their static .md file', () => {
|
|
const req = makeRequest('/changelog/100', { accept: 'text/markdown' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/changelog/100.md')
|
|
})
|
|
|
|
it('rewrites the bare changelog index to its static .md file', () => {
|
|
const req = makeRequest('/changelog', { accept: 'text/markdown' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/changelog.md')
|
|
})
|
|
})
|
|
|
|
describe('Accept header q-value parsing', () => {
|
|
it('serves markdown for Cursor-style Accept (markdown preferred, plain fallback)', () => {
|
|
const req = makeRequest('/auth', {
|
|
accept: 'text/markdown, text/plain;q=0.9, */*;q=0.8',
|
|
})
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/api-v2/md/auth')
|
|
})
|
|
|
|
it('serves markdown when md and html have equal q-values', () => {
|
|
const req = makeRequest('/auth', { accept: 'text/markdown, text/html, */*' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/api-v2/md/auth')
|
|
})
|
|
|
|
it('serves HTML when html q-value beats markdown q-value', () => {
|
|
const req = makeRequest('/auth', { accept: 'text/html;q=1.0, text/markdown;q=0.5' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBeNull()
|
|
})
|
|
|
|
it('serves HTML for browser-style Accept (html with */* fallback)', () => {
|
|
const req = makeRequest('/auth', {
|
|
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
|
})
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBeNull()
|
|
})
|
|
|
|
it('serves markdown when md q-value beats html q-value', () => {
|
|
const req = makeRequest('/auth', { accept: 'text/html;q=0.5, text/markdown;q=1.0' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/api-v2/md/auth')
|
|
})
|
|
|
|
it('tolerates OWS around the q parameter (per RFC 9110)', () => {
|
|
const req = makeRequest('/auth', { accept: 'text/html ; q = 1.0, text/markdown ; q = 0.5' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBeNull()
|
|
})
|
|
|
|
it('ignores out-of-range q-values rather than treating them as preference', () => {
|
|
const req = makeRequest('/auth', { accept: 'text/html;q=2.0, text/markdown;q=1.0' })
|
|
const res = middleware(req)
|
|
|
|
// text/html's q=2.0 is invalid and falls back to default 1.0; tie -> markdown.
|
|
expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/api-v2/md/auth')
|
|
})
|
|
})
|
|
|
|
describe('406 Not Acceptable', () => {
|
|
it('returns 406 on MD-eligible page when Accept excludes every type we serve', () => {
|
|
const req = makeRequest('/pricing', { accept: 'application/x-content-negotiation-probe' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.status).toBe(406)
|
|
expect(res.headers.get('x-middleware-rewrite')).toBeNull()
|
|
})
|
|
|
|
it('does not return 406 on non-MD pages (no negotiation contract there)', () => {
|
|
const req = makeRequest('/not-a-page', { accept: 'application/x-content-negotiation-probe' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.status).not.toBe(406)
|
|
})
|
|
|
|
it('does not return 406 when Accept includes */*', () => {
|
|
const req = makeRequest('/pricing', { accept: '*/*' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.status).not.toBe(406)
|
|
})
|
|
|
|
it('returns 406 for a probe Accept header regardless of user agent', () => {
|
|
const req = makeRequest('/pricing', {
|
|
accept: 'application/x-content-negotiation-probe',
|
|
userAgent: 'Claude-User/1.0',
|
|
})
|
|
const res = middleware(req)
|
|
|
|
expect(res.status).toBe(406)
|
|
})
|
|
|
|
it('returns 406 on changelog entries when Accept excludes every type', () => {
|
|
const req = makeRequest('/changelog/100', {
|
|
accept: 'application/x-content-negotiation-probe',
|
|
})
|
|
const res = middleware(req)
|
|
|
|
expect(res.status).toBe(406)
|
|
})
|
|
|
|
it('sets Cache-Control: no-store and Vary: Accept on 406 responses', () => {
|
|
const req = makeRequest('/pricing', { accept: 'application/x-content-negotiation-probe' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.status).toBe(406)
|
|
expect(res.headers.get('Cache-Control')).toBe('no-store')
|
|
expect(res.headers.get('Vary')).toBe('Accept')
|
|
})
|
|
})
|
|
|
|
describe('user-agent independence', () => {
|
|
it('serves HTML to agent and bot user agents that send no markdown Accept preference', () => {
|
|
for (const ua of [
|
|
'Claude-User (claude-code/2.1.119; +https://support.anthropic.com/)',
|
|
'Claude-Web/1.0',
|
|
'Mozilla/5.0 (compatible; ChatGPT-User/1.0)',
|
|
'PerplexityBot/1.0',
|
|
'GPTBot/1.0',
|
|
'ClaudeBot/1.0',
|
|
'CCBot/2.0',
|
|
'chatgpt-userscript/2.0',
|
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
|
|
]) {
|
|
const req = makeRequest('/auth', { userAgent: ua })
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBeNull()
|
|
}
|
|
})
|
|
|
|
it('negotiates by Accept as usual when an agent user agent is present', () => {
|
|
const req = makeRequest('/auth', {
|
|
accept: 'text/markdown',
|
|
userAgent: 'Claude-User (claude-code/2.1.119; +https://support.anthropic.com/)',
|
|
})
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/api-v2/md/auth')
|
|
})
|
|
|
|
it('falls through when slug is not in the allowlist', () => {
|
|
const req = makeRequest('/not-a-page', { accept: 'text/markdown' })
|
|
const res = middleware(req)
|
|
|
|
expect(res.headers.get('x-middleware-rewrite')).toBeNull()
|
|
})
|
|
})
|
|
})
|