Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 30 additions & 21 deletions .github/workflows/deploy-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
2 changes: 2 additions & 0 deletions coaching/pulumi/Pulumi.preprod.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
config:
aws:region: us-east-1
43 changes: 39 additions & 4 deletions coaching/pulumi/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down
14 changes: 3 additions & 11 deletions coaching/src/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down