From 4be013ce6665794eaa3113e68c7c991228f43591 Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Wed, 1 Jul 2026 01:56:55 -0400 Subject: [PATCH 1/3] fix: answer CORS preflight in preview API Gateway The frontend sends Content-Type: application/json on every request, so even GETs are non-simple and trigger a browser preflight. The preview frontend (shared CloudFront) and preview API are cross-origin; the API resources only defined GET/POST, so preflight OPTIONS hit no method and API Gateway returned a 403 with no CORS headers -> opaque 'CORS error' in the browser. - Add OPTIONS to every preview API resource, routed to the proxy lambda (which already returns 200 + Access-Control-Allow-Origin: * for OPTIONS). - Add CORS headers to DEFAULT_4XX/5XX gateway responses so real errors (lambda 502s, unmatched routes) surface with their status instead of as CORS errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- infrastructure/preview/api_gateway.tf | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/infrastructure/preview/api_gateway.tf b/infrastructure/preview/api_gateway.tf index 62ed277..ab1d95c 100644 --- a/infrastructure/preview/api_gateway.tf +++ b/infrastructure/preview/api_gateway.tf @@ -9,9 +9,33 @@ resource "aws_api_gateway_rest_api" "preview_api" { } } +# Attach CORS headers to API-Gateway-generated error responses (e.g. a lambda +# 502 or a 403 for an unmatched route). Without these, such errors reach the +# browser with no Access-Control-Allow-Origin and surface as an opaque "CORS +# error" that hides the real status. '*' matches the lambda json() helper. +resource "aws_api_gateway_gateway_response" "cors" { + for_each = toset(["DEFAULT_4XX", "DEFAULT_5XX"]) + rest_api_id = aws_api_gateway_rest_api.preview_api.id + response_type = each.key + + response_parameters = { + "gatewayresponse.header.Access-Control-Allow-Origin" = "'*'" + "gatewayresponse.header.Access-Control-Allow-Headers" = "'Content-Type,Authorization'" + "gatewayresponse.header.Access-Control-Allow-Methods" = "'GET,POST,PUT,DELETE,OPTIONS'" + } +} + # Must be kept in sync with infrastructure/aws/api_gateway.tf. +# OPTIONS is added to every resource so browser CORS preflights are answered: +# the frontend sends Content-Type: application/json on every call (see +# apps/frontend/src/lib/api.ts), which makes even GETs non-simple and triggers a +# preflight. Preview frontend (shared CloudFront) and the preview API are +# cross-origin, so without an OPTIONS method API Gateway rejects the preflight +# with no CORS headers → the browser reports a CORS error. Routing OPTIONS to the +# proxy lambda works because the lambda short-circuits OPTIONS with 200 + +# Access-Control-Allow-Origin: * (see each handler's json() helper). locals { - lambda_methods = { + base_methods = { auth = ["GET", "POST"] donors = ["GET"] expenditures = ["GET", "POST"] @@ -19,6 +43,7 @@ locals { reports = ["GET"] users = ["GET", "POST", "DELETE", "PATCH"] } + lambda_methods = { for svc, methods in local.base_methods : svc => concat(methods, ["OPTIONS"]) } } resource "aws_api_gateway_resource" "lambda_resources" { From ab502ac07793633ff2faa574605fc43a42dddada Mon Sep 17 00:00:00 2001 From: nourshoreibah Date: Wed, 1 Jul 2026 02:12:44 -0400 Subject: [PATCH 2/3] fix: answer CORS preflight in prod API Gateway too Same missing-OPTIONS issue as the preview API: the frontend sends Content-Type: application/json on every request, so cross-origin GETs preflight, and the prod API resources defined only GET/POST -> preflight 403 with no CORS headers. - Append OPTIONS to every prod resource, routed to the proxy lambda. - CORS headers on DEFAULT_4XX/5XX gateway responses. - Add a redeploy trigger to the deployment (it had none) so the new OPTIONS methods actually reach the prod stage. Co-Authored-By: Claude Opus 4.8 (1M context) --- infrastructure/aws/api_gateway.tf | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/infrastructure/aws/api_gateway.tf b/infrastructure/aws/api_gateway.tf index 1ebde4c..d61e921 100644 --- a/infrastructure/aws/api_gateway.tf +++ b/infrastructure/aws/api_gateway.tf @@ -8,10 +8,32 @@ resource "aws_api_gateway_rest_api" "branch_api" { } } +# Attach CORS headers to API-Gateway-generated error responses (e.g. a lambda +# 502 or a 403 for an unmatched route) so they surface with their real status +# instead of as an opaque browser "CORS error". '*' matches the lambda json() +# helper. +resource "aws_api_gateway_gateway_response" "cors" { + for_each = toset(["DEFAULT_4XX", "DEFAULT_5XX"]) + rest_api_id = aws_api_gateway_rest_api.branch_api.id + response_type = each.key + + response_parameters = { + "gatewayresponse.header.Access-Control-Allow-Origin" = "'*'" + "gatewayresponse.header.Access-Control-Allow-Headers" = "'Content-Type,Authorization'" + "gatewayresponse.header.Access-Control-Allow-Methods" = "'GET,POST,PUT,DELETE,OPTIONS'" + } +} + # Define supported HTTP methods per Lambda function based on handlers # NOTE: Must be kept in sync with actual Lambda handlers in apps/backend/lambdas/*/openapi.yaml +# OPTIONS is appended to every resource so browser CORS preflights are answered. +# The frontend sends Content-Type: application/json on every call (see +# apps/frontend/src/lib/api.ts), which makes even GETs non-simple and triggers a +# preflight; the frontend (CloudFront) and this API are cross-origin. Preflight +# is routed to the proxy lambda, which short-circuits OPTIONS with 200 + +# Access-Control-Allow-Origin: * (see each handler's json() helper). locals { - lambda_methods = { + base_methods = { auth = ["GET", "POST"] donors = ["GET"] expenditures = ["GET", "POST"] @@ -19,6 +41,7 @@ locals { reports = ["GET"] users = ["GET", "POST", "DELETE", "PATCH"] } + lambda_methods = { for svc, methods in local.base_methods : svc => concat(methods, ["OPTIONS"]) } } # Create a resource for each Lambda function @@ -90,6 +113,12 @@ resource "aws_api_gateway_deployment" "branch_deployment" { rest_api_id = aws_api_gateway_rest_api.branch_api.id + # Force a new deployment when routing changes (e.g. the OPTIONS methods added + # for CORS) — otherwise the stage keeps serving the old method set. + triggers = { + redeploy = sha1(jsonencode(local.lambda_methods)) + } + lifecycle { create_before_destroy = true } From 6c056ae90d91d67bba07f5e05789eb9d70db8a1c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 1 Jul 2026 06:13:06 +0000 Subject: [PATCH 3/3] chore: auto-format terraform and update documentation - Auto-formatted .tf files with terraform fmt - Updated README.md with terraform-docs Co-authored-by: nourshoreibah --- infrastructure/aws/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/infrastructure/aws/README.md b/infrastructure/aws/README.md index 81b9b81..0960f51 100644 --- a/infrastructure/aws/README.md +++ b/infrastructure/aws/README.md @@ -24,6 +24,7 @@ No modules. | Name | Type | |------|------| | [aws_api_gateway_deployment.branch_deployment](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_deployment) | resource | +| [aws_api_gateway_gateway_response.cors](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_gateway_response) | resource | | [aws_api_gateway_integration.lambda_integrations](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_integration) | resource | | [aws_api_gateway_method.lambda_methods](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_method) | resource | | [aws_api_gateway_resource.lambda_resources](https://registry.terraform.io/providers/hashicorp/aws/6.14.1/docs/resources/api_gateway_resource) | resource |