From 331310905d82eaa16a8a3752c9cd021a3aacefe0 Mon Sep 17 00:00:00 2001 From: Adamskiee Date: Mon, 10 Aug 2026 19:05:36 +0800 Subject: [PATCH 1/4] feat(deploy-release): automate bundle releases --- .github/workflows/release-bundle.yml | 128 + .gitignore | 2 + VERSIONING.md | 26 +- admin-frontend/sapot-admin/.gitignore | 1 + admin-frontend/sapot-admin/pnpm-lock.yaml | 4708 +++++++++++++++++ deploy/VERSION | 1 + deploy/bundle-release-policy.json | 5 + deploy/manifest.schema.json | 31 +- deploy/scripts/flash-gsm-firmware.sh | 2 +- deploy/scripts/lib/deploy-common.sh | 2 +- deploy/scripts/rollback.sh | 3 +- deploy/scripts/status.sh | 2 +- .../scripts/tests/test_bundle_versions.bash | 23 + deploy/scripts/upgrade.sh | 4 +- .../bundle-release-workflow-plan.md | 38 + docs/deployment/docker-bundle.md | 42 +- docs/deployment/install-ubuntu-server.md | 14 +- docs/deployment/tileserver.md | 5 + docs/devops/README.md | 4 +- scripts/AGENTS.md | 9 +- scripts/build-bundle.sh | 349 +- scripts/release.sh | 33 +- scripts/tests/test_validate_bundle_release.py | 64 + scripts/validate_bundle_release.py | 88 + tileserver/CLAUDE.md | 5 +- tileserver/download-script.sh | 77 +- tileserver/osm-source.sha256 | 1 + 27 files changed, 5536 insertions(+), 131 deletions(-) create mode 100644 .github/workflows/release-bundle.yml create mode 100644 admin-frontend/sapot-admin/pnpm-lock.yaml create mode 100644 deploy/VERSION create mode 100644 deploy/bundle-release-policy.json create mode 100644 deploy/scripts/tests/test_bundle_versions.bash create mode 100644 docs/deployment/bundle-release-workflow-plan.md create mode 100644 scripts/tests/test_validate_bundle_release.py create mode 100755 scripts/validate_bundle_release.py create mode 100644 tileserver/osm-source.sha256 diff --git a/.github/workflows/release-bundle.yml b/.github/workflows/release-bundle.yml new file mode 100644 index 00000000..a4811caf --- /dev/null +++ b/.github/workflows/release-bundle.yml @@ -0,0 +1,128 @@ +name: Release Deployment Bundle + +on: + push: + tags: ["bundle/v*"] + +permissions: + contents: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-24.04 + timeout-minutes: 180 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate tag and release metadata + run: | + python3 scripts/validate_bundle_release.py --tag "$GITHUB_REF_NAME" + VERSION=${GITHUB_REF_NAME#bundle/v} + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + [ "$(git cat-file -t "$GITHUB_REF_NAME")" = tag ] || { + echo "Bundle releases require an annotated tag." >&2 + exit 1 + } + if [[ "$VERSION" != *-* ]]; then + git fetch --no-tags origin main:refs/remotes/origin/main + git merge-base --is-ancestor "$GITHUB_SHA" origin/main || { + echo "Stable bundle tags must point to a commit on main." >&2 + exit 1 + } + fi + git tag -l --format='%(contents)' "$GITHUB_REF_NAME" > notes.md + [ -s notes.md ] || { + echo "Release notes are empty. Use an annotated bundle tag." >&2 + exit 1 + } + + - name: Install bundle build tools + run: | + sudo apt-get update + sudo apt-get install -y jq zstd + docker compose version + df -h / + + - uses: arduino/setup-arduino-cli@v2 + + - name: Run release metadata and deployment script tests + run: | + python3 -m unittest scripts.tests.test_validate_bundle_release -v + deploy/scripts/tests/run-tests.sh + bash -n scripts/build-bundle.sh scripts/release.sh tileserver/download-script.sh + bash -n deploy/scripts/*.sh deploy/scripts/lib/*.sh + + - name: Download and validate Batangas map data + run: ./tileserver/download-script.sh --cleanup-source + + - name: Build deployment bundle + run: ./scripts/build-bundle.sh --low-disk + + - name: Validate release assets + run: | + ARCHIVE="dist/sapot-bundle-v$VERSION.tar.zst" + [ -f "$ARCHIVE" ] || { echo "Missing $ARCHIVE" >&2; exit 1; } + SIZE=$(stat -c '%s' "$ARCHIVE") + [ "$SIZE" -lt 2147483648 ] || { + echo "Bundle is too large for a GitHub release asset: $SIZE bytes" >&2 + exit 1 + } + ROOT="sapot-bundle-v$VERSION" + tar --use-compress-program=unzstd -xOf "$ARCHIVE" "$ROOT/manifest.json" > manifest.json + python3 - "$GITHUB_SHA" "$VERSION" <<'PY' + import json + import sys + + expected_sha, expected_version = sys.argv[1:] + manifest = json.load(open("manifest.json", encoding="utf-8")) + if manifest["schemaVersion"] != "2.0": + raise SystemExit("unexpected manifest schema") + if manifest["gitSha"] != expected_sha: + raise SystemExit("manifest git SHA does not match the tag commit") + if manifest["version"] != expected_version: + raise SystemExit("manifest version does not match the bundle tag") + if manifest["gsmFirmware"]["compatibleGsmFastapiVersion"] != f"={manifest['componentVersions']['gsmFastapi']}": + raise SystemExit("firmware compatibility does not match the GSM service version") + suffix = f"bundle-v{expected_version}" + for image in manifest["images"].values(): + if not image["tag"].endswith(suffix): + raise SystemExit(f"image tag is not bundle-versioned: {image['tag']}") + PY + tar --use-compress-program=unzstd -tf "$ARCHIVE" | grep -q "/CHECKSUMS.sha256$" + VERIFY_DIR=$(mktemp -d) + tar --use-compress-program=unzstd -xf "$ARCHIVE" -C "$VERIFY_DIR" + (cd "$VERIFY_DIR/$ROOT" && sha256sum --check CHECKSUMS.sha256) + rm -rf "$VERIFY_DIR" + ( + cd dist + sha256sum "sapot-bundle-v$VERSION.tar.zst" > "sapot-bundle-v$VERSION.tar.zst.sha256" + ) + + - name: Create or resume draft release + env: + GH_TOKEN: ${{ github.token }} + run: | + ARCHIVE="dist/sapot-bundle-v$VERSION.tar.zst" + CHECKSUM="$ARCHIVE.sha256" + ENCODED_TAG=$(jq -rn --arg value "$GITHUB_REF_NAME" '$value | @uri') + EXISTING=$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$ENCODED_TAG" 2>/dev/null || true) + if [ -n "$EXISTING" ]; then + if [ "$(jq -r .draft <<<"$EXISTING")" != true ]; then + echo "Published release $GITHUB_REF_NAME already exists; refusing to replace it." >&2 + exit 1 + fi + gh release edit "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes-file notes.md + else + PRERELEASE=() + case "$VERSION" in *-*) PRERELEASE=(--prerelease) ;; esac + gh release create "$GITHUB_REF_NAME" --verify-tag --draft \ + --title "$GITHUB_REF_NAME" --notes-file notes.md "${PRERELEASE[@]}" + fi + gh release upload "$GITHUB_REF_NAME" "$ARCHIVE" "$CHECKSUM" --clobber + gh release edit "$GITHUB_REF_NAME" --draft=false diff --git a/.gitignore b/.gitignore index bf031958..83ec1e94 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ logs/* .worktrees/ node_modules/ +__pycache__/ +*.py[cod] reports/ .pnpm-store/ .env diff --git a/VERSIONING.md b/VERSIONING.md index 62e188cd..6be767b9 100644 --- a/VERSIONING.md +++ b/VERSIONING.md @@ -1,6 +1,6 @@ # Versioning Guide -This repo uses git-tag-driven versioning for five independent components: **mobile**, **server**, **admin**, **portal** (captive portal), and **gsm** (GSM-module: `GSM-fastapi` + the production Arduino firmware, versioned together since they implement one wire-protocol contract). +This repo uses git-tag-driven versioning for five application components and one deployment artifact: **mobile**, **server**, **admin**, **portal** (captive portal), **gsm** (GSM-module: `GSM-fastapi` + the production Arduino firmware), and the independent offline deployment **bundle**. `GSM-module/GSM-API/` and `GSM-trial-code/` are not covered — neither is deployed (see `GSM-module/CLAUDE.md`). `tileserver/` has no source of its own (deploy scripts + `.mbtiles` data only) and isn't versioned. @@ -15,13 +15,15 @@ This repo uses git-tag-driven versioning for five independent components: **mobi | Admin | `admin/vX.Y.Z` | `admin/v0.2.0` | | Captive Portal | `portal/vX.Y.Z` | `portal/v0.2.0` | | GSM Module | `gsm/vX.Y.Z` | `gsm/v0.2.0` | +| Deployment Bundle | `bundle/vX.Y.Z` | `bundle/v0.0.1` | **Pre-release suffix:** append `-(alpha|beta|rc).N` — e.g. `mobile/v1.0.0-beta.2`. A tag with any `-` suffix is published as a GitHub **pre-release**. A tag without one is a full release. Tags are immutable release identifiers. Never move, delete, or reuse a published -component tag to point at a newer commit. Each component is versioned independently: -a new server release does not require a new mobile, admin, portal, or GSM release. +component tag to point at a newer commit. Every release unit is versioned independently. +A new server release does not require a new bundle release, and a bundle release does +not change any bundled component's version. --- @@ -69,7 +71,7 @@ preflight above: ```bash # From the repo root, on a clean release branch: -./scripts/release.sh +./scripts/release.sh # Examples: ./scripts/release.sh mobile 1.0.0-beta.1 @@ -77,11 +79,13 @@ preflight above: ./scripts/release.sh admin 0.2.0 ./scripts/release.sh portal 0.2.0 ./scripts/release.sh gsm 0.2.0-beta.1 +./scripts/release.sh bundle 0.0.1 ``` The script will: -1. Bump the component's version file(s) (`package.json` for mobile and admin, `server/app/version.py` for server, `captive-portal/VERSION` for portal, `GSM-module/GSM-fastapi/app_version.py` **and** the Arduino firmware's `FIRMWARE_VERSION` define for gsm). -2. Commit the bump: `chore(version): `. +1. Bump the release unit's version file(s) (`package.json` for mobile and admin, `server/app/version.py` for server, `captive-portal/VERSION` for portal, `GSM-module/GSM-fastapi/app_version.py` **and** the Arduino firmware's `FIRMWARE_VERSION` define for gsm, or `deploy/VERSION` for the bundle). +2. Commit the bump: `chore(version): `, or + `chore(deploy-version): bump bundle to ` for the bundle. 3. Create a local **annotated git tag** (`/vX.Y.Z`). 4. Print the push command — **it does NOT push automatically**. @@ -148,6 +152,7 @@ Generated Docs Check fails even when no endpoint shape changed. | Admin | `admin-frontend/sapot-admin/package.json` → `version` | | Captive Portal | `captive-portal/VERSION` (plain text) | | GSM Module | `GSM-module/GSM-fastapi/app_version.py` → `__version__` **and** `GSM-module/GSM-arduino-actual-code/GSM-arduino-actual-code.ino` → `FIRMWARE_VERSION` (kept in lockstep by `GSM-module/scripts/set_version.py`) | +| Deployment Bundle | `deploy/VERSION` (independent of all component versions) | `app.config.ts` (`version` and `extra.displayVersion`) is kept in sync by `set-version.js` — do not edit it manually. @@ -155,7 +160,7 @@ Generated Docs Check fails even when no endpoint shape changed. ## CI Behaviour -When a tag is pushed, the corresponding GitHub Actions workflow: +When an application component tag is pushed, the corresponding GitHub Actions workflow: 1. Derives the version from the tag name. 2. **Asserts** the version in the source file matches the tag (fails loudly if not). 3. Extracts the annotated tag message (` `) as the GitHub Release body. @@ -163,6 +168,12 @@ When a tag is pushed, the corresponding GitHub Actions workflow: CI does not draft or edit notes — it only reads the tag message and publishes it, using the default `GITHUB_TOKEN`. +For `bundle/v*`, CI additionally validates the committed compatibility policy, +downloads and verifies the pinned map source, builds the offline bundle, and publishes +`sapot-bundle-vX.Y.Z.tar.zst` plus its SHA-256 file. The release remains a draft until +both assets upload successfully. Bundle `0.0.1` starts manifest schema `2.0` and is a +fresh-install release; older locally built server-derived bundles are not compatible. + --- ## Out of Scope @@ -170,5 +181,4 @@ CI does not draft or edit notes — it only reads the tag message and publishes This versioning system covers **only** version strings and GitHub Releases. The following are handled separately: - EAS builds and OTA updates -- Offline Docker bundles: a bundle uses the server `__version__` at build time and records its exact artifact metadata in `manifest.json`; see [the Docker bundle deployment guide](docs/deployment/docker-bundle.md) - In-app update banners and client-version gating diff --git a/admin-frontend/sapot-admin/.gitignore b/admin-frontend/sapot-admin/.gitignore index 4c577398..8b265e2b 100644 --- a/admin-frontend/sapot-admin/.gitignore +++ b/admin-frontend/sapot-admin/.gitignore @@ -31,6 +31,7 @@ npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* +!/pnpm-lock.yaml # env files (can opt-in for committing if needed) .env diff --git a/admin-frontend/sapot-admin/pnpm-lock.yaml b/admin-frontend/sapot-admin/pnpm-lock.yaml new file mode 100644 index 00000000..f69989c0 --- /dev/null +++ b/admin-frontend/sapot-admin/pnpm-lock.yaml @@ -0,0 +1,4708 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + clsx: + specifier: ^2.1.1 + version: 2.1.1 + crypto: + specifier: ^1.0.1 + version: 1.0.1 + dexie: + specifier: ^4.4.2 + version: 4.4.2 + dexie-react-hooks: + specifier: ^4.4.0 + version: 4.4.0(dexie@4.4.2)(react@19.2.4) + lucide-react: + specifier: ^1.7.0 + version: 1.7.0(react@19.2.4) + maplibre-gl: + specifier: ^5.24.0 + version: 5.24.0 + next: + specifier: 16.2.2 + version: 16.2.2(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: + specifier: 19.2.4 + version: 19.2.4 + react-dom: + specifier: 19.2.4 + version: 19.2.4(react@19.2.4) + recharts: + specifier: ^3.8.1 + version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + tailwind-merge: + specifier: ^3.5.0 + version: 3.5.0 + tailwind-scrollbar-hide: + specifier: ^4.0.0 + version: 4.0.0(tailwindcss@4.2.2) + tweetnacl: + specifier: ^1.0.3 + version: 1.0.3 + tweetnacl-util: + specifier: ^0.15.1 + version: 0.15.1 + uuid: + specifier: ^14.0.0 + version: 14.0.0 + devDependencies: + '@tailwindcss/postcss': + specifier: ^4 + version: 4.2.2 + '@types/node': + specifier: ^20 + version: 20.19.37 + '@types/react': + specifier: ^19 + version: 19.2.14 + '@types/react-dom': + specifier: ^19 + version: 19.2.3(@types/react@19.2.14) + autoprefixer: + specifier: ^10.4.27 + version: 10.4.27(postcss@8.5.15) + eslint: + specifier: ^9 + version: 9.39.4(jiti@2.6.1) + eslint-config-next: + specifier: 16.2.2 + version: 16.2.2(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + postcss: + specifier: ^8.5.8 + version: 8.5.15 + tailwindcss: + specifier: ^4.2.2 + version: 4.2.2 + typescript: + specifier: ^5 + version: 5.9.3 + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@emnapi/core@1.9.2': + resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mapbox/jsonlint-lines-primitives@2.0.2': + resolution: {integrity: sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==} + engines: {node: '>= 0.6'} + + '@mapbox/point-geometry@1.1.0': + resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==} + + '@mapbox/tiny-sdf@2.1.0': + resolution: {integrity: sha512-uFJhNh36BR4OCuWIEiWaEix9CA2WzT6CAIcqVjWYpnx8+QDtS+oC4QehRrx5cX4mgWs37MmKnwUejeHxVymzNg==} + + '@mapbox/unitbezier@0.0.1': + resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==} + + '@mapbox/vector-tile@2.0.4': + resolution: {integrity: sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==} + + '@mapbox/whoots-js@3.1.0': + resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==} + engines: {node: '>=6.0.0'} + + '@maplibre/geojson-vt@5.0.4': + resolution: {integrity: sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==} + + '@maplibre/geojson-vt@6.1.0': + resolution: {integrity: sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==} + + '@maplibre/maplibre-gl-style-spec@24.8.1': + resolution: {integrity: sha512-zxa92qF96ZNojLxeAjnaRpjVCy+swoUNJvDhtpC90k7u5F0TMr4GmvNqMKvYrMoPB8d7gRSXbMG1hBbmgESIsw==} + hasBin: true + + '@maplibre/mlt@1.1.9': + resolution: {integrity: sha512-g/tD8EYJB97udq33ipuJ9a4Q7fcbZnTEnUrgnEc/tLMmEL+zaCbR+X5fkDBO2dgpaAMsLH179qE3UXg2N0Nc/g==} + + '@maplibre/vt-pbf@4.3.0': + resolution: {integrity: sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==} + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + + '@next/env@16.2.2': + resolution: {integrity: sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ==} + + '@next/eslint-plugin-next@16.2.2': + resolution: {integrity: sha512-IOPbWzDQ+76AtjZioaCjpIY72xNSDMnarZ2GMQ4wjNLvnJEJHqxQwGFhgnIWLV9klb4g/+amg88Tk5OXVpyLTw==} + + '@next/swc-darwin-arm64@16.2.2': + resolution: {integrity: sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.2.2': + resolution: {integrity: sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.2.2': + resolution: {integrity: sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-arm64-musl@16.2.2': + resolution: {integrity: sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@next/swc-linux-x64-gnu@16.2.2': + resolution: {integrity: sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-x64-musl@16.2.2': + resolution: {integrity: sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@next/swc-win32-arm64-msvc@16.2.2': + resolution: {integrity: sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.2.2': + resolution: {integrity: sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nolyfill/is-core-module@1.0.39': + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} + engines: {node: '>=12.4.0'} + + '@reduxjs/toolkit@2.11.2': + resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@tailwindcss/node@4.2.2': + resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} + + '@tailwindcss/oxide-android-arm64@4.2.2': + resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.2': + resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.2': + resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} + engines: {node: '>= 20'} + + '@tailwindcss/postcss@4.2.2': + resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/node@20.19.37': + resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@types/supercluster@7.1.3': + resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==} + + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + + '@typescript-eslint/eslint-plugin@8.58.0': + resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.58.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.58.0': + resolution: {integrity: sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.58.0': + resolution: {integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.58.0': + resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.58.0': + resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.58.0': + resolution: {integrity: sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.58.0': + resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.58.0': + resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.58.0': + resolution: {integrity: sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.58.0': + resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.11.1': + resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.11.1': + resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} + cpu: [x64] + os: [win32] + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + ast-types-flow@0.0.8: + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + autoprefixer@10.4.27: + resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axe-core@4.11.2: + resolution: {integrity: sha512-byD6KPdvo72y/wj2T/4zGEvvlis+PsZsn/yPS3pEO+sFpcrqRpX/TJCxvVaEsNeMrfQbCr7w163YqoD9IYwHXw==} + engines: {node: '>=4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.13: + resolution: {integrity: sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@1.1.13: + resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001784: + resolution: {integrity: sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + crypto@1.0.1: + resolution: {integrity: sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==} + deprecated: This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in. + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + damerau-levenshtein@1.0.8: + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dexie-react-hooks@4.4.0: + resolution: {integrity: sha512-ObLXBS5+4BJU8vtSvBx6b9fY6zZYgniAtwxzjCHsUQadgbqYN6935X2/1TWw4Rf2N1aZV1io5/ziox4vKuxABA==} + peerDependencies: + dexie: '>=4.2.0-alpha.1 <5.0.0' + react: '>=16' + + dexie@4.4.2: + resolution: {integrity: sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw==} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + earcut@3.0.2: + resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} + + electron-to-chromium@1.5.331: + resolution: {integrity: sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + engines: {node: '>=10.13.0'} + + es-abstract@1.24.1: + resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.3.1: + resolution: {integrity: sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + es-toolkit@1.45.1: + resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-next@16.2.2: + resolution: {integrity: sha512-6VlvEhwoug2JpVgjZDhyXrJXUEuPY++TddzIpTaIRvlvlXXFgvQUtm3+Zr84IjFm0lXtJt73w19JA08tOaZVwg==} + peerDependencies: + eslint: '>=9.0.0' + typescript: '>=3.3.1' + peerDependenciesMeta: + typescript: + optional: true + + eslint-import-resolver-node@0.3.10: + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} + + eslint-import-resolver-typescript@3.10.1: + resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true + + eslint-module-utils@2.12.1: + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-jsx-a11y@6.10.2: + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} + engines: {node: '>=4.0'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 + + eslint-plugin-react-hooks@7.0.1: + resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.1: + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.7: + resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + + gl-matrix@3.4.4: + resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.4.0: + resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.4: + resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-bun-module@2.0.0: + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json-stringify-pretty-compact@4.0.0: + resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + kdbush@4.0.2: + resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + language-subtag-registry@0.3.23: + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} + + language-tags@1.0.9: + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} + engines: {node: '>=0.10'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@1.7.0: + resolution: {integrity: sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + maplibre-gl@5.24.0: + resolution: {integrity: sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==} + engines: {node: '>=16.14.0', npm: '>=8.1.0'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + murmurhash-js@1.0.0: + resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + next@16.2.2: + resolution: {integrity: sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + + node-releases@2.0.37: + resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + pbf@4.0.1: + resolution: {integrity: sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + potpack@2.1.0: + resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + protocol-buffers-schema@3.6.1: + resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + quickselect@3.0.0: + resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} + + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + + recharts@3.8.1: + resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve-protobuf-schema@2.1.0: + resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} + + resolve@2.0.0-next.6: + resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + safe-array-concat@1.1.3: + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + engines: {node: '>=0.4'} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stable-hash@0.0.5: + resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + string.prototype.includes@2.0.1: + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} + engines: {node: '>= 0.4'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + supercluster@8.0.1: + resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tailwind-scrollbar-hide@4.0.0: + resolution: {integrity: sha512-gobtvVcThB2Dxhy0EeYSS1RKQJ5baDFkamkhwBvzvevwX6L4XQfpZ3me9s25Ss1ecFVT5jPYJ50n+7xTBJG9WQ==} + peerDependencies: + tailwindcss: '>=3.0.0 || >= 4.0.0 || >= 4.0.0-beta.8 || >= 4.0.0-alpha.20' + + tailwindcss@4.2.2: + resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} + + tapable@2.3.2: + resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} + engines: {node: '>=6'} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyqueue@3.0.0: + resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tweetnacl-util@0.15.1: + resolution: {integrity: sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==} + + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typescript-eslint@8.58.0: + resolution: {integrity: sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unrs-resolver@1.11.1: + resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + uuid@14.0.0: + resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} + hasBin: true + + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@emnapi/core@1.9.2': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.9.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': + dependencies: + eslint: 9.39.4(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.14.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.9.2 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mapbox/jsonlint-lines-primitives@2.0.2': {} + + '@mapbox/point-geometry@1.1.0': {} + + '@mapbox/tiny-sdf@2.1.0': {} + + '@mapbox/unitbezier@0.0.1': {} + + '@mapbox/vector-tile@2.0.4': + dependencies: + '@mapbox/point-geometry': 1.1.0 + '@types/geojson': 7946.0.16 + pbf: 4.0.1 + + '@mapbox/whoots-js@3.1.0': {} + + '@maplibre/geojson-vt@5.0.4': {} + + '@maplibre/geojson-vt@6.1.0': + dependencies: + kdbush: 4.0.2 + + '@maplibre/maplibre-gl-style-spec@24.8.1': + dependencies: + '@mapbox/jsonlint-lines-primitives': 2.0.2 + '@mapbox/unitbezier': 0.0.1 + json-stringify-pretty-compact: 4.0.0 + minimist: 1.2.8 + quickselect: 3.0.0 + rw: 1.3.3 + tinyqueue: 3.0.0 + + '@maplibre/mlt@1.1.9': + dependencies: + '@mapbox/point-geometry': 1.1.0 + + '@maplibre/vt-pbf@4.3.0': + dependencies: + '@mapbox/point-geometry': 1.1.0 + '@mapbox/vector-tile': 2.0.4 + '@maplibre/geojson-vt': 5.0.4 + '@types/geojson': 7946.0.16 + '@types/supercluster': 7.1.3 + pbf: 4.0.1 + supercluster: 8.0.1 + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@next/env@16.2.2': {} + + '@next/eslint-plugin-next@16.2.2': + dependencies: + fast-glob: 3.3.1 + + '@next/swc-darwin-arm64@16.2.2': + optional: true + + '@next/swc-darwin-x64@16.2.2': + optional: true + + '@next/swc-linux-arm64-gnu@16.2.2': + optional: true + + '@next/swc-linux-arm64-musl@16.2.2': + optional: true + + '@next/swc-linux-x64-gnu@16.2.2': + optional: true + + '@next/swc-linux-x64-musl@16.2.2': + optional: true + + '@next/swc-win32-arm64-msvc@16.2.2': + optional: true + + '@next/swc-win32-x64-msvc@16.2.2': + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@nolyfill/is-core-module@1.0.39': {} + + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.4 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.4 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + + '@rtsao/scc@1.1.0': {} + + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.2.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.1 + jiti: 2.6.1 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.2 + + '@tailwindcss/oxide-android-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.2': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.2': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.2': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.2': + optional: true + + '@tailwindcss/oxide@4.2.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-arm64': 4.2.2 + '@tailwindcss/oxide-darwin-x64': 4.2.2 + '@tailwindcss/oxide-freebsd-x64': 4.2.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 + '@tailwindcss/oxide-linux-x64-musl': 4.2.2 + '@tailwindcss/oxide-wasm32-wasi': 4.2.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 + + '@tailwindcss/postcss@4.2.2': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.2.2 + '@tailwindcss/oxide': 4.2.2 + postcss: 8.5.15 + tailwindcss: 4.2.2 + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/geojson@7946.0.16': {} + + '@types/json-schema@7.0.15': {} + + '@types/json5@0.0.29': {} + + '@types/node@20.19.37': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@types/supercluster@7.1.3': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/use-sync-external-store@0.0.6': {} + + '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/type-utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 + eslint: 9.39.4(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.0 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.58.0': + dependencies: + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 + + '@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.58.0': {} + + '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.58.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/visitor-keys': 8.58.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.58.0 + '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.58.0': + dependencies: + '@typescript-eslint/types': 8.58.0 + eslint-visitor-keys: 5.0.1 + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + optional: true + + '@unrs/resolver-binding-android-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + optional: true + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + ast-types-flow@0.0.8: {} + + async-function@1.0.0: {} + + autoprefixer@10.4.27(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + caniuse-lite: 1.0.30001784 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axe-core@4.11.2: {} + + axobject-query@4.1.0: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.13: {} + + brace-expansion@1.1.13: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.13 + caniuse-lite: 1.0.30001784 + electron-to-chromium: 1.5.331 + node-releases: 2.0.37 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001784: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + client-only@0.0.1: {} + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + crypto@1.0.1: {} + + csstype@3.2.3: {} + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + damerau-levenshtein@1.0.8: {} + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js-light@2.5.1: {} + + deep-is@0.1.4: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + detect-libc@2.1.2: {} + + dexie-react-hooks@4.4.0(dexie@4.4.2)(react@19.2.4): + dependencies: + dexie: 4.4.2 + react: 19.2.4 + + dexie@4.4.2: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + earcut@3.0.2: {} + + electron-to-chromium@1.5.331: {} + + emoji-regex@9.2.2: {} + + enhanced-resolve@5.20.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.2 + + es-abstract@1.24.1: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.3 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.20 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.3.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 + safe-array-concat: 1.1.3 + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.2 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + es-toolkit@1.45.1: {} + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-next@16.2.2(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@next/eslint-plugin-next': 16.2.2 + eslint: 9.39.4(jiti@2.6.1) + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1)) + globals: 16.4.0 + typescript-eslint: 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - supports-color + + eslint-import-resolver-node@0.3.10: + dependencies: + debug: 3.2.7 + is-core-module: 2.16.1 + resolve: 2.0.0-next.6 + transitivePeerDependencies: + - supports-color + + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + get-tsconfig: 4.13.7 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.15 + unrs-resolver: 1.11.1 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.4(jiti@2.6.1) + eslint-import-resolver-node: 0.3.10 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@2.6.1)): + dependencies: + aria-query: 5.3.2 + array-includes: 3.1.9 + array.prototype.flatmap: 1.3.3 + ast-types-flow: 0.0.8 + axe-core: 4.11.2 + axobject-query: 4.1.0 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + eslint: 9.39.4(jiti@2.6.1) + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + safe-regex-test: 1.1.0 + string.prototype.includes: 2.0.1 + + eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + eslint: 9.39.4(jiti@2.6.1) + hermes-parser: 0.25.1 + zod: 4.3.6 + zod-validation-error: 4.0.2(zod@4.3.6) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.6.1)): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.3.1 + eslint: 9.39.4(jiti@2.6.1) + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.6 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + eventemitter3@5.0.4: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.1: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + fraction.js@5.3.4: {} + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.2 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.13.7: + dependencies: + resolve-pkg-maps: 1.0.0 + + gl-matrix@3.4.4: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@16.4.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + immer@10.2.0: {} + + immer@11.1.4: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + + internmap@2.0.3: {} + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-bun-module@2.0.0: + dependencies: + semver: 7.7.4 + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + jiti@2.6.1: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json-stringify-pretty-compact@4.0.0: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + json5@2.2.3: {} + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + kdbush@4.0.2: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + language-subtag-registry@0.3.23: {} + + language-tags@1.0.9: + dependencies: + language-subtag-registry: 0.3.23 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@1.7.0(react@19.2.4): + dependencies: + react: 19.2.4 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + maplibre-gl@5.24.0: + dependencies: + '@mapbox/jsonlint-lines-primitives': 2.0.2 + '@mapbox/point-geometry': 1.1.0 + '@mapbox/tiny-sdf': 2.1.0 + '@mapbox/unitbezier': 0.0.1 + '@mapbox/vector-tile': 2.0.4 + '@mapbox/whoots-js': 3.1.0 + '@maplibre/geojson-vt': 6.1.0 + '@maplibre/maplibre-gl-style-spec': 24.8.1 + '@maplibre/mlt': 1.1.9 + '@maplibre/vt-pbf': 4.3.0 + '@types/geojson': 7946.0.16 + earcut: 3.0.2 + gl-matrix: 3.4.4 + kdbush: 4.0.2 + murmurhash-js: 1.0.0 + pbf: 4.0.1 + potpack: 2.1.0 + quickselect: 3.0.0 + tinyqueue: 3.0.0 + + math-intrinsics@1.1.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.13 + + minimist@1.2.8: {} + + ms@2.1.3: {} + + murmurhash-js@1.0.0: {} + + nanoid@3.3.12: {} + + napi-postinstall@0.3.4: {} + + natural-compare@1.4.0: {} + + next@16.2.2(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@next/env': 16.2.2 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.13 + caniuse-lite: 1.0.30001784 + postcss: 8.4.31 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) + optionalDependencies: + '@next/swc-darwin-arm64': 16.2.2 + '@next/swc-darwin-x64': 16.2.2 + '@next/swc-linux-arm64-gnu': 16.2.2 + '@next/swc-linux-arm64-musl': 16.2.2 + '@next/swc-linux-x64-gnu': 16.2.2 + '@next/swc-linux-x64-musl': 16.2.2 + '@next/swc-win32-arm64-msvc': 16.2.2 + '@next/swc-win32-x64-msvc': 16.2.2 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + node-exports-info@1.6.0: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + + node-releases@2.0.37: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + pbf@4.0.1: + dependencies: + resolve-protobuf-schema: 2.1.0 + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + possible-typed-array-names@1.1.0: {} + + postcss-value-parser@4.2.0: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + potpack@2.1.0: {} + + prelude-ls@1.2.1: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + protocol-buffers-schema@3.6.1: {} + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + quickselect@3.0.0: {} + + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react-is@16.13.1: {} + + react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + redux: 5.0.1 + + react@19.2.4: {} + + recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.45.1 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-is: 16.13.1 + react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.4) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + reselect@5.1.1: {} + + resolve-from@4.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve-protobuf-schema@2.1.0: + dependencies: + protocol-buffers-schema: 3.6.1 + + resolve@2.0.0-next.6: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + node-exports-info: 1.6.0 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rw@1.3.3: {} + + safe-array-concat@1.1.3: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.7.4: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + source-map-js@1.2.1: {} + + stable-hash@0.0.5: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string.prototype.includes@2.0.1: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.0 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.1 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + strip-bom@3.0.0: {} + + strip-json-comments@3.1.1: {} + + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): + dependencies: + client-only: 0.0.1 + react: 19.2.4 + optionalDependencies: + '@babel/core': 7.29.0 + + supercluster@8.0.1: + dependencies: + kdbush: 4.0.2 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tailwind-merge@3.5.0: {} + + tailwind-scrollbar-hide@4.0.0(tailwindcss@4.2.2): + dependencies: + tailwindcss: 4.2.2 + + tailwindcss@4.2.2: {} + + tapable@2.3.2: {} + + tiny-invariant@1.3.3: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyqueue@3.0.0: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + tweetnacl-util@0.15.1: {} + + tweetnacl@1.0.3: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript-eslint@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + undici-types@6.21.0: {} + + unrs-resolver@1.11.1: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.11.1 + '@unrs/resolver-binding-android-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-x64': 1.11.1 + '@unrs/resolver-binding-freebsd-x64': 1.11.1 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 + '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-musl': 1.11.1 + '@unrs/resolver-binding-wasm32-wasi': 1.11.1 + '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 + '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 + '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + + uuid@14.0.0: {} + + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.20 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + + zod@4.3.6: {} diff --git a/deploy/VERSION b/deploy/VERSION new file mode 100644 index 00000000..8acdd82b --- /dev/null +++ b/deploy/VERSION @@ -0,0 +1 @@ +0.0.1 diff --git a/deploy/bundle-release-policy.json b/deploy/bundle-release-policy.json new file mode 100644 index 00000000..5402d893 --- /dev/null +++ b/deploy/bundle-release-policy.json @@ -0,0 +1,5 @@ +{ + "schemaVersion": "1.0", + "minimumUpgradeVersion": "0.0.1", + "minimumRollbackVersion": "0.0.1" +} diff --git a/deploy/manifest.schema.json b/deploy/manifest.schema.json index 9a687202..3da64735 100644 --- a/deploy/manifest.schema.json +++ b/deploy/manifest.schema.json @@ -1,14 +1,37 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", - "required": ["schemaVersion", "bundleId", "version", "gitSha", "builtAt", "minimumUpgradeVersion", "maximumRollbackVersion", "requiredDiskBytes", "images", "gsmFirmware", "buildEnvironment"], + "required": ["schemaVersion", "bundleId", "version", "gitSha", "builtAt", "minimumUpgradeVersion", "minimumRollbackVersion", "requiredDiskBytes", "componentVersions", "images", "mapData", "gsmFirmware", "buildEnvironment"], "properties": { - "schemaVersion": {"const": "1.0"}, + "schemaVersion": {"const": "2.0"}, "bundleId": {"type": "string"}, "version": {"type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+(-(alpha|beta|rc)\\.\\d+)?$"}, "gitSha": {"type": "string"}, "builtAt": {"type": "string"}, - "minimumUpgradeVersion": {"type": "string"}, "maximumRollbackVersion": {"type": "string"}, + "minimumUpgradeVersion": {"type": "string"}, "minimumRollbackVersion": {"type": "string"}, "requiredDiskBytes": {"type": "integer", "minimum": 1}, - "images": {"type": "object"}, "gsmFirmware": {"type": "object"}, "buildEnvironment": {"type": "object"} + "componentVersions": { + "type": "object", + "required": ["server", "admin", "gsmFastapi", "gsmFirmware"], + "properties": { + "server": {"type": "string"}, + "admin": {"type": "string"}, + "gsmFastapi": {"type": "string"}, + "gsmFirmware": {"type": "string"} + }, + "additionalProperties": false + }, + "images": {"type": "object"}, + "mapData": { + "type": "object", + "required": ["region", "sourceSha256", "artifactSha256"], + "properties": { + "region": {"const": "batangas"}, + "sourceSha256": {"type": "string"}, + "artifactSha256": {"type": "string"} + }, + "additionalProperties": false + }, + "gsmFirmware": {"type": "object"}, + "buildEnvironment": {"type": "object"} } } diff --git a/deploy/scripts/flash-gsm-firmware.sh b/deploy/scripts/flash-gsm-firmware.sh index a34be69f..270c299e 100755 --- a/deploy/scripts/flash-gsm-firmware.sh +++ b/deploy/scripts/flash-gsm-firmware.sh @@ -11,7 +11,7 @@ hex="$current/firmware/gsm-arduino-actual-code.hex"; expected=$(manifest_value " [ "$(sha256sum "$hex" | awk '{print $1}')" = "$expected" ] || { log_error "firmware checksum mismatch"; exit 1; } [ -c "$port" ] || { log_error "no device at $port - check the cable, or is gsm-fastapi still holding it?"; exit 1; } [ "$(manifest_value "$manifest" gsmFirmware.fqbn)" = "$GSM_ARDUINO_FQBN" ] || { log_error "firmware board does not match GSM_ARDUINO_FQBN"; exit 1; } -constraint=$(manifest_value "$manifest" gsmFirmware.compatibleGsmFastapiVersion); installed=$(manifest_value "$current/manifest.json" version) +constraint=$(manifest_value "$manifest" gsmFirmware.compatibleGsmFastapiVersion); installed=$(manifest_value "$current/manifest.json" componentVersions.gsmFastapi) python3 "$SEMVER" satisfies "$installed" "$constraint" || { log_error "gsm-fastapi v$installed does not satisfy $constraint"; exit 1; } compose "$current" stop gsm-fastapi || true restart() { compose "$current" up -d gsm-fastapi >/dev/null 2>&1 || true; } diff --git a/deploy/scripts/lib/deploy-common.sh b/deploy/scripts/lib/deploy-common.sh index 9f96261b..6ed088a7 100755 --- a/deploy/scripts/lib/deploy-common.sh +++ b/deploy/scripts/lib/deploy-common.sh @@ -26,7 +26,7 @@ PY check_schema() { local manifest=$1 actual actual=$(manifest_value "$manifest" schemaVersion) - [ "$actual" = "1.0" ] || { log_error "Unsupported manifest schema version: got $actual, expected 1.0"; return 1; } + [ "$actual" = "2.0" ] || { log_error "Unsupported manifest schema version: got $actual, expected 2.0"; return 1; } } compose() { local release=$1; shift diff --git a/deploy/scripts/rollback.sh b/deploy/scripts/rollback.sh index 1bc66a21..1c856a55 100755 --- a/deploy/scripts/rollback.sh +++ b/deploy/scripts/rollback.sh @@ -4,8 +4,9 @@ SELF=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd); source "$SELF/lib/deploy-com target_version=${1:?usage: rollback.sh }; acquire_lock current=$(readlink -f "$SAPOT_ROOT/releases/current" 2>/dev/null || true); [ -n "$current" ] || { log_error "not installed"; exit 1; } check_schema "$current/manifest.json" -current_version=$(manifest_value "$current/manifest.json" version); minimum=$(manifest_value "$current/manifest.json" maximumRollbackVersion) +current_version=$(manifest_value "$current/manifest.json" version); minimum=$(manifest_value "$current/manifest.json" minimumRollbackVersion) [ "$(python3 "$SEMVER" compare "$target_version" "$minimum")" -ge 0 ] || { log_error "rollback target v$target_version is older than allowed v$minimum"; exit 1; } +[ "$(python3 "$SEMVER" compare "$target_version" "$current_version")" -lt 0 ] || { log_error "rollback target v$target_version must be older than current v$current_version"; exit 1; } target="$SAPOT_ROOT/releases/v$target_version" if [ ! -d "$target" ]; then last=$(find "$SAPOT_ROOT/releases" -maxdepth 1 -type d -name 'v*' -printf '%f\n' | sort -V | tail -1) diff --git a/deploy/scripts/status.sh b/deploy/scripts/status.sh index e96fc8f9..207b8efb 100755 --- a/deploy/scripts/status.sh +++ b/deploy/scripts/status.sh @@ -4,7 +4,7 @@ SELF=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd); source "$SELF/lib/deploy-com current=$(readlink -f "$SAPOT_ROOT/releases/current" 2>/dev/null || true) [ -n "$current" ] || { log_error "SAPOT is not installed"; exit 1; } manifest="$current/manifest.json"; check_schema "$manifest" -echo "SAPOT v$(manifest_value "$manifest" version) ($(manifest_value "$manifest" bundleId))" +echo "SAPOT bundle v$(manifest_value "$manifest" version) ($(manifest_value "$manifest" bundleId))" echo "Git: $(manifest_value "$manifest" gitSha) Built: $(manifest_value "$manifest" builtAt)" echo "Disk: $(df -h "$SAPOT_ROOT" | awk 'NR==2 {print $4 " free of " $2}')" hardware=$(manifest_value "$SAPOT_ROOT/shared/state.json" gsmHardwarePresent) diff --git a/deploy/scripts/tests/test_bundle_versions.bash b/deploy/scripts/tests/test_bundle_versions.bash new file mode 100644 index 00000000..6d726e7a --- /dev/null +++ b/deploy/scripts/tests/test_bundle_versions.bash @@ -0,0 +1,23 @@ +# shellcheck shell=bash + +test_bundle_version_ordering() { + local semver="$SELF/../lib/semver.py" + assert_eq 1 "$(python3 "$semver" compare 0.0.2 0.0.1)" 'new bundle version sorts after current' + assert_eq -1 "$(python3 "$semver" compare 0.0.1 0.0.2)" 'rollback target sorts before current' +} + +test_bundle_schema_starts_fresh() { + local dir; dir=$(mktemp -d); trap 'rm -rf "$dir"' RETURN + printf '{"schemaVersion":"1.0"}\n' > "$dir/legacy.json" + printf '{"schemaVersion":"2.0"}\n' > "$dir/current.json" + ( + source "$SELF/../lib/deploy-common.sh" + check_schema "$dir/legacy.json" >/dev/null 2>&1 + ) + assert_rc 1 $? 'legacy server-derived bundle schema is rejected' + ( + source "$SELF/../lib/deploy-common.sh" + check_schema "$dir/current.json" >/dev/null 2>&1 + ) + assert_rc 0 $? 'independent bundle schema is accepted' +} diff --git a/deploy/scripts/upgrade.sh b/deploy/scripts/upgrade.sh index b492286b..43485da4 100755 --- a/deploy/scripts/upgrade.sh +++ b/deploy/scripts/upgrade.sh @@ -8,8 +8,10 @@ check_schema "$current/manifest.json" verify_checksums "$source_release" || { log_error "bundle checksum verification failed"; exit 1; } current_version=$(manifest_value "$current/manifest.json" version); minimum=$(manifest_value "$source_manifest" minimumUpgradeVersion) [ "$(python3 "$SEMVER" compare "$current_version" "$minimum")" -ge 0 ] || { log_error "current v$current_version is older than minimum upgrade version v$minimum"; exit 1; } +version=$(manifest_value "$source_manifest" version) +[ "$(python3 "$SEMVER" compare "$version" "$current_version")" -gt 0 ] || { log_error "upgrade target v$version must be newer than current v$current_version"; exit 1; } disk_preflight "$(manifest_value "$source_manifest" requiredDiskBytes)" -version=$(manifest_value "$source_manifest" version); target="$SAPOT_ROOT/releases/v$version"; mkdir -p "$SAPOT_ROOT/releases"; [ -e "$target" ] || cp -a "$source_release" "$target" +target="$SAPOT_ROOT/releases/v$version"; mkdir -p "$SAPOT_ROOT/releases"; [ -e "$target" ] || cp -a "$source_release" "$target" for image in "$target"/images/*.tar; do docker load -i "$image"; done; "$VERIFY_DIGESTS" "$target/manifest.json" compose "$target" up -d db redis; wait_healthy "$target" db; wait_healthy "$target" redis live=$(compose "$current" run --rm api alembic current 2>/dev/null | awk '/^[0-9a-f]+/ {print $1; exit}') diff --git a/docs/deployment/bundle-release-workflow-plan.md b/docs/deployment/bundle-release-workflow-plan.md new file mode 100644 index 00000000..546a9c59 --- /dev/null +++ b/docs/deployment/bundle-release-workflow-plan.md @@ -0,0 +1,38 @@ +# Independent Deployment Bundle Release Workflow + +## Summary + +- Introduce the deployment bundle as an independent release unit starting at `0.0.1`. +- Publish bundles from annotated `bundle/vX.Y.Z` tags as dedicated GitHub Releases. +- Treat `bundle/v0.0.1` as a fresh first deployment. Existing locally built server-derived bundles receive no upgrade or rollback compatibility. +- Mirror the repository's existing component release conventions while adding bundle-specific build assets. + +## Interfaces and Versioning + +- Add `deploy/VERSION` as the bundle version source, initially `0.0.1`. The server version will no longer affect bundle naming, manifests, tags, or compatibility. +- Add a committed bundle policy containing `minimumUpgradeVersion` and `minimumRollbackVersion`, both initially `0.0.1`. +- Extend `scripts/release.sh` with `bundle`, updating `deploy/VERSION`, committing with the repository's deploy scope, and creating `bundle/vX.Y.Z`. +- Introduce manifest schema `2.0` with independent bundle versioning and explicit versions for the bundled server, admin, GSM service, and GSM firmware. +- Correct firmware validation to compare against the GSM service version instead of the bundle version. +- Use bundle-versioned Docker tags so future releases can coexist for rollback. + +## Workflow and Build Changes + +- Add a tag-triggered GitHub workflow that validates the tag, version, release policy, source commit, and annotated release notes. +- Download and checksum the national MBTiles source, crop Batangas, and validate the result before packaging. +- Add a low-disk build mode that saves images sequentially and removes no-longer-needed Docker data. +- Publish the bundle archive and its SHA-256 file through a draft release that becomes public only after both uploads succeed. +- Update versioning and deployment documentation for the independent bundle lifecycle. + +## Verification + +- Test policy validation, independent SemVer ordering, schema rejection, map integrity, and firmware-to-GSM compatibility. +- Run Bash syntax checks, deployment script tests, metadata validation tests, and a complete low-disk bundle build. +- Confirm `bundle/v0.0.1` creates one dedicated release without touching or depending on a `server/v*` release. + +## Assumptions + +- `bundle/v0.0.1` is only for fresh installation. +- No migration path is required for local `v0.1.0` or `v0.1.1` bundles. +- Every distributed rebuild receives a new bundle version even when component versions are unchanged. +- Publishing a GitHub Release does not deploy it to a live server. diff --git a/docs/deployment/docker-bundle.md b/docs/deployment/docker-bundle.md index 954e10a0..402c40e0 100644 --- a/docs/deployment/docker-bundle.md +++ b/docs/deployment/docker-bundle.md @@ -17,7 +17,7 @@ sequence from creating the CA USB stick to verifying the running stack. ```mermaid flowchart LR subgraph dev["Dev machine"] - RS["scripts/release.sh
tags version (semver)"] + RS["scripts/release.sh bundle
tags independent bundle version"] BB["scripts/build-bundle.sh
builds images, manifest.json,
CHECKSUMS.sha256, firmware .hex"] end @@ -140,23 +140,42 @@ tarball itself. ## Build and transport -On a clean, tagged checkout with Docker, Compose v2, `python3`, `zstd`, and -`arduino-cli` installed, run: +The bundle has its own version in `deploy/VERSION`; it does not inherit the server +version. Its committed compatibility bounds live in +`deploy/bundle-release-policy.json`. + +For the normal release path, prepare and merge the version change, then push the +annotated bundle tag. The GitHub workflow downloads and validates the map data, +builds the bundle, and attaches the archive and SHA-256 file to a dedicated release: + +```bash +./scripts/release.sh bundle 0.0.1 +git push origin HEAD +git push origin bundle/v0.0.1 +``` + +For a manual build on a clean, tagged checkout with Docker, Compose v2, `python3`, +`zstd`, `arduino-cli`, and the map data installed, run: ```bash -./scripts/build-bundle.sh --min-upgrade-version 1.4.0 --max-rollback-version 1.4.0 +./tileserver/download-script.sh +./scripts/build-bundle.sh ``` +Release CI passes `--low-disk`, which removes each saved image tag and prunes +Docker build cache as it proceeds. Use that mode only on an ephemeral or dedicated +build host, not on a workstation whose Docker cache or local image tags must be +preserved. + The result is `dist/sapot-bundle-vX.Y.Z.tar.zst`. Its `manifest.json` records the version, source commit, image IDs, firmware checksum, compatibility gates, and disk requirement. `CHECKSUMS.sha256` detects accidental corruption during transport. It is not a tamper-evident signature. -`--min-upgrade-version` sets the manifest's `minimumUpgradeVersion` gate: -`upgrade.sh` refuses to upgrade a target whose installed release is older than -this version, so operators aren't allowed to skip straight from an -unsupported ancient release into this one. `--max-rollback-version` sets -`maximumRollbackVersion`, described below under rollback. +The policy's `minimumUpgradeVersion` prevents an upgrade from an unsupported old +bundle. `minimumRollbackVersion` is the oldest bundle to which this release may +roll back. Bundle `0.0.1` sets both values to `0.0.1` because it is the first release +in a fresh version line and is intended only for new installations. Copy the tarball to the offline host by removable media, extract it, and run the bundled scripts from the extracted release. The host needs Docker Engine, @@ -196,7 +215,7 @@ sudo /opt/sapot/releases/current/scripts/rollback.sh 1.4.0 ``` Rollback never runs `alembic downgrade`. It requires the target to be retained -locally, permitted by `maximumRollbackVersion`, and an ancestor of the live DB +locally, permitted by `minimumRollbackVersion`, and an ancestor of the live DB revision. By default the current release and two prior releases, along with their image IDs and recent firmware backups, are retained. Run `scripts/lib/retention.sh --dry-run` to preview cleanup. @@ -279,7 +298,8 @@ At the end of a new install, `bootstrap-admin.sh` creates the first administrato ## Pitfalls -- **Bump the version for every rebuild**, even for a config/frontend-only fix. +- **Bump the bundle version for every rebuild**, even when all component versions + remain unchanged. `upgrade.sh` targets `releases/v$version`; if that directory already exists it skips copying the new bundle in, so a same-version rebuild silently fails to deploy. diff --git a/docs/deployment/install-ubuntu-server.md b/docs/deployment/install-ubuntu-server.md index e39781c0..32c69e1f 100644 --- a/docs/deployment/install-ubuntu-server.md +++ b/docs/deployment/install-ubuntu-server.md @@ -170,18 +170,20 @@ From the repository root, on a **clean** checkout of the tagged commit you want to release: ```bash -./scripts/build-bundle.sh --min-upgrade-version 1.4.0 --max-rollback-version 1.4.0 +./tileserver/download-script.sh +./scripts/build-bundle.sh ``` The build refuses to start if tracked files are modified, so that the recorded git SHA describes exactly what is inside the bundle. -| Flag | Meaning | -|---|---| -| `--min-upgrade-version` | The oldest installed release `upgrade.sh` will upgrade from. Prevents a jump from an unsupported ancient release. | -| `--max-rollback-version` | The newest release this one may be rolled back to. | +Compatibility bounds come from the reviewed `deploy/bundle-release-policy.json` +file. `minimumUpgradeVersion` is the oldest installed bundle accepted by +`upgrade.sh`; `minimumRollbackVersion` is the oldest retained bundle accepted by +`rollback.sh`. -Both default to the version being built, taken from `server/app/version.py`. +The bundle version comes from `deploy/VERSION` and is independent of the server, +admin, and GSM component versions recorded in the generated manifest. **Bump the version for every rebuild**, even a config-only or frontend-only change. `upgrade.sh` targets `releases/v$version` and skips copying if that diff --git a/docs/deployment/tileserver.md b/docs/deployment/tileserver.md index 8b639c04..47a42254 100644 --- a/docs/deployment/tileserver.md +++ b/docs/deployment/tileserver.md @@ -58,6 +58,11 @@ bash tileserver/download-script.sh # fetches the national source, the python3 tileserver/crop-mbtiles.py --region batangas --no-overview --min-zoom 9 ``` +The download script verifies the national extract against the committed +`tileserver/osm-source.sha256` before cropping. Release CI uses +`--cleanup-source` to remove the 432 MB source after producing and validating the +Batangas database. + To change the deployment region, add a bbox to `REGIONS` in `crop-mbtiles.py`, re-run the crop for the new region, and update the hardcoded filename in **both** `deploy-tiling-server-detached.sh` and `docker-compose.yml`'s `tileserver.command` — plus the matching `REGION_MAX_BOUNDS`/`REGION_MIN_ZOOM` client constants in `admin-frontend/sapot-admin/ui/components/MapLibre.tsx` and `mobile-app/sapot-mobile-app/app/(drawer)/(tabs)/map.tsx`. See `tileserver/CLAUDE.md` for the full detail, including the zoom-floor pitfall (stored min-zoom must be one below the clients' `minZoom`). --- diff --git a/docs/devops/README.md b/docs/devops/README.md index c9ae8978..0eea1f20 100644 --- a/docs/devops/README.md +++ b/docs/devops/README.md @@ -44,7 +44,9 @@ Entry point for deploying, operating, and maintaining SAPOT in a live environmen ## Releases -[VERSIONING.md](../../VERSIONING.md) — git-tag-driven versioning, independent for mobile and server. `server/app/version.py` must match the git tag before tagging a server release. +[VERSIONING.md](../../VERSIONING.md) documents the independent mobile, server, +admin, portal, GSM, and deployment-bundle tags. Bundle releases use +`deploy/VERSION` and publish the offline archive from `bundle/v*` tags. ## When something breaks diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 1bb48322..5849b16e 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -5,7 +5,7 @@ Root-level release orchestration and documentation-generation tooling — spans ## Development Workflow - Python scripts (`generate_openapi_docs.py`, `generate_db_docs.py`) run against `server/`'s dependencies — install `server/app/requirements.txt` first, or run from an environment that already has the server's stack. -- `release.sh` is the entry point end users/agents invoke directly: `./scripts/release.sh ` — see root `VERSIONING.md` for the full flow. +- `release.sh` is the entry point end users/agents invoke directly: `./scripts/release.sh ` - see root `VERSIONING.md` for the full flow. ## Build @@ -13,7 +13,9 @@ None — these are standalone scripts, not a packaged/compiled project. ## Test -- No tests exist for `generate_openapi_docs.py` / `generate_db_docs.py` or `release.sh` — verify these manually (e.g. `--check` mode, per CI) rather than assuming coverage. +- No tests exist for `generate_openapi_docs.py` / `generate_db_docs.py` or the + component-bump branches of `release.sh`. Bundle release metadata validation is + covered by `python3 -m unittest scripts.tests.test_validate_bundle_release`. ## Lint / Format @@ -24,6 +26,9 @@ None configured for this directory specifically (no ESLint/Prettier/ruff config - `generate_openapi_docs.py` and `generate_db_docs.py` support a `--check` flag (used by `.github/workflows/openapi-docs-check.yml` to fail on drift) — if you change either script's output format, run it with `--check` against current `docs/` output to confirm CI won't break, and regenerate `docs/api/openapi/**`/`docs/database/*.md` if the change is intentional. - `release.sh` never pushes automatically (prints the push command for the user to run). Don't add auto-push without flagging it as a deliberate policy change. - `release.sh` tags with a plain ` ` annotated message — it no longer drafts or opens release notes; CI publishes that message verbatim as the GitHub Release body. +- Deployment bundles use `deploy/VERSION`, not the server version. The committed + `deploy/bundle-release-policy.json` supplies upgrade and rollback floors, and + `.github/workflows/release-bundle.yml` publishes the archive for `bundle/v*` tags. - The bundle's `systemd/` directory carries only the two `sapot-db-backup` units, copied by name rather than by glob. The other units in `deployment-scripts/` describe the bare-metal deployment (`server-main-api`, `server-GSM-api`, `tileserver`) and must not ship: `install.sh` installs everything it finds in `systemd/`, so shipping them would drop bare-metal units onto a containerized host. - CA **key material** (`server_ca.key`, `server_ca.pem`) must never be baked into a bundle. Servers read the CA from a USB stick plugged in at issuance time (`deploy/scripts/lib/ca-certs.sh`) and retain only the public `server_ca.pem` afterwards, as `doctor.sh`'s trust anchor. `scripts/build-bundle.sh`'s `check_no_ca_material` guard enforces this and must keep firing. - `docker/gen-certs.sh` must never be copied into a bundle either: it can fall back to self-signing, and a self-signed cert is untrusted by every CA-pinning mobile build. It is a dev/CI-only tool for `docker-compose.yml`'s `certgen` service. Servers have exactly one issuance path — the offline CA on the USB stick — and `install.sh` aborts rather than falling back. diff --git a/scripts/build-bundle.sh b/scripts/build-bundle.sh index bca0b610..20872100 100755 --- a/scripts/build-bundle.sh +++ b/scripts/build-bundle.sh @@ -4,46 +4,161 @@ set -euo pipefail repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) cd "$repo_root" -usage() { echo "usage: $0 [--min-upgrade-version VERSION] [--max-rollback-version VERSION]" >&2; exit 2; } -min_version= max_version= -while [ $# -gt 0 ]; do case "$1" in - --min-upgrade-version) min_version=${2:?}; shift 2;; - --max-rollback-version) max_version=${2:?}; shift 2;; - *) usage;; esac; done -for command in arduino-cli docker python3 zstd sha256sum tar; do command -v "$command" >/dev/null || { echo "missing required command: $command" >&2; exit 1; }; done + +usage() { + echo "usage: $0 [--release-policy PATH] [--low-disk]" >&2 + exit 2 +} + +policy=deploy/bundle-release-policy.json +low_disk=false +while [ "$#" -gt 0 ]; do + case "$1" in + --release-policy) policy=${2:?}; shift 2 ;; + --low-disk) low_disk=true; shift ;; + *) usage ;; + esac +done + +for command in arduino-cli docker python3 zstd sha256sum tar; do + command -v "$command" >/dev/null || { echo "missing required command: $command" >&2; exit 1; } +done docker compose version >/dev/null || { echo "Docker Compose v2 is required" >&2; exit 1; } -if [ -n "$(git status --porcelain --untracked-files=no)" ]; then echo "refusing to build from a dirty tracked worktree" >&2; exit 1; fi +if [ -n "$(git status --porcelain --untracked-files=no)" ]; then + echo "refusing to build from a dirty tracked worktree" >&2 + exit 1 +fi + +release_metadata=$(python3 scripts/validate_bundle_release.py --policy "$policy" --json) +readarray -t release_values < <(python3 - "$release_metadata" <<'PY' +import json +import sys -version=$(python3 -c "import ast; print(ast.literal_eval(open('server/app/version.py').read().split('=', 1)[1].strip()))") -min_version=${min_version:-$version}; max_version=${max_version:-$version} -python3 deploy/scripts/lib/semver.py compare "$version" "$min_version" >/dev/null -python3 deploy/scripts/lib/semver.py compare "$version" "$max_version" >/dev/null -git_sha=$(git rev-parse HEAD); short_sha=$(git rev-parse --short HEAD); built_at=$(date -u +%Y-%m-%dT%H:%M:%SZ); build_stamp=$(date -u +%Y%m%dT%H%M%SZ) +metadata = json.loads(sys.argv[1]) +for key in ("version", "minimumUpgradeVersion", "minimumRollbackVersion"): + print(metadata[key]) +PY +) +version=${release_values[0]} +minimum_upgrade=${release_values[1]} +minimum_rollback=${release_values[2]} +output="dist/sapot-bundle-v$version.tar.zst" +[ ! -e "$output" ] || { echo "refusing to overwrite existing $output" >&2; exit 1; } + +server_version=$(python3 -c "import ast; print(ast.literal_eval(open('server/app/version.py').read().split('=', 1)[1].strip()))") +admin_version=$(python3 -c "import json; print(json.load(open('admin-frontend/sapot-admin/package.json'))['version'])") +gsm_version=$(python3 -c "import ast; print(ast.literal_eval(open('GSM-module/GSM-fastapi/app_version.py').read().split('=', 1)[1].strip()))") +firmware_source=GSM-module/GSM-arduino-actual-code/GSM-arduino-actual-code.ino +firmware_version=$(sed -n 's/^#define FIRMWARE_VERSION "\([^"]*\)"/\1/p' "$firmware_source") +[ "$gsm_version" = "$firmware_version" ] || { + echo "GSM service version ($gsm_version) does not match firmware version ($firmware_version)" >&2 + exit 1 +} + +map_file=tileserver/osm-batangas.mbtiles +[ -f "$map_file" ] || { + echo "missing $map_file; run tileserver/download-script.sh first" >&2 + exit 1 +} +map_source_sha=$(awk 'NR == 1 {print $1}' tileserver/osm-source.sha256) +python3 - "$map_file" "$map_source_sha" <<'PY' +import sqlite3 +import sys + +with sqlite3.connect(f"file:{sys.argv[1]}?mode=ro", uri=True) as database: + integrity = database.execute("PRAGMA integrity_check").fetchone()[0] + metadata = dict(database.execute("SELECT name, value FROM metadata")) +if integrity != "ok": + raise SystemExit(f"MBTiles integrity check failed: {integrity}") +expected = { + "sapot:region": "batangas", + "sapot:source_sha256": sys.argv[2], + "minzoom": "9", + "maxzoom": "14", + "scheme": "tms", +} +for key, value in expected.items(): + if metadata.get(key) != value: + raise SystemExit(f"MBTiles metadata {key}={metadata.get(key)!r}, expected {value!r}") +PY +map_artifact_sha=$(sha256sum "$map_file" | awk '{print $1}') + +if "$low_disk"; then + minimum_free=${SAPOT_BUNDLE_MIN_FREE_BYTES:-10737418240} + docker_root=$(docker info --format '{{.DockerRootDir}}') + for path in "$repo_root" "$docker_root"; do + available=$(df -PB1 "$path" | awk 'NR == 2 {print $4}') + [ "$available" -ge "$minimum_free" ] || { + echo "low-disk build requires at least $minimum_free free bytes on $path; found $available" >&2 + exit 1 + } + done +fi + +git_sha=$(git rev-parse HEAD) +built_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) scratch=$(mktemp -d "$repo_root/.bundle-build.XXXXXX") -trap 'rm -rf "$scratch"' EXIT +temporary_output= +cleanup() { + rm -rf "$scratch" + [ -z "$temporary_output" ] || rm -f "$temporary_output" +} +trap cleanup EXIT + bundle="$scratch/sapot-bundle-v$version" mkdir -p "$bundle"/{images,compose,config,data,certs,firmware,scripts,systemd} +digest_file="$scratch/image-digests.tsv" +: > "$digest_file" declare -A tags=( - [api]="sapot/api:bundle" [admin]="sapot/admin:bundle" [gsm-fastapi]="sapot/gsm-fastapi:bundle" - [arduino-flasher]="sapot/arduino-flasher:bundle" [mariadb]="sapot/mariadb:bundle" - [redis]="sapot/redis:bundle" [nginx]="sapot/nginx:bundle" [tileserver-gl]="sapot/tileserver-gl:bundle" + [api]="sapot/api:bundle-v$version" + [admin]="sapot/admin:bundle-v$version" + [gsm-fastapi]="sapot/gsm-fastapi:bundle-v$version" + [arduino-flasher]="sapot/arduino-flasher:bundle-v$version" + [mariadb]="sapot/mariadb:bundle-v$version" + [redis]="sapot/redis:bundle-v$version" + [nginx]="sapot/nginx:bundle-v$version" + [tileserver-gl]="sapot/tileserver-gl:bundle-v$version" ) -docker build -t "${tags[api]}" server -docker build -t "${tags[admin]}" admin-frontend/sapot-admin -docker build -t "${tags[gsm-fastapi]}" GSM-module/GSM-fastapi -docker build -t "${tags[arduino-flasher]}" deploy/docker/arduino-flasher -for pair in "mariadb:mariadb:11" "redis:redis:7-alpine" "nginx:nginx:1.27-alpine" "tileserver-gl:maptiler/tileserver-gl"; do - name=${pair%%:*}; source_image=${pair#*:}; docker pull "$source_image"; docker tag "$source_image" "${tags[$name]}" -done -for name in "${!tags[@]}"; do docker save -o "$bundle/images/$name.tar" "${tags[$name]}"; done -cp tileserver/osm-batangas.mbtiles "$bundle/data/" -cp -a server/static "$bundle/data/static" -firmware_source=GSM-module/GSM-arduino-actual-code/GSM-arduino-actual-code.ino +save_image() { + local name=$1 tag=$2 digest + digest=$(docker image inspect --format '{{.Id}}' "$tag") + docker save -o "$bundle/images/$name.tar" "$tag" + printf '%s\t%s\t%s\n' "$name" "$tag" "$digest" >> "$digest_file" +} + +cleanup_images() { + "$low_disk" || return 0 + docker image rm "$@" >/dev/null 2>&1 || true + docker builder prune --all --force >/dev/null +} + +build_and_save() { + local name=$1 context=$2 tag=${tags[$1]} + docker build -t "$tag" "$context" + save_image "$name" "$tag" + cleanup_images "$tag" +} + +pull_and_save() { + local name=$1 source=$2 tag=${tags[$1]} + docker pull "$source" + docker tag "$source" "$tag" + save_image "$name" "$tag" + cleanup_images "$tag" "$source" +} + +build_and_save api server +build_and_save admin admin-frontend/sapot-admin +build_and_save gsm-fastapi GSM-module/GSM-fastapi +build_and_save arduino-flasher deploy/docker/arduino-flasher +pull_and_save mariadb mariadb:11 +pull_and_save redis redis:7-alpine +pull_and_save nginx nginx:1.27-alpine +pull_and_save tileserver-gl maptiler/tileserver-gl + fqbn=${GSM_ARDUINO_FQBN:-arduino:avr:uno} -# A fresh arduino-cli installation has no board platform. This runs only on -# the connected build host; deployment hosts receive the already-built hex. arduino-cli core list | awk '{print $1}' | grep -qx 'arduino:avr' || { arduino-cli core update-index arduino-cli core install arduino:avr @@ -53,72 +168,160 @@ hex=$(find "$scratch/firmware-build" -name '*.hex' -print -quit) [ -n "$hex" ] || { echo "firmware compilation produced no .hex file" >&2; exit 1; } cp "$hex" "$bundle/firmware/gsm-arduino-actual-code.hex" cp "$firmware_source" "$bundle/firmware/gsm-arduino-actual-code.ino" -firmware_version=$(sed -n 's/^#define FIRMWARE_VERSION "\([^"]*\)"/\1/p' "$firmware_source") firmware_sha=$(sha256sum "$bundle/firmware/gsm-arduino-actual-code.hex" | awk '{print $1}') -cp docker-compose.prod.yml "$bundle/compose/docker-compose.yml" +python3 - docker-compose.prod.yml "$bundle/compose/docker-compose.yml" "$version" <<'PY' +from pathlib import Path +import sys + +source, destination, version = sys.argv[1:] +text = Path(source).read_text(encoding="utf-8") +names = ("api", "admin", "gsm-fastapi", "arduino-flasher", "mariadb", "redis", "nginx", "tileserver-gl") +for name in names: + text = text.replace(f"sapot/{name}:bundle", f"sapot/{name}:bundle-v{version}") +Path(destination).write_text(text, encoding="utf-8") +PY cp docker-compose.gsm-hardware.yml "$bundle/compose/" cp deploy/config/* "$bundle/config/" -# gen-certs.sh is deliberately not shipped: it can self-sign, and servers issue -# their leaf from the offline CA on a USB stick instead (scripts/request-cert.sh). -# It stays a dev/CI-only tool driven by docker-compose.yml's certgen service. +cp "$map_file" "$bundle/data/" +cp -a server/static "$bundle/data/static" cp docker/detect-ip.sh "$bundle/certs/" cp -a deploy/scripts/. "$bundle/scripts/" -# Units get their own directory rather than riding along in scripts/, which is -# chmod +x wholesale below - a unit file is read by systemd, never executed. -# install.sh installs whatever lands here; see deploy-common.sh. cp deployment-scripts/sapot-db-backup.service deployment-scripts/sapot-db-backup.timer "$bundle/systemd/" -# Guards against CA key material ever reaching a bundle (scripts/AGENTS.md). -# Servers read the CA from a USB stick at issuance time and keep only the public -# server_ca.pem afterwards; neither may be baked into a release. Called here -# right after the file copies that could introduce them, and again immediately -# before CHECKSUMS.sha256 is generated below - the second call is the last check -# before the bundle is sealed, so anything added between here and there still -# gets caught. check_no_ca_material() { local hit - hit=$(find "$bundle" \( -name 'server_ca.key' -o -name 'server_ca.pem' \) -print -quit) - [ -z "$hit" ] || { echo "refusing to ship CA key material in a bundle: $hit" >&2; exit 1; } + hit=$(find "$bundle" \( -name 'server_ca.key' -o -name 'server_ca.pem' -o -name 'gen-certs.sh' \) -print -quit) + [ -z "$hit" ] || { echo "refusing to ship forbidden certificate material in bundle: $hit" >&2; exit 1; } } check_no_ca_material chmod +x "$bundle/scripts"/*.sh "$bundle/scripts"/lib/*.sh "$bundle/scripts"/lib/*.py -python3 - "$bundle/manifest.json" "$version" "$git_sha" "$built_at" "$min_version" "$max_version" "$firmware_version" "$fqbn" "$firmware_sha" "$bundle" <<'PY' -import json, os, subprocess, sys -out, version, sha, built, minimum, maximum, fw_version, fqbn, fw_sha, root = sys.argv[1:] -names = ["api", "admin", "gsm-fastapi", "arduino-flasher", "mariadb", "redis", "nginx", "tileserver-gl"] +python3 - "$bundle/manifest.json" "$version" "$git_sha" "$built_at" "$minimum_upgrade" "$minimum_rollback" "$server_version" "$admin_version" "$gsm_version" "$firmware_version" "$fqbn" "$firmware_sha" "$map_source_sha" "$map_artifact_sha" "$bundle" "$digest_file" <<'PY' +import json +import os +import subprocess +import sys + +( + out, + version, + sha, + built, + minimum_upgrade, + minimum_rollback, + server_version, + admin_version, + gsm_version, + firmware_version, + fqbn, + firmware_sha, + map_source_sha, + map_artifact_sha, + root, + digest_file, +) = sys.argv[1:] + images = {} -for name in names: - tag = f"sapot/{name}:bundle" - digest = subprocess.check_output(["docker", "image", "inspect", "--format", "{{.Id}}", tag], text=True).strip() - images[name] = {"tag": tag, "digest": digest} -required = sum(os.path.getsize(os.path.join(root, "images", f"{name}.tar")) for name in names) -required += sum(os.path.getsize(os.path.join(path, file)) for path, _, files in os.walk(os.path.join(root, "data")) for file in files) -required += sum(os.path.getsize(os.path.join(path, file)) for path, _, files in os.walk(os.path.join(root, "firmware")) for file in files) +with open(digest_file, encoding="utf-8") as lines: + for line in lines: + name, tag, digest = line.rstrip("\n").split("\t") + images[name] = {"tag": tag, "digest": digest} + +required = sum(os.path.getsize(os.path.join(root, "images", f"{name}.tar")) for name in images) +for directory in ("data", "firmware"): + required += sum( + os.path.getsize(os.path.join(path, filename)) + for path, _, filenames in os.walk(os.path.join(root, directory)) + for filename in filenames + ) + def command(*args): - try: return subprocess.check_output(args, text=True, stderr=subprocess.DEVNULL).strip() - except Exception: return "unavailable" -manifest = {"schemaVersion":"1.0", "bundleId": f"{version}-{sha[:7]}-{built.replace('-', '').replace(':', '')}", "version":version, "gitSha":sha, "builtAt":built, "minimumUpgradeVersion":minimum, "maximumRollbackVersion":maximum, "requiredDiskBytes":required, "images":images, "gsmFirmware":{"version":fw_version,"board":"Arduino Uno","fqbn":fqbn,"sha256":fw_sha,"protocolVersion":"2","compatibleGsmFastapiVersion":f">={version}"}, "buildEnvironment":{"os":command("sh", "-c", ". /etc/os-release && echo $PRETTY_NAME"), "dockerEngineVersion":command("docker", "version", "--format", "{{.Server.Version}}"), "composeVersion":command("docker", "compose", "version", "--short"), "arduinoCliVersion":command("arduino-cli", "version")}} -json.dump(manifest, open(out, "w", encoding="utf-8"), indent=2); print() + try: + return subprocess.check_output(args, text=True, stderr=subprocess.DEVNULL).strip() + except Exception: + return "unavailable" + +manifest = { + "schemaVersion": "2.0", + "bundleId": f"{version}-{sha[:7]}-{built.replace('-', '').replace(':', '')}", + "version": version, + "gitSha": sha, + "builtAt": built, + "minimumUpgradeVersion": minimum_upgrade, + "minimumRollbackVersion": minimum_rollback, + "requiredDiskBytes": required, + "componentVersions": { + "server": server_version, + "admin": admin_version, + "gsmFastapi": gsm_version, + "gsmFirmware": firmware_version, + }, + "images": images, + "mapData": { + "region": "batangas", + "sourceSha256": map_source_sha, + "artifactSha256": map_artifact_sha, + }, + "gsmFirmware": { + "version": firmware_version, + "board": "Arduino Uno", + "fqbn": fqbn, + "sha256": firmware_sha, + "protocolVersion": "2", + "compatibleGsmFastapiVersion": f"={gsm_version}", + }, + "buildEnvironment": { + "os": command("sh", "-c", ". /etc/os-release && echo $PRETTY_NAME"), + "dockerEngineVersion": command("docker", "version", "--format", "{{.Server.Version}}"), + "composeVersion": command("docker", "compose", "version", "--short"), + "arduinoCliVersion": command("arduino-cli", "version"), + }, +} +with open(out, "w", encoding="utf-8") as output: + json.dump(manifest, output, indent=2) + output.write("\n") PY + python3 - "$bundle/manifest.json" <<'PY' -import json, sys -m=json.load(open(sys.argv[1])); required={"schemaVersion","bundleId","version","gitSha","builtAt","minimumUpgradeVersion","maximumRollbackVersion","requiredDiskBytes","images","gsmFirmware","buildEnvironment"} -if m.get("schemaVersion") != "1.0" or not required <= m.keys() or set(m["images"]) != {"api","admin","gsm-fastapi","arduino-flasher","mariadb","redis","nginx","tileserver-gl"}: raise SystemExit("manifest does not satisfy schema 1.0") +import json +import sys + +manifest = json.load(open(sys.argv[1], encoding="utf-8")) +required = { + "schemaVersion", "bundleId", "version", "gitSha", "builtAt", + "minimumUpgradeVersion", "minimumRollbackVersion", "requiredDiskBytes", + "componentVersions", "images", "mapData", "gsmFirmware", "buildEnvironment", +} +expected_images = {"api", "admin", "gsm-fastapi", "arduino-flasher", "mariadb", "redis", "nginx", "tileserver-gl"} +if manifest.get("schemaVersion") != "2.0" or not required <= manifest.keys() or set(manifest["images"]) != expected_images: + raise SystemExit("manifest does not satisfy schema 2.0") PY + python3 - "$bundle/manifest.json" > "$bundle/BUILD_INFO.txt" <<'PY' -import json, sys -m=json.load(open(sys.argv[1])); print(f"SAPOT bundle v{m['version']}\nBundle ID: {m['bundleId']}\nGit SHA: {m['gitSha']}\nBuilt: {m['builtAt']}") -print("Images:"); [print(f" {n}: {i['tag']} ({i['digest']})") for n,i in m['images'].items()] -print(f"Firmware: {m['gsmFirmware']['version']} ({m['gsmFirmware']['fqbn']})") +import json +import sys + +manifest = json.load(open(sys.argv[1], encoding="utf-8")) +print(f"SAPOT bundle v{manifest['version']}") +print(f"Bundle ID: {manifest['bundleId']}") +print(f"Git SHA: {manifest['gitSha']}") +print(f"Built: {manifest['builtAt']}") +print("Components:") +for name, version in manifest["componentVersions"].items(): + print(f" {name}: {version}") +print("Images:") +for name, image in manifest["images"].items(): + print(f" {name}: {image['tag']} ({image['digest']})") PY + check_no_ca_material -(cd "$bundle" && find . -type f ! -name CHECKSUMS.sha256 -print0 | sort -z | xargs -0 sha256sum > CHECKSUMS.sha256) +checksums="$scratch/CHECKSUMS.sha256" +(cd "$bundle" && find . -type f -print0 | sort -z | xargs -0 sha256sum > "$checksums") +mv "$checksums" "$bundle/CHECKSUMS.sha256" mkdir -p dist -output="dist/sapot-bundle-v$version.tar.zst" temporary_output="$output.tmp.$$" -trap 'rm -rf "$scratch"; rm -f "$temporary_output"' EXIT tar -C "$scratch" -cf - "$(basename "$bundle")" | zstd -T0 -19 -o "$temporary_output" -mv -f "$temporary_output" "$output" +mv "$temporary_output" "$output" +temporary_output= echo "built $output" diff --git a/scripts/release.sh b/scripts/release.sh index bfaf877d..22277ad3 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# scripts/release.sh — cut a component release tag. -# Usage: ./scripts/release.sh +# scripts/release.sh - cut a component release tag. +# Usage: ./scripts/release.sh set -euo pipefail component="${1:-}" @@ -8,12 +8,12 @@ version="${2:-}" root="$(cd "$(dirname "$0")/.." && pwd)" if [[ -z "$component" || -z "$version" ]]; then - echo "Usage: ./scripts/release.sh " >&2 + echo "Usage: ./scripts/release.sh " >&2 exit 1 fi if [[ -n "$(git -C "$root" status --porcelain)" ]]; then - echo "Working tree not clean — commit or stash first." >&2 + echo "Working tree not clean - commit or stash first." >&2 exit 1 fi @@ -28,7 +28,7 @@ case "$component" in tag="server/v$version" ;; admin) if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$ ]]; then - echo "Invalid version '$version' — expected X.Y.Z[-(alpha|beta|rc).N]" >&2 + echo "Invalid version '$version' - expected X.Y.Z[-(alpha|beta|rc).N]" >&2 exit 1 fi (cd "$root/admin-frontend/sapot-admin" && pnpm version "$version" --no-git-tag-version --allow-same-version >/dev/null) @@ -36,7 +36,7 @@ case "$component" in tag="admin/v$version" ;; portal) if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$ ]]; then - echo "Invalid version '$version' — expected X.Y.Z[-(alpha|beta|rc).N]" >&2 + echo "Invalid version '$version' - expected X.Y.Z[-(alpha|beta|rc).N]" >&2 exit 1 fi echo "$version" > "$root/captive-portal/VERSION" @@ -46,19 +46,32 @@ case "$component" in python3 "$root/GSM-module/scripts/set_version.py" "$version" git -C "$root" add GSM-module/GSM-fastapi/app_version.py GSM-module/GSM-arduino-actual-code/GSM-arduino-actual-code.ino tag="gsm/v$version" ;; + bundle) + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$ ]]; then + echo "Invalid version '$version' - expected X.Y.Z[-(alpha|beta|rc).N]" >&2 + exit 1 + fi + printf '%s\n' "$version" > "$root/deploy/VERSION" + python3 "$root/scripts/validate_bundle_release.py" + git -C "$root" add deploy/VERSION + tag="bundle/v$version" ;; *) - echo "Unknown component '$component' (expected mobile|server|admin|portal|gsm)" >&2 + echo "Unknown component '$component' (expected mobile|server|admin|portal|gsm|bundle)" >&2 exit 1 ;; esac if git -C "$root" diff --cached --quiet; then - echo "Version already at $version — skipping bump commit." >&2 + echo "Version already at $version - skipping bump commit." >&2 else - git -C "$root" commit -m "chore(version): $component $version" + if [[ "$component" = bundle ]]; then + git -C "$root" commit -m "chore(deploy-version): bump bundle to $version" + else + git -C "$root" commit -m "chore(version): $component $version" + fi fi if git -C "$root" tag -l "$tag" | grep -q .; then - echo "Tag $tag already exists — delete it first with: git tag -d $tag" >&2 + echo "Tag $tag already exists - delete it first with: git tag -d $tag" >&2 exit 1 fi git -C "$root" tag -a "$tag" -m "$component $version" diff --git a/scripts/tests/test_validate_bundle_release.py b/scripts/tests/test_validate_bundle_release.py new file mode 100644 index 00000000..3820a307 --- /dev/null +++ b/scripts/tests/test_validate_bundle_release.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import json +from pathlib import Path +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts")) + +from validate_bundle_release import validate + + +class ValidateBundleReleaseTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary_directory = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary_directory.cleanup) + self.directory = Path(self.temporary_directory.name) + self.version_file = self.directory / "VERSION" + self.policy_file = self.directory / "policy.json" + self.version_file.write_text("0.0.1\n", encoding="utf-8") + self.write_policy("0.0.1", "0.0.1") + + def write_policy(self, minimum_upgrade: str, minimum_rollback: str) -> None: + self.policy_file.write_text( + json.dumps( + { + "schemaVersion": "1.0", + "minimumUpgradeVersion": minimum_upgrade, + "minimumRollbackVersion": minimum_rollback, + } + ), + encoding="utf-8", + ) + + def test_accepts_matching_initial_release(self) -> None: + values = validate(self.version_file, self.policy_file, "bundle/v0.0.1") + self.assertEqual("0.0.1", values["version"]) + + def test_rejects_tag_mismatch(self) -> None: + with self.assertRaisesRegex(ValueError, "does not match"): + validate(self.version_file, self.policy_file, "bundle/v0.0.2") + + def test_rejects_non_bundle_tag(self) -> None: + with self.assertRaisesRegex(ValueError, "invalid bundle tag"): + validate(self.version_file, self.policy_file, "server/v0.0.1") + + def test_rejects_floor_above_bundle_version(self) -> None: + self.write_policy("0.0.2", "0.0.1") + with self.assertRaisesRegex(ValueError, "cannot exceed"): + validate(self.version_file, self.policy_file) + + def test_rejects_unknown_policy_fields(self) -> None: + policy = json.loads(self.policy_file.read_text(encoding="utf-8")) + policy["maximumRollbackVersion"] = "0.0.1" + self.policy_file.write_text(json.dumps(policy), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "invalid policy keys"): + validate(self.version_file, self.policy_file) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/validate_bundle_release.py b/scripts/validate_bundle_release.py new file mode 100755 index 00000000..1f5bf640 --- /dev/null +++ b/scripts/validate_bundle_release.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Validate the independent deployment-bundle version and release policy.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +from pathlib import Path +import re +import sys + + +ROOT = Path(__file__).resolve().parents[1] +SEMVER_PATH = ROOT / "deploy" / "scripts" / "lib" / "semver.py" +TAG_PATTERN = re.compile(r"bundle/v(.+)") + + +def load_semver_module(): + spec = importlib.util.spec_from_file_location("sapot_semver", SEMVER_PATH) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot load SemVer helper from {SEMVER_PATH}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def validate(version_file: Path, policy_file: Path, tag: str | None = None) -> dict[str, str]: + semver = load_semver_module() + version = version_file.read_text(encoding="utf-8").strip() + semver.parse(version) + + policy = json.loads(policy_file.read_text(encoding="utf-8")) + expected_keys = { + "schemaVersion", + "minimumUpgradeVersion", + "minimumRollbackVersion", + } + if set(policy) != expected_keys: + missing = sorted(expected_keys - set(policy)) + extra = sorted(set(policy) - expected_keys) + raise ValueError(f"invalid policy keys: missing={missing}, extra={extra}") + if policy["schemaVersion"] != "1.0": + raise ValueError("unsupported bundle release policy schema") + + for field in ("minimumUpgradeVersion", "minimumRollbackVersion"): + value = policy[field] + semver.parse(value) + if semver.compare(value, version) > 0: + raise ValueError(f"{field} ({value}) cannot exceed bundle version ({version})") + + if tag is not None: + match = TAG_PATTERN.fullmatch(tag) + if match is None: + raise ValueError(f"invalid bundle tag: {tag}") + if match.group(1) != version: + raise ValueError(f"bundle tag version ({match.group(1)}) does not match deploy/VERSION ({version})") + + return { + "version": version, + "minimumUpgradeVersion": policy["minimumUpgradeVersion"], + "minimumRollbackVersion": policy["minimumRollbackVersion"], + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--version-file", type=Path, default=ROOT / "deploy" / "VERSION") + parser.add_argument("--policy", type=Path, default=ROOT / "deploy" / "bundle-release-policy.json") + parser.add_argument("--tag") + parser.add_argument("--json", action="store_true", dest="as_json") + args = parser.parse_args() + + try: + values = validate(args.version_file, args.policy, args.tag) + except (OSError, ValueError, json.JSONDecodeError) as error: + print(f"bundle release validation failed: {error}", file=sys.stderr) + return 1 + + if args.as_json: + print(json.dumps(values, separators=(",", ":"))) + else: + print(f"bundle release metadata is valid for v{values['version']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tileserver/CLAUDE.md b/tileserver/CLAUDE.md index 8934b637..c3637b00 100644 --- a/tileserver/CLAUDE.md +++ b/tileserver/CLAUDE.md @@ -14,7 +14,10 @@ Docker image `maptiler/tileserver-gl`, bind-mounting this directory (which must - `deploy-tiling-server.sh` — foreground/interactive run (`docker run -it`), for local development. - `deploy-tiling-server-detached.sh` — background/production run: force-removes any existing `tileserver` container first, then starts a fresh named container with a hardcoded `.mbtiles` filename and host path. -- `download-script.sh` — fetches the full Philippines `.mbtiles` via `curl` from a Google Drive link, then crops it to the deployment region. Takes an optional region argument (default `batangas`). +- `download-script.sh` - fetches the full Philippines `.mbtiles`, verifies it + against `osm-source.sha256`, then crops it to the deployment region. It takes + an optional region argument (default `batangas`) and `--cleanup-source` for + ephemeral release runners. - `crop-mbtiles.py` — cuts the 432 MB national extract down to the ~27 MB regional file that is actually served. Deployment uses `--no-overview --min-zoom 9`, keeping only z9-14 inside the region bbox; without `--no-overview` it instead keeps a nationwide low-zoom overview plus regional detail. Opens the source read-only and builds the output fresh, so peak disk usage is the size of the output, not 3x the input. - `documentation.org` — setup instructions, the `.mbtiles` download link, and an example tile-consumer HTML snippet. - `.gitignore` — excludes `*.mbtiles` (the data file is not committed; see Common Pitfalls). diff --git a/tileserver/download-script.sh b/tileserver/download-script.sh index e9f1b4ea..6c59af0e 100755 --- a/tileserver/download-script.sh +++ b/tileserver/download-script.sh @@ -1,31 +1,88 @@ #!/bin/sh -# Fetch the full Philippines extract, then crop it to the deployment region. +# Fetch the pinned Philippines extract, verify it, then crop the deployment region. # # Only the cropped file is served (see deploy-tiling-server-detached.sh and # docker-compose.yml). The full extract is kept afterwards so a different -# region can be cut later without re-downloading 432 MB -- delete it manually -# if the host is short on disk. +# region can be cut later without re-downloading 432 MB. Pass --cleanup-source +# on an ephemeral CI runner to remove it after a successful crop. set -e -REGION="${1:-batangas}" +SCRIPT_DIR=$(cd -P -- "$(dirname -- "$0")" && pwd) +cd "$SCRIPT_DIR" + +REGION=batangas +CLEANUP_SOURCE=false +while [ "$#" -gt 0 ]; do + case "$1" in + --cleanup-source) CLEANUP_SOURCE=true ;; + *) REGION=$1 ;; + esac + shift +done + SOURCE="osm-2020-02-10-v3.11_asia_philippines.mbtiles" +SOURCE_URL="https://drive.usercontent.google.com/download?id=1UVakmRkrHaz2J1cgCIbkAHsHDW9SYwLq&export=download&confirm=t&uuid=decf843a-461b-43c5-8c08-11a3789ab93b" if [ ! -f "$SOURCE" ]; then echo "### downloading full Philippines extract (432 MB) ###" - curl -L -o "$SOURCE" \ - "https://drive.usercontent.google.com/download?id=1UVakmRkrHaz2J1cgCIbkAHsHDW9SYwLq&export=download&confirm=t&uuid=decf843a-461b-43c5-8c08-11a3789ab93b" + temporary="$SOURCE.download.$$" + trap 'rm -f "$temporary"' EXIT HUP INT TERM + curl --fail --location --retry 3 --output "$temporary" "$SOURCE_URL" + mv "$temporary" "$SOURCE" + trap - EXIT HUP INT TERM else echo "### $SOURCE already present, skipping download ###" fi +sha256sum --check --strict osm-source.sha256 + echo "### cropping to region: $REGION ###" # --no-overview: this deployment shows only its own province, so the # nationwide low-zoom levels are dropped too. --min-zoom 9 is one below the # clients' minZoom of 10, because tileserver-gl renders a raster tile at zoom # Z from vector tiles at Z-1 -- without z9 present, z10 renders blank. -./crop-mbtiles.py --region "$REGION" --no-overview --min-zoom 9 +OUTPUT="osm-$REGION.mbtiles" +TEMPORARY_OUTPUT="$OUTPUT.new.$$" +trap 'rm -f "$TEMPORARY_OUTPUT"' EXIT HUP INT TERM +python3 ./crop-mbtiles.py --region "$REGION" --no-overview --min-zoom 9 --output "$TEMPORARY_OUTPUT" + +SOURCE_SHA=$(awk 'NR == 1 {print $1}' osm-source.sha256) +python3 - "$TEMPORARY_OUTPUT" "$REGION" "$SOURCE_SHA" <<'PY' +import sqlite3 +import sys + +path, expected_region, source_sha = sys.argv[1:] +with sqlite3.connect(path) as database: + database.execute( + "INSERT OR REPLACE INTO metadata(name, value) VALUES (?, ?)", + ("sapot:source_sha256", source_sha), + ) + database.commit() + integrity = database.execute("PRAGMA integrity_check").fetchone()[0] + metadata = dict(database.execute("SELECT name, value FROM metadata")) +if integrity != "ok": + raise SystemExit(f"MBTiles integrity check failed: {integrity}") +expected = { + "sapot:region": expected_region, + "sapot:source_sha256": source_sha, + "minzoom": "9", + "maxzoom": "14", + "scheme": "tms", +} +for key, value in expected.items(): + if metadata.get(key) != value: + raise SystemExit(f"MBTiles metadata {key}={metadata.get(key)!r}, expected {value!r}") +PY +mv "$TEMPORARY_OUTPUT" "$OUTPUT" +trap - EXIT HUP INT TERM + +if "$CLEANUP_SOURCE"; then + rm -f "$SOURCE" +fi echo -echo "Serving file is osm-$REGION.mbtiles." -echo "The $SOURCE source (432 MB) is no longer needed at runtime -- remove it" -echo "if disk is tight, but you will need to re-download it to cut a new region." +echo "Serving file is $OUTPUT." +if ! "$CLEANUP_SOURCE"; then + echo "The $SOURCE source (432 MB) is no longer needed at runtime. Remove it" + echo "if disk is tight, but it must be downloaded again to cut another region." +fi diff --git a/tileserver/osm-source.sha256 b/tileserver/osm-source.sha256 new file mode 100644 index 00000000..5cd46863 --- /dev/null +++ b/tileserver/osm-source.sha256 @@ -0,0 +1 @@ +aea1a51b36ae5a80408534cd4cd56ac573ef65b71bbc9feb22740df8921ee2d9 osm-2020-02-10-v3.11_asia_philippines.mbtiles From e1ad2bf2fcf7652221f8a859ca90c8336bd983fb Mon Sep 17 00:00:00 2001 From: Adamskiee Date: Tue, 11 Aug 2026 15:00:35 +0800 Subject: [PATCH 2/4] feat(deploy-release): harden bundle publication --- .github/workflows/release-bundle.yml | 103 ++++--------- VERSIONING.md | 19 ++- deploy/manifest.schema.json | 43 ++---- deploy/scripts/lib/semver.py | 2 +- docs/deployment/docker-bundle.md | 8 +- docs/deployment/tileserver.md | 29 ++-- scripts/build-bundle.sh | 67 +++++---- scripts/lib/github-api.sh | 7 + scripts/publish_bundle_release.sh | 47 ++++++ scripts/release.sh | 9 +- scripts/tests/test_validate_bundle_release.py | 35 ++++- scripts/validate_archive_size.py | 19 +++ scripts/validate_bundle_release.py | 40 ++++- scripts/validate_bundle_release_ci.sh | 20 +++ scripts/validate_extracted_bundle.py | 50 +++++++ tileserver/CLAUDE.md | 15 +- tileserver/download-script.sh | 138 +++++++----------- tileserver/map-artifact.json | 14 ++ tileserver/tests/test_map_artifact.py | 27 ++++ tileserver/validate-map-artifact.py | 70 +++++++++ 20 files changed, 495 insertions(+), 267 deletions(-) create mode 100755 scripts/lib/github-api.sh create mode 100755 scripts/publish_bundle_release.sh create mode 100755 scripts/validate_archive_size.py create mode 100755 scripts/validate_bundle_release_ci.sh create mode 100755 scripts/validate_extracted_bundle.py create mode 100644 tileserver/map-artifact.json create mode 100644 tileserver/tests/test_map_artifact.py create mode 100755 tileserver/validate-map-artifact.py diff --git a/.github/workflows/release-bundle.yml b/.github/workflows/release-bundle.yml index a4811caf..efe2d175 100644 --- a/.github/workflows/release-bundle.yml +++ b/.github/workflows/release-bundle.yml @@ -14,6 +14,7 @@ concurrency: jobs: release: runs-on: ubuntu-24.04 + environment: bundle-release timeout-minutes: 180 steps: - uses: actions/checkout@v4 @@ -22,30 +23,14 @@ jobs: - name: Validate tag and release metadata run: | - python3 scripts/validate_bundle_release.py --tag "$GITHUB_REF_NAME" - VERSION=${GITHUB_REF_NAME#bundle/v} - echo "VERSION=$VERSION" >> "$GITHUB_ENV" - [ "$(git cat-file -t "$GITHUB_REF_NAME")" = tag ] || { - echo "Bundle releases require an annotated tag." >&2 - exit 1 - } - if [[ "$VERSION" != *-* ]]; then - git fetch --no-tags origin main:refs/remotes/origin/main - git merge-base --is-ancestor "$GITHUB_SHA" origin/main || { - echo "Stable bundle tags must point to a commit on main." >&2 - exit 1 - } - fi - git tag -l --format='%(contents)' "$GITHUB_REF_NAME" > notes.md - [ -s notes.md ] || { - echo "Release notes are empty. Use an annotated bundle tag." >&2 - exit 1 - } + git fetch --no-tags origin main:refs/remotes/origin/main + scripts/validate_bundle_release_ci.sh "$GITHUB_REF_NAME" "$GITHUB_SHA" origin/main notes.md + echo "VERSION=${GITHUB_REF_NAME#bundle/v}" >> "$GITHUB_ENV" - name: Install bundle build tools run: | sudo apt-get update - sudo apt-get install -y jq zstd + sudo apt-get install -y jq zstd python3-jsonschema docker compose version df -h / @@ -53,13 +38,16 @@ jobs: - name: Run release metadata and deployment script tests run: | - python3 -m unittest scripts.tests.test_validate_bundle_release -v + python3 -m unittest discover -s scripts/tests -p 'test_*.py' -v + python3 -m unittest discover -s tileserver/tests -p 'test_*.py' -v deploy/scripts/tests/run-tests.sh - bash -n scripts/build-bundle.sh scripts/release.sh tileserver/download-script.sh - bash -n deploy/scripts/*.sh deploy/scripts/lib/*.sh + bash -n scripts/build-bundle.sh scripts/release.sh scripts/validate_bundle_release_ci.sh scripts/publish_bundle_release.sh tileserver/download-script.sh + bash -n scripts/lib/*.sh deploy/scripts/*.sh deploy/scripts/lib/*.sh - name: Download and validate Batangas map data - run: ./tileserver/download-script.sh --cleanup-source + env: + GH_TOKEN: ${{ github.token }} + run: ./tileserver/download-script.sh - name: Build deployment bundle run: ./scripts/build-bundle.sh --low-disk @@ -67,62 +55,27 @@ jobs: - name: Validate release assets run: | ARCHIVE="dist/sapot-bundle-v$VERSION.tar.zst" - [ -f "$ARCHIVE" ] || { echo "Missing $ARCHIVE" >&2; exit 1; } - SIZE=$(stat -c '%s' "$ARCHIVE") - [ "$SIZE" -lt 2147483648 ] || { - echo "Bundle is too large for a GitHub release asset: $SIZE bytes" >&2 - exit 1 - } + python3 scripts/validate_archive_size.py "$ARCHIVE" ROOT="sapot-bundle-v$VERSION" - tar --use-compress-program=unzstd -xOf "$ARCHIVE" "$ROOT/manifest.json" > manifest.json - python3 - "$GITHUB_SHA" "$VERSION" <<'PY' - import json - import sys - - expected_sha, expected_version = sys.argv[1:] - manifest = json.load(open("manifest.json", encoding="utf-8")) - if manifest["schemaVersion"] != "2.0": - raise SystemExit("unexpected manifest schema") - if manifest["gitSha"] != expected_sha: - raise SystemExit("manifest git SHA does not match the tag commit") - if manifest["version"] != expected_version: - raise SystemExit("manifest version does not match the bundle tag") - if manifest["gsmFirmware"]["compatibleGsmFastapiVersion"] != f"={manifest['componentVersions']['gsmFastapi']}": - raise SystemExit("firmware compatibility does not match the GSM service version") - suffix = f"bundle-v{expected_version}" - for image in manifest["images"].values(): - if not image["tag"].endswith(suffix): - raise SystemExit(f"image tag is not bundle-versioned: {image['tag']}") - PY - tar --use-compress-program=unzstd -tf "$ARCHIVE" | grep -q "/CHECKSUMS.sha256$" VERIFY_DIR=$(mktemp -d) tar --use-compress-program=unzstd -xf "$ARCHIVE" -C "$VERIFY_DIR" + scripts/validate_extracted_bundle.py "$VERIFY_DIR/$ROOT" --version "$VERSION" --commit "$GITHUB_SHA" (cd "$VERIFY_DIR/$ROOT" && sha256sum --check CHECKSUMS.sha256) rm -rf "$VERIFY_DIR" - ( - cd dist - sha256sum "sapot-bundle-v$VERSION.tar.zst" > "sapot-bundle-v$VERSION.tar.zst.sha256" - ) + (cd dist && sha256sum "sapot-bundle-v$VERSION.tar.zst" > "sapot-bundle-v$VERSION.tar.zst.sha256") - - name: Create or resume draft release + - name: Create immutable-policy reader token + id: policy-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.BUNDLE_RELEASE_POLICY_APP_ID }} + private-key: ${{ secrets.BUNDLE_RELEASE_POLICY_PRIVATE_KEY }} + owner: SAPOT-System + repositories: YLP-software + permission-administration: read + + - name: Stage and publish verified release assets env: GH_TOKEN: ${{ github.token }} - run: | - ARCHIVE="dist/sapot-bundle-v$VERSION.tar.zst" - CHECKSUM="$ARCHIVE.sha256" - ENCODED_TAG=$(jq -rn --arg value "$GITHUB_REF_NAME" '$value | @uri') - EXISTING=$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$ENCODED_TAG" 2>/dev/null || true) - if [ -n "$EXISTING" ]; then - if [ "$(jq -r .draft <<<"$EXISTING")" != true ]; then - echo "Published release $GITHUB_REF_NAME already exists; refusing to replace it." >&2 - exit 1 - fi - gh release edit "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes-file notes.md - else - PRERELEASE=() - case "$VERSION" in *-*) PRERELEASE=(--prerelease) ;; esac - gh release create "$GITHUB_REF_NAME" --verify-tag --draft \ - --title "$GITHUB_REF_NAME" --notes-file notes.md "${PRERELEASE[@]}" - fi - gh release upload "$GITHUB_REF_NAME" "$ARCHIVE" "$CHECKSUM" --clobber - gh release edit "$GITHUB_REF_NAME" --draft=false + SAPOT_RELEASE_POLICY_TOKEN: ${{ steps.policy-token.outputs.token }} + run: scripts/publish_bundle_release.sh "$GITHUB_REPOSITORY" "$GITHUB_REF_NAME" "$VERSION" "$GITHUB_SHA" notes.md "dist/sapot-bundle-v$VERSION.tar.zst" "dist/sapot-bundle-v$VERSION.tar.zst.sha256" diff --git a/VERSIONING.md b/VERSIONING.md index 6be767b9..475443c8 100644 --- a/VERSIONING.md +++ b/VERSIONING.md @@ -2,7 +2,7 @@ This repo uses git-tag-driven versioning for five application components and one deployment artifact: **mobile**, **server**, **admin**, **portal** (captive portal), **gsm** (GSM-module: `GSM-fastapi` + the production Arduino firmware), and the independent offline deployment **bundle**. -`GSM-module/GSM-API/` and `GSM-trial-code/` are not covered — neither is deployed (see `GSM-module/CLAUDE.md`). `tileserver/` has no source of its own (deploy scripts + `.mbtiles` data only) and isn't versioned. +`GSM-module/GSM-API/` and `GSM-trial-code/` are not covered — neither is deployed (see `GSM-module/CLAUDE.md`). TileServer GL deployment scripts are unversioned, but the reviewed map data is independently identified by immutable `map/v*` release tags. --- @@ -168,11 +168,18 @@ When an application component tag is pushed, the corresponding GitHub Actions wo CI does not draft or edit notes — it only reads the tag message and publishes it, using the default `GITHUB_TOKEN`. -For `bundle/v*`, CI additionally validates the committed compatibility policy, -downloads and verifies the pinned map source, builds the offline bundle, and publishes -`sapot-bundle-vX.Y.Z.tar.zst` plus its SHA-256 file. The release remains a draft until -both assets upload successfully. Bundle `0.0.1` starts manifest schema `2.0` and is a -fresh-install release; older locally built server-derived bundles are not compatible. +For `bundle/v*`, CI validates the committed compatibility policy, downloads the pinned +immutable `map/v1.0.0` asset, builds the offline bundle, and publishes the archive plus +its SHA-256 file only after both remote asset digests are verified. Bundle versions use +canonical SAPOT SemVer. The `0.0.1` prerelease and stable family is fresh-install-only, +so both compatibility floors must equal the exact candidate. Later releases require +`minimumRollbackVersion <= minimumUpgradeVersion < bundle version`. + +Repository immutable releases are a publication prerequisite. A repository administrator +must enable them and approve the protected `bundle-release` environment before a map or +bundle publication. This locks every future repository release tag and asset, not only +bundle releases. Build hosts need a GitHub CLI with `gh release verify` and +`gh release verify-asset`, plus `python3-jsonschema` for bundle-content validation. --- diff --git a/deploy/manifest.schema.json b/deploy/manifest.schema.json index 3da64735..e1a605c3 100644 --- a/deploy/manifest.schema.json +++ b/deploy/manifest.schema.json @@ -1,37 +1,16 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", + "type": "object", "additionalProperties": false, "required": ["schemaVersion", "bundleId", "version", "gitSha", "builtAt", "minimumUpgradeVersion", "minimumRollbackVersion", "requiredDiskBytes", "componentVersions", "images", "mapData", "gsmFirmware", "buildEnvironment"], "properties": { - "schemaVersion": {"const": "2.0"}, - "bundleId": {"type": "string"}, - "version": {"type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+(-(alpha|beta|rc)\\.\\d+)?$"}, - "gitSha": {"type": "string"}, "builtAt": {"type": "string"}, - "minimumUpgradeVersion": {"type": "string"}, "minimumRollbackVersion": {"type": "string"}, - "requiredDiskBytes": {"type": "integer", "minimum": 1}, - "componentVersions": { - "type": "object", - "required": ["server", "admin", "gsmFastapi", "gsmFirmware"], - "properties": { - "server": {"type": "string"}, - "admin": {"type": "string"}, - "gsmFastapi": {"type": "string"}, - "gsmFirmware": {"type": "string"} - }, - "additionalProperties": false - }, - "images": {"type": "object"}, - "mapData": { - "type": "object", - "required": ["region", "sourceSha256", "artifactSha256"], - "properties": { - "region": {"const": "batangas"}, - "sourceSha256": {"type": "string"}, - "artifactSha256": {"type": "string"} - }, - "additionalProperties": false - }, - "gsmFirmware": {"type": "object"}, - "buildEnvironment": {"type": "object"} - } + "schemaVersion": {"const": "2.0"}, "bundleId": {"type": "string", "minLength": 1}, + "version": {"type": "string", "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-(alpha|beta|rc)\\.(0|[1-9]\\d*))?$"}, + "gitSha": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, "builtAt": {"type": "string", "minLength": 1}, + "minimumUpgradeVersion": {"type": "string"}, "minimumRollbackVersion": {"type": "string"}, "requiredDiskBytes": {"type": "integer", "minimum": 1}, + "componentVersions": {"type":"object","additionalProperties":false,"required":["server","admin","gsmFastapi","gsmFirmware"],"properties":{"server":{"type":"string"},"admin":{"type":"string"},"gsmFastapi":{"type":"string"},"gsmFirmware":{"type":"string"}}}, + "images": {"type":"object","additionalProperties":false,"required":["api","admin","gsm-fastapi","arduino-flasher","mariadb","redis","nginx","tileserver-gl"],"properties":{"api":{"$ref":"#/$defs/image"},"admin":{"$ref":"#/$defs/image"},"gsm-fastapi":{"$ref":"#/$defs/image"},"arduino-flasher":{"$ref":"#/$defs/image"},"mariadb":{"$ref":"#/$defs/image"},"redis":{"$ref":"#/$defs/image"},"nginx":{"$ref":"#/$defs/image"},"tileserver-gl":{"$ref":"#/$defs/image"}}}, + "mapData": {"type":"object","additionalProperties":false,"required":["repository","releaseTag","assetName","sha256","size","region","bounds","minZoom","maxZoom","scheme","format"],"properties":{"repository":{"const":"SAPOT-System/YLP-software"},"releaseTag":{"pattern":"^map/v","type":"string"},"assetName":{"const":"osm-batangas.mbtiles"},"sha256":{"pattern":"^[0-9a-f]{64}$","type":"string"},"size":{"type":"integer","minimum":1},"region":{"const":"batangas"},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4},"minZoom":{"type":"integer"},"maxZoom":{"type":"integer"},"scheme":{"const":"tms"},"format":{"const":"pbf"}}}, + "gsmFirmware":{"type":"object"}, "buildEnvironment":{"type":"object"} + }, + "$defs":{"image":{"type":"object","additionalProperties":false,"required":["tag","digest"],"properties":{"tag":{"type":"string"},"digest":{"type":"string"}}}} } diff --git a/deploy/scripts/lib/semver.py b/deploy/scripts/lib/semver.py index ab0a75d8..367b067f 100755 --- a/deploy/scripts/lib/semver.py +++ b/deploy/scripts/lib/semver.py @@ -3,7 +3,7 @@ import re import sys -PATTERN = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:-(alpha|beta|rc)\.(\d+))?$") +PATTERN = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(alpha|beta|rc)\.(0|[1-9]\d*))?$") STAGES = {"alpha": 0, "beta": 1, "rc": 2} diff --git a/docs/deployment/docker-bundle.md b/docs/deployment/docker-bundle.md index 402c40e0..fee13ec3 100644 --- a/docs/deployment/docker-bundle.md +++ b/docs/deployment/docker-bundle.md @@ -155,7 +155,7 @@ git push origin bundle/v0.0.1 ``` For a manual build on a clean, tagged checkout with Docker, Compose v2, `python3`, -`zstd`, `arduino-cli`, and the map data installed, run: +`python3-jsonschema`, `zstd`, `arduino-cli`, and GitHub CLI, run: ```bash ./tileserver/download-script.sh @@ -169,8 +169,10 @@ preserved. The result is `dist/sapot-bundle-vX.Y.Z.tar.zst`. Its `manifest.json` records the version, source commit, image IDs, firmware checksum, compatibility gates, -and disk requirement. `CHECKSUMS.sha256` detects accidental corruption during -transport. It is not a tamper-evident signature. +and immutable map-release provenance. The connected build host downloads the pinned +map release; the offline host receives it inside the bundle and never downloads it. +`CHECKSUMS.sha256` detects accidental corruption during transport. It is not a +tamper-evident signature. The policy's `minimumUpgradeVersion` prevents an upgrade from an unsupported old bundle. `minimumRollbackVersion` is the oldest bundle to which this release may diff --git a/docs/deployment/tileserver.md b/docs/deployment/tileserver.md index 47a42254..a07a942a 100644 --- a/docs/deployment/tileserver.md +++ b/docs/deployment/tileserver.md @@ -45,25 +45,26 @@ full stack for local dev. ## MBTiles file -`.mbtiles` files are gitignored — a fresh checkout has no map data. Two files are involved: - -1. The national source (`osm-2020-02-10-v3.11_asia_philippines.mbtiles`, OSM 2020-02-10 v3.11, ~432 MB) — a download artifact, never served directly. -2. The regional crop (`osm-batangas.mbtiles`, ~27 MB) — what the deploy scripts and `docker-compose.yml` actually point at. - -To (re)generate the served file: +`.mbtiles` files are gitignored. The production file is the reviewed +`map/v1.0.0` GitHub Release asset, `osm-batangas.mbtiles`. Its exact SHA-256, +size, bounds, zoom range, and MBTiles metadata are pinned in +`tileserver/map-artifact.json`. ```bash -bash tileserver/download-script.sh # fetches the national source, then crops it (default region: batangas) -# or, if the national source is already present: -python3 tileserver/crop-mbtiles.py --region batangas --no-overview --min-zoom 9 +tileserver/download-script.sh ``` -The download script verifies the national extract against the committed -`tileserver/osm-source.sha256` before cropping. Release CI uses -`--cleanup-source` to remove the 432 MB source after producing and validating the -Batangas database. +The downloader rejects draft or mutable releases, duplicate or invalid assets, and an +invalid local cache. It writes downloads to a temporary file and replaces the cache only +after the shared validator succeeds. A connected build host downloads the map before +building the bundle. The disconnected deployment host receives the same bytes inside +the bundle and never downloads or crops map data. + +`crop-mbtiles.py` remains a developer-only preparation tool for a future map release. +Changed map bytes require a new immutable `map/vX.Y.Z` release and a matching contract +update, rather than a release-time crop. -To change the deployment region, add a bbox to `REGIONS` in `crop-mbtiles.py`, re-run the crop for the new region, and update the hardcoded filename in **both** `deploy-tiling-server-detached.sh` and `docker-compose.yml`'s `tileserver.command` — plus the matching `REGION_MAX_BOUNDS`/`REGION_MIN_ZOOM` client constants in `admin-frontend/sapot-admin/ui/components/MapLibre.tsx` and `mobile-app/sapot-mobile-app/app/(drawer)/(tabs)/map.tsx`. See `tileserver/CLAUDE.md` for the full detail, including the zoom-floor pitfall (stored min-zoom must be one below the clients' `minZoom`). +To change the deployment region, prepare and validate a new artifact with the developer-only crop tool, publish it under a new immutable `map/vX.Y.Z` tag, and update the contract plus the hardcoded filename in **both** `deploy-tiling-server-detached.sh` and `docker-compose.yml`'s `tileserver.command` — plus the matching `REGION_MAX_BOUNDS`/`REGION_MIN_ZOOM` client constants in `admin-frontend/sapot-admin/ui/components/MapLibre.tsx` and `mobile-app/sapot-mobile-app/app/(drawer)/(tabs)/map.tsx`. See `tileserver/CLAUDE.md` for the zoom-floor requirement. --- diff --git a/scripts/build-bundle.sh b/scripts/build-bundle.sh index 20872100..dbbf8982 100755 --- a/scripts/build-bundle.sh +++ b/scripts/build-bundle.sh @@ -55,35 +55,26 @@ firmware_version=$(sed -n 's/^#define FIRMWARE_VERSION "\([^"]*\)"/\1/p' "$firmw exit 1 } +map_metadata=tileserver/map-artifact.json map_file=tileserver/osm-batangas.mbtiles [ -f "$map_file" ] || { echo "missing $map_file; run tileserver/download-script.sh first" >&2 exit 1 } -map_source_sha=$(awk 'NR == 1 {print $1}' tileserver/osm-source.sha256) -python3 - "$map_file" "$map_source_sha" <<'PY' -import sqlite3 -import sys - -with sqlite3.connect(f"file:{sys.argv[1]}?mode=ro", uri=True) as database: - integrity = database.execute("PRAGMA integrity_check").fetchone()[0] - metadata = dict(database.execute("SELECT name, value FROM metadata")) -if integrity != "ok": - raise SystemExit(f"MBTiles integrity check failed: {integrity}") -expected = { - "sapot:region": "batangas", - "sapot:source_sha256": sys.argv[2], - "minzoom": "9", - "maxzoom": "14", - "scheme": "tms", -} -for key, value in expected.items(): - if metadata.get(key) != value: - raise SystemExit(f"MBTiles metadata {key}={metadata.get(key)!r}, expected {value!r}") +python3 tileserver/validate-map-artifact.py "$map_file" --metadata "$map_metadata" +readarray -t map_values < <(python3 - "$map_metadata" <<'PY' +import importlib.util, sys +spec = importlib.util.spec_from_file_location("map_validator", "tileserver/validate-map-artifact.py") +module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module) +for key, value in module.load_contract(__import__("pathlib").Path(sys.argv[1])).items(): + if key in {"repository", "releaseTag", "assetName", "sha256", "size", "region", "bounds", "minZoom", "maxZoom", "scheme", "format"}: + print(f"{key}={value}") PY -map_artifact_sha=$(sha256sum "$map_file" | awk '{print $1}') +) +declare -A map +for value in "${map_values[@]}"; do map[${value%%=*}]=${value#*=}; done -if "$low_disk"; then +check_free_space() { minimum_free=${SAPOT_BUNDLE_MIN_FREE_BYTES:-10737418240} docker_root=$(docker info --format '{{.DockerRootDir}}') for path in "$repo_root" "$docker_root"; do @@ -93,7 +84,8 @@ if "$low_disk"; then exit 1 } done -fi +} +"$low_disk" && check_free_space git_sha=$(git rev-parse HEAD) built_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) @@ -124,7 +116,17 @@ declare -A tags=( save_image() { local name=$1 tag=$2 digest digest=$(docker image inspect --format '{{.Id}}' "$tag") - docker save -o "$bundle/images/$name.tar" "$tag" + local partial="$bundle/images/$name.tar.partial" + local error_file="$scratch/$name.save.stderr" + if ! docker save -o "$partial" "$tag" 2>"$error_file"; then + if grep -qi 'ENOSPC\|no space left' "$error_file"; then + echo "docker save ran out of disk space while writing $name" >&2 + fi + rm -f "$partial" "$error_file" + return 1 + fi + rm -f "$error_file" + mv "$partial" "$bundle/images/$name.tar" printf '%s\t%s\t%s\n' "$name" "$tag" "$digest" >> "$digest_file" } @@ -139,6 +141,7 @@ build_and_save() { docker build -t "$tag" "$context" save_image "$name" "$tag" cleanup_images "$tag" + "$low_disk" && check_free_space } pull_and_save() { @@ -147,6 +150,7 @@ pull_and_save() { docker tag "$source" "$tag" save_image "$name" "$tag" cleanup_images "$tag" "$source" + "$low_disk" && check_free_space } build_and_save api server @@ -197,7 +201,7 @@ check_no_ca_material() { check_no_ca_material chmod +x "$bundle/scripts"/*.sh "$bundle/scripts"/lib/*.sh "$bundle/scripts"/lib/*.py -python3 - "$bundle/manifest.json" "$version" "$git_sha" "$built_at" "$minimum_upgrade" "$minimum_rollback" "$server_version" "$admin_version" "$gsm_version" "$firmware_version" "$fqbn" "$firmware_sha" "$map_source_sha" "$map_artifact_sha" "$bundle" "$digest_file" <<'PY' +python3 - "$bundle/manifest.json" "$version" "$git_sha" "$built_at" "$minimum_upgrade" "$minimum_rollback" "$server_version" "$admin_version" "$gsm_version" "$firmware_version" "$fqbn" "$firmware_sha" "${map[repository]}" "${map[releaseTag]}" "${map[assetName]}" "${map[sha256]}" "${map[size]}" "${map[region]}" "${map[bounds]}" "${map[minZoom]}" "${map[maxZoom]}" "${map[scheme]}" "${map[format]}" "$bundle" "$digest_file" <<'PY' import json import os import subprocess @@ -216,8 +220,8 @@ import sys firmware_version, fqbn, firmware_sha, - map_source_sha, - map_artifact_sha, + map_repository, map_release_tag, map_asset_name, map_sha, map_size, map_region, + map_bounds, map_min_zoom, map_max_zoom, map_scheme, map_format, root, digest_file, ) = sys.argv[1:] @@ -259,9 +263,10 @@ manifest = { }, "images": images, "mapData": { - "region": "batangas", - "sourceSha256": map_source_sha, - "artifactSha256": map_artifact_sha, + "repository": map_repository, "releaseTag": map_release_tag, "assetName": map_asset_name, + "sha256": map_sha, "size": int(map_size), "region": map_region, + "bounds": json.loads(map_bounds), "minZoom": int(map_min_zoom), "maxZoom": int(map_max_zoom), + "scheme": map_scheme, "format": map_format, }, "gsmFirmware": { "version": firmware_version, @@ -319,9 +324,11 @@ check_no_ca_material checksums="$scratch/CHECKSUMS.sha256" (cd "$bundle" && find . -type f -print0 | sort -z | xargs -0 sha256sum > "$checksums") mv "$checksums" "$bundle/CHECKSUMS.sha256" +python3 scripts/validate_extracted_bundle.py "$bundle" --version "$version" --commit "$git_sha" --metadata "$map_metadata" mkdir -p dist temporary_output="$output.tmp.$$" tar -C "$scratch" -cf - "$(basename "$bundle")" | zstd -T0 -19 -o "$temporary_output" +python3 scripts/validate_archive_size.py "$temporary_output" mv "$temporary_output" "$output" temporary_output= echo "built $output" diff --git a/scripts/lib/github-api.sh b/scripts/lib/github-api.sh new file mode 100755 index 00000000..529fc0f2 --- /dev/null +++ b/scripts/lib/github-api.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Shared GitHub REST API settings for release artifact workflows. +SAPOT_GITHUB_API_VERSION=2026-03-10 + +github_api() { + gh api -H "X-GitHub-Api-Version: $SAPOT_GITHUB_API_VERSION" "$@" +} diff --git a/scripts/publish_bundle_release.sh b/scripts/publish_bundle_release.sh new file mode 100755 index 00000000..fbc999ef --- /dev/null +++ b/scripts/publish_bundle_release.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Atomically stage and verify both bundle assets before publishing a draft release. +set -euo pipefail +repository=${1:?repository}; tag=${2:?tag}; version=${3:?version}; commit=${4:?commit}; notes=${5:?notes}; archive=${6:?archive}; checksum=${7:?checksum} +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +source "$root/scripts/lib/github-api.sh" +[[ -n "${SAPOT_RELEASE_POLICY_TOKEN:-}" ]] || { echo "missing SAPOT_RELEASE_POLICY_TOKEN" >&2; exit 1; } +for command in "verify" "verify-asset"; do gh release --help | grep -Eq "(^|[[:space:]])$command([[:space:]]|$)" || { echo "installed gh lacks gh release $command" >&2; exit 1; }; done +check_immutable() { GH_TOKEN="$SAPOT_RELEASE_POLICY_TOKEN" github_api "repos/$repository/immutable-releases" --jq '.enabled' | grep -qx true; } +check_immutable || { echo "repository immutable releases must be enabled" >&2; exit 1; } +[[ -f "$archive" && -f "$checksum" && -f "$notes" ]] || { echo "release inputs are missing" >&2; exit 1; } +archive_name=$(basename "$archive"); checksum_name=$(basename "$checksum") +archive_sha=$(sha256sum "$archive" | awk '{print $1}') +[[ "$(wc -l < "$checksum" | tr -d ' ')" = 1 ]] && [[ "$(<"$checksum")" = "$archive_sha $archive_name" ]] || { echo "checksum file must contain exactly the archive digest and basename" >&2; exit 1; } +encoded_tag=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$tag") +remote_commit=$(github_api "repos/$repository/git/ref/tags/$encoded_tag" --jq .object.sha) +[[ "$(github_api "repos/$repository/git/tags/$remote_commit" --jq .object.sha)" = "$commit" ]] || { echo "remote annotated tag does not match build commit" >&2; exit 1; } +existing=$(github_api "repos/$repository/releases/tags/$encoded_tag" 2>/dev/null || true) +if [[ -n "$existing" ]]; then + [[ "$(python3 -c 'import json,sys; print(json.load(sys.stdin)["draft"])' <<<"$existing")" = True ]] || { echo "published release exists" >&2; exit 1; } + release_id=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$existing") +else + args=(release create "$tag" --verify-tag --draft --title "$tag" --notes-file "$notes") + [[ "$version" == *-* ]] && args+=(--prerelease) + gh "${args[@]}" >/dev/null + release_id=$(github_api "repos/$repository/releases/tags/$encoded_tag" --jq .id) +fi +assets=$(github_api "repos/$repository/releases/$release_id/assets") +for id in $(ASSETS="$assets" python3 - "$archive_name" "$checksum_name" <<'PY' +import json,os,sys +names=set(sys.argv[1:]); print(*[a['id'] for a in json.loads(os.environ['ASSETS']) if a['name'] in names]) +PY +); do github_api -X DELETE "repos/$repository/releases/assets/$id"; done +gh release upload "$tag" "$archive" "$checksum" +assets=$(github_api "repos/$repository/releases/$release_id/assets") +ASSETS="$assets" python3 - "$archive_name" "$checksum_name" "$archive_sha" <<'PY' +import json, os, sys +assets=json.loads(os.environ['ASSETS']); expected={sys.argv[1],sys.argv[2]} +found={a['name']:a for a in assets if a['name'] in expected} +if set(found) != expected or any(a.get('state') != 'uploaded' for a in found.values()): raise SystemExit('both release assets must be uploaded exactly once') +if found[sys.argv[1]].get('digest') != 'sha256:'+sys.argv[3]: raise SystemExit('archive digest mismatch') +PY +check_immutable || { echo "repository immutable releases were disabled before publication" >&2; exit 1; } +github_api -X PATCH "repos/$repository/releases/$release_id" -f draft=false >/dev/null +gh release verify "$tag" --repo "$repository" +gh release verify-asset "$tag" "$archive_name" --repo "$repository" +gh release verify-asset "$tag" "$checksum_name" --repo "$repository" diff --git a/scripts/release.sh b/scripts/release.sh index 22277ad3..e09a8bcd 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -47,14 +47,15 @@ case "$component" in git -C "$root" add GSM-module/GSM-fastapi/app_version.py GSM-module/GSM-arduino-actual-code/GSM-arduino-actual-code.ino tag="gsm/v$version" ;; bundle) - if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$ ]]; then - echo "Invalid version '$version' - expected X.Y.Z[-(alpha|beta|rc).N]" >&2 + tag="bundle/v$version" + if git -C "$root" rev-parse -q --verify "refs/tags/$tag" >/dev/null; then + echo "Tag $tag already exists - delete it first with: git tag -d $tag" >&2 exit 1 fi + python3 "$root/scripts/validate_bundle_release.py" --candidate-version "$version" printf '%s\n' "$version" > "$root/deploy/VERSION" - python3 "$root/scripts/validate_bundle_release.py" git -C "$root" add deploy/VERSION - tag="bundle/v$version" ;; + ;; *) echo "Unknown component '$component' (expected mobile|server|admin|portal|gsm|bundle)" >&2 exit 1 ;; diff --git a/scripts/tests/test_validate_bundle_release.py b/scripts/tests/test_validate_bundle_release.py index 3820a307..3237ebaf 100644 --- a/scripts/tests/test_validate_bundle_release.py +++ b/scripts/tests/test_validate_bundle_release.py @@ -35,9 +35,16 @@ def write_policy(self, minimum_upgrade: str, minimum_rollback: str) -> None: encoding="utf-8", ) - def test_accepts_matching_initial_release(self) -> None: - values = validate(self.version_file, self.policy_file, "bundle/v0.0.1") - self.assertEqual("0.0.1", values["version"]) + def test_accepts_initial_family_when_floors_match_exactly(self) -> None: + for version in ("0.0.1-alpha.1", "0.0.1-beta.1", "0.0.1-rc.1", "0.0.1"): + self.version_file.write_text(version, encoding="utf-8") + self.write_policy(version, version) + self.assertEqual(version, validate(self.version_file, self.policy_file, f"bundle/v{version}")["version"]) + + def test_accepts_later_prerelease_with_ordered_floors(self) -> None: + self.version_file.write_text("0.0.2-rc.1", encoding="utf-8") + self.write_policy("0.0.1-rc.1", "0.0.1-alpha.1") + validate(self.version_file, self.policy_file) def test_rejects_tag_mismatch(self) -> None: with self.assertRaisesRegex(ValueError, "does not match"): @@ -47,11 +54,29 @@ def test_rejects_non_bundle_tag(self) -> None: with self.assertRaisesRegex(ValueError, "invalid bundle tag"): validate(self.version_file, self.policy_file, "server/v0.0.1") - def test_rejects_floor_above_bundle_version(self) -> None: + def test_rejects_upgrade_floor_at_or_above_bundle_version(self) -> None: + self.version_file.write_text("0.0.2", encoding="utf-8") self.write_policy("0.0.2", "0.0.1") - with self.assertRaisesRegex(ValueError, "cannot exceed"): + with self.assertRaisesRegex(ValueError, "must be older"): + validate(self.version_file, self.policy_file) + + def test_rejects_rollback_newer_than_upgrade(self) -> None: + self.version_file.write_text("0.0.2", encoding="utf-8") + self.write_policy("0.0.1", "0.0.2-alpha.1") + with self.assertRaisesRegex(ValueError, "cannot be newer"): validate(self.version_file, self.policy_file) + def test_rejects_initial_family_with_different_floor(self) -> None: + self.version_file.write_text("0.0.1-rc.1", encoding="utf-8") + self.write_policy("0.0.1", "0.0.1") + with self.assertRaisesRegex(ValueError, "fresh-install"): + validate(self.version_file, self.policy_file) + + def test_rejects_zero_and_leading_zeroes(self) -> None: + for version in ("0.0.0", "00.0.1", "0.01.1", "0.0.01", "0.0.1-rc.01"): + with self.assertRaises(ValueError): + validate(self.version_file, self.policy_file, candidate_version=version) + def test_rejects_unknown_policy_fields(self) -> None: policy = json.loads(self.policy_file.read_text(encoding="utf-8")) policy["maximumRollbackVersion"] = "0.0.1" diff --git a/scripts/validate_archive_size.py b/scripts/validate_archive_size.py new file mode 100755 index 00000000..90a5bb91 --- /dev/null +++ b/scripts/validate_archive_size.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""Enforce GitHub Release's deployment-bundle archive limit.""" +import argparse +from pathlib import Path +import sys + +LIMIT = 2_147_483_648 + +def validate(path: Path) -> None: + size = path.stat().st_size + if size >= LIMIT: + raise ValueError(f"archive size {size} bytes must be smaller than {LIMIT} bytes") + +if __name__ == "__main__": + parser = argparse.ArgumentParser(); parser.add_argument("archive", type=Path) + args = parser.parse_args() + try: validate(args.archive) + except (OSError, ValueError) as error: + print(f"archive size validation failed: {error}", file=sys.stderr); raise SystemExit(1) diff --git a/scripts/validate_bundle_release.py b/scripts/validate_bundle_release.py index 1f5bf640..d9f060c3 100755 --- a/scripts/validate_bundle_release.py +++ b/scripts/validate_bundle_release.py @@ -25,10 +25,17 @@ def load_semver_module(): return module -def validate(version_file: Path, policy_file: Path, tag: str | None = None) -> dict[str, str]: +def validate( + version_file: Path, + policy_file: Path, + tag: str | None = None, + candidate_version: str | None = None, +) -> dict[str, str]: semver = load_semver_module() - version = version_file.read_text(encoding="utf-8").strip() + version = candidate_version or version_file.read_text(encoding="utf-8").strip() semver.parse(version) + if semver.parse(version)[:3] < (0, 0, 1): + raise ValueError(f"bundle version ({version}) must have a core version of at least 0.0.1") policy = json.loads(policy_file.read_text(encoding="utf-8")) expected_keys = { @@ -46,8 +53,30 @@ def validate(version_file: Path, policy_file: Path, tag: str | None = None) -> d for field in ("minimumUpgradeVersion", "minimumRollbackVersion"): value = policy[field] semver.parse(value) - if semver.compare(value, version) > 0: - raise ValueError(f"{field} ({value}) cannot exceed bundle version ({version})") + if semver.parse(value)[:3] < (0, 0, 1): + raise ValueError(f"{field} ({value}) must have a core version of at least 0.0.1") + + initial_family = version.startswith("0.0.1") and ( + version == "0.0.1" or re.fullmatch(r"0\.0\.1-(alpha|beta|rc)\.(0|[1-9]\d*)", version) + ) + upgrade = policy["minimumUpgradeVersion"] + rollback = policy["minimumRollbackVersion"] + if initial_family: + if upgrade != version or rollback != version: + raise ValueError( + "fresh-install 0.0.1 releases require minimumUpgradeVersion and " + "minimumRollbackVersion to equal the bundle version" + ) + else: + if semver.compare(upgrade, version) >= 0: + raise ValueError( + f"minimumUpgradeVersion ({upgrade}) must be older than bundle version ({version})" + ) + if semver.compare(rollback, upgrade) > 0: + raise ValueError( + f"minimumRollbackVersion ({rollback}) cannot be newer than " + f"minimumUpgradeVersion ({upgrade})" + ) if tag is not None: match = TAG_PATTERN.fullmatch(tag) @@ -68,11 +97,12 @@ def main() -> int: parser.add_argument("--version-file", type=Path, default=ROOT / "deploy" / "VERSION") parser.add_argument("--policy", type=Path, default=ROOT / "deploy" / "bundle-release-policy.json") parser.add_argument("--tag") + parser.add_argument("--candidate-version") parser.add_argument("--json", action="store_true", dest="as_json") args = parser.parse_args() try: - values = validate(args.version_file, args.policy, args.tag) + values = validate(args.version_file, args.policy, args.tag, args.candidate_version) except (OSError, ValueError, json.JSONDecodeError) as error: print(f"bundle release validation failed: {error}", file=sys.stderr) return 1 diff --git a/scripts/validate_bundle_release_ci.sh b/scripts/validate_bundle_release_ci.sh new file mode 100755 index 00000000..e0633bc4 --- /dev/null +++ b/scripts/validate_bundle_release_ci.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Validate every release precondition before a bundle build can begin. +set -euo pipefail +tag=${1:?tag is required} +workflow_commit=${2:?workflow commit is required} +main_ref=${3:?main ref is required} +notes_file=${4:?notes output is required} +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$root" +python3 scripts/validate_bundle_release.py --tag "$tag" +[[ "$(git cat-file -t "$tag")" = tag ]] || { echo "Bundle releases require an annotated tag." >&2; exit 1; } +tag_commit=$(git rev-parse "$tag^{}") +[[ "$tag_commit" = "$workflow_commit" ]] || { echo "workflow commit does not match the annotated tag commit" >&2; exit 1; } +version=${tag#bundle/v} +if [[ "$version" != *-* ]]; then + git merge-base --is-ancestor "$tag_commit" "$main_ref" || { echo "Stable bundle tags must point to a commit on main." >&2; exit 1; } +fi +git for-each-ref --format='%(contents)' "refs/tags/$tag" > "$notes_file" +[[ -s "$notes_file" ]] || { echo "Release notes are empty. Use an annotated bundle tag." >&2; exit 1; } +printf '%s\n' "$tag_commit" diff --git a/scripts/validate_extracted_bundle.py b/scripts/validate_extracted_bundle.py new file mode 100755 index 00000000..aa14a7cd --- /dev/null +++ b/scripts/validate_extracted_bundle.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Validate an extracted bundle at the build and release trust boundaries.""" +from __future__ import annotations +import argparse, hashlib, json, os, stat +from pathlib import Path +import sys +import jsonschema + +ROOT = Path(__file__).resolve().parents[1] + +def digest(path: Path) -> str: + value = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): value.update(chunk) + return value.hexdigest() + +def validate(root: Path, version: str, commit: str, metadata_path: Path) -> None: + manifest = json.loads((root / "manifest.json").read_text()) + schema = json.loads((ROOT / "deploy" / "manifest.schema.json").read_text()) + jsonschema.validate(manifest, schema) + if manifest["version"] != version or manifest["gitSha"] != commit: raise ValueError("manifest release identity does not match expected tag") + map_contract = json.loads(metadata_path.read_text()) + if manifest["mapData"] != {key: map_contract[key] for key in manifest["mapData"]}: raise ValueError("manifest map provenance does not match map artifact contract") + if manifest["gsmFirmware"]["compatibleGsmFastapiVersion"] != "=" + manifest["componentVersions"]["gsmFastapi"]: raise ValueError("firmware compatibility does not match GSM service") + if (root / "docker/gen-certs.sh").exists() or any(root.rglob(name) for name in ("server_ca.key", "server_ca.pem")): raise ValueError("bundle contains forbidden CA material") + regular = set(); entries = {} + for path in root.rglob("*"): + mode = path.lstat().st_mode + relative = path.relative_to(root).as_posix() + if stat.S_ISDIR(mode): continue + if not stat.S_ISREG(mode): raise ValueError(f"forbidden bundle entry type: {relative}") + if relative != "CHECKSUMS.sha256": regular.add(relative) + for line in (root / "CHECKSUMS.sha256").read_text().splitlines(): + try: expected, raw = line.split(" ", 1) + except ValueError: raise ValueError("invalid checksum entry") + normalized = Path(raw).as_posix() + if raw.startswith("/") or ".." in Path(raw).parts or normalized == "CHECKSUMS.sha256" or normalized in entries: raise ValueError("unsafe or duplicate checksum path") + entries[normalized] = expected + if set(entries) != regular: raise ValueError("checksum paths must exactly match regular bundle files") + for relative, expected in entries.items(): + if digest(root / relative) != expected: raise ValueError(f"checksum mismatch: {relative}") + +def main() -> int: + parser = argparse.ArgumentParser(); parser.add_argument("root", type=Path); parser.add_argument("--version", required=True); parser.add_argument("--commit", required=True); parser.add_argument("--metadata", type=Path, default=ROOT / "tileserver/map-artifact.json") + args = parser.parse_args() + try: validate(args.root, args.version, args.commit, args.metadata) + except (OSError, ValueError, KeyError, json.JSONDecodeError, jsonschema.ValidationError) as error: + print(f"bundle content validation failed: {error}", file=sys.stderr); return 1 + return 0 +if __name__ == "__main__": raise SystemExit(main()) diff --git a/tileserver/CLAUDE.md b/tileserver/CLAUDE.md index c3637b00..63d15827 100644 --- a/tileserver/CLAUDE.md +++ b/tileserver/CLAUDE.md @@ -14,18 +14,17 @@ Docker image `maptiler/tileserver-gl`, bind-mounting this directory (which must - `deploy-tiling-server.sh` — foreground/interactive run (`docker run -it`), for local development. - `deploy-tiling-server-detached.sh` — background/production run: force-removes any existing `tileserver` container first, then starts a fresh named container with a hardcoded `.mbtiles` filename and host path. -- `download-script.sh` - fetches the full Philippines `.mbtiles`, verifies it - against `osm-source.sha256`, then crops it to the deployment region. It takes - an optional region argument (default `batangas`) and `--cleanup-source` for - ephemeral release runners. -- `crop-mbtiles.py` — cuts the 432 MB national extract down to the ~27 MB regional file that is actually served. Deployment uses `--no-overview --min-zoom 9`, keeping only z9-14 inside the region bbox; without `--no-overview` it instead keeps a nationwide low-zoom overview plus regional detail. Opens the source read-only and builds the output fresh, so peak disk usage is the size of the output, not 3x the input. +- `map-artifact.json` — reviewed contract for the immutable `map/v1.0.0` GitHub Release asset. +- `validate-map-artifact.py` — validates contract shape, bytes, SQLite integrity, metadata, and tile storage without opening the database for writing. +- `download-script.sh` — downloads only the pinned release asset, validates it, and atomically refreshes an invalid cache. It accepts only `--metadata PATH`. +- `crop-mbtiles.py` — developer-only preparation tool for a future map release. It is not a setup, runtime, or bundle-release dependency. - `documentation.org` — setup instructions, the `.mbtiles` download link, and an example tile-consumer HTML snippet. - `.gitignore` — excludes `*.mbtiles` (the data file is not committed; see Common Pitfalls). ## Key Concepts - The `.mbtiles` file is the entire dataset — everything else in this directory is just the serving mechanism. Without it, the container starts but serves no tiles. -- Two `.mbtiles` files exist in a full setup: the national source (`osm-2020-02-10-v3.11_asia_philippines.mbtiles`, download artifact, not served) and the regional crop (`osm-batangas.mbtiles`, what the deploy scripts and `docker-compose.yml` point at). Both are gitignored. +- The production `.mbtiles` file is `osm-batangas.mbtiles`, downloaded unchanged from its immutable GitHub Release and then bundled for offline hosts. It is gitignored. - `bounds` metadata tracks the mode: `--no-overview` writes the region bbox (clients then never request outside it), while the overview mode keeps the source extent (narrowing it there would suppress the nationwide low-zoom levels that mode exists to preserve). - **The stored zoom floor must be one below the clients' `minZoom`.** tileserver-gl renders a raster tile at zoom Z from vector tiles at Z-1, so a file whose lowest stored zoom is 10 serves z10 vector tiles fine yet renders a blank PNG at z10. Deployment stores z9 and the clients floor at z10. - The region box is duplicated as `REGION_MAX_BOUNDS`/`REGION_MIN_ZOOM` in `admin-frontend/sapot-admin/ui/components/MapLibre.tsx` and `mobile-app/sapot-mobile-app/app/(drawer)/(tabs)/map.tsx`. Re-cropping for a different province means editing all three. @@ -39,7 +38,7 @@ Docker image `maptiler/tileserver-gl`, bind-mounting this directory (which must ## Common Pitfalls - `deploy-tiling-server-detached.sh` hardcodes both the absolute host path (`/home/sapot/YLP-software/tileserver`) and the `.mbtiles` filename (`osm-batangas.mbtiles`) — it is not portable across machines/usernames without editing it first. -- A fresh checkout has no map data — `.mbtiles` files are gitignored. Run `download-script.sh` (or follow the Google Drive link in `documentation.org`) before expecting tiles to serve. +- A fresh checkout has no map data — `.mbtiles` files are gitignored. Run `download-script.sh` before expecting tiles to serve. - The tile endpoint must be a literal tile URL (`.../{z}/{x}/{y}.png`), not the TileServer GL `#` viewer URL — `documentation.org`'s example flags this explicitly as a common mistake. - Tile rows are stored TMS (bottom-up, per the file's `scheme=tms` metadata) but the HTTP API serves XYZ (top-down). Mixing the two silently returns the wrong tile or a blank one — `crop-mbtiles.py`'s `tile_ranges()` does the flip, and any hand-written SQL against `map`/`tiles` must too. - Outside the cropped region, tile requests return HTTP 204 (vector) or a blank 1833-byte PNG (raster). That is the designed degradation, not a broken tileserver, and the blank PNG is deliberate: a 404 would trip the admin's `tiles-unavailable` banner (`MapLibre.tsx`'s `error` handler). @@ -47,6 +46,6 @@ Docker image `maptiler/tileserver-gl`, bind-mounting this directory (which must ## When Modifying This Project - Changing the `.mbtiles` filename requires updating `deploy-tiling-server-detached.sh` **and** `docker-compose.yml`'s `tileserver.command` (both hardcode `osm-batangas.mbtiles`) — the foreground script (`deploy-tiling-server.sh`) doesn't hardcode a filename since it doesn't pass `--mbtiles` explicitly. -- Changing the deployment region means re-running `./crop-mbtiles.py --region ` against the national source and repointing those two hardcoded filenames. Adding a region means adding a bbox to `REGIONS` in `crop-mbtiles.py`. +- Changing the deployment region requires a separately reviewed and immutable map release, a new contract, and matching client bounds. Do not add release-time cropping. - Changing the exposed port or data path requires updating both deploy scripts, `server/nginx.conf` / `docker/nginx.docker.conf`'s `/tiles/` proxy target, and the tile endpoint URLs `mobile-app`/`admin-frontend` clients use — this is a cross-component change (see root `CLAUDE.md`). - `deploy-tiling-server-detached.sh` binds to `127.0.0.1:8080` intentionally — the container is only reachable through the Nginx TLS proxy, not directly on the LAN. Don't revert this to `0.0.0.0`/`:8080` without updating the client URL scheme back to a direct port too. diff --git a/tileserver/download-script.sh b/tileserver/download-script.sh index 6c59af0e..c8c9fbcc 100755 --- a/tileserver/download-script.sh +++ b/tileserver/download-script.sh @@ -1,88 +1,58 @@ -#!/bin/sh -# Fetch the pinned Philippines extract, verify it, then crop the deployment region. -# -# Only the cropped file is served (see deploy-tiling-server-detached.sh and -# docker-compose.yml). The full extract is kept afterwards so a different -# region can be cut later without re-downloading 432 MB. Pass --cleanup-source -# on an ephemeral CI runner to remove it after a successful crop. -set -e - -SCRIPT_DIR=$(cd -P -- "$(dirname -- "$0")" && pwd) -cd "$SCRIPT_DIR" - -REGION=batangas -CLEANUP_SOURCE=false -while [ "$#" -gt 0 ]; do - case "$1" in - --cleanup-source) CLEANUP_SOURCE=true ;; - *) REGION=$1 ;; - esac - shift -done - -SOURCE="osm-2020-02-10-v3.11_asia_philippines.mbtiles" -SOURCE_URL="https://drive.usercontent.google.com/download?id=1UVakmRkrHaz2J1cgCIbkAHsHDW9SYwLq&export=download&confirm=t&uuid=decf843a-461b-43c5-8c08-11a3789ab93b" - -if [ ! -f "$SOURCE" ]; then - echo "### downloading full Philippines extract (432 MB) ###" - temporary="$SOURCE.download.$$" - trap 'rm -f "$temporary"' EXIT HUP INT TERM - curl --fail --location --retry 3 --output "$temporary" "$SOURCE_URL" - mv "$temporary" "$SOURCE" - trap - EXIT HUP INT TERM -else - echo "### $SOURCE already present, skipping download ###" +#!/usr/bin/env bash +# Download the reviewed, immutable Batangas map release without transforming it. +set -euo pipefail + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +repo_root=$(cd "$script_dir/.." && pwd) +metadata="$script_dir/map-artifact.json" +if [[ "${1:-}" = "--metadata" ]]; then + metadata=${2:?missing metadata path} + shift 2 fi - -sha256sum --check --strict osm-source.sha256 - -echo "### cropping to region: $REGION ###" -# --no-overview: this deployment shows only its own province, so the -# nationwide low-zoom levels are dropped too. --min-zoom 9 is one below the -# clients' minZoom of 10, because tileserver-gl renders a raster tile at zoom -# Z from vector tiles at Z-1 -- without z9 present, z10 renders blank. -OUTPUT="osm-$REGION.mbtiles" -TEMPORARY_OUTPUT="$OUTPUT.new.$$" -trap 'rm -f "$TEMPORARY_OUTPUT"' EXIT HUP INT TERM -python3 ./crop-mbtiles.py --region "$REGION" --no-overview --min-zoom 9 --output "$TEMPORARY_OUTPUT" - -SOURCE_SHA=$(awk 'NR == 1 {print $1}' osm-source.sha256) -python3 - "$TEMPORARY_OUTPUT" "$REGION" "$SOURCE_SHA" <<'PY' -import sqlite3 -import sys - -path, expected_region, source_sha = sys.argv[1:] -with sqlite3.connect(path) as database: - database.execute( - "INSERT OR REPLACE INTO metadata(name, value) VALUES (?, ?)", - ("sapot:source_sha256", source_sha), - ) - database.commit() - integrity = database.execute("PRAGMA integrity_check").fetchone()[0] - metadata = dict(database.execute("SELECT name, value FROM metadata")) -if integrity != "ok": - raise SystemExit(f"MBTiles integrity check failed: {integrity}") -expected = { - "sapot:region": expected_region, - "sapot:source_sha256": source_sha, - "minzoom": "9", - "maxzoom": "14", - "scheme": "tms", -} -for key, value in expected.items(): - if metadata.get(key) != value: - raise SystemExit(f"MBTiles metadata {key}={metadata.get(key)!r}, expected {value!r}") +[[ $# -eq 0 ]] || { echo "usage: $0 [--metadata PATH]" >&2; exit 2; } +command -v gh >/dev/null || { echo "missing required command: gh" >&2; exit 1; } +source "$repo_root/scripts/lib/github-api.sh" + +readarray -t values < <(python3 - "$metadata" <<'PY' +import importlib.util, sys +spec = importlib.util.spec_from_file_location("map_validator", "tileserver/validate-map-artifact.py") +module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module) +contract = module.load_contract(__import__("pathlib").Path(sys.argv[1])) +for key in ("repository", "releaseTag", "assetName", "sha256"): + print(contract[key]) +PY +) +repository=${values[0]}; release_tag=${values[1]}; asset_name=${values[2]}; expected_sha=${values[3]} +encoded_tag=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$release_tag") + +immutable=$(github_api "repos/$repository/immutable-releases" --jq '.enabled' 2>/dev/null || true) +[[ "$immutable" = true ]] || { echo "map release requires repository immutable releases to be enabled" >&2; exit 1; } +release=$(github_api "repos/$repository/releases/tags/$encoded_tag" 2>/dev/null) || { echo "map release $release_tag was not found" >&2; exit 1; } +RELEASE_JSON="$release" python3 - "$asset_name" "$expected_sha" <<'PY' +import json, os, sys +asset_name, expected_sha = sys.argv[1:] +release = json.loads(os.environ["RELEASE_JSON"]) +if release.get("draft") or not release.get("published_at"): + raise SystemExit("map release must be published") +assets = [asset for asset in release.get("assets", []) if asset.get("name") == asset_name] +if len(assets) != 1: + raise SystemExit("map release must contain exactly one expected asset") +asset = assets[0] +if asset.get("state") != "uploaded": raise SystemExit("map asset is not uploaded") +if asset.get("digest") != f"sha256:{expected_sha}": raise SystemExit("map asset digest does not match metadata") PY -mv "$TEMPORARY_OUTPUT" "$OUTPUT" -trap - EXIT HUP INT TERM - -if "$CLEANUP_SOURCE"; then - rm -f "$SOURCE" -fi -echo -echo "Serving file is $OUTPUT." -if ! "$CLEANUP_SOURCE"; then - echo "The $SOURCE source (432 MB) is no longer needed at runtime. Remove it" - echo "if disk is tight, but it must be downloaded again to cut another region." +destination="$script_dir/$asset_name" +if [[ -f "$destination" ]] && python3 "$script_dir/validate-map-artifact.py" "$destination" --metadata "$metadata"; then + echo "validated cached $destination" + exit 0 fi +rm -f "$destination" +temporary_directory=$(mktemp -d "$script_dir/.map-download.XXXXXX") +cleanup() { rm -rf "$temporary_directory"; } +trap cleanup EXIT +gh release download "$release_tag" --repo "$repository" --pattern "$asset_name" --dir "$temporary_directory" +candidate="$temporary_directory/$asset_name" +python3 "$script_dir/validate-map-artifact.py" "$candidate" --metadata "$metadata" +mv "$candidate" "$destination" +echo "downloaded and validated $destination" diff --git a/tileserver/map-artifact.json b/tileserver/map-artifact.json new file mode 100644 index 00000000..8533547f --- /dev/null +++ b/tileserver/map-artifact.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": "1.0", + "region": "batangas", + "repository": "SAPOT-System/YLP-software", + "releaseTag": "map/v1.0.0", + "assetName": "osm-batangas.mbtiles", + "sha256": "4ff05f69e78ff4a4751e7c1c9afdd63d803cc5a6d9b340bb6a0523cac093a598", + "size": 28549120, + "bounds": [120.45, 13.4, 121.6, 14.32], + "minZoom": 9, + "maxZoom": 14, + "scheme": "tms", + "format": "pbf" +} diff --git a/tileserver/tests/test_map_artifact.py b/tileserver/tests/test_map_artifact.py new file mode 100644 index 00000000..7d01fa92 --- /dev/null +++ b/tileserver/tests/test_map_artifact.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location("map_validator", ROOT / "tileserver" / "validate-map-artifact.py") +validator = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(validator) + + +class MapContractTests(unittest.TestCase): + def test_checked_in_contract_is_valid(self) -> None: + contract = validator.load_contract(ROOT / "tileserver" / "map-artifact.json") + self.assertEqual("map/v1.0.0", contract["releaseTag"]) + + def test_rejects_extra_contract_field(self) -> None: + contract = json.loads((ROOT / "tileserver" / "map-artifact.json").read_text()) + contract["sourceSha256"] = "x" + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "contract.json" + path.write_text(json.dumps(contract)) + with self.assertRaisesRegex(ValueError, "keys"): + validator.load_contract(path) diff --git a/tileserver/validate-map-artifact.py b/tileserver/validate-map-artifact.py new file mode 100755 index 00000000..f715e9e7 --- /dev/null +++ b/tileserver/validate-map-artifact.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Validate the reviewed MBTiles release contract and a read-only artifact.""" +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import re +import sqlite3 +import sys + +EXPECTED_KEYS = {"schemaVersion", "region", "repository", "releaseTag", "assetName", "sha256", "size", "bounds", "minZoom", "maxZoom", "scheme", "format"} + + +def fail(message: str) -> None: + raise ValueError(message) + + +def load_contract(path: Path) -> dict: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict) or set(data) != EXPECTED_KEYS: + fail(f"metadata keys must exactly equal {sorted(EXPECTED_KEYS)}") + if data["schemaVersion"] != "1.0": fail("unsupported metadata schemaVersion") + if not all(isinstance(data[key], str) and data[key] for key in ("region", "repository", "releaseTag", "assetName", "scheme", "format")): fail("metadata string field is missing or invalid") + if not re.fullmatch(r"[0-9a-f]{64}", data["sha256"]): fail("metadata sha256 must be a lowercase 64-character SHA-256") + if not isinstance(data["size"], int) or isinstance(data["size"], bool) or data["size"] <= 0: fail("metadata size must be a positive integer") + if not isinstance(data["bounds"], list) or len(data["bounds"]) != 4 or not all(isinstance(x, (int, float)) and not isinstance(x, bool) for x in data["bounds"]): fail("metadata bounds must contain four numbers") + if not all(isinstance(data[key], int) and not isinstance(data[key], bool) for key in ("minZoom", "maxZoom")) or data["minZoom"] > data["maxZoom"]: fail("metadata zoom range is invalid") + return data + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def validate(path: Path, contract_path: Path) -> None: + contract = load_contract(contract_path) + if not path.is_file(): fail(f"map artifact does not exist: {path}") + if path.name != contract["assetName"]: fail(f"artifact filename {path.name!r} does not match {contract['assetName']!r}") + if path.stat().st_size != contract["size"]: fail(f"artifact size is {path.stat().st_size}, expected {contract['size']}") + actual_sha = sha256(path) + if actual_sha != contract["sha256"]: fail(f"artifact SHA-256 is {actual_sha}, expected {contract['sha256']}") + with sqlite3.connect(f"file:{path}?mode=ro", uri=True) as database: + integrity = database.execute("PRAGMA integrity_check").fetchone()[0] + metadata = dict(database.execute("SELECT name, value FROM metadata")) + tables = {row[0] for row in database.execute("SELECT name FROM sqlite_master WHERE type='table'")} + counts = {table: database.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0] for table in ("map", "images") if table in tables} + if integrity != "ok": fail(f"MBTiles integrity check failed: {integrity}") + if not {"map", "images"} <= tables or any(counts.get(table, 0) <= 0 for table in ("map", "images")): fail("MBTiles map and images tables must exist and be nonempty") + expected = {"sapot:region": contract["region"], "bounds": ",".join(str(x) for x in contract["bounds"]), "minzoom": str(contract["minZoom"]), "maxzoom": str(contract["maxZoom"]), "scheme": contract["scheme"], "format": contract["format"]} + for key, value in expected.items(): + if metadata.get(key) != value: fail(f"MBTiles metadata {key}={metadata.get(key)!r}, expected {value!r}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("artifact", type=Path) + parser.add_argument("--metadata", type=Path, default=Path(__file__).with_name("map-artifact.json")) + args = parser.parse_args() + try: validate(args.artifact, args.metadata) + except (OSError, ValueError, sqlite3.Error, json.JSONDecodeError) as error: + print(f"map artifact validation failed: {error}", file=sys.stderr); return 1 + return 0 + +if __name__ == "__main__": raise SystemExit(main()) From 617642bfa0d597c825e1b6f8447f24baf26a7e5f Mon Sep 17 00:00:00 2001 From: Adamskiee Date: Tue, 11 Aug 2026 15:09:12 +0800 Subject: [PATCH 3/4] docs(deploy-release): remove bundle workflow plan --- .../bundle-release-workflow-plan.md | 38 ------------------- 1 file changed, 38 deletions(-) delete mode 100644 docs/deployment/bundle-release-workflow-plan.md diff --git a/docs/deployment/bundle-release-workflow-plan.md b/docs/deployment/bundle-release-workflow-plan.md deleted file mode 100644 index 546a9c59..00000000 --- a/docs/deployment/bundle-release-workflow-plan.md +++ /dev/null @@ -1,38 +0,0 @@ -# Independent Deployment Bundle Release Workflow - -## Summary - -- Introduce the deployment bundle as an independent release unit starting at `0.0.1`. -- Publish bundles from annotated `bundle/vX.Y.Z` tags as dedicated GitHub Releases. -- Treat `bundle/v0.0.1` as a fresh first deployment. Existing locally built server-derived bundles receive no upgrade or rollback compatibility. -- Mirror the repository's existing component release conventions while adding bundle-specific build assets. - -## Interfaces and Versioning - -- Add `deploy/VERSION` as the bundle version source, initially `0.0.1`. The server version will no longer affect bundle naming, manifests, tags, or compatibility. -- Add a committed bundle policy containing `minimumUpgradeVersion` and `minimumRollbackVersion`, both initially `0.0.1`. -- Extend `scripts/release.sh` with `bundle`, updating `deploy/VERSION`, committing with the repository's deploy scope, and creating `bundle/vX.Y.Z`. -- Introduce manifest schema `2.0` with independent bundle versioning and explicit versions for the bundled server, admin, GSM service, and GSM firmware. -- Correct firmware validation to compare against the GSM service version instead of the bundle version. -- Use bundle-versioned Docker tags so future releases can coexist for rollback. - -## Workflow and Build Changes - -- Add a tag-triggered GitHub workflow that validates the tag, version, release policy, source commit, and annotated release notes. -- Download and checksum the national MBTiles source, crop Batangas, and validate the result before packaging. -- Add a low-disk build mode that saves images sequentially and removes no-longer-needed Docker data. -- Publish the bundle archive and its SHA-256 file through a draft release that becomes public only after both uploads succeed. -- Update versioning and deployment documentation for the independent bundle lifecycle. - -## Verification - -- Test policy validation, independent SemVer ordering, schema rejection, map integrity, and firmware-to-GSM compatibility. -- Run Bash syntax checks, deployment script tests, metadata validation tests, and a complete low-disk bundle build. -- Confirm `bundle/v0.0.1` creates one dedicated release without touching or depending on a `server/v*` release. - -## Assumptions - -- `bundle/v0.0.1` is only for fresh installation. -- No migration path is required for local `v0.1.0` or `v0.1.1` bundles. -- Every distributed rebuild receives a new bundle version even when component versions are unchanged. -- Publishing a GitHub Release does not deploy it to a live server. From afcc45cbd6abfdb05836e56ce64f393dfac52cc4 Mon Sep 17 00:00:00 2001 From: Adamskiee Date: Tue, 11 Aug 2026 15:45:27 +0800 Subject: [PATCH 4/4] feat(deploy-backup): verify database backup restores --- deploy/scripts/doctor.sh | 24 +++ deploy/scripts/install.sh | 9 +- deploy/scripts/lib/backup-lib.sh | 20 +++ deploy/scripts/lib/dump-inspector.py | 162 ++++++++++++++++++ deploy/scripts/lib/verification-state.py | 73 ++++++++ deploy/scripts/rollback.sh | 5 + deploy/scripts/tests/run-tests.sh | 3 +- deploy/scripts/tests/test_dump_inspector.py | 44 +++++ deploy/scripts/tests/test_paths.bash | 17 ++ deploy/scripts/upgrade.sh | 5 + deploy/scripts/verify-db-backup.sh | 150 ++++++++++++++++ deployment-scripts/AGENTS.md | 2 +- .../sapot-db-backup-verify.service | 17 ++ .../sapot-db-backup-verify.timer | 11 ++ docs/architecture/component-map.md | 2 + docs/deployment/docker-bundle.md | 12 +- docs/deployment/install-ubuntu-server.md | 3 +- docs/deployment/maintenance.md | 1 + docs/deployment/monitoring-logging.md | 1 + docs/deployment/runbooks.md | 22 ++- scripts/AGENTS.md | 2 +- scripts/build-bundle.sh | 3 +- 22 files changed, 573 insertions(+), 15 deletions(-) create mode 100755 deploy/scripts/lib/dump-inspector.py create mode 100755 deploy/scripts/lib/verification-state.py create mode 100644 deploy/scripts/tests/test_dump_inspector.py create mode 100755 deploy/scripts/verify-db-backup.sh create mode 100644 deployment-scripts/sapot-db-backup-verify.service create mode 100644 deployment-scripts/sapot-db-backup-verify.timer diff --git a/deploy/scripts/doctor.sh b/deploy/scripts/doctor.sh index 3ee3e08b..bb9bf40e 100755 --- a/deploy/scripts/doctor.sh +++ b/deploy/scripts/doctor.sh @@ -37,6 +37,30 @@ else if [ "$backup_age_hours" -lt "$max_age_hours" ]; then check db-backup PASS "newest ${backup_age_hours}h ago; $offhost_detail" else check db-backup FAIL "newest ${backup_age_hours}h ago exceeds ${max_age_hours}h threshold; $offhost_detail"; fi fi +verification_state="$backup_dir/verification-status.json" +verify_max_age_hours=${SAPOT_BACKUP_VERIFY_MAX_AGE_HOURS:-36} +if ! verify_detail=$(python3 - "$SELF/lib/verification-state.py" "$verification_state" "$backup_dir" "$verify_max_age_hours" <<'PY' +import json, os, sys, time +reader, state_path, backup_dir, maximum = sys.argv[1:] +import subprocess +try: + state=json.loads(subprocess.check_output([sys.executable, reader, state_path], text=True)) + now=time.time() + if state['status'] != 'PASS': raise ValueError('latest verification failed: ' + str(state['reason'])) + if state['checkedAtEpoch'] > now + 300: raise ValueError('verification timestamp is future-dated') + if now - state['checkedAtEpoch'] > int(maximum) * 3600: raise ValueError('verification is stale') + path=os.path.join(backup_dir, state['backupFilename']) + if not os.path.isfile(path) or os.path.islink(path): raise ValueError('verified backup is missing') + if os.path.getsize(path) != state['compressedSize'] or int(os.path.getmtime(path)) != state['backupMtimeEpoch']: raise ValueError('verified backup changed') + names=sorted(n for n in os.listdir(backup_dir) if n.startswith('sapot_db_') and n.endswith('.sql.gz')) + if not names or names[-1] != state['backupFilename']: raise ValueError('verification is not for newest backup') + print('PASS|' + '%s; %dh ago; schema %s; %s tables; %s rows' % (state['backupFilename'], (now-state['checkedAtEpoch'])//3600, state['schemaRevision'], state['tablesChecked'], state['rowsChecked'])) +except Exception as error: + print('FAIL|' + str(error)) +PY +); then verify_detail='FAIL|verification state could not be read'; fi +IFS='|' read -r verify_status verify_text <<< "$verify_detail" +check db-backup-restore "$verify_status" "$verify_text" check_certificate() { local cert="$SAPOT_ROOT/shared/certs/server.crt" local key="$SAPOT_ROOT/shared/certs/server.key" diff --git a/deploy/scripts/install.sh b/deploy/scripts/install.sh index ddad0c13..96ab3e1d 100755 --- a/deploy/scripts/install.sh +++ b/deploy/scripts/install.sh @@ -39,8 +39,13 @@ install_systemd_units "$target" # and a bundle built before units shipped. if [ -e "${SAPOT_SYSTEMD_DIR:-/etc/systemd/system}/sapot-db-backup.timer" ]; then provision_service_account - systemctl enable --now sapot-db-backup.timer - log_info "scheduled database backups enabled (sapot-db-backup.timer)" + systemctl enable --now sapot-db-backup.timer sapot-db-backup-verify.timer + python3 - "$SAPOT_ROOT/shared/state.json" <<'PY' +import json, os, sys +path=sys.argv[1]; state=json.load(open(path)); state['dbBackupVerifyTimerEnabled']=True +tmp=path+'.tmp'; open(tmp,'w').write(json.dumps(state, indent=2)+'\n'); os.replace(tmp,path) +PY + log_info "scheduled database backup and verification enabled" fi "$SELF/lib/retention.sh"; log_pass "installed SAPOT v$version" # The install is complete and recorded by this point. Release the lock before an diff --git a/deploy/scripts/lib/backup-lib.sh b/deploy/scripts/lib/backup-lib.sh index 7a8f794f..f7b036a6 100644 --- a/deploy/scripts/lib/backup-lib.sh +++ b/deploy/scripts/lib/backup-lib.sh @@ -144,3 +144,23 @@ newest_backup_epoch() { find "$dir" -maxdepth 1 -type f -name 'sapot_db_*.sql.gz' -printf '%T@\n' 2>/dev/null \ | sort -rn | head -n 1 | cut -d. -f1 } + +# newest_finalized_backup +# Prints the selected regular, non-symlink backup path. The timestamp in its +# name, rather than mtime, is the ordering contract for restore verification. +newest_finalized_backup() { + local dir=$1 now stamp name path selected="" selected_stamp="" + now=$(date -u +%s) + while IFS= read -r -d '' name; do + [[ $name =~ ^sapot_db_([0-9]{8}T[0-9]{6}Z)\.sql\.gz$ ]] || continue + stamp=${BASH_REMATCH[1]} + if ! epoch=$(date -u -d "${stamp:0:4}-${stamp:4:2}-${stamp:6:2} ${stamp:9:2}:${stamp:11:2}:${stamp:13:2} UTC" +%s 2>/dev/null) || [ "$epoch" -gt $((now + 300)) ]; then + log_error "invalid or future-dated backup filename: $name"; return 2 + fi + if [[ -z $selected_stamp || $stamp > $selected_stamp ]]; then selected=$name; selected_stamp=$stamp; fi + done < <(find "$dir" -maxdepth 1 -type f -printf '%f\0' 2>/dev/null) + [ -n "$selected" ] || return 1 + path=$(realpath -e -- "$dir/$selected") || return 2 + [[ $path == "$(realpath -e -- "$dir")"/* ]] && [ -f "$path" ] && [ ! -L "$dir/$selected" ] || return 2 + printf '%s\n' "$path" +} diff --git a/deploy/scripts/lib/dump-inspector.py b/deploy/scripts/lib/dump-inspector.py new file mode 100755 index 00000000..1f8c44d2 --- /dev/null +++ b/deploy/scripts/lib/dump-inspector.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Inspect a mysqldump stream without retaining its SQL or row values.""" + +import argparse +import gzip +import hashlib +import json +import os +import re +import sys + +IDENTIFIER = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") +MAX_TAIL = 8192 + + +class Inspector: + def __init__(self) -> None: + self.tables: dict[str, int] = {} + self.total = 0 + self.statement = bytearray() + self.mode = "normal" + self.quote = 0 + self.escape = False + self.line_comment = False + self.block_comment = False + self.delimiter = b";" + self.parens = 0 + self.insert_table: str | None = None + self.insert_values = False + self.row_depth = 0 + self.row_open = False + self.declaration_parsed = False + + @staticmethod + def name(raw: bytes) -> str: + text = raw.decode("ascii", "strict").strip().strip("`").lower() + if not IDENTIFIER.fullmatch(text): + raise ValueError("unsafe SQL identifier") + return text + + def begin_statement(self) -> None: + prefix = bytes(self.statement).strip() + upper = prefix.upper() + if upper.startswith(b"CREATE TABLE") and not self.declaration_parsed: + match = re.match(rb"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(`[^`]+`|[A-Za-z_][A-Za-z0-9_]*)\s*\(", prefix, re.I) + if not match: + return + table = self.name(match.group(1)) + if table in self.tables: + raise ValueError("duplicate table declaration") + self.tables[table] = 0 + self.declaration_parsed = True + elif upper.startswith(b"INSERT"): + match = re.match(rb"INSERT\s+INTO\s+(`[^`]+`|[A-Za-z_][A-Za-z0-9_]*)(?=\s|\(|$)", prefix, re.I) + if not match: + return + self.insert_table = self.name(match.group(1)) + if self.insert_table not in self.tables: + raise ValueError("insert into undeclared table") + self.insert_values = bool(re.search(rb"\bVALUES\s*$", prefix, re.I)) + elif re.match(rb"(?:REPLACE|LOAD\s+DATA)\b", prefix, re.I): + raise ValueError("unsupported data statement") + + def finish_statement(self) -> None: + prefix = bytes(self.statement).strip() + if prefix.upper().startswith(b"DELIMITER"): + parts = prefix.split() + if len(parts) != 2 or len(parts[1]) > 16: + raise ValueError("invalid delimiter") + self.delimiter = parts[1] + self.statement.clear() + self.insert_table = None + self.insert_values = False + self.row_depth = 0 + self.row_open = False + self.declaration_parsed = False + + def feed(self, data: bytes) -> None: + for char in data: + if self.line_comment: + if char in (10, 13): self.line_comment = False + continue + if self.block_comment: + self.statement.append(char) + if len(self.statement) >= 2 and self.statement[-2:] == b"*/": self.block_comment = False + continue + if self.quote: + if self.escape: self.escape = False + elif char == 92: self.escape = True + elif char == self.quote: self.quote = 0 + continue + if char in (39, 34): + self.quote = char; continue + if char == 35: + self.line_comment = True; continue + if char == 45 and self.statement.endswith(b"-"): + self.statement.pop(); self.line_comment = True; continue + if char == 42 and self.statement.endswith(b"/"): + self.statement.pop() + self.block_comment = True; continue + if not self.insert_values: + self.statement.append(char) + if not self.insert_table and len(self.statement) < 1024: + self.begin_statement() + if self.insert_table and not self.insert_values and re.search(rb"\bVALUES\s*$", self.statement, re.I): + self.insert_values = True + # Values may be arbitrarily large. The prefix has already + # established the target table, so retaining it is needless. + self.statement.clear() + if self.insert_values: + if char == 40: + self.row_depth += 1 + if self.row_depth == 1: self.row_open = True + elif char == 41 and self.row_depth: + self.row_depth -= 1 + if self.row_depth == 0 and self.row_open: + self.tables[self.insert_table] += 1; self.total += 1; self.row_open = False + ends_statement = (self.insert_values and self.row_depth == 0 and self.delimiter == bytes([char])) or self.statement.endswith(self.delimiter) + if ends_statement: + if not self.insert_values: + self.statement = self.statement[:-len(self.delimiter)] + self.finish_statement() + + def finish(self) -> dict: + if self.quote or self.block_comment or self.parens or self.statement.strip(): + raise ValueError("unterminated SQL statement") + if not self.tables: + raise ValueError("dump has no base tables") + return {"tables": dict(sorted(self.tables.items())), "totalRows": self.total} + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--fd", type=int, required=True) + parser.add_argument("--output", required=True) + args = parser.parse_args() + compressed_hash = hashlib.sha256(); compressed_size = 0; uncompressed_size = 0; tail = bytearray(); inspector = Inspector() + try: + with os.fdopen(os.dup(args.fd), "rb", closefd=True) as raw: + class HashingReader: + def read(self, size=-1): + nonlocal compressed_size + chunk = raw.read(size) + compressed_hash.update(chunk); compressed_size += len(chunk) + return chunk + with gzip.GzipFile(fileobj=HashingReader(), mode="rb") as source: + while chunk := source.read(65536): + uncompressed_size += len(chunk); tail.extend(chunk); del tail[:-MAX_TAIL] + inspector.feed(chunk) + if b"-- Dump completed" not in tail: + raise ValueError("missing dump completion footer") + result = inspector.finish() | {"schemaVersion": "1.0", "compressedSize": compressed_size, "sha256": compressed_hash.hexdigest(), "uncompressedSize": uncompressed_size} + with open(args.output, "x", encoding="utf-8") as out: + json.dump(result, out, separators=(",", ":"), sort_keys=True) + return 0 + except (OSError, EOFError, gzip.BadGzipFile, ValueError, UnicodeError) as error: + print(f"dump inspection failed: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deploy/scripts/lib/verification-state.py b/deploy/scripts/lib/verification-state.py new file mode 100755 index 00000000..9a3f9857 --- /dev/null +++ b/deploy/scripts/lib/verification-state.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Validate the non-executable backup verification state format.""" + +import json +import re +import sys +from pathlib import Path + +FILENAME = re.compile(r"^sapot_db_\d{8}T\d{6}Z\.sql\.gz$") +REASONS = { + "NO_BACKUP", "INVALID_CONFIG", "INVALID_DUMP", "INSUFFICIENT_SPACE", + "RUNTIME_UNAVAILABLE", "START_TIMEOUT", "RESTORE_FAILED", "STRUCTURE_FAILED", + "SCHEMA_INVALID", "ROW_COUNT_MISMATCH", "SUPERSEDED", "CLEANUP_FAILED", + "INTERNAL_ERROR", +} +BACKENDS = {"bundle-docker", "baremetal-local"} + + +def fail(message: str) -> None: + raise ValueError(message) + + +def validate(value: object) -> dict: + if not isinstance(value, dict): + fail("state must be an object") + required = { + "schemaVersion", "status", "checkedAtEpoch", "backupFilename", "compressedSize", + "backupMtimeEpoch", "sha256", "durationSeconds", "backend", "schemaRevision", + "tablesChecked", "rowsChecked", "keyTableCounts", "reason", "message", + } + if set(value) != required: + fail("state fields do not match schema 1.0") + if value["schemaVersion"] != "1.0" or value["status"] not in {"PASS", "FAIL"}: + fail("unsupported state schema or status") + if value["backend"] not in BACKENDS: + fail("invalid backend") + if value["backupFilename"] is not None and (not isinstance(value["backupFilename"], str) or not FILENAME.fullmatch(value["backupFilename"])): + fail("invalid backup filename") + if value["sha256"] is not None and (not isinstance(value["sha256"], str) or not re.fullmatch(r"[0-9a-f]{64}", value["sha256"])): + fail("invalid sha256") + for field in ("checkedAtEpoch", "compressedSize", "backupMtimeEpoch", "durationSeconds", "tablesChecked", "rowsChecked"): + if value[field] is not None and (type(value[field]) is not int or value[field] < 0): + fail(f"invalid {field}") + if value["schemaRevision"] is not None and (not isinstance(value["schemaRevision"], str) or not re.fullmatch(r"[0-9a-f]{12}", value["schemaRevision"])): + fail("invalid schema revision") + if not isinstance(value["keyTableCounts"], dict) or any(not isinstance(k, str) or type(v) is not int or v < 0 for k, v in value["keyTableCounts"].items()): + fail("invalid key table counts") + if value["status"] == "PASS": + if value["reason"] is not None: + fail("successful state must not have a reason") + elif value["reason"] not in REASONS: + fail("failed state has an invalid reason") + if not isinstance(value["message"], str): + fail("invalid message") + return value + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: verification-state.py STATE_FILE", file=sys.stderr) + return 2 + try: + with Path(sys.argv[1]).open(encoding="utf-8") as source: + state = validate(json.load(source)) + print(json.dumps(state, separators=(",", ":"), sort_keys=True)) + except (OSError, json.JSONDecodeError, ValueError) as error: + print(f"invalid verification state: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deploy/scripts/rollback.sh b/deploy/scripts/rollback.sh index 1c856a55..63ed804d 100755 --- a/deploy/scripts/rollback.sh +++ b/deploy/scripts/rollback.sh @@ -22,5 +22,10 @@ ln -sfn "$target" "$SAPOT_ROOT/releases/current"; compose "$target" up -d # Restore the rolled-back release's units so they match the code now running. # Like upgrade, this refreshes without enabling anything. install_systemd_units "$target" +if [ ! -e "$target/systemd/sapot-db-backup-verify.timer" ] && [ -e "${SAPOT_SYSTEMD_DIR:-/etc/systemd/system}/sapot-db-backup-verify.timer" ]; then + systemctl disable --now sapot-db-backup-verify.timer || true + rm -f -- "${SAPOT_SYSTEMD_DIR:-/etc/systemd/system}/sapot-db-backup-verify.timer" "${SAPOT_SYSTEMD_DIR:-/etc/systemd/system}/sapot-db-backup-verify.service" + systemctl daemon-reload +fi hardware=$(manifest_value "$SAPOT_ROOT/shared/state.json" gsmHardwarePresent); write_state rollback "$current_version" "$target_version" "$hardware" log_pass "rolled back from v$current_version to v$target_version" diff --git a/deploy/scripts/tests/run-tests.sh b/deploy/scripts/tests/run-tests.sh index c3daa6aa..ddcd6586 100755 --- a/deploy/scripts/tests/run-tests.sh +++ b/deploy/scripts/tests/run-tests.sh @@ -33,4 +33,5 @@ for fn in $(declare -F | awk '{print $3}' | grep '^test_' | sort); do done printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" -[ "$FAIL" -eq 0 ] +[ "$FAIL" -eq 0 ] || exit 1 +python3 -m unittest discover -s "$SELF" -p 'test_*.py' diff --git a/deploy/scripts/tests/test_dump_inspector.py b/deploy/scripts/tests/test_dump_inspector.py new file mode 100644 index 00000000..941c2b68 --- /dev/null +++ b/deploy/scripts/tests/test_dump_inspector.py @@ -0,0 +1,44 @@ +import gzip +import importlib.util +import json +import os +import sys +import tempfile +import unittest + + +ROOT = os.path.dirname(os.path.dirname(__file__)) +SPEC = importlib.util.spec_from_file_location("dump_inspector", os.path.join(ROOT, "lib", "dump-inspector.py")) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class DumpInspectorTests(unittest.TestCase): + def test_counts_empty_and_extended_rows_without_values_in_summary(self): + inspector = MODULE.Inspector() + inspector.feed(b"CREATE TABLE `empty_table` (id int);\n") + inspector.feed(b"CREATE TABLE `message` (id int, body text);\n") + inspector.feed(b"INSERT INTO `message` (id, body) VALUES (1, 'comma, and (paren)'),(2, 'text');\n") + self.assertEqual(inspector.finish(), {"tables": {"empty_table": 0, "message": 2}, "totalRows": 2}) + + def test_rejects_insert_for_unknown_table(self): + inspector = MODULE.Inspector() + with self.assertRaisesRegex(ValueError, "undeclared"): + inspector.feed(b"INSERT INTO `unknown` VALUES (1);") + + def test_gzip_inspection_does_not_include_row_value(self): + secret = "do-not-leak-row-value" + with tempfile.TemporaryDirectory() as directory: + dump = os.path.join(directory, "dump.gz") + summary = os.path.join(directory, "summary.json") + with gzip.open(dump, "wb") as out: + out.write(("CREATE TABLE `message` (id int);\nINSERT INTO `message` VALUES (1, '%s');\n-- Dump completed\n" % secret).encode()) + fd = os.open(dump, os.O_RDONLY) + try: + old, sys.argv = sys.argv, ["dump-inspector.py", "--fd", str(fd), "--output", summary] + self.assertEqual(MODULE.main(), 0) + finally: + sys.argv = old + os.close(fd) + with open(summary, encoding="utf-8") as result: + self.assertNotIn(secret, result.read()) diff --git a/deploy/scripts/tests/test_paths.bash b/deploy/scripts/tests/test_paths.bash index 911debb8..f6219383 100644 --- a/deploy/scripts/tests/test_paths.bash +++ b/deploy/scripts/tests/test_paths.bash @@ -14,3 +14,20 @@ test_resolve_backup_paths() { assert_eq /mnt/backups "${fields[2]}" 'backup override' assert_eq /etc/sapot.env "${fields[3]}" 'env override' ) } + +test_newest_finalized_backup_uses_filename_timestamp() { + local dir path; dir=$(mktemp -d); trap 'rm -rf "$dir"' RETURN + : > "$dir/sapot_db_20260101T000000Z.sql.gz" + : > "$dir/sapot_db_20260102T000000Z.sql.gz" + touch -d '2020-01-01' "$dir/sapot_db_20260102T000000Z.sql.gz" + path=$(newest_finalized_backup "$dir") + assert_eq "$dir/sapot_db_20260102T000000Z.sql.gz" "$path" 'verification selects by filename timestamp, not mtime' +} + +test_newest_finalized_backup_rejects_future_artifact() { + local dir stamp; dir=$(mktemp -d); trap 'rm -rf "$dir"' RETURN + stamp=$(date -u -d '10 minutes' +%Y%m%dT%H%M%SZ) + : > "$dir/sapot_db_${stamp}.sql.gz" + newest_finalized_backup "$dir" >/dev/null 2>&1 + assert_rc 2 $? 'future-dated finalized artifact is invalid' +} diff --git a/deploy/scripts/upgrade.sh b/deploy/scripts/upgrade.sh index 43485da4..23f16aa6 100755 --- a/deploy/scripts/upgrade.sh +++ b/deploy/scripts/upgrade.sh @@ -23,5 +23,10 @@ curl -kfsS https://localhost/version >/dev/null || { log_error "nginx/api did no hardware=$(manifest_value "$SAPOT_ROOT/shared/state.json" gsmHardwarePresent); ln -sfn "$target" "$SAPOT_ROOT/releases/current"; write_state upgrade "$current_version" "$version" "$hardware" # Refresh the unit files only. An operator who deliberately disabled a timer # should not have an upgrade switch it back on, so nothing is enabled here. +verify_was_installed=false +[ -e "${SAPOT_SYSTEMD_DIR:-/etc/systemd/system}/sapot-db-backup-verify.timer" ] && verify_was_installed=true install_systemd_units "$target" +if ! "$verify_was_installed" && [ -e "$target/systemd/sapot-db-backup-verify.timer" ] && systemctl is-enabled --quiet sapot-db-backup.timer; then + systemctl enable --now sapot-db-backup-verify.timer +fi "$SELF/lib/retention.sh"; log_pass "upgraded SAPOT from v$current_version to v$version" diff --git a/deploy/scripts/verify-db-backup.sh b/deploy/scripts/verify-db-backup.sh new file mode 100755 index 00000000..b9ee1015 --- /dev/null +++ b/deploy/scripts/verify-db-backup.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# Restore a finalized dump only into a disposable, network-isolated MariaDB. +set -euo pipefail + +SELF=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +source "$SELF/lib/deploy-common.sh" +source "$SELF/lib/backup-lib.sh" + +usage() { echo "usage: verify-db-backup.sh [--dry-run|--status]" >&2; exit 2; } +case "${1:-}" in '') ACTION=run ;; --dry-run) ACTION=dry-run ;; --status) ACTION=status ;; *) usage ;; esac +[ "$#" -le 1 ] || usage + +fields=(); mapfile -t -d '' fields < <(resolve_backup_paths) +MODE=${fields[0]} RELEASE=${fields[1]} BACKUP_DIR=${fields[2]} +STATE="$BACKUP_DIR/verification-status.json" +STATUS_REASON= INTERNAL_MESSAGE= BACKUP= RUN_DIR= CONTAINER= VOLUME= DAEMON_PID= SUMMARY= +STARTED=$(date +%s); CLEANUP_ERROR= + +cleanup() { + local rc=$? + [ -z "$CONTAINER" ] || docker rm -f "$CONTAINER" >/dev/null 2>&1 || CLEANUP_ERROR="container:$CONTAINER" + [ -z "$VOLUME" ] || docker volume rm "$VOLUME" >/dev/null 2>&1 || CLEANUP_ERROR="volume:$VOLUME" + if [ -n "$DAEMON_PID" ] && kill -0 "$DAEMON_PID" 2>/dev/null; then kill "$DAEMON_PID" 2>/dev/null || true; wait "$DAEMON_PID" 2>/dev/null || CLEANUP_ERROR="daemon:$DAEMON_PID"; fi + [ -z "$RUN_DIR" ] || rm -rf -- "$RUN_DIR" || CLEANUP_ERROR="run-directory:$RUN_DIR" + return "$rc" +} +trap cleanup EXIT INT TERM + +state_write() { + local status=$1 reason=${2:-} message=${3:-} duration + duration=$(( $(date +%s) - STARTED )) + python3 - "$STATE" "$status" "$reason" "$message" "$BACKUP" "${COMPRESSED_SIZE:-}" "${BACKUP_MTIME:-}" "${SHA256:-}" "$duration" "${BACKEND:-}" "${SCHEMA_REVISION:-}" "${TABLES_CHECKED:-0}" "${ROWS_CHECKED:-0}" "${KEY_COUNTS:-{}}" <<'PY' +import json, os, sys, tempfile, time +(path, status, reason, message, filename, size, mtime, digest, duration, backend, revision, tables, rows, key_counts) = sys.argv[1:] +value = {"schemaVersion":"1.0", "status":status, "checkedAtEpoch":int(time.time()), + "backupFilename":os.path.basename(filename) if filename else None, + "compressedSize":int(size) if size else None, "backupMtimeEpoch":int(mtime) if mtime else None, + "sha256":digest or None, "durationSeconds":int(duration), "backend":backend or "bundle-docker", + "schemaRevision":revision or None, "tablesChecked":int(tables), "rowsChecked":int(rows), + "keyTableCounts":json.loads(key_counts), "reason":None if status == "PASS" else reason, + "message":message} +fd, temporary = tempfile.mkstemp(prefix=".verification-status.", dir=os.path.dirname(path)) +try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as out: + json.dump(value, out, separators=(",",":"), sort_keys=True); out.flush(); os.fsync(out.fileno()) + os.replace(temporary, path) +finally: + if os.path.exists(temporary): os.unlink(temporary) +PY +} + +fail() { STATUS_REASON=$1; INTERNAL_MESSAGE=$2; } +select_backup() { + mkdir -p "$BACKUP_DIR" + BACKUP=$(newest_finalized_backup "$BACKUP_DIR") || { [ "$?" = 1 ] && fail NO_BACKUP "no finalized backup" || fail INVALID_DUMP "newest backup filename or path is unsafe"; return 1; } + BACKUP_MTIME=$(stat -c %Y -- "$BACKUP") +} + +if [ "$ACTION" = status ]; then + if ! validated=$(python3 "$SELF/lib/verification-state.py" "$STATE" 2>/dev/null); then log_error "backup restore verification state is missing or invalid"; exit 1; fi + select_backup || { log_error "backup restore verification failed [$STATUS_REASON]"; exit 1; } + python3 - "$validated" "$BACKUP" <<'PY' +import json, os, sys +s=json.loads(sys.argv[1]); p=sys.argv[2] +if s["status"] != "PASS" or s["backupFilename"] != os.path.basename(p) or s["compressedSize"] != os.path.getsize(p) or s["backupMtimeEpoch"] != int(os.path.getmtime(p)): + raise SystemExit(1) +print(json.dumps(s, indent=2, sort_keys=True)) +PY + exit $? +fi + +umask 0077 +mkdir -p "$BACKUP_DIR" +exec 8>"$BACKUP_DIR/.verify.lock" +if ! flock -n 8; then log_warn "backup restore verification already running"; exit 0; fi +select_backup || { state_write FAIL "$STATUS_REASON" "$INTERNAL_MESSAGE"; log_error "backup restore verification failed [$STATUS_REASON]"; exit 1; } +if [ "$MODE" = bundle ]; then BACKEND=bundle-docker; RUN_BASE="$BACKUP_DIR/.verify-runs"; else BACKEND=baremetal-local; RUN_BASE=${SAPOT_BACKUP_VERIFY_TMPDIR:-/var/tmp}; fi + +multiplier=${SAPOT_BACKUP_VERIFY_DISK_MULTIPLIER:-2}; minimum=${SAPOT_BACKUP_VERIFY_MIN_FREE_BYTES:-1073741824} +[[ $multiplier =~ ^[1-9][0-9]*$ && $minimum =~ ^[0-9]+$ ]] || { fail INVALID_CONFIG "disk limits must be non-negative integers"; state_write FAIL "$STATUS_REASON" "$INTERNAL_MESSAGE"; log_error "backup restore verification failed [$STATUS_REASON]"; exit 1; } +mkdir -p "$RUN_BASE"; chmod 700 "$RUN_BASE" 2>/dev/null || true +RUN_ID="verify-$(date -u +%Y%m%dT%H%M%SZ)-$$"; RUN_DIR=$(mktemp -d "$RUN_BASE/$RUN_ID.XXXXXX"); chmod 700 "$RUN_DIR" +SUMMARY="$RUN_DIR/inspection.json"; exec {INSPECT_FD}<"$BACKUP"; exec {RESTORE_FD}<"$BACKUP" +if ! python3 "$SELF/lib/dump-inspector.py" --fd "$INSPECT_FD" --output "$SUMMARY"; then fail INVALID_DUMP "compressed dump inspection failed"; state_write FAIL "$STATUS_REASON" "$INTERNAL_MESSAGE"; log_error "backup restore verification failed [$STATUS_REASON]"; exit 1; fi +readarray -t values < <(python3 - "$SUMMARY" <<'PY' +import json,sys +s=json.load(open(sys.argv[1])); print(s['compressedSize']); print(s['sha256']); print(s['uncompressedSize']); print(s['totalRows']); print(len(s['tables'])) +PY +) +COMPRESSED_SIZE=${values[0]}; SHA256=${values[1]}; UNCOMPRESSED=${values[2]}; ROWS_CHECKED=${values[3]}; TABLES_CHECKED=${values[4]} +if [ "$MODE" = bundle ]; then SPACE_PATH=$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true); else SPACE_PATH=$(realpath -e "$RUN_BASE"); fi +[ -n "$SPACE_PATH" ] || { fail RUNTIME_UNAVAILABLE "unable to locate temporary filesystem"; state_write FAIL "$STATUS_REASON" "$INTERNAL_MESSAGE"; log_error "backup restore verification failed [$STATUS_REASON]"; exit 1; } +readarray -t space < <(python3 - "$UNCOMPRESSED" "$multiplier" "$minimum" "$SPACE_PATH" <<'PY' +import os,sys +u,m,n,p=sys.argv[1:]; required=max(int(u)*int(m),int(n)); print(required); print(os.statvfs(p).f_bavail * os.statvfs(p).f_frsize) +PY +) +if [ "${space[1]}" -lt "${space[0]}" ]; then fail INSUFFICIENT_SPACE "temporary storage is below the verification requirement"; state_write FAIL "$STATUS_REASON" "$INTERNAL_MESSAGE"; log_error "backup restore verification failed [$STATUS_REASON]"; exit 1; fi +if [ "$ACTION" = dry-run ]; then log_info "selected $(basename "$BACKUP") using $BACKEND; need ${space[0]} bytes, have ${space[1]} bytes at $SPACE_PATH"; exit 0; fi + +password=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n'); SECRETS="$RUN_DIR/secrets"; mkdir -m 700 "$SECRETS" +printf '%s\n' "$password" > "$SECRETS/root-password"; chmod 600 "$SECRETS/root-password" +printf '[client]\nuser=root\npassword="%s"\nprotocol=socket\n' "$password" > "$SECRETS/client.cnf"; chmod 600 "$SECRETS/client.cnf" +if [ "$MODE" = bundle ]; then + image=$(manifest_value "$RELEASE/manifest.json" images.mariadb.tag 2>/dev/null || true) + [ -n "$image" ] || { fail RUNTIME_UNAVAILABLE "bundle MariaDB image is unavailable"; state_write FAIL "$STATUS_REASON" "$INTERNAL_MESSAGE"; log_error "backup restore verification failed [$STATUS_REASON]"; exit 1; } + CONTAINER="sapot-backup-verify-$RUN_ID"; VOLUME="sapot-backup-verify-$RUN_ID" + docker volume create --label purpose=db-backup-verification --label run-id="$RUN_ID" --label owner-pid="$$" "$VOLUME" >/dev/null + docker run -d --name "$CONTAINER" --network none --mount "type=volume,src=$VOLUME,dst=/var/lib/mysql" --mount "type=bind,src=$SECRETS,dst=/run/secrets,readonly" --env MARIADB_ROOT_PASSWORD_FILE=/run/secrets/root-password --label purpose=db-backup-verification --label run-id="$RUN_ID" --label owner-pid="$$" "$image" >/dev/null + for _ in {1..60}; do docker exec "$CONTAINER" mariadb-admin --defaults-extra-file=/run/secrets/client.cnf ping --silent >/dev/null 2>&1 && break; sleep 5; done + docker exec "$CONTAINER" mariadb-admin --defaults-extra-file=/run/secrets/client.cnf ping --silent >/dev/null 2>&1 || { fail START_TIMEOUT "disposable MariaDB did not become ready"; state_write FAIL "$STATUS_REASON" "$INTERNAL_MESSAGE"; log_error "backup restore verification failed [$STATUS_REASON]"; exit 1; } + client=(docker exec -i "$CONTAINER" mariadb --defaults-extra-file=/run/secrets/client.cnf); check=(docker exec "$CONTAINER" mariadb-check --defaults-extra-file=/run/secrets/client.cnf) +else + for command in mariadb-install-db mariadbd mariadb mariadb-admin mariadb-check; do command -v "$command" >/dev/null || { fail RUNTIME_UNAVAILABLE "missing $command"; state_write FAIL "$STATUS_REASON" "$INTERNAL_MESSAGE"; log_error "backup restore verification failed [$STATUS_REASON]"; exit 1; }; done + DATA="$RUN_DIR/data"; SOCKET="$RUN_DIR/mariadb.sock"; mariadb-install-db --no-defaults --datadir="$DATA" --auth-root-authentication-method=normal >/dev/null 2>&1 + mariadbd --no-defaults --datadir="$DATA" --socket="$SOCKET" --pid-file="$RUN_DIR/mariadb.pid" --skip-networking >"$RUN_DIR/daemon.log" 2>&1 & DAEMON_PID=$! + for _ in {1..60}; do mariadb-admin --no-defaults --socket="$SOCKET" ping --silent >/dev/null 2>&1 && break; sleep 5; done + mariadb-admin --no-defaults --socket="$SOCKET" ping --silent >/dev/null 2>&1 || { fail START_TIMEOUT "disposable MariaDB did not become ready"; state_write FAIL "$STATUS_REASON" "$INTERNAL_MESSAGE"; log_error "backup restore verification failed [$STATUS_REASON]"; exit 1; } + client=(mariadb --no-defaults --socket="$SOCKET" -uroot); check=(mariadb-check --no-defaults --socket="$SOCKET" -uroot) +fi +"${client[@]}" -e 'CREATE DATABASE sapot_verify CHARACTER SET utf8mb4' || { fail RESTORE_FAILED "could not create disposable database"; state_write FAIL "$STATUS_REASON" "$INTERNAL_MESSAGE"; log_error "backup restore verification failed [$STATUS_REASON]"; exit 1; } +if ! gzip -dc <&$RESTORE_FD | "${client[@]}" sapot_verify 2>"$RUN_DIR/restore.stderr"; then fail RESTORE_FAILED "restore stream failed"; state_write FAIL "$STATUS_REASON" "$INTERNAL_MESSAGE"; log_error "backup restore verification failed [$STATUS_REASON]"; exit 1; fi +if ! "${check[@]}" --check sapot_verify >/dev/null; then fail STRUCTURE_FAILED "mariadb-check failed"; state_write FAIL "$STATUS_REASON" "$INTERNAL_MESSAGE"; log_error "backup restore verification failed [$STATUS_REASON]"; exit 1; fi +SCHEMA_REVISION=$("${client[@]}" -N -e 'SELECT version_num FROM sapot_verify.alembic_version' 2>/dev/null || true) +[[ $SCHEMA_REVISION =~ ^[0-9a-f]{12}$ ]] || { fail SCHEMA_INVALID "alembic revision is missing or invalid"; state_write FAIL "$STATUS_REASON" "$INTERNAL_MESSAGE"; log_error "backup restore verification failed [$STATUS_REASON]"; exit 1; } +if ! python3 - "$SUMMARY" "${client[*]}" <<'PY' +import json, subprocess, sys +s=json.load(open(sys.argv[1])); command=sys.argv[2].split(); actual={} +for name, expected in s['tables'].items(): + out=subprocess.check_output(command + ['-N', '-e', 'SELECT COUNT(*) FROM sapot_verify.`%s`' % name], text=True).strip() + if not out.isdecimal() or int(out) != expected: raise SystemExit(1) + actual[name]=int(out) +if sum(actual.values()) != s['totalRows']: raise SystemExit(1) +PY +then fail ROW_COUNT_MISMATCH "restored row counts differ from the inspected dump"; state_write FAIL "$STATUS_REASON" "$INTERNAL_MESSAGE"; log_error "backup restore verification failed [$STATUS_REASON]"; exit 1; fi +KEY_COUNTS=$(python3 - "$SUMMARY" <<'PY' +import json,sys +t=json.load(open(sys.argv[1]))['tables']; print(json.dumps({k:t[k] for k in ('user','conversation','message','announcement') if k in t})) +PY +) +cleanup +RUN_DIR= CONTAINER= VOLUME= DAEMON_PID= +if [ -n "$CLEANUP_ERROR" ]; then + state_write FAIL CLEANUP_FAILED "verification succeeded but cleanup failed: $CLEANUP_ERROR" + log_error "backup restore verification failed [CLEANUP_FAILED]" + exit 1 +fi +state_write PASS '' 'backup restored and validated' +log_pass "backup restore verification passed: $(basename "$BACKUP")" diff --git a/deployment-scripts/AGENTS.md b/deployment-scripts/AGENTS.md index 2e9c1b0f..b6720b6b 100644 --- a/deployment-scripts/AGENTS.md +++ b/deployment-scripts/AGENTS.md @@ -4,7 +4,7 @@ systemd unit files for running SAPOT's services in production. See the root `AGE ## Development Workflow -Five unit files, no build/package tooling: `server-main-api.service` (runs `server/runserver.sh`), `server-GSM-api.service` (runs `GSM-module/GSM-fastapi/run-api.sh`), `tileserver.service` (runs `tileserver/deploy-tiling-server-detached.sh`), plus `sapot-db-backup.service` and `sapot-db-backup.timer`. The backup units run `deploy/scripts/backup-db.sh` daily as a oneshot plus timer. The service selects the installed bundle script when present and otherwise runs the bare-metal checkout. For a standing or dev environment, use `systemctl edit sapot-db-backup.timer` with `OnCalendar=weekly`, rather than editing this file. In bundle mode the script uses `docker compose`, so the `sapot` user must be in the `docker` group. +Seven unit files, no build/package tooling: `server-main-api.service` (runs `server/runserver.sh`), `server-GSM-api.service` (runs `GSM-module/GSM-fastapi/run-api.sh`), `tileserver.service` (runs `tileserver/deploy-tiling-server-detached.sh`), and backup plus restore-verification service/timer pairs. The verification service restores only into disposable MariaDB storage and must never receive production credentials. Bundle builds ship exactly those four database-maintenance units. The three service units are **reference/deployment artifacts** — nothing in this repo installs or reloads them; changes take effect only once someone copies them to the production host's `/etc/systemd/system/` and runs `systemctl daemon-reload` plus a restart there. **The two backup units are different**: `scripts/build-bundle.sh` copies them into every bundle's `systemd/` directory, and `deploy/scripts/install.sh` installs them, provisions the `sapot` account they run as, and enables the timer; `upgrade.sh` and `rollback.sh` refresh the files without enabling anything. Editing them here therefore changes what the next bundle installs on a docker-bundle host, while a bare-metal host still needs the manual copy. See [runbooks.md](../docs/deployment/runbooks.md#backup-automated). diff --git a/deployment-scripts/sapot-db-backup-verify.service b/deployment-scripts/sapot-db-backup-verify.service new file mode 100644 index 00000000..dbe8a06e --- /dev/null +++ b/deployment-scripts/sapot-db-backup-verify.service @@ -0,0 +1,17 @@ +[Unit] +Description=SAPOT MariaDB backup restore verification +After=sapot-db-backup.service network.target +Documentation=https://github.com/Emman-pip/YLP-software/blob/main/docs/deployment/runbooks.md + +[Service] +Type=oneshot +User=sapot +Group=sapot +EnvironmentFile=-/etc/sapot/backup.env +UMask=0077 +RuntimeMaxSec=2h +TimeoutStopSec=5m +KillMode=control-group +ExecStart=/bin/sh -c 'root=${SAPOT_ROOT:-/opt/sapot}; if [ -x "$root/releases/current/scripts/verify-db-backup.sh" ]; then exec "$root/releases/current/scripts/verify-db-backup.sh"; else exec /home/sapot/YLP-software/deploy/scripts/verify-db-backup.sh; fi' +StandardOutput=journal +StandardError=journal diff --git a/deployment-scripts/sapot-db-backup-verify.timer b/deployment-scripts/sapot-db-backup-verify.timer new file mode 100644 index 00000000..3bc87197 --- /dev/null +++ b/deployment-scripts/sapot-db-backup-verify.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Daily SAPOT MariaDB backup restore verification + +[Timer] +OnCalendar=*-*-* 02:00:00 +RandomizedDelaySec=30m +Persistent=true +Unit=sapot-db-backup-verify.service + +[Install] +WantedBy=timers.target diff --git a/docs/architecture/component-map.md b/docs/architecture/component-map.md index 9ce25e4a..229cdf5c 100644 --- a/docs/architecture/component-map.md +++ b/docs/architecture/component-map.md @@ -99,6 +99,8 @@ HTTP (port 80) redirects to HTTPS with 301. | `server-main-api.service` | SAPOT FastAPI server | | `server-GSM-api.service` | GSM module API | | `tileserver.service` | Offline tile server | +| `sapot-db-backup.service` / `.timer` | MariaDB backup | +| `sapot-db-backup-verify.service` / `.timer` | Disposable restore verification | > **Note:** Verify exact unit names from `deployment-scripts/` or the server directory. diff --git a/docs/deployment/docker-bundle.md b/docs/deployment/docker-bundle.md index fee13ec3..bf2f7e26 100644 --- a/docs/deployment/docker-bundle.md +++ b/docs/deployment/docker-bundle.md @@ -31,8 +31,8 @@ flowchart LR CE["certs/detect-ip.sh"] F["firmware/gsm-arduino-*.hex"] D["data/static"] - SC["scripts/ install · upgrade · rollback
status · doctor · request-cert
flash-gsm-firmware · backup-db · lib/"] - SD["systemd/ sapot-db-backup.service
sapot-db-backup.timer"] + SC["scripts/ install · upgrade · rollback
status · doctor · request-cert
flash-gsm-firmware · backup-db · verify-db-backup · lib/"] + SD["systemd/ backup and verification
service/timer pairs"] end subgraph target["Target (offline, LAN-only)"] @@ -47,7 +47,7 @@ flowchart LR ### On-target lifecycle Every operator script (`install.sh`, `upgrade.sh`, `rollback.sh`, `status.sh`, -`doctor.sh`, `backup-db.sh`) is a thin wrapper around `scripts/lib/deploy-common.sh`, +`doctor.sh`, `backup-db.sh`, `verify-db-backup.sh`) is a thin wrapper around `scripts/lib/deploy-common.sh`, which provides `check_schema`, `acquire_lock`, `compose`, `verify_checksums`, `disk_preflight`, `wait_healthy`, and `write_state`. @@ -127,8 +127,8 @@ upgrade/rollback validate Alembic revisions ([ADR 0007](../adr/0007-alembic-for- `releases/current` is only repointed after the new stack passes a `/version` health check, so a failed cutover leaves the previous release live. -`backup-db.sh` is driven by a host systemd timer rather than by a running -container, but it takes the same `$SAPOT_ROOT/.lock` as install, upgrade, +`backup-db.sh` and `verify-db-backup.sh` are driven by host systemd timers rather than by a running +container. Verification uses a disposable, network-isolated MariaDB and never touches the production database. Backup takes the same `$SAPOT_ROOT/.lock` as install, upgrade, rollback, and firmware flash. A backup therefore cannot run while `alembic upgrade head` is mid-migration. On lock contention it skips that run and the next timer cycle retries. @@ -223,7 +223,7 @@ their image IDs and recent firmware backups, are retained. Run `scripts/lib/retention.sh --dry-run` to preview cleanup. `doctor.sh` checks release checksums, image IDs, services, certificate, ports, -disk, expected hardware, and the `db-backup` row for on-host backup age and +disk, expected hardware, and `db-backup` plus `db-backup-restore` rows for on-host backup age and restore verification, off-host-copy status. Add `--json` for structured output. A site with no GSM modem normally shows `gsm-fastapi` as `unhealthy` in raw Docker output: that is expected because its `/health` endpoint signals no modem. When the diff --git a/docs/deployment/install-ubuntu-server.md b/docs/deployment/install-ubuntu-server.md index 00ea8fe2..78bd3581 100644 --- a/docs/deployment/install-ubuntu-server.md +++ b/docs/deployment/install-ubuntu-server.md @@ -277,7 +277,7 @@ The installer, in order: 8. Starts the full stack and polls `https://localhost/version` for up to three minutes. 9. Points `/opt/sapot/releases/current` at the new release, installs the - systemd units, and enables the daily database backup timer. +systemd units, and enables the daily database backup and restore-verification timers. 10. Prompts you to create the first administrator. Three prompts need an answer: @@ -329,6 +329,7 @@ Three results are expected on a fresh install and are not faults: | Result | Why | What to do | |---|---|---| | `db-backup: FAIL - no backups in /opt/sapot/shared/db-backups` | The backup timer fires daily and has not run yet | Take one now: `sudo systemctl start sapot-db-backup.service`, then rerun `doctor.sh` | +| `db-backup-restore: FAIL` | The newest dump has not yet been proven restorable | Run `sudo systemctl start sapot-db-backup-verify.service` after a backup, then inspect its journal if it fails | | `gsm-fastapi: PASS - no modem attached, degraded health expected` | The service reports unhealthy without a modem, and the installer recorded that this site has none | Nothing | | `administrator: PASS - initial password not yet changed` | The one-shot password is still in place | Clears itself at first dashboard login (Step 8) | diff --git a/docs/deployment/maintenance.md b/docs/deployment/maintenance.md index 295d601e..6cc70289 100644 --- a/docs/deployment/maintenance.md +++ b/docs/deployment/maintenance.md @@ -9,6 +9,7 @@ Routine, scheduled upkeep for a running SAPOT deployment, as opposed to [runbook | Task | Frequency | Reference | |---|---|---| | Confirm the backup timer is firing | Weekly | `systemctl list-timers sapot-db-backup.timer` and `journalctl -u sapot-db-backup.service --since '7 days ago'`. Bundle installs enable this timer automatically, and `doctor.sh` reports a `db-backup` row | +| Review backup restore verification | Daily during an active field deployment; weekly for a standing/dev environment | `systemctl status sapot-db-backup-verify.timer`, `journalctl -u sapot-db-backup-verify.service --since '7 days ago'`, and the `db-backup-restore` doctor row | | Swap or verify the off-host backup drive | Daily during an active field deployment; weekly for a standing/dev environment | The script copies each dump to `SAPOT_BACKUP_OFFHOST_DIR` but never deletes from it, so capacity is managed by hand. `doctor.sh` reports the off-host copy's age | | Check disk space on the server host | Weekly | `df -h` — MariaDB and journald logs grow without rotation; database backups are bounded by `SAPOT_BACKUP_RETENTION_DAYS` (default 14, newest 3 always kept) | | Review GSM module log size | Weekly | `GSM-module/GSM-fastapi/sapot.log` has no automatic rotation configured — see [monitoring-logging.md](monitoring-logging.md#gsm-module-logs); truncate or `logrotate` it manually | diff --git a/docs/deployment/monitoring-logging.md b/docs/deployment/monitoring-logging.md index a8d75e25..22ff01f6 100644 --- a/docs/deployment/monitoring-logging.md +++ b/docs/deployment/monitoring-logging.md @@ -37,6 +37,7 @@ sudo journalctl -u server-main-api -f Scheduled database backup events are also in journald: - `sudo journalctl -u sapot-db-backup.service --since '7 days ago' --no-pager` records each scheduled backup. `[PASS]` confirms the dump and any off-host copy. `[WARN]` means a lifecycle operation held the lock or the off-host drive was absent, leaving the on-host dump intact. `[ERROR]` means no backup was produced. +- `sudo journalctl -u sapot-db-backup-verify.service --since '7 days ago' --no-pager` records restore verification. Its state file is `verification-status.json` beside the dumps, and bundle `doctor.sh` exposes it as `db-backup-restore`. Stable reasons include `INVALID_DUMP`, `INSUFFICIENT_SPACE`, `RESTORE_FAILED`, `STRUCTURE_FAILED`, `SCHEMA_INVALID`, and `ROW_COUNT_MISMATCH`; no remote pager is configured. Nothing watches these entries. A backup that stops running is noticed only when someone reads the journal or runs `doctor.sh`, whose `db-backup` row fails once the newest dump is older than `SAPOT_BACKUP_MAX_AGE_HOURS` (default 36). Since "MariaDB corrupted with no recent backup" is a SEV1 in [incident-response.md](incident-response.md), treat the weekly check in [maintenance.md](maintenance.md#recurring-schedule) as load-bearing rather than routine. diff --git a/docs/deployment/runbooks.md b/docs/deployment/runbooks.md index f442f571..eea6c635 100644 --- a/docs/deployment/runbooks.md +++ b/docs/deployment/runbooks.md @@ -10,7 +10,7 @@ Step-by-step procedures for operating SAPOT in production. Each runbook includes ### Backup (automated) -Backups run unattended via `sapot-db-backup.timer`. Both deployment paths use the same two unit files and the same script; only how they reach the host differs. +Backups and restore verification run unattended via separate systemd timers. Verification proves the newest finalized dump can be restored into a disposable MariaDB instance. It never reads production database credentials, mounts production data, or connects to the production database. **Docker bundle: nothing to do.** The bundle carries the units in its `systemd/` directory, and `install.sh` copies them to `/etc/systemd/system/`, creates the `sapot` account they run as, and enables the timer. `upgrade.sh` and `rollback.sh` refresh the unit files so a changed unit travels with its release, but neither enables anything — a timer you deliberately disable stays disabled. @@ -18,8 +18,9 @@ Backups run unattended via `sapot-db-backup.timer`. Both deployment paths use th ```bash sudo cp /home/sapot/YLP-software/deployment-scripts/sapot-db-backup.{service,timer} /etc/systemd/system/ +sudo cp /home/sapot/YLP-software/deployment-scripts/sapot-db-backup-verify.{service,timer} /etc/systemd/system/ sudo systemctl daemon-reload -sudo systemctl enable --now sapot-db-backup.timer +sudo systemctl enable --now sapot-db-backup.timer sapot-db-backup-verify.timer systemctl list-timers sapot-db-backup.timer ``` @@ -54,6 +55,10 @@ Settings go in `/etc/sapot/backup.env` (mode 600, optional; all have defaults): | `SAPOT_BACKUP_MIN_KEEP` | `3` | Newest N dumps are kept regardless of age | | `SAPOT_BACKUP_OFFHOST_DIR` | unset | Mountpoint of the removable drive to copy to | | `SAPOT_BACKUP_MAX_AGE_HOURS` | `36` | Age at which `doctor.sh` reports the backup stale | +| `SAPOT_BACKUP_VERIFY_TMPDIR` | `/var/tmp` | Bare-metal disposable MariaDB location | +| `SAPOT_BACKUP_VERIFY_DISK_MULTIPLIER` | `2` | Uncompressed-dump multiplier reserved for verification | +| `SAPOT_BACKUP_VERIFY_MIN_FREE_BYTES` | `1073741824` | Minimum bytes available for verification | +| `SAPOT_BACKUP_VERIFY_MAX_AGE_HOURS` | `36` | Age at which restore verification is stale | Dumps are named `sapot_db_.sql.gz`. Each is verified for gzip integrity and mysqldump's completion footer before receiving its final name. @@ -63,6 +68,19 @@ journalctl -u sapot-db-backup.service -n 50 --no-pager /opt/sapot/releases/current/scripts/doctor.sh ``` +### Restore verification + +The verifier runs daily at 02:00 with up to 30 minutes of jitter. Run it manually after a backup or before relying on an artifact: + +```bash +/opt/sapot/releases/current/scripts/verify-db-backup.sh --dry-run +/opt/sapot/releases/current/scripts/verify-db-backup.sh +/opt/sapot/releases/current/scripts/verify-db-backup.sh --status +journalctl -u sapot-db-backup-verify.service -n 50 --no-pager +``` + +Its state is `/verification-status.json`. A failure means the newest dump is unverified. Inspect the unit and journal, run `--dry-run`, check free space and only the exact verifier leftovers under `.verify-runs`, retain the failed dump for diagnosis, then create and immediately verify a new dump. Repeated failures are degraded backup protection and require escalation. For weekly backups, override both timers to weekly and set `SAPOT_BACKUP_VERIFY_MAX_AGE_HOURS=192`. + #### Off-host copies Set `SAPOT_BACKUP_OFFHOST_DIR` to a removable drive's mountpoint to copy each verified dump off-host. Do this wherever the site allows it: a backup that lives only on the machine it protects against does not survive that machine's hardware failure, which is the disaster this runbook exists for. An absent or unwritable drive logs a warning but does not discard the on-host dump. The script never deletes copies on removable media, so that drive's capacity is yours to manage. diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 5849b16e..26924f56 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -29,7 +29,7 @@ None configured for this directory specifically (no ESLint/Prettier/ruff config - Deployment bundles use `deploy/VERSION`, not the server version. The committed `deploy/bundle-release-policy.json` supplies upgrade and rollback floors, and `.github/workflows/release-bundle.yml` publishes the archive for `bundle/v*` tags. -- The bundle's `systemd/` directory carries only the two `sapot-db-backup` units, copied by name rather than by glob. The other units in `deployment-scripts/` describe the bare-metal deployment (`server-main-api`, `server-GSM-api`, `tileserver`) and must not ship: `install.sh` installs everything it finds in `systemd/`, so shipping them would drop bare-metal units onto a containerized host. +- The bundle's `systemd/` directory carries exactly four database-maintenance units: the `sapot-db-backup` and `sapot-db-backup-verify` service/timer pairs. They are copied by name rather than by glob. The other units in `deployment-scripts/` describe the bare-metal deployment and must not ship. - CA **key material** (`server_ca.key`, `server_ca.pem`) must never be baked into a bundle. Servers read the CA from a USB stick plugged in at issuance time (`deploy/scripts/lib/ca-certs.sh`) and retain only the public `server_ca.pem` afterwards, as `doctor.sh`'s trust anchor. `scripts/build-bundle.sh`'s `check_no_ca_material` guard enforces this and must keep firing. - `docker/gen-certs.sh` must never be copied into a bundle either: it can fall back to self-signing, and a self-signed cert is untrusted by every CA-pinning mobile build. It is a dev/CI-only tool for `docker-compose.yml`'s `certgen` service. Servers have exactly one issuance path — the offline CA on the USB stick — and `install.sh` aborts rather than falling back. - The CA private key *is* readable on the server during a `request-cert.sh` run. That was a deliberate trade for a single-machine workflow (it retired the offline signing laptop and `scripts/ca/sign-leaf.sh`); the protection is physical control of the stick, documented in `docs/deployment/runbooks.md`. Don't add anything that copies, caches, or persists `server_ca.key` to disk. diff --git a/scripts/build-bundle.sh b/scripts/build-bundle.sh index dbbf8982..cb423d4c 100755 --- a/scripts/build-bundle.sh +++ b/scripts/build-bundle.sh @@ -191,7 +191,8 @@ cp "$map_file" "$bundle/data/" cp -a server/static "$bundle/data/static" cp docker/detect-ip.sh "$bundle/certs/" cp -a deploy/scripts/. "$bundle/scripts/" -cp deployment-scripts/sapot-db-backup.service deployment-scripts/sapot-db-backup.timer "$bundle/systemd/" +cp deployment-scripts/sapot-db-backup.service deployment-scripts/sapot-db-backup.timer \ + deployment-scripts/sapot-db-backup-verify.service deployment-scripts/sapot-db-backup-verify.timer "$bundle/systemd/" check_no_ca_material() { local hit