diff --git a/docs/guide/edge/headers.md b/docs/guide/edge/headers.md index 07a9e17e5..701e28b3e 100644 --- a/docs/guide/edge/headers.md +++ b/docs/guide/edge/headers.md @@ -24,6 +24,8 @@ Define custom response headers in [`void.config.ts`](../../reference/config) usi - **Header values** use `Name: value` format. The value may contain colons. - All matching rules are merged. When multiple rules set the same header name, the **last match wins**. - User-defined `Cache-Control` overrides the built-in default. The default still applies when no rule matches. +- With [authentication](../auth.md), responses to requests that send cookies or credentials stay private, and auth endpoints stay uncached, during local development, with `void deploy --platform cloudflare`, and on [other runtimes](../deployment.md#other-runtimes). On a [Void platform](../deployment.md#deploy-to-a-team-platform), a `Cache-Control` rule replaces the app's value, so do not match signed-in pages or `/api/auth/*` with a public cache rule. +- Rules apply to matching error responses too, including 404 responses. An immutable cache rule can therefore cache a missing file. Use immutable caching only for URLs that change when their content changes. ## Scope diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index 611786510..02dd6e96a 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -294,9 +294,6 @@ export function compileRouteTable( if (emitEdgeRules && options.redirectRules?.length) { compileRoutingRulesMiddleware(lines, options.redirectRules, options.dev === true); } - if (emitEdgeRules && options.headerRules?.length) { - compileHeaderMiddleware(lines, options.headerRules); - } // After routing rules, which ignore the action key as platform dispatch does, // and before auth and user middleware, so their c.redirect() drops it too. @@ -307,13 +304,24 @@ export function compileRouteTable( lines.push('app.use(__createNamedActionMarker(__matchedRoutes));'); } + const headerRules = emitEdgeRules ? options.headerRules : undefined; + if (headerRules?.length) { + lines.push(`const __headerRules = ${JSON.stringify(headerRules)};`); + } if (auth) { + if (headerRules?.length) { + lines.push('app.use("*", __routingRuntime.headerMiddleware(__headerRules, "auth-error"));'); + } lines.push('app.use(async (c, next) => {'); lines.push(' return runVoidAuthMiddleware(c, next, __voidAuthOptions, __voidAuthDatabase);'); lines.push('});'); lines.push(''); } + if (headerRules?.length) { + lines.push('app.use("*", __routingRuntime.headerMiddleware(__headerRules));'); + } + // Register user middleware for (let i = 0; i < scan.middleware.length; i++) { lines.push(`app.use(middleware${i});`); @@ -1486,12 +1494,6 @@ function compileRoutingRulesMiddleware( lines.push('app.use("*", __routingRuntime.routingMiddleware(__routingRules));'); } -function compileHeaderMiddleware(lines: Array, rules: Array): void { - lines.push('// Header rules (applied to responses)'); - lines.push(`const __headerRules = ${JSON.stringify(rules)};`); - lines.push('app.use("*", __routingRuntime.headerMiddleware(__headerRules));'); -} - /** Generate the server-side fetch client, which calls `app.fetch()` with the current request's credentials. */ export function compileWorkerClient(authClientPath: string, nodeTarget = false): string { const envImport = nodeTarget ? '' : 'import { env } from "cloudflare:workers";\n'; diff --git a/packages/void/src/runtime/auth-response.ts b/packages/void/src/runtime/auth-response.ts new file mode 100644 index 000000000..e0f0cca6f --- /dev/null +++ b/packages/void/src/runtime/auth-response.ts @@ -0,0 +1,9 @@ +// Requests whose response depends on auth, including parent requests after a rewrite. +export const authDependentRequests: WeakSet = new WeakSet(); + +// Targeted policies a CDN can follow ahead of Cache-Control. +export const SHARED_CACHE_HEADERS: ReadonlyArray = [ + 'cdn-cache-control', + 'cloudflare-cdn-cache-control', + 'surrogate-control', +]; diff --git a/packages/void/src/runtime/better-auth-shared.ts b/packages/void/src/runtime/better-auth-shared.ts index e28111fe9..ae9e034dd 100644 --- a/packages/void/src/runtime/better-auth-shared.ts +++ b/packages/void/src/runtime/better-auth-shared.ts @@ -10,6 +10,7 @@ import { type VoidAuthProvider, type VoidSocialAuthProvider, } from '../auth/providers.ts'; +import { SHARED_CACHE_HEADERS, authDependentRequests } from './auth-response.ts'; import { authContext, type AuthState, type VoidAuthConfig } from './auth.ts'; import { isUnchangedStaticAsset } from './static-asset-marker.ts'; @@ -282,6 +283,7 @@ function appendAuthHeaders( ? 'private, no-store' : privateCachePolicy(c.res.headers.get('cache-control')), ); + dropSharedCachePolicy(c); } return; } @@ -315,6 +317,19 @@ function appendAuthHeaders( ) { c.header('Cache-Control', 'private, no-store'); } + if (includeCachePolicy || source.has('set-cookie')) { + dropSharedCachePolicy(c); + } +} + +// A CDN may follow these ahead of Cache-Control, so a private response drops +// them, including values a header rule set. +function dropSharedCachePolicy(c: Context): void { + for (const name of SHARED_CACHE_HEADERS) { + if (c.res.headers.has(name)) { + c.header(name, undefined); + } + } } // Lowercased Cache-Control directive names that carry no value. Commas inside a @@ -333,6 +348,36 @@ function bareCacheDirectives(policy: string | null): Set { * `s-maxage` and a field-scoped `private` give way to a leading `private`. * A policy that is already private or no-store stays as it is. */ +/** + * True when the response sets a Better Auth cookie, such as the session from a + * sign-in reached through a rewrite or fallback. A cookie the app sets itself + * keeps the route's own cache policy. + */ +function setsAuthCookie( + headers: Headers, + advanced: + | { cookiePrefix?: string; cookies?: Record } + | undefined, +): boolean { + const prefix = `${advanced?.cookiePrefix || 'better-auth'}.`; + const custom = new Set(); + for (const cookie of Object.values(advanced?.cookies ?? {})) { + if (cookie?.name) { + custom.add(cookie.name); + } + } + return headers.getSetCookie().some((value) => { + const equals = value.indexOf('='); + let name = (equals === -1 ? value : value.slice(0, equals)).trim(); + for (const secure of ['__Secure-', '__Host-']) { + if (name.startsWith(secure)) { + name = name.slice(secure.length); + } + } + return name.startsWith(prefix) || custom.has(name); + }); +} + export function privateCachePolicy(policy: string | null): string { const directives = splitCacheDirectives(policy ?? ''); if (directives.length === 0) { @@ -619,6 +664,11 @@ export function createVoidAuthRuntime( c.set('user', state?.user ?? null); c.set('session', state?.session ?? null); + const authEndpoint = c.req.path === '/api/auth' || c.req.path.startsWith('/api/auth/'); + if (authEndpoint) { + authDependentRequests.add(c.req.raw); + } + await authContext.run({ user: state?.user ?? null, state }, async () => { await next(); }); @@ -629,7 +679,11 @@ export function createVoidAuthRuntime( appendAuthHeaders( c, result.headers, - state !== null || credentialed || result.headers?.has('set-cookie') === true, + state !== null || + credentialed || + result.headers?.has('set-cookie') === true || + setsAuthCookie(c.res.headers, auth.options.advanced) || + authEndpoint, isUnchangedStaticAsset(c), ); } diff --git a/packages/void/src/runtime/routing.ts b/packages/void/src/runtime/routing.ts index 27ef66a5e..154eed712 100644 --- a/packages/void/src/runtime/routing.ts +++ b/packages/void/src/runtime/routing.ts @@ -16,11 +16,13 @@ import { markActionKey, searchWithoutActionKey, } from '../pages/action-key.ts'; +import { SHARED_CACHE_HEADERS, authDependentRequests } from './auth-response.ts'; import { VoidAssetRewriteError } from './handler.ts'; import { convertReturnValue } from './response.ts'; import { toUrlString } from './route-url.ts'; import { ASSET_EXTENSIONS } from './routing-assets.ts'; import { markRouteHandler } from './routing-pages.ts'; +import { isUnchangedStaticAsset, markStaticAsset } from './static-asset-marker.ts'; export { createErrorRenderer } from './routing-errors.ts'; export type RoutingRule = { @@ -47,7 +49,7 @@ export type RoutingRuntime = { contextMiddleware: MiddlewareHandler; requestMiddleware: MiddlewareHandler; routingMiddleware: (rules: Array) => MiddlewareHandler; - headerMiddleware: (rules: Array) => MiddlewareHandler; + headerMiddleware: (rules: Array, phase?: 'auth-error') => MiddlewareHandler; applyFallback: (context: Context, rules: Array) => Promise; markNoMatch: (request: Request, response: Response) => Response; }; @@ -64,6 +66,12 @@ export function createRoutingRuntime( ): RoutingRuntime { const originalUrls = new WeakMap(); const fallbackHandled = new WeakSet(); + // Outer requests a fallback answered. The fallback already applied the + // destination's header rules, as edge routing and platform dispatch do. + const fallbackServed = new WeakSet(); + // Contexts whose inner header pass ran after next(). A handler that throws + // past it, such as notFound, leaves the context out. + const headerApplied = new WeakSet(); const warnedPatterns = new Set(); const requestContext = new AsyncLocalStorage(); const assetExtensions = options.blockedAssetExtensions ?? ASSET_EXTENSIONS; @@ -82,10 +90,19 @@ export function createRoutingRuntime( headers, }); }; - const dispatch = (request: Request, c: Context): Promise => - Promise.resolve( - options.nodeTarget ? app.fetch(request, c.env) : app.fetch(request, c.env, c.executionCtx), + const dispatch = async (request: Request, c: Context, trace: string): Promise => { + const response = annotate( + await (options.nodeTarget + ? app.fetch(request, c.env) + : app.fetch(request, c.env, c.executionCtx)), + trace, ); + if (authDependentRequests.has(request)) { + // The enclosing response may depend on auth even if middleware clones or replaces it. + authDependentRequests.add(c.req.raw); + } + return response; + }; const rememberOriginal = (request: Request, c: Context): void => { originalUrls.set(request, originalUrls.get(c.req.raw) ?? new URL(c.req.url)); }; @@ -135,11 +152,12 @@ export function createRoutingRuntime( keepActionKey(c.req.raw, request, search); if (fallback) { fallbackHandled.add(request); + fallbackServed.add(c.req.raw); } - const response = await dispatch(request, c); const kind = rule.force === true ? 'rewrite' : 'fallback'; - return annotate( - response, + return dispatch( + request, + c, `${kind}[${rule.source}] -> ${destination} (${rule.origin || 'unknown'})`, ); } @@ -185,9 +203,7 @@ export function createRoutingRuntime( const request = new Request(url.toString(), c.req.raw); rememberOriginal(request, c); keepActionKey(c.req.raw, request, search); - return dispatch(request, c).then((response) => - annotate(response, `c.rewrite -> ${path} (middleware)`), - ); + return dispatch(request, c, `c.rewrite -> ${path} (middleware)`); }; const redirect = context.redirect.bind(context); c.redirect = (destination, status) => { @@ -229,8 +245,29 @@ export function createRoutingRuntime( } } }, - headerMiddleware: (rules) => async (c, next) => { + headerMiddleware: (rules, phase) => async (c, next) => { + const authErrorsOnly = phase === 'auth-error'; await next(); + if (authErrorsOnly && (!c.error || headerApplied.has(c))) { + return; + } + if (!authErrorsOnly) { + headerApplied.add(c); + } + const protectCache = authErrorsOnly || authDependentRequests.has(c.req.raw); + if (protectCache) { + authDependentRequests.add(c.req.raw); + c.header('Cache-Control', 'no-store'); + c.header('Pragma', 'no-cache'); + for (const name of SHARED_CACHE_HEADERS) { + if (c.res.headers.has(name)) { + c.header(name, undefined); + } + } + } + if (fallbackServed.has(c.req.raw)) { + return; + } const pathname = new URL(c.req.url).pathname; const matched: Array<[string, string]> = []; for (const rule of rules) { @@ -247,15 +284,31 @@ export function createRoutingRuntime( if (!matched.length) { return; } - const headers = new Headers(c.res.headers); + // The last match wins for each header name, Set-Cookie included. + const last = new Map(); for (const [name, value] of matched) { - headers.set(name, value); + last.set(name.toLowerCase(), [name, value]); + } + const asset = isUnchangedStaticAsset(c); + for (const [key, [name, value]] of last) { + if ( + protectCache && + (key === 'cache-control' || key === 'pragma' || SHARED_CACHE_HEADERS.includes(key)) + ) { + continue; + } + // A Set-Cookie rule adds its cookie and keeps the response's own cookies. + // After a rewrite the destination's pass may already have added it last. + if (key === 'set-cookie' && c.res.headers.getSetCookie().at(-1) === value) { + continue; + } + c.header(name, value, { append: key === 'set-cookie' }); + } + if (asset) { + // A configured Cache-Control is the file's policy from here on, so auth + // still treats the response as a static file and only makes it private. + markStaticAsset(c, c.res); } - c.res = new Response(c.res.body, { - headers, - status: c.res.status, - statusText: c.res.statusText, - }); }, applyFallback: (c, rules) => fallbackHandled.has(c.req.raw) ? Promise.resolve(undefined) : applyRules(c, rules, true), diff --git a/packages/void/test/integration/auth-isr-cache-policy.test.ts b/packages/void/test/integration/auth-isr-cache-policy.test.ts index 711d5f868..3e64edcfb 100644 --- a/packages/void/test/integration/auth-isr-cache-policy.test.ts +++ b/packages/void/test/integration/auth-isr-cache-policy.test.ts @@ -10,7 +10,11 @@ import { createPagesFallback, createSsrFallback, } from '../../src/runtime/routing-pages.ts'; -import { createRoutingRuntime } from '../../src/runtime/routing.ts'; +import { + createRoutingRuntime, + type RoutingContext, + type RoutingHeaderRule, +} from '../../src/runtime/routing.ts'; const env = { BETTER_AUTH_SECRET: 'local-test-secret-with-at-least-32-characters' }; const options = { dialect: 'sqlite' as const, providers: null }; @@ -100,6 +104,190 @@ describe('real anonymous Better Auth lookup cache policy', () => { }); }); +describe('auth cache policy with authored headers', () => { + const cdnHeaders = ['CDN-Cache-Control', 'Cloudflare-CDN-Cache-Control', 'Surrogate-Control']; + function headerFixture(dev = false) { + const database = memoryAdapter({ user: [], session: [], account: [], verification: [] }); + const runtime = createVoidAuthRuntime('sqlite', async () => {}); + const app = new Hono(); + const routing = createRoutingRuntime(app, { nodeTarget: true, dev }); + const rules: Array = [ + { + pattern: '/*', + headers: [ + ['Cache-Control', 'public, max-age=31536000, immutable'], + ...cdnHeaders.map((name): [string, string] => [name, 'max-age=600']), + ['X-Frame-Options', 'DENY'], + ['Access-Control-Allow-Origin', 'https://client.test'], + ], + }, + ]; + app.use(routing.contextMiddleware); + app.use(routing.headerMiddleware(rules, 'auth-error')); + app.use(async (c, next) => runtime.runVoidAuthMiddleware(c, next, options, database)); + app.use(routing.headerMiddleware(rules)); + app.on(['GET', 'POST'], '/api/auth/*', (c) => + runtime.handleVoidAuthRequest(c, options, database), + ); + return { app, routing }; + } + + it('retains anonymous auth endpoint cache protection under a public rule', async () => { + const { app } = headerFixture(); + const response = await app.request('https://app.test/api/auth/get-session', undefined, env); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('pragma')).toBe('no-cache'); + expect(response.headers.get('x-frame-options')).toBe('DENY'); + for (const name of cdnHeaders) { + expect(response.headers.has(name)).toBe(false); + } + await expect(response.json()).resolves.toBeNull(); + }); + + it.each([false, true])('keeps a nested session rewrite private (dev=%s)', async (dev) => { + const { app } = headerFixture(dev); + app.get('/session', async (c) => + (await (c as RoutingContext).rewrite('/session-alias')).clone(), + ); + app.get('/session-alias', (c) => (c as RoutingContext).rewrite('/api/auth/get-session')); + + const response = await app.request('https://app.test/session', undefined, env); + + expect(response.status).toBe(200); + expect(response.headers.has('set-cookie')).toBe(false); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('pragma')).toBe('no-cache'); + expect(response.headers.get('x-frame-options')).toBe('DENY'); + for (const name of cdnHeaders) { + expect(response.headers.has(name)).toBe(false); + } + await expect(response.json()).resolves.toBeNull(); + }); + + it('protects a HEAD session rewrite when Hono merges response headers', async () => { + const { app } = headerFixture(); + app.get('/session', (c) => { + c.res.headers.set('cache-control', 'public, max-age=60'); + c.res.headers.set('cdn-cache-control', 'max-age=90'); + return (c as RoutingContext).rewrite('/api/auth/get-session'); + }); + + const method = { method: 'HEAD' }; + const direct = await app.request('https://app.test/api/auth/get-session', method, env); + const response = await app.request('https://app.test/session', method, env); + + expect(response.status).toBe(direct.status); + expect(direct.headers.get('cache-control')).toBe('no-store'); + expect(response.body).toBeNull(); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.has('cdn-cache-control')).toBe(false); + }); + + it('keeps authored headers when auth fails before routing', async () => { + const { app } = headerFixture(); + app.onError( + createErrorRenderer({ dev: false, projectRoot: process.cwd(), homeDir: process.cwd() }) + .onError, + ); + app.get('/page', (c) => c.text('page')); + + const response = await app.request('https://app.test/page', undefined, {}); + + expect(response.status).toBe(500); + expect(response.headers.get('access-control-allow-origin')).toBe('https://client.test'); + expect(response.headers.get('cache-control')).toBe('no-store'); + for (const name of cdnHeaders) { + expect(response.headers.has(name)).toBe(false); + } + }); + + it('keeps authored headers when notFound throws past the inner header pass', async () => { + const { app } = headerFixture(); + app.onError( + createErrorRenderer({ dev: false, projectRoot: process.cwd(), homeDir: process.cwd() }) + .onError, + ); + app.notFound(() => { + throw new Error('not found failed'); + }); + + const response = await app.request('https://app.test/missing', undefined, env); + + expect(response.status).toBe(500); + expect(response.headers.get('x-frame-options')).toBe('DENY'); + expect(response.headers.get('access-control-allow-origin')).toBe('https://client.test'); + expect(response.headers.get('cache-control')).toBe('no-store'); + }); + + it.each(['/api/auth/sign-in/email', '/login', '/fallback-login'])( + 'keeps an anonymous body-credential sign-in private through %s', + async (path) => { + const { app, routing } = headerFixture(); + const email = 'auth-headers@example.test'; + const password = 'correct-horse-battery-staple'; + app.post('/login', (c) => (c as RoutingContext).rewrite('/api/auth/sign-in/email')); + app.notFound( + async (c) => + (await routing.applyFallback(c, [ + { source: '/fallback-login', destination: '/api/auth/sign-in/email', status: 200 }, + ])) ?? c.text('not found', 404), + ); + const signUp = await app.request( + 'https://app.test/api/auth/sign-up/email', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'Auth headers', email, password }), + }, + env, + ); + expect(signUp.status).toBe(200); + await signUp.arrayBuffer(); + const response = await app.request( + 'https://app.test' + path, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password }), + }, + env, + ); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.get('pragma')).toBe('no-cache'); + expect(response.headers.get('x-frame-options')).toBe('DENY'); + const cookie = response.headers + .getSetCookie() + .map((value) => value.split(';')[0]) + .join('; '); + expect(cookie).toContain('better-auth.session_token='); + await expect(response.json()).resolves.toMatchObject({ user: { email } }); + const session = await app.request( + 'https://app.test/api/auth/get-session', + { + headers: { cookie }, + }, + env, + ); + await expect(session.json()).resolves.toMatchObject({ user: { email } }); + }, + ); + + it('keeps authored headers on a user middleware redirect', async () => { + const { app } = headerFixture(); + app.use('/private', async (c) => c.redirect('/login')); + const response = await app.request('https://app.test/private', undefined, env); + for (const name of cdnHeaders) { + expect(response.headers.get(name)).toBe('max-age=600'); + } + expect(response.status).toBe(302); + expect(response.headers.get('location')).toBe('/login'); + expect(response.headers.get('x-frame-options')).toBe('DENY'); + await response.arrayBuffer(); + }); +}); + describe('real authenticated Better Auth session cache policy', () => { async function signedInApp( routeHeaders: Record, diff --git a/packages/void/test/integration/native-asset-cache-policy.test.ts b/packages/void/test/integration/native-asset-cache-policy.test.ts index 54c1031c0..d62836ad5 100644 --- a/packages/void/test/integration/native-asset-cache-policy.test.ts +++ b/packages/void/test/integration/native-asset-cache-policy.test.ts @@ -183,10 +183,17 @@ beforeAll(async () => { import { createRoutingRuntime } from ${JSON.stringify(runtimeSource('routing.ts'))}; import { createErrorRenderer } from ${JSON.stringify(runtimeSource('routing-errors.ts'))}; const apps = {}; - for (const mode of ['pages', 'ssr', 'static']) { + for (const mode of ['pages', 'ssr', 'static', 'headers']) { const app = new Hono(); const routing = createRoutingRuntime(app, {}); app.use(routing.contextMiddleware); + if (mode === 'headers') { + app.use(routing.headerMiddleware([{ pattern: '/static/*', headers: [ + ['Cache-Control', 'public, max-age=31536000, immutable'], + ['X-Void-Header', 'configured'], + ] }])); + app.get('/health', (c) => c.text('healthy', 200, { 'cache-control': 'private, no-store' })); + } app.notFound(mode === 'pages' ? createPagesFallback({ routing, assetsDir: 'static', errors: createErrorRenderer({ projectRoot: '/fixture', homeDir: '/home' }) }) : mode === 'ssr' @@ -304,6 +311,37 @@ describe('native asset cache policy in real Workers', () => { }, ); + it.each(['css', 'javascript'])( + 'overrides an existing ASSETS cache policy for %s', + async (kind) => { + const file = kind === 'css' ? cssAsset : uncachedAsset; + const response = await runtime!.dispatchFetch(`https://app.test/${file}?mode=headers`); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); + expect(response.headers.get('x-void-header')).toBe('configured'); + expect(response.headers.get('content-type')).toContain( + kind === 'css' ? 'text/css' : 'javascript', + ); + expect(await response.text()).toContain(kind === 'css' ? 'color' : 'uncached'); + }, + ); + + it('applies matching headers to real asset misses and preserves unmatched responses', async () => { + const missing = await runtime!.dispatchFetch( + 'https://app.test/static/missing-abcdefgh.js?mode=headers', + ); + expect(missing.status).toBe(404); + expect(missing.headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); + expect(missing.headers.get('x-void-header')).toBe('configured'); + await missing.arrayBuffer(); + + const unmatched = await runtime!.dispatchFetch('https://app.test/health?mode=headers'); + expect(unmatched.status).toBe(200); + expect(unmatched.headers.get('cache-control')).toBe('private, no-store'); + expect(unmatched.headers.has('x-void-header')).toBe(false); + await expect(unmatched.text()).resolves.toBe('healthy'); + }); + it('wraps an immutable Cache API response without mutating its headers or consuming its body', async () => { const response = await runtime!.dispatchFetch('https://app.test/cache-proof'); expect(response.status).toBe(200); diff --git a/packages/void/test/unit/auth-middleware.test.ts b/packages/void/test/unit/auth-middleware.test.ts index 296b1f90c..4e6b3d527 100644 --- a/packages/void/test/unit/auth-middleware.test.ts +++ b/packages/void/test/unit/auth-middleware.test.ts @@ -16,6 +16,7 @@ import { privateCachePolicy, type VoidAuthRuntimeOptions, } from '../../src/runtime/better-auth-shared.ts'; +import { createRoutingRuntime } from '../../src/runtime/routing.ts'; import { markStaticAsset } from '../../src/runtime/static-asset-marker.ts'; const authRuntimeOptions: VoidAuthRuntimeOptions = { @@ -29,6 +30,7 @@ function createApp(sessionResult: { response: AuthState | null; headers: Headers mocks.getSession.mockResolvedValue(sessionResult); mocks.betterAuth.mockReturnValue({ api: { getSession: mocks.getSession }, + options: {}, }); const app = new Hono(); @@ -132,6 +134,34 @@ describe('runVoidAuthMiddleware response headers', () => { }, ); + it('drops CDN cache policies from a credentialed page, including rule values', async () => { + const app = createApp({ + response: null, + headers: new Headers({ 'cache-control': 'no-store', pragma: 'no-cache' }), + }); + app.use( + createRoutingRuntime(app, { nodeTarget: true }).headerMiddleware([ + { + pattern: '/*', + headers: [ + ['Cache-Control', 'public, max-age=600'], + ['CDN-Cache-Control', 'max-age=600'], + ['Surrogate-Control', 'max-age=600'], + ], + }, + ]), + ); + app.get('/dashboard', (c) => c.text('signed in', 200, { 'cdn-cache-control': 'no-store' })); + const response = await app.request( + 'http://localhost/dashboard', + { headers: { cookie: 'session=current' } }, + { BETTER_AUTH_SECRET: 'test-secret' }, + ); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(response.headers.has('cdn-cache-control')).toBe(false); + expect(response.headers.has('surrogate-control')).toBe(false); + }); + it('keeps a resolved authenticated session private even without a request credential header', async () => { const now = new Date(); const app = createApp({ @@ -443,6 +473,99 @@ describe('runVoidAuthMiddleware static asset cache policy', () => { ); } + it.each([ + { + name: 'anonymous override', + cookie: false, + policy: 'public, max-age=60', + refresh: false, + expected: 'public, max-age=60', + }, + { + name: 'credentialed override', + cookie: true, + policy: 'public, max-age=60', + refresh: false, + expected: 'private, max-age=60', + }, + { + name: 'unchanged credentialed policy', + cookie: true, + policy: immutable, + refresh: false, + expected: 'private, max-age=31536000, immutable', + }, + { + name: 'session refresh', + cookie: true, + policy: immutable, + refresh: true, + expected: 'private, no-store', + }, + ])( + 'finalizes authored asset headers safely for $name', + async ({ cookie, policy, refresh, expected }) => { + const headers = sessionLookup(); + if (refresh) { + headers.append('set-cookie', 'session=rotated; Path=/; HttpOnly'); + } + const app = createApp({ response: null, headers }); + const routing = createRoutingRuntime(app, { nodeTarget: true }); + app.use( + routing.headerMiddleware([ + { + pattern: '/assets/*', + headers: [ + ['Cache-Control', policy], + ['X-Frame-Options', 'DENY'], + ], + }, + ]), + ); + app.get('/assets/client-abcdefgh.js', (c) => + markStaticAsset(c, new Response('export {}', { headers: { 'cache-control': immutable } })), + ); + const response = await app.request( + 'http://localhost/assets/client-abcdefgh.js', + { + headers: cookie ? { cookie: 'session=current' } : {}, + }, + env, + ); + expect(response.headers.get('cache-control')).toBe(expected); + expect(response.headers.get('pragma')).toBeNull(); + expect(response.headers.get('x-frame-options')).toBe('DENY'); + expect(response.headers.getSetCookie()).toEqual( + refresh ? ['session=rotated; Path=/; HttpOnly'] : [], + ); + await expect(response.text()).resolves.toBe('export {}'); + }, + ); + + it.each([ + { name: 'an app cookie', cookie: 'theme=dark; Path=/', expected: 'private, max-age=300' }, + { + name: 'a Better Auth cookie', + cookie: '__Secure-better-auth.session_token=abc; Path=/; HttpOnly', + expected: 'no-store', + }, + ])( + 'decides the cache policy of an anonymous response that sets $name', + async ({ cookie, expected }) => { + const app = createApp({ response: null, headers: sessionLookup() }); + app.get( + '/prefs', + () => + new Response('ok', { + headers: { 'cache-control': 'private, max-age=300', 'set-cookie': cookie }, + }), + ); + const response = await app.request('http://localhost/prefs', {}, env); + expect(response.headers.get('cache-control')).toBe(expected); + expect(response.headers.getSetCookie()).toEqual([cookie]); + }, + ); + it('makes the file policy private for a credentialed request', async () => { const response = await withCookie(assetApp(sessionLookup())); expect(response.headers.get('cache-control')).toBe('private, max-age=31536000, immutable'); @@ -500,10 +623,11 @@ describe('runVoidAuthMiddleware static asset cache policy', () => { it('keeps the file policy when later middleware only adds a header', async () => { const app = createApp({ response: null, headers: sessionLookup() }); - app.use(async (c, next) => { - await next(); - c.header('x-frame-options', 'DENY'); - }); + app.use( + createRoutingRuntime(app, { nodeTarget: true }).headerMiddleware([ + { pattern: '/assets/*', headers: [['X-Frame-Options', 'DENY']] }, + ]), + ); app.get('/assets/client-abcdefgh.js', (c) => markStaticAsset(c, new Response('export {}', { headers: { 'cache-control': immutable } })), ); diff --git a/packages/void/test/unit/compile-auth.test.ts b/packages/void/test/unit/compile-auth.test.ts index 83126adae..02862273f 100644 --- a/packages/void/test/unit/compile-auth.test.ts +++ b/packages/void/test/unit/compile-auth.test.ts @@ -50,6 +50,32 @@ describe('compileRouteTable auth integration', () => { expect(code).not.toContain('"/auth/login"'); }); + it('finalizes auth caching after header rules and user middleware', () => { + const code = compileRouteTable( + { ...emptyScan, middleware: [{ order: 1, filePath: '01.auth.ts' }] }, + '/routes', + '/middleware', + '/crons', + '/queues', + null, + { + ...baseOptions, + auth: sqliteAuth, + headerRules: [{ pattern: '/*', headers: [['Cache-Control', 'public, max-age=60']] }], + }, + ); + const auth = code.indexOf('return runVoidAuthMiddleware(c, next,'); + const errorHeaders = code.indexOf( + '__routingRuntime.headerMiddleware(__headerRules, "auth-error")', + ); + const headers = code.indexOf('__routingRuntime.headerMiddleware(__headerRules)'); + const user = code.indexOf('app.use(middleware0)'); + expect(errorHeaders).toBeGreaterThan(-1); + expect(auth).toBeGreaterThan(errorHeaders); + expect(headers).toBeGreaterThan(auth); + expect(user).toBeGreaterThan(headers); + }); + it('mounts Better Auth middleware and routes when auth is enabled', () => { const code = compileRouteTable(emptyScan, '/routes', '/middleware', '/crons', '/queues', null, { ...baseOptions, diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index b6e9fd782..a7aaf8e48 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -31,6 +31,7 @@ import { createModuleLoader, createRoutingRuntime, type RoutingContext, + type RoutingHeaderRule, } from '../../src/runtime/routing.ts'; import type { StandardSchemaV1 } from '../../src/standard-schema.ts'; @@ -180,6 +181,164 @@ describe('typed HTTP execution', () => { dev ? 'c.rewrite -> /new (middleware)' : null, ); }); + it.each([200, 404])( + 'overrides an existing response header with the last matching rule on %s', + async (status) => { + const app = new Hono(); + const runtime = createRoutingRuntime(app, { nodeTarget: true }); + app.use( + runtime.headerMiddleware([ + { pattern: '/assets/*', headers: [['Cache-Control', 'no-cache']] }, + { + pattern: '/assets/*', + headers: [['Cache-Control', 'public, max-age=31536000, immutable']], + }, + ]), + ); + app.get( + '/assets/app.css', + () => + new Response('body{}', { + status, + headers: { + 'Content-Type': 'text/css', + 'Cache-Control': 'public, max-age=0, must-revalidate', + }, + }), + ); + const response = await app.request('https://app.test/assets/app.css'); + expect(response.headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); + expect(response.status).toBe(status); + expect(response.headers.get('content-type')).toBe('text/css'); + expect(await response.text()).toBe('body{}'); + }, + ); + it('updates immutable response headers and preserves unrelated cookies and metadata', async () => { + const app = new Hono(); + const runtime = createRoutingRuntime(app, { nodeTarget: true }); + app.use( + runtime.headerMiddleware([ + { + pattern: '/assets/*', + headers: [ + ['Location', '/new'], + ['X-Robots-Tag', 'noindex'], + ], + }, + ]), + ); + app.get('/assets/redirect', () => Response.redirect('https://old.test/', 302)); + app.get('/assets/cookies', () => { + const headers = new Headers({ 'Content-Type': 'text/css' }); + headers.append('Set-Cookie', 'one=1; Path=/'); + headers.append('Set-Cookie', 'two=2; Path=/'); + return new Response('body{}', { status: 202, statusText: 'Queued', headers }); + }); + app.get( + '/other', + () => new Response('other', { headers: { 'Cache-Control': 'private, no-store' } }), + ); + const redirect = await app.request('https://app.test/assets/redirect'); + expect(redirect.status).toBe(302); + expect(redirect.headers.get('location')).toBe('/new'); + const response = await app.request('https://app.test/assets/cookies'); + expect(response.status).toBe(202); + expect(response.statusText).toBe('Queued'); + expect(response.headers.get('content-type')).toBe('text/css'); + expect(response.headers.get('x-robots-tag')).toBe('noindex'); + expect(response.headers.getSetCookie()).toEqual(['one=1; Path=/', 'two=2; Path=/']); + expect(await response.text()).toBe('body{}'); + const other = await app.request('https://app.test/other'); + expect(other.headers.get('cache-control')).toBe('private, no-store'); + expect(other.headers.has('x-robots-tag')).toBe(false); + }); + it('adds a Set-Cookie rule next to the response cookies', async () => { + const app = new Hono(); + const runtime = createRoutingRuntime(app, { nodeTarget: true }); + app.use( + runtime.headerMiddleware([{ pattern: '/*', headers: [['Set-Cookie', 'consent=1; Path=/']] }]), + ); + app.get('/sign-in', (c) => { + c.header('Set-Cookie', 'session=abc; Path=/; HttpOnly', { append: true }); + return c.text('ok'); + }); + const response = await app.request('https://app.test/sign-in'); + expect(response.headers.getSetCookie()).toEqual([ + 'session=abc; Path=/; HttpOnly', + 'consent=1; Path=/', + ]); + }); + it('uses the last matching Set-Cookie rule value', async () => { + const app = new Hono(); + const runtime = createRoutingRuntime(app, { nodeTarget: true }); + app.use(runtime.contextMiddleware); + app.use( + runtime.headerMiddleware( + ['theme=light; Path=/', 'theme=dark; Path=/', 'theme=light; Path=/'].map( + (cookie): RoutingHeaderRule => ({ pattern: '/*', headers: [['Set-Cookie', cookie]] }), + ), + ), + ); + app.get('/old', (c) => (c as RoutingContext).rewrite('/new')); + app.get('/new', (c) => c.text('new')); + for (const path of ['/new', '/old']) { + const response = await app.request(`https://app.test${path}`); + expect(response.headers.getSetCookie()).toEqual(['theme=light; Path=/']); + } + }); + it('ends with the source rule cookie after a rewrite', async () => { + const app = new Hono(); + const runtime = createRoutingRuntime(app, { nodeTarget: true }); + app.use(runtime.contextMiddleware); + app.use( + runtime.headerMiddleware([ + { pattern: '/new', headers: [['Set-Cookie', 'theme=dark; Path=/']] }, + { pattern: '/old', headers: [['Set-Cookie', 'theme=light; Path=/']] }, + ]), + ); + app.get('/old', (c) => (c as RoutingContext).rewrite('/new')); + app.get('/new', (c) => c.text('new', 200, { 'set-cookie': 'theme=light; Path=/' })); + const response = await app.request('https://app.test/old'); + expect(response.headers.getSetCookie().at(-1)).toBe('theme=light; Path=/'); + }); + it('adds a Set-Cookie rule once when a rewrite source and destination both match', async () => { + const app = new Hono(); + const runtime = createRoutingRuntime(app, { nodeTarget: true }); + app.use(runtime.contextMiddleware); + app.use( + runtime.headerMiddleware([{ pattern: '/*', headers: [['Set-Cookie', 'consent=1; Path=/']] }]), + ); + app.get('/old', (c) => (c as RoutingContext).rewrite('/new')); + app.get('/new', (c) => c.text('new')); + const response = await app.request('https://app.test/old'); + expect(response.headers.getSetCookie()).toEqual(['consent=1; Path=/']); + }); + it('does not give a fallback page the rules of the missing path', async () => { + const app = new Hono(); + const runtime = createRoutingRuntime(app, { nodeTarget: true }); + app.use(runtime.contextMiddleware); + app.use( + runtime.headerMiddleware([ + { + pattern: '/assets/*', + headers: [['Cache-Control', 'public, max-age=31536000, immutable']], + }, + ]), + ); + app.get( + '/index.html', + (c) => new Response('', { headers: { 'Cache-Control': 'no-cache' } }), + ); + app.notFound( + async (c) => + (await runtime.applyFallback(c, [ + { source: '/*', destination: '/index.html', status: 200 }, + ])) ?? c.text('missing', 404), + ); + const response = await app.fetch(new Request('https://app.test/assets/old-chunk.js')); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-cache'); + }); it('merges header rules in order after recursive fallback and stops fallback cycles', async () => { const app = new Hono(); const runtime = createRoutingRuntime(app, { nodeTarget: true, dev: true }); @@ -199,6 +358,7 @@ describe('typed HTTP execution', () => { ); const response = await app.fetch(new Request('https://app.test/a')); expect(response.status).toBe(404); + // Only the destination's rules apply, as in edge routing and platform dispatch. expect(response.headers.get('x-rule')).toBe('one'); expect(response.headers.get('X-Void-Routing')).toBe('fallback[/a] -> /b (unknown)'); expect(response.headers.get('X-Void-No-Match')).toBe('1');