unoverse 0.1.138 → 0.1.140

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.
@@ -400,8 +400,18 @@ data "archive_file" "pretoken" {
400
400
  output_path = "${path.module}/pretoken/pretoken.zip"
401
401
  }
402
402
 
403
+ # THE ROLE IS OURS TO CREATE ONLY WHERE IAM WRITE IS DELEGATED. Where it is not, the
404
+ # account's own pipeline creates it and hands us the ARN, and we must reference it rather
405
+ # than manage it: two states owning one role fight, and our destroy would take theirs with
406
+ # it. See variables.tf, `pretoken_role_arn`.
407
+ locals {
408
+ pretoken_role_arn = var.pretoken_role_arn != "" ? var.pretoken_role_arn : one(aws_iam_role.pretoken[*].arn)
409
+ pretoken_role_ours = var.pretoken_role_arn == ""
410
+ }
411
+
403
412
  resource "aws_iam_role" "pretoken" {
404
- name = "${var.name}-pretoken"
413
+ count = local.pretoken_role_ours ? 1 : 0
414
+ name = "${var.name}-pretoken"
405
415
  assume_role_policy = jsonencode({
406
416
  Version = "2012-10-17"
407
417
  Statement = [{
@@ -413,7 +423,8 @@ resource "aws_iam_role" "pretoken" {
413
423
  }
414
424
 
415
425
  resource "aws_iam_role_policy_attachment" "pretoken_logs" {
416
- role = aws_iam_role.pretoken.name
426
+ count = local.pretoken_role_ours ? 1 : 0
427
+ role = aws_iam_role.pretoken[0].name
417
428
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
418
429
  }
419
430
 
@@ -422,8 +433,11 @@ resource "aws_iam_role_policy_attachment" "pretoken_logs" {
422
433
  # assumed by Lambda" — the role is correct and simply not visible yet. Terraform's
423
434
  # dependency graph is satisfied because the role exists; AWS's own propagation is not.
424
435
  #
425
- # Ten seconds is the usual advice and it costs ten seconds on a first apply only.
436
+ # Ten seconds is the usual advice and it costs ten seconds on a first apply only. A role we
437
+ # were handed was created by another pipeline long before this apply, so there is nothing to
438
+ # outrun and the wait is skipped with the role.
426
439
  resource "time_sleep" "iam_propagation" {
440
+ count = local.pretoken_role_ours ? 1 : 0
427
441
  depends_on = [aws_iam_role.pretoken, aws_iam_role_policy_attachment.pretoken_logs]
428
442
  create_duration = "10s"
429
443
  }
@@ -435,7 +449,7 @@ resource "aws_lambda_function" "pretoken" {
435
449
  variables = { ROLE_PERMISSIONS = jsonencode(local.all_roles) }
436
450
  }
437
451
  function_name = "${var.name}-pretoken"
438
- role = aws_iam_role.pretoken.arn
452
+ role = local.pretoken_role_arn
439
453
  runtime = "nodejs20.x"
440
454
  handler = "index.handler"
441
455
  filename = data.archive_file.pretoken.output_path
@@ -57,3 +57,11 @@ roles = [
57
57
  # Marketplace catalogue. Defaults to the official one — set this only to point somewhere
58
58
  # else, or to "" for local items only.
59
59
  # marketplace_url = "https://your-own-marketplace.example.com"
60
+
61
+ # An account that will not let you create IAM roles. Leave both of these alone unless your
62
+ # deploy fails with AccessDenied on iam:CreateRole or iam:CreateUser, which is what a
63
+ # customer-owned account governed by a permission set (AWS's PowerUserAccess excludes IAM
64
+ # by design) looks like. Then: their team creates the Lambda execution role, you paste the
65
+ # ARN here, and you skip the Bedrock user and paste its keys into a credential by hand.
66
+ # pretoken_role_arn = "arn:aws:iam::000000000000:role/unoverse-pretoken-role"
67
+ # bedrock_credentials = false
@@ -130,3 +130,21 @@ variable "bedrock_credentials" {
130
130
  default = true
131
131
  description = "Create an IAM user and access key for the Bedrock nodes. Set false where IAM write is not delegated."
132
132
  }
133
+
134
+ # The pre-token Lambda's execution role (main.tf, "pretoken"), when the account will not let
135
+ # us create it.
136
+ #
137
+ # EMPTY IS EVERY DEPLOYMENT WE HAVE. The module creates the role, attaches
138
+ # AWSLambdaBasicExecutionRole, and nothing changes. Set this only where `iam:CreateRole` is
139
+ # not delegated, which is the normal shape of a customer-owned account governed by a
140
+ # permission set: their pipeline creates the role, we reference the ARN it produces.
141
+ #
142
+ # Creating the Lambda still needs `iam:PassRole` on that role, conditioned on
143
+ # lambda.amazonaws.com, because AWS asks that of whoever attaches the role, not whoever made
144
+ # it. Handing us an ARN does not remove it. The role needs only lambda.amazonaws.com in its
145
+ # trust policy and AWSLambdaBasicExecutionRole attached, which is log writes and nothing else.
146
+ variable "pretoken_role_arn" {
147
+ type = string
148
+ default = ""
149
+ description = "ARN of an existing Lambda execution role for the pre-token trigger. Empty = the module creates it."
150
+ }
@@ -146,9 +146,9 @@ resource "digitalocean_firewall" "app" {
146
146
  # NOTE (INFRASTRUCTURE.md § Ingress): DO caps http idle timeout at 600s — set to
147
147
  # the cap; long-quiet SSE/WS sessions must survive a reconnect (smoke-test item).
148
148
  resource "digitalocean_certificate" "api" {
149
- count = local.has_domain ? 1 : 0
150
- name = "${var.name}-api"
151
- type = "lets_encrypt"
149
+ count = local.has_domain ? 1 : 0
150
+ name = "${var.name}-api"
151
+ type = "lets_encrypt"
152
152
  # canvas.<domain> rides the SAME certificate as a SAN (and the same LB): the
153
153
  # clean hostname costs nothing — only the PORT cannot be dropped, because DO
154
154
  # LBs cannot host-route (DECIDED 2026-07-29: one LB, port in the URL).
@@ -156,11 +156,11 @@ resource "digitalocean_certificate" "api" {
156
156
  }
157
157
 
158
158
  resource "digitalocean_loadbalancer" "public" {
159
- name = "${var.name}-lb"
160
- region = var.region
161
- droplet_ids = [digitalocean_droplet.app.id]
162
- redirect_http_to_https = local.has_domain # domainless serves HTTP itself, nothing to redirect to
163
- http_idle_timeout_seconds = 600 # DO's maximum
159
+ name = "${var.name}-lb"
160
+ region = var.region
161
+ droplet_ids = [digitalocean_droplet.app.id]
162
+ redirect_http_to_https = local.has_domain # domainless serves HTTP itself, nothing to redirect to
163
+ http_idle_timeout_seconds = 600 # DO's maximum
164
164
 
165
165
  # With a domain: HTTPS 443 with the managed cert. Without: plain HTTP 80 on
166
166
  # the LB IP. Same port meanings for the developer either way (API on the root).
@@ -367,6 +367,13 @@ resource "random_password" "credential_key" {
367
367
  special = false
368
368
  }
369
369
 
370
+ # The key that actually ships: the operator's when they brought one (a database that
371
+ # already holds credentials keeps the key that encrypted them), otherwise the generated
372
+ # one above. See variables.tf § credential master key.
373
+ locals {
374
+ credential_encryption_key = var.credential_encryption_key != "" ? var.credential_encryption_key : random_password.credential_key.result
375
+ }
376
+
370
377
  # ── One project, so a universe looks like one thing ───────────────────────────
371
378
  #
372
379
  # Without this, a universe's parts scatter through the DigitalOcean default project among
@@ -23,7 +23,7 @@ locals {
23
23
  # spends it on one GRANT, and it goes no further.
24
24
  pg_admin_user = local.pg_adopt ? data.digitalocean_database_cluster.existing_pg[0].user : (local.provision_pg ? digitalocean_database_cluster.pg[0].user : "")
25
25
  pg_admin_pass = local.pg_adopt ? data.digitalocean_database_cluster.existing_pg[0].password : (local.provision_pg ? digitalocean_database_cluster.pg[0].password : "")
26
- pg_admin_url = local.pg_managed ? "postgresql://${local.pg_admin_user}:${local.pg_admin_pass}@${local.pg_cluster_host}:${local.pg_cluster_port}/${digitalocean_database_db.universe[0].name}?sslmode=require" : ""
26
+ pg_admin_url = local.pg_managed ? "postgresql://${local.pg_admin_user}:${local.pg_admin_pass}@${local.pg_cluster_host}:${local.pg_cluster_port}/${digitalocean_database_db.universe[0].name}?sslmode=require" : ""
27
27
 
28
28
  # Redis is always ours — provisioned above, no BYO branch.
29
29
  redis_host = digitalocean_database_cluster.redis.private_host
@@ -100,7 +100,7 @@ output "env_production" {
100
100
 
101
101
  # Credential encryption at rest — per-deployment, generated by Terraform.
102
102
  # Back this up with the database (a DB backup is unreadable without it).
103
- CREDENTIAL_ENCRYPTION_KEY=${random_password.credential_key.result}
103
+ CREDENTIAL_ENCRYPTION_KEY=${local.credential_encryption_key}
104
104
 
105
105
  # Redis (managed, TLS)
106
106
  REDIS_HOST=${local.redis_host}
@@ -43,6 +43,14 @@ openai_api_key = "sk-..." # memory server + OpenAI nodes
43
43
  # Or a fully external database, used verbatim:
44
44
  # byo_postgres_url = "postgresql://user:pass@host:5432/db?sslmode=require"
45
45
 
46
+ # BRINGING A DATABASE MEANS BRINGING ITS KEY. Credentials are encrypted at rest, and the
47
+ # key belongs to the data: reuse a database that already holds credentials without it and
48
+ # every one of them fails to decrypt. Leave this empty for a FRESH database (Terraform
49
+ # generates one, the common case); set it to the existing key when the database is not
50
+ # new. The same applies to a local .env pointed at this universe's database — one key,
51
+ # everywhere that reads those rows.
52
+ # credential_encryption_key = "the key that already encrypted those rows"
53
+
46
54
  # Redis: always provisioned by Terraform (Managed Redis, TLS). Not configurable.
47
55
 
48
56
  # The rendered output (terraform output -raw env_production > ../../.env.production)
@@ -141,3 +141,31 @@ variable "marketplace_url" {
141
141
  type = string
142
142
  default = "https://unoverse-marketplace-4hlb9.ondigitalocean.app"
143
143
  }
144
+
145
+ # ── The credential master key: BRING YOUR OWN when you bring your own database ──
146
+ #
147
+ # THE KEY BELONGS TO THE DATA, NOT TO THE DEPLOYMENT. It decrypts the credential rows in
148
+ # the database it is paired with, so any two environments sharing a database must share
149
+ # this key. Terraform generating a fresh one per deployment is right for a fresh database
150
+ # and WRONG for byo_postgres_url / existing_pg_cluster_name pointing at a database that
151
+ # already holds credentials: the deploy renders a new key and every stored credential
152
+ # fails with OpenSSL "bad decrypt". The same trap catches a local .env pointed at this
153
+ # universe's database (SECURITY.md § Credential encryption at rest).
154
+ #
155
+ # DIGITALOCEAN ONLY, deliberately. AWS always provisions its own RDS instance, so its
156
+ # database is always new and a generated key is always right; a variable there would be
157
+ # a way to get it wrong with no case that needs it.
158
+ #
159
+ # Leave it empty for a fresh database and Terraform generates one, which is the common
160
+ # case. Set it to the key that already encrypted those rows when the database is not new,
161
+ # and keep the two in step from then on.
162
+ variable "credential_encryption_key" {
163
+ description = "Master key for credentials at rest. Empty = generate one (fresh database). Set it to the EXISTING key when reusing a database that already holds credentials."
164
+ type = string
165
+ sensitive = true
166
+ default = ""
167
+ validation {
168
+ condition = var.credential_encryption_key == "" || length(var.credential_encryption_key) >= 32
169
+ error_message = "credential_encryption_key must be at least 32 characters (openssl rand -base64 32)."
170
+ }
171
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unoverse",
3
- "version": "0.1.138",
3
+ "version": "0.1.140",
4
4
  "description": "The Unoverse front door — create a Studio project, a universe, or a client app, and launch Studio.",
5
5
  "license": "SEE LICENSE IN README.md",
6
6
  "type": "module",