thinkwork-cli 0.8.2 → 0.9.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.
Files changed (57) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +18 -2
  3. package/dist/cli.js +3004 -215
  4. package/dist/terraform/examples/greenfield/main.tf +325 -19
  5. package/dist/terraform/examples/greenfield/terraform.tfvars.example +14 -0
  6. package/dist/terraform/modules/app/agentcore-code-interpreter/Dockerfile.sandbox-base +61 -0
  7. package/dist/terraform/modules/app/agentcore-code-interpreter/README.md +54 -0
  8. package/dist/terraform/modules/app/agentcore-code-interpreter/main.tf +197 -0
  9. package/dist/terraform/modules/app/agentcore-code-interpreter/scripts/build_and_push_sandbox_base.sh +70 -0
  10. package/dist/terraform/modules/app/agentcore-flue/README.md +58 -0
  11. package/dist/terraform/modules/app/agentcore-flue/main.tf +322 -0
  12. package/dist/terraform/modules/app/agentcore-flue/outputs.tf +23 -0
  13. package/dist/terraform/modules/app/agentcore-flue/variables.tf +91 -0
  14. package/dist/terraform/modules/app/agentcore-memory/scripts/create_or_find_memory.sh +0 -0
  15. package/dist/terraform/modules/app/agentcore-runtime/main.tf +204 -4
  16. package/dist/terraform/modules/app/appsync-subscriptions/main.tf +4 -0
  17. package/dist/terraform/modules/app/appsync-subscriptions/outputs.tf +5 -0
  18. package/dist/terraform/modules/app/computer-runtime/README.md +15 -0
  19. package/dist/terraform/modules/app/computer-runtime/main.tf +406 -0
  20. package/dist/terraform/modules/app/computer-runtime/outputs.tf +75 -0
  21. package/dist/terraform/modules/app/computer-runtime/variables.tf +66 -0
  22. package/dist/terraform/modules/app/hindsight-memory/main.tf +6 -0
  23. package/dist/terraform/modules/app/lambda-api/eval-fanout.tf +128 -0
  24. package/dist/terraform/modules/app/lambda-api/handlers.tf +1557 -42
  25. package/dist/terraform/modules/app/lambda-api/main.tf +299 -15
  26. package/dist/terraform/modules/app/lambda-api/mcp-oauth.tf +118 -0
  27. package/dist/terraform/modules/app/lambda-api/oauth-secrets.tf +49 -0
  28. package/dist/terraform/modules/app/lambda-api/outputs.tf +38 -0
  29. package/dist/terraform/modules/app/lambda-api/slack-app-secrets.tf +43 -0
  30. package/dist/terraform/modules/app/lambda-api/stripe-secrets.tf +53 -0
  31. package/dist/terraform/modules/app/lambda-api/variables.tf +349 -2
  32. package/dist/terraform/modules/app/lambda-api/workspace-events.tf +125 -0
  33. package/dist/terraform/modules/app/routines-stepfunctions/main.tf +453 -0
  34. package/dist/terraform/modules/app/sandbox-log-scrubber/README.md +66 -0
  35. package/dist/terraform/modules/app/sandbox-log-scrubber/main.tf +200 -0
  36. package/dist/terraform/modules/app/static-site/main.tf +146 -5
  37. package/dist/terraform/modules/app/www-dns/main.tf +118 -15
  38. package/dist/terraform/modules/app/www-dns/outputs.tf +10 -0
  39. package/dist/terraform/modules/app/www-dns/variables.tf +42 -0
  40. package/dist/terraform/modules/data/aurora-postgres/main.tf +164 -3
  41. package/dist/terraform/modules/data/aurora-postgres/outputs.tf +34 -0
  42. package/dist/terraform/modules/data/aurora-postgres/variables.tf +16 -0
  43. package/dist/terraform/modules/data/compliance-audit-bucket/README.md +145 -0
  44. package/dist/terraform/modules/data/compliance-audit-bucket/main.tf +573 -0
  45. package/dist/terraform/modules/data/compliance-audit-bucket/outputs.tf +43 -0
  46. package/dist/terraform/modules/data/compliance-audit-bucket/variables.tf +93 -0
  47. package/dist/terraform/modules/data/compliance-exports-bucket/main.tf +269 -0
  48. package/dist/terraform/modules/data/compliance-exports-bucket/outputs.tf +23 -0
  49. package/dist/terraform/modules/data/compliance-exports-bucket/variables.tf +50 -0
  50. package/dist/terraform/modules/data/s3-backups-bucket/main.tf +123 -0
  51. package/dist/terraform/modules/data/s3-buckets/main.tf +13 -0
  52. package/dist/terraform/modules/foundation/cognito/variables.tf +5 -2
  53. package/dist/terraform/modules/thinkwork/main.tf +439 -21
  54. package/dist/terraform/modules/thinkwork/outputs.tf +121 -0
  55. package/dist/terraform/modules/thinkwork/variables.tf +165 -6
  56. package/dist/terraform/schema.graphql +45 -0
  57. package/package.json +15 -14
