diff --git a/.github/workflows/release-bundle.yml b/.github/workflows/release-bundle.yml new file mode 100644 index 00000000..efe2d175 --- /dev/null +++ b/.github/workflows/release-bundle.yml @@ -0,0 +1,81 @@ +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 + environment: bundle-release + timeout-minutes: 180 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate tag and release metadata + run: | + 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 python3-jsonschema + docker compose version + df -h / + + - uses: arduino/setup-arduino-cli@v2 + + - name: Run release metadata and deployment script tests + run: | + 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 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 + env: + GH_TOKEN: ${{ github.token }} + run: ./tileserver/download-script.sh + + - name: Build deployment bundle + run: ./scripts/build-bundle.sh --low-disk + + - name: Validate release assets + run: | + ARCHIVE="dist/sapot-bundle-v$VERSION.tar.zst" + python3 scripts/validate_archive_size.py "$ARCHIVE" + ROOT="sapot-bundle-v$VERSION" + 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") + + - 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 }} + 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/.gitignore b/.gitignore index 2416eead..554990ef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ logs/* .worktrees/ node_modules/ +__pycache__/ +*.py[cod] docs/superpowers/ reports/ .pnpm-store/ diff --git a/VERSIONING.md b/VERSIONING.md index 62e188cd..475443c8 100644 --- a/VERSIONING.md +++ b/VERSIONING.md @@ -1,8 +1,8 @@ # 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. +`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. --- @@ -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,19 @@ 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 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. + --- ## Out of Scope @@ -170,5 +188,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..e1a605c3 100644 --- a/deploy/manifest.schema.json +++ b/deploy/manifest.schema.json @@ -1,14 +1,16 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "required": ["schemaVersion", "bundleId", "version", "gitSha", "builtAt", "minimumUpgradeVersion", "maximumRollbackVersion", "requiredDiskBytes", "images", "gsmFirmware", "buildEnvironment"], + "type": "object", "additionalProperties": false, + "required": ["schemaVersion", "bundleId", "version", "gitSha", "builtAt", "minimumUpgradeVersion", "minimumRollbackVersion", "requiredDiskBytes", "componentVersions", "images", "mapData", "gsmFirmware", "buildEnvironment"], "properties": { - "schemaVersion": {"const": "1.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"}, - "requiredDiskBytes": {"type": "integer", "minimum": 1}, - "images": {"type": "object"}, "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/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/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/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/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/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/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/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 1bc66a21..63ed804d 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) @@ -21,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/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/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_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/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 b492286b..23f16aa6 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}') @@ -21,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 954e10a0..bf2f7e26 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 @@ -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. @@ -140,23 +140,44 @@ 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`, +`python3-jsonschema`, `zstd`, `arduino-cli`, and GitHub CLI, 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. +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. -`--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,13 +217,13 @@ 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. `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 @@ -279,7 +300,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 17ac4d12..78bd3581 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 @@ -275,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: @@ -327,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/docs/deployment/tileserver.md b/docs/deployment/tileserver.md index 8b639c04..a07a942a 100644 --- a/docs/deployment/tileserver.md +++ b/docs/deployment/tileserver.md @@ -45,20 +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 ``` -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`). +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, 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/docs/devops/README.md b/docs/devops/README.md index c67465a6..cdfb0413 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 -The repo-root `VERSIONING.md` defines 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..26924f56 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,7 +26,10 @@ 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. -- 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. +- 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 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 bca0b610..cb423d4c 100755 --- a/scripts/build-bundle.sh +++ b/scripts/build-bundle.sh @@ -4,46 +4,165 @@ 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 + +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_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 +} +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 +) +declare -A map +for value in "${map_values[@]}"; do map[${value%%=*}]=${value#*=}; done + +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 + 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 +} +"$low_disk" && check_free_space -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) +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") + 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" +} + +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" + "$low_disk" && check_free_space +} + +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" + "$low_disk" && check_free_space +} + +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 +172,164 @@ 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. +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 - 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[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 +import sys + +( + out, + version, + sha, + built, + minimum_upgrade, + minimum_rollback, + server_version, + admin_version, + gsm_version, + firmware_version, + fqbn, + firmware_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:] + 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": { + "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, + "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" +python3 scripts/validate_extracted_bundle.py "$bundle" --version "$version" --commit "$git_sha" --metadata "$map_metadata" 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" +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 bfaf877d..e09a8bcd 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,33 @@ 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) + 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" + git -C "$root" add deploy/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..3237ebaf --- /dev/null +++ b/scripts/tests/test_validate_bundle_release.py @@ -0,0 +1,89 @@ +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_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"): + 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_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, "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" + 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_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 new file mode 100755 index 00000000..d9f060c3 --- /dev/null +++ b/scripts/validate_bundle_release.py @@ -0,0 +1,118 @@ +#!/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, + candidate_version: str | None = None, +) -> dict[str, str]: + semver = load_semver_module() + 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 = { + "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.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) + 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("--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, args.candidate_version) + 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/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 8934b637..63d15827 100644 --- a/tileserver/CLAUDE.md +++ b/tileserver/CLAUDE.md @@ -14,15 +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` via `curl` from a Google Drive link, then crops it to the deployment region. Takes an optional region argument (default `batangas`). -- `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. @@ -36,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). @@ -44,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 e9f1b4ea..c8c9fbcc 100755 --- a/tileserver/download-script.sh +++ b/tileserver/download-script.sh @@ -1,31 +1,58 @@ -#!/bin/sh -# Fetch the full Philippines extract, then crop it to 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. -set -e +#!/usr/bin/env bash +# Download the reviewed, immutable Batangas map release without transforming it. +set -euo pipefail -REGION="${1:-batangas}" -SOURCE="osm-2020-02-10-v3.11_asia_philippines.mbtiles" - -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" -else - echo "### $SOURCE already present, skipping download ###" +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 +[[ $# -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") -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 +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 -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." +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/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 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())