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.
- package/bin/unoverse.mjs +1 -1
- package/lib/create/credential.mjs +90 -0
- package/lib/create/download.mjs +19 -0
- package/lib/create/index.mjs +141 -0
- package/lib/create/target.mjs +40 -0
- package/lib/ui.mjs +119 -0
- package/lib/urls.mjs +1 -7
- package/operator/infra/aws/main.tf +639 -0
- package/operator/infra/aws/outputs.tf +139 -0
- package/operator/infra/aws/pretoken/index.mjs +37 -0
- package/operator/infra/aws/prices.tf +53 -0
- package/operator/infra/aws/terraform.tfvars.example +55 -0
- package/operator/infra/aws/variables.tf +92 -0
- package/operator/infra/digitalocean/main.tf +297 -0
- package/operator/infra/digitalocean/outputs.tf +96 -0
- package/operator/infra/digitalocean/prices.tf +49 -0
- package/operator/infra/digitalocean/terraform.tfvars.example +52 -0
- package/operator/infra/digitalocean/variables.tf +125 -0
- package/operator/lib/deploy.sh +146 -162
- package/operator/lib/init.sh +2 -9
- package/operator/operator.sh +16 -0
- package/package.json +1 -1
- package/lib/create.mjs +0 -369
|
@@ -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
|
+
}
|