From 4c433b06f3141e2f14b169ddba68783ec4833fcc Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Tue, 9 Jun 2026 15:04:15 -0500 Subject: [PATCH 01/63] add structure for initial local deployments skill --- .claude/skills/mmgis-deployment/SKILL.md | 54 ++++++ .../references/deployment-model.md | 52 +++++ .../references/troubleshooting.md | 50 +++++ .../skills/mmgis-deployment/scripts/_lib.sh | 183 ++++++++++++++++++ .../skills/mmgis-deployment/scripts/create.sh | 88 +++++++++ .../skills/mmgis-deployment/scripts/doctor.sh | 41 ++++ .../skills/mmgis-deployment/scripts/list.sh | 16 ++ .../scripts/refresh-golden.sh | 31 +++ .../skills/mmgis-deployment/scripts/start.sh | 29 +++ .../skills/mmgis-deployment/scripts/stop.sh | 18 ++ .../mmgis-deployment/scripts/teardown.sh | 79 ++++++++ .../skills/mmgis-deployment/scripts/test.sh | 30 +++ .gitignore | 6 +- 13 files changed, 676 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/mmgis-deployment/SKILL.md create mode 100644 .claude/skills/mmgis-deployment/references/deployment-model.md create mode 100644 .claude/skills/mmgis-deployment/references/troubleshooting.md create mode 100755 .claude/skills/mmgis-deployment/scripts/_lib.sh create mode 100755 .claude/skills/mmgis-deployment/scripts/create.sh create mode 100755 .claude/skills/mmgis-deployment/scripts/doctor.sh create mode 100755 .claude/skills/mmgis-deployment/scripts/list.sh create mode 100755 .claude/skills/mmgis-deployment/scripts/refresh-golden.sh create mode 100755 .claude/skills/mmgis-deployment/scripts/start.sh create mode 100755 .claude/skills/mmgis-deployment/scripts/stop.sh create mode 100755 .claude/skills/mmgis-deployment/scripts/teardown.sh create mode 100755 .claude/skills/mmgis-deployment/scripts/test.sh diff --git a/.claude/skills/mmgis-deployment/SKILL.md b/.claude/skills/mmgis-deployment/SKILL.md new file mode 100644 index 000000000..5288ce245 --- /dev/null +++ b/.claude/skills/mmgis-deployment/SKILL.md @@ -0,0 +1,54 @@ +--- +name: mmgis-deployment +description: Use when deploying MMGIS locally — standing up or booting a dev instance, running several MMGIS deployments or git worktrees in parallel, tearing one down, or debugging a local deployment that won't start or serve. +--- + +# Deploying MMGIS locally + +## Overview + +A **deployment** is one MMGIS instance: a server process on a `PORT`, a database named `DB_NAME`, an `.env`, and its own `node_modules`, in one directory. All deployments share a single Postgres+PostGIS container and coexist simply by using different `PORT` and `DB_NAME` values. A git worktree is just one convenient way to get an independent directory — a separate clone, or the main checkout itself, works the same. + +Each deployment has its **own database**, so its Configure page, users, and missions are fully independent and persistent. New deployments start from a frozen baseline database (`mmgis_golden`) so they boot as a working app (admin user + a mission) with no manual setup. + +To understand the internals (the single-instance deploy and the multi-instance pattern), read `references/deployment-model.md`. + +## Prerequisites + +- The shared DB container must be running. From the main checkout: `npm run db:start`. +- `mmgis_golden` must exist. If `scripts/list.sh` shows `DB? no` everywhere or `create.sh` complains, run `scripts/refresh-golden.sh` once. + +## Routing — which script for which intent + +Scripts live in `scripts/` next to this file. Invoke them by path; each takes a deployment directory (default: current dir) or, for `create`/`teardown`, a name. + +| Intent | Command | +|--------|---------| +| Understand how local deploy works | read `references/deployment-model.md` | +| Provision a new deployment | `scripts/create.sh ` (new worktree) or `scripts/create.sh --here` | +| Boot the dashboard | `scripts/start.sh ` — prints the dashboard URL when healthy | +| Stop the server | `scripts/stop.sh ` | +| See everything running | `scripts/list.sh` | +| Diagnose a sick deployment | `scripts/doctor.sh ` | +| Run tests | `scripts/test.sh [unit\|e2e\|all]` | +| Remove a deployment | `scripts/teardown.sh ` | +| Update the baseline | `scripts/refresh-golden.sh [source-db]` | + +A typical new-feature flow: `create.sh ` → `start.sh ` → open the dashboard URL. When done: `teardown.sh `. + +## Autonomy and safety + +Run these **autonomously** when the user's intent is clear: `create`, `start`, `stop`, `list`, `doctor`, `test`. + +**Confirm with the user first** — show exactly what will change, then wait for explicit approval — before: + +- `teardown.sh` — drops a database and removes a worktree. It refuses when the deployment has uncommitted or unpushed work unless `--force`; never bypass that for the user without surfacing the warning. Show its printed plan and get a yes. +- `refresh-golden.sh` — overwrites the baseline all future deployments clone from. + +## Common mistakes + +- **Using `npm start` in a coexisting deployment.** Its `prestart` hook starts another DB container fighting for port 5432. Coexisting deployments use `start:no-docker` (which `start.sh` does). The main checkout may still use `npm start`. +- **Cloning a deployment DB from the live `mmgis` database.** Postgres can't use a connected database as a clone template. Always clone the frozen `mmgis_golden` (`create.sh` does this). +- **Expecting `refresh-golden` to update existing deployments.** It only changes the baseline for *future* clones; existing deployments keep their own databases. +- **Reaching for `FORCE_CONFIG_PATH`.** It forces one read-only mission and bypasses the Configure system — special-case only, not how normal deployments are configured. +- **Running e2e while another server holds port 8888.** Playwright would attach to it and test the wrong code. `test.sh e2e` guards against this. diff --git a/.claude/skills/mmgis-deployment/references/deployment-model.md b/.claude/skills/mmgis-deployment/references/deployment-model.md new file mode 100644 index 000000000..2958a6f2e --- /dev/null +++ b/.claude/skills/mmgis-deployment/references/deployment-model.md @@ -0,0 +1,52 @@ +# MMGIS local deployment model + +How MMGIS runs on a developer machine — both the simple single-instance case and the multi-instance pattern this skill automates. + +## What a deployment is + +A deployment is one MMGIS instance: a server process on a `PORT`, a database named `DB_NAME`, an `.env`, and its own `node_modules`, in one directory. The only backing service required is **one Postgres+PostGIS container**. STAC / TiTiler / tipg / veloserver are optional (`--profile stac`, `--profile veloserver`) and off by default — not needed for a dashboard or for tests. + +## The three run modes (from package.json) + +- **`npm start`** — has a `prestart` hook that runs `docker-compose -f docker-compose.db.yml up -d --wait` to bring up a DB container, then `node scripts/init-db.js && node scripts/server.js`. The vanilla single-machine path. +- **`npm run start:no-docker`** — `init-db.js` + `server.js` with **no** `prestart` hook (npm only fires `prestart` before `start`). Assumes a DB is already reachable. **This is what coexisting deployments use**, because the `prestart` container always binds host port 5432 and would collide when a shared container already holds it. +- **`npm run start:test`** — `NODE_ENV=test PORT=8888 node scripts/server.js`. What Playwright auto-launches for e2e. + +## Database + +- **Auto-init.** `init-db.js` creates the `$DB_NAME` database, the `postgis` + `btree_gist` extensions, the `session` table, and spatial indexes. Idempotent ("already exists → nothing to do"). +- **Auto-schema.** `server.js` runs Sequelize `.sync()`, which creates all tables on boot. **No manual migrations.** +- **Cross-branch schema.** `.sync()` reconciles *additive* changes automatically. Dropping/renaming columns across branches is the only case needing manual care. + +## Ports + +- In `NODE_ENV=development`: the API + WebSocket run on `PORT`; the **dashboard UI is served by webpack-dev-server on `PORT+1`**. `/` on `PORT` redirects to `PORT+1`. So dev uses **two** adjacent ports. +- In test/production: everything is on `PORT` (no `+1`). +- The API healthcheck is `GET /api/utils/healthcheck` on `PORT` (returns "Alive and Well!"). It comes up before webpack finishes its first compile, so the API can be healthy while the dashboard is still compiling for a few seconds. + +## Coexisting many deployments + +They differ only in `PORT` and `DB_NAME`, against one shared container: + +- **One shared Postgres container.** `docker-compose.db.yml` hardcodes host `5432:5432`, so only one can run. Start it once from the main checkout (`npm run db:start`); every deployment points at `localhost:5432` with a distinct `DB_NAME`. Coexisting deployments launch with `start:no-docker`. +- **Ports stepped by 10** (dev burns `PORT` and `PORT+1`), starting at 8888 and skipping any already in an `.env` or listening. +- **`.env` and `node_modules` are gitignored** — never present in a fresh worktree; each deployment provisions its own (`npm install --force`; `--force` is required by this dependency tree, not `--legacy-peer-deps`). + +## Config: how a deployment gets a working dashboard + +A fresh database has no admin and no missions, so the landing page would be empty. Two ways to populate it: + +- **Golden clone (default).** A frozen baseline database `mmgis_golden` holds a baseline admin (`admin`, permission `111`) and a mission. Each new deployment's database is created as `CREATE DATABASE TEMPLATE mmgis_golden`, so it boots already populated and then evolves independently. Postgres requires the template to have no active connections — that's why `mmgis_golden` is frozen (nothing connects to it) and why you must never clone from the live `mmgis` database. +- **First-run signup.** Without a golden, the `/first_signup` endpoint makes the first account created a full admin (permission `111`); you then build missions in the Configure page. + +`FORCE_CONFIG_PATH=` forces a single read-only mission from a file and **bypasses the Configure system** — a special case, not how normal deployments are configured. + +## Tests + +- **Unit (`npm run test:unit`)** — pure JS, no server, no database. Run anywhere in parallel, no collisions. +- **E2E (`npm run test:e2e`)** — Playwright auto-starts the server via `start:test`, which hardcodes `PORT=8888`, and `reuseExistingServer` is on locally. If another server is already on 8888, Playwright attaches to it and tests the wrong code. Run e2e only when 8888 is free (the `test.sh` helper enforces this). Parallel e2e across deployments would need app changes and is out of scope. + +## Gotchas + +- **CRLF in `.env`.** Some `.env` files use Windows line endings; a naive shell read yields values with a trailing `\r` (e.g. role `mmgis\r` → "role does not exist"). Always strip CR when reading `.env` in shell. +- **Stray DB containers.** Running plain `npm start` in a worktree spins up that worktree's *own* DB container (e.g. `mmgis--db-1`). These can linger (often in "Created" state) and confuse "which container is the shared one." The shared one is whichever publishes host port 5432. diff --git a/.claude/skills/mmgis-deployment/references/troubleshooting.md b/.claude/skills/mmgis-deployment/references/troubleshooting.md new file mode 100644 index 000000000..58902d311 --- /dev/null +++ b/.claude/skills/mmgis-deployment/references/troubleshooting.md @@ -0,0 +1,50 @@ +# Troubleshooting MMGIS deployments + +`doctor.sh` points here. Find the symptom, apply the fix. + +## Server won't start / healthcheck never passes + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `start.sh` times out; log shows DB connection error | Shared DB container not running | `npm run db:start` in the main checkout | +| Log: `role "mmgis\r" does not exist` or auth fails on a clean DB | CRLF in `.env` (trailing `\r` in values) | The scripts strip CR when reading `.env`; if you hand-edit, save with LF endings | +| Log: `database "" does not exist` and it isn't created | `DB_NAME` mismatch, or DB never cloned | `doctor.sh`; if DB missing, `create.sh` (clones golden) or clone manually | +| `start.sh` says "already up on port N" | A server (maybe a stale one) is on that port | `stop.sh `, or find the listener with `lsof -nP -iTCP:N -sTCP:LISTEN` | +| API healthy but dashboard (PORT+1) returns 000/connection refused | webpack-dev-server still compiling | Wait ~30–90s for the first compile; re-curl | + +## Port conflicts + +| Symptom | Cause | Fix | +|---------|-------|-----| +| Two deployments show the same PORT in `list.sh` | They were configured with the same `PORT` (e.g. both copied 8888) | Edit one deployment's `.env` to a free port (`create.sh` picks free ports automatically for new deployments) | +| `start.sh` fails immediately, port in use | Another deployment or stray process holds the port | `lsof -nP -iTCP: -sTCP:LISTEN`; stop the owner | +| Plain `npm start` failed with port 5432 in use | `npm start`'s `prestart` tried to start a second DB container | Use `start:no-docker` (what `start.sh` does); remove the stray container `docker rm mmgis--db-1` | + +## Database / golden + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `create.sh`: "mmgis_golden does not exist" | Baseline never created | `refresh-golden.sh` (snapshots `mmgis` by default) | +| Clone fails: "source database is being accessed by other users" | Tried to use a live DB as a TEMPLATE | Clone only from frozen `mmgis_golden`, never from `mmgis`; if refreshing golden, the dump/restore path avoids this | +| New deployment opens to an empty landing page | DB cloned from a golden that had no mission, or `first_signup` not done | Refresh golden from a DB that has the mission, or sign up the first admin and build a mission | +| `DROP DATABASE` fails: in use | Server still connected | `stop.sh ` first; `teardown.sh` stops the server before dropping | + +## Teardown + +| Symptom | Cause | Fix | +|---------|-------|-----| +| "refusing: uncommitted or unpushed work" | Real source changes or local-only commits in the worktree | Review the listed files/commits; commit or push them, or re-run with `--force` if you truly want to discard | +| `git worktree remove` fails: "contains modified or untracked files" | Build churn (node_modules, lockfile) in the worktree | Expected — `teardown.sh` falls back to `--force` for the worktree removal step automatically | +| Branch still exists after teardown | `teardown.sh` removes the worktree + database but leaves the branch | Delete it yourself if unwanted: `git branch -D ` | + +## Tests + +| Symptom | Cause | Fix | +|---------|-------|-----| +| e2e passes but didn't seem to test your changes | Playwright `reuseExistingServer` attached to another server on 8888 | `test.sh e2e` refuses when 8888 is busy; stop the other server and re-run | +| e2e can't start its server | Port 8888 occupied | Free it; `test.sh` reports the conflict | +| Unit tests fail to import modules | `node_modules` missing/incomplete | `npm install --force` in the deployment | + +## Stray containers + +`docker ps -a | grep postgis` may show extra `mmgis--db-1` containers from accidental `npm start` runs. The shared container is the one publishing host port 5432 (`docker ps --format '{{.Names}} {{.Ports}}'`). Remove strays: `docker rm ` (add `-f` if running). diff --git a/.claude/skills/mmgis-deployment/scripts/_lib.sh b/.claude/skills/mmgis-deployment/scripts/_lib.sh new file mode 100755 index 000000000..02e932bd8 --- /dev/null +++ b/.claude/skills/mmgis-deployment/scripts/_lib.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# _lib.sh — shared helpers for the mmgis-deployment skill scripts. +# Source this from the other scripts: `source "$(dirname "$0")/_lib.sh"`. +# +# Conventions: +# - A "deployment" is an MMGIS checkout directory with its own .env (PORT + DB_NAME). +# - State is derived from reality: `git worktree list`, each .env, and the DB container. +# - All DB work goes through the shared Postgres container via `docker exec` (no host psql needed). + +set -euo pipefail + +# Anchor for all repo discovery: this file's own location, NOT $PWD. The skill is +# tracked in the repo, so invoking any copy of these scripts must act on the repo +# that copy lives in — never on whatever repo the caller happens to be cd'd into. +MW_SCRIPTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +mw_die() { echo "error: $*" >&2; exit 1; } +mw_info() { echo " $*"; } + +# Absolute path of a deployment dir (default: current directory). +mw_resolve_dir() { + local d="${1:-$PWD}" + [ -d "$d" ] || mw_die "no such directory: $d" + (cd "$d" && pwd) +} + +# The primary working tree (where .env is templated from). Resolved from the +# scripts' own repo, so it is correct even when invoked from a worktree's copy +# of the skill or from an unrelated cwd. +mw_main_dir() { + local cdir + cdir="$(git -C "$MW_SCRIPTS_DIR" rev-parse --git-common-dir 2>/dev/null)" \ + || mw_die "skill scripts are not inside a git repo: $MW_SCRIPTS_DIR" + case "$cdir" in + /*) : ;; # already absolute + *) cdir="$MW_SCRIPTS_DIR/$cdir" ;; # make relative path absolute + esac + (cd "$(dirname "$cdir")" && pwd) +} + +# Read KEY from a .env file. Prints value (quotes stripped) or empty string. +# Always returns 0 so `var=$(mw_env_get ...)` is safe under `set -e`. +mw_env_get() { + local file="$1" key="$2" + [ -f "$file" ] || return 0 + # tr strips CR first (some .env files use CRLF line endings), then sed peels key= and quotes. + grep -E "^${key}=" "$file" 2>/dev/null | tail -n1 | tr -d '\r' | sed -E "s/^${key}=//; s/^[\"']//; s/[\"']$//" || true + return 0 +} + +# Set KEY=VAL in a .env file (update in place or append). +mw_env_set() { + local file="$1" key="$2" val="$3" tmp + if grep -qE "^${key}=" "$file" 2>/dev/null; then + tmp="$(mktemp)" + sed -E "s|^${key}=.*|${key}=${val}|" "$file" >"$tmp" && mv "$tmp" "$file" + else + printf '%s=%s\n' "$key" "$val" >>"$file" + fi +} + +# Name of the running Postgres+PostGIS container (detected, not hardcoded). +mw_db_container() { + local c + c="$(docker ps --format '{{.Names}}\t{{.Image}}' 2>/dev/null | awk -F'\t' '$2 ~ /postgis/ {print $1; exit}')" + [ -n "$c" ] || mw_die "no running postgis container found — start the shared DB (npm run db:start in the main checkout)" + echo "$c" +} + +# Load DB connection settings from a deployment's .env into MW_DB_* globals. +# Falls back to the main checkout's .env, then to mmgis/mmgis defaults. +mw_db_env() { + local envfile="${1:-}" + [ -n "$envfile" ] && [ -f "$envfile" ] || envfile="$(mw_main_dir)/.env" + MW_DB_USER="$(mw_env_get "$envfile" DB_USER)"; : "${MW_DB_USER:=mmgis}" + MW_DB_PASS="$(mw_env_get "$envfile" DB_PASS)"; : "${MW_DB_PASS:=mmgis}" + MW_DB_CONTAINER="$(mw_db_container)" +} + +# Run SQL against a database in the container. Usage: mw_psql +mw_psql() { + docker exec -e PGPASSWORD="$MW_DB_PASS" "$MW_DB_CONTAINER" \ + psql -v ON_ERROR_STOP=1 -U "$MW_DB_USER" -d "$1" -tAc "$2" +} + +# True if a database exists. +mw_db_exists() { + [ "$(mw_psql postgres "SELECT 1 FROM pg_database WHERE datname='$1'")" = "1" ] +} + +# Sanitize a deployment name into a Postgres identifier: mmgis_. +mw_db_name_for() { + local name="$1" + name="$(echo "$name" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/_/g; s/^_+//; s/_+$//')" + echo "mmgis_${name}" +} + +# True if a TCP port has a LISTEN socket. +mw_port_in_use() { lsof -nP -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1; } + +# Kill the node process(es) listening on the given ports. Prints 1 if anything +# was killed, else 0. Non-node listeners are skipped with a warning rather than +# killed — a deployment's server is always node, so anything else on the port is +# not ours to kill. +mw_kill_ports() { + local killed=0 p pid comm + for p in "$@"; do + for pid in $(lsof -tnP -iTCP:"$p" -sTCP:LISTEN 2>/dev/null || true); do + comm="$(ps -o comm= -p "$pid" 2>/dev/null || true)" + case "$comm" in + *node*) kill "$pid" 2>/dev/null && killed=1 || true ;; + *) echo " warning: leaving pid $pid on port $p alone (${comm:-unknown}, not a node process)" >&2 ;; + esac + done + done + echo "$killed" +} + +# All PORT values declared across worktree .env files (space-separated). +mw_all_env_ports() { + local line dir p + while IFS= read -r line; do + case "$line" in + "worktree "*) dir="${line#worktree }" ;; + *) continue ;; + esac + p="$(mw_env_get "$dir/.env" PORT)" + if [[ "$p" =~ ^[0-9]+$ ]]; then printf '%s ' "$p"; fi + done < <(git -C "$MW_SCRIPTS_DIR" worktree list --porcelain) + return 0 +} + +# Next free port: base 8888, stepped by 10, skipping ports used by an .env or +# currently listening (checks PORT and PORT+1 since dev uses both). +mw_next_port() { + local base=8888 used cand + used=" $(mw_all_env_ports) " + cand=$base + while :; do + if [[ "$used" != *" $cand "* ]] && ! mw_port_in_use "$cand" && ! mw_port_in_use $((cand + 1)); then + echo "$cand"; return + fi + cand=$((cand + 10)) + done +} + +# Iterate deployments. Emits "\t" per worktree that looks like MMGIS. +mw_each_deployment() { + local dir="" branch="" line + _mw_emit_dep() { + if [ -n "$dir" ] && [ -f "$dir/package.json" ]; then printf '%s\t%s\n' "$dir" "$branch"; fi + } + while IFS= read -r line; do + case "$line" in + "worktree "*) dir="${line#worktree }" ;; + "branch "*) branch="${line#branch refs/heads/}" ;; + "detached") branch="(detached)" ;; + "") _mw_emit_dep; dir=""; branch="" ;; + esac + done < <(git -C "$MW_SCRIPTS_DIR" worktree list --porcelain) + _mw_emit_dep + return 0 +} + +# Dashboard URL for a deployment (dev serves UI on PORT+1). +mw_dashboard_url() { + local port="$1" + echo "http://localhost:$((port + 1))" +} + +# Poll the API healthcheck until it returns 200 or timeout (seconds). +mw_wait_healthy() { + local port="$1" timeout="${2:-90}" url + url="http://localhost:${port}/api/utils/healthcheck" + for ((i = 0; i < timeout; i++)); do + if curl -fsS -o /dev/null "$url" 2>/dev/null; then return 0; fi + sleep 1 + done + return 1 +} + +# True if a deployment's server appears up (API port listening). +mw_server_up() { mw_port_in_use "$1"; } diff --git a/.claude/skills/mmgis-deployment/scripts/create.sh b/.claude/skills/mmgis-deployment/scripts/create.sh new file mode 100755 index 000000000..980f69c2d --- /dev/null +++ b/.claude/skills/mmgis-deployment/scripts/create.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# create.sh — provision an MMGIS deployment (idempotent; re-running skips finished steps). +# create.sh [--base ] [--branch ] # new worktree at ../MMGIS- +# create.sh --here [--name ] # provision the current directory +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +source "$HERE/_lib.sh" + +name="" base="development" branch="" here=0 +while [ $# -gt 0 ]; do + case "$1" in + --here) here=1 ;; + --base) base="$2"; shift ;; + --branch) branch="$2"; shift ;; + --name) name="$2"; shift ;; + -*) mw_die "unknown flag: $1" ;; + *) [ -z "$name" ] && name="$1" || mw_die "unexpected arg: $1" ;; + esac + shift +done + +main_dir="$(mw_main_dir)" + +if [ "$here" -eq 1 ]; then + dir="$PWD" + [ -n "$name" ] || name="$(basename "$dir")" +else + [ -n "$name" ] || mw_die "usage: create.sh [--base ref] [--branch b] | create.sh --here" + parent="$(dirname "$main_dir")" + dir="$parent/MMGIS-$name" + [ -n "$branch" ] || branch="$name" + if [ -d "$dir" ]; then + mw_info "worktree dir already exists: $dir (skipping git worktree add)" + elif git -C "$main_dir" show-ref --verify --quiet "refs/heads/$branch"; then + git -C "$main_dir" worktree add "$dir" "$branch" + else + git -C "$main_dir" worktree add -b "$branch" "$dir" "$base" + fi +fi + +dir="$(mw_resolve_dir "$dir")" +[ -f "$dir/package.json" ] || mw_die "$dir is not an MMGIS checkout" + +mw_db_env "$main_dir/.env" + +# --- .env --- +# An existing .env is the source of truth for this deployment's PORT and +# DB_NAME (they may differ from what the name argument would generate). +if [ -f "$dir/.env" ]; then + port="$(mw_env_get "$dir/.env" PORT)" + dbname="$(mw_env_get "$dir/.env" DB_NAME)" + [ -n "$port" ] || mw_die "$dir/.env has no PORT — fix it or delete it and re-run" + [ -n "$dbname" ] || mw_die "$dir/.env has no DB_NAME — fix it or delete it and re-run" + mw_info ".env exists (leaving as-is): port=$port db=$dbname" +else + [ -f "$main_dir/.env" ] || mw_die "no template .env in main checkout: $main_dir/.env" + dbname="$(mw_db_name_for "$name")" + cp "$main_dir/.env" "$dir/.env" + port="$(mw_next_port)" + mw_env_set "$dir/.env" PORT "$port" + mw_env_set "$dir/.env" DB_NAME "$dbname" + mw_env_set "$dir/.env" NODE_ENV development + mw_env_set "$dir/.env" AUTH none + mw_env_set "$dir/.env" DB_HOST localhost + mw_info "wrote .env: port=$port db=$dbname" +fi + +# --- database (clone the frozen golden; before npm install so a missing golden fails fast) --- +if mw_db_exists "$dbname"; then + mw_info "database $dbname already exists (skipping clone)" +else + mw_db_exists mmgis_golden || mw_die "mmgis_golden does not exist — run refresh-golden.sh first" + mw_psql postgres "CREATE DATABASE \"$dbname\" TEMPLATE mmgis_golden" + mw_info "cloned mmgis_golden -> $dbname" +fi + +# --- node_modules --- +if [ -d "$dir/node_modules" ]; then + mw_info "node_modules present (skipping npm install)" +else + mw_info "running npm install --force (takes a few minutes)..." + (cd "$dir" && npm install --force) +fi + +echo +echo "ready: $dir" +echo " port $port · db $dbname · dashboard $(mw_dashboard_url "$port") (after start)" +echo " start it: $HERE/start.sh \"$dir\"" diff --git a/.claude/skills/mmgis-deployment/scripts/doctor.sh b/.claude/skills/mmgis-deployment/scripts/doctor.sh new file mode 100755 index 000000000..742c34b2b --- /dev/null +++ b/.claude/skills/mmgis-deployment/scripts/doctor.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# doctor.sh — diagnose a deployment. Read-only. +# doctor.sh [dir] (default: current directory) +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +source "$HERE/_lib.sh" + +dir="$(mw_resolve_dir "${1:-$PWD}")" +echo "doctor: $dir" +ok() { echo " [ok] $*"; } +bad() { echo " [FAIL] $*"; } +warn() { echo " [warn] $*"; } + +if c="$(mw_db_container 2>/dev/null)"; then ok "db container: $c"; else bad "no postgis container running — 'npm run db:start' in the main checkout"; fi +mw_db_env "$dir/.env" 2>/dev/null || true + +[ -f "$dir/.env" ] && ok ".env present" || bad ".env missing — run create.sh" +port="$(mw_env_get "$dir/.env" PORT)" +db="$(mw_env_get "$dir/.env" DB_NAME)" +[ -n "$port" ] && ok "PORT=$port" || bad "PORT not set in .env" +[ -n "$db" ] && ok "DB_NAME=$db" || bad "DB_NAME not set in .env" +[ -d "$dir/node_modules" ] && ok "node_modules present" || bad "node_modules missing — npm install --force" + +if [ -n "${MW_DB_CONTAINER:-}" ]; then + if mw_psql postgres "SELECT 1" >/dev/null 2>&1; then ok "db reachable"; else bad "cannot connect to postgres in container"; fi + if [ -n "$db" ]; then mw_db_exists "$db" && ok "database $db exists" || bad "database $db missing — create.sh clones it from golden"; fi +fi + +if [ -n "$port" ]; then + if mw_server_up "$port"; then + if mw_wait_healthy "$port" 3; then ok "server up + healthcheck passing ($(mw_dashboard_url "$port"))"; else warn "port $port listening but healthcheck failing — check the log below"; fi + else + echo " [info] server not running — start.sh to boot" + fi +fi + +if [ -f "$dir/.mmgis/server.log" ]; then + echo " --- last 15 log lines ---" + tail -n 15 "$dir/.mmgis/server.log" | sed 's/^/ /' +fi +echo " Fixes: references/troubleshooting.md" diff --git a/.claude/skills/mmgis-deployment/scripts/list.sh b/.claude/skills/mmgis-deployment/scripts/list.sh new file mode 100755 index 000000000..5b19845b0 --- /dev/null +++ b/.claude/skills/mmgis-deployment/scripts/list.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# list.sh — show every MMGIS deployment with its port, database, and server status. +# All state is derived from reality (git worktrees, each .env, the DB container). +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +source "$HERE/_lib.sh" + +mw_db_env +printf '%-26s %-30s %-6s %-22s %-8s %s\n' DIR BRANCH PORT DB DB? SERVER +while IFS=$'\t' read -r dir branch; do + port="$(mw_env_get "$dir/.env" PORT)"; [ -n "$port" ] || port="-" + db="$(mw_env_get "$dir/.env" DB_NAME)"; [ -n "$db" ] || db="-" + dbex="no"; if [ "$db" != "-" ] && mw_db_exists "$db"; then dbex="yes"; fi + srv="down"; if [ "$port" != "-" ] && mw_server_up "$port"; then srv="up"; fi + printf '%-26s %-30s %-6s %-22s %-8s %s\n' "$(basename "$dir")" "$branch" "$port" "$db" "$dbex" "$srv" +done < <(mw_each_deployment) diff --git a/.claude/skills/mmgis-deployment/scripts/refresh-golden.sh b/.claude/skills/mmgis-deployment/scripts/refresh-golden.sh new file mode 100755 index 000000000..079791303 --- /dev/null +++ b/.claude/skills/mmgis-deployment/scripts/refresh-golden.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# refresh-golden.sh — (re)create the frozen mmgis_golden baseline from a source database. +# refresh-golden.sh [source-db] (default: mmgis) +# Only affects FUTURE deployments; existing ones keep their own databases. +# NOTE: the agent must confirm with the user before running (it overwrites the baseline). +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +source "$HERE/_lib.sh" + +src="${1:-mmgis}" +mw_db_env +case "$src" in mmgis_golden|mmgis_golden_new) mw_die "source cannot be $src" ;; esac +mw_db_exists "$src" || mw_die "source database '$src' does not exist" + +echo "Refreshing mmgis_golden from '$src'." +echo "(Only affects future deployments; existing deployments keep their own databases.)" + +# Restore into a staging database first and swap only on success, so a failed +# dump/restore can never leave mmgis_golden existing-but-broken (create.sh only +# checks existence before cloning it). +staging="mmgis_golden_new" +if mw_db_exists "$staging"; then mw_psql postgres "DROP DATABASE $staging WITH (FORCE)"; fi +mw_psql postgres "CREATE DATABASE $staging" + +docker exec -e PGPASSWORD="$MW_DB_PASS" "$MW_DB_CONTAINER" pg_dump -U "$MW_DB_USER" "$src" \ + | docker exec -i -e PGPASSWORD="$MW_DB_PASS" "$MW_DB_CONTAINER" psql -q -v ON_ERROR_STOP=1 -U "$MW_DB_USER" -d "$staging" >/dev/null + +if mw_db_exists mmgis_golden; then mw_psql postgres "DROP DATABASE mmgis_golden WITH (FORCE)"; fi +mw_psql postgres "ALTER DATABASE $staging RENAME TO mmgis_golden" + +echo "mmgis_golden ready (snapshot of $src)." diff --git a/.claude/skills/mmgis-deployment/scripts/start.sh b/.claude/skills/mmgis-deployment/scripts/start.sh new file mode 100755 index 000000000..0e05efca6 --- /dev/null +++ b/.claude/skills/mmgis-deployment/scripts/start.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# start.sh — start a deployment's dev server in the background and wait for healthcheck. +# start.sh [dir] (default: current directory) +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +source "$HERE/_lib.sh" + +dir="$(mw_resolve_dir "${1:-$PWD}")" +[ -f "$dir/.env" ] || mw_die "no .env in $dir — run create.sh first" +port="$(mw_env_get "$dir/.env" PORT)" +[ -n "$port" ] || mw_die "no PORT in $dir/.env" + +if mw_server_up "$port"; then + mw_die "server already listening on port $port ($(mw_dashboard_url "$port"))" +fi + +mkdir -p "$dir/.mmgis" +# Use start:no-docker so we never fight the shared DB container for port 5432. +# No pid file: npm forks node children, so stop.sh kills by port instead. +( cd "$dir" && nohup npm run start:no-docker >"$dir/.mmgis/server.log" 2>&1 & ) + +echo "starting server (port $port) — waiting for healthcheck..." +if mw_wait_healthy "$port" 120; then + echo "up: dashboard $(mw_dashboard_url "$port") · api http://localhost:$port" +else + echo "healthcheck did not pass within timeout. Last log lines:" >&2 + tail -n 30 "$dir/.mmgis/server.log" >&2 || true + exit 1 +fi diff --git a/.claude/skills/mmgis-deployment/scripts/stop.sh b/.claude/skills/mmgis-deployment/scripts/stop.sh new file mode 100755 index 000000000..6a2daac5d --- /dev/null +++ b/.claude/skills/mmgis-deployment/scripts/stop.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# stop.sh — stop a deployment's dev server (kills the node processes on its PORT and PORT+1). +# stop.sh [dir] (default: current directory) +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +source "$HERE/_lib.sh" + +dir="$(mw_resolve_dir "${1:-$PWD}")" +port="$(mw_env_get "$dir/.env" PORT)" +[ -n "$port" ] || mw_die "no PORT in $dir/.env" + +killed="$(mw_kill_ports "$port" $((port + 1)))" + +if [ "$killed" -eq 1 ]; then + echo "stopped server for $dir (ports $port/$((port + 1)))" +else + echo "no server was listening for $dir" +fi diff --git a/.claude/skills/mmgis-deployment/scripts/teardown.sh b/.claude/skills/mmgis-deployment/scripts/teardown.sh new file mode 100755 index 000000000..a354065cb --- /dev/null +++ b/.claude/skills/mmgis-deployment/scripts/teardown.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# teardown.sh — remove a deployment: stop server, drop its database, remove the worktree. +# teardown.sh [--force] +# Safety: refuses if the deployment has uncommitted or unpushed work, unless --force. +# Never touches the main checkout or the protected databases (mmgis, mmgis_golden, mmgis-stac). +# NOTE: the agent must show this plan and get explicit user confirmation before running. +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +source "$HERE/_lib.sh" + +force=0 target="" +while [ $# -gt 0 ]; do + case "$1" in + --force) force=1 ;; + *) target="$1" ;; + esac + shift +done +[ -n "$target" ] || mw_die "usage: teardown.sh [--force]" + +main_dir="$(mw_main_dir)" +if [ -d "$target" ]; then dir="$(mw_resolve_dir "$target")"; else dir="$(dirname "$main_dir")/MMGIS-$target"; fi +[ -d "$dir" ] || mw_die "no such deployment dir: $dir" +[ "$dir" != "$main_dir" ] || mw_die "refusing to tear down the main checkout" + +db="$(mw_env_get "$dir/.env" DB_NAME)" +port="$(mw_env_get "$dir/.env" PORT)" +mw_db_env "$main_dir/.env" + +is_wt=0 +git -C "$main_dir" worktree list --porcelain | grep -qx "worktree $dir" && is_wt=1 + +# Ignore known churn that normal operation produces (not source work worth protecting): +# our runtime dir, the install lockfile, and the dev-server-regenerated tool config. +churn='\.mmgis/|package-lock\.json|configure/public/toolConfigs\.json' +dirty="$(git -C "$dir" status --porcelain 2>/dev/null | grep -vE "$churn" || true)" +# unpushed = commits unique to THIS worktree's HEAD not yet on its upstream — or, if the +# branch was never pushed, commits beyond the integration branch (MW_MAIN_BRANCH, default +# development). Scoped to this worktree so a shared, unpushed base branch doesn't false-positive. +main_branch="${MW_MAIN_BRANCH:-development}" +if up="$(git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null)"; then + cnt="$(git -C "$dir" rev-list --count "${up}..HEAD" 2>/dev/null || echo 0)" +else + cnt="$(git -C "$dir" rev-list --count "${main_branch}..HEAD" 2>/dev/null || echo 0)" +fi +cnt="${cnt//[^0-9]/}"; cnt="${cnt:-0}" +unpushed=""; [ "$cnt" -gt 0 ] && unpushed="$cnt commit(s) not pushed/merged" + +echo "teardown plan for $dir:" +echo " drop database : ${db:-}" +echo " remove worktree : $([ $is_wt -eq 1 ] && echo yes || echo 'no (not a worktree)')" +if [ -n "$port" ]; then echo " stop server on : $port/$((port + 1))"; else echo " stop server on : "; fi +if [ -n "$dirty" ]; then echo " WARNING: uncommitted changes:"; echo "$dirty" | sed 's/^/ /'; fi +[ -n "$unpushed" ] && echo " WARNING: $unpushed" + +if [ "$force" -ne 1 ] && { [ -n "$dirty" ] || [ -n "$unpushed" ]; }; then + mw_die "uncommitted or unpushed work present — re-run with --force to override." +fi +case "$db" in + mmgis|mmgis_golden|mmgis-stac|"") [ -z "$db" ] || mw_die "refusing to drop protected database: $db" ;; +esac + +# stop server +if [ -n "$port" ]; then + mw_kill_ports "$port" $((port + 1)) >/dev/null +fi +# drop database +if [ -n "$db" ] && mw_db_exists "$db"; then + mw_psql postgres "DROP DATABASE \"$db\" WITH (FORCE)" + echo "dropped database $db" +fi +# remove worktree +if [ "$is_wt" -eq 1 ]; then + # plain string flag (not an array) — macOS bash 3.2 errors on empty "${arr[@]}" under set -u + fflag=""; [ "$force" -eq 1 ] && fflag="--force" + git -C "$main_dir" worktree remove $fflag "$dir" || git -C "$main_dir" worktree remove --force "$dir" + echo "removed worktree $dir" +fi +echo "teardown complete." diff --git a/.claude/skills/mmgis-deployment/scripts/test.sh b/.claude/skills/mmgis-deployment/scripts/test.sh new file mode 100755 index 000000000..318dbaab7 --- /dev/null +++ b/.claude/skills/mmgis-deployment/scripts/test.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# test.sh — run a deployment's tests safely. +# test.sh [dir] [unit|e2e|all] (default dir: current; default mode: all) +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +source "$HERE/_lib.sh" + +dir="$PWD" mode="all" +for a in "$@"; do + case "$a" in unit|e2e|all) mode="$a" ;; *) dir="$a" ;; esac +done +dir="$(mw_resolve_dir "$dir")" +[ -f "$dir/package.json" ] || mw_die "not an MMGIS checkout: $dir" + +run_unit() { ( cd "$dir" && npm run test:unit ); } +run_e2e() { + # E2E auto-starts the server via `npm run start:test`, which hardcodes PORT=8888. + # If something is already listening on 8888, Playwright's reuseExistingServer would + # attach to THAT server and silently test the wrong code. Fail fast instead. + if mw_port_in_use 8888; then + mw_die "port 8888 is in use — e2e would attach to that server and test the wrong code. Stop it first. (Parallel e2e across deployments needs app changes; out of scope.)" + fi + ( cd "$dir" && npm run test:e2e ) +} + +case "$mode" in + unit) run_unit ;; + e2e) run_e2e ;; + all) run_unit; run_e2e ;; +esac diff --git a/.gitignore b/.gitignore index a2701073d..5f70cf4d6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ .env nul +# mmgis-deployment skill: per-deployment runtime files (server log) +.mmgis/ + /node_modules/ /dist/ /ssl/* @@ -51,7 +54,8 @@ sessions .mcp.json .serena -.claude +.claude/* +!.claude/skills/ .playwright-mcp # Playwright Test Artifacts From c13a831862041bd0cefc5722bd47327b22a3fc47 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Tue, 9 Jun 2026 15:20:43 -0500 Subject: [PATCH 02/63] Add seed-golden bootstrap: build mmgis_golden from committed baseline Distributes the deployment baseline as a recipe instead of a database: seed/baseline-mission.json (sanitized mission config, token placeholder) plus seed-golden.sh, which boots a temporary server against a scratch DB and seeds an admin + the baseline mission via MMGIS's own APIs (first_signup -> login -> configure/add), then renames it to mmgis_golden. MAPBOX_TOKEN is injected from the environment at seed time; tokens are never committed. --- .claude/skills/mmgis-deployment/SKILL.md | 9 +- .../references/deployment-model.md | 7 + .../references/troubleshooting.md | 3 +- .../skills/mmgis-deployment/scripts/create.sh | 2 +- .../mmgis-deployment/scripts/seed-golden.sh | 96 ++++++ .../seed/baseline-mission.json | 296 ++++++++++++++++++ sample.env | 6 +- 7 files changed, 412 insertions(+), 7 deletions(-) create mode 100755 .claude/skills/mmgis-deployment/scripts/seed-golden.sh create mode 100644 .claude/skills/mmgis-deployment/seed/baseline-mission.json diff --git a/.claude/skills/mmgis-deployment/SKILL.md b/.claude/skills/mmgis-deployment/SKILL.md index 5288ce245..b6ec1a504 100644 --- a/.claude/skills/mmgis-deployment/SKILL.md +++ b/.claude/skills/mmgis-deployment/SKILL.md @@ -16,7 +16,7 @@ To understand the internals (the single-instance deploy and the multi-instance p ## Prerequisites - The shared DB container must be running. From the main checkout: `npm run db:start`. -- `mmgis_golden` must exist. If `scripts/list.sh` shows `DB? no` everywhere or `create.sh` complains, run `scripts/refresh-golden.sh` once. +- `mmgis_golden` must exist. On a fresh machine run `scripts/seed-golden.sh` once — it builds the baseline (admin/admin + the `arst` mission) from the committed seed via a temporary server; no existing database needed. Set `MAPBOX_TOKEN` (env or main `.env`) first if you want basemaps to render — tokens are never committed to git. `scripts/refresh-golden.sh` instead re-snapshots the baseline from a live database you already have. ## Routing — which script for which intent @@ -32,18 +32,19 @@ Scripts live in `scripts/` next to this file. Invoke them by path; each takes a | Diagnose a sick deployment | `scripts/doctor.sh ` | | Run tests | `scripts/test.sh [unit\|e2e\|all]` | | Remove a deployment | `scripts/teardown.sh ` | -| Update the baseline | `scripts/refresh-golden.sh [source-db]` | +| Bootstrap the baseline on a fresh machine | `scripts/seed-golden.sh` | +| Re-baseline from a live DB | `scripts/refresh-golden.sh [source-db]` | A typical new-feature flow: `create.sh ` → `start.sh ` → open the dashboard URL. When done: `teardown.sh `. ## Autonomy and safety -Run these **autonomously** when the user's intent is clear: `create`, `start`, `stop`, `list`, `doctor`, `test`. +Run these **autonomously** when the user's intent is clear: `create`, `start`, `stop`, `list`, `doctor`, `test`, and `seed-golden` when no golden exists (it refuses to overwrite one without `--force`). **Confirm with the user first** — show exactly what will change, then wait for explicit approval — before: - `teardown.sh` — drops a database and removes a worktree. It refuses when the deployment has uncommitted or unpushed work unless `--force`; never bypass that for the user without surfacing the warning. Show its printed plan and get a yes. -- `refresh-golden.sh` — overwrites the baseline all future deployments clone from. +- `refresh-golden.sh`, or `seed-golden.sh --force` — both overwrite the baseline all future deployments clone from. ## Common mistakes diff --git a/.claude/skills/mmgis-deployment/references/deployment-model.md b/.claude/skills/mmgis-deployment/references/deployment-model.md index 2958a6f2e..203a9e280 100644 --- a/.claude/skills/mmgis-deployment/references/deployment-model.md +++ b/.claude/skills/mmgis-deployment/references/deployment-model.md @@ -39,6 +39,13 @@ A fresh database has no admin and no missions, so the landing page would be empt - **Golden clone (default).** A frozen baseline database `mmgis_golden` holds a baseline admin (`admin`, permission `111`) and a mission. Each new deployment's database is created as `CREATE DATABASE TEMPLATE mmgis_golden`, so it boots already populated and then evolves independently. Postgres requires the template to have no active connections — that's why `mmgis_golden` is frozen (nothing connects to it) and why you must never clone from the live `mmgis` database. - **First-run signup.** Without a golden, the `/first_signup` endpoint makes the first account created a full admin (permission `111`); you then build missions in the Configure page. +Where the golden itself comes from — it lives only in the local Docker volume, so each machine builds its own: + +- **Seed (fresh machine).** `seed-golden.sh` builds it from the committed `seed/baseline-mission.json`: it boots a temporary server against a scratch database (Sequelize creates the schema), seeds an admin + the baseline mission through MMGIS's own APIs (`first_signup` → `login` → `/api/configure/add`), then renames the scratch DB to `mmgis_golden`. The basemap token is injected at seed time from `MAPBOX_TOKEN` (env or the main checkout's `.env`) — **tokens are never committed to git**; the seed file holds a `{{MAPBOX_TOKEN}}` placeholder. +- **Snapshot (existing machine).** `refresh-golden.sh` re-baselines from a live database via `pg_dump` (default source: `mmgis`). + +Note the golden does **not** survive `docker-compose down -v` or deleting the `mmgis_mmgis-local-db` volume — that wipes every database. Re-seed or re-snapshot afterward. + `FORCE_CONFIG_PATH=` forces a single read-only mission from a file and **bypasses the Configure system** — a special case, not how normal deployments are configured. ## Tests diff --git a/.claude/skills/mmgis-deployment/references/troubleshooting.md b/.claude/skills/mmgis-deployment/references/troubleshooting.md index 58902d311..42c5b46d2 100644 --- a/.claude/skills/mmgis-deployment/references/troubleshooting.md +++ b/.claude/skills/mmgis-deployment/references/troubleshooting.md @@ -24,7 +24,8 @@ | Symptom | Cause | Fix | |---------|-------|-----| -| `create.sh`: "mmgis_golden does not exist" | Baseline never created | `refresh-golden.sh` (snapshots `mmgis` by default) | +| `create.sh`: "mmgis_golden does not exist" | Baseline never created on this machine | `seed-golden.sh` (builds from the committed seed — works on a fresh machine) or `refresh-golden.sh` (snapshots a live `mmgis`) | +| Basemap blank in a fresh deployment | Golden was seeded without `MAPBOX_TOKEN` | Set `MAPBOX_TOKEN` and `seed-golden.sh --force`, or paste a token into the mission's basemap settings in Configure | | Clone fails: "source database is being accessed by other users" | Tried to use a live DB as a TEMPLATE | Clone only from frozen `mmgis_golden`, never from `mmgis`; if refreshing golden, the dump/restore path avoids this | | New deployment opens to an empty landing page | DB cloned from a golden that had no mission, or `first_signup` not done | Refresh golden from a DB that has the mission, or sign up the first admin and build a mission | | `DROP DATABASE` fails: in use | Server still connected | `stop.sh ` first; `teardown.sh` stops the server before dropping | diff --git a/.claude/skills/mmgis-deployment/scripts/create.sh b/.claude/skills/mmgis-deployment/scripts/create.sh index 980f69c2d..48c0f4caa 100755 --- a/.claude/skills/mmgis-deployment/scripts/create.sh +++ b/.claude/skills/mmgis-deployment/scripts/create.sh @@ -69,7 +69,7 @@ fi if mw_db_exists "$dbname"; then mw_info "database $dbname already exists (skipping clone)" else - mw_db_exists mmgis_golden || mw_die "mmgis_golden does not exist — run refresh-golden.sh first" + mw_db_exists mmgis_golden || mw_die "mmgis_golden does not exist — run seed-golden.sh (builds it from the committed baseline) or refresh-golden.sh (snapshots a live DB)" mw_psql postgres "CREATE DATABASE \"$dbname\" TEMPLATE mmgis_golden" mw_info "cloned mmgis_golden -> $dbname" fi diff --git a/.claude/skills/mmgis-deployment/scripts/seed-golden.sh b/.claude/skills/mmgis-deployment/scripts/seed-golden.sh new file mode 100755 index 000000000..f6d0d2b40 --- /dev/null +++ b/.claude/skills/mmgis-deployment/scripts/seed-golden.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# seed-golden.sh — build mmgis_golden from the committed seed (no existing database needed). +# seed-golden.sh [--force] +# For fresh machines: boots a temporary server from the main checkout against a scratch +# database, seeds an admin user + the baseline mission through MMGIS's own APIs +# (first_signup → login → configure/add), then freezes the result as mmgis_golden. +# To re-baseline from your live mmgis database instead, use refresh-golden.sh. +# +# Env: +# MAPBOX_TOKEN basemap token injected into the seed config (never committed to git; +# falls back to MAPBOX_TOKEN in the main checkout's .env, else empty) +# MW_SEED_ADMIN_USER seeded admin username (default: admin) +# MW_SEED_ADMIN_PASS seeded admin password (default: admin) +# MW_GOLDEN_DB target database name (default: mmgis_golden) +# NOTE: the agent must confirm with the user before running with --force (overwrites the baseline). +set -euo pipefail +HERE="$(cd "$(dirname "$0")" && pwd)" +source "$HERE/_lib.sh" + +force=0 +[ "${1:-}" = "--force" ] && force=1 + +target="${MW_GOLDEN_DB:-mmgis_golden}" +seed_json="$HERE/../seed/baseline-mission.json" +[ -f "$seed_json" ] || mw_die "seed file missing: $seed_json" + +main_dir="$(mw_main_dir)" +[ -d "$main_dir/node_modules" ] || mw_die "main checkout has no node_modules — run npm install --force in $main_dir first" +mw_db_env "$main_dir/.env" + +if mw_db_exists "$target" && [ "$force" -ne 1 ]; then + mw_die "$target already exists — use refresh-golden.sh to re-baseline from a live DB, or --force to overwrite with the committed seed" +fi + +admin_user="${MW_SEED_ADMIN_USER:-admin}" +admin_pass="${MW_SEED_ADMIN_PASS:-admin}" +token="${MAPBOX_TOKEN:-$(mw_env_get "$main_dir/.env" MAPBOX_TOKEN)}" +[ -n "$token" ] || echo "warning: MAPBOX_TOKEN not set (env or main .env) — basemaps won't render until a token is added in Configure" >&2 + +scratch="${target}_seed" +port="$(mw_next_port)" +log="$main_dir/.mmgis/seed-server.log" +mkdir -p "$main_dir/.mmgis" + +cleanup() { mw_kill_ports "$port" >/dev/null 2>&1 || true; } +trap cleanup EXIT + +if mw_db_exists "$scratch"; then mw_psql postgres "DROP DATABASE \"$scratch\" WITH (FORCE)"; fi + +echo "seeding $target via a temporary server (port $port, scratch db $scratch)..." + +# init-db creates the scratch DB + extensions; the server's Sequelize sync creates the +# tables on boot. Real env vars beat .env values (dotenv never overrides existing env), +# so the main checkout can be reused safely. AUTH=none matches deployment behavior; +# NODE_ENV=test serves the API without webpack. +( cd "$main_dir" && DB_NAME="$scratch" PORT="$port" NODE_ENV=test AUTH=none node scripts/init-db.js >>"$log" 2>&1 ) +( cd "$main_dir" && DB_NAME="$scratch" PORT="$port" NODE_ENV=test AUTH=none nohup node scripts/server.js >>"$log" 2>&1 & ) + +mw_wait_healthy "$port" 60 || { tail -n 20 "$log" >&2; mw_die "temporary seed server never became healthy — see $log"; } + +base="http://localhost:$port" +jar="$(mktemp)" +api() { curl -fsS -X POST -H 'Content-Type: application/json' -b "$jar" -c "$jar" -d "$2" "$base$1"; } + +# Sequelize sync can finish a beat after the healthcheck passes; retry signup briefly. +ok=0 out="" +for _ in 1 2 3 4 5 6 7 8 9 10; do + out="$(api /api/users/first_signup "{\"username\":\"$admin_user\",\"password\":\"$admin_pass\"}" || true)" + case "$out" in *'"status":"success"'*) ok=1; break ;; esac + sleep 2 +done +[ "$ok" -eq 1 ] || mw_die "first_signup failed: ${out:-no response}" + +out="$(api /api/users/login "{\"username\":\"$admin_user\",\"password\":\"$admin_pass\"}")" +case "$out" in *'"status":"success"'*) : ;; *) mw_die "login failed: $out" ;; esac + +# Inject the token and POST the baseline mission. add() deep-merges the provided config +# into MMGIS's mission template and sets msv.mission/missionFolderName itself. +# (Mapbox pk. tokens are [A-Za-z0-9._-], so plain sed substitution is safe.) +payload="$(mktemp)" +{ printf '{"mission":"arst","config":'; sed "s|{{MAPBOX_TOKEN}}|${token}|" "$seed_json"; printf '}'; } >"$payload" +out="$(curl -fsS -X POST -H 'Content-Type: application/json' -b "$jar" -d @"$payload" "$base/api/configure/add")" +rm -f "$payload" "$jar" +case "$out" in *'"status":"success"'*) : ;; *) mw_die "mission add failed: $out" ;; esac + +mw_kill_ports "$port" >/dev/null +# Let the server's DB connections drain so the rename below can take the lock. +for _ in 1 2 3 4 5; do + [ "$(mw_psql postgres "SELECT count(*) FROM pg_stat_activity WHERE datname='$scratch'")" = "0" ] && break + sleep 1 +done + +if mw_db_exists "$target"; then mw_psql postgres "DROP DATABASE \"$target\" WITH (FORCE)"; fi +mw_psql postgres "ALTER DATABASE \"$scratch\" RENAME TO \"$target\"" + +echo "$target ready (admin: $admin_user/$admin_pass · mission: arst)." diff --git a/.claude/skills/mmgis-deployment/seed/baseline-mission.json b/.claude/skills/mmgis-deployment/seed/baseline-mission.json new file mode 100644 index 000000000..d690ca07a --- /dev/null +++ b/.claude/skills/mmgis-deployment/seed/baseline-mission.json @@ -0,0 +1,296 @@ +{ + "msv": { + "mission": "arst", + "missionFolderName": "arst", + "site": "", + "masterdb": false, + "view": [ + "0", + "0", + "0" + ], + "radius": { + "major": 6371008, + "minor": 6371008 + }, + "mapscale": "", + "mapEngine": "deckgl", + "mode": "modern", + "basemap": { + "provider": "mapbox", + "style": "mapbox://styles/mapbox/streets-v12", + "accessToken": "{{MAPBOX_TOKEN}}" + }, + "theme": "disasters" + }, + "projection": { + "custom": false, + "epsg": "", + "proj": "", + "globeproj": "webmercator", + "xmlpath": "", + "bounds": [ + "", + "", + "", + "" + ], + "origin": [ + "", + "" + ], + "reszoomlevel": "", + "resunitsperpixel": "" + }, + "look": { + "pagename": "Disasters Program", + "minimalist": false, + "topbar": true, + "toolbar": true, + "scalebar": true, + "coordinates": true, + "zoomcontrol": false, + "graticule": false, + "miscellaneous": true, + "bodycolor": "", + "topbarcolor": "", + "toolbarcolor": "", + "mapcolor": "", + "swap": true, + "copylink": true, + "screenshot": true, + "fullscreen": true, + "help": true, + "logourl": "", + "helpurl": "", + "mapLogoUrl": "" + }, + "panelSettings": { + "panels": [ + { + "position": "left", + "priority": 0, + "layoutType": "stacked", + "hasHeader": false, + "stateConstraints": { + "allowedStates": [ + "expanded" + ], + "defaultState": "expanded" + }, + "capabilities": { + "resizable": false + }, + "panelTools": [], + "id": "left" + } + ] + }, + "panels": { + "viewer": true, + "map": true, + "globe": true + }, + "time": { + "enabled": false + }, + "tools": [ + { + "name": "Layers", + "icon": "layers", + "js": "LayersTool", + "on": false, + "variables": { + "expanded": false + } + }, + { + "name": "Legend", + "icon": "format-list-bulleted-type", + "js": "LegendTool", + "on": false, + "separatedTool": true, + "variables": { + "displayOnStart": false, + "justification": "left", + "showHeadersInLegend": false + } + }, + { + "name": "Info", + "icon": "information-variant", + "js": "InfoTool", + "on": false, + "variables": { + "sortAlphabetically": false + } + }, + { + "on": true, + "name": "Title", + "icon": "information-variant", + "js": "TitleTool" + }, + { + "on": true, + "name": "LayerManager", + "icon": "layers-triple-outline", + "js": "LayerManagerTool", + "variables": { + "displayOnStart": false, + "showOnlyVisible": false + } + }, + { + "on": false, + "name": "Chart", + "icon": "chart-bar", + "js": "ChartTool" + }, + { + "on": true, + "name": "AOI", + "icon": "selection-drag", + "js": "AOITool", + "separatedTool": true, + "variables": { + "justification": "left" + } + }, + { + "on": true, + "name": "MapControl", + "icon": "layers", + "js": "MapControlTool", + "variables": { + "showBasemapSwitcher": true, + "showSearch": true, + "showMeasure": true, + "showZoom": true + } + }, + { + "on": true, + "name": "Card", + "icon": "card-text-outline", + "js": "CardTool", + "variables": { + "cards": [ + { + "title": "arstarst", + "subtitle": "asrtarst", + "linkUrl": "www.google.com", + "image": "CardPlugin/uploads/7bc16cae-68f5-44f4-a8ce-83a137bbf786.jpg" + }, + { + "title": "Seeing Beyond the Flames: Mapping Wildfire Impacts in Southern California", + "subtitle": "Story of Impact", + "linkUrl": "www.google.com", + "image": "CardPlugin/uploads/da9cf3d3-6782-43b7-9729-cd2552a9b12a.jpg" + } + ] + } + } + ], + "layers": [ + { + "name": "Sentinel2-Colorir-Daily", + "uuid": "0f2b776e-8fa0-47a7-8d72-dddf12694e2c", + "sublayers": [], + "type": "TileLayer", + "visibility": false, + "sourceType": "url", + "url": "https://dev.disasters.openveda.cloud/api/raster/collections/sentinel2-colorir-daily/items/sentinel2-colorir-daily-NW_CentralTX_S2C_colorInfrared_merged_2025-07-10_day/tiles/WebMercatorQuad/{z}/{x}/{y}@1x?bidx=1&bidx=2&bidx=3&assets=colorir&nodata=0", + "tileformat": "tms", + "controlled": false, + "minZoom": 0, + "maxNativeZoom": 20, + "maxZoom": 20, + "demparser": "rgba", + "throughTileServer": false, + "cogResampling": "nearest", + "cogExpressionEditable": false, + "cogTransform": false, + "cogColormap": "blues", + "style": { + "brightness": 1, + "contrast": 1, + "saturation": 1, + "blend": "none" + }, + "time": { + "enabled": false, + "type": "requery", + "compositeTile": false, + "refreshIntervalEnabled": false + }, + "variables": { + "legendOrientation": "vertical", + "analysis": { + "is_analysis_supported": true, + "itemUrl": "https://dev.disasters.openveda.cloud/api/raster/collections/sentinel2-colorir-daily/items/sentinel2-colorir-daily-NW_CentralTX_S2C_colorInfrared_merged_2025-07-10_day", + "assets": [ + "colorir" + ], + "bidx": [ + 1, + 2, + 3 + ], + "nodata": 0 + } + }, + "initialOpacity": 1 + }, + { + "name": "Sentinel-2 dNBR", + "uuid": "5a1b2c3d-4e5f-4789-9abc-def012345678", + "sublayers": [], + "type": "TileLayer", + "visibility": false, + "sourceType": "url", + "url": "https://dev.disasters.openveda.cloud/api/raster/cog/tiles/WebMercatorQuad/{z}/{x}/{y}@1x?url=s3://nasa-disasters-staging/ProgramData/Sentinel-2/dNBR/s2_dnbr_dNBR_2025-01-12_day.tif&nodata=-9999&rescale=-1,1&colormap_name=gray&resampling=bilinear", + "tileformat": "wms", + "controlled": false, + "minZoom": 0, + "maxNativeZoom": 20, + "maxZoom": 20, + "demparser": "rgba", + "throughTileServer": false, + "cogResampling": "nearest", + "cogExpressionEditable": false, + "cogTransform": false, + "cogColormap": "gray", + "style": { + "brightness": 1, + "contrast": 1, + "saturation": 1, + "blend": "none" + }, + "time": { + "enabled": true, + "type": "requery", + "format": "%Y-%m-%d", + "compositeTile": false, + "refreshIntervalEnabled": false, + "dataStartTime": "2015-06-23T00:00:00Z", + "dataEndTime": "2025-12-31T23:59:59Z" + }, + "variables": { + "legendOrientation": "vertical", + "analysis": { + "is_analysis_supported": true, + "itemUrl": "https://dev.disasters.openveda.cloud/api/raster/collections/sentinel2-dnbr-daily/items/sentinel2-dnbr-daily-s2_dnbr_dNBR_2025-01-12_day", + "assets": [ + "dnbr" + ], + "bidx": [ + 1 + ], + "nodata": -9999 + } + }, + "initialOpacity": 1 + } + ] +} \ No newline at end of file diff --git a/sample.env b/sample.env index b97176e1e..577369bd9 100644 --- a/sample.env +++ b/sample.env @@ -168,4 +168,8 @@ SPICE_SCHEDULED_KERNEL_CRON_EXPR= # When using composited time tiles, MMGIS queries the tileset's folder for existing time folders. # It caches the results of the these folder listings every COMPOSITE_TILE_DIR_STORE_MAX_AGE_MS milliseconds # defaults to requerying every 30 minutes. If 0, no caching. If null or NaN, uses default. -COMPOSITE_TILE_DIR_STORE_MAX_AGE_MS= \ No newline at end of file +COMPOSITE_TILE_DIR_STORE_MAX_AGE_MS= +# Mapbox access token (pk.) injected into the baseline mission when seeding mmgis_golden +# (.claude/skills/mmgis-deployment/scripts/seed-golden.sh). Not read by MMGIS itself. +# Tokens are never committed to git. +MAPBOX_TOKEN= From 4846162e37c1560fc556e495683eb72fa5e52be4 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Tue, 9 Jun 2026 15:43:49 -0500 Subject: [PATCH 03/63] Add deployment-mode foundation for lean deployments Introduce MMGIS_DEPLOYMENT_MODE (full|lean, default full) with a backend helper, client-exposed build flags, the STATIC_MISSION_CONFIG webpack alias, and a gitignore rule for the baked-config stub. No behavior change; nothing reads the gate yet. --- .gitignore | 4 ++ API/Backend/Utils/deploymentMode.js | 29 ++++++++++ configuration/env.js | 4 ++ configuration/webpack.config.js | 6 +++ sample.env | 5 ++ tests/unit/deploymentMode.spec.js | 84 +++++++++++++++++++++++++++++ 6 files changed, 132 insertions(+) create mode 100644 API/Backend/Utils/deploymentMode.js create mode 100644 tests/unit/deploymentMode.spec.js diff --git a/.gitignore b/.gitignore index 5f70cf4d6..78cca7129 100644 --- a/.gitignore +++ b/.gitignore @@ -35,12 +35,16 @@ nul /src/pre/tools.js /src/pre/components.js +# Baked mission config stub (overwritten by the static publish flow) +/src/pre/staticConfig.js + /spice/kernels/* !/spice/kernels/.gitkeep /Missions/spice-kernels-conf.json !/Missions/spice-kernels-conf.example*json /build/* +/build-static/ /data/* *__pycache__ diff --git a/API/Backend/Utils/deploymentMode.js b/API/Backend/Utils/deploymentMode.js new file mode 100644 index 000000000..f28093bcf --- /dev/null +++ b/API/Backend/Utils/deploymentMode.js @@ -0,0 +1,29 @@ +/** + * deploymentMode.js + * Resolves the MMGIS deployment mode (MMGIS_DEPLOYMENT_MODE) once at load. + * - "full" (default): the complete MMGIS application as shipped today. + * - "lean": a gated-down deployment shape. + * Any other value is a configuration error and throws at startup. + */ + +const VALID_MODES = ["full", "lean"]; + +const mode = process.env.MMGIS_DEPLOYMENT_MODE || "full"; + +if (!VALID_MODES.includes(mode)) { + throw new Error( + `Invalid MMGIS_DEPLOYMENT_MODE: '${mode}'. Expected one of: ${VALID_MODES.join( + ", " + )}. Unset it or set MMGIS_DEPLOYMENT_MODE=full for the default full deployment.` + ); +} + +function isLean() { + return mode === "lean"; +} + +function isFull() { + return mode === "full"; +} + +module.exports = { MODE: mode, isLean, isFull }; diff --git a/configuration/env.js b/configuration/env.js index 13795c517..e338891c9 100644 --- a/configuration/env.js +++ b/configuration/env.js @@ -112,6 +112,10 @@ function getClientEnvironment(publicUrl) { SKIP_CLIENT_INITIAL_LOGIN: process.env.SKIP_CLIENT_INITIAL_LOGIN || "", IS_DOCKER: process.env.IS_DOCKER, WITH_TITILER: process.env.WITH_TITILER, + // Deployment shape: 'full' (upstream default) or 'lean' + MMGIS_DEPLOYMENT_MODE: process.env.MMGIS_DEPLOYMENT_MODE || "full", + // 'true' when building a statically-deployable bundle + STATIC_MODE: process.env.STATIC_MODE, } ); // Stringify all values so we can feed into webpack DefinePlugin diff --git a/configuration/webpack.config.js b/configuration/webpack.config.js index f60ce6f3b..e1168356f 100644 --- a/configuration/webpack.config.js +++ b/configuration/webpack.config.js @@ -295,6 +295,12 @@ module.exports = function (webpackEnv) { }), ...(modules.webpackAliases || {}), markjs: "mark.js/dist/jquery.mark.js", + // Baked mission config stub for static (lean) deployments. + // The publish flow overwrites the target file; it's gitignored. + STATIC_MISSION_CONFIG: path.resolve( + paths.appSrc, + "pre/staticConfig.js" + ), }, plugins: [ // Prevents users from importing files from outside of src/ (or node_modules/). diff --git a/sample.env b/sample.env index 577369bd9..83d60d95d 100644 --- a/sample.env +++ b/sample.env @@ -3,6 +3,11 @@ # SERVER - node || apache(deprecated) SERVER=node +# MMGIS_DEPLOYMENT_MODE - full || lean +# full: The complete MMGIS application. This is the default and is fully compatible with upstream deployments. +# lean: A stripped-down deployment shape that gates off full-only backend services and features. +MMGIS_DEPLOYMENT_MODE=full + # PORT # In development mode only, PORT+1 will also be used for the main site PORT=8888 diff --git a/tests/unit/deploymentMode.spec.js b/tests/unit/deploymentMode.spec.js new file mode 100644 index 000000000..986e3f459 --- /dev/null +++ b/tests/unit/deploymentMode.spec.js @@ -0,0 +1,84 @@ +import { test, expect } from '@playwright/test' + +// Tests for the MMGIS_DEPLOYMENT_MODE backend helper. +// The module resolves the mode once at load, so each test clears the +// require cache and re-requires it under a fresh environment. + +const HELPER_PATH = '../../API/Backend/Utils/deploymentMode.js' + +function freshRequire() { + delete require.cache[require.resolve(HELPER_PATH)] + return require(HELPER_PATH) +} + +test.describe('deploymentMode', () => { + let savedMode + + test.beforeEach(() => { + savedMode = process.env.MMGIS_DEPLOYMENT_MODE + delete process.env.MMGIS_DEPLOYMENT_MODE + }) + + test.afterEach(() => { + if (savedMode === undefined) { + delete process.env.MMGIS_DEPLOYMENT_MODE + } else { + process.env.MMGIS_DEPLOYMENT_MODE = savedMode + } + delete require.cache[require.resolve(HELPER_PATH)] + }) + + test('defaults to full when MMGIS_DEPLOYMENT_MODE is unset', () => { + const { isFull, isLean, MODE } = freshRequire() + expect(isFull()).toBe(true) + expect(isLean()).toBe(false) + expect(MODE).toBe('full') + }) + + test('resolves full when MMGIS_DEPLOYMENT_MODE=full', () => { + process.env.MMGIS_DEPLOYMENT_MODE = 'full' + const { isFull, isLean, MODE } = freshRequire() + expect(isFull()).toBe(true) + expect(isLean()).toBe(false) + expect(MODE).toBe('full') + }) + + test('resolves lean when MMGIS_DEPLOYMENT_MODE=lean', () => { + process.env.MMGIS_DEPLOYMENT_MODE = 'lean' + const { isFull, isLean, MODE } = freshRequire() + expect(isLean()).toBe(true) + expect(isFull()).toBe(false) + expect(MODE).toBe('lean') + }) + + test('throws at load on an unknown mode', () => { + process.env.MMGIS_DEPLOYMENT_MODE = 'bogus' + expect(() => freshRequire()).toThrow( + /Invalid MMGIS_DEPLOYMENT_MODE: 'bogus'/ + ) + }) + + test('treats an empty string as unset and defaults to full', () => { + process.env.MMGIS_DEPLOYMENT_MODE = '' + const { isFull, isLean, MODE } = freshRequire() + expect(isFull()).toBe(true) + expect(isLean()).toBe(false) + expect(MODE).toBe('full') + }) + + test('is case-sensitive and throws on LEAN', () => { + process.env.MMGIS_DEPLOYMENT_MODE = 'LEAN' + expect(() => freshRequire()).toThrow( + /Invalid MMGIS_DEPLOYMENT_MODE: 'LEAN'/ + ) + }) + + test('resolves the mode once at load; later env changes are ignored', () => { + process.env.MMGIS_DEPLOYMENT_MODE = 'lean' + const helper = freshRequire() + process.env.MMGIS_DEPLOYMENT_MODE = 'full' + expect(helper.MODE).toBe('lean') + expect(helper.isLean()).toBe(true) + expect(helper.isFull()).toBe(false) + }) +}) From 64a7f269a3a3faee9b997f60856e600266aaf59d Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Tue, 9 Jun 2026 16:01:00 -0500 Subject: [PATCH 04/63] Gate sidecar proxy, spawner, and mmgis-stac creation in lean mode In lean deployments the adjacent-server proxy routes never register, the sidecar spawner is a no-op, the Configure Pug shell receives WITH_*=false, and init-db skips the mmgis-stac database. Full mode (the default) is unchanged. --- API/Backend/Config/setup.js | 11 +++++++---- adjacent-servers/adjacent-servers-proxy.js | 10 ++++++++++ adjacent-servers/adjacent-servers.js | 10 ++++++++++ scripts/init-db.js | 8 +++++--- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/API/Backend/Config/setup.js b/API/Backend/Config/setup.js index a7c1f54da..6cb39579d 100644 --- a/API/Backend/Config/setup.js +++ b/API/Backend/Config/setup.js @@ -1,6 +1,7 @@ const router = require("./routes/configs"); const triggerWebhooks = require("../Webhooks/processes/triggerwebhooks.js"); const configurePackageJson = require("../../../configure/package.json"); +const { isLean } = require("../Utils/deploymentMode"); let setup = { //Once the app initializes @@ -35,10 +36,12 @@ let setup = { ? "" : process.env.WEBSOCKET_ROOT_PATH || "", IS_DOCKER: process.env.IS_DOCKER, - WITH_STAC: process.env.WITH_STAC, - WITH_TIPG: process.env.WITH_TIPG, - WITH_TITILER: process.env.WITH_TITILER, - WITH_TITILER_PGSTAC: process.env.WITH_TITILER_PGSTAC, + WITH_STAC: isLean() ? "false" : process.env.WITH_STAC, + WITH_TIPG: isLean() ? "false" : process.env.WITH_TIPG, + WITH_TITILER: isLean() ? "false" : process.env.WITH_TITILER, + WITH_TITILER_PGSTAC: isLean() + ? "false" + : process.env.WITH_TITILER_PGSTAC, }); } ); diff --git a/adjacent-servers/adjacent-servers-proxy.js b/adjacent-servers/adjacent-servers-proxy.js index aa74b7ddb..b811e50e4 100644 --- a/adjacent-servers/adjacent-servers-proxy.js +++ b/adjacent-servers/adjacent-servers-proxy.js @@ -4,8 +4,18 @@ const { } = require("http-proxy-middleware"); const logger = require("../API/logger"); +const { isFull } = require("../API/Backend/Utils/deploymentMode"); function initAdjacentServersProxy(app, isDocker, ensureAdmin) { + if (!isFull()) { + logger( + "info", + "adjacent-servers proxy disabled (deployment mode = lean)", + "adjacent-servers" + ); + return; + } + /////////////////////////// // Proxies //// STAC diff --git a/adjacent-servers/adjacent-servers.js b/adjacent-servers/adjacent-servers.js index 457c3cc3c..bb9e9cba2 100755 --- a/adjacent-servers/adjacent-servers.js +++ b/adjacent-servers/adjacent-servers.js @@ -1,8 +1,18 @@ require("dotenv").config(); const logger = require("../API/logger"); const { spawn } = require("child_process"); +const { isFull } = require("../API/Backend/Utils/deploymentMode"); function adjacentServers() { + if (!isFull()) { + logger( + "info", + "adjacent-servers spawner disabled (deployment mode = lean)", + "adjacent-servers" + ); + return; + } + const IS_WINDOWS = /^win/i.test(process.platform) ? true : false; const EXT = IS_WINDOWS ? ".bat" : ".sh"; const CMD = IS_WINDOWS ? "" : "sh "; diff --git a/scripts/init-db.js b/scripts/init-db.js index eccf48f2e..2b6268985 100644 --- a/scripts/init-db.js +++ b/scripts/init-db.js @@ -4,6 +4,7 @@ const logger = require("../API/logger"); const utils = require("../API/utils"); const execSync = require("child_process").execSync; require("dotenv").config({ path: __dirname + "/../.env" }); +const { isFull } = require("../API/Backend/Utils/deploymentMode"); const isDocker = utils.isDocker(); @@ -122,9 +123,10 @@ async function initializeDatabase() { }); if ( - process.env.WITH_STAC === "true" || - process.env.WITH_TIPG === "true" || - process.env.WITH_TITILER_PGSTAC === "true" + (process.env.WITH_STAC === "true" || + process.env.WITH_TIPG === "true" || + process.env.WITH_TITILER_PGSTAC === "true") && + isFull() ) { // mmgis-stac await baseSequelize From fb5e6014575658cb9f3fbc985e2759fdbab6eeb4 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Tue, 9 Jun 2026 16:03:40 -0500 Subject: [PATCH 05/63] Gate Datasets and Geodatasets in lean mode; expose DEPLOYMENT_MODE to Configure In lean deployments the Datasets and Geodatasets API modules never mount and their Configure nav tabs are hidden. The deployment mode is plumbed through the Pug shell to window.mmgisglobal for the Configure SPA, riding the same path as the existing WITH_* flags. Full mode (the default) is unchanged; the geodatasets table still syncs in both modes. --- API/Backend/Config/setup.js | 2 + API/Backend/Datasets/setup.js | 17 ++++---- API/Backend/Geodatasets/setup.js | 18 +++++---- configure/public/index.html | 1 + configure/src/components/Panel/Panel.js | 52 +++++++++++++------------ 5 files changed, 52 insertions(+), 38 deletions(-) diff --git a/API/Backend/Config/setup.js b/API/Backend/Config/setup.js index a7c1f54da..f41dd43c8 100644 --- a/API/Backend/Config/setup.js +++ b/API/Backend/Config/setup.js @@ -1,6 +1,7 @@ const router = require("./routes/configs"); const triggerWebhooks = require("../Webhooks/processes/triggerwebhooks.js"); const configurePackageJson = require("../../../configure/package.json"); +const { MODE } = require("../Utils/deploymentMode"); let setup = { //Once the app initializes @@ -39,6 +40,7 @@ let setup = { WITH_TIPG: process.env.WITH_TIPG, WITH_TITILER: process.env.WITH_TITILER, WITH_TITILER_PGSTAC: process.env.WITH_TITILER_PGSTAC, + DEPLOYMENT_MODE: MODE, }); } ); diff --git a/API/Backend/Datasets/setup.js b/API/Backend/Datasets/setup.js index afd9a37cf..387cc4a38 100644 --- a/API/Backend/Datasets/setup.js +++ b/API/Backend/Datasets/setup.js @@ -1,14 +1,17 @@ const router = require("./routes/datasets"); +const { isFull } = require("../Utils/deploymentMode"); let setup = { //Once the app initializes onceInit: (s) => { - s.app.use( - s.ROOT_PATH + "/api/datasets", - s.ensureAdmin(), - s.checkHeadersCodeInjection, - s.setContentType, - router - ); + if (isFull()) { + s.app.use( + s.ROOT_PATH + "/api/datasets", + s.ensureAdmin(), + s.checkHeadersCodeInjection, + s.setContentType, + router + ); + } }, //Once the server starts onceStarted: (s) => {}, diff --git a/API/Backend/Geodatasets/setup.js b/API/Backend/Geodatasets/setup.js index 8464ba682..dbc29588b 100644 --- a/API/Backend/Geodatasets/setup.js +++ b/API/Backend/Geodatasets/setup.js @@ -2,16 +2,20 @@ const router = require("./routes/geodatasets"); const geodatasets = require("./models/geodatasets"); +const { isFull } = require("../Utils/deploymentMode"); + let setup = { //Once the app initializes onceInit: (s) => { - s.app.use( - s.ROOT_PATH + "/api/geodatasets", - s.ensureAdmin(), - s.checkHeadersCodeInjection, - s.setContentType, - router - ); + if (isFull()) { + s.app.use( + s.ROOT_PATH + "/api/geodatasets", + s.ensureAdmin(), + s.checkHeadersCodeInjection, + s.setContentType, + router + ); + } }, //Once the server starts onceStarted: (s) => {}, diff --git a/configure/public/index.html b/configure/public/index.html index e2531b7b6..233c5a01f 100644 --- a/configure/public/index.html +++ b/configure/public/index.html @@ -28,6 +28,7 @@ mmgisglobal.WITH_TIPG = "#{WITH_TIPG}"; mmgisglobal.WITH_TITILER = "#{WITH_TITILER}"; mmgisglobal.WITH_TITILER_PGSTAC = "#{WITH_TITILER_PGSTAC}"; + mmgisglobal.DEPLOYMENT_MODE = "#{DEPLOYMENT_MODE}"; // prettier-ignore console.info( diff --git a/configure/src/components/Panel/Panel.js b/configure/src/components/Panel/Panel.js index 22f4d7bdc..b5fe0449e 100644 --- a/configure/src/components/Panel/Panel.js +++ b/configure/src/components/Panel/Panel.js @@ -315,30 +315,34 @@ export default function Panel() {
- - + {window.mmgisglobal.DEPLOYMENT_MODE !== "lean" ? ( + + ) : null} + {window.mmgisglobal.DEPLOYMENT_MODE !== "lean" ? ( + + ) : null} {window.mmgisglobal.WITH_STAC === "true" ? (
); case "button": + // The populate-from-COG/XML button calls the local /titiler proxy and + // reads tilemapresource.xml from Missions/ — both absent in lean. + if ( + com.action === "tile-populate-from-x" && + window.mmgisglobal.DEPLOYMENT_MODE === "lean" + ) + return null; inner = ( ) : null} + {window.mmgisglobal.DEPLOYMENT_MODE === "lean" ? ( + + ) : null} + {window.mmgisglobal.WITH_STAC === "true" ? ( + {window.mmgisglobal.DEPLOYMENT_MODE === "lean" ? ( + + ) : null} diff --git a/configure/src/core/calls.js b/configure/src/core/calls.js index 3d29cfd3e..442fb9e9d 100644 --- a/configure/src/core/calls.js +++ b/configure/src/core/calls.js @@ -174,6 +174,26 @@ const c = { type: "POST", url: "api/webhooks/config", }, + getDeployments: { + type: "GET", + url: "api/deployments", + }, + getDeployment: { + type: "GET", + url: "api/deployments/:id", + }, + publishDeployment: { + type: "POST", + url: "api/deployments/publish", + }, + updateDeployment: { + type: "POST", + url: "api/deployments/:id/update", + }, + deleteDeployment: { + type: "DELETE", + url: "api/deployments/:id", + }, titiler_tileMatrixSets: { type: "GET", url: "titiler/tileMatrixSets", diff --git a/configure/src/pages/Deployments/Deployments.js b/configure/src/pages/Deployments/Deployments.js new file mode 100644 index 000000000..232dcb9d5 --- /dev/null +++ b/configure/src/pages/Deployments/Deployments.js @@ -0,0 +1,390 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useSelector, useDispatch } from "react-redux"; +import { makeStyles } from "@mui/styles"; + +import { calls } from "../../core/calls"; +import { setSnackBarText } from "../../core/ConfigureStore"; + +import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; +import Toolbar from "@mui/material/Toolbar"; +import Typography from "@mui/material/Typography"; +import Tooltip from "@mui/material/Tooltip"; +import TextField from "@mui/material/TextField"; +import InputLabel from "@mui/material/InputLabel"; +import MenuItem from "@mui/material/MenuItem"; +import FormControl from "@mui/material/FormControl"; +import Select from "@mui/material/Select"; +import Link from "@mui/material/Link"; + +import RocketLaunchIcon from "@mui/icons-material/RocketLaunch"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import PublishIcon from "@mui/icons-material/Publish"; +import UpgradeIcon from "@mui/icons-material/Upgrade"; +import DeleteForeverIcon from "@mui/icons-material/DeleteForever"; + +const useStyles = makeStyles((theme) => ({ + Deployments: { + width: "100%", + height: "100%", + display: "flex", + flexFlow: "column", + background: theme.palette.swatches.grey[1000], + padding: "0px", + boxSizing: "border-box", + backgroundImage: "url(configure/build/gridlines.png)", + }, + topbar: { + width: "calc(100% - 100px)", + height: "44px", + minHeight: "44px !important", + display: "flex", + justifyContent: "space-between", + padding: `0px 20px`, + boxSizing: `border-box !important`, + }, + topbarTitle: { + display: "flex", + color: theme.palette.swatches.grey[150], + "& > svg": { + color: theme.palette.swatches.grey[150], + margin: "3px 10px 0px 2px", + }, + }, + content: { + width: "100%", + overflowY: "auto", + padding: "0px 60px", + boxSizing: "border-box", + }, + subtitle: { + fontSize: "13px !important", + fontStyle: "italic", + color: theme.palette.swatches.grey[400], + margin: "10px 0px !important", + }, + publishForm: { + display: "flex", + gap: "16px", + margin: "20px 0px", + alignItems: "center", + }, + missionSelect: { + width: "280px", + }, + nameField: { + width: "280px", + }, + publishButton: { + height: "40px", + borderRadius: "3px !important", + background: `${theme.palette.swatches.p[11]} !important`, + color: "white !important", + }, + listHeader: { + display: "flex", + height: "32px", + lineHeight: "32px", + width: "100%", + background: theme.palette.swatches.grey[150], + color: "white", + fontWeight: "bold", + fontSize: "12px", + textTransform: "uppercase", + marginBottom: "2px", + "& > div": { + padding: "0px 16px", + boxSizing: "border-box", + }, + }, + listItem: { + display: "flex", + minHeight: "42px", + lineHeight: "42px", + width: "100%", + background: theme.palette.swatches.grey[900], + border: `1px solid ${theme.palette.swatches.grey[700]}`, + boxShadow: `0px 1px 3px 0px rgba(0,0,0,0.15)`, + marginBottom: "3px", + fontSize: "13px", + "& > div": { + padding: "0px 16px", + boxSizing: "border-box", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + }, + colId: { width: "60px", textAlign: "center" }, + colName: { width: "200px" }, + colMission: { width: "160px" }, + colStatus: { width: "200px", textTransform: "uppercase", fontSize: "12px" }, + colUrl: { flex: 1 }, + colActions: { + width: "120px", + display: "flex", + justifyContent: "end", + }, + statusError: { + color: theme.palette.swatches.red + ? theme.palette.swatches.red[400] + : "#e57373", + }, + lastError: { + fontSize: "11px", + fontStyle: "italic", + color: "#e57373", + lineHeight: "16px", + padding: "0px 16px 8px 76px", + }, + empty: { + padding: "40px 0px", + textAlign: "center", + fontStyle: "italic", + color: theme.palette.swatches.grey[400], + }, +})); + +export default function Deployments() { + const c = useStyles(); + + const dispatch = useDispatch(); + + const missions = useSelector((state) => state.core.missions); + + const [deployments, setDeployments] = useState([]); + const [publishMission, setPublishMission] = useState(""); + const [publishName, setPublishName] = useState(""); + + const queryDeployments = useCallback(() => { + calls.api( + "getDeployments", + {}, + (res) => { + setDeployments(res?.body?.deployments || []); + }, + (res) => { + dispatch( + setSnackBarText({ + text: res?.message || "Failed to get deployments.", + severity: "error", + }) + ); + } + ); + }, [dispatch]); + + useEffect(() => { + queryDeployments(); + }, [queryDeployments]); + + const publish = () => { + if (publishMission === "") { + dispatch( + setSnackBarText({ + text: "Pick a mission to publish.", + severity: "warning", + }) + ); + return; + } + calls.api( + "publishDeployment", + { mission: publishMission, name: publishName || publishMission }, + () => { + dispatch( + setSnackBarText({ + text: "Publishing… This runs in the background; refresh for live status.", + severity: "success", + }) + ); + setPublishName(""); + queryDeployments(); + }, + (res) => { + dispatch( + setSnackBarText({ + text: res?.message || "Failed to publish.", + severity: "error", + }) + ); + } + ); + }; + + const update = (deployment) => { + calls.api( + "updateDeployment", + { urlReplacements: { id: deployment.id } }, + () => { + dispatch( + setSnackBarText({ + text: `Updating '${deployment.name}'… This runs in the background.`, + severity: "success", + }) + ); + queryDeployments(); + }, + (res) => { + dispatch( + setSnackBarText({ + text: res?.message || "Failed to update.", + severity: "error", + }) + ); + } + ); + }; + + const remove = (deployment) => { + calls.api( + "deleteDeployment", + { urlReplacements: { id: deployment.id } }, + () => { + dispatch( + setSnackBarText({ + text: `Deleting '${deployment.name}'…`, + severity: "success", + }) + ); + queryDeployments(); + }, + (res) => { + dispatch( + setSnackBarText({ + text: res?.message || "Failed to delete.", + severity: "error", + }) + ); + } + ); + }; + + return ( +
+ +
+ + + Deployments + +
+ + + + + +
+
+ + Publish a mission as a standalone, statically-hosted dashboard. + Status is read live from CloudFormation on every refresh. + +
+ + Mission + + + setPublishName(e.target.value)} + /> + +
+
+
ID
+
Name
+
Mission
+
Status
+
URL
+
Actions
+
+ {deployments.length === 0 ? ( +
No published dashboards yet.
+ ) : ( + deployments.map((d) => ( +
+
+
{d.id}
+
+ {d.name} +
+
+ {d.mission} +
+
+ {d.status} + {d.stack_status ? ` — ${d.stack_status}` : ""} +
+
+ {d.cloudfront_url ? ( + + {d.cloudfront_url} + + ) : ( + "—" + )} +
+
+ + update(d)}> + + + + + remove(d)}> + + + +
+
+ {d.last_error ? ( +
{d.last_error}
+ ) : null} +
+ )) + )} +
+
+ ); +} diff --git a/configure/src/pages/WebHooks/WebHooks.js b/configure/src/pages/WebHooks/WebHooks.js index 4f9b7c9a3..b6e32cbcc 100644 --- a/configure/src/pages/WebHooks/WebHooks.js +++ b/configure/src/pages/WebHooks/WebHooks.js @@ -35,7 +35,14 @@ const config = { "Which action upon which to trigger the webhook request.", type: "dropdown", width: 2, - options: ["DrawFileAdd", "DrawFileChange", "DrawFileDelete"], + options: [ + "DrawFileAdd", + "DrawFileChange", + "DrawFileDelete", + "DeploymentPublish", + "DeploymentUpdate", + "DeploymentDelete", + ], }, { field: "type", diff --git a/package-lock.json b/package-lock.json index 0660886ca..fcb34f6c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "mmgis", "version": "4.2.9-20260211", "dependencies": { + "@aws-sdk/client-cloudformation": "^3.1065.0", + "@aws-sdk/client-ecs": "^3.1065.0", + "@aws-sdk/client-s3": "^3.1065.0", "@azure/ai-agents": "^1.0.0-beta.1", "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.4.0", @@ -30,7 +33,6 @@ "@trussworks/react-uswds": "^11.0.1", "@turf/turf": "^6.5.0", "@uswds/uswds": "^3.13.0", - "@uswds/uswds": "^3.13.0", "bcryptjs": "^2.4.3", "bluebird": "^3.7.2", "busboy": "1.6", @@ -111,6 +113,7 @@ "sequelize": "^6.33.0", "sharp": "^0.31.2", "showdown": "^2.1.0", + "shpjs": "^6.2.0", "snap-bbox": "^0.2.0", "sortablejs": "^1.15.0", "swagger-ui-express": "^4.1.4", @@ -134,6 +137,7 @@ "@babel/plugin-transform-private-property-in-object": "^7.22.11", "@playwright/test": "^1.57.0", "@svgr/webpack": "^8.1.0", + "@types/jest": "^30.0.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^2.10.0", @@ -196,8 +200,6 @@ "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", @@ -207,13 +209,36 @@ "node": ">=16.0.0" } }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", @@ -229,8 +254,6 @@ "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", @@ -245,8 +268,6 @@ "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { "tslib": "^2.6.2" } @@ -256,14 +277,52 @@ "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.4.tgz", + "integrity": "sha512-Pt1JEVLu02jTWzpcUzUHciiWScyZg3JpHCTB1h9DtDPWY3dBufBnFJAevVHali/bAkmMdMhYUD8tH/VvPuBkUg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cloudformation": { + "version": "3.1065.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudformation/-/client-cloudformation-3.1065.0.tgz", + "integrity": "sha512-2xcEMcqVWpU8ENMQh3eh/2PQjIwuBeDUddsL+PmxCr0O11vY7u/I0bQRloi/Hr+n9Dkwr9iftvUuvpnij8hoJw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/credential-provider-node": "^3.972.54", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-cognito-identity": { "version": "3.1050.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1050.0.tgz", @@ -287,20 +346,64 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-ecs": { + "version": "3.1065.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ecs/-/client-ecs-3.1065.0.tgz", + "integrity": "sha512-CPxy/ioWNDzSy6A6qfIFBF4zp6zMa3oXPZrF2lh+9hCVLLH8svJXp79hjqc08jzaXdX6xTtt9ISrveCbreRC6g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/credential-provider-node": "^3.972.54", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1065.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1065.0.tgz", + "integrity": "sha512-KtCpg2vihUjhUpqpsZIcB6Dm1hv9lRn5hWwU6hXtYfSQionFD/dB/gTwgyn9AHX6eGiCFqHfjIZwETz5Cz4RWA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/credential-provider-node": "^3.972.54", + "@aws-sdk/middleware-flexible-checksums": "^3.974.29", + "@aws-sdk/middleware-sdk-s3": "^3.972.50", + "@aws-sdk/signature-v4-multi-region": "^3.996.33", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/core": { - "version": "3.974.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.12.tgz", - "integrity": "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A==", + "version": "3.974.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.20.tgz", + "integrity": "sha512-7sDi2B2N3mc3nf1nz6FyEx/FCrJ1N1QnBmraHHQNabFaeAh2IaOOLml48/rHOD1bICHgTRkbBgNTvUzEr5Z35g==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/xml-builder": "^3.972.24", + "@aws-sdk/types": "^3.973.12", + "@aws-sdk/xml-builder": "^3.972.29", "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/core": "^3.24.2", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", + "@smithy/core": "^3.24.6", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" }, @@ -327,17 +430,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.38.tgz", - "integrity": "sha512-m3WjZEgPtioMhPmwqUt+DhlTJ2i9ufR6DhfkyXojb9puEvfR+ur2U5shavu5/Cc9WHHsDCvALi6UFHgcqjhQ5w==", + "version": "3.972.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.46.tgz", + "integrity": "sha512-+GPXVS2srMOlH74S+SmC1gVuP2TvUZ0siuC0onKO93q+udP+M72dmY8wJfVQ5CX9z/9X5A1HHwz5yRIGBtskvQ==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -345,19 +446,17 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.40", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.40.tgz", - "integrity": "sha512-D78L/m2Dr6cJnnSvWoAudPhQmCwmJ7j6APXsPYmFpPaKfQTfCSu0rdm8j14Np+VmXF9z8Aj8HE3xFpsrwtfgeg==", + "version": "3.972.48", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.48.tgz", + "integrity": "sha512-fA5loSdlocacRxyUXtpoHSMuk5rsIKRDzQYVMnMxjcmFeZshaJlJ8lymy/hYKji6sne/UmNGj5pxuEs6kq/Qcg==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -365,25 +464,23 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.42.tgz", - "integrity": "sha512-Mu5ESvFXeinafVM8jTIvRqcvK2Ehj4kz3auT39yUcHwu1Vfxo6xRlmUafdKLW4tusjAJukQwK09sCSMgOm7OKg==", + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.52.tgz", + "integrity": "sha512-szg1nnebqC+Svv6Vfsdf6P/QK8x5g/ghG2CKa/1WkHifRnq0BBmDELj2Qnqk9nPsUvEu/OEcYic97CPLpKqF9g==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/credential-provider-env": "^3.972.38", - "@aws-sdk/credential-provider-http": "^3.972.40", - "@aws-sdk/credential-provider-login": "^3.972.42", - "@aws-sdk/credential-provider-process": "^3.972.38", - "@aws-sdk/credential-provider-sso": "^3.972.42", - "@aws-sdk/credential-provider-web-identity": "^3.972.42", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/credential-provider-env": "^3.972.46", + "@aws-sdk/credential-provider-http": "^3.972.48", + "@aws-sdk/credential-provider-login": "^3.972.51", + "@aws-sdk/credential-provider-process": "^3.972.46", + "@aws-sdk/credential-provider-sso": "^3.972.51", + "@aws-sdk/credential-provider-web-identity": "^3.972.51", + "@aws-sdk/nested-clients": "^3.997.19", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -391,18 +488,16 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.42.tgz", - "integrity": "sha512-O6WkZga3kf0yqyJYd1dbeJqVhEgJx/x1UaLgtbR+XuL/YP+K5y6QTxQKL7ka9z3jnQASESKGAPnRyt4D5hQrxA==", + "version": "3.972.51", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.51.tgz", + "integrity": "sha512-csHFsH+/VjnI40oqm1l1OqMY4B4kza36DbfcbHcgcbobgjebasqUbTU34xvwUkvtoNGGizbfyMSlMzJWUPv3dQ==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/nested-clients": "^3.997.19", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -410,23 +505,21 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.43.tgz", - "integrity": "sha512-D/DJmbrWRP5BXEO3FH+ar4el+2n6OlGofiud7dQun2jES+AQEJjczenp1jBb4MBN7CpGpS8nsWGQLtuzc9tQbA==", + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.54.tgz", + "integrity": "sha512-vinTSQtziNHxi2nqXF+76jr2sO44q88Ind1qFFVaotNgBaC1rcWDjBug8yoE8n0ov33s21xks9WY5XDHH9SENw==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.38", - "@aws-sdk/credential-provider-http": "^3.972.40", - "@aws-sdk/credential-provider-ini": "^3.972.42", - "@aws-sdk/credential-provider-process": "^3.972.38", - "@aws-sdk/credential-provider-sso": "^3.972.42", - "@aws-sdk/credential-provider-web-identity": "^3.972.42", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/credential-provider-env": "^3.972.46", + "@aws-sdk/credential-provider-http": "^3.972.48", + "@aws-sdk/credential-provider-ini": "^3.972.52", + "@aws-sdk/credential-provider-process": "^3.972.46", + "@aws-sdk/credential-provider-sso": "^3.972.51", + "@aws-sdk/credential-provider-web-identity": "^3.972.51", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -434,17 +527,15 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.38.tgz", - "integrity": "sha512-EnbYVajGgbkb24s0K1eo4VNAPV5mHIET7LSvirTaFCwkfrfaOJxtSE+wY/tJdKDS21cEYkZs2ruCaAm+W4iblg==", + "version": "3.972.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.46.tgz", + "integrity": "sha512-VUoNFBIjWrUN8NbFiQiuxQEgFjvziAlBRPK+ddh27aj65gk0BYu6bLZnrdrNZwpW6vAihtSUtEMQ1PUJ32QRPA==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -452,19 +543,17 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.42.tgz", - "integrity": "sha512-RVV/9NbFwI8ZHEH5dn39lGyFmSbSVj1+orZdr6QsOe1mW9DCglmlen0cFaNZmCcqkqc7erNRHNBduxbeZuHAnw==", + "version": "3.972.51", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.51.tgz", + "integrity": "sha512-60qhpQcSDIKIr0AuBlmJezKX0b5nbJPCINiR49N9yJXrEI5tTRwsXVBr0IdSvvsNJyqgiINyoBd++Ed0yvggbw==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/token-providers": "3.1049.0", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/nested-clients": "^3.997.19", + "@aws-sdk/token-providers": "3.1065.0", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -472,18 +561,16 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.42.tgz", - "integrity": "sha512-/67fXX0ddllD4u2Nujc5PvT4byHgpMUfz6+RxIKi/0nFIckeorm7JvXgzBuDyVKw0s58EbofmETDWUf9vTEuHQ==", + "version": "3.972.51", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.51.tgz", + "integrity": "sha512-0X5eWsUIp8ItRJeJBBrhQAPzc9AQelDetRTVTsycCAISCCzM17R4hs/vFAPeQ0o0B35sciLiqe/Pwmml909cZA==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/nested-clients": "^3.997.19", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -520,23 +607,51 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.974.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.29.tgz", + "integrity": "sha512-ALTHDXk6YWVDfAWIHzXyaTZ82QFoMWhHENXlO61lv4ZqSMl3cvh2s0ZVOS89qbtw9LRJhIDoZaaC9FYo/Z4KLQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.50", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.50.tgz", + "integrity": "sha512-yOXn9mmJQQODpbmwQB224IX1PLLneyqInX2Fv2nEmSHWpJj54nrzdrUT1TGQk/s8mr+XPssDQy1at/8GS4EFVQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/signature-v4-multi-region": "^3.996.33", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.10.tgz", - "integrity": "sha512-FtQ/Bt327peZJuyo4WZSOLVUTw9ujRxntepiC7L65FxA2P82Xlq0g14T22BuqBUeMjDoxa9nvwiMHjLIfP3eUg==", + "version": "3.997.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.19.tgz", + "integrity": "sha512-P2Otgf15GBJMKzG6j5Ddf7w+Kz6z2jvesMy874TD3jlMfDWNK7clJeUd7hgigdeVOotjoUP4emcTWVdS9sfZDw==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/signature-v4-multi-region": "^3.996.27", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/signature-v4-multi-region": "^3.996.33", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -544,17 +659,14 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", - "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", + "version": "3.996.33", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.33.tgz", + "integrity": "sha512-Hn0RThJEbyOZWV2PV9Z4YD3nitGPxybmyU17dSe9b61WOBcKnqS0WTtM3c1zyZq9WnGiyrfi/i+UBPUk7cM8Ug==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/types": "^3.973.12", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -562,18 +674,16 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1049.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1049.0.tgz", - "integrity": "sha512-r7+d0lQMTHKypkmaF5jRTBYLYHCUHzt3gaVoN9SidLhQeWhCmHk3AKrboDTpPF5b7Pt7vKu3+oeMjznM2Eu1ow==", + "version": "3.1065.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1065.0.tgz", + "integrity": "sha512-qdHQntq82gMqG6Tf8xrgmhJxacaYkxW4PEeDg/ISMVJ84EWe7iD6JyCTgbyox3uNDH6vqEJ8GUiTaXCq307zVw==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/nested-clients": "^3.997.19", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -581,14 +691,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", - "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.12.tgz", + "integrity": "sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { - "@smithy/types": "^4.14.1", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -600,8 +708,6 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { "tslib": "^2.6.2" }, @@ -610,15 +716,12 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.24", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", - "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.29.tgz", + "integrity": "sha512-fk0niuGFxfi8yIJuMVM4mhwObkiQSuwZFj3tAPrLVx64Pk3BkrEIpqjzHKY4hKoEBUD6Jg/S74Zj9jy+5F3DnQ==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { - "@nodable/entities": "2.1.0", - "@smithy/types": "^4.14.1", + "@smithy/types": "^4.14.3", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" }, @@ -637,8 +740,6 @@ } ], "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", @@ -650,9 +751,9 @@ } }, "node_modules/@aws-sdk/xml-builder/node_modules/strnum": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", - "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.0.tgz", + "integrity": "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==", "funding": [ { "type": "github", @@ -660,16 +761,15 @@ } ], "license": "MIT", - "optional": true, - "peer": true + "dependencies": { + "anynum": "^1.0.0" + } }, "node_modules/@aws/lambda-invoke-store": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", "license": "Apache-2.0", - "optional": true, - "peer": true, "engines": { "node": ">=18.0.0" } @@ -3308,6 +3408,53 @@ "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", "dev": true }, + "node_modules/@jest/diff-sequences": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -4183,18 +4330,16 @@ } }, "node_modules/@nodable/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.1.tgz", + "integrity": "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/nodable" } ], - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", @@ -4775,15 +4920,13 @@ "dev": true }, "node_modules/@smithy/core": { - "version": "3.24.3", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", - "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.6.tgz", + "integrity": "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.14.2", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -4791,15 +4934,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", - "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.8.tgz", + "integrity": "sha512-5cAM+KZC02sTqDt6NaLXyu50M/GNMd1eTzDVR8Lb0BBsVtu7RWHo47VPPEEv1vt3Yub6uzr+M5FHC+GtoT0USg==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -4807,15 +4948,13 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", - "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.6.tgz", + "integrity": "sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -4827,8 +4966,6 @@ "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { "tslib": "^2.6.2" }, @@ -4837,15 +4974,13 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", - "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "version": "4.7.7", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.7.tgz", + "integrity": "sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -4853,15 +4988,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", - "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.6.tgz", + "integrity": "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -4869,12 +5002,10 @@ } }, "node_modules/@smithy/types": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", - "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.3.tgz", + "integrity": "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { "tslib": "^2.6.2" }, @@ -4887,8 +5018,6 @@ "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" @@ -4902,8 +5031,6 @@ "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", "license": "Apache-2.0", - "optional": true, - "peer": true, "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" @@ -6942,10 +7069,11 @@ } }, "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", - "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==", - "dev": true + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" }, "node_modules/@types/istanbul-lib-report": { "version": "3.0.1", @@ -6957,14 +7085,26 @@ } }, "node_modules/@types/istanbul-reports": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.2.tgz", - "integrity": "sha512-kv43F9eb3Lhj+lr/Hn6OcLCs/sSM8bt+fIaP11rCYngfV6NVjzWXJ17owQtDQTL9tQ8WSLUrGsSJ6rJz0F1w1A==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -7095,6 +7235,13 @@ "@types/node": "*" } }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/supercluster": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", @@ -7148,10 +7295,11 @@ } }, "node_modules/@types/yargs": { - "version": "17.0.28", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.28.tgz", - "integrity": "sha512-N3e3fkS86hNhtk6BEnc0rj3zcehaxx8QWhCROJkqpl5Zaoi7nAic3jH8q94jVD3zu5LGk+PUB6KAiDmimYOEQw==", + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", "dev": true, + "license": "MIT", "dependencies": { "@types/yargs-parser": "*" } @@ -7941,6 +8089,18 @@ "node": ">= 8" } }, + "node_modules/anynum": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.0.tgz", + "integrity": "sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/aproba": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", @@ -8706,9 +8866,7 @@ "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, "node_modules/brace-expansion": { "version": "1.1.12", @@ -9061,6 +9219,12 @@ "node": ">=10.16.0" } }, + "node_modules/but-unzip": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/but-unzip/-/but-unzip-0.1.10.tgz", + "integrity": "sha512-hLfQ9WlUimmv/okzsRl6AYG3Ew5HNWhWgUslSR93FsDdeL0MAoQvmC/BJfs35lqEAO5t/QD7Y4vCFcPJtijt3A==", + "license": "Apache-2.0" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -13395,6 +13559,176 @@ "node": ">=6" } }, + "node_modules/expect": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/expect/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/expect/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/expect/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/expect/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/expect/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/expect/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/expect/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/expect/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/expect/node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/expect/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/expect/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -13745,8 +14079,6 @@ } ], "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" @@ -18474,6 +18806,521 @@ "resolved": "https://registry.npmjs.org/jdataview/-/jdataview-2.5.0.tgz", "integrity": "sha512-ZJop3D5nyDcWPBPv4NPnhCvx3HgQNsCXMfw8gpNKY16BobgxmVF+kJ08aHuqk6bJQVeL2mkf6nDCcZPMompalw==" }, + "node_modules/jest-diff": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.4.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-diff/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-diff/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-diff/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-matcher-utils/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-message-util/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-message-util/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-message-util/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-message-util/node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-message-util/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock/node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-mock/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-mock/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-mock/node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-mock/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-mock/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-mock/node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-mock/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-regex-util": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/jest-util": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", @@ -21364,6 +22211,12 @@ "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==" }, + "node_modules/parsedbf": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parsedbf/-/parsedbf-2.0.0.tgz", + "integrity": "sha512-WNjKn/cwgGBkXqQLif+2VMEahcRHkBRU0/RfBWZ7Vj7snRNNW63yW1mVuuHRDyXTRxuGCzAHHBcr/Fn+U/bXjQ==", + "license": "MIT" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -21601,8 +22454,6 @@ } ], "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">=14.0.0" } @@ -23796,6 +24647,55 @@ "renderkid": "^3.0.0" } }, + "node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -24570,6 +25470,22 @@ "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==", "dev": true }, + "node_modules/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "dev": true, + "license": "MIT" + }, "node_modules/react-pdf": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-9.2.1.tgz", @@ -26195,6 +27111,17 @@ "node": "^12.20.0 || >=14" } }, + "node_modules/shpjs": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/shpjs/-/shpjs-6.2.0.tgz", + "integrity": "sha512-8cR/RKYHQepmVyBMtzZQ+1bnSbWrtLXS6aoEJmpUlOSHtSUddterebVxYmIWq2g9kOEX9jm2kjHiikyPX7cNQA==", + "license": "MIT", + "dependencies": { + "but-unzip": "^0.1.4", + "parsedbf": "^2.0.0", + "proj4": "^2.1.4" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -26808,6 +27735,29 @@ "node": "*" } }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/static-extend": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", @@ -30525,8 +31475,6 @@ } ], "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">=16.0.0" } @@ -30682,20 +31630,39 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", - "optional": true, - "peer": true, "requires": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, + "@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "requires": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "requires": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, "@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "optional": true, - "peer": true, "requires": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", @@ -30710,8 +31677,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "optional": true, - "peer": true, "requires": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", @@ -30722,8 +31687,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "optional": true, - "peer": true, "requires": { "tslib": "^2.6.2" } @@ -30732,14 +31695,44 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "optional": true, - "peer": true, "requires": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, + "@aws-sdk/checksums": { + "version": "3.1000.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.4.tgz", + "integrity": "sha512-Pt1JEVLu02jTWzpcUzUHciiWScyZg3JpHCTB1h9DtDPWY3dBufBnFJAevVHali/bAkmMdMhYUD8tH/VvPuBkUg==", + "requires": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/client-cloudformation": { + "version": "3.1065.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudformation/-/client-cloudformation-3.1065.0.tgz", + "integrity": "sha512-2xcEMcqVWpU8ENMQh3eh/2PQjIwuBeDUddsL+PmxCr0O11vY7u/I0bQRloi/Hr+n9Dkwr9iftvUuvpnij8hoJw==", + "requires": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/credential-provider-node": "^3.972.54", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + } + }, "@aws-sdk/client-cognito-identity": { "version": "3.1050.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1050.0.tgz", @@ -30759,19 +31752,55 @@ "tslib": "^2.6.2" } }, + "@aws-sdk/client-ecs": { + "version": "3.1065.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ecs/-/client-ecs-3.1065.0.tgz", + "integrity": "sha512-CPxy/ioWNDzSy6A6qfIFBF4zp6zMa3oXPZrF2lh+9hCVLLH8svJXp79hjqc08jzaXdX6xTtt9ISrveCbreRC6g==", + "requires": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/credential-provider-node": "^3.972.54", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/client-s3": { + "version": "3.1065.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1065.0.tgz", + "integrity": "sha512-KtCpg2vihUjhUpqpsZIcB6Dm1hv9lRn5hWwU6hXtYfSQionFD/dB/gTwgyn9AHX6eGiCFqHfjIZwETz5Cz4RWA==", + "requires": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/credential-provider-node": "^3.972.54", + "@aws-sdk/middleware-flexible-checksums": "^3.974.29", + "@aws-sdk/middleware-sdk-s3": "^3.972.50", + "@aws-sdk/signature-v4-multi-region": "^3.996.33", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + } + }, "@aws-sdk/core": { - "version": "3.974.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.12.tgz", - "integrity": "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A==", - "optional": true, - "peer": true, + "version": "3.974.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.20.tgz", + "integrity": "sha512-7sDi2B2N3mc3nf1nz6FyEx/FCrJ1N1QnBmraHHQNabFaeAh2IaOOLml48/rHOD1bICHgTRkbBgNTvUzEr5Z35g==", "requires": { - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/xml-builder": "^3.972.24", + "@aws-sdk/types": "^3.973.12", + "@aws-sdk/xml-builder": "^3.972.29", "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/core": "^3.24.2", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", + "@smithy/core": "^3.24.6", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" } @@ -30791,134 +31820,118 @@ } }, "@aws-sdk/credential-provider-env": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.38.tgz", - "integrity": "sha512-m3WjZEgPtioMhPmwqUt+DhlTJ2i9ufR6DhfkyXojb9puEvfR+ur2U5shavu5/Cc9WHHsDCvALi6UFHgcqjhQ5w==", - "optional": true, - "peer": true, - "requires": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "version": "3.972.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.46.tgz", + "integrity": "sha512-+GPXVS2srMOlH74S+SmC1gVuP2TvUZ0siuC0onKO93q+udP+M72dmY8wJfVQ5CX9z/9X5A1HHwz5yRIGBtskvQ==", + "requires": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "@aws-sdk/credential-provider-http": { - "version": "3.972.40", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.40.tgz", - "integrity": "sha512-D78L/m2Dr6cJnnSvWoAudPhQmCwmJ7j6APXsPYmFpPaKfQTfCSu0rdm8j14Np+VmXF9z8Aj8HE3xFpsrwtfgeg==", - "optional": true, - "peer": true, - "requires": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", + "version": "3.972.48", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.48.tgz", + "integrity": "sha512-fA5loSdlocacRxyUXtpoHSMuk5rsIKRDzQYVMnMxjcmFeZshaJlJ8lymy/hYKji6sne/UmNGj5pxuEs6kq/Qcg==", + "requires": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "@aws-sdk/credential-provider-ini": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.42.tgz", - "integrity": "sha512-Mu5ESvFXeinafVM8jTIvRqcvK2Ehj4kz3auT39yUcHwu1Vfxo6xRlmUafdKLW4tusjAJukQwK09sCSMgOm7OKg==", - "optional": true, - "peer": true, - "requires": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/credential-provider-env": "^3.972.38", - "@aws-sdk/credential-provider-http": "^3.972.40", - "@aws-sdk/credential-provider-login": "^3.972.42", - "@aws-sdk/credential-provider-process": "^3.972.38", - "@aws-sdk/credential-provider-sso": "^3.972.42", - "@aws-sdk/credential-provider-web-identity": "^3.972.42", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.1", + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.52.tgz", + "integrity": "sha512-szg1nnebqC+Svv6Vfsdf6P/QK8x5g/ghG2CKa/1WkHifRnq0BBmDELj2Qnqk9nPsUvEu/OEcYic97CPLpKqF9g==", + "requires": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/credential-provider-env": "^3.972.46", + "@aws-sdk/credential-provider-http": "^3.972.48", + "@aws-sdk/credential-provider-login": "^3.972.51", + "@aws-sdk/credential-provider-process": "^3.972.46", + "@aws-sdk/credential-provider-sso": "^3.972.51", + "@aws-sdk/credential-provider-web-identity": "^3.972.51", + "@aws-sdk/nested-clients": "^3.997.19", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "@aws-sdk/credential-provider-login": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.42.tgz", - "integrity": "sha512-O6WkZga3kf0yqyJYd1dbeJqVhEgJx/x1UaLgtbR+XuL/YP+K5y6QTxQKL7ka9z3jnQASESKGAPnRyt4D5hQrxA==", - "optional": true, - "peer": true, - "requires": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "version": "3.972.51", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.51.tgz", + "integrity": "sha512-csHFsH+/VjnI40oqm1l1OqMY4B4kza36DbfcbHcgcbobgjebasqUbTU34xvwUkvtoNGGizbfyMSlMzJWUPv3dQ==", + "requires": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/nested-clients": "^3.997.19", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "@aws-sdk/credential-provider-node": { - "version": "3.972.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.43.tgz", - "integrity": "sha512-D/DJmbrWRP5BXEO3FH+ar4el+2n6OlGofiud7dQun2jES+AQEJjczenp1jBb4MBN7CpGpS8nsWGQLtuzc9tQbA==", - "optional": true, - "peer": true, - "requires": { - "@aws-sdk/credential-provider-env": "^3.972.38", - "@aws-sdk/credential-provider-http": "^3.972.40", - "@aws-sdk/credential-provider-ini": "^3.972.42", - "@aws-sdk/credential-provider-process": "^3.972.38", - "@aws-sdk/credential-provider-sso": "^3.972.42", - "@aws-sdk/credential-provider-web-identity": "^3.972.42", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.1", + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.54.tgz", + "integrity": "sha512-vinTSQtziNHxi2nqXF+76jr2sO44q88Ind1qFFVaotNgBaC1rcWDjBug8yoE8n0ov33s21xks9WY5XDHH9SENw==", + "requires": { + "@aws-sdk/credential-provider-env": "^3.972.46", + "@aws-sdk/credential-provider-http": "^3.972.48", + "@aws-sdk/credential-provider-ini": "^3.972.52", + "@aws-sdk/credential-provider-process": "^3.972.46", + "@aws-sdk/credential-provider-sso": "^3.972.51", + "@aws-sdk/credential-provider-web-identity": "^3.972.51", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "@aws-sdk/credential-provider-process": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.38.tgz", - "integrity": "sha512-EnbYVajGgbkb24s0K1eo4VNAPV5mHIET7LSvirTaFCwkfrfaOJxtSE+wY/tJdKDS21cEYkZs2ruCaAm+W4iblg==", - "optional": true, - "peer": true, - "requires": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "version": "3.972.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.46.tgz", + "integrity": "sha512-VUoNFBIjWrUN8NbFiQiuxQEgFjvziAlBRPK+ddh27aj65gk0BYu6bLZnrdrNZwpW6vAihtSUtEMQ1PUJ32QRPA==", + "requires": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "@aws-sdk/credential-provider-sso": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.42.tgz", - "integrity": "sha512-RVV/9NbFwI8ZHEH5dn39lGyFmSbSVj1+orZdr6QsOe1mW9DCglmlen0cFaNZmCcqkqc7erNRHNBduxbeZuHAnw==", - "optional": true, - "peer": true, - "requires": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/token-providers": "3.1049.0", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "version": "3.972.51", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.51.tgz", + "integrity": "sha512-60qhpQcSDIKIr0AuBlmJezKX0b5nbJPCINiR49N9yJXrEI5tTRwsXVBr0IdSvvsNJyqgiINyoBd++Ed0yvggbw==", + "requires": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/nested-clients": "^3.997.19", + "@aws-sdk/token-providers": "3.1065.0", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "@aws-sdk/credential-provider-web-identity": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.42.tgz", - "integrity": "sha512-/67fXX0ddllD4u2Nujc5PvT4byHgpMUfz6+RxIKi/0nFIckeorm7JvXgzBuDyVKw0s58EbofmETDWUf9vTEuHQ==", - "optional": true, - "peer": true, - "requires": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "version": "3.972.51", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.51.tgz", + "integrity": "sha512-0X5eWsUIp8ItRJeJBBrhQAPzc9AQelDetRTVTsycCAISCCzM17R4hs/vFAPeQ0o0B35sciLiqe/Pwmml909cZA==", + "requires": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/nested-clients": "^3.997.19", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, @@ -30948,62 +31961,75 @@ "tslib": "^2.6.2" } }, + "@aws-sdk/middleware-flexible-checksums": { + "version": "3.974.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.29.tgz", + "integrity": "sha512-ALTHDXk6YWVDfAWIHzXyaTZ82QFoMWhHENXlO61lv4ZqSMl3cvh2s0ZVOS89qbtw9LRJhIDoZaaC9FYo/Z4KLQ==", + "requires": { + "@aws-sdk/checksums": "^3.1000.4", + "tslib": "^2.6.2" + } + }, + "@aws-sdk/middleware-sdk-s3": { + "version": "3.972.50", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.50.tgz", + "integrity": "sha512-yOXn9mmJQQODpbmwQB224IX1PLLneyqInX2Fv2nEmSHWpJj54nrzdrUT1TGQk/s8mr+XPssDQy1at/8GS4EFVQ==", + "requires": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/signature-v4-multi-region": "^3.996.33", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + } + }, "@aws-sdk/nested-clients": { - "version": "3.997.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.10.tgz", - "integrity": "sha512-FtQ/Bt327peZJuyo4WZSOLVUTw9ujRxntepiC7L65FxA2P82Xlq0g14T22BuqBUeMjDoxa9nvwiMHjLIfP3eUg==", - "optional": true, - "peer": true, + "version": "3.997.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.19.tgz", + "integrity": "sha512-P2Otgf15GBJMKzG6j5Ddf7w+Kz6z2jvesMy874TD3jlMfDWNK7clJeUd7hgigdeVOotjoUP4emcTWVdS9sfZDw==", "requires": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/signature-v4-multi-region": "^3.996.27", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/signature-v4-multi-region": "^3.996.33", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "@aws-sdk/signature-v4-multi-region": { - "version": "3.996.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", - "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", - "optional": true, - "peer": true, + "version": "3.996.33", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.33.tgz", + "integrity": "sha512-Hn0RThJEbyOZWV2PV9Z4YD3nitGPxybmyU17dSe9b61WOBcKnqS0WTtM3c1zyZq9WnGiyrfi/i+UBPUk7cM8Ug==", "requires": { - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/types": "^3.973.12", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "@aws-sdk/token-providers": { - "version": "3.1049.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1049.0.tgz", - "integrity": "sha512-r7+d0lQMTHKypkmaF5jRTBYLYHCUHzt3gaVoN9SidLhQeWhCmHk3AKrboDTpPF5b7Pt7vKu3+oeMjznM2Eu1ow==", - "optional": true, - "peer": true, - "requires": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "version": "3.1065.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1065.0.tgz", + "integrity": "sha512-qdHQntq82gMqG6Tf8xrgmhJxacaYkxW4PEeDg/ISMVJ84EWe7iD6JyCTgbyox3uNDH6vqEJ8GUiTaXCq307zVw==", + "requires": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/nested-clients": "^3.997.19", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "@aws-sdk/types": { - "version": "3.973.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", - "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", - "optional": true, - "peer": true, + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.12.tgz", + "integrity": "sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA==", "requires": { - "@smithy/types": "^4.14.1", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, @@ -31011,21 +32037,16 @@ "version": "3.965.5", "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", - "optional": true, - "peer": true, "requires": { "tslib": "^2.6.2" } }, "@aws-sdk/xml-builder": { - "version": "3.972.24", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", - "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", - "optional": true, - "peer": true, + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.29.tgz", + "integrity": "sha512-fk0niuGFxfi8yIJuMVM4mhwObkiQSuwZFj3tAPrLVx64Pk3BkrEIpqjzHKY4hKoEBUD6Jg/S74Zj9jy+5F3DnQ==", "requires": { - "@nodable/entities": "2.1.0", - "@smithy/types": "^4.14.1", + "@smithy/types": "^4.14.3", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" }, @@ -31034,8 +32055,6 @@ "version": "5.7.3", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", - "optional": true, - "peer": true, "requires": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", @@ -31044,20 +32063,19 @@ } }, "strnum": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", - "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", - "optional": true, - "peer": true + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.0.tgz", + "integrity": "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==", + "requires": { + "anynum": "^1.0.0" + } } } }, "@aws/lambda-invoke-store": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", - "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", - "optional": true, - "peer": true + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==" }, "@azure-rest/core-client": { "version": "2.5.1", @@ -32873,6 +33891,37 @@ "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", "dev": true }, + "@jest/diff-sequences": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", + "dev": true + }, + "@jest/expect-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", + "dev": true, + "requires": { + "@jest/get-type": "30.1.0" + } + }, + "@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true + }, + "@jest/pattern": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", + "dev": true, + "requires": { + "@types/node": "*", + "jest-regex-util": "30.4.0" + } + }, "@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -33577,11 +34626,9 @@ } }, "@nodable/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", - "optional": true, - "peer": true + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.1.tgz", + "integrity": "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==" }, "@nodelib/fs.scandir": { "version": "2.1.5", @@ -33909,38 +34956,32 @@ "dev": true }, "@smithy/core": { - "version": "3.24.3", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", - "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", - "optional": true, - "peer": true, + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.6.tgz", + "integrity": "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug==", "requires": { "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.14.2", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "@smithy/credential-provider-imds": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", - "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", - "optional": true, - "peer": true, + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.8.tgz", + "integrity": "sha512-5cAM+KZC02sTqDt6NaLXyu50M/GNMd1eTzDVR8Lb0BBsVtu7RWHo47VPPEEv1vt3Yub6uzr+M5FHC+GtoT0USg==", "requires": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "@smithy/fetch-http-handler": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", - "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", - "optional": true, - "peer": true, + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.6.tgz", + "integrity": "sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g==", "requires": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, @@ -33948,42 +34989,34 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "optional": true, - "peer": true, "requires": { "tslib": "^2.6.2" } }, "@smithy/node-http-handler": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", - "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", - "optional": true, - "peer": true, + "version": "4.7.7", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.7.tgz", + "integrity": "sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A==", "requires": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "@smithy/signature-v4": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", - "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", - "optional": true, - "peer": true, + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.6.tgz", + "integrity": "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ==", "requires": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "@smithy/types": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", - "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", - "optional": true, - "peer": true, + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.3.tgz", + "integrity": "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==", "requires": { "tslib": "^2.6.2" } @@ -33992,8 +35025,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "optional": true, - "peer": true, "requires": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" @@ -34003,8 +35034,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "optional": true, - "peer": true, "requires": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" @@ -35566,9 +36595,9 @@ } }, "@types/istanbul-lib-coverage": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", - "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", "dev": true }, "@types/istanbul-lib-report": { @@ -35581,14 +36610,24 @@ } }, "@types/istanbul-reports": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.2.tgz", - "integrity": "sha512-kv43F9eb3Lhj+lr/Hn6OcLCs/sSM8bt+fIaP11rCYngfV6NVjzWXJ17owQtDQTL9tQ8WSLUrGsSJ6rJz0F1w1A==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", "dev": true, "requires": { "@types/istanbul-lib-report": "*" } }, + "@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "requires": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, "@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -35711,6 +36750,12 @@ "@types/node": "*" } }, + "@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true + }, "@types/supercluster": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", @@ -35760,9 +36805,9 @@ } }, "@types/yargs": { - "version": "17.0.28", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.28.tgz", - "integrity": "sha512-N3e3fkS86hNhtk6BEnc0rj3zcehaxx8QWhCROJkqpl5Zaoi7nAic3jH8q94jVD3zu5LGk+PUB6KAiDmimYOEQw==", + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -36363,6 +37408,11 @@ "picomatch": "^2.0.4" } }, + "anynum": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.0.tgz", + "integrity": "sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==" + }, "aproba": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", @@ -36949,9 +37999,7 @@ "bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", - "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", - "optional": true, - "peer": true + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==" }, "brace-expansion": { "version": "1.1.12", @@ -37200,6 +38248,11 @@ "streamsearch": "^1.1.0" } }, + "but-unzip": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/but-unzip/-/but-unzip-0.1.10.tgz", + "integrity": "sha512-hLfQ9WlUimmv/okzsRl6AYG3Ew5HNWhWgUslSR93FsDdeL0MAoQvmC/BJfs35lqEAO5t/QD7Y4vCFcPJtijt3A==" + }, "bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -40341,6 +41394,121 @@ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==" }, + "expect": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", + "dev": true, + "requires": { + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "dependencies": { + "@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "requires": { + "@sinclair/typebox": "^0.34.0" + } + }, + "@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "requires": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + } + }, + "@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "requires": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + } + }, + "picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, "express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -40606,8 +41774,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", - "optional": true, - "peer": true, "requires": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" @@ -44234,6 +45400,357 @@ "resolved": "https://registry.npmjs.org/jdataview/-/jdataview-2.5.0.tgz", "integrity": "sha512-ZJop3D5nyDcWPBPv4NPnhCvx3HgQNsCXMfw8gpNKY16BobgxmVF+kJ08aHuqk6bJQVeL2mkf6nDCcZPMompalw==" }, + "jest-diff": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", + "dev": true, + "requires": { + "@jest/diff-sequences": "30.4.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.4.1" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "jest-matcher-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", + "dev": true, + "requires": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "dependencies": { + "@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "requires": { + "@sinclair/typebox": "^0.34.0" + } + }, + "@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "requires": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + } + }, + "@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "requires": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + } + }, + "picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "dev": true, + "requires": { + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" + }, + "dependencies": { + "@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "requires": { + "@sinclair/typebox": "^0.34.0" + } + }, + "@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "requires": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + } + }, + "@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "requires": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + } + }, + "picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "jest-regex-util": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true + }, "jest-util": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", @@ -46533,6 +48050,11 @@ "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==" }, + "parsedbf": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parsedbf/-/parsedbf-2.0.0.tgz", + "integrity": "sha512-WNjKn/cwgGBkXqQLif+2VMEahcRHkBRU0/RfBWZ7Vj7snRNNW63yW1mVuuHRDyXTRxuGCzAHHBcr/Fn+U/bXjQ==" + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -46690,9 +48212,7 @@ "path-expression-matcher": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", - "optional": true, - "peer": true + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==" }, "path-is-absolute": { "version": "1.0.1", @@ -48259,6 +49779,41 @@ "renderkid": "^3.0.0" } }, + "pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "requires": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "dependencies": { + "@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "requires": { + "@sinclair/typebox": "^0.34.0" + } + }, + "@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true + }, + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + } + } + }, "process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -48896,6 +50451,18 @@ "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==", "dev": true }, + "react-is-18": { + "version": "npm:react-is@18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true + }, + "react-is-19": { + "version": "npm:react-is@19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "dev": true + }, "react-pdf": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-9.2.1.tgz", @@ -50099,6 +51666,16 @@ } } }, + "shpjs": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/shpjs/-/shpjs-6.2.0.tgz", + "integrity": "sha512-8cR/RKYHQepmVyBMtzZQ+1bnSbWrtLXS6aoEJmpUlOSHtSUddterebVxYmIWq2g9kOEX9jm2kjHiikyPX7cNQA==", + "requires": { + "but-unzip": "^0.1.4", + "parsedbf": "^2.0.0", + "proj4": "^2.1.4" + } + }, "side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -50572,6 +52149,23 @@ "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==" }, + "stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "requires": { + "escape-string-regexp": "^2.0.0" + }, + "dependencies": { + "escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true + } + } + }, "static-extend": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", @@ -53399,9 +54993,7 @@ "xml-naming": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", - "optional": true, - "peer": true + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==" }, "xml-utils": { "version": "0.2.0", diff --git a/package.json b/package.json index 39608a1cb..8f7b6711b 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,9 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@aws-sdk/client-cloudformation": "^3.1065.0", + "@aws-sdk/client-ecs": "^3.1065.0", + "@aws-sdk/client-s3": "^3.1065.0", "@azure/ai-agents": "^1.0.0-beta.1", "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.4.0", diff --git a/sample.env b/sample.env index 83d60d95d..2e9ab64d8 100644 --- a/sample.env +++ b/sample.env @@ -8,6 +8,33 @@ SERVER=node # lean: A stripped-down deployment shape that gates off full-only backend services and features. MMGIS_DEPLOYMENT_MODE=full +## Lean publish flow (lean only) ## +# The Deployments feature (Configure page) publishes a mission as a standalone, +# statically-hosted dashboard. All of the variables below are used only when +# MMGIS_DEPLOYMENT_MODE=lean; the real values are provisioned with the lean +# infrastructure (see docs/adr/deployment/lean/). +# AWS region for the publish flow's CloudFormation/S3/ECS clients (lean only) +AWS_REGION= +# ECS cluster the publish task runs on (lean only) +MMGIS_PUBLISH_ECS_CLUSTER= +# ECS task definition (ARN or family) for the publish task — same image as the +# admin app, invoked with `node scripts/publish-static.js` (lean only) +MMGIS_PUBLISH_TASK_DEFINITION= +# Comma-separated subnet ids for the publish task's awsvpc network (lean only) +MMGIS_PUBLISH_SUBNETS= +# Comma-separated security group ids for the publish task (lean only) +MMGIS_PUBLISH_SECURITY_GROUPS= +# Container name inside the publish task definition; defaults to "mmgis" (lean only) +MMGIS_PUBLISH_CONTAINER_NAME= +# Shared S3 bucket holding the admin app's uploaded mission assets +# (/assets//…); publish same-key copies them into each dashboard's +# own bucket (lean only) +MMGIS_SHARED_ASSET_BUCKET= +# Shared password gating every published dashboard (HTTP Basic auth at the +# CloudFront edge). Baked into each stack's CloudFront Function at publish +# time — never a CloudFormation parameter (lean only) +MMGIS_DASHBOARDS_PASSWORD= + # PORT # In development mode only, PORT+1 will also be used for the main site PORT=8888 @@ -173,8 +200,8 @@ SPICE_SCHEDULED_KERNEL_CRON_EXPR= # When using composited time tiles, MMGIS queries the tileset's folder for existing time folders. # It caches the results of the these folder listings every COMPOSITE_TILE_DIR_STORE_MAX_AGE_MS milliseconds # defaults to requerying every 30 minutes. If 0, no caching. If null or NaN, uses default. -COMPOSITE_TILE_DIR_STORE_MAX_AGE_MS= -# Mapbox access token (pk.) injected into the baseline mission when seeding mmgis_golden -# (.claude/skills/mmgis-deployment/scripts/seed-golden.sh). Not read by MMGIS itself. -# Tokens are never committed to git. -MAPBOX_TOKEN= +COMPOSITE_TILE_DIR_STORE_MAX_AGE_MS= +# Mapbox access token (pk.) injected into the baseline mission when seeding mmgis_golden +# (.claude/skills/mmgis-deployment/scripts/seed-golden.sh). Not read by MMGIS itself. +# Tokens are never committed to git. +MAPBOX_TOKEN= diff --git a/scripts/lib/aws-provision.js b/scripts/lib/aws-provision.js new file mode 100644 index 000000000..4c44b0f78 --- /dev/null +++ b/scripts/lib/aws-provision.js @@ -0,0 +1,391 @@ +/** + * aws-provision.js + * Thin wrappers over the @aws-sdk v3 clients used by the Deployments + * publish flow: CloudFormation stack lifecycle, S3 bundle upload / + * asset copy / bucket emptying, and the ECS RunTask that starts the + * publish task. + * + * Clients are created lazily (first call) and can be injected with + * setClients() so unit tests never touch real AWS. + */ + +const fs = require("fs"); +const path = require("path"); + +const { + CloudFormationClient, + CreateStackCommand, + DescribeStacksCommand, + DeleteStackCommand, +} = require("@aws-sdk/client-cloudformation"); +const { + S3Client, + PutObjectCommand, + CopyObjectCommand, + ListObjectsV2Command, + DeleteObjectsCommand, +} = require("@aws-sdk/client-s3"); +const { ECSClient, RunTaskCommand } = require("@aws-sdk/client-ecs"); + +let _clients = null; + +function getClients() { + if (_clients == null) { + const region = process.env.AWS_REGION; + _clients = { + cfn: new CloudFormationClient({ region }), + s3: new S3Client({ region }), + ecs: new ECSClient({ region }), + }; + } + return _clients; +} + +// Test seam: inject mock clients ({ cfn, s3, ecs }), or null to reset. +function setClients(clients) { + _clients = clients; +} + +/* ------------------------------ CloudFormation ------------------------------ */ + +const TERMINAL_STACK_STATUSES = [ + "CREATE_COMPLETE", + "CREATE_FAILED", + "ROLLBACK_COMPLETE", + "ROLLBACK_FAILED", + "DELETE_COMPLETE", + "DELETE_FAILED", + "UPDATE_COMPLETE", + "UPDATE_ROLLBACK_COMPLETE", + "UPDATE_ROLLBACK_FAILED", +]; + +async function createStack({ stackName, templateBody }) { + const { cfn } = getClients(); + const resp = await cfn.send( + new CreateStackCommand({ + StackName: stackName, + TemplateBody: templateBody, + OnFailure: "DO_NOTHING", + Tags: [{ Key: "mmgis:deployment", Value: stackName }], + }) + ); + return resp.StackId; +} + +// Returns the Stack object, or null when the stack does not exist. +// Other errors (credentials, network, throttling) are rethrown. +async function describeStack({ stackName }) { + const { cfn } = getClients(); + try { + const resp = await cfn.send( + new DescribeStacksCommand({ StackName: stackName }) + ); + return (resp.Stacks && resp.Stacks[0]) || null; + } catch (err) { + if ( + err.name === "ValidationError" || + (err.message || "").indexOf("does not exist") !== -1 + ) + return null; + throw err; + } +} + +// Polls DescribeStacks until the stack reaches a terminal status. +// Resolves with the Stack object on success; throws on failure statuses, +// disappearance, or timeout. +async function waitForStack({ + stackName, + desiredStatus = "CREATE_COMPLETE", + pollIntervalMs = 15000, + timeoutMs = 30 * 60 * 1000, +}) { + const startedAt = Date.now(); + for (;;) { + const stack = await describeStack({ stackName }); + if (stack == null) + throw new Error( + `Stack '${stackName}' does not exist (deleted or never created)` + ); + if (stack.StackStatus === desiredStatus) return stack; + if (TERMINAL_STACK_STATUSES.indexOf(stack.StackStatus) !== -1) + throw new Error( + `Stack '${stackName}' reached terminal status '${stack.StackStatus}'` + + (stack.StackStatusReason ? `: ${stack.StackStatusReason}` : "") + ); + if (Date.now() - startedAt > timeoutMs) + throw new Error( + `Timed out waiting for stack '${stackName}' to reach '${desiredStatus}' (last status '${stack.StackStatus}')` + ); + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } +} + +// { OutputKey: OutputValue, ... } from a Stack object. +function getStackOutputs(stack) { + const outputs = {}; + ((stack && stack.Outputs) || []).forEach((o) => { + outputs[o.OutputKey] = o.OutputValue; + }); + return outputs; +} + +async function deleteStack({ stackName }) { + const { cfn } = getClients(); + await cfn.send(new DeleteStackCommand({ StackName: stackName })); +} + +/* ----------------------------------- S3 ----------------------------------- */ + +const CONTENT_TYPES = { + ".html": "text/html", + ".htm": "text/html", + ".js": "application/javascript", + ".mjs": "application/javascript", + ".css": "text/css", + ".json": "application/json", + ".geojson": "application/geo+json", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".webp": "image/webp", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".otf": "font/otf", + ".eot": "application/vnd.ms-fontobject", + ".map": "application/json", + ".txt": "text/plain", + ".csv": "text/csv", + ".xml": "application/xml", + ".pdf": "application/pdf", + ".wasm": "application/wasm", + ".glb": "model/gltf-binary", + ".gltf": "model/gltf+json", + ".mp4": "video/mp4", +}; + +function contentTypeForFile(filePath) { + return ( + CONTENT_TYPES[path.extname(filePath).toLowerCase()] || + "application/octet-stream" + ); +} + +function walkDirectory(dir, baseDir) { + baseDir = baseDir || dir; + let files = []; + fs.readdirSync(dir, { withFileTypes: true }).forEach((item) => { + const full = path.join(dir, item.name); + if (item.isDirectory()) files = files.concat(walkDirectory(full, baseDir)); + else if (item.isFile()) + files.push({ + absolute: full, + key: path.relative(baseDir, full).split(path.sep).join("/"), + }); + }); + return files; +} + +// Uploads every file under `dir` to `bucket`, keys relative to `dir` +// (optionally prefixed). Returns the number of files uploaded. +async function uploadDirectory({ bucket, dir, prefix = "", concurrency = 8 }) { + const { s3 } = getClients(); + const files = walkDirectory(dir); + let index = 0; + async function worker() { + while (index < files.length) { + const file = files[index++]; + await s3.send( + new PutObjectCommand({ + Bucket: bucket, + Key: `${prefix}${file.key}`, + Body: fs.createReadStream(file.absolute), + ContentType: contentTypeForFile(file.absolute), + }) + ); + } + } + await Promise.all( + Array.from({ length: Math.min(concurrency, files.length || 1) }, worker) + ); + return files.length; +} + +// Same-key copies every object under `prefix` from sourceBucket into +// destBucket. Returns the number of objects copied. +async function copyPrefix({ sourceBucket, destBucket, prefix }) { + const { s3 } = getClients(); + let copied = 0; + let continuationToken; + do { + const list = await s3.send( + new ListObjectsV2Command({ + Bucket: sourceBucket, + Prefix: prefix, + ContinuationToken: continuationToken, + }) + ); + for (const obj of list.Contents || []) { + await s3.send( + new CopyObjectCommand({ + Bucket: destBucket, + Key: obj.Key, + CopySource: encodeURIComponent(`${sourceBucket}/${obj.Key}`), + }) + ); + copied++; + } + continuationToken = list.IsTruncated + ? list.NextContinuationToken + : undefined; + } while (continuationToken); + return copied; +} + +// Same-key copies a single object if it exists in the source bucket. +// Returns true when copied, false when the source object is absent. +async function copyObjectIfExists({ sourceBucket, destBucket, key }) { + const { s3 } = getClients(); + try { + await s3.send( + new CopyObjectCommand({ + Bucket: destBucket, + Key: key, + CopySource: encodeURIComponent(`${sourceBucket}/${key}`), + }) + ); + return true; + } catch (err) { + if ( + err.name === "NoSuchKey" || + err.name === "NotFound" || + err.$metadata?.httpStatusCode === 404 + ) + return false; + throw err; + } +} + +// Deletes every object in the bucket (required before DeleteStack can +// remove it). A missing bucket is treated as already empty. +async function emptyBucket({ bucket }) { + const { s3 } = getClients(); + let deleted = 0; + try { + let continuationToken; + do { + const list = await s3.send( + new ListObjectsV2Command({ + Bucket: bucket, + ContinuationToken: continuationToken, + }) + ); + const objects = (list.Contents || []).map((o) => ({ Key: o.Key })); + if (objects.length > 0) { + await s3.send( + new DeleteObjectsCommand({ + Bucket: bucket, + Delete: { Objects: objects, Quiet: true }, + }) + ); + deleted += objects.length; + } + continuationToken = list.IsTruncated + ? list.NextContinuationToken + : undefined; + } while (continuationToken); + } catch (err) { + if (err.name === "NoSuchBucket") return deleted; + throw err; + } + return deleted; +} + +/* ----------------------------------- ECS ----------------------------------- */ + +function requireEnv(name) { + const value = process.env[name]; + if (value == null || value === "") + throw new Error( + `Missing required environment variable '${name}' (lean publish flow; see sample.env)` + ); + return value; +} + +// Starts the ECS publish task (scripts/publish-static.js) for a deployment. +// The task definition, cluster, and network configuration come from env +// (provisioned by PR 11). Throws when configuration is missing or RunTask +// fails — callers record the error on the deployment row. +async function runPublishTask({ deploymentId, action }) { + const cluster = requireEnv("MMGIS_PUBLISH_ECS_CLUSTER"); + const taskDefinition = requireEnv("MMGIS_PUBLISH_TASK_DEFINITION"); + const subnets = requireEnv("MMGIS_PUBLISH_SUBNETS") + .split(",") + .map((sub) => sub.trim()) + .filter(Boolean); + const securityGroups = requireEnv("MMGIS_PUBLISH_SECURITY_GROUPS") + .split(",") + .map((sg) => sg.trim()) + .filter(Boolean); + const containerName = process.env.MMGIS_PUBLISH_CONTAINER_NAME || "mmgis"; + + const { ecs } = getClients(); + const resp = await ecs.send( + new RunTaskCommand({ + cluster, + taskDefinition, + launchType: "FARGATE", + count: 1, + networkConfiguration: { + awsvpcConfiguration: { + subnets, + securityGroups, + assignPublicIp: "ENABLED", + }, + }, + overrides: { + containerOverrides: [ + { + name: containerName, + command: ["node", "scripts/publish-static.js"], + environment: [ + { name: "DEPLOYMENT_ID", value: `${deploymentId}` }, + { name: "DEPLOYMENT_ACTION", value: action }, + ], + }, + ], + }, + }) + ); + + const failures = resp.failures || []; + if (failures.length > 0) + throw new Error( + `ECS RunTask failed: ${failures + .map((f) => `${f.reason || "unknown"}${f.detail ? ` (${f.detail})` : ""}`) + .join("; ")}` + ); + return (resp.tasks && resp.tasks[0] && resp.tasks[0].taskArn) || null; +} + +module.exports = { + getClients, + setClients, + TERMINAL_STACK_STATUSES, + createStack, + describeStack, + waitForStack, + getStackOutputs, + deleteStack, + contentTypeForFile, + uploadDirectory, + copyPrefix, + copyObjectIfExists, + emptyBucket, + runPublishTask, +}; diff --git a/scripts/lib/bake-guards.js b/scripts/lib/bake-guards.js new file mode 100644 index 000000000..8e1ba0b6c --- /dev/null +++ b/scripts/lib/bake-guards.js @@ -0,0 +1,63 @@ +/** + * bake-guards.js + * Pure config transforms applied when baking a mission configuration into + * a static (backend-less) dashboard bundle. + * + * Lean gates by default: time-windowed layers whose tiles are served by the + * admin backend can't resolve in a published dashboard, so when no + * resolvable (externally-served) time-enabled layer remains, the baked + * config disables the time UI rather than shipping a scrubber that goes + * nowhere. Time scrubbing on externally-served time layers still works. + */ + +// A URL is resolvable from a static dashboard only when it's absolute +// (http://, https:// or protocol-relative //) — i.e. served by something +// other than the (absent) MMGIS backend. +function isExternallyServedUrl(url) { + return typeof url === "string" && /^(https?:)?\/\//i.test(url.trim()); +} + +// Walks the mission layer tree (layers nest through `sublayers`) and calls +// fn(layer) on every node. +function forEachLayer(layers, fn) { + (layers || []).forEach((layer) => { + if (layer == null) return; + fn(layer); + if (Array.isArray(layer.sublayers)) forEachLayer(layer.sublayers, fn); + }); +} + +// True when at least one time-enabled layer references an external URL and +// therefore still resolves in a backend-less dashboard. +function hasResolvableTimeLayer(config) { + let found = false; + forEachLayer(config && config.layers, (layer) => { + if ( + layer.time != null && + layer.time.enabled === true && + isExternallyServedUrl(layer.url) + ) + found = true; + }); + return found; +} + +// Disables config.time.enabled in place when no resolvable time-enabled +// layer remains. Returns the (mutated) config for chaining. +function applyTimeBakeGuard(config) { + if ( + config != null && + config.time != null && + config.time.enabled === true && + !hasResolvableTimeLayer(config) + ) + config.time.enabled = false; + return config; +} + +module.exports = { + isExternallyServedUrl, + forEachLayer, + hasResolvableTimeLayer, + applyTimeBakeGuard, +}; diff --git a/scripts/lib/cfn-template.js b/scripts/lib/cfn-template.js new file mode 100644 index 000000000..babef45d9 --- /dev/null +++ b/scripts/lib/cfn-template.js @@ -0,0 +1,221 @@ +/** + * cfn-template.js + * Renders the CloudFormation template for a single published dashboard: + * a private S3 bucket fronted by a CloudFront distribution whose + * viewer-request CloudFront Function enforces a shared password + * (HTTP Basic auth). + * + * The shared password is baked into the Function source as a base64 + * constant. It is deliberately NOT a CloudFormation Parameter — parameters + * surface in DescribeStacks output, which the Deployments list reads. + */ + +const STACK_NAME_PREFIX = "mmgis-dashboard-"; + +// Basic-auth username paired with the shared password. +const BASIC_AUTH_USER = "mmgis"; + +/** + * The deterministic stack name for a deployment row id, e.g. + * stackNameForDeployment(12) === "mmgis-dashboard-12". + */ +function stackNameForDeployment(deploymentId) { + if (deploymentId == null || `${deploymentId}`.length === 0) + throw new Error("stackNameForDeployment requires a deployment id"); + return `${STACK_NAME_PREFIX}${deploymentId}`; +} + +/** + * The viewer-request CloudFront Function source. The expected + * "Basic " Authorization value is baked in as a constant. + */ +function renderAuthFunctionCode(password) { + const expected = Buffer.from(`${BASIC_AUTH_USER}:${password}`).toString( + "base64" + ); + return [ + "function handler(event) {", + " var request = event.request;", + ` var EXPECTED = 'Basic ${expected}';`, + " var headers = request.headers;", + " var auth =", + " headers.authorization && headers.authorization.value;", + " if (auth !== EXPECTED) {", + " return {", + " statusCode: 401,", + " statusDescription: 'Unauthorized',", + " headers: {", + " 'www-authenticate': {", + " value: 'Basic realm=\"MMGIS Dashboard\"'", + " }", + " }", + " };", + " }", + " return request;", + "}", + ].join("\n"); +} + +/** + * Renders the full CloudFormation template body (JSON string) for one + * dashboard. No Parameters block — everything is baked. + * + * Outputs: BucketName, DistributionId, DistributionDomainName. + */ +function renderCfnTemplate({ password } = {}) { + if (password == null || password === "") + throw new Error( + "renderCfnTemplate requires the shared dashboards password (MMGIS_DASHBOARDS_PASSWORD)" + ); + + const template = { + AWSTemplateFormatVersion: "2010-09-09", + Description: + "MMGIS published dashboard: private S3 bucket + CloudFront distribution with a shared-password viewer-request Function. Managed by the MMGIS Deployments feature.", + Resources: { + DashboardBucket: { + Type: "AWS::S3::Bucket", + Properties: { + PublicAccessBlockConfiguration: { + BlockPublicAcls: true, + BlockPublicPolicy: true, + IgnorePublicAcls: true, + RestrictPublicBuckets: true, + }, + BucketEncryption: { + ServerSideEncryptionConfiguration: [ + { + ServerSideEncryptionByDefault: { + SSEAlgorithm: "AES256", + }, + }, + ], + }, + }, + }, + DashboardBucketPolicy: { + Type: "AWS::S3::BucketPolicy", + Properties: { + Bucket: { Ref: "DashboardBucket" }, + PolicyDocument: { + Version: "2012-10-17", + Statement: [ + { + Sid: "AllowCloudFrontServicePrincipalReadOnly", + Effect: "Allow", + Principal: { Service: "cloudfront.amazonaws.com" }, + Action: "s3:GetObject", + Resource: { + "Fn::Join": [ + "", + [{ "Fn::GetAtt": ["DashboardBucket", "Arn"] }, "/*"], + ], + }, + Condition: { + StringEquals: { + "AWS:SourceArn": { + "Fn::Join": [ + "", + [ + "arn:aws:cloudfront::", + { Ref: "AWS::AccountId" }, + ":distribution/", + { Ref: "DashboardDistribution" }, + ], + ], + }, + }, + }, + }, + ], + }, + }, + }, + DashboardOriginAccessControl: { + Type: "AWS::CloudFront::OriginAccessControl", + Properties: { + OriginAccessControlConfig: { + Name: { "Fn::Sub": "${AWS::StackName}-oac" }, + OriginAccessControlOriginType: "s3", + SigningBehavior: "always", + SigningProtocol: "sigv4", + }, + }, + }, + DashboardAuthFunction: { + Type: "AWS::CloudFront::Function", + Properties: { + Name: { "Fn::Sub": "${AWS::StackName}-auth" }, + AutoPublish: true, + FunctionConfig: { + Comment: "Shared-password (Basic auth) gate for the dashboard", + Runtime: "cloudfront-js-1.0", + }, + FunctionCode: renderAuthFunctionCode(password), + }, + }, + DashboardDistribution: { + Type: "AWS::CloudFront::Distribution", + Properties: { + DistributionConfig: { + Comment: { "Fn::Sub": "MMGIS dashboard ${AWS::StackName}" }, + Enabled: true, + DefaultRootObject: "index.html", + HttpVersion: "http2", + Origins: [ + { + Id: "DashboardBucketOrigin", + DomainName: { + "Fn::GetAtt": ["DashboardBucket", "RegionalDomainName"], + }, + OriginAccessControlId: { + "Fn::GetAtt": ["DashboardOriginAccessControl", "Id"], + }, + S3OriginConfig: { OriginAccessIdentity: "" }, + }, + ], + DefaultCacheBehavior: { + TargetOriginId: "DashboardBucketOrigin", + ViewerProtocolPolicy: "redirect-to-https", + // AWS managed policy: CachingOptimized + CachePolicyId: "658327ea-f89d-4fab-a63d-7e88639e58f6", + FunctionAssociations: [ + { + EventType: "viewer-request", + FunctionARN: { + "Fn::GetAtt": ["DashboardAuthFunction", "FunctionARN"], + }, + }, + ], + }, + ViewerCertificate: { CloudFrontDefaultCertificate: true }, + }, + }, + }, + }, + Outputs: { + BucketName: { + Description: "The dashboard's S3 bucket", + Value: { Ref: "DashboardBucket" }, + }, + DistributionId: { + Description: "The dashboard's CloudFront distribution id", + Value: { Ref: "DashboardDistribution" }, + }, + DistributionDomainName: { + Description: "The dashboard's CloudFront domain name", + Value: { "Fn::GetAtt": ["DashboardDistribution", "DomainName"] }, + }, + }, + }; + + return JSON.stringify(template, null, 2); +} + +module.exports = { + STACK_NAME_PREFIX, + BASIC_AUTH_USER, + stackNameForDeployment, + renderAuthFunctionCode, + renderCfnTemplate, +}; diff --git a/scripts/publish-static.js b/scripts/publish-static.js new file mode 100644 index 000000000..bf6c95328 --- /dev/null +++ b/scripts/publish-static.js @@ -0,0 +1,224 @@ +/** + * publish-static.js + * ECS publish-task entrypoint for the lean Deployments feature + * (run as `node scripts/publish-static.js` from the repo root, in the same + * image as the admin app — PR 11 provisions the task definition). + * + * Driven by environment: + * DEPLOYMENT_ID - the deployments row to publish (required) + * DEPLOYMENT_ACTION - "publish" (default) creates the CloudFormation + * stack first; "update" reuses the existing stack + * and just re-bakes + re-uploads (same URL). + * + * Flow: read the mission config from Postgres → apply bake guards → bake + * via bakeStaticConfig → build themes + static webpack bundle + * (SERVER=static STATIC_MODE=true) → CreateStack + poll to CREATE_COMPLETE + * (publish only) → same-key copy the mission's assets from the shared + * admin bucket → upload the bundle → mark the row `published`. + * Any failure marks the row `failed` with last_error. + */ + +require("dotenv").config(); + +const path = require("path"); +const { spawnSync } = require("child_process"); + +const rootDir = path.join(__dirname, ".."); + +const provision = require("./lib/aws-provision"); +const { renderCfnTemplate, stackNameForDeployment } = require("./lib/cfn-template"); +const { applyTimeBakeGuard } = require("./lib/bake-guards"); + +const DEPLOYMENT_ID = process.env.DEPLOYMENT_ID || process.argv[2]; +const ACTION = process.env.DEPLOYMENT_ACTION || process.argv[3] || "publish"; + +function log(message) { + console.log(`[publish-static] ${message}`); +} + +function requireEnv(name) { + const value = process.env[name]; + if (value == null || value === "") + throw new Error( + `Missing required environment variable '${name}' (lean publish flow; see sample.env)` + ); + return value; +} + +// Runs an npm script synchronously from the repo root; throws on failure. +function run(command, args, extraEnv) { + log(`Running: ${command} ${args.join(" ")}`); + const result = spawnSync(command, args, { + cwd: rootDir, + stdio: "inherit", + env: { ...process.env, ...(extraEnv || {}) }, + }); + if (result.error) throw result.error; + if (result.status !== 0) + throw new Error( + `'${command} ${args.join(" ")}' exited with code ${result.status}` + ); +} + +// Builds the baked static config object (keyed by call name — see +// src/pre/staticHandlers.js) from the mission's latest configuration. +async function buildBakedConfig(mission) { + const Config = require("../API/Backend/Config/models/config"); + const GeneralOptions = require("../API/Backend/GeneralOptions/models/generaloptions"); + + const entry = await Config.findOne({ + where: { mission }, + order: [["version", "DESC"]], + }); + if (entry == null) + throw new Error(`Mission '${mission}' not found in the configs table`); + + const config = JSON.parse(JSON.stringify(entry.config)); + // Match /api/configure/get's missionFolderName fallback + if ( + config.msv && + (config.msv.missionFolderName == null || + config.msv.missionFolderName === "") + ) + config.msv.missionFolderName = config.msv.mission || ""; + + // Gate-by-default: don't ship a time scrubber that goes nowhere + applyTimeBakeGuard(config); + + let options = {}; + try { + const generalOptions = await GeneralOptions.findOne({ where: { id: 1 } }); + if (generalOptions != null && generalOptions.options != null) + options = generalOptions.options; + } catch (err) { + log(`No general options found (${err.message}); baking empty options.`); + } + + return { + get: config, + missions: { status: "success", missions: [mission] }, + get_generaloptions: { status: "success", options }, + }; +} + +async function main() { + if (DEPLOYMENT_ID == null || DEPLOYMENT_ID === "") + throw new Error("DEPLOYMENT_ID is required (env or first argument)"); + if (ACTION !== "publish" && ACTION !== "update") + throw new Error(`Unknown DEPLOYMENT_ACTION '${ACTION}'`); + + const Deployments = require("../API/Backend/Deployments/models/deployment"); + const deployment = await Deployments.findByPk(DEPLOYMENT_ID); + if (deployment == null) + throw new Error(`Deployment row ${DEPLOYMENT_ID} not found`); + + try { + const mission = deployment.mission; + const stackName = + deployment.stack_name || stackNameForDeployment(deployment.id); + + // 1. Bake the mission config into the bundle + log(`Baking mission '${mission}' for deployment ${deployment.id}...`); + const baked = await buildBakedConfig(mission); + const { bakeStaticConfig } = require("../API/updateTools"); + bakeStaticConfig(baked); + + // 2. Build the static bundle + run("npm", ["run", "build:themes"]); + run("npm", ["run", "build"], { + SERVER: "static", + STATIC_MODE: "true", + }); + + // 3. Provision (publish) or look up (update) the dashboard stack + let stack; + if (ACTION === "publish") { + const templateBody = renderCfnTemplate({ + password: requireEnv("MMGIS_DASHBOARDS_PASSWORD"), + }); + log(`Creating stack '${stackName}'...`); + await provision.createStack({ stackName, templateBody }); + stack = await provision.waitForStack({ stackName }); + log(`Stack '${stackName}' reached ${stack.StackStatus}.`); + } else { + stack = await provision.describeStack({ stackName }); + if (stack == null) + throw new Error( + `Stack '${stackName}' does not exist — publish before updating` + ); + } + const outputs = provision.getStackOutputs(stack); + const bucket = outputs.BucketName; + if (bucket == null) + throw new Error(`Stack '${stackName}' has no BucketName output`); + + // 4. Same-key copy the mission's assets from the shared admin bucket + // so root-relative /assets//… references resolve + // same-origin against the dashboard's CloudFront. Copied assets + // inherit the dashboard's password gate as ordinary bundle content. + const sharedBucket = process.env.MMGIS_SHARED_ASSET_BUCKET; + if (sharedBucket != null && sharedBucket !== "") { + const copied = await provision.copyPrefix({ + sourceBucket: sharedBucket, + destBucket: bucket, + prefix: `assets/${mission}/`, + }); + log(`Copied ${copied} mission asset(s) from ${sharedBucket}.`); + + // Viewer-panel mosaic file (conditional): the Photosphere/ModelViewer + // panes fetch this hardcoded same-origin path. Copy it when present; + // when absent the panes fail silently rather than erroring. + const mosaicKey = `Missions/${mission}/Data/mosaic_parameters.csv`; + const mosaicCopied = await provision.copyObjectIfExists({ + sourceBucket: sharedBucket, + destBucket: bucket, + key: mosaicKey, + }); + if (mosaicCopied) log(`Copied ${mosaicKey}.`); + } else { + log("MMGIS_SHARED_ASSET_BUCKET not set; skipping mission asset copy."); + } + + // 5. Upload the bundle + const uploaded = await provision.uploadDirectory({ + bucket, + dir: path.join(rootDir, "build"), + }); + log(`Uploaded ${uploaded} bundle file(s) to ${bucket}.`); + + // 6. Terminal row update + const cloudfrontUrl = + outputs.DistributionDomainName != null + ? `https://${outputs.DistributionDomainName}` + : deployment.cloudfront_url; + await deployment.update({ + status: "published", + stack_arn: stack.StackId, + stack_name: stackName, + cloudfront_url: cloudfrontUrl, + last_error: null, + settings: { + ...(deployment.settings || {}), + bucket, + distributionId: outputs.DistributionId, + }, + }); + log(`Deployment ${deployment.id} published at ${cloudfrontUrl}.`); + } catch (err) { + console.error(err); + await deployment + .update({ + status: "failed", + last_error: err.message || String(err), + }) + .catch(() => {}); + throw err; + } +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error(`[publish-static] Failed: ${err.message}`); + process.exit(1); + }); diff --git a/tests/unit/awsProvision.spec.js b/tests/unit/awsProvision.spec.js new file mode 100644 index 000000000..98a8b1459 --- /dev/null +++ b/tests/unit/awsProvision.spec.js @@ -0,0 +1,305 @@ +import { test, expect } from '@playwright/test' + +// Tests for scripts/lib/aws-provision.js using injected mock clients — +// no test here (or anywhere) ever calls real AWS. + +const provision = require('../../scripts/lib/aws-provision') + +function mockClient(handler) { + return { send: async (command) => handler(command) } +} + +test.describe('describeStack', () => { + test.afterEach(() => provision.setClients(null)) + + test('returns the stack when it exists', async () => { + provision.setClients({ + cfn: mockClient(() => ({ + Stacks: [{ StackName: 'mmgis-dashboard-1', StackStatus: 'CREATE_COMPLETE' }], + })), + }) + const stack = await provision.describeStack({ + stackName: 'mmgis-dashboard-1', + }) + expect(stack.StackStatus).toBe('CREATE_COMPLETE') + }) + + test('returns null when the stack does not exist', async () => { + provision.setClients({ + cfn: mockClient(() => { + const err = new Error( + 'Stack with id mmgis-dashboard-9 does not exist' + ) + err.name = 'ValidationError' + throw err + }), + }) + const stack = await provision.describeStack({ + stackName: 'mmgis-dashboard-9', + }) + expect(stack).toBe(null) + }) + + test('rethrows other errors (e.g. missing credentials)', async () => { + provision.setClients({ + cfn: mockClient(() => { + const err = new Error('Could not load credentials') + err.name = 'CredentialsProviderError' + throw err + }), + }) + await expect( + provision.describeStack({ stackName: 'mmgis-dashboard-1' }) + ).rejects.toThrow(/credentials/i) + }) +}) + +test.describe('waitForStack', () => { + test.afterEach(() => provision.setClients(null)) + + test('resolves once the stack reaches the desired status', async () => { + const statuses = ['CREATE_IN_PROGRESS', 'CREATE_COMPLETE'] + let i = 0 + provision.setClients({ + cfn: mockClient(() => ({ + Stacks: [{ StackStatus: statuses[Math.min(i++, 1)] }], + })), + }) + const stack = await provision.waitForStack({ + stackName: 'mmgis-dashboard-1', + pollIntervalMs: 1, + }) + expect(stack.StackStatus).toBe('CREATE_COMPLETE') + }) + + test('throws on a terminal failure status', async () => { + provision.setClients({ + cfn: mockClient(() => ({ + Stacks: [ + { + StackStatus: 'ROLLBACK_COMPLETE', + StackStatusReason: 'Resource creation cancelled', + }, + ], + })), + }) + await expect( + provision.waitForStack({ + stackName: 'mmgis-dashboard-1', + pollIntervalMs: 1, + }) + ).rejects.toThrow(/ROLLBACK_COMPLETE/) + }) +}) + +test.describe('getStackOutputs', () => { + test('maps Outputs into a key/value object', () => { + expect( + provision.getStackOutputs({ + Outputs: [ + { OutputKey: 'BucketName', OutputValue: 'bkt' }, + { OutputKey: 'DistributionDomainName', OutputValue: 'd.cloudfront.net' }, + ], + }) + ).toEqual({ + BucketName: 'bkt', + DistributionDomainName: 'd.cloudfront.net', + }) + expect(provision.getStackOutputs(null)).toEqual({}) + }) +}) + +test.describe('emptyBucket', () => { + test.afterEach(() => provision.setClients(null)) + + test('lists and deletes every object, following pagination', async () => { + const deleted = [] + let page = 0 + provision.setClients({ + s3: mockClient((command) => { + const name = command.constructor.name + if (name === 'ListObjectsV2Command') { + page++ + return page === 1 + ? { + Contents: [{ Key: 'a' }, { Key: 'b' }], + IsTruncated: true, + NextContinuationToken: 't', + } + : { Contents: [{ Key: 'c' }], IsTruncated: false } + } + if (name === 'DeleteObjectsCommand') { + deleted.push( + ...command.input.Delete.Objects.map((o) => o.Key) + ) + return {} + } + throw new Error(`Unexpected command ${name}`) + }), + }) + const count = await provision.emptyBucket({ bucket: 'bkt' }) + expect(count).toBe(3) + expect(deleted).toEqual(['a', 'b', 'c']) + }) + + test('treats a missing bucket as already empty', async () => { + provision.setClients({ + s3: mockClient(() => { + const err = new Error('The specified bucket does not exist') + err.name = 'NoSuchBucket' + throw err + }), + }) + expect(await provision.emptyBucket({ bucket: 'gone' })).toBe(0) + }) +}) + +test.describe('copyPrefix', () => { + test.afterEach(() => provision.setClients(null)) + + test('same-key copies every object under the prefix', async () => { + const copies = [] + provision.setClients({ + s3: mockClient((command) => { + const name = command.constructor.name + if (name === 'ListObjectsV2Command') { + expect(command.input.Prefix).toBe('assets/TestMission/') + return { + Contents: [ + { Key: 'assets/TestMission/icon.png' }, + { Key: 'assets/TestMission/photo.jpg' }, + ], + IsTruncated: false, + } + } + if (name === 'CopyObjectCommand') { + copies.push(command.input) + return {} + } + throw new Error(`Unexpected command ${name}`) + }), + }) + const count = await provision.copyPrefix({ + sourceBucket: 'shared', + destBucket: 'dash', + prefix: 'assets/TestMission/', + }) + expect(count).toBe(2) + // Same keys in the destination bucket + expect(copies[0].Bucket).toBe('dash') + expect(copies[0].Key).toBe('assets/TestMission/icon.png') + expect(decodeURIComponent(copies[0].CopySource)).toBe( + 'shared/assets/TestMission/icon.png' + ) + }) +}) + +test.describe('copyObjectIfExists', () => { + test.afterEach(() => provision.setClients(null)) + + test('returns false when the source object is absent', async () => { + provision.setClients({ + s3: mockClient(() => { + const err = new Error('NoSuchKey') + err.name = 'NoSuchKey' + throw err + }), + }) + expect( + await provision.copyObjectIfExists({ + sourceBucket: 'shared', + destBucket: 'dash', + key: 'Missions/Test/Data/mosaic_parameters.csv', + }) + ).toBe(false) + }) + + test('returns true when copied', async () => { + provision.setClients({ s3: mockClient(() => ({})) }) + expect( + await provision.copyObjectIfExists({ + sourceBucket: 'shared', + destBucket: 'dash', + key: 'Missions/Test/Data/mosaic_parameters.csv', + }) + ).toBe(true) + }) +}) + +test.describe('runPublishTask', () => { + const ENV_NAMES = [ + 'MMGIS_PUBLISH_ECS_CLUSTER', + 'MMGIS_PUBLISH_TASK_DEFINITION', + 'MMGIS_PUBLISH_SUBNETS', + 'MMGIS_PUBLISH_SECURITY_GROUPS', + ] + let savedEnv + + test.beforeEach(() => { + savedEnv = {} + ENV_NAMES.forEach((name) => { + savedEnv[name] = process.env[name] + delete process.env[name] + }) + }) + + test.afterEach(() => { + ENV_NAMES.forEach((name) => { + if (savedEnv[name] === undefined) delete process.env[name] + else process.env[name] = savedEnv[name] + }) + provision.setClients(null) + }) + + test('throws a clear error when configuration is missing', async () => { + provision.setClients({ ecs: mockClient(() => ({})) }) + await expect( + provision.runPublishTask({ deploymentId: 1, action: 'publish' }) + ).rejects.toThrow(/MMGIS_PUBLISH_ECS_CLUSTER/) + }) + + test('starts the task with the deployment id and action', async () => { + process.env.MMGIS_PUBLISH_ECS_CLUSTER = 'mmgis-cluster' + process.env.MMGIS_PUBLISH_TASK_DEFINITION = 'mmgis-publish' + process.env.MMGIS_PUBLISH_SUBNETS = 'subnet-1, subnet-2' + process.env.MMGIS_PUBLISH_SECURITY_GROUPS = 'sg-1' + + let input + provision.setClients({ + ecs: mockClient((command) => { + input = command.input + return { tasks: [{ taskArn: 'arn:aws:ecs:task/1' }], failures: [] } + }), + }) + const arn = await provision.runPublishTask({ + deploymentId: 7, + action: 'update', + }) + expect(arn).toBe('arn:aws:ecs:task/1') + expect(input.cluster).toBe('mmgis-cluster') + expect(input.taskDefinition).toBe('mmgis-publish') + expect(input.networkConfiguration.awsvpcConfiguration.subnets).toEqual( + ['subnet-1', 'subnet-2'] + ) + const env = input.overrides.containerOverrides[0].environment + expect(env).toContainEqual({ name: 'DEPLOYMENT_ID', value: '7' }) + expect(env).toContainEqual({ name: 'DEPLOYMENT_ACTION', value: 'update' }) + }) + + test('throws when RunTask reports failures', async () => { + process.env.MMGIS_PUBLISH_ECS_CLUSTER = 'mmgis-cluster' + process.env.MMGIS_PUBLISH_TASK_DEFINITION = 'mmgis-publish' + process.env.MMGIS_PUBLISH_SUBNETS = 'subnet-1' + process.env.MMGIS_PUBLISH_SECURITY_GROUPS = 'sg-1' + + provision.setClients({ + ecs: mockClient(() => ({ + tasks: [], + failures: [{ reason: 'RESOURCE:MEMORY' }], + })), + }) + await expect( + provision.runPublishTask({ deploymentId: 7, action: 'publish' }) + ).rejects.toThrow(/RESOURCE:MEMORY/) + }) +}) diff --git a/tests/unit/bakeGuards.spec.js b/tests/unit/bakeGuards.spec.js new file mode 100644 index 000000000..d8fc4912a --- /dev/null +++ b/tests/unit/bakeGuards.spec.js @@ -0,0 +1,125 @@ +import { test, expect } from '@playwright/test' + +// Tests for the static-bake config guards (scripts/lib/bake-guards.js): +// a published dashboard has no backend, so config.time.enabled is baked +// off unless an externally-served time-enabled layer remains. + +const { + isExternallyServedUrl, + hasResolvableTimeLayer, + applyTimeBakeGuard, +} = require('../../scripts/lib/bake-guards') + +const timeLayer = (url) => ({ + name: 'tl', + type: 'tile', + url, + time: { enabled: true }, +}) + +test.describe('isExternallyServedUrl', () => { + test('absolute and protocol-relative urls are external', () => { + expect(isExternallyServedUrl('https://example.com/{z}/{x}/{y}.png')).toBe(true) + expect(isExternallyServedUrl('http://example.com/tiles')).toBe(true) + expect(isExternallyServedUrl('//example.com/tiles')).toBe(true) + }) + + test('backend-relative urls and non-strings are not', () => { + expect(isExternallyServedUrl('Missions/Test/Layers/tiles/{z}/{x}/{y}.png')).toBe(false) + expect(isExternallyServedUrl('/api/tiles/{z}/{x}/{y}.png')).toBe(false) + expect(isExternallyServedUrl('')).toBe(false) + expect(isExternallyServedUrl(null)).toBe(false) + expect(isExternallyServedUrl(undefined)).toBe(false) + }) +}) + +test.describe('hasResolvableTimeLayer', () => { + test('false with no layers or no time layers', () => { + expect(hasResolvableTimeLayer({})).toBe(false) + expect(hasResolvableTimeLayer({ layers: [] })).toBe(false) + expect( + hasResolvableTimeLayer({ + layers: [{ name: 'plain', url: 'https://example.com/t' }], + }) + ).toBe(false) + }) + + test('false when the only time layer is backend-served', () => { + expect( + hasResolvableTimeLayer({ + layers: [timeLayer('Missions/Test/tiles/{z}/{x}/{y}.png')], + }) + ).toBe(false) + }) + + test('true when an external time layer exists', () => { + expect( + hasResolvableTimeLayer({ + layers: [timeLayer('https://example.com/{time}/{z}/{x}/{y}.png')], + }) + ).toBe(true) + }) + + test('finds time layers nested in sublayers', () => { + expect( + hasResolvableTimeLayer({ + layers: [ + { + name: 'header', + sublayers: [timeLayer('https://example.com/t')], + }, + ], + }) + ).toBe(true) + }) + + test('ignores layers with time.enabled false', () => { + expect( + hasResolvableTimeLayer({ + layers: [ + { + name: 'tl', + url: 'https://example.com/t', + time: { enabled: false }, + }, + ], + }) + ).toBe(false) + }) +}) + +test.describe('applyTimeBakeGuard', () => { + test('disables time.enabled when no resolvable time layer remains', () => { + const config = { + time: { enabled: true }, + layers: [timeLayer('Missions/Test/tiles/{z}/{x}/{y}.png')], + } + applyTimeBakeGuard(config) + expect(config.time.enabled).toBe(false) + }) + + test('disables time.enabled when there are no time layers at all', () => { + const config = { time: { enabled: true }, layers: [] } + applyTimeBakeGuard(config) + expect(config.time.enabled).toBe(false) + }) + + test('keeps time.enabled when an external time layer remains', () => { + const config = { + time: { enabled: true }, + layers: [timeLayer('https://example.com/{time}/{z}/{x}/{y}.png')], + } + applyTimeBakeGuard(config) + expect(config.time.enabled).toBe(true) + }) + + test('leaves configs without time untouched and tolerates null', () => { + expect(applyTimeBakeGuard(null)).toBe(null) + const config = { layers: [] } + applyTimeBakeGuard(config) + expect(config.time).toBeUndefined() + const disabled = { time: { enabled: false }, layers: [] } + applyTimeBakeGuard(disabled) + expect(disabled.time.enabled).toBe(false) + }) +}) diff --git a/tests/unit/cfnTemplate.spec.js b/tests/unit/cfnTemplate.spec.js new file mode 100644 index 000000000..695b706cf --- /dev/null +++ b/tests/unit/cfnTemplate.spec.js @@ -0,0 +1,115 @@ +import { test, expect } from '@playwright/test' + +// Tests for the per-dashboard CloudFormation template renderer +// (scripts/lib/cfn-template.js) used by the lean publish flow. + +const { + STACK_NAME_PREFIX, + BASIC_AUTH_USER, + stackNameForDeployment, + renderAuthFunctionCode, + renderCfnTemplate, +} = require('../../scripts/lib/cfn-template') + +const PASSWORD = 'a-Distinctive-Passw0rd!' + +test.describe('stackNameForDeployment', () => { + test('encodes the deployment id with the mmgis-dashboard- prefix', () => { + expect(stackNameForDeployment(12)).toBe('mmgis-dashboard-12') + expect(stackNameForDeployment('40')).toBe('mmgis-dashboard-40') + expect(STACK_NAME_PREFIX).toBe('mmgis-dashboard-') + }) + + test('throws without an id', () => { + expect(() => stackNameForDeployment(null)).toThrow() + expect(() => stackNameForDeployment('')).toThrow() + }) +}) + +test.describe('renderCfnTemplate', () => { + test('throws without a password', () => { + expect(() => renderCfnTemplate({})).toThrow(/password/) + expect(() => renderCfnTemplate({ password: '' })).toThrow(/password/) + }) + + test('renders valid JSON with the expected resources', () => { + const template = JSON.parse(renderCfnTemplate({ password: PASSWORD })) + const resources = template.Resources + expect(resources.DashboardBucket.Type).toBe('AWS::S3::Bucket') + expect(resources.DashboardBucketPolicy.Type).toBe( + 'AWS::S3::BucketPolicy' + ) + expect(resources.DashboardOriginAccessControl.Type).toBe( + 'AWS::CloudFront::OriginAccessControl' + ) + expect(resources.DashboardAuthFunction.Type).toBe( + 'AWS::CloudFront::Function' + ) + expect(resources.DashboardDistribution.Type).toBe( + 'AWS::CloudFront::Distribution' + ) + }) + + test('has no Parameters block — the password is never a CFN parameter', () => { + const template = JSON.parse(renderCfnTemplate({ password: PASSWORD })) + expect(template.Parameters).toBeUndefined() + }) + + test('bakes the password into the Function code as a base64 constant', () => { + const body = renderCfnTemplate({ password: PASSWORD }) + const template = JSON.parse(body) + const code = + template.Resources.DashboardAuthFunction.Properties.FunctionCode + const expected = Buffer.from( + `${BASIC_AUTH_USER}:${PASSWORD}` + ).toString('base64') + expect(code).toContain(`Basic ${expected}`) + // The plaintext password never appears anywhere in the template + expect(body).not.toContain(PASSWORD) + }) + + test('auth function returns 401 with a www-authenticate challenge', () => { + const code = renderAuthFunctionCode(PASSWORD) + expect(code).toContain('statusCode: 401') + expect(code).toContain('www-authenticate') + expect(code).toContain('return request') + }) + + test('distribution is gated by the viewer-request function and serves index.html', () => { + const template = JSON.parse(renderCfnTemplate({ password: PASSWORD })) + const dist = + template.Resources.DashboardDistribution.Properties + .DistributionConfig + expect(dist.DefaultRootObject).toBe('index.html') + const associations = dist.DefaultCacheBehavior.FunctionAssociations + expect(associations).toHaveLength(1) + expect(associations[0].EventType).toBe('viewer-request') + expect(associations[0].FunctionARN['Fn::GetAtt']).toEqual([ + 'DashboardAuthFunction', + 'FunctionARN', + ]) + }) + + test('bucket blocks all public access; CloudFront reads via OAC', () => { + const template = JSON.parse(renderCfnTemplate({ password: PASSWORD })) + const block = + template.Resources.DashboardBucket.Properties + .PublicAccessBlockConfiguration + expect(block.BlockPublicAcls).toBe(true) + expect(block.RestrictPublicBuckets).toBe(true) + const statement = + template.Resources.DashboardBucketPolicy.Properties.PolicyDocument + .Statement[0] + expect(statement.Principal.Service).toBe('cloudfront.amazonaws.com') + expect(statement.Action).toBe('s3:GetObject') + }) + + test('declares the outputs the publish task and routes consume', () => { + const template = JSON.parse(renderCfnTemplate({ password: PASSWORD })) + expect(Object.keys(template.Outputs).sort()).toEqual([ + 'BucketName', + 'DistributionDomainName', + 'DistributionId', + ]) + }) +}) From d161d197961fb842341fe92b708c52f1ad7ac0ec Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 10 Jun 2026 08:17:01 -0500 Subject: [PATCH 13/63] Address publish-flow review findings Add a delete confirmation modal, make bundle uploads retryable (explicit ContentLength), skip live stack lookups for deleted rows, centralize deployment status strings, make publish task re-runs idempotent when the stack already exists, surface SaveBar publish errors and guard against double-click duplicates, and deduplicate requireEnv. --- API/Backend/Deployments/models/deployment.js | 19 +- API/Backend/Deployments/routes/deployments.js | 19 +- configure/src/components/SaveBar/SaveBar.js | 31 ++- configure/src/core/ConfigureStore.js | 1 + .../src/pages/Deployments/Deployments.js | 39 ++- .../DeleteDeploymentModal.js | 242 ++++++++++++++++++ scripts/lib/aws-provision.js | 5 + scripts/publish-static.js | 35 +-- 8 files changed, 339 insertions(+), 52 deletions(-) create mode 100644 configure/src/pages/Deployments/Modals/DeleteDeploymentModal/DeleteDeploymentModal.js diff --git a/API/Backend/Deployments/models/deployment.js b/API/Backend/Deployments/models/deployment.js index 0730d4771..46f0df8a4 100644 --- a/API/Backend/Deployments/models/deployment.js +++ b/API/Backend/Deployments/models/deployment.js @@ -4,6 +4,18 @@ const Sequelize = require("sequelize"); const { sequelize } = require("../../../connection"); +// Canonical deployment lifecycle statuses (the `status` column). Server-side +// code (routes, publish task) must use these constants; the Configure UI keeps +// its display literals. +const STATUS = Object.freeze({ + PROVISIONING: "provisioning", + PUBLISHED: "published", + UPDATING: "updating", + DELETING: "deleting", + DELETED: "deleted", + FAILED: "failed", +}); + // One row per published dashboard (a standalone, statically-hosted copy of a // mission). Identity lives here; live stack status comes from CloudFormation // DescribeStacks at read time, not from this row. @@ -24,11 +36,12 @@ var Deployments = sequelize.define( type: Sequelize.STRING, allowNull: true, }, - // provisioning | published | updating | deleting | deleted | failed + // One of STATUS (provisioning | published | updating | deleting | + // deleted | failed) status: { type: Sequelize.STRING, allowNull: false, - defaultValue: "provisioning", + defaultValue: STATUS.PROVISIONING, }, stack_arn: { type: Sequelize.STRING, @@ -59,5 +72,7 @@ var Deployments = sequelize.define( } ); +Deployments.STATUS = STATUS; + // export Deployments model for use in other files. module.exports = Deployments; diff --git a/API/Backend/Deployments/routes/deployments.js b/API/Backend/Deployments/routes/deployments.js index 5a401a563..ff1aa081e 100644 --- a/API/Backend/Deployments/routes/deployments.js +++ b/API/Backend/Deployments/routes/deployments.js @@ -7,6 +7,7 @@ const router = express.Router(); const logger = require("../../../logger"); const Deployments = require("../models/deployment"); +const STATUS = Deployments.STATUS; const triggerWebhooks = require("../../Webhooks/processes/triggerwebhooks"); @@ -31,6 +32,8 @@ async function withLiveStatus(deployment) { const row = deployment.toJSON(); row.stack_status = null; if (row.stack_name == null) return row; + // Deleted rows are history — never describe their (long-gone) stacks. + if (row.status === STATUS.DELETED) return row; try { const stack = await provision.describeStack({ stackName: row.stack_name, @@ -39,9 +42,9 @@ async function withLiveStatus(deployment) { row.stack_status = stack.StackStatus; if (stack.StackStatusReason != null) row.stack_status_reason = stack.StackStatusReason; - } else if (row.status === "deleting") { - await deployment.update({ status: "deleted" }); - row.status = "deleted"; + } else if (row.status === STATUS.DELETING) { + await deployment.update({ status: STATUS.DELETED }); + row.status = STATUS.DELETED; } } catch (err) { // Live status is best-effort; report the failure rather than erroring @@ -68,7 +71,7 @@ router.post("/publish", async function (req, res) { name: name != null && name !== "" ? name : mission, mission: mission, created_by: req.user || null, - status: "provisioning", + status: STATUS.PROVISIONING, }); await deployment.update({ stack_name: stackNameForDeployment(deployment.id), @@ -88,7 +91,7 @@ router.post("/publish", async function (req, res) { ); Deployments.update( { - status: "failed", + status: STATUS.FAILED, last_error: `Failed to start publish task: ${err.message}`, }, { where: { id: deployment.id } } @@ -119,7 +122,7 @@ router.post("/:id/update", async function (req, res) { return; } - await deployment.update({ status: "updating", last_error: null }); + await deployment.update({ status: STATUS.UPDATING, last_error: null }); provision .runPublishTask({ deploymentId: deployment.id, action: "update" }) @@ -133,7 +136,7 @@ router.post("/:id/update", async function (req, res) { ); Deployments.update( { - status: "failed", + status: STATUS.FAILED, last_error: `Failed to start update task: ${err.message}`, }, { where: { id: deployment.id } } @@ -167,7 +170,7 @@ router.delete("/:id", async function (req, res) { return; } - await deployment.update({ status: "deleting", last_error: null }); + await deployment.update({ status: STATUS.DELETING, last_error: null }); // Teardown continues after the response; failures land in last_error // and the Delete affordance retries. diff --git a/configure/src/components/SaveBar/SaveBar.js b/configure/src/components/SaveBar/SaveBar.js index da43bd439..f82c150ca 100644 --- a/configure/src/components/SaveBar/SaveBar.js +++ b/configure/src/components/SaveBar/SaveBar.js @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import { useSelector, useDispatch } from "react-redux"; import {} from "./SaveBarSlice"; import { makeStyles } from "@mui/styles"; @@ -72,11 +72,18 @@ export default function SaveBar() { const validationErrors = useSelector((state) => state.core.validationErrors); const hasValidationErrors = validationErrors && validationErrors.length > 0; + // True while a save-and-publish round trip is in flight; disables the + // Publish button so a double-click can't start duplicate publish tasks. + const [publishing, setPublishing] = useState(false); + // Lean publish flow: publish this mission as a standalone dashboard (or // update its existing one — lean is 1:1, a mission is a dashboard). // Publishing is a background job; the Deployments page shows live status. const publishMission = () => { - if (mission == null) return; + if (mission == null) { + setPublishing(false); + return; + } calls.api( "getDeployments", {}, @@ -93,6 +100,7 @@ export default function SaveBar() { call, data, () => { + setPublishing(false); dispatch( setSnackBarText({ text: "Publishing… — see Deployments for live status.", @@ -101,6 +109,7 @@ export default function SaveBar() { ); }, (res) => { + setPublishing(false); dispatch( setSnackBarText({ text: res?.message || "Failed to publish.", @@ -111,6 +120,7 @@ export default function SaveBar() { ); }, (res) => { + setPublishing(false); dispatch( setSnackBarText({ text: res?.message || "Failed to query deployments.", @@ -122,6 +132,8 @@ export default function SaveBar() { }; const saveAndPublish = () => { + if (publishing) return; + setPublishing(true); dispatch( saveConfiguration({ cb: (status, resp) => { @@ -134,11 +146,21 @@ export default function SaveBar() { dispatch(setConfiguration(res)); dispatch(clearLockConfig({})); }, - () => {} + (res) => { + dispatch( + setSnackBarText({ + text: + res?.message || + "Failed to get configuration for mission.", + severity: "error", + }) + ); + } ); } publishMission(); } else { + setPublishing(false); dispatch( setSnackBarText({ text: @@ -220,13 +242,14 @@ export default function SaveBar() { ) : null} diff --git a/configure/src/core/ConfigureStore.js b/configure/src/core/ConfigureStore.js index 2f79624aa..94fd66b80 100644 --- a/configure/src/core/ConfigureStore.js +++ b/configure/src/core/ConfigureStore.js @@ -50,6 +50,7 @@ export const ConfigureStore = createSlice({ deleteUser: false, newUser: false, resetPassword: false, + deleteDeployment: false, }, snackBarText: false, lockConfig: false, diff --git a/configure/src/pages/Deployments/Deployments.js b/configure/src/pages/Deployments/Deployments.js index 232dcb9d5..74cd788fa 100644 --- a/configure/src/pages/Deployments/Deployments.js +++ b/configure/src/pages/Deployments/Deployments.js @@ -2,8 +2,12 @@ import React, { useCallback, useEffect, useState } from "react"; import { useSelector, useDispatch } from "react-redux"; import { makeStyles } from "@mui/styles"; +import clsx from "clsx"; + import { calls } from "../../core/calls"; -import { setSnackBarText } from "../../core/ConfigureStore"; +import { setModal, setSnackBarText } from "../../core/ConfigureStore"; + +import DeleteDeploymentModal from "./Modals/DeleteDeploymentModal/DeleteDeploymentModal"; import Button from "@mui/material/Button"; import IconButton from "@mui/material/IconButton"; @@ -236,27 +240,15 @@ export default function Deployments() { ); }; + // Deletion is destructive (tears down the dashboard's hosting), so it + // goes through an explicit type-the-name confirmation modal; the modal + // owns the DELETE call. const remove = (deployment) => { - calls.api( - "deleteDeployment", - { urlReplacements: { id: deployment.id } }, - () => { - dispatch( - setSnackBarText({ - text: `Deleting '${deployment.name}'…`, - severity: "success", - }) - ); - queryDeployments(); - }, - (res) => { - dispatch( - setSnackBarText({ - text: res?.message || "Failed to delete.", - severity: "error", - }) - ); - } + dispatch( + setModal({ + name: "deleteDeployment", + deployment: deployment, + }) ); }; @@ -338,7 +330,9 @@ export default function Deployments() { {d.mission}
{d.status} @@ -385,6 +379,7 @@ export default function Deployments() { )) )}
+ ); } diff --git a/configure/src/pages/Deployments/Modals/DeleteDeploymentModal/DeleteDeploymentModal.js b/configure/src/pages/Deployments/Modals/DeleteDeploymentModal/DeleteDeploymentModal.js new file mode 100644 index 000000000..cb7b7d039 --- /dev/null +++ b/configure/src/pages/Deployments/Modals/DeleteDeploymentModal/DeleteDeploymentModal.js @@ -0,0 +1,242 @@ +import React, { useState } from "react"; +import { useSelector, useDispatch } from "react-redux"; + +import { calls } from "../../../../core/calls"; + +import { setModal, setSnackBarText } from "../../../../core/ConfigureStore"; + +import Typography from "@mui/material/Typography"; +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import IconButton from "@mui/material/IconButton"; +import TextField from "@mui/material/TextField"; + +import CloseSharpIcon from "@mui/icons-material/CloseSharp"; +import RocketLaunchIcon from "@mui/icons-material/RocketLaunch"; +import DeleteForeverIcon from "@mui/icons-material/DeleteForever"; + +import { makeStyles, useTheme } from "@mui/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; + +const useStyles = makeStyles((theme) => ({ + Modal: { + margin: theme.headHeights[1], + [theme.breakpoints.down("xs")]: { + margin: "6px", + }, + "& .MuiDialog-container": { + height: "unset !important", + transform: "translateX(-50%) translateY(-50%)", + left: "50%", + top: "50%", + position: "absolute", + }, + }, + contents: { + background: theme.palette.primary.main, + height: "100%", + width: "500px", + }, + heading: { + height: theme.headHeights[2], + boxSizing: "border-box", + background: theme.palette.swatches.p[4], + borderBottom: `1px solid ${theme.palette.swatches.grey[800]}`, + padding: `4px ${theme.spacing(2)} 4px ${theme.spacing(4)} !important`, + }, + title: { + padding: `8px 0px`, + fontSize: theme.typography.pxToRem(16), + fontWeight: "bold", + color: theme.palette.swatches.grey[0], + textTransform: "uppercase", + }, + content: { + padding: "8px 16px 16px 16px !important", + height: `calc(100% - ${theme.headHeights[2]}px)`, + }, + closeIcon: { + padding: theme.spacing(1.5), + height: "100%", + margin: "4px 0px", + }, + flexBetween: { + display: "flex", + justifyContent: "space-between", + }, + backgroundIcon: { + margin: "7px 8px 0px 0px", + }, + deploymentName: { + textAlign: "center", + fontSize: "24px !important", + letterSpacing: "1px !important", + color: theme.palette.swatches.p[4], + fontWeight: "bold !important", + margin: "10px !important", + borderBottom: `1px solid ${theme.palette.swatches.grey[100]}`, + paddingBottom: "10px", + }, + warning: { + fontStyle: "italic", + fontSize: "14px !important", + margin: "10px !important", + color: theme.palette.swatches.grey[300], + }, + confirmInput: { + width: "100%", + margin: "10px 0px 4px 0px !important", + borderTop: `1px solid ${theme.palette.swatches.grey[500]}`, + }, + confirmMessage: { + fontStyle: "italic", + fontSize: "15px !important", + }, + dialogActions: { + display: "flex !important", + justifyContent: "space-between !important", + }, + delete: { + background: `${theme.palette.swatches.p[4]} !important`, + color: `${theme.palette.swatches.grey[1000]} !important`, + "&:hover": { + background: `${theme.palette.swatches.grey[0]} !important`, + }, + }, + cancel: {}, +})); + +const MODAL_NAME = "deleteDeployment"; +const DeleteDeploymentModal = (props) => { + const { queryDeployments } = props; + const c = useStyles(); + + const modal = useSelector((state) => state.core.modal[MODAL_NAME]); + + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("md")); + + const dispatch = useDispatch(); + + const [deploymentName, setDeploymentName] = useState(""); + + const deployment = modal?.deployment; + + const handleClose = () => { + setDeploymentName(""); + dispatch(setModal({ name: MODAL_NAME, on: false })); + }; + const handleSubmit = () => { + if (deployment?.name == null) { + dispatch( + setSnackBarText({ + text: "Cannot delete undefined Deployment.", + severity: "error", + }) + ); + return; + } + + if (deploymentName !== deployment.name) { + dispatch( + setSnackBarText({ + text: "Confirmation Deployment name does not match.", + severity: "error", + }) + ); + return; + } + + calls.api( + "deleteDeployment", + { urlReplacements: { id: deployment.id } }, + () => { + dispatch( + setSnackBarText({ + text: `Deleting '${deployment.name}'…`, + severity: "success", + }) + ); + queryDeployments(); + handleClose(); + }, + (res) => { + dispatch( + setSnackBarText({ + text: res?.message || `Failed to delete '${deployment.name}'.`, + severity: "error", + }) + ); + } + ); + }; + + return ( + + +
+
+ +
Delete a Deployment
+
+ + + +
+
+ + {`Deleting: ${deployment?.name}`} + + This tears down the dashboard's hosting — its bucket is emptied and + its CloudFormation stack is deleted. The published URL stops working. + + { + setDeploymentName(e.target.value); + }} + /> + {`Enter '${deployment?.name}' above and click 'Delete' to confirm the permanent deletion of this Deployment.`} + + + + + +
+ ); +}; + +export default DeleteDeploymentModal; diff --git a/scripts/lib/aws-provision.js b/scripts/lib/aws-provision.js index 4c44b0f78..9649feed8 100644 --- a/scripts/lib/aws-provision.js +++ b/scripts/lib/aws-provision.js @@ -205,6 +205,10 @@ async function uploadDirectory({ bucket, dir, prefix = "", concurrency = 8 }) { Bucket: bucket, Key: `${prefix}${file.key}`, Body: fs.createReadStream(file.absolute), + // An explicit length keeps the streaming PUT retryable by the + // SDK (an unknown-length stream is sent unsigned/non-retryable, + // so one network blip would fail the whole publish). + ContentLength: fs.statSync(file.absolute).size, ContentType: contentTypeForFile(file.absolute), }) ); @@ -387,5 +391,6 @@ module.exports = { copyPrefix, copyObjectIfExists, emptyBucket, + requireEnv, runPublishTask, }; diff --git a/scripts/publish-static.js b/scripts/publish-static.js index bf6c95328..22914a6a6 100644 --- a/scripts/publish-static.js +++ b/scripts/publish-static.js @@ -32,19 +32,12 @@ const { applyTimeBakeGuard } = require("./lib/bake-guards"); const DEPLOYMENT_ID = process.env.DEPLOYMENT_ID || process.argv[2]; const ACTION = process.env.DEPLOYMENT_ACTION || process.argv[3] || "publish"; +const { requireEnv } = provision; + function log(message) { console.log(`[publish-static] ${message}`); } -function requireEnv(name) { - const value = process.env[name]; - if (value == null || value === "") - throw new Error( - `Missing required environment variable '${name}' (lean publish flow; see sample.env)` - ); - return value; -} - // Runs an npm script synchronously from the repo root; throws on failure. function run(command, args, extraEnv) { log(`Running: ${command} ${args.join(" ")}`); @@ -133,11 +126,21 @@ async function main() { // 3. Provision (publish) or look up (update) the dashboard stack let stack; if (ACTION === "publish") { - const templateBody = renderCfnTemplate({ - password: requireEnv("MMGIS_DASHBOARDS_PASSWORD"), - }); - log(`Creating stack '${stackName}'...`); - await provision.createStack({ stackName, templateBody }); + // Idempotent re-run: a previous attempt may have created the stack + // and failed later (e.g. mid-upload) — reuse it instead of dying on + // CloudFormation's AlreadyExistsException. + const existing = await provision.describeStack({ stackName }); + if (existing == null) { + const templateBody = renderCfnTemplate({ + password: requireEnv("MMGIS_DASHBOARDS_PASSWORD"), + }); + log(`Creating stack '${stackName}'...`); + await provision.createStack({ stackName, templateBody }); + } else { + log( + `Stack '${stackName}' already exists (${existing.StackStatus}); skipping CreateStack.` + ); + } stack = await provision.waitForStack({ stackName }); log(`Stack '${stackName}' reached ${stack.StackStatus}.`); } else { @@ -192,7 +195,7 @@ async function main() { ? `https://${outputs.DistributionDomainName}` : deployment.cloudfront_url; await deployment.update({ - status: "published", + status: Deployments.STATUS.PUBLISHED, stack_arn: stack.StackId, stack_name: stackName, cloudfront_url: cloudfrontUrl, @@ -208,7 +211,7 @@ async function main() { console.error(err); await deployment .update({ - status: "failed", + status: Deployments.STATUS.FAILED, last_error: err.message || String(err), }) .catch(() => {}); From cb834deeb6266f865457a7a88bda253690c566c8 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 10 Jun 2026 08:28:45 -0500 Subject: [PATCH 14/63] Add lean AWS infrastructure recipes and deploy pipeline infrastructure/ holds the lean deployment's ECS task definitions, two-roles-per-task least-privilege IAM (everything pinned to the mmgis-dashboard-* prefix or the shared asset bucket), the admin CloudFront config (AllViewer + CachingDisabled, /assets/* behavior), the password-gate CloudFront Function reference, and the shared S3 asset bucket. deploy-lean.yml builds and pushes the image and rolls out a new task-definition revision via ECS Express Mode. trust proxy becomes 2 for the CloudFront->ALB->ECS hop count. The full/upstream deployment uses none of this. --- .github/workflows/deploy-lean.yml | 132 +++++++++ infrastructure/README.md | 74 +++++ infrastructure/cloudfront-admin.json | 76 ++++++ infrastructure/cloudfront-function.js | 34 +++ infrastructure/ecs/admin-task.json | 68 +++++ infrastructure/ecs/publish-task.json | 46 ++++ .../iam/admin-task-execution-role.json | 65 +++++ infrastructure/iam/admin-task-role.json | 78 ++++++ .../iam/publish-task-execution-role.json | 59 ++++ infrastructure/iam/publish-task-role.json | 115 ++++++++ infrastructure/s3-asset-bucket.json | 65 +++++ scripts/server.js | 4 +- tests/unit/infrastructure.spec.js | 257 ++++++++++++++++++ 13 files changed, 1071 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/deploy-lean.yml create mode 100644 infrastructure/README.md create mode 100644 infrastructure/cloudfront-admin.json create mode 100644 infrastructure/cloudfront-function.js create mode 100644 infrastructure/ecs/admin-task.json create mode 100644 infrastructure/ecs/publish-task.json create mode 100644 infrastructure/iam/admin-task-execution-role.json create mode 100644 infrastructure/iam/admin-task-role.json create mode 100644 infrastructure/iam/publish-task-execution-role.json create mode 100644 infrastructure/iam/publish-task-role.json create mode 100644 infrastructure/s3-asset-bucket.json create mode 100644 tests/unit/infrastructure.spec.js diff --git a/.github/workflows/deploy-lean.yml b/.github/workflows/deploy-lean.yml new file mode 100644 index 000000000..aaaefb5b3 --- /dev/null +++ b/.github/workflows/deploy-lean.yml @@ -0,0 +1,132 @@ +name: Deploy lean (AWS ECS Express Mode) + +# Lean-deployment pipeline (see infrastructure/README.md): +# build theme assets -> build image -> push to ECR -> register new +# mmgis-admin + mmgis-publish task-definition revisions -> trigger the +# ECS Express Mode managed rollout of the admin service. +# +# Per the D1 decision, ECS Express Mode owns the ALB, target groups, +# rollout strategy, and scaling — this workflow deliberately defines and +# touches none of them; it only registers task-definition revisions and +# asks the managed service to roll. +# +# Repository configuration: +# vars: AWS_REGION, ECR_REPOSITORY, ECS_CLUSTER, ECS_SERVICE +# secrets: AWS_DEPLOY_ROLE_ARN (OIDC-assumable deploy role; no +# long-lived keys) +# +# The full/upstream deployment does not use this workflow +# (docker-build.yml continues to publish the GHCR image). + +# limit concurrency +concurrency: deploy_lean_mmgis + +on: + # Deploy on final releases (same release convention as docker-build.yml) + release: + types: [published] + + # Plus manual rollouts + workflow_dispatch: + +permissions: + # OIDC token for aws-actions/configure-aws-credentials + id-token: write + contents: read + +env: + ADMIN_TASK_FAMILY: mmgis-admin + PUBLISH_TASK_FAMILY: mmgis-publish + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Use Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: 20 + + # The Dockerfile's `npm run build` does NOT run build:themes; the + # generated public/ CSS + fonts must exist in the build context + # (COPY . .) or themed missions/dashboards render unstyled. + - name: Build theme assets + run: | + npm install --force + npm run build:themes + + - name: Configure AWS credentials (OIDC) + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + + - name: Login to Amazon ECR + id: ecr-login + uses: aws-actions/amazon-ecr-login@v2 + + - name: Docker build and push + id: build-image + env: + ECR_REGISTRY: ${{ steps.ecr-login.outputs.registry }} + ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }} + run: | + # Tag with the release tag (or the short SHA for manual runs) + VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') + [ "${{ github.event_name }}" == "workflow_dispatch" ] && VERSION=$(git rev-parse --short HEAD) + IMAGE_URI=$ECR_REGISTRY/$ECR_REPOSITORY:$VERSION + + docker build \ + --tag $IMAGE_URI \ + --no-cache \ + . + docker push $IMAGE_URI + + echo "IMAGE_URI=$IMAGE_URI" >> $GITHUB_OUTPUT + + # Register new revisions of both families pointing at the new image. + # The two families share the image (the publish task only overrides + # `command`); registering mmgis-publish is enough to roll it because + # RunTask resolves the bare family name to its latest revision. + - name: Register new task-definition revisions + id: register + env: + IMAGE_URI: ${{ steps.build-image.outputs.IMAGE_URI }} + run: | + register_revision() { + local family=$1 + aws ecs describe-task-definition \ + --task-definition "$family" \ + --query taskDefinition \ + --output json \ + | jq --arg IMAGE "$IMAGE_URI" ' + .containerDefinitions[0].image = $IMAGE + | del(.taskDefinitionArn, .revision, .status, + .requiresAttributes, .compatibilities, + .registeredAt, .registeredBy, .deregisteredAt) + ' > /tmp/$family.json + aws ecs register-task-definition \ + --cli-input-json file:///tmp/$family.json \ + --query taskDefinition.taskDefinitionArn \ + --output text + } + + ADMIN_TASK_DEF_ARN=$(register_revision "$ADMIN_TASK_FAMILY") + PUBLISH_TASK_DEF_ARN=$(register_revision "$PUBLISH_TASK_FAMILY") + echo "Registered $ADMIN_TASK_DEF_ARN" + echo "Registered $PUBLISH_TASK_DEF_ARN" + echo "ADMIN_TASK_DEF_ARN=$ADMIN_TASK_DEF_ARN" >> $GITHUB_OUTPUT + + # Express Mode handles the rollout choreography from here. + - name: Trigger ECS Express Mode rollout + run: | + aws ecs update-service \ + --cluster "${{ vars.ECS_CLUSTER }}" \ + --service "${{ vars.ECS_SERVICE }}" \ + --task-definition "${{ steps.register.outputs.ADMIN_TASK_DEF_ARN }}" + aws ecs wait services-stable \ + --cluster "${{ vars.ECS_CLUSTER }}" \ + --services "${{ vars.ECS_SERVICE }}" diff --git a/infrastructure/README.md b/infrastructure/README.md new file mode 100644 index 000000000..70580c7f6 --- /dev/null +++ b/infrastructure/README.md @@ -0,0 +1,74 @@ +# MMGIS Lean AWS Infrastructure + +Recipes for running MMGIS in the **lean** deployment shape (`MMGIS_DEPLOYMENT_MODE=lean`) on AWS: the admin app as a long-running ECS service, a short-lived ECS task that publishes a mission as a standalone static dashboard, the least-privilege IAM for both, the CloudFront distribution in front of the admin, the password-gate CloudFront Function reference, and the shared S3 asset bucket. + +**Dual-deployment posture:** the **full** deployment is the upstream MMGIS default (docker-compose, bundled sidecar services) and uses **none** of this directory. The **lean** deployment is this directory plus `.github/workflows/deploy-lean.yml`. The same image serves both; `MMGIS_DEPLOYMENT_MODE` is a runtime ECS environment variable, never a Docker build-arg (the `Dockerfile` is shared and unmodified). + +## Contents + +| File | What it is | +|---|---| +| `ecs/admin-task.json` | Task definition for the admin app (long-running service, ECS Express Mode) | +| `ecs/publish-task.json` | Task definition for the publish job (`node scripts/publish-static.js`, started per publish via `RunTask`) | +| `iam/admin-task-execution-role.json` | ECS-side role for the admin task (image pull, logs, secret injection) | +| `iam/admin-task-role.json` | Runtime role for the admin container code (RunTask + PassRole, dashboard stack read/delete, asset upload) | +| `iam/publish-task-execution-role.json` | ECS-side role for the publish task | +| `iam/publish-task-role.json` | Runtime role for the publish container code (dashboard stack create + the resources CloudFormation manages for it) | +| `cloudfront-admin.json` | `DistributionConfig` for the admin CloudFront distribution (`aws cloudfront create-distribution --distribution-config file://cloudfront-admin.json`) | +| `cloudfront-function.js` | Canonical reference for the per-dashboard password-gate Function (the deployed copy is generated by `scripts/lib/cfn-template.js`) | +| `s3-asset-bucket.json` | CloudFormation template for the shared admin asset bucket | + +The IAM files are `AWS::IAM::Role` resource snippets — drop them into a CloudFormation template's `Resources` block, or translate the `Policies[].PolicyDocument` blocks into `aws iam create-role` / `put-role-policy` calls. + +## Placeholders + +Replace these consistently across every file before applying: + +| Placeholder | Meaning | +|---|---| +| `` | AWS account id | +| `` | AWS region (same value as the `AWS_REGION` env var) | +| `` | ECR repository name holding the MMGIS image | +| `` | Full image URI incl. tag (the deploy workflow overwrites this per release) | +| `` | ECS cluster both tasks run on (value of `MMGIS_PUBLISH_ECS_CLUSTER`) | +| `` | Comma-separated subnet ids for the publish task (value of `MMGIS_PUBLISH_SUBNETS`) | +| `` | Comma-separated security group ids for the publish task (value of `MMGIS_PUBLISH_SECURITY_GROUPS`) | +| `` | Shared asset bucket name (value of `MMGIS_SHARED_ASSET_BUCKET`; created by `s3-asset-bucket.json`) | +| `` | Secrets Manager ARN of the DB-credentials secret (JSON keys `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS`) | +| `` | Secrets Manager ARN of the express-session secret (injected as env `SECRET` — the name `scripts/server.js` reads) | +| `` | Secrets Manager ARN of the superadmin seed username (`SEED_SUPERADMIN_USERNAME`) | +| `` | Secrets Manager ARN of the superadmin seed password (`SEED_SUPERADMIN_PASSWORD`) | +| `` | Secrets Manager ARN of the shared dashboards password (`MMGIS_DASHBOARDS_PASSWORD`) | +| `` | The admin's dedicated hostname (CloudFront alias) — operator-provided | +| `` | ACM certificate (us-east-1) covering `` — operator-provided | +| `` | DNS name of the ALB that ECS Express Mode manages for the admin service | +| `` | CloudFront Origin Access Control id created for the asset-bucket origin | +| `` | The admin CloudFront distribution id (for the asset bucket policy's `AWS:SourceArn` condition) | + +## Prerequisites (operator-provided, not created here) + +- **VPC + subnets.** An existing VPC; subnets for the admin service and for the publish task (`MMGIS_PUBLISH_SUBNETS`). +- **Admin hostname + ACM certificate.** The admin is reached at a dedicated subdomain. The DNS record and the ACM cert (in us-east-1 for CloudFront) are operator-provided, out-of-band; `cloudfront-admin.json` only references the cert ARN and the alias. +- **Secrets Manager entries:** the DB-credentials secret (JSON with `DB_HOST`/`DB_PORT`/`DB_NAME`/`DB_USER`/`DB_PASS` keys), the session secret, the dashboards shared password, and the superadmin seed credentials `SEED_SUPERADMIN_USERNAME`/`SEED_SUPERADMIN_PASSWORD`. The admin task def injects the seed credentials and sets `DISABLE_FIRST_SIGNUP=true` so that the superadmin is seeded automatically and the open first-signup route stays closed on a fresh lean deploy. +- **Outbound HTTPS egress.** The admin fires `triggerWebhooks(...)` to external URLs on Config saves and on Dashboards Publish/Update/Delete; a task in a private subnet needs a NAT gateway (or VPC endpoints for the AWS APIs plus egress for webhook targets) or webhooks hang and time out silently. The publish task also needs egress to reach CloudFormation/S3/CloudFront. +- **CloudWatch log groups** `/ecs/mmgis-admin` and `/ecs/mmgis-publish` pre-created (the execution roles deliberately omit `logs:CreateLogGroup`). +- **ECS cluster + Express Mode service.** Create the cluster and an ECS Express Mode service for the `mmgis-admin` family. Per the D1 decision, Express Mode owns the ALB, target groups, rollout strategy, and scaling — **no** ALB/target-group/scaling-policy definitions live in this directory, and the deploy workflow only registers a new task-definition revision and triggers the managed deployment. Point `cloudfront-admin.json`'s `` at the ALB endpoint Express Mode exposes. +- **ECR repository** for the image, and (for `deploy-lean.yml`) an OIDC deploy role with permission to push to it and roll the service. + +## How the pieces fit + +- **Admin task** (`ecs/admin-task.json`): runs the stock image (`_docker-entrypoint.sh`). `environment[]` carries `MMGIS_DEPLOYMENT_MODE=lean`, `DISABLE_FIRST_SIGNUP=true`, and the publish-flow variables the Deployments backend reads (`AWS_REGION`, `MMGIS_PUBLISH_ECS_CLUSTER`, `MMGIS_PUBLISH_TASK_DEFINITION`, `MMGIS_PUBLISH_SUBNETS`, `MMGIS_PUBLISH_SECURITY_GROUPS`, `MMGIS_PUBLISH_CONTAINER_NAME`, `MMGIS_SHARED_ASSET_BUCKET`). Sensitive values come through `secrets[]`. Note the env name `SECRET` (what `scripts/server.js` reads), not `SESSION_SECRET`. +- **Publish task** (`ecs/publish-task.json`): same image, `command` overridden to `node scripts/publish-static.js`. `DEPLOYMENT_ID` and `DEPLOYMENT_ACTION` (`publish` | `update`) are **not** in the task definition — `runPublishTask()` in `scripts/lib/aws-provision.js` passes them per run via `RunTask` container overrides, on the container named `mmgis` (the `MMGIS_PUBLISH_CONTAINER_NAME` default; the names must match). The generous cpu/memory is for the in-task webpack static build. +- **Two roles per task.** The *execution* role is what ECS itself uses (pull image, write logs, inject `secrets[]`); the *task* role is what the container code's AWS SDK calls use. They are intentionally separate and minimal. The classic gotcha: because the admin calls `ecs:RunTask` and hands the publish task its two roles, the **admin task role must hold `iam:PassRole` on both publish role ARNs** — without it, `RunTask` fails with an opaque AccessDenied that never mentions PassRole. +- **Everything is pinned.** No IAM statement uses `Resource: "*"`. Dashboard-facing grants are pinned to the `mmgis-dashboard-*` prefix (stack names, bucket names, and CloudFront Function names all carry it — see `STACK_NAME_PREFIX` in `scripts/lib/cfn-template.js`); asset grants are pinned to ``. CloudFront distribution and origin-access-control ARNs cannot be name-pinned (their ids are random), so those statements are scoped to the resource type within the account. One caveat: `ecr:GetAuthorizationToken` does not support resource-level permissions in live IAM; it is written here pinned to the account's ECR ARN space as documentation of intent — if your tooling rejects it at apply time, that single action must be widened to `*`. +- **Publish task role vs. `cfn-template.js`.** Beyond the core create set, the role carries the actions the rendered template actually requires: origin-access-control lifecycle (the template creates an `AWS::CloudFront::OriginAccessControl`), `s3:PutBucketPublicAccessBlock`/`s3:PutEncryptionConfiguration`/`s3:DeleteBucketPolicy` (the bucket ships with public-access block + encryption + a bucket policy), and `cloudfront:TagResource`/`s3:PutBucketTagging` (`createStack()` tags the stack and CloudFormation propagates tags to resources). **No `rds-db:connect`** — the app uses password auth. +- **Admin CloudFront** (`cloudfront-admin.json`): the default behavior targets the ALB over HTTPS with the AWS managed policies **AllViewer** origin-request policy (`216adef6-5c7f-47e4-b989-5492eafa07d3` — forwards all cookies, headers, and query strings; required for login, Postgres-backed sessions, and WebSocket upgrade headers) and **CachingDisabled** cache policy (`4135ea2d-6df8-44a3-9df3-4b5a84be39ad`). CloudFront's defaults forward nothing and would silently break auth. The `/assets/*` behavior targets the shared asset bucket with **CachingOptimized** (`658327ea-f89d-4fab-a63d-7e88639e58f6`), so admin-uploaded images resolve same-origin at the root-relative `/assets/...` paths the app stores. +- **Asset bucket** (`s3-asset-bucket.json`): one private shared bucket for admin uploads. The admin task role may `PutObject` to it; the publish task role reads it (`GetObject`/`ListBucket`) to same-key copy a mission's `assets//...` prefix (and `Missions//Data/mosaic_parameters.csv` when present) into that dashboard's own `mmgis-dashboard-*` bucket at publish. Assets carry no special auth of their own — they inherit whichever distribution serves them (the admin distribution here; a dashboard's password gate once copied). +- **Password gate** (`cloudfront-function.js`): reference source only. At publish, `renderAuthFunctionCode()` in `scripts/lib/cfn-template.js` bakes `base64("mmgis:" + MMGIS_DASHBOARDS_PASSWORD)` into the Function body inside each dashboard stack. A unit test (`tests/unit/infrastructure.spec.js`) keeps this reference and the generator in sync. +- **`trust proxy`**: `scripts/server.js` sets `app.set("trust proxy", 2)` to match the lean topology's two proxy hops (CloudFront → ALB → ECS) so `Secure` cookies, rate limiting, and `X-Forwarded-For` resolve the real client. + +## Deploy pipeline + +`.github/workflows/deploy-lean.yml` runs on a published GitHub release (and manually via `workflow_dispatch`): it builds the theme assets (`npm run build:themes` — the `Dockerfile` does not run it, and the generated `public/` CSS/fonts must be in the build context so themed missions and dashboards don't render unstyled), builds and pushes the image to ECR, registers new `mmgis-admin` **and** `mmgis-publish` task-definition revisions pointing at the new image (the two families share it), and triggers the Express Mode managed rollout of the admin service with `aws ecs update-service`. It defines no ALB/target-group/scaling resources (D1). + +Workflow configuration (GitHub repo settings): variables `AWS_REGION`, `ECR_REPOSITORY`, `ECS_CLUSTER`, `ECS_SERVICE`; secret `AWS_DEPLOY_ROLE_ARN` (an OIDC-assumable role — the workflow uses GitHub's OIDC provider, no long-lived keys). diff --git a/infrastructure/cloudfront-admin.json b/infrastructure/cloudfront-admin.json new file mode 100644 index 000000000..514ccf4df --- /dev/null +++ b/infrastructure/cloudfront-admin.json @@ -0,0 +1,76 @@ +{ + "CallerReference": "mmgis-admin-cloudfront", + "Comment": "MMGIS lean admin distribution: default behavior forwards everything to the admin ALB and caches nothing (AllViewer + CachingDisabled); /assets/* serves the shared asset bucket same-origin.", + "Enabled": true, + "HttpVersion": "http2", + "Aliases": { + "Quantity": 1, + "Items": [""] + }, + "ViewerCertificate": { + "ACMCertificateArn": "", + "SSLSupportMethod": "sni-only", + "MinimumProtocolVersion": "TLSv1.2_2021" + }, + "Origins": { + "Quantity": 2, + "Items": [ + { + "Id": "AdminAlbOrigin", + "DomainName": "", + "CustomOriginConfig": { + "HTTPPort": 80, + "HTTPSPort": 443, + "OriginProtocolPolicy": "https-only", + "OriginSslProtocols": { + "Quantity": 1, + "Items": ["TLSv1.2"] + } + } + }, + { + "Id": "AssetBucketOrigin", + "DomainName": ".s3..amazonaws.com", + "OriginAccessControlId": "", + "S3OriginConfig": { + "OriginAccessIdentity": "" + } + } + ] + }, + "DefaultCacheBehavior": { + "TargetOriginId": "AdminAlbOrigin", + "ViewerProtocolPolicy": "redirect-to-https", + "AllowedMethods": { + "Quantity": 7, + "Items": ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"], + "CachedMethods": { + "Quantity": 2, + "Items": ["GET", "HEAD"] + } + }, + "Compress": true, + "CachePolicyId": "4135ea2d-6df8-44a3-9df3-4b5a84be39ad", + "OriginRequestPolicyId": "216adef6-5c7f-47e4-b989-5492eafa07d3" + }, + "CacheBehaviors": { + "Quantity": 1, + "Items": [ + { + "PathPattern": "/assets/*", + "TargetOriginId": "AssetBucketOrigin", + "ViewerProtocolPolicy": "redirect-to-https", + "AllowedMethods": { + "Quantity": 2, + "Items": ["GET", "HEAD"], + "CachedMethods": { + "Quantity": 2, + "Items": ["GET", "HEAD"] + } + }, + "Compress": true, + "CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6" + } + ] + } +} diff --git a/infrastructure/cloudfront-function.js b/infrastructure/cloudfront-function.js new file mode 100644 index 000000000..c1c0f1792 --- /dev/null +++ b/infrastructure/cloudfront-function.js @@ -0,0 +1,34 @@ +/** + * Reference source for the per-dashboard password-gate CloudFront Function + * (viewer-request, cloudfront-js-1.0 runtime). + * + * Canonical reference only — this file is never deployed directly. The + * deployed copy is generated at publish time by renderAuthFunctionCode() in + * scripts/lib/cfn-template.js, which inlines this exact body into each + * mmgis-dashboard-* CloudFormation stack with + * replaced by base64("mmgis:" + MMGIS_DASHBOARDS_PASSWORD). The password is + * baked into the Function body, never a CloudFormation Parameter (parameters + * surface in DescribeStacks output, which the Deployments list reads). + * + * tests/unit/infrastructure.spec.js asserts this reference stays in sync + * with renderAuthFunctionCode(). If you change one, change both. + */ +function handler(event) { + var request = event.request; + var EXPECTED = 'Basic '; + var headers = request.headers; + var auth = + headers.authorization && headers.authorization.value; + if (auth !== EXPECTED) { + return { + statusCode: 401, + statusDescription: 'Unauthorized', + headers: { + 'www-authenticate': { + value: 'Basic realm="MMGIS Dashboard"' + } + } + }; + } + return request; +} diff --git a/infrastructure/ecs/admin-task.json b/infrastructure/ecs/admin-task.json new file mode 100644 index 000000000..e53ef9936 --- /dev/null +++ b/infrastructure/ecs/admin-task.json @@ -0,0 +1,68 @@ +{ + "family": "mmgis-admin", + "requiresCompatibilities": ["FARGATE"], + "networkMode": "awsvpc", + "cpu": "1024", + "memory": "2048", + "runtimePlatform": { + "cpuArchitecture": "X86_64", + "operatingSystemFamily": "LINUX" + }, + "executionRoleArn": "arn:aws:iam:::role/mmgis-admin-task-execution-role", + "taskRoleArn": "arn:aws:iam:::role/mmgis-admin-task-role", + "containerDefinitions": [ + { + "name": "mmgis", + "image": "", + "essential": true, + "portMappings": [ + { + "containerPort": 8888, + "protocol": "tcp" + } + ], + "environment": [ + { "name": "MMGIS_DEPLOYMENT_MODE", "value": "lean" }, + { "name": "DISABLE_FIRST_SIGNUP", "value": "true" }, + { "name": "NODE_ENV", "value": "production" }, + { "name": "PORT", "value": "8888" }, + { "name": "AUTH", "value": "local" }, + { "name": "AWS_REGION", "value": "" }, + { "name": "MMGIS_PUBLISH_ECS_CLUSTER", "value": "" }, + { "name": "MMGIS_PUBLISH_TASK_DEFINITION", "value": "mmgis-publish" }, + { "name": "MMGIS_PUBLISH_SUBNETS", "value": "" }, + { "name": "MMGIS_PUBLISH_SECURITY_GROUPS", "value": "" }, + { "name": "MMGIS_PUBLISH_CONTAINER_NAME", "value": "mmgis" }, + { "name": "MMGIS_SHARED_ASSET_BUCKET", "value": "" } + ], + "secrets": [ + { "name": "DB_HOST", "valueFrom": ":DB_HOST::" }, + { "name": "DB_PORT", "valueFrom": ":DB_PORT::" }, + { "name": "DB_NAME", "valueFrom": ":DB_NAME::" }, + { "name": "DB_USER", "valueFrom": ":DB_USER::" }, + { "name": "DB_PASS", "valueFrom": ":DB_PASS::" }, + { "name": "SECRET", "valueFrom": "" }, + { + "name": "SEED_SUPERADMIN_USERNAME", + "valueFrom": "" + }, + { + "name": "SEED_SUPERADMIN_PASSWORD", + "valueFrom": "" + }, + { + "name": "MMGIS_DASHBOARDS_PASSWORD", + "valueFrom": "" + } + ], + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "/ecs/mmgis-admin", + "awslogs-region": "", + "awslogs-stream-prefix": "mmgis-admin" + } + } + } + ] +} diff --git a/infrastructure/ecs/publish-task.json b/infrastructure/ecs/publish-task.json new file mode 100644 index 000000000..ed82aeb9d --- /dev/null +++ b/infrastructure/ecs/publish-task.json @@ -0,0 +1,46 @@ +{ + "family": "mmgis-publish", + "requiresCompatibilities": ["FARGATE"], + "networkMode": "awsvpc", + "cpu": "2048", + "memory": "8192", + "runtimePlatform": { + "cpuArchitecture": "X86_64", + "operatingSystemFamily": "LINUX" + }, + "executionRoleArn": "arn:aws:iam:::role/mmgis-publish-task-execution-role", + "taskRoleArn": "arn:aws:iam:::role/mmgis-publish-task-role", + "containerDefinitions": [ + { + "name": "mmgis", + "image": "", + "essential": true, + "command": ["node", "scripts/publish-static.js"], + "environment": [ + { "name": "MMGIS_DEPLOYMENT_MODE", "value": "lean" }, + { "name": "NODE_ENV", "value": "production" }, + { "name": "AWS_REGION", "value": "" }, + { "name": "MMGIS_SHARED_ASSET_BUCKET", "value": "" } + ], + "secrets": [ + { "name": "DB_HOST", "valueFrom": ":DB_HOST::" }, + { "name": "DB_PORT", "valueFrom": ":DB_PORT::" }, + { "name": "DB_NAME", "valueFrom": ":DB_NAME::" }, + { "name": "DB_USER", "valueFrom": ":DB_USER::" }, + { "name": "DB_PASS", "valueFrom": ":DB_PASS::" }, + { + "name": "MMGIS_DASHBOARDS_PASSWORD", + "valueFrom": "" + } + ], + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "/ecs/mmgis-publish", + "awslogs-region": "", + "awslogs-stream-prefix": "mmgis-publish" + } + } + } + ] +} diff --git a/infrastructure/iam/admin-task-execution-role.json b/infrastructure/iam/admin-task-execution-role.json new file mode 100644 index 000000000..f51f4a848 --- /dev/null +++ b/infrastructure/iam/admin-task-execution-role.json @@ -0,0 +1,65 @@ +{ + "Type": "AWS::IAM::Role", + "Properties": { + "RoleName": "mmgis-admin-task-execution-role", + "Description": "ECS-side role for the mmgis-admin task: pull the image from ECR, write CloudWatch Logs, and inject the secrets[] entries of infrastructure/ecs/admin-task.json. Lean deployment only.", + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowEcsTasksAssume", + "Effect": "Allow", + "Principal": { "Service": "ecs-tasks.amazonaws.com" }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { "aws:SourceAccount": "" } + } + } + ] + }, + "Policies": [ + { + "PolicyName": "mmgis-admin-task-execution", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "EcrAuth", + "Effect": "Allow", + "Action": ["ecr:GetAuthorizationToken"], + "Resource": "arn:aws:ecr:::*" + }, + { + "Sid": "EcrPullImage", + "Effect": "Allow", + "Action": [ + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage" + ], + "Resource": "arn:aws:ecr:::repository/" + }, + { + "Sid": "CloudWatchLogsWrite", + "Effect": "Allow", + "Action": ["logs:CreateLogStream", "logs:PutLogEvents"], + "Resource": "arn:aws:logs:::log-group:/ecs/mmgis-admin:*" + }, + { + "Sid": "InjectAdminTaskSecrets", + "Effect": "Allow", + "Action": ["secretsmanager:GetSecretValue"], + "Resource": [ + "", + "", + "", + "", + "" + ] + } + ] + } + } + ] + } +} diff --git a/infrastructure/iam/admin-task-role.json b/infrastructure/iam/admin-task-role.json new file mode 100644 index 000000000..e235d7809 --- /dev/null +++ b/infrastructure/iam/admin-task-role.json @@ -0,0 +1,78 @@ +{ + "Type": "AWS::IAM::Role", + "Properties": { + "RoleName": "mmgis-admin-task-role", + "Description": "Runtime role for the mmgis-admin container code: start the publish task (RunTask + PassRole of both publish roles), merge/delete dashboard stacks from the Deployments page, empty dashboard buckets before delete, and upload admin assets to the shared asset bucket. Everything is pinned to the mmgis-dashboard-* prefix or the shared asset bucket. Lean deployment only.", + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowEcsTasksAssume", + "Effect": "Allow", + "Principal": { "Service": "ecs-tasks.amazonaws.com" }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { "aws:SourceAccount": "" } + } + } + ] + }, + "Policies": [ + { + "PolicyName": "mmgis-admin-task", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "RunPublishTask", + "Effect": "Allow", + "Action": ["ecs:RunTask"], + "Resource": "arn:aws:ecs:::task-definition/mmgis-publish:*" + }, + { + "Sid": "PassBothPublishRoles", + "Effect": "Allow", + "Action": ["iam:PassRole"], + "Resource": [ + "arn:aws:iam:::role/mmgis-publish-task-execution-role", + "arn:aws:iam:::role/mmgis-publish-task-role" + ], + "Condition": { + "StringEquals": { + "iam:PassedToService": "ecs-tasks.amazonaws.com" + } + } + }, + { + "Sid": "DashboardStackReadDelete", + "Effect": "Allow", + "Action": [ + "cloudformation:DescribeStacks", + "cloudformation:DeleteStack" + ], + "Resource": "arn:aws:cloudformation:::stack/mmgis-dashboard-*/*" + }, + { + "Sid": "EmptyDashboardBuckets", + "Effect": "Allow", + "Action": ["s3:DeleteObject"], + "Resource": "arn:aws:s3:::mmgis-dashboard-*/*" + }, + { + "Sid": "ListDashboardBuckets", + "Effect": "Allow", + "Action": ["s3:ListBucket"], + "Resource": "arn:aws:s3:::mmgis-dashboard-*" + }, + { + "Sid": "UploadAdminAssets", + "Effect": "Allow", + "Action": ["s3:PutObject"], + "Resource": "arn:aws:s3:::/*" + } + ] + } + } + ] + } +} diff --git a/infrastructure/iam/publish-task-execution-role.json b/infrastructure/iam/publish-task-execution-role.json new file mode 100644 index 000000000..e4e7d2f00 --- /dev/null +++ b/infrastructure/iam/publish-task-execution-role.json @@ -0,0 +1,59 @@ +{ + "Type": "AWS::IAM::Role", + "Properties": { + "RoleName": "mmgis-publish-task-execution-role", + "Description": "ECS-side role for the mmgis-publish task: pull the image from ECR, write CloudWatch Logs, and inject the secrets[] entries of infrastructure/ecs/publish-task.json. Lean deployment only.", + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowEcsTasksAssume", + "Effect": "Allow", + "Principal": { "Service": "ecs-tasks.amazonaws.com" }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { "aws:SourceAccount": "" } + } + } + ] + }, + "Policies": [ + { + "PolicyName": "mmgis-publish-task-execution", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "EcrAuth", + "Effect": "Allow", + "Action": ["ecr:GetAuthorizationToken"], + "Resource": "arn:aws:ecr:::*" + }, + { + "Sid": "EcrPullImage", + "Effect": "Allow", + "Action": [ + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage" + ], + "Resource": "arn:aws:ecr:::repository/" + }, + { + "Sid": "CloudWatchLogsWrite", + "Effect": "Allow", + "Action": ["logs:CreateLogStream", "logs:PutLogEvents"], + "Resource": "arn:aws:logs:::log-group:/ecs/mmgis-publish:*" + }, + { + "Sid": "InjectPublishTaskSecrets", + "Effect": "Allow", + "Action": ["secretsmanager:GetSecretValue"], + "Resource": ["", ""] + } + ] + } + } + ] + } +} diff --git a/infrastructure/iam/publish-task-role.json b/infrastructure/iam/publish-task-role.json new file mode 100644 index 000000000..e4ce9cb75 --- /dev/null +++ b/infrastructure/iam/publish-task-role.json @@ -0,0 +1,115 @@ +{ + "Type": "AWS::IAM::Role", + "Properties": { + "RoleName": "mmgis-publish-task-role", + "Description": "Runtime role for the mmgis-publish container code (scripts/publish-static.js): create/describe/delete the mmgis-dashboard-* CloudFormation stacks and the S3/CloudFront resources CloudFormation manages on its behalf, read the shared asset bucket to copy mission assets, and read the dashboards-shared-password secret. No rds-db:connect — the code uses password auth (DB_USER/DB_PASS). Lean deployment only.", + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowEcsTasksAssume", + "Effect": "Allow", + "Principal": { "Service": "ecs-tasks.amazonaws.com" }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { "aws:SourceAccount": "" } + } + } + ] + }, + "Policies": [ + { + "PolicyName": "mmgis-publish-task", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "DashboardStackLifecycle", + "Effect": "Allow", + "Action": [ + "cloudformation:CreateStack", + "cloudformation:DescribeStacks", + "cloudformation:DescribeStackEvents", + "cloudformation:DeleteStack" + ], + "Resource": "arn:aws:cloudformation:::stack/mmgis-dashboard-*/*" + }, + { + "Sid": "DashboardBucketLifecycle", + "Effect": "Allow", + "Action": [ + "s3:CreateBucket", + "s3:DeleteBucket", + "s3:GetBucketLocation", + "s3:PutBucketPolicy", + "s3:DeleteBucketPolicy", + "s3:PutBucketPublicAccessBlock", + "s3:PutEncryptionConfiguration", + "s3:PutBucketTagging" + ], + "Resource": "arn:aws:s3:::mmgis-dashboard-*" + }, + { + "Sid": "DashboardBucketWriteObjects", + "Effect": "Allow", + "Action": ["s3:PutObject"], + "Resource": "arn:aws:s3:::mmgis-dashboard-*/*" + }, + { + "Sid": "DashboardDistributionLifecycle", + "Effect": "Allow", + "Action": [ + "cloudfront:CreateDistribution", + "cloudfront:GetDistribution", + "cloudfront:UpdateDistribution", + "cloudfront:DeleteDistribution", + "cloudfront:TagResource" + ], + "Resource": "arn:aws:cloudfront:::distribution/*" + }, + { + "Sid": "DashboardAuthFunctionLifecycle", + "Effect": "Allow", + "Action": [ + "cloudfront:CreateFunction", + "cloudfront:PublishFunction", + "cloudfront:DescribeFunction", + "cloudfront:DeleteFunction", + "cloudfront:GetFunction" + ], + "Resource": "arn:aws:cloudfront:::function/mmgis-dashboard-*" + }, + { + "Sid": "DashboardOriginAccessControlLifecycle", + "Effect": "Allow", + "Action": [ + "cloudfront:CreateOriginAccessControl", + "cloudfront:GetOriginAccessControl", + "cloudfront:DeleteOriginAccessControl" + ], + "Resource": "arn:aws:cloudfront:::origin-access-control/*" + }, + { + "Sid": "ReadSharedAssetObjects", + "Effect": "Allow", + "Action": ["s3:GetObject"], + "Resource": "arn:aws:s3:::/*" + }, + { + "Sid": "ListSharedAssetBucket", + "Effect": "Allow", + "Action": ["s3:ListBucket"], + "Resource": "arn:aws:s3:::" + }, + { + "Sid": "ReadDashboardsPasswordSecret", + "Effect": "Allow", + "Action": ["secretsmanager:GetSecretValue"], + "Resource": "" + } + ] + } + } + ] + } +} diff --git a/infrastructure/s3-asset-bucket.json b/infrastructure/s3-asset-bucket.json new file mode 100644 index 000000000..7c8d48685 --- /dev/null +++ b/infrastructure/s3-asset-bucket.json @@ -0,0 +1,65 @@ +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "MMGIS lean shared admin asset bucket: one private bucket holding every admin-uploaded mission asset (/assets//...), served same-origin through the admin CloudFront distribution's /assets/* behavior. The publish task same-key copies a mission's assets out of this bucket into that dashboard's own mmgis-dashboard-* bucket at publish time. Lean deployment only.", + "Resources": { + "SharedAssetBucket": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketName": "", + "PublicAccessBlockConfiguration": { + "BlockPublicAcls": true, + "BlockPublicPolicy": true, + "IgnorePublicAcls": true, + "RestrictPublicBuckets": true + }, + "BucketEncryption": { + "ServerSideEncryptionConfiguration": [ + { + "ServerSideEncryptionByDefault": { + "SSEAlgorithm": "AES256" + } + } + ] + } + } + }, + "SharedAssetBucketPolicy": { + "Type": "AWS::S3::BucketPolicy", + "Properties": { + "Bucket": { "Ref": "SharedAssetBucket" }, + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowAdminCloudFrontReadOnly", + "Effect": "Allow", + "Principal": { "Service": "cloudfront.amazonaws.com" }, + "Action": "s3:GetObject", + "Resource": { + "Fn::Join": [ + "", + [{ "Fn::GetAtt": ["SharedAssetBucket", "Arn"] }, "/*"] + ] + }, + "Condition": { + "StringEquals": { + "AWS:SourceArn": "arn:aws:cloudfront:::distribution/" + } + } + } + ] + } + } + } + }, + "Outputs": { + "AssetBucketName": { + "Description": "Set MMGIS_SHARED_ASSET_BUCKET to this value on both task definitions", + "Value": { "Ref": "SharedAssetBucket" } + }, + "AssetBucketArn": { + "Description": "Referenced by the admin task role (PutObject) and the publish task role (GetObject/ListBucket)", + "Value": { "Fn::GetAtt": ["SharedAssetBucket", "Arn"] } + } + } +} diff --git a/scripts/server.js b/scripts/server.js index e37fc6750..0c43081bb 100644 --- a/scripts/server.js +++ b/scripts/server.js @@ -506,8 +506,8 @@ let s = { ROOT_PATH, }; -// Trust first proxy -app.set("trust proxy", 1); +// Trust two proxy hops (lean deployment topology: CloudFront -> ALB -> ECS) +app.set("trust proxy", 2); app.use("/api/", apilimiter); diff --git a/tests/unit/infrastructure.spec.js b/tests/unit/infrastructure.spec.js new file mode 100644 index 000000000..2084862fb --- /dev/null +++ b/tests/unit/infrastructure.spec.js @@ -0,0 +1,257 @@ +import { test, expect } from '@playwright/test' + +// Tests for the lean deployment's AWS recipes in infrastructure/. +// These are static checks: every JSON file must parse, the IAM must stay +// least-privilege (no `Resource: "*"`, dashboard grants pinned to the +// mmgis-dashboard-* prefix, PassRole for both publish roles), the admin +// task definition must carry every env var the publish flow's code +// actually reads, and the password-gate Function reference must stay in +// sync with the generator in scripts/lib/cfn-template.js. + +const fs = require('fs') +const path = require('path') + +const ROOT = path.join(__dirname, '..', '..') +const INFRA = path.join(ROOT, 'infrastructure') + +const JSON_FILES = [ + 'ecs/admin-task.json', + 'ecs/publish-task.json', + 'iam/admin-task-execution-role.json', + 'iam/admin-task-role.json', + 'iam/publish-task-execution-role.json', + 'iam/publish-task-role.json', + 'cloudfront-admin.json', + 's3-asset-bucket.json', +] + +const IAM_FILES = JSON_FILES.filter((f) => f.startsWith('iam/')) + +function readJson(relative) { + return JSON.parse(fs.readFileSync(path.join(INFRA, relative), 'utf8')) +} + +// Every "Resource" value (string or array) in a parsed IAM document. +function collectResources(node, found = []) { + if (Array.isArray(node)) { + node.forEach((item) => collectResources(item, found)) + } else if (node != null && typeof node === 'object') { + Object.keys(node).forEach((key) => { + if (key === 'Resource') { + const value = node[key] + if (Array.isArray(value)) found.push(...value) + else found.push(value) + } else { + collectResources(node[key], found) + } + }) + } + return found +} + +// All policy statements of a role file, flattened. +function statementsOf(roleJson) { + return (roleJson.Properties.Policies || []).flatMap( + (policy) => policy.PolicyDocument.Statement + ) +} + +function statementBySid(roleJson, sid) { + const statement = statementsOf(roleJson).find((s) => s.Sid === sid) + expect(statement, `statement '${sid}' exists`).toBeTruthy() + return statement +} + +test.describe('infrastructure/ JSON recipes', () => { + test('every infrastructure JSON file parses', () => { + for (const file of JSON_FILES) { + expect(() => readJson(file), `${file} parses`).not.toThrow() + } + }) + + test('no IAM statement uses Resource: "*"', () => { + for (const file of IAM_FILES) { + const resources = collectResources(readJson(file)) + expect(resources.length).toBeGreaterThan(0) + for (const resource of resources) { + expect(resource, `${file} pins every Resource`).not.toBe('*') + } + } + }) + + test('dashboard-facing grants are pinned to the mmgis-dashboard-* prefix', () => { + // The prefix the IAM is pinned to must be the one the code creates + // stacks under. + const { STACK_NAME_PREFIX } = require('../../scripts/lib/cfn-template') + expect(STACK_NAME_PREFIX).toBe('mmgis-dashboard-') + + const adminRole = readJson('iam/admin-task-role.json') + const publishRole = readJson('iam/publish-task-role.json') + + const pinned = [ + [adminRole, 'DashboardStackReadDelete', ':stack/mmgis-dashboard-'], + [adminRole, 'EmptyDashboardBuckets', 'arn:aws:s3:::mmgis-dashboard-'], + [adminRole, 'ListDashboardBuckets', 'arn:aws:s3:::mmgis-dashboard-'], + [publishRole, 'DashboardStackLifecycle', ':stack/mmgis-dashboard-'], + [publishRole, 'DashboardBucketLifecycle', 'arn:aws:s3:::mmgis-dashboard-'], + [publishRole, 'DashboardBucketWriteObjects', 'arn:aws:s3:::mmgis-dashboard-'], + [publishRole, 'DashboardAuthFunctionLifecycle', ':function/mmgis-dashboard-'], + ] + for (const [role, sid, prefix] of pinned) { + const resources = collectResources(statementBySid(role, sid)) + expect(resources.length).toBeGreaterThan(0) + for (const resource of resources) { + expect(resource, `${sid} resource carries '${prefix}'`).toContain( + prefix + ) + } + } + }) + + test('admin task role passes BOTH publish roles to RunTask', () => { + const adminRole = readJson('iam/admin-task-role.json') + const passRole = statementBySid(adminRole, 'PassBothPublishRoles') + expect(passRole.Action).toContain('iam:PassRole') + + const resources = collectResources(passRole) + const publishExecutionArn = readJson('ecs/publish-task.json') + .executionRoleArn + const publishTaskArn = readJson('ecs/publish-task.json').taskRoleArn + expect(resources).toContain(publishExecutionArn) + expect(resources).toContain(publishTaskArn) + expect(publishExecutionArn).not.toBe(publishTaskArn) + + const runTask = statementBySid(adminRole, 'RunPublishTask') + expect(runTask.Action).toContain('ecs:RunTask') + const publishFamily = readJson('ecs/publish-task.json').family + for (const resource of collectResources(runTask)) { + expect(resource).toContain(`:task-definition/${publishFamily}:`) + } + }) + + test('admin task def covers every env var the publish-flow code reads', () => { + // Env names the code actually reads, greppable from + // requireEnv("...") and process.env.MMGIS_* / process.env.AWS_REGION. + const sourceFiles = fs + .readdirSync(path.join(ROOT, 'scripts', 'lib')) + .filter((f) => f.endsWith('.js')) + .map((f) => path.join(ROOT, 'scripts', 'lib', f)) + sourceFiles.push(path.join(ROOT, 'scripts', 'publish-static.js')) + + // DEPLOYMENT_ID / DEPLOYMENT_ACTION are deliberately NOT in any task + // definition: runPublishTask() supplies them per run via RunTask + // container overrides (see infrastructure/README.md). + const RUN_TASK_OVERRIDES = ['DEPLOYMENT_ID', 'DEPLOYMENT_ACTION'] + + const wanted = new Set() + const pattern = + /requireEnv\(\s*["']([A-Z0-9_]+)["']\s*\)|process\.env\.(MMGIS_[A-Z0-9_]+|AWS_REGION)/g + for (const file of sourceFiles) { + const source = fs.readFileSync(file, 'utf8') + let match + while ((match = pattern.exec(source)) !== null) { + const name = match[1] || match[2] + if ( + (name.startsWith('MMGIS_') || name === 'AWS_REGION') && + !RUN_TASK_OVERRIDES.includes(name) + ) + wanted.add(name) + } + } + // Sanity: the grep found the publish-flow configuration set. + expect(wanted.size).toBeGreaterThanOrEqual(8) + + const container = readJson('ecs/admin-task.json').containerDefinitions[0] + const provided = new Set([ + ...(container.environment || []).map((e) => e.name), + ...(container.secrets || []).map((s) => s.name), + ]) + for (const name of wanted) { + expect(provided.has(name), `admin task def provides ${name}`).toBe( + true + ) + } + }) + + test('publish task def runs publish-static.js with the same image and its own roles', () => { + const adminTask = readJson('ecs/admin-task.json') + const publishTask = readJson('ecs/publish-task.json') + const adminContainer = adminTask.containerDefinitions[0] + const publishContainer = publishTask.containerDefinitions[0] + + expect(publishContainer.command).toEqual([ + 'node', + 'scripts/publish-static.js', + ]) + // Same image placeholder, distinct role pairs + expect(publishContainer.image).toBe(adminContainer.image) + expect(publishTask.executionRoleArn).not.toBe(adminTask.executionRoleArn) + expect(publishTask.taskRoleArn).not.toBe(adminTask.taskRoleArn) + + // The container name must match runPublishTask()'s override target + // (MMGIS_PUBLISH_CONTAINER_NAME, default "mmgis"). + const configuredName = (adminContainer.environment || []).find( + (e) => e.name === 'MMGIS_PUBLISH_CONTAINER_NAME' + ) + expect(configuredName.value).toBe(publishContainer.name) + + // Lean-mode switch + first-signup gate ride the admin environment[] + const adminEnv = Object.fromEntries( + adminContainer.environment.map((e) => [e.name, e.value]) + ) + expect(adminEnv.MMGIS_DEPLOYMENT_MODE).toBe('lean') + expect(adminEnv.DISABLE_FIRST_SIGNUP).toBe('true') + }) + + test('publish task role omits rds-db:connect (password auth only)', () => { + for (const file of IAM_FILES) { + const actions = statementsOf(readJson(file)).flatMap((s) => + Array.isArray(s.Action) ? s.Action : [s.Action] + ) + expect(actions).not.toContain('rds-db:connect') + } + }) + + test('cloudfront-function.js reference matches renderAuthFunctionCode()', () => { + const { + renderAuthFunctionCode, + BASIC_AUTH_USER, + } = require('../../scripts/lib/cfn-template') + + const reference = fs.readFileSync( + path.join(INFRA, 'cloudfront-function.js'), + 'utf8' + ) + // Strip the leading doc comment; bake a known password into the + // placeholder. + const body = reference.replace(/^\/\*[\s\S]*?\*\/\s*/, '').trimEnd() + const password = 'reference-sync-check' + const baked = body.replace( + '', + Buffer.from(`${BASIC_AUTH_USER}:${password}`).toString('base64') + ) + expect(baked).toBe(renderAuthFunctionCode(password)) + }) + + test('admin CloudFront uses AllViewer + CachingDisabled and serves /assets/*', () => { + const distribution = readJson('cloudfront-admin.json') + const defaultBehavior = distribution.DefaultCacheBehavior + // AWS managed policy ids (documented in infrastructure/README.md): + // CachingDisabled + AllViewer on the ALB default behavior. + expect(defaultBehavior.CachePolicyId).toBe( + '4135ea2d-6df8-44a3-9df3-4b5a84be39ad' + ) + expect(defaultBehavior.OriginRequestPolicyId).toBe( + '216adef6-5c7f-47e4-b989-5492eafa07d3' + ) + + const assetBehavior = distribution.CacheBehaviors.Items.find( + (b) => b.PathPattern === '/assets/*' + ) + expect(assetBehavior).toBeTruthy() + const assetOrigin = distribution.Origins.Items.find( + (o) => o.Id === assetBehavior.TargetOriginId + ) + expect(assetOrigin.DomainName).toContain('') + }) +}) From d7642c7ccbdf21b3e7c4ed03695e2f2740c8257d Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 10 Jun 2026 09:02:39 -0500 Subject: [PATCH 15/63] Address AWS infra review findings Give ecr:GetAuthorizationToken its required Resource:"*" (the action supports no resource scoping; the prior ARN pin was an implicit deny), grant the admin task role the mmgis-dashboard-* teardown permissions CloudFormation exercises with the caller's credentials during inline DeleteStack, drop the dashboards password from the admin task (only the publish task reads it), and remove the publish task role's unused runtime secret-read grant. --- infrastructure/README.md | 7 +- infrastructure/ecs/admin-task.json | 4 - .../iam/admin-task-execution-role.json | 7 +- infrastructure/iam/admin-task-role.json | 38 +++++- .../iam/publish-task-execution-role.json | 4 +- infrastructure/iam/publish-task-role.json | 8 +- tests/unit/infrastructure.spec.js | 114 +++++++++++++++--- 7 files changed, 142 insertions(+), 40 deletions(-) diff --git a/infrastructure/README.md b/infrastructure/README.md index 70580c7f6..8cd4510f3 100644 --- a/infrastructure/README.md +++ b/infrastructure/README.md @@ -11,7 +11,7 @@ Recipes for running MMGIS in the **lean** deployment shape (`MMGIS_DEPLOYMENT_MO | `ecs/admin-task.json` | Task definition for the admin app (long-running service, ECS Express Mode) | | `ecs/publish-task.json` | Task definition for the publish job (`node scripts/publish-static.js`, started per publish via `RunTask`) | | `iam/admin-task-execution-role.json` | ECS-side role for the admin task (image pull, logs, secret injection) | -| `iam/admin-task-role.json` | Runtime role for the admin container code (RunTask + PassRole, dashboard stack read/delete, asset upload) | +| `iam/admin-task-role.json` | Runtime role for the admin container code (RunTask + PassRole, dashboard stack read/delete, asset upload). Also carries the `mmgis-dashboard-*` S3/CloudFront **teardown** grants: the DELETE handler runs `DeleteStack` inline with no CloudFormation service role, so CloudFormation deletes the stack's resources with this role's credentials. (Attaching a dedicated CFN service role to the stacks would be the stricter future alternative.) | | `iam/publish-task-execution-role.json` | ECS-side role for the publish task | | `iam/publish-task-role.json` | Runtime role for the publish container code (dashboard stack create + the resources CloudFormation manages for it) | | `cloudfront-admin.json` | `DistributionConfig` for the admin CloudFront distribution (`aws cloudfront create-distribution --distribution-config file://cloudfront-admin.json`) | @@ -60,8 +60,9 @@ Replace these consistently across every file before applying: - **Admin task** (`ecs/admin-task.json`): runs the stock image (`_docker-entrypoint.sh`). `environment[]` carries `MMGIS_DEPLOYMENT_MODE=lean`, `DISABLE_FIRST_SIGNUP=true`, and the publish-flow variables the Deployments backend reads (`AWS_REGION`, `MMGIS_PUBLISH_ECS_CLUSTER`, `MMGIS_PUBLISH_TASK_DEFINITION`, `MMGIS_PUBLISH_SUBNETS`, `MMGIS_PUBLISH_SECURITY_GROUPS`, `MMGIS_PUBLISH_CONTAINER_NAME`, `MMGIS_SHARED_ASSET_BUCKET`). Sensitive values come through `secrets[]`. Note the env name `SECRET` (what `scripts/server.js` reads), not `SESSION_SECRET`. - **Publish task** (`ecs/publish-task.json`): same image, `command` overridden to `node scripts/publish-static.js`. `DEPLOYMENT_ID` and `DEPLOYMENT_ACTION` (`publish` | `update`) are **not** in the task definition — `runPublishTask()` in `scripts/lib/aws-provision.js` passes them per run via `RunTask` container overrides, on the container named `mmgis` (the `MMGIS_PUBLISH_CONTAINER_NAME` default; the names must match). The generous cpu/memory is for the in-task webpack static build. - **Two roles per task.** The *execution* role is what ECS itself uses (pull image, write logs, inject `secrets[]`); the *task* role is what the container code's AWS SDK calls use. They are intentionally separate and minimal. The classic gotcha: because the admin calls `ecs:RunTask` and hands the publish task its two roles, the **admin task role must hold `iam:PassRole` on both publish role ARNs** — without it, `RunTask` fails with an opaque AccessDenied that never mentions PassRole. -- **Everything is pinned.** No IAM statement uses `Resource: "*"`. Dashboard-facing grants are pinned to the `mmgis-dashboard-*` prefix (stack names, bucket names, and CloudFront Function names all carry it — see `STACK_NAME_PREFIX` in `scripts/lib/cfn-template.js`); asset grants are pinned to ``. CloudFront distribution and origin-access-control ARNs cannot be name-pinned (their ids are random), so those statements are scoped to the resource type within the account. One caveat: `ecr:GetAuthorizationToken` does not support resource-level permissions in live IAM; it is written here pinned to the account's ECR ARN space as documentation of intent — if your tooling rejects it at apply time, that single action must be widened to `*`. -- **Publish task role vs. `cfn-template.js`.** Beyond the core create set, the role carries the actions the rendered template actually requires: origin-access-control lifecycle (the template creates an `AWS::CloudFront::OriginAccessControl`), `s3:PutBucketPublicAccessBlock`/`s3:PutEncryptionConfiguration`/`s3:DeleteBucketPolicy` (the bucket ships with public-access block + encryption + a bucket policy), and `cloudfront:TagResource`/`s3:PutBucketTagging` (`createStack()` tags the stack and CloudFormation propagates tags to resources). **No `rds-db:connect`** — the app uses password auth. +- **Everything is pinned — with one deliberate exception.** Dashboard-facing grants are pinned to the `mmgis-dashboard-*` prefix (stack names, bucket names, and CloudFront Function names all carry it — see `STACK_NAME_PREFIX` in `scripts/lib/cfn-template.js`); asset grants are pinned to ``. CloudFront distribution and origin-access-control ARNs cannot be name-pinned (their ids are random), so those statements are scoped to the resource type within the account. The one `Resource: "*"` in this directory is the `EcrAuthTokenNoResourceScoping` statement in both execution roles: `ecr:GetAuthorizationToken` supports **no** resource-level permissions, so it authorizes against `*` by design (any narrower Resource is an implicit deny that fails every Fargate image pull). This matches AWS's own `AmazonECSTaskExecutionRolePolicy`, and the action only returns a registry auth token — it grants no access to repository data (the pull actions stay pinned to ``). +- **Admin-side teardown.** The Deployments DELETE handler empties the dashboard bucket and calls `DeleteStack` inline under the **admin task role**, with no CloudFormation service role attached to the stack — so CloudFormation deletes the stack's S3 bucket, CloudFront distribution, password-gate Function, and origin access control with the caller's (the admin role's) credentials. That is why `iam/admin-task-role.json` carries `s3:DeleteBucket`/`s3:DeleteBucketPolicy` and the CloudFront disable/delete set, still pinned to the `mmgis-dashboard-*` prefix (Function names) or the account's distribution/OAC ARN space. Giving the stacks a dedicated CloudFormation service role would let the admin role shed these grants and is the stricter future alternative. +- **Publish task role vs. `cfn-template.js`.** Beyond the core create set, the role carries the actions the rendered template actually requires: origin-access-control lifecycle (the template creates an `AWS::CloudFront::OriginAccessControl`), `s3:PutBucketPublicAccessBlock`/`s3:PutEncryptionConfiguration`/`s3:DeleteBucketPolicy` (the bucket ships with public-access block + encryption + a bucket policy), and `cloudfront:TagResource`/`s3:PutBucketTagging` (`createStack()` tags the stack and CloudFormation propagates tags to resources). **No `rds-db:connect`** — the app uses password auth. **No `secretsmanager:GetSecretValue` either**: the dashboards password reaches `publish-static.js` as the `MMGIS_DASHBOARDS_PASSWORD` env var through the publish *execution* role's `secrets[]` injection — the container code never reads Secrets Manager at runtime (the spec's "read at runtime" wording is outdated). For the same reason the password rides only the publish task definition; the admin task neither injects it nor may read it. - **Admin CloudFront** (`cloudfront-admin.json`): the default behavior targets the ALB over HTTPS with the AWS managed policies **AllViewer** origin-request policy (`216adef6-5c7f-47e4-b989-5492eafa07d3` — forwards all cookies, headers, and query strings; required for login, Postgres-backed sessions, and WebSocket upgrade headers) and **CachingDisabled** cache policy (`4135ea2d-6df8-44a3-9df3-4b5a84be39ad`). CloudFront's defaults forward nothing and would silently break auth. The `/assets/*` behavior targets the shared asset bucket with **CachingOptimized** (`658327ea-f89d-4fab-a63d-7e88639e58f6`), so admin-uploaded images resolve same-origin at the root-relative `/assets/...` paths the app stores. - **Asset bucket** (`s3-asset-bucket.json`): one private shared bucket for admin uploads. The admin task role may `PutObject` to it; the publish task role reads it (`GetObject`/`ListBucket`) to same-key copy a mission's `assets//...` prefix (and `Missions//Data/mosaic_parameters.csv` when present) into that dashboard's own `mmgis-dashboard-*` bucket at publish. Assets carry no special auth of their own — they inherit whichever distribution serves them (the admin distribution here; a dashboard's password gate once copied). - **Password gate** (`cloudfront-function.js`): reference source only. At publish, `renderAuthFunctionCode()` in `scripts/lib/cfn-template.js` bakes `base64("mmgis:" + MMGIS_DASHBOARDS_PASSWORD)` into the Function body inside each dashboard stack. A unit test (`tests/unit/infrastructure.spec.js`) keeps this reference and the generator in sync. diff --git a/infrastructure/ecs/admin-task.json b/infrastructure/ecs/admin-task.json index e53ef9936..fd41bc3fd 100644 --- a/infrastructure/ecs/admin-task.json +++ b/infrastructure/ecs/admin-task.json @@ -49,10 +49,6 @@ { "name": "SEED_SUPERADMIN_PASSWORD", "valueFrom": "" - }, - { - "name": "MMGIS_DASHBOARDS_PASSWORD", - "valueFrom": "" } ], "logConfiguration": { diff --git a/infrastructure/iam/admin-task-execution-role.json b/infrastructure/iam/admin-task-execution-role.json index f51f4a848..0e655c49c 100644 --- a/infrastructure/iam/admin-task-execution-role.json +++ b/infrastructure/iam/admin-task-execution-role.json @@ -24,10 +24,10 @@ "Version": "2012-10-17", "Statement": [ { - "Sid": "EcrAuth", + "Sid": "EcrAuthTokenNoResourceScoping", "Effect": "Allow", "Action": ["ecr:GetAuthorizationToken"], - "Resource": "arn:aws:ecr:::*" + "Resource": "*" }, { "Sid": "EcrPullImage", @@ -53,8 +53,7 @@ "", "", "", - "", - "" + "" ] } ] diff --git a/infrastructure/iam/admin-task-role.json b/infrastructure/iam/admin-task-role.json index e235d7809..262e9f6ec 100644 --- a/infrastructure/iam/admin-task-role.json +++ b/infrastructure/iam/admin-task-role.json @@ -2,7 +2,7 @@ "Type": "AWS::IAM::Role", "Properties": { "RoleName": "mmgis-admin-task-role", - "Description": "Runtime role for the mmgis-admin container code: start the publish task (RunTask + PassRole of both publish roles), merge/delete dashboard stacks from the Deployments page, empty dashboard buckets before delete, and upload admin assets to the shared asset bucket. Everything is pinned to the mmgis-dashboard-* prefix or the shared asset bucket. Lean deployment only.", + "Description": "Runtime role for the mmgis-admin container code: start the publish task (RunTask + PassRole of both publish roles), merge/delete dashboard stacks from the Deployments page, empty dashboard buckets before delete, and upload admin assets to the shared asset bucket. Because the DELETE handler calls DeleteStack inline with NO CloudFormation service role, CloudFormation tears down the stack's S3/CloudFront resources with THIS role's credentials, so it also carries the mmgis-dashboard-* teardown grants. Everything is pinned to the mmgis-dashboard-* prefix, the account's distribution/OAC ARN space (their ids are random), or the shared asset bucket. Lean deployment only.", "AssumeRolePolicyDocument": { "Version": "2012-10-17", "Statement": [ @@ -64,6 +64,42 @@ "Action": ["s3:ListBucket"], "Resource": "arn:aws:s3:::mmgis-dashboard-*" }, + { + "Sid": "TeardownDashboardBuckets", + "Effect": "Allow", + "Action": ["s3:DeleteBucket", "s3:DeleteBucketPolicy"], + "Resource": "arn:aws:s3:::mmgis-dashboard-*" + }, + { + "Sid": "TeardownDashboardDistributions", + "Effect": "Allow", + "Action": [ + "cloudfront:GetDistribution", + "cloudfront:GetDistributionConfig", + "cloudfront:UpdateDistribution", + "cloudfront:DeleteDistribution" + ], + "Resource": "arn:aws:cloudfront:::distribution/*" + }, + { + "Sid": "TeardownDashboardAuthFunctions", + "Effect": "Allow", + "Action": [ + "cloudfront:DescribeFunction", + "cloudfront:GetFunction", + "cloudfront:DeleteFunction" + ], + "Resource": "arn:aws:cloudfront:::function/mmgis-dashboard-*" + }, + { + "Sid": "TeardownDashboardOriginAccessControls", + "Effect": "Allow", + "Action": [ + "cloudfront:GetOriginAccessControl", + "cloudfront:DeleteOriginAccessControl" + ], + "Resource": "arn:aws:cloudfront:::origin-access-control/*" + }, { "Sid": "UploadAdminAssets", "Effect": "Allow", diff --git a/infrastructure/iam/publish-task-execution-role.json b/infrastructure/iam/publish-task-execution-role.json index e4e7d2f00..b0ea495ba 100644 --- a/infrastructure/iam/publish-task-execution-role.json +++ b/infrastructure/iam/publish-task-execution-role.json @@ -24,10 +24,10 @@ "Version": "2012-10-17", "Statement": [ { - "Sid": "EcrAuth", + "Sid": "EcrAuthTokenNoResourceScoping", "Effect": "Allow", "Action": ["ecr:GetAuthorizationToken"], - "Resource": "arn:aws:ecr:::*" + "Resource": "*" }, { "Sid": "EcrPullImage", diff --git a/infrastructure/iam/publish-task-role.json b/infrastructure/iam/publish-task-role.json index e4ce9cb75..c4c2e00c9 100644 --- a/infrastructure/iam/publish-task-role.json +++ b/infrastructure/iam/publish-task-role.json @@ -2,7 +2,7 @@ "Type": "AWS::IAM::Role", "Properties": { "RoleName": "mmgis-publish-task-role", - "Description": "Runtime role for the mmgis-publish container code (scripts/publish-static.js): create/describe/delete the mmgis-dashboard-* CloudFormation stacks and the S3/CloudFront resources CloudFormation manages on its behalf, read the shared asset bucket to copy mission assets, and read the dashboards-shared-password secret. No rds-db:connect — the code uses password auth (DB_USER/DB_PASS). Lean deployment only.", + "Description": "Runtime role for the mmgis-publish container code (scripts/publish-static.js): create/describe/delete the mmgis-dashboard-* CloudFormation stacks and the S3/CloudFront resources CloudFormation manages on its behalf, and read the shared asset bucket to copy mission assets. The dashboards password arrives as the MMGIS_DASHBOARDS_PASSWORD env var via the EXECUTION role's secrets[] injection — the container code never reads Secrets Manager at runtime. No rds-db:connect — the code uses password auth (DB_USER/DB_PASS). Lean deployment only.", "AssumeRolePolicyDocument": { "Version": "2012-10-17", "Statement": [ @@ -100,12 +100,6 @@ "Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": "arn:aws:s3:::" - }, - { - "Sid": "ReadDashboardsPasswordSecret", - "Effect": "Allow", - "Action": ["secretsmanager:GetSecretValue"], - "Resource": "" } ] } diff --git a/tests/unit/infrastructure.spec.js b/tests/unit/infrastructure.spec.js index 2084862fb..c6b0b599b 100644 --- a/tests/unit/infrastructure.spec.js +++ b/tests/unit/infrastructure.spec.js @@ -2,11 +2,14 @@ import { test, expect } from '@playwright/test' // Tests for the lean deployment's AWS recipes in infrastructure/. // These are static checks: every JSON file must parse, the IAM must stay -// least-privilege (no `Resource: "*"`, dashboard grants pinned to the -// mmgis-dashboard-* prefix, PassRole for both publish roles), the admin -// task definition must carry every env var the publish flow's code -// actually reads, and the password-gate Function reference must stay in -// sync with the generator in scripts/lib/cfn-template.js. +// least-privilege (no `Resource: "*"` except the unscopeable +// ecr:GetAuthorizationToken, dashboard grants pinned to the +// mmgis-dashboard-* prefix, PassRole for both publish roles), the task +// definitions must carry every env var the publish flow's code actually +// reads (publish-only vars like MMGIS_DASHBOARDS_PASSWORD on the publish +// task, the rest on the admin task), and the password-gate Function +// reference must stay in sync with the generator in +// scripts/lib/cfn-template.js. const fs = require('fs') const path = require('path') @@ -69,12 +72,37 @@ test.describe('infrastructure/ JSON recipes', () => { } }) - test('no IAM statement uses Resource: "*"', () => { + test('no IAM statement uses Resource: "*" (except ecr:GetAuthorizationToken)', () => { + // Single documented exception: ecr:GetAuthorizationToken supports NO + // resource-level permissions, so a statement whose ONLY action is + // that one MUST use Resource: "*" (anything narrower is an implicit + // deny that fails every Fargate image pull). This matches AWS's own + // AmazonECSTaskExecutionRolePolicy. for (const file of IAM_FILES) { - const resources = collectResources(readJson(file)) - expect(resources.length).toBeGreaterThan(0) - for (const resource of resources) { - expect(resource, `${file} pins every Resource`).not.toBe('*') + const statements = statementsOf(readJson(file)) + expect(statements.length).toBeGreaterThan(0) + for (const statement of statements) { + const actions = Array.isArray(statement.Action) + ? statement.Action + : [statement.Action] + if ( + actions.length === 1 && + actions[0] === 'ecr:GetAuthorizationToken' + ) { + expect( + collectResources(statement), + `${file} '${statement.Sid}' must NOT pin the token call` + ).toEqual(['*']) + continue + } + const resources = collectResources(statement) + expect(resources.length).toBeGreaterThan(0) + for (const resource of resources) { + expect( + resource, + `${file} '${statement.Sid}' pins every Resource` + ).not.toBe('*') + } } } }) @@ -92,6 +120,8 @@ test.describe('infrastructure/ JSON recipes', () => { [adminRole, 'DashboardStackReadDelete', ':stack/mmgis-dashboard-'], [adminRole, 'EmptyDashboardBuckets', 'arn:aws:s3:::mmgis-dashboard-'], [adminRole, 'ListDashboardBuckets', 'arn:aws:s3:::mmgis-dashboard-'], + [adminRole, 'TeardownDashboardBuckets', 'arn:aws:s3:::mmgis-dashboard-'], + [adminRole, 'TeardownDashboardAuthFunctions', ':function/mmgis-dashboard-'], [publishRole, 'DashboardStackLifecycle', ':stack/mmgis-dashboard-'], [publishRole, 'DashboardBucketLifecycle', 'arn:aws:s3:::mmgis-dashboard-'], [publishRole, 'DashboardBucketWriteObjects', 'arn:aws:s3:::mmgis-dashboard-'], @@ -108,6 +138,31 @@ test.describe('infrastructure/ JSON recipes', () => { } }) + test('admin task role can complete inline DeleteStack teardown', () => { + // The DELETE handler calls DeleteStack with no CloudFormation + // service role, so CloudFormation deletes the dashboard's S3 bucket + // and CloudFront distribution with the ADMIN task role's + // credentials. The role must hold those teardown actions, pinned to + // the mmgis-dashboard-* prefix (buckets) or the account's + // distribution ARN space (ids are random, so no name pin possible). + const adminRole = readJson('iam/admin-task-role.json') + + const buckets = statementBySid(adminRole, 'TeardownDashboardBuckets') + expect(buckets.Action).toContain('s3:DeleteBucket') + for (const resource of collectResources(buckets)) { + expect(resource).toContain('arn:aws:s3:::mmgis-dashboard-') + } + + const distributions = statementBySid( + adminRole, + 'TeardownDashboardDistributions' + ) + expect(distributions.Action).toContain('cloudfront:DeleteDistribution') + for (const resource of collectResources(distributions)) { + expect(resource).toContain(':distribution/') + } + }) + test('admin task role passes BOTH publish roles to RunTask', () => { const adminRole = readJson('iam/admin-task-role.json') const passRole = statementBySid(adminRole, 'PassBothPublishRoles') @@ -129,7 +184,7 @@ test.describe('infrastructure/ JSON recipes', () => { } }) - test('admin task def covers every env var the publish-flow code reads', () => { + test('task defs cover every env var the publish-flow code reads', () => { // Env names the code actually reads, greppable from // requireEnv("...") and process.env.MMGIS_* / process.env.AWS_REGION. const sourceFiles = fs @@ -143,6 +198,11 @@ test.describe('infrastructure/ JSON recipes', () => { // container overrides (see infrastructure/README.md). const RUN_TASK_OVERRIDES = ['DEPLOYMENT_ID', 'DEPLOYMENT_ACTION'] + // Vars only the publish-side code (scripts/publish-static.js and the + // template renderer it calls) reads. They ride the PUBLISH task + // definition; the admin task deliberately does not carry them. + const PUBLISH_ONLY = ['MMGIS_DASHBOARDS_PASSWORD'] + const wanted = new Set() const pattern = /requireEnv\(\s*["']([A-Z0-9_]+)["']\s*\)|process\.env\.(MMGIS_[A-Z0-9_]+|AWS_REGION)/g @@ -161,15 +221,31 @@ test.describe('infrastructure/ JSON recipes', () => { // Sanity: the grep found the publish-flow configuration set. expect(wanted.size).toBeGreaterThanOrEqual(8) - const container = readJson('ecs/admin-task.json').containerDefinitions[0] - const provided = new Set([ - ...(container.environment || []).map((e) => e.name), - ...(container.secrets || []).map((s) => s.name), - ]) + function providedBy(taskDefFile) { + const container = readJson(taskDefFile).containerDefinitions[0] + return new Set([ + ...(container.environment || []).map((e) => e.name), + ...(container.secrets || []).map((s) => s.name), + ]) + } + const adminProvided = providedBy('ecs/admin-task.json') + const publishProvided = providedBy('ecs/publish-task.json') for (const name of wanted) { - expect(provided.has(name), `admin task def provides ${name}`).toBe( - true - ) + if (PUBLISH_ONLY.includes(name)) { + expect( + publishProvided.has(name), + `publish task def provides ${name}` + ).toBe(true) + expect( + adminProvided.has(name), + `admin task def omits publish-only ${name}` + ).toBe(false) + } else { + expect( + adminProvided.has(name), + `admin task def provides ${name}` + ).toBe(true) + } } }) From 90755e45ea23b988d6fd5ef1c4e047aed7c4a0e5 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 10 Jun 2026 09:24:09 -0500 Subject: [PATCH 16/63] Repoint lean asset uploads to the shared S3 bucket In lean mode the Upload module writes validated images to the shared admin asset bucket under assets//... and returns the root-relative /assets/... path, which resolves same-origin in the admin (CloudFront /assets/* behavior) and in published dashboards (PR 8 copies the keys into each dashboard bucket). Full mode keeps writing to Missions/ on disk, unchanged. Validators, size cap, and the mission path-traversal guard apply identically in both modes. --- API/Backend/Upload/uploadRouter.js | 91 ++++++++- sample.env | 7 +- tests/unit/uploadRouterS3.spec.js | 295 +++++++++++++++++++++++++++++ 3 files changed, 386 insertions(+), 7 deletions(-) create mode 100644 tests/unit/uploadRouterS3.spec.js diff --git a/API/Backend/Upload/uploadRouter.js b/API/Backend/Upload/uploadRouter.js index 9d9bf6203..cf0d87791 100644 --- a/API/Backend/Upload/uploadRouter.js +++ b/API/Backend/Upload/uploadRouter.js @@ -4,6 +4,7 @@ const path = require('path'); const crypto = require('crypto'); const busboy = require('busboy'); const logger = require('../../logger'); +const { isLean } = require('../Utils/deploymentMode'); const { extensionForMime, isValidMission, @@ -14,11 +15,36 @@ const { const MISSIONS_DIR = path.join(__dirname, '../../../Missions'); const DEFAULT_MAX_FILE_BYTES = 5 * 1024 * 1024; // 5 MB +// Lean-mode S3 client for the shared admin asset bucket: created lazily on the +// first upload and injectable with setS3Client() so unit tests never touch +// real AWS. Mirrors the seam in scripts/lib/aws-provision.js. +let _s3 = null; +function getS3Client() { + if (_s3 == null) { + const { S3Client } = require('@aws-sdk/client-s3'); + _s3 = new S3Client({ region: process.env.AWS_REGION }); + } + return _s3; +} +// Test seam: inject a mock S3 client ({ send }), or null to reset. +function setS3Client(client) { + _s3 = client; +} + // Build a generic, plugin-agnostic single-file image-upload router. The caller // supplies the target per request via the `mission` and `subdir` query params -// (both validated against path traversal); the file lands at -// Missions///uploads/. and the route responds with -// { status: 'success', path: '/uploads/.' } (mission-relative). +// (both validated against path traversal). Storage depends on the deployment +// mode (validators, size cap, and response shape are identical in both): +// full (default): the file lands at +// Missions///uploads/. and the route responds +// { status: 'success', path: '/uploads/.' } +// (mission-relative). +// lean: the file is written to the shared admin asset bucket +// (MMGIS_SHARED_ASSET_BUCKET) under assets///uploads/ +// . and the route responds with the root-relative +// { status: 'success', path: '/assets///uploads/.' } +// — served same-origin by the admin's /assets/* CloudFront behavior and, +// after publish copies the keys, by each dashboard's own bucket. // // Options (mount-level policy, not per-request): // allowedMimeToExt mimetype -> extension allow-list (default: images) @@ -94,6 +120,62 @@ function createUploadRouter(options = {}) { return fail(400, 'Unsupported image type'); } + const filename = `${crypto.randomUUID()}.${ext}`; + + if (isLean()) { + // Lean mode: containers are ephemeral and published dashboards + // are static, so persist to the shared admin asset bucket + // instead of local disk. Buffer-then-put (the cap is 5 MB) so + // an oversize upload — busboy's 'limit' — aborts without ever + // starting a PutObject, guaranteeing no partial object. + const bucket = process.env.MMGIS_SHARED_ASSET_BUCKET; + if (!bucket) { + file.resume(); + return fail( + 500, + 'Image upload is not configured', + new Error( + 'MMGIS_SHARED_ASSET_BUCKET is unset (required for uploads in lean mode)' + ) + ); + } + const key = `assets/${mission}/${subdir}/uploads/${filename}`; + let chunks = []; + let aborted = false; + file.on('data', (chunk) => { + if (!aborted) chunks.push(chunk); + }); + file.on('limit', () => { + aborted = true; + chunks = []; + fail(413, 'Image exceeds size limit'); + }); + file.on('end', () => { + if (aborted) return; + const body = Buffer.concat(chunks); + chunks = []; + const { PutObjectCommand } = require('@aws-sdk/client-s3'); + getS3Client() + .send( + new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: body, + ContentLength: body.length, + ContentType: info.mimeType.toLowerCase(), + }) + ) + .then(() => { + savedRelPath = `/${key}`; + succeed(); + }) + .catch((err) => + fail(500, 'Failed to save image', err) + ); + }); + return; + } + const uploadsDir = path.join(missionDir, subdir, 'uploads'); try { fs.mkdirSync(uploadsDir, { recursive: true }); @@ -102,7 +184,6 @@ function createUploadRouter(options = {}) { return fail(500, 'Failed to create uploads directory', err); } - const filename = `${crypto.randomUUID()}.${ext}`; destPath = path.join(uploadsDir, filename); savedRelPath = `${subdir}/uploads/${filename}`; @@ -144,4 +225,4 @@ function createUploadRouter(options = {}) { return router; } -module.exports = { createUploadRouter }; +module.exports = { createUploadRouter, setS3Client }; diff --git a/sample.env b/sample.env index 2e9ab64d8..e9003e696 100644 --- a/sample.env +++ b/sample.env @@ -27,8 +27,11 @@ MMGIS_PUBLISH_SECURITY_GROUPS= # Container name inside the publish task definition; defaults to "mmgis" (lean only) MMGIS_PUBLISH_CONTAINER_NAME= # Shared S3 bucket holding the admin app's uploaded mission assets -# (/assets//…); publish same-key copies them into each dashboard's -# own bucket (lean only) +# (/assets//…). In lean mode the image-upload route (POST /api/upload) +# writes uploads here and returns a root-relative /assets/… path; publish +# same-key copies them into each dashboard's own bucket. In full mode uploads +# are written to local Missions// disk instead and this var is unused +# (lean only) MMGIS_SHARED_ASSET_BUCKET= # Shared password gating every published dashboard (HTTP Basic auth at the # CloudFront edge). Baked into each stack's CloudFront Function at publish diff --git a/tests/unit/uploadRouterS3.spec.js b/tests/unit/uploadRouterS3.spec.js new file mode 100644 index 000000000..fc2febbee --- /dev/null +++ b/tests/unit/uploadRouterS3.spec.js @@ -0,0 +1,295 @@ +import { test, expect } from '@playwright/test' +import express from 'express' +import fs from 'fs' +import http from 'http' +import path from 'path' + +// Tests for the Upload module's lean-mode storage repoint: in lean mode the +// router writes validated images to the shared S3 asset bucket (via an +// injected mock client — no test here ever calls real AWS) and returns a +// root-relative /assets/… path; in full mode it keeps writing to Missions/ +// on disk and never touches S3. +// +// The router resolves the deployment mode through +// API/Backend/Utils/deploymentMode.js, which reads the env once at load — so +// each test clears the require cache and re-requires the router under a fresh +// environment (same pattern as deploymentMode.spec.js). The HTTP harness +// mirrors Card/uploadRouting.spec.js — a real express server on an ephemeral +// port — but drives it with http.request and a hand-built multipart body +// rather than global fetch: Card/uploadImage.spec.js replaces global.fetch +// with a mock and never restores it, which poisons any later fetch-based +// spec sharing its worker. + +const ROUTER_PATH = '../../API/Backend/Upload/uploadRouter.js' +const MODE_PATH = '../../API/Backend/Utils/deploymentMode.js' +const MISSIONS_DIR = path.join(__dirname, '../../Missions') + +const ENV_KEYS = ['MMGIS_DEPLOYMENT_MODE', 'MMGIS_SHARED_ASSET_BUCKET'] + +function freshRouterModule(env) { + ENV_KEYS.forEach((key) => { + if (env[key] === undefined) delete process.env[key] + else process.env[key] = env[key] + }) + delete require.cache[require.resolve(MODE_PATH)] + delete require.cache[require.resolve(ROUTER_PATH)] + return require(ROUTER_PATH) +} + +async function startServer(routerModule, options = {}) { + const app = express() + app.use( + '/api/upload', + routerModule.createUploadRouter({ routePath: '/', ...options }) + ) + return await new Promise((resolve) => { + const server = app.listen(0, () => { + resolve({ + server, + base: `http://127.0.0.1:${server.address().port}`, + }) + }) + }) +} + +function closeServer(server) { + return new Promise((resolve) => server.close(resolve)) +} + +function mockS3() { + const calls = [] + return { + calls, + client: { + send: async (command) => { + calls.push(command) + return {} + }, + }, + } +} + +// POST a single-file multipart upload and resolve { status, body }. +function postUpload(base, query, bytes, type = 'image/png', name = 'test.png') { + const boundary = '----UploadRouterS3SpecBoundary' + const payload = Buffer.concat([ + Buffer.from( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="image"; filename="${name}"\r\n` + + `Content-Type: ${type}\r\n\r\n` + ), + bytes, + Buffer.from(`\r\n--${boundary}--\r\n`), + ]) + return new Promise((resolve, reject) => { + const req = http.request( + `${base}/api/upload?${query}`, + { + method: 'POST', + headers: { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + 'Content-Length': payload.length, + }, + }, + (res) => { + let data = '' + res.on('data', (chunk) => (data += chunk)) + res.on('end', () => + resolve({ status: res.statusCode, body: JSON.parse(data) }) + ) + } + ) + req.on('error', reject) + req.end(payload) + }) +} + +const PNG_BYTES = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex') + +test.describe('upload router lean-mode S3 storage', () => { + let savedEnv + + test.beforeEach(() => { + savedEnv = {} + ENV_KEYS.forEach((key) => { + savedEnv[key] = process.env[key] + }) + }) + + test.afterEach(() => { + ENV_KEYS.forEach((key) => { + if (savedEnv[key] === undefined) delete process.env[key] + else process.env[key] = savedEnv[key] + }) + delete require.cache[require.resolve(MODE_PATH)] + delete require.cache[require.resolve(ROUTER_PATH)] + }) + + test('lean: PutObject to the asset bucket, root-relative /assets/ path, nothing on disk', async () => { + const mission = `LeanUploadSpec${Date.now()}` + const routerModule = freshRouterModule({ + MMGIS_DEPLOYMENT_MODE: 'lean', + MMGIS_SHARED_ASSET_BUCKET: 'test-asset-bucket', + }) + const s3 = mockS3() + routerModule.setS3Client(s3.client) + const { server, base } = await startServer(routerModule) + try { + const res = await postUpload( + base, + `mission=${encodeURIComponent(mission)}&subdir=CardPlugin`, + PNG_BYTES + ) + expect(res.status).toBe(200) + const body = res.body + expect(body.status).toBe('success') + // Root-relative (leading slash), mission-scoped, uuid filename. + expect(body.path).toMatch( + new RegExp( + `^/assets/${mission}/CardPlugin/uploads/[0-9a-f-]{36}\\.png$` + ) + ) + + expect(s3.calls.length).toBe(1) + const cmd = s3.calls[0] + expect(cmd.constructor.name).toBe('PutObjectCommand') + expect(cmd.input.Bucket).toBe('test-asset-bucket') + // The S3 key is the response path without the leading slash. + expect(cmd.input.Key).toBe(body.path.slice(1)) + expect(cmd.input.ContentType).toBe('image/png') + expect(Buffer.compare(cmd.input.Body, PNG_BYTES)).toBe(0) + expect(cmd.input.ContentLength).toBe(PNG_BYTES.length) + + // No partial/parallel write under Missions/. + expect(fs.existsSync(path.join(MISSIONS_DIR, mission))).toBe(false) + } finally { + await closeServer(server) + } + }) + + test('lean: oversize upload -> 413 and no PutObject (no partial object)', async () => { + const routerModule = freshRouterModule({ + MMGIS_DEPLOYMENT_MODE: 'lean', + MMGIS_SHARED_ASSET_BUCKET: 'test-asset-bucket', + }) + const s3 = mockS3() + routerModule.setS3Client(s3.client) + const { server, base } = await startServer(routerModule, { + maxFileBytes: 64, + }) + try { + const res = await postUpload( + base, + 'mission=MSL&subdir=CardPlugin', + Buffer.alloc(1024, 1) + ) + expect(res.status).toBe(413) + expect(res.body.status).toBe('failure') + expect(s3.calls.length).toBe(0) + } finally { + await closeServer(server) + } + }) + + test('lean: path-traversal mission name -> 400 and no PutObject', async () => { + const routerModule = freshRouterModule({ + MMGIS_DEPLOYMENT_MODE: 'lean', + MMGIS_SHARED_ASSET_BUCKET: 'test-asset-bucket', + }) + const s3 = mockS3() + routerModule.setS3Client(s3.client) + const { server, base } = await startServer(routerModule) + try { + const res = await postUpload( + base, + `mission=${encodeURIComponent('../escape')}&subdir=CardPlugin`, + PNG_BYTES + ) + expect(res.status).toBe(400) + expect(res.body.message).toBe('Invalid or missing mission') + expect(s3.calls.length).toBe(0) + } finally { + await closeServer(server) + } + }) + + test('lean: unsupported MIME type -> 400 and no PutObject', async () => { + const routerModule = freshRouterModule({ + MMGIS_DEPLOYMENT_MODE: 'lean', + MMGIS_SHARED_ASSET_BUCKET: 'test-asset-bucket', + }) + const s3 = mockS3() + routerModule.setS3Client(s3.client) + const { server, base } = await startServer(routerModule) + try { + const res = await postUpload( + base, + 'mission=MSL&subdir=CardPlugin', + PNG_BYTES, + 'text/plain', + 'x.txt' + ) + expect(res.status).toBe(400) + expect(res.body.message).toBe('Unsupported image type') + expect(s3.calls.length).toBe(0) + } finally { + await closeServer(server) + } + }) + + test('lean: missing MMGIS_SHARED_ASSET_BUCKET -> graceful 500, no PutObject', async () => { + const routerModule = freshRouterModule({ + MMGIS_DEPLOYMENT_MODE: 'lean', + MMGIS_SHARED_ASSET_BUCKET: undefined, + }) + const s3 = mockS3() + routerModule.setS3Client(s3.client) + const { server, base } = await startServer(routerModule) + try { + const res = await postUpload( + base, + 'mission=MSL&subdir=CardPlugin', + PNG_BYTES + ) + expect(res.status).toBe(500) + expect(res.body.status).toBe('failure') + expect(s3.calls.length).toBe(0) + } finally { + await closeServer(server) + } + }) + + test('full: writes to Missions/ disk, mission-relative path, S3 untouched', async () => { + const mission = `FullUploadSpec${Date.now()}` + const missionDir = path.join(MISSIONS_DIR, mission) + const routerModule = freshRouterModule({ + MMGIS_DEPLOYMENT_MODE: 'full', + // Set the bucket too: full mode must ignore it entirely. + MMGIS_SHARED_ASSET_BUCKET: 'test-asset-bucket', + }) + // Inject a tripwire client: any S3 use in full mode fails the test. + const s3 = mockS3() + routerModule.setS3Client(s3.client) + const { server, base } = await startServer(routerModule) + try { + const res = await postUpload( + base, + `mission=${encodeURIComponent(mission)}&subdir=CardPlugin`, + PNG_BYTES + ) + expect(res.status).toBe(200) + const body = res.body + expect(body.status).toBe('success') + // Mission-relative, no leading slash — byte-for-byte today's shape. + expect(body.path).toMatch( + /^CardPlugin\/uploads\/[0-9a-f-]{36}\.png$/ + ) + const written = fs.readFileSync(path.join(missionDir, body.path)) + expect(Buffer.compare(written, PNG_BYTES)).toBe(0) + expect(s3.calls.length).toBe(0) + } finally { + await closeServer(server) + fs.rmSync(missionDir, { recursive: true, force: true }) + } + }) +}) From ebcb43a99b6883a96228339a08de12167124877e Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 10 Jun 2026 10:30:51 -0500 Subject: [PATCH 17/63] Poll deployment status and surface publish completion in Configure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While any deployment is provisioning, updating, or deleting, the Deployments page refreshes itself every 15s, and a Configure-level watcher raises a snackbar when a publish completes (with the dashboard link), fails, or a delete finishes — so the admin no longer has to refresh manually to learn the outcome. Polling runs only while a transition is in flight; one shared poller prevents duplicate request streams. Frontend-only. --- .../DeploymentsWatcher/DeploymentsWatcher.js | 147 ++++++++++++++++++ configure/src/components/SaveBar/SaveBar.js | 16 +- configure/src/components/SnackBar/SnackBar.js | 12 ++ configure/src/core/Configure.js | 2 + configure/src/core/ConfigureStore.js | 22 +++ .../src/pages/Deployments/Deployments.js | 30 ++-- 6 files changed, 209 insertions(+), 20 deletions(-) create mode 100644 configure/src/components/DeploymentsWatcher/DeploymentsWatcher.js diff --git a/configure/src/components/DeploymentsWatcher/DeploymentsWatcher.js b/configure/src/components/DeploymentsWatcher/DeploymentsWatcher.js new file mode 100644 index 000000000..b4157e9d5 --- /dev/null +++ b/configure/src/components/DeploymentsWatcher/DeploymentsWatcher.js @@ -0,0 +1,147 @@ +import { useEffect, useRef } from "react"; +import { useSelector, useDispatch } from "react-redux"; + +import { calls } from "../../core/calls"; +import { + setDeployments, + watchDeployment, + unwatchDeployment, + setSnackBarText, +} from "../../core/ConfigureStore"; + +const POLL_INTERVAL_MS = 15000; +const TRANSITIONAL_STATUSES = ["provisioning", "updating", "deleting"]; + +// The one shared deployments fetch — every reader (the Deployments page, +// this watcher's poll) goes through here and subscribes to +// state.core.deployments, so there is never a second parallel request +// stream. `quiet` suppresses the error snackbar for background polls. +export function queryDeployments(dispatch, options) { + const { quiet, onDone } = options || {}; + calls.api( + "getDeployments", + {}, + (res) => { + dispatch(setDeployments(res?.body?.deployments || [])); + if (typeof onDone === "function") onDone(); + }, + (res) => { + if (!quiet) + dispatch( + setSnackBarText({ + text: res?.message || "Failed to get deployments.", + severity: "error", + }) + ); + if (typeof onDone === "function") onDone(); + } + ); +} + +// Lean-only (no-op in full mode where /api/deployments doesn't exist). +// Mounted once at the Configure level so the admin hears about publish +// completion no matter which page they're on. Watches begin from the +// SaveBar's publish response and from any transitional row seen in the +// deployments list; while any watch is open, this polls getDeployments +// every 15s and raises a snackbar on each terminal transition. +export default function DeploymentsWatcher() { + const dispatch = useDispatch(); + + const deployments = useSelector((state) => state.core.deployments); + const deploymentsWatch = useSelector((state) => state.core.deploymentsWatch); + + const inFlightRef = useRef(false); + const lastDiffedRef = useRef(null); + + const isLean = window.mmgisglobal.DEPLOYMENT_MODE === "lean"; + const isWatching = Object.keys(deploymentsWatch).length > 0; + + // Diff each deployments list against the watch set: auto-watch + // transitional rows, toast watched rows that reached a terminal status. + useEffect(() => { + if (!isLean) return; + + const byId = {}; + deployments.forEach((d) => { + byId[d.id] = d; + }); + + deployments.forEach((d) => { + const watched = deploymentsWatch[d.id]; + if (TRANSITIONAL_STATUSES.includes(d.status)) { + if (watched == null || watched.status !== d.status) + dispatch( + watchDeployment({ id: d.id, name: d.name, status: d.status }) + ); + } else if (watched != null) { + if (d.status === "published") + dispatch( + setSnackBarText({ + text: `'${d.name}' published —`, + severity: "success", + link: d.cloudfront_url, + }) + ); + else if (d.status === "failed") + dispatch( + setSnackBarText({ + text: `'${d.name}' publish failed — see Deployments.`, + severity: "error", + }) + ); + else if (d.status === "deleted") + dispatch( + setSnackBarText({ + text: `'${d.name}' deleted.`, + severity: "info", + }) + ); + dispatch(unwatchDeployment({ id: d.id })); + } + }); + + // A watched row that vanished from the list finished a delete (the row + // is gone outright instead of flipping to 'deleted'). Only judged + // against a freshly fetched list — when this effect re-runs because the + // watch set changed (e.g. SaveBar just added a watch), the list on hand + // predates that watch and would falsely report the row missing. + const listIsFresh = lastDiffedRef.current !== deployments; + lastDiffedRef.current = deployments; + if (listIsFresh) + Object.keys(deploymentsWatch).forEach((id) => { + if (byId[id] == null) { + if (deploymentsWatch[id].status === "deleting") + dispatch( + setSnackBarText({ + text: `'${deploymentsWatch[id].name}' deleted.`, + severity: "info", + }) + ); + dispatch(unwatchDeployment({ id: id })); + } + }); + }, [deployments, deploymentsWatch, dispatch, isLean]); + + // Poll while any watch is open. Keyed on the boolean so watch-set churn + // doesn't reset the timer's phase. + useEffect(() => { + if (!isLean || !isWatching) return; + + const interval = setInterval(() => { + // Skip the tick if the previous poll hasn't answered yet so a slow + // backend can't stack parallel requests. + if (inFlightRef.current) return; + inFlightRef.current = true; + queryDeployments(dispatch, { + quiet: true, + onDone: () => { + inFlightRef.current = false; + }, + }); + }, POLL_INTERVAL_MS); + + return () => clearInterval(interval); + }, [isWatching, dispatch, isLean]); + + return null; +} diff --git a/configure/src/components/SaveBar/SaveBar.js b/configure/src/components/SaveBar/SaveBar.js index f82c150ca..de34e9318 100644 --- a/configure/src/components/SaveBar/SaveBar.js +++ b/configure/src/components/SaveBar/SaveBar.js @@ -13,6 +13,7 @@ import { clearLockConfig, saveConfiguration, setSnackBarText, + watchDeployment, } from "../../core/ConfigureStore"; import Button from "@mui/material/Button"; @@ -99,11 +100,22 @@ export default function SaveBar() { calls.api( call, data, - () => { + (res) => { setPublishing(false); + // Hand the in-flight deployment to DeploymentsWatcher, which + // polls and raises a snackbar when the publish completes. + const deployment = res?.body?.deployment; + if (deployment != null) + dispatch( + watchDeployment({ + id: deployment.id, + name: deployment.name, + status: deployment.status, + }) + ); dispatch( setSnackBarText({ - text: "Publishing… — see Deployments for live status.", + text: "Publishing… — you'll be notified here when it finishes.", severity: "success", }) ); diff --git a/configure/src/components/SnackBar/SnackBar.js b/configure/src/components/SnackBar/SnackBar.js index 06d4af04f..acb0f4568 100644 --- a/configure/src/components/SnackBar/SnackBar.js +++ b/configure/src/components/SnackBar/SnackBar.js @@ -7,6 +7,7 @@ import { makeStyles } from "@mui/styles"; import Snackbar from "@mui/material/Snackbar"; import MuiAlert from "@mui/material/Alert"; +import Link from "@mui/material/Link"; const useStyles = makeStyles((theme) => ({ main: { top: "74px !important" }, @@ -54,6 +55,17 @@ const SnackBar = (props) => { severity={snackBarText.severity || "success"} > {snackBarText.text || afterImage} + {snackBarText.link ? ( + + {snackBarText.link} + + ) : null} ); diff --git a/configure/src/core/Configure.js b/configure/src/core/Configure.js index aab4e4817..f8b0788eb 100644 --- a/configure/src/core/Configure.js +++ b/configure/src/core/Configure.js @@ -5,6 +5,7 @@ import { makeStyles } from "@mui/styles"; import Main from "../components/Main/Main"; import Panel from "../components/Panel/Panel"; +import DeploymentsWatcher from "../components/DeploymentsWatcher/DeploymentsWatcher"; import { calls } from "../core/calls"; import { setMissions, setSnackBarText } from "./ConfigureStore"; @@ -63,6 +64,7 @@ export default function Configure() {
+ ); } diff --git a/configure/src/core/ConfigureStore.js b/configure/src/core/ConfigureStore.js index 94fd66b80..13e51eec2 100644 --- a/configure/src/core/ConfigureStore.js +++ b/configure/src/core/ConfigureStore.js @@ -16,6 +16,12 @@ export const ConfigureStore = createSlice({ componentConfiguration: {}, geodatasets: [], datasets: [], + deployments: [], + // Deployments currently in a transitional status (provisioning, updating, + // deleting) being watched for completion: { [id]: { name, status } }. + // DeploymentsWatcher polls while this is non-empty and raises a snackbar + // on each watched deployment's terminal transition. + deploymentsWatch: {}, stacCollections: [], userEntries: [], page: null, @@ -86,6 +92,18 @@ export const ConfigureStore = createSlice({ setDatasets: (state, action) => { state.datasets = action.payload; }, + setDeployments: (state, action) => { + state.deployments = action.payload; + }, + watchDeployment: (state, action) => { + state.deploymentsWatch[action.payload.id] = { + name: action.payload.name, + status: action.payload.status, + }; + }, + unwatchDeployment: (state, action) => { + delete state.deploymentsWatch[action.payload.id]; + }, setStacCollections: (state, action) => { state.stacCollections = action.payload; }, @@ -116,6 +134,7 @@ export const ConfigureStore = createSlice({ state.snackBarText = { text: String(action.payload.text), severity: action.payload.severity, + link: action.payload.link != null ? String(action.payload.link) : null, }; }, setValidationErrors: (state, action) => { @@ -221,6 +240,9 @@ export const { setComponentConfiguration, setGeodatasets, setDatasets, + setDeployments, + watchDeployment, + unwatchDeployment, setStacCollections, setUserEntries, setPage, diff --git a/configure/src/pages/Deployments/Deployments.js b/configure/src/pages/Deployments/Deployments.js index 74cd788fa..80b3e5b23 100644 --- a/configure/src/pages/Deployments/Deployments.js +++ b/configure/src/pages/Deployments/Deployments.js @@ -6,6 +6,7 @@ import clsx from "clsx"; import { calls } from "../../core/calls"; import { setModal, setSnackBarText } from "../../core/ConfigureStore"; +import { queryDeployments as queryDeploymentsCall } from "../../components/DeploymentsWatcher/DeploymentsWatcher"; import DeleteDeploymentModal from "./Modals/DeleteDeploymentModal/DeleteDeploymentModal"; @@ -156,26 +157,18 @@ export default function Deployments() { const missions = useSelector((state) => state.core.missions); - const [deployments, setDeployments] = useState([]); + // The list lives in the store and is shared with DeploymentsWatcher, + // which polls every 15s while any row is transitional — so this page + // auto-refreshes through the same single request stream (one shared + // poller, no page-local interval). Manual refreshes here are one-shot + // fetches through the same helper. + const deployments = useSelector((state) => state.core.deployments); + const [publishMission, setPublishMission] = useState(""); const [publishName, setPublishName] = useState(""); const queryDeployments = useCallback(() => { - calls.api( - "getDeployments", - {}, - (res) => { - setDeployments(res?.body?.deployments || []); - }, - (res) => { - dispatch( - setSnackBarText({ - text: res?.message || "Failed to get deployments.", - severity: "error", - }) - ); - } - ); + queryDeploymentsCall(dispatch); }, [dispatch]); useEffect(() => { @@ -198,7 +191,7 @@ export default function Deployments() { () => { dispatch( setSnackBarText({ - text: "Publishing… This runs in the background; refresh for live status.", + text: "Publishing… This runs in the background; status refreshes automatically.", severity: "success", }) ); @@ -274,7 +267,8 @@ export default function Deployments() {
Publish a mission as a standalone, statically-hosted dashboard. - Status is read live from CloudFormation on every refresh. + Status is read live from CloudFormation and refreshes automatically + while a publish, update or delete is in flight.
From 58ff7e532ec5c4762a0e5096403282bde8b27f82 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 10 Jun 2026 10:39:55 -0500 Subject: [PATCH 18/63] Polish deployment completion toasts Keep link-carrying toasts (the published-dashboard URL) on screen until dismissed, combine simultaneous completion toasts into one message instead of dropping all but the last, and avoid a dangling dash when a published row has no URL. --- .../DeploymentsWatcher/DeploymentsWatcher.js | 58 +++++++++++-------- configure/src/components/SnackBar/SnackBar.js | 4 +- 2 files changed, 36 insertions(+), 26 deletions(-) diff --git a/configure/src/components/DeploymentsWatcher/DeploymentsWatcher.js b/configure/src/components/DeploymentsWatcher/DeploymentsWatcher.js index b4157e9d5..bb8350e96 100644 --- a/configure/src/components/DeploymentsWatcher/DeploymentsWatcher.js +++ b/configure/src/components/DeploymentsWatcher/DeploymentsWatcher.js @@ -66,6 +66,11 @@ export default function DeploymentsWatcher() { byId[d.id] = d; }); + // Collected through both passes below and dispatched once at the end — + // the snackbar has a single slot, so simultaneous finishes get one + // combined message instead of silently dropping all but the last. + const toasts = []; + deployments.forEach((d) => { const watched = deploymentsWatch[d.id]; if (TRANSITIONAL_STATUSES.includes(d.status)) { @@ -75,27 +80,23 @@ export default function DeploymentsWatcher() { ); } else if (watched != null) { if (d.status === "published") - dispatch( - setSnackBarText({ - text: `'${d.name}' published —`, - severity: "success", - link: d.cloudfront_url, - }) - ); + toasts.push({ + text: d.cloudfront_url + ? `'${d.name}' published —` + : `'${d.name}' published.`, + severity: "success", + link: d.cloudfront_url, + }); else if (d.status === "failed") - dispatch( - setSnackBarText({ - text: `'${d.name}' publish failed — see Deployments.`, - severity: "error", - }) - ); + toasts.push({ + text: `'${d.name}' publish failed — see Deployments.`, + severity: "error", + }); else if (d.status === "deleted") - dispatch( - setSnackBarText({ - text: `'${d.name}' deleted.`, - severity: "info", - }) - ); + toasts.push({ + text: `'${d.name}' deleted.`, + severity: "info", + }); dispatch(unwatchDeployment({ id: d.id })); } }); @@ -111,15 +112,22 @@ export default function DeploymentsWatcher() { Object.keys(deploymentsWatch).forEach((id) => { if (byId[id] == null) { if (deploymentsWatch[id].status === "deleting") - dispatch( - setSnackBarText({ - text: `'${deploymentsWatch[id].name}' deleted.`, - severity: "info", - }) - ); + toasts.push({ + text: `'${deploymentsWatch[id].name}' deleted.`, + severity: "info", + }); dispatch(unwatchDeployment({ id: id })); } }); + + if (toasts.length === 1) dispatch(setSnackBarText(toasts[0])); + else if (toasts.length > 1) + dispatch( + setSnackBarText({ + text: `${toasts.length} deployments finished — see Deployments.`, + severity: "info", + }) + ); }, [deployments, deploymentsWatch, dispatch, isLean]); // Poll while any watch is open. Keyed on the boolean so watch-set churn diff --git a/configure/src/components/SnackBar/SnackBar.js b/configure/src/components/SnackBar/SnackBar.js index acb0f4568..b139e9dac 100644 --- a/configure/src/components/SnackBar/SnackBar.js +++ b/configure/src/components/SnackBar/SnackBar.js @@ -44,7 +44,9 @@ const SnackBar = (props) => { horizontal: "right", }} open={openSnackbar} - autoHideDuration={5000} + // A toast carrying a link (e.g. a freshly published dashboard URL) + // stays until dismissed; plain toasts auto-hide. + autoHideDuration={snackBarText.link ? null : 5000} onClose={handleCloseSnackbar} > Date: Wed, 10 Jun 2026 11:04:05 -0500 Subject: [PATCH 19/63] Add lean follow-up ledger and next-steps doc PR bodies in the lean series (#138, #139) reference follow-up.md for deferred items; commit it and the review/merge/deploy next-steps doc so those references resolve. --- docs/adr/deployment/lean/prs/follow-up.md | 26 +++++++++++++++ docs/adr/deployment/lean/prs/next-steps.md | 37 ++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 docs/adr/deployment/lean/prs/follow-up.md create mode 100644 docs/adr/deployment/lean/prs/next-steps.md diff --git a/docs/adr/deployment/lean/prs/follow-up.md b/docs/adr/deployment/lean/prs/follow-up.md new file mode 100644 index 000000000..2a45e38de --- /dev/null +++ b/docs/adr/deployment/lean/prs/follow-up.md @@ -0,0 +1,26 @@ +This is an LLM artifact — out-of-scope findings collected while implementing PRs 1–7, 9, 12 (2026-06-09). None block the lean PR stack; each is a candidate for a real issue later. "Source" = where it was discovered. + +# Follow-ups (not in any PR's scope) + +## Latent bugs / hazards + +1. **Latent `DrawTool` cross-references break specific flows in lean.** `src/essence/Tools/Kinds/Kinds.js:341` calls `TC_.getTool('DrawTool').showContextMenu(...)` for the `draw_tool` kind (null deref when Draw is gated out, PR 4), and `src/essence/Tools/Shade/ShadeTool.js:~1752–1769` references a module-global `DrawTool` during Shade indicator drag. Only triggered by specific user actions (a mission configuring a `draw_tool` click behavior; Shade indicator drag), but both deserve null-guards in a hardening pass. *Source: PR 4 spec gotchas, confirmed by implementer.* +2. **Six pre-existing `ServiceUrls` builders interpolate a possibly-null base URL.** `buildTiTilerCogTilesUrl`, `buildStacCollectionTilesUrl`, `buildTiTilerPointUrl`, `buildStacCollectionPointUrl`, `buildColormapImageUrl`, `buildStacItemsUrl` return strings like `"null/cog/tiles/…"` when the service is unconfigured in a static build, and their `@returns {string}` JSDoc is now inaccurate (PR 7 made `getServiceUrl` nullable in static mode). Add the same `if (baseUrl == null) return null` guard the new builders use + update JSDoc, then audit call sites. *Source: PR 7 quality review.* +3. **`scripts/server.js:788` websocket gate is a truthy check.** `if (process.env.ENABLE_MMGIS_WEBSOCKETS)` — the string `"false"` still enables websockets. Pre-existing; compare with the `=== 'true'` checks used elsewhere. *Source: PR 12 review.* + +## Stale / broken repo state + +4. **Committed `configure/public/toolConfigs.json` is stale vs the in-tree tools.** Regenerating in full mode adds `Chart` and `FetchStats` entries and a differing `Title` config the committed file lacks (plus formatting differences), so any full-mode boot dirties a tracked file. Regenerate and commit it in a dedicated change (or stop tracking it — it's boot-generated). *Source: PR 4 implementer.* +5. **`tests/unit/` cannot run as a single command.** Eight specs fail at import time with `window is not defined`: `deckGLAdapter`, `LeafletAdapter`, `mapEngineRegistry`, and the `panelManager/*` suite (5 files). Identical at the pre-lean base commit. *Source: PR 7 implementer.* +6. **`tests/unit/Card/uploadRouting.spec.js` is order-dependent.** `uploadImage.spec.js` replaces `global.fetch` and never restores it, so `uploadRouting` fails under `--workers=1`. Restore the mock in an `afterEach`/`afterAll`. *Source: PR 7 implementer.* +7. **`AGENTS.md` testing section is wrong.** It claims "Framework: Jest 29"; the repo's `npm test` is `playwright test` (unit specs live in `tests/unit/*.spec.{js,ts}`; there is no jest config or runner). Misled one implementation pass already. PR 13's doc sweep could absorb this. *Source: PR 1 quality review.* +8. **PR-04 spec doc has a wrong import path.** `docs/adr/deployment/lean/prs/pr-04-gate-draw.md` suggests `require("../../Utils/deploymentMode")` from `API/Backend/Draw/setup.js`; correct is `../Utils/deploymentMode` (the wrong path resolves to a nonexistent `API/Utils/` and broke Draw setup in both modes until caught at boot). The shipped code is correct; the doc isn't. *Source: PR 4 implementer.* + +## Cleanups / verification debt + +9. **`scripts/init-db.js` explicit-executor antipattern.** The `new Promise(async (resolve, reject) => …)` wrapper with floating internal promise chains predates the lean work; PR 12 mirrored it to stay minimal. Flatten to plain async/await in a dedicated cleanup. *Source: PR 12 quality review.* +10. **Verify PR 9's TiTiler statistics parsing against a live TiTiler.** The static-mode COG min/max reroute assumes the documented `{"b1": {min, max}}` response shape (with a first-band fallback); it has not been exercised against a real TiTiler instance yet. *Source: PR 9 implementer.* +11. **Deployment webhooks signal submission, not completion.** PR 8 fires `deploymentPublish`/`deploymentUpdate` from the route handlers (row at `provisioning`/`updating`; first-publish payload has `cloudfront_url: null`) because the true terminal update happens inside the detached ECS publish task — and `triggerwebhooks.js` relies on an in-memory `webhooksConfig` cache a fresh task process never hydrates. Consider a completion event (e.g. `deploymentPublished`) fired from `publish-static.js` after the terminal row update, reading webhook entries directly from the DB; or document that consumers should poll `GET /api/deployments/:id`. *Source: PR 8 spec review.* +12. **Admin-side teardown: consider a CFN service role.** PR 8's DELETE handler runs `DeleteStack` with the admin task role's credentials (no stack service role), so PR 11 grants the admin role the `mmgis-dashboard-*` teardown set (`s3:DeleteBucket`, `cloudfront:Delete*`, etc. — the PR 11 spec's narrower scoping would have failed every delete). The stricter long-term shape is a CloudFormation service role passed at create/delete so the admin role shrinks back down. Validate the whole delete path in the staging deploy either way. *Source: PR 11 review (fixed in PR 11; service-role alternative deferred).* +13. **Save-bar Publish silently republishes a live dashboard.** The save-bar button auto-decides publish-vs-update (lean is 1:1 mission↔dashboard); on an already-published mission a click replaces the live dashboard's contents with the just-saved config, with no "you're about to change the published version" affirmation. Consider a lightweight confirm on the update path. (Delete already has a type-the-name modal; publish-status visibility is handled by the polling/toast commit on #138.) *Source: UX walkthrough, 2026-06-10.* +14. **Deferred review nits** (all optional, noted-and-skipped during review): silent skip of the `mmgis-stac` block in lean could log like its sibling gates (`scripts/init-db.js`, PR 2); the four `WITH_*` ternaries in `API/Backend/Config/setup.js` could hoist a single `isLean()` call (PR 2); the ~140-line gated block in `API/Backend/Utils/routes/utils.js` could label its closing brace (PR 5); `titiler.ts` re-implements the static check inline instead of an exported `ServiceUrls.isStaticBuild()` (PR 7); the staticHandlers parity spec's registry regex requires the exact multi-line entry shape — drop the `$` anchor to also catch single-line entries (PR 7). diff --git a/docs/adr/deployment/lean/prs/next-steps.md b/docs/adr/deployment/lean/prs/next-steps.md new file mode 100644 index 000000000..a102818a1 --- /dev/null +++ b/docs/adr/deployment/lean/prs/next-steps.md @@ -0,0 +1,37 @@ +This is an LLM artifact — the path from the current state (12 draft PRs, #129–#140, nothing on AWS) to reviewed, merged, and deployed. Written 2026-06-10. + +# Lean deployment — next steps + +## Where things stand + +PRs 1–12 of the series are open as stacked drafts (see any PR body's "Merge order" for the graph). PR 13 (cleanup + dual-mode CI) is unwritten by design — it lands last. Nothing has touched AWS; `infrastructure/` is unapplied recipes and `deploy-lean.yml` has never run. Out-of-scope findings live in [`follow-up.md`](./follow-up.md). + +## 1. Review + +1. Flip drafts to "Ready for review" in dependency order — #129 first, then the rest in any order; reviewing a child before its parent works fine since each diff shows only its own work. +2. Review-fix churn propagates by **merge, never rebase**: a fix lands on its own PR's branch; children pick it up by merging the parent in (only needed when the fix functionally affects them). For #138/#140, fixes to a parent also get merged into their integration branches (`lean/pr-08-base`, `lean/pr-10-base`) to keep diffs clean. +3. The local worktrees (`MMGIS-lean-pr-NN`, each with its own port + DB) are still up for hands-on testing of any PR — flip `MMGIS_DEPLOYMENT_MODE` in the worktree `.env` and use the deployment skill's `start.sh`. + +## 2. Merge + +1. Merge **#129** into `feature/mmgis-deployment-skill`, deleting its branch — GitHub auto-retargets the siblings. +2. Merge the independent tier in any order: #130, #131, #132, #133, #136, #135. Known trivial conflict: #130 and #131 both touch `API/Backend/Config/setup.js`; whichever merges second resolves a few lines. +3. Merge the children as their parents land: #134 (after #131), #137 (after #136), #138 (retarget its base from `lean/pr-08-base` to the skill branch once #131 + #136 are in, then delete `lean/pr-08-base`), #139 (after #138), #140 (same retarget dance with `lean/pr-10-base` once #139 + #133 are in). +4. After everything merges: write and land **PR 13** (gate audit, CI matrix running both modes, README/AGENTS/docs updates — fold in the AGENTS.md Jest→Playwright fix), convert [`follow-up.md`](./follow-up.md) items into real issues, regenerate the stale `configure/public/toolConfigs.json`, and tear down the local worktrees (`teardown.sh`, which prompts per deployment). +5. Eventually this all rides `feature/mmgis-deployment-skill` → `development` through whatever review that merge gets. + +## 3. Deploy (staging first) + +Prereqs are operator setup, detailed in [`infrastructure/README.md`](../../../../infrastructure/README.md) (on the #139 branch until merged): + +1. **One-time AWS setup:** ECR repository; the five Secrets Manager entries (DB creds, `SECRET` session secret, `SEED_SUPERADMIN_USERNAME`/`_PASSWORD`, dashboards shared password); managed Postgres reachable from the VPC; CloudWatch log groups; the shared asset bucket; the GitHub OIDC deploy role; admin hostname + ACM cert (operator-owned DNS); NAT/egress for outbound webhooks. +2. **Fill placeholders** (``, ``, ARNs, …) in `infrastructure/`, register the two task definitions, create the ECS **Express Mode** service for the admin, and put the admin CloudFront distribution in front of the endpoint it exposes. +3. **First deploy** via `deploy-lean.yml` (release trigger or `workflow_dispatch`): builds the image (with themes), pushes to ECR, registers new task-def revisions, triggers the managed rollout. +4. **Staging verification checklist** (from the PR 11 spec — this is where the deliberately-unverified items get proven): + - Log in at the admin URL (proves AllViewer + CachingDisabled + `trust proxy 2`); confirm the seeded superadmin works and `first_signup` is gated. + - Configure a mission against a public COG URL; upload an image (proves the asset bucket + `/assets/*` behavior + PR 10's S3 write). + - **Publish** a dashboard end-to-end (proves `RunTask` + PassRole + the publish role's CFN/S3/CloudFront scopes, and the open D1 question of RunTask under Express Mode networking); open the URL, confirm the password gate 401s without credentials and the map renders with them, with zero `/api/*` calls. + - **Update** the dashboard (same URL re-baked), then **Delete** it (proves the admin role's teardown set — watch for `DELETE_FAILED`; the CFN-service-role alternative in follow-up.md is the fallback). + - IAM least-privilege spot-check with the policy simulator against out-of-prefix ARNs. + - Publish a `modern`-mode mission and confirm panels render (the PR 8 spec's e2e check that can't run locally). +5. Fix what staging surfaces (expected suspects are listed in follow-up.md), then repeat for production with its own secrets/cert/hostname. From 1fd4df26592a6705d26ebe14c31ecd1b5b4eace6 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 10 Jun 2026 11:04:51 -0500 Subject: [PATCH 20/63] Enable admin WebSocket flows in the lean task definition The lean ADR commits to two admin-only WebSocket flows (Configure lock warnings and layer-update push), but ENABLE_MMGIS_WEBSOCKETS and ENABLE_CONFIG_WEBSOCKETS default to off, so a by-the-README deploy shipped them silently disabled. Set both true in the admin task def and document why. --- infrastructure/README.md | 1 + infrastructure/ecs/admin-task.json | 2 ++ 2 files changed, 3 insertions(+) diff --git a/infrastructure/README.md b/infrastructure/README.md index 8cd4510f3..f3b2a5ef3 100644 --- a/infrastructure/README.md +++ b/infrastructure/README.md @@ -63,6 +63,7 @@ Replace these consistently across every file before applying: - **Everything is pinned — with one deliberate exception.** Dashboard-facing grants are pinned to the `mmgis-dashboard-*` prefix (stack names, bucket names, and CloudFront Function names all carry it — see `STACK_NAME_PREFIX` in `scripts/lib/cfn-template.js`); asset grants are pinned to ``. CloudFront distribution and origin-access-control ARNs cannot be name-pinned (their ids are random), so those statements are scoped to the resource type within the account. The one `Resource: "*"` in this directory is the `EcrAuthTokenNoResourceScoping` statement in both execution roles: `ecr:GetAuthorizationToken` supports **no** resource-level permissions, so it authorizes against `*` by design (any narrower Resource is an implicit deny that fails every Fargate image pull). This matches AWS's own `AmazonECSTaskExecutionRolePolicy`, and the action only returns a registry auth token — it grants no access to repository data (the pull actions stay pinned to ``). - **Admin-side teardown.** The Deployments DELETE handler empties the dashboard bucket and calls `DeleteStack` inline under the **admin task role**, with no CloudFormation service role attached to the stack — so CloudFormation deletes the stack's S3 bucket, CloudFront distribution, password-gate Function, and origin access control with the caller's (the admin role's) credentials. That is why `iam/admin-task-role.json` carries `s3:DeleteBucket`/`s3:DeleteBucketPolicy` and the CloudFront disable/delete set, still pinned to the `mmgis-dashboard-*` prefix (Function names) or the account's distribution/OAC ARN space. Giving the stacks a dedicated CloudFormation service role would let the admin role shed these grants and is the stricter future alternative. - **Publish task role vs. `cfn-template.js`.** Beyond the core create set, the role carries the actions the rendered template actually requires: origin-access-control lifecycle (the template creates an `AWS::CloudFront::OriginAccessControl`), `s3:PutBucketPublicAccessBlock`/`s3:PutEncryptionConfiguration`/`s3:DeleteBucketPolicy` (the bucket ships with public-access block + encryption + a bucket policy), and `cloudfront:TagResource`/`s3:PutBucketTagging` (`createStack()` tags the stack and CloudFormation propagates tags to resources). **No `rds-db:connect`** — the app uses password auth. **No `secretsmanager:GetSecretValue` either**: the dashboards password reaches `publish-static.js` as the `MMGIS_DASHBOARDS_PASSWORD` env var through the publish *execution* role's `secrets[]` injection — the container code never reads Secrets Manager at runtime (the spec's "read at runtime" wording is outdated). For the same reason the password rides only the publish task definition; the admin task neither injects it nor may read it. +- **Admin WebSockets are enabled by the task definition.** `admin-task.json` sets `ENABLE_MMGIS_WEBSOCKETS=true` and `ENABLE_CONFIG_WEBSOCKETS=true` — both default to off in the app — because the lean admin relies on the two ADR-committed WebSocket flows (Configure lock warnings when one admin saves over another's edit, and layer-update push so open map clients refresh on config changes). Published dashboards never connect regardless (their static builds skip the WebSocket entirely). - **Admin CloudFront** (`cloudfront-admin.json`): the default behavior targets the ALB over HTTPS with the AWS managed policies **AllViewer** origin-request policy (`216adef6-5c7f-47e4-b989-5492eafa07d3` — forwards all cookies, headers, and query strings; required for login, Postgres-backed sessions, and WebSocket upgrade headers) and **CachingDisabled** cache policy (`4135ea2d-6df8-44a3-9df3-4b5a84be39ad`). CloudFront's defaults forward nothing and would silently break auth. The `/assets/*` behavior targets the shared asset bucket with **CachingOptimized** (`658327ea-f89d-4fab-a63d-7e88639e58f6`), so admin-uploaded images resolve same-origin at the root-relative `/assets/...` paths the app stores. - **Asset bucket** (`s3-asset-bucket.json`): one private shared bucket for admin uploads. The admin task role may `PutObject` to it; the publish task role reads it (`GetObject`/`ListBucket`) to same-key copy a mission's `assets//...` prefix (and `Missions//Data/mosaic_parameters.csv` when present) into that dashboard's own `mmgis-dashboard-*` bucket at publish. Assets carry no special auth of their own — they inherit whichever distribution serves them (the admin distribution here; a dashboard's password gate once copied). - **Password gate** (`cloudfront-function.js`): reference source only. At publish, `renderAuthFunctionCode()` in `scripts/lib/cfn-template.js` bakes `base64("mmgis:" + MMGIS_DASHBOARDS_PASSWORD)` into the Function body inside each dashboard stack. A unit test (`tests/unit/infrastructure.spec.js`) keeps this reference and the generator in sync. diff --git a/infrastructure/ecs/admin-task.json b/infrastructure/ecs/admin-task.json index fd41bc3fd..800f3e077 100644 --- a/infrastructure/ecs/admin-task.json +++ b/infrastructure/ecs/admin-task.json @@ -24,6 +24,8 @@ "environment": [ { "name": "MMGIS_DEPLOYMENT_MODE", "value": "lean" }, { "name": "DISABLE_FIRST_SIGNUP", "value": "true" }, + { "name": "ENABLE_MMGIS_WEBSOCKETS", "value": "true" }, + { "name": "ENABLE_CONFIG_WEBSOCKETS", "value": "true" }, { "name": "NODE_ENV", "value": "production" }, { "name": "PORT", "value": "8888" }, { "name": "AUTH", "value": "local" }, From a2f9671f99d2a6527b00b86f3cf639eecdd7b854 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 10 Jun 2026 16:05:06 +0000 Subject: [PATCH 21/63] chore: bump version to 4.2.10-20260610 [version bump] --- configure/package.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/configure/package.json b/configure/package.json index e640228ed..1386aae99 100644 --- a/configure/package.json +++ b/configure/package.json @@ -1,6 +1,6 @@ { "name": "configure", - "version": "4.2.9-20260211", + "version": "4.2.10-20260610", "homepage": "./configure/build", "private": true, "dependencies": { diff --git a/package.json b/package.json index 39608a1cb..821e7dcca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mmgis", - "version": "4.2.9-20260211", + "version": "4.2.10-20260610", "description": "A web-based mapping and localization solution for science operation on planetary missions.", "homepage": "build", "repository": { From 01a331e2a72ca05e2aa7ec2cf6b08c425885e03a Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 10 Jun 2026 11:07:44 -0500 Subject: [PATCH 22/63] Record vision-review ledgers in the lean follow-up doc Add the overhaul-1 plugin-debt items and the non-coder UX gaps surfaced by the 2026-06-10 integrated sanity check. --- docs/adr/deployment/lean/prs/follow-up.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/adr/deployment/lean/prs/follow-up.md b/docs/adr/deployment/lean/prs/follow-up.md index 2a45e38de..bd219bdf6 100644 --- a/docs/adr/deployment/lean/prs/follow-up.md +++ b/docs/adr/deployment/lean/prs/follow-up.md @@ -23,4 +23,6 @@ This is an LLM artifact — out-of-scope findings collected while implementing P 11. **Deployment webhooks signal submission, not completion.** PR 8 fires `deploymentPublish`/`deploymentUpdate` from the route handlers (row at `provisioning`/`updating`; first-publish payload has `cloudfront_url: null`) because the true terminal update happens inside the detached ECS publish task — and `triggerwebhooks.js` relies on an in-memory `webhooksConfig` cache a fresh task process never hydrates. Consider a completion event (e.g. `deploymentPublished`) fired from `publish-static.js` after the terminal row update, reading webhook entries directly from the DB; or document that consumers should poll `GET /api/deployments/:id`. *Source: PR 8 spec review.* 12. **Admin-side teardown: consider a CFN service role.** PR 8's DELETE handler runs `DeleteStack` with the admin task role's credentials (no stack service role), so PR 11 grants the admin role the `mmgis-dashboard-*` teardown set (`s3:DeleteBucket`, `cloudfront:Delete*`, etc. — the PR 11 spec's narrower scoping would have failed every delete). The stricter long-term shape is a CloudFormation service role passed at create/delete so the admin role shrinks back down. Validate the whole delete path in the staging deploy either way. *Source: PR 11 review (fixed in PR 11; service-role alternative deferred).* 13. **Save-bar Publish silently republishes a live dashboard.** The save-bar button auto-decides publish-vs-update (lean is 1:1 mission↔dashboard); on an already-published mission a click replaces the live dashboard's contents with the just-saved config, with no "you're about to change the published version" affirmation. Consider a lightweight confirm on the update path. (Delete already has a type-the-name modal; publish-status visibility is handled by the polling/toast commit on #138.) *Source: UX walkthrough, 2026-06-10.* -14. **Deferred review nits** (all optional, noted-and-skipped during review): silent skip of the `mmgis-stac` block in lean could log like its sibling gates (`scripts/init-db.js`, PR 2); the four `WITH_*` ternaries in `API/Backend/Config/setup.js` could hoist a single `isLean()` call (PR 2); the ~140-line gated block in `API/Backend/Utils/routes/utils.js` could label its closing brace (PR 5); `titiler.ts` re-implements the static check inline instead of an exported `ServiceUrls.isStaticBuild()` (PR 7); the staticHandlers parity spec's registry regex requires the exact multi-line entry shape — drop the `$` anchor to also catch single-line entries (PR 7). +14. **Overhaul-#1 (plugin) debt introduced by the lean batch** — from the 2026-06-10 vision-alignment review; none reverse direction, all should be paid down during the plugin overhaul rather than fossilizing: name-hardcoded tool exclusion in the core tool scanner (`API/updateTools.js` `items[i].name === "Draw"`); field-specific action hardcode in Configure's form engine (`Maker.js` `tile-populate-from-x`); ~10 inline `window.mmgisglobal.SERVER != 'node'` branches inside tool code (Identifier, Measure, Coordinates, TimeUI) instead of a core-exposed capability; `staticHandlers.js` as a second per-call registry kept in sync with `calls.js` only by regex-extraction tests; the publish path is AWS-only with no provider seam (`Deployments/routes` requires `aws-provision` directly), and `publish-static.js` propagates the legacy `Missions//Data/mosaic_parameters.csv` filesystem convention into dashboards. +15. **Non-coder UX gaps in the publish journey** — same review; the publish click meets the non-coder bar, configuring and diagnosing don't: missing external service URLs fail as console warnings + blank tiles (no UI-visible explanation; `options.services` isn't editable in GeneralOptions); layer help text still teaches `Missions/`-relative paths that 404 in dashboards, and there is no publish-time "these layers won't resolve" report (the time-layer bake guard disables silently); raw CloudFormation/SDK text renders verbatim as `last_error`; the shared dashboard password is invisible to the UI (set/rotated only by ops); preview (node admin) ≠ published (static) behavior differences are documented only in feature-gaps.md; no mission-clone affordance for the copy-to-vary workflow (ADR constraint 6). +16. **Deferred review nits** (all optional, noted-and-skipped during review): silent skip of the `mmgis-stac` block in lean could log like its sibling gates (`scripts/init-db.js`, PR 2); the four `WITH_*` ternaries in `API/Backend/Config/setup.js` could hoist a single `isLean()` call (PR 2); the ~140-line gated block in `API/Backend/Utils/routes/utils.js` could label its closing brace (PR 5); `titiler.ts` re-implements the static check inline instead of an exported `ServiceUrls.isStaticBuild()` (PR 7); the staticHandlers parity spec's registry regex requires the exact multi-line entry shape — drop the `$` anchor to also catch single-line entries (PR 7). From 177690cabf5a6f154022343ac30ccd99caa9ce92 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 10 Jun 2026 12:13:36 -0500 Subject: [PATCH 23/63] Record Express Mode API mismatch from staging pre-flight --- docs/adr/deployment/lean/prs/follow-up.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/adr/deployment/lean/prs/follow-up.md b/docs/adr/deployment/lean/prs/follow-up.md index bd219bdf6..4d93c8405 100644 --- a/docs/adr/deployment/lean/prs/follow-up.md +++ b/docs/adr/deployment/lean/prs/follow-up.md @@ -25,4 +25,5 @@ This is an LLM artifact — out-of-scope findings collected while implementing P 13. **Save-bar Publish silently republishes a live dashboard.** The save-bar button auto-decides publish-vs-update (lean is 1:1 mission↔dashboard); on an already-published mission a click replaces the live dashboard's contents with the just-saved config, with no "you're about to change the published version" affirmation. Consider a lightweight confirm on the update path. (Delete already has a type-the-name modal; publish-status visibility is handled by the polling/toast commit on #138.) *Source: UX walkthrough, 2026-06-10.* 14. **Overhaul-#1 (plugin) debt introduced by the lean batch** — from the 2026-06-10 vision-alignment review; none reverse direction, all should be paid down during the plugin overhaul rather than fossilizing: name-hardcoded tool exclusion in the core tool scanner (`API/updateTools.js` `items[i].name === "Draw"`); field-specific action hardcode in Configure's form engine (`Maker.js` `tile-populate-from-x`); ~10 inline `window.mmgisglobal.SERVER != 'node'` branches inside tool code (Identifier, Measure, Coordinates, TimeUI) instead of a core-exposed capability; `staticHandlers.js` as a second per-call registry kept in sync with `calls.js` only by regex-extraction tests; the publish path is AWS-only with no provider seam (`Deployments/routes` requires `aws-provision` directly), and `publish-static.js` propagates the legacy `Missions//Data/mosaic_parameters.csv` filesystem convention into dashboards. 15. **Non-coder UX gaps in the publish journey** — same review; the publish click meets the non-coder bar, configuring and diagnosing don't: missing external service URLs fail as console warnings + blank tiles (no UI-visible explanation; `options.services` isn't editable in GeneralOptions); layer help text still teaches `Missions/`-relative paths that 404 in dashboards, and there is no publish-time "these layers won't resolve" report (the time-layer bake guard disables silently); raw CloudFormation/SDK text renders verbatim as `last_error`; the shared dashboard password is invisible to the UI (set/rotated only by ops); preview (node admin) ≠ published (static) behavior differences are documented only in feature-gaps.md; no mission-clone affordance for the copy-to-vary workflow (ADR constraint 6). -16. **Deferred review nits** (all optional, noted-and-skipped during review): silent skip of the `mmgis-stac` block in lean could log like its sibling gates (`scripts/init-db.js`, PR 2); the four `WITH_*` ternaries in `API/Backend/Config/setup.js` could hoist a single `isLean()` call (PR 2); the ~140-line gated block in `API/Backend/Utils/routes/utils.js` could label its closing brace (PR 5); `titiler.ts` re-implements the static check inline instead of an exported `ServiceUrls.isStaticBuild()` (PR 7); the staticHandlers parity spec's registry regex requires the exact multi-line entry shape — drop the `$` anchor to also catch single-line entries (PR 7). +16. **`deploy-lean.yml` + infra recipes don't match the real ECS Express Mode API.** Discovered during staging pre-flight (2026-06-10): Express gateway services are not created/updated from task definitions — the CLI surface is `create|update-express-gateway-service` with an inline `--primary-container` (image/port/env/secrets), a required **infrastructure role** (trust `ecs.amazonaws.com`) absent from the IAM recipes, and `monitor-express-gateway-service` for rollout status. The workflow's `register-task-definition` + `update-service --task-definition` + `wait services-stable` steps need rewriting, the github-deploy role needs the express-gateway actions, and `infrastructure/README.md` should document the infra role. Also `cloudfront-admin.json` hardcodes `Aliases` + ACM cert + `https-only` origin — impossible without a custom domain (no ACM for `*.elb.amazonaws.com`); parameterize for a bare-CloudFront posture (default cert, `http-only` origin, ALB SG tightened to the CloudFront origin-facing prefix list). Fix PR 11 with the exact shapes learned from the staging deploy. *Source: staging pre-flight check.* +17. **Deferred review nits** (all optional, noted-and-skipped during review): silent skip of the `mmgis-stac` block in lean could log like its sibling gates (`scripts/init-db.js`, PR 2); the four `WITH_*` ternaries in `API/Backend/Config/setup.js` could hoist a single `isLean()` call (PR 2); the ~140-line gated block in `API/Backend/Utils/routes/utils.js` could label its closing brace (PR 5); `titiler.ts` re-implements the static check inline instead of an exported `ServiceUrls.isStaticBuild()` (PR 7); the staticHandlers parity spec's registry regex requires the exact multi-line entry shape — drop the `$` anchor to also catch single-line entries (PR 7). From f0cdbebadd0023abf0fda9b24560a15b03ff3000 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 10 Jun 2026 13:07:45 -0500 Subject: [PATCH 24/63] Skip the publish-task themes build when dist/ is already baked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish image always carries prebuilt theme assets (deploy-lean.yml runs build:themes before docker build), and build-assets.sh needs rsync, which the slim runtime image lacks — the first staging publish died with exit 127. Only run build:themes when dist/ is absent or empty. --- scripts/publish-static.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/publish-static.js b/scripts/publish-static.js index 22914a6a6..dc704fb17 100644 --- a/scripts/publish-static.js +++ b/scripts/publish-static.js @@ -20,6 +20,7 @@ require("dotenv").config(); +const fs = require("fs"); const path = require("path"); const { spawnSync } = require("child_process"); @@ -116,8 +117,16 @@ async function main() { const { bakeStaticConfig } = require("../API/updateTools"); bakeStaticConfig(baked); - // 2. Build the static bundle - run("npm", ["run", "build:themes"]); + // 2. Build the static bundle. Theme assets (dist/) are baked into the + // image at image-build time (deploy-lean.yml runs build:themes before + // docker build), and build-assets.sh needs tools absent from the slim + // runtime image (rsync) — so only build themes when they're missing. + const distDir = path.join(__dirname, "..", "dist"); + if (fs.existsSync(distDir) && fs.readdirSync(distDir).length > 0) { + log("Theme assets already present (dist/), skipping build:themes."); + } else { + run("npm", ["run", "build:themes"]); + } run("npm", ["run", "build"], { SERVER: "static", STATIC_MODE: "true", From 62749d1d650ef827b12b6fc15592f66ff3152df6 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 10 Jun 2026 14:14:29 -0500 Subject: [PATCH 25/63] Align infra recipes and deploy pipeline with real Express Mode behavior Staging deploy learnings: Express gateway services are created and rolled out via the express-gateway-service API (not update-service), require a dedicated infrastructure role, and derive endpoint visibility from the subnets given. The admin sits behind a CloudFront VPC origin to the internal ALB (https-only, on.aws origin domain, AllViewerExceptHostHeader since the managed ALB host-header-routes on the on.aws name). Task defs gain the RDS forced-SSL env vars, the publish role gains cloudfront:TagResource on function ARNs (CFN tags every stack resource; the first live publish failed on this), and the README documents the create flow, RDS master-user requirement, and amd64 image note. --- .github/workflows/deploy-lean.yml | 85 +++++++++++++++---- infrastructure/README.md | 76 +++++++++++++++-- infrastructure/cloudfront-admin.json | 28 +++--- infrastructure/ecs/admin-task.json | 2 + infrastructure/ecs/publish-task.json | 2 + .../iam/express-infrastructure-role.json | 24 ++++++ infrastructure/iam/publish-task-role.json | 27 ++++-- 7 files changed, 193 insertions(+), 51 deletions(-) create mode 100644 infrastructure/iam/express-infrastructure-role.json diff --git a/.github/workflows/deploy-lean.yml b/.github/workflows/deploy-lean.yml index aaaefb5b3..cc2032aec 100644 --- a/.github/workflows/deploy-lean.yml +++ b/.github/workflows/deploy-lean.yml @@ -2,13 +2,17 @@ name: Deploy lean (AWS ECS Express Mode) # Lean-deployment pipeline (see infrastructure/README.md): # build theme assets -> build image -> push to ECR -> register new -# mmgis-admin + mmgis-publish task-definition revisions -> trigger the -# ECS Express Mode managed rollout of the admin service. +# mmgis-admin + mmgis-publish task-definition revisions -> point the +# ECS Express Mode gateway service's primary container at the new +# image and let the managed rollout run. # # Per the D1 decision, ECS Express Mode owns the ALB, target groups, # rollout strategy, and scaling — this workflow deliberately defines and -# touches none of them; it only registers task-definition revisions and -# asks the managed service to roll. +# touches none of them. Express Mode services are NOT task-definition +# driven: the admin rollout goes through +# `aws ecs update-express-gateway-service --primary-container`, not +# `update-service --task-definition`. The task-definition registrations +# below still matter (see that step's comment). # # Repository configuration: # vars: AWS_REGION, ECR_REPOSITORY, ECS_CLUSTER, ECS_SERVICE @@ -88,14 +92,19 @@ jobs: echo "IMAGE_URI=$IMAGE_URI" >> $GITHUB_OUTPUT # Register new revisions of both families pointing at the new image. - # The two families share the image (the publish task only overrides - # `command`); registering mmgis-publish is enough to roll it because - # RunTask resolves the bare family name to its latest revision. + # mmgis-publish genuinely needs its revision: the Deployments backend + # starts publish jobs with RunTask on the bare family name, which + # resolves to the latest revision. mmgis-admin is NOT what the Express + # Mode service deploys from (the service carries its own + # primary-container definition), but we keep registering it as the + # human-auditable source-of-truth the service's primary container — + # environment, secrets, logging — is derived from. - name: Register new task-definition revisions id: register env: IMAGE_URI: ${{ steps.build-image.outputs.IMAGE_URI }} run: | + set -o pipefail register_revision() { local family=$1 aws ecs describe-task-definition \ @@ -118,15 +127,57 @@ jobs: PUBLISH_TASK_DEF_ARN=$(register_revision "$PUBLISH_TASK_FAMILY") echo "Registered $ADMIN_TASK_DEF_ARN" echo "Registered $PUBLISH_TASK_DEF_ARN" - echo "ADMIN_TASK_DEF_ARN=$ADMIN_TASK_DEF_ARN" >> $GITHUB_OUTPUT - # Express Mode handles the rollout choreography from here. - - name: Trigger ECS Express Mode rollout + # Express gateway services are updated through their own API: point + # the primary container at the new image and Express Mode rolls it. + # `update-service --task-definition` does not apply here. + # + # We poll `describe-express-gateway-service` rather than use + # `monitor-express-gateway-service`: monitor is an interactive, + # open-ended streamer meant for a human terminal, while describe + # returns a discrete deployment status we can bound with a timeout + # and a clean exit code in CI. + - name: Roll the Express Mode gateway service + env: + ECS_CLUSTER: ${{ vars.ECS_CLUSTER }} + ECS_SERVICE: ${{ vars.ECS_SERVICE }} + IMAGE_URI: ${{ steps.build-image.outputs.IMAGE_URI }} run: | - aws ecs update-service \ - --cluster "${{ vars.ECS_CLUSTER }}" \ - --service "${{ vars.ECS_SERVICE }}" \ - --task-definition "${{ steps.register.outputs.ADMIN_TASK_DEF_ARN }}" - aws ecs wait services-stable \ - --cluster "${{ vars.ECS_CLUSTER }}" \ - --services "${{ vars.ECS_SERVICE }}" + set -o pipefail + + # ECS_SERVICE is the service NAME; the express-gateway-service + # API addresses services by ARN, so resolve it first (an Express + # Mode service is still an ECS service in its cluster). + SERVICE_ARN=$(aws ecs describe-services \ + --cluster "$ECS_CLUSTER" \ + --services "$ECS_SERVICE" \ + --query 'services[0].serviceArn' \ + --output text) + echo "Resolved $ECS_SERVICE -> $SERVICE_ARN" + + aws ecs update-express-gateway-service \ + --cluster "$ECS_CLUSTER" \ + --service-arn "$SERVICE_ARN" \ + --primary-container "{\"image\": \"$IMAGE_URI\"}" + + # Bounded poll (up to 20 minutes) until the managed deployment is + # steady. A deployment that is still converging reports an + # in-progress status; anything FAILED fails the job immediately. + for attempt in $(seq 1 80); do + STATUS=$(aws ecs describe-express-gateway-service \ + --service-arn "$SERVICE_ARN" \ + --query 'service.deployments[0].rolloutState' \ + --output text) + echo "[$attempt/80] deployment status: $STATUS" + if [ "$STATUS" = "COMPLETED" ]; then + echo "Rollout steady." + exit 0 + fi + if [ "$STATUS" = "FAILED" ]; then + echo "Rollout FAILED — see the ECS console / service events." >&2 + exit 1 + fi + sleep 15 + done + echo "Rollout did not reach a steady state within 20 minutes." >&2 + exit 1 diff --git a/infrastructure/README.md b/infrastructure/README.md index f3b2a5ef3..fa6f6f13d 100644 --- a/infrastructure/README.md +++ b/infrastructure/README.md @@ -14,7 +14,8 @@ Recipes for running MMGIS in the **lean** deployment shape (`MMGIS_DEPLOYMENT_MO | `iam/admin-task-role.json` | Runtime role for the admin container code (RunTask + PassRole, dashboard stack read/delete, asset upload). Also carries the `mmgis-dashboard-*` S3/CloudFront **teardown** grants: the DELETE handler runs `DeleteStack` inline with no CloudFormation service role, so CloudFormation deletes the stack's resources with this role's credentials. (Attaching a dedicated CFN service role to the stacks would be the stricter future alternative.) | | `iam/publish-task-execution-role.json` | ECS-side role for the publish task | | `iam/publish-task-role.json` | Runtime role for the publish container code (dashboard stack create + the resources CloudFormation manages for it) | -| `cloudfront-admin.json` | `DistributionConfig` for the admin CloudFront distribution (`aws cloudfront create-distribution --distribution-config file://cloudfront-admin.json`) | +| `iam/express-infrastructure-role.json` | Infrastructure role required by `create-express-gateway-service`: trusts `ecs.amazonaws.com`, carries **no inline policy** — attach the AWS managed policy `arn:aws:iam::aws:policy/service-role/AmazonECSInfrastructureRoleforExpressGatewayServices` (note the lowercase "for"; the camel-cased name does not exist). It provisions the service's ALB/SGs/certs and **cannot be modified after service creation** | +| `cloudfront-admin.json` | `DistributionConfig` for the admin CloudFront distribution (`aws cloudfront create-distribution --distribution-config file://cloudfront-admin.json`) — bare-CloudFront posture: default viewer cert, no Aliases, VPC origin to the Express service's internal ALB. A custom-domain variant (Aliases + ACM cert + public-ALB custom origin) exists in git history on this file if DNS is ever adopted | | `cloudfront-function.js` | Canonical reference for the per-dashboard password-gate Function (the deployed copy is generated by `scripts/lib/cfn-template.js`) | | `s3-asset-bucket.json` | CloudFormation template for the shared admin asset bucket | @@ -39,21 +40,80 @@ Replace these consistently across every file before applying: | `` | Secrets Manager ARN of the superadmin seed username (`SEED_SUPERADMIN_USERNAME`) | | `` | Secrets Manager ARN of the superadmin seed password (`SEED_SUPERADMIN_PASSWORD`) | | `` | Secrets Manager ARN of the shared dashboards password (`MMGIS_DASHBOARDS_PASSWORD`) | -| `` | The admin's dedicated hostname (CloudFront alias) — operator-provided | -| `` | ACM certificate (us-east-1) covering `` — operator-provided | -| `` | DNS name of the ALB that ECS Express Mode manages for the admin service | +| `` | base64 of the **per-region** RDS CA bundle (`https://truststore.pki.rds.amazonaws.com//-bundle.pem`). RDS forces SSL by default; both task defs set `DB_SSL=true` + `DB_SSL_CERT_BASE64`. Use the regional bundle — the global bundle is too big for ECS environment-variable limits | +| `` | The Express service's endpoint name from `ingressPaths[0]` (`mm-.ecs..on.aws`) — the CloudFront origin `DomainName` MUST be this name (it satisfies the ALB cert's SNI and its host-header rule) | +| `` | Id of the CloudFront VPC origin (`aws cloudfront create-vpc-origin`) pointing at the Express service's internal ALB ARN | | `` | CloudFront Origin Access Control id created for the asset-bucket origin | | `` | The admin CloudFront distribution id (for the asset bucket policy's `AWS:SourceArn` condition) | ## Prerequisites (operator-provided, not created here) -- **VPC + subnets.** An existing VPC; subnets for the admin service and for the publish task (`MMGIS_PUBLISH_SUBNETS`). -- **Admin hostname + ACM certificate.** The admin is reached at a dedicated subdomain. The DNS record and the ACM cert (in us-east-1 for CloudFront) are operator-provided, out-of-band; `cloudfront-admin.json` only references the cert ARN and the alias. +- **VPC + subnets.** An existing VPC; subnets for the admin service and for the publish task (`MMGIS_PUBLISH_SUBNETS`). The subnets you hand the Express service decide its endpoint visibility (see [Endpoint and subnet semantics](#endpoint-and-subnet-semantics)) — use **private** subnets so the admin is reachable only through CloudFront. +- **No custom domain required.** The admin runs in the bare-CloudFront posture: viewers use the default `*.cloudfront.net` certificate and the distribution carries no Aliases. No DNS record, no ACM cert. (The custom-domain variant of `cloudfront-admin.json` lives in git history if DNS is ever adopted.) +- **PostgreSQL on RDS.** Two hard-won requirements: the **master username must be `postgres`** — `scripts/init-db.js`'s bootstrap connection defaults the maintenance database name to the username, so a non-`postgres` master user fails the very first connection; and **RDS forces SSL by default**, so both task defs set `DB_SSL=true` and `DB_SSL_CERT_BASE64` (base64 of the small **per-region** CA bundle from `truststore.pki.rds.amazonaws.com//-bundle.pem` — the global bundle exceeds env-var size limits). - **Secrets Manager entries:** the DB-credentials secret (JSON with `DB_HOST`/`DB_PORT`/`DB_NAME`/`DB_USER`/`DB_PASS` keys), the session secret, the dashboards shared password, and the superadmin seed credentials `SEED_SUPERADMIN_USERNAME`/`SEED_SUPERADMIN_PASSWORD`. The admin task def injects the seed credentials and sets `DISABLE_FIRST_SIGNUP=true` so that the superadmin is seeded automatically and the open first-signup route stays closed on a fresh lean deploy. - **Outbound HTTPS egress.** The admin fires `triggerWebhooks(...)` to external URLs on Config saves and on Dashboards Publish/Update/Delete; a task in a private subnet needs a NAT gateway (or VPC endpoints for the AWS APIs plus egress for webhook targets) or webhooks hang and time out silently. The publish task also needs egress to reach CloudFormation/S3/CloudFront. - **CloudWatch log groups** `/ecs/mmgis-admin` and `/ecs/mmgis-publish` pre-created (the execution roles deliberately omit `logs:CreateLogGroup`). -- **ECS cluster + Express Mode service.** Create the cluster and an ECS Express Mode service for the `mmgis-admin` family. Per the D1 decision, Express Mode owns the ALB, target groups, rollout strategy, and scaling — **no** ALB/target-group/scaling-policy definitions live in this directory, and the deploy workflow only registers a new task-definition revision and triggers the managed deployment. Point `cloudfront-admin.json`'s `` at the ALB endpoint Express Mode exposes. +- **ECS cluster + Express Mode service.** Create the cluster and an Express Mode gateway service for the admin (see [Creating the Express Mode service](#creating-the-express-mode-service--cloudfront-front-door) for the exact CLI flow). Per the D1 decision, Express Mode owns the ALB, target groups, rollout strategy, and scaling — **no** ALB/target-group/scaling-policy definitions live in this directory. Note that Express Mode services are **not task-definition driven**: the service is created/updated via the `*-express-gateway-service` API with an inline `--primary-container`, and the deploy workflow rolls it by updating that primary container's image. `ecs/admin-task.json` stays registered as the source-of-truth the primary container is derived from; `ecs/publish-task.json` is genuinely load-bearing (RunTask resolves the family). - **ECR repository** for the image, and (for `deploy-lean.yml`) an OIDC deploy role with permission to push to it and roll the service. +- **linux/amd64 images only.** The task defs pin `cpuArchitecture: X86_64`; an image built on an ARM machine (Apple Silicon) must use `docker buildx build --platform linux/amd64`. The GitHub-hosted CI runners are amd64, so `deploy-lean.yml`'s plain `docker build` is fine. + +## Creating the Express Mode service + CloudFront front door + +The order matters (each step consumes the previous step's output). All of this is one-time setup; afterwards `deploy-lean.yml` only updates the primary container's image. + +1. **Create the infrastructure role** from `iam/express-infrastructure-role.json`: trust `ecs.amazonaws.com`, attach the AWS managed policy `arn:aws:iam::aws:policy/service-role/AmazonECSInfrastructureRoleforExpressGatewayServices` (lowercase "for"), **no inline policy**. ECS uses it to provision the service's ALB, security groups, and certificates. It cannot be changed after the service is created, so get it right first. + +2. **Create the gateway service.** The `--primary-container` JSON is derived from `ecs/admin-task.json`'s container definition (same `environment[]`/`secrets[]` name/valueFrom shape as task defs): + + ```sh + aws ecs create-express-gateway-service \ + --service-name mmgis-admin \ + --cluster \ + --execution-role-arn arn:aws:iam:::role/mmgis-admin-task-execution-role \ + --task-role-arn arn:aws:iam:::role/mmgis-admin-task-role \ + --infrastructure-role-arn arn:aws:iam:::role/mmgis-express-infrastructure-role \ + --cpu 1024 --memory 2048 \ + --health-check-path /api/utils/healthcheck \ + --network-configuration "securityGroups=,subnets=" \ + --primary-container '{ + "image": "", + "containerPort": 8888, + "awsLogsConfiguration": { "logGroup": "/ecs/mmgis-admin", "logStreamPrefix": "mmgis-admin" }, + "environment": [ ...admin-task.json environment[]... ], + "secrets": [ ...admin-task.json secrets[]... ] + }' + ``` + + Pass **private** subnets so the endpoint comes out PRIVATE with an internal ALB (next section). `monitor-express-gateway-service` / `describe-express-gateway-service` show progress. + +3. **Read the endpoint** from `describe-express-gateway-service`: `ingressPaths[0]` yields `` (`mm-.ecs..on.aws`) and the managed ALB's ARN. + +4. **Open the ALB to CloudFront's VPC-origin ENIs.** Add a `:443` ingress rule **from the VPC CIDR** to the ALB's security group — VPC-origin traffic arrives from ENIs inside the VPC, not from CloudFront's public ranges. + +5. **Create the VPC origin** pointing at the internal ALB: + + ```sh + aws cloudfront create-vpc-origin \ + --vpc-origin-endpoint-config '{ + "Name": "mmgis-admin-vpc-origin", + "Arn": "", + "HTTPPort": 80, + "HTTPSPort": 443, + "OriginProtocolPolicy": "https-only", + "OriginSslProtocols": { "Quantity": 1, "Items": ["TLSv1.2"] } + }' + ``` + + Its id is ``. Note: VPC origins **cannot be updated while status=Deploying**, and deploy cycles run ~6–10 minutes — be patient between changes. + +6. **Create the distribution** from `cloudfront-admin.json` (placeholders filled). Two details are load-bearing: the origin `DomainName` must be `` (NOT the ALB's DNS name — the on.aws name is what satisfies the ALB cert's SNI and its host-header rule), and the default behavior must use the **AllViewerExceptHostHeader** origin-request policy (`b689b0a8-53d0-40ab-baf2-68738e2966ac`) — forwarding the viewer's Host (the `*.cloudfront.net` name) misses the host rule and hits the listener's fixed-response default. + +### Endpoint and subnet semantics + +- The service endpoint's `accessType` (PUBLIC | PRIVATE) is **derived from the subnets you pass** to `create-express-gateway-service`: private subnets → PRIVATE endpoint + an **internal** ALB; public subnets → a public one. There is no separate flag. +- The managed ALB carries an **HTTPS:443 listener only**, with an AWS-managed ACM certificate for the on.aws name and a **host-header rule matching only that name**; the listener's default action is a fixed response. This is why CloudFront must send the on.aws name as Host (AllViewerExceptHostHeader rewrites Host to the origin's DomainName) and why the origin DomainName must be the on.aws endpoint, never the raw ALB DNS name. +- In the lean posture the admin uses private subnets, so the only path in is CloudFront → VPC origin → internal ALB → task. Outbound (webhooks, AWS APIs) still needs the NAT/VPC-endpoint egress noted above. ## How the pieces fit @@ -64,7 +124,7 @@ Replace these consistently across every file before applying: - **Admin-side teardown.** The Deployments DELETE handler empties the dashboard bucket and calls `DeleteStack` inline under the **admin task role**, with no CloudFormation service role attached to the stack — so CloudFormation deletes the stack's S3 bucket, CloudFront distribution, password-gate Function, and origin access control with the caller's (the admin role's) credentials. That is why `iam/admin-task-role.json` carries `s3:DeleteBucket`/`s3:DeleteBucketPolicy` and the CloudFront disable/delete set, still pinned to the `mmgis-dashboard-*` prefix (Function names) or the account's distribution/OAC ARN space. Giving the stacks a dedicated CloudFormation service role would let the admin role shed these grants and is the stricter future alternative. - **Publish task role vs. `cfn-template.js`.** Beyond the core create set, the role carries the actions the rendered template actually requires: origin-access-control lifecycle (the template creates an `AWS::CloudFront::OriginAccessControl`), `s3:PutBucketPublicAccessBlock`/`s3:PutEncryptionConfiguration`/`s3:DeleteBucketPolicy` (the bucket ships with public-access block + encryption + a bucket policy), and `cloudfront:TagResource`/`s3:PutBucketTagging` (`createStack()` tags the stack and CloudFormation propagates tags to resources). **No `rds-db:connect`** — the app uses password auth. **No `secretsmanager:GetSecretValue` either**: the dashboards password reaches `publish-static.js` as the `MMGIS_DASHBOARDS_PASSWORD` env var through the publish *execution* role's `secrets[]` injection — the container code never reads Secrets Manager at runtime (the spec's "read at runtime" wording is outdated). For the same reason the password rides only the publish task definition; the admin task neither injects it nor may read it. - **Admin WebSockets are enabled by the task definition.** `admin-task.json` sets `ENABLE_MMGIS_WEBSOCKETS=true` and `ENABLE_CONFIG_WEBSOCKETS=true` — both default to off in the app — because the lean admin relies on the two ADR-committed WebSocket flows (Configure lock warnings when one admin saves over another's edit, and layer-update push so open map clients refresh on config changes). Published dashboards never connect regardless (their static builds skip the WebSocket entirely). -- **Admin CloudFront** (`cloudfront-admin.json`): the default behavior targets the ALB over HTTPS with the AWS managed policies **AllViewer** origin-request policy (`216adef6-5c7f-47e4-b989-5492eafa07d3` — forwards all cookies, headers, and query strings; required for login, Postgres-backed sessions, and WebSocket upgrade headers) and **CachingDisabled** cache policy (`4135ea2d-6df8-44a3-9df3-4b5a84be39ad`). CloudFront's defaults forward nothing and would silently break auth. The `/assets/*` behavior targets the shared asset bucket with **CachingOptimized** (`658327ea-f89d-4fab-a63d-7e88639e58f6`), so admin-uploaded images resolve same-origin at the root-relative `/assets/...` paths the app stores. +- **Admin CloudFront** (`cloudfront-admin.json`): the default behavior reaches the Express service's internal ALB through a **VPC origin** (`https-only`, port 443) whose `DomainName` is the on.aws endpoint, with the AWS managed policies **AllViewerExceptHostHeader** origin-request policy (`b689b0a8-53d0-40ab-baf2-68738e2966ac` — forwards all cookies, headers, and query strings *except* Host, which CloudFront rewrites to the origin's on.aws name so the ALB's host rule matches; forwarding the viewer Host breaks it) and **CachingDisabled** cache policy (`4135ea2d-6df8-44a3-9df3-4b5a84be39ad`). The full forwarding is required for login, Postgres-backed sessions, and WebSocket upgrade headers — CloudFront's defaults forward nothing and would silently break auth. Viewer side: default CloudFront certificate, `redirect-to-https`, no Aliases. The `/assets/*` behavior targets the shared asset bucket with **CachingOptimized** (`658327ea-f89d-4fab-a63d-7e88639e58f6`), so admin-uploaded images resolve same-origin at the root-relative `/assets/...` paths the app stores. - **Asset bucket** (`s3-asset-bucket.json`): one private shared bucket for admin uploads. The admin task role may `PutObject` to it; the publish task role reads it (`GetObject`/`ListBucket`) to same-key copy a mission's `assets//...` prefix (and `Missions//Data/mosaic_parameters.csv` when present) into that dashboard's own `mmgis-dashboard-*` bucket at publish. Assets carry no special auth of their own — they inherit whichever distribution serves them (the admin distribution here; a dashboard's password gate once copied). - **Password gate** (`cloudfront-function.js`): reference source only. At publish, `renderAuthFunctionCode()` in `scripts/lib/cfn-template.js` bakes `base64("mmgis:" + MMGIS_DASHBOARDS_PASSWORD)` into the Function body inside each dashboard stack. A unit test (`tests/unit/infrastructure.spec.js`) keeps this reference and the generator in sync. - **`trust proxy`**: `scripts/server.js` sets `app.set("trust proxy", 2)` to match the lean topology's two proxy hops (CloudFront → ALB → ECS) so `Secure` cookies, rate limiting, and `X-Forwarded-For` resolve the real client. diff --git a/infrastructure/cloudfront-admin.json b/infrastructure/cloudfront-admin.json index 514ccf4df..ca19a3c65 100644 --- a/infrastructure/cloudfront-admin.json +++ b/infrastructure/cloudfront-admin.json @@ -1,31 +1,23 @@ { "CallerReference": "mmgis-admin-cloudfront", - "Comment": "MMGIS lean admin distribution: default behavior forwards everything to the admin ALB and caches nothing (AllViewer + CachingDisabled); /assets/* serves the shared asset bucket same-origin.", + "Comment": "MMGIS lean admin distribution (bare-CloudFront posture: default *.cloudfront.net viewer cert, no Aliases). Default behavior reaches the Express Mode service's INTERNAL ALB through a CloudFront VPC origin; the origin DomainName must be the service's on.aws endpoint name (it satisfies the ALB's SNI cert AND its host-header listener rule) and the origin-request policy must be AllViewerExceptHostHeader (forwarding the viewer Host would miss that host rule and hit the listener's fixed-response default). CachingDisabled keeps auth/sessions correct. /assets/* serves the shared asset bucket same-origin.", "Enabled": true, "HttpVersion": "http2", "Aliases": { - "Quantity": 1, - "Items": [""] + "Quantity": 0 }, "ViewerCertificate": { - "ACMCertificateArn": "", - "SSLSupportMethod": "sni-only", - "MinimumProtocolVersion": "TLSv1.2_2021" + "CloudFrontDefaultCertificate": true, + "MinimumProtocolVersion": "TLSv1" }, "Origins": { "Quantity": 2, "Items": [ { - "Id": "AdminAlbOrigin", - "DomainName": "", - "CustomOriginConfig": { - "HTTPPort": 80, - "HTTPSPort": 443, - "OriginProtocolPolicy": "https-only", - "OriginSslProtocols": { - "Quantity": 1, - "Items": ["TLSv1.2"] - } + "Id": "AdminExpressVpcOrigin", + "DomainName": "", + "VpcOriginConfig": { + "VpcOriginId": "" } }, { @@ -39,7 +31,7 @@ ] }, "DefaultCacheBehavior": { - "TargetOriginId": "AdminAlbOrigin", + "TargetOriginId": "AdminExpressVpcOrigin", "ViewerProtocolPolicy": "redirect-to-https", "AllowedMethods": { "Quantity": 7, @@ -51,7 +43,7 @@ }, "Compress": true, "CachePolicyId": "4135ea2d-6df8-44a3-9df3-4b5a84be39ad", - "OriginRequestPolicyId": "216adef6-5c7f-47e4-b989-5492eafa07d3" + "OriginRequestPolicyId": "b689b0a8-53d0-40ab-baf2-68738e2966ac" }, "CacheBehaviors": { "Quantity": 1, diff --git a/infrastructure/ecs/admin-task.json b/infrastructure/ecs/admin-task.json index 800f3e077..d25148112 100644 --- a/infrastructure/ecs/admin-task.json +++ b/infrastructure/ecs/admin-task.json @@ -29,6 +29,8 @@ { "name": "NODE_ENV", "value": "production" }, { "name": "PORT", "value": "8888" }, { "name": "AUTH", "value": "local" }, + { "name": "DB_SSL", "value": "true" }, + { "name": "DB_SSL_CERT_BASE64", "value": "" }, { "name": "AWS_REGION", "value": "" }, { "name": "MMGIS_PUBLISH_ECS_CLUSTER", "value": "" }, { "name": "MMGIS_PUBLISH_TASK_DEFINITION", "value": "mmgis-publish" }, diff --git a/infrastructure/ecs/publish-task.json b/infrastructure/ecs/publish-task.json index ed82aeb9d..8b8890d28 100644 --- a/infrastructure/ecs/publish-task.json +++ b/infrastructure/ecs/publish-task.json @@ -19,6 +19,8 @@ "environment": [ { "name": "MMGIS_DEPLOYMENT_MODE", "value": "lean" }, { "name": "NODE_ENV", "value": "production" }, + { "name": "DB_SSL", "value": "true" }, + { "name": "DB_SSL_CERT_BASE64", "value": "" }, { "name": "AWS_REGION", "value": "" }, { "name": "MMGIS_SHARED_ASSET_BUCKET", "value": "" } ], diff --git a/infrastructure/iam/express-infrastructure-role.json b/infrastructure/iam/express-infrastructure-role.json new file mode 100644 index 000000000..9c155b630 --- /dev/null +++ b/infrastructure/iam/express-infrastructure-role.json @@ -0,0 +1,24 @@ +{ + "Type": "AWS::IAM::Role", + "Properties": { + "RoleName": "mmgis-express-infrastructure-role", + "Description": "Infrastructure role REQUIRED by `aws ecs create-express-gateway-service` (--infrastructure-role-arn): ECS assumes it to provision and manage the service's ALB, security groups, and certificates. Trust-only plus the AWS managed policy below — NO inline policy. It cannot be modified after service creation. Lean deployment only.", + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowEcsServiceAssume", + "Effect": "Allow", + "Principal": { "Service": "ecs.amazonaws.com" }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { "aws:SourceAccount": "" } + } + } + ] + }, + "ManagedPolicyArns": [ + "arn:aws:iam::aws:policy/service-role/AmazonECSInfrastructureRoleforExpressGatewayServices" + ] + } +} diff --git a/infrastructure/iam/publish-task-role.json b/infrastructure/iam/publish-task-role.json index c4c2e00c9..6398ab2ca 100644 --- a/infrastructure/iam/publish-task-role.json +++ b/infrastructure/iam/publish-task-role.json @@ -2,17 +2,21 @@ "Type": "AWS::IAM::Role", "Properties": { "RoleName": "mmgis-publish-task-role", - "Description": "Runtime role for the mmgis-publish container code (scripts/publish-static.js): create/describe/delete the mmgis-dashboard-* CloudFormation stacks and the S3/CloudFront resources CloudFormation manages on its behalf, and read the shared asset bucket to copy mission assets. The dashboards password arrives as the MMGIS_DASHBOARDS_PASSWORD env var via the EXECUTION role's secrets[] injection — the container code never reads Secrets Manager at runtime. No rds-db:connect — the code uses password auth (DB_USER/DB_PASS). Lean deployment only.", + "Description": "Runtime role for the mmgis-publish container code (scripts/publish-static.js): create/describe/delete the mmgis-dashboard-* CloudFormation stacks and the S3/CloudFront resources CloudFormation manages on its behalf, and read the shared asset bucket to copy mission assets. The dashboards password arrives as the MMGIS_DASHBOARDS_PASSWORD env var via the EXECUTION role's secrets[] injection \u2014 the container code never reads Secrets Manager at runtime. No rds-db:connect \u2014 the code uses password auth (DB_USER/DB_PASS). Lean deployment only.", "AssumeRolePolicyDocument": { "Version": "2012-10-17", "Statement": [ { "Sid": "AllowEcsTasksAssume", "Effect": "Allow", - "Principal": { "Service": "ecs-tasks.amazonaws.com" }, + "Principal": { + "Service": "ecs-tasks.amazonaws.com" + }, "Action": "sts:AssumeRole", "Condition": { - "StringEquals": { "aws:SourceAccount": "" } + "StringEquals": { + "aws:SourceAccount": "" + } } } ] @@ -52,7 +56,9 @@ { "Sid": "DashboardBucketWriteObjects", "Effect": "Allow", - "Action": ["s3:PutObject"], + "Action": [ + "s3:PutObject" + ], "Resource": "arn:aws:s3:::mmgis-dashboard-*/*" }, { @@ -75,7 +81,8 @@ "cloudfront:PublishFunction", "cloudfront:DescribeFunction", "cloudfront:DeleteFunction", - "cloudfront:GetFunction" + "cloudfront:GetFunction", + "cloudfront:TagResource" ], "Resource": "arn:aws:cloudfront:::function/mmgis-dashboard-*" }, @@ -92,13 +99,17 @@ { "Sid": "ReadSharedAssetObjects", "Effect": "Allow", - "Action": ["s3:GetObject"], + "Action": [ + "s3:GetObject" + ], "Resource": "arn:aws:s3:::/*" }, { "Sid": "ListSharedAssetBucket", "Effect": "Allow", - "Action": ["s3:ListBucket"], + "Action": [ + "s3:ListBucket" + ], "Resource": "arn:aws:s3:::" } ] @@ -106,4 +117,4 @@ } ] } -} +} \ No newline at end of file From aef3164ad3d1924dffaa8182f7e66c08147ccff9 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 10 Jun 2026 14:15:06 -0500 Subject: [PATCH 26/63] Mark Express Mode recipe mismatch as fixed in PR 11 --- docs/adr/deployment/lean/prs/follow-up.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/adr/deployment/lean/prs/follow-up.md b/docs/adr/deployment/lean/prs/follow-up.md index 4d93c8405..020cfde7e 100644 --- a/docs/adr/deployment/lean/prs/follow-up.md +++ b/docs/adr/deployment/lean/prs/follow-up.md @@ -25,5 +25,6 @@ This is an LLM artifact — out-of-scope findings collected while implementing P 13. **Save-bar Publish silently republishes a live dashboard.** The save-bar button auto-decides publish-vs-update (lean is 1:1 mission↔dashboard); on an already-published mission a click replaces the live dashboard's contents with the just-saved config, with no "you're about to change the published version" affirmation. Consider a lightweight confirm on the update path. (Delete already has a type-the-name modal; publish-status visibility is handled by the polling/toast commit on #138.) *Source: UX walkthrough, 2026-06-10.* 14. **Overhaul-#1 (plugin) debt introduced by the lean batch** — from the 2026-06-10 vision-alignment review; none reverse direction, all should be paid down during the plugin overhaul rather than fossilizing: name-hardcoded tool exclusion in the core tool scanner (`API/updateTools.js` `items[i].name === "Draw"`); field-specific action hardcode in Configure's form engine (`Maker.js` `tile-populate-from-x`); ~10 inline `window.mmgisglobal.SERVER != 'node'` branches inside tool code (Identifier, Measure, Coordinates, TimeUI) instead of a core-exposed capability; `staticHandlers.js` as a second per-call registry kept in sync with `calls.js` only by regex-extraction tests; the publish path is AWS-only with no provider seam (`Deployments/routes` requires `aws-provision` directly), and `publish-static.js` propagates the legacy `Missions//Data/mosaic_parameters.csv` filesystem convention into dashboards. 15. **Non-coder UX gaps in the publish journey** — same review; the publish click meets the non-coder bar, configuring and diagnosing don't: missing external service URLs fail as console warnings + blank tiles (no UI-visible explanation; `options.services` isn't editable in GeneralOptions); layer help text still teaches `Missions/`-relative paths that 404 in dashboards, and there is no publish-time "these layers won't resolve" report (the time-layer bake guard disables silently); raw CloudFormation/SDK text renders verbatim as `last_error`; the shared dashboard password is invisible to the UI (set/rotated only by ops); preview (node admin) ≠ published (static) behavior differences are documented only in feature-gaps.md; no mission-clone affordance for the copy-to-vary workflow (ADR constraint 6). -16. **`deploy-lean.yml` + infra recipes don't match the real ECS Express Mode API.** Discovered during staging pre-flight (2026-06-10): Express gateway services are not created/updated from task definitions — the CLI surface is `create|update-express-gateway-service` with an inline `--primary-container` (image/port/env/secrets), a required **infrastructure role** (trust `ecs.amazonaws.com`) absent from the IAM recipes, and `monitor-express-gateway-service` for rollout status. The workflow's `register-task-definition` + `update-service --task-definition` + `wait services-stable` steps need rewriting, the github-deploy role needs the express-gateway actions, and `infrastructure/README.md` should document the infra role. Also `cloudfront-admin.json` hardcodes `Aliases` + ACM cert + `https-only` origin — impossible without a custom domain (no ACM for `*.elb.amazonaws.com`); parameterize for a bare-CloudFront posture (default cert, `http-only` origin, ALB SG tightened to the CloudFront origin-facing prefix list). Fix PR 11 with the exact shapes learned from the staging deploy. *Source: staging pre-flight check.* +16. ~~**`deploy-lean.yml` + infra recipes don't match the real ECS Express Mode API.**~~ **FIXED in PR 11** (2026-06-10, commit `62749d1d`, informed by the live staging deploy): workflow rewritten to `update-express-gateway-service` + a bounded `describe` poll; `express-infrastructure-role.json` recipe added (managed policy `AmazonECSInfrastructureRoleforExpressGatewayServices`, lowercase "for"); `cloudfront-admin.json` rewritten to the proven bare-CloudFront VPC-origin shape (on.aws origin domain + AllViewerExceptHostHeader — the managed internal ALB host-header-routes on the on.aws name); task defs gained `DB_SSL`/`DB_SSL_CERT_BASE64`; publish role gained `cloudfront:TagResource` on function ARNs (first live publish failed on it). Residual nit: `tests/unit/infrastructure.spec.js` doesn't pin the new origin-request-policy ID. Original finding below for the record: +**`deploy-lean.yml` + infra recipes don't match the real ECS Express Mode API.** Discovered during staging pre-flight (2026-06-10): Express gateway services are not created/updated from task definitions — the CLI surface is `create|update-express-gateway-service` with an inline `--primary-container` (image/port/env/secrets), a required **infrastructure role** (trust `ecs.amazonaws.com`) absent from the IAM recipes, and `monitor-express-gateway-service` for rollout status. The workflow's `register-task-definition` + `update-service --task-definition` + `wait services-stable` steps need rewriting, the github-deploy role needs the express-gateway actions, and `infrastructure/README.md` should document the infra role. Also `cloudfront-admin.json` hardcodes `Aliases` + ACM cert + `https-only` origin — impossible without a custom domain (no ACM for `*.elb.amazonaws.com`); parameterize for a bare-CloudFront posture (default cert, `http-only` origin, ALB SG tightened to the CloudFront origin-facing prefix list). Fix PR 11 with the exact shapes learned from the staging deploy. *Source: staging pre-flight check.* 17. **Deferred review nits** (all optional, noted-and-skipped during review): silent skip of the `mmgis-stac` block in lean could log like its sibling gates (`scripts/init-db.js`, PR 2); the four `WITH_*` ternaries in `API/Backend/Config/setup.js` could hoist a single `isLean()` call (PR 2); the ~140-line gated block in `API/Backend/Utils/routes/utils.js` could label its closing brace (PR 5); `titiler.ts` re-implements the static check inline instead of an exported `ServiceUrls.isStaticBuild()` (PR 7); the staticHandlers parity spec's registry regex requires the exact multi-line entry shape — drop the `$` anchor to also catch single-line entries (PR 7). From e9551ded0f3ae0ca3508d5c3fef9c8ee51da89e8 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 10 Jun 2026 14:20:08 -0500 Subject: [PATCH 27/63] Grant the publish role the full CloudFront tag-action family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CloudFormation's resource handlers don't just TagResource at create — they ListTagsForResource when reading resource state back (the second live publish failed on exactly that). Grant Tag/Untag/ListTags on the dashboard function and distribution statements. --- infrastructure/iam/publish-task-role.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/infrastructure/iam/publish-task-role.json b/infrastructure/iam/publish-task-role.json index 6398ab2ca..c6d84d36b 100644 --- a/infrastructure/iam/publish-task-role.json +++ b/infrastructure/iam/publish-task-role.json @@ -69,7 +69,9 @@ "cloudfront:GetDistribution", "cloudfront:UpdateDistribution", "cloudfront:DeleteDistribution", - "cloudfront:TagResource" + "cloudfront:TagResource", + "cloudfront:UntagResource", + "cloudfront:ListTagsForResource" ], "Resource": "arn:aws:cloudfront:::distribution/*" }, @@ -82,7 +84,9 @@ "cloudfront:DescribeFunction", "cloudfront:DeleteFunction", "cloudfront:GetFunction", - "cloudfront:TagResource" + "cloudfront:TagResource", + "cloudfront:UntagResource", + "cloudfront:ListTagsForResource" ], "Resource": "arn:aws:cloudfront:::function/mmgis-dashboard-*" }, From fb838de15c08cc3e94d732d0a2d36677c381aef6 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 10 Jun 2026 14:35:33 -0500 Subject: [PATCH 28/63] Upload the dashboard bundle in the layout the static index expects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The static index references ./build/... and public/... (mirroring the Express mounts in server mode), but the publish task uploaded the webpack output to the bucket root and never shipped public/ at all — the first published dashboard 403'd on every script and image. Upload build/ and public/ under their own prefixes and put index.html at the root as the default root object. --- scripts/lib/aws-provision.js | 15 +++++++++++++++ scripts/publish-static.js | 23 ++++++++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/scripts/lib/aws-provision.js b/scripts/lib/aws-provision.js index 9649feed8..45a62ea34 100644 --- a/scripts/lib/aws-provision.js +++ b/scripts/lib/aws-provision.js @@ -220,6 +220,20 @@ async function uploadDirectory({ bucket, dir, prefix = "", concurrency = 8 }) { return files.length; } +// Uploads a single local file to an exact key. +async function uploadFile({ bucket, key, filePath }) { + const { s3 } = getClients(); + await s3.send( + new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: fs.createReadStream(filePath), + ContentLength: fs.statSync(filePath).size, + ContentType: contentTypeForFile(filePath), + }) + ); +} + // Same-key copies every object under `prefix` from sourceBucket into // destBucket. Returns the number of objects copied. async function copyPrefix({ sourceBucket, destBucket, prefix }) { @@ -388,6 +402,7 @@ module.exports = { deleteStack, contentTypeForFile, uploadDirectory, + uploadFile, copyPrefix, copyObjectIfExists, emptyBucket, diff --git a/scripts/publish-static.js b/scripts/publish-static.js index dc704fb17..aded7acba 100644 --- a/scripts/publish-static.js +++ b/scripts/publish-static.js @@ -191,12 +191,29 @@ async function main() { log("MMGIS_SHARED_ASSET_BUCKET not set; skipping mission asset copy."); } - // 5. Upload the bundle - const uploaded = await provision.uploadDirectory({ + // 5. Upload the bundle. The static index references ./build/... and + // public/... — the same paths Express mounts in server mode — so the + // bucket must mirror that layout: the webpack output under build/, + // the repo's public/ assets under public/, and index.html at the + // root (the distribution's default root object). + const uploadedBuild = await provision.uploadDirectory({ bucket, dir: path.join(rootDir, "build"), + prefix: "build/", }); - log(`Uploaded ${uploaded} bundle file(s) to ${bucket}.`); + const uploadedPublic = await provision.uploadDirectory({ + bucket, + dir: path.join(rootDir, "public"), + prefix: "public/", + }); + await provision.uploadFile({ + bucket, + key: "index.html", + filePath: path.join(rootDir, "build", "index.html"), + }); + log( + `Uploaded ${uploadedBuild} build and ${uploadedPublic} public file(s) to ${bucket}.` + ); // 6. Terminal row update const cloudfrontUrl = From c976ab9021ce8f3be99ec38b94280858bc232a54 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 10 Jun 2026 14:57:07 -0500 Subject: [PATCH 29/63] Add design notes for the deployments-registry redesign --- .../lean/prs/deployments-registry-redesign.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 docs/adr/deployment/lean/prs/deployments-registry-redesign.md diff --git a/docs/adr/deployment/lean/prs/deployments-registry-redesign.md b/docs/adr/deployment/lean/prs/deployments-registry-redesign.md new file mode 100644 index 000000000..4c065efb3 --- /dev/null +++ b/docs/adr/deployment/lean/prs/deployments-registry-redesign.md @@ -0,0 +1,41 @@ +This is an LLM artifact — design notes from the first real staging use of the Deployments page (2026-06-10), for a planned amendment to PR 8 (#138). Not yet implemented. + +# Deployments registry redesign — one identity row per dashboard + +## The problem, as discovered + +During staging, three failed publish attempts (debugging infra issues) plus one success left the Deployments page showing five rows for two missions. The first real user's reaction — "it seems like it is showing multiple lines for the same mission" — is the design bug in one sentence: a row currently means *a publish attempt*, but the ADR, the 1:1 mission↔dashboard rule, the save-bar Publish button, and the user's mental model all treat a row as *the dashboard for a mission*. The ADR already says the intended thing: "a small registry — **one row per published dashboard**." Per-attempt rows are implementation drift (same species as the lost "SPA polling" detail). + +Concrete harms beyond clutter: + +- **The save-bar Publish wedges after any failed publish.** Its publish-vs-update decision treats any non-deleted row for the mission as "dashboard exists" → it fires an *update* at a row with no stack, which can only fail. The mission is stuck until someone deletes the failed row. +- **A failed *update* misreports a live dashboard as dead.** If an update fails, the old bundle is still serving — but the row flips to `failed`, telling the admin their dashboard is down when it isn't. +- 1:1 semantics are ambiguous everywhere a "the deployment for mission X" lookup happens. + +## Target design + +1. **At most one live row per mission**, enforced by a partial unique index on `mission` where `status != 'deleted'`. The row is the dashboard's identity: mission, name, stack name/ARN, `cloudfront_url`, status, `last_error`, timestamps. +2. **Publish = upsert + state transition, not insert.** No row → create + provision. Row in `failed` → **retry the same row**: the task deletes any `CREATE_FAILED`/`ROLLBACK_COMPLETE` stack remnant first, then recreates (stack name stays bound to the row id — idempotency improves as a side effect). Row `published` → this is an update. +3. **Status means "state of the dashboard", not "result of the last attempt".** `failed` is reserved for "no working dashboard exists" (create-path failure). An update failure keeps `published` and surfaces the error separately (e.g. `last_error` + an `updated`-vs-`update_failed` marker) — the URL keeps serving the previous bundle. +4. **Delete** tears down and tombstones. Default page view shows live dashboards only; a "show deleted" toggle reveals tombstones (cheap history, already exists in the data). +5. **Save-bar logic collapses** to: no row → publish; `failed` → retry; `published` → update. No list-scan heuristics. +6. **Page layout**: one line per dashboard — mission, name, status, URL, last updated, error-if-any — actions Open / Update / Retry / Delete. + +## History (separate, later enhancement) + +Tombstones + CloudWatch publish-task logs (full forensic record per attempt) + CloudFormation's 90-day deleted-stack history cover audit needs today. If real audit requirements exist (ask the senior dev — NASA contexts sometimes require it), add a `deployment_events` table (attempt started / published / update_failed / deleted; timestamp, actor, config version, error) rendered as an expandable per-dashboard history drawer. Webhooks already emit submission events for external capture. + +## Scope of the change (amendment to PR 8 / #138) + +- `API/Backend/Deployments/models/deployment.js`: partial unique index; possibly an `update_failed` marker field. +- `routes/deployments.js`: publish handler becomes upsert/retry-aware; update-failure status semantics; list keeps returning tombstones (UI filters). +- `scripts/publish-static.js`: on retry, clear failed stack remnants before CreateStack; on update failure, terminal write preserves `published`. +- `configure/.../Deployments.js`: live-only default view + toggle; Retry action; status copy. +- `SaveBar.js`: simplified decision. +- Tests for the transitions (esp. update-failure-keeps-published and retry-after-create-failed). + +Estimated effort: a focused day including review. PRs are drafts, so amending #138 is clean. + +## Decision status + +Recommended (it is an ADR-conformance fix, found by first real use). Awaiting go-ahead; staging currently works with the append-only model + manual tombstone cleanup. From 975a958f24802135c45fd8ba12c48e58face2651 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 10 Jun 2026 14:57:47 -0500 Subject: [PATCH 30/63] Bake the index Pug placeholders into static dashboards In server mode Express renders build/index.pug per request, filling mmgisglobal values like FORCE_CONFIG_PATH and MAIN_MISSION. The static bundle shipped those placeholders raw, so the first rendered dashboard treated the literal '#{FORCE_CONFIG_PATH}' as a forced config path and died with Mission Not Found. Substitute the static equivalents at publish time (empty for unset, the baked mission for MAIN_MISSION, auth/websockets off). --- scripts/publish-static.js | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/scripts/publish-static.js b/scripts/publish-static.js index aded7acba..0627e477f 100644 --- a/scripts/publish-static.js +++ b/scripts/publish-static.js @@ -191,6 +191,45 @@ async function main() { log("MMGIS_SHARED_ASSET_BUCKET not set; skipping mission asset copy."); } + // 4.5 Interpolate the Pug placeholders in the built index. In server + // mode Express renders build/index.pug per request, filling globals + // like FORCE_CONFIG_PATH and MAIN_MISSION; a dashboard has no server, + // so bake the static equivalents here (unknown placeholders become + // empty strings — the same as unset env vars under Pug). + const indexPath = path.join(rootDir, "build", "index.html"); + const packagejson = require(path.join(rootDir, "package.json")); + const staticGlobals = { + user: "", + permission: "000", + groups: "[]", + AUTH: "off", + NODE_ENV: "production", + VERSION: packagejson.version, + FORCE_CONFIG_PATH: "", + CLEARANCE_NUMBER: "", + LINK_PREVIEW_TITLE: deployment.name || mission, + LINK_PREVIEW_DESCRIPTION: `MMGIS dashboard for ${mission}`, + ENABLE_MMGIS_WEBSOCKETS: "false", + MAIN_MISSION: mission, + IS_DOCKER: "false", + SKIP_CLIENT_INITIAL_LOGIN: "true", + THIRD_PARTY_COOKIES: "false", + PORT: "", + ROOT_PATH: "", + WEBSOCKET_ROOT_PATH: "", + WITH_TITILER: "false", + HOSTS: "{}", + }; + fs.writeFileSync( + indexPath, + fs + .readFileSync(indexPath, "utf8") + .replace(/#\{([A-Za-z_]+)\}/g, (m, key) => + staticGlobals[key] != null ? staticGlobals[key] : "" + ) + ); + log("Interpolated static globals into index.html."); + // 5. Upload the bundle. The static index references ./build/... and // public/... — the same paths Express mounts in server mode — so the // bucket must mirror that layout: the webpack output under build/, From d5f125a2c82d8a2fa1984ba5542abed0b5085f56 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 10 Jun 2026 15:05:52 -0500 Subject: [PATCH 31/63] Ship the baked config at Missions//config.json LandingPage's static branch fetches Missions//config.json via a direct $.getJSON (the legacy static-hosting convention, not routed through the STATIC_HANDLERS dispatcher), so a dashboard without that object dies with Mission Not Found even though the config is baked into the bundle. Upload the guarded baked config at that key. --- scripts/publish-static.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scripts/publish-static.js b/scripts/publish-static.js index 0627e477f..e421fc4b5 100644 --- a/scripts/publish-static.js +++ b/scripts/publish-static.js @@ -21,6 +21,7 @@ require("dotenv").config(); const fs = require("fs"); +const os = require("os"); const path = require("path"); const { spawnSync } = require("child_process"); @@ -250,6 +251,20 @@ async function main() { key: "index.html", filePath: path.join(rootDir, "build", "index.html"), }); + // LandingPage's static branch fetches Missions//config.json + // directly (the legacy static-hosting convention; not routed through + // the dispatcher), so the baked config must also live at that key. + const bakedConfigPath = path.join( + os.tmpdir(), + `mmgis-baked-config-${deployment.id}.json` + ); + fs.writeFileSync(bakedConfigPath, JSON.stringify(baked.get)); + await provision.uploadFile({ + bucket, + key: `Missions/${mission}/config.json`, + filePath: bakedConfigPath, + }); + fs.unlinkSync(bakedConfigPath); log( `Uploaded ${uploadedBuild} build and ${uploadedPublic} public file(s) to ${bucket}.` ); From ef35caa2160299ff1efe01b4f53e183ca9dc2897 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 10 Jun 2026 15:35:40 -0500 Subject: [PATCH 32/63] Copy dashboard assets by mission folder name, not registry name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uploads key assets by the mission's folder name (msv.missionFolderName falling back to msv.mission — matching the full-mode Missions/ disk convention), but the publish copy looked under the registry name, so a mission whose display name differs shipped no images. The first live card-image upload surfaced the mismatch. --- scripts/publish-static.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/publish-static.js b/scripts/publish-static.js index e421fc4b5..336d1d372 100644 --- a/scripts/publish-static.js +++ b/scripts/publish-static.js @@ -171,17 +171,22 @@ async function main() { // inherit the dashboard's password gate as ordinary bundle content. const sharedBucket = process.env.MMGIS_SHARED_ASSET_BUCKET; if (sharedBucket != null && sharedBucket !== "") { + // Uploads are keyed by the mission's FOLDER name (msv.missionFolderName, + // falling back to msv.mission — the same name the full-mode disk path + // uses), not the registry name. The bake already normalized it. + const missionFolderName = + (baked.get.msv && baked.get.msv.missionFolderName) || mission; const copied = await provision.copyPrefix({ sourceBucket: sharedBucket, destBucket: bucket, - prefix: `assets/${mission}/`, + prefix: `assets/${missionFolderName}/`, }); log(`Copied ${copied} mission asset(s) from ${sharedBucket}.`); // Viewer-panel mosaic file (conditional): the Photosphere/ModelViewer // panes fetch this hardcoded same-origin path. Copy it when present; // when absent the panes fail silently rather than erroring. - const mosaicKey = `Missions/${mission}/Data/mosaic_parameters.csv`; + const mosaicKey = `Missions/${missionFolderName}/Data/mosaic_parameters.csv`; const mosaicCopied = await provision.copyObjectIfExists({ sourceBucket: sharedBucket, destBucket: bucket, From afa90efe032d1734f579fc7a4138c973ac7a17ea Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 10 Jun 2026 15:38:25 -0500 Subject: [PATCH 33/63] Add new-developer orientation for the lean deployment effort --- docs/adr/deployment/lean/onboarding.md | 44 ++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/adr/deployment/lean/onboarding.md diff --git a/docs/adr/deployment/lean/onboarding.md b/docs/adr/deployment/lean/onboarding.md new file mode 100644 index 000000000..60c8d457a --- /dev/null +++ b/docs/adr/deployment/lean/onboarding.md @@ -0,0 +1,44 @@ +This is an LLM artifact — a new-developer orientation for the lean deployment effort. Last updated 2026-06-10. + +# Lean deployment — start here + +MMGIS is gaining a second deployment shape from one codebase: `full` (the upstream NASA-AMMOS app, unchanged, the default) and `lean` (an AWS-hosted admin that publishes static, backend-less, password-gated dashboards). One env var — `MMGIS_DEPLOYMENT_MODE` — decides which shape a server runs; one build flag (`SERVER=static`) decides whether the frontend bundle expects a backend at all. + +## Reading order + +1. **[`/vision.md`](../../../../vision.md)** — why any of this exists (plugin-based spatial tool builder; the statically-deployable app is a vision commitment). +2. **[`adr.md`](./adr.md)** — the contract: admin-stack + many-dashboards architecture, the 12 settled constraints, the publish flow, decisions D1 (ECS Express Mode) and D2 (keep code, env-gate it — lean is a *mode*, not a fork). +3. **[`prs/00-overview.md`](./prs/00-overview.md)** — the 13-PR implementation map and dependency graph. Then dip into the per-PR docs (`prs/pr-NN-*.md`) for whatever area you're touching; each is code-verified with file:line anchors. +4. **[`api.md`](./api.md)** — how every named frontend API call behaves in a static dashboard (Bake / Reroute / Compute / Drop), and [`shared/features.md`](../shared/features.md) + [`feature-gaps.md`](./feature-gaps.md) for per-feature dispositions. +5. **[`prs/follow-up.md`](./prs/follow-up.md)** — the honest ledger: out-of-scope findings, plugin-overhaul debt, non-coder UX gaps, and what staging taught us. +6. **[`prs/next-steps.md`](./prs/next-steps.md)** — review → merge → deploy path, and [`prs/deployments-registry-redesign.md`](./prs/deployments-registry-redesign.md) for the planned Deployments-page rework. + +## The PRs (all draft, stacked) + +PRs 1–12 are open as #129–#140 on `NASA-IMPACT/MMGIS`; PR 13 (gate audit + dual-mode CI + docs) is written last. Every PR body carries the same merge-order graph — read any one of them. The stack roots at `feature/mmgis-deployment-skill`; #129 (foundation) merges first; the gate PRs are siblings; #138/#140 sit on integration branches (`lean/pr-08-base`, `lean/pr-10-base`) that merge their two parents so each diff shows only its own work. + +| Area | PRs | +|---|---| +| Mode switch foundation | #129 | +| Backend gates (sidecars, datasets, draw, missions/utils) | #130, #131, #132, #133 | +| Configure polish | #134 | +| Static frontend (dispatcher, ServiceUrls, short-circuits) | #136, #137 | +| Publish flow + Configure Deployments page | #138 | +| AWS recipes + deploy pipeline | #139 | +| S3 asset uploads | #140 | +| Hardening (boot retry, seed, signup gate, WS heartbeat) | #135 | + +**Working agreement:** fixes land on the owning PR branch first, then propagate *down* the stack by merge (never rebase — branches are public and reviewed). The local-only `lean/integration-check` branch (worktree `MMGIS-lean-integration`) merges all heads and is what staging images build from. + +## Key code touchpoints + +- `API/Backend/Utils/deploymentMode.js` — the one true mode read (`isLean()`/`isFull()`); gates live inside each module's `setup.js` (modules are auto-discovered by `API/setups.js`). +- `src/pre/calls.js` + `src/pre/staticHandlers.js` — the static dispatcher (all 40 named calls; parity is test-enforced). Beware **direct-`$.ajax` bypasses** that skip it — several staging bugs lived there (see follow-up.md). +- `src/essence/Basics/ServiceUrls/ServiceUrls.js` — external service URL resolution; static mode never falls back to same-origin. +- `API/Backend/Deployments/` + `scripts/publish-static.js` + `scripts/lib/{cfn-template,aws-provision}.js` — the publish flow (registry, ECS task, per-dashboard CloudFormation stack). +- `infrastructure/` + `.github/workflows/deploy-lean.yml` — AWS recipes (ECS Express Mode, least-privilege IAM, CloudFront VPC origin) with an operator README and placeholder table. + +## Running it + +- **Local:** the `mmgis-deployment` skill (`.claude/skills/mmgis-deployment/`) provisions per-worktree deployments (own port + Postgres DB). Flip `MMGIS_DEPLOYMENT_MODE=lean` in a worktree's `.env` to exercise the gates. Unit tests: `npx playwright test tests/unit/` (the runner is Playwright — ignore stale "Jest" references). +- **Staging:** a live lean admin + published dashboard run in the team's AWS account; URLs, resource names, and credentials live in the team's deployment plan file and Secrets Manager (ask the deploy owner — deliberately not committed here). Five-plus real bugs were found and fixed through it; the ledger and lessons are in follow-up.md. From 76a7e46e102e0fc967fba214b9e45032efdf6475 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 10 Jun 2026 15:41:57 -0500 Subject: [PATCH 34/63] Remove local working docs from the branch These are session working artifacts (follow-up ledger, next-steps runbook, redesign notes, onboarding orientation), kept locally rather than in the repo. --- docs/adr/deployment/lean/onboarding.md | 44 ------------------- .../lean/prs/deployments-registry-redesign.md | 41 ----------------- docs/adr/deployment/lean/prs/follow-up.md | 30 ------------- docs/adr/deployment/lean/prs/next-steps.md | 37 ---------------- 4 files changed, 152 deletions(-) delete mode 100644 docs/adr/deployment/lean/onboarding.md delete mode 100644 docs/adr/deployment/lean/prs/deployments-registry-redesign.md delete mode 100644 docs/adr/deployment/lean/prs/follow-up.md delete mode 100644 docs/adr/deployment/lean/prs/next-steps.md diff --git a/docs/adr/deployment/lean/onboarding.md b/docs/adr/deployment/lean/onboarding.md deleted file mode 100644 index 60c8d457a..000000000 --- a/docs/adr/deployment/lean/onboarding.md +++ /dev/null @@ -1,44 +0,0 @@ -This is an LLM artifact — a new-developer orientation for the lean deployment effort. Last updated 2026-06-10. - -# Lean deployment — start here - -MMGIS is gaining a second deployment shape from one codebase: `full` (the upstream NASA-AMMOS app, unchanged, the default) and `lean` (an AWS-hosted admin that publishes static, backend-less, password-gated dashboards). One env var — `MMGIS_DEPLOYMENT_MODE` — decides which shape a server runs; one build flag (`SERVER=static`) decides whether the frontend bundle expects a backend at all. - -## Reading order - -1. **[`/vision.md`](../../../../vision.md)** — why any of this exists (plugin-based spatial tool builder; the statically-deployable app is a vision commitment). -2. **[`adr.md`](./adr.md)** — the contract: admin-stack + many-dashboards architecture, the 12 settled constraints, the publish flow, decisions D1 (ECS Express Mode) and D2 (keep code, env-gate it — lean is a *mode*, not a fork). -3. **[`prs/00-overview.md`](./prs/00-overview.md)** — the 13-PR implementation map and dependency graph. Then dip into the per-PR docs (`prs/pr-NN-*.md`) for whatever area you're touching; each is code-verified with file:line anchors. -4. **[`api.md`](./api.md)** — how every named frontend API call behaves in a static dashboard (Bake / Reroute / Compute / Drop), and [`shared/features.md`](../shared/features.md) + [`feature-gaps.md`](./feature-gaps.md) for per-feature dispositions. -5. **[`prs/follow-up.md`](./prs/follow-up.md)** — the honest ledger: out-of-scope findings, plugin-overhaul debt, non-coder UX gaps, and what staging taught us. -6. **[`prs/next-steps.md`](./prs/next-steps.md)** — review → merge → deploy path, and [`prs/deployments-registry-redesign.md`](./prs/deployments-registry-redesign.md) for the planned Deployments-page rework. - -## The PRs (all draft, stacked) - -PRs 1–12 are open as #129–#140 on `NASA-IMPACT/MMGIS`; PR 13 (gate audit + dual-mode CI + docs) is written last. Every PR body carries the same merge-order graph — read any one of them. The stack roots at `feature/mmgis-deployment-skill`; #129 (foundation) merges first; the gate PRs are siblings; #138/#140 sit on integration branches (`lean/pr-08-base`, `lean/pr-10-base`) that merge their two parents so each diff shows only its own work. - -| Area | PRs | -|---|---| -| Mode switch foundation | #129 | -| Backend gates (sidecars, datasets, draw, missions/utils) | #130, #131, #132, #133 | -| Configure polish | #134 | -| Static frontend (dispatcher, ServiceUrls, short-circuits) | #136, #137 | -| Publish flow + Configure Deployments page | #138 | -| AWS recipes + deploy pipeline | #139 | -| S3 asset uploads | #140 | -| Hardening (boot retry, seed, signup gate, WS heartbeat) | #135 | - -**Working agreement:** fixes land on the owning PR branch first, then propagate *down* the stack by merge (never rebase — branches are public and reviewed). The local-only `lean/integration-check` branch (worktree `MMGIS-lean-integration`) merges all heads and is what staging images build from. - -## Key code touchpoints - -- `API/Backend/Utils/deploymentMode.js` — the one true mode read (`isLean()`/`isFull()`); gates live inside each module's `setup.js` (modules are auto-discovered by `API/setups.js`). -- `src/pre/calls.js` + `src/pre/staticHandlers.js` — the static dispatcher (all 40 named calls; parity is test-enforced). Beware **direct-`$.ajax` bypasses** that skip it — several staging bugs lived there (see follow-up.md). -- `src/essence/Basics/ServiceUrls/ServiceUrls.js` — external service URL resolution; static mode never falls back to same-origin. -- `API/Backend/Deployments/` + `scripts/publish-static.js` + `scripts/lib/{cfn-template,aws-provision}.js` — the publish flow (registry, ECS task, per-dashboard CloudFormation stack). -- `infrastructure/` + `.github/workflows/deploy-lean.yml` — AWS recipes (ECS Express Mode, least-privilege IAM, CloudFront VPC origin) with an operator README and placeholder table. - -## Running it - -- **Local:** the `mmgis-deployment` skill (`.claude/skills/mmgis-deployment/`) provisions per-worktree deployments (own port + Postgres DB). Flip `MMGIS_DEPLOYMENT_MODE=lean` in a worktree's `.env` to exercise the gates. Unit tests: `npx playwright test tests/unit/` (the runner is Playwright — ignore stale "Jest" references). -- **Staging:** a live lean admin + published dashboard run in the team's AWS account; URLs, resource names, and credentials live in the team's deployment plan file and Secrets Manager (ask the deploy owner — deliberately not committed here). Five-plus real bugs were found and fixed through it; the ledger and lessons are in follow-up.md. diff --git a/docs/adr/deployment/lean/prs/deployments-registry-redesign.md b/docs/adr/deployment/lean/prs/deployments-registry-redesign.md deleted file mode 100644 index 4c065efb3..000000000 --- a/docs/adr/deployment/lean/prs/deployments-registry-redesign.md +++ /dev/null @@ -1,41 +0,0 @@ -This is an LLM artifact — design notes from the first real staging use of the Deployments page (2026-06-10), for a planned amendment to PR 8 (#138). Not yet implemented. - -# Deployments registry redesign — one identity row per dashboard - -## The problem, as discovered - -During staging, three failed publish attempts (debugging infra issues) plus one success left the Deployments page showing five rows for two missions. The first real user's reaction — "it seems like it is showing multiple lines for the same mission" — is the design bug in one sentence: a row currently means *a publish attempt*, but the ADR, the 1:1 mission↔dashboard rule, the save-bar Publish button, and the user's mental model all treat a row as *the dashboard for a mission*. The ADR already says the intended thing: "a small registry — **one row per published dashboard**." Per-attempt rows are implementation drift (same species as the lost "SPA polling" detail). - -Concrete harms beyond clutter: - -- **The save-bar Publish wedges after any failed publish.** Its publish-vs-update decision treats any non-deleted row for the mission as "dashboard exists" → it fires an *update* at a row with no stack, which can only fail. The mission is stuck until someone deletes the failed row. -- **A failed *update* misreports a live dashboard as dead.** If an update fails, the old bundle is still serving — but the row flips to `failed`, telling the admin their dashboard is down when it isn't. -- 1:1 semantics are ambiguous everywhere a "the deployment for mission X" lookup happens. - -## Target design - -1. **At most one live row per mission**, enforced by a partial unique index on `mission` where `status != 'deleted'`. The row is the dashboard's identity: mission, name, stack name/ARN, `cloudfront_url`, status, `last_error`, timestamps. -2. **Publish = upsert + state transition, not insert.** No row → create + provision. Row in `failed` → **retry the same row**: the task deletes any `CREATE_FAILED`/`ROLLBACK_COMPLETE` stack remnant first, then recreates (stack name stays bound to the row id — idempotency improves as a side effect). Row `published` → this is an update. -3. **Status means "state of the dashboard", not "result of the last attempt".** `failed` is reserved for "no working dashboard exists" (create-path failure). An update failure keeps `published` and surfaces the error separately (e.g. `last_error` + an `updated`-vs-`update_failed` marker) — the URL keeps serving the previous bundle. -4. **Delete** tears down and tombstones. Default page view shows live dashboards only; a "show deleted" toggle reveals tombstones (cheap history, already exists in the data). -5. **Save-bar logic collapses** to: no row → publish; `failed` → retry; `published` → update. No list-scan heuristics. -6. **Page layout**: one line per dashboard — mission, name, status, URL, last updated, error-if-any — actions Open / Update / Retry / Delete. - -## History (separate, later enhancement) - -Tombstones + CloudWatch publish-task logs (full forensic record per attempt) + CloudFormation's 90-day deleted-stack history cover audit needs today. If real audit requirements exist (ask the senior dev — NASA contexts sometimes require it), add a `deployment_events` table (attempt started / published / update_failed / deleted; timestamp, actor, config version, error) rendered as an expandable per-dashboard history drawer. Webhooks already emit submission events for external capture. - -## Scope of the change (amendment to PR 8 / #138) - -- `API/Backend/Deployments/models/deployment.js`: partial unique index; possibly an `update_failed` marker field. -- `routes/deployments.js`: publish handler becomes upsert/retry-aware; update-failure status semantics; list keeps returning tombstones (UI filters). -- `scripts/publish-static.js`: on retry, clear failed stack remnants before CreateStack; on update failure, terminal write preserves `published`. -- `configure/.../Deployments.js`: live-only default view + toggle; Retry action; status copy. -- `SaveBar.js`: simplified decision. -- Tests for the transitions (esp. update-failure-keeps-published and retry-after-create-failed). - -Estimated effort: a focused day including review. PRs are drafts, so amending #138 is clean. - -## Decision status - -Recommended (it is an ADR-conformance fix, found by first real use). Awaiting go-ahead; staging currently works with the append-only model + manual tombstone cleanup. diff --git a/docs/adr/deployment/lean/prs/follow-up.md b/docs/adr/deployment/lean/prs/follow-up.md deleted file mode 100644 index 020cfde7e..000000000 --- a/docs/adr/deployment/lean/prs/follow-up.md +++ /dev/null @@ -1,30 +0,0 @@ -This is an LLM artifact — out-of-scope findings collected while implementing PRs 1–7, 9, 12 (2026-06-09). None block the lean PR stack; each is a candidate for a real issue later. "Source" = where it was discovered. - -# Follow-ups (not in any PR's scope) - -## Latent bugs / hazards - -1. **Latent `DrawTool` cross-references break specific flows in lean.** `src/essence/Tools/Kinds/Kinds.js:341` calls `TC_.getTool('DrawTool').showContextMenu(...)` for the `draw_tool` kind (null deref when Draw is gated out, PR 4), and `src/essence/Tools/Shade/ShadeTool.js:~1752–1769` references a module-global `DrawTool` during Shade indicator drag. Only triggered by specific user actions (a mission configuring a `draw_tool` click behavior; Shade indicator drag), but both deserve null-guards in a hardening pass. *Source: PR 4 spec gotchas, confirmed by implementer.* -2. **Six pre-existing `ServiceUrls` builders interpolate a possibly-null base URL.** `buildTiTilerCogTilesUrl`, `buildStacCollectionTilesUrl`, `buildTiTilerPointUrl`, `buildStacCollectionPointUrl`, `buildColormapImageUrl`, `buildStacItemsUrl` return strings like `"null/cog/tiles/…"` when the service is unconfigured in a static build, and their `@returns {string}` JSDoc is now inaccurate (PR 7 made `getServiceUrl` nullable in static mode). Add the same `if (baseUrl == null) return null` guard the new builders use + update JSDoc, then audit call sites. *Source: PR 7 quality review.* -3. **`scripts/server.js:788` websocket gate is a truthy check.** `if (process.env.ENABLE_MMGIS_WEBSOCKETS)` — the string `"false"` still enables websockets. Pre-existing; compare with the `=== 'true'` checks used elsewhere. *Source: PR 12 review.* - -## Stale / broken repo state - -4. **Committed `configure/public/toolConfigs.json` is stale vs the in-tree tools.** Regenerating in full mode adds `Chart` and `FetchStats` entries and a differing `Title` config the committed file lacks (plus formatting differences), so any full-mode boot dirties a tracked file. Regenerate and commit it in a dedicated change (or stop tracking it — it's boot-generated). *Source: PR 4 implementer.* -5. **`tests/unit/` cannot run as a single command.** Eight specs fail at import time with `window is not defined`: `deckGLAdapter`, `LeafletAdapter`, `mapEngineRegistry`, and the `panelManager/*` suite (5 files). Identical at the pre-lean base commit. *Source: PR 7 implementer.* -6. **`tests/unit/Card/uploadRouting.spec.js` is order-dependent.** `uploadImage.spec.js` replaces `global.fetch` and never restores it, so `uploadRouting` fails under `--workers=1`. Restore the mock in an `afterEach`/`afterAll`. *Source: PR 7 implementer.* -7. **`AGENTS.md` testing section is wrong.** It claims "Framework: Jest 29"; the repo's `npm test` is `playwright test` (unit specs live in `tests/unit/*.spec.{js,ts}`; there is no jest config or runner). Misled one implementation pass already. PR 13's doc sweep could absorb this. *Source: PR 1 quality review.* -8. **PR-04 spec doc has a wrong import path.** `docs/adr/deployment/lean/prs/pr-04-gate-draw.md` suggests `require("../../Utils/deploymentMode")` from `API/Backend/Draw/setup.js`; correct is `../Utils/deploymentMode` (the wrong path resolves to a nonexistent `API/Utils/` and broke Draw setup in both modes until caught at boot). The shipped code is correct; the doc isn't. *Source: PR 4 implementer.* - -## Cleanups / verification debt - -9. **`scripts/init-db.js` explicit-executor antipattern.** The `new Promise(async (resolve, reject) => …)` wrapper with floating internal promise chains predates the lean work; PR 12 mirrored it to stay minimal. Flatten to plain async/await in a dedicated cleanup. *Source: PR 12 quality review.* -10. **Verify PR 9's TiTiler statistics parsing against a live TiTiler.** The static-mode COG min/max reroute assumes the documented `{"b1": {min, max}}` response shape (with a first-band fallback); it has not been exercised against a real TiTiler instance yet. *Source: PR 9 implementer.* -11. **Deployment webhooks signal submission, not completion.** PR 8 fires `deploymentPublish`/`deploymentUpdate` from the route handlers (row at `provisioning`/`updating`; first-publish payload has `cloudfront_url: null`) because the true terminal update happens inside the detached ECS publish task — and `triggerwebhooks.js` relies on an in-memory `webhooksConfig` cache a fresh task process never hydrates. Consider a completion event (e.g. `deploymentPublished`) fired from `publish-static.js` after the terminal row update, reading webhook entries directly from the DB; or document that consumers should poll `GET /api/deployments/:id`. *Source: PR 8 spec review.* -12. **Admin-side teardown: consider a CFN service role.** PR 8's DELETE handler runs `DeleteStack` with the admin task role's credentials (no stack service role), so PR 11 grants the admin role the `mmgis-dashboard-*` teardown set (`s3:DeleteBucket`, `cloudfront:Delete*`, etc. — the PR 11 spec's narrower scoping would have failed every delete). The stricter long-term shape is a CloudFormation service role passed at create/delete so the admin role shrinks back down. Validate the whole delete path in the staging deploy either way. *Source: PR 11 review (fixed in PR 11; service-role alternative deferred).* -13. **Save-bar Publish silently republishes a live dashboard.** The save-bar button auto-decides publish-vs-update (lean is 1:1 mission↔dashboard); on an already-published mission a click replaces the live dashboard's contents with the just-saved config, with no "you're about to change the published version" affirmation. Consider a lightweight confirm on the update path. (Delete already has a type-the-name modal; publish-status visibility is handled by the polling/toast commit on #138.) *Source: UX walkthrough, 2026-06-10.* -14. **Overhaul-#1 (plugin) debt introduced by the lean batch** — from the 2026-06-10 vision-alignment review; none reverse direction, all should be paid down during the plugin overhaul rather than fossilizing: name-hardcoded tool exclusion in the core tool scanner (`API/updateTools.js` `items[i].name === "Draw"`); field-specific action hardcode in Configure's form engine (`Maker.js` `tile-populate-from-x`); ~10 inline `window.mmgisglobal.SERVER != 'node'` branches inside tool code (Identifier, Measure, Coordinates, TimeUI) instead of a core-exposed capability; `staticHandlers.js` as a second per-call registry kept in sync with `calls.js` only by regex-extraction tests; the publish path is AWS-only with no provider seam (`Deployments/routes` requires `aws-provision` directly), and `publish-static.js` propagates the legacy `Missions//Data/mosaic_parameters.csv` filesystem convention into dashboards. -15. **Non-coder UX gaps in the publish journey** — same review; the publish click meets the non-coder bar, configuring and diagnosing don't: missing external service URLs fail as console warnings + blank tiles (no UI-visible explanation; `options.services` isn't editable in GeneralOptions); layer help text still teaches `Missions/`-relative paths that 404 in dashboards, and there is no publish-time "these layers won't resolve" report (the time-layer bake guard disables silently); raw CloudFormation/SDK text renders verbatim as `last_error`; the shared dashboard password is invisible to the UI (set/rotated only by ops); preview (node admin) ≠ published (static) behavior differences are documented only in feature-gaps.md; no mission-clone affordance for the copy-to-vary workflow (ADR constraint 6). -16. ~~**`deploy-lean.yml` + infra recipes don't match the real ECS Express Mode API.**~~ **FIXED in PR 11** (2026-06-10, commit `62749d1d`, informed by the live staging deploy): workflow rewritten to `update-express-gateway-service` + a bounded `describe` poll; `express-infrastructure-role.json` recipe added (managed policy `AmazonECSInfrastructureRoleforExpressGatewayServices`, lowercase "for"); `cloudfront-admin.json` rewritten to the proven bare-CloudFront VPC-origin shape (on.aws origin domain + AllViewerExceptHostHeader — the managed internal ALB host-header-routes on the on.aws name); task defs gained `DB_SSL`/`DB_SSL_CERT_BASE64`; publish role gained `cloudfront:TagResource` on function ARNs (first live publish failed on it). Residual nit: `tests/unit/infrastructure.spec.js` doesn't pin the new origin-request-policy ID. Original finding below for the record: -**`deploy-lean.yml` + infra recipes don't match the real ECS Express Mode API.** Discovered during staging pre-flight (2026-06-10): Express gateway services are not created/updated from task definitions — the CLI surface is `create|update-express-gateway-service` with an inline `--primary-container` (image/port/env/secrets), a required **infrastructure role** (trust `ecs.amazonaws.com`) absent from the IAM recipes, and `monitor-express-gateway-service` for rollout status. The workflow's `register-task-definition` + `update-service --task-definition` + `wait services-stable` steps need rewriting, the github-deploy role needs the express-gateway actions, and `infrastructure/README.md` should document the infra role. Also `cloudfront-admin.json` hardcodes `Aliases` + ACM cert + `https-only` origin — impossible without a custom domain (no ACM for `*.elb.amazonaws.com`); parameterize for a bare-CloudFront posture (default cert, `http-only` origin, ALB SG tightened to the CloudFront origin-facing prefix list). Fix PR 11 with the exact shapes learned from the staging deploy. *Source: staging pre-flight check.* -17. **Deferred review nits** (all optional, noted-and-skipped during review): silent skip of the `mmgis-stac` block in lean could log like its sibling gates (`scripts/init-db.js`, PR 2); the four `WITH_*` ternaries in `API/Backend/Config/setup.js` could hoist a single `isLean()` call (PR 2); the ~140-line gated block in `API/Backend/Utils/routes/utils.js` could label its closing brace (PR 5); `titiler.ts` re-implements the static check inline instead of an exported `ServiceUrls.isStaticBuild()` (PR 7); the staticHandlers parity spec's registry regex requires the exact multi-line entry shape — drop the `$` anchor to also catch single-line entries (PR 7). diff --git a/docs/adr/deployment/lean/prs/next-steps.md b/docs/adr/deployment/lean/prs/next-steps.md deleted file mode 100644 index a102818a1..000000000 --- a/docs/adr/deployment/lean/prs/next-steps.md +++ /dev/null @@ -1,37 +0,0 @@ -This is an LLM artifact — the path from the current state (12 draft PRs, #129–#140, nothing on AWS) to reviewed, merged, and deployed. Written 2026-06-10. - -# Lean deployment — next steps - -## Where things stand - -PRs 1–12 of the series are open as stacked drafts (see any PR body's "Merge order" for the graph). PR 13 (cleanup + dual-mode CI) is unwritten by design — it lands last. Nothing has touched AWS; `infrastructure/` is unapplied recipes and `deploy-lean.yml` has never run. Out-of-scope findings live in [`follow-up.md`](./follow-up.md). - -## 1. Review - -1. Flip drafts to "Ready for review" in dependency order — #129 first, then the rest in any order; reviewing a child before its parent works fine since each diff shows only its own work. -2. Review-fix churn propagates by **merge, never rebase**: a fix lands on its own PR's branch; children pick it up by merging the parent in (only needed when the fix functionally affects them). For #138/#140, fixes to a parent also get merged into their integration branches (`lean/pr-08-base`, `lean/pr-10-base`) to keep diffs clean. -3. The local worktrees (`MMGIS-lean-pr-NN`, each with its own port + DB) are still up for hands-on testing of any PR — flip `MMGIS_DEPLOYMENT_MODE` in the worktree `.env` and use the deployment skill's `start.sh`. - -## 2. Merge - -1. Merge **#129** into `feature/mmgis-deployment-skill`, deleting its branch — GitHub auto-retargets the siblings. -2. Merge the independent tier in any order: #130, #131, #132, #133, #136, #135. Known trivial conflict: #130 and #131 both touch `API/Backend/Config/setup.js`; whichever merges second resolves a few lines. -3. Merge the children as their parents land: #134 (after #131), #137 (after #136), #138 (retarget its base from `lean/pr-08-base` to the skill branch once #131 + #136 are in, then delete `lean/pr-08-base`), #139 (after #138), #140 (same retarget dance with `lean/pr-10-base` once #139 + #133 are in). -4. After everything merges: write and land **PR 13** (gate audit, CI matrix running both modes, README/AGENTS/docs updates — fold in the AGENTS.md Jest→Playwright fix), convert [`follow-up.md`](./follow-up.md) items into real issues, regenerate the stale `configure/public/toolConfigs.json`, and tear down the local worktrees (`teardown.sh`, which prompts per deployment). -5. Eventually this all rides `feature/mmgis-deployment-skill` → `development` through whatever review that merge gets. - -## 3. Deploy (staging first) - -Prereqs are operator setup, detailed in [`infrastructure/README.md`](../../../../infrastructure/README.md) (on the #139 branch until merged): - -1. **One-time AWS setup:** ECR repository; the five Secrets Manager entries (DB creds, `SECRET` session secret, `SEED_SUPERADMIN_USERNAME`/`_PASSWORD`, dashboards shared password); managed Postgres reachable from the VPC; CloudWatch log groups; the shared asset bucket; the GitHub OIDC deploy role; admin hostname + ACM cert (operator-owned DNS); NAT/egress for outbound webhooks. -2. **Fill placeholders** (``, ``, ARNs, …) in `infrastructure/`, register the two task definitions, create the ECS **Express Mode** service for the admin, and put the admin CloudFront distribution in front of the endpoint it exposes. -3. **First deploy** via `deploy-lean.yml` (release trigger or `workflow_dispatch`): builds the image (with themes), pushes to ECR, registers new task-def revisions, triggers the managed rollout. -4. **Staging verification checklist** (from the PR 11 spec — this is where the deliberately-unverified items get proven): - - Log in at the admin URL (proves AllViewer + CachingDisabled + `trust proxy 2`); confirm the seeded superadmin works and `first_signup` is gated. - - Configure a mission against a public COG URL; upload an image (proves the asset bucket + `/assets/*` behavior + PR 10's S3 write). - - **Publish** a dashboard end-to-end (proves `RunTask` + PassRole + the publish role's CFN/S3/CloudFront scopes, and the open D1 question of RunTask under Express Mode networking); open the URL, confirm the password gate 401s without credentials and the map renders with them, with zero `/api/*` calls. - - **Update** the dashboard (same URL re-baked), then **Delete** it (proves the admin role's teardown set — watch for `DELETE_FAILED`; the CFN-service-role alternative in follow-up.md is the fallback). - - IAM least-privilege spot-check with the policy simulator against out-of-prefix ARNs. - - Publish a `modern`-mode mission and confirm panels render (the PR 8 spec's e2e check that can't run locally). -5. Fix what staging surfaces (expected suspects are listed in follow-up.md), then repeat for production with its own secrets/cert/hostname. From c349e4f3bed0ef13d9d4a2b81096147209bc38f5 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 10 Jun 2026 15:55:19 -0500 Subject: [PATCH 35/63] Invalidate the dashboard CDN after publish and update uploads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-dashboard distribution caches aggressively (CachingOptimized); hashed bundle names dodge it but index.html, the baked config.json, and copied assets do not — an Update could serve the stale dashboard for up to a day. Create a /* invalidation after the uploads (new @aws-sdk cloudfront client; the publish role gains cloudfront:CreateInvalidation in the PR 11 recipes). --- package-lock.json | 187 +++++++++++++++++++++-------------- package.json | 1 + scripts/lib/aws-provision.js | 24 ++++- scripts/publish-static.js | 13 +++ 4 files changed, 150 insertions(+), 75 deletions(-) diff --git a/package-lock.json b/package-lock.json index fcb34f6c7..f643a9d28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "4.2.9-20260211", "dependencies": { "@aws-sdk/client-cloudformation": "^3.1065.0", + "@aws-sdk/client-cloudfront": "^3.1066.0", "@aws-sdk/client-ecs": "^3.1065.0", "@aws-sdk/client-s3": "^3.1065.0", "@azure/ai-agents": "^1.0.0-beta.1", @@ -323,6 +324,27 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-cloudfront": { + "version": "3.1066.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudfront/-/client-cloudfront-3.1066.0.tgz", + "integrity": "sha512-c1bn81+pxFqBE1SRAbGHbYSQzf8CgRbRyYDeJtMFLV2foVHcqlsiIffwVQoCDw91i45toWn/cLIiroI5hb4GZg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/credential-provider-node": "^3.972.55", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-cognito-identity": { "version": "3.1050.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1050.0.tgz", @@ -464,19 +486,19 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.52", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.52.tgz", - "integrity": "sha512-szg1nnebqC+Svv6Vfsdf6P/QK8x5g/ghG2CKa/1WkHifRnq0BBmDELj2Qnqk9nPsUvEu/OEcYic97CPLpKqF9g==", + "version": "3.972.53", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.53.tgz", + "integrity": "sha512-ZfdhIOR41q8TcWEnUac+gCOb+O2LBWdHLmjedXpXz4IEFW2ppNuFcm6p0sMTavpM+zD5TYfpH5Gp7guRyqSgsQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/credential-provider-env": "^3.972.46", "@aws-sdk/credential-provider-http": "^3.972.48", - "@aws-sdk/credential-provider-login": "^3.972.51", + "@aws-sdk/credential-provider-login": "^3.972.52", "@aws-sdk/credential-provider-process": "^3.972.46", - "@aws-sdk/credential-provider-sso": "^3.972.51", - "@aws-sdk/credential-provider-web-identity": "^3.972.51", - "@aws-sdk/nested-clients": "^3.997.19", + "@aws-sdk/credential-provider-sso": "^3.972.52", + "@aws-sdk/credential-provider-web-identity": "^3.972.52", + "@aws-sdk/nested-clients": "^3.997.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", @@ -488,13 +510,13 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.51", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.51.tgz", - "integrity": "sha512-csHFsH+/VjnI40oqm1l1OqMY4B4kza36DbfcbHcgcbobgjebasqUbTU34xvwUkvtoNGGizbfyMSlMzJWUPv3dQ==", + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.52.tgz", + "integrity": "sha512-9hu2oR0qH7Fst5Tzdx+UWxm+w5zCXtErTLtOOW5hwwQc170CLwOeniRxyFY6s9mHfGEfC5zFukNBdKBwJR8mhQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.20", - "@aws-sdk/nested-clients": "^3.997.19", + "@aws-sdk/nested-clients": "^3.997.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", @@ -505,17 +527,17 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.54", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.54.tgz", - "integrity": "sha512-vinTSQtziNHxi2nqXF+76jr2sO44q88Ind1qFFVaotNgBaC1rcWDjBug8yoE8n0ov33s21xks9WY5XDHH9SENw==", + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.55.tgz", + "integrity": "sha512-zMGLa/dhESVqmCD7mmIFFKSwSFrJGScvCXcjvBZEVOOMauFS5JRQvLTMukFpMEFWiV6dTAlsen2ATDBulLPtbg==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.46", "@aws-sdk/credential-provider-http": "^3.972.48", - "@aws-sdk/credential-provider-ini": "^3.972.52", + "@aws-sdk/credential-provider-ini": "^3.972.53", "@aws-sdk/credential-provider-process": "^3.972.46", - "@aws-sdk/credential-provider-sso": "^3.972.51", - "@aws-sdk/credential-provider-web-identity": "^3.972.51", + "@aws-sdk/credential-provider-sso": "^3.972.52", + "@aws-sdk/credential-provider-web-identity": "^3.972.52", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", @@ -543,14 +565,14 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.51", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.51.tgz", - "integrity": "sha512-60qhpQcSDIKIr0AuBlmJezKX0b5nbJPCINiR49N9yJXrEI5tTRwsXVBr0IdSvvsNJyqgiINyoBd++Ed0yvggbw==", + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.52.tgz", + "integrity": "sha512-nb2/n4o/HQf+FVpVbZe9vCTFngmuDoIsltMgLAtjixaKzvzhB4J8WSDFyWgnErgLHk55ctWH+I4PU+LIHhyffg==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.20", - "@aws-sdk/nested-clients": "^3.997.19", - "@aws-sdk/token-providers": "3.1065.0", + "@aws-sdk/nested-clients": "^3.997.20", + "@aws-sdk/token-providers": "3.1066.0", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", @@ -561,13 +583,13 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.51", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.51.tgz", - "integrity": "sha512-0X5eWsUIp8ItRJeJBBrhQAPzc9AQelDetRTVTsycCAISCCzM17R4hs/vFAPeQ0o0B35sciLiqe/Pwmml909cZA==", + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.52.tgz", + "integrity": "sha512-lKj6aRSGbqLmpYmM24bY7a1Xmfcq2vkE3hv8CSPYfc1yCu0BPu/XEJ1L4Fm61MsU6ULLNSG8UGsffNoFUBjESA==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.20", - "@aws-sdk/nested-clients": "^3.997.19", + "@aws-sdk/nested-clients": "^3.997.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", @@ -638,15 +660,15 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.19.tgz", - "integrity": "sha512-P2Otgf15GBJMKzG6j5Ddf7w+Kz6z2jvesMy874TD3jlMfDWNK7clJeUd7hgigdeVOotjoUP4emcTWVdS9sfZDw==", + "version": "3.997.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.20.tgz", + "integrity": "sha512-IYJuLpXp2DEILVQpQOy0PMpkftv0AHEOCn52o0atyOaumA0CdWQ3klPyXdViGYLbNpESsVFMVybvHUeZAuiGxA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.20", - "@aws-sdk/signature-v4-multi-region": "^3.996.33", + "@aws-sdk/signature-v4-multi-region": "^3.996.34", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", @@ -659,9 +681,9 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.33", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.33.tgz", - "integrity": "sha512-Hn0RThJEbyOZWV2PV9Z4YD3nitGPxybmyU17dSe9b61WOBcKnqS0WTtM3c1zyZq9WnGiyrfi/i+UBPUk7cM8Ug==", + "version": "3.996.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.34.tgz", + "integrity": "sha512-mx1L5qlumSOt/nKM3BFaHE2HVkWwz0i4Bw0pyYO42FfX/FeLlo8YI6csC0gSPprEk6fTIqI+CZN9RwUwKd5krQ==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.12", @@ -674,13 +696,13 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1065.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1065.0.tgz", - "integrity": "sha512-qdHQntq82gMqG6Tf8xrgmhJxacaYkxW4PEeDg/ISMVJ84EWe7iD6JyCTgbyox3uNDH6vqEJ8GUiTaXCq307zVw==", + "version": "3.1066.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1066.0.tgz", + "integrity": "sha512-UqEUJq7dqa44hneLDUcX7UJy95cg8YqEWyakRpvIPnrNS3Mq+UlQHgCDGu5pvwAPtlIW4qcYbvW6reG6++FyvA==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.20", - "@aws-sdk/nested-clients": "^3.997.19", + "@aws-sdk/nested-clients": "^3.997.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", @@ -31733,6 +31755,23 @@ "tslib": "^2.6.2" } }, + "@aws-sdk/client-cloudfront": { + "version": "3.1066.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudfront/-/client-cloudfront-3.1066.0.tgz", + "integrity": "sha512-c1bn81+pxFqBE1SRAbGHbYSQzf8CgRbRyYDeJtMFLV2foVHcqlsiIffwVQoCDw91i45toWn/cLIiroI5hb4GZg==", + "requires": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/credential-provider-node": "^3.972.55", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + } + }, "@aws-sdk/client-cognito-identity": { "version": "3.1050.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1050.0.tgz", @@ -31846,18 +31885,18 @@ } }, "@aws-sdk/credential-provider-ini": { - "version": "3.972.52", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.52.tgz", - "integrity": "sha512-szg1nnebqC+Svv6Vfsdf6P/QK8x5g/ghG2CKa/1WkHifRnq0BBmDELj2Qnqk9nPsUvEu/OEcYic97CPLpKqF9g==", + "version": "3.972.53", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.53.tgz", + "integrity": "sha512-ZfdhIOR41q8TcWEnUac+gCOb+O2LBWdHLmjedXpXz4IEFW2ppNuFcm6p0sMTavpM+zD5TYfpH5Gp7guRyqSgsQ==", "requires": { "@aws-sdk/core": "^3.974.20", "@aws-sdk/credential-provider-env": "^3.972.46", "@aws-sdk/credential-provider-http": "^3.972.48", - "@aws-sdk/credential-provider-login": "^3.972.51", + "@aws-sdk/credential-provider-login": "^3.972.52", "@aws-sdk/credential-provider-process": "^3.972.46", - "@aws-sdk/credential-provider-sso": "^3.972.51", - "@aws-sdk/credential-provider-web-identity": "^3.972.51", - "@aws-sdk/nested-clients": "^3.997.19", + "@aws-sdk/credential-provider-sso": "^3.972.52", + "@aws-sdk/credential-provider-web-identity": "^3.972.52", + "@aws-sdk/nested-clients": "^3.997.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", @@ -31866,12 +31905,12 @@ } }, "@aws-sdk/credential-provider-login": { - "version": "3.972.51", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.51.tgz", - "integrity": "sha512-csHFsH+/VjnI40oqm1l1OqMY4B4kza36DbfcbHcgcbobgjebasqUbTU34xvwUkvtoNGGizbfyMSlMzJWUPv3dQ==", + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.52.tgz", + "integrity": "sha512-9hu2oR0qH7Fst5Tzdx+UWxm+w5zCXtErTLtOOW5hwwQc170CLwOeniRxyFY6s9mHfGEfC5zFukNBdKBwJR8mhQ==", "requires": { "@aws-sdk/core": "^3.974.20", - "@aws-sdk/nested-clients": "^3.997.19", + "@aws-sdk/nested-clients": "^3.997.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", @@ -31879,16 +31918,16 @@ } }, "@aws-sdk/credential-provider-node": { - "version": "3.972.54", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.54.tgz", - "integrity": "sha512-vinTSQtziNHxi2nqXF+76jr2sO44q88Ind1qFFVaotNgBaC1rcWDjBug8yoE8n0ov33s21xks9WY5XDHH9SENw==", + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.55.tgz", + "integrity": "sha512-zMGLa/dhESVqmCD7mmIFFKSwSFrJGScvCXcjvBZEVOOMauFS5JRQvLTMukFpMEFWiV6dTAlsen2ATDBulLPtbg==", "requires": { "@aws-sdk/credential-provider-env": "^3.972.46", "@aws-sdk/credential-provider-http": "^3.972.48", - "@aws-sdk/credential-provider-ini": "^3.972.52", + "@aws-sdk/credential-provider-ini": "^3.972.53", "@aws-sdk/credential-provider-process": "^3.972.46", - "@aws-sdk/credential-provider-sso": "^3.972.51", - "@aws-sdk/credential-provider-web-identity": "^3.972.51", + "@aws-sdk/credential-provider-sso": "^3.972.52", + "@aws-sdk/credential-provider-web-identity": "^3.972.52", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", @@ -31909,13 +31948,13 @@ } }, "@aws-sdk/credential-provider-sso": { - "version": "3.972.51", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.51.tgz", - "integrity": "sha512-60qhpQcSDIKIr0AuBlmJezKX0b5nbJPCINiR49N9yJXrEI5tTRwsXVBr0IdSvvsNJyqgiINyoBd++Ed0yvggbw==", + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.52.tgz", + "integrity": "sha512-nb2/n4o/HQf+FVpVbZe9vCTFngmuDoIsltMgLAtjixaKzvzhB4J8WSDFyWgnErgLHk55ctWH+I4PU+LIHhyffg==", "requires": { "@aws-sdk/core": "^3.974.20", - "@aws-sdk/nested-clients": "^3.997.19", - "@aws-sdk/token-providers": "3.1065.0", + "@aws-sdk/nested-clients": "^3.997.20", + "@aws-sdk/token-providers": "3.1066.0", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", @@ -31923,12 +31962,12 @@ } }, "@aws-sdk/credential-provider-web-identity": { - "version": "3.972.51", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.51.tgz", - "integrity": "sha512-0X5eWsUIp8ItRJeJBBrhQAPzc9AQelDetRTVTsycCAISCCzM17R4hs/vFAPeQ0o0B35sciLiqe/Pwmml909cZA==", + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.52.tgz", + "integrity": "sha512-lKj6aRSGbqLmpYmM24bY7a1Xmfcq2vkE3hv8CSPYfc1yCu0BPu/XEJ1L4Fm61MsU6ULLNSG8UGsffNoFUBjESA==", "requires": { "@aws-sdk/core": "^3.974.20", - "@aws-sdk/nested-clients": "^3.997.19", + "@aws-sdk/nested-clients": "^3.997.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", @@ -31984,14 +32023,14 @@ } }, "@aws-sdk/nested-clients": { - "version": "3.997.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.19.tgz", - "integrity": "sha512-P2Otgf15GBJMKzG6j5Ddf7w+Kz6z2jvesMy874TD3jlMfDWNK7clJeUd7hgigdeVOotjoUP4emcTWVdS9sfZDw==", + "version": "3.997.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.20.tgz", + "integrity": "sha512-IYJuLpXp2DEILVQpQOy0PMpkftv0AHEOCn52o0atyOaumA0CdWQ3klPyXdViGYLbNpESsVFMVybvHUeZAuiGxA==", "requires": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.20", - "@aws-sdk/signature-v4-multi-region": "^3.996.33", + "@aws-sdk/signature-v4-multi-region": "^3.996.34", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", @@ -32001,9 +32040,9 @@ } }, "@aws-sdk/signature-v4-multi-region": { - "version": "3.996.33", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.33.tgz", - "integrity": "sha512-Hn0RThJEbyOZWV2PV9Z4YD3nitGPxybmyU17dSe9b61WOBcKnqS0WTtM3c1zyZq9WnGiyrfi/i+UBPUk7cM8Ug==", + "version": "3.996.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.34.tgz", + "integrity": "sha512-mx1L5qlumSOt/nKM3BFaHE2HVkWwz0i4Bw0pyYO42FfX/FeLlo8YI6csC0gSPprEk6fTIqI+CZN9RwUwKd5krQ==", "requires": { "@aws-sdk/types": "^3.973.12", "@smithy/signature-v4": "^5.4.6", @@ -32012,12 +32051,12 @@ } }, "@aws-sdk/token-providers": { - "version": "3.1065.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1065.0.tgz", - "integrity": "sha512-qdHQntq82gMqG6Tf8xrgmhJxacaYkxW4PEeDg/ISMVJ84EWe7iD6JyCTgbyox3uNDH6vqEJ8GUiTaXCq307zVw==", + "version": "3.1066.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1066.0.tgz", + "integrity": "sha512-UqEUJq7dqa44hneLDUcX7UJy95cg8YqEWyakRpvIPnrNS3Mq+UlQHgCDGu5pvwAPtlIW4qcYbvW6reG6++FyvA==", "requires": { "@aws-sdk/core": "^3.974.20", - "@aws-sdk/nested-clients": "^3.997.19", + "@aws-sdk/nested-clients": "^3.997.20", "@aws-sdk/types": "^3.973.12", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", diff --git a/package.json b/package.json index 8f7b6711b..f4ff7f49f 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ }, "dependencies": { "@aws-sdk/client-cloudformation": "^3.1065.0", + "@aws-sdk/client-cloudfront": "^3.1066.0", "@aws-sdk/client-ecs": "^3.1065.0", "@aws-sdk/client-s3": "^3.1065.0", "@azure/ai-agents": "^1.0.0-beta.1", diff --git a/scripts/lib/aws-provision.js b/scripts/lib/aws-provision.js index 45a62ea34..97fe8310c 100644 --- a/scripts/lib/aws-provision.js +++ b/scripts/lib/aws-provision.js @@ -26,6 +26,10 @@ const { DeleteObjectsCommand, } = require("@aws-sdk/client-s3"); const { ECSClient, RunTaskCommand } = require("@aws-sdk/client-ecs"); +const { + CloudFrontClient, + CreateInvalidationCommand, +} = require("@aws-sdk/client-cloudfront"); let _clients = null; @@ -36,12 +40,13 @@ function getClients() { cfn: new CloudFormationClient({ region }), s3: new S3Client({ region }), ecs: new ECSClient({ region }), + cloudfront: new CloudFrontClient({ region }), }; } return _clients; } -// Test seam: inject mock clients ({ cfn, s3, ecs }), or null to reset. +// Test seam: inject mock clients ({ cfn, s3, ecs, cloudfront }), or null to reset. function setClients(clients) { _clients = clients; } @@ -234,6 +239,22 @@ async function uploadFile({ bucket, key, filePath }) { ); } +// Invalidates CloudFront paths so an updated dashboard is served +// immediately (the distribution caches aggressively; hashed bundle +// names dodge it but index.html, config.json, and assets do not). +async function createInvalidation({ distributionId, paths = ["/*"] }) { + const { cloudfront } = getClients(); + await cloudfront.send( + new CreateInvalidationCommand({ + DistributionId: distributionId, + InvalidationBatch: { + CallerReference: `mmgis-publish-${Date.now()}`, + Paths: { Quantity: paths.length, Items: paths }, + }, + }) + ); +} + // Same-key copies every object under `prefix` from sourceBucket into // destBucket. Returns the number of objects copied. async function copyPrefix({ sourceBucket, destBucket, prefix }) { @@ -403,6 +424,7 @@ module.exports = { contentTypeForFile, uploadDirectory, uploadFile, + createInvalidation, copyPrefix, copyObjectIfExists, emptyBucket, diff --git a/scripts/publish-static.js b/scripts/publish-static.js index 336d1d372..aa83b67a4 100644 --- a/scripts/publish-static.js +++ b/scripts/publish-static.js @@ -274,6 +274,19 @@ async function main() { `Uploaded ${uploadedBuild} build and ${uploadedPublic} public file(s) to ${bucket}.` ); + // 5.5 Bust the CDN so the refreshed bundle/config/assets serve + // immediately — the distribution caches aggressively, and only the + // hashed bundle filenames are naturally cache-safe. A brand-new + // distribution has nothing cached, so doing this unconditionally + // keeps publish and update on one path. + if (outputs.DistributionId) { + await provision.createInvalidation({ + distributionId: outputs.DistributionId, + paths: ["/*"], + }); + log("Created CloudFront invalidation (/*)."); + } + // 6. Terminal row update const cloudfrontUrl = outputs.DistributionDomainName != null From fc730d649b928417583fee294fca511d5c566327 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 10 Jun 2026 15:55:21 -0500 Subject: [PATCH 36/63] Grant the publish role cloudfront:CreateInvalidation The publish task now invalidates the dashboard distribution after uploads so updates serve immediately. --- infrastructure/iam/publish-task-role.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/infrastructure/iam/publish-task-role.json b/infrastructure/iam/publish-task-role.json index c6d84d36b..703093baf 100644 --- a/infrastructure/iam/publish-task-role.json +++ b/infrastructure/iam/publish-task-role.json @@ -71,7 +71,8 @@ "cloudfront:DeleteDistribution", "cloudfront:TagResource", "cloudfront:UntagResource", - "cloudfront:ListTagsForResource" + "cloudfront:ListTagsForResource", + "cloudfront:CreateInvalidation" ], "Resource": "arn:aws:cloudfront:::distribution/*" }, From e9bdff394fc7b8c357a329a073e5f23c39cb2501 Mon Sep 17 00:00:00 2001 From: Carson Davis Date: Wed, 10 Jun 2026 16:28:52 -0500 Subject: [PATCH 37/63] Gate Configure components by declared capability, not hardcoded action Replaces the engine-level special case for tile-populate-from-x with a generic rule: metaconfig components may declare requiresCapability, and Maker renders them only when the deployment supports it. The mode -> capability mapping lives in one place (core/capabilities.js); the populate-from-COG button declares localSidecars in its own metaconfig entry. Same behavior: button absent in lean, unchanged in full. --- configure/src/core/Maker.js | 12 ++++------ configure/src/core/capabilities.js | 24 +++++++++++++++++++ .../src/metaconfigs/layer-tile-config.json | 1 + 3 files changed, 30 insertions(+), 7 deletions(-) create mode 100644 configure/src/core/capabilities.js diff --git a/configure/src/core/Maker.js b/configure/src/core/Maker.js index b21644d85..93a2f166b 100644 --- a/configure/src/core/Maker.js +++ b/configure/src/core/Maker.js @@ -44,6 +44,7 @@ import { isUrlAbsolute, } from "./utils"; import { isFieldRequired } from "./validators"; +import { isCapabilityEnabled } from "./capabilities"; import Map from "../components/Map/Map"; import VideoPreview from "../components/VideoPreview/VideoPreview"; @@ -385,6 +386,10 @@ const getComponent = ( dispatch, fieldDefaults ) => { + // Metaconfigs may declare a capability the deployment must support + if (com.requiresCapability && !isCapabilityEnabled(com.requiresCapability)) + return null; + const directConf = layer == null ? (tool == null ? (component == null ? configuration : component) : tool) : layer; @@ -559,13 +564,6 @@ const getComponent = (
); case "button": - // The populate-from-COG/XML button calls the local /titiler proxy and - // reads tilemapresource.xml from Missions/ — both absent in lean. - if ( - com.action === "tile-populate-from-x" && - window.mmgisglobal.DEPLOYMENT_MODE === "lean" - ) - return null; inner = (
- {window.mmgisglobal.DEPLOYMENT_MODE !== "lean" ? ( + {!isLeanMode() ? ( ) : null} - {window.mmgisglobal.DEPLOYMENT_MODE === "lean" ? ( + {isLeanMode() ? ( - {window.mmgisglobal.DEPLOYMENT_MODE === "lean" ? ( + {isLeanMode() ? (