Skip to content

fix: answer CORS preflight in preview + prod API Gateway#277

Merged
nourshoreibah merged 3 commits into
mainfrom
fix/preview-api-cors-preflight
Jul 1, 2026
Merged

fix: answer CORS preflight in preview + prod API Gateway#277
nourshoreibah merged 3 commits into
mainfrom
fix/preview-api-cors-preflight

Conversation

@nourshoreibah

@nourshoreibah nourshoreibah commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Bug

Cross-origin API calls fail with a CORS error (e.g. GET .../prod/expenditures from the CloudFront frontend). Hit in a preview environment; the same latent bug exists in prod.

Cause

apps/frontend/src/lib/api.ts sends Content-Type: application/json on every request → even GETs are non-simple → the browser fires a preflight OPTIONS. Frontend (CloudFront) and API (*.execute-api) are cross-origin, but the API resources only defined GET/POST — no OPTIONS. API Gateway answers the preflight 403 Missing Authentication Token with no Access-Control-Allow-Origin, so the browser blocks it. The lambda's own Access-Control-Allow-Origin: * never applies because the preflight never reaches it.

Fix — both infrastructure/preview/api_gateway.tf and infrastructure/aws/api_gateway.tf

  • Append OPTIONS to every resource, routed to the proxy lambda — each handler short-circuits OPTIONS with 200 + Access-Control-Allow-Origin: *.
  • CORS headers on DEFAULT_4XX / DEFAULT_5XX gateway responses so lambda 502s / unmatched routes surface with their real status instead of as CORS errors.
  • Prod deployment had no triggers, so a method-set change wouldn't redeploy the stage — added triggers = { redeploy = sha1(jsonencode(local.lambda_methods)) } (preview already had one).

Both modules terraform validate clean.

Apply / rollout

  • Prod: applies on merge via terraform-apply (redeploys the prod stage with the OPTIONS methods).
  • Preview: infra change → takes effect on preview create; existing previews re-provision by removing + re-adding the test-environment label.

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) <noreply@anthropic.com>
@github-actions github-actions Bot requested review from Rayna-Yu and mehanana July 1, 2026 05:57
github-actions Bot added a commit that referenced this pull request Jul 1, 2026
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) <noreply@anthropic.com>
@nourshoreibah nourshoreibah changed the title fix: answer CORS preflight in preview API Gateway fix: answer CORS preflight in preview + prod API Gateway Jul 1, 2026
  - Auto-formatted .tf files with terraform fmt
  - Updated README.md with terraform-docs

  Co-authored-by: nourshoreibah <nourshoreibah@users.noreply.github.com>
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Terraform Plan 📖 infrastructure/aws

Terraform Initialization ⚙️success

Terraform Validation 🤖success

Terraform Plan 📖success

