package httpserver import ( "net/http" "strconv" "strings" ) // Cross-origin access, for local development. // // In Phase 2D the frontend fetches this API directly from the Vite dev server, // which is a different origin (http://localhost:5173 → http://127.0.0.1:8080). // Without these headers the browser makes the request and then refuses to let // the page read the response, which surfaces in the app as an opaque "Failed to // fetch" with a perfectly healthy 200 in the server log. // // This is a transport concern only. No endpoint, request shape, response shape // or status code in docs/api-contract.md changes because of it. // corsMaxAge is how long a browser may cache a preflight result. Ten minutes // keeps preflight off the hot path without making an allowlist change take an // awkwardly long time to be noticed in development. const corsMaxAge = 600 // allowedCORSMethods is every method the router actually registers, plus // OPTIONS for the preflight itself. It is a fixed list rather than something // derived per path: the browser asks about one method at a time and only needs // to know it is permitted in general. var allowedCORSMethods = []string{ http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete, http.MethodOptions, } // cors answers preflights and marks cross-origin responses as readable. // // Origins are matched exactly against the allowlist and echoed back one at a // time — never "*" — so adding credentials later does not require rewriting // this. A request whose Origin is not on the list is served normally, with no // CORS headers: the API does not refuse it, the browser simply will not hand // the response to the page. That distinction matters, because curl, the health // checker and any server-to-server caller send no Origin at all and must not be // affected by this middleware. // // With an empty allowlist the middleware is not installed at all (see New), so // the same-origin deployment pays nothing for it. func cors(origins []string) func(http.Handler) http.Handler { allowed := make(map[string]bool, len(origins)) for _, o := range origins { allowed[o] = true } methods := strings.Join(allowedCORSMethods, ", ") return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") // Vary on Origin whether or not this particular origin matched: the // response differs by Origin, so a cache that ignored it could hand // one origin's headers to another. w.Header().Add("Vary", "Origin") if origin == "" || !allowed[origin] { if isPreflight(r) { // A preflight is never a real request. Answering it with // the router's 404 for "OPTIONS /api/v1/…" would be // misleading; 403 says plainly that the origin was refused. w.WriteHeader(http.StatusForbidden) return } next.ServeHTTP(w, r) return } w.Header().Set("Access-Control-Allow-Origin", origin) // Authentication is a cookie, so the browser will neither send it // nor expose the response without this. It is set for allowlisted // origins only, and the origin above is always a specific one — // the pairing of Allow-Credentials with "*" is rejected outright by // browsers, which is the second reason this middleware never echoes // a wildcard. // // Both the preflight and the actual response need it: the preflight // decides whether the browser is willing to SEND the cookie, and the // actual response decides whether the page may READ the result. // Setting it here, before the preflight branch, covers both. w.Header().Set("Access-Control-Allow-Credentials", "true") if isPreflight(r) { w.Header().Add("Vary", "Access-Control-Request-Method") w.Header().Add("Vary", "Access-Control-Request-Headers") w.Header().Set("Access-Control-Allow-Methods", methods) // Echo the requested headers rather than listing them. The // frontend sends only Content-Type today; echoing means a // future header does not need a change here to be allowed from // an origin that is already trusted. if h := r.Header.Get("Access-Control-Request-Headers"); h != "" { w.Header().Set("Access-Control-Allow-Headers", h) } else { w.Header().Set("Access-Control-Allow-Headers", "Content-Type") } w.Header().Set("Access-Control-Max-Age", strconv.Itoa(corsMaxAge)) w.WriteHeader(http.StatusNoContent) return } next.ServeHTTP(w, r) }) } } // isPreflight identifies the browser's OPTIONS probe. A bare OPTIONS with no // Access-Control-Request-Method is not a preflight and is left to the router. func isPreflight(r *http.Request) bool { return r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" }