@@ -0,0 +1,200 @@
1
+ ################################################################################
2
+ # Sandbox Log Scrubber — App Module
3
+ #
4
+ # Secondary (backstop) R13 layer for the AgentCore Code Interpreter sandbox
5
+ # (plan Unit 12). Pattern-redacts known-shape OAuth tokens in AgentCore
6
+ # APPLICATION_LOGS before they land in the long-term CloudWatch tier.
7
+ #
8
+ # **This is not the primary R13 layer.** The primary layer is the base-image
9
+ # sitecustomize.py stdio wrapper (plan Unit 4, terraform/modules/app/
10
+ # agentcore-code-interpreter) which redacts by *value* using the session-
11
+ # scoped token set. That layer can catch any token the preamble registered,
12
+ # regardless of shape.
13
+ #
14
+ # This backstop redacts by *pattern* — Authorization: Bearer, JWTs, and
15
+ # known OAuth prefixes (gh[opsru]_, xox[abep]-, ya29.). It does not have
16
+ # access to session token values so it cannot catch arbitrary leaks. It
17
+ # exists to mitigate stdio-bypass classes (subprocess env dumps, os.write,
18
+ # C-extension direct writes, multiprocessing workers) whose bytes carry a
19
+ # recognizable token prefix.
20
+ #
21
+ # If the scrubber Lambda fails, source log events remain in the original
22
+ # CloudWatch group. S3 tier is delayed, data is not lost.
23
+ ################################################################################
24
+
25
+ terraform {
26
+ required_providers {
27
+ aws = {
28
+ source = "hashicorp/aws"
29
+ version = ">= 5.0"
30
+ }
31
+ }
32
+ }
33
+
34
+ variable "stage" {
35
+ description = "Deployment stage (dev, prod, etc.)."
36
+ type = string
37
+ }
38
+
39
+ variable "region" {
40
+ description = "AWS region."
41
+ type = string
42
+ }
43
+
44
+ variable "account_id" {
45
+ description = "AWS account ID."
46
+ type = string
47
+ }
48
+
49
+ variable "source_log_group_name" {
50
+ description = "Source CloudWatch log group to subscribe to. Typically the AgentCore runtime group, e.g. /aws/bedrock-agentcore/runtimes/<runtime-name>."
51
+ type = string
52
+ }
53
+
54
+ variable "lambda_zip_path" {
55
+ description = "Path to the built Lambda zip. Produced by scripts/build-lambdas.sh sandbox-log-scrubber."
56
+ type = string
57
+ }
58
+
59
+ variable "lambda_zip_hash" {
60
+ description = "source_code_hash (base64 SHA-256) for the Lambda zip. Triggers function update when the bundle changes."
61
+ type = string
62
+ default = ""
63
+ }
64
+
65
+ variable "retention_days" {
66
+ description = "CloudWatch retention on the scrubbed output log group. Matches standard runtime retention."
67
+ type = number
68
+ default = 90
69
+ }
70
+
71
+ ################################################################################
72
+ # Output log group — where scrubbed events land
73
+ ################################################################################
74
+
75
+ resource "aws_cloudwatch_log_group" "scrubbed" {
76
+ name = "/thinkwork/${var.stage}/sandbox/scrubbed"
77
+ retention_in_days = var.retention_days
78
+
79
+ tags = {
80
+ Stage = var.stage
81
+ Purpose = "sandbox-log-scrubber-output"
82
+ }
83
+ }
84
+
85
+ ################################################################################
86
+ # Lambda execution role + policy
87
+ ################################################################################
88
+
89
+ resource "aws_iam_role" "scrubber" {
90
+ name = "thinkwork-${var.stage}-sandbox-log-scrubber"
91
+
92
+ assume_role_policy = jsonencode({
93
+ Version = "2012-10-17"
94
+ Statement = [{
95
+ Effect = "Allow"
96
+ Principal = { Service = "lambda.amazonaws.com" }
97
+ Action = "sts:AssumeRole"
98
+ }]
99
+ })
100
+ }
101
+
102
+ resource "aws_iam_role_policy" "scrubber" {
103
+ name = "sandbox-log-scrubber"
104
+ role = aws_iam_role.scrubber.id
105
+
106
+ policy = jsonencode({
107
+ Version = "2012-10-17"
108
+ Statement = [
109
+ {
110
+ Sid = "WriteScrubbedEvents"
111
+ Effect = "Allow"
112
+ Action = [
113
+ "logs:CreateLogStream",
114
+ "logs:PutLogEvents",
115
+ "logs:DescribeLogStreams",
116
+ ]
117
+ Resource = [
118
+ aws_cloudwatch_log_group.scrubbed.arn,
119
+ "${aws_cloudwatch_log_group.scrubbed.arn}:*",
120
+ ]
121
+ },
122
+ {
123
+ # Lambda's own execution logs (separate group managed by AWS).
124
+ Sid = "SelfLogs"
125
+ Effect = "Allow"
126
+ Action = [
127
+ "logs:CreateLogGroup",
128
+ "logs:CreateLogStream",
129
+ "logs:PutLogEvents",
130
+ ]
131
+ Resource = "arn:aws:logs:${var.region}:${var.account_id}:log-group:/aws/lambda/thinkwork-${var.stage}-sandbox-log-scrubber:*"
132
+ },
133
+ ]
134
+ })
135
+ }
136
+
137
+ ################################################################################
138
+ # Lambda function
139
+ ################################################################################
140
+
141
+ resource "aws_lambda_function" "scrubber" {
142
+ function_name = "thinkwork-${var.stage}-sandbox-log-scrubber"
143
+ role = aws_iam_role.scrubber.arn
144
+ handler = "index.handler"
145
+ runtime = "nodejs20.x"
146
+ filename = var.lambda_zip_path
147
+ source_code_hash = var.lambda_zip_hash
148
+ timeout = 30
149
+ memory_size = 256
150
+
151
+ environment {
152
+ variables = {
153
+ OUTPUT_LOG_GROUP = aws_cloudwatch_log_group.scrubbed.name
154
+ }
155
+ }
156
+
157
+ tags = {
158
+ Stage = var.stage
159
+ Purpose = "sandbox-log-scrubber"
160
+ }
161
+ }
162
+
163
+ resource "aws_lambda_permission" "allow_cloudwatch" {
164
+ statement_id = "AllowExecutionFromCloudWatchLogs"
165
+ action = "lambda:InvokeFunction"
166
+ function_name = aws_lambda_function.scrubber.function_name
167
+ principal = "logs.${var.region}.amazonaws.com"
168
+ source_arn = "arn:aws:logs:${var.region}:${var.account_id}:log-group:${var.source_log_group_name}:*"
169
+ }
170
+
171
+ ################################################################################
172
+ # Subscription filter — fan source events into the Lambda
173
+ ################################################################################
174
+
175
+ resource "aws_cloudwatch_log_subscription_filter" "source" {
176
+ name = "thinkwork-${var.stage}-sandbox-scrubber"
177
+ log_group_name = var.source_log_group_name
178
+ filter_pattern = "" # deliver every event; the Lambda decides
179
+ destination_arn = aws_lambda_function.scrubber.arn
180
+ depends_on = [aws_lambda_permission.allow_cloudwatch]
181
+ }
182
+
183
+ ################################################################################
184
+ # Outputs
185
+ ################################################################################
186
+
187
+ output "scrubbed_log_group_name" {
188
+ description = "CloudWatch log group receiving scrubbed events."
189
+ value = aws_cloudwatch_log_group.scrubbed.name
190
+ }
191
+
192
+ output "scrubber_function_name" {
193
+ description = "Scrubber Lambda function name."
194
+ value = aws_lambda_function.scrubber.function_name
195
+ }
196
+
197
+ output "scrubber_role_arn" {
198
+ description = "Execution role ARN."
199
+ value = aws_iam_role.scrubber.arn
200
+ }
@@ -39,12 +39,85 @@ variable "is_spa" {
39
39
  default = false
40
40
  }
41
41
 
42
+ # ---------------------------------------------------------------------------
43
+ # Response-headers policy (optional, plan-012 U3)
44
+ #
45
+ # Two opt-in forms, both backwards compatible — existing callers
46
+ # (computer_site, admin_site, docs_site, www_site) leave both unset and the
47
+ # distribution is created with no response-headers policy attached:
48
+ #
49
+ # - Pass `response_headers_policy_id` to attach an existing policy by id.
50
+ # Useful if a sibling module already minted the policy and you want to
51
+ # share it across distributions.
52
+ #
53
+ # - Pass `inline_response_headers` (an object describing the CSP + other
54
+ # response headers) to have this module mint a fresh policy and attach
55
+ # it. Used by `computer_sandbox_site` to ship the iframe-side CSP
56
+ # (script-src 'self' blob:; connect-src 'none'; frame-ancestors ... etc.)
57
+ # alongside the dedicated sandbox distribution.
58
+ #
59
+ # Passing both is an error — the inline policy would be unused.
60
+ # ---------------------------------------------------------------------------
61
+
62
+ variable "response_headers_policy_id" {
63
+ description = "ID of an existing aws_cloudfront_response_headers_policy to attach. Mutually exclusive with inline_response_headers."
64
+ type = string
65
+ default = ""
66
+ }
67
+
68
+ variable "inline_response_headers" {
69
+ description = "When set, this module mints a new response-headers policy and attaches it. Pass null to skip. Fields: content_security_policy (string), content_type_options_override (bool), strict_transport_security (object: max_age_sec, include_subdomains, preload, override), cors (object: allow_origins, allow_methods, allow_headers, allow_credentials, max_age_sec, origin_override). Mutually exclusive with response_headers_policy_id."
70
+ type = object({
71
+ content_security_policy = optional(string)
72
+ content_type_options_override = optional(bool, true)
73
+ strict_transport_security = optional(object({
74
+ max_age_sec = number
75
+ include_subdomains = bool
76
+ preload = bool
77
+ override = bool
78
+ }))
79
+ cors = optional(object({
80
+ allow_origins = list(string)
81
+ allow_methods = optional(list(string), ["GET", "HEAD", "OPTIONS"])
82
+ allow_headers = optional(list(string), ["*"])
83
+ allow_credentials = optional(bool, false)
84
+ max_age_sec = optional(number, 600)
85
+ origin_override = optional(bool, true)
86
+ }))
87
+ })
88
+ default = null
89
+ }
90
+
42
91
  ################################################################################
43
92
  # S3 Bucket
44
93
  ################################################################################
45
94
 
46
95
  locals {
47
96
  bucket_name = var.bucket_name != "" ? var.bucket_name : "thinkwork-${var.stage}-${var.site_name}"
97
+
98
+ # Mutually-exclusive validator — surface as an error during plan.
99
+ _conflicting_policy_inputs = var.response_headers_policy_id != "" && var.inline_response_headers != null
100
+
101
+ inline_policy_enabled = var.inline_response_headers != null
102
+
103
+ # Final policy id wired into the cache behavior. Empty string means no
104
+ # policy (CloudFront default). Terraform's resource attribute treats ""
105
+ # the same as null because we condition on it below.
106
+ effective_response_headers_policy_id = (
107
+ var.response_headers_policy_id != ""
108
+ ? var.response_headers_policy_id
109
+ : (local.inline_policy_enabled
110
+ ? aws_cloudfront_response_headers_policy.inline[0].id
111
+ : ""
112
+ )
113
+ )
114
+ }
115
+
116
+ check "policy_inputs_are_mutually_exclusive" {
117
+ assert {
118
+ condition = !local._conflicting_policy_inputs
119
+ error_message = "static-site: response_headers_policy_id and inline_response_headers are mutually exclusive."
120
+ }
48
121
  }
49
122
 
50
123
  resource "aws_s3_bucket" "site" {
@@ -64,6 +137,68 @@ resource "aws_s3_bucket_public_access_block" "site" {
64
137
  restrict_public_buckets = true
65
138
  }
66
139
 
140
+ ################################################################################
141
+ # CloudFront Response-Headers Policy (optional, inline-minted variant)
142
+ ################################################################################
143
+
144
+ resource "aws_cloudfront_response_headers_policy" "inline" {
145
+ count = local.inline_policy_enabled ? 1 : 0
146
+
147
+ name = "thinkwork-${var.stage}-${var.site_name}-headers"
148
+ comment = "Response-headers policy for thinkwork-${var.stage}-${var.site_name}"
149
+
150
+ dynamic "security_headers_config" {
151
+ for_each = var.inline_response_headers.content_security_policy != null || var.inline_response_headers.content_type_options_override != false || var.inline_response_headers.strict_transport_security != null ? [1] : []
152
+ content {
153
+ dynamic "content_security_policy" {
154
+ for_each = var.inline_response_headers.content_security_policy != null ? [1] : []
155
+ content {
156
+ content_security_policy = var.inline_response_headers.content_security_policy
157
+ override = true
158
+ }
159
+ }
160
+
161
+ dynamic "content_type_options" {
162
+ for_each = var.inline_response_headers.content_type_options_override == true ? [1] : []
163
+ content {
164
+ override = true
165
+ }
166
+ }
167
+
168
+ dynamic "strict_transport_security" {
169
+ for_each = var.inline_response_headers.strict_transport_security != null ? [1] : []
170
+ content {
171
+ access_control_max_age_sec = var.inline_response_headers.strict_transport_security.max_age_sec
172
+ include_subdomains = var.inline_response_headers.strict_transport_security.include_subdomains
173
+ preload = var.inline_response_headers.strict_transport_security.preload
174
+ override = var.inline_response_headers.strict_transport_security.override
175
+ }
176
+ }
177
+ }
178
+ }
179
+
180
+ dynamic "cors_config" {
181
+ for_each = var.inline_response_headers.cors != null ? [var.inline_response_headers.cors] : []
182
+ content {
183
+ access_control_allow_credentials = cors_config.value.allow_credentials
184
+ access_control_max_age_sec = cors_config.value.max_age_sec
185
+ origin_override = cors_config.value.origin_override
186
+
187
+ access_control_allow_headers {
188
+ items = cors_config.value.allow_headers
189
+ }
190
+
191
+ access_control_allow_methods {
192
+ items = cors_config.value.allow_methods
193
+ }
194
+
195
+ access_control_allow_origins {
196
+ items = cors_config.value.allow_origins
197
+ }
198
+ }
199
+ }
200
+ }
201
+
67
202
  ################################################################################
68
203
  # CloudFront OAC
69
204
  ################################################################################
@@ -135,11 +270,12 @@ resource "aws_cloudfront_distribution" "site" {
135
270
  }
136
271
 
137
272
  default_cache_behavior {
138
- target_origin_id = "s3-${local.bucket_name}"
139
- viewer_protocol_policy = "redirect-to-https"
140
- allowed_methods = ["GET", "HEAD", "OPTIONS"]
141
- cached_methods = ["GET", "HEAD"]
142
- compress = true
273
+ target_origin_id = "s3-${local.bucket_name}"
274
+ viewer_protocol_policy = "redirect-to-https"
275
+ allowed_methods = ["GET", "HEAD", "OPTIONS"]
276
+ cached_methods = ["GET", "HEAD"]
277
+ compress = true
278
+ response_headers_policy_id = local.effective_response_headers_policy_id != "" ? local.effective_response_headers_policy_id : null
143
279
 
144
280
  dynamic "function_association" {
145
281
  for_each = var.is_spa ? [] : [1]
@@ -228,3 +364,8 @@ output "bucket_name" {
228
364
  description = "S3 bucket name for the site"
229
365
  value = aws_s3_bucket.site.id
230
366
  }
367
+
368
+ output "response_headers_policy_id" {
369
+ description = "ID of the response-headers policy attached to the default cache behavior. Empty when no policy is attached."
370
+ value = local.effective_response_headers_policy_id
371
+ }
@@ -29,27 +29,38 @@ terraform {
29
29
  }
30
30
 
31
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.
32
+ apex = var.domain
33
+ www = "www.${var.domain}"
34
+ docs = "docs.${var.domain}"
35
+ admin = "admin.${var.domain}"
36
+ computer = "computer.${var.domain}"
37
+ sandbox = "sandbox.${var.domain}"
38
+ api = "api.${var.domain}"
39
+ name_id = replace(var.domain, ".", "-")
40
+
41
+ # ACM SANs: always include www, conditionally include docs, admin, computer,
42
+ # and api. The Computer iframe sandbox uses its own certificate so adding or
43
+ # rotating the sandbox host never forces a replacement of this shared
44
+ # production certificate.
45
+ # Gated on plain bool vars (not on CloudFront/API Gateway outputs) to keep
46
+ # the dependency graph acyclic — distributions / custom domain names
47
+ # depend on the cert, so the cert mustn't depend on those outputs.
42
48
  cert_sans = concat(
43
49
  [local.www],
44
50
  var.include_docs ? [local.docs] : [],
45
51
  var.include_admin ? [local.admin] : [],
52
+ var.include_computer ? [local.computer] : [],
53
+ var.include_api ? [local.api] : [],
46
54
  )
47
55
 
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 != ""
56
+ # Existing CNAME records stay gated on non-empty targets because their target
57
+ # outputs are already known in the deployed stack. Newly bootstrapped records
58
+ # such as the sandbox CNAME must gate only on an explicit boolean so Terraform
59
+ # can plan the resource count before the new CloudFront distribution exists.
60
+ create_docs_record = var.include_docs && var.docs_cloudfront_domain_name != ""
61
+ create_admin_record = var.include_admin && var.admin_cloudfront_domain_name != ""
62
+ create_computer_record = var.include_computer && var.computer_cloudfront_domain_name != ""
63
+ create_api_record = var.include_api && var.api_gateway_id != ""
53
64
  }
54
65
 
55
66
  ################################################################################
@@ -86,6 +97,17 @@ resource "cloudflare_record" "acm_validation" {
86
97
  ttl = 60
87
98
  proxied = false
88
99
  comment = "ACM DNS validation for ${each.key}"
100
+
101
+ # When the cert SAN list changes (adding admin/computer/api/etc.), ACM may
102
+ # reissue with new validation tokens. With create_before_destroy on the cert,
103
+ # Terraform creates the new cert + validation records before destroying the
104
+ # old ones — and the Cloudflare provider rejects with "expected DNS record to
105
+ # not already be present but already exists" when names collide. allow_overwrite
106
+ # tells the provider to take ownership of an existing record by name instead
107
+ # of failing. Safe here because the validation records are fully managed by
108
+ # this resource — anything else writing _acm-challenge records on this zone
109
+ # would already be a conflict we'd want to overwrite.
110
+ allow_overwrite = true
89
111
  }
90
112
 
91
113
  resource "aws_acm_certificate_validation" "www" {
@@ -243,3 +265,84 @@ resource "cloudflare_record" "admin" {
243
265
  proxied = false
244
266
  comment = "thinkwork-${var.stage} admin → CloudFront"
245
267
  }
268
+
269
+ ################################################################################
270
+ # computer.<domain> → computer CloudFront distribution (optional)
271
+ ################################################################################
272
+
273
+ resource "cloudflare_record" "computer" {
274
+ count = local.create_computer_record ? 1 : 0
275
+
276
+ zone_id = var.cloudflare_zone_id
277
+ name = local.computer
278
+ content = var.computer_cloudfront_domain_name
279
+ type = "CNAME"
280
+ ttl = 300
281
+ proxied = false
282
+ comment = "thinkwork-${var.stage} computer → CloudFront"
283
+ }
284
+
285
+ ################################################################################
286
+ # sandbox.<domain> → Computer iframe sandbox CloudFront distribution (optional)
287
+ ################################################################################
288
+
289
+ resource "cloudflare_record" "computer_sandbox" {
290
+ count = var.include_computer_sandbox ? 1 : 0
291
+
292
+ zone_id = var.cloudflare_zone_id
293
+ name = local.sandbox
294
+ content = var.computer_sandbox_cloudfront_domain_name
295
+ type = "CNAME"
296
+ ttl = 300
297
+ proxied = false
298
+ comment = "thinkwork-${var.stage} sandbox → Computer iframe sandbox CloudFront"
299
+ }
300
+
301
+ ################################################################################
302
+ # api.<domain> → HTTP API Gateway (optional)
303
+ #
304
+ # API Gateway v2 HTTP APIs support regional custom domains. The cert lives in
305
+ # the same region as the API (us-east-1 here) and the domain name maps the
306
+ # target stage under the root base path ("") so routes defined on the API
307
+ # (/api/stripe/webhook, /graphql, etc.) are reachable at the vanity domain.
308
+ #
309
+ # Cloudflare must stay DNS-only (proxied=false). API Gateway presents the
310
+ # ACM cert directly; a proxied CNAME would mess with the TLS handshake.
311
+ ################################################################################
312
+
313
+ resource "aws_apigatewayv2_domain_name" "api" {
314
+ count = var.include_api ? 1 : 0
315
+
316
+ domain_name = local.api
317
+
318
+ domain_name_configuration {
319
+ certificate_arn = aws_acm_certificate_validation.www.certificate_arn
320
+ endpoint_type = "REGIONAL"
321
+ security_policy = "TLS_1_2"
322
+ }
323
+
324
+ tags = {
325
+ Name = "thinkwork-${var.stage}-api-custom-domain"
326
+ Stage = var.stage
327
+ }
328
+ }
329
+
330
+ resource "aws_apigatewayv2_api_mapping" "api" {
331
+ count = local.create_api_record ? 1 : 0
332
+
333
+ api_id = var.api_gateway_id
334
+ domain_name = aws_apigatewayv2_domain_name.api[0].id
335
+ stage = var.api_gateway_stage_name
336
+ }
337
+
338
+ resource "cloudflare_record" "api" {
339
+ count = local.create_api_record ? 1 : 0
340
+
341
+ zone_id = var.cloudflare_zone_id
342
+ name = local.api
343
+ content = aws_apigatewayv2_domain_name.api[0].domain_name_configuration[0].target_domain_name
344
+ type = "CNAME"
345
+ ttl = 300
346
+ proxied = false
347
+ comment = "thinkwork-${var.stage} api → API Gateway v2 regional domain"
348
+ }
@@ -12,3 +12,13 @@ output "www_redirect_distribution_domain" {
12
12
  description = "CloudFront distribution domain for the www→apex redirect"
13
13
  value = aws_cloudfront_distribution.www_redirect.domain_name
14
14
  }
15
+
16
+ output "api_custom_domain_name" {
17
+ description = "Custom domain name for the HTTP API (e.g. api.thinkwork.ai). Empty string when include_api is false."
18
+ value = var.include_api ? aws_apigatewayv2_domain_name.api[0].domain_name : ""
19
+ }
20
+
21
+ output "api_custom_domain_target" {
22
+ description = "API Gateway regional target domain to CNAME to (useful for external DNS configuration). Empty string when include_api is false."
23
+ value = var.include_api ? aws_apigatewayv2_domain_name.api[0].domain_name_configuration[0].target_domain_name : ""
24
+ }
@@ -41,3 +41,45 @@ variable "admin_cloudfront_domain_name" {
41
41
  type = string
42
42
  default = ""
43
43
  }
44
+
45
+ variable "include_computer" {
46
+ description = "When true, add computer.<domain> to the ACM cert SANs and create a Cloudflare CNAME for it. Same cycle-avoidance rationale as include_docs."
47
+ type = bool
48
+ default = false
49
+ }
50
+
51
+ variable "computer_cloudfront_domain_name" {
52
+ description = "CloudFront distribution domain name for the computer SPA. Used as the target for the computer.<domain> Cloudflare CNAME when include_computer is true."
53
+ type = string
54
+ default = ""
55
+ }
56
+
57
+ variable "include_computer_sandbox" {
58
+ description = "When true, add sandbox.<domain> to the ACM cert SANs and create a Cloudflare CNAME for it. Same cycle-avoidance rationale as include_docs."
59
+ type = bool
60
+ default = false
61
+ }
62
+
63
+ variable "computer_sandbox_cloudfront_domain_name" {
64
+ description = "CloudFront distribution domain name for the Computer iframe sandbox host. Used as the target for the sandbox.<domain> Cloudflare CNAME when include_computer_sandbox is true."
65
+ type = string
66
+ default = ""
67
+ }
68
+
69
+ variable "include_api" {
70
+ description = "When true, add api.<domain> to the ACM cert SANs, create an API Gateway v2 custom domain name + base-path mapping, and create a Cloudflare CNAME pointing api.<domain> at the API Gateway regional domain. The API Gateway must be in the same region as this module since regional custom domains require a cert in the same region."
71
+ type = bool
72
+ default = false
73
+ }
74
+
75
+ variable "api_gateway_id" {
76
+ description = "aws_apigatewayv2_api.id of the HTTP API to expose at api.<domain>. Required when include_api is true."
77
+ type = string
78
+ default = ""
79
+ }
80
+
81
+ variable "api_gateway_stage_name" {
82
+ description = "aws_apigatewayv2_stage.name to map under the root base path on the custom domain. Defaults to the auto-deployed `$default` stage."
83
+ type = string
84
+ default = "$default"
85
+ }