Show Plan
data.archive_file.lambda_placeholder: Reading...
data.archive_file.lambda_placeholder: Read complete after 0s [id=96878a51e358033297a32b882fd5223cc95fb8a7]
aws_cloudfront_origin_access_control.frontend: Refreshing state... [id=E2T090T8V5CDLN]
aws_cloudfront_function.rewrite_index: Refreshing state... [id=branch-frontend-rewrite-index]
data.aws_caller_identity.current: Reading...
aws_iam_role.lambda_role: Refreshing state... [id=branch-lambda-role]
aws_iam_openid_connect_provider.github: Refreshing state... [id=arn:aws:iam::489881683177:oidc-provider/token.actions.githubusercontent.com]
aws_api_gateway_rest_api.branch_api: Refreshing state... [id=2apxzxb0r8]
aws_s3_bucket.reports_bucket: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
aws_cognito_user_pool.branch_user_pool: Refreshing state... [id=us-east-2_CxTueqe6g]
data.infisical_secrets.github_folder: Reading...
data.infisical_secrets.rds_folder: Reading...
data.aws_caller_identity.current: Read complete after 0s [id=489881683177]
aws_s3_bucket.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
data.infisical_secrets.github_folder: Read complete after 0s
data.infisical_secrets.rds_folder: Read complete after 0s
aws_s3_bucket.frontend: Refreshing state... [id=branch-frontend-489881683177]
aws_db_instance.branch_rds: Refreshing state... [id=db-AMMYFTORW6XJGRELV7WQZCNHQI]
data.aws_iam_policy_document.ci_preview_assume: Reading...
data.aws_iam_policy_document.ci_preview_assume: Read complete after 0s [id=282080688]
data.aws_iam_policy_document.ci_plan_assume: Reading...
data.aws_iam_policy_document.ci_plan_assume: Read complete after 0s [id=3057813384]
data.aws_iam_policy_document.ci_apply_assume: Reading...
data.aws_iam_policy_document.ci_apply_assume: Read complete after 0s [id=813913]
aws_iam_role.ci_preview: Refreshing state... [id=branch-ci-preview]
aws_iam_role.ci_plan: Refreshing state... [id=branch-ci-plan]
aws_iam_role.ci_apply: Refreshing state... [id=branch-ci-apply]
aws_iam_role_policy_attachment.lambda_basic: Refreshing state... [id=branch-lambda-role/arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]
aws_api_gateway_resource.lambda_resources["users"]: Refreshing state... [id=0dkbds]
aws_api_gateway_resource.lambda_resources["auth"]: Refreshing state... [id=u8unad]
aws_api_gateway_resource.lambda_resources["donors"]: Refreshing state... [id=hybur2]
aws_api_gateway_resource.lambda_resources["expenditures"]: Refreshing state... [id=6sdj3w]
aws_api_gateway_resource.lambda_resources["projects"]: Refreshing state... [id=chhy2i]
aws_api_gateway_resource.lambda_resources["reports"]: Refreshing state... [id=wsnfk2]
aws_iam_role_policy.ci_preview: Refreshing state... [id=branch-ci-preview:preview-env]
aws_iam_role_policy_attachment.ci_apply_admin: Refreshing state... [id=branch-ci-apply/arn:aws:iam::aws:policy/AdministratorAccess]
aws_iam_role_policy.ci_plan_state_lock: Refreshing state... [id=branch-ci-plan:tfstate-lock]
aws_iam_role_policy_attachment.ci_plan_readonly: Refreshing state... [id=branch-ci-plan/arn:aws:iam::aws:policy/ReadOnlyAccess]
aws_cognito_user_pool_client.branch_client: Refreshing state... [id=570i6ocj0882qu0ditm4vrr60f]
aws_api_gateway_method.lambda_methods["auth-GET"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-GET]
aws_api_gateway_method.lambda_methods["users-POST"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-POST]
aws_api_gateway_method.lambda_methods["users-GET"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-GET]
aws_api_gateway_method.lambda_methods["projects-POST"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-POST]
aws_api_gateway_method.lambda_methods["users-DELETE"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-DELETE]
aws_api_gateway_method.lambda_methods["users-PATCH"]: Refreshing state... [id=agm-2apxzxb0r8-0dkbds-PATCH]
aws_api_gateway_method.lambda_methods["auth-POST"]: Refreshing state... [id=agm-2apxzxb0r8-u8unad-POST]
aws_api_gateway_method.lambda_methods["donors-GET"]: Refreshing state... [id=agm-2apxzxb0r8-hybur2-GET]
aws_api_gateway_method.lambda_methods["projects-GET"]: Refreshing state... [id=agm-2apxzxb0r8-chhy2i-GET]
aws_api_gateway_method.lambda_methods["reports-GET"]: Refreshing state... [id=agm-2apxzxb0r8-wsnfk2-GET]
aws_api_gateway_method.lambda_methods["expenditures-POST"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-POST]
aws_api_gateway_method.lambda_methods["expenditures-GET"]: Refreshing state... [id=agm-2apxzxb0r8-6sdj3w-GET]
aws_s3_bucket_versioning.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_s3_object.lambda_placeholder["users"]: Refreshing state... [id=branch-lambda-deployments-489881683177/users/initial.zip]
aws_s3_object.lambda_placeholder["expenditures"]: Refreshing state... [id=branch-lambda-deployments-489881683177/expenditures/initial.zip]
aws_s3_object.lambda_placeholder["reports"]: Refreshing state... [id=branch-lambda-deployments-489881683177/reports/initial.zip]
aws_s3_object.lambda_placeholder["auth"]: Refreshing state... [id=branch-lambda-deployments-489881683177/auth/initial.zip]
aws_s3_bucket_server_side_encryption_configuration.lambda_deployments: Refreshing state... [id=branch-lambda-deployments-489881683177]
aws_s3_object.lambda_placeholder["projects"]: Refreshing state... [id=branch-lambda-deployments-489881683177/projects/initial.zip]
aws_s3_object.lambda_placeholder["donors"]: Refreshing state... [id=branch-lambda-deployments-489881683177/donors/initial.zip]
aws_s3_bucket_public_access_block.reports_bucket_public_access: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
aws_s3_bucket_public_access_block.frontend: Refreshing state... [id=branch-frontend-489881683177]
aws_s3_bucket_policy.reports_bucket_policy: Refreshing state... [id=c4c-branch-generated-reports20251030194253425700000001]
aws_lambda_function.functions["donors"]: Refreshing state... [id=branch-donors]
aws_lambda_function.functions["reports"]: Refreshing state... [id=branch-reports]
aws_lambda_function.functions["projects"]: Refreshing state... [id=branch-projects]
aws_lambda_function.functions["users"]: Refreshing state... [id=branch-users]
aws_lambda_function.functions["expenditures"]: Refreshing state... [id=branch-expenditures]
aws_lambda_function.functions["auth"]: Refreshing state... [id=branch-auth]
aws_cloudfront_distribution.frontend: Refreshing state... [id=E37FDHRYNZNF4R]
data.aws_iam_policy_document.frontend_bucket: Reading...
data.aws_iam_policy_document.frontend_bucket: Read complete after 0s [id=1471335443]
aws_s3_bucket_policy.frontend: Refreshing state... [id=branch-frontend-489881683177]
aws_api_gateway_integration.lambda_integrations["users-DELETE"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-DELETE]
aws_api_gateway_integration.lambda_integrations["expenditures-POST"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-POST]
aws_api_gateway_integration.lambda_integrations["projects-GET"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-GET]
aws_api_gateway_integration.lambda_integrations["auth-GET"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-GET]
aws_api_gateway_integration.lambda_integrations["donors-GET"]: Refreshing state... [id=agi-2apxzxb0r8-hybur2-GET]
aws_api_gateway_integration.lambda_integrations["auth-POST"]: Refreshing state... [id=agi-2apxzxb0r8-u8unad-POST]
aws_api_gateway_integration.lambda_integrations["users-POST"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-POST]
aws_api_gateway_integration.lambda_integrations["projects-POST"]: Refreshing state... [id=agi-2apxzxb0r8-chhy2i-POST]
aws_api_gateway_integration.lambda_integrations["expenditures-GET"]: Refreshing state... [id=agi-2apxzxb0r8-6sdj3w-GET]
aws_api_gateway_integration.lambda_integrations["reports-GET"]: Refreshing state... [id=agi-2apxzxb0r8-wsnfk2-GET]
aws_api_gateway_integration.lambda_integrations["users-GET"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-GET]
aws_lambda_permission.api_gateway_permissions["users"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_api_gateway_integration.lambda_integrations["users-PATCH"]: Refreshing state... [id=agi-2apxzxb0r8-0dkbds-PATCH]
aws_lambda_permission.api_gateway_permissions["donors"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["auth"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["projects"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["expenditures"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_lambda_permission.api_gateway_permissions["reports"]: Refreshing state... [id=AllowAPIGatewayInvoke]
aws_api_gateway_deployment.branch_deployment: Refreshing state... [id=klh9jv]
aws_api_gateway_stage.branch_stage: Refreshing state... [id=ags-2apxzxb0r8-prod]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
  ~ update in-place
+/- create replacement and then destroy

Terraform will perform the following actions:

  # aws_api_gateway_deployment.branch_deployment must be replaced
+/- resource "aws_api_gateway_deployment" "branch_deployment" {
      ~ created_date = "2026-04-12T17:44:10Z" -> (known after apply)
      ~ id           = "klh9jv" -> (known after apply)
      + triggers     = { # forces replacement
          + "redeploy" = "f158e536679f5c024208ec92e4bf9af90524d98c"
        }
        # (3 unchanged attributes hidden)
    }

  # aws_api_gateway_gateway_response.cors["DEFAULT_4XX"] will be created
  + resource "aws_api_gateway_gateway_response" "cors" {
      + id                  = (known after apply)
      + region              = "us-east-2"
      + response_parameters = {
          + "gatewayresponse.header.Access-Control-Allow-Headers" = "'Content-Type,Authorization'"
          + "gatewayresponse.header.Access-Control-Allow-Methods" = "'GET,POST,PUT,DELETE,OPTIONS'"
          + "gatewayresponse.header.Access-Control-Allow-Origin"  = "'*'"
        }
      + response_type       = "DEFAULT_4XX"
      + rest_api_id         = "2apxzxb0r8"
    }

  # aws_api_gateway_gateway_response.cors["DEFAULT_5XX"] will be created
  + resource "aws_api_gateway_gateway_response" "cors" {
      + id                  = (known after apply)
      + region              = "us-east-2"
      + response_parameters = {
          + "gatewayresponse.header.Access-Control-Allow-Headers" = "'Content-Type,Authorization'"
          + "gatewayresponse.header.Access-Control-Allow-Methods" = "'GET,POST,PUT,DELETE,OPTIONS'"
          + "gatewayresponse.header.Access-Control-Allow-Origin"  = "'*'"
        }
      + response_type       = "DEFAULT_5XX"
      + rest_api_id         = "2apxzxb0r8"
    }

  # aws_api_gateway_integration.lambda_integrations["auth-OPTIONS"] will be created
  + resource "aws_api_gateway_integration" "lambda_integrations" {
      + cache_namespace         = (known after apply)
      + connection_type         = "INTERNET"
      + http_method             = "OPTIONS"
      + id                      = (known after apply)
      + integration_http_method = "POST"
      + passthrough_behavior    = (known after apply)
      + region                  = "us-east-2"
      + resource_id             = "u8unad"
      + rest_api_id             = "2apxzxb0r8"
      + timeout_milliseconds    = 29000
      + type                    = "AWS_PROXY"
      + uri                     = "arn:aws:apigateway:us-east-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-2:489881683177:function:branch-auth/invocations"
    }

  # aws_api_gateway_integration.lambda_integrations["donors-OPTIONS"] will be created
  + resource "aws_api_gateway_integration" "lambda_integrations" {
      + cache_namespace         = (known after apply)
      + connection_type         = "INTERNET"
      + http_method             = "OPTIONS"
      + id                      = (known after apply)
      + integration_http_method = "POST"
      + passthrough_behavior    = (known after apply)
      + region                  = "us-east-2"
      + resource_id             = "hybur2"
      + rest_api_id             = "2apxzxb0r8"
      + timeout_milliseconds    = 29000
      + type                    = "AWS_PROXY"
      + uri                     = "arn:aws:apigateway:us-east-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-2:489881683177:function:branch-donors/invocations"
    }

  # aws_api_gateway_integration.lambda_integrations["expenditures-OPTIONS"] will be created
  + resource "aws_api_gateway_integration" "lambda_integrations" {
      + cache_namespace         = (known after apply)
      + connection_type         = "INTERNET"
      + http_method             = "OPTIONS"
      + id                      = (known after apply)
      + integration_http_method = "POST"
      + passthrough_behavior    = (known after apply)
      + region                  = "us-east-2"
      + resource_id             = "6sdj3w"
      + rest_api_id             = "2apxzxb0r8"
      + timeout_milliseconds    = 29000
      + type                    = "AWS_PROXY"
      + uri                     = "arn:aws:apigateway:us-east-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-2:489881683177:function:branch-expenditures/invocations"
    }

  # aws_api_gateway_integration.lambda_integrations["projects-OPTIONS"] will be created
  + resource "aws_api_gateway_integration" "lambda_integrations" {
      + cache_namespace         = (known after apply)
      + connection_type         = "INTERNET"
      + http_method             = "OPTIONS"
      + id                      = (known after apply)
      + integration_http_method = "POST"
      + passthrough_behavior    = (known after apply)
      + region                  = "us-east-2"
      + resource_id             = "chhy2i"
      + rest_api_id             = "2apxzxb0r8"
      + timeout_milliseconds    = 29000
      + type                    = "AWS_PROXY"
      + uri                     = "arn:aws:apigateway:us-east-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-2:489881683177:function:branch-projects/invocations"
    }

  # aws_api_gateway_integration.lambda_integrations["reports-OPTIONS"] will be created
  + resource "aws_api_gateway_integration" "lambda_integrations" {
      + cache_namespace         = (known after apply)
      + connection_type         = "INTERNET"
      + http_method             = "OPTIONS"
      + id                      = (known after apply)
      + integration_http_method = "POST"
      + passthrough_behavior    = (known after apply)
      + region                  = "us-east-2"
      + resource_id             = "wsnfk2"
      + rest_api_id             = "2apxzxb0r8"
      + timeout_milliseconds    = 29000
      + type                    = "AWS_PROXY"
      + uri                     = "arn:aws:apigateway:us-east-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-2:489881683177:function:branch-reports/invocations"
    }

  # aws_api_gateway_integration.lambda_integrations["users-OPTIONS"] will be created
  + resource "aws_api_gateway_integration" "lambda_integrations" {
      + cache_namespace         = (known after apply)
      + connection_type         = "INTERNET"
      + http_method             = "OPTIONS"
      + id                      = (known after apply)
      + integration_http_method = "POST"
      + passthrough_behavior    = (known after apply)
      + region                  = "us-east-2"
      + resource_id             = "0dkbds"
      + rest_api_id             = "2apxzxb0r8"
      + timeout_milliseconds    = 29000
      + type                    = "AWS_PROXY"
      + uri                     = "arn:aws:apigateway:us-east-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-2:489881683177:function:branch-users/invocations"
    }

  # aws_api_gateway_method.lambda_methods["auth-OPTIONS"] will be created
  + resource "aws_api_gateway_method" "lambda_methods" {
      + api_key_required = false
      + authorization    = "NONE"
      + http_method      = "OPTIONS"
      + id               = (known after apply)
      + region           = "us-east-2"
      + resource_id      = "u8unad"
      + rest_api_id      = "2apxzxb0r8"
    }

  # aws_api_gateway_method.lambda_methods["donors-OPTIONS"] will be created
  + resource "aws_api_gateway_method" "lambda_methods" {
      + api_key_required = false
      + authorization    = "NONE"
      + http_method      = "OPTIONS"
      + id               = (known after apply)
      + region           = "us-east-2"
      + resource_id      = "hybur2"
      + rest_api_id      = "2apxzxb0r8"
    }

  # aws_api_gateway_method.lambda_methods["expenditures-OPTIONS"] will be created
  + resource "aws_api_gateway_method" "lambda_methods" {
      + api_key_required = false
      + authorization    = "NONE"
      + http_method      = "OPTIONS"
      + id               = (known after apply)
      + region           = "us-east-2"
      + resource_id      = "6sdj3w"
      + rest_api_id      = "2apxzxb0r8"
    }

  # aws_api_gateway_method.lambda_methods["projects-OPTIONS"] will be created
  + resource "aws_api_gateway_method" "lambda_methods" {
      + api_key_required = false
      + authorization    = "NONE"
      + http_method      = "OPTIONS"
      + id               = (known after apply)
      + region           = "us-east-2"
      + resource_id      = "chhy2i"
      + rest_api_id      = "2apxzxb0r8"
    }

  # aws_api_gateway_method.lambda_methods["reports-OPTIONS"] will be created
  + resource "aws_api_gateway_method" "lambda_methods" {
      + api_key_required = false
      + authorization    = "NONE"
      + http_method      = "OPTIONS"
      + id               = (known after apply)
      + region           = "us-east-2"
      + resource_id      = "wsnfk2"
      + rest_api_id      = "2apxzxb0r8"
    }

  # aws_api_gateway_method.lambda_methods["users-OPTIONS"] will be created
  + resource "aws_api_gateway_method" "lambda_methods" {
      + api_key_required = false
      + authorization    = "NONE"
      + http_method      = "OPTIONS"
      + id               = (known after apply)
      + region           = "us-east-2"
      + resource_id      = "0dkbds"
      + rest_api_id      = "2apxzxb0r8"
    }

  # aws_api_gateway_stage.branch_stage will be updated in-place
  ~ resource "aws_api_gateway_stage" "branch_stage" {
      ~ deployment_id         = "klh9jv" -> (known after apply)
        id                    = "ags-2apxzxb0r8-prod"
        tags                  = {}
        # (15 unchanged attributes hidden)
    }

Plan: 15 to add, 1 to change, 1 to destroy.

─────────────────────────────────────────────────────────────────────────────

Saved the plan to: tfplan

To perform exactly these actions, run the following command to apply:
    terraform apply "tfplan"

Pushed by: @nourshoreibah, Action: pull_request

@nourshoreibah nourshoreibah merged commit c123af3 into main Jul 1, 2026
14 checks passed
@nourshoreibah nourshoreibah deleted the fix/preview-api-cors-preflight branch July 1, 2026 06:14
github-actions Bot added a commit that referenced this pull request Jul 1, 2026
nourshoreibah added a commit that referenced this pull request Jul 1, 2026
)

* fix: bundle lambda deps + add {proxy+} routing (fixes 502/CORS outage)

Every lambda returned 502 InternalServerErrorException on every request
(including OPTIONS preflight), which the browser surfaced as an opaque
"CORS error" on all routes in both prod and preview.

Root cause: the `package` script was `tsc && zip dist` — tsc only
transpiles, it does not bundle dependencies, so the deploy zip contained
the compiled handler but no node_modules. At cold start `require('kysely')`
/ `require('pg')` / etc. failed with Runtime.ImportModuleError, so the
function crashed at init — before the handler's OPTIONS short-circuit ran.
A 502 preflight fails the browser's CORS check regardless of the CORS
headers added in #277.

Fixes, in one PR:

Bundling
- package script now bundles with esbuild into a single self-contained
  handler.js (deps inlined). Verified the bundle loads and runs with no
  node_modules present.
- reports keeps its Roboto TTFs: esbuild can't inline the font files
  pdfmake reads at render time, so the package step copies them to
  dist/fonts/Roboto and report-service resolves FONT_DIR from __dirname
  (falls back to node_modules in local dev). Verified PDF generation works
  from the bundle with only the shipped fonts.
- moved `jest` from dependencies to devDependencies (it was wrongly a
  runtime dep) and added `esbuild` as a devDependency; lockfiles regenerated
  so `npm ci` stays in sync.

Routing
- API Gateway only had single-level resources (/auth, /donors, ...), so
  sub-paths like /auth/login and /projects/{id} matched no method and
  returned 403 — never reaching the lambda. Added a {proxy+} greedy child
  per service with an ANY method (covers OPTIONS preflight too), in both
  prod and preview, and bumped the deployment redeploy trigger.
- handlers strip their own /service mount prefix from rawPath so the
  existing route table (written for bare paths like /login, /{id}) matches;
  no-op for local dev where paths are already bare.
- added the missing OPTIONS short-circuit to the reports and users handlers
  (the other four already had it) so preflight returns 200.

Verified: all 6 bundles build; OPTIONS=200 on every service; POST
/auth/login reaches the login handler (400, not 404); auth unit tests pass;
terraform validate passes for both workspaces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: auto-format terraform and update documentation

  - Auto-formatted .tf files with terraform fmt
  - Updated README.md with terraform-docs

  Co-authored-by: nourshoreibah <nourshoreibah@users.noreply.github.com>

* chore: regenerate lambda READMEs

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant