thinkwork-cli 0.5.4 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,17 @@
1
1
  ################################################################################
2
2
  # SES Email — App Module
3
3
  #
4
- # Configures SES for email inbound (receipt rules) and domain verification.
5
- # Full Lambda wiring comes in Phase 4 when email handlers are migrated.
6
- # Phase 1 creates the domain identity and DKIM records only.
4
+ # Wires up inbound and outbound email for a delegated subdomain
5
+ # (e.g. agents.thinkwork.ai). The module:
6
+ #
7
+ # 1. Creates a Route53 hosted zone for the subdomain (Option A — delegated
8
+ # subzone). The operator pastes the output name servers at whatever hosts
9
+ # the parent domain (Google, Squarespace, Cloudflare, etc.).
10
+ # 2. Creates the SES domain identity + DKIM tokens.
11
+ # 3. Writes the SES verification TXT, DKIM CNAMEs, and an MX record into the
12
+ # new subzone.
13
+ # 4. Creates an SES receipt rule set that stores inbound mail in S3 at
14
+ # `email/inbound/<sesMessageId>` and invokes the email-inbound Lambda.
7
15
  ################################################################################
8
16
 
9
17
  variable "stage" {
@@ -16,36 +24,191 @@ variable "account_id" {
16
24
  type = string
17
25
  }
18
26
 
27
+ variable "region" {
28
+ description = "AWS region (determines the inbound SMTP endpoint)"
29
+ type = string
30
+ default = "us-east-1"
31
+ }
32
+
19
33
  variable "email_domain" {
20
- description = "Domain for SES email (e.g. thinkwork.ai)"
34
+ description = "Subdomain used for agent email (e.g. agents.thinkwork.ai). Leave empty to skip all SES resources."
35
+ type = string
36
+ default = ""
37
+ }
38
+
39
+ variable "inbound_bucket_name" {
40
+ description = "S3 bucket that SES writes raw inbound .eml files into. Its policy must already allow ses.amazonaws.com PutObject."
21
41
  type = string
22
42
  default = ""
23
43
  }
24
44
 
45
+ variable "email_inbound_fn_arn" {
46
+ description = "ARN of the email-inbound Lambda. If empty, receipt rule is still created but without a Lambda action."
47
+ type = string
48
+ default = ""
49
+ }
50
+
51
+ variable "email_inbound_fn_name" {
52
+ description = "Function name of the email-inbound Lambda (for the Lambda permission)."
53
+ type = string
54
+ default = ""
55
+ }
56
+
57
+ variable "manage_active_rule_set" {
58
+ description = "Activate the receipt rule set. Only ONE rule set can be active per region per account, so set false in secondary stages that share an account."
59
+ type = bool
60
+ default = true
61
+ }
62
+
63
+ locals {
64
+ enabled = var.email_domain != ""
65
+ inbound_smtp = "inbound-smtp.${var.region}.amazonaws.com"
66
+ rule_set_name = "thinkwork-${var.stage}-email-rules"
67
+ has_lambda = var.email_inbound_fn_arn != ""
68
+ has_bucket = var.inbound_bucket_name != ""
69
+ }
70
+
71
+ ################################################################################
72
+ # Route53 — delegated subzone for the agent email subdomain
73
+ ################################################################################
74
+
75
+ resource "aws_route53_zone" "agents" {
76
+ count = local.enabled ? 1 : 0
77
+ name = var.email_domain
78
+
79
+ tags = {
80
+ Name = "thinkwork-${var.stage}-email-zone"
81
+ Stage = var.stage
82
+ }
83
+ }
84
+
25
85
  ################################################################################
26
- # SES Domain Identity (only if email_domain is provided)
86
+ # SES Domain Identity + DKIM
27
87
  ################################################################################
28
88
 
29
89
  resource "aws_ses_domain_identity" "main" {
30
- count = var.email_domain != "" ? 1 : 0
90
+ count = local.enabled ? 1 : 0
31
91
  domain = var.email_domain
32
92
  }
33
93
 
34
94
  resource "aws_ses_domain_dkim" "main" {
35
- count = var.email_domain != "" ? 1 : 0
95
+ count = local.enabled ? 1 : 0
36
96
  domain = aws_ses_domain_identity.main[0].domain
37
97
  }
38
98
 
99
+ ################################################################################
100
+ # DNS records in the subzone — verification TXT, DKIM CNAMEs, MX
101
+ ################################################################################
102
+
103
+ resource "aws_route53_record" "ses_verification" {
104
+ count = local.enabled ? 1 : 0
105
+ zone_id = aws_route53_zone.agents[0].zone_id
106
+ name = "_amazonses.${var.email_domain}"
107
+ type = "TXT"
108
+ ttl = 600
109
+ records = [aws_ses_domain_identity.main[0].verification_token]
110
+ }
111
+
112
+ resource "aws_route53_record" "dkim" {
113
+ count = local.enabled ? 3 : 0
114
+ zone_id = aws_route53_zone.agents[0].zone_id
115
+ name = "${aws_ses_domain_dkim.main[0].dkim_tokens[count.index]}._domainkey.${var.email_domain}"
116
+ type = "CNAME"
117
+ ttl = 600
118
+ records = ["${aws_ses_domain_dkim.main[0].dkim_tokens[count.index]}.dkim.amazonses.com"]
119
+ }
120
+
121
+ resource "aws_route53_record" "mx" {
122
+ count = local.enabled ? 1 : 0
123
+ zone_id = aws_route53_zone.agents[0].zone_id
124
+ name = var.email_domain
125
+ type = "MX"
126
+ ttl = 600
127
+ records = ["10 ${local.inbound_smtp}"]
128
+ }
129
+
130
+ ################################################################################
131
+ # SES Receipt Rule Set + Rule → S3 + Lambda
132
+ ################################################################################
133
+
134
+ resource "aws_ses_receipt_rule_set" "main" {
135
+ count = local.enabled ? 1 : 0
136
+ rule_set_name = local.rule_set_name
137
+ }
138
+
139
+ resource "aws_ses_active_receipt_rule_set" "main" {
140
+ count = local.enabled && var.manage_active_rule_set ? 1 : 0
141
+ rule_set_name = aws_ses_receipt_rule_set.main[0].rule_set_name
142
+ }
143
+
144
+ resource "aws_lambda_permission" "ses_invoke_email_inbound" {
145
+ count = local.enabled && local.has_lambda ? 1 : 0
146
+ statement_id = "AllowSESInvokeEmailInbound"
147
+ action = "lambda:InvokeFunction"
148
+ function_name = var.email_inbound_fn_name
149
+ principal = "ses.amazonaws.com"
150
+ source_account = var.account_id
151
+ }
152
+
153
+ resource "aws_ses_receipt_rule" "inbound" {
154
+ count = local.enabled ? 1 : 0
155
+ name = "thinkwork-${var.stage}-inbound-email"
156
+ rule_set_name = aws_ses_receipt_rule_set.main[0].rule_set_name
157
+ recipients = [var.email_domain]
158
+ enabled = true
159
+ scan_enabled = true
160
+
161
+ dynamic "s3_action" {
162
+ for_each = local.has_bucket ? [1] : []
163
+ content {
164
+ bucket_name = var.inbound_bucket_name
165
+ object_key_prefix = "email/inbound/"
166
+ position = 1
167
+ }
168
+ }
169
+
170
+ dynamic "lambda_action" {
171
+ for_each = local.has_lambda ? [1] : []
172
+ content {
173
+ function_arn = var.email_inbound_fn_arn
174
+ invocation_type = "Event"
175
+ position = local.has_bucket ? 2 : 1
176
+ }
177
+ }
178
+
179
+ depends_on = [aws_lambda_permission.ses_invoke_email_inbound]
180
+ }
181
+
39
182
  ################################################################################
40
183
  # Outputs
41
184
  ################################################################################
42
185
 
43
186
  output "ses_domain_identity_arn" {
44
187
  description = "SES domain identity ARN"
45
- value = var.email_domain != "" ? aws_ses_domain_identity.main[0].arn : null
188
+ value = local.enabled ? aws_ses_domain_identity.main[0].arn : null
46
189
  }
47
190
 
48
191
  output "dkim_tokens" {
49
- description = "DKIM tokens for DNS verification"
50
- value = var.email_domain != "" ? aws_ses_domain_dkim.main[0].dkim_tokens : []
192
+ description = "DKIM tokens (already written as CNAMEs in the subzone)"
193
+ value = local.enabled ? aws_ses_domain_dkim.main[0].dkim_tokens : []
194
+ }
195
+
196
+ output "zone_id" {
197
+ description = "Route53 hosted zone ID for the email subdomain"
198
+ value = local.enabled ? aws_route53_zone.agents[0].zone_id : null
199
+ }
200
+
201
+ output "name_servers" {
202
+ description = "Name servers for the delegated email subzone. Paste these as NS records at the registrar that hosts the parent domain (e.g. Google Domains) before SES can verify."
203
+ value = local.enabled ? aws_route53_zone.agents[0].name_servers : []
204
+ }
205
+
206
+ output "mx_target" {
207
+ description = "MX target host for the email subdomain"
208
+ value = local.enabled ? local.inbound_smtp : null
209
+ }
210
+
211
+ output "rule_set_name" {
212
+ description = "SES receipt rule set name"
213
+ value = local.enabled ? aws_ses_receipt_rule_set.main[0].rule_set_name : null
51
214
  }
@@ -33,6 +33,12 @@ variable "certificate_arn" {
33
33
  default = ""
34
34
  }
35
35
 
36
+ variable "is_spa" {
37
+ description = "When true, configure CloudFront for a single-page app: drop the directory-rewrite function and fall back to /index.html with 200 on 403/404 so the client router can handle deep links."
38
+ type = bool
39
+ default = false
40
+ }
41
+
36
42
  ################################################################################
37
43
  # S3 Bucket
38
44
  ################################################################################
@@ -74,6 +80,11 @@ resource "aws_cloudfront_origin_access_control" "site" {
74
80
  #
75
81
  # S3 with OAC doesn't auto-serve index.html for subdirectory requests.
76
82
  # /getting-started/ → /getting-started/index.html
83
+ #
84
+ # Always created (so flipping is_spa on an existing distribution doesn't hit
85
+ # CloudFront's "can't delete a function still associated with a distribution"
86
+ # error), but the viewer-request association is only wired up for non-SPA
87
+ # sites. For SPAs we rely on the 403/404 → /index.html fallback below.
77
88
  ################################################################################
78
89
 
79
90
  resource "aws_cloudfront_function" "rewrite" {
@@ -94,6 +105,19 @@ resource "aws_cloudfront_function" "rewrite" {
94
105
  EOF
95
106
  }
96
107
 
108
+ locals {
109
+ # For SPAs, 403/404 from S3 means "not a real asset" — serve index.html with
110
+ # a 200 so the client router can resolve the route. For directory-style
111
+ # static sites, surface a real 404 page.
112
+ error_responses = var.is_spa ? [
113
+ { error_code = 403, response_code = 200, response_page_path = "/index.html" },
114
+ { error_code = 404, response_code = 200, response_page_path = "/index.html" },
115
+ ] : [
116
+ { error_code = 404, response_code = 404, response_page_path = "/404.html" },
117
+ { error_code = 403, response_code = 404, response_page_path = "/404.html" },
118
+ ]
119
+ }
120
+
97
121
  ################################################################################
98
122
  # CloudFront Distribution
99
123
  ################################################################################
@@ -117,9 +141,12 @@ resource "aws_cloudfront_distribution" "site" {
117
141
  cached_methods = ["GET", "HEAD"]
118
142
  compress = true
119
143
 
120
- function_association {
121
- event_type = "viewer-request"
122
- function_arn = aws_cloudfront_function.rewrite.arn
144
+ dynamic "function_association" {
145
+ for_each = var.is_spa ? [] : [1]
146
+ content {
147
+ event_type = "viewer-request"
148
+ function_arn = aws_cloudfront_function.rewrite.arn
149
+ }
123
150
  }
124
151
 
125
152
  forwarded_values {
@@ -130,17 +157,13 @@ resource "aws_cloudfront_distribution" "site" {
130
157
  }
131
158
  }
132
159
 
133
- # Fallback for true 404s (e.g. deleted pages) — serve the 404 page
134
- custom_error_response {
135
- error_code = 404
136
- response_code = 404
137
- response_page_path = "/404.html"
138
- }
139
-
140
- custom_error_response {
141
- error_code = 403
142
- response_code = 404
143
- response_page_path = "/404.html"
160
+ dynamic "custom_error_response" {
161
+ for_each = local.error_responses
162
+ content {
163
+ error_code = custom_error_response.value.error_code
164
+ response_code = custom_error_response.value.response_code
165
+ response_page_path = custom_error_response.value.response_page_path
166
+ }
144
167
  }
145
168
 
146
169
  restrictions {
@@ -0,0 +1,39 @@
1
+ # www-dns
2
+
3
+ DNS + TLS wiring for the public website, using a Cloudflare-managed zone and an AWS ACM certificate.
4
+
5
+ ## What it creates
6
+
7
+ 1. ACM certificate in `us-east-1` covering the apex and `www` SAN, DNS-validated via Cloudflare records.
8
+ 2. Apex CNAME in Cloudflare pointing at the primary www CloudFront distribution (DNS-only, grey-cloud).
9
+ 3. S3 website-redirect bucket + a second CloudFront distribution that 301s `www.<domain>` → `https://<domain>`, plus its Cloudflare CNAME.
10
+
11
+ ## Why a second CloudFront distribution instead of a Cloudflare page rule
12
+
13
+ The apex and www records must be **DNS-only** so CloudFront can terminate TLS with the ACM cert. DNS-only traffic bypasses Cloudflare's proxy, so Cloudflare page rules and transform rules never run. The redirect has to live at AWS. An S3 website bucket with `redirect_all_requests_to` is the cheapest, simplest thing that works.
14
+
15
+ ## Required environment
16
+
17
+ - `CLOUDFLARE_API_TOKEN` — exported in the shell or CI environment. **Never** committed to tfvars. Rotate after anyone outside the deploy path sees it.
18
+
19
+ ## Usage (from greenfield example)
20
+
21
+ ```hcl
22
+ provider "cloudflare" {
23
+ # token read from CLOUDFLARE_API_TOKEN env var
24
+ }
25
+
26
+ module "www_dns" {
27
+ source = "../../modules/app/www-dns"
28
+ stage = var.stage
29
+ domain = var.www_domain
30
+ cloudflare_zone_id = var.cloudflare_zone_id
31
+ cloudfront_domain_name = module.thinkwork.www_distribution_domain
32
+ }
33
+ ```
34
+
35
+ Then pass `module.www_dns.certificate_arn` back into `module "thinkwork"` as `www_certificate_arn`.
36
+
37
+ ## First-apply ordering
38
+
39
+ On a fresh apply, Terraform has to create the primary CloudFront distribution (via `module.thinkwork.www_site`) before this module can reference its domain name. Terraform's dependency graph handles that automatically. If you're applying after a `terraform destroy`, two `apply` passes are fine — the first resolves the cert + distributions, the second binds the apex CNAME once the CloudFront distribution has deployed.
@@ -0,0 +1,245 @@
1
+ ################################################################################
2
+ # Public Website DNS + TLS (Cloudflare zone, AWS ACM cert, www→apex 301)
3
+ #
4
+ # Responsibilities:
5
+ # 1. ACM certificate in us-east-1 covering apex + www
6
+ # (+ optional docs + optional admin).
7
+ # 2. Cloudflare DNS records for ACM DNS validation.
8
+ # 3. Apex CNAME in Cloudflare → primary CloudFront distribution (DNS-only).
9
+ # 4. Second CloudFront distribution fronting an S3 website-redirect bucket
10
+ # that 301s www.<domain> → https://<domain>, plus its Cloudflare CNAME.
11
+ # 5. Optional docs.<domain> CNAME → docs CloudFront distribution.
12
+ # 6. Optional admin.<domain> CNAME → admin CloudFront distribution.
13
+ #
14
+ # Cloudflare records MUST be DNS-only (grey cloud). CloudFront terminates TLS
15
+ # with the ACM cert and needs the real Host header.
16
+ ################################################################################
17
+
18
+ terraform {
19
+ required_providers {
20
+ aws = {
21
+ source = "hashicorp/aws"
22
+ version = "~> 5.0"
23
+ }
24
+ cloudflare = {
25
+ source = "cloudflare/cloudflare"
26
+ version = "~> 4.0"
27
+ }
28
+ }
29
+ }
30
+
31
+ locals {
32
+ apex = var.domain
33
+ www = "www.${var.domain}"
34
+ docs = "docs.${var.domain}"
35
+ admin = "admin.${var.domain}"
36
+ name_id = replace(var.domain, ".", "-")
37
+
38
+ # ACM SANs: always include www, conditionally include docs and admin.
39
+ # Gated on plain bool vars (not on CloudFront outputs) to keep the
40
+ # dependency graph acyclic — distributions depend on the cert, so
41
+ # the cert mustn't depend on distribution outputs.
42
+ cert_sans = concat(
43
+ [local.www],
44
+ var.include_docs ? [local.docs] : [],
45
+ var.include_admin ? [local.admin] : [],
46
+ )
47
+
48
+ # CNAME records can only be created when we have the distribution
49
+ # domain to point at. Those inputs come after the cert is done, so
50
+ # they don't participate in the cert's dependency graph.
51
+ create_docs_record = var.include_docs && var.docs_cloudfront_domain_name != ""
52
+ create_admin_record = var.include_admin && var.admin_cloudfront_domain_name != ""
53
+ }
54
+
55
+ ################################################################################
56
+ # ACM certificate (us-east-1, covers apex + www [+ docs])
57
+ ################################################################################
58
+
59
+ resource "aws_acm_certificate" "www" {
60
+ domain_name = local.apex
61
+ subject_alternative_names = local.cert_sans
62
+ validation_method = "DNS"
63
+
64
+ lifecycle {
65
+ create_before_destroy = true
66
+ }
67
+
68
+ tags = {
69
+ Name = "thinkwork-${var.stage}-${local.name_id}"
70
+ }
71
+ }
72
+
73
+ resource "cloudflare_record" "acm_validation" {
74
+ for_each = {
75
+ for dvo in aws_acm_certificate.www.domain_validation_options : dvo.domain_name => {
76
+ name = dvo.resource_record_name
77
+ value = dvo.resource_record_value
78
+ type = dvo.resource_record_type
79
+ }
80
+ }
81
+
82
+ zone_id = var.cloudflare_zone_id
83
+ name = trimsuffix(each.value.name, ".")
84
+ content = trimsuffix(each.value.value, ".")
85
+ type = each.value.type
86
+ ttl = 60
87
+ proxied = false
88
+ comment = "ACM DNS validation for ${each.key}"
89
+ }
90
+
91
+ resource "aws_acm_certificate_validation" "www" {
92
+ certificate_arn = aws_acm_certificate.www.arn
93
+ validation_record_fqdns = [for r in cloudflare_record.acm_validation : r.hostname]
94
+ }
95
+
96
+ ################################################################################
97
+ # Apex DNS → primary CloudFront distribution
98
+ #
99
+ # Cloudflare flattens apex CNAMEs automatically. proxied=false is required so
100
+ # CloudFront sees the real Host header and the ACM cert matches.
101
+ ################################################################################
102
+
103
+ resource "cloudflare_record" "apex" {
104
+ zone_id = var.cloudflare_zone_id
105
+ name = local.apex
106
+ content = var.cloudfront_domain_name
107
+ type = "CNAME"
108
+ ttl = 300
109
+ proxied = false
110
+ comment = "thinkwork-${var.stage} apex → CloudFront (www)"
111
+ }
112
+
113
+ ################################################################################
114
+ # www.<domain> → apex 301 redirect
115
+ #
116
+ # Uses an S3 website-redirect bucket (website endpoint, not REST). Fronted by
117
+ # its own CloudFront distribution with the www alias and the same ACM cert.
118
+ ################################################################################
119
+
120
+ resource "aws_s3_bucket" "www_redirect" {
121
+ bucket = "thinkwork-${var.stage}-www-redirect"
122
+
123
+ tags = {
124
+ Name = "thinkwork-${var.stage}-www-redirect"
125
+ }
126
+ }
127
+
128
+ resource "aws_s3_bucket_public_access_block" "www_redirect" {
129
+ bucket = aws_s3_bucket.www_redirect.id
130
+
131
+ block_public_acls = true
132
+ block_public_policy = true
133
+ ignore_public_acls = true
134
+ restrict_public_buckets = true
135
+ }
136
+
137
+ resource "aws_s3_bucket_website_configuration" "www_redirect" {
138
+ bucket = aws_s3_bucket.www_redirect.id
139
+
140
+ redirect_all_requests_to {
141
+ host_name = local.apex
142
+ protocol = "https"
143
+ }
144
+ }
145
+
146
+ resource "aws_cloudfront_distribution" "www_redirect" {
147
+ enabled = true
148
+ aliases = [local.www]
149
+ price_class = "PriceClass_100"
150
+ is_ipv6_enabled = true
151
+ comment = "thinkwork-${var.stage}-www-redirect → ${local.apex}"
152
+
153
+ origin {
154
+ domain_name = aws_s3_bucket_website_configuration.www_redirect.website_endpoint
155
+ origin_id = "s3-website-${aws_s3_bucket.www_redirect.id}"
156
+
157
+ custom_origin_config {
158
+ http_port = 80
159
+ https_port = 443
160
+ origin_protocol_policy = "http-only"
161
+ origin_ssl_protocols = ["TLSv1.2"]
162
+ }
163
+ }
164
+
165
+ default_cache_behavior {
166
+ target_origin_id = "s3-website-${aws_s3_bucket.www_redirect.id}"
167
+ viewer_protocol_policy = "redirect-to-https"
168
+ allowed_methods = ["GET", "HEAD"]
169
+ cached_methods = ["GET", "HEAD"]
170
+ compress = true
171
+
172
+ forwarded_values {
173
+ query_string = false
174
+ cookies {
175
+ forward = "none"
176
+ }
177
+ }
178
+
179
+ min_ttl = 0
180
+ default_ttl = 3600
181
+ max_ttl = 86400
182
+ }
183
+
184
+ restrictions {
185
+ geo_restriction {
186
+ restriction_type = "none"
187
+ }
188
+ }
189
+
190
+ viewer_certificate {
191
+ acm_certificate_arn = aws_acm_certificate_validation.www.certificate_arn
192
+ ssl_support_method = "sni-only"
193
+ minimum_protocol_version = "TLSv1.2_2021"
194
+ }
195
+
196
+ tags = {
197
+ Name = "thinkwork-${var.stage}-www-redirect"
198
+ }
199
+ }
200
+
201
+ resource "cloudflare_record" "www_redirect" {
202
+ zone_id = var.cloudflare_zone_id
203
+ name = local.www
204
+ content = aws_cloudfront_distribution.www_redirect.domain_name
205
+ type = "CNAME"
206
+ ttl = 300
207
+ proxied = false
208
+ comment = "thinkwork-${var.stage} www → redirect distribution"
209
+ }
210
+
211
+ ################################################################################
212
+ # docs.<domain> → docs CloudFront distribution (optional)
213
+ #
214
+ # Created only when the greenfield stack wires docs_cloudfront_domain_name.
215
+ # The docs CloudFront alias picks up the same ACM cert via certificate_arn
216
+ # on the thinkwork module's docs_site.
217
+ ################################################################################
218
+
219
+ resource "cloudflare_record" "docs" {
220
+ count = local.create_docs_record ? 1 : 0
221
+
222
+ zone_id = var.cloudflare_zone_id
223
+ name = local.docs
224
+ content = var.docs_cloudfront_domain_name
225
+ type = "CNAME"
226
+ ttl = 300
227
+ proxied = false
228
+ comment = "thinkwork-${var.stage} docs → CloudFront"
229
+ }
230
+
231
+ ################################################################################
232
+ # admin.<domain> → admin CloudFront distribution (optional)
233
+ ################################################################################
234
+
235
+ resource "cloudflare_record" "admin" {
236
+ count = local.create_admin_record ? 1 : 0
237
+
238
+ zone_id = var.cloudflare_zone_id
239
+ name = local.admin
240
+ content = var.admin_cloudfront_domain_name
241
+ type = "CNAME"
242
+ ttl = 300
243
+ proxied = false
244
+ comment = "thinkwork-${var.stage} admin → CloudFront"
245
+ }
@@ -0,0 +1,14 @@
1
+ output "certificate_arn" {
2
+ description = "ACM certificate ARN (us-east-1) covering apex + www. Pass this to the static-site module via www_certificate_arn."
3
+ value = aws_acm_certificate_validation.www.certificate_arn
4
+ }
5
+
6
+ output "www_redirect_distribution_id" {
7
+ description = "CloudFront distribution ID for the www→apex redirect"
8
+ value = aws_cloudfront_distribution.www_redirect.id
9
+ }
10
+
11
+ output "www_redirect_distribution_domain" {
12
+ description = "CloudFront distribution domain for the www→apex redirect"
13
+ value = aws_cloudfront_distribution.www_redirect.domain_name
14
+ }
@@ -0,0 +1,43 @@
1
+ variable "stage" {
2
+ description = "Deployment stage (used for resource naming)"
3
+ type = string
4
+ }
5
+
6
+ variable "domain" {
7
+ description = "Apex domain served by the public website (e.g. thinkwork.ai)"
8
+ type = string
9
+ }
10
+
11
+ variable "cloudflare_zone_id" {
12
+ description = "Cloudflare zone ID for the apex domain. Non-secret; lives in tfvars."
13
+ type = string
14
+ }
15
+
16
+ variable "cloudfront_domain_name" {
17
+ description = "CloudFront distribution domain name for the primary www site (e.g. d123.cloudfront.net). Passed in from the static-site module output."
18
+ type = string
19
+ }
20
+
21
+ variable "include_docs" {
22
+ description = "When true, add docs.<domain> to the ACM cert SANs and create a Cloudflare CNAME for it. Separated from docs_cloudfront_domain_name to avoid a Terraform dependency cycle (distribution depends on cert, so the cert can't depend on the distribution output)."
23
+ type = bool
24
+ default = false
25
+ }
26
+
27
+ variable "docs_cloudfront_domain_name" {
28
+ description = "CloudFront distribution domain name for the docs site. Used as the target for the docs.<domain> Cloudflare CNAME when include_docs is true."
29
+ type = string
30
+ default = ""
31
+ }
32
+
33
+ variable "include_admin" {
34
+ description = "When true, add admin.<domain> to the ACM cert SANs and create a Cloudflare CNAME for it. Same cycle-avoidance rationale as include_docs."
35
+ type = bool
36
+ default = false
37
+ }
38
+
39
+ variable "admin_cloudfront_domain_name" {
40
+ description = "CloudFront distribution domain name for the admin SPA. Used as the target for the admin.<domain> Cloudflare CNAME when include_admin is true."
41
+ type = string
42
+ default = ""
43
+ }