2026-08-07 19:19:51 -04:00
# HTTP API fronting the order form.
#
# Three authorization modes coexist, matching template.yaml:
2026-09-17 19:34:03 -04:00
# NONE — public form routes plus GET /api/orders/{week} (bearer checked in Lambda)
2026-08-07 19:19:51 -04:00
# AWS_IAM — the weekly-menu publication routes, called with SigV4 by
# scripts/upload_menu.py from GitHub Actions
# CUSTOM — every /api/admin route, behind the Google ID token authorizer
#
2026-09-17 19:34:03 -04:00
# All eleven routes integrate with submit-order, which dispatches internally on
2026-08-07 19:19:51 -04:00
# the route key.
resource " aws_apigatewayv2_api " " order_api " {
name = local . project
protocol_type = " HTTP "
description = " meal-order-manager order form and admin API "
cors_configuration {
2026-09-15 21:41:10 +00:00
allow_origins = [
" https://orders.seahaven.com " ,
" https://internal.seahaven.com " ,
" https://internal.dev.seahaven.com " ,
" http://localhost:5173 " ,
" http://localhost:4173 " ,
]
allow_methods = [ " GET " , " POST " , " PUT " , " DELETE " , " OPTIONS " ]
allow_headers = [ " Authorization " , " Content-Type " ]
allow_credentials = false
max_age = 3600
2026-08-07 19:19:51 -04:00
}
}
# Result caching is off. With caching, an expired Google token or an admin
# removed from the allow-list would stay authorized for the cache TTL, and the
# tokeninfo call dominates latency anyway.
2026-08-10 17:17:51 -04:00
#
# Invoke permission is a Lambda resource policy (below), not AuthorizerCredentialsArn.
# The credentials-role path returned 500 without invoking the authorizer in prod
# (PLAT-102); resource policy matches the submit-order route grants and the live hotfix.
2026-08-07 19:19:51 -04:00
resource " aws_apigatewayv2_authorizer " " admin_google " {
api_id = aws_apigatewayv2_api . order_api . id
name = " AdminGoogleAuthorizer "
authorizer_type = " REQUEST "
authorizer_uri = aws_lambda_function . admin_authorizer . invoke_arn
authorizer_payload_format_version = " 2.0 "
authorizer_result_ttl_in_seconds = 0
enable_simple_responses = true
identity_sources = [ " $ request.header.Authorization " ]
}
resource " aws_apigatewayv2_integration " " submit_order " {
api_id = aws_apigatewayv2_api . order_api . id
integration_type = " AWS_PROXY "
integration_method = " POST "
integration_uri = aws_lambda_function . submit_order . invoke_arn
payload_format_version = " 2.0 "
timeout_milliseconds = 30000
}
resource " aws_apigatewayv2_route " " this " {
for_each = local . api_routes
api_id = aws_apigatewayv2_api . order_api . id
route_key = each . value . route_key
target = " integrations/ ${ aws_apigatewayv2_integration . submit_order . id } "
authorization_type = each . value . authorizer
authorizer_id = each . value . authorizer == " CUSTOM " ? aws_apigatewayv2_authorizer . admin_google . id : null
}
resource " aws_apigatewayv2_stage " " default " {
api_id = aws_apigatewayv2_api . order_api . id
name = " $ default "
auto_deploy = true
access_log_settings {
destination_arn = aws_cloudwatch_log_group . api_access . arn
format = " { \ " requestId \ " : \ " $ context . requestId \ " , \ " ip \ " : \ " $ context . identity . sourceIp \ " , \ " requestTime \ " : \ " $ context . requestTime \ " , \ " method \ " : \ " $ context . httpMethod \ " , \ " routeKey \ " : \ " $ context . routeKey \ " , \ " status \ " : \ " $ context . status \ " , \ " protocol \ " : \ " $ context . protocol \ " , \ " responseLength \ " : \ " $ context . responseLength \ " , \ " integrationError \ " : \ " $ context . integrationErrorMessage \ " } "
}
default_route_settings {
throttling_burst_limit = 50
throttling_rate_limit = 100
}
# Order submission is human-paced; menu publication runs once a week. Both are
# throttled well below the account default so a loop in either client cannot
# exhaust the API's burst budget for the public form.
route_settings {
route_key = " POST /api/submit-order "
throttling_burst_limit = 10
throttling_rate_limit = 5
}
route_settings {
route_key = " POST /api/publish/menu "
throttling_burst_limit = 2
throttling_rate_limit = 1
}
depends_on = [ aws_apigatewayv2_route . this ]
}
# One grant per route rather than a single wildcard, so adding a route to the
# API does not silently make the function invocable through it.
resource " aws_lambda_permission " " api_route " {
for_each = local . api_routes
statement_id = " AllowApiGatewayInvoke- ${ each . key } "
action = " lambda:InvokeFunction "
function_name = aws_lambda_function . submit_order . function_name
principal = " apigateway.amazonaws.com "
source_arn = " ${ aws_apigatewayv2_api . order_api . execution_arn } /*/ ${ each . value . permission_source } "
}
2026-08-10 17:17:51 -04:00
# Grant API Gateway permission to invoke the admin authorizer Lambda. Source ARN
# is the authorizer itself (not a route), matching the AWS HTTP API docs.
# Import adopts the PLAT-102 live hotfix statement so the first apply does not
# attempt a duplicate AddPermission.
import {
to = aws_lambda_permission . admin_authorizer
id = " meal-order-manager-admin-authorizer/AllowApiGatewayInvokeAuthorizer "
}
resource " aws_lambda_permission " " admin_authorizer " {
statement_id = " AllowApiGatewayInvokeAuthorizer "
action = " lambda:InvokeFunction "
function_name = aws_lambda_function . admin_authorizer . function_name
principal = " apigateway.amazonaws.com "
source_arn = " ${ aws_apigatewayv2_api . order_api . execution_arn } /authorizers/ ${ aws_apigatewayv2_authorizer . admin_google . id } "
}
2026-08-10 17:48:05 -04:00
# PLAT-102 follow-up: role already deleted in AWS after apply failed on
# iam:ListInstanceProfilesForRole. Drop from state without a destroy call.
removed {
from = aws_iam_role . admin_authorizer_invoke
lifecycle {
destroy = false
}
}