From c276fb4b83cffd19eeea6f36dbc33a6cd0ac04e4 Mon Sep 17 00:00:00 2001 From: iPLAYCAFE-dev Date: Mon, 27 Jul 2026 00:37:27 +0700 Subject: [PATCH 1/3] fix: keep authenticated documents out of shared caches --- .../functions/__tests__/public-cache.test.ts | 67 +++++++++++++++++++ .../src/lib/server/functions/public-cache.ts | 17 +++-- 2 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/lib/server/functions/__tests__/public-cache.test.ts 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/public-cache.ts b/apps/web/src/lib/server/functions/public-cache.ts index 77f57b323..fdc2c13ad 100644 --- a/apps/web/src/lib/server/functions/public-cache.ts +++ b/apps/web/src/lib/server/functions/public-cache.ts @@ -2,11 +2,11 @@ import { createServerFn } from '@tanstack/react-start' import { getRequestHeaders, setResponseHeader } from '@tanstack/react-start/server' /** - * 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 + * must vary public documents across both credential transports as defense in + * depth. * * Call from a route loader under an SSR-only guard, mirroring * setPortalFrameHeaders: @@ -14,6 +14,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') }) From b01c8c69f96ab0a093495a547829936e67eb2c62 Mon Sep 17 00:00:00 2001 From: iPLAYCAFE-dev Date: Mon, 27 Jul 2026 00:37:31 +0700 Subject: [PATCH 2/3] fix: vary portal documents by authorization --- .../__tests__/bootstrap-headers.test.ts | 91 +++++++++++++++++++ .../web/src/lib/server/functions/bootstrap.ts | 16 ++-- .../src/lib/server/functions/public-cache.ts | 6 +- 3 files changed, 103 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/lib/server/functions/__tests__/bootstrap-headers.test.ts 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..4c891fa0c --- /dev/null +++ b/apps/web/src/lib/server/functions/__tests__/bootstrap-headers.test.ts @@ -0,0 +1,91 @@ +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(), +})) + +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/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') +}) + +beforeEach(() => { + hoisted.headers = new Headers() + hoisted.setResponseHeader.mockClear() +}) + +afterAll(() => { + vi.clearAllTimers() + vi.useRealTimers() +}) + +describe('bootstrap response headers', () => { + it('emits the canonical credential-aware root document Vary value', async () => { + const bootstrapHandler = hoisted.handlers.at(-1) + expect(bootstrapHandler).toBeTypeOf('function') + + await bootstrapHandler!() + + expect(hoisted.setResponseHeader).toHaveBeenCalledWith( + 'Vary', + 'Cookie, Authorization, Accept-Language, Sec-CH-Prefers-Color-Scheme, Host' + ) + }) +}) diff --git a/apps/web/src/lib/server/functions/bootstrap.ts b/apps/web/src/lib/server/functions/bootstrap.ts index 2f7221fe2..56acaed89 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 is keyed on every input we render into it: Cookie and + // Authorization for session-, role-, and segment-aware context; + // Accept-Language for ``/`dir`; the color-scheme hint; and Host + // because baseUrl can switch to 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 fdc2c13ad..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 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 - * must vary public documents across both credential transports as defense in - * depth. + * consumes DOCUMENT_CACHE_VARY as defense in depth. * * Call from a route loader under an SSR-only guard, mirroring * setPortalFrameHeaders: From 944e800116ef97e614a82ec8a785be84fba1264a Mon Sep 17 00:00:00 2001 From: iPLAYCAFE-dev Date: Mon, 27 Jul 2026 00:37:34 +0700 Subject: [PATCH 3/3] test: enforce shared document vary contract --- .../__tests__/bootstrap-headers.test.ts | 27 ++++++++++++++----- .../web/src/lib/server/functions/bootstrap.ts | 8 +++--- 2 files changed, 25 insertions(+), 10 deletions(-) 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 index 4c891fa0c..5ee06fa1b 100644 --- a/apps/web/src/lib/server/functions/__tests__/bootstrap-headers.test.ts +++ b/apps/web/src/lib/server/functions/__tests__/bootstrap-headers.test.ts @@ -6,8 +6,12 @@ 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) { @@ -23,6 +27,10 @@ vi.mock('@tanstack/react-start/server', () => ({ setResponseHeader: hoisted.setResponseHeader, })) +vi.mock('@/lib/server/functions/public-cache', () => ({ + DOCUMENT_CACHE_VARY: hoisted.varySentinel, +})) + vi.mock('@/lib/shared/theme', () => ({ getThemeCookie: () => 'system', parsePrefersColorScheme: () => null, @@ -64,6 +72,11 @@ vi.mock('@/lib/server/domains/help-center/help-center-domain.service', () => ({ 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(() => { @@ -77,15 +90,17 @@ afterAll(() => { }) describe('bootstrap response headers', () => { - it('emits the canonical credential-aware root document Vary value', async () => { - const bootstrapHandler = hoisted.handlers.at(-1) + 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', - 'Cookie, Authorization, Accept-Language, Sec-CH-Prefers-Color-Scheme, Host' - ) + expect(hoisted.setResponseHeader).toHaveBeenCalledWith('Vary', hoisted.varySentinel) }) }) diff --git a/apps/web/src/lib/server/functions/bootstrap.ts b/apps/web/src/lib/server/functions/bootstrap.ts index 56acaed89..27de1642c 100644 --- a/apps/web/src/lib/server/functions/bootstrap.ts +++ b/apps/web/src/lib/server/functions/bootstrap.ts @@ -178,10 +178,10 @@ const getBootstrapDataInternal = createServerOnlyFn(async (): Promise`/`dir`; the color-scheme hint; and Host - // because baseUrl can switch to a verified Help Center custom domain. + // 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)