unoverse 0.1.69 → 0.1.70

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.
@@ -0,0 +1,639 @@
1
+ # The Universe — AWS POC (docs/architecture/AWS_DEPLOYMENT.md)
2
+ #
3
+ # One VM + managed Postgres/Redis + Cognito + a scoped Bedrock IAM user.
4
+ # Deliberately flat and minimal: this is the POC tier. Terraform provisions,
5
+ # the existing `unoverse deploy` (Ansible) deploys — outputs feed .env.production.
6
+
7
+ terraform {
8
+ required_version = ">= 1.5"
9
+ required_providers {
10
+ # >= 5.83: `user_pool_tier` (the Essentials plan pin) landed in 5.83.0.
11
+ aws = { source = "hashicorp/aws", version = ">= 5.83, < 6.0" }
12
+ random = { source = "hashicorp/random", version = "~> 3.6" }
13
+ archive = { source = "hashicorp/archive", version = "~> 2.4" }
14
+ }
15
+ }
16
+
17
+ provider "aws" {
18
+ region = var.region
19
+ }
20
+
21
+ # ── Network: default VPC + two security groups ─────────────────────────────────
22
+ # POC uses the default VPC on purpose (no subnet/NAT machinery to own). The trust
23
+ # story is the two SGs: the app faces the world on 80/443 only; the data stores
24
+ # accept the app SG only. Ports 4101/4105/4106 are NEVER opened here — 443 via
25
+ # Caddy is the only application entry, and publish-key minting is on-box (SSH).
26
+
27
+ data "aws_vpc" "default" {
28
+ default = true
29
+ }
30
+
31
+ # The ALB needs subnets in ≥2 AZs; the default VPC has one per AZ.
32
+ data "aws_subnets" "default" {
33
+ filter {
34
+ name = "vpc-id"
35
+ values = [data.aws_vpc.default.id]
36
+ }
37
+ }
38
+
39
+ # ── Size table (INFRASTRUCTURE.md § Sizes; AWS instance equivalents) ──────────
40
+ locals {
41
+ sizes = {
42
+ small = { # 4 vCPU / 16 GB — the POC box
43
+ instance = "t3.xlarge"
44
+ pg = "db.t4g.small"
45
+ redis = "cache.t4g.micro"
46
+ pool_engine = 8
47
+ pool_legacy = 4
48
+ pool_memory = 4
49
+ }
50
+ medium = { # 8 vCPU / 32 GB
51
+ instance = "m6i.2xlarge"
52
+ pg = "db.t4g.medium"
53
+ redis = "cache.t4g.small"
54
+ pool_engine = 20
55
+ pool_legacy = 8
56
+ pool_memory = 10
57
+ }
58
+ large = { # 8 vCPU / 64 GB — memory-optimized: the engine is ONE event loop
59
+ instance = "r6i.2xlarge"
60
+ pg = "db.m6g.large"
61
+ redis = "cache.t4g.medium"
62
+ pool_engine = 40
63
+ pool_legacy = 12
64
+ pool_memory = 20
65
+ }
66
+ }
67
+ s = local.sizes[var.size]
68
+ # Domainless-first (DECIDED 2026-07-31, universal across grounds): no domain =
69
+ # no cert, the ALB speaks plain HTTP on its DNS name. Setting domain later and
70
+ # re-applying upgrades the SAME ALB in place — ACM cert, HTTPS listener, redirect.
71
+ has_domain = var.domain != ""
72
+ api_host = "api.${var.domain}"
73
+ dns_auto = local.has_domain && var.route53_zone_id != ""
74
+ }
75
+
76
+ # The ALB is the ONLY public surface (native ingress, 2026-07-29 — Caddy retired;
77
+ # the old 80/443-from-world rules died with it).
78
+ resource "aws_security_group" "alb" {
79
+ name = "${var.name}-alb"
80
+ description = "Public front door: 80 (redirect) + 443 from the world"
81
+ vpc_id = data.aws_vpc.default.id
82
+
83
+ ingress {
84
+ from_port = 80
85
+ to_port = 80
86
+ protocol = "tcp"
87
+ cidr_blocks = ["0.0.0.0/0"]
88
+ }
89
+ ingress {
90
+ from_port = 443
91
+ to_port = 443
92
+ protocol = "tcp"
93
+ cidr_blocks = ["0.0.0.0/0"]
94
+ }
95
+ # Domainless canvas_public rides a second port (no hostname to host-route by).
96
+ dynamic "ingress" {
97
+ for_each = var.canvas_public && var.domain == "" ? [1] : []
98
+ content {
99
+ from_port = 3001
100
+ to_port = 3001
101
+ protocol = "tcp"
102
+ cidr_blocks = ["0.0.0.0/0"]
103
+ }
104
+ }
105
+ egress {
106
+ from_port = 0
107
+ to_port = 0
108
+ protocol = "-1"
109
+ cidr_blocks = ["0.0.0.0/0"]
110
+ }
111
+ }
112
+
113
+ resource "aws_security_group" "app" {
114
+ name = "${var.name}-app"
115
+ description = "Gravity platform VM: app ports from the ALB only, SSH/Dozzle from operator"
116
+ vpc_id = data.aws_vpc.default.id
117
+
118
+ ingress {
119
+ description = "Platform (api.<domain>) from the ALB only"
120
+ from_port = 4105
121
+ to_port = 4105
122
+ protocol = "tcp"
123
+ security_groups = [aws_security_group.alb.id]
124
+ }
125
+ ingress {
126
+ description = "Canvas from the ALB only (public only when canvas_public adds the host rule)"
127
+ from_port = 3001
128
+ to_port = 3001
129
+ protocol = "tcp"
130
+ security_groups = [aws_security_group.alb.id]
131
+ }
132
+ ingress {
133
+ description = "Canvas direct, operator IP only (the standing admin posture)"
134
+ from_port = 3001
135
+ to_port = 3001
136
+ protocol = "tcp"
137
+ cidr_blocks = [var.admin_cidr]
138
+ }
139
+ ingress {
140
+ description = "Dozzle log viewer, operator IP only"
141
+ from_port = 8080
142
+ to_port = 8080
143
+ protocol = "tcp"
144
+ cidr_blocks = [var.admin_cidr]
145
+ }
146
+ ingress {
147
+ description = "SSH, operator IP only (deploys + ./unoverse key)"
148
+ from_port = 22
149
+ to_port = 22
150
+ protocol = "tcp"
151
+ cidr_blocks = [var.admin_cidr]
152
+ }
153
+ egress {
154
+ from_port = 0
155
+ to_port = 0
156
+ protocol = "-1"
157
+ cidr_blocks = ["0.0.0.0/0"]
158
+ }
159
+ }
160
+
161
+ resource "aws_security_group" "data" {
162
+ name = "${var.name}-data"
163
+ description = "Postgres + Redis: reachable from the app SG only"
164
+ vpc_id = data.aws_vpc.default.id
165
+
166
+ ingress {
167
+ description = "Postgres from app"
168
+ from_port = 5432
169
+ to_port = 5432
170
+ protocol = "tcp"
171
+ security_groups = [aws_security_group.app.id]
172
+ }
173
+ ingress {
174
+ description = "Redis from app"
175
+ from_port = 6379
176
+ to_port = 6379
177
+ protocol = "tcp"
178
+ security_groups = [aws_security_group.app.id]
179
+ }
180
+ egress {
181
+ from_port = 0
182
+ to_port = 0
183
+ protocol = "-1"
184
+ cidr_blocks = ["0.0.0.0/0"]
185
+ }
186
+ }
187
+
188
+ # ── VM: t3.xlarge, Ubuntu 22.04, 100GB gp3, Elastic IP ────────────────────────
189
+ # POC spec is 4 cores / 8 GB with the 8 GB fully allocated; 16 GB is the headroom
190
+ # choice at the same price as the exact-match c6i.xlarge (AWS_DEPLOYMENT.md).
191
+
192
+ data "aws_ami" "ubuntu" {
193
+ most_recent = true
194
+ owners = ["099720109477"] # Canonical
195
+
196
+ filter {
197
+ name = "name"
198
+ values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
199
+ }
200
+ filter {
201
+ name = "virtualization-type"
202
+ values = ["hvm"]
203
+ }
204
+ }
205
+
206
+ resource "aws_instance" "app" {
207
+ ami = data.aws_ami.ubuntu.id
208
+ instance_type = local.s.instance
209
+ key_name = var.ssh_key_name
210
+ vpc_security_group_ids = [aws_security_group.app.id]
211
+
212
+ root_block_device {
213
+ volume_size = 100
214
+ volume_type = "gp3"
215
+ }
216
+
217
+ tags = { Name = "${var.name}-app" }
218
+ }
219
+
220
+ resource "aws_eip" "app" {
221
+ instance = aws_instance.app.id
222
+ domain = "vpc"
223
+ tags = { Name = "${var.name}-app" }
224
+ }
225
+
226
+ # ── Postgres: RDS single-AZ, small on purpose ──────────────────────────────────
227
+ # The engine's ~19-usable-connection cap means a bigger instance buys nothing at
228
+ # POC (ONE_ENGINE.md). Retention is EXPLICIT: the API default is 1 day, and the
229
+ # rolling window self-purges — only manual/final snapshots persist and cost.
230
+
231
+ resource "random_password" "db" {
232
+ length = 32
233
+ special = false
234
+ }
235
+
236
+ # Per-deployment credential-encryption key (SECURITY.md § Credential encryption at
237
+ # rest): the engine encrypts stored credentials with this. Generated here so no
238
+ # deployment ever runs on the committed default. Back it up WITH the database.
239
+ resource "random_password" "credential_key" {
240
+ length = 44 # ~32 bytes of entropy, base64-ish charset
241
+ special = false
242
+ }
243
+
244
+ resource "aws_db_instance" "postgres" {
245
+ identifier = "${var.name}-pg"
246
+ engine = "postgres"
247
+ engine_version = "16"
248
+ instance_class = local.s.pg
249
+
250
+ db_name = "universe"
251
+ username = "universe"
252
+ password = random_password.db.result
253
+
254
+ allocated_storage = 20
255
+ max_allocated_storage = 50
256
+ storage_type = "gp3"
257
+
258
+ vpc_security_group_ids = [aws_security_group.data.id]
259
+ publicly_accessible = false
260
+ multi_az = false
261
+
262
+ backup_retention_period = 7
263
+ backup_window = "03:00-04:00"
264
+ skip_final_snapshot = false
265
+ final_snapshot_identifier = "${var.name}-pg-final"
266
+ # POC: no deletion_protection so teardown stays one command. Turn it on at graduation.
267
+ }
268
+
269
+ # ── Redis: ElastiCache single node, TLS + auth token ───────────────────────────
270
+ # Cache/queue state only — no backups by design (AWS_DEPLOYMENT.md).
271
+
272
+ resource "random_password" "redis" {
273
+ length = 32
274
+ special = false
275
+ }
276
+
277
+ resource "aws_elasticache_replication_group" "redis" {
278
+ replication_group_id = "${var.name}-redis"
279
+ description = "Gravity platform Redis (POC, single node)"
280
+ engine = "redis"
281
+ engine_version = "7.1"
282
+ node_type = local.s.redis
283
+ num_cache_clusters = 1
284
+
285
+ security_group_ids = [aws_security_group.data.id]
286
+ transit_encryption_enabled = true
287
+ auth_token = random_password.redis.result
288
+ automatic_failover_enabled = false
289
+ snapshot_retention_limit = 0
290
+ }
291
+
292
+ # ── Cognito: user pool (Essentials) + pre-token Lambda ────────────────────────
293
+ # The Lambda is LOAD-BEARING: it puts email/roles/permissions on the ACCESS token
294
+ # (the platform's token contract, AUTH_TOKEN_FLOW.md). Roles come from Cognito
295
+ # groups. It lives in Terraform precisely so a pool rebuild cannot drop it — the
296
+ # documented Auth0 footgun, not repeated here.
297
+
298
+ data "archive_file" "pretoken" {
299
+ type = "zip"
300
+ source_file = "${path.module}/pretoken/index.mjs"
301
+ output_path = "${path.module}/pretoken/pretoken.zip"
302
+ }
303
+
304
+ resource "aws_iam_role" "pretoken" {
305
+ name = "${var.name}-pretoken"
306
+ assume_role_policy = jsonencode({
307
+ Version = "2012-10-17"
308
+ Statement = [{
309
+ Effect = "Allow"
310
+ Principal = { Service = "lambda.amazonaws.com" }
311
+ Action = "sts:AssumeRole"
312
+ }]
313
+ })
314
+ }
315
+
316
+ resource "aws_iam_role_policy_attachment" "pretoken_logs" {
317
+ role = aws_iam_role.pretoken.name
318
+ policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
319
+ }
320
+
321
+ resource "aws_lambda_function" "pretoken" {
322
+ function_name = "${var.name}-pretoken"
323
+ role = aws_iam_role.pretoken.arn
324
+ runtime = "nodejs20.x"
325
+ handler = "index.handler"
326
+ filename = data.archive_file.pretoken.output_path
327
+ source_code_hash = data.archive_file.pretoken.output_base64sha256
328
+ }
329
+
330
+ resource "aws_lambda_permission" "cognito" {
331
+ statement_id = "AllowCognito"
332
+ action = "lambda:InvokeFunction"
333
+ function_name = aws_lambda_function.pretoken.function_name
334
+ principal = "cognito-idp.amazonaws.com"
335
+ source_arn = aws_cognito_user_pool.pool.arn
336
+ }
337
+
338
+ resource "aws_cognito_user_pool" "pool" {
339
+ name = "${var.name}-users"
340
+
341
+ # Essentials is the default plan for new pools and includes access-token
342
+ # customization (verified 2026-07-28); stated explicitly so an AWS-side default
343
+ # change can never silently downgrade the pool below what the Lambda needs.
344
+ user_pool_tier = "ESSENTIALS"
345
+
346
+ auto_verified_attributes = ["email"]
347
+ username_attributes = ["email"]
348
+
349
+ lambda_config {
350
+ pre_token_generation_config {
351
+ lambda_arn = aws_lambda_function.pretoken.arn
352
+ # V2_0 = "basic features + access token customization" — the whole point.
353
+ lambda_version = "V2_0"
354
+ }
355
+ }
356
+ }
357
+
358
+ # Groups ARE the RBAC surface: membership becomes the `roles`/`permissions`
359
+ # claims via the Lambda. The two PLATFORM permissions are always provisioned
360
+ # (without them nobody can author or publish on this universe); everything else
361
+ # comes from var.roles — the deployment's own `noun:verb` role list, matched by
362
+ # node manifests' `requires.role` (DECLARATIVE_NODES.md §9.13).
363
+ locals {
364
+ platform_roles = {
365
+ "workflow:author" = "May use the hosted workflow builder"
366
+ "marketplace:publish" = "May publish items to this universe"
367
+ }
368
+ all_roles = merge(local.platform_roles, { for r in var.roles : r => "requires.role gate: ${r}" })
369
+ }
370
+
371
+ resource "aws_cognito_user_group" "roles" {
372
+ for_each = local.all_roles
373
+ name = each.key
374
+ user_pool_id = aws_cognito_user_pool.pool.id
375
+ description = each.value
376
+ }
377
+
378
+ # The initial ADMIN — the first human in the universe. Without this, a fresh pool
379
+ # has no user who can author or publish. Cognito emails an invite with a temporary
380
+ # password (default message_action); first sign-in forces a password change. The
381
+ # admin sits in EVERY role group, so their token carries the full role set.
382
+ resource "aws_cognito_user" "admin" {
383
+ user_pool_id = aws_cognito_user_pool.pool.id
384
+ username = var.admin_email
385
+
386
+ attributes = {
387
+ email = var.admin_email
388
+ email_verified = "true"
389
+ }
390
+ }
391
+
392
+ resource "aws_cognito_user_in_group" "admin" {
393
+ for_each = aws_cognito_user_group.roles
394
+ user_pool_id = aws_cognito_user_pool.pool.id
395
+ username = aws_cognito_user.admin.username
396
+ group_name = each.value.name
397
+ }
398
+
399
+ resource "aws_cognito_user_pool_client" "spa" {
400
+ name = "${var.name}-spa"
401
+ user_pool_id = aws_cognito_user_pool.pool.id
402
+
403
+ # SPA/desktop client: public by definition, no secret (Studio/Canvas cannot keep one).
404
+ generate_secret = false
405
+ allowed_oauth_flows_user_pool_client = true
406
+ allowed_oauth_flows = ["code"]
407
+ allowed_oauth_scopes = ["openid", "email", "profile"]
408
+ callback_urls = var.oauth_callback_urls
409
+ logout_urls = var.oauth_callback_urls
410
+ supported_identity_providers = ["COGNITO"]
411
+ }
412
+
413
+ # Cognito domains are GLOBALLY unique across all AWS accounts — a plain
414
+ # "gravity-poc-auth" will collide sooner or later; the random suffix makes the
415
+ # apply deterministic-safe. The full hosted-UI URL is an output either way.
416
+ resource "random_id" "auth_domain" {
417
+ byte_length = 3
418
+ }
419
+
420
+ resource "aws_cognito_user_pool_domain" "domain" {
421
+ domain = "${var.name}-auth-${random_id.auth_domain.hex}"
422
+ user_pool_id = aws_cognito_user_pool.pool.id
423
+ }
424
+
425
+ # ── Bedrock: one IAM user scoped to invoke ────────────────────────────────────
426
+ # Keys are stored as a platform `awsCredential` (aws-bedrock / aws-nova nodes).
427
+ # NOTE: the secret lands in Terraform state — acceptable at POC, noted in the doc.
428
+ # Model ACCESS is enabled in the console per model/region, outside Terraform.
429
+
430
+ resource "aws_iam_user" "bedrock" {
431
+ name = "${var.name}-bedrock-invoke"
432
+ }
433
+
434
+ resource "aws_iam_user_policy" "bedrock" {
435
+ name = "bedrock-invoke-only"
436
+ user = aws_iam_user.bedrock.name
437
+ policy = jsonencode({
438
+ Version = "2012-10-17"
439
+ Statement = [{
440
+ Effect = "Allow"
441
+ Action = [
442
+ "bedrock:InvokeModel",
443
+ "bedrock:InvokeModelWithResponseStream",
444
+ "bedrock:InvokeModelWithBidirectionalStream",
445
+ ]
446
+ Resource = "*"
447
+ }]
448
+ })
449
+ }
450
+
451
+ resource "aws_iam_access_key" "bedrock" {
452
+ user = aws_iam_user.bedrock.name
453
+ }
454
+
455
+ # ── Native ingress (2026-07-29): ONE ALB, host-routed — the thing DO's LB can't
456
+ # do. api.<domain> → :4105 always; unoverse.<domain> → :3001 when canvas_public.
457
+ # Idle timeout 3600s: /stream (SSE) and /ws/gravity are long-lived; the ALB
458
+ # default of 60s severs them (INFRASTRUCTURE.md § Ingress).
459
+
460
+ resource "aws_acm_certificate" "public" {
461
+ count = local.has_domain ? 1 : 0
462
+ domain_name = local.api_host
463
+ subject_alternative_names = var.canvas_public ? ["unoverse.${var.domain}"] : []
464
+ validation_method = "DNS"
465
+
466
+ lifecycle {
467
+ create_before_destroy = true
468
+ }
469
+ }
470
+
471
+ # DNS validation — automatic when the zone is in Route53, manual CNAME otherwise
472
+ # (the records to create are printed by the acm_validation_records output).
473
+ resource "aws_route53_record" "acm_validation" {
474
+ for_each = local.dns_auto ? {
475
+ for dvo in aws_acm_certificate.public[0].domain_validation_options : dvo.domain_name => {
476
+ name = dvo.resource_record_name
477
+ type = dvo.resource_record_type
478
+ record = dvo.resource_record_value
479
+ }
480
+ } : {}
481
+
482
+ zone_id = var.route53_zone_id
483
+ name = each.value.name
484
+ type = each.value.type
485
+ ttl = 300
486
+ records = [each.value.record]
487
+ }
488
+
489
+ resource "aws_acm_certificate_validation" "public" {
490
+ count = local.dns_auto ? 1 : 0
491
+ certificate_arn = aws_acm_certificate.public[0].arn
492
+ validation_record_fqdns = [for r in aws_route53_record.acm_validation : r.fqdn]
493
+ }
494
+
495
+ resource "aws_lb" "public" {
496
+ name = "${var.name}-alb"
497
+ load_balancer_type = "application"
498
+ security_groups = [aws_security_group.alb.id]
499
+ subnets = data.aws_subnets.default.ids
500
+ idle_timeout = 3600
501
+ }
502
+
503
+ resource "aws_lb_target_group" "app" {
504
+ name = "${var.name}-app"
505
+ port = 4105
506
+ protocol = "HTTP"
507
+ vpc_id = data.aws_vpc.default.id
508
+
509
+ health_check {
510
+ path = "/health"
511
+ matcher = "200"
512
+ }
513
+ }
514
+
515
+ resource "aws_lb_target_group_attachment" "app" {
516
+ target_group_arn = aws_lb_target_group.app.arn
517
+ target_id = aws_instance.app.id
518
+ port = 4105
519
+ }
520
+
521
+ resource "aws_lb_target_group" "canvas" {
522
+ count = var.canvas_public ? 1 : 0
523
+ name = "${var.name}-canvas"
524
+ port = 3001
525
+ protocol = "HTTP"
526
+ vpc_id = data.aws_vpc.default.id
527
+
528
+ health_check {
529
+ path = "/"
530
+ matcher = "200"
531
+ }
532
+ }
533
+
534
+ resource "aws_lb_target_group_attachment" "canvas" {
535
+ count = var.canvas_public ? 1 : 0
536
+ target_group_arn = aws_lb_target_group.canvas[0].arn
537
+ target_id = aws_instance.app.id
538
+ port = 3001
539
+ }
540
+
541
+ # :80 — with a domain it redirects to HTTPS; domainless it IS the front door
542
+ # (plain HTTP straight to the platform on the ALB's DNS name).
543
+ resource "aws_lb_listener" "http" {
544
+ load_balancer_arn = aws_lb.public.arn
545
+ port = 80
546
+ protocol = "HTTP"
547
+
548
+ dynamic "default_action" {
549
+ for_each = local.has_domain ? [1] : []
550
+ content {
551
+ type = "redirect"
552
+ redirect {
553
+ port = "443"
554
+ protocol = "HTTPS"
555
+ status_code = "HTTP_301"
556
+ }
557
+ }
558
+ }
559
+ dynamic "default_action" {
560
+ for_each = local.has_domain ? [] : [1]
561
+ content {
562
+ type = "forward"
563
+ target_group_arn = aws_lb_target_group.app.arn
564
+ }
565
+ }
566
+ }
567
+
568
+ resource "aws_lb_listener" "https" {
569
+ count = local.has_domain ? 1 : 0
570
+ load_balancer_arn = aws_lb.public.arn
571
+ port = 443
572
+ protocol = "HTTPS"
573
+ ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
574
+ certificate_arn = local.dns_auto ? aws_acm_certificate_validation.public[0].certificate_arn : aws_acm_certificate.public[0].arn
575
+
576
+ # Default: the platform. An unknown Host lands here too, and the JWT gate is
577
+ # in-app, so the ALB is never load-bearing for auth (the contract's rule 3).
578
+ default_action {
579
+ type = "forward"
580
+ target_group_arn = aws_lb_target_group.app.arn
581
+ }
582
+ }
583
+
584
+ resource "aws_lb_listener_rule" "canvas" {
585
+ count = local.has_domain && var.canvas_public ? 1 : 0
586
+ listener_arn = aws_lb_listener.https[0].arn
587
+ priority = 10
588
+
589
+ action {
590
+ type = "forward"
591
+ target_group_arn = aws_lb_target_group.canvas[0].arn
592
+ }
593
+ condition {
594
+ host_header {
595
+ values = ["unoverse.${var.domain}"]
596
+ }
597
+ }
598
+ }
599
+
600
+ # Domainless canvas_public: no hostname to host-route by, so Canvas takes a
601
+ # second PORT instead — http://<alb-dns>:3001, the same shape as the DO ground.
602
+ resource "aws_lb_listener" "canvas_http" {
603
+ count = !local.has_domain && var.canvas_public ? 1 : 0
604
+ load_balancer_arn = aws_lb.public.arn
605
+ port = 3001
606
+ protocol = "HTTP"
607
+
608
+ default_action {
609
+ type = "forward"
610
+ target_group_arn = aws_lb_target_group.canvas[0].arn
611
+ }
612
+ }
613
+
614
+ # App DNS records (Route53 only — external DNS points these at the ALB by hand).
615
+ resource "aws_route53_record" "api" {
616
+ count = local.dns_auto ? 1 : 0
617
+ zone_id = var.route53_zone_id
618
+ name = local.api_host
619
+ type = "A"
620
+
621
+ alias {
622
+ name = aws_lb.public.dns_name
623
+ zone_id = aws_lb.public.zone_id
624
+ evaluate_target_health = true
625
+ }
626
+ }
627
+
628
+ resource "aws_route53_record" "unoverse" {
629
+ count = local.dns_auto && var.canvas_public ? 1 : 0
630
+ zone_id = var.route53_zone_id
631
+ name = "unoverse.${var.domain}"
632
+ type = "A"
633
+
634
+ alias {
635
+ name = aws_lb.public.dns_name
636
+ zone_id = aws_lb.public.zone_id
637
+ evaluate_target_health = true
638
+ }
639
+ }