Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions apps/web/src/lib/server/functions/__tests__/bootstrap-headers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'

type Handler = () => Promise<unknown>

const hoisted = vi.hoisted(() => ({
handlers: [] as Handler[],
headers: new Headers(),
setResponseHeader: vi.fn(),
varySentinel: 'test-document-cache-vary-sentinel',
}))

let bootstrapHandler: Handler | undefined
let actualDocumentCacheVary: string

vi.mock('@tanstack/react-start', () => ({
createServerFn: () => ({
handler(fn: Handler) {
hoisted.handlers.push(fn)
return fn
},
}),
createServerOnlyFn: <T>(fn: T) => fn,
}))

vi.mock('@tanstack/react-start/server', () => ({
getRequestHeaders: () => hoisted.headers,
setResponseHeader: hoisted.setResponseHeader,
}))

vi.mock('@/lib/server/functions/public-cache', () => ({
DOCUMENT_CACHE_VARY: hoisted.varySentinel,
}))

vi.mock('@/lib/shared/theme', () => ({
getThemeCookie: () => 'system',
parsePrefersColorScheme: () => null,
}))

vi.mock('@/lib/shared/update-banner-cookie', () => ({
getUpdateBannerDismissedVersionCookie: () => null,
}))

vi.mock('@/lib/shared/i18n', () => ({
resolveLocale: () => 'en',
}))

vi.mock('@/lib/server/logger', () => ({
logger: {
child: () => ({
debug: vi.fn(),
error: vi.fn(),
}),
},
}))

vi.mock('@/lib/server/domains/settings/settings.service', () => ({
getTenantSettings: vi.fn(async () => null),
}))

vi.mock('@/lib/server/auth/registered-providers', () => ({
getRegisteredAuthProviders: vi.fn(async () => []),
}))

vi.mock('@/lib/server/config', () => ({
config: { baseUrl: 'http://localhost:3000' },
}))

vi.mock('@/lib/server/domains/help-center/help-center-domain.service', () => ({
resolveHelpCenterBaseUrl: () => 'http://localhost:3000',
}))

beforeAll(async () => {
vi.useFakeTimers()
await import('../bootstrap')
bootstrapHandler = hoisted.handlers.at(-1)

const actualPublicCache =
await vi.importActual<typeof import('../public-cache')>('../public-cache')
actualDocumentCacheVary = actualPublicCache.DOCUMENT_CACHE_VARY
})

beforeEach(() => {
hoisted.headers = new Headers()
hoisted.setResponseHeader.mockClear()
})

afterAll(() => {
vi.clearAllTimers()
vi.useRealTimers()
})

describe('bootstrap response headers', () => {
it('defines the canonical credential-aware root document Vary value', () => {
expect(actualDocumentCacheVary).toBe(
'Cookie, Authorization, Accept-Language, Sec-CH-Prefers-Color-Scheme, Host'
)
})

it('consumes the shared document Vary value', async () => {
expect(bootstrapHandler).toBeTypeOf('function')

await bootstrapHandler!()

expect(hoisted.setResponseHeader).toHaveBeenCalledWith('Vary', hoisted.varySentinel)
})
})
67 changes: 67 additions & 0 deletions apps/web/src/lib/server/functions/__tests__/public-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'

type Handler = () => Promise<void>

const hoisted = vi.hoisted(() => ({
handler: null as Handler | null,
headers: new Headers(),
setResponseHeader: vi.fn(),
}))

vi.mock('@tanstack/react-start', () => ({
createServerFn: () => ({
handler(fn: Handler) {
hoisted.handler = fn
return fn
},
}),
}))

vi.mock('@tanstack/react-start/server', () => ({
getRequestHeaders: () => hoisted.headers,
setResponseHeader: hoisted.setResponseHeader,
}))

beforeAll(async () => {
await import('../public-cache')
expect(hoisted.handler).not.toBeNull()
})

beforeEach(() => {
hoisted.headers = new Headers()
hoisted.setResponseHeader.mockClear()
})

async function runWithHeaders(entries: Array<[string, string]> = []) {
hoisted.headers = new Headers(entries)
await hoisted.handler!()
}

describe('setPublicDocumentCacheHeaders', () => {
it('keeps a credential-free document shared-cacheable', async () => {
await runWithHeaders()

expect(hoisted.setResponseHeader).toHaveBeenCalledTimes(1)
expect(hoisted.setResponseHeader).toHaveBeenCalledWith(
'Cache-Control',
'public, s-maxage=60, stale-while-revalidate=600'
)
})

it.each([
['Cookie', [['CoOkIe', '']] as Array<[string, string]>],
['Authorization', [['AUTHORIZATION', '']] as Array<[string, string]>],
[
'Cookie and Authorization',
[
['Cookie', ''],
['Authorization', ''],
] as Array<[string, string]>,
],
])('marks a document with %s headers private and uncacheable', async (_label, entries) => {
await runWithHeaders(entries)

expect(hoisted.setResponseHeader).toHaveBeenCalledTimes(1)
expect(hoisted.setResponseHeader).toHaveBeenCalledWith('Cache-Control', 'private, no-store')
})
})
16 changes: 8 additions & 8 deletions apps/web/src/lib/server/functions/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { resolveLocale, type SupportedLocale } from '@/lib/shared/i18n'
import type { Session, PrincipalType } from '@/lib/server/auth/session'
import type { TenantSettings } from '@/lib/server/domains/settings'
import type { SessionId, UserId } from '@quackback/ids'
import { DOCUMENT_CACHE_VARY } from '@/lib/server/functions/public-cache'
import { logger } from '@/lib/server/logger'

const log = logger.child({ component: 'bootstrap' })
Expand Down Expand Up @@ -177,14 +178,13 @@ const getBootstrapDataInternal = createServerOnlyFn(async (): Promise<BootstrapD
// `color-scheme: light dark` canvas.
setResponseHeader('Accept-CH', 'Sec-CH-Prefers-Color-Scheme')
setResponseHeader('Critical-CH', 'Sec-CH-Prefers-Color-Scheme')
// This document is keyed on every input we render into it: the `theme` cookie
// (and the session/role embedded in the dehydrated context), Accept-Language
// for `<html lang>`/`dir`, the color-scheme hint, and now Host (below,
// baseUrl switches to the help center's verified custom domain when the
// request arrives on it). List them all so a shared cache can never serve
// e.g. a dark-cookie document to a no-cookie visitor that happens to share
// the same hint.
setResponseHeader('Vary', 'Cookie, Accept-Language, Sec-CH-Prefers-Color-Scheme, Host')
// This document varies on Cookie for session- and role-aware context, and on
// Authorization as defense in depth for credential-bearing requests.
// Accept-Language controls `<html lang>`/`dir`; the color-scheme hint controls
// theme rendering; and Host can select a verified Help Center custom domain.
// Keep this call on the shared constant so bootstrap and the public document
// cache helper cannot silently disagree about representation variance.
setResponseHeader('Vary', DOCUMENT_CACHE_VARY)
const prefersColorScheme = parsePrefersColorScheme(headers.get('sec-ch-prefers-color-scheme'))

// Canonical URLs switch to the help center's custom domain when the
Expand Down
19 changes: 13 additions & 6 deletions apps/web/src/lib/server/functions/public-cache.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
import { createServerFn } from '@tanstack/react-start'
import { getRequestHeaders, setResponseHeader } from '@tanstack/react-start/server'

export const DOCUMENT_CACHE_VARY =
'Cookie, Authorization, Accept-Language, Sec-CH-Prefers-Color-Scheme, Host'

/**
* Mark a public document response as shared-cacheable when the request
* carries no cookies (anonymous visitors and crawlers). Cookie-bearing
* requests stay uncached so personalized SSR is never served from a shared
* cache; the root bootstrap already emits the matching `Vary` set
* (Cookie, Accept-Language, Sec-CH-Prefers-Color-Scheme, Host).
* Mark a credential-free public document response as shared-cacheable.
* The presence of Cookie or Authorization makes the response private and
* uncacheable, including malformed or denied credentials. The root bootstrap
* consumes DOCUMENT_CACHE_VARY as defense in depth.
*
* Call from a route loader under an SSR-only guard, mirroring
* setPortalFrameHeaders:
*
* if (typeof window === 'undefined') await setPublicDocumentCacheHeaders()
*/
export const setPublicDocumentCacheHeaders = createServerFn({ method: 'GET' }).handler(async () => {
if (getRequestHeaders().get('cookie')) return
const headers = getRequestHeaders()
if (headers.has('cookie') || headers.has('authorization')) {
setResponseHeader('Cache-Control', 'private, no-store')
return
}

setResponseHeader('Cache-Control', 'public, s-maxage=60, stale-while-revalidate=600')
})
Loading