diff --git a/apps/web/src/lib/server/functions/__tests__/bootstrap-headers.test.ts b/apps/web/src/lib/server/functions/__tests__/bootstrap-headers.test.ts new file mode 100644 index 000000000..5ee06fa1b --- /dev/null +++ b/apps/web/src/lib/server/functions/__tests__/bootstrap-headers.test.ts @@ -0,0 +1,106 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +type Handler = () => Promise + +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: (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('../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) + }) +}) diff --git a/apps/web/src/lib/server/functions/__tests__/public-cache.test.ts b/apps/web/src/lib/server/functions/__tests__/public-cache.test.ts new file mode 100644 index 000000000..0ac7ed510 --- /dev/null +++ b/apps/web/src/lib/server/functions/__tests__/public-cache.test.ts @@ -0,0 +1,67 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +type Handler = () => Promise + +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') + }) +}) diff --git a/apps/web/src/lib/server/functions/bootstrap.ts b/apps/web/src/lib/server/functions/bootstrap.ts index a72ac24f2..4c1962a33 100644 --- a/apps/web/src/lib/server/functions/bootstrap.ts +++ b/apps/web/src/lib/server/functions/bootstrap.ts @@ -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' }) @@ -177,14 +178,13 @@ const getBootstrapDataInternal = createServerOnlyFn(async (): Promise`/`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 ``/`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 diff --git a/apps/web/src/lib/server/functions/public-cache.ts b/apps/web/src/lib/server/functions/public-cache.ts index 77f57b323..73366db2d 100644 --- a/apps/web/src/lib/server/functions/public-cache.ts +++ b/apps/web/src/lib/server/functions/public-cache.ts @@ -1,12 +1,14 @@ 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: @@ -14,6 +16,11 @@ import { getRequestHeaders, setResponseHeader } from '@tanstack/react-start/serv * 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') })