unoverse 0.1.69 → 0.1.71

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,139 @@
1
+ # Everything .env.production needs — the rendered file is COMPLETE, nothing
2
+ # is hand-edited afterwards:
3
+ # terraform output -raw env_production > ../../.env.production
4
+ # unoverse deploy && unoverse deploy db && unoverse deploy test
5
+
6
+ output "deploy_host" {
7
+ description = ".env.production DEPLOY_HOST (SSH target). Public traffic goes to the ALB, never here."
8
+ value = aws_eip.app.public_ip
9
+ }
10
+
11
+ output "alb_dns_name" {
12
+ description = "Point api.<domain> (and unoverse.<domain> when canvas_public) at this — CNAME/ALIAS. Created automatically when route53_zone_id is set."
13
+ value = aws_lb.public.dns_name
14
+ }
15
+
16
+ output "acm_validation_records" {
17
+ description = "External DNS only (domain set, route53_zone_id empty): create these CNAMEs so the certificate can issue."
18
+ value = (local.has_domain && !local.dns_auto) ? [
19
+ for dvo in aws_acm_certificate.public[0].domain_validation_options : {
20
+ name = dvo.resource_record_name, type = dvo.resource_record_type, value = dvo.resource_record_value
21
+ }
22
+ ] : []
23
+ }
24
+
25
+ output "canvas_url" {
26
+ description = "Public Canvas URL (canvas_public = true only) — add it to the client origins."
27
+ value = var.canvas_public ? (local.has_domain ? "https://unoverse.${var.domain}" : "http://${aws_lb.public.dns_name}:3001") : "canvas is admin-only (direct http://<instance-ip>:3001 from admin_cidr)"
28
+ }
29
+
30
+ output "api_url" {
31
+ description = "The API base URL as deployed — https://api.<domain> with a domain, http://<alb-dns> without one."
32
+ value = local.has_domain ? "https://${local.api_host}" : "http://${aws_lb.public.dns_name}"
33
+ }
34
+
35
+ output "database_url" {
36
+ description = ".env.production DATABASE_URL (sslmode=require: RDS enforces TLS by default on PG16 — rds.force_ssl=1 — so the client must ask for it)"
37
+ value = "postgresql://${aws_db_instance.postgres.username}:${random_password.db.result}@${aws_db_instance.postgres.address}:5432/${aws_db_instance.postgres.db_name}?sslmode=require"
38
+ sensitive = true
39
+ }
40
+
41
+ output "redis_host" {
42
+ description = ".env.production REDIS_HOST (REDIS_PORT=6379, REDIS_TLS=true)"
43
+ value = aws_elasticache_replication_group.redis.primary_endpoint_address
44
+ }
45
+
46
+ output "redis_password" {
47
+ description = ".env.production REDIS_PASSWORD"
48
+ value = random_password.redis.result
49
+ sensitive = true
50
+ }
51
+
52
+ output "auth_issuer" {
53
+ description = ".env.production AUTH_ISSUER (the Cognito user pool issuer)"
54
+ value = "https://cognito-idp.${var.region}.amazonaws.com/${aws_cognito_user_pool.pool.id}"
55
+ }
56
+
57
+ output "auth_client_id" {
58
+ description = ".env.production AUTH_CLIENT_ID — and the AUTH_AUDIENCE candidate: Cognito access tokens carry client_id, not aud. SMOKE-TEST audience validation with a real token before declaring the universe up (AWS_DEPLOYMENT.md)."
59
+ value = aws_cognito_user_pool_client.spa.id
60
+ }
61
+
62
+ output "roles" {
63
+ description = "Every RBAC role provisioned as a Cognito group (platform permissions + var.roles)"
64
+ value = sort(keys(aws_cognito_user_group.roles))
65
+ }
66
+
67
+ output "cognito_hosted_ui" {
68
+ description = "Hosted login domain (Managed Login)"
69
+ value = "https://${aws_cognito_user_pool_domain.domain.domain}.auth.${var.region}.amazoncognito.com"
70
+ }
71
+
72
+ output "bedrock_access_key_id" {
73
+ description = "Store as a platform awsCredential (accessKeyId) for the Bedrock/Nova nodes"
74
+ value = aws_iam_access_key.bedrock.id
75
+ }
76
+
77
+ output "bedrock_secret_access_key" {
78
+ description = "Store as a platform awsCredential (secretAccessKey)"
79
+ value = aws_iam_access_key.bedrock.secret
80
+ sensitive = true
81
+ }
82
+
83
+ # ── The whole .env.production, rendered COMPLETE ──────────────────────────────
84
+ output "env_production" {
85
+ sensitive = true
86
+ description = "Rendered .env.production — complete, write to a file and deploy"
87
+ value = <<-ENV
88
+ # UNIVERSE (AWS) — generated by infra/aws. COMPLETE:
89
+ # do not hand-edit; change terraform.tfvars and re-render instead.
90
+
91
+ # Deploy target (SSH; public traffic goes through the ALB)
92
+ DEPLOY_HOST=${aws_eip.app.public_ip}
93
+ DEPLOY_USER=ubuntu
94
+
95
+ # Container registry
96
+ DOCR_TOKEN=${var.docr_token}
97
+
98
+ # Database (RDS enforces TLS on PG16 — sslmode stays). AWS runs DIRECT (no
99
+ # pooler until `large` per the Postgres law): both URLs are the same server.
100
+ DATABASE_URL=postgresql://${aws_db_instance.postgres.username}:${random_password.db.result}@${aws_db_instance.postgres.address}:5432/${aws_db_instance.postgres.db_name}?sslmode=require
101
+ DATABASE_URL_DIRECT=postgresql://${aws_db_instance.postgres.username}:${random_password.db.result}@${aws_db_instance.postgres.address}:5432/${aws_db_instance.postgres.db_name}?sslmode=require
102
+
103
+ # Connection budget (INFRASTRUCTURE.md, size = ${var.size})
104
+ DB_POOL_ENGINE=${local.s.pool_engine}
105
+ DB_POOL_ENGINE_LEGACY=${local.s.pool_legacy}
106
+ DB_POOL_MEMORY=${local.s.pool_memory}
107
+
108
+ # Credential encryption at rest — per-deployment, generated by Terraform.
109
+ # Back this up with the database (a DB backup is unreadable without it).
110
+ CREDENTIAL_ENCRYPTION_KEY=${random_password.credential_key.result}
111
+
112
+ # Redis (ElastiCache, TLS + auth token)
113
+ REDIS_HOST=${aws_elasticache_replication_group.redis.primary_endpoint_address}
114
+ REDIS_PORT=6379
115
+ REDIS_PASSWORD=${random_password.redis.result}
116
+ REDIS_TLS=true
117
+ REDIS_NAMESPACE=universe
118
+
119
+ # OIDC (Cognito). AUTH_AUDIENCE = the app client id — Cognito access tokens
120
+ # carry client_id, not aud; SMOKE-TEST with a real token (AWS_DEPLOYMENT.md).
121
+ AUTH_ISSUER=https://cognito-idp.${var.region}.amazonaws.com/${aws_cognito_user_pool.pool.id}
122
+ AUTH_CLIENT_ID=${aws_cognito_user_pool_client.spa.id}
123
+ AUTH_AUDIENCE=${aws_cognito_user_pool_client.spa.id}
124
+
125
+ # Service keys
126
+ OPENAI_API_KEY=${var.openai_api_key}
127
+ HYPERBROWSER_API_KEY=${var.hyperbrowser_api_key}
128
+
129
+ # Ingress. With a domain: DOMAIN drives every URL (compose derives
130
+ # https://api.<domain>). Without one (POC): DOMAIN stays empty and the
131
+ # explicit URLs below point at the ALB's DNS name over plain HTTP.
132
+ # To upgrade later: set domain in terraform.tfvars, terraform apply,
133
+ # re-render this file, unoverse deploy. Nothing is destroyed.
134
+ DOMAIN=${var.domain}
135
+ ${local.has_domain ? "" : "API_URL=http://${aws_lb.public.dns_name}"}
136
+ ${local.has_domain ? "" : "VITE_SERVER_WS_URL=ws://${aws_lb.public.dns_name}"}
137
+ ${local.has_domain ? "" : "UNOVERSE_URL=http://${aws_lb.public.dns_name}"}
138
+ ENV
139
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Cognito Pre Token Generation (V2_0) — the platform's token contract.
3
+ *
4
+ * LOAD-BEARING. The platform reads sub/email/roles (and permissions) off the
5
+ * ACCESS token and never calls back to the IdP (docs/AUTH_TOKEN_FLOW.md). Without
6
+ * this Lambda, email-keyed features silently no-op and no role-gated surface
7
+ * (workflow:author builder, marketplace:publish publish, requires.role nodes)
8
+ * can ever pass. The Cognito equivalent of the Auth0 Post-Login Action.
9
+ *
10
+ * Roles = the user's Cognito groups, verbatim. Groups are named in the platform's
11
+ * noun:verb grammar (workflow:author, marketplace:publish, finance:approve, ...),
12
+ * so group membership IS the RBAC surface. They are emitted on BOTH `roles` and
13
+ * `permissions`: the platform's builder/publish gates read permissions, node
14
+ * requires.role matches either.
15
+ */
16
+ export const handler = async (event) => {
17
+ const groups = event.request.groupConfiguration?.groupsToOverride ?? [];
18
+ const email = event.request.userAttributes?.email;
19
+
20
+ const claims = {
21
+ roles: groups,
22
+ permissions: groups,
23
+ ...(email ? { email } : {}),
24
+ };
25
+
26
+ event.response = {
27
+ claimsAndScopeOverrideDetails: {
28
+ accessTokenGeneration: {
29
+ claimsToAddOrOverride: claims,
30
+ },
31
+ idTokenGeneration: {
32
+ claimsToAddOrOverride: claims,
33
+ },
34
+ },
35
+ };
36
+ return event;
37
+ };
@@ -0,0 +1,53 @@
1
+ # What this ground costs to run, priced where its sizes are chosen.
2
+ #
3
+ # ONE HOME FOR THE NUMBERS, the same arrangement as the DigitalOcean ground: the ground
4
+ # owns its sizes, so the ground prices them. `unoverse deploy` reads this map out of the
5
+ # plan JSON instead of carrying a second table, which is how the CLI came to quote
6
+ # t3-series rates for a ground that had moved to t4g and silently price its database at
7
+ # nothing at all.
8
+ #
9
+ # NOT A COST ORACLE. On-demand rates in a mid-priced region, so a developer sees an order
10
+ # of magnitude instead of a surprise. Rates move and vary by region; the bill is AWS's.
11
+
12
+ locals {
13
+ # Monthly USD, keyed by the instance type it prices. A type absent here is simply not
14
+ # priced (the summary shows the resource with no figure) rather than guessed at.
15
+ prices = {
16
+ # EC2
17
+ "t3.xlarge" = 120 # small: the POC box
18
+ "m6i.2xlarge" = 280 # medium
19
+ "r6i.2xlarge" = 365 # large
20
+ # RDS Postgres
21
+ "db.t4g.small" = 25
22
+ "db.t4g.medium" = 50
23
+ "db.m6g.large" = 125
24
+ # ElastiCache Redis
25
+ "cache.t4g.micro" = 12
26
+ "cache.t4g.small" = 25
27
+ "cache.t4g.medium" = 50
28
+ # Fixed-price resources, keyed by what they are rather than a size
29
+ "alb" = 18
30
+ # Not a size: EBS, the public IPv4 charge, Route 53 and egress at POC traffic. Small
31
+ # and unavoidable, so the estimate carries it rather than reading low by a sixth.
32
+ "baseline" = 15
33
+ }
34
+
35
+ # The steady-state bill for THIS universe, as configured.
36
+ monthly_estimate = sum([
37
+ lookup(local.prices, local.s.instance, 0),
38
+ lookup(local.prices, local.s.pg, 0),
39
+ lookup(local.prices, local.s.redis, 0),
40
+ local.prices["alb"],
41
+ local.prices["baseline"],
42
+ ])
43
+ }
44
+
45
+ output "prices" {
46
+ description = "Monthly USD by instance type. The CLI's plan summary reads this, so the deploy screen and the docs cannot drift apart."
47
+ value = local.prices
48
+ }
49
+
50
+ output "monthly_estimate" {
51
+ description = "Roughly what this universe costs a month at on-demand prices, for the size configured."
52
+ value = local.monthly_estimate
53
+ }
@@ -0,0 +1,55 @@
1
+ # Copy to terraform.tfvars and fill in — or let `./unoverse ground aws` write it
2
+ # for you, prefilled from the AWS CLI (region, key pair, Route53 zone, your IP).
3
+ # terraform.tfvars is gitignored.
4
+
5
+ region = "us-east-1"
6
+ name = "universe-poc"
7
+ admin_cidr = "203.0.113.7/32" # YOUR IP — the only SSH source
8
+ ssh_key_name = "your-ec2-keypair-name" # must already exist in the region
9
+ admin_email = "you@example.com" # initial admin (Cognito user, all roles; invite emailed)
10
+ size = "small" # small (POC) | medium | large
11
+ # Domain is OPTIONAL — the same law on every ground. Leave it empty and the
12
+ # universe comes up on the ALB's DNS name over plain HTTP (terraform output
13
+ # api_url shows the address). Fill it in later and `terraform apply` again:
14
+ # the SAME deployment upgrades in place to TLS at api.<domain>.
15
+ domain = "" # empty = HTTP on the ALB DNS name; "yourdomain.com" = TLS at api.yourdomain.com
16
+
17
+ # OPTIONAL (with a domain): the domain's Route53 hosted zone id — set it and Terraform creates the
18
+ # DNS records AND auto-validates the certificate. Empty = you add the printed
19
+ # CNAMEs/aliases yourself (outputs: acm_validation_records, alb_dns_name).
20
+ # route53_zone_id = "Z0123456789ABC"
21
+ # (Domain hosted elsewhere — GoDaddy etc.? That's fine on AWS: leave this unset
22
+ # and paste the printed CNAMEs into your DNS panel. No Route53 required.)
23
+
24
+ # POC ONLY: public Canvas at https://unoverse.<domain> — a host rule on the ONE
25
+ # ALB (no second LB; ALBs host-route, unlike DO). Add the URL to oauth origins.
26
+ canvas_public = true
27
+
28
+ # Service secrets — with these set, the rendered .env.production is COMPLETE.
29
+ docr_token = "dop_v1_..." # registry token (pulls platform images)
30
+ openai_api_key = "sk-..." # memory server + OpenAI nodes
31
+ # hyperbrowser_api_key = "" # optional — page intelligence
32
+
33
+ oauth_callback_urls = [
34
+ "https://yourdomain.com",
35
+ "http://localhost:5173", # local Studio/Canvas dev against this universe
36
+ ]
37
+
38
+ # RBAC roles (noun:verb — the grammar node manifests' requires.role matches).
39
+ # Each becomes a Cognito group; put a user in the group and the role rides their
40
+ # token. THE FULL SET, mirroring the Auth0 tenant's Gravity API permissions
41
+ # (2026-07-28). The first two are enforced by code gates and are always created
42
+ # even if removed from this list; the last two are declared intent (no code gate
43
+ # yet — enforcement arrives with the surfaces they name).
44
+ roles = [
45
+ "workflow:author", # build and test workflows (builder gate — ENFORCED)
46
+ "marketplace:publish", # publish assets to this universe (publish gate — ENFORCED)
47
+ "workflow:promote", # promote a draft to active / go-live (declared only)
48
+ "admin:access", # admin to Unoverse Canvas (declared only)
49
+ ]
50
+
51
+ # The rendered output (terraform output -raw env_production > ../../.env.production)
52
+ # IS your universe's secrets, including the MASTER KEY (CREDENTIAL_ENCRYPTION_KEY)
53
+ # that locks every credential users save in the platform. If it's lost, no backup
54
+ # can bring them back. Never commit it; keep a safe copy with your database backups.
55
+ # To change any value: edit this file and re-render. Never hand-edit the output.
@@ -0,0 +1,92 @@
1
+ variable "region" {
2
+ description = "AWS region. Must have Bedrock model access enabled for the models you use."
3
+ type = string
4
+ default = "us-east-1"
5
+ }
6
+
7
+ variable "name" {
8
+ description = "Prefix for every resource (also the Cognito domain prefix, so keep it dns-safe)."
9
+ type = string
10
+ default = "universe-poc"
11
+ }
12
+
13
+ variable "admin_cidr" {
14
+ description = "The ONLY CIDR allowed to SSH (deploys + ./unoverse key). Your IP as x.x.x.x/32."
15
+ type = string
16
+ }
17
+
18
+ variable "ssh_key_name" {
19
+ description = "Name of an existing EC2 key pair for SSH access."
20
+ type = string
21
+ }
22
+
23
+ variable "oauth_callback_urls" {
24
+ description = "OIDC redirect URLs for the SPA client (Canvas/Studio origins, e.g. https://yourdomain.com and http://localhost:5173 for dev)."
25
+ type = list(string)
26
+ }
27
+
28
+ variable "admin_email" {
29
+ description = "The initial ADMIN user's email. Created in the Cognito pool and placed in every role group (Cognito emails an invite with a temporary password). This is the platform admin — a Cognito user, not an IAM one."
30
+ type = string
31
+ }
32
+
33
+ variable "roles" {
34
+ description = "This deployment's RBAC roles, always noun:verb (matched by node manifests' requires.role). Each becomes a Cognito group; membership rides the token's roles/permissions claims. The two platform permissions (workflow:author, marketplace:publish) are always created and need not be listed."
35
+ type = list(string)
36
+ default = []
37
+ validation {
38
+ condition = alltrue([for r in var.roles : can(regex("^[a-z][a-z0-9_-]*:[a-z][a-z0-9_-]*$", r))])
39
+ error_message = "Every role must be noun:verb (lowercase), e.g. finance:approve — the same grammar the node linter enforces."
40
+ }
41
+ }
42
+
43
+ # ── Added 2026-07-29: parity with infra/digitalocean ──────────────────────────
44
+
45
+ variable "size" {
46
+ description = "Deployment size (INFRASTRUCTURE.md size table): small = POC box."
47
+ type = string
48
+ default = "small"
49
+ validation {
50
+ condition = contains(["small", "medium", "large"], var.size)
51
+ error_message = "size must be small, medium or large."
52
+ }
53
+ }
54
+
55
+ variable "domain" {
56
+ description = "OPTIONAL — the same law on every ground. Empty (the default) = no certificate and no hostnames: the ALB serves plain HTTP on its DNS name (terraform output api_url) — the zero-friction first apply. Set it later and re-apply to upgrade IN PLACE to TLS at api.<domain> (and unoverse.<domain> when canvas_public). Nothing is destroyed by the upgrade."
57
+ type = string
58
+ default = ""
59
+ }
60
+
61
+ variable "route53_zone_id" {
62
+ description = "OPTIONAL: the domain's Route53 hosted zone id. Set = Terraform creates the DNS records AND auto-validates the ACM cert. Empty = you create the validation CNAME + A records yourself (printed as outputs)."
63
+ type = string
64
+ default = ""
65
+ }
66
+
67
+ variable "canvas_public" {
68
+ description = "POC ONLY: Canvas publicly at https://unoverse.<domain> — a HOST RULE on the one ALB (ALBs host-route; no second LB needed, unlike DO). false = Canvas stays admin-only direct :3001 (the standing posture). Add https://unoverse.<domain> to the client origins."
69
+ type = bool
70
+ default = false
71
+ }
72
+
73
+ # Service secrets — taken here so the rendered .env.production is COMPLETE;
74
+ # the operator fills terraform.tfvars once and never hand-edits the env file.
75
+ variable "docr_token" {
76
+ description = "Container registry token — pulls the platform images."
77
+ type = string
78
+ sensitive = true
79
+ }
80
+
81
+ variable "openai_api_key" {
82
+ description = "OpenAI API key — memory server + OpenAI nodes."
83
+ type = string
84
+ sensitive = true
85
+ }
86
+
87
+ variable "hyperbrowser_api_key" {
88
+ description = "Optional — page-intelligence features. Empty = feature off."
89
+ type = string
90
+ default = ""
91
+ sensitive = true
92
+ }
@@ -0,0 +1,297 @@
1
+ # The Universe — DigitalOcean ground (docs/architecture/INFRASTRUCTURE.md)
2
+ #
3
+ # Same five-input contract as infra/aws, DO implementation: Droplet + Managed
4
+ # Postgres (fronted by its built-in PgBouncer, per the Postgres law) + Managed
5
+ # Redis + DO Load Balancer with a managed Let's Encrypt cert (native ingress —
6
+ # Caddy is retired) + cloud firewall (the ground owns the firewall; Docker
7
+ # cannot bypass what never reaches the box). Terraform provisions and renders
8
+ # .env.production; `unoverse deploy` does the rest.
9
+
10
+ terraform {
11
+ required_version = ">= 1.5"
12
+ required_providers {
13
+ digitalocean = { source = "digitalocean/digitalocean", version = "~> 2.40" }
14
+ random = { source = "hashicorp/random", version = "~> 3.6" }
15
+ }
16
+ }
17
+
18
+ provider "digitalocean" {
19
+ # Empty token falls through to the DIGITALOCEAN_TOKEN env var.
20
+ token = var.do_token != "" ? var.do_token : null
21
+ }
22
+
23
+ # ── Size table (INFRASTRUCTURE.md § Sizes — the numbers live in the contract) ──
24
+ locals {
25
+ sizes = {
26
+ small = {
27
+ droplet = "s-4vcpu-16gb-amd" # 4 vCPU / 16 GB — the POC box
28
+ pg = "db-s-1vcpu-1gb" # ~22 backend connections (~19 usable) — hence the pool
29
+ redis = "db-s-1vcpu-1gb"
30
+ # Connection budget: engine 8 / legacy 4 / memory 4 (+2 ops, +1 reserve = 19)
31
+ pool_engine = 8
32
+ pool_legacy = 4
33
+ pool_memory = 4
34
+ pgbouncer = 17 # backend connections the managed pool holds (leaves 5 direct: migrations + ops)
35
+ }
36
+ medium = {
37
+ droplet = "g-8vcpu-32gb"
38
+ pg = "db-s-2vcpu-4gb"
39
+ redis = "db-s-1vcpu-2gb"
40
+ pool_engine = 20
41
+ pool_legacy = 8
42
+ pool_memory = 10
43
+ pgbouncer = 40
44
+ }
45
+ large = {
46
+ droplet = "m-8vcpu-64gb" # memory-optimized: the engine is ONE event loop — RAM, not cores
47
+ pg = "db-s-4vcpu-8gb"
48
+ redis = "db-s-2vcpu-4gb"
49
+ pool_engine = 40
50
+ pool_legacy = 12
51
+ pool_memory = 20
52
+ pgbouncer = 80
53
+ }
54
+ }
55
+ s = local.sizes[var.size]
56
+
57
+ # Postgres modes (variables.tf): external URL > existing DO cluster > provision.
58
+ pg_external = var.byo_postgres_url != ""
59
+ pg_adopt = !local.pg_external && var.existing_pg_cluster_name != ""
60
+ provision_pg = !local.pg_external && !local.pg_adopt
61
+ pg_managed = local.pg_adopt || local.provision_pg # any DO-managed mode
62
+
63
+ # Domainless-first (DECIDED 2026-07-31): no domain = no cert, the LB speaks
64
+ # plain HTTP on its IP. Setting domain later and re-applying upgrades the SAME
65
+ # LB in place — cert issued, HTTPS rules swap in, HTTP redirect on.
66
+ has_domain = var.domain != ""
67
+ api_host = "api.${var.domain}"
68
+ }
69
+
70
+ # The adopted cluster's facts (id/host/port) when reusing an existing one.
71
+ data "digitalocean_database_cluster" "existing_pg" {
72
+ count = local.pg_adopt ? 1 : 0
73
+ name = var.existing_pg_cluster_name
74
+ }
75
+
76
+ locals {
77
+ # The cluster every universe resource (db/user/pool/firewall) attaches to.
78
+ pg_cluster_id = local.pg_adopt ? data.digitalocean_database_cluster.existing_pg[0].id : (local.provision_pg ? digitalocean_database_cluster.pg[0].id : "")
79
+ pg_cluster_host = local.pg_adopt ? data.digitalocean_database_cluster.existing_pg[0].private_host : (local.provision_pg ? digitalocean_database_cluster.pg[0].private_host : "")
80
+ pg_cluster_port = local.pg_adopt ? data.digitalocean_database_cluster.existing_pg[0].port : (local.provision_pg ? digitalocean_database_cluster.pg[0].port : 25060)
81
+ }
82
+
83
+ data "digitalocean_ssh_key" "operator" {
84
+ name = var.ssh_key_name
85
+ }
86
+
87
+ # ── The box ───────────────────────────────────────────────────────────────────
88
+ resource "digitalocean_droplet" "app" {
89
+ name = "${var.name}-app"
90
+ region = var.region
91
+ size = local.s.droplet
92
+ image = "ubuntu-22-04-x64"
93
+ ssh_keys = [data.digitalocean_ssh_key.operator.id]
94
+ }
95
+
96
+ # ── Firewall: the ground owns it (harden.yml's ufw could be bypassed by
97
+ # Docker's iptables rules; a cloud firewall cannot) ────────────────────────────
98
+ resource "digitalocean_firewall" "app" {
99
+ name = "${var.name}-app"
100
+ droplet_ids = [digitalocean_droplet.app.id]
101
+
102
+ inbound_rule {
103
+ protocol = "tcp"
104
+ port_range = "22"
105
+ source_addresses = [var.admin_cidr]
106
+ }
107
+ # Dozzle log viewer — POC size ships it ON, admin-only (INFRASTRUCTURE.md size table).
108
+ inbound_rule {
109
+ protocol = "tcp"
110
+ port_range = "8080"
111
+ source_addresses = [var.admin_cidr]
112
+ }
113
+ # The public surface arrives ONLY through the load balancer.
114
+ inbound_rule {
115
+ protocol = "tcp"
116
+ port_range = "4105"
117
+ source_load_balancer_uids = [digitalocean_loadbalancer.public.id]
118
+ }
119
+ # Canvas (operator UI): the DO LB has NO host-based routing (Caddy did), so the
120
+ # public LB carries ONLY the api host → :4105. Canvas is an OPERATOR tool —
121
+ # admin-only direct access, same trust ring as SSH and Dozzle. It still calls
122
+ # the platform at https://api.<domain> like any client.
123
+ # POC (canvas_public = true): :3001 ALSO accepts the public LB, which serves
124
+ # Canvas at https://api.<domain>:3001. Direct-IP access stays admin-only either way.
125
+ inbound_rule {
126
+ protocol = "tcp"
127
+ port_range = "3001"
128
+ source_addresses = [var.admin_cidr]
129
+ source_load_balancer_uids = var.canvas_public ? [digitalocean_loadbalancer.public.id] : []
130
+ }
131
+
132
+ outbound_rule {
133
+ protocol = "tcp"
134
+ port_range = "1-65535"
135
+ destination_addresses = ["0.0.0.0/0", "::/0"]
136
+ }
137
+ outbound_rule {
138
+ protocol = "udp"
139
+ port_range = "1-65535"
140
+ destination_addresses = ["0.0.0.0/0", "::/0"]
141
+ }
142
+ }
143
+
144
+ # ── Native ingress: DO LB + managed Let's Encrypt cert (Caddy retired) ────────
145
+ # NOTE (INFRASTRUCTURE.md § Ingress): DO caps http idle timeout at 600s — set to
146
+ # the cap; long-quiet SSE/WS sessions must survive a reconnect (smoke-test item).
147
+ resource "digitalocean_certificate" "api" {
148
+ count = local.has_domain ? 1 : 0
149
+ name = "${var.name}-api"
150
+ type = "lets_encrypt"
151
+ # canvas.<domain> rides the SAME certificate as a SAN (and the same LB): the
152
+ # clean hostname costs nothing — only the PORT cannot be dropped, because DO
153
+ # LBs cannot host-route (DECIDED 2026-07-29: one LB, port in the URL).
154
+ domains = var.canvas_public ? [local.api_host, "canvas.${var.domain}"] : [local.api_host]
155
+ }
156
+
157
+ resource "digitalocean_loadbalancer" "public" {
158
+ name = "${var.name}-lb"
159
+ region = var.region
160
+ droplet_ids = [digitalocean_droplet.app.id]
161
+ redirect_http_to_https = local.has_domain # domainless serves HTTP itself, nothing to redirect to
162
+ http_idle_timeout_seconds = 600 # DO's maximum
163
+
164
+ # With a domain: HTTPS 443 with the managed cert. Without: plain HTTP 80 on
165
+ # the LB IP. Same port meanings for the developer either way (API on the root).
166
+ dynamic "forwarding_rule" {
167
+ for_each = local.has_domain ? [1] : []
168
+ content {
169
+ entry_port = 443
170
+ entry_protocol = "https"
171
+ target_port = 4105
172
+ target_protocol = "http"
173
+ certificate_name = one(digitalocean_certificate.api[*].name)
174
+ }
175
+ }
176
+ dynamic "forwarding_rule" {
177
+ for_each = local.has_domain ? [] : [1]
178
+ content {
179
+ entry_port = 80
180
+ entry_protocol = "http"
181
+ target_port = 4105
182
+ target_protocol = "http"
183
+ }
184
+ }
185
+
186
+ # POC (canvas_public = true, DECIDED 2026-07-29): Canvas rides the SAME LB on a
187
+ # second port — https://canvas.<domain>:3001 (a SAN on the api certificate, an A record to this same LB). ONE load balancer total: DO LBs can't
188
+ # host-route, and a clean second hostname would cost a second LB; the POC takes
189
+ # the port in the URL instead. Domainless: same second port, plain HTTP.
190
+ dynamic "forwarding_rule" {
191
+ for_each = var.canvas_public ? [1] : []
192
+ content {
193
+ entry_port = 3001
194
+ entry_protocol = local.has_domain ? "https" : "http"
195
+ target_port = 3001
196
+ target_protocol = "http"
197
+ certificate_name = one(digitalocean_certificate.api[*].name)
198
+ }
199
+ }
200
+
201
+
202
+ healthcheck {
203
+ port = 4105
204
+ protocol = "http"
205
+ path = "/health"
206
+ }
207
+ }
208
+
209
+ resource "digitalocean_record" "canvas" {
210
+ count = local.has_domain && var.canvas_public && var.manage_dns ? 1 : 0
211
+ domain = var.domain
212
+ type = "A"
213
+ name = "canvas"
214
+ value = digitalocean_loadbalancer.public.ip
215
+ ttl = 300
216
+ }
217
+
218
+ # Optional DNS (manage_dns = true and the domain hosted on DO).
219
+ resource "digitalocean_record" "api" {
220
+ count = local.has_domain && var.manage_dns ? 1 : 0
221
+ domain = var.domain
222
+ type = "A"
223
+ name = "api"
224
+ value = digitalocean_loadbalancer.public.ip
225
+ ttl = 300
226
+ }
227
+
228
+ # ── Postgres: Managed PG fronted by its built-in PgBouncer (the law) ──────────
229
+ resource "digitalocean_database_cluster" "pg" {
230
+ count = local.provision_pg ? 1 : 0
231
+ name = "${var.name}-pg"
232
+ engine = "pg"
233
+ version = "16"
234
+ size = local.s.pg
235
+ region = var.region
236
+ node_count = 1
237
+ }
238
+
239
+ resource "digitalocean_database_db" "universe" {
240
+ count = local.pg_managed ? 1 : 0
241
+ cluster_id = local.pg_cluster_id
242
+ name = "universe"
243
+ }
244
+
245
+ resource "digitalocean_database_user" "universe" {
246
+ count = local.pg_managed ? 1 : 0
247
+ cluster_id = local.pg_cluster_id
248
+ name = "universe"
249
+ }
250
+
251
+ # Transaction-mode pool: ~hundreds of client connections over `pgbouncer` backend
252
+ # slots. Services use the POOLED url; migrations use the DIRECT url
253
+ # (DATABASE_URL_DIRECT — db-setup prefers it; DDL doesn't ride transaction mode).
254
+ resource "digitalocean_database_connection_pool" "universe" {
255
+ count = local.pg_managed ? 1 : 0
256
+ cluster_id = local.pg_cluster_id
257
+ name = "universe-pool"
258
+ mode = "transaction"
259
+ size = local.s.pgbouncer
260
+ db_name = digitalocean_database_db.universe[0].name
261
+ user = digitalocean_database_user.universe[0].name
262
+ }
263
+
264
+ resource "digitalocean_database_firewall" "pg" {
265
+ count = local.pg_managed ? 1 : 0
266
+ cluster_id = local.pg_cluster_id
267
+ rule {
268
+ type = "droplet"
269
+ value = digitalocean_droplet.app.id
270
+ }
271
+ }
272
+
273
+ # ── Redis: ALWAYS ours (Managed Redis, TLS, droplet-only). Never BYO — it is
274
+ # the shared-state backbone and the active/active future; the stack owns it. ──
275
+ resource "digitalocean_database_cluster" "redis" {
276
+ name = "${var.name}-redis"
277
+ engine = "redis"
278
+ version = "7"
279
+ size = local.s.redis
280
+ region = var.region
281
+ node_count = 1
282
+ }
283
+
284
+ resource "digitalocean_database_firewall" "redis" {
285
+ cluster_id = digitalocean_database_cluster.redis.id
286
+ rule {
287
+ type = "droplet"
288
+ value = digitalocean_droplet.app.id
289
+ }
290
+ }
291
+
292
+ # ── Per-deployment credential-encryption key (SECURITY.md § Credential
293
+ # encryption at rest): no deployment ever runs on the committed default. ──────
294
+ resource "random_password" "credential_key" {
295
+ length = 44
296
+ special = false
297
+ }