Files
galaxy-game/gateway/internal/grpcapi/cors.go
T
Ilia Denisov 57e6c1d253
Tests · Go / test (push) Successful in 2m9s
Tests · Go / test (pull_request) Successful in 2m9s
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · UI / test (pull_request) Successful in 2m52s
gateway: CORS allow-list for the authenticated Connect-Web surface
The public REST listener already exposes
`GATEWAY_PUBLIC_HTTP_CORS_ALLOWED_ORIGINS`; the authenticated
Connect-Web listener on the separate gRPC port had no equivalent.
That worked in `tools/local-dev` (Vite proxy makes everything
same-origin) and would work in production once UI and gateway share
a single hostname, but the long-lived dev environment serves the
UI from `https://www.galaxy.lan` and the gateway from
`https://api.galaxy.lan` — every `/galaxy.gateway.v1.EdgeGateway/*`
fetch failed in the browser with the WebKit "Load failed" generic
message because the response carried no `Access-Control-Allow-Origin`
header. Lobby rendered as "[unknown] Load failed" with no game.

Mirror the public-REST CORS surface for the authenticated handler:

- new env `GATEWAY_AUTHENTICATED_GRPC_CORS_ALLOWED_ORIGINS`;
- new `AuthenticatedGRPCConfig.CORSAllowedOrigins` field;
- new `grpcapi.withCORS` middleware wrapping the Connect mux;
- dev-deploy stack sets the env to `https://www.galaxy.lan`.

The middleware speaks plain net/http (the Connect handler is mounted
on a ServeMux, not gin), handles preflight 204 immediately, and
exposes the Connect-Web header set the browser needs to read the
response (`Grpc-Status`, `Grpc-Message`, `Connect-Protocol-Version`).
Empty allow-list disables the middleware — production stays at
"single hostname" by default.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:15:11 +02:00

65 lines
2.6 KiB
Go

package grpcapi
import (
"net/http"
)
// withCORS wraps next so that CORS preflight (OPTIONS) requests with an
// allow-listed Origin receive 204 plus the `Access-Control-Allow-*`
// headers Connect-Web needs, and actual requests get the matching
// `Access-Control-Allow-Origin` header echoed back. Origins are
// compared exactly: scheme, host, and port must match. An empty
// allow-list passes through untouched — the production posture when
// the UI and the gateway share one hostname.
//
// The wrapper mirrors `restapi.withCORS` but speaks plain `net/http`
// because the Connect handler is mounted on a `http.ServeMux`, not a
// gin engine. Connect-Web POSTs use `Content-Type: application/connect+json`
// which triggers a browser preflight; without these headers the
// browser surfaces "Load failed" before the Connect handler even sees
// the request.
func withCORS(allowedOrigins []string, next http.Handler) http.Handler {
allowed := make(map[string]struct{}, len(allowedOrigins))
for _, origin := range allowedOrigins {
allowed[origin] = struct{}{}
}
if len(allowed) == 0 {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin == "" {
next.ServeHTTP(w, r)
return
}
if _, ok := allowed[origin]; !ok {
next.ServeHTTP(w, r)
return
}
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Add("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
if r.Method == http.MethodOptions {
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" {
w.Header().Set("Access-Control-Allow-Headers", reqHeaders)
} else {
// Defaults cover the Connect-Web preflight set: protocol
// version, content type, timeout, and the signed-request
// metadata the gateway interceptor expects.
w.Header().Set("Access-Control-Allow-Headers",
"Content-Type, Connect-Protocol-Version, Connect-Timeout-Ms, Authorization")
}
// Expose the response headers Connect-Web needs to read on
// the client (e.g. trailers folded into headers for unary).
w.Header().Set("Access-Control-Expose-Headers", "Connect-Protocol-Version, Grpc-Status, Grpc-Message")
w.Header().Set("Access-Control-Max-Age", "3600")
w.WriteHeader(http.StatusNoContent)
return
}
// Expose the same response headers on the actual call.
w.Header().Set("Access-Control-Expose-Headers", "Connect-Protocol-Version, Grpc-Status, Grpc-Message")
next.ServeHTTP(w, r)
})
}