Skip to content

Commit 86c9d7d

Browse files
gmoonclaude
andcommitted
Add subscribe Lambda with API Gateway, DynamoDB, and security hardening
- Lambda handler: Origin check, rate limiting, honeypot, body size cap, RFC 5321 email validation, conditional put (no overwrites) - setup-subscribe.sh: DynamoDB (PAY_PER_REQUEST, PITR, deletion protection), IAM role (PutItem only), Lambda, API Gateway HTTP API with throttling (10 req/s sustained, 50 burst) - setup-subscribe-domain.sh: ACM cert, custom domain mapping, Route 53 DNS for app.forkzero.ai/api/subscribe Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ceda423 commit 86c9d7d

3 files changed

Lines changed: 667 additions & 0 deletions

File tree

infra/setup-subscribe-domain.sh

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
# ===========================================================================
5+
# Custom domain setup for the subscribe API: app.forkzero.ai
6+
#
7+
# Creates/updates:
8+
# 1. ACM certificate for app.forkzero.ai (with DNS validation)
9+
# 2. API Gateway custom domain mapping
10+
# 3. Route 53 DNS record pointing to the API Gateway domain
11+
# 4. Updates API route from POST / to POST /api/subscribe
12+
#
13+
# Prerequisites:
14+
# - AWS CLI v2 configured with appropriate credentials
15+
# - jq installed
16+
# - setup-subscribe.sh already run (API Gateway + Lambda exist)
17+
#
18+
# Usage:
19+
# ./infra/setup-subscribe-domain.sh
20+
#
21+
# Note: ACM DNS validation may take a few minutes on first run.
22+
# Re-run the script if it times out — it is idempotent.
23+
# ===========================================================================
24+
25+
REGION="${AWS_DEFAULT_REGION:-us-east-1}"
26+
DOMAIN="app.forkzero.ai"
27+
API_NAME="forkzero-subscribe-api"
28+
HOSTED_ZONE_NAME="forkzero.ai."
29+
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
30+
31+
echo "==> Region: ${REGION} | Account: ${ACCOUNT_ID}"
32+
33+
# ---- Find existing API Gateway ----
34+
echo ""
35+
echo "==> Looking up API Gateway: ${API_NAME}"
36+
37+
API_ID=$(aws apigatewayv2 get-apis \
38+
--region "${REGION}" \
39+
--query "Items[?Name=='${API_NAME}'].ApiId | [0]" \
40+
--output text 2>/dev/null || true)
41+
42+
if [ -z "${API_ID}" ] || [ "${API_ID}" = "None" ]; then
43+
echo " ERROR: API Gateway '${API_NAME}' not found. Run setup-subscribe.sh first." >&2
44+
exit 1
45+
fi
46+
echo " API ID: ${API_ID}"
47+
48+
# ---- Update route to POST /api/subscribe ----
49+
echo ""
50+
echo "==> Configuring API route"
51+
52+
# Remove old POST / route if it exists
53+
OLD_ROUTE_ID=$(aws apigatewayv2 get-routes \
54+
--api-id "${API_ID}" \
55+
--region "${REGION}" \
56+
--query "Items[?RouteKey=='POST /'].RouteId | [0]" --output text 2>/dev/null || true)
57+
58+
if [ -n "${OLD_ROUTE_ID}" ] && [ "${OLD_ROUTE_ID}" != "None" ]; then
59+
echo " Removing old POST / route..."
60+
aws apigatewayv2 delete-route \
61+
--api-id "${API_ID}" \
62+
--route-id "${OLD_ROUTE_ID}" \
63+
--region "${REGION}"
64+
fi
65+
66+
# Get integration ID
67+
INTEGRATION_ID=$(aws apigatewayv2 get-integrations \
68+
--api-id "${API_ID}" \
69+
--region "${REGION}" \
70+
--query 'Items[0].IntegrationId' --output text)
71+
72+
# Create POST /api/subscribe route if missing
73+
ROUTE_ID=$(aws apigatewayv2 get-routes \
74+
--api-id "${API_ID}" \
75+
--region "${REGION}" \
76+
--query "Items[?RouteKey=='POST /api/subscribe'].RouteId | [0]" --output text 2>/dev/null || true)
77+
78+
if [ -z "${ROUTE_ID}" ] || [ "${ROUTE_ID}" = "None" ]; then
79+
echo " Creating POST /api/subscribe route..."
80+
aws apigatewayv2 create-route \
81+
--api-id "${API_ID}" \
82+
--route-key "POST /api/subscribe" \
83+
--target "integrations/${INTEGRATION_ID}" \
84+
--region "${REGION}" > /dev/null
85+
else
86+
echo " Route POST /api/subscribe already exists."
87+
fi
88+
89+
# ---- ACM Certificate ----
90+
echo ""
91+
echo "==> Configuring ACM certificate for ${DOMAIN}"
92+
93+
CERT_ARN=$(aws acm list-certificates \
94+
--region "${REGION}" \
95+
--query "CertificateSummaryList[?DomainName=='${DOMAIN}'].CertificateArn | [0]" \
96+
--output text 2>/dev/null || true)
97+
98+
if [ -z "${CERT_ARN}" ] || [ "${CERT_ARN}" = "None" ]; then
99+
echo " Requesting certificate..."
100+
CERT_ARN=$(aws acm request-certificate \
101+
--domain-name "${DOMAIN}" \
102+
--validation-method DNS \
103+
--region "${REGION}" \
104+
--query 'CertificateArn' --output text)
105+
echo " Certificate requested: ${CERT_ARN}"
106+
echo " Waiting for DNS validation record..."
107+
sleep 5
108+
else
109+
echo " Certificate exists: ${CERT_ARN}"
110+
fi
111+
112+
# ---- DNS validation ----
113+
echo ""
114+
echo "==> Setting up DNS validation"
115+
116+
HOSTED_ZONE_ID=$(aws route53 list-hosted-zones \
117+
--query "HostedZones[?Name=='${HOSTED_ZONE_NAME}'].Id | [0]" \
118+
--output text | sed 's|/hostedzone/||')
119+
120+
if [ -z "${HOSTED_ZONE_ID}" ] || [ "${HOSTED_ZONE_ID}" = "None" ]; then
121+
echo " ERROR: Hosted zone for ${HOSTED_ZONE_NAME} not found." >&2
122+
exit 1
123+
fi
124+
125+
# Get validation CNAME
126+
VALIDATION_NAME=$(aws acm describe-certificate \
127+
--certificate-arn "${CERT_ARN}" \
128+
--region "${REGION}" \
129+
--query 'Certificate.DomainValidationOptions[0].ResourceRecord.Name' --output text)
130+
131+
VALIDATION_VALUE=$(aws acm describe-certificate \
132+
--certificate-arn "${CERT_ARN}" \
133+
--region "${REGION}" \
134+
--query 'Certificate.DomainValidationOptions[0].ResourceRecord.Value' --output text)
135+
136+
if [ -n "${VALIDATION_NAME}" ] && [ "${VALIDATION_NAME}" != "None" ]; then
137+
echo " Upserting validation CNAME: ${VALIDATION_NAME}"
138+
aws route53 change-resource-record-sets \
139+
--hosted-zone-id "${HOSTED_ZONE_ID}" \
140+
--change-batch "{
141+
\"Changes\": [{
142+
\"Action\": \"UPSERT\",
143+
\"ResourceRecordSet\": {
144+
\"Name\": \"${VALIDATION_NAME}\",
145+
\"Type\": \"CNAME\",
146+
\"TTL\": 300,
147+
\"ResourceRecords\": [{\"Value\": \"${VALIDATION_VALUE}\"}]
148+
}
149+
}]
150+
}" > /dev/null
151+
fi
152+
153+
# Wait for certificate to be issued
154+
CERT_STATUS=$(aws acm describe-certificate \
155+
--certificate-arn "${CERT_ARN}" \
156+
--region "${REGION}" \
157+
--query 'Certificate.Status' --output text)
158+
159+
if [ "${CERT_STATUS}" != "ISSUED" ]; then
160+
echo " Waiting for certificate validation (this may take 1-5 minutes)..."
161+
aws acm wait certificate-validated \
162+
--certificate-arn "${CERT_ARN}" \
163+
--region "${REGION}"
164+
fi
165+
echo " Certificate issued."
166+
167+
# ---- API Gateway Custom Domain ----
168+
echo ""
169+
echo "==> Configuring custom domain: ${DOMAIN}"
170+
171+
EXISTING_DOMAIN=$(aws apigatewayv2 get-domain-names \
172+
--region "${REGION}" \
173+
--query "Items[?DomainName=='${DOMAIN}'].DomainName | [0]" \
174+
--output text 2>/dev/null || true)
175+
176+
if [ -z "${EXISTING_DOMAIN}" ] || [ "${EXISTING_DOMAIN}" = "None" ]; then
177+
echo " Creating custom domain..."
178+
aws apigatewayv2 create-domain-name \
179+
--domain-name "${DOMAIN}" \
180+
--domain-name-configurations "CertificateArn=${CERT_ARN}" \
181+
--region "${REGION}" > /dev/null
182+
else
183+
echo " Custom domain already exists."
184+
fi
185+
186+
# Get the target domain name for DNS
187+
TARGET_DOMAIN=$(aws apigatewayv2 get-domain-name \
188+
--domain-name "${DOMAIN}" \
189+
--region "${REGION}" \
190+
--query 'DomainNameConfigurations[0].ApiGatewayDomainName' --output text)
191+
192+
echo " Target: ${TARGET_DOMAIN}"
193+
194+
# Create/update API mapping
195+
MAPPING_ID=$(aws apigatewayv2 get-api-mappings \
196+
--domain-name "${DOMAIN}" \
197+
--region "${REGION}" \
198+
--query "Items[?ApiId=='${API_ID}'].ApiMappingId | [0]" \
199+
--output text 2>/dev/null || true)
200+
201+
if [ -z "${MAPPING_ID}" ] || [ "${MAPPING_ID}" = "None" ]; then
202+
echo " Creating API mapping..."
203+
aws apigatewayv2 create-api-mapping \
204+
--domain-name "${DOMAIN}" \
205+
--api-id "${API_ID}" \
206+
--stage '$default' \
207+
--region "${REGION}" > /dev/null
208+
else
209+
echo " API mapping already exists."
210+
fi
211+
212+
# ---- Route 53 DNS record ----
213+
echo ""
214+
echo "==> Creating DNS record: ${DOMAIN} -> ${TARGET_DOMAIN}"
215+
216+
aws route53 change-resource-record-sets \
217+
--hosted-zone-id "${HOSTED_ZONE_ID}" \
218+
--change-batch "{
219+
\"Changes\": [{
220+
\"Action\": \"UPSERT\",
221+
\"ResourceRecordSet\": {
222+
\"Name\": \"${DOMAIN}\",
223+
\"Type\": \"CNAME\",
224+
\"TTL\": 300,
225+
\"ResourceRecords\": [{\"Value\": \"${TARGET_DOMAIN}\"}]
226+
}
227+
}]
228+
}" > /dev/null
229+
230+
echo " DNS record set."
231+
232+
# ---- Done ----
233+
echo ""
234+
echo "==========================================================================="
235+
echo " Custom domain configured: https://${DOMAIN}/api/subscribe"
236+
echo ""
237+
echo " DNS may take a few minutes to propagate."
238+
echo ""
239+
echo " Update src/constants.ts:"
240+
echo " export const SUBSCRIBE_API_URL = 'https://${DOMAIN}/api/subscribe'"
241+
echo ""
242+
echo " Test:"
243+
echo " curl -X POST https://${DOMAIN}/api/subscribe \\"
244+
echo " -H 'Content-Type: application/json' \\"
245+
echo " -d '{\"email\":\"test@example.com\"}'"
246+
echo "==========================================================================="

0 commit comments

Comments
 (0)