Skip to content

[316] Serve published dashboards at a customer subpath - #330

Merged
CarsonDavis merged 47 commits into
developmentfrom
316-dashboard-subpath
Sep 9, 2026
Merged

[316] Serve published dashboards at a customer subpath#330
CarsonDavis merged 47 commits into
developmentfrom
316-dashboard-subpath

Conversation

@CarsonDavis

@CarsonDavis CarsonDavis commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Line breakdown

Category Lines added %
Tests 673 61.7%
Production code 301 27.6%
Docs 75 6.9%
Config/build 41 3.8%
Generated (package-lock.json) 1 0.1%
Total 1091

Overview

A published dashboard can now be served from a path on a customer's own domain — site.gov/tools/dashboard/ — not just from its own CloudFront address.

Part of #316 — PR 1 of 3 (with #348 republish stack convergence and #349 cache-control tiering). Serves dashboards published after this merges; existing dashboards pick up the new edge function via #348.

What this does

  • Strips the customer's prefix at the edge. The per-dashboard CloudFront Function reads X-Forwarded-Prefix, validates it, and removes it from the request path. A missing or malformed header passes through untouched — a loud 403, never the wrong files — and the password gate still runs first.
  • Redirects slash-less entry URLs (…/tools/dashboard…/tools/dashboard/) with the query string preserved, marked no-store. A character that would corrupt the rebuilt URL (a raw & or #) is percent-escaped so the pair survives intact.
  • Single-sources the edge function. The deployed code is read from infrastructure/cloudfront-function.js at publish time instead of a hand-copied string; tests pin it to ES5 and under CloudFront's 10 KB cap.
  • Stores uploaded image paths slash-less (assets/…, not /assets/…) so they resolve inside the dashboard wherever it's mounted. Both readers (Card renderer, Configure preview) rebase legacy /assets/… values on read, so existing configs keep working.
  • Removes the other domain-root assumptions. The PDF worker, geologic patterns, and static-build tile/viewer paths no longer emit /… or ../.. escapes; server and Docker modes are unchanged.
  • Hardens the URL readers. Query values containing = survive whole, malformed percent-encoding reads as absent instead of throwing at startup, a valueless param reads '' instead of the string "undefined", and bare ?_preview/?forcelanding flags are presence-based at both entry points (LandingPage now agrees with modern.js). Pinned by a new spec.
  • Theme icons resolve through the same rule as Card images. Both upload fields share one resolver in src/essence/Tools/_shared/content/uploadKey.ts; the viewer's master image and model texture use the plain mission path instead of a ../../../../ climb.
  • Ships the customer docdocs/infrastructure/serving-a-dashboard-from-your-domain.md: setup steps, a behavior table, and the why behind each setting.

Decisions to review

  • X-Forwarded-Prefix is validated by deny-list (//, :, \, /../), not an allowlist.
  • Old /assets/… values are supported forever instead of migrated once
  • Publish reads infrastructure/cloudfront-function.js from the ECS image at runtime — nothing pins that directory into the image beyond today's COPY . .; slimming the image later breaks publish at runtime.
  • forceLanding is accepted in both casings at every entry point, though nothing in the app emits the camelCase form; the alternative is reverting the landing page to lowercase only.

@CarsonDavis CarsonDavis linked an issue Aug 20, 2026 that may be closed by this pull request
6 tasks
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

Terraform plan preview — production

Commit db6a133 · run log

Plan: 30 to add, 0 to change, 0 to destroy.

Show plan

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # module.mmgis.aws_cloudfront_origin_access_control.assets will be created
  + resource "aws_cloudfront_origin_access_control" "assets" {
      + arn                               = (known after apply)
      + description                       = "OAC for the production shared asset bucket origin."
      + etag                              = (known after apply)
      + id                                = (known after apply)
      + name                              = "mmgis-production-assets-oac"
      + origin_access_control_origin_type = "s3"
      + signing_behavior                  = "always"
      + signing_protocol                  = "sigv4"
    }

  # module.mmgis.aws_cloudwatch_log_group.admin will be created
  + resource "aws_cloudwatch_log_group" "admin" {
      + arn                         = (known after apply)
      + deletion_protection_enabled = (known after apply)
      + id                          = (known after apply)
      + log_group_class             = (known after apply)
      + name                        = "/ecs/mmgis-production-admin"
      + name_prefix                 = (known after apply)
      + region                      = "us-west-2"
      + retention_in_days           = 30
      + skip_destroy                = false
      + tags_all                    = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }
    }

  # module.mmgis.aws_cloudwatch_log_group.publish will be created
  + resource "aws_cloudwatch_log_group" "publish" {
      + arn                         = (known after apply)
      + deletion_protection_enabled = (known after apply)
      + id                          = (known after apply)
      + log_group_class             = (known after apply)
      + name                        = "/ecs/mmgis-production-publish"
      + name_prefix                 = (known after apply)
      + region                      = "us-west-2"
      + retention_in_days           = 30
      + skip_destroy                = false
      + tags_all                    = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }
    }

  # module.mmgis.aws_db_instance.this will be created
  + resource "aws_db_instance" "this" {
      + address                               = (known after apply)
      + allocated_storage                     = 20
      + apply_immediately                     = false
      + arn                                   = (known after apply)
      + auto_minor_version_upgrade            = true
      + availability_zone                     = (known after apply)
      + backup_retention_period               = 7
      + backup_target                         = (known after apply)
      + backup_window                         = (known after apply)
      + ca_cert_identifier                    = (known after apply)
      + character_set_name                    = (known after apply)
      + copy_tags_to_snapshot                 = false
      + database_insights_mode                = (known after apply)
      + db_name                               = (known after apply)
      + db_subnet_group_name                  = "mmgis-production-db"
      + dedicated_log_volume                  = false
      + delete_automated_backups              = true
      + deletion_protection                   = true
      + domain_fqdn                           = (known after apply)
      + endpoint                              = (known after apply)
      + engine                                = "postgres"
      + engine_lifecycle_support              = (known after apply)
      + engine_version                        = "17"
      + engine_version_actual                 = (known after apply)
      + final_snapshot_identifier             = "mmgis-production-postgres-final"
      + hosted_zone_id                        = (known after apply)
      + id                                    = (known after apply)
      + identifier                            = "mmgis-production-postgres"
      + identifier_prefix                     = (known after apply)
      + instance_class                        = "db.t3.micro"
      + iops                                  = (known after apply)
      + kms_key_id                            = (known after apply)
      + latest_restorable_time                = (known after apply)
      + license_model                         = (known after apply)
      + listener_endpoint                     = (known after apply)
      + maintenance_window                    = (known after apply)
      + manage_master_user_password           = true
      + master_user_secret                    = (known after apply)
      + master_user_secret_kms_key_id         = "arn:aws:kms:us-west-2:853558080719:key/8b8e2d0c-9ded-488c-a69f-be4bb7a4f8ce"
      + monitoring_interval                   = 0
      + monitoring_role_arn                   = (known after apply)
      + multi_az                              = false
      + nchar_character_set_name              = (known after apply)
      + network_type                          = (known after apply)
      + option_group_name                     = (known after apply)
      + parameter_group_name                  = (known after apply)
      + password_wo                           = (write-only attribute)
      + performance_insights_enabled          = false
      + performance_insights_kms_key_id       = (known after apply)
      + performance_insights_retention_period = (known after apply)
      + port                                  = 5432
      + publicly_accessible                   = false
      + region                                = "us-west-2"
      + replica_mode                          = (known after apply)
      + replicas                              = (known after apply)
      + resource_id                           = (known after apply)
      + skip_final_snapshot                   = false
      + snapshot_identifier                   = (known after apply)
      + status                                = (known after apply)
      + storage_encrypted                     = true
      + storage_throughput                    = (known after apply)
      + storage_type                          = "gp3"
      + tags_all                              = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }
      + timezone                              = (known after apply)
      + upgrade_rollout_order                 = (known after apply)
      + username                              = "postgres"
      + vpc_security_group_ids                = (known after apply)
    }

  # module.mmgis.aws_db_subnet_group.this will be created
  + resource "aws_db_subnet_group" "this" {
      + arn                     = (known after apply)
      + description             = "Managed by Terraform"
      + id                      = (known after apply)
      + name                    = "mmgis-production-db"
      + name_prefix             = (known after apply)
      + region                  = "us-west-2"
      + subnet_ids              = [
          + "subnet-03b8a64d4f7ceb0c8",
          + "subnet-0497702c40bd7951c",
        ]
      + supported_network_types = (known after apply)
      + tags_all                = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }
      + vpc_id                  = (known after apply)
    }

  # module.mmgis.aws_ecr_repository.this will be created
  + resource "aws_ecr_repository" "this" {
      + arn                  = (known after apply)
      + force_delete         = false
      + id                   = (known after apply)
      + image_tag_mutability = "MUTABLE"
      + name                 = "mmgis-production"
      + region               = "us-west-2"
      + registry_id          = (known after apply)
      + repository_url       = (known after apply)
      + tags_all             = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }

      + image_scanning_configuration {
          + scan_on_push = true
        }
    }

  # module.mmgis.aws_ecs_cluster.this will be created
  + resource "aws_ecs_cluster" "this" {
      + arn      = (known after apply)
      + id       = (known after apply)
      + name     = "mmgis-production"
      + region   = "us-west-2"
      + tags_all = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }

      + setting (known after apply)
    }

  # module.mmgis.aws_ecs_express_gateway_service.admin will be created
  + resource "aws_ecs_express_gateway_service" "admin" {
      + cluster                 = "mmgis-production"
      + cpu                     = "1024"
      + current_deployment      = (known after apply)
      + execution_role_arn      = (known after apply)
      + health_check_path       = "/api/utils/healthcheck"
      + infrastructure_role_arn = (known after apply)
      + ingress_paths           = (known after apply)
      + memory                  = "2048"
      + network_configuration   = [
          + {
              + security_groups = [
                  + (known after apply),
                ]
              + subnets         = [
                  + "subnet-03b8a64d4f7ceb0c8",
                  + "subnet-0497702c40bd7951c",
                ]
            },
        ]
      + region                  = "us-west-2"
      + scaling_target          = (known after apply)
      + service_arn             = (known after apply)
      + service_name            = "mmgis-production-admin"
      + service_revision_arn    = (known after apply)
      + tags_all                = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }
      + task_role_arn           = (known after apply)
      + wait_for_steady_state   = false

      + primary_container {
          + aws_logs_configuration = [
              + {
                  + log_group         = "/ecs/mmgis-production-admin"
                  + log_stream_prefix = "mmgis-admin"
                },
            ]
          + container_port         = 8888
          + image                  = (known after apply)

          + environment {
              + name  = "DB_HOST"
              + value = (known after apply)
            }
          + environment {
              + name  = "DB_PORT"
              + value = "5432"
            }
          + environment {
              + name  = "DB_USER"
              + value = "postgres"
            }
          + environment {
              + name  = "DB_NAME"
              + value = "postgres"
            }
          + environment {
              + name  = "DB_SECRET_ARN"
              + value = (known after apply)
            }
          + environment {
              + name  = "MMGIS_DEPLOYMENT_MODE"
              + value = "lean"
            }
          + environment {
              + name  = "DISABLE_FIRST_SIGNUP"
              + value = "true"
            }
          + environment {
              + name  = "ENABLE_MMGIS_WEBSOCKETS"
              + value = "true"
            }
          + environment {
              + name  = "ENABLE_CONFIG_WEBSOCKETS"
              + value = "true"
            }
          + environment {
              + name  = "NODE_ENV"
              + value = "production"
            }
          + environment {
              + name  = "PORT"
              + value = "8888"
            }
          + environment {
              + name  = "AUTH"
              + value = "local"
            }
          + environment {
              + name  = "DB_SSL"
              + value = "true"
            }
          + environment {
              + name  = "DB_SSL_CERT_BASE64"
              + value = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNyakNDQWpTZ0F3SUJBZ0lSQU94dTBJMVF1TUFoSWVzekIzZkpJbGt3Q2dZSUtvWkl6ajBFQXdNd2daWXgKQ3pBSkJnTlZCQVlUQWxWVE1TSXdJQVlEVlFRS0RCbEJiV0Y2YjI0Z1YyVmlJRk5sY25acFkyVnpMQ0JKYm1NdQpNUk13RVFZRFZRUUxEQXBCYldGNmIyNGdVa1JUTVFzd0NRWURWUVFJREFKWFFURXZNQzBHQTFVRUF3d21RVzFoCmVtOXVJRkpFVXlCMWN5MTNaWE4wTFRJZ1VtOXZkQ0JEUVNCRlEwTXpPRFFnUnpFeEVEQU9CZ05WQkFjTUIxTmwKWVhSMGJHVXdJQmNOTWpFd05USTBNakl3TmpVNVdoZ1BNakV5TVRBMU1qUXlNekEyTlRsYU1JR1dNUXN3Q1FZRApWUVFHRXdKVlV6RWlNQ0FHQTFVRUNnd1pRVzFoZW05dUlGZGxZaUJUWlhKMmFXTmxjeXdnU1c1akxqRVRNQkVHCkExVUVDd3dLUVcxaGVtOXVJRkpFVXpFTE1Ba0dBMVVFQ0F3Q1YwRXhMekF0QmdOVkJBTU1Ka0Z0WVhwdmJpQlMKUkZNZ2RYTXRkMlZ6ZEMweUlGSnZiM1FnUTBFZ1JVTkRNemcwSUVjeE1SQXdEZ1lEVlFRSERBZFRaV0YwZEd4bApNSFl3RUFZSEtvWkl6ajBDQVFZRks0RUVBQ0lEWWdBRXo0YnlsUmNHcXFEV2RQN2dRSUlvVEhkQks2Rk50S0gxCjRTa0VJWFJYa1lEbVJ2TDlCY2kxTXVHcnd1dnJrYTVURGo0YjdlK2NzWTBsbEV6SHBLZnE2bkpQRmxqb1lZUDkKdXFIRmt2NzduT3BKSjYzM0tPcjhJeG1lSFc1UlhnclpvMEl3UURBUEJnTlZIUk1CQWY4RUJUQURBUUgvTUIwRwpBMVVkRGdRV0JCUVFpa1Z6OHdtamQ5ZURGUlh6QklVOE9zZWlHekFPQmdOVkhROEJBZjhFQkFNQ0FZWXdDZ1lJCktvWkl6ajBFQXdNRGFBQXdaUUl3ZjA2TWNycHcxTzBFQkxCQnJwODRtMzdOWXRPa0UvMFowTytDN0Q0MXduWGkKRVFkbjZQWFVWZ2REMjNHajgyU3JBakVBa2xoS3MrbGlPMVB0TjE1eWVaUjFJbzk4bkZ2ZStsTHB0YUxha1pjSAoraGZGdVV0Q3FNYmFJOENkdkpsS25QcVQKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQotLS0tLUJFR0lOIENFUlRJRklDQVRFLS0tLS0KTUlJRi9qQ0NBK2FnQXdJQkFnSVFSNzFaOGxUTzVTaithczJqQjdJV1h6QU5CZ2txaGtpRzl3MEJBUXdGQURDQgpsekVMTUFrR0ExVUVCaE1DVlZNeElqQWdCZ05WQkFvTUdVRnRZWHB2YmlCWFpXSWdVMlZ5ZG1salpYTXNJRWx1Cll5NHhFekFSQmdOVkJBc01Da0Z0WVhwdmJpQlNSRk14Q3pBSkJnTlZCQWdNQWxkQk1UQXdMZ1lEVlFRRERDZEIKYldGNmIyNGdVa1JUSUhWekxYZGxjM1F0TWlCU2IyOTBJRU5CSUZKVFFUUXdPVFlnUnpFeEVEQU9CZ05WQkFjTQpCMU5sWVhSMGJHVXdJQmNOTWpFd05USTBNakl3TXpJd1doZ1BNakV5TVRBMU1qUXlNekF6TWpCYU1JR1hNUXN3CkNRWURWUVFHRXdKVlV6RWlNQ0FHQTFVRUNnd1pRVzFoZW05dUlGZGxZaUJUWlhKMmFXTmxjeXdnU1c1akxqRVQKTUJFR0ExVUVDd3dLUVcxaGVtOXVJRkpFVXpFTE1Ba0dBMVVFQ0F3Q1YwRXhNREF1QmdOVkJBTU1KMEZ0WVhwdgpiaUJTUkZNZ2RYTXRkMlZ6ZEMweUlGSnZiM1FnUTBFZ1VsTkJOREE1TmlCSE1URVFNQTRHQTFVRUJ3d0hVMlZoCmRIUnNaVENDQWlJd0RRWUpLb1pJaHZjTkFRRUJCUUFEZ2dJUEFEQ0NBZ29DZ2dJQkFNOTc3YkhJczFXSmlqclMKWFFNZlVPaG1sSmpyMnYwSzBValBsNTJzRTFUSjc2SDh1bW8xeVI0VDdXaGtkOUl3QkhOR0tYQ0p0Sm1Ncjl6cApmQjM4ZUxUdSs1eWRVQVhkRnVacFJNS0JXd1BWZTM3QWRKUktxbjViZVM4SFFqZDNKWEFnR0tVTk51RTkyaXFGCnFpMmZJcUZNcG5KWFdvMEZJVzZzMkRsMnprT1JkN3RIMER5Z2NSaTdsZ1Z4Q3N3MUJKUWhGSm9uM3krSVY4L0YKYm5iVVhTTlNEVW5EVzJFaHZXU0Q4TCt0NGVpWFlzb3poREF6aEJ2b2pweGhQSDlPQjd2cUZZdzVxeEZ4K0cwdApsU0xYNWlXaTFqenpjM1h5R25CNldJblpEVmJ2bnZKNEJHWitkVFJwT0N2c29NSW45Yno0RVFUdnUyNDNjN2FVCkhiUy9rdm5DQVNOdCt6azdDNmxibWFxMEFHTnp0d05qODVPcG4yZW5GY2lXWlZubkovNE9lZWZVV1F4RDBFUHAKU2pFZDlDbjJJSHprQlpySENnK2xXWkpRQktiVVZTMGxMSU1Tc0xRUTZXdlIzOGpZN0QybnhNMUE5M3hXeHdwdApadFFuWVJDVlhINnp0Mk93REFGZVBJbld3eFVqUjV0L3d1M1h4UGdwU2ZybVRpM1dZdHIxd0Z5cEFKODExZS9QCnlCdHN3V1VRNkJOSlF2eStLbk9FZUdmT3dtdGRERllSK0dPQ2Z2Q2loenJLSnJ4T3RISWllZWhSNUl3M2NiWEcKc200cER6Zk1VVnZERHo2QzJNNlBSbEpoaENsYmF0SENqaWs5aHhGWUVzQWxxdFZWSzlweGF6OWk4aE9xU0ZRcQprSlNRc2dXdytvTS9CMkN5amNTcWtTUUV1OFJMQWdNQkFBR2pRakJBTUE4R0ExVWRFd0VCL3dRRk1BTUJBZjh3CkhRWURWUjBPQkJZRUZQbXJkeHBSUmd1M0ljYUI1QlRxbHByY0tkVHNNQTRHQTFVZER3RUIvd1FFQXdJQmhqQU4KQmdrcWhraUc5dzBCQVF3RkFBT0NBZ0VBVmRseFdqUHZWS2t5M2tuOFppemVNNEQrRXNMdzlkV0xhdTJVRC9scwp6d0RDRm9UNmV1YWdWZUNrbnJuK1lFbDdnMjBDUllUOWlhb25Hb01VUHVNUi9jZHRQTDFXL1JmNDBQU3JHZjlxClF1eGF2V2lITEVYT1FUQ3RDYVZaTW9ra3ZqdXVMTkRYeVpuc3RnRUN1aVpFQ1R3aGV4VUY0b2l1aHlHazlvMDEKUU1haXo0SFg0bGdrMG96QUxVdkV6YU5kOWdXRXdEMnFlK3JxOWNRTVRWcTNJQXJVa3ZUSWZ0WlVhVlVNenIwTwplZDErekFzTmE5bkpoVVJKLzZhbkpQSmpiUWdiNXFBMWFzRmNwOVVhTVQxa3UzNlUzZ25SMVQvQmRnRzJqWDNYClVtMFVjYUdOVlBySDF1a0luV1c3NDNweFdRYjcvMnN1bUVFTVZoK2pXYkIxOFNBeUxJNFdJaDRsa3VyZGlmelMKSXVURnA4VEV4K01vdUlTRmh6L3ZKRFdaODR0cW9MVmprRWNQNm9EeXBxOWxGb0V6SERKdjNWMUNZY0lnT3VzVAprMWptOVA3QlhkVEc3VFl6VWFUYjlVU2I2Ymtxa0Q5RXdKQU9TczdESTk0YUU2cnNTd3MyeUFIYXZqQU1mdU1aCnNEQVp2a3FTMlFnMloyK0NJNndVWm43bXprSlhiWm9xUmpEdkNoRFhFQjFtSWh6VlhoaU5XL0NSNVdLVkR2bGoKOXYxc2RHQnloMnBieGNMUXRWYXEvNWNvTTRBTmdwaG9OejNwT1lVUFdIUytKVXJJaXZCWitKb2JqWGN4cjNTTgo5aUR6Y3U1L0ZWVk5icTcrS04vbnZQTW5nVCtnZHVFTjVtK0VCam04R3VrSnltRkcwbTZCRU5SQTBRU0RxWjdrCnpEWT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQotLS0tLUJFR0lOIENFUlRJRklDQVRFLS0tLS0KTUlJRC9qQ0NBdWFnQXdJQkFnSVFSaXdzcEt5ck8weG94RGdTa3FMWmN6QU5CZ2txaGtpRzl3MEJBUXNGQURDQgpsekVMTUFrR0ExVUVCaE1DVlZNeElqQWdCZ05WQkFvTUdVRnRZWHB2YmlCWFpXSWdVMlZ5ZG1salpYTXNJRWx1Cll5NHhFekFSQmdOVkJBc01Da0Z0WVhwdmJpQlNSRk14Q3pBSkJnTlZCQWdNQWxkQk1UQXdMZ1lEVlFRRERDZEIKYldGNmIyNGdVa1JUSUhWekxYZGxjM1F0TWlCU2IyOTBJRU5CSUZKVFFUSXdORGdnUnpFeEVEQU9CZ05WQkFjTQpCMU5sWVhSMGJHVXdJQmNOTWpFd05USTBNakUxT1RBd1doZ1BNakEyTVRBMU1qUXlNalU1TURCYU1JR1hNUXN3CkNRWURWUVFHRXdKVlV6RWlNQ0FHQTFVRUNnd1pRVzFoZW05dUlGZGxZaUJUWlhKMmFXTmxjeXdnU1c1akxqRVQKTUJFR0ExVUVDd3dLUVcxaGVtOXVJRkpFVXpFTE1Ba0dBMVVFQ0F3Q1YwRXhNREF1QmdOVkJBTU1KMEZ0WVhwdgpiaUJTUkZNZ2RYTXRkMlZ6ZEMweUlGSnZiM1FnUTBFZ1VsTkJNakEwT0NCSE1URVFNQTRHQTFVRUJ3d0hVMlZoCmRIUnNaVENDQVNJd0RRWUpLb1pJaHZjTkFRRUJCUUFEZ2dFUEFEQ0NBUW9DZ2dFQkFMNTNKazNHc0tpdSs0YngKakRmc2V2V2J3UENOSjNIMDhacDdHV2h2STNUZ2kzOW9wZkhZdjJrdTJCS0ZqSzhOMkw2UnZOUFNSOHlwbHY1agpZMHRLMFUrWFZObDhvMGliaHFSRGhiVHVoNktMOENGSU5XWXpBYWp1eEZTK0NGMFU2YzFRM3RYTEJkQUx4QTdsCkZsWEo3MVFyUDA2VzMxa1JlN2t2Z3J2TzdxV1UzL096VWY5cVl3NExTaVIxL1ZrdnZSQ1RxY1ZOdzA5Y2x3L00KSmJ3NkZTZ3dlTjY1TTlqN3pQYmpHQVhTSGtYeXhIMUVyaW4yZmErQjlQRTRaRGdYOWNwMkMxREhld1lKUUwvZwpTZXB3d2N1ZFZOUk4xaWJLSDdrcE1yZ1BuYU5JVk54NXNYVnNUams2cTJacVl3M1NWSGVnbHRKcEx5L2NaUmVQCm1saXZGMmtDQXdFQUFhTkNNRUF3RHdZRFZSMFRBUUgvQkFVd0F3RUIvekFkQmdOVkhRNEVGZ1FVbVRjUWQ2bzEKQ3VTNjVNakJyTXdROUpKam1Cd3dEZ1lEVlIwUEFRSC9CQVFEQWdHR01BMEdDU3FHU0liM0RRRUJDd1VBQTRJQgpBUUFLU0RTSXpsOTU2d1ZkZFBUaGYyVkF6SThzeXc5bmdTd3NFSFp2eFZHSEJ2dTVnZzYxOHJEeWd1VkNZWDlMCjRLdy94SnJrNlMzcXhPUzJaRHlCY09wc3JCc2tnYWhERkl1bnpvUlAzYTE4QVJRVnE1NUxWZ2Z3U0RRaXVuY2gKQmQwNWNuRkdMb2lMa1I1cnJrZ1lhUDJmdG4zZ1JCUmFmMHkwUzNKWFoyWEIzc01aeEd4YXZZcTltZmlFY3dCMApMTVRNUTFOWXphaEllRzZKbTNMcVJxUjhIa3pQL1p0cTRkVDJBdFNMdkZlYmJOTWlXcWVxVDdPY1lwOTRIVFlUCnpxcnRhVmRVZzlid3lBVUNEZ3kwR1Y5UkhESWROQU9JblUvNExFRVRvdnJ0dUJVN1oxcTR0Y0hYdk42SGQxSDgKZ01iMG1DRzVJMzkzcVc1aEZzQS9kaUZiCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K"
            }
          + environment {
              + name  = "AWS_REGION"
              + value = "us-west-2"
            }
          + environment {
              + name  = "MMGIS_PUBLISH_ECS_CLUSTER"
              + value = "mmgis-production"
            }
          + environment {
              + name  = "MMGIS_PUBLISH_TASK_DEFINITION"
              + value = "mmgis-production-publish"
            }
          + environment {
              + name  = "MMGIS_PUBLISH_SUBNETS"
              + value = "subnet-03b8a64d4f7ceb0c8,subnet-0497702c40bd7951c"
            }
          + environment {
              + name  = "MMGIS_PUBLISH_SECURITY_GROUPS"
              + value = (known after apply)
            }
          + environment {
              + name  = "MMGIS_PUBLISH_CONTAINER_NAME"
              + value = "mmgis"
            }
          + environment {
              + name  = "MMGIS_SHARED_ASSET_BUCKET"
              + value = "mmgis-production-assets-853558080719"
            }
          + environment {
              + name  = "MMGIS_ENVIRONMENT"
              + value = "production"
            }

          + secret {
              + name       = "DB_PASS"
              + value_from = (known after apply)
            }
          + secret {
              + name       = "SECRET"
              + value_from = (known after apply)
            }
          + secret {
              + name       = "SEED_SUPERADMIN_USERNAME"
              + value_from = (known after apply)
            }
          + secret {
              + name       = "SEED_SUPERADMIN_PASSWORD"
              + value_from = (known after apply)
            }
          + secret {
              + name       = "MAPBOX_TOKEN"
              + value_from = (known after apply)
            }
        }
    }

  # module.mmgis.aws_ecs_task_definition.admin will be created
  + resource "aws_ecs_task_definition" "admin" {
      + arn                      = (known after apply)
      + arn_without_revision     = (known after apply)
      + container_definitions    = (known after apply)
      + cpu                      = "1024"
      + enable_fault_injection   = (known after apply)
      + execution_role_arn       = (known after apply)
      + family                   = "mmgis-production-admin"
      + id                       = (known after apply)
      + memory                   = "2048"
      + network_mode             = "awsvpc"
      + region                   = "us-west-2"
      + requires_compatibilities = [
          + "FARGATE",
        ]
      + revision                 = (known after apply)
      + skip_destroy             = false
      + tags_all                 = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }
      + task_role_arn            = (known after apply)
      + track_latest             = false

      + runtime_platform {
          + cpu_architecture        = "X86_64"
          + operating_system_family = "LINUX"
        }
    }

  # module.mmgis.aws_ecs_task_definition.publish will be created
  + resource "aws_ecs_task_definition" "publish" {
      + arn                      = (known after apply)
      + arn_without_revision     = (known after apply)
      + container_definitions    = (known after apply)
      + cpu                      = "2048"
      + enable_fault_injection   = (known after apply)
      + execution_role_arn       = (known after apply)
      + family                   = "mmgis-production-publish"
      + id                       = (known after apply)
      + memory                   = "8192"
      + network_mode             = "awsvpc"
      + region                   = "us-west-2"
      + requires_compatibilities = [
          + "FARGATE",
        ]
      + revision                 = (known after apply)
      + skip_destroy             = false
      + tags_all                 = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }
      + task_role_arn            = (known after apply)
      + track_latest             = false

      + runtime_platform {
          + cpu_architecture        = "X86_64"
          + operating_system_family = "LINUX"
        }
    }

  # module.mmgis.aws_iam_role.admin_exec will be created
  + resource "aws_iam_role" "admin_exec" {
      + arn                   = (known after apply)
      + assume_role_policy    = jsonencode(
            {
              + Statement = [
                  + {
                      + Action    = "sts:AssumeRole"
                      + Condition = {
                          + StringEquals = {
                              + "aws:SourceAccount" = "853558080719"
                            }
                        }
                      + Effect    = "Allow"
                      + Principal = {
                          + Service = "ecs-tasks.amazonaws.com"
                        }
                      + Sid       = "AllowEcsTasksAssume"
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + create_date           = (known after apply)
      + description           = "ECS-side role for the mmgis-production-admin task: pull image, write logs, inject admin secrets[]. Lean deployment only."
      + force_detach_policies = false
      + id                    = (known after apply)
      + managed_policy_arns   = (known after apply)
      + max_session_duration  = 3600
      + name                  = "mmgis-production-admin-task-execution"
      + name_prefix           = (known after apply)
      + path                  = "/"
      + permissions_boundary  = "arn:aws:iam::853558080719:policy/mmgis-ci-role-boundary-production"
      + tags_all              = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }
      + unique_id             = (known after apply)

      + inline_policy (known after apply)
    }

  # module.mmgis.aws_iam_role.admin_task will be created
  + resource "aws_iam_role" "admin_task" {
      + arn                   = (known after apply)
      + assume_role_policy    = jsonencode(
            {
              + Statement = [
                  + {
                      + Action    = "sts:AssumeRole"
                      + Condition = {
                          + StringEquals = {
                              + "aws:SourceAccount" = "853558080719"
                            }
                        }
                      + Effect    = "Allow"
                      + Principal = {
                          + Service = "ecs-tasks.amazonaws.com"
                        }
                      + Sid       = "AllowEcsTasksAssume"
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + create_date           = (known after apply)
      + description           = "Runtime role for the mmgis-production-admin container: RunTask + PassRole of the publish roles, dashboard stack read/delete + teardown, admin asset upload. Lean deployment only."
      + force_detach_policies = false
      + id                    = (known after apply)
      + managed_policy_arns   = (known after apply)
      + max_session_duration  = 3600
      + name                  = "mmgis-production-admin-task"
      + name_prefix           = (known after apply)
      + path                  = "/"
      + permissions_boundary  = "arn:aws:iam::853558080719:policy/mmgis-ci-role-boundary-production"
      + tags_all              = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }
      + unique_id             = (known after apply)

      + inline_policy (known after apply)
    }

  # module.mmgis.aws_iam_role.express_infra will be created
  + resource "aws_iam_role" "express_infra" {
      + arn                   = (known after apply)
      + assume_role_policy    = jsonencode(
            {
              + Statement = [
                  + {
                      + Action    = "sts:AssumeRole"
                      + Condition = {
                          + StringEquals = {
                              + "aws:SourceAccount" = "853558080719"
                            }
                        }
                      + Effect    = "Allow"
                      + Principal = {
                          + Service = "ecs.amazonaws.com"
                        }
                      + Sid       = "AllowEcsServiceAssume"
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + create_date           = (known after apply)
      + description           = "Infrastructure role for the mmgis-production-admin Express service. Trust-only + AWS managed policy; no inline policy. Immutable after service creation. Lean deployment only."
      + force_detach_policies = false
      + id                    = (known after apply)
      + managed_policy_arns   = (known after apply)
      + max_session_duration  = 3600
      + name                  = "mmgis-production-express-infrastructure"
      + name_prefix           = (known after apply)
      + path                  = "/"
      + permissions_boundary  = "arn:aws:iam::853558080719:policy/mmgis-ci-role-boundary-production"
      + tags_all              = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }
      + unique_id             = (known after apply)

      + inline_policy (known after apply)
    }

  # module.mmgis.aws_iam_role.publish_exec will be created
  + resource "aws_iam_role" "publish_exec" {
      + arn                   = (known after apply)
      + assume_role_policy    = jsonencode(
            {
              + Statement = [
                  + {
                      + Action    = "sts:AssumeRole"
                      + Condition = {
                          + StringEquals = {
                              + "aws:SourceAccount" = "853558080719"
                            }
                        }
                      + Effect    = "Allow"
                      + Principal = {
                          + Service = "ecs-tasks.amazonaws.com"
                        }
                      + Sid       = "AllowEcsTasksAssume"
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + create_date           = (known after apply)
      + description           = "ECS-side role for the mmgis-production-publish task: pull image, write logs, inject publish secrets[]. Lean deployment only."
      + force_detach_policies = false
      + id                    = (known after apply)
      + managed_policy_arns   = (known after apply)
      + max_session_duration  = 3600
      + name                  = "mmgis-production-publish-task-execution"
      + name_prefix           = (known after apply)
      + path                  = "/"
      + permissions_boundary  = "arn:aws:iam::853558080719:policy/mmgis-ci-role-boundary-production"
      + tags_all              = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }
      + unique_id             = (known after apply)

      + inline_policy (known after apply)
    }

  # module.mmgis.aws_iam_role.publish_task will be created
  + resource "aws_iam_role" "publish_task" {
      + arn                   = (known after apply)
      + assume_role_policy    = jsonencode(
            {
              + Statement = [
                  + {
                      + Action    = "sts:AssumeRole"
                      + Condition = {
                          + StringEquals = {
                              + "aws:SourceAccount" = "853558080719"
                            }
                        }
                      + Effect    = "Allow"
                      + Principal = {
                          + Service = "ecs-tasks.amazonaws.com"
                        }
                      + Sid       = "AllowEcsTasksAssume"
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + create_date           = (known after apply)
      + description           = "Runtime role for the mmgis-production-publish container (scripts/publish-static.js): create/describe/delete the mmgis-production-dashboard-* stacks and their S3/CloudFront resources, read the shared asset bucket, and read the RDS master secret at connection time to track rotation. No rds-db:connect (password auth). Lean deployment only."
      + force_detach_policies = false
      + id                    = (known after apply)
      + managed_policy_arns   = (known after apply)
      + max_session_duration  = 3600
      + name                  = "mmgis-production-publish-task"
      + name_prefix           = (known after apply)
      + path                  = "/"
      + permissions_boundary  = "arn:aws:iam::853558080719:policy/mmgis-ci-role-boundary-production"
      + tags_all              = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }
      + unique_id             = (known after apply)

      + inline_policy (known after apply)
    }

  # module.mmgis.aws_iam_role_policy.admin_exec will be created
  + resource "aws_iam_role_policy" "admin_exec" {
      + id          = (known after apply)
      + name        = "mmgis-production-admin-task-execution"
      + name_prefix = (known after apply)
      + policy      = (known after apply)
      + role        = (known after apply)
    }

  # module.mmgis.aws_iam_role_policy.admin_task will be created
  + resource "aws_iam_role_policy" "admin_task" {
      + id          = (known after apply)
      + name        = "mmgis-production-admin-task"
      + name_prefix = (known after apply)
      + policy      = (known after apply)
      + role        = (known after apply)
    }

  # module.mmgis.aws_iam_role_policy.publish_exec will be created
  + resource "aws_iam_role_policy" "publish_exec" {
      + id          = (known after apply)
      + name        = "mmgis-production-publish-task-execution"
      + name_prefix = (known after apply)
      + policy      = (known after apply)
      + role        = (known after apply)
    }

  # module.mmgis.aws_iam_role_policy.publish_task will be created
  + resource "aws_iam_role_policy" "publish_task" {
      + id          = (known after apply)
      + name        = "mmgis-production-publish-task"
      + name_prefix = (known after apply)
      + policy      = (known after apply)
      + role        = (known after apply)
    }

  # module.mmgis.aws_iam_role_policy_attachment.express_infra will be created
  + resource "aws_iam_role_policy_attachment" "express_infra" {
      + id         = (known after apply)
      + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSInfrastructureRoleforExpressGatewayServices"
      + role       = "mmgis-production-express-infrastructure"
    }

  # module.mmgis.aws_s3_bucket.assets will be created
  + resource "aws_s3_bucket" "assets" {
      + acceleration_status         = (known after apply)
      + acl                         = (known after apply)
      + arn                         = (known after apply)
      + bucket                      = "mmgis-production-assets-853558080719"
      + bucket_domain_name          = (known after apply)
      + bucket_namespace            = (known after apply)
      + bucket_prefix               = (known after apply)
      + bucket_region               = (known after apply)
      + bucket_regional_domain_name = (known after apply)
      + force_destroy               = false
      + hosted_zone_id              = (known after apply)
      + id                          = (known after apply)
      + object_lock_enabled         = (known after apply)
      + policy                      = (known after apply)
      + region                      = "us-west-2"
      + request_payer               = (known after apply)
      + tags_all                    = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }
      + website_domain              = (known after apply)
      + website_endpoint            = (known after apply)

      + cors_rule (known after apply)

      + grant (known after apply)

      + lifecycle_rule (known after apply)

      + logging (known after apply)

      + object_lock_configuration (known after apply)

      + replication_configuration (known after apply)

      + server_side_encryption_configuration (known after apply)

      + versioning (known after apply)

      + website (known after apply)
    }

  # module.mmgis.aws_s3_bucket_public_access_block.assets will be created
  + resource "aws_s3_bucket_public_access_block" "assets" {
      + block_public_acls       = true
      + block_public_policy     = true
      + bucket                  = (known after apply)
      + id                      = (known after apply)
      + ignore_public_acls      = true
      + region                  = "us-west-2"
      + restrict_public_buckets = true
    }

  # module.mmgis.aws_s3_bucket_server_side_encryption_configuration.assets will be created
  + resource "aws_s3_bucket_server_side_encryption_configuration" "assets" {
      + bucket = (known after apply)
      + id     = (known after apply)
      + region = "us-west-2"

      + rule {
          + blocked_encryption_types = (known after apply)
          + bucket_key_enabled       = (known after apply)

          + apply_server_side_encryption_by_default {
              + kms_master_key_id = (known after apply)
              + sse_algorithm     = "AES256"
            }
        }
    }

  # module.mmgis.aws_secretsmanager_secret.dashboards_password will be created
  + resource "aws_secretsmanager_secret" "dashboards_password" {
      + arn                            = (known after apply)
      + description                    = "MMGIS production shared dashboards password (MMGIS_DASHBOARDS_PASSWORD). Value generated by the CI secret bootstrap on first deploy if empty; never overwritten."
      + force_overwrite_replica_secret = false
      + id                             = (known after apply)
      + name                           = "mmgis/production/dashboards-password"
      + name_prefix                    = (known after apply)
      + policy                         = (known after apply)
      + recovery_window_in_days        = 0
      + region                         = "us-west-2"
      + tags_all                       = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }

      + replica (known after apply)
    }

  # module.mmgis.aws_secretsmanager_secret.mapbox_token will be created
  + resource "aws_secretsmanager_secret" "mapbox_token" {
      + arn                            = (known after apply)
      + description                    = "MMGIS production Mapbox token (injected as MAPBOX_TOKEN, admin task only). External credential: hand-set once, excluded from CI secret generation."
      + force_overwrite_replica_secret = false
      + id                             = (known after apply)
      + name                           = "mmgis/production/mapbox-token"
      + name_prefix                    = (known after apply)
      + policy                         = (known after apply)
      + recovery_window_in_days        = 0
      + region                         = "us-west-2"
      + tags_all                       = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }

      + replica (known after apply)
    }

  # module.mmgis.aws_secretsmanager_secret.seed_password will be created
  + resource "aws_secretsmanager_secret" "seed_password" {
      + arn                            = (known after apply)
      + description                    = "MMGIS production superadmin seed password (SEED_SUPERADMIN_PASSWORD). Value generated by the CI secret bootstrap on first deploy if empty; never overwritten."
      + force_overwrite_replica_secret = false
      + id                             = (known after apply)
      + name                           = "mmgis/production/superadmin-password"
      + name_prefix                    = (known after apply)
      + policy                         = (known after apply)
      + recovery_window_in_days        = 0
      + region                         = "us-west-2"
      + tags_all                       = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }

      + replica (known after apply)
    }

  # module.mmgis.aws_secretsmanager_secret.seed_username will be created
  + resource "aws_secretsmanager_secret" "seed_username" {
      + arn                            = (known after apply)
      + description                    = "MMGIS production superadmin seed username (SEED_SUPERADMIN_USERNAME). Value generated by the CI secret bootstrap on first deploy if empty; never overwritten."
      + force_overwrite_replica_secret = false
      + id                             = (known after apply)
      + name                           = "mmgis/production/superadmin-username"
      + name_prefix                    = (known after apply)
      + policy                         = (known after apply)
      + recovery_window_in_days        = 0
      + region                         = "us-west-2"
      + tags_all                       = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }

      + replica (known after apply)
    }

  # module.mmgis.aws_secretsmanager_secret.session will be created
  + resource "aws_secretsmanager_secret" "session" {
      + arn                            = (known after apply)
      + description                    = "MMGIS production express-session secret (injected as env SECRET). Value generated by the CI secret bootstrap on first deploy if empty; never overwritten."
      + force_overwrite_replica_secret = false
      + id                             = (known after apply)
      + name                           = "mmgis/production/session-secret"
      + name_prefix                    = (known after apply)
      + policy                         = (known after apply)
      + recovery_window_in_days        = 0
      + region                         = "us-west-2"
      + tags_all                       = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Project"     = "MMGIS"
        }

      + replica (known after apply)
    }

  # module.mmgis.aws_security_group.rds will be created
  + resource "aws_security_group" "rds" {
      + arn                    = (known after apply)
      + description            = "MMGIS production RDS: ingress 5432 from the task SG only."
      + egress                 = (known after apply)
      + id                     = (known after apply)
      + ingress                = [
          + {
              + cidr_blocks      = []
              + description      = "PostgreSQL from the admin/publish task SG."
              + from_port        = 5432
              + ipv6_cidr_blocks = []
              + prefix_list_ids  = []
              + protocol         = "tcp"
              + security_groups  = (known after apply)
              + self             = false
              + to_port          = 5432
            },
        ]
      + name                   = "mmgis-production-rds-sg"
      + name_prefix            = (known after apply)
      + owner_id               = (known after apply)
      + region                 = "us-west-2"
      + revoke_rules_on_delete = false
      + tags                   = {
          + "Name" = "mmgis-production-rds-sg"
        }
      + tags_all               = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Name"        = "mmgis-production-rds-sg"
          + "Project"     = "MMGIS"
        }
      + vpc_id                 = "vpc-0512162c42da5e645"
    }

  # module.mmgis.aws_security_group.service will be created
  + resource "aws_security_group" "service" {
      + arn                    = (known after apply)
      + description            = "MMGIS production admin + publish tasks: ingress 8888 from the in-VPC ALB, egress all."
      + egress                 = [
          + {
              + cidr_blocks      = [
                  + "0.0.0.0/0",
                ]
              + description      = "All outbound (image pull, AWS APIs, webhooks via NAT)."
              + from_port        = 0
              + ipv6_cidr_blocks = []
              + prefix_list_ids  = []
              + protocol         = "-1"
              + security_groups  = []
              + self             = false
              + to_port          = 0
            },
        ]
      + id                     = (known after apply)
      + ingress                = [
          + {
              + cidr_blocks      = [
                  + "10.41.0.0/16",
                ]
              + description      = "App port from the ECS-managed ALB (in-VPC)."
              + from_port        = 8888
              + ipv6_cidr_blocks = []
              + prefix_list_ids  = []
              + protocol         = "tcp"
              + security_groups  = []
              + self             = false
              + to_port          = 8888
            },
        ]
      + name                   = "mmgis-production-service-sg"
      + name_prefix            = (known after apply)
      + owner_id               = (known after apply)
      + region                 = "us-west-2"
      + revoke_rules_on_delete = false
      + tags                   = {
          + "Name" = "mmgis-production-service-sg"
        }
      + tags_all               = {
          + "Environment" = "production"
          + "ManagedBy"   = "Terraform"
          + "Name"        = "mmgis-production-service-sg"
          + "Project"     = "MMGIS"
        }
      + vpc_id                 = "vpc-0512162c42da5e645"
    }

Plan: 30 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + express_ingress_paths         = (known after apply)
  + express_service_arn           = (known after apply)
  + rds_managed_master_secret_arn = (known after apply)
  + workflow_variables            = {
      + ADMIN_TASK_FAMILY   = "mmgis-production-admin"
      + AWS_REGION          = "us-west-2"
      + ECR_REPOSITORY      = "mmgis-production"
      + ECS_CLUSTER         = "mmgis-production"
      + ECS_SERVICE         = "mmgis-production-admin"
      + PUBLISH_TASK_FAMILY = "mmgis-production-publish"
    }

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

Terraform plan preview — development

Commit db6a133 · run log

Plan: 2 to add, 1 to change, 2 to destroy.

Show plan

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  ~ update in-place
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # module.mmgis.aws_ecs_task_definition.admin must be replaced
-/+ resource "aws_ecs_task_definition" "admin" {
      ~ arn                      = "arn:aws:ecs:us-west-2:853558080719:task-definition/mmgis-development-admin:14" -> (known after apply)
      ~ arn_without_revision     = "arn:aws:ecs:us-west-2:853558080719:task-definition/mmgis-development-admin" -> (known after apply)
      ~ container_definitions    = jsonencode(
          ~ [
              ~ {
                  ~ image            = "853558080719.dkr.ecr.us-west-2.amazonaws.com/mmgis-development:77d6d61" -> "853558080719.dkr.ecr.us-west-2.amazonaws.com/mmgis-development:e18f01e"
                  - mountPoints      = []
                    name             = "mmgis"
                  ~ portMappings     = [
                      ~ {
                          - hostPort      = 8888
                            # (2 unchanged attributes hidden)
                        },
                    ]
                  - systemControls   = []
                  - volumesFrom      = []
                    # (4 unchanged attributes hidden)
                },
            ] # forces replacement
        )
      ~ enable_fault_injection   = false -> (known after apply)
      ~ id                       = "mmgis-development-admin" -> (known after apply)
      ~ revision                 = 14 -> (known after apply)
      - tags                     = {} -> null
        # (13 unchanged attributes hidden)

        # (1 unchanged block hidden)
    }

  # module.mmgis.aws_ecs_task_definition.publish must be replaced
-/+ resource "aws_ecs_task_definition" "publish" {
      ~ arn                      = "arn:aws:ecs:us-west-2:853558080719:task-definition/mmgis-development-publish:14" -> (known after apply)
      ~ arn_without_revision     = "arn:aws:ecs:us-west-2:853558080719:task-definition/mmgis-development-publish" -> (known after apply)
      ~ container_definitions    = jsonencode(
          ~ [
              ~ {
                  ~ image            = "853558080719.dkr.ecr.us-west-2.amazonaws.com/mmgis-development:77d6d61" -> "853558080719.dkr.ecr.us-west-2.amazonaws.com/mmgis-development:e18f01e"
                  - mountPoints      = []
                    name             = "mmgis"
                  - portMappings     = []
                  - systemControls   = []
                  - volumesFrom      = []
                    # (5 unchanged attributes hidden)
                },
            ] # forces replacement
        )
      ~ enable_fault_injection   = false -> (known after apply)
      ~ id                       = "mmgis-development-publish" -> (known after apply)
      ~ revision                 = 14 -> (known after apply)
      - tags                     = {} -> null
        # (13 unchanged attributes hidden)

        # (1 unchanged block hidden)
    }

  # module.mmgis.aws_iam_role_policy.publish_task will be updated in-place
  ~ resource "aws_iam_role_policy" "publish_task" {
        id          = "mmgis-development-publish-task:mmgis-development-publish-task"
        name        = "mmgis-development-publish-task"
      ~ policy      = jsonencode(
          ~ {
              ~ Statement = [
                  ~ {
                      ~ Action   = [
                            "cloudformation:CreateStack",
                          ~ "cloudformation:DescribeStacks" -> "cloudformation:UpdateStack",
                          ~ "cloudformation:DescribeStackEvents" -> "cloudformation:DescribeStacks",
                            "cloudformation:DeleteStack",
                        ]
                        # (3 unchanged attributes hidden)
                    },
                    {
                        Action   = [
                            "s3:CreateBucket",
                            "s3:DeleteBucket",
                            "s3:GetBucketLocation",
                            "s3:PutBucketPolicy",
                            "s3:DeleteBucketPolicy",
                            "s3:PutBucketPublicAccessBlock",
                            "s3:PutEncryptionConfiguration",
                            "s3:PutBucketTagging",
                        ]
                        Effect   = "Allow"
                        Resource = "arn:aws:s3:::mmgis-development-dashboard-*"
                        Sid      = "DashboardBucketLifecycle"
                    },
                    {
                        Action   = [
                            "s3:PutObject",
                        ]
                        Effect   = "Allow"
                        Resource = "arn:aws:s3:::mmgis-development-dashboard-*/*"
                        Sid      = "DashboardBucketWriteObjects"
                    },
                  ~ {
                      ~ Action   = [
                            # (1 unchanged element hidden)
                            "cloudfront:GetDistribution",
                          + "cloudfront:GetDistributionConfig",
                            "cloudfront:UpdateDistribution",
                            # (5 unchanged elements hidden)
                        ]
                        # (3 unchanged attributes hidden)
                    },
                  ~ {
                      ~ Action   = [
                            # (1 unchanged element hidden)
                            "cloudfront:PublishFunction",
                          + "cloudfront:UpdateFunction",
                            "cloudfront:DescribeFunction",
                            # (5 unchanged elements hidden)
                        ]
                        # (3 unchanged attributes hidden)
                    },
                    {
                        Action   = [
                            "cloudfront:CreateOriginAccessControl",
                            "cloudfront:GetOriginAccessControl",
                            "cloudfront:DeleteOriginAccessControl",
                        ]
                        Effect   = "Allow"
                        Resource = "arn:aws:cloudfront::853558080719:origin-access-control/*"
                        Sid      = "DashboardOriginAccessControlLifecycle"
                    },
                    # (4 unchanged elements hidden)
                ]
                # (1 unchanged attribute hidden)
            }
        )
        # (2 unchanged attributes hidden)
    }

Plan: 2 to add, 1 to change, 2 to destroy.

A customer fronting a published dashboard with their own CloudFront forwards
the full path and declares the matched prefix in X-Forwarded-Prefix. The
viewer-request Function now validates that header (open-redirect and
path-traversal shapes are ignored), strips the prefix from request.uri, and
302-redirects a slash-less entry to its trailing-slash form so relative asset
URLs resolve. infrastructure/cloudfront-function.js becomes the deployed
source: cfn-template.js reads and templates it at render time instead of
carrying a second copy.

For that to work every client-side URL under the dashboard must be relative to
the document rather than the origin root: the upload route returns the S3 key
with no leading slash, the Card adapter and the Configure preview classify
that key shape instead of mission-prefixing it, the PDF worker resolves
against document.baseURI, LayerGeologic drops its rooted /public, and a static
build stops routing tile URLs through the server-side tile proxy.

Adds the customer-facing integration contract at
docs/infrastructure/serving-a-dashboard-from-your-domain.md.
- docs: require query-string forwarding and https-only origin in the
  customer subpath guide
- docs: drop the deprecated apache SERVER option from sample.env and ENVs
- resolveImageUrl/buildPreviewSrc: guard non-string config values instead
  of throwing (blank SPA / blanked cards)
- Viewer_: build the mosaic path from missionPath (folder name), matching
  the rest of the file, so missions whose folder != display name resolve
- QueryURL: return '' for a valueless param instead of the literal "undefined"
- tests: add a CloudFront-function 10KB size check; cut duplicate,
  impossible-input, dead-guard, and redundant tests
- cfn-template: drop the redundant readFileSync try/catch (Node's ENOENT
  already names the path)
…e literals are runtime-supported; the real limit is no let/const plus the acorn ES5 test)
… doc

The doc explained the edge behavior in prose spread across four
paragraphs; a debugging customer wants one table answering "here's my
URL and my header — which row am I in?". Adds that table to the end of
How it works, and converges the page's running example onto
site.gov/tools/dashboard (shorter, and the table rows stay readable).
…dability

The ASSETS_UPLOAD_KEY and resolveImageUrl comments were dense single
paragraphs; restructure them around the question each answers and the
four branches in check order, and correct one claim: a published
dashboard's entry URL doesn't always end in '/' (<root>/index.html is
reachable) — the real invariant is that the page is only ever served
from the dashboard's root folder. The Configure twin now points here
for the full story instead of restating it.
Same four-bullet case list as resolveImageUrl, with the one real
difference — base-anchoring, because the CMS knows where it lives while
the page URL is untrustworthy — stated up front instead of mid-paragraph.
…branch

Each branch now ends with its why, and the prefix-escape hazard of a
'../' climb in a static build is stated outright instead of implied.
Same requirements, same table, same examples — the dense paragraphs
become bold-led items and short warnings so a customer can act on the
page without rereading it. The pattern warnings move to bullets, the
cache-policy requirements each lead with the instruction, and every
'why' follows its rule instead of wrapping around it.
…c with setup

The rebase onto development kept PDFViewer at .jsx (development parses
it as JSX in tests), so the source tripwire reads that path now. The
customer doc is restructured for its audience: bare setup steps first,
the behavior table next, every explanation demoted to a closing
section.
@CarsonDavis
CarsonDavis force-pushed the 316-dashboard-subpath branch from c8557fa to 5dfd36d Compare September 1, 2026 21:24
@CarsonDavis
CarsonDavis marked this pull request as ready for review September 1, 2026 21:32
getSingleQueryVariable cut a value at every equals sign (share links
don't encode '=', so a stored '?token=abc=xyz' read back as 'abc') and
threw uncaught on malformed percent-encoding during app startup. Rejoin
the split and treat a malformed value as absent — not '' — so it can't
flow into the parseFloat/split cascades behind the strict !== false
checks. getMultipleQueryVariable gets the same two fixes plus the one
it was missing: a valueless param no longer pushes the literal string
'undefined' into the list. New spec pins all of it, both functions.
…ldcard

A single /tools/dashboard* pattern also captures the customer's own
sibling routes (/tools/dashboard-archive) and 403s them, which forced a
'pick a path that is no other route's prefix' constraint on their URL
space. An exact /tools/dashboard behavior plus /tools/dashboard/*
covers the bare entry (so the slash-less redirect still runs) without
matching anything else; the header and HTTPS-only setting live on the
shared origin, so the second behavior duplicates nothing. Setup is
reordered origin-first to make that sharing plain.
Add the behavior-precedence warning (a broader customer pattern listed
higher silently swallows both of ours), restrict the declared path
charset to characters CloudFront path patterns documentedly accept —
dropping the space-path row, which promised a behavior a customer
cannot wire — and say the header must match the path, not the pattern.
A bare ?_preview or ?forcelanding read as set in modern.js
(urlParams.has) but as unset in LandingPage, whose truthy checks
treated the reader's '' -valueless return as absent — and only
modern.js accepted the forceLanding casing. LandingPage now counts a
flag as set when the reader returns anything but false, accepts both
casings through one helper, and strips whichever spelling the URL
carried. Also state why the PDF worker path is absolutized.
…uild

Declaring acorn at the root for one test made npm dedupe webpack's and
terser's nested acorn@8.15.0 onto 8.18.0 — the production build's
parser changed as a side effect of a test convenience. espree (what
eslint already uses; nothing in the tree nests it) does the same
ecmaVersion-5 assertion with no hoisting collision. The lockfile is
restored to the pre-PR baseline plus exactly one line: the espree
declaration. Verified the assertion still bites on const/let/arrows/
template literals/class/for-of/spread.
…he encoding

The rebuilt Location concatenated keys and values raw, resting on the
observed-but-undocumented behavior that CloudFront hands them over
still percent-encoded. The rebuild now skips any pair carrying a
character outside the RFC 3986 query set (minus '&' everywhere and '='
in keys — the two shapes that corrupt this reassembly), so a decoded
'&' or '#' loses its pair instead of forging a param break or fragment,
and CR/LF can never reach the header. Values keep their '=' (base64
padding survives; parsers split on the first). Normal percent-encoded
traffic is byte-identical, test-pinned.
AWS documents the cloudfront-js-1.0 regex engine as Perl compatible,
and Perl's $ also matches before a final trailing newline — so the
anchored ^[...]*$ allowlists could theoretically accept one trailing
newline under strict-Perl semantics. An unanchored negated-class search
has no $ to interpret: a newline anywhere is simply a disallowed
character. Accepted set proven byte-identical by enumerating ASCII
0..127 alone and embedded. Also pin the mixed-multiValue policy: an
unsafe value drops only itself, safe siblings of the same key survive.
The header described how the file is deployed but never what the
handler actually does; open with the four steps in execution order and
regroup the deployment, trust-model, ES5, and encoding paragraphs under
plain lead-ins. Comment-only; the rendered body strips this header.
…ing the pair

Per adversarial review: dropping silently loses real-world pairs
(filter[x]=1, bbox=1|2|3) that escaping preserves. Characters outside
the safe set are percent-encoded into the rebuilt Location — '%' stays
in the safe set so already-encoded values pass byte-identical, no
double-encoding. The negated class matches runs so surrogate pairs
encode as one character; a lone surrogate (unencodable) falls back to
dropping its pair rather than throwing. Header comment now cites the
actual basis for the wire-form-values premise: AWS's own
normalize-query-string-parameters example rebuilds by raw concatenation
of .value, correct only for wire-form, per the doc-research pass. New
pins: %26/%3D values byte-identical, escaped survival, lone-surrogate
drop.
# Conflicts:
#	src/essence/Basics/Layers_/Layers_.js
@CarsonDavis
CarsonDavis requested a review from slesaad September 3, 2026 17:15
… runtime, and share the express test harness
…lver, the viewer's plain mission path, both forceLanding casings, and four doc corrections
…subpath

# Conflicts:
#	src/essence/Basics/Layers_/Layers_.js
Comment thread src/essence/Tools/Card/adapters/buildCardData.ts Outdated
resolveMissionAssetUrl is used only by the Card and theme-rail adapters,
so it lives in the plugins' shared toolkit rather than in core.
@CarsonDavis
CarsonDavis merged commit 74065ad into development Sep 9, 2026
4 checks passed
@CarsonDavis
CarsonDavis deleted the 316-dashboard-subpath branch September 9, 2026 15:13
CarsonDavis added a commit that referenced this pull request Sep 9, 2026
| Category | Lines added | % |
|---|---|---|
| Tests | 1538 | 52.2% |
| Production code | 1343 | 45.6% |
| Config/build | 43 | 1.5% |
| Docs | 21 | 0.7% |

Clicking Update on a published dashboard now brings its CloudFormation
stack up to the current template, so infrastructure fixes reach
dashboards that already exist. The Deployments page also stops an admin
from starting two publishes for one dashboard or deleting one while its
publish task is still running, and says why when it refuses.

Part of #316. PR 2 of 3: stacked on #330 (serve a dashboard at a
customer subpath) with #349 (cache-control) on top of this one;
retargets to `development` once #330 merges.

### Republishing a dashboard now updates its AWS infrastructure, not
just its files

Previously, an operator clicked Update on a published dashboard in
/configure → Deployments, the row went `updating` → `published`, the
bundle was replaced, the URL was unchanged, and nothing about the
dashboard's CloudFront changed. The entire update branch of
`scripts/publish-static.js` was a DescribeStacks call and a "does not
exist, publish before updating" throw.

Each dashboard's bucket, distribution and edge Function are created
once, by CreateStack at the first publish, and were never touched again.
So a dashboard already sitting behind a customer path prefix kept
serving the old edge Function and stayed broken, and a rotated
`MMGIS_DASHBOARDS_PASSWORD` never reached it. The only escape was delete
and republish, which mints a new CloudFront domain: the one thing path
owners hardcode.

The fix: Update renders the current template, which bakes in the current
password, calls UpdateStack, and polls to UPDATE_COMPLETE. Same stack,
same bucket, same URL. It logs `Converging stack 'mmgis-dashboard-N' to
the current template — this re-bakes the current dashboards password
into the auth Function.` and then `Stack '…' reached UPDATE_COMPLETE.`
When the template already matches, CloudFormation's "No updates are to
be performed" is caught and logged as `Stack '…' is already up to date.`
rather than failing the republish.

### Publishing over an existing stack either fails fast with guidance or
waits for it to settle

Previously, a publish over a stack that already existed could hang for
the full 30-minute timeout, or fail on the stack's own success status. A
stack left at ROLLBACK_COMPLETE by a failed create ended, after the
whole build, at `Stack 'mmgis-dashboard-N' reached terminal status
'ROLLBACK_COMPLETE'`: accurate, but it doesn't say what to do, and
CloudFormation cannot update such a stack at all. DELETE_IN_PROGRESS and
UPDATE_FAILED were not in the terminal list, so the poller sat on a
deleting stack until it vanished (`Stack '…' does not exist (deleted or
never created)`) and on a failed update until the 30-minute timeout. And
the wait always ran against exactly CREATE_COMPLETE, so a stack resting
at UPDATE_COMPLETE, which the converge path now produces routinely,
killed a publish re-run with `Stack '…' reached terminal status
'UPDATE_COMPLETE'`.

The cause is one list of statuses doing two jobs, "this stack has
stopped moving" and "this stack is unusable", written back when a stack
was only ever created.

The fix: a preflight, before the build, rejects the eight statuses only
a delete can clear (CREATE_FAILED, ROLLBACK_COMPLETE,
ROLLBACK_IN_PROGRESS, ROLLBACK_FAILED, UPDATE_ROLLBACK_FAILED,
UPDATE_FAILED, DELETE_FAILED, DELETE_IN_PROGRESS) with guidance matched
to the state. Five of them get `Stack '…' is in ROLLBACK_COMPLETE and
cannot be used — delete the deployment and publish it again (this mints
a new URL)`. UPDATE_ROLLBACK_FAILED is the one state where a live
dashboard, whose URL a customer may have hardcoded, can be rescued, so
its message points at the console's "Continue update rollback" first
(the stack then reads UPDATE_ROLLBACK_COMPLETE and the next republish
keeps the URL) and a delete second. The two delete states say to finish
or retry the delete; those rows already belong to the delete flow, so
that text only reaches the task log. UPDATE_FAILED also joins the
terminal list, so it throws on the first poll instead of timing out. And
publish now waits for whatever the status it finds settles at (UPDATE_*
to UPDATE_COMPLETE, UPDATE_ROLLBACK_* to UPDATE_ROLLBACK_COMPLETE,
otherwise CREATE_COMPLETE), so a stack already at rest resolves on the
first poll and one found mid-operation is waited out.
UPDATE_ROLLBACK_COMPLETE is deliberately not on the delete-only list:
that stack still has a working bucket and distribution and stays
reusable.

### A republish waits for its own update, and reports why it failed

Nothing waited on an update before, so the three problems here are
hazards the converge introduces rather than bugs an operator has seen.

Two Update clicks start two ECS tasks, and CloudFormation rejects the
loser with `Stack:arn:… is in UPDATE_IN_PROGRESS state and can not be
updated.`, which would mark that republish failed. A rollback settles at
UPDATE_ROLLBACK_COMPLETE, which nothing distinguished from success, and
the terminal-status error read StackStatusReason off the terminal
status, which CloudFormation usually leaves empty (the reason sits on
the preceding rollback status), so an operator would get a bare status
with no cause. DescribeStacks is eventually consistent, so the first
poll after an UpdateStack can still return the pre-update
UPDATE_COMPLETE, and status equality alone would resolve the wait on a
stack that had not begun converging: the task would upload files and
report `published` while CloudFormation was still working.

The fix, in three parts:

- The busy rejection is recognized, logged as `Stack '…' is busy with
another operation (UPDATE_IN_PROGRESS); waiting for it to settle, then
retrying.`, and retried, re-running this run's own UpdateStack so this
template converges rather than only the winner's. It re-reads the stack
each attempt and sleeps one poll interval first, because a read taken at
the instant of the rejection can still show the pre-operation status and
would otherwise burn the retry budget instantly. After 11 attempts it
gives up with `Stack '…' stayed busy after 11 UpdateStack attempts —
another operation may be stuck; try again shortly.` Only an
`*_IN_PROGRESS` status counts as busy, even though CloudFormation words
the wedged-status rejection identically, and DELETE_IN_PROGRESS is
excluded because waiting out a teardown can only arrive at a stack that
no longer exists.
- The converge waits specifically for UPDATE_COMPLETE, so a rollback
hits the terminal throw instead of passing as a successful publish, and
the wait now remembers the last StackStatusReason it saw that wasn't
"User Initiated" and appends it to both the terminal-status and the
timeout messages.
- The wait takes the status and LastUpdatedTime read immediately before
the UpdateStack and treats any read matching both as stale. An advanced
LastUpdatedTime is positive proof this operation landed. That read is
taken fresh on every attempt and is what a no-op converge returns, so an
Update queued behind a first publish sees the finished stack's outputs
rather than an empty pre-create snapshot.

### Failures surface before the build, with the real error

Previously, the password was only read inside the create branch, after
`npm run build:themes` and `npm run build`. A first publish with the
secret unset burned the entire bake-and-webpack build, minutes of it,
and then failed with `Missing required environment variable
'MMGIS_DASHBOARDS_PASSWORD' (lean publish flow; see sample.env)`. An
update against a stack that doesn't exist reached `Stack '…' does not
exist — publish before updating` the same way, only after the build.

Separately, the stack read treated any ValidationError as "there is no
stack here": the check was `err.name === "ValidationError" || message
includes "does not exist"`, an or rather than an and. ValidationError is
CloudFormation's name for a whole family of complaints, a rejected stack
name or a malformed request among them, so publish could go on to
CreateStack against a stack it had merely failed to read, and update
would report the stack missing while hiding the actual error.

The fix: step one renders the template and reads the stack, so a doomed
run flips the row to `failed` within seconds of the task starting. And
only the ValidationError whose message names a missing stack reads as
absence; anything else is rethrown with its own message. The delete
flow's teardown calls the same helper, so it sees the real error now
too.

### Deleting a dashboard mid-publish no longer resurrects the row

Previously, an operator who hit Delete while a publish or update task
was running saw the row go to `deleting` and the teardown start, and
then the still-running task flipped that same row back to `published`,
complete with a `cloudfront_url` and bucket for resources that were
being torn down, or to `failed`. The Deployments list showed a live
dashboard that no longer existed.

The task's two terminal writes were `deployment.update({ status:
PUBLISHED, … })` and the failed equivalent, called on the instance,
unconditionally, with no notion that anyone else might have claimed the
row.

The fix: both writes now go through `Deployments.update(…, { where: {
id, status not in [DELETING, DELETED] } })`. A row the delete flow has
claimed is left alone, and the delete flow decides its next status.

### The permissions for UpdateStack, in all three layers

Previously nothing called UpdateStack, so nothing granted it. The
task-role JSON recipe, the Terraform module's `iam.tf` and the bootstrap
`boundary.tf` each listed CreateStack, DescribeStacks and DeleteStack,
and no `cloudfront:UpdateFunction`. Had the code shipped on its own, a
republish would have died on AccessDenied, and because the permissions
boundary caps everything, granting it on the role alone would still have
failed.

The fix: all three layers gain `cloudformation:UpdateStack`,
`cloudfront:UpdateFunction`, `cloudfront:GetDistributionConfig`,
`cloudfront:GetOriginAccessControlConfig` and
`cloudfront:UpdateOriginAccessControl`. The read grants are there
because CloudFormation reads a resource's existing config before
changing it. Unused `cloudformation:DescribeStackEvents` is dropped from
all three.

### A template edit that would silently mint a new URL now fails CI

Previously nothing stopped someone adding an explicit `BucketName` to
the dashboard bucket or renaming a logical ID. Under the new UpdateStack
path, either one makes CloudFormation replace the resource: the
distribution loses its origin and the dashboard's domain changes, and
that domain is hardcoded in the customer's CloudFront forwarding rule.

The fix: a test asserts `DashboardBucket.Properties` carries no
`BucketName`, and together with the existing logical-ID test a rename
fails the suite. A second test pins
`DashboardAuthFunction.Properties.AutoPublish` to true, because without
it a converge would update the function's code while every request kept
meeting the old LIVE stage, which is to say the old password.

### The admin can no longer start two publish tasks for one dashboard

Previously, the Update button on the Deployments page was always enabled
and the update route took no lock. Two clicks, or two admins in two
tabs, started two ECS tasks against the same stack. Save & Publish in
the config editor took the same path: it picks the mission's existing
row and calls Update on it whatever state the row is in, so a second
Save & Publish during a first provision hit Update on a `provisioning`
row. The Publish form had no guard either, so a second Publish for a
mission that already had a dashboard quietly created a second stack with
a second URL.

Nothing on the server could tell a running task from a dead one. The
routes discarded the ECS task identifier that RunTask returns, and the
admin role could only start tasks, not describe them. So a task that
died without writing its row left the row at `provisioning` or
`updating` forever, and any lock keyed on that status would have locked
the row forever. That is why an earlier attempt at a server-side refusal
was reverted.

The fix: the row now remembers its task. Both routes store the task ARN
in the row's settings, and the publish task registers its own ARN at
startup from the ECS metadata endpoint, so a missing ARN means the task
never reached its first line. The admin task role gains
`ecs:DescribeTasks`, scoped to the environment's cluster, in all three
IAM layers. With that, the update route claims the row with a
compare-and-set (from a resting status to `updating`, in one statement)
and refuses the loser with a message naming the state: a publish or
update already running, a dashboard being deleted, or one already
deleted. The publish route refuses a second dashboard for a mission that
already has one; the page shows a dialog naming the existing dashboard
and lets the admin publish another on purpose. The Deployments page
disables Update on any row that is not at rest, with a tooltip per
state, and Save & Publish shows the server's reason and watches the
existing row instead of reporting a generic failure.

### A dead publish task is reported as failed, and a live one is visible

Previously, a task killed mid-run (container stopped, out of memory, a
deploy during a publish) left its row at `provisioning` or `updating`
with no error text, indefinitely. The page showed the status and nothing
else.

The fix: on every list read, a row in flight with a stored ARN is
checked against ECS. If ECS says the task stopped, or no longer lists
it, the row flips to `failed` with the stop reason and a next step
("Delete this row and publish again" for a provisioning row, "Use Update
to retry, or Delete and publish again" for an updating one). That write
is pinned to the row's status and to the ARN whose death was observed,
so a stalled ECS answer about an old task can never land on a row an
Update has since re-claimed. A task ECS does not list yet, within two
minutes of the claim, is reported as not yet confirmed rather than dead.
If ECS cannot be asked at all (the grant not yet applied, no credentials
locally), the row is left alone and says so. While a task is alive the
row shows it, with the start time and the ECS status, and says what to
do if it looks stuck.

### Delete waits for a running publish

Previously, Delete was always enabled. An admin who clicked it during a
publish saw the teardown start while the task was still uploading into
the bucket being emptied. A re-populated bucket can push the stack
delete to DELETE_FAILED, which this same PR classifies as delete-only.

The fix: the delete route asks ECS about the row's task and refuses
while it is alive, with the task's ECS status in the message; a stopped
or forgotten task never blocks a delete. The Delete button is disabled
only when ECS confirms the task is alive. When the task cannot be
confirmed, Delete stays available and both the row and the delete modal
say that deleting now may race a live publish. The publish task also
re-reads its row after registering its ARN and stops before touching AWS
if the row has been deleted in the meantime. A row already in `deleting`
keeps Delete enabled, since clicking it retries a stuck teardown.
Refusals refetch the list, so a stale tab catches up.

## Decisions to review

- **Converging the stack is a hard gate on every republish.**
UpdateStack runs before the asset copy and bundle upload, and a rollback
or failure throws, so a template or IAM regression now fails routine
content republishes on every existing dashboard. The alternative is to
converge best-effort and upload the bundle regardless.
- **A rotated `MMGIS_DASHBOARDS_PASSWORD` reaches each dashboard on its
next republish, not all at once.** Update bakes the environment's
current secret into the edge function, so after a rotation dashboards
move to the new password one republish at a time; before this PR a
rotation reached no existing dashboard at all. The alternative is a
separate "push the password to every dashboard" action, which can be a
follow-up.
- **The admin lock is keyed on ECS task liveness, and fails open.**
Update and Delete are refused only when the row's task is confirmed
alive; if ECS cannot be asked, the row is left alone, Delete stays
available, and the page says the task could not be confirmed. The
alternative is to refuse on row status alone, which is simpler and would
lock a row forever after a task dies while the grant is missing.
- **The busy-wait retry inside the converge stays as a backstop.** With
the compare-and-set claim, a second UpdateStack on the same stack can
only come from an operation started outside MMGIS or from the
millisecond window before the ARN is stored; the retry loop (up to 11
attempts, each wait bounded by the 30-minute stack timeout) covers
those. The alternative is to remove it and fail such a run outright.
- **A row becomes `failed` only on a definitive ECS answer.** A task ECS
does not list within two minutes of the claim is reported as
unconfirmed, not dead, and the page shows a neutral "Starting the task"
note for the first minute after a click. The alternative is to also time
out a row with no recorded task after some interval, which was cut
because it could mark a live task's row failed.
- **A second dashboard for a mission needs a confirmed Publish.** The
publish route refuses when the mission already has a dashboard that is
not deleted or being deleted, and the page offers "Publish another" with
the existing one named. The alternative is a hard
one-dashboard-per-mission rule.
- **A hung task holds Delete until it is stopped in ECS.** The row and
the tooltip say so. The alternative is a Stop button backed by
`ecs:StopTask`, which is a separate grant and its own decision.
- **A Publish re-run against an existing stack waits for it to settle
rather than converging it.** Publish only needs a working bucket, so it
never runs UpdateStack; only the update action converges. The
alternative is to converge on both actions.
- **`cloudformation:DescribeStackEvents` is removed from all three IAM
layers.** Nothing calls it, and failure reasons now come from the
stack-level StackStatusReason carried across polls rather than from
per-resource events. Getting it back costs a boundary apply and a role
apply.
- **Two OriginAccessControl grants are added as headroom.**
`GetOriginAccessControlConfig` and `UpdateOriginAccessControl` go into
all three layers even though the template's OAC properties are constants
today; least privilege says drop them, and adding them later is one PR
across three files.

Post-merge, in order:

1. Apply `infrastructure/terraform/bootstrap` by hand so the permissions
boundary carries `UpdateStack`, the new CloudFront grants, and the admin
role's `ecs:DescribeTasks`. Skipping this makes every republish fail
with AccessDenied, because the boundary caps the role; until the
DescribeTasks grant lands, in-flight rows show as unconfirmed and Delete
stays available.
2. Let the environment deploy workflow (`iac-deploy.yml`) apply the
module's publish and admin task-role changes.
3. Click Update once on every existing dashboard so it picks up the edge
function from #330.
4. Do one live republish in the DEV account: it is the only end-to-end
check that can't be run locally.
CarsonDavis added a commit that referenced this pull request Sep 9, 2026
| Category | Lines added | % |
|---|---|---|
| Tests | 242 | 76.6% |
| Production code | 67 | 21.2% |
| Docs | 7 | 2.2% |

Every object a publish writes now carries a Cache-Control header, so a
republished dashboard reaches visitors instead of sitting stale on a
cache we cannot reach.

Closes #316. PR 3 of 3 for the issue, stacked on #348 (which stacks on
#330), retargeted to `development` as the stack merges.

### A republish now reaches a customer's own cache

Previously, an operator republished a dashboard, saw the new content on
our CloudFront (the publish invalidates `/*`), and a customer fronting
that same dashboard on their own domain kept serving the old
`index.html`.

Every object we wrote went up with no Cache-Control at all: the PUTs
carried only Bucket, Key, Body, ContentLength and ContentType, and the
shared-asset copies used CopyObject's default COPY directive, which
carries over the source object's headers, and the upload router never
set a Cache-Control on those either. With no instruction from the
origin, a CloudFront cache policy falls back to its Default TTL, 86400
seconds in the managed policies and in most hand-rolled ones. The old
entry page could stay pinned for a day in a distribution we have no
ability to invalidate.

The fix: `cacheControlForKey(key)` assigns one of three tiers to every
object, applied on all four write paths (`uploadDirectory`,
`uploadFile`, `copyPrefix`, `copyObjectIfExists`):

- `no-cache`, revalidate on every request, for `index.html`,
`build/index.html` and `Missions/<mission>/config.json`: the entry page
and the baked mission config.
- `public, max-age=31536000, immutable` for
`build/static/{js,css,media}/…` and
`assets/<mission>/<subdir>/uploads/…`: the two classes whose filenames
are content-addressed, webpack content hashes on one side and
`crypto.randomUUID()` names that are never overwritten on the other.
- `public, max-age=300` for everything else, the keys that genuinely
change in place on republish: `build/static/cesium/…`,
`public/workers/…`, `Missions/…/Data/mosaic_parameters.csv`, and
non-upload `assets/…`.

A republish now reaches a header-honoring cache in stages: entry page
and config at once, the rest within about five minutes. The two copy
paths switched to `MetadataDirective: REPLACE`, because COPY cannot add
a header the source never had; REPLACE drops the source's whole metadata
set, so those copies restate ContentType as well, re-derived from the
file extension.

One test guards the premise of the immutable tier: a new spec reads
`configuration/webpack.config.js` as text and asserts a content hash in
all six output-name settings, so dropping one breaks the suite instead
of quietly putting a stable filename into the year-long tier.

### Two un-rendered templates are no longer published

Previously, anyone hitting `<dashboard>/build/index.pug` or
`<dashboard>/public/index.html` got a raw template with
`#{LINK_PREVIEW_TITLE}`, `#{AUTH}` and `#{user}` still in it.

The publish uploads `build/` and `public/` wholesale, and it
interpolates those placeholders into `build/index.html` only. The two
siblings shipped exactly as they sit on disk, and `uploadDirectory` had
no way to leave a key out.

The fix: `uploadDirectory` takes an optional `filter(key)` and
`publish-static.js` skips those two exact keys. They are left out of the
upload and out of the reported file count.

### The customer doc sets a Maximum TTL and covers password rotation

Previously,
`docs/infrastructure/serving-a-dashboard-from-your-domain.md` asked the
path owner for a custom cache policy with Authorization in the cache
key, all query strings, and Minimum TTL 0. It said nothing about a
maximum, so a customer who set a one-day Maximum TTL would silently cap
our year-long tier down to a day. Nothing told an operator what happens
to a fronting cache after a dashboard's password is rotated.

The doc was written when we sent no Cache-Control at all, so there were
no TTL ceilings and no revalidating tier to describe.

The fix: the setup list adds "Maximum TTL 31536000 (one year) or more",
and the rationale section explains the three tiers as behavior,
including what a staged republish looks like from the customer's side.
It also notes that AWS's managed `UseOriginCacheControlHeaders` policies
get the TTLs right but omit Authorization from the cache key, so a
custom policy is still required. A closing paragraph tells operators to
invalidate their distribution after rotating a password: the cache is
keyed on Authorization, so responses fetched under the old password keep
being served to anyone still presenting it, never reaching us to be
rejected, for up to a year on the immutable tier.

## Decisions to review

- **One-year `immutable` on password-gated files.** `public` is the
directive that lets a shared cache store a response to a request
carrying Authorization, so cross-visitor safety rests entirely on the
customer keeping Authorization in their cache key, which the doc
requires and we cannot enforce. The alternative is a ceiling of hours or
days, which still fixes the day-long pin and bounds the exposure after a
rotation without a customer-side step.
- **The immutable tier is decided by key prefix, not by evidence of a
hash in the filename.** The webpack half of that claim is guarded by the
new spec and the uploads half is only a code comment; the alternative is
to require a hash segment in the key itself, which needs no cross-file
guard. Being wrong is asymmetric, because we cannot purge a stale
immutable object from a customer's edge for a year.
- **`MetadataDirective: REPLACE` restates only ContentType and
CacheControl.** Content-Encoding, Content-Disposition and `x-amz-meta-*`
are dropped, which is exact for every type the upload router writes
today; the alternatives are to HeadObject the source and carry its
headers forward, or to set Cache-Control at upload time so a plain COPY
suffices.
- **Our own per-dashboard distribution stays on the managed
CachingOptimized policy.** Its 1-second minimum TTL overrides `no-cache`
at our edge, which does not matter because the publish already
invalidates `/*`; the alternative is an owned cache policy in the
template with minimum 0.
- **The two raw templates are kept out by an exact-key skip on a publish
that only writes.** A dashboard published before this keeps its copies
until its bucket is emptied; the alternative is to delete those two keys
on republish, which needs `s3:ListBucket` and `s3:DeleteObject` added in
three IAM layers.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The final dashboard should be deployable to a subpath

2 participants