diff --git a/.claude/skills/mmgis-deployment/SKILL.md b/.claude/skills/mmgis-deployment/SKILL.md new file mode 100644 index 000000000..b6ec1a504 --- /dev/null +++ b/.claude/skills/mmgis-deployment/SKILL.md @@ -0,0 +1,55 @@ +--- +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. 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 + +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 ` | +| 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`, 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`, or `seed-golden.sh --force` — both overwrite 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..203a9e280 --- /dev/null +++ b/.claude/skills/mmgis-deployment/references/deployment-model.md @@ -0,0 +1,59 @@ +# 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. + +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 + +- **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..42c5b46d2 --- /dev/null +++ b/.claude/skills/mmgis-deployment/references/troubleshooting.md @@ -0,0 +1,51 @@ +# 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 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 | + +## 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..48c0f4caa --- /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 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 + +# --- 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/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/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/.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/.github/workflows/deploy-lean.yml b/.github/workflows/deploy-lean.yml new file mode 100644 index 000000000..cc2032aec --- /dev/null +++ b/.github/workflows/deploy-lean.yml @@ -0,0 +1,183 @@ +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 -> 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. 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 +# 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. + # 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 \ + --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" + + # 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: | + 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/.gitignore b/.gitignore index a2701073d..a66eec216 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/* @@ -32,6 +35,9 @@ 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 @@ -51,7 +57,8 @@ sessions .mcp.json .serena -.claude +.claude/* +!.claude/skills/ .playwright-mcp # Playwright Test Artifacts diff --git a/API/Backend/Config/setup.js b/API/Backend/Config/setup.js index a7c1f54da..06cc80915 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, isLean } = require("../Utils/deploymentMode"); let setup = { //Once the app initializes @@ -35,10 +36,13 @@ 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, + 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/Deployments/models/deployment.js b/API/Backend/Deployments/models/deployment.js new file mode 100644 index 000000000..46f0df8a4 --- /dev/null +++ b/API/Backend/Deployments/models/deployment.js @@ -0,0 +1,78 @@ +/*********************************************************** + * Loading all required dependencies, libraries and packages + **********************************************************/ +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. +// Note: this feature is named "Deployments" to avoid colliding with the +// modern-ui "Dashboard*" (panel layout) symbols. +var Deployments = sequelize.define( + "deployments", + { + name: { + type: Sequelize.STRING, + allowNull: false, + }, + mission: { + type: Sequelize.STRING, + allowNull: false, + }, + created_by: { + type: Sequelize.STRING, + allowNull: true, + }, + // One of STATUS (provisioning | published | updating | deleting | + // deleted | failed) + status: { + type: Sequelize.STRING, + allowNull: false, + defaultValue: STATUS.PROVISIONING, + }, + stack_arn: { + type: Sequelize.STRING, + allowNull: true, + }, + stack_name: { + type: Sequelize.STRING, + allowNull: true, + }, + // Cached at publish time for convenience; the live status is always + // re-read from DescribeStacks. + cloudfront_url: { + type: Sequelize.STRING, + allowNull: true, + }, + settings: { + type: Sequelize.JSON, + allowNull: true, + defaultValue: {}, + }, + last_error: { + type: Sequelize.TEXT, + allowNull: true, + }, + }, + { + timestamps: true, + } +); + +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 new file mode 100644 index 000000000..32ddacddf --- /dev/null +++ b/API/Backend/Deployments/routes/deployments.js @@ -0,0 +1,262 @@ +/*********************************************************** + * JavaScript syntax format: ES5/ES6 - ECMAScript 2015 + * Loading all required dependencies, libraries and packages + **********************************************************/ +const express = require("express"); +const router = express.Router(); + +const logger = require("../../../logger"); +const Deployments = require("../models/deployment"); +const STATUS = Deployments.STATUS; + +const triggerWebhooks = require("../../Webhooks/processes/triggerwebhooks"); + +const provision = require("../../../../scripts/lib/aws-provision"); +const { stackNameForDeployment } = require("../../../../scripts/lib/cfn-template"); + +// Webhook payload for a deployment row. +function webhookPayload(deployment) { + return { + id: deployment.id, + name: deployment.name, + mission: deployment.mission, + status: deployment.status, + cloudfront_url: deployment.cloudfront_url || null, + }; +} + +// Merges a deployment row with its live CloudFormation stack status +// (DescribeStacks at read time — no reconcile job). A row in `deleting` +// whose stack no longer exists flips to `deleted`. +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, + }); + if (stack != null) { + row.stack_status = stack.StackStatus; + if (stack.StackStatusReason != null) + row.stack_status_reason = stack.StackStatusReason; + } 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 + // the whole listing (e.g. missing AWS credentials). + row.stack_status_error = err.message; + } + return row; +} + +// POST /api/deployments/publish { mission, name } +// Inserts a `provisioning` row, starts the ECS publish task, and returns +// immediately. The task (scripts/publish-static.js) does the long-running +// bake/build/provision/upload work and writes the terminal status. +router.post("/publish", async function (req, res) { + try { + const mission = req.body.mission; + const name = req.body.name; + if (mission == null || mission === "") { + res.send({ status: "failure", message: "'mission' is required." }); + return; + } + + const deployment = await Deployments.create({ + name: name != null && name !== "" ? name : mission, + mission: mission, + created_by: req.user || null, + status: STATUS.PROVISIONING, + }); + await deployment.update({ + stack_name: stackNameForDeployment(deployment.id), + }); + + // Fire-and-forget: a RunTask failure marks the row failed with the + // error, never crashes the request (which has already returned). + provision + .runPublishTask({ deploymentId: deployment.id, action: "publish" }) + .catch((err) => { + logger( + "error", + `Failed to start publish task for deployment ${deployment.id}.`, + "deployments", + null, + err + ); + Deployments.update( + { + status: STATUS.FAILED, + last_error: `Failed to start publish task: ${err.message}`, + }, + { where: { id: deployment.id } } + ).catch(() => {}); + }); + + triggerWebhooks("deploymentPublish", webhookPayload(deployment)); + + res.send({ + status: "success", + deployment_id: deployment.id, + body: { deployment: deployment.toJSON() }, + }); + } catch (err) { + logger("error", "Failed to publish deployment.", req.originalUrl, req, err); + res.send({ status: "failure", message: "Failed to publish deployment." }); + } +}); + +// POST /api/deployments/:id/update +// Re-bakes against the mission's current configuration and replaces the +// bundle in the existing dashboard bucket — same stack, same URL. +router.post("/:id/update", async function (req, res) { + try { + const deployment = await Deployments.findByPk(req.params.id); + if (deployment == null) { + res.send({ status: "failure", message: "Deployment not found." }); + return; + } + + await deployment.update({ status: STATUS.UPDATING, last_error: null }); + + provision + .runPublishTask({ deploymentId: deployment.id, action: "update" }) + .catch((err) => { + logger( + "error", + `Failed to start update task for deployment ${deployment.id}.`, + "deployments", + null, + err + ); + Deployments.update( + { + status: STATUS.FAILED, + last_error: `Failed to start update task: ${err.message}`, + }, + { where: { id: deployment.id } } + ).catch(() => {}); + }); + + triggerWebhooks("deploymentUpdate", webhookPayload(deployment)); + + res.send({ + status: "success", + deployment_id: deployment.id, + body: { deployment: deployment.toJSON() }, + }); + } catch (err) { + logger("error", "Failed to update deployment.", req.originalUrl, req, err); + res.send({ status: "failure", message: "Failed to update deployment." }); + } +}); + +// Empties the deployment's bucket (if any) and issues DeleteStack. +// Best-effort and not awaited by the route: failures are logged and recorded +// in last_error so the Delete affordance can retry. +// +// The publish task writes settings.bucket only at the very end, so a delete +// fired after CREATE_COMPLETE but before that write would otherwise skip +// emptyBucket and leave a non-empty bucket that blocks DeleteStack +// (CloudFormation can't remove a non-empty bucket). Fall back to the stack's +// BucketName output, which is the source of truth and is populated exactly in +// that window. +async function teardownDeployment(deployment) { + try { + let bucket = + deployment.settings != null ? deployment.settings.bucket : null; + if (bucket == null && deployment.stack_name != null) { + const stack = await provision.describeStack({ + stackName: deployment.stack_name, + }); + if (stack != null) + bucket = provision.getStackOutputs(stack).BucketName || null; + } + if (bucket != null) await provision.emptyBucket({ bucket }); + if (deployment.stack_name != null) + await provision.deleteStack({ stackName: deployment.stack_name }); + } catch (err) { + logger( + "error", + `Failed to tear down deployment ${deployment.id}.`, + "deployments", + null, + err + ); + Deployments.update( + { last_error: `Teardown failed: ${err.message}` }, + { where: { id: deployment.id } } + ).catch(() => {}); + } +} + +// DELETE /api/deployments/:id +// Marks the row `deleting`, then inline (no spawned task) empties the +// bucket and issues DeleteStack, returning immediately — CloudFormation +// handles the multi-step teardown async and the row flips to `deleted` on +// the next read once DescribeStacks 404s. Idempotent: re-deleting retries +// a stuck teardown. +router.delete("/:id", async function (req, res) { + try { + const deployment = await Deployments.findByPk(req.params.id); + if (deployment == null) { + res.send({ status: "failure", message: "Deployment not found." }); + return; + } + + await deployment.update({ status: STATUS.DELETING, last_error: null }); + + // Teardown continues after the response; failures land in last_error + // and the Delete affordance retries. + teardownDeployment(deployment); + + triggerWebhooks("deploymentDelete", webhookPayload(deployment)); + + res.send({ + status: "success", + deployment_id: deployment.id, + body: { deployment: deployment.toJSON() }, + }); + } catch (err) { + logger("error", "Failed to delete deployment.", req.originalUrl, req, err); + res.send({ status: "failure", message: "Failed to delete deployment." }); + } +}); + +// GET /api/deployments +// All rows, each merged with its live stack status. +router.get("/", async function (req, res) { + try { + const deployments = await Deployments.findAll({ + order: [["id", "DESC"]], + }); + const merged = await Promise.all(deployments.map(withLiveStatus)); + res.send({ status: "success", body: { deployments: merged } }); + } catch (err) { + logger("error", "Failed to list deployments.", req.originalUrl, req, err); + res.send({ status: "failure", message: "Failed to list deployments." }); + } +}); + +// GET /api/deployments/:id +router.get("/:id", async function (req, res) { + try { + const deployment = await Deployments.findByPk(req.params.id); + if (deployment == null) { + res.send({ status: "failure", message: "Deployment not found." }); + return; + } + const merged = await withLiveStatus(deployment); + res.send({ status: "success", body: { deployment: merged } }); + } catch (err) { + logger("error", "Failed to get deployment.", req.originalUrl, req, err); + res.send({ status: "failure", message: "Failed to get deployment." }); + } +}); + +module.exports = { router, teardownDeployment }; diff --git a/API/Backend/Deployments/setup.js b/API/Backend/Deployments/setup.js new file mode 100644 index 000000000..b20765083 --- /dev/null +++ b/API/Backend/Deployments/setup.js @@ -0,0 +1,28 @@ +const { isLean } = require("../Utils/deploymentMode"); + +// Requiring the model registers it with Sequelize so the global +// sequelize.sync() on boot creates the `deployments` table in BOTH modes — +// a later mode flip needs no migration. In full mode the table stays +// passive: the routes below are never mounted, so nothing writes to it. +require("./models/deployment"); + +let setup = { + //Once the app initializes + onceInit: (s) => { + if (isLean()) { + const routeDeployments = require("./routes/deployments"); + s.app.use( + s.ROOT_PATH + "/api/deployments", + s.ensureAdmin(), + s.checkHeadersCodeInjection, + routeDeployments.router + ); + } + }, + //Once the server starts + onceStarted: (s) => {}, + //Once all tables sync + onceSynced: (s) => {}, +}; + +module.exports = setup; diff --git a/API/Backend/Draw/setup.js b/API/Backend/Draw/setup.js index 8f38d04fd..d38c1a979 100644 --- a/API/Backend/Draw/setup.js +++ b/API/Backend/Draw/setup.js @@ -4,36 +4,39 @@ const routerDraw = require("./routes/draw").router; const routerAggregations = require("./routes/aggregations"); const ufiles = require("./models/userfiles"); const file_histories = require("./models/filehistories"); +const { isFull } = require("../Utils/deploymentMode"); let setup = { //Once the app initializes onceInit: (s) => { - s.app.use( - s.ROOT_PATH + "/api/files", - s.ensureUser(), - s.checkHeadersCodeInjection, - s.setContentType, - s.stopGuests, - routerFiles - ); + if (isFull()) { + s.app.use( + s.ROOT_PATH + "/api/files", + s.ensureUser(), + s.checkHeadersCodeInjection, + s.setContentType, + s.stopGuests, + routerFiles + ); - s.app.use( - s.ROOT_PATH + "/api/draw", - s.ensureUser(), - s.checkHeadersCodeInjection, - s.setContentType, - s.stopGuests, - routerDraw - ); + s.app.use( + s.ROOT_PATH + "/api/draw", + s.ensureUser(), + s.checkHeadersCodeInjection, + s.setContentType, + s.stopGuests, + routerDraw + ); - s.app.use( - s.ROOT_PATH + "/api/draw", - s.ensureUser(), - s.checkHeadersCodeInjection, - s.setContentType, - s.stopGuests, - routerAggregations - ); + s.app.use( + s.ROOT_PATH + "/api/draw", + s.ensureUser(), + s.checkHeadersCodeInjection, + s.setContentType, + s.stopGuests, + routerAggregations + ); + } }, //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/API/Backend/Shortener/setup.js b/API/Backend/Shortener/setup.js index 4bb5dc0e8..37a631810 100644 --- a/API/Backend/Shortener/setup.js +++ b/API/Backend/Shortener/setup.js @@ -1,15 +1,18 @@ const router = require("./routes/shortener"); +const { isFull } = require("../Utils/deploymentMode"); let setup = { //Once the app initializes onceInit: (s) => { - s.app.use( - s.ROOT_PATH + "/api/shortener", - s.ensureUser(), - s.checkHeadersCodeInjection, - s.setContentType, - router - ); + if (isFull()) { + s.app.use( + s.ROOT_PATH + "/api/shortener", + s.ensureUser(), + s.checkHeadersCodeInjection, + s.setContentType, + router + ); + } }, //Once the server starts onceStarted: (s) => {}, 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/API/Backend/Users/routes/users.js b/API/Backend/Users/routes/users.js index 842fb3d7a..60be2a0a4 100644 --- a/API/Backend/Users/routes/users.js +++ b/API/Backend/Users/routes/users.js @@ -39,6 +39,12 @@ router.post("/has", function (req, res, next) { }); router.post("/first_signup", function (req, res, next) { + if (process.env.DISABLE_FIRST_SIGNUP === "true") { + res + .status(404) + .send({ status: "failure", message: "First-time signup is disabled." }); + return; + } User.count() .then((count) => { if (count === 0) { 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/API/Backend/Utils/routes/utils.js b/API/Backend/Utils/routes/utils.js index a691a061a..0d7a240d0 100644 --- a/API/Backend/Utils/routes/utils.js +++ b/API/Backend/Utils/routes/utils.js @@ -12,6 +12,7 @@ const execFile = require("child_process").execFile; const Sequelize = require("sequelize"); const { sequelizeSTAC } = require("../../../connection"); const logger = require("../../../logger"); +const { isFull } = require("../deploymentMode"); const rootDir = `${__dirname}/../../../..`; @@ -226,11 +227,6 @@ function queryTilesetTimesStac(req, res) { }); } -router.get("/queryTilesetTimes", function (req, res) { - if (req.query.stacCollection != null) queryTilesetTimesStac(req, res); - else queryTilesetTimesDir(req, res); -}); - // API // TODO: move to API/Backend //TEST @@ -240,155 +236,170 @@ router.get("/healthcheck", function (req, res) { // TODO: Remove or move to Setup structure. Some are definitely still used. -//utils getprofile -router.post("/getprofile", function (req, res) { - const path = encodeURIComponent(req.body.path); - const lat1 = encodeURIComponent(req.body.lat1); - const lon1 = encodeURIComponent(req.body.lon1); - const lat2 = encodeURIComponent(req.body.lat2); - const lon2 = encodeURIComponent(req.body.lon2); - const steps = encodeURIComponent(req.body.steps); - const axes = encodeURIComponent(req.body.axes); +// Every route below is gated out of lean: each one reads the on-disk +// Missions/ tree, local SPICE data, or shells out to Python — none of which +// exist in a lean container. healthcheck (above) is the only utils route +// lean serves. +if (isFull()) { + // Reads the on-disk Missions/<...>/_time_/ tree + router.get("/queryTilesetTimes", function (req, res) { + if (req.query.stacCollection != null) queryTilesetTimesStac(req, res); + else queryTilesetTimesDir(req, res); + }); - execFile( - "python", - [ - "private/api/2ptsToProfile.py", - path, - lat1, - lon1, - lat2, - lon2, - steps, - axes, - 1, - ], - function (error, stdout, stderr) { - if (error) { - logger("warn", error); - res.status(400).send(); - } else { - res.send(stdout.replace(/None/g, null)); + //utils getprofile + router.post("/getprofile", function (req, res) { + const path = encodeURIComponent(req.body.path); + const lat1 = encodeURIComponent(req.body.lat1); + const lon1 = encodeURIComponent(req.body.lon1); + const lat2 = encodeURIComponent(req.body.lat2); + const lon2 = encodeURIComponent(req.body.lon2); + const steps = encodeURIComponent(req.body.steps); + const axes = encodeURIComponent(req.body.axes); + + execFile( + "python", + [ + "private/api/2ptsToProfile.py", + path, + lat1, + lon1, + lat2, + lon2, + steps, + axes, + 1, + ], + function (error, stdout, stderr) { + if (error) { + logger("warn", error); + res.status(400).send(); + } else { + res.send(stdout.replace(/None/g, null)); + } } - } - ); -}); + ); + }); -//utils getbands -router.post("/getbands", function (req, res) { - const path = encodeURIComponent(req.body.path); - const x = encodeURIComponent(req.body.x); - const y = encodeURIComponent(req.body.y); - const xyorll = encodeURIComponent(req.body.xyorll); - const bands = encodeURIComponent(req.body.bands); + //utils getbands + router.post("/getbands", function (req, res) { + const path = encodeURIComponent(req.body.path); + const x = encodeURIComponent(req.body.x); + const y = encodeURIComponent(req.body.y); + const xyorll = encodeURIComponent(req.body.xyorll); + const bands = encodeURIComponent(req.body.bands); - execFile( - "python", - ["private/api/BandsToProfile.py", path, x, y, xyorll, bands], - function (error, stdout, stderr) { - if (error) { - logger("warn", error); - res.status(400).send(); - } else { - res.send(stdout); + execFile( + "python", + ["private/api/BandsToProfile.py", path, x, y, xyorll, bands], + function (error, stdout, stderr) { + if (error) { + logger("warn", error); + res.status(400).send(); + } else { + res.send(stdout); + } } - } - ); -}); + ); + }); -//utils getminmax -router.post("/getminmax", function (req, res) { - const path = encodeURIComponent(req.body.path); - const bands = encodeURIComponent(req.body.bands); + //utils getminmax + router.post("/getminmax", function (req, res) { + const path = encodeURIComponent(req.body.path); + const bands = encodeURIComponent(req.body.bands); - execFile( - "python", - ["private/api/gdalinfoMinMax.py", path, bands], - function (error, stdout, stderr) { - if (error) { - logger("warn", error); - res.status(400).send(); - } else { - res.send(stdout); + execFile( + "python", + ["private/api/gdalinfoMinMax.py", path, bands], + function (error, stdout, stderr) { + if (error) { + logger("warn", error); + res.status(400).send(); + } else { + res.send(stdout); + } } - } - ); -}); + ); + }); -//utils ll2aerll -router.post("/ll2aerll", function (req, res) { - const lng = encodeURIComponent(req.body.lng); - const lat = encodeURIComponent(req.body.lat); - const height = encodeURIComponent(req.body.height); - const target = encodeURIComponent(req.body.target); - const time = encodeURIComponent(req.body.time) - .replace(/%20/g, " ") - .replace(/%3A/g, ":"); - const obsRefFrame = encodeURIComponent(req.body.obsRefFrame) || "IAU_MARS"; - const obsBody = encodeURIComponent(req.body.obsBody) || "MARS"; - const includeSunEarth = - encodeURIComponent(req.body.includeSunEarth) || "False"; + //utils ll2aerll + router.post("/ll2aerll", function (req, res) { + const lng = encodeURIComponent(req.body.lng); + const lat = encodeURIComponent(req.body.lat); + const height = encodeURIComponent(req.body.height); + const target = encodeURIComponent(req.body.target); + const time = encodeURIComponent(req.body.time) + .replace(/%20/g, " ") + .replace(/%3A/g, ":"); + const obsRefFrame = encodeURIComponent(req.body.obsRefFrame) || "IAU_MARS"; + const obsBody = encodeURIComponent(req.body.obsBody) || "MARS"; + const includeSunEarth = + encodeURIComponent(req.body.includeSunEarth) || "False"; - const isCustom = encodeURIComponent(req.body.isCustom) || "False"; - const customAz = encodeURIComponent(req.body.customAz); - const customEl = encodeURIComponent(req.body.customEl); - const customRange = encodeURIComponent(req.body.customRange); + const isCustom = encodeURIComponent(req.body.isCustom) || "False"; + const customAz = encodeURIComponent(req.body.customAz); + const customEl = encodeURIComponent(req.body.customEl); + const customRange = encodeURIComponent(req.body.customRange); - execFile( - "python", - [ - "private/api/ll2aerll.py", - lng, - lat, - height, - target, - time, - obsRefFrame, - obsBody, - includeSunEarth, - isCustom, - customAz, - customEl, - customRange, - ], - function (error, stdout, stderr) { - if (error) logger("error", "ll2aerll failure:", "server", null, error); - res.send(stdout); - } - ); -}); + execFile( + "python", + [ + "private/api/ll2aerll.py", + lng, + lat, + height, + target, + time, + obsRefFrame, + obsBody, + includeSunEarth, + isCustom, + customAz, + customEl, + customRange, + ], + function (error, stdout, stderr) { + if (error) logger("error", "ll2aerll failure:", "server", null, error); + res.send(stdout); + } + ); + }); -//utils chronos (spice time converter) -router.post("/chronice", function (req, res) { - const body = encodeURIComponent(req.body.body); - const target = encodeURIComponent(req.body.target); - const fromFormat = encodeURIComponent(req.body.from); - const time = encodeURIComponent(req.body.time) - .replace(/%20/g, " ") - .replace(/%3A/g, ":"); + //utils chronos (spice time converter) + router.post("/chronice", function (req, res) { + const body = encodeURIComponent(req.body.body); + const target = encodeURIComponent(req.body.target); + const fromFormat = encodeURIComponent(req.body.from); + const time = encodeURIComponent(req.body.time) + .replace(/%20/g, " ") + .replace(/%3A/g, ":"); - execFile( - "python", - ["private/api/chronice.py", body, target, fromFormat, time], - function (error, stdout, stderr) { - if (error) logger("error", "chronice failure:", "server", null, error); - res.send(stdout); - } - ); -}); + execFile( + "python", + ["private/api/chronice.py", body, target, fromFormat, time], + function (error, stdout, stderr) { + if (error) logger("error", "chronice failure:", "server", null, error); + res.send(stdout); + } + ); + }); -//utils chronos (spice time converter) -router.get("/proj42wkt", function (req, res) { - const proj4 = encodeURIComponent(req.query.proj4); + // proj4 -> WKT via a Python shellout (GDAL). DrawTool/LayersTool call + // this via calls.api('proj42wkt') only as a fallback, when the in-browser + // projStringToWkt can't handle the projection (reachable in full only; + // lean/static have no GDAL and fail that case gracefully). + router.get("/proj42wkt", function (req, res) { + const proj4 = encodeURIComponent(req.query.proj4); - execFile( - "python", - ["private/api/proj42wkt.py", proj4], - function (error, stdout, stderr) { - if (error) logger("error", "proj42wkt failure:", "server", null, error); - res.send(stdout); - } - ); -}); + execFile( + "python", + ["private/api/proj42wkt.py", proj4], + function (error, stdout, stderr) { + if (error) logger("error", "proj42wkt failure:", "server", null, error); + res.send(stdout); + } + ); + }); +} // if (isFull()) — full-only utils routes module.exports = router; diff --git a/API/Backend/Webhooks/processes/triggerwebhooks.js b/API/Backend/Webhooks/processes/triggerwebhooks.js index 5cfc6fe75..3e3b98041 100644 --- a/API/Backend/Webhooks/processes/triggerwebhooks.js +++ b/API/Backend/Webhooks/processes/triggerwebhooks.js @@ -60,6 +60,21 @@ function triggerWebhooks(action, payload) { drawFileDelete(wh, payload); } break; + case "DeploymentPublish": + if (action === "deploymentPublish") { + deploymentEvent(wh, payload); + } + break; + case "DeploymentUpdate": + if (action === "deploymentUpdate") { + deploymentEvent(wh, payload); + } + break; + case "DeploymentDelete": + if (action === "deploymentDelete") { + deploymentEvent(wh, payload); + } + break; default: break; } @@ -136,6 +151,41 @@ function drawFileDelete(webhook, payload) { getfile(data, response); } +// Deployments (lean publish flow): pushes the deployment payload to the +// configured remote. Injectable variables: {deployment_id}, {name}, +// {mission}, {status}, {cloudfront_url}. +function deploymentEvent(webhook, payload) { + try { + const webhookHeader = JSON.parse(webhook.header); + const webhookBody = JSON.parse(webhook.body); + + const injectableVariables = { + deployment_id: payload.id, + name: payload.name, + mission: payload.mission, + status: payload.status, + cloudfront_url: payload.cloudfront_url, + }; + + // Build the body + buildBody(webhookBody, injectableVariables); + + // Build the url + const url = buildUrl(webhook.url, injectableVariables); + + // Push to the remote webhook + pushToRemote(url, webhook.type, webhookHeader, webhookBody); + } catch (err) { + logger( + "error", + "Failed to trigger deployment webhook", + "TriggerWebhooks", + null, + err + ); + } +} + function getInjectableVariables(type, file, res) { const injectableVariables = {}; switch (type) { diff --git a/API/updateTools.js b/API/updateTools.js index 740071631..c50aa980e 100644 --- a/API/updateTools.js +++ b/API/updateTools.js @@ -2,6 +2,7 @@ const fs = require("fs"); const path = require("path"); const logger = require("./logger"); +const { isLean } = require("./Backend/Utils/deploymentMode"); function updateTools() { let tools = {}; @@ -38,6 +39,9 @@ function updateTools() { return; } + // Lean deployments exclude the Draw tool entirely + if (isLean() && items[i].name === "Draw") continue; + if (isDir && items[i].name[0] != "_" && items[i].name[0] != ".") { try { const contents = fs.readFileSync( @@ -211,6 +215,39 @@ function updateTools() { ); } } + + bakeStaticConfig(); +} + +// Writes src/pre/staticConfig.js (gitignored; the STATIC_MISSION_CONFIG +// webpack alias target) so static builds can answer baked calls. With no +// config given, only ensures the file exists (an empty bake) — the publish +// flow (PR 8) supplies the real mission config and overwrites it. +function bakeStaticConfig(config) { + const staticConfigPath = "./src/pre/staticConfig.js"; + + if (config == null && fs.existsSync(staticConfigPath)) return; + + const contents = [ + "// Generated by API/updateTools.js (bakeStaticConfig). Do not edit.", + "// Static (backend-less) builds answer baked calls from this object,", + "// keyed by call name (see src/pre/staticHandlers.js).", + `export default ${JSON.stringify(config || {})}`, + "", + ].join("\n"); + + try { + fs.writeFileSync(staticConfigPath, contents); + logger("success", "Successfully baked static config.", "StaticConfig"); + } catch (err) { + logger( + "error", + "Failed to write src/pre/staticConfig.js", + "StaticConfig", + null, + err + ); + } } function updateComponents() { @@ -346,4 +383,4 @@ function updateComponents() { } } -module.exports = { updateTools, updateComponents }; +module.exports = { updateTools, updateComponents, bakeStaticConfig }; diff --git a/API/websocket.js b/API/websocket.js index 55a3cb644..e09816a89 100644 --- a/API/websocket.js +++ b/API/websocket.js @@ -28,6 +28,22 @@ const websocket = { const wss = new WebSocket.Server({ noServer: true }); websocket.wss = wss; + // Heartbeat: proxies and load balancers silently drop idle connections, + // so periodically ping every client and terminate the ones that stopped + // answering. Browsers answer pings automatically. + const pingIntervalMs = + parseInt(process.env.WEBSOCKET_PING_INTERVAL_MS, 10) || 30000; + const heartbeatInterval = setInterval(() => { + wss.clients.forEach((client) => { + if (client.isAlive === false) { + client.terminate(); + return; + } + client.isAlive = false; + client.ping(); + }); + }, pingIntervalMs); + // Broadcast to all clients wss.broadcast = function broadcast(data, isBinary) { wss.clients.forEach((client) => { @@ -38,6 +54,10 @@ const websocket = { }; wss.on("connection", (ws) => { + ws.isAlive = true; + ws.on("pong", () => { + ws.isAlive = true; + }); ws.on("message", (message) => { wss.broadcast(message); }); @@ -63,6 +83,7 @@ const websocket = { wss.on("close", () => { logger("info", "Websocket disconnected...", "websocket", null, ""); + clearInterval(heartbeatInterval); websocket.wss = null; }); }, diff --git a/Dockerfile b/Dockerfile index 598a440b5..ae6d85a90 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,7 +49,7 @@ WORKDIR /usr/src/app # Copy only python env file first (cached unless this file changes) COPY python-environment.yml ./ RUN if [ "$WITH_STAC" = "true" ]; then \ - . /root/.bashrc && micromamba env create -y --name mmgis --file=python-environment.yml; \ + MAMBA_ROOT_PREFIX=/opt/micromamba /opt/micromamba/bin/micromamba env create -y --name mmgis --file=python-environment.yml; \ else \ echo "Skipping Python environment creation (WITH_STAC=false)"; \ fi @@ -60,10 +60,10 @@ RUN if [ "$WITH_STAC" = "true" ]; then \ # Copy only package files first (cached unless these change) COPY package*.json ./ -# --force is required: react-chartjs-2@5 wants chart.js@^4.1.1, but -# chartjs-plugin-zoom@1.2.1 pins ^3.2.0. Don't use --legacy-peer-deps; in this -# tree it silently drops @deck.gl/extensions and @deck.gl/mesh-layers. -RUN npm install --force +# chart.js@4, chartjs-plugin-zoom@2 and react-chartjs-2@5 all agree on +# chart.js v4, so peer deps resolve cleanly without --force. Don't use +# --legacy-peer-deps; it silently drops @deck.gl/extensions and mesh-layers. +RUN npm install ############################# # MMGIS Configure Dependencies 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/configuration/env.js b/configuration/env.js index 13795c517..a6691bed9 100644 --- a/configuration/env.js +++ b/configuration/env.js @@ -112,6 +112,12 @@ 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 + // Frontend personality: 'node' talks to a live backend; 'static' + // answers calls from baked config (substitutes %SERVER% in index.html) + SERVER: process.env.SERVER || "node", } ); // 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/configure/package.json b/configure/package.json index e640228ed..b920c8496 100644 --- a/configure/package.json +++ b/configure/package.json @@ -1,6 +1,6 @@ { "name": "configure", - "version": "4.2.9-20260211", + "version": "4.2.11-20260611", "homepage": "./configure/build", "private": true, "dependencies": { 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/DeploymentsWatcher/DeploymentsWatcher.js b/configure/src/components/DeploymentsWatcher/DeploymentsWatcher.js new file mode 100644 index 000000000..36e75b5a1 --- /dev/null +++ b/configure/src/components/DeploymentsWatcher/DeploymentsWatcher.js @@ -0,0 +1,156 @@ +import { useEffect, useRef } from "react"; +import { isLeanMode } from "../../core/capabilities"; +import { STATUS, TRANSITIONAL_STATUSES } from "../../core/deploymentStatus"; +import { useSelector, useDispatch } from "react-redux"; + +import { calls } from "../../core/calls"; +import { + setDeployments, + watchDeployment, + unwatchDeployment, + setSnackBarText, +} from "../../core/ConfigureStore"; + +const POLL_INTERVAL_MS = 15000; + +// 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 = isLeanMode(); + 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; + }); + + // 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)) { + 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 === STATUS.PUBLISHED) + toasts.push({ + text: d.cloudfront_url + ? `'${d.name}' published —` + : `'${d.name}' published.`, + severity: "success", + link: d.cloudfront_url, + }); + else if (d.status === STATUS.FAILED) + toasts.push({ + text: `'${d.name}' publish failed — see Deployments.`, + severity: "error", + }); + else if (d.status === STATUS.DELETED) + toasts.push({ + 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 === STATUS.DELETING) + 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 + // 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/Main/Main.js b/configure/src/components/Main/Main.js index 962dda869..f1149cec9 100644 --- a/configure/src/components/Main/Main.js +++ b/configure/src/components/Main/Main.js @@ -43,6 +43,7 @@ import APITokens from "../../pages/APITokens/APITokens"; import GeoDatasets from "../../pages/GeoDatasets/GeoDatasets"; import Datasets from "../../pages/Datasets/Datasets"; import WebHooks from "../../pages/WebHooks/WebHooks"; +import Deployments from "../../pages/Deployments/Deployments"; import APIs from "../../pages/APIs/APIs"; import STAC from "../../pages/STAC/STAC"; import GeneralOptions from "../../pages/GeneralOptions/GeneralOptions"; @@ -229,6 +230,9 @@ export default function Main() { case "webhooks": Page = ; break; + case "deployments": + Page = ; + break; case "apis": Page = ; break; diff --git a/configure/src/components/Panel/Panel.js b/configure/src/components/Panel/Panel.js index 22f4d7bdc..b34c5a20d 100644 --- a/configure/src/components/Panel/Panel.js +++ b/configure/src/components/Panel/Panel.js @@ -14,6 +14,7 @@ import { setSnackBarText, } from "../../core/ConfigureStore"; import { calls } from "../../core/calls"; +import { isLeanMode } from "../../core/capabilities"; import NewMissionModal from "./Modals/NewMissionModal/NewMissionModal"; @@ -29,6 +30,7 @@ import ApiIcon from "@mui/icons-material/Api"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import HorizontalSplitIcon from "@mui/icons-material/HorizontalSplit"; import AccountBoxIcon from "@mui/icons-material/AccountBox"; +import RocketLaunchIcon from "@mui/icons-material/RocketLaunch"; import WarningIcon from "@mui/icons-material/Warning"; const useStyles = makeStyles((theme) => ({ @@ -315,30 +317,49 @@ export default function Panel() {
- - + {!isLeanMode() ? ( + + ) : null} + {!isLeanMode() ? ( + + ) : null} + + {isLeanMode() ? ( + + ) : null} {window.mmgisglobal.WITH_STAC === "true" ? ( + {isLeanMode() ? ( + + ) : null}
diff --git a/configure/src/components/SnackBar/SnackBar.js b/configure/src/components/SnackBar/SnackBar.js index 06d4af04f..b139e9dac 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" }, @@ -43,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} > { 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 2f79624aa..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, @@ -50,6 +56,7 @@ export const ConfigureStore = createSlice({ deleteUser: false, newUser: false, resetPassword: false, + deleteDeployment: false, }, snackBarText: false, lockConfig: false, @@ -85,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; }, @@ -115,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) => { @@ -220,6 +240,9 @@ export const { setComponentConfiguration, setGeodatasets, setDatasets, + setDeployments, + watchDeployment, + unwatchDeployment, setStacCollections, setUserEntries, setPage, diff --git a/configure/src/core/Maker.js b/configure/src/core/Maker.js index 28ad18eff..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; 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/core/capabilities.js b/configure/src/core/capabilities.js new file mode 100644 index 000000000..64e130ae5 --- /dev/null +++ b/configure/src/core/capabilities.js @@ -0,0 +1,28 @@ +// Deployment-capability lookup for the Configure SPA. +// +// Metaconfig components may declare `"requiresCapability": ""`; Maker +// renders them only when the current deployment supports that capability. +// The mapping from deployment mode to capabilities lives here, in one place, +// so the form engine never needs to know about specific fields or modes. + +const currentMode = () => (window.mmgisglobal || {}).DEPLOYMENT_MODE || "full"; + +// The one mode predicate — consumers ask this instead of comparing strings. +export const isLeanMode = () => currentMode() === "lean"; + +const CAPABILITY_RULES = { + // Anything served by the local sidecar proxies (/titiler, /stac, …) or the + // on-disk Missions/ tree — both absent in lean deployments. + localSidecars: (mode) => mode !== "lean", +}; + +export const isCapabilityEnabled = (capability) => { + const rule = CAPABILITY_RULES[capability]; + if (rule == null) { + console.warn( + `Unknown capability "${capability}" — hiding the component that requires it.` + ); + return false; + } + return rule(currentMode()); +}; diff --git a/configure/src/core/deploymentStatus.js b/configure/src/core/deploymentStatus.js new file mode 100644 index 000000000..8a7c14512 --- /dev/null +++ b/configure/src/core/deploymentStatus.js @@ -0,0 +1,17 @@ +// Deployment status values, mirrored from the backend model +// (API/Backend/Deployments/models/deployment.js — a parity unit test +// keeps the two in sync). Configure code uses these instead of raw +// status string literals. +export const STATUS = Object.freeze({ + PROVISIONING: "provisioning", + PUBLISHED: "published", + UPDATING: "updating", + DELETING: "deleting", + DELETED: "deleted", + FAILED: "failed", +}); +export const TRANSITIONAL_STATUSES = Object.freeze([ + STATUS.PROVISIONING, + STATUS.UPDATING, + STATUS.DELETING, +]); diff --git a/configure/src/metaconfigs/layer-tile-config.json b/configure/src/metaconfigs/layer-tile-config.json index 75741388b..34ce9d947 100644 --- a/configure/src/metaconfigs/layer-tile-config.json +++ b/configure/src/metaconfigs/layer-tile-config.json @@ -171,6 +171,7 @@ "description": "If the above URL is relative to the Missions/{mission} directory and the tileset contains a tilemapresource.xml within it, queries that xml and auto-fills the 'Minimum Zoom', 'Maximum Native Zoom' and 'Bounding Box' fields above. If it is a COG and TiTiler is true, the COG's data will be queried instead.", "type": "button", "action": "tile-populate-from-x", + "requiresCapability": "localSidecars", "width": 6 } ] diff --git a/configure/src/pages/Deployments/Deployments.js b/configure/src/pages/Deployments/Deployments.js new file mode 100644 index 000000000..d2390f762 --- /dev/null +++ b/configure/src/pages/Deployments/Deployments.js @@ -0,0 +1,380 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { STATUS } from "../../core/deploymentStatus"; +import { useSelector, useDispatch } from "react-redux"; +import { makeStyles } from "@mui/styles"; + +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"; + +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); + + // 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(() => { + queryDeploymentsCall(dispatch); + }, [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; status refreshes automatically.", + 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", + }) + ); + } + ); + }; + + // 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) => { + dispatch( + setModal({ + name: "deleteDeployment", + deployment: deployment, + }) + ); + }; + + return ( +
+ +
+ + + Deployments + +
+ + + + + +
+
+ + Publish a mission as a standalone, statically-hosted dashboard. + Status is read live from CloudFormation and refreshes automatically + while a publish, update or delete is in flight. + +
+ + 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/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/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/docs/adr/deployment/lean/adr.md b/docs/adr/deployment/lean/adr.md index fd122c603..08668631b 100644 --- a/docs/adr/deployment/lean/adr.md +++ b/docs/adr/deployment/lean/adr.md @@ -80,8 +80,8 @@ Each published dashboard is provisioned as a **CloudFormation stack**, created b **Why CloudFormation rather than direct SDK calls:** -- CloudFront's multi-step teardown (disable distribution, wait for propagation, delete distribution, delete Function, empty and delete bucket) is CFN's problem, not ours. -- Failed creates roll back automatically. No partial-state cleanup code to write or maintain. +- CloudFront's multi-step teardown (disable distribution, wait for propagation, delete distribution, delete Function, delete bucket) is CFN's problem, not ours; the handler only empties the bucket first, since CFN won't delete a non-empty one. +- Failed creates roll back automatically. No create-path cleanup code to write or maintain. - One stack ARN is the handle for everything a dashboard owns. Bookkeeping is one foreign key, not five. ### Publish flow @@ -99,7 +99,7 @@ Publish, Update, and Delete actions live on a new Deployments page in `/configur **Update:** `POST /api/deployments/:id/update` re-bakes the bundle from the current mission config and PutObjects the new assets to the existing bucket. The CloudFront distribution is not replaced — same `deployment_id`, same stack, same URL. The row's `updated_at` reflects the latest republish. The same ECS RunTask shape as Publish, minus the CFN `CreateStack` + polling. -**Delete:** `DELETE /api/deployments/:id` marks the row `deleting` and calls `DeleteStack`. CFN handles the 15–30 min teardown. The row flips to `deleted` on the next read where `DescribeStacks` 404s. +**Delete:** `DELETE /api/deployments/:id` marks the row `deleting`, empties the bucket (its name from `settings.bucket`, falling back to the stack's `BucketName` output for a delete mid-provision), then calls `DeleteStack`. CFN handles the 15–30 min teardown. The row flips to `deleted` on the next read where `DescribeStacks` 404s. **Live state:** `GET /api/deployments*` joins each row's `stack_arn` with `DescribeStacks`. The row holds identity (id, mission, owner, stack ARN); CFN holds status. No reconcile job. diff --git a/docs/adr/deployment/lean/api.md b/docs/adr/deployment/lean/api.md index 2fc89b303..febf2fa0e 100644 --- a/docs/adr/deployment/lean/api.md +++ b/docs/adr/deployment/lean/api.md @@ -52,7 +52,7 @@ Utils is a grab-bag. Per-endpoint: | `POST /getminmax` | **Gate** | Python+GDAL shellout against a local raster in `Missions/`. | | `POST /ll2aerll` | **Gate** | Python coord-transform script; no caller without local data. | | `POST /chronice` | **Gate** | Python time-op script; no caller in lean. | -| `GET /proj42wkt` | **Keep** (optional) | Pure compute (proj4 → WKT). Harmless either way. | +| `GET /proj42wkt` | **Gate** | Python shellout (`private/api/proj42wkt.py`) — not pure compute as first classified (review catch, 2026-06-11). The shapefile export tries the in-browser WKT converter first and falls back to this route via `calls.api('proj42wkt')` (review, 2026-06-16), so full hits GDAL here for projections the converter can't handle; lean stays gated (404), where only the converter's coverage is available. | ## Server-side patterns diff --git a/docs/adr/deployment/lean/prs/pr-05-gate-missions-time-shortener.md b/docs/adr/deployment/lean/prs/pr-05-gate-missions-time-shortener.md index 107901c82..a1c0544ef 100644 --- a/docs/adr/deployment/lean/prs/pr-05-gate-missions-time-shortener.md +++ b/docs/adr/deployment/lean/prs/pr-05-gate-missions-time-shortener.md @@ -25,7 +25,7 @@ One thing this PR deliberately leaves alone: the ability to *upload* a static mi | `scripts/server.js` | Wrap the `${ROOT_PATH}/Missions` mount — the three-piece stack `app.use(..., ensureUser(), middleware.missions(ROOT_PATH), express.static('Missions'))` (L643–648) — in `if (isFull()) { ... }`. | Verified at L643–648. This single mount is the only place `middleware.missions` and the `Missions` static dir are served, so gating it disables the `_time_` compositor too (see next row). The other `express.static` mounts (build, docs, configure, public — L612–642) are **not** touched. | | `scripts/middleware.js` | **No edit.** The `missions(ROOT_PATH)` factory (L155+) — which contains the `_time_`/`sharp` compositing branch (L169+, `sharp` required L3) — stays as-is; it's simply never mounted in lean because the server.js mount above is gated. | Verified. The `_time_` logic lives inside `middleware.missions`, reached only through the gated mount. No standalone `_time_` route exists elsewhere. | | `API/Backend/Shortener/setup.js` | Wrap the route mount in `onceInit` — `s.app.use(s.ROOT_PATH + "/api/shortener", ...)` (L6–13) — in `if (isFull()) { ... }`. Model/routes remain in the repo. | Verified. Backend setups are auto-discovered by directory (`API/setups.js`), so the gate must live **inside** this `setup.js`, not in `scripts/server.js`. Import the PR-1 helper here (e.g. `require("../Utils/deploymentMode")` — match PR 1's canonical path). | -| `API/Backend/Utils/setup.js` (or `routes/utils.js`) | Gate the `Missions/`-bound endpoints in lean: `getprofile`, `getbands`, `getminmax`, and `queryTilesetTimes`. All resolve a frontend-supplied relative path against the on-disk `Missions/` tree (Python/GDAL shellout for the first three; `fs.readdir` of `Missions/<…>/_time_/` for `queryTilesetTimes`), so they are **LOCAL-ONLY** — broken/useless once the `Missions/` filesystem is gone. Cleanest gate: wrap their registrations in `if (isFull())`. | The single auth guard is `s.ensureUser()` (`setup.js`), which short-circuits to `next()` when `AUTH != "local"` — so in non-local-auth deployments these GDAL-spawning endpoints are effectively **open**; gating them in lean also closes that exposure. `proj42wkt` + `healthcheck` are pure compute → **keep**. `ll2aerll`/`chronice` are SPICE compute reading a local `spice/` dir (not `Missions/`), non-Earth, and frontend-dropped → gating optional (recommend gate; no lean caller). The server-side `getminmax` is LOCAL-ONLY — lean's external-data path reroutes on the **frontend** to an external TiTiler (PR 9), so gating the server endpoint loses nothing. | +| `API/Backend/Utils/setup.js` (or `routes/utils.js`) | Gate the `Missions/`-bound endpoints in lean: `getprofile`, `getbands`, `getminmax`, and `queryTilesetTimes`. All resolve a frontend-supplied relative path against the on-disk `Missions/` tree (Python/GDAL shellout for the first three; `fs.readdir` of `Missions/<…>/_time_/` for `queryTilesetTimes`), so they are **LOCAL-ONLY** — broken/useless once the `Missions/` filesystem is gone. Cleanest gate: wrap their registrations in `if (isFull())`. | The single auth guard is `s.ensureUser()` (`setup.js`), which short-circuits to `next()` when `AUTH != "local"` — so in non-local-auth deployments these GDAL-spawning endpoints are effectively **open**; gating them in lean also closes that exposure. `healthcheck` is pure compute → **keep**. `proj42wkt` was first classified as pure compute but is a **Python shellout** → **gated** (review catch, 2026-06-11); the frontend computes WKT client-side in every mode per PR 9. `ll2aerll`/`chronice` are SPICE compute reading a local `spice/` dir (not `Missions/`), non-Earth, and frontend-dropped → gating optional (recommend gate; no lean caller). The server-side `getminmax` is LOCAL-ONLY — lean's external-data path reroutes on the **frontend** to an external TiTiler (PR 9), so gating the server endpoint loses nothing. | | `package.json` | **No edit** — `sharp` (L163, `^0.31.2`) stays in dependencies; the full-mode `_time_` compositor uses it. | Verified `sharp` present at L163. | ## Implementation steps @@ -33,14 +33,14 @@ One thing this PR deliberately leaves alone: the ability to *upload* a static mi 1. In `scripts/server.js`, wrap the `${ROOT_PATH}/Missions` mount (L643–648) in `if (isFull()) { ... }`. Import the PR-1 helper at the top of the file if it isn't already in scope (match PR 1's canonical import path). 2. Leave `scripts/middleware.js` untouched — gating the mount is sufficient to disable the `_time_` compositor. 3. In `API/Backend/Shortener/setup.js`, import the PR-1 helper and wrap the `onceInit` route mount in `if (isFull())`. -4. In `API/Backend/Utils/` (the `setup.js` mount, or per-route in `routes/utils.js`), gate `getprofile`/`getbands`/`getminmax`/`queryTilesetTimes` on `if (isFull())`. Leave `proj42wkt`/`healthcheck` mounted; gating `ll2aerll`/`chronice` is optional (recommend gate). Re-grep `API/Backend/Utils/routes/utils.js` for the exact route registrations before editing. +4. In `API/Backend/Utils/` (the `setup.js` mount, or per-route in `routes/utils.js`), gate `getprofile`/`getbands`/`getminmax`/`queryTilesetTimes` on `if (isFull())`. Leave only `healthcheck` mounted; gate `proj42wkt` (Python shellout — review catch) and `ll2aerll`/`chronice` alongside the GDAL endpoints. Re-grep `API/Backend/Utils/routes/utils.js` for the exact route registrations before editing. 5. Leave `package.json` (`sharp`) untouched. 6. Do **not** touch upload routing (`Upload/uploadRouter.js`) or any webhook code — those are PR 10 and PR 8 respectively. ## Verification - `MMGIS_DEPLOYMENT_MODE=lean`: `GET /Missions/whatever` returns 404 from Express (no middleware mounted); the **tile-image `_time_` compositor served under the `Missions` mount** is likewise unreachable and 404s. -- `MMGIS_DEPLOYMENT_MODE=lean`: `/api/utils/getprofile`, `/getbands`, `/getminmax`, and `/queryTilesetTimes` return 404; `/api/utils/proj42wkt` and `/healthcheck` still respond. +- `MMGIS_DEPLOYMENT_MODE=lean`: `/api/utils/getprofile`, `/getbands`, `/getminmax`, `/queryTilesetTimes`, `/ll2aerll`, `/chronice`, and `/proj42wkt` return 404; `/api/utils/healthcheck` still responds. - `MMGIS_DEPLOYMENT_MODE=lean`: `/api/shortener` routes return 404. - `MMGIS_DEPLOYMENT_MODE=full` (or unset): `Missions/` files serve as today (including path-traversal rejection), `_time_` URLs still composite via `sharp`, and `/api/shortener` works as today. - Webhook routes work in **both** modes (untouched by this PR). diff --git a/docs/adr/deployment/lean/prs/pr-09-publish-time-bakes.md b/docs/adr/deployment/lean/prs/pr-09-publish-time-bakes.md index c68bdd3d6..b9923f8b3 100644 --- a/docs/adr/deployment/lean/prs/pr-09-publish-time-bakes.md +++ b/docs/adr/deployment/lean/prs/pr-09-publish-time-bakes.md @@ -25,15 +25,15 @@ This PR is **frontend-only** static-mode call-site edits — there is no publish | File | Change | Disposition | Notes (verified against code) | |---|---|---|---| | `src/essence/Basics/Map_/Map_.js` | Single-band COG branch: in static mode, instead of the `calls.getminmax` AJAX, fetch the band's min/max from the layer's external TiTiler (e.g. `/cog/statistics?url=`), with the TiTiler base resolved via `ServiceUrls` (PR 7). Keep the existing prefer-`cogMin`/`cogMax`-from-config path and the `console.warn` fallback. | **Reroute → external TiTiler** | `calls.getminmax` is a **direct `$.ajax`** at ~L2074–2075 inside the `georaster.numberOfRasters === 1` block (~L2064), guarded by `isNaN(parseFloat(layerObj.cogMin/Max))` (~L2069–2070). It bypasses the `calls.js` dispatcher, so this is a call-site edit (the "direct-`$.ajax` bypass" PR 7 flagged). Line numbers are approximate — re-grep `calls.getminmax`. | -| `src/essence/Tools/Layers/LayersTool.js` | Shapefile (`'shp'`) export: in static mode compute the WKT from `window.mmgisglobal.customCRS.projString` via `proj4js` (already bundled) instead of the `calls.api('proj42wkt', …)` round-trip; pass it as `shpwrite.zip(…, { prj })`. | **Compute (client-side)** | `calls.api('proj42wkt', { proj4: … }, …)` at **L1959–1984**; result passed as `prj: data`. `proj4` is already a dependency. | +| `src/essence/Tools/Layers/LayersTool.js` | Shapefile (`'shp'`) export: compute the WKT from `window.mmgisglobal.customCRS.projString` via `proj4js` (already bundled), falling back to `calls.api('proj42wkt', …)` when the converter returns null; pass it as `shpwrite.zip(…, { prj })`. | **Compute (client-side), GDAL fallback** | Hybrid (review, 2026-06-16): the in-browser converter handles common projections in every mode; the fallback reaches GDAL in full and the converter again in static. `proj4` is already a dependency. | | `src/essence/Basics/TimeControl_/TimeUI.js` | `_makeHistogram`: in static mode short-circuit to a no-op (and hide `#mmgisTimeUITimelineHisto`) so the `calls.api('query_tileset_times', …)` loop never runs. The rest of the time slider is untouched. | **Drop** | `_makeHistogram` at **L2835**; `calls.api('query_tileset_times', …)` at **L2896–2898** inside `sparklineLayers.forEach`. Senior-dev decision (2026-06-08): the histogram is not needed in lean. | -`src/essence/Tools/Draw/DrawTool_Files.js:717` also calls `proj42wkt`, but **Draw is gated out** (PR 4), so that call site is moot — no work here. +`src/essence/Tools/Draw/DrawTool_Files.js` also exports shapefiles and got the same hybrid — relevant in full (Draw is kept), moot in lean where **Draw is gated out** (PR 4). ## Implementation steps 1. **COG min/max — `Map_.js` (Reroute to TiTiler).** In the single-band branch, keep preferring `layerObj.cogMin/cogMax` when present (~L2065–2066). When absent in static mode, replace the `calls.getminmax` `$.ajax` (~L2073–2107; re-grep `calls.getminmax`) with a fetch to the layer's external TiTiler statistics endpoint (e.g. `/cog/statistics`), resolving the TiTiler base through `ServiceUrls` (PR 7) and the COG URL from the layer config. Parse the band's min/max from the response into the same `min`/`max` variables. Keep the existing `console.warn` fallback so a failed fetch leaves the layer drawing (range stays NaN) rather than crashing. *(In lean, COG is always served by an external TiTiler, so this source is always available.)* -2. **Projection WKT — `LayersTool.js` (client compute).** In the `'shp'` case (L1956–1985), in static mode convert `window.mmgisglobal.customCRS.projString` to WKT with `proj4js` and pass it as `shpwrite.zip(…, { prj })`. Preserve the existing failure `CursorInfo.update` message (L1975–1982) so a missing/failed WKT degrades gracefully (zip without `.prj`). +2. **Projection WKT — `LayersTool.js`/`DrawTool_Files.js` (client compute, GDAL fallback).** In the `'shp'` case, convert `window.mmgisglobal.customCRS.projString` to WKT with `proj4js`; when it returns null, fall back to `calls.api('proj42wkt')` (GDAL in full; the client converter again in static). Pass the result as `shpwrite.zip(…, { prj })`, preserving the existing failure `CursorInfo.update` message so a missing WKT degrades gracefully. 3. **Time histogram — `TimeUI.js` (Drop).** Guard `_makeHistogram` (L2835) to no-op in static mode — return early and hide `#mmgisTimeUITimelineHisto` so the `query_tileset_times` loop (L2896+) never runs. No `times.json`, no baked counts. Verify the rest of `TimeUI` (scrubbing, play/animation) does not depend on the histogram bins. ## Verification @@ -52,6 +52,6 @@ Revert the three static-mode guards. In `full` mode there is zero impact. In `le - **Three dispositions (decided 2026-06-08) — none is a publish-time bake:** - **Time histogram → Drop.** Disabled in lean (not needed); no `times.json`, no per-bin count source. - **COG min/max → Reroute to the external TiTiler.** Lean always serves COG via an external TiTiler, so the band statistics come from it directly (e.g. `/cog/statistics`) rather than a bake or COG-IFD read. - - **Projection WKT → client-side compute** via `proj4js` (already bundled). + - **Projection WKT → client-side compute** via `proj4js`, with a `calls.api('proj42wkt')` GDAL fallback in full mode (review, 2026-06-16). - **No publish-time generation.** Nothing is baked, so this PR adds no generators to `scripts/publish-static.js` and depends only on **PR 7** (static-mode `ServiceUrls` for the TiTiler URL), not PR 8. - **`getminmax` is a direct `$.ajax`** (`Map_.js:~2074–2075`), bypassing the `calls.js` dispatcher — so its reroute is a call-site edit, not a `STATIC_HANDLERS` entry. (Same bypass PR 7 flags.) diff --git a/infrastructure/README.md b/infrastructure/README.md new file mode 100644 index 000000000..5ff915282 --- /dev/null +++ b/infrastructure/README.md @@ -0,0 +1,136 @@ +# 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). 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) | +| `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 | + +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`) | +| `` | 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`). 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 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 + +- **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`. `MMGIS_DEPLOYMENT_ID` and `MMGIS_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 — 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 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. + +## 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..ca19a3c65 --- /dev/null +++ b/infrastructure/cloudfront-admin.json @@ -0,0 +1,68 @@ +{ + "CallerReference": "mmgis-admin-cloudfront", + "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": 0 + }, + "ViewerCertificate": { + "CloudFrontDefaultCertificate": true, + "MinimumProtocolVersion": "TLSv1" + }, + "Origins": { + "Quantity": 2, + "Items": [ + { + "Id": "AdminExpressVpcOrigin", + "DomainName": "", + "VpcOriginConfig": { + "VpcOriginId": "" + } + }, + { + "Id": "AssetBucketOrigin", + "DomainName": ".s3..amazonaws.com", + "OriginAccessControlId": "", + "S3OriginConfig": { + "OriginAccessIdentity": "" + } + } + ] + }, + "DefaultCacheBehavior": { + "TargetOriginId": "AdminExpressVpcOrigin", + "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": "b689b0a8-53d0-40ab-baf2-68738e2966ac" + }, + "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..d25148112 --- /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": "ENABLE_MMGIS_WEBSOCKETS", "value": "true" }, + { "name": "ENABLE_CONFIG_WEBSOCKETS", "value": "true" }, + { "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" }, + { "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": "" + } + ], + "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..8b8890d28 --- /dev/null +++ b/infrastructure/ecs/publish-task.json @@ -0,0 +1,48 @@ +{ + "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": "DB_SSL", "value": "true" }, + { "name": "DB_SSL_CERT_BASE64", "value": "" }, + { "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..0e655c49c --- /dev/null +++ b/infrastructure/iam/admin-task-execution-role.json @@ -0,0 +1,64 @@ +{ + "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": "EcrAuthTokenNoResourceScoping", + "Effect": "Allow", + "Action": ["ecr:GetAuthorizationToken"], + "Resource": "*" + }, + { + "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..262e9f6ec --- /dev/null +++ b/infrastructure/iam/admin-task-role.json @@ -0,0 +1,114 @@ +{ + "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. 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": [ + { + "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": "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", + "Action": ["s3:PutObject"], + "Resource": "arn:aws:s3:::/*" + } + ] + } + } + ] + } +} 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-execution-role.json b/infrastructure/iam/publish-task-execution-role.json new file mode 100644 index 000000000..b0ea495ba --- /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": "EcrAuthTokenNoResourceScoping", + "Effect": "Allow", + "Action": ["ecr:GetAuthorizationToken"], + "Resource": "*" + }, + { + "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..703093baf --- /dev/null +++ b/infrastructure/iam/publish-task-role.json @@ -0,0 +1,125 @@ +{ + "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 \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" + }, + "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", + "cloudfront:UntagResource", + "cloudfront:ListTagsForResource", + "cloudfront:CreateInvalidation" + ], + "Resource": "arn:aws:cloudfront:::distribution/*" + }, + { + "Sid": "DashboardAuthFunctionLifecycle", + "Effect": "Allow", + "Action": [ + "cloudfront:CreateFunction", + "cloudfront:PublishFunction", + "cloudfront:DescribeFunction", + "cloudfront:DeleteFunction", + "cloudfront:GetFunction", + "cloudfront:TagResource", + "cloudfront:UntagResource", + "cloudfront:ListTagsForResource" + ], + "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:::" + } + ] + } + } + ] + } +} \ No newline at end of file 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/package-lock.json b/package-lock.json index 0660886ca..b6b782dfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,17 @@ { "name": "mmgis", - "version": "4.2.9-20260211", + "version": "4.2.11-20260611", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "mmgis", - "version": "4.2.9-20260211", + "version": "4.2.11-20260611", "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", "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.4.0", @@ -30,14 +34,13 @@ "@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", "camelcase": "^5.3.1", "cesium": "^1.121.0", - "chart.js": "^3.6.0", - "chartjs-plugin-zoom": "^1.2.1", + "chart.js": "^4.4.0", + "chartjs-plugin-zoom": "^2.2.0", "chroma-js": "^1.4.1", "compression": "^1.7.4", "connect-pg-simple": "^8.0.0", @@ -111,6 +114,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 +138,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 +201,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 +210,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 +255,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 +269,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 +278,73 @@ "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-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", @@ -287,20 +368,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 +452,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 +468,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 +486,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.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", - "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.52", + "@aws-sdk/credential-provider-process": "^3.972.46", + "@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", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -391,18 +510,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.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", - "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.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -410,23 +527,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.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", - "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.53", + "@aws-sdk/credential-provider-process": "^3.972.46", + "@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", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -434,17 +549,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 +565,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.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", - "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.20", + "@aws-sdk/token-providers": "3.1066.0", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -472,18 +583,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.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", - "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.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -520,23 +629,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.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.20.tgz", + "integrity": "sha512-IYJuLpXp2DEILVQpQOy0PMpkftv0AHEOCn52o0atyOaumA0CdWQ3klPyXdViGYLbNpESsVFMVybvHUeZAuiGxA==", "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.34", + "@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 +681,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.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", - "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 +696,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.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", - "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.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -581,14 +713,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 +730,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 +738,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 +762,6 @@ } ], "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", @@ -650,9 +773,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 +783,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 +3430,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", @@ -3457,6 +3626,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", @@ -4183,18 +4358,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 +4948,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 +4962,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 +4976,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 +4994,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 +5002,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 +5016,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 +5030,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 +5046,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 +5059,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" @@ -6922,6 +7077,12 @@ "@types/geojson": "*" } }, + "node_modules/@types/hammerjs": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", + "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", + "license": "MIT" + }, "node_modules/@types/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -6942,10 +7103,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 +7119,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 +7269,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 +7329,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 +8123,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 +8900,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 +9253,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", @@ -9425,19 +9623,28 @@ } }, "node_modules/chart.js": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.9.1.tgz", - "integrity": "sha512-Ro2JbLmvg83gXF5F4sniaQ+lTbSv18E+TIf2cOeiH1Iqd2PGFOtem+DUufMZsCJwFE7ywPOpfXFBwRTGq7dh6w==" + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } }, "node_modules/chartjs-plugin-zoom": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-1.2.1.tgz", - "integrity": "sha512-2zbWvw2pljrtMLMXkKw1uxYzAne5PtjJiOZftcut4Lo3Ee8qUt95RpMKDWrZ+pBZxZKQKOD/etdU4pN2jxZUmg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.2.0.tgz", + "integrity": "sha512-in6kcdiTlP6npIVLMd4zXZ08PDUXC52gZ4FAy5oyjk1zX3gKarXMAof7B9eFiisf9WOC3bh2saHg+J5WtLXZeA==", + "license": "MIT", "dependencies": { + "@types/hammerjs": "^2.0.45", "hammerjs": "^2.0.8" }, "peerDependencies": { - "chart.js": "^3.2.0" + "chart.js": ">=3.2.0" } }, "node_modules/cheap-ruler": { @@ -13395,6 +13602,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 +14122,6 @@ } ], "license": "MIT", - "optional": true, - "peer": true, "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" @@ -18474,6 +18849,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 +22254,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 +22497,6 @@ } ], "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">=14.0.0" } @@ -23796,6 +24690,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 +25513,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 +27154,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 +27778,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 +31518,6 @@ } ], "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">=16.0.0" } @@ -30682,20 +31673,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 +31720,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 +31730,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 +31738,61 @@ "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-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", @@ -30759,19 +31812,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 +31880,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.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.52", + "@aws-sdk/credential-provider-process": "^3.972.46", + "@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", + "@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.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.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-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.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.53", + "@aws-sdk/credential-provider-process": "^3.972.46", + "@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", + "@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.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.20", + "@aws-sdk/token-providers": "3.1066.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.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.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, @@ -30948,62 +32021,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.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.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.34", + "@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.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.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.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.20", + "@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 +32097,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 +32115,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 +32123,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 +33951,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", @@ -32990,6 +34099,11 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==" + }, "@leichtgewicht/ip-codec": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", @@ -33577,11 +34691,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 +35021,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 +35054,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 +35090,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 +35099,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" @@ -35546,6 +36640,11 @@ "@types/geojson": "*" } }, + "@types/hammerjs": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", + "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==" + }, "@types/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -35566,9 +36665,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 +36680,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 +36820,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 +36875,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 +37478,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 +38069,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 +38318,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", @@ -37469,15 +38592,19 @@ "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==" }, "chart.js": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.9.1.tgz", - "integrity": "sha512-Ro2JbLmvg83gXF5F4sniaQ+lTbSv18E+TIf2cOeiH1Iqd2PGFOtem+DUufMZsCJwFE7ywPOpfXFBwRTGq7dh6w==" + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "requires": { + "@kurkle/color": "^0.3.0" + } }, "chartjs-plugin-zoom": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-1.2.1.tgz", - "integrity": "sha512-2zbWvw2pljrtMLMXkKw1uxYzAne5PtjJiOZftcut4Lo3Ee8qUt95RpMKDWrZ+pBZxZKQKOD/etdU4pN2jxZUmg==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.2.0.tgz", + "integrity": "sha512-in6kcdiTlP6npIVLMd4zXZ08PDUXC52gZ4FAy5oyjk1zX3gKarXMAof7B9eFiisf9WOC3bh2saHg+J5WtLXZeA==", "requires": { + "@types/hammerjs": "^2.0.45", "hammerjs": "^2.0.8" } }, @@ -40341,6 +41468,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 +41848,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 +45474,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 +48124,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 +48286,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 +49853,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 +50525,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 +51740,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 +52223,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 +55067,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..b55026871 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mmgis", - "version": "4.2.9-20260211", + "version": "4.2.11-20260611", "description": "A web-based mapping and localization solution for science operation on planetary missions.", "homepage": "build", "repository": { @@ -60,6 +60,10 @@ "typecheck": "tsc --noEmit" }, "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", "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.4.0", @@ -87,8 +91,8 @@ "busboy": "1.6", "camelcase": "^5.3.1", "cesium": "^1.121.0", - "chart.js": "^3.6.0", - "chartjs-plugin-zoom": "^1.2.1", + "chart.js": "^4.4.0", + "chartjs-plugin-zoom": "^2.2.0", "chroma-js": "^1.4.1", "compression": "^1.7.4", "connect-pg-simple": "^8.0.0", diff --git a/public/adminlogin.js b/public/adminlogin.js index 14a8374c9..ba2bc76ec 100644 --- a/public/adminlogin.js +++ b/public/adminlogin.js @@ -90,9 +90,12 @@ function setupLogin() { document.getElementById("msg").style.opacity = 1; } }, - error: function () { + error: function (xhr) { //error - document.getElementById("msg").innerHTML = "Server error."; + document.getElementById("msg").innerHTML = + xhr && xhr.status === 404 + ? "First-time signup is disabled." + : "Server error."; document.getElementById("msg").style.opacity = 1; }, }); @@ -111,7 +114,9 @@ $(document).ready(function () { data: {}, success: function (data) { if (data.status == "success") { - if (data.has == false) { + // When first-time signup is disabled, the first admin is expected + // to be seeded server-side, so never offer the setup path. + if (data.has == false && window.DISABLE_FIRST_SIGNUP !== true) { setup = true; $("#setup").css("opacity", "1"); $(".container").css("height", "520px"); diff --git a/public/index.html b/public/index.html index 435b985be..291aca50c 100644 --- a/public/index.html +++ b/public/index.html @@ -338,7 +338,7 @@ const NODE_ENV = "%NODE_ENV%"; mmgisglobal = {}; mmgisglobal.name = "MMGIS"; - mmgisglobal.SERVER = "node"; + mmgisglobal.SERVER = "%SERVER%"; switch (NODE_ENV) { case "production": mmgisglobal.AUTH = "#{AUTH}"; diff --git a/sample.env b/sample.env index b97176e1e..2b09ccad7 100644 --- a/sample.env +++ b/sample.env @@ -3,6 +3,44 @@ # SERVER - node || apache(deprecated) SERVER=node +# MMGIS_DEPLOYMENT_MODE - full || lean +# Naming rule: configuration that only exists for the lean deployment carries +# the MMGIS_ prefix; mode-agnostic settings follow the unprefixed style of the +# section they extend (AUTH, PORT, DB_*, ...). +# 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 + +## 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//…). 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 +# 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 @@ -19,6 +57,18 @@ AUTH=none # otherwise, they just see a login page with no signup section AUTH_LOCAL_ALLOW_SIGNUP=false +# If true, disables the open "create the first administrator account" page and its +# /api/users/first_signup endpoint (it returns 404). SECURITY: when unset, a fresh install lets +# whoever reaches the admin login page first claim the first admin account — set this to true +# (and seed the admin with SEED_SUPERADMIN_*) on any publicly reachable deployment. default false +DISABLE_FIRST_SIGNUP= + +# If BOTH are set, init-db seeds a first administrator account (permission 111) with these +# credentials — only when no users exist yet (idempotent, password is bcrypt-hashed, never logged). +# Pairs with DISABLE_FIRST_SIGNUP=true so fresh deploys never expose the open first-signup page. +SEED_SUPERADMIN_USERNAME= +SEED_SUPERADMIN_PASSWORD= + # NODE_ENV - development || production NODE_ENV=development @@ -126,6 +176,11 @@ DB_SSL=false DB_SSL_CERT= # Alternatively, if DB_SSL=true and if needed, a base64 encoded certificate for ssl. DB_SSL_CERT_BASE64 will take priority over DB_SSL_CERT | string DB_SSL_CERT_BASE64= +# At startup (init-db), how many times to attempt connecting to the database before failing, +# retrying only connection-level errors (the database still coming up). Default is 10 +DB_INIT_RETRY_ATTEMPTS= +# How many milliseconds to wait between init-db connection attempts. Default is 3000 (3 sec) +DB_INIT_RETRY_DELAY_MS= #CONFIG # Disable the configure page @@ -142,6 +197,10 @@ LEADS=["user1"] # If true, enables the backend MMGIS websockets to tell clients to update layers ENABLE_MMGIS_WEBSOCKETS=false +# How many milliseconds between server-side websocket heartbeat pings. Clients that fail to answer +# a ping by the next tick are terminated. Keeps idle connections alive through proxies/load +# balancers that drop quiet connections. Default is 30000 (30 sec) +WEBSOCKET_PING_INTERVAL_MS= # If true, notifications are sent to /configure users whenever the configuration objects changes out from under them and puts (overridable) limits on saving. ENABLE_CONFIG_WEBSOCKETS=false # For use when ENABLE_CONFIG_WEBSOCKETS=true (if ENABLE_CONFIG_WEBSOCKETS=false, all saves will freely overwrite already). If true, gives /configure users the ability to override changes made to the configuration while they were working on it with their own. @@ -168,4 +227,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= diff --git a/scripts/init-db-retry.js b/scripts/init-db-retry.js new file mode 100644 index 000000000..5f0a0894c --- /dev/null +++ b/scripts/init-db-retry.js @@ -0,0 +1,84 @@ +/** + * init-db-retry.js + * Bounded retry around the boot-time database connection in init-db.js. + * In orchestrated hosting the database often comes up slightly after the + * app, so the first connection attempts can fail with connection-level + * errors that resolve themselves seconds later. + */ + +// Node errnos surfaced on err.parent.code / err.original.code when the +// database server is unreachable. These are NOT Postgres SQLSTATEs. +const CONNECTION_ERRNOS = [ + "ECONNREFUSED", + "ECONNRESET", + "ETIMEDOUT", + "ENOTFOUND", + "EHOSTUNREACH", + "EAI_AGAIN", +]; + +// Postgres SQLSTATEs that indicate auth/config problems, never a +// database that is still starting up. The postgres dialect does NOT +// throw SequelizeAccessDeniedError (that's mysql-only): bad credentials +// arrive as a generic SequelizeConnectionError whose parent.code is a +// class-28 SQLSTATE (28000/28P01 invalid authorization), and a missing +// default database as 3D000 (invalid catalog name). These must fail +// fast, so check them before the class-name check below. +function isNonRetryableSqlState(code) { + return code.startsWith("28") || code === "3D000"; +} + +// Sequelize connection-error class names worth retrying. Note that with +// the postgres dialect, auth failures also surface as +// SequelizeConnectionError — they are excluded above via their SQLSTATE +// (28*/3D000) on err.parent.code before this list is consulted. +const CONNECTION_ERROR_NAMES = [ + "SequelizeConnectionError", + "SequelizeConnectionRefusedError", + "SequelizeConnectionTimedOutError", + "SequelizeHostNotFoundError", + "SequelizeHostNotReachableError", +]; + +// True only for connection-level failures worth retrying: Node errnos, +// Sequelize connection-error classes, or the Postgres connection SQLSTATEs +// (class 08, or 53300 too_many_connections). Permission/config errors +// (e.g. 42501, bad credentials) are not retryable. +function isRetryableConnectionError(err) { + if (!err) return false; + + const code = (err.parent && err.parent.code) || (err.original && err.original.code); + if (typeof code === "string") { + if (isNonRetryableSqlState(code)) return false; + if (CONNECTION_ERRNOS.includes(code)) return true; + if (code.startsWith("08") || code === "53300") return true; + } + + if (CONNECTION_ERROR_NAMES.includes(err.name)) return true; + + return false; +} + +// Calls sequelizeInstance.authenticate(), retrying connection-level +// failures up to maxAttempts with delayMs between attempts. Any other +// error (or an exhausted budget) rethrows so the caller fails fast. +async function authenticateWithRetry(sequelizeInstance, options) { + const maxAttempts = Math.max(options.maxAttempts || 1, 1); + const delayMs = Math.max(options.delayMs || 0, 0); + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await sequelizeInstance.authenticate(); + } catch (err) { + if (!isRetryableConnectionError(err) || attempt === maxAttempts) { + throw err; + } + if (typeof options.onRetry === "function") { + options.onRetry(attempt, maxAttempts, delayMs, err); + } + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } +} + +module.exports = { isRetryableConnectionError, authenticateWithRetry }; diff --git a/scripts/init-db.js b/scripts/init-db.js index eccf48f2e..f8c4031a5 100644 --- a/scripts/init-db.js +++ b/scripts/init-db.js @@ -4,9 +4,30 @@ 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 { + isRetryableConnectionError, + authenticateWithRetry, +} = require("./init-db-retry"); const isDocker = utils.isDocker(); +function getRetryOptions(label) { + return { + maxAttempts: parseInt(process.env.DB_INIT_RETRY_ATTEMPTS, 10) || 10, + delayMs: parseInt(process.env.DB_INIT_RETRY_DELAY_MS, 10) || 3000, + onRetry: (attempt, maxAttempts, delayMs, err) => { + const reason = + err.message || (err.parent && err.parent.code) || err.name; + logger( + "info", + `Database connection (${label}) failed (attempt ${attempt} of ${maxAttempts}): ${reason}. Retrying in ${delayMs}ms...`, + "connection" + ); + }, + }; +} + function classifyPostgresError(err) { // Check if we have a PostgreSQL error with a code if (!err || !err.parent || !err.parent.code) { @@ -108,6 +129,31 @@ async function initializeDatabase() { }, } ); + // The database server may still be coming up (common right after a + // restart in orchestrated hosting), so wait for it with a bounded retry + // instead of crash-looping on the first refused connection. + try { + await authenticateWithRetry( + baseSequelize, + getRetryOptions("database server") + ); + } catch (err) { + if (isRetryableConnectionError(err)) { + logger( + "infrastructure_error", + "Unable to reach the database server after retries.", + "connection", + null, + err + ); + reject(err); + return; + } + // Non-connection errors here (e.g. no default database to + // authenticate against) were tolerated before; the CREATE DATABASE + // calls and keepGoing() still determine real success/failure. + } + await baseSequelize .query(`SELECT version();`) .then((version) => { @@ -122,9 +168,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 @@ -275,8 +322,7 @@ async function initializeDatabase() { } ); // Source: http://docs.sequelizejs.com/manual/installation/getting-started.html - sequelize - .authenticate() + authenticateWithRetry(sequelize, getRetryOptions(process.env.DB_NAME)) .then(async () => { logger("info", "Database connection is successful.", "connection"); await sequelize @@ -357,6 +403,20 @@ async function initializeDatabase() { return null; }); + try { + await seedSuperadmin(); + } catch (err) { + logger( + "error", + "Failed to seed the superadmin user.", + "connection", + null, + err + ); + reject(err); + return; + } + resolve(); }) .catch((err) => { @@ -375,3 +435,40 @@ async function initializeDatabase() { return null; }); } + +// Optionally seed the first admin account from env so fresh deploys don't +// depend on the open first-signup page. No-op unless both vars are set and +// no users exist yet. +async function seedSuperadmin() { + const username = process.env.SEED_SUPERADMIN_USERNAME; + const password = process.env.SEED_SUPERADMIN_PASSWORD; + if (!username || !password) return; + + // Required lazily — pulls in API/connection.js, which opens its own + // database connection on load. + const { User } = require("../API/Backend/Users/models/user"); + + // init-db runs before server.js's sequelize.sync() creates the users + // table, so ensure it exists first. + await User.sync(); + + const count = await User.count(); + if (count > 0) { + logger( + "info", + "Users already exist. Skipping the superadmin seed.", + "connection" + ); + return; + } + + // The model's beforeCreate hook bcrypt-hashes the password. + await User.create({ + username: username, + email: null, + password: password, + permission: "111", + token: null, + }); + logger("info", `Seeded superadmin user '${username}'.`, "connection"); +} diff --git a/scripts/lib/aws-provision.js b/scripts/lib/aws-provision.js new file mode 100644 index 000000000..22a146e93 --- /dev/null +++ b/scripts/lib/aws-provision.js @@ -0,0 +1,443 @@ +/** + * 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"); +const { + CloudFrontClient, + CreateInvalidationCommand, +} = require("@aws-sdk/client-cloudfront"); + +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 }), + cloudfront: new CloudFrontClient({ region }), + }; + } + return _clients; +} + +// Test seam: inject mock clients ({ cfn, s3, ecs, cloudfront }), 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), + // 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), + }) + ); + } + } + await Promise.all( + Array.from({ length: Math.min(concurrency, files.length || 1) }, worker) + ); + 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), + }) + ); +} + +// 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 }, + }, + }) + ); +} + +// Builds an S3 CopySource ("bucket/key") with each path segment percent- +// encoded but the "/" separators preserved. encodeURIComponent over the +// whole string would also encode the bucket/key boundary and intra-key +// slashes into %2F, which S3 reads as one literal bucket name -> the copy +// fails with NoSuchBucket/InvalidArgument. +function buildCopySource(bucket, key) { + const encodedKey = key.split("/").map(encodeURIComponent).join("/"); + return `${bucket}/${encodedKey}`; +} + +// 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: buildCopySource(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: buildCopySource(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: "MMGIS_DEPLOYMENT_ID", value: `${deploymentId}` }, + { name: "MMGIS_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, + uploadFile, + createInvalidation, + copyPrefix, + copyObjectIfExists, + emptyBucket, + requireEnv, + 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..f0e949da0 --- /dev/null +++ b/scripts/publish-static.js @@ -0,0 +1,350 @@ +/** + * 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: + * MMGIS_DEPLOYMENT_ID - the deployments row to publish (required) + * MMGIS_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) → 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 fs = require("fs"); +const os = require("os"); +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.MMGIS_DEPLOYMENT_ID || process.argv[2]; +const ACTION = process.env.MMGIS_DEPLOYMENT_ACTION || process.argv[3] || "publish"; + +const { requireEnv } = provision; + +function log(message) { + console.log(`[publish-static] ${message}`); +} + +// 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("MMGIS_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. 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", + }); + + // 3. Provision (publish) or look up (update) the dashboard stack + let stack; + if (ACTION === "publish") { + // 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 { + 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 !== "") { + // 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/${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/${missionFolderName}/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."); + } + + // 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: "{}", + }; + // Escape per placeholder context so values like a mission named + // `Jezero "Delta"` can't break the inline " from closing the inline