From 9404d09d5251ce1e3a8b78502ef4169b18d42984 Mon Sep 17 00:00:00 2001 From: Motty Chen Date: Wed, 4 Mar 2026 15:47:43 -0600 Subject: [PATCH 1/2] fix(cors): restore preprod and production preflight handling Ensure browser preflight requests always return CORS headers by configuring API Gateway for allowed frontend origins and relaxing app-level allowed headers. Add a stronger production smoke test that validates all purposepath.app frontend origins and common tracing headers to prevent regressions. Made-with: Cursor --- .github/workflows/deploy-production.yml | 51 +++++++++++++++---------- coaching/pulumi/__main__.py | 43 +++++++++++++++++++-- coaching/src/api/main.py | 14 ++----- 3 files changed, 72 insertions(+), 36 deletions(-) diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index dd2e5fc..a7b3e66 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -277,28 +277,37 @@ jobs: - name: CORS Preflight Check run: | echo "Testing CORS preflight behavior..." - ORIGIN="https://purposepath.app" TARGET="${{ steps.api-url.outputs.url }}/api/v1/health" - - CORS_HEADERS=$(curl -s -D - -o /dev/null -X OPTIONS "$TARGET" \ - -H "Origin: $ORIGIN" \ - -H "Access-Control-Request-Method: GET" \ - -H "Access-Control-Request-Headers: Authorization,Content-Type") - - ALLOW_ORIGIN=$(echo "$CORS_HEADERS" | tr -d '\r' | awk -F': ' 'tolower($1)=="access-control-allow-origin"{print $2}' | tail -n 1) - ALLOW_CREDENTIALS=$(echo "$CORS_HEADERS" | tr -d '\r' | awk -F': ' 'tolower($1)=="access-control-allow-credentials"{print $2}' | tail -n 1) - - if [ "$ALLOW_ORIGIN" != "$ORIGIN" ]; then - echo "❌ Invalid Access-Control-Allow-Origin: '$ALLOW_ORIGIN' (expected '$ORIGIN')" - exit 1 - fi - - if [ "$ALLOW_CREDENTIALS" != "true" ]; then - echo "❌ Invalid Access-Control-Allow-Credentials: '$ALLOW_CREDENTIALS' (expected 'true')" - exit 1 - fi - - echo "✅ CORS preflight returned expected headers" + ORIGINS=( + "https://dev.purposepath.app" + "https://staging.purposepath.app" + "https://preprod.purposepath.app" + "https://www.purposepath.app" + "https://purposepath.app" + ) + + for ORIGIN in "${ORIGINS[@]}"; do + echo "Checking origin: $ORIGIN" + CORS_HEADERS=$(curl -s -D - -o /dev/null -X OPTIONS "$TARGET" \ + -H "Origin: $ORIGIN" \ + -H "Access-Control-Request-Method: GET" \ + -H "Access-Control-Request-Headers: Authorization,Content-Type,Baggage,Sentry-Trace") + + ALLOW_ORIGIN=$(echo "$CORS_HEADERS" | tr -d '\r' | awk -F': ' 'tolower($1)=="access-control-allow-origin"{print $2}' | tail -n 1) + ALLOW_CREDENTIALS=$(echo "$CORS_HEADERS" | tr -d '\r' | awk -F': ' 'tolower($1)=="access-control-allow-credentials"{print $2}' | tail -n 1) + + if [ "$ALLOW_ORIGIN" != "$ORIGIN" ]; then + echo "❌ Invalid Access-Control-Allow-Origin: '$ALLOW_ORIGIN' (expected '$ORIGIN')" + exit 1 + fi + + if [ "$ALLOW_CREDENTIALS" != "true" ]; then + echo "❌ Invalid Access-Control-Allow-Credentials: '$ALLOW_CREDENTIALS' (expected 'true')" + exit 1 + fi + done + + echo "✅ CORS preflight returned expected headers for all production frontend origins" - name: Smoke Test Summary run: | diff --git a/coaching/pulumi/__main__.py b/coaching/pulumi/__main__.py index 4d8be5c..ea6ebb8 100644 --- a/coaching/pulumi/__main__.py +++ b/coaching/pulumi/__main__.py @@ -41,6 +41,20 @@ "business_api_base_url": "https://api.staging.purposepath.app/account/api/v1", "log_level": "INFO", }, + "preprod": { + "infra_stack": "mottych/purposepath-infrastructure/preprod", + "coaching_infra_stack": "mottych/purposepath-coaching-infrastructure/preprod", + "api_domain": "api.preprod.purposepath.app", + "certificate_output": "apiPreprod", + "jwt_secret": "purposepath-jwt-secret-preprod", + "openai_api_key_secret": "purposepath/preprod/openai-api-key", + "google_vertex_credentials_secret": "purposepath/preprod/google-vertex-credentials", + "jwt_issuer": "https://api.preprod.purposepath.app", + "jwt_audience": "https://preprod.purposepath.app", + "account_api_url": "https://api.preprod.purposepath.app", + "business_api_base_url": "https://api.preprod.purposepath.app/account/api/v1", + "log_level": "INFO", + }, "prod": { "infra_stack": "mottych/purposepath-infrastructure/prod", "coaching_infra_stack": "mottych/purposepath-coaching-infrastructure/prod", @@ -74,6 +88,10 @@ "basic": "CLAUDE_3_5_SONNET_V2", "premium": "CLAUDE_OPUS_4_5", }, + "preprod": { + "basic": "CLAUDE_3_5_SONNET_V2", + "premium": "CLAUDE_OPUS_4_5", + }, "prod": { "basic": "CLAUDE_3_5_SONNET_V2", "premium": "CLAUDE_OPUS_4_5", @@ -363,13 +381,30 @@ ) # API Gateway HTTP API -# IMPORTANT: CORS is intentionally handled only in FastAPI middleware. -# Keeping API Gateway CORS enabled created split-brain behavior where -# preflight responses could come from APIGW ("*") while app responses came -# from FastAPI (credential-aware origin regex), causing intermittent browser failures. +# CORS is configured at the gateway layer so browser preflight OPTIONS requests +# always receive CORS headers, even when requests are rejected before FastAPI. api = aws.apigatewayv2.Api( "coaching-api", protocol_type="HTTP", + cors_configuration={ + "allow_credentials": True, + "allow_headers": ["*"], + "allow_methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + "allow_origins": [ + "https://dev.purposepath.app", + "https://staging.purposepath.app", + "https://preprod.purposepath.app", + "https://www.purposepath.app", + "https://purposepath.app", + ], + "expose_headers": [ + "X-Request-Id", + "X-RateLimit-Limit", + "X-RateLimit-Remaining", + "X-RateLimit-Reset", + ], + "max_age": 3600, + }, ) integration = aws.apigatewayv2.Integration( diff --git a/coaching/src/api/main.py b/coaching/src/api/main.py index e4965fc..c2245c8 100644 --- a/coaching/src/api/main.py +++ b/coaching/src/api/main.py @@ -118,17 +118,9 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: "allow_origin_regex": r"(^https://([a-zA-Z0-9-]+\.)*(purposepath|purpopsepath)\.app$)|(^http://localhost:\d+$)", "allow_credentials": True, "allow_methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"], - "allow_headers": [ - "Content-Type", - "Authorization", - "X-Requested-With", - "Accept", - "Origin", - "X-Api-Key", - "X-Tenant-Id", - "X-User-Id", - "X-CSRF-Token", - ], + # Allow all request headers to prevent preflight breakage when frontend tooling + # adds non-static headers (for example tracing/monitoring headers). + "allow_headers": ["*"], "expose_headers": [ "X-Request-Id", "X-RateLimit-Limit", From 610342c74bf35a3a42a360c05846bf127782c9da Mon Sep 17 00:00:00 2001 From: Motty Chen Date: Wed, 4 Mar 2026 16:02:30 -0600 Subject: [PATCH 2/2] chore(pulumi): add preprod stack config Add the coaching Pulumi preprod stack configuration file with AWS region so future hotfix branches can deploy to preprod without manual stack config setup. Made-with: Cursor --- coaching/pulumi/Pulumi.preprod.yaml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 coaching/pulumi/Pulumi.preprod.yaml diff --git a/coaching/pulumi/Pulumi.preprod.yaml b/coaching/pulumi/Pulumi.preprod.yaml new file mode 100644 index 0000000..1a38cef --- /dev/null +++ b/coaching/pulumi/Pulumi.preprod.yaml @@ -0,0 +1,2 @@ +config: + aws:region: us-east-1