From 6d43f10b20f31d7bc0606d7ca5be252afe33f8f4 Mon Sep 17 00:00:00 2001 From: John O'Reilly Date: Sat, 12 Sep 2026 11:51:34 +0100 Subject: [PATCH] Always send Access-Control-Allow-Origin so CDN-cached responses stay usable The web client intermittently showed no data - an empty conference list, an empty schedule - with no error, then recovered on its own. Apollo sends persisted queries as GETs and those responses are public, max-age=1800, so Cloud CDN caches them. The cache key is {protocol, host, query string, conference header} and does not include Origin. CorsWebFilter follows the CORS spec and only emits Access-Control-Allow-Origin when the request has an Origin header, which browsers send and the mobile apps do not. So whichever client warmed a cache entry decided for the next 30 minutes whether browsers could read it: an entry warmed by a mobile request carried no CORS header, and every browser served that copy had the response blocked. The mobile apps poll far more than anyone opens the web client, so they usually won that race. The policy is "*" for every caller anyway, so emit the header unconditionally and every cached copy is valid for everyone. The filter only fills in a header CorsWebFilter did not already set, leaving real Origin requests untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01E3Syr6Ss5YAKH69qjbVUe4 --- .../confetti/backend/DefaultApplication.kt | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/backend/service-graphql/src/main/kotlin/dev/johnoreilly/confetti/backend/DefaultApplication.kt b/backend/service-graphql/src/main/kotlin/dev/johnoreilly/confetti/backend/DefaultApplication.kt index 1095b73d9..1c796c593 100644 --- a/backend/service-graphql/src/main/kotlin/dev/johnoreilly/confetti/backend/DefaultApplication.kt +++ b/backend/service-graphql/src/main/kotlin/dev/johnoreilly/confetti/backend/DefaultApplication.kt @@ -33,6 +33,9 @@ import org.springframework.context.ApplicationContext import org.springframework.context.ApplicationListener import org.springframework.context.ConfigurableApplicationContext import org.springframework.context.annotation.Bean +import org.springframework.core.Ordered +import org.springframework.core.annotation.Order +import org.springframework.http.HttpHeaders import org.springframework.http.MediaType import org.springframework.http.client.reactive.JdkClientHttpConnector import org.springframework.http.codec.ServerCodecConfigurer @@ -43,6 +46,8 @@ import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.server.* import org.springframework.web.reactive.result.view.ViewResolver +import org.springframework.web.server.WebFilter +import reactor.core.publisher.Mono import java.net.http.HttpClient import kotlin.jvm.optionals.getOrNull @@ -62,6 +67,36 @@ class DefaultApplication { return CorsWebFilter(source) } + /** + * Emit Access-Control-Allow-Origin even when the request carries no Origin header. + * + * Apollo sends persisted queries as GETs, and those responses are `public, max-age=1800`, so + * Cloud CDN caches them - under a key of {protocol, host, query string, conference header} + * that does NOT include Origin (see backend/terraform/main.tf). [corsWebFilter] follows the + * CORS spec and only adds the header when the request has an Origin, which browsers send and + * the mobile apps do not. So whichever client warmed a cache entry decided, for the next 30 + * minutes, whether browsers could read it: an entry warmed by a mobile request had no + * Access-Control-Allow-Origin, and every browser served that copy had the response blocked, + * silently leaving the web client with no data. + * + * The policy above is "*" regardless of caller, so emitting it unconditionally makes every + * cached copy valid for every client. Only fills in a header [corsWebFilter] did not already + * set, so genuine Origin requests keep their spec-compliant handling. + */ + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + fun cacheSafeCorsHeaderFilter(): WebFilter = WebFilter { exchange, chain -> + exchange.response.beforeCommit { + exchange.response.headers.apply { + if (getFirst(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN) == null) { + set(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "*") + } + } + Mono.empty() + } + chain.filter(exchange) + } + @Bean fun errorWebExceptionHandler( errorAttributes: ErrorAttributes?,