commit b33b9af85a41182464ccee78161d43bb1c26b9e7 Author: Archipelago Date: Wed Aug 12 10:55:49 2026 +0000 Archipelago — open-source initial import diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..f323aac2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +# Ignore everything except what the demo Dockerfiles need +* + +# Allow neode-ui (frontend + mock backend + docker configs) +!neode-ui/ + +# Allow demo assets (AIUI pre-built dist) +!demo/ + +# Allow the Bitcoin UI + ElectrumX UI mock shells (served from /docker/*) +!docker/ +docker/* +!docker/bitcoin-ui/ +!docker/electrs-ui/ +!docker/lnd-ui/ +!docker/fedimint-ui/ + +# Allow backend source for ISO source builds +!core/ +!scripts/ +!image-recipe/ +image-recipe/build/ +image-recipe/results/ +image-recipe/output/ + +# Exclude nested node_modules (will npm install in container) +neode-ui/node_modules +neode-ui/dist diff --git a/.gitea/workflows/build-iso.yml b/.gitea/workflows/build-iso.yml new file mode 100644 index 00000000..d1abdc54 --- /dev/null +++ b/.gitea/workflows/build-iso.yml @@ -0,0 +1,62 @@ +name: Build Archipelago release ISO (gated) + +# Resurrected from image-recipe/_archived/.gitea-workflows/build-iso-dev.yml. +# Dispatch-only on purpose: the ISO is cut per release, not per push, and +# the iso-builder runner is a live node — builds are deliberate events. +on: + workflow_dispatch: + +jobs: + build-iso: + runs-on: iso-builder + timeout-minutes: 180 + steps: + - name: Sync source to workspace + run: | + # Direct fetch + sync (actions/checkout token is broken on this Gitea) + REPO_DIR="$HOME/Projects/archy" + [ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy" + cd "$REPO_DIR" && git fetch origin main && git reset --hard origin/main + echo "=== Source at commit: $(git log --oneline -1) ===" + + - name: Install ISO build dependencies + run: | + if dpkg -s debootstrap squashfs-tools xorriso isolinux syslinux-common mtools \ + grub-efi-amd64-bin grub-pc-bin grub-common >/dev/null 2>&1; then + echo "ISO build deps already installed, skipping apt" + else + sudo apt-get update -qq + sudo apt-get install -y -qq \ + debootstrap squashfs-tools xorriso \ + isolinux syslinux-common mtools \ + grub-efi-amd64-bin grub-pc-bin grub-common + fi + + - name: Build backend + frontend if stale + run: | + REPO_DIR="$HOME/Projects/archy" + [ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy" + cd "$REPO_DIR" + . "$HOME/.cargo/env" 2>/dev/null || true + VERSION=$(grep -m1 '^version' core/archipelago/Cargo.toml | sed 's/.*"\(.*\)".*/\1/') + if ! strings core/target/release/archipelago 2>/dev/null | grep -qF "$VERSION"; then + cargo build --release --manifest-path core/Cargo.toml -p archipelago + fi + if ! grep -rqoF "$VERSION" web/dist/neode-ui/assets/*.js 2>/dev/null; then + (cd neode-ui && npm ci && npm run build) + fi + + - name: Gated ISO build (gates + build + smoke + qemu) + run: | + REPO_DIR="$HOME/Projects/archy" + [ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy" + cd "$REPO_DIR" + . "$HOME/.cargo/env" 2>/dev/null || true + bash scripts/build-iso-release.sh + + - name: Report artifacts + if: always() + run: | + REPO_DIR="$HOME/Projects/archy" + [ -d "$REPO_DIR" ] || REPO_DIR="$HOME/archy" + ls -lh "$REPO_DIR"/image-recipe/results/*.iso 2>/dev/null | tail -3 || echo "no ISO produced" diff --git a/.gitea/workflows/demo-images.yml b/.gitea/workflows/demo-images.yml new file mode 100644 index 00000000..c6b58198 --- /dev/null +++ b/.gitea/workflows/demo-images.yml @@ -0,0 +1,74 @@ +name: Demo images + +# Builds and pushes the public-demo images on every change to the UI / mock +# backend, so the separated `archy-demo` Portainer stack auto-tracks the real +# code (see demo-deploy/ and docs/demo-deployment-design.md). +# +# Required repo configuration: +# vars.DEMO_REGISTRY e.g. 146.59.87.168:3000/lfg2025 +# vars.DEMO_REGISTRY_HOST registry host for docker login (no org suffix) +# secrets.DEMO_REGISTRY_USER +# secrets.DEMO_REGISTRY_TOKEN +# Optional: +# secrets.PORTAINER_WEBHOOK redeploy hook called after a successful push + +on: + push: + branches: [main] + paths: + - 'neode-ui/**' + - 'docker-compose.demo.yml' + - '.gitea/workflows/demo-images.yml' + workflow_dispatch: + +jobs: + build: + name: Build & push demo images + runs-on: ubuntu-latest + # Skip cleanly on forks / before registry config is set. + if: ${{ vars.DEMO_REGISTRY != '' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + # The demo registry is plain HTTP — teach buildkit to push without TLS + # (the host docker daemon needs it in insecure-registries for login too). + buildkitd-config-inline: | + [registry."${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }}"] + http = true + + - name: Log in to registry + uses: docker/login-action@v3 + with: + registry: ${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }} + username: ${{ secrets.DEMO_REGISTRY_USER }} + password: ${{ secrets.DEMO_REGISTRY_TOKEN }} + + - name: Build & push backend + uses: docker/build-push-action@v6 + with: + context: . + file: neode-ui/Dockerfile.backend + push: true + tags: | + ${{ vars.DEMO_REGISTRY }}/archy-demo-backend:demo + ${{ vars.DEMO_REGISTRY }}/archy-demo-backend:${{ github.sha }} + + - name: Build & push web + uses: docker/build-push-action@v6 + with: + context: . + file: neode-ui/Dockerfile.web + push: true + build-args: | + VITE_DEMO=1 + tags: | + ${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo + ${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }} + + - name: Trigger Portainer redeploy + if: ${{ success() && secrets.PORTAINER_WEBHOOK != '' }} + run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}" diff --git a/.gitea/workflows/post-install-tests.yml b/.gitea/workflows/post-install-tests.yml new file mode 100644 index 00000000..7c5c4c86 --- /dev/null +++ b/.gitea/workflows/post-install-tests.yml @@ -0,0 +1,72 @@ +name: Post-Install Tests + +on: + workflow_dispatch: + inputs: + target: + description: 'Target node IP (e.g. 192.168.1.198)' + required: true + default: '192.168.1.198' + password: + description: 'Node password (or "auto" for fresh install)' + required: false + default: 'auto' + +jobs: + post-install-tests: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run post-install tests on target + run: | + TARGET="${{ github.event.inputs.target }}" + PASSWORD="${{ github.event.inputs.password }}" + if [ "$PASSWORD" = "auto" ]; then + PASSWORD="testpass123!" + fi + + echo "══════════════════════════════════════════" + echo "Running post-install tests on $TARGET" + echo "══════════════════════════════════════════" + + # Copy test script to target and run + sshpass -p 'archipelago' scp -o StrictHostKeyChecking=no \ + scripts/run-post-install-tests.sh \ + archipelago@${TARGET}:/tmp/run-post-install-tests.sh 2>/dev/null || \ + scp -o StrictHostKeyChecking=no \ + scripts/run-post-install-tests.sh \ + archipelago@${TARGET}:/tmp/run-post-install-tests.sh + + # Run tests (with sudo for service checks) + sshpass -p 'archipelago' ssh -o StrictHostKeyChecking=no \ + archipelago@${TARGET} \ + "sudo bash /tmp/run-post-install-tests.sh '$PASSWORD'" 2>/dev/null || \ + ssh -o StrictHostKeyChecking=no \ + archipelago@${TARGET} \ + "sudo bash /tmp/run-post-install-tests.sh '$PASSWORD'" + + frontend-tests: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Install dependencies + run: cd neode-ui && npm ci + + - name: Type check + run: cd neode-ui && npx vue-tsc -b --noEmit + + - name: Run tests + run: cd neode-ui && npx vitest run + + - name: Audit dependencies + run: cd neode-ui && npm audit --omit=dev diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 00000000..c943bc06 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Keep the served companion APK in sync with main on every push. +# +# When a push to main includes Android changes, rebuild the APK, refresh +# neode-ui/public/packages/archipelago-companion.apk, commit it, and ask +# you to push again (so the refreshed APK rides along in the same push). +# +# Enable once per clone: git config core.hooksPath .githooks +set -euo pipefail + +ROOT="$(git rev-parse --show-toplevel)" +cd "$ROOT" + +# ship-companion.sh already (re)published the APK for this push — don't redo it. +[ -n "${SHIP_COMPANION:-}" ] && exit 0 + +PUSH_MAIN=0; RANGE_OLD=""; RANGE_NEW="" +while read -r _local_ref local_sha remote_ref remote_sha; do + if [ "${remote_ref##*/}" = "main" ]; then + PUSH_MAIN=1; RANGE_OLD="$remote_sha"; RANGE_NEW="$local_sha" + fi +done +[ "$PUSH_MAIN" = "1" ] || exit 0 + +# Loop-break: if the tip is already the auto APK commit, let the push proceed. +case "$(git log -1 --pretty=%s)" in + *"companion APK"*) exit 0 ;; +esac + +# Only rebuild when this push actually touches the Android app. +ZEROS="0000000000000000000000000000000000000000" +if [ -z "$RANGE_OLD" ] || [ "$RANGE_OLD" = "$ZEROS" ]; then + ANDROID_CHANGED=1 +elif git diff --quiet "$RANGE_OLD" "$RANGE_NEW" -- Android/ 2>/dev/null; then + ANDROID_CHANGED=0 +else + ANDROID_CHANGED=1 +fi +[ "$ANDROID_CHANGED" = "1" ] || exit 0 + +bash scripts/publish-companion-apk.sh || exit 0 + +DEST="neode-ui/public/packages/archipelago-companion.apk" +if git diff --cached --quiet -- "$DEST"; then + exit 0 # APK unchanged — nothing to do +fi + +git commit -q -m "chore(android): update companion APK download [skip ci]" +echo "" >&2 +echo "▶ Companion APK rebuilt and committed. Run your push again to include it." >&2 +exit 1 diff --git a/.github/ISSUE_TEMPLATE/app_submission.yml b/.github/ISSUE_TEMPLATE/app_submission.yml new file mode 100644 index 00000000..caca79ec --- /dev/null +++ b/.github/ISSUE_TEMPLATE/app_submission.yml @@ -0,0 +1,78 @@ +name: App Submission +description: Submit an app for the Archipelago marketplace +title: "[App]: " +labels: ["app-submission"] +body: + - type: input + id: app_name + attributes: + label: App Name + placeholder: My Bitcoin App + validations: + required: true + + - type: input + id: docker_image + attributes: + label: Container Image + description: Full image reference with tag (no :latest) + placeholder: "ghcr.io/org/app:1.2.3" + validations: + required: true + + - type: textarea + id: description + attributes: + label: Description + description: What does this app do? + validations: + required: true + + - type: input + id: homepage + attributes: + label: Homepage / Repository + placeholder: "https://github.com/..." + + - type: dropdown + id: category + attributes: + label: Category + options: + - Bitcoin + - Lightning + - Privacy + - Storage + - Communication + - Development + - Other + validations: + required: true + + - type: checkboxes + id: requirements + attributes: + label: App Requirements Met + options: + - label: Runs as non-root user (UID > 1000) + required: true + - label: No `latest` tag — pinned version + required: true + - label: "Supports x86_64" + required: true + - label: "Supports ARM64" + - label: Tested on Archipelago hardware + required: true + + - type: textarea + id: ports + attributes: + label: Required Ports + description: List ports the app needs exposed + placeholder: "8080 (web UI), 9735 (Lightning)" + + - type: textarea + id: dependencies + attributes: + label: Dependencies + description: Does this app require other apps (e.g., Bitcoin, LND)? diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..fddcceee --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,81 @@ +name: Bug Report +description: Report a bug in Archipelago +title: "[Bug]: " +labels: ["bug", "triage"] +body: + - type: markdown + attributes: + value: | + Thank you for reporting a bug. Please fill out the sections below. + + - type: textarea + id: description + attributes: + label: Description + description: A clear description of the bug. + placeholder: What happened? + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to Reproduce + description: Minimal steps to reproduce the issue. + placeholder: | + 1. Go to '...' + 2. Click on '...' + 3. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What should have happened? + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What actually happened? + validations: + required: true + + - type: input + id: version + attributes: + label: Archipelago Version + description: Check Settings page or run `archipelago --version` + placeholder: "0.1.0" + validations: + required: true + + - type: dropdown + id: hardware + attributes: + label: Hardware + options: + - x86_64 (Intel/AMD) + - ARM64 (Raspberry Pi 5) + - ARM64 (Other) + - Other + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Relevant Logs + description: | + Run `journalctl -u archipelago --since "1 hour ago"` and paste relevant output. + render: shell + + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: If applicable, add screenshots. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..c2c2d00d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Security Vulnerability + url: mailto:security@archipelago-os.org + about: Do NOT open public issues for security vulnerabilities. Email us directly. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..2d4e125c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,44 @@ +name: Feature Request +description: Suggest a new feature or improvement +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What problem does this solve? + placeholder: I'm always frustrated when... + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed Solution + description: How should this work? + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: What other approaches did you consider? + + - type: dropdown + id: area + attributes: + label: Area + options: + - Web UI + - Backend / API + - App Management + - Networking + - Security + - Web5 / Identity + - ISO / Installation + - Documentation + - Other + validations: + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..ef60d323 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,16 @@ +## Summary + + + +## Verification + + + +## Checklist + +- [ ] Rust formatting/clippy/tests pass when backend code changed. +- [ ] Frontend type-check/build/tests pass when frontend code changed. +- [ ] App manifests validate when app packaging changed. +- [ ] Generated catalogs are updated when manifest-owned catalog fields changed. +- [ ] Docs are updated for user-facing or developer-facing behavior changes. +- [ ] No secrets, generated build outputs, local screenshots, or private host details are included. diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml new file mode 100644 index 00000000..3b3678bc --- /dev/null +++ b/.github/workflows/build-macos.yml @@ -0,0 +1,219 @@ +name: macOS Production Build + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Version number (e.g., 0.1.0)' + required: true + default: '0.1.0' + +env: + RUST_VERSION: stable + NODE_VERSION: 18 + +jobs: + build-macos: + name: Build macOS App + runs-on: macos-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set version + id: version + run: | + if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.version }}" + else + VERSION="${GITHUB_REF#refs/tags/v}" + fi + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + echo "Building version: $VERSION" + + - name: Setup Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ env.RUST_VERSION }} + components: rustfmt, clippy + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: neode-ui/package-lock.json + + - name: Install frontend dependencies + working-directory: neode-ui + run: npm ci + + - name: Build Rust backend (Release) + working-directory: core + run: | + cargo build --release --workspace + strip target/release/archipelago + ls -lh target/release/archipelago + + - name: Build Vue.js frontend (Production) + working-directory: neode-ui + run: | + npm run build:production + ls -lh dist/ + + - name: Run production build script + env: + ARCHIPELAGO_VERSION: ${{ steps.version.outputs.VERSION }} + run: | + chmod +x build-macos-production.sh + ./build-macos-production.sh + + - name: Verify build artifacts + run: | + ls -lh build/macos/ + if [ ! -d "build/macos/Archipelago.app" ]; then + echo "❌ App bundle not found!" + exit 1 + fi + if [ ! -f "build/macos/Archipelago-${{ steps.version.outputs.VERSION }}-macOS.dmg" ]; then + echo "⚠️ DMG not created (optional)" + fi + + - name: Code sign (if credentials available) + if: ${{ secrets.MACOS_CERTIFICATE != '' }} + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }} + KEYCHAIN_PWD: ${{ secrets.KEYCHAIN_PWD }} + run: | + # Import certificate + echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12 + security create-keychain -p "$KEYCHAIN_PWD" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "$KEYCHAIN_PWD" build.keychain + security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PWD" build.keychain + + # Sign the app + codesign --deep --force --verify --verbose \ + --sign "Developer ID Application" \ + --options runtime \ + build/macos/Archipelago.app + + # Verify + codesign --verify --verbose build/macos/Archipelago.app + + - name: Notarize (if credentials available) + if: ${{ secrets.APPLE_ID != '' }} + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} + run: | + # Create zip for notarization + ditto -c -k --keepParent build/macos/Archipelago.app Archipelago.zip + + # Submit for notarization + xcrun notarytool submit Archipelago.zip \ + --apple-id "$APPLE_ID" \ + --team-id "$APPLE_TEAM_ID" \ + --password "$APPLE_APP_PASSWORD" \ + --wait + + # Staple + xcrun stapler staple build/macos/Archipelago.app + + # Recreate DMG with notarized app + rm -f build/macos/Archipelago-${{ steps.version.outputs.VERSION }}-macOS.dmg + hdiutil create -volname "Archipelago ${{ steps.version.outputs.VERSION }}" \ + -srcfolder build/macos/Archipelago.app \ + -ov -format UDZO \ + build/macos/Archipelago-${{ steps.version.outputs.VERSION }}-macOS.dmg + + xcrun stapler staple build/macos/Archipelago-${{ steps.version.outputs.VERSION }}-macOS.dmg + + - name: Create checksums + working-directory: build/macos + run: | + if [ -f "Archipelago-${{ steps.version.outputs.VERSION }}-macOS.dmg" ]; then + shasum -a 256 "Archipelago-${{ steps.version.outputs.VERSION }}-macOS.dmg" > checksums.txt + fi + cat checksums.txt || echo "No DMG to checksum" + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: Archipelago-${{ steps.version.outputs.VERSION }}-macOS + path: | + build/macos/Archipelago.app + build/macos/*.dmg + build/macos/checksums.txt + retention-days: 30 + + - name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v1 + with: + files: | + build/macos/Archipelago-${{ steps.version.outputs.VERSION }}-macOS.dmg + build/macos/checksums.txt + draft: true + generate_release_notes: true + body: | + ## Archipelago v${{ steps.version.outputs.VERSION }} + + ### 🎉 macOS Release + + **Download**: `Archipelago-${{ steps.version.outputs.VERSION }}-macOS.dmg` + + ### Installation + 1. Download the DMG file + 2. Open and drag Archipelago to Applications + 3. Install [Docker Desktop](https://www.docker.com/products/docker-desktop) + 4. Launch Archipelago + + ### What's New + See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) + + ### System Requirements + - macOS 10.15 (Catalina) or later + - 8GB RAM minimum (16GB recommended) + - Docker Desktop 23.0+ + + ### Checksums + See `checksums.txt` for SHA-256 verification + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + test-build: + name: Test Build (No Artifacts) + runs-on: macos-latest + if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/') + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Test backend build + working-directory: core + run: cargo build --release + + - name: Test frontend build + working-directory: neode-ui + run: | + npm ci + npm run build:production diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..2cca99ad --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,100 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + RUST_VERSION: stable + NODE_VERSION: 20 + +jobs: + rust: + name: Rust + runs-on: ubuntu-latest + defaults: + run: + working-directory: core + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ env.RUST_VERSION }} + components: rustfmt, clippy + + - name: Format + run: cargo fmt --all -- --check + + # KEY-05 layer (b) is enforced HERE, with no step of its own: core/clippy.toml + # bans the defaulted RNG entry points, and `-D warnings` already turns a + # `disallowed_methods` hit into a build failure. `--all-targets` covers tests + # too, deliberately. See docs/security/KEY-05-ENTROPY-ENFORCEMENT.md + - name: Clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + # KEY-05 layer (c) — see core/deny.toml for the policy and its rationale. + # + # The version is pinned deliberately. EmbarkStudios/cargo-deny-action exposes + # no input to pin the cargo-deny version, and an unpinned supply-chain checker + # is a contradiction in terms, so the tool is installed from crates.io — the + # source actually vetted at the 10-06 Task 5 legitimacy checkpoint — rather + # than by adding another unvetted action to this workflow. + # + # `check bans` ONLY: the advisories gate is not enabled (bans-only policy). + - name: Supply chain (cargo-deny) + run: | + cargo install --locked cargo-deny --version 0.20.2 + cargo deny check bans + + - name: Test + run: cargo test --all-features + + frontend: + name: Frontend + runs-on: ubuntu-latest + defaults: + run: + working-directory: neode-ui + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: neode-ui/package-lock.json + + - name: Install + run: npm ci + + - name: Type check + run: npm run type-check + + - name: Test + run: npm test + + - name: Build + run: npm run build + + manifests: + name: App Manifests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate manifests + run: | + for manifest in apps/*/manifest.yml; do + ./scripts/validate-app-manifest.sh --repo-audit "$manifest" + done diff --git a/.github/workflows/demo-images.yml b/.github/workflows/demo-images.yml new file mode 100644 index 00000000..0471538b --- /dev/null +++ b/.github/workflows/demo-images.yml @@ -0,0 +1,74 @@ +name: Demo images + +# Builds and pushes the public-demo images on every change to the UI / mock +# backend, so the separated `archy-demo` Portainer stack auto-tracks the real +# code (see demo-deploy/ and docs/demo-deployment-design.md). +# +# Required repo configuration: +# vars.DEMO_REGISTRY e.g. 146.59.87.168:3000/lfg2025 +# vars.DEMO_REGISTRY_HOST registry host for docker login (no org suffix) +# secrets.DEMO_REGISTRY_USER +# secrets.DEMO_REGISTRY_TOKEN +# Optional: +# secrets.PORTAINER_WEBHOOK redeploy hook called after a successful push + +on: + push: + branches: [main] + paths: + - 'neode-ui/**' + - 'docker-compose.demo.yml' + - '.github/workflows/demo-images.yml' + workflow_dispatch: + +jobs: + build: + name: Build & push demo images + runs-on: ubuntu-latest + # Skip cleanly on forks / before registry config is set. + if: ${{ vars.DEMO_REGISTRY != '' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + # The demo registry is plain HTTP — teach buildkit to push without TLS + # (the host docker daemon needs it in insecure-registries for login too). + buildkitd-config-inline: | + [registry."${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }}"] + http = true + + - name: Log in to registry + uses: docker/login-action@v3 + with: + registry: ${{ vars.DEMO_REGISTRY_HOST || vars.DEMO_REGISTRY }} + username: ${{ secrets.DEMO_REGISTRY_USER }} + password: ${{ secrets.DEMO_REGISTRY_TOKEN }} + + - name: Build & push backend + uses: docker/build-push-action@v6 + with: + context: . + file: neode-ui/Dockerfile.backend + push: true + tags: | + ${{ vars.DEMO_REGISTRY }}/archy-demo-backend:demo + ${{ vars.DEMO_REGISTRY }}/archy-demo-backend:${{ github.sha }} + + - name: Build & push web + uses: docker/build-push-action@v6 + with: + context: . + file: neode-ui/Dockerfile.web + push: true + build-args: | + VITE_DEMO=1 + tags: | + ${{ vars.DEMO_REGISTRY }}/archy-demo-web:demo + ${{ vars.DEMO_REGISTRY }}/archy-demo-web:${{ github.sha }} + + - name: Trigger Portainer redeploy + if: ${{ success() && secrets.PORTAINER_WEBHOOK != '' }} + run: curl -fsS -X POST "${{ secrets.PORTAINER_WEBHOOK }}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..b99a37af --- /dev/null +++ b/.gitignore @@ -0,0 +1,94 @@ +# SSH keys and sandbox copies +.ssh/ + +# Rust build output +target/ +**/target/ + +# Node.js +node_modules/ +**/node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Build outputs +dist/ +dist-ssr/ +build/ +*.local + +# Vite build cache +neode-ui/.vite/ + +# IDE / editor +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store +._* +Thumbs.db + +# Environment and local overrides +.env +.env.local +.env.*.local +.env.production +core/.env.production +scripts/deploy-config.sh + +# Logs +logs/ +*.log + +# Testing +coverage/ +.nyc_output/ + +# Image / release artifacts +*.iso +*.img +*.dmg +*.app +*.apk +*.keystore +*.s9pk +*.tar.gz + +# Release artifacts live in release attachments, not Git history. +releases/** +!releases/ +!releases/manifest.json + +# Image recipe output +image-recipe/output/ +image-recipe/*.iso +image-recipe/*.img + +# Loop tool artifacts +*/loop/ +loop/loop/ +loop/loop.log.bak + +# Separate repos nested in tree +web/ + +# Resilience harness reports contain session cookies. +scripts/resilience/reports/ + +# Codex / pnpm / python caches / editor backups +.codex +.codex-target-*/ +.codex-tmp/ +.claude/ +.pnpm-store/ +**/__pycache__/ +*.bak + +# Local evidence screenshots; intentional UI screenshots should live under an +# app/docs asset path with a descriptive filename. +Screenshot *.png +uploads/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..b79b5f6c --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "indeedhub"] + path = indeedhub + url = http://146.59.87.168:3000/lfg2025/indeehub.git diff --git a/.planning/.continue-here.md b/.planning/.continue-here.md new file mode 100644 index 00000000..0f76dce7 --- /dev/null +++ b/.planning/.continue-here.md @@ -0,0 +1,88 @@ +--- +context: default +phase: 09-botfights-platform-upgrade (already complete — this is off-plan work) +task: n/a +total_tasks: n/a +status: paused +last_updated: 2026-08-02T10:34:47.198Z +--- + +# BLOCKING CONSTRAINTS — Read Before Anything Else + +- [ ] CONSTRAINT: Never assume pushing one repo pushed another — this session pushed `archy` repeatedly via `git push gitea-ai main`, but the `botfight` repo's last 4 commits (the entire security-fix body of work) sat **local-only** the whole time and were only discovered/pushed at the very end of this session, during this handoff step. Structural mitigation: whenever a session touches more than one git repo, explicitly run `git status -sb` (ahead/behind vs. the tracked remote) in **every** repo touched before ending the session — not just the one most recently `git push`ed. + +**Do not proceed until the box above is checked (i.e. verify both repos are still in sync with their remotes before doing anything else).** + + +This is **not** a GSD plan/task in progress. Phase 09 (BotFights Platform Upgrade) is fully complete — plans 09-01 through 09-07 all have SUMMARY.md files, the last dated 2026-07-31 05:08. Everything described below happened *after* that, as live, user-directed, reactive work preparing for a same-day BotFights demo ("two real fighters playing with cashu"). None of it was tracked against a PLAN.md task list — the original GSD task (execute 09-06-PLAN.md: bump manifest + sign catalog) completed normally and stopped cleanly at its signing checkpoint, exactly as designed. Everything after that was ad hoc. + +**As of this handoff, everything is committed and pushed in both repos, and both demo nodes are deployed and verified healthy.** There is nothing mid-flight to resume — this file exists so a future session (or this one, after compaction) has the full picture instead of re-discovering it. + + + + +**botfight repo** (`/home/archipelago/Projects/botfight`, pushed to `origin/main` @ `10d4209`): +- iframe embedding fix (X-Frame-Options was unconditional), native Archipelago signer bridge (`nostr-provider.js`), "Sign in with Archipelago" docs for app developers +- Discoverability fixes: mode-picker guide banner, AI-answer visibility, "Latest Bouts" cut off on short viewports +- Fixed a proxy-URL leak (local/Tailscale addresses leaking into AI setup prompts via client-side `window.location.origin` — switched to server-rendered `/api/docs/prompt`) +- "Let BotFights answer for me" — server-side AI bot using an operator-supplied Anthropic/OpenAI API key (poll-mode bots) +- Fixed broken profile images (CSP `img-src`) +- Cashu ecash payments made the **primary** entry-fee AND payout UX (Lightning/NWC now secondary) — Minibits mint, `BOTFIGHTS_WALLET_ENCRYPTION_KEY`, escrow-style entry fee (21 sats, 42-sat winner-take-all pot) +- Fixed anonymous poll-mode bots being locked out of staked/ranked fights (auth gap) +- **Security audit found + fixed 6 instances of the same IDOR pattern** (client-supplied `pubkey` trusted with no verification against a real JWT) — `f5f57e6`, `c162d5e`: + - `POST /api/auth/update` — could hijack any bot's webhook/customization + - `GET /api/payments/winnings/:botId` — **critical**: zero auth at all, leaked live spendable Cashu bearer tokens to anyone who knew a botId (public in every URL) + - `POST /api/payments/connect-wallet` — **critical**: zero ownership check, could redirect any victim bot's future payouts to an attacker's wallet + - `POST /api/payments/claim/:paymentId`, `DELETE /api/payments/disconnect-wallet`, `POST /api/queue/join-ranked/:botId` — same pattern, lower severity + - Fix pattern: pubkey now always derived from `extractPubkeyFromAuth(Authorization: Bearer )`, never trusted from body/query. Added `verifyBotOwner()` helper in `bot-auth.ts` for routes serving both nostr-owner and anonymous-bot-secret audiences. +- Built the two things actually requested when the audit was found: **AI-answer settings reachable for existing bots** (`/api/bots/:name/ai-config`, not just at creation) and a **claim-winnings UI** (Cashu payouts were minted server-side but had zero frontend consumer — `41f1b93`) +- `10d4209`: fixed a real `tsc` error the podman build caught that local verification initially missed (misread a wrapper's exit code instead of the actual log content — lesson: always check log *content*, not just the shell wrapper's `$?`) +- Built + pushed `146.59.87.168:3000/lfg2025/botfights:1.2.11` + +**archy repo** (pushed to `gitea-ai/main`, my commits at `aea17248`/`b0a08345` — many other agents' commits have landed on top since, this is a busy shared tree): +- `apps/botfights/manifest.yml` bumped to 1.2.11; fixed `data_uid` from `1001` to `999` (the container's real internal UID — first attempt copied fedimint-clientd/barkd's value without checking this image's actual `Dockerfile`, which does `useradd --system` with no explicit UID) +- `scripts/image-versions.sh` kept in lockstep +- Catalog regenerated, signed (user ran `sign-catalog.sh`), published — verified live on `146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/app-catalog.json` +- Deployed to both nodes via RPC (`package.update`), both verified healthy: + - **archi-dev-box** (local): `botfights` container on `1.2.11`, `/api/health` → ok + - **x250-beta** (`archy-x250-beta.tail08d8f2.ts.net`): `botfights` container on `1.2.11`, `/api/health` → ok, `/api/bots` confirmed identical to `botfights.archipelago-foundation.org` (arena-proxy forwarding correctly) + + + +Nothing blocking the demo. One loose end, likely moot: +- Framework PT (`100.65.115.109`) SSH access is still blocked — the password was rotated 2026-07-26 and the current one isn't recorded anywhere. User redirected the demo plan away from Framework PT to x250-beta earlier in the session, so this probably doesn't matter anymore unless the user brings it up again. + + + +- Cashu is now the primary UX for both paying entry fees AND receiving payouts, Lightning/NWC demoted to a secondary "or connect a Lightning wallet instead" option — explicit user instruction. +- `data_uid: 999:999` (not 1001) in the botfights manifest — verified against the running container's actual `id` output, not assumed from another app's manifest. +- ai-config routes accept EITHER a nostr JWT (new, for browser owners) OR the bot's own secret (existing, for anonymous AI-agent poll-mode bots) — additive, not a replacement, since both audiences are real and pre-existing. + + + +- Framework PT SSH: password unknown since 2026-07-26 rotation. Not currently blocking anything (user moved to x250-beta). + + +## Required Reading (in order) +1. This file, obviously. +2. `.planning/phases/09-botfights-platform-upgrade/09-06-SUMMARY.md` and `09-07-SUMMARY.md` — the actual last GSD-tracked work in this area, for anyone confused about why there's no PLAN.md for tonight's work. +3. If continuing security work: re-read the fix pattern in `botfight` repo commits `f5f57e6` and `c162d5e` before touching any other route that reads a pubkey — the same bug class may exist elsewhere in the codebase that wasn't audited (only `auth.ts`, `payments.ts`, and `queue.ts` were checked; `bots.ts`, `tournaments.ts`, `bets.ts` were not re-audited for this exact pattern). + +## Critical Anti-Patterns (do NOT repeat these) +- **ANTI-PATTERN: trusting a shell wrapper's exit code instead of the actual command output.** During this session, `tsc --noEmit ... ; echo "EXIT=$?"` was read as "passed" from the *notification summary* (which reports the wrapper's own exit code, always 0 because `echo` always succeeds) rather than the log *content*. This let a real `tsc` compile error through to a `podman build` failure. → Structural mitigation: always `cat`/`Read` the actual log file and look for the error pattern or an explicit `EXIT=N` marker line before treating a background verification command as passed. +- **ANTI-PATTERN: assuming multi-repo work is saved because one repo was pushed.** → Structural mitigation described in the BLOCKING CONSTRAINT above. +- **ANTI-PATTERN (from earlier this session, already corrected): never run `archipelago --version` on a fleet node** — it starts the full daemon rather than printing a version string (deployed binaries predate the flag). Use source-reading instead of the binary for investigation. + +## Infrastructure State +- **archi-dev-box** (local node): `archipelago` daemon healthy, RPC on `127.0.0.1:5678` (session cookie in `/tmp/archy-dev-cookies.txt`, likely stale by the time this is read — re-login with `auth.login` / password `ThisIsWeb54321@`). `botfights` container healthy on `1.2.11`. +- **x250-beta** (`archy-x250-beta.tail08d8f2.ts.net`, tailnet IP rotates — resolve by MagicDNS name): reachable via plain `ssh archipelago@archy-x250-beta.tail08d8f2.ts.net` this session (no password prompt hit — key-based or cached). RPC session cookie in `/tmp/archy-cookies.txt` **on that remote node**, likely stale — re-login same way. `botfights` container healthy on `1.2.11`. +- Both nodes' local `/tmp` filled up mid-session (a 12G tmpfs, hit 0MB free once) — if you hit `ENOSPC` from the harness itself (not the actual command), check `df -h /tmp` and clean up stray large files (this session's culprit: two OTA release assets, ~260MB, downloaded to `/tmp` on the **local** machine as a relay step for an unrelated node update earlier in the session). +- Canonical arena: `https://botfights.archipelago-foundation.org` — both demo nodes proxy to this via `ARENA_UPSTREAM_URL`, confirmed serving identical bot/fight data on both. + + +The user is demoing BotFights live, same day, wants two real fighters paying/winning with Cashu ecash across two real node installs. All of that is now in place and verified. The security audit was NOT originally requested — it was triggered by investigating the user's question "can we confirm the fighter wins all the cashu sats into their node wallet automatically", which led to reading `payments.ts` end to end and discovering the payout claim flow had no frontend UI *and* the backend route serving it had no auth at all. That in turn led to checking every other route with a similar shape, which is how 5 more instances of the same bug were found. This is worth remembering: a seemingly simple product question ("where does the money go") uncovered a real, live, exploitable vulnerability in a publicly-deployed app — treat "let me just check how this actually works end to end" as time well spent, not scope creep. + + + +Nothing is required to "resume" — this was a complete, self-contained session of off-plan work, fully committed, pushed, deployed, and verified. If the user opens a new session and says something like "continue" or "where were we", the right first move is to summarize the state above (both nodes on `1.2.11`, security fixes live, demo-ready), not to look for a GSD plan to execute. If the user wants to resume *GSD-tracked* work specifically, `STATE.md` says Phase 10 (Key-Material Hardening, KEY-01..KEY-04, sourced from `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`) is planned and ready to execute — but that is a separate, unrelated thread from tonight's BotFights work, and STATE.md is being actively updated by other concurrent agents working other phases (01, 02, 10) in this shared tree, so re-read it fresh rather than trusting anything cached. + diff --git a/.planning/APP-PORT-AUTH-GATE.md b/.planning/APP-PORT-AUTH-GATE.md new file mode 100644 index 00000000..3717af7b --- /dev/null +++ b/.planning/APP-PORT-AUTH-GATE.md @@ -0,0 +1,106 @@ +# App-port authentication gate — design + +Item 1 of `RELEASE-1.7.121-TASKS.md`. Opened 2026-08-04. + +> "if I'm logged out I can reach every app port on tailscale and LAN, this can not be +> allowed… it must present the login to access the app with an app icon of what you're +> accessing to confirm, and 2FA if present" — operator, 2026-08-03 +> +> "make sure we fix FIPS, Tor, everything … umbrel definitely shows a port when you go to +> tailscale IP or other + port but demands the node login and 2FA if activated" +> — operator, 2026-08-04 + +--- + +## What we already built, and why it did not close this + +The operator's recollection that FIPS and Tor were "done" is correct — but that work was +about **reachability**, and about restricting the **daemon's own** API. Neither one ever +authenticated an app port. Read together, each transport got a door and none got a lock: + +| Layer | What exists today | What it protects | +| --- | --- | --- | +| `server.rs:1271` `is_peer_allowed_path` | Federated peers hitting the **daemon** port may only reach `/health`, `/rpc/v1`, `/content`, `/blob/`, `/dwn/`, `/transport/inbox`, `/archipelago/*` | The daemon's API surface. **Not app ports.** | +| `fips/app_ports.rs` `APP_LAUNCH_PORTS` | 35 app ports **allowed through** the fips0 firewall | Nothing — it *opens* them | +| `server.rs:1130` `app_port_v6_relay_loop` | Daemon relays mesh v6 → v4 loopback for those same ports | Nothing — it *bridges* them | +| `api/rpc/tor/mod.rs:243` | Per-app `HiddenServicePort 80 → 127.0.0.1:` | Nothing — it *publishes* them to an onion | +| `container/quadlet.rs:261` | `PublishPort=0.0.0.0:{host}:{container}` | Nothing — it binds every interface | + +So the app ports are reachable, by construction, over LAN, Tailscale, FIPS mesh and Tor, +and nothing on any of those paths checks a session. This is the same bug class as the +v1.7.120 `/lnd-connect-info` + `/bitcoin-rpc/` leaks, but structural rather than +per-endpoint. + +## The rule this design is built on + +**You cannot gate a socket you do not own.** Every previous fix added a check *beside* the +listener, which is why each one only covered the transport it was written for. The gate +has to *be* the listener. + +## Design + +Port numbers do not change. For an app whose UI port is `P`: + +- **The app binds `127.0.0.1:P` only** (`PublishPort=127.0.0.1:P:`), so it is + no longer reachable from any interface. +- **The gate binds `P` on every external address** — LAN IP, Tailscale IP, fips0 ULA — + and on **`127.0.0.2:P`** for Tor. `127.0.0.2` is a distinct loopback address, so it does + not collide with the app on `127.0.0.1:P`, and it means **no app needs a second port + number**. `torrc` changes to `HiddenServicePort 80 127.0.0.2:P`. +- Upstream for the gate is always `127.0.0.1:P`. + +Because the gate owns the socket, LAN / Tailscale / FIPS / Tor are one code path. There is +no per-transport work, and therefore no transport to forget. + +### Request handling + +1. Read the `session` cookie. Cookies are **host-scoped and port-agnostic**, so the + session minted on the dashboard is presented to `:P` automatically — this is the + same mechanism umbrel's "proxy token" relies on. (Scheme still matters: a `Secure` + cookie will not travel to a plain-HTTP app port. See open questions.) +2. **Valid session** → proxy to `127.0.0.1:P`, passing through `Upgrade` so WebSockets work. +3. **No/invalid session** → serve the login page **on the app port itself**, naming the app + and showing its icon, POSTing back to the same origin. The gate verifies the password, + enforces TOTP when enabled, and sets the session cookie — so logging in at + `:P` also logs you into the dashboard, exactly as umbrel behaves. +4. Non-browser clients get `401` with a JSON body rather than an HTML page. + +### What must NOT be gated + +Non-HTTP ports cannot carry a cookie and must be declared, not discovered: +electrum `50002`, bitcoin p2p `8333`, LND gRPC `10009`/`9735`. These need an explicit +manifest field (`auth: none` + rationale) so the exception list is a `grep`, and they are +a firewall/allowlist question, tracked separately. + +Note `api/rpc/tor/mod.rs:238-240` already special-cases lnd's `9735`/`10009` as +`is_protocol_service` — that distinction is the seed of the manifest field. + +## Deploy traps this walks into + +- **Three copies of every container spec** — `apps//manifest.yml`, + `scripts/container-specs.sh`, `scripts/first-boot-containers.sh`. Changing `PublishPort` + in one leaves fresh installs broken while the node looks fixed. This is exactly what bit + lnd-ui (item 4). **Deduplicating these is arguably a prerequisite, not a follow-up.** +- Changing `PublishPort` drifts every app → one-time recreate fleet-wide. +- The gate must rebind when addresses change (Tailscale up/down, DHCP, fips0 re-key). + Precedent exists: `peer_late_bind_loop` in `server.rs` already does this for fips0. +- Verify **on the node**, not from source. v1.7.120's headline bug was a fix that shipped + in the binary and never reached the running container. + +## Open questions for the operator + +1. **Machine clients.** Umbrel's real-world failure mode: Home Assistant (or any API + client) hitting an app's API has no cookie and breaks. Browser-only, or do we mint + per-app long-lived tokens? +2. **TLS/scheme.** The daemon serves plain HTTP with nginx terminating TLS in front. If the + dashboard is HTTPS and app ports are HTTP, a `Secure` session cookie will not be sent — + the gate would prompt for login every time. Either the gate serves TLS on app ports too, + or app ports are HTTP-only on such nodes. + +## Sequencing + +1. Gate module + login page + proxy, behind an env opt-in. +2. Prove on **one** HTTP app on .228, across all four transports. +3. Dedupe the container-spec declarations. +4. Roll to all HTTP apps; declare the non-HTTP exceptions. +5. Repoint `torrc` at `127.0.0.2`. diff --git a/.planning/HANDOFF.json b/.planning/HANDOFF.json new file mode 100644 index 00000000..cc92f2c1 --- /dev/null +++ b/.planning/HANDOFF.json @@ -0,0 +1,43 @@ +{ + "version": "1.0", + "timestamp": "2026-08-02T10:34:47.198Z", + "phase": "09", + "phase_name": "BotFights Platform Upgrade", + "phase_dir": ".planning/phases/09-botfights-platform-upgrade", + "plan": null, + "task": null, + "total_tasks": null, + "status": "paused", + "context_type": "ad_hoc_reactive", + "note": "This handoff does NOT track a GSD plan/task. Phase 09's plans 09-01..09-07 are all already complete (SUMMARY.md exists for each, most recent 09-07-SUMMARY.md dated 2026-07-31 05:08). Everything recorded here happened AFTER 09-06/09-07 were done, as live reactive demo-day work directed by the user in conversation, not from a PLAN.md task list. There is no in-progress GSD plan to resume — this is purely a work-state save so uncommitted/unpushed work and node state are not lost.", + "completed_tasks": [ + {"id": "botfight-security-audit", "name": "Found + fixed 6 IDOR/missing-auth vulnerabilities in botfight repo", "status": "done", "commit": "f5f57e6 (auth.ts), c162d5e (payments.ts/queue.ts)"}, + {"id": "botfight-ai-config-existing-bots", "name": "AI-answer settings UI for existing bots (not just at creation)", "status": "done", "commit": "41f1b93"}, + {"id": "botfight-claim-winnings-ui", "name": "Claim-winnings UI (Cashu payouts were backend-only, no frontend consumer)", "status": "done", "commit": "41f1b93"}, + {"id": "botfight-tsc-fix", "name": "Fixed possibly-undefined route param tsc error caught by podman build", "status": "done", "commit": "10d4209"}, + {"id": "botfight-1.2.11-release", "name": "Built + pushed botfights:1.2.11 image to registry", "status": "done"}, + {"id": "archy-manifest-1.2.11", "name": "Bumped apps/botfights/manifest.yml + scripts/image-versions.sh to 1.2.11, regenerated+signed+published catalog", "status": "done", "commit": "aea17248 (manifest bump), b0a08345 (signed catalog)"}, + {"id": "deploy-archi-dev-box", "name": "Updated BotFights to 1.2.11 on archi-dev-box via package.update RPC", "status": "done"}, + {"id": "deploy-x250-beta", "name": "Updated BotFights to 1.2.11 on x250-beta via package.update RPC", "status": "done"}, + {"id": "botfight-push-to-origin", "name": "Pushed 4 local-only botfight commits to origin (were unpushed until this handoff step)", "status": "done", "commit": "d00e792..10d4209 -> origin/main"} + ], + "remaining_tasks": [ + {"id": "framework-pt-access", "name": "Framework PT (100.65.115.109) SSH access still blocked — password was rotated 2026-07-26, current password unknown. User redirected focus to x250-beta instead, so this may no longer be needed for the demo.", "status": "blocked"} + ], + "blockers": [ + {"description": "Framework PT SSH password unknown (rotated, not recorded)", "type": "human_action", "workaround": "User already redirected demo plan to use x250-beta instead of Framework PT — likely moot unless user asks for Framework PT again."} + ], + "async_jobs": [], + "human_actions_pending": [], + "decisions": [ + {"decision": "Made Cashu the primary entry-fee AND payout UX for BotFights, Lightning/NWC secondary", "rationale": "Explicit user instruction: \"please make cashu the primary UX and lightning secondary\"", "phase": "09"}, + {"decision": "Fixed data_uid in apps/botfights/manifest.yml from 1001 to 999", "rationale": "Container's actual internal UID (confirmed via `podman exec botfights id`) is 999, not 1001 — first attempt copied fedimint-clientd/barkd's value without verifying against this specific image's Dockerfile (`useradd --system` with no explicit UID lands at 999)", "phase": "09"}, + {"decision": "Extended ai-config routes to accept EITHER nostr JWT (verifyBotOwner) OR the bot's own secret, rather than replacing bot-secret auth", "rationale": "Poll-mode AI-agent bots (no nostr identity) still need the original bot-secret path; nostr-logged-in browser owners needed a new path that didn't exist before", "phase": "09"} + ], + "uncommitted_files": [], + "unrelated_uncommitted_by_other_agent": [ + "core/archipelago/src/container/prod_orchestrator.rs (archy repo) — modified by a DIFFERENT concurrent agent, not touched by this session. Do NOT stage, commit, or stash this file." + ], + "next_action": "No GSD action required to resume — Phase 09 is fully complete and this was off-plan reactive work, now fully committed and pushed in both repos (archy @ b0a08345, botfight @ 10d4209 on origin/main), deployed to both demo nodes (archi-dev-box + x250-beta, both verified healthy on botfights:1.2.11), and catalog signed+published. If resuming demo work: verify nodes are still healthy (`curl http://127.0.0.1:9100/api/health` on each) since time has passed. If resuming GSD-tracked work: STATE.md says Phase 10 (Key-Material Hardening) is planned and ready to execute — that is a SEPARATE, unrelated GSD phase from tonight's BotFights firefighting.", + "context_notes": "This was a long reactive demo-prep session, not GSD-plan-driven. Started from GSD-executing 09-06-PLAN.md (bump BotFights manifest + sign catalog), which completed normally and STOPPED at the signing checkpoint as designed. Everything after that was live user-directed firefighting for a same-day demo: iframe embedding, native signer bridge, AI-answer feature, Cashu payment integration (both entry-fee and payout sides), a security audit that surfaced a systemic IDOR pattern (client-supplied pubkey trusted without verification) repeated across 6 routes — 2 of them critical (unauthenticated Cashu-token leak, unauthenticated wallet-hijack) — and a second-node deployment to x250-beta that surfaced a real manifest bug (data_uid). The single biggest risk caught in this handoff step itself: 4 botfight-repo commits (the entire security-fix work) were sitting LOCAL-ONLY, never pushed to origin, until this pause-work step explicitly checked ahead/behind counts and pushed them. Always verify `git status -sb` / ahead-behind against the actual remote before ending a session that touched a repo other than the one being actively `git push`ed in the visible workflow — pushing archy did not imply botfight got pushed too, they are separate repos." +} diff --git a/.planning/INGEST-CONFLICTS.md b/.planning/INGEST-CONFLICTS.md new file mode 100644 index 00000000..703e66c4 --- /dev/null +++ b/.planning/INGEST-CONFLICTS.md @@ -0,0 +1,32 @@ +# Ingest Conflict Report + +Mode: new (fresh bootstrap — no existing .planning/ context to check against) +Precedence: ADR > SPEC > PRD > DOC (no per-doc overrides present) + +## Conflict Detection Report + +### BLOCKERS (0) + +(none) + +### WARNINGS (0) + +(none) + +### INFO (4) + +[INFO] Overlapping locked ADRs on Nostr marketplace discovery — consistent, not contradictory + Found: docs/adr/003-nostr-for-discovery.md and docs/adr/006-nostr-marketplace-discovery.md are both locked and both decide "Nostr relays (NIP-78, kind 30078) for app manifest discovery" over the same scope + Note: The decisions agree; ADR-006 refines ADR-003 with concrete trust tiers (Verified/Community/Unverified), curated built-in app list, and pre-install signature verification. Both preserved as separate entries in intel/decisions.md; no resolution needed. Consider marking one as superseding/refining the other in the docs for hygiene. + +[INFO] SPEC security validation list narrower than ADR-009 mandatory defaults + Found: docs/adr/009-manifest-container-security.md (locked) mandates non-root UID (> 1000), pinned image tags (no `latest`), and a default seccomp profile as non-negotiable defaults; docs/app-manifest-spec.md's documented SecurityPolicy schema and AppManifest::validate() list do not mention these three (SecurityPolicy has apparmor_profile but no seccomp field) + Note: This is SPEC silence, not contradiction — no auto-resolution applied. ADR-009 governs by precedence (ADR > SPEC) and lock status. The SPEC itself declares `core/container/src/manifest.rs` canonical over the doc, so the gap may be documentation drift rather than implementation drift. Flagged for downstream verification, recorded as absent in intel/constraints.md. + +[INFO] ADR numbering gap — ADR-010 absent from ingest set + Found: Classified ADRs run 001–009 and 011; no classification exists for an ADR-010 + Note: Either ADR-010 does not exist, was withdrawn, or was not included in the ingest. No action required for synthesis; noted for completeness of the decision record. + +[INFO] Cross-reference graph is acyclic + Found: cross_refs edges: ADR-007 → ADR-003; ADR-009 → docs/app-manifest-spec.md (+ code paths); SPEC → out-of-set docs and code only (app-developer-guide.md, manifest-hooks-design.md, marketplace-protocol.md, core/container/src/manifest.rs, api/rpc/package/stacks.rs) + Note: DFS cycle detection found no cycles; all 11 docs were synthesized. Several SPEC cross-refs point to documents not in the ingest set — they were not followed. diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md new file mode 100644 index 00000000..b645fc09 --- /dev/null +++ b/.planning/PROJECT.md @@ -0,0 +1,115 @@ +# Archipelago + +## What This Is + +Archipelago is a self-hosted personal-server platform: a Rust daemon (workspace at `core/`) +plus a Vue 3 frontend (`neode-ui/`, built to `web/dist/neode-ui/`) running on Debian nodes +with rootless Podman, managing ~40 declarative, manifest-driven apps (Bitcoin, Lightning, +mesh/LoRa, federation, media, and more). It ships as OTA-updated releases to a live fleet +and is actively shipping v1.7.x alpha releases. This milestone drives it to the +**developer-ready app platform** north star. + +## Core Value + +A third-party developer can publish an app via the signed/decentralized registry and a user +can install it on their node — every app manifest-driven, manifests shipped via the signed +registry (not OTA disk files), all rootless, secure, robust, and 100%-uptime-capable. + +## Current State (brownfield baseline, 2026-07-29) + +- Single-node production gate is **GREEN** (5/5 on .228, 2026-06-23) — that exit criterion is met. +- ~40 apps are manifest-based and Quadlet-migrated; all multi-container stacks use the + orchestrator stack pattern; the legacy per-app installer anti-pattern is deleted. +- Workstream B (registry-distributed manifests) phases 1+2 are code-complete; the signing + ceremony is done (release-root pinned in `anchor.rs`); the fleet flip is not yet authorized. +- Workstream C (marketplace) is design-only (`docs/marketplace-protocol.md`); no tooling or + trust UX built. Developer CLI suite (`archy app …`) does not exist yet. +- Phase-3 Quadlet default-flip is validated opt-in on .228/.198 but not default. +- Declared next exit criteria: the multinode pass (`docs/multinode-testing-plan.md`) and the + remaining workstreams. + +## Requirements + +### Validated + +- ✓ Single-node lifecycle gate green 5× on .228 (install/UI/stop/start/restart/reinstall/ + reboot-survive/daemon-restart-survive/uninstall) — 2026-06-23 +- ✓ Manifest-driven app packaging for all ~40 apps incl. multi-container stacks (workstream A) +- ✓ Signed catalog + release-root signing ceremony (workstream B phases 1+2, code-complete) + +### Active + +See `.planning/REQUIREMENTS.md` — 20 v1 requirements across MNODE / LIFE / REG / SEC / DEV / MKT, +all mapped to phases in `.planning/ROADMAP.md`. + +### Out of Scope + +- Rootful containers, Docker, privileged containers — invariant (ADR-001/ADR-009) +- Per-app Rust installers / OS-level provisioning — the anti-pattern being deleted +- Centralized gatekept app store — decentralized Nostr marketplace instead (ADR-006) +- Web5 DWN spec compliance — deprioritized after TBD shutdown (ADR-011) +- Custom live voice-call protocol — deprioritized per user 2026-07-01; revisit later +- DHT/iroh distribution backbone (workstream D) — design-only, tracker-marked backlog; v2 + +## Context + +- Repo: `core/` Rust workspace (no root Cargo.toml), `neode-ui/` Vue frontend, `apps/` manifests, + `tests/lifecycle/` + `tests/multinode/` gates, `docs/` authoritative plans. +- Authoritative narrative: `docs/PRODUCTION-MASTER-PLAN.md`; day-to-day open list: + `docs/UNIFIED-TASK-TRACKER.md`. Codebase map: `.planning/codebase/ARCHITECTURE.md` + + `.planning/codebase/CONCERNS.md`. +- Known debt informing this milestone (from CONCERNS.md): federation tombstone-write errors + swallowed; reconciler has no flap observability and no failed-unit self-healing; generated + AppArmor profiles are never applied; multinode test harness curl calls lack timeouts; + SPEC validation is narrower than ADR-009's mandates (non-root UID, pinned tags, seccomp). +- Fleet is live and OTA-updated; all destructive verification happens on designated test + nodes per the deploy roster — never uninvited on in-use nodes. + +## Constraints + +- **Security**: Rootless Podman only; manifest-declared secrets (0600, never logged); + mandatory container security defaults enforced at manifest level (ADR-009) +- **Data safety**: Migrations never destroy data — preserve `/var/lib/archipelago/`, + secrets, credentials, ports, adoption container names; always a rollback path +- **Verification**: Real-node verification before any tag; lifecycle gate runs ON the node, + not via RPC; mesh changes need real-RF E2E tests; re-run the gate after orchestrator changes +- **Process**: Commit + push every unit of work (`git push gitea-ai main`); stage by explicit + path; deploy to the dev pair before any OTA; never commit secrets +- **Tech stack**: Rust (Tokio/Hyper, JSON-RPC 2.0) backend; Vue 3 + Pinia frontend; + Quadlet/systemd-user container units; Ed25519-signed release artifacts + +## Key Decisions + + + +All ten ADRs below are **locked** (Status: Accepted; ingest source `docs/adr/*.md`). They are +non-negotiable inputs to planning and cannot be overridden without a new ADR. + +| ID | Decision | Scope | +|----|----------|-------| +| ADR-001 | Podman over Docker — rootless, daemonless, systemd-native; `archy-net` for inter-container DNS | Container runtime | +| ADR-002 | `did:key` (Ed25519) node identity — self-contained, offline-capable; gaps mitigated via federation trust lists | Identity | +| ADR-003 | Nostr relays (NIP-78, kind 30078) for node + app discovery — multi-relay query, 15-min cache, trust scoring, Tor-compatible | Discovery | +| ADR-004 | Tor hidden services for inter-node RPC/control plane — bulk data via registries, not Tor | Federation transport | +| ADR-005 | ChaCha20-Poly1305 + Argon2id (64MB, 3 iter) for backup encryption | Backups | +| ADR-006 | Nostr relays for marketplace discovery — DID-signed manifests, trust tiers (Verified/Community/Unverified), signature verification before install | Marketplace | +| ADR-007 | Bilateral DID federation trust via single-use invite codes; Trusted/Observer/Untrusted levels | Federation trust | +| ADR-008 | Dual keys from one master seed — Ed25519 canonical identity, secp256k1 for Nostr/Bitcoin/Lightning, linked via NIP-05 | Keys | +| ADR-009 | Manifest-level container security enforcement — readonly_root, no_new_privileges, non-root UID, drop-ALL caps, pinned tags, seccomp; overrides explicit + audited | Container security | +| ADR-011 | DWN deprioritized — keep custom `dwn_store.rs`, stop branding as Web5, invest in Nostr + Tor federation instead | Peer data sync | + +(ADR-010 does not exist in the repo — numbering gap, noted in `.planning/INGEST-CONFLICTS.md`.) + + + +Milestone-level decisions: + +| Decision | Rationale | Outcome | +|----------|-----------|---------| +| Milestone version = 1.8.0-alpha | Decided 2026-07-08 per tracker | — Pending ship | +| Workstream D (DHT) deferred to v2 | Design-only, tracker-marked backlog; not needed for north-star metric | — Pending | +| App manifest canonical schema = `core/container/src/manifest.rs` | SPEC self-declares code wins over doc | ✓ Good | +| Phase-3 Quadlet flip gated on multinode gate reporting clean | Prior uncommitted-flip confusion; flip fresh as a 2-line change when gate is clean | — Pending | + +--- +*Last updated: 2026-07-29 after intel ingest (10 ADRs + 1 SPEC) + codebase mapping* diff --git a/.planning/RELEASE-1.7.121-TASKS.md b/.planning/RELEASE-1.7.121-TASKS.md new file mode 100644 index 00000000..7542bda1 --- /dev/null +++ b/.planning/RELEASE-1.7.121-TASKS.md @@ -0,0 +1,554 @@ +# Release 1.7.121 — task list + +Opened 2026-08-03, immediately after v1.7.120-alpha shipped. Everything the operator has +asked for since, plus the items v1.7.120 deliberately left open. Ordered by severity. + +Status key: **DONE** (committed) · **READY** (written, not yet committed/tested) · +**OPEN** (not started) · **BLOCKED** (needs an operator decision) + +--- + +## P0 — Security + +### 1. App ports are reachable with no login, on every transport — **OPEN** +> "if I'm logged out I can reach every app port on tailscale and LAN, this can not be +> allowed… it must present the login to access the app with an app icon of what you're +> accessing to confirm, and 2FA if present" — operator, 2026-08-03 + +- Applies to **Tailscale, LAN, Tor, FIPS** alike, and to "ssh access to that port or whatever". +- Required behaviour: an unauthenticated request to any app port serves a **login page + naming and showing the icon of the app being accessed**, then honours **2FA when set**. +- **Research first:** how umbrelOS and StartOS gate app access (operator asked explicitly). + Both are open source — `getumbrel/umbrel` and `Start9Labs/start-os`. Do not guess at + their model; read it. +- This is the same class as the v1.7.120 `/lnd-connect-info` + `/bitcoin-rpc/` leaks, but + **fleet-wide across every app port** rather than two endpoints. Those two were closed by + moving authorisation to the resource; this needs a general gate. +- Scope note: `fips/app_ports.rs` holds the mesh allowlist; `is_peer_allowed_path` in + `server.rs` holds the peer HTTP allowlist. Neither currently authenticates app ports. + +#### Research — umbrelOS (verified from their docs/source, 2026-08-03) + +umbrelOS solves this **architecturally, not per-app**: the app's own port is never +published. Each app gets a sidecar `app_proxy` container that owns the published port and +forwards to the app on the internal network. + +- `containers/app-proxy` is described as *"a transparent HTTP proxy to add authentication + to Umbrel apps"* — **every** HTTP request and WebSocket upgrade passes through it and + has its session token checked. +- Tokens come from a separate `app-auth` service; the proxy talks to it over a local port + (default 2000) with a shared secret (`UMBREL_AUTH_SECRET`). Two JWTs exist: an **API + token** in localStorage (`{loggedIn: true}`) for the dashboard's own API, and a + **proxy token** in an **HttpOnly cookie** (`{proxyToken: true}`) for app access. Both + HS256, 7-day expiry. +- Unauthenticated requests are redirected to the login screen. +- Per-app escape hatches, all env vars on the proxy: `PROXY_AUTH_ADD` (bool, **default + true** — so apps are protected unless opted out), `PROXY_AUTH_WHITELIST` (paths exempt, + e.g. `/public/*`), `PROXY_AUTH_BLACKLIST` (paths that must be authed, e.g. `/admin/*`). +- Known friction worth designing around: apps with their own login (Frigate, and the + `PROXY_AUTH_ADD=false` tracker issue) end up double-authenticating, and non-browser API + clients (Home Assistant hitting an app's API) break because they have no cookie. Any + gate we build needs a story for machine clients, not just browsers. + +**The lesson for us:** the reason umbrel doesn't have this bug class is that there is no +unauthenticated path to bind to in the first place. Our apps publish their own ports +directly, so a gate bolted onto one transport leaves the others open — which is exactly +the shape of the `/lnd-connect-info` + `/bitcoin-rpc/` leaks. The fix likely has to move +the port binding, not just add a check. + +#### Reproduced ON archi-dev-box, 2026-08-03 — baseline before the fix + +No session cookie, over the Tailscale IP `100.69.68.39`: + +``` +port 18083 HTTP 200 LND - Archipelago +port 8334 HTTP 200 +port 8175 HTTP 200 Fedimint Guardian - Archipelago +port 8336 HTTP 200 FIPS Mesh +port 8090 HTTP 200 +port 7777 HTTP 200 +``` + +`ss -tlnp` confirms these are bound `0.0.0.0`, so the same responses are served on the LAN +IP and every other host address. Re-run this exact loop after the fix: every one must +become the login page, and the ports listed as protocol exemptions (item 1b) must be the +*only* ones still answering. + +#### Exposure map — how app ports are reachable TODAY (verified in source, 2026-08-03) + +All four transports converge on `127.0.0.1:`. This is the whole reason the fix +is tractable: it is **one gate, not four**. + +| Transport | Path to the app | Code | +|---|---|---| +| LAN / Tailscale | container publishes the port on the host (`--network host`, so `0.0.0.0:`) — reachable on *every* host IP | `scripts/container-specs.sh`, `first-boot-containers.sh` | +| FIPS mesh | daemon binds `[fips0-ULA]:` and raw-TCP-forwards to `127.0.0.1:` | `server.rs:1130` `app_port_v6_relay_loop` | +| FIPS firewall | `tcp dport { …APP_LAUNCH_PORTS… } accept` drop-in opens them all | `fips/config.rs:274`, `fips/app_ports.rs` | +| Tor | `HiddenServicePort 80 127.0.0.1:` per service | `api/rpc/tor/mod.rs:243` | + +#### Design decision (operator, 2026-08-03) + +**Gate app UIs + bearer tokens; protocol ports exempt.** HTTP app UIs get the login gate +(app name + icon, 2FA honoured). Protocol ports (LND gRPC 10009 + REST, electrum 50002, +bitcoin p2p 8333) stay open but MUST be declared `auth: none` with a rationale in the +manifest, so the exceptions are a grep rather than a discovery — see item 1b. Per-app +long-lived bearer tokens cover machine clients that speak HTTP (Home Assistant). **Zeus +and electrum wallets keep working untouched** — that was the deciding constraint. + +The gate lives in the **daemon**, not a per-app sidecar container (umbrel's `app_proxy` +model): rootless, no extra containers per app, one place to update, and it can reuse the +existing `app_port_v6_relay_loop` rather than fight it. + +#### ⚠️ Trap found while designing — an nft-only gate FAILS OPEN + +The obvious implementation is an nft redirect of inbound app-port traffic to the gate. +But `/etc/fips/fips.nft` is **provisioned out-of-band** and `fips/config.rs:290` treats its +absence as a no-op (`if try_exists("/etc/fips/fips.nft")`). A gate shipped as a `fips.d` +drop-in would therefore be **silently absent on every node without the hardening +baseline** — i.e. it fails open, which is exactly the failure class this item exists to +close. + +Two viable shapes, both fail-closed: +- **(a) Apps bind loopback only**, daemon owns every external bind. Airtight, the true + umbrel model, but requires touching each app's own listen config (nginx.conf etc.). + Note you *cannot* half-do this: while an app holds `0.0.0.0:`, the daemon cannot + bind `:` at all. +- **(b) Daemon owns a dedicated `archipelago-appgate` nft table** with its own + default-deny + redirect, independent of whether `fips.nft` exists, and refuses to start + / alarms loudly if it cannot install it. Non-invasive to apps. + +#### Enabler found — `PortMapping.bind` already does half of (a) + +`core/container/src/manifest.rs:518` — `PortMapping` has a `bind` field, documented as +*"Host address to bind the publish to. Empty = all interfaces (0.0.0.0). Set `127.0.0.1` +to keep a port host-local"*. So for **bridge apps that declare `ports:`**, going +loopback-only is a **manifest edit, not app surgery**, and the daemon can then own the +external bind. That is most of the catalog. + +The exception is **host-networked apps** (`security.network_policy: host` — `lnd-ui`, +`bitcoin-ui`, `electrs-ui`): host networking bypasses port mapping entirely, so `bind` has +no effect and `ports:` is deliberately empty. Those bind whatever their internal nginx +binds. We build those images ourselves, so the fix is a `listen 127.0.0.1:;` change +in each `docker/*-ui/nginx.conf` — still no third-party surgery. + +Watch the rootless trap documented at `manifest.rs:532`: a publish bound to an address the +host cannot actually bind crash-loops the whole unit (took bitcoin down fleet-wide on .228, +2026-07-09). Loopback binds are explicitly always accepted without probing, so this +direction is safe. + +**Tor needs separate handling either way**: the onion connects *from* localhost, so a +redirect that exempts loopback will not catch it. `HiddenServicePort` must be repointed at +the gate, and since that mapping loses the original destination port, each app needs its +own gate port (or an HTTP-level Host mapping). + +#### Primitives that already exist — do NOT build these from scratch + +The gate is mostly assembly, not invention: + +| Need | Existing API | +|---|---| +| Read the session cookie off a request | `session::extract_session_cookie(&HeaderMap) -> Option` (`session.rs:479`) | +| Validate a session | `SessionStore::validate(&token) -> bool` (`session.rs:194`) | +| **Honour 2FA** | Already modelled: `create_pending(totp_secret)` (`:176`) + `upgrade_to_full` (`:247`). A session still pending 2FA **fails `validate()`**, so the gate gets 2FA for free by calling `validate` — no TOTP code in the gate itself | +| **Machine-client bearer tokens** | `device_tokens::create/verify` (`device_tokens.rs:63/:90`) — long-lived, minted from an authenticated session, only the SHA-256 persisted, plaintext returned once. Built for the companion pairing QR; needs **per-app scoping** added for this use | +| Rate limiting | `device_tokens` verification already rides `auth.login`'s limiter | + +So the new code is: the listener/redirect, the app-identification step (which app is this port?), +the login page render (app name + icon), and per-app scoping on `device_tokens`. + +#### Research — StartOS: **DROPPED** (operator, 2026-08-03) + +"don't need the startOS research we decided on a approach already." The umbrelOS read +plus the design decision above settled it; no further prior-art work. + +### 1b. Manifest declaration of unauthenticated ports — **DONE** (`0c4826f8`, pushed) + +`PortMapping` grew `auth` (`session` | `none`, defaulting to **`session`**) and +`auth_rationale`. The default is the protected one, so exposure is now something a +manifest has to ask for rather than something it gets by saying nothing. + +Validation is two-sided: `auth: none` without a rationale is rejected, **and** a +rationale without `auth: none` is rejected — that combination means the author wrote an +exemption and did not get one, and shipping it silently would leave them believing +otherwise. + +**17 ports across 12 apps are exempt**, each with its reason: Lightning p2p (BOLT-8 +noise), LND gRPC 10009 / REST 18080 and CLN gRPC 9835 (macaroon / mutual TLS — this is +what keeps Zeus and remote wallets working), Bitcoin p2p 8333, electrum 50001, the three +Wyoming voice ports, git-over-SSH 2222, and the UDP discovery protocols (mDNS 5353, +SSDP 1900, STUN 3478). **The other 39 published ports now default to gated.** + +Bitcoin RPC 8332 is deliberately *not* exempted: it is already `bind: 127.0.0.1`, so the +gate never sees it, and claiming an exemption it does not need would put a meaningless +line in the audit list. If that bind is ever dropped it fails closed. + +Two corpus tests pin this: every shipped manifest must parse, and the exempt set is +frozen at 17 so the node's unauthenticated surface cannot grow by accident. + +### 1c. The gate itself — **IN PROGRESS** + +`core/archipelago/src/appgate/` — `identity.rs` (port → app id/name/icon, gated vs +exempt, re-read from manifests so a catalog refresh applies without a restart), +`mod.rs` (authorize + login page + TOTP step + reverse proxy), `listener.rs` (binds the +external addresses, sweeps every 60s). + +Design points worth not re-deriving: + +- **It invents no auth policy.** `verify_password`, `totp::decrypt_secret`, + `verify_code` + used-step replay protection, `SessionStore::create/create_pending/ + upgrade_to_full`, and the *same* `LoginRateLimiter` instance as the JSON-RPC path. + Only the transport differs (HTML form vs JSON-RPC), because a browser being redirected + to an app cannot speak JSON-RPC. Sharing the limiter matters: otherwise an attacker + gets a fresh budget of password guesses by moving to an app port. +- **2FA is free.** A session still pending its TOTP step fails `validate()`, so the gate + rejects it without knowing anything about second factors. +- **Cookies ignore port.** The session cookie is host-only with no `Domain`, so one + sign-in covers the dashboard and every app port on the same host. The corollary is + that an app reached on a *different* host — its own onion — is a separate sign-in. +- **401, not a redirect.** A redirect to a login page is indistinguishable from the app + itself redirecting, and machine clients would follow it and parse HTML as their API + response. +- **The gate strips `Cookie` and `Authorization` before proxying.** The app has no use + for the node session and must never be in a position to log or forward it. +- **Machine clients**: `device_tokens` grew `apps: Option>` and + `verify_for_app`. `None` = node-wide (what every existing companion token is — + migrating them by guessing a scope would silently revoke access nobody asked to + revoke); `Some(list)` restricts to those apps. An empty list is rejected rather than + minted, since it would read as "unrestricted" while authorising nothing. + +#### ⚠️ The ordering constraint that shapes the rollout + +A published container port is bound `0.0.0.0:`, which claims **every** host +address. While the app holds that, the gate **cannot** bind `:` at all. +So the gate can only stand in front of an app whose publish has been pinned to loopback +(`bind: 127.0.0.1`) and whose container has been recreated. Gate-first is not possible; +all-apps-at-once would recreate every container on the node simultaneously. + +Therefore the rollout is **per app**, and the gate is built to be honest about being +partially deployed: a port it cannot claim is logged at **warn** every sweep and recorded +in `GateStatus::unprotected`. The failure mode this exists to prevent is a gate that +binds nothing, logs at debug, and reports success while every app stays exactly as open +as before — worse than no gate, because it stops anyone looking. (Same reasoning that +killed the nft drop-in: `/etc/fips/fips.nft` is provisioned out-of-band and its absence +is a silent no-op.) + +**Still open on this item:** pin the 39 gated ports to loopback app-by-app, repoint +`HiddenServicePort` at the gate (Tor connects *from* loopback, so a loopback-exempt +redirect will not catch it, and the mapping loses the original destination port), gate +the FIPS relay path, surface `GateStatus` in the UI, and verify on a real node. + +### 2. Filebrowser ships an insecure default login — **OPEN** +- Change the default credential **without breaking the dashboard's Cloud view**, which + authenticates to filebrowser on the user's behalf. +- Related prior art: FED-07 rotated the shipped Fedimint gateway credential and had to + recreate the running container for it to take effect (`06e0e695`) — the same trap + applies here. + +### 3. Federation trust escalation — **DONE** (`c0cfc72a`, pushed) +Two independent fail-open paths granted `Trusted` without any operator decision: + +- `federation.peer-joined` is **unauthenticated** (middleware no-session list) and + peer-reachable on `/rpc/v1`. Its ed25519 check verifies the caller against **the pubkey + the caller supplied**, so it proves key possession, never authorisation. A join with no + `invite_token` fell through to `TrustLevel::Trusted.min(claimed_trust)`, and + `claimed_trust` defaults to `Trusted` — so anyone able to reach the node could + self-grant Trusted. **Now capped at `Observer`.** +- `merge_transitive_peers` added every peer advertised by a Trusted source as `Trusted`, + making trust viral across the whole federation graph. **Now `Observer`** — which is what + `NodeStateSnapshot.federated_peers`' own doc comment always said it should be + ("adds them as Observers on her side… doesn't auto-promote to Trusted"). The code + contradicted its own spec. +- Added `FederatedNode.trust_source` (`invite` | `uninvited-join` | `transitive-merge` | + `manual`, `None` = pre-existing/unknown) so existing grants are **auditable**. Per + operator decision: existing peers are **left alone, not auto-demoted**. +- `trust_source` is now **surfaced** in `federation.list-nodes` (as an explicit `null` + when unknown, not omitted — "recorded before this was tracked" is the population that + needs review, so the UI must be able to tell it apart from a field it didn't read) and + rendered under the trust dropdown in the node detail modal as "Granted via:". + +### 3b. Granting Trusted must require the node password — **DONE** (uncommitted at time of writing) +> "to make someone trusted must require the node password to generate the code or change +> in the modal dropdown when you click a node" — operator, 2026-08-03 + +Re-authentication on privilege escalation. Both entry points are covered: + +- **Minting a Trusted invite** (`federation.invite`) — gated on the **resolved** level, + which matters because "Link Your Nodes" sends no `trust_level` at all and falls through + to the `Trusted` default. The invite is a bearer grant of Trusted to whoever redeems + it, so minting it *is* the escalation. Observer invites are untouched. +- **Changing a node's level in the UI dropdown** (`federation.set-trust`) — gated only + when the peer is **not already** Trusted, so the dropdown re-emitting its own value + doesn't demand a password for a no-op. + +Demotion is NOT gated: making something less privileged must never be harder than leaving +it, or the safe action becomes the inconvenient one. The operator path stamps +`TrustSource::Manual`; `set_trust_level` grew an `Option` so automatic +adjustments (the discovery-handshake demotion safety net) pass `None` and leave the +recorded provenance alone rather than laundering an `uninvited-join` peer into looking +operator-approved. + +Wiring: the backend is the sole authority on what counts as an escalation — it returns a +`PASSWORD_REQUIRED:` prefixed error, and the UI prompts and retries only on that. The +frontend never pre-judges, so the rule lives in exactly one place. +`TrustPasswordModal.vue` (modelled on `RotateDidModal.vue`) serves both flows. +`NodeDetailModal`'s select now snaps back to the node's real level on change, because a +cancelled or failed promotion would otherwise leave the dropdown displaying a level the +node never accepted. + +**Follow-up, deliberately not done here:** `federation.join` also grants Trusted (when +redeeming someone else's Trusted invite) with no re-auth. It is an explicit operator +paste rather than a UI toggle, and was outside the two entry points specified — but it is +the third way a node reaches Trusted and should be reviewed. + +--- + +## P1 — Correctness the operator hit directly + +### 4. LND UI never updates over OTA — **DONE** (`5088aef5`, pushed) +- `LND_UI_IMAGE` was `lnd-ui:latest` while `BITCOIN_UI_IMAGE` was pinned to + `1.7.119-alpha`. Podman will not re-pull a tag it already holds locally, so nodes kept a + stale lnd-ui forever. **Now pinned to `1.7.119-alpha`.** +- `scripts/first-boot-containers.sh` declared lnd-ui as bridge `-p 18083:80`. That is the + **third copy** of the declaration the UI agent already corrected in + `scripts/container-specs.sh` and `apps/lnd-ui/manifest.yml` — so **fresh installs** still + produced the reproduced `HTTP 000`. **Now `--network host`, ports empty.** +- Root cause worth fixing separately: the same container spec is declared in three places. + +### 5. Federated/peered nodes must message without a LoRa hop first — **OPEN** +> "make it so federated/peered nodes can message without needing to connect on Lora first +> once connected" + +- Investigate the split contact model (radio contact vs federation peer) — there is prior + art in memory: `project_archy_lora_e2e_rootcause` ("split contact model; don't touch + federation") and `mesh::seed_federation_peers_into_mesh` / + `upsert_federation_peer`, which already mirror federation peers into the mesh table. +- Likely the gap is addressing/route selection rather than transport availability. + +### 6. In-app app updates, independent of OTA — **OPEN** +> "we need app update to see updates in the registry, whether UI or not… show the update +> mechanism in the app… a modal and update now / cancel… same in the detail page… the +> update button should show 'see update' and a different graphic for just ui, app, or both +> together. All pushed through the signed-catalog flow." … "This has to show independent of +> OTA updates as a separate pipeline, I think we've done a lot of work on it." + +- **Operator says much of this already exists — research the codebase before building.** + Known groundwork: the signed catalog (`releases/app-catalog.json`, `sign-catalog.sh`), + catalog→manifest runtime reload, `package.update` RPC, `check-app-catalog-drift.py`, + and `scripts/image-versions.sh` pinning. + +#### What already exists (verified in source, 2026-08-03) — the operator was right + +The whole update *pipeline* is built and is already independent of OTA: + +- `package.check-updates` (`api/rpc/package/update.rs:180`) refreshes the signed catalog + and hot-reloads manifests when it changed — no daemon restart, no OTA involved. +- `package.update` (`spawn_package_update`), `package.versions`, `package.set-config` + version pinning, and `execute_update` (stop → pull → remove → recreate → verify). +- Version awareness: `app_catalog::catalog_versions(app_id)` vs `installed_version()` + (`api/rpc/package/set_config.rs:46`). +- Frontend: `AppCard.vue` already renders an Update button off `pkg['available-update']` + (`:48`, `:128`) and emits `update`. + +#### What is actually MISSING (this is the real scope of item 6) + +1. **The UI-vs-app-vs-both distinction does not exist.** `available-update` is a single + version string — nothing classifies whether the change is the app image, its `*-ui` + image, or both. This is the core of the operator's ask ("a different graphic for just + ui, app, or both together") and needs a backend change, not just an icon. + ⚠️ Compounding factor: per `reference_app_ui_delivery_model`, `*-ui` apps are **not in + the signed catalog** at all — so "is there a UI update" cannot be answered from the + catalog today. That gap has to be closed first or the UI half is unanswerable. +2. **The modal** (Update now / Cancel) — the card currently updates on click, no confirm. +3. **The detail-page affordance** — same treatment as the card. +4. **Button copy**: "See update" rather than "Update". + +### 6b. Multiversion for ALL apps + upstream release discovery — **OPEN** (operator, 2026-08-03) +> "we also need a way to provide multiversion support for all apps and it automatically +> pulls the latest versions from the source app repository, safely, and the user can +> choose to update so we aren't always updating manually" + +#### Verified 2026-08-03: the schema and runtime already exist + +This is much less work than it sounds, because the multiversion machinery built for +Bitcoin generalises as data rather than code: + +- `releases/app-catalog.json` entries already support a `versions[]` array of + `{version, image, default?, deprecated?}`. +- Runtime is complete: `catalog_versions(app_id)`, `catalog_default_version`, + `catalog_image_for_version`, `package.versions`, version pinning through + `package.set-config`, and `available_update_for_app` falling back to the + `image-versions.sh` baseline pin. + +**It is populated for 2 of 66 apps** — `bitcoin-core` (9 versions) and `bitcoin-knots` +(5). Every other app carries a single `version`. So "multiversion for all apps" is +primarily a **catalog-generation and image-mirroring job**, not new runtime plumbing. + +#### What has to be built + +1. **Populate `versions[]` fleet-wide.** Extend `scripts/generate-app-catalog.py` to emit + a version list per app instead of a single pin. Needs a per-app policy for how many + historical versions to carry and which is `default` (Bitcoin's list shows the shape, + including `deprecated: true` for old-but-installable). +2. **Mirror the images.** A version in the catalog that is not in our registry is a + broken promise — `package.update` would pull and fail. Use the existing skopeo path + (`feedback_skopeo_source_selection`: prefer `.160`, concurrency ≤ 6). +3. **An upstream release-watcher.** 48 manifests already carry a `repo:` URL under + `metadata`, so there is something to poll (GitHub releases / registry tags). It runs + **off-node**, as part of catalog generation. +4. **Keep the signed catalog as the trust boundary.** This is the whole of "safely": + the watcher **proposes** versions, the offline signing ceremony **admits** them, and + nodes only ever install what the signed catalog carries. A node must never pull + straight from an upstream repo — that would put an unsigned third party inside the + supply chain, which is exactly what the signed-registry model exists to prevent. +5. **The user chooses.** Discovery must never auto-apply. `package.check-updates` already + refreshes and hot-reloads without touching the running containers, so "a new version + exists" and "install it" stay separate — which is also what item 6's modal is for. + +**Sequencing note:** 6b's step 1 and item 6's UI-vs-app classification want the same +thing — `*-ui` images represented in the catalog. Doing that once unblocks both. + +--- + +## P2 — Carried over from v1.7.120 + +### 7. `create-release.sh` commits the manifest BEFORE signing — **OPEN** +Release commit always carries an **unsigned** manifest; nodes fetch it from branch `main` +and refuse to auto-apply. Caught manually this cycle. Fix the ordering so it cannot ship. + +### 8. `gitea-vps2` remote is dead, and is the same server as `gitea-ai` — **OPEN** +Stored token fails auth. `source.archipelago-foundation.org` == `146.59.87.168`, so +`git push gitea-ai` already publishes to the "primary" OTA host. Ties into the existing +"migrate VPS2 IP to domain" todo. + +### 9. Fleet SSH host-key rotation — **BLOCKED** (operator decision) +`archipelago-1`, `archy-x250-beta`, `archipelago` share all three SSH host keys; two also +share a TLS private key. Detection shipped; rotation deliberately not performed. + +### 10. 5× lifecycle gate — **OPEN** +Not run for v1.7.120 (disclosed in its changelog). Needs repeated reboots of a live node. + +### 11. `prod_orchestrator.rs:3181` unreachable code — **OPEN** +`bitcoin_host()` returns unconditionally at :3171, so the podman container-name lookup +below is dead on every path. Pre-existing; spotted in the v1.7.120 build warnings. + +--- + +## Notes for whoever picks this up + +- A separate agent is doing **AIUI planning with GSD** — do not touch AIUI. +- AIUI must always be built `VITE_BASE_PATH=/aiui/` (see the memory note); a hand-built + bundle renders a black page. +- Verify security claims on the node, not from the source. v1.7.120's headline bug was a + fix that shipped in the binary and silently never reached the running container. + +--- + +## STATUS 2026-08-04 — what shipped in 1.7.121 and what did not + +### Shipped (committed + pushed) + +| Item | Commit | Verified | +|---|---|---| +| 3. Federation trust escalation | `c0cfc72a` | 42/42 federation tests | +| 3b. Trusted requires node password | `24ce8b39` | 44/44 + 79/79 + vue-tsc | +| 4. lnd-ui OTA pin + host networking | `5088aef5` | — | +| 1b. Manifest `auth:` declarations | `0c4826f8` | 73/73, all 56 manifests parse | +| 1c. App gate (engine + audit) | `0de67ca6` | 23/23 appgate | +| Dashboard backdrop-filter seam | `63d0183d` | 3/3, **live on archi-dev-box** | +| 7. Release refuses unsigned manifest | `cc9e1958` | dry-run: signed/stripped/wrong-signer | +| Gate safety model (`Option`) | `ab2c8b6e` | 75/75 incl. LND wallet-port case | +| Companion rebuild-loop | `719446c0` | podman behaviour proven first | +| 5. Federated peers messageable | `edc9a172` | predicate pinned across device types | + +### The two gate incidents — read before touching the gate again + +Both were ONE mistake: a safety decision read an ABSENT manifest field as a +value. A node's installed manifests always lag the binary, so "absent" is the +normal state, and the daemon acted on instructions no manifest ever gave. + +1. Gating any `session` port regardless of `bind` **published Bitcoin's + loopback-only RPC 8332 on the LAN/Tailscale/IPv6** within seconds of deploy. +2. The `bind`-keyed replacement looked safe (it protected `bind: 127.0.0.1`) + but LND's gRPC 10009 / REST 18080 carry an EMPTY bind — one container + recreate from pinning them to loopback and **breaking Zeus and every remote + wallet**. + +Now structural: `auth_policy()` classifies (undeclared → reported as +unprotected, always safe), `auth_is_declared()` gates action (undeclared → +never acted on). **Silence is not consent.** + +### Proven on the node, empirically, not by reasoning + +- Gate challenge → login → proxy works end to end over LAN and Tailscale. +- **Daemon-side publish rewriting was removed.** Publishes are built in several + places (`podman_client`, `package::install`, `stacks`); patching one covered + one — the strfry recreate went through another and the pin never fired. +- **Disk manifest edits do not apply to catalog-covered apps.** Even + `bind: 127.0.0.1` written into the node's strfry manifest was overridden by + the signed catalog. The catalog re-sign is REQUIRED; there is no shortcut. +- A loopback-bound host port is **unreachable** from a pasta container, so + loopback-pinning the Wyoming ports would break Home Assistant voice. + +### Open for 1.7.122 + +1. **Catalog re-sign** — `bind: 127.0.0.1` + `auth: session` on the ~39 gated + UI ports. This is what turns the gate from auditing into enforcing. Nothing + in code can substitute for it. +2. **Release-root rotation** — branch `rotate-release-root`, key + `did:key:z6Mkfu5LT…DLWT` / `1578adcc…4418`, validated as a real curve point. + **Sign the rotation release with the OLD key**; only the release after it + uses the new one. Re-sign the catalog too. +3. **Wyoming voice ports** (10200/10300/10400) — unauthenticated, and by the + operator's policy they should not be. Correct fix is co-locating Home + Assistant with the pine services on one container network so nothing is + published; needs a node running both. +4. **Item 2** filebrowser default login. **Items 6/6b** app updates + + multiversion (`versions[]` already exists, populated for 2 of 66 apps). +5. **`cargo-test-weekly` times out** at its 1500s cap on a loaded box — raise + the cap or split the stage; it is not a code failure. + +## RESUME HERE — next session + +**Landed this session (both pushed):** +- `c0cfc72a` federation trust escalation (items 3) — 42/42 federation tests green +- `5088aef5` lnd-ui OTA pin + host networking (item 4), and this task file + +**v1.7.120-alpha is SHIPPED** — signed, published, assets verified live. Do not re-cut it. + +### Start with item 3b (password gate) — groundwork already located + +Everything needed to implement it, so the next session does not re-search: + +- **The helper to use:** `self.auth_manager.verify_password(password).await?` — returns + `bool`. Existing callers to copy the shape from: `api/rpc/node.rs:176`, + `api/rpc/totp.rs:18` / `:66` / `:121`. +- **Entry point A — minting a Trusted invite:** `handle_federation_invite`, + `api/rpc/federation/handlers.rs:58`. It reads `trust_level` from params and + **defaults to `TrustLevel::Trusted` at :72**. Gate only when the resolved level is + `Trusted`; leave Observer invites unchanged. +- **Entry point B — the UI dropdown:** `handle_federation_set_trust`, + `api/rpc/federation/handlers.rs:326`, dispatched as `"federation.set-trust"` + (`api/rpc/dispatcher.rs:353`). Its parse is at `:342`. +- **Rule:** gate PROMOTION to Trusted only. Demotion must stay ungated — making something + less privileged must never be harder than leaving it. +- Set `TrustSource::Manual` on the operator path so the audit trail distinguishes a + deliberate grant from the capped automatic ones. +- Frontend will need the password prompt in both places (invite modal, node dropdown). + +### Then item 1 (app ports unauthenticated) — the big one + +Start with the research the operator explicitly asked for: how **umbrelOS** +(`getumbrel/umbrel`) and **StartOS** (`Start9Labs/start-os`) gate app access. Read their +model rather than inventing one. Only then design the gate. + +Give this a fresh session with real context — it is the largest item here and is the same +bug class as the `/lnd-connect-info` + `/bitcoin-rpc/` leaks fixed in v1.7.120, but across +every app port and every transport. + +### Working notes +- A separate agent is doing **AIUI planning with GSD** — do not touch AIUI. +- The shared tree has concurrent agents: stage by explicit path, never `git add -A`. +- Verify security claims **on the node**, not from source. v1.7.120's headline bug was a + fix that shipped in the binary and silently never reached the running container. +- A piped command's exit code is the pipe's, not the script's — redirect to a log file and + read the content. diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md new file mode 100644 index 00000000..cd48075e --- /dev/null +++ b/.planning/REQUIREMENTS.md @@ -0,0 +1,171 @@ +# Requirements: Archipelago (v1.8.0 — Developer-Ready App Platform) + +**Defined:** 2026-07-29 +**Core Value:** A third-party developer can publish an app via the signed/decentralized registry and a user can install it on their node — manifest-driven, rootless, secure, robust. + +No PRDs existed in the ingest set; these requirements are derived from the master plan's +declared exit criteria (multinode pass + workstreams B/C/F), `.planning/codebase/CONCERNS.md`, +`docs/UNIFIED-TASK-TRACKER.md`, and the user-chosen success metric. Constraints from +`docs/app-manifest-spec.md` and the locked ADRs (see PROJECT.md) bound how each is built. + +## v1 Requirements + +### Federation & Mesh Hardening (FED) + +- [ ] **FED-01**: Removing a federation node sticks — it disappears from every UI surface, tombstones propagate, it never reappears via later sync cycles, and a failed removal surfaces an error (never a silent no-op) +- [ ] **FED-02**: Federation sync converges and is observable — after sync settles, fleet nodes agree on the node list with fresh status; stale entries, duplicates, and silent sync failures are eliminated and sync errors are operator-visible +- [ ] **FED-03**: A structured code review of the federation/fleet area (`core/archipelago/src/federation`, node sync, FIPS/transport dial layer) and mesh area (`core/archipelago/src/mesh`, mesh RPC surface) is completed, with every finding fixed or explicitly deferred with a reason +- [x] **FED-04**: Mesh messaging parity — attachment send (and the rest of the mesh chat surface) behaves identically on the demo and on real nodes: the demo backend implements the same RPC surface the UI calls, transport decisions mirror the real size-based tier logic, and no demo-only modals exist +- [ ] **FED-05**: Inter-node Lightning channel opening UX — the UI shows the node's shareable Lightning URI; lists trusted (federated) nodes by hostname for one-click channel opening; and lets the user browse/request channels with public nodes — using the existing design system and components, verified on the :8100 dev preview against archi-dev before deploy +- [x] **FED-06**: On-brand payment success animation — the invoice "paid" tick's circle uses the screensaver-style ring with outer EQ-segment lines (reuse `ScreensaverRing.vue`'s compact size) in place of the current success burst, applied consistently everywhere the paid tick shows +- [x] **FED-08**: Lightning invoices created by the wallet embed route hints (LND `private` flag) so nodes whose channels are unannounced can actually receive payments — diagnosed on archy-x250-mad2 2026-07-31, where every wallet-UI invoice had `route_hints: []` and was unroutable; the bug is unconditional and affects any node without a public channel +- [x] **FED-09**: The container doctor does not restart Tor on every run — it recognises Tor's own setgid `2700` hidden-service directory mode as correct rather than "fixing" it to `700` and restarting, a loop that reset Tor every ~5 minutes, starved it of its consensus/HSDir cache (`No more HSDir available to query`), and broke the mesh's Tor fallback entirely; genuinely permissive modes are still corrected, and a restart backoff makes the failure class non-recurring +- [x] **FED-07**: Fedimint gateway never installs with a pre-set password — gateway credentials are generated per-install via manifest-declared `generated_secrets` (or explicitly set by the user), never baked into the image/manifest; existing installs with the default password get a migration path (BLOCKER — default credentials are a security hole) + +### UI Fixes (UIFIX) — user-reported blockers, added 2026-07-30 + +- [ ] **UIFIX-01**: The FIPS/Tor pills on cloud files are kept (never removed by cleanups) and render at mobile widths — on mobile, users can see each file's security/transport state (BLOCKER) +- [x] **UIFIX-02**: The connected-nodes list scrolls at row-matched height — its height tracks the taller right-hand sibling in the row and the inner list scrolls within it, never growing to fit all rows scroll-free (BLOCKER) +- [x] **UIFIX-03**: On short viewports the onboarding confirmation tickbox is discoverably visible — an on-brand affordance (scroll cue, sticky footer, or equivalent) makes it obvious without altering tall-screen appearance (BLOCKER) +- [x] **UIFIX-04**: Paid Files pictures open in the app's lightbox, not a browser tab — consistent with the rest of the app's media UX +- [x] **UIFIX-05**: Picture-in-picture is robust — entering PiP closes the lightbox with a fluid on-brand animation, and an active PiP session survives main-tab changes and video buffering pauses (only an explicit user stop ends it) +- [x] **UIFIX-06**: Surfaces with genuinely slow opens show house-style loader states — no dead-feeling clicks (cached revisits stay spinner-free per PERF-02) + +### UI Performance (PERF) + +- [x] **PERF-01**: The slowest tab switches and secondary-screen opens are profiled with causes named (remount storms, serial RPC waterfalls, uncached fetches) — fixes are targeted, not guessed +- [x] **PERF-02**: Main-tab switches render immediately from cached state with background refresh — no blank screens or long spinners on tabs already visited this session +- [x] **PERF-03**: Secondary screens (screens reached from a tab's main page) open without a blocking full reload and are instant on repeat visits — verified on real node hardware, not just the dev box + +### Multinode Verification (MNODE) + +- [ ] **MNODE-01**: The 5× destructive lifecycle gate passes on a second fleet node (archy-x250-beta) with 0 failures, run on-node per gate policy +- [ ] **MNODE-02**: Cross-node federation/mesh/transport suites (`tests/multinode/smoke.sh`, `meshtastic.sh`) pass between fleet nodes, with all harness RPC calls time-bounded (no indefinite curl hangs) +- [ ] **MNODE-03**: Removing a federation peer sticks — tombstone-write failures are surfaced (not swallowed) and a removed peer never silently reappears after subsequent sync cycles + +### Lifecycle Perfection (LIFE) + +- [ ] **LIFE-01**: Quadlet backends are the default — restarting `archipelago.service` leaves every app container running (no SIGKILL-the-world, no multi-minute rebuild storm) +- [ ] **LIFE-02**: The reconciler self-heals failed Quadlet units — a `.service` in `failed` state (and not user-stopped) is reset-failed + started automatically, with backoff against busy-looping +- [ ] **LIFE-03**: Per-app restart/flap observability — restart counters, a threshold log line when an app restarts >N times in M minutes, and restart counts surfaced in health/status RPC output +- [ ] **LIFE-04**: Cascade uninstall→reinstall is gate-verified for multi-container stacks and installed apps — no ghost entries, no orphan containers, data preserved per policy, reinstall returns healthy +- [ ] **LIFE-05**: Install and uninstall report real, monotonic progress driven by backend progress events, always reaching a terminal success/failure state — asserted in the gate, never a fake or stuck bar + +### Registry-Distributed Manifests (REG) + +- [ ] **REG-01**: The published signed catalog embeds full app manifests; nodes install/update from signature-verified catalog manifests (disk manifests remain the fallback for build-source apps); tampered catalogs are rejected with safe fallback +- [ ] **REG-02**: The fleet is flipped to registry-distributed manifests — adding or bumping an image-only app requires only a re-signed catalog publish, no binary OTA or disk rsync + +### Security Enforcement (SEC) + +- [ ] **SEC-01**: `AppManifest::validate()` enforces the full ADR-009 mandate set — non-root UID, pinned image tags (no `latest`), capability allow-list, seccomp — with explicit, documented, auditable overrides +- [ ] **SEC-02**: Generated AppArmor/seccomp security profiles are actually applied at container creation (`--security-opt`) and verified effective on running apps + +### Developer Tooling (DEV) + +- [ ] **DEV-01**: `archy app validate` checks a manifest locally and returns the same pass/fail verdict the node enforces (schema + security rules) +- [ ] **DEV-02**: `archy app render` previews the exact Quadlet/podman configuration a manifest produces +- [ ] **DEV-03**: A developer can local-install and lifecycle-test an app against a dev node from the CLI (`archy app local-install` / `lifecycle-test`) +- [ ] **DEV-04**: The developer guide walks a new third-party developer from an empty directory to an installed, running app using only the CLI and docs + +### Decentralized Marketplace (MKT) + +- [ ] **MKT-01**: A third-party developer can publish a DID-signed app manifest to public Nostr relays (NIP-78, kind 30078) via the tooling +- [ ] **MKT-02**: A node discovers marketplace apps from multiple relays and displays each app's trust tier (Verified / Community / Unverified) per ADR-006 trust scoring +- [ ] **MKT-03**: Manifest signatures are verified before installation; tampered or invalid marketplace manifests cannot be installed +- [ ] **MKT-04**: End-to-end north star: a user installs a third-party marketplace-published app on their node and it runs healthy under the standard lifecycle guarantees + +### AIUI — Conversational Node Control & Content Surfaces (AIUI) — added 2026-08-03 + +- [ ] **AIUI-01**: Human-language node control — a typed request in AIUI chat ("restart bitcoin", "how much space is left", "who's connected") reaches a real node action and returns a real result, over a permissioned tool-calling bridge rather than raw RPC +- [ ] **AIUI-02**: Conversational settings — the system settings surfaced across neode-ui become reachable by conversation, scoped to what the user has granted +- [ ] **AIUI-03**: Content surfaces made real — AIUI's designed-but-empty content views render live node data (peer files, music, IndeeHub movies, owned/paid content); audio belongs to the global bottom-bar player and media streams via Range requests, never base64 blobs +- [ ] **AIUI-04**: Sandboxed by construction, permissioned by the user — secrets never reach the browser or the model context; the chat gets an explicit, user-granted, default-closed, revocable capability scope; destructive and identity-touching operations are human-confirmed; tool authority never derives from peer-controlled content (BLOCKER) +- [ ] **AIUI-05**: Delivery and build — AIUI reaches nodes on a delivery path an operator can actually receive updates through, with `VITE_BASE_PATH=/aiui/` enforced by the build script so a hand-built bundle cannot ship a black page +- [ ] **AIUI-06**: Verified on device — in the real embedded iframe on archi-dev-box, mobile included, not only in the local `dev:mock` loop + +## v2 Requirements + +Deferred to a future milestone. Tracked but not in the current roadmap. + +### Distribution Backbone (DIST) + +- **DIST-01**: BLAKE3 content-addressed catalog distribution via iroh swarm, origin-always-wins (workstream D — design-only today, tracker-marked backlog) + +### Fleet & Hardening (FLEET) + +- **FLEET-01**: Bitcoin multi-version fleet-wide OTA rollout (user-gated on timing per `docs/bitcoin-version-bulletproof-rollout.md`) +- **FLEET-02**: App-specific health assertions for the ~34 apps with only baseline lifecycle coverage +- **FLEET-03**: LUKS2 full-partition encryption for `/var/lib/archipelago/` +- **FLEET-04**: Dynamic per-app resource rebalancing (cgroup-stats feedback loop) + +## Out of Scope + +| Feature | Reason | +|---------|--------| +| Rootful/privileged containers, Docker | Invariant — ADR-001/ADR-009 | +| Per-app Rust installers / host provisioning | The anti-pattern workstream A deleted | +| Centralized gatekept app store | ADR-006 chose decentralized Nostr marketplace | +| Web5 DWN spec compliance | ADR-011 — deprioritized after TBD shutdown | +| Custom live voice-call protocol | Deprioritized 2026-07-01 per user; no scope decided | + +## Traceability + +Which phases cover which requirements. Updated during roadmap creation. + +| Requirement | Phase | Status | +|-------------|-------|--------| +| FED-01 | Phase 1 | Pending | +| FED-02 | Phase 1 | Pending | +| FED-03 | Phase 1 | Pending | +| FED-04 | Phase 1 | Complete | +| FED-05 | Phase 1 | Pending | +| FED-06 | Phase 1 | Complete | +| FED-07 | Phase 1 | Complete — rotation + recreate verified on archi-dev-box 2026-08-02 | +| FED-08 | Phase 1 | Code complete + unit-pinned; post-OTA check on the user device pending | +| FED-09 | Phase 1 | Complete — 15h Tor uptime / 0 permission-fixes on archi-dev-box; onion-resolution check post-OTA | +| UIFIX-01 | Phase 1 | Pending | +| UIFIX-02 | Phase 1 | Complete | +| UIFIX-03 | Phase 1 | Complete | +| UIFIX-04 | Phase 1 | Complete | +| UIFIX-05 | Phase 1 | Complete | +| UIFIX-06 | Phase 1 | Complete | +| PERF-01 | Phase 2 | Complete | +| PERF-02 | Phase 2 | Complete. 02-11 (`02-FINDINGS.md` § Client-Side Render Cost Root Cause + § Task 3) named and fixed the real cause of Web5/Server's revisit-ms regressions — three leaked background pollers (`useFleetData.ts`, `FipsNetworkCard.vue`, `Web5Monitoring.vue`) armed in `onMounted` and never disarmed once their owning views joined `KEEP_ALIVE_PATHS`, gated to activate/deactivate. Web5 now fixed (275ms, below both its 566ms pre-phase-2 baseline and the 300ms pass bar); Server's regression is closed (574ms, below its 738ms baseline) though not yet under the 300ms stretch target — residual named as real, un-eliminated per-resource reactivation cost, not a new defect | +| PERF-03 | Phase 2 | Complete. 02-11 fixed Fleet's leaked `useFleetData.ts` poll (790ms, down from a 2631ms regression, substantially closing the gap to its 330ms baseline). AppDetails restored to at/near its own baseline (1231ms vs. 1204ms) — residual is the already-documented `useCachedResource` per-mount setup cost, not fixed further. Discover (1389ms) has a SECOND, distinct, evidenced cause found this session (CSS entrance-animation replay on KeepAlive reactivation, `card-stagger`/`showStagger` never removed from the DOM) — named with full profiling/diagnostic evidence but NOT fixed (blast radius spans 5+ files outside this plan's scope, needs its own real-device verification budget) — recommended as a dedicated follow-up. OpenWrtGateway: not measurable this pass (Chromium crash cascading from an unrelated surface); prior numbers stand, confirmed to reflect a real (not empty) disconnected-device UI render, not retracted | +| MNODE-01 | Phase 3 | Pending | +| MNODE-02 | Phase 3 | Pending | +| MNODE-03 | Phase 3 | Pending | +| LIFE-01 | Phase 4 | Pending | +| LIFE-02 | Phase 4 | Pending | +| LIFE-03 | Phase 4 | Pending | +| LIFE-04 | Phase 4 | Pending | +| LIFE-05 | Phase 4 | Pending | +| REG-01 | Phase 5 | Pending | +| REG-02 | Phase 5 | Pending | +| SEC-01 | Phase 6 | Pending | +| SEC-02 | Phase 6 | Pending | +| DEV-01 | Phase 7 | Pending | +| DEV-02 | Phase 7 | Pending | +| DEV-03 | Phase 7 | Pending | +| DEV-04 | Phase 7 | Pending | +| MKT-01 | Phase 8 | Pending | +| MKT-02 | Phase 8 | Pending | +| MKT-03 | Phase 8 | Pending | +| MKT-04 | Phase 8 | Pending | +| AIUI-01 | Phase 13 | Pending | +| AIUI-02 | Phase 13 | Pending | +| AIUI-03 | Phase 13 | Pending | +| AIUI-04 | Phase 13 | Pending | +| AIUI-05 | Phase 13 | Pending | +| AIUI-06 | Phase 13 | Pending | + +**Coverage:** + +- v1 requirements: 35 total +- Mapped to phases: 35 +- Unmapped: 0 + +--- +*Requirements defined: 2026-07-29* +*Last updated: 2026-07-29 — added FED (federation/mesh hardening) and PERF (UI performance) requirement groups; phases renumbered after inserting them as Phases 1–2* diff --git a/.planning/RESUME-2026-08-05-appgate-fixes.md b/.planning/RESUME-2026-08-05-appgate-fixes.md new file mode 100644 index 00000000..250900e9 --- /dev/null +++ b/.planning/RESUME-2026-08-05-appgate-fixes.md @@ -0,0 +1,113 @@ +# Resume — 2026-08-05 (app gate, releases .122–.125) + +Paste the block at the bottom into a new session. + +## Where things stand + +- **v1.7.124-alpha is SHIPPED** (signed with the NEW root, published, verified). +- **Signed catalog is LIVE** carrying two hotfixes made after .124: + the repaired bitcoin start script and the fedimint 8175 removal. + Last commit: `4ace62fa`. +- **Release-root rotation is COMPLETE.** .122 was the last release signed with + the old key; .123/.124 and all catalogs use the new one. No override needed. + +## Two bugs I introduced in .124 (both fixed, both instructive) + +1. **Bitcoin vanished from every node.** I put a `#` comment INSIDE the + manifest's folded YAML scalar (`>-`), where `#` is not a comment — it + reaches the shell, and folding joins lines with spaces so it commented out + the `if ... then` while the more-indented `echo` survived, leaving an orphan + `fi`. Container exited instantly; app detection is container-based so the + app disappeared. **Guard added:** `scripts/check-manifest-shell.py` runs + `sh -n` over every embedded manifest script and rejects `#` in these + scalars; wired into `tests/release/run.sh`. +2. **Fedimint crash-looped.** I declared port 8175 on the `fedimint` app so the + gate could name it — but 8175 is served by the separate `archy-fedimint-ui` + companion. The orchestrator then tried to publish 8175 from fedimintd, + collided, and `start_container` failed forever. Removed. **Rule: never + declare a port on an app whose container does not actually serve it.** + +Also: I published an UNSIGNED catalog at one point, which nodes correctly +reject — they silently keep their old cached copy. **Always verify +`'signature' in catalog` on the live URL after publishing.** + +## OPEN TASKS + +1. **indeedhub crash-loop — NOT mine, needs a real fix.** `indeedhub-minio` is + **absent** on `.38` and `.88`, so nginx fails with + `host not found in upstream "minio"` and both `indeedhub` and + `indeedhub-api` exit(1). The stack member never gets created. Look at + `api/rpc/package/stacks.rs` + `dependencies.rs`. +2. **Verify `.38` refetched the signed catalog** and bitcoin-knots starts. + `.88` already did (signed: True, script fixed). +3. **Deploy the .125 build to archi-dev-box for operator confirmation.** + Binary is built at `core/target/release/archipelago` with: app-login page + using the sidebar **A mark** (`favico-black-v2.svg`) not the wordmark; + page pinned to `100svh` + `position:fixed` so mobile stays centred and the + keyboard overlays instead of scrolling; install-version modal icon uses + `object-contain` so non-square icons are not cropped. **Operator has not + seen these yet.** +4. **Cut v1.7.125-alpha** once confirmed. Sign with the **NEW** mnemonic. + +## Traps that cost time today + +- `create-release.sh` says "sign, then re-run" — **re-running regenerates the + manifest and DESTROYS the signature**, and its clean-tree check blocks + anyway. Do steps 7/8 by hand: `git add` version+changelog+manifest → + commit `chore: release vX` → `git tag -a vX` → push main → **push the tag + explicitly** → `git ls-remote --tags` to prove it → `publish-release-assets.sh`. +- The release gate's `cargo-test-weekly` times out on the **compile** after any + version bump. Pre-warm: `CARGO_INCREMENTAL=0 cargo test --manifest-path + core/Cargo.toml -p archipelago --no-run`. +- The frontend version check fails until the in-app **What's New** block for + that version exists (`neode-ui/src/views/settings/AccountInfoSection.vue`) — + that string is what it greps for. +- `generate-app-catalog.py` writes `APP_LAUNCH_PORTS` one-per-line; rustfmt + packs it, so run `cargo fmt` after any catalog sync or the gate fails. +- **Manifest changes reach nodes via the SIGNED CATALOG, not the binary.** A + manifest hotfix needs only a catalog re-sign — no release. + +## Fleet + +SSH: `sshpass -p 'ThisIsWeb54321!' ssh archipelago@` (note the `!`; `@` +is older and still works on some). RPC/node password differs per node — the +`!` one failed RPC login on `.38`. + +- `100.69.68.39` archi-dev-box — dev target +- `100.82.34.38` archipelago-1 +- `100.70.96.88` austin-sapien +- `100.64.204.114` .228 shorty-s — **in real use, treat carefully** + +**Force a catalog refresh on a node:** Settings → App Updates → Check for +updates, or `sudo rm -f /var/lib/archipelago/app-catalog.json && sudo +systemctl restart archipelago`. + +**All fleet nodes were repaired** from `Restart=on-failure` → +`Restart=always`; a node with the old value stays DEAD after an in-process +update (the updater exits cleanly and systemd reads that as success). +`bootstrap::ensure_restart_policy()` now self-heals it. + +--- + +## PASTE THIS INTO THE NEW SESSION + +Resume the archy work from 2026-08-05. Read +`.planning/RESUME-2026-08-05-appgate-fixes.md` and the memory notes +`project_fleet_ota_restart_policy_incident` and +`project_v1_7_121_shipped_appgate` first. + +v1.7.124-alpha is shipped and the signed catalog is live with two hotfixes +(bitcoin start script, fedimint 8175). Four things are open, in order: + +1. Fix the indeedhub crash-loop: `indeedhub-minio` is absent on .38 and .88 so + nginx fails on upstream "minio" and indeedhub + indeedhub-api exit(1). This + one is pre-existing, not from the port work. +2. Verify .38 refetched the signed catalog and bitcoin-knots starts (.88 + already did). +3. Deploy the built .125 binary + frontend to archi-dev-box (100.69.68.39) so + I can confirm the app-login page (A mark, mobile centring, keyboard + behaviour) and the install-modal icon. +4. Then cut v1.7.125-alpha — I sign with the new mnemonic. + +Do not re-run create-release.sh after signing; it destroys the signature — +do the commit/tag/publish steps by hand as the resume doc describes. diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md new file mode 100644 index 00000000..5b42df9f --- /dev/null +++ b/.planning/ROADMAP.md @@ -0,0 +1,395 @@ +# Roadmap: Archipelago — v1.8.0 Developer-Ready App Platform + +## Overview + +Brownfield milestone starting from a green single-node production gate (5/5 on .228, +2026-06-23). The journey: make federation and mesh rock-solid (node removal, sync, +messaging parity), fix the UI slowness users feel on every tab switch, prove the platform +across the fleet (multinode pass), make the container lifecycle bulletproof (Quadlet +default, self-healing, honest progress, no ghosts), flip manifest distribution from OTA +disk files to the signed registry, harden manifest security enforcement to the full +ADR-009 bar, ship the `archy app` developer CLI, and land the decentralized Nostr +marketplace — ending at the north star: a third-party developer publishes an app via the +signed/decentralized registry and a user installs it on their node. + +## Phases + +**Phase Numbering:** + +- Integer phases (1, 2, 3): Planned milestone work +- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED) + +- [ ] **Phase 1: Federation & Mesh Hardening** - Deep review of federation/fleet + mesh code; node removal sticks, sync converges, mesh messaging behaves identically on demo and real nodes +- [x] **Phase 2: UI Performance** - Tab switches and secondary screens render fast; worst transitions measured and fixed (completed 2026-07-31) +- [ ] **Phase 3: Multinode Verification Pass** - Lifecycle gate green on a second node; cross-node federation/mesh/transport suites pass; federation removal sticks +- [ ] **Phase 4: Lifecycle Perfection & Quadlet Default** - Quadlet backends default, failed-unit self-healing, flap observability, cascade gate, truthful progress +- [ ] **Phase 5: Registry-Distributed Manifests** - Signed catalog carries full manifests; fleet flipped off OTA disk-file distribution +- [ ] **Phase 6: Manifest Security Enforcement** - Validation matches ADR-009 mandates; generated security profiles actually applied +- [ ] **Phase 7: Developer Tooling CLI** - `archy app validate/render/local-install/lifecycle-test` + developer guide +- [ ] **Phase 8: Decentralized Marketplace** - DID-signed publish to Nostr relays, trust-tier discovery, verified third-party install end-to-end +- [ ] **Phase 9: BotFights Platform Upgrade** - Native nostr signer login, one self-contained AI bot-setup prompt, shared public VPS2 match endpoint so all nodes see all fighters, registry updated +- [ ] **Phase 12: Bitcoin Node Settings & Core/Knots Parity** - Every bitcoind option reachable in the UI, Knots-only options gated to Knots, network mode a setting defaulting to Tor +- [ ] **Phase 13: AIUI — Conversational Node Control & Content Surfaces** - Human-language node control and settings in AIUI chat, its designed content surfaces wired to real peer/music/movie data, all inside a user-granted capability sandbox that keeps keys and secrets away from the browser and the model + +## Phase Details + +### Phase 1: Federation & Mesh Hardening + +**Goal**: Federation and mesh are tight — a structured review of the fleet/federation and mesh code feeds fixes so node removal sticks, sync converges, and mesh messaging (including attachments) behaves identically everywhere it runs +**Depends on**: Nothing (first phase) +**Requirements**: FED-01, FED-02, FED-03, FED-04, FED-05, FED-06, FED-07, UIFIX-01, UIFIX-02, UIFIX-03, UIFIX-04, UIFIX-05, UIFIX-06, FED-08, FED-09 +**Success Criteria** (what must be TRUE): + + 1. A structured code review of the federation/fleet area (`core/archipelago/src/federation`, node sync, FIPS/transport dial layer) and the mesh area (`core/archipelago/src/mesh`, mesh RPC surface) produces a findings list, and every finding is fixed or explicitly deferred with a reason + 2. Removing a federation node removes it everywhere — it disappears from all UI surfaces, tombstones propagate, and it never reappears after later sync cycles; a failed removal surfaces an error instead of silently no-opping + 3. Federation sync converges: after sync settles, fleet nodes agree on the node list and node status is fresh — stale entries, duplicates, and silent sync failures are gone, and sync errors are visible to the operator + 4. Mesh attachment send works identically on the demo and on real nodes — same modals, same transport decisions, same success — with the demo backend implementing the same RPC surface the UI calls (no "Method not found", no demo-only chooser modal) + 5. Channel-opening between nodes is first-class UI: a user can copy/share their node's Lightning URI; sees a list of trusted (federated) nodes by hostname to open a channel with in one flow; and can browse/request channels with public nodes — built with the existing design system (Teleport-to-body modals, house style), tested live on the :8100 dev preview against archi-dev, and fixed there before any deploy + 6. The invoice/payment "paid" success animation is on-brand: the tick's circle is the screensaver-style ring with the outer EQ-segment lines (reuse `neode-ui/src/components/ScreensaverRing.vue`, which already ships a `compact` overlay size), replacing the current burst in the payment success pane (`neode-ui/src/components/SendBitcoinModal.vue`) and matching wherever else the paid tick appears + 7. Fedimint gateway installs have no pre-set password (BLOCKER, added 2026-07-30): a fresh install generates its gateway credentials per-install via manifest-declared `generated_secrets` (per the repo secrets invariant) or requires the user to set one — never a baked-in default; existing installs carrying the default password are migrated or flagged. NOTE: phase 1's 10 plans predate this criterion — an additional gap plan is required before phase 1 execution completes + 8. The FIPS/Tor pills on cloud files are kept and visible at mobile widths (UIFIX-01, BLOCKER, added 2026-07-30 — see `.planning/todos/pending/2026-07-30-keep-fips-tor-pills-on-cloud-files-and-show-them-on-mobile.md`) + 9. The connected-nodes list scrolls at row-matched height instead of growing to fit (UIFIX-02, BLOCKER, added 2026-07-30 — see `.planning/todos/pending/2026-07-30-connected-nodes-list-must-scroll-at-row-matched-height.md`) + 10. The onboarding tickbox is discoverably visible on short viewports via an on-brand affordance (UIFIX-03, BLOCKER, added 2026-07-30 — see `.planning/todos/pending/2026-07-30-onboarding-tickbox-hidden-below-fold-on-short-screens.md`) + 11. Paid Files pictures open in the app lightbox, not a browser tab (UIFIX-04, added 2026-07-30 — see `.planning/todos/pending/2026-07-30-peer-files-pictures-open-in-tab-not-lightbox.md`) + 12. Picture-in-picture closes the lightbox with a fluid on-brand animation (UIFIX-05, added 2026-07-30 — see `.planning/todos/pending/2026-07-30-pip-should-close-lightbox-with-fluid-animation.md`) + 13. Genuinely slow opens show loader states (UIFIX-06, added 2026-07-30 — see `.planning/todos/pending/2026-07-30-missing-loader-states-on-slow-opens.md`; 02-08's flagged timing regressions are the starting inventory) + NOTE for criteria 7–13: all were added after phase 1's 10 plans were written — before phase 1 execution completes, create gap plan(s) covering FED-07 + UIFIX-01..06 (existing desktop visuals must remain untouched per the standing visual-invisibility rule; UIFIX items themselves are user-approved visual changes) + +**Plans**: 11/20 plans executed + +Plans: + +- [x] 01-20-PLAN.md — URGENT wave 1: doctor stops restarting Tor every 5min (mesh Tor fallback) (FED-09) +- [x] 01-19-PLAN.md — URGENT wave 1: wallet invoices embed route hints so private-channel nodes can receive (FED-08) +- [x] 01-01-PLAN.md — Serialize the federation node store and make removal stick (FED-01) +- [x] 01-02-PLAN.md — Demo mesh/federation RPC parity + automated parity harness (FED-04) +- [x] 01-03-PLAN.md — On-brand paid tick: ScreensaverRing badge variant on both success surfaces (FED-06) +- [ ] 01-04-PLAN.md — Lightning identity: own-node URI + meshed Lightning peer discovery (FED-05) +- [ ] 01-05-PLAN.md — Federation sync convergence and operator-visible sync errors (FED-02) +- [ ] 01-06-PLAN.md — Lightning URI on the federation sync payload, sharing default decided (FED-05) +- [ ] 01-07-PLAN.md — Channel-open request messaging over the mesh (FED-05) +- [ ] 01-08-PLAN.md — Channel-open UX: own URI, trusted-node picker, meshed-peer requests (FED-05) +- [ ] 01-09-PLAN.md — Structured federation/mesh review + dev-pair deploy (FED-03) +- [ ] 01-10-PLAN.md — Consolidated phase verification on the dev pair (FED-01/02/05/06) + +**Wave 7** *(gap closure — criteria 7–13, added 2026-07-30 after the original 10 plans were written)* + +- [x] 01-11-PLAN.md — No baked-in Fedimint gateway credential: per-install secret on every path (FED-07) +- [x] 01-12-PLAN.md — Connected-nodes list scrolls at row-matched height instead of growing (UIFIX-02) +- [x] 01-13-PLAN.md — On-brand scroll cue makes the onboarding tickbox findable on short screens (UIFIX-03) +- [x] 01-14-PLAN.md — Paid Files open in the app lightbox, with a visible wait and a real error path (UIFIX-04/06) +- [x] 01-15-PLAN.md — PiP hands off from the lightbox and survives tab changes and buffering (UIFIX-05) + +**Wave 8** *(blocked on Wave 7 completion)* + +- [x] 01-16-PLAN.md — Migrate existing installs off the default gateway credential, data preserved (FED-07) +- [ ] 01-17-PLAN.md — FIPS/Tor pills pinned against removal and readable at phone widths (UIFIX-01) + +**Wave 9** *(blocked on Wave 8 completion)* + +- [ ] 01-18-PLAN.md — Six-fix sign-off on archi-dev-box (UIFIX-01/02/03/04/05/06) + +**UI hint**: yes + +### Phase 2: UI Performance + +**Goal**: The UI feels fast — switching tabs and opening secondary screens (screens reached from a tab's main page) renders promptly instead of stalling on refetches and remounts +**Depends on**: Nothing (frontend-focused; parallelizable with Phase 1) +**Requirements**: PERF-01, PERF-02, PERF-03 +**Success Criteria** (what must be TRUE): + + 1. The slowest tab switches and secondary-screen opens are profiled and the causes named (remount storms, serial RPC waterfalls, uncached fetches) before fixes land + 2. Switching between main tabs renders the target view immediately from cached state, refreshing data in the background — no blank screens or long spinners on tabs already visited this session + 3. Secondary screens open without a blocking full reload; repeat visits are instant + 4. The fixes are verified on real node hardware (not just the dev box) — the sluggishness the user reported is gone on-device + +**Plans**: 11/11 plans executed + +Plans: +**Wave 1** + +- [x] 02-01-PLAN.md — Profile every D-09 surface on archi-dev-box and commit the findings doc (PERF-01) + +**Wave 2** *(blocked on Wave 1 completion)* + +- [x] 02-02-PLAN.md — TRACER: KeepAlive host, hook reactivation, app-store tab, refresh indicator (PERF-02) +- [x] 02-03-PLAN.md — Secondary screens: per-item cache, parallel loads, purge on logout (PERF-03) + +**Wave 3** *(blocked on Wave 2 completion)* + +- [x] 02-04-PLAN.md — Keep every main tab alive safely: lifecycle audit + full registration (PERF-02) + +**Wave 4** *(blocked on Wave 3 completion)* + +- [x] 02-05-PLAN.md — Mesh: cache the six fetch groups, bound the D3 graph and Leaflet map (PERF-02) +- [x] 02-06-PLAN.md — Server and Home: cache the uncached fan-out, guarantee wallet freshness (PERF-02) +- [x] 02-07-PLAN.md — Chat/AIUI: stable embed URL + the two D-14 UX defaults (PERF-02) + +**Wave 5** *(blocked on Wave 4 completion)* + +- [x] 02-08-PLAN.md — Dev-pair deploy, on-device re-measure, D-11 pass bar (PERF-01/02/03) + +**Wave 6** *(gap closure — blocked on Wave 5 completion)* + +- [x] 02-09-PLAN.md — Server.vue KeepAlive remount: name the cause, fix it, pin it (PERF-02) + +**Wave 7** *(gap closure — blocked on Wave 6 completion)* + +- [x] 02-10-PLAN.md — Timing-regression verdict: three-way re-measure, clear or name each surface (PERF-02/03) + +**Wave 8** *(gap closure — blocked on Wave 7 completion)* + +- [x] 02-11-PLAN.md — Profile the real cause of the six confirmed regressions, fix what's fixable, re-measure (PERF-02/03) + +**UI hint**: yes + +### Phase 3: Multinode Verification Pass + +**Goal**: The platform's lifecycle and federation guarantees are proven across the fleet, not just on .228 — the declared next exit criterion +**Depends on**: Phase 1 (proves the federation/mesh fixes hold fleet-wide) +**Requirements**: MNODE-01, MNODE-02, MNODE-03 +**Success Criteria** (what must be TRUE): + + 1. The 5× destructive lifecycle gate reports 0 failures on a second fleet node (archy-x250-beta), run on-node + 2. The cross-node smoke suite (federation pairing both directions, FIPS anchors, peer content browse) passes between two fleet nodes with every harness RPC time-bounded — a slow node produces a test failure, never an indefinite hang + 3. An operator who removes a federation peer never sees it reappear in the peer list after later sync cycles; a tombstone-write failure is surfaced as an error instead of silently swallowed + 4. The on-air mesh suite passes between two radio-equipped nodes over real RF + +**Plans**: TBD + +### Phase 4: Lifecycle Perfection & Quadlet Default + +**Goal**: An insanely-reliable container environment — every app installs, runs, restarts, uninstalls, and reinstalls cleanly with honest progress, no ghosts, and automatic recovery +**Depends on**: Phase 3 (Quadlet default-flip is gated on the second-node gate reporting clean) +**Requirements**: LIFE-01, LIFE-02, LIFE-03, LIFE-04, LIFE-05 +**Success Criteria** (what must be TRUE): + + 1. Restarting `archipelago.service` on a fleet node leaves every app container running — no SIGKILL-the-world, no multi-minute reconciler rebuild + 2. An app whose Quadlet unit enters `failed` state (and was not user-stopped) comes back automatically within a bounded window, with backoff on persistent failure — no operator intervention + 3. An operator can see per-app restart counts in status output, and a flapping app (>N restarts in M minutes) is flagged in logs instead of being invisible + 4. Uninstalling then reinstalling any gated app — including multi-container stacks like immich/btcpay — leaves no ghost My-Apps entries or orphan containers, preserves data per policy, and returns the app healthy, verified by the cascade gate tier + 5. Install and uninstall progress bars move monotonically from real backend progress events and always land on a terminal success/failure state — asserted in the gate, and the single-node gate stays green after all orchestrator changes + +**Plans**: TBD +**UI hint**: yes + +### Phase 5: Registry-Distributed Manifests + +**Goal**: Manifests ship via the signed registry, not OTA disk files — bumping or adding an app becomes a signed catalog change +**Depends on**: Phase 4 (fleet lifecycle stable under Quadlet default before changing the distribution channel) +**Requirements**: REG-01, REG-02 +**Success Criteria** (what must be TRUE): + + 1. A fleet node installs and updates an image-only app from the full manifest embedded in the signed catalog, verified against the pinned release-root key, with no corresponding OTA disk file present (disk remains the fallback for build-source apps) + 2. A tampered or unsigned catalog manifest is rejected and the node falls back safely — it never installs from an unverified manifest + 3. Bumping an app version fleet-wide requires only regenerating, re-signing, and publishing the catalog — no binary OTA, no disk rsync — proven live on the fleet + +**Plans**: TBD + +### Phase 6: Manifest Security Enforcement + +**Goal**: A third-party manifest cannot weaken node security — declared security policy is fully validated and actually enforced at runtime +**Depends on**: Phase 5 (enforcement guards the registry channel third-party manifests will arrive through) +**Requirements**: SEC-01, SEC-02 +**Success Criteria** (what must be TRUE): + + 1. A manifest violating ADR-009 mandates (root user, unpinned `latest` tag, capability outside the allow-list, disabled seccomp) is rejected at validation with a clear error naming the violation + 2. Security overrides (`readonly_root: false`, extra capabilities) work only when explicitly listed in the manifest and leave an audit trail + 3. Generated AppArmor/seccomp profiles are applied to containers at creation and verifiably effective on a running app — not just generated and ignored + 4. The single-node lifecycle gate stays green with enforcement on — existing catalog apps all pass the strengthened validation (or carry documented overrides) + +**Plans**: TBD + +### Phase 7: Developer Tooling CLI + +**Goal**: A third-party developer can build, validate, and test an Archipelago app locally without reading platform internals +**Depends on**: Phase 6 (CLI validation must mirror the final enforced rule set) +**Requirements**: DEV-01, DEV-02, DEV-03, DEV-04 +**Success Criteria** (what must be TRUE): + + 1. A developer runs `archy app validate` on a manifest directory and gets the same pass/fail verdict — including security rules — that a node would enforce at install + 2. A developer runs `archy app render` and sees the exact Quadlet/podman configuration their manifest produces before ever touching a node + 3. A developer can install their app onto a dev node and run its lifecycle test (install/UI/stop/start/restart/uninstall) from the CLI + 4. A new developer following only the developer guide goes from an empty directory to a running app on a node — no tribal knowledge required + +**Plans**: TBD + +### Phase 8: Decentralized Marketplace + +**Goal**: The north star — third-party developers publish apps via the decentralized registry and users install them on their nodes +**Depends on**: Phase 7 (publish rides the CLI; installs ride registry distribution from Phase 5 and enforcement from Phase 6) +**Requirements**: MKT-01, MKT-02, MKT-03, MKT-04 +**Success Criteria** (what must be TRUE): + + 1. A third-party developer publishes a DID-signed app manifest to public Nostr relays (NIP-78, kind 30078) using the tooling + 2. A node discovers the published app from multiple relays and the app store UI shows its trust tier (Verified / Community / Unverified) per ADR-006 scoring + 3. The node verifies the manifest signature before installation; a tampered or invalid marketplace manifest cannot be installed + 4. A user installs the third-party marketplace-published app on their node and it runs healthy under the standard lifecycle guarantees — the user-chosen success metric, demonstrated end-to-end + +**Plans**: TBD +**UI hint**: yes + +## Progress + +**Execution Order:** +Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 +(Phases 1 and 2 are independent and may be worked in parallel.) + +| Phase | Plans Complete | Status | Completed | +|-------|----------------|--------|-----------| +| 1. Federation & Mesh Hardening | 11/20 | In Progress| | +| 2. UI Performance | 11/12 | Complete | 2026-07-31 | +| 3. Multinode Verification Pass | 0/TBD | Not started | - | +| 4. Lifecycle Perfection & Quadlet Default | 0/TBD | Not started | - | +| 5. Registry-Distributed Manifests | 0/TBD | Not started | - | +| 6. Manifest Security Enforcement | 0/TBD | Not started | - | +| 7. Developer Tooling CLI | 0/TBD | Not started | - | +| 8. Decentralized Marketplace | 0/TBD | Not started | - | +| 9. BotFights Platform Upgrade | 7/7 | Executed — awaiting human demo verification | 2026-07-31 | +| 10. Key-Material Hardening | 0/5 | Planned — **priority override, see phase note** | - | +| 11. Wallet Experience & LND UI Parity | 0/TBD | Not started — gated on 10-05's watch-only verdict | - | + +### Phase 9: BotFights Platform Upgrade + +**Goal:** BotFights (app + registry) works great on every node: users sign in with the native nostr signer, a single self-contained AI prompt sets up their bot (replacing the confusing docs page), and every node's instance talks to a shared public match endpoint on VPS2 so all fighters are visible and battle across all nodes. +**Requirements**: BOT-01 native nostr signer login; BOT-02 unified AI bot-setup prompt (one copy-paste prompt, no doc-hopping); BOT-03 public shared match/fighter endpoint hosted on VPS2, node instances federate to it by default; BOT-04 registry/manifest + signed catalog updated and republished for the new version +**Depends on:** Nothing (independent app work; parallelizable with Phases 1–8) +**Plans:** 7 plans + +Plans: + +- [x] 09-01-PLAN.md — Arena reverse-proxy tracer: node instances become thin clients of one shared arena (BOT-03) +- [x] 09-02-PLAN.md — Finish native nostr signer login: JWT-only GET /api/auth/me, bare-pubkey path retired (BOT-01) +- [x] 09-03-PLAN.md — One self-contained AI bot-setup prompt served at /api/docs/prompt (BOT-02) +- [x] 09-04-PLAN.md — Canonical public arena on VPS2 + DNS/TLS via nginx-proxy-manager (BOT-03) +- [x] 09-05-PLAN.md — Build+push botfights:1.2.0, roll the arena, prove cross-instance visibility (BOT-03/BOT-04) +- [x] 09-06-PLAN.md — Manifest 1.2.0 with generated JWT secret + signed catalog republished (BOT-04) +- [x] 09-07-PLAN.md — archi-dev-box deploy + demo rehearsal: real signer login, cloud bot from the prompt (BOT-01/02/03/04) + +### Phase 10: Key-Material Hardening + +**Goal:** Every path that creates, restores, or persists node key material proves the caller is authorized and the material is per-node — closing the three exploitable findings from `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`. A node that is already onboarded must refuse to have its identity replaced; a node flashed from the shared rootfs must never share another node's host keys; and the wallet spending key must not exist in cleartext outside the encrypted envelope. +**Requirements**: KEY-01 (F-01, **Critical**) `seed.generate`/`seed.restore` are unauthenticated (`api/rpc/middleware.rs:25`) and `NodeIdentity::from_seed` (`identity.rs:79`) overwrites `node_key`/`nostr_secret`/FIPS key unconditionally — one unauthenticated POST with an attacker-chosen mnemonic hijacks a live node; gate on onboarding-incomplete (the unused `identity.rs:117` `key_exists` guard) + rate-limit; KEY-02 (F-03, **High**) first-boot per-device secret regeneration is fail-open and its completion marker is set even on failure (`image-recipe/_archived/build-auto-installer-iso.sh:1647,:1659,:1663`), over a fleet-shared cached rootfs that bakes SSH host keys + the TLS key — make it fail-closed and retried; KEY-03 (F-13, **High**) the BIP-84 account **private** key is imported into Bitcoin Core's wallet (`api/rpc/bitcoin.rs:203,:229-231`), duplicating the spending key outside the encrypted envelope — move to watch-only descriptors per `docs/security/PSBT-SIGNING-ARCHITECTURE.md`; KEY-04 on-node verification of C-3/C-4/C-6 from the audit's UNVERIFIED checklist (host-key uniqueness across two real nodes, rootfs tar contents on the build host, unauthenticated LAN reachability of the RPC endpoint); KEY-05 (F-10a, **Medium**, added 2026-08-02) **a defaulted RNG cannot be inherited anywhere in the crate**. The audit's F-10 recorded this as 2 call sites; it is **41 raw matches across 15 files** (`session.rs` 16 → 4 prod + 12 test, `pine_ha.rs` 6, `wallet/bdhke.rs` 4 → 2 prod — *Cashu proof secret + blinding factor, genuine key material*, `storage_crypto.rs` 1 — *AEAD nonce*, `mesh/x3dh.rs` 2 — *prekey identifiers, **not** key material, corrected 2026-08-02*, +10 more; full table in the audit's §F-10a. Per-site prod/test classification is KEY-05's Task 1, not an assumption). Nothing is broken today — `rand::random()`/`thread_rng()` are ChaCha12 seeded from `getrandom(2)` — but this is the exact T1 structural shape that produced the 2026-07-30 COLDCARD defect, now with key material in its blast radius. Five layers, all required: (a) **sealed allowlist trait** at key-generation seams (private supertrait, so no other module *or crate* can implement it; exactly one production impl, `OsRng`) — this also retires the `impl rand::CryptoRng for CountingRng` false promise at `seed.rs:656`; (b) **`clippy.toml` `disallowed-methods`** banning `rand::thread_rng`/`rand::random` crate-wide, so enforcement is a compile failure in CI rather than a review convention (no `clippy.toml` exists today; CI already runs clippy); (c) **`cargo-deny`** failing on duplicate `rand` majors — two coexist today, which is the mechanism by which a bump could silently rebind (absorbs R-05); (d) **degenerate-entropy runtime check** before key generation (rejects all-zero / counter-like draws — the one layer that would catch the Coldcard failure *on the device* rather than in review); (e) **persist the CSPRNG-readiness verdict** that `seed.rs:59` already computes and discards, so a node can answer after the fact "was the pool seeded when this key was born?" (absorbs R-09). Supersedes R-13 +**Depends on:** Nothing (independent security work; parallelizable with Phases 1–8). **Priority override: F-01 is Critical and live on every fleet node — this phase should be planned and executed ahead of its numeric position, which reflects append order in a shared roadmap, not sequencing.** +**Plans:** 6 plans + +> **EXECUTION GATE (user instruction, 2026-08-02):** do **not** begin executing this phase until +> (a) the concurrent agent working Phase 1 has finished, and (b) their changes are synced and +> accounted for. Rationale: Phase 10 edits `middleware.rs`, `identity.rs`, `seed_rpc.rs`, +> `bitcoin.rs` and — under KEY-05 — ~15 further files across the same crate that agent is +> actively committing to. Verify a clean tree and a fetched `gitea-ai/main` before starting. +> +> **KEY-05 is planned** as `10-06` (added 2026-08-02). The other 5 plans predate KEY-05 and +> are unchanged by it. `10-06` is wave 2 because it shares `seed.rs` with `10-05` and +> `api/rpc/auth.rs` with `10-01`; see its ``. + +Plans: + +**Wave 1** *(parallel — no shared files)* + +- [ ] 10-01-PLAN.md — Identity-mutating unauthenticated RPCs hard-refuse on a provisioned node, with the byte-identity regression suite (KEY-01) +- [ ] 10-03-PLAN.md — First-boot secret regeneration retries then fails closed, and the rootfs tar ships identity-free (KEY-02/KEY-04 C-4) +- [ ] 10-05-PLAN.md — Delete the Bitcoin Core xprv-import path; make LND's PSBT round trip first-class, tested and honestly documented (KEY-03) + +**Wave 2** *(each blocked on its wave-1 sibling)* + +- [ ] 10-02-PLAN.md — On-node C-6 exposure measurement, live refusal proof, and the fresh-node onboarding non-regression (KEY-01/KEY-04) — depends on 10-01 +- [ ] 10-04-PLAN.md — Fleet detection of image-baked host secrets, guarded one-time rotation, and C-3 two-node verification (KEY-02/KEY-04) — depends on 10-03 +- [ ] 10-06-PLAN.md — A defaulted RNG cannot be inherited anywhere in the crate: sealed allowlist, clippy ban, cargo-deny, degenerate-entropy check, persisted CSPRNG verdict (KEY-05) — depends on 10-01 and 10-05 + +### Phase 11: Wallet Experience & LND UI Parity + +**Goal:** The wallet is something a user chooses and understands, not something that just appears. A first-run wallet screen lets them pick a wallet type and route accordingly; seed handling reuses the SeedQR + seed-words patterns already shipped; and the day-to-day Lightning interface offers what umbrelOS's LND UI offers, so nothing is missing for someone arriving from Umbrel. +**Requirements**: WALLET-01 first-run wallet-type chooser (an intro/initial screen presenting the available wallet types with plain-language trade-offs, routing into the matching setup flow) — the available types depend on Phase 10's `10-05` watch-only verdict, so this requirement is **gated on that evidence**, not on assumption; WALLET-02 seed handling in the wallet flow reuses the existing SeedQR + seed-words components rather than reimplementing them (`neode-ui/src/utils/seedqr.ts`, `OnboardingSeedGenerate.vue`, `SeedRevealPanel.vue`, `WalletScanModal.vue`) — including the standing constraint that the LND aezeed is text-only by design and has no SeedQR; WALLET-03 evidence-based umbrelOS LND UI parity — produce a feature-by-feature comparison matrix from the actual Umbrel interface (researched, not assumed), classify each row as already-shipped / gap / deliberately-not-wanted, and close the gaps worth closing; WALLET-04 the resulting interface is house-style (Teleport-to-body modals, existing design system) and verified on the :8100 dev preview against archi-dev before any deploy; WALLET-05 **the PSBT air-gap round trip is a real, usable flow** — the standard two-scan dance (node displays the unsigned PSBT as an animated QR → offline signer scans and signs → signer displays the signed PSBT → node scans it back with the camera → finalize + broadcast). Three sub-gaps, all verified 2026-08-01: (a) **no UI exists** — `lnd.create-psbt`/`lnd.finalize-psbt` and their `rpc-client.ts:417` wrappers are called by nothing but unit tests; (b) **no animated-QR encoder** — `qrcode`/`qrloop` are dependencies and `useAnimatedQRDecoder.ts` + `WalletScanModal.vue` already handle the *inbound* scan, but nothing encodes a PSBT for display; (c) **format interop is wrong for real signers** — the animated format in use is `qrloop` (Ledger's), while Passport/SeedSigner speak **BC-UR** (`ur:crypto-psbt`) and Coldcard Q speaks **BBQr**; BC-UR is the priority given the existing Passport-Prime-compatible SeedQR work. **WALLET-05 is meaningless until 10-05's watch-only verdict lands** — `lnd.create-psbt` funds from LND's own wallet whose keys LND holds, so until LND is watch-only against the external signer the offline device would produce a signature the node does not need +**Depends on:** **Phase 10** — specifically `10-05`, which produces the evidence-backed verdict on whether LND can be provisioned watch-only against an external signer. WALLET-01's list of offerable wallet types is a direct consequence of that verdict; building the chooser first would mean guessing at what it can offer. `10-05` also deletes the dead Core wallet path, so this phase never has to represent it in the UI. +**Plans:** 0 plans + +**Already shipped — do not rebuild (verified 2026-08-01):** `LightningChannelsPanel.vue`, `SendBitcoinModal.vue`, `ReceiveBitcoinModal.vue`, `WalletScanModal.vue`, `WalletSettingsModal.vue`, `SeedRevealPanel.vue`, `LndSeedBackupPrompt.vue`, `utils/seedqr.ts`, and the channels All/Active/Pending/Closed tabs. The parity matrix (WALLET-03) must start from this inventory so the phase closes real gaps instead of re-implementing existing surfaces. + +Plans: + +- [ ] TBD (run /gsd-plan-phase 11 to break down) + +### Phase 12: Bitcoin Node Settings & Core/Knots Parity + +**Goal:** The Bitcoin node's configuration is something the operator chooses in the UI, not something baked into three shell scripts. Every option umbrelOS surfaces for its Bitcoin app is reachable, the options that exist **only** on Knots are surfaced separately from the ones Core shares, and the node's network mode is a first-class setting whose **default is Tor, not clearnet**. + +**Requirements**: BTCSET-01 **a single source of truth for bitcoind arguments** — today they are hardcoded and duplicated across `scripts/first-boot-containers.sh:666`, `scripts/container-specs.sh:193-202` and `apps/bitcoin-knots/manifest.yml:43`, which is the exact triplication that produced the lnd-ui bridge/host defect (`HTTP 000`, found 2026-08-02); a persisted settings model must replace it, with those three call sites rendering FROM it rather than restating it; BTCSET-02 **network mode is a setting, defaulting to Tor** — Tor / clearnet / both, wired to the archy-net SOCKS listener shipped in `f0494193` via `-onion=:9050` (onion-only) or `-proxy=` (everything), with the operator's 2026-08-02 choice of onion-only as the shipped default for the "both" mode; **inbound onion is out of scope and must be stated as such in the UI** — it needs Tor's ControlPort, deliberately disabled for security, so the node can reach .onion peers but stays unlisted; BTCSET-03 **Core options surfaced** (prune, dbcache, txindex, maxconnections, maxmempool, mempoolexpiry, persistmempool, blocksonly, peerbloomfilters, blockfilterindex, and the rest of the umbrelOS set, researched from `getumbrel/umbrel-bitcoin` rather than assumed); BTCSET-04 **Knots-only options surfaced separately and gated to Knots** (`datacarrier`, `datacarriersize`, `permitbaremultisig`, `rejectparasites`, `maxscriptsize`, the spam-filter family) — offering a Knots-only flag on Core would produce a node that refuses to start, so the gate is a correctness requirement, not a cosmetic one; BTCSET-05 house-style UI verified on the `:8100` dev preview against archi-dev before any deploy, mobile included. + +**The hazard this phase must not get wrong:** several of these options are **not freely reversible**. Turning `txindex` on forces a full reindex; turning `prune` on is destructive to block data and cannot be undone without a full resync; lowering `prune` below what is already pruned is meaningless. Any setting in that class must be labelled, confirmed, and — where it implies hours of resync on a node that is somebody's wallet backend — refused or gated rather than silently applied. Changing any option at all requires a bitcoind restart, which interrupts LND, electrs and the fedimint gateways that depend on it. + +**Depends on:** `f0494193` (the archy-net SOCKS listener) for BTCSET-02's Tor path to exist at all. Independent of Phases 1–11 otherwise. + +**Plans:** 0 plans + +Plans: + +- [ ] TBD (run /gsd-plan-phase 12 to break down) + +### Phase 13: AIUI — Conversational Node Control & Content Surfaces + +**Goal:** AIUI stops being a beautiful shell and becomes the node's conversational front door. Today it is embedded in `neode-ui/src/views/Chat.vue` as an iframe, its D-14 embed defaults are honoured, and its surfaces are designed — but the chat cannot *do* anything to the node, and the content views are not wired to real data. This phase makes it functional in three directions at once: (1) **ask the node in human language and have it act** — the capability Pine already demonstrates through voice becomes reachable from typed chat; (2) **talk to the system's settings** conversationally instead of hunting through screens; (3) **surface the node's content beautifully** — peer files, music, IndeeHub movies, owned/paid content — in the design AIUI already has but does not yet fill. + +**Requirements**: AIUI-01, AIUI-02, AIUI-03, AIUI-04, AIUI-05, AIUI-06 + +**Requirement detail**: +- **AIUI-01 — human-language node control.** A typed request in AIUI chat ("restart bitcoin", "how much space is left", "who's connected") reaches a real node action and returns a real result. The Pine stack (`core/archipelago/src/api/rpc/pine_status.rs`, `.../package/pine_ha.rs`, the wyoming/Home-Assistant voice pipeline) already proves the intent→action path exists for voice; this requirement is about exposing that capability over a **permissioned tool-calling bridge** the browser can reach — not about handing the chat raw RPC. Whether a text entry point exists today or must be built is the first thing the phase research must settle. +- **AIUI-02 — conversational settings.** The system settings surfaced across neode-ui become reachable by conversation, scoped to what the user has granted. +- **AIUI-03 — content surfaces made real.** AIUI's designed-but-empty content views render live node data: **peer files** (the `/content`, `/content/`, `/api/peer-content//` subsystem and the `content.*` RPCs), **music** (today only a MIME branch and a hardcoded `Music` folder — there is no library domain, so scope must be honest about what "music" means here), **IndeeHub movies**, and owned/paid content. Playback must respect the existing rules: audio belongs to the global bottom-bar player, never the lightbox; media streams via Range requests, never base64 blobs. +- **AIUI-04 — sandboxed by construction, permissioned by the user.** *(see hazard below — this is the gating requirement, not a nice-to-have)* +- **AIUI-05 — delivery and build.** AIUI is a `*-ui` app outside the signed catalog; it reaches nodes on the frontend rsync, which is how the `/assets` 404 happened (fixed in `fbec7006`). A functional AIUI needs a delivery path an operator can actually receive updates through, and the `VITE_BASE_PATH=/aiui/` build requirement pinned so a hand-built bundle cannot ship a black page. +- **AIUI-06 — verified on device**, in the real embedded iframe on archi-dev-box, mobile included — not only in the local `dev:mock` loop. + +**The hazard this phase must not get wrong — an LLM is now touching a node that holds keys.** AIUI runs in the browser and talks to a model. The node holds wallet keys, LND macaroons, Fedimint credentials, node identity and per-app secrets, and Phase 10 is currently hardening exactly that material. So: **secrets never reach the browser or the model context** — the existing pattern where credentials stay server-side and the client gets a scoped token (`app.filebrowser-token`) is the model to follow, not an exception to it. The chat gets an **explicit, user-granted capability scope** — it can reach only what the user has allowed, defaults closed, and the grant is visible and revocable. **Destructive and identity-touching operations are confirmed by the human**, never executed on model say-so alone; the Phase-10 hard-refuse gates and the loopback/auth boundaries must hold with AIUI on the other side of them, not be widened to accommodate it. Prompt injection is in the threat model: peer-supplied content (filenames, descriptions, chat) will enter the model's context, so tool authority must not be derivable from anything a peer controls. Note also the known leak to resolve rather than propagate: `filebrowser-client.ts` puts a JWT in the media URL query string. + +**Depends on:** Independent of Phases 1–12 for its UI and content work. Its security model must not contradict Phase 10 (Key-Material Hardening) — coordinate rather than widen. AIUI's own source lives in a **separate repository** (`git.tx1138.com/lfg2025/AIUI`, branch `development`, cloned at `~/Projects/AIUI`), so this phase spans two repos and needs push access to both. + +**Plans:** 15 plans in 8 waves + +Plans: + +**Wave 1** *(tracer + the two independent security/spike tracks)* + +- [ ] 13-01-PLAN.md — TRACER: typed AIUI chat reaches a real node tool and returns a real result (AIUI-01) +- [ ] 13-02-PLAN.md — Close the live unauthenticated model proxies: session-gated Rust forwarder, port-3142 sidecar deleted (AIUI-04) +- [ ] 13-03-PLAN.md — Routstr protocol spike + capability coverage matrix (AIUI-01) + +**Wave 2** + +- [ ] 13-04-PLAN.md — Music library: one-way entity-model decision + lofty legitimacy gate + tag extraction (AIUI-03) +- [ ] 13-05-PLAN.md — Curated tool registry, D-09 authority ceiling, D-16 default-closed grants, conversational settings (AIUI-01/02) +- [ ] 13-06-PLAN.md — Content surfaces: ContentItem → Film/Song/Podcast adapter, AIUI grids fed from Archy (AIUI-03) + +**Wave 3** + +- [ ] 13-07-PLAN.md — Music index + music.* RPCs + freshness (AIUI-03) +- [ ] 13-08-PLAN.md — D-11 confirm gate: node-authored, nonce-bound, rendered in trusted chrome (AIUI-01/04) + +**Wave 4** + +- [ ] 13-09-PLAN.md — AIUI delivery: enforced build, pinned commit, live-asset verify, iframe sandbox mechanism (AIUI-04/05) +- [ ] 13-10-PLAN.md — D-04 chain: Ollama tool-calling + D-08 node-side history (AIUI-01) +- [ ] 13-11-PLAN.md — SongGrid lit from the real library + the m4a/aac/opus/wma share-mime fix (AIUI-03) + +**Wave 5** + +- [ ] 13-12-PLAN.md — D-10 untrusted-content boundary + cloud-egress guardrails + rate limiting (AIUI-04) + +**Wave 6** + +- [ ] 13-13-PLAN.md — Routstr backend + D-05 hard budget ceiling (AIUI-01) + +**Wave 7** + +- [ ] 13-14-PLAN.md — Eval harness: ScriptedBackend suite, EV-01..EV-18, cross-backend parity (AIUI-01/04) + +**Wave 8** + +- [ ] 13-15-PLAN.md — On-device sign-off: archi-dev-box, embedded iframe, desktop + mobile (AIUI-06) + +**Track note (D-13):** the music-library track (13-04 → 13-07 → 13-11) is independent — no plan +on the control or content track depends on any music plan, **and neither does the phase-closing +gate**. 13-15 depends on 13-06, 13-09 and 13-14 only, so there is no path from it to 13-04, +13-07 or 13-11: if the music track slips or is deferred, 13-15 records that at its step 7b and +the control and content work still closes and ships. 13-11 is therefore a terminal plan of the +phase rather than a gate on it. diff --git a/.planning/STATE.md b/.planning/STATE.md new file mode 100644 index 00000000..60c41e5f --- /dev/null +++ b/.planning/STATE.md @@ -0,0 +1,225 @@ +--- +gsd_state_version: 1.0 +milestone: v1.8.0 +milestone_name: milestone +current_phase: 09 +current_phase_name: BotFights Platform Upgrade +status: executing +stopped_at: v1.7.120-alpha SHIPPED; 1.7.121 queue open — see .planning/RELEASE-1.7.121-TASKS.md (12 items, RESUME HERE section at the end) +last_updated: "2026-08-03T15:15:58.798Z" +last_activity: 2026-07-31 +last_activity_desc: Phase 02 complete, transitioned to Phase 09 +progress: + total_phases: 13 + completed_phases: 2 + total_plans: 60 + completed_plans: 38 + percent: 15 +--- + +# Project State + +## Project Reference + +See: .planning/PROJECT.md (updated 2026-07-29) + +**Core value:** A third-party developer can publish an app via the signed/decentralized registry and a user can install it on their node — manifest-driven, rootless, secure, robust. +**Current focus:** Phase 02 — ui-performance + +## Current Position + +Phase: 09 — BotFights Platform Upgrade +Plan: Not started +Status: Ready to execute +Last activity: 2026-07-31 — Phase 02 complete, transitioned to Phase 09 + +Progress: [█████░░░░░] 54% + +## Performance Metrics + +**Velocity:** + +- Total plans completed: 11 +- Average duration: — +- Total execution time: — + +**By Phase:** + +| Phase | Plans | Total | Avg/Plan | +|-------|-------|-------|----------| +| 02 | 11 | - | - | +**Per-Plan Metrics:** + +| Plan | Duration | Tasks | Files | +|------|----------|-------|-------| +| Phase 02 P01 | 100min | 3 tasks | 5 files | +| Phase 02 P03 | 45min | 3 tasks | 5 files | +| Phase 02 P02 | 105min | 3 tasks | 11 files | +| Phase 02 P04 | 150min | 3 tasks | 12 files | +| Phase 02 P05 | 50min | 2 tasks | 8 files | +| Phase 02 P06 | 73min | 2 tasks | 7 files | +| Phase 02 P07 | 75min | 3 tasks | 5 files | +| Phase 02 P08 | ~190min | 3 tasks | 4 files | +| Phase 02 P09 | 130min | 3 tasks | 3 files | +| Phase 02 P10 | 55min | 2 tasks | 3 files | +| Phase 02 P11 | ~150min | 3 tasks | 8 files | +| Phase 01 P01 | n/a-continuation | 2 tasks | 1 files | + +## Accumulated Context + +### Roadmap Evolution + +- Phase 1 added (2026-07-29): Federation & Mesh Hardening — user-directed top priority (node removal/sync issues, mesh attachment parity incl. demo); prior phases shifted down +- Phase 2 added (2026-07-29): UI Performance — slow tab switches and secondary screens; prior phases shifted down +- FED-05 added to Phase 1 (2026-07-29): inter-node Lightning channel-opening UX (share node URI, pick trusted/federated nodes by hostname, request channels with public nodes); UI tested on :8100 dev preview against archi-dev before deploy +- FED-06 added to Phase 1 (2026-07-29): on-brand paid-tick animation — screensaver ring + EQ segments (reuse ScreensaverRing.vue compact) replacing the success burst in SendBitcoinModal.vue +- Phase 9 added (2026-07-30): BotFights Platform Upgrade — native nostr signer login, unified AI bot-setup prompt replacing docs page, shared public match endpoint on VPS2 (all nodes see all fighters), registry/manifest update. Independent of Phases 1–8. +- Phase 13 added (2026-08-03): AIUI — Conversational Node Control & Content Surfaces. User-directed: AIUI is embedded and styled but non-functional — chat cannot act on the node, content surfaces are unwired. Scope is (a) Pine's human-language intent→action capability reachable from typed chat, (b) conversational settings, (c) peer files / music / IndeeHub movies / node content rendered live, (d) **a user-granted capability sandbox** keeping keys, secrets and identity material away from the browser and the model — the user called this out explicitly as non-negotiable. Spans two repos: this one and `git.tx1138.com/lfg2025/AIUI` (branch `development`, clone at `~/Projects/AIUI`). Appended, not inserted — numeric position is append order, not priority. +- Phase 10 added (2026-08-01): Key-Material Hardening — KEY-01/F-01 (Critical: unauthenticated `seed.generate`/`seed.restore` overwrite a live node's identity keys), KEY-02/F-03 (fail-open first-boot secret regeneration over a fleet-shared rootfs), KEY-03/F-13 (BIP-84 private key imported into Bitcoin Core), KEY-04 (on-node verification of the audit's UNVERIFIED checklist). Sourced from `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` (quick task 260731-upz). Appended rather than inserted to avoid renumbering a roadmap with concurrent uncommitted edits — **numeric position is append order, not priority; F-01 is Critical and live on the fleet.** + +### Decisions + +Decisions are logged in PROJECT.md (10 locked ADRs in the `` block + milestone decisions table). Recent decisions affecting current work: + +- Milestone version = 1.8.0-alpha (decided 2026-07-08) +- Phase-3 Quadlet default-flip is gated on the second-node gate reporting clean (do fresh, never stage uncommitted) +- Workstream D (DHT distribution) deferred to v2 — design-only backlog +- Canonical manifest schema = `core/container/src/manifest.rs` (code wins over spec doc) +- [Phase ?]: Marketplace is the 02-02 tracer tab (worst-measured main tab, 2033ms revisit) matching the user's own 'often app store' complaint +- [Phase ?]: ContainerAppDetails.vue confirmed fully unreachable dead code (no importer, no route) — no serial-RPC-waterfall target exists in the measured D-09 surface set +- [Phase ?]: archi-dev-box UI password was unknown/undiscoverable from this environment — paused at a checkpoint:human-action rather than guessing or falling back to a mock baseline silently +- [Phase ?]: Purged the resource cache on logout via clearAll() + a generation guard, so no in-flight fetch from an ending session can repopulate memory or sessionStorage (T-02-02) +- [Phase ?]: AppDetails/MarketplaceAppDetails/OpenWrtGateway converted to per-item (or single-key) keyed useCachedResource; CloudFolder.vue's existing store-level cache left as-is (cloud.ts TTL gate is a follow-up, out of this plan's file scope) +- [Phase ?]: Wallet/send flow (SendBitcoinModal.vue) reported as an unplanned-item gap — named by findings as owned by 02-03 but not in files_modified; its cost is pure client-side remount, not a caching problem +- [Phase ?]: PERF-03 reverted to Pending in REQUIREMENTS.md after an initial mark-complete was premature — its own text requires real-node-hardware verification, which is 02-08's job (also declares PERF-03); 02-03 delivers the code-level portion only +- [Phase ?]: 02-02: DashboardRouterView final shape uses statically-named per-route KeepAlive wrapper components (dashboardViewWrappers.ts) with :include name-matching, restoring pre-restructure view-wrapper DOM/animations byte-for-byte after a checkpoint-caught regression +- [Phase ?]: 02-02: HARD RULE for rest of Phase 02 — perf work must be visually invisible; verify against the real dev preview before considering a checkpoint satisfied +- [Phase ?]: 02-02: app-catalog persist:true ttl 300000ms; bitcoin.prune-status persist:true ttl 30000ms — both explicit per T-02-01, no default relied on +- [Phase ?]: 02-02: PERF-02 reverted to Pending/In-Progress in REQUIREMENTS.md after an automated mark-complete run — PERF-02 also spans 02-04..02-07 (extending KeepAlive caching to every remaining main tab); this plan proves the architecture on the tracer tab only +- [Phase ?]: 02-04: KEEP_ALIVE_PATHS widened to every audited main tab (10 paths) derived from TAB_ORDER + /dashboard/discover; /dashboard/settings deliberately withheld — its child sections (SystemDangerZone reboot poll, several onMounted-only fetches) were never audited by this plan +- [Phase ?]: 02-04: onActivated is a documented no-op outside a KeepAlive boundary — every arm function now runs from both onMounted and onActivated (fresh-mount guards on Home/Web5/Mesh/Server avoid doubling first-load RPC cost); caught by CloudPeersRefresh.test.ts +- [Phase ?]: 02-04: useCachedResource.ts's onActivated no longer eagerly force-loads a never-fetched immediate:false resource, so tab-gated lazy data (Cloud.vue Paid Files/My Files) isn't force-loaded merely by its view entering the KeepAlive cache +- [Phase ?]: 02-04: AIUI blank-screen-and-loading symptom reported at Task 3 checkpoint diagnosed as pre-existing (local mock-backend dev mode sets VITE_AIUI_URL=http://localhost:5173 unconditionally with no AIUI repo checked out) — not a regression, left for 02-07 (Chat/AIUI) to address +- [Phase ?]: 02-05: mesh.refreshAll()/transport.fetchStatus() stay uncached at the store level (other callers need guaranteed-fresh reads); the useCachedResource wrapper around each lives in Mesh.vue instead, since Pinia's defineStore(id,setup) runs in a bare effectScope where onActivated() silently no-ops +- [Phase ?]: 02-05: FLAGGED - RESEARCH.md's premise that Mesh.vue owns a D3 force simulation is incorrect for this codebase (verified via grep); only NetworkMap.vue/Federation.vue has one. Task 2's D3 truths are vacuously satisfied; only the real Leaflet map lifecycle (MeshMap.vue, added to scope) was implemented +- [Phase ?]: 02-05: per-group TTL/persist table - mesh.refresh-all/federation-nodes/self-onion/self-did/contacts all persist:false (identity payload); transport-status persists (aggregate, non-identity); reachability groups get 10s TTL, identity groups 300s +- [Phase ?]: 02-06: RESEARCH A3 settled — none of Server's seven load-group loaders consumes another's result; concurrent fan-out is correct as-is +- [Phase ?]: 02-06: Five of Server's seven groups were already on useCachedResource from a pre-phase legacy commit (ea254f63) with only composable defaults (30s TTL, persist:true) — this plan's work was explicit TTL/persist/dedup, not initial conversion; only loadDiskStatus was a genuinely uncached plain fetch +- [Phase ?]: 02-06: Home's wallet composite does NOT share a cache key with Web5.vue's web5.lnd-info — sharing would either corrupt Web5's typed entry.data or fail to close the sessionStorage gap since Web5.vue's own hook (out of scope) defaults persist:true +- [Phase ?]: 02-06: homeStatus.refresh() wrapped by useCachedResource at Home.vue (the view), not inside the homeStatus Pinia store — defineStore(id,setup) runs in a bare effectScope where onActivated() silently no-ops, same finding as 02-05's Mesh.vue +- [Phase ?]: 02-07: AIUI source located mid-plan at git.tx1138.com/lfg2025/AIUI (base branch development, not stale main); AIUI-side D-14 commit 900c0b9 initially local-only (anonymous push 403) then pushed/merged upstream onto development by the orchestrator using a user-supplied write token +- [Phase ?]: 02-07: D-14a fixed via new ?chatExpanded param overriding chat.ts's chatCollapsed default (never persisted to localStorage); D-14b fixed via new ?mobileChat param re-asserting ChatPage.vue's mobileTab='chat' once on mount, guarding against module-singleton content-panel state surviving an internal AIUI remount +- [Phase ?]: 02-07: PERF-02 marked Complete in REQUIREMENTS.md — 02-02 through 02-07 extended KeepAlive/useCachedResource to every main tab, each dev-preview-verified against archi-dev-box per D-11 +- [Phase ?]: 02-08: KEEP_ALIVE_MAX left at 6, now backed by an on-device memory reading (4 cycles, 11 tabs, JS heap fluctuating 10-21MB, no monotonic growth) rather than the FA-D estimate +- [Phase ?]: 02-08: archy-x250-dev offline for the entire plan (checked 3x); archi-dev-box (D-11's named target) is the only dev-pair node this phase reached +- [Phase ?]: 02-08: the harness's remount-probe field is confounded for main tabs once real KeepAlive keeps multiple instances alive simultaneously; corrected via an independent, reproduced-twice verification rather than editing the frozen 02-01 harness — revealed Server.vue genuinely does not survive a round-trip (open gap, not hidden) +- [Phase ?]: 02-08: a user-reported Cloud first-visit navigation regression was treated as release-blocking, not known-open, per explicit direction — root-caused to content.browse-peer's unbounded, untimed-enough per-peer RPC fan-out starving Chromium's connection pool; fixed via a concurrency cap + shortened timeout, verified 5/5, user-approved on-node +- [Phase ?]: 02-08: 4 other user-reported UX issues (Paid Files window.open, PiP not closing lightbox, missing loader on Paid Files item-open, PiP not surviving tab changes) classified as pre-existing (predate phase 2 via git history) and captured into UIFIX-04/05/06, not fixed +- [Phase ?]: 02-09: /dashboard/server's (and Web5's) 'genuinely remounts' reading was a proven probe-measurement artifact (generic .view-container selector can't disambiguate the foreground tab from other still-connected cached tabs) — confirmed via document.elementFromPoint() hit-test contradicting the naive verdict across device runs; no source change needed, pinned with vm.$.uid-based regression tests instead +- [Phase ?]: 02-09: committed neode-ui/e2e/perf/keepalive-remount-probe.spec.ts as a re-runnable, instrumented probe covering every KEEP_ALIVE_PATHS tab, replacing the ad-hoc 02-08 probe so this class of false positive cannot recur +- [Phase ?]: [Phase 2, gap closure 02-10]: Wallet/send-flow's timing regression cleared as environmental noise (re-measure at/below baseline); Discover/Server/Web5/AppDetails/OpenWrtGateway confirmed as real, phase-2-caused client-side render/reactivation regressions via 3-run dispersion + git bisection, recorded as accepted deviations (not fixed — deploy blocked mid-session by a shared-tree hazard with concurrent security-follow-up and BotFights sessions) +- [Phase 2, gap closure 02-11]: Real cause of the six regressions was NOT compute-bound render cost (CPU profile: 86-99% idle/program, <10% JS self-time everywhere) — it was three background pollers (useFleetData.ts 60s, FipsNetworkCard.vue 15s, Web5Monitoring.vue 30s) armed in onMounted and never disarmed once their owning views joined KEEP_ALIVE_PATHS in 02-04, invisible to that audit because it grepped the top-level view files, not the child composables they delegate to. Gated to onActivated/onDeactivated, mirroring 02-04's own established pattern. Fixed: web5 275ms (was 566ms baseline/1329ms regressed), server 574ms (was 738/1239), fleet 790ms (was 330/2631) +- [Phase 2, gap closure 02-11]: Discover (1389ms, worst remaining) has a SECOND, distinct cause: card-stagger/showStagger entrance-animation classes are baked into the DOM at first mount and never programmatically removed, so every KeepAlive detach/reattach cycle restarts the CSS animation on reactivation — replaying the full entrance cascade on every revisit. Confirmed via a diagnostic (DOM card count doubling transiently on every revisit) and an extended animation-event log. NOT fixed — blast radius spans 5+ files outside 02-11's scope (Apps.vue, Marketplace.vue, Home.vue, several Web5 sub-cards), needs its own real-device verification budget; recommended as a dedicated follow-up +- [Phase 2, gap closure 02-11]: openwrt-gateway unmeasurable in the final re-measure (Chromium "Target crashed" cascading from an unrelated surface, cloud-folder, earlier in the same harness run) — recorded as not-measurable, not written in as data. Separately confirmed the prior baseline/after/remeasure numbers were measuring a real, substantive disconnected-state UI (OpenWrtGateway.vue's h1 is unconditional; a "No router configured" RPC error deterministically renders a real Connect-to-Router form, not a blank/error page) — the six-surface regression count is not retracted, but the numbers reflect one specific code branch (no OpenWrt device has ever been connected to archi-dev-box) +- [Phase ?]: 01-01: record_peer_transport and update_node routed through FEDERATION_STORE_LOCK via *_inner; tombstone-write-failure test added; full-suite verify blocked by a concurrent agent's uncommitted install.rs edit (unrelated file, not fixed per scope boundary) +- [Phase ?]: UIFIX-02: connected-nodes card height tracks row sibling via xl:flex-1 xl:basis-0 (zero-basis flex-grow) instead of flex-auto, with an xl:min-h-[40rem] floor for a short sibling (discovery disabled), tuned from an initial 20rem guess per Dorian's live feedback + +### Pending Todos + +- [blocker/ui] Keep FIPS/Tor pills on cloud files and show them on mobile (`.planning/todos/pending/2026-07-30-keep-fips-tor-pills-on-cloud-files-and-show-them-on-mobile.md`) +- [blocker/security] Fedimint gateway must not install with a pre-set password — tracked as FED-07 / Phase 1 gap plan (`.planning/todos/pending/2026-07-30-fedimint-gateway-must-not-install-with-preset-password.md`) +- [blocker/ui] Connected-nodes list must scroll at row-matched height, not grow to fit (`.planning/todos/pending/2026-07-30-connected-nodes-list-must-scroll-at-row-matched-height.md`) +- [blocker/ui] Onboarding tickbox hidden below fold on short screens — make it beautifully obvious (`.planning/todos/pending/2026-07-30-onboarding-tickbox-hidden-below-fold-on-short-screens.md`) +- [major/ui] Paid Files pictures open in browser tab, not the app lightbox — UIFIX-04 (`.planning/todos/pending/2026-07-30-peer-files-pictures-open-in-tab-not-lightbox.md`) +- [major/ui] PiP should close the lightbox with a fluid animation — UIFIX-05 (`.planning/todos/pending/2026-07-30-pip-should-close-lightbox-with-fluid-animation.md`) +- [major/ui] Missing loader states on slow opens — UIFIX-06 (`.planning/todos/pending/2026-07-30-missing-loader-states-on-slow-opens.md`) + +### Blockers/Concerns + +- [Phase 1] Federation tombstone fix touches trust code — fix carefully, re-verify with `tests/multinode/smoke.sh`, don't patch blind +- [Phase 3] Multinode gate on archy-x250-beta was launched 2026-07-01 (log on-node); verify outcome before re-running +- [Phase 5] Fleet registry flip awaits explicit user authorization + timing call +- [Phase 6] Strengthened ADR-009 validation may reject existing catalog apps — audit manifests before enforcement lands +- [Global] Live OTA fleet: deploy to the dev pair before any OTA; gate re-runs required after orchestrator changes; some verification is user/hardware-gated (radios, on-device tests) +- cloud.ts's navigate() needs a TTL gate to fully satisfy 'no new RPC within TTL' for CloudFolder.vue — currently always re-fetches on revisit (just doesn't block paint) +- [Phase 2, RESOLVED by 02-09] ~~Server.vue does not survive a tab round-trip despite KEEP_ALIVE_PATHS registration~~ — retracted: proven a probe-measurement artifact (shared generic `.view-container` selector couldn't disambiguate the foreground tab from other cached tabs), not a real defect. Server.vue's (and Web5.vue's) instance genuinely survives; pinned with `vm.$.uid`-based regression tests immune to the same ambiguity. Checkpoint approved on real hardware. +- [Phase 2, RESOLVED by 02-11] ~~Timing regressions on Discover/Web5/Fleet/AppDetails/OpenWrtGateway~~ — root cause found (three leaked background pollers, not compute-bound render cost) and fixed for web5/server/fleet, each proven with a real before/after number on archi-dev-box. AppDetails restored to at/near its own pre-phase-2 baseline (pre-existing per-mount cost, not a new defect). OpenWrtGateway not measurable this pass (browser crash); prior numbers stand with a data-integrity note (measuring a real disconnected-device UI, not an empty page). +- [Phase 2, follow-up needed] Discover (1389ms, worst remaining named surface) has a second, evidenced, phase-2-caused defect: KeepAlive'd entrance-stagger animations (`card-stagger`/`showStagger`) never get their class removed from the DOM after first play, so every reactivation replays the full CSS animation cascade. Fix requires touching 5+ files outside 02-11's scope (Apps.vue, Marketplace.vue, Home.vue, Web5Wallet.vue/Web5Identities.vue/Web5NodeVisibility.vue/Web5NostrRelays.vue) with its own real-device visual-regression verification budget (the same class of risk 02-02's original KeepAlive rollout hit on its first checkpoint attempt) — needs a dedicated follow-up plan, not squeezed into a gap-closure pass. + +### Quick Tasks Completed + +| # | Description | Date | Commit | Directory | +|---|-------------|------|--------|-----------| +| 260729-fw7 | improve mesh message hop graphic/animation: balanced desktop sizing, vertical mobile layout, archipelago branding | 2026-07-29 | ac09fc5d | [260729-fw7-improve-mesh-message-hop-graphic-animati](./quick/260729-fw7-improve-mesh-message-hop-graphic-animati/) | +| 260729-gjd | demo: indee.tx1138.com in app iframe (:2101 whole-origin proxy), auto nostr signer sign-in, IndeeHub pre-installed on fresh session | 2026-07-29 | d00ca624 | [260729-gjd-demo-make-indee-tx1138-com-work-in-the-a](./quick/260729-gjd-demo-make-indee-tx1138-com-work-in-the-a/) | +| 260729-hj1 | peer-files media batch: Wavlake paid tracks + purchases + dedupe + real photos (demo); lightbox/player open routing + free-image lightbox fix (both builds) | 2026-07-29 | f52c5407 | [260729-hj1-peer-files-media-batch-wavlake-paid-trac](./quick/260729-hj1-peer-files-media-batch-wavlake-paid-trac/) | +| 260729-je5 | connected-nodes list fills card height (constant footer gap); companion app skips demo intro | 2026-07-29 | d54517cf | [260729-je5-ui-fixes-connected-nodes-scrollable-list](./quick/260729-je5-ui-fixes-connected-nodes-scrollable-list/) | + +## Deferred Items + +| Category | Item | Status | Deferred At | +|----------|------|--------|-------------| +| Distribution | DIST-01 DHT/iroh backbone (workstream D) | v2 | 2026-07-29 | +| Fleet | FLEET-01 Bitcoin multi-version fleet OTA (user-gated) | v2 | 2026-07-29 | +| Fleet | FLEET-02 per-app deep health assertions (~34 apps) | v2 | 2026-07-29 | +| Fleet | FLEET-03 LUKS2 data-partition encryption | v2 | 2026-07-29 | + +## Release SHIPPED — v1.7.120-alpha (2026-08-03) + +**LIVE.** signature PRESENT (did:key:z6Mkkid…q7ur), both assets HTTP 200 at exactly their +manifest byte counts, tag pushed. Two release-process traps hit and documented in memory: +create-release.sh commits the manifest BEFORE signing (fleet refuses unsigned), and +gitea-vps2 is the SAME server as gitea-ai (vps2 token is dead). + +### Staging record (kept for the evidence trail) + +Built from `4d67f56b` (release profile, 15m15s, exit 0), deployed to archi-dev-box, +`.bak` rollback at /opt/archipelago/rollback/archipelago.bak. + +Verified on the node: both security gates 401 unauthenticated from a non-loopback +address; CORS origin-scoped; AIUI assets 200 AFTER the frontend rsync (the deploy that +would have wiped a copied-file fix); mesh.lightning-peers/send-lightning-info answer +correctly; system.stats host_secrets = per-node; served bundle sha256-matches the build +on all three chunks; 31 containers up, none down, no restart loop. + +NOT verified, deliberately: the new torrc SocksPort/SocksPolicy block. regenerate_torrc +only fires on a Tor services change, so the live torrc still reads only `SocksPort 9050`. +Gateway detection was proven in isolation (10.89.0.1 10.89.0.0/24; missing network exits +non-zero -> stays loopback-only). The change is INERT this release since bitcoind has no +-onion flag yet (Phase 12), so forcing a torrc regeneration would risk bouncing every +onion service for zero benefit. + +Frontend is a proven no-op this cycle — built chunks are byte-identical to those already +served — so a fleet node only changes binary + the two app-UI images + nginx. + +Remaining to ship: operator go/no-go, then `scripts/create-release.sh 1.7.120-alpha` +(stops at the signing prompt — reads the master mnemonic interactively, operator-only), +then publish-release-assets.sh to gitea-vps2, then push tags. CHANGELOG.md already +carries curated v1.7.120-alpha notes (create-release.sh hard-fails without them). +The 5x lifecycle gate was NOT run. + +## Session Continuity + +Last session: 2026-08-03T12:57:50.980Z +Stopped at: Phase 13 context gathered +Resume file: .planning/phases/13-aiui-functional-conversational-node-control-and-content-surf/13-CONTEXT.md + +Open on this thread (all recorded as broken windows, none blocking): + +- Window 15 CLOSED 2026-08-02 20:02 — f6b5245b's reconcile path proven on archi-dev-box by + a controlled test: stale conf installed + container restarted (probe 200, genuinely + re-exposed), daemon started, reconcile repaired it unaided at 20:02:19 with the expected + warn line, probe 401, conf byte-identical to the known-good. Both halves now proven on + hardware. + +- Windows 11/12: host-secret rotation on three fleet nodes sharing SSH host keys — + detect-only so far; rotation is USER-GATED and deliberately not actioned. + +- Credential rotation DECIDED AGAINST 2026-08-02 (operator): no LND macaroon rotation, no + Bitcoin RPC password rotation — no evidence of exploitation and the vulnerability is + being closed rather than lived with. rotate-lnd-macaroon.sh stays as a tool, exercised in + detect mode only, never run against a node. Do not re-litigate; see + docs/security/BITCOIN-RPC-PROXY-EXPOSURE.md. + +- Dev-pair verification is archi-dev-box ONLY, by operator instruction 2026-08-02. Do not + raise archy-x250-dev as a blocker again. diff --git a/.planning/WINDOWS.md b/.planning/WINDOWS.md new file mode 100644 index 00000000..553df6af --- /dev/null +++ b/.planning/WINDOWS.md @@ -0,0 +1,217 @@ +--- +schema_version: 1 +open_count: 11 +waived_count: 0 +fixed_count: 4 +total_count: 15 +last_updated: 2026-08-03T00:06:03.112Z +--- + +# Broken Windows Ledger + +> Cross-phase defect register. `/gsd-ship` blocks while `open_count > 0`. +> Waive with `gsd-tools windows waive ""` (reason required). +> Mark fixed with `gsd-tools windows fixed `. + +| id | phase | kind | file | line | description | status | reason | recorded_at | resolved_at | +|----|-------|------|------|------|-------------|--------|--------|-------------|-------------| +| 1 | 02 | deviation | neode-ui/src/stores/cloud.ts | | CloudFolder.vue's file listing cache lacks a TTL gate in cloudStore.navigate() — always re-issues the RPC on revisit (paints from cache instantly first, but still refetches unconditionally). Needs a TTL check added to navigate() to fully satisfy 'no new RPC within TTL'. | open | | 2026-07-30T12:25:22.301Z | | +| 2 | 02 | deviation | neode-ui/src/views/Home.vue | | Wallet/send flow (SendBitcoinModal.vue via Home.vue) named by 02-FINDINGS.md as owned by 02-03 (worst-ranked revisit, 2607ms) but not in 02-03-PLAN.md's files_modified — reported as an unplanned-item gap, not converted. Cause is pure client-side remount cost (0 RPC), not a caching problem. | open | | 2026-07-30T12:25:22.450Z | | +| 3 | 02 | deviation | neode-ui/src/views/PeerFiles.vue | | 02-03-PLAN.md assumed PeerFiles.vue already used useCachedResource; it actually uses the raw resources store directly (correctly per-item-keyed) with no TTL gate and the same loading/refreshing conflation bug fixed in OpenWrtGateway.vue this plan. Left untouched (out of files_modified scope) — candidate for the same fix in a future plan. | open | | 2026-07-30T12:25:22.605Z | | +| 4 | 02 | deviation | neode-ui/src/views/Chat.vue | | AIUI-side D-14 commit (900c0b9, branch feat/d14-embed-defaults in local clone /home/archipelago/Projects/AIUI, based on development) is NOT pushed upstream to git.tx1138.com/lfg2025/AIUI — anonymous push returned 403 Forbidden. neode-ui's two new query params (chatExpanded, mobileChat) are inert no-ops against any currently-deployed AIUI build until a maintainer with push rights merges and it is rebuilt/redeployed. 02-08 (deploy) or the user must resolve push access. | fixed | | 2026-07-30T22:37:25.565Z | 2026-07-30T22:37:44.642Z | +| 5 | 09 | unrun-verify | botfight/e2e/signup-bot.spec.ts | | pnpm test:e2e -- e2e/signup-bot.spec.ts not run: local backend dev port 9100 is occupied by the live archi-dev-box botfights container (podman, 42h uptime) needed for tomorrow's demo — could not free it to run a local dev server. Task-level automated verify (vue-tsc + grep sweep) passed; vitest server suite passed with only pre-existing unrelated flaky failures. | open | | 2026-07-31T02:35:00.391Z | | +| 6 | 02 | deviation | neode-ui/src/views/Discover.vue | | Discover revisit-ms regression (1083->1257->1453ms across 3 runs), confirmed phase-2-caused split-signal client-side render cost, not fixed (deploy blocked this session) | open | | 2026-07-31T10:56:26.089Z | | +| 7 | 02 | deviation | neode-ui/src/views/Server.vue | | Server revisit-ms regression (738->849->1239ms across 3 runs) despite confirmed instance survival and improved RPC count; confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session) | open | | 2026-07-31T10:56:26.305Z | | +| 8 | 02 | deviation | neode-ui/src/views/web5/Web5.vue | | Web5 revisit-ms regression (566->709->1329ms, zero overlap across 3 runs) despite confirmed instance survival; confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session) | open | | 2026-07-31T10:56:26.570Z | | +| 9 | 02 | deviation | neode-ui/src/views/AppDetails.vue | | AppDetails revisit-ms regression (1204->1510->2668ms across 3 runs); confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session) | open | | 2026-07-31T10:56:26.751Z | | +| 10 | 02 | deviation | neode-ui/src/views/server/OpenWrtGateway.vue | | OpenWrtGateway revisit-ms regression (663.5->1148->1460ms across 3 runs); confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session) | open | | 2026-07-31T10:56:26.933Z | | +| 11 | 10 | unrun-verify | docs/security/KEY-02-FLEET-ROTATION.md | | C-3 FAILED: archipelago-1, archy-x250-beta and archipelago share all three SSH host keys; the first two also share their TLS private key. Not rotated — needs an operator-driven --apply --yes per node. | open | | 2026-08-02T19:07:39.861Z | | +| 12 | 10 | unrun-verify | scripts/security/host-secrets-audit.sh | | Rotation never exercised on real hardware: that 'systemctl reload ssh' keeps the operator's own forked session alive is proven only by design, not by observation. Needs --apply --yes on one disposable node from a session the operator is willing to lose. | open | | 2026-08-02T19:07:40.217Z | | +| 13 | 10 | unrun-verify | core/archipelago/src/api/rpc/system/handlers.rs | | system.stats host_secrets never observed on a real node — proven against the file contract in unit tests only. Needs a build carrying 10-04 deployed to the dev pair, then a system.stats call. | fixed | | 2026-08-02T19:07:40.522Z | 2026-08-02T23:00:30.894Z | +| 14 | 10 | unrun-verify | core/archipelago/src/container/prod_orchestrator.rs | | LIVE EXPOSURE on archi-dev-box: archy-bitcoin-ui (systemd/Quadlet-owned, user-uninstalled marker set) still serves unauthenticated POST /bitcoin-rpc/ on 0.0.0.0:8334 with Access-Control-Allow-Origin *, reaching Bitcoin Core RPC through a credential-injecting proxy. Verified live 2026-08-02 (returned a real block height with no cookies). Code fix committed f6b5245b but NOT deployed: closing it needs the new binary on the node plus an archy-bitcoin-ui restart. archy-electrs-ui is in the same uninstalled-but-running state (static UI only, no credential proxy). Operator-gated; no node touched. | fixed | | 2026-08-02T22:44:15.215Z | 2026-08-02T23:16:04.071Z | +| 15 | 10 | unrun-verify | core/archipelago/src/container/prod_orchestrator.rs | | The f6b5245b reconcile fix is DEPLOYED on archi-dev-box (binary installed 19:06, running) but NEVER EXERCISED on hardware: the state it repairs (uninstall marker + Quadlet-running + stale config) stopped existing here at 18:36, when a separate rebuild of bitcoin-ui rendered the fixed conf and restarted the container. So :8334 returning 401 proves a05956c4's template, NOT the reconcile path that is supposed to deliver it. archy-electrs-ui still carries the marker+running shape and could exercise it, but has no rendered config to rewrite. Needs a node that still has a stale bitcoin-ui conf, or a deliberately re-staled one. | fixed | | 2026-08-02T23:16:04.510Z | 2026-08-03T00:06:03.112Z | + +````json +[ + { + "id": 1, + "kind": "deviation", + "phase": "02", + "file": "neode-ui/src/stores/cloud.ts", + "line": null, + "description": "CloudFolder.vue's file listing cache lacks a TTL gate in cloudStore.navigate() — always re-issues the RPC on revisit (paints from cache instantly first, but still refetches unconditionally). Needs a TTL check added to navigate() to fully satisfy 'no new RPC within TTL'.", + "status": "open", + "reason": "", + "recorded_at": "2026-07-30T12:25:22.301Z", + "resolved_at": null + }, + { + "id": 2, + "kind": "deviation", + "phase": "02", + "file": "neode-ui/src/views/Home.vue", + "line": null, + "description": "Wallet/send flow (SendBitcoinModal.vue via Home.vue) named by 02-FINDINGS.md as owned by 02-03 (worst-ranked revisit, 2607ms) but not in 02-03-PLAN.md's files_modified — reported as an unplanned-item gap, not converted. Cause is pure client-side remount cost (0 RPC), not a caching problem.", + "status": "open", + "reason": "", + "recorded_at": "2026-07-30T12:25:22.450Z", + "resolved_at": null + }, + { + "id": 3, + "kind": "deviation", + "phase": "02", + "file": "neode-ui/src/views/PeerFiles.vue", + "line": null, + "description": "02-03-PLAN.md assumed PeerFiles.vue already used useCachedResource; it actually uses the raw resources store directly (correctly per-item-keyed) with no TTL gate and the same loading/refreshing conflation bug fixed in OpenWrtGateway.vue this plan. Left untouched (out of files_modified scope) — candidate for the same fix in a future plan.", + "status": "open", + "reason": "", + "recorded_at": "2026-07-30T12:25:22.605Z", + "resolved_at": null + }, + { + "id": 4, + "kind": "deviation", + "phase": "02", + "file": "neode-ui/src/views/Chat.vue", + "line": null, + "description": "AIUI-side D-14 commit (900c0b9, branch feat/d14-embed-defaults in local clone /home/archipelago/Projects/AIUI, based on development) is NOT pushed upstream to git.tx1138.com/lfg2025/AIUI — anonymous push returned 403 Forbidden. neode-ui's two new query params (chatExpanded, mobileChat) are inert no-ops against any currently-deployed AIUI build until a maintainer with push rights merges and it is rebuilt/redeployed. 02-08 (deploy) or the user must resolve push access.", + "status": "fixed", + "reason": "", + "recorded_at": "2026-07-30T22:37:25.565Z", + "resolved_at": "2026-07-30T22:37:44.642Z" + }, + { + "id": 5, + "kind": "unrun-verify", + "phase": "09", + "file": "botfight/e2e/signup-bot.spec.ts", + "line": null, + "description": "pnpm test:e2e -- e2e/signup-bot.spec.ts not run: local backend dev port 9100 is occupied by the live archi-dev-box botfights container (podman, 42h uptime) needed for tomorrow's demo — could not free it to run a local dev server. Task-level automated verify (vue-tsc + grep sweep) passed; vitest server suite passed with only pre-existing unrelated flaky failures.", + "status": "open", + "reason": "", + "recorded_at": "2026-07-31T02:35:00.391Z", + "resolved_at": null + }, + { + "id": 6, + "kind": "deviation", + "phase": "02", + "file": "neode-ui/src/views/Discover.vue", + "line": null, + "description": "Discover revisit-ms regression (1083->1257->1453ms across 3 runs), confirmed phase-2-caused split-signal client-side render cost, not fixed (deploy blocked this session)", + "status": "open", + "reason": "", + "recorded_at": "2026-07-31T10:56:26.089Z", + "resolved_at": null + }, + { + "id": 7, + "kind": "deviation", + "phase": "02", + "file": "neode-ui/src/views/Server.vue", + "line": null, + "description": "Server revisit-ms regression (738->849->1239ms across 3 runs) despite confirmed instance survival and improved RPC count; confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session)", + "status": "open", + "reason": "", + "recorded_at": "2026-07-31T10:56:26.305Z", + "resolved_at": null + }, + { + "id": 8, + "kind": "deviation", + "phase": "02", + "file": "neode-ui/src/views/web5/Web5.vue", + "line": null, + "description": "Web5 revisit-ms regression (566->709->1329ms, zero overlap across 3 runs) despite confirmed instance survival; confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session)", + "status": "open", + "reason": "", + "recorded_at": "2026-07-31T10:56:26.570Z", + "resolved_at": null + }, + { + "id": 9, + "kind": "deviation", + "phase": "02", + "file": "neode-ui/src/views/AppDetails.vue", + "line": null, + "description": "AppDetails revisit-ms regression (1204->1510->2668ms across 3 runs); confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session)", + "status": "open", + "reason": "", + "recorded_at": "2026-07-31T10:56:26.751Z", + "resolved_at": null + }, + { + "id": 10, + "kind": "deviation", + "phase": "02", + "file": "neode-ui/src/views/server/OpenWrtGateway.vue", + "line": null, + "description": "OpenWrtGateway revisit-ms regression (663.5->1148->1460ms across 3 runs); confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session)", + "status": "open", + "reason": "", + "recorded_at": "2026-07-31T10:56:26.933Z", + "resolved_at": null + }, + { + "id": 11, + "kind": "unrun-verify", + "phase": "10", + "file": "docs/security/KEY-02-FLEET-ROTATION.md", + "line": null, + "description": "C-3 FAILED: archipelago-1, archy-x250-beta and archipelago share all three SSH host keys; the first two also share their TLS private key. Not rotated — needs an operator-driven --apply --yes per node.", + "status": "open", + "reason": "", + "recorded_at": "2026-08-02T19:07:39.861Z", + "resolved_at": null + }, + { + "id": 12, + "kind": "unrun-verify", + "phase": "10", + "file": "scripts/security/host-secrets-audit.sh", + "line": null, + "description": "Rotation never exercised on real hardware: that 'systemctl reload ssh' keeps the operator's own forked session alive is proven only by design, not by observation. Needs --apply --yes on one disposable node from a session the operator is willing to lose.", + "status": "open", + "reason": "", + "recorded_at": "2026-08-02T19:07:40.217Z", + "resolved_at": null + }, + { + "id": 13, + "kind": "unrun-verify", + "phase": "10", + "file": "core/archipelago/src/api/rpc/system/handlers.rs", + "line": null, + "description": "system.stats host_secrets never observed on a real node — proven against the file contract in unit tests only. Needs a build carrying 10-04 deployed to the dev pair, then a system.stats call.", + "status": "fixed", + "reason": "", + "recorded_at": "2026-08-02T19:07:40.522Z", + "resolved_at": "2026-08-02T23:00:30.894Z" + }, + { + "id": 14, + "kind": "unrun-verify", + "phase": "10", + "file": "core/archipelago/src/container/prod_orchestrator.rs", + "line": null, + "description": "LIVE EXPOSURE on archi-dev-box: archy-bitcoin-ui (systemd/Quadlet-owned, user-uninstalled marker set) still serves unauthenticated POST /bitcoin-rpc/ on 0.0.0.0:8334 with Access-Control-Allow-Origin *, reaching Bitcoin Core RPC through a credential-injecting proxy. Verified live 2026-08-02 (returned a real block height with no cookies). Code fix committed f6b5245b but NOT deployed: closing it needs the new binary on the node plus an archy-bitcoin-ui restart. archy-electrs-ui is in the same uninstalled-but-running state (static UI only, no credential proxy). Operator-gated; no node touched.", + "status": "fixed", + "reason": "", + "recorded_at": "2026-08-02T22:44:15.215Z", + "resolved_at": "2026-08-02T23:16:04.071Z" + }, + { + "id": 15, + "kind": "unrun-verify", + "phase": "10", + "file": "core/archipelago/src/container/prod_orchestrator.rs", + "line": null, + "description": "The f6b5245b reconcile fix is DEPLOYED on archi-dev-box (binary installed 19:06, running) but NEVER EXERCISED on hardware: the state it repairs (uninstall marker + Quadlet-running + stale config) stopped existing here at 18:36, when a separate rebuild of bitcoin-ui rendered the fixed conf and restarted the container. So :8334 returning 401 proves a05956c4's template, NOT the reconcile path that is supposed to deliver it. archy-electrs-ui still carries the marker+running shape and could exercise it, but has no rendered config to rewrite. Needs a node that still has a stale bitcoin-ui conf, or a deliberately re-staled one.", + "status": "fixed", + "reason": "", + "recorded_at": "2026-08-02T23:16:04.510Z", + "resolved_at": "2026-08-03T00:06:03.112Z" + } +] +```` diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md new file mode 100644 index 00000000..904c84b6 --- /dev/null +++ b/.planning/codebase/ARCHITECTURE.md @@ -0,0 +1,333 @@ + +# Architecture + +**Analysis Date:** 2026-07-29 + +## System Overview + +```text +┌────────────────────────────────────────────────────────────────┐ +│ Frontend Layer (Vue 3) │ +│ `neode-ui/src` (TypeScript + SPA) │ +│ Routes → Views → Components → Composables → RPC Client │ +└────────────────┬─────────────────────────────────────────────┘ + │ WebSocket + HTTP(S) + │ JSON-RPC 2.0 protocol + ▼ +┌────────────────────────────────────────────────────────────────┐ +│ HTTP Server Layer (Hyper) │ +│ `core/archipelago/src/server.rs` │ +│ TCP Listener → Hyper → Router → ApiHandler/RpcHandler │ +└────────────────┬─────────────────────────────────────────────┘ + │ + ┌──────────┴──────────┬──────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌─────────┐ ┌──────────┐ ┌────────────┐ + │ WebSocket │ RPC │ │ Content │ + │ Handler │ Handler │ │ Proxy │ + │ (state sync) │ (methods)│ │ (app URIs) │ + └─────────┘ └──────────┘ └────────────┘ + │ │ + └──────────┬───────┘ + │ + ▼ + ┌─────────────────────────────────────┐ + │ Service Layer (Async Tasks) │ + │ `core/archipelago/src/api/rpc/*` │ + │ │ + │ • auth, identity, secrets │ + │ • container orchestration │ + │ • bitcoin, lightning, wallet │ + │ • mesh, federation, FIPS │ + │ • content, backup, settings │ + └─────────────┬───────────────────────┘ + │ + ┌───────────┼───────────┬──────────────┐ + │ │ │ │ + ▼ ▼ ▼ ▼ + ┌─────────┐ ┌──────────┐ ┌────────┐ ┌──────────┐ + │Container│ │ State │ │BlobStore + │Orch. │ │Manager │ │ │ │Identity │ + │(Podman) │ │(Broadcast + │ │ │ channels)│ │ ContentClient Manager │ + └─────────┘ └──────────┘ └────────┘ └──────────┘ + │ │ │ │ + └───────────┼───────────┼─────────┘ + │ + ▼ + ┌─────────────────────────────────────┐ + │ Persistent Storage Layer │ + │ │ + │ • Data directory files (YAML/JSON) │ + │ • SQLite (session store) │ + │ • Blob store (content-addressed) │ + │ • Podman container state │ + │ • Secret vaults (encrypted) │ + └─────────────────────────────────────┘ +``` + +## Component Responsibilities + +| Component | Responsibility | File | +|-----------|----------------|------| +| **Server** | HTTP listener, connection multiplexing, TLS/encryption | `core/archipelago/src/server.rs` | +| **ApiHandler** | HTTP request routing, authentication, response formatting | `core/archipelago/src/api/handler/mod.rs` | +| **RpcHandler** | JSON-RPC 2.0 dispatch, method registration, rate limiting | `core/archipelago/src/api/rpc/mod.rs` | +| **ContainerOrchestrator** | Podman lifecycle, manifest reconciliation, adoption | `core/archipelago/src/container/prod_orchestrator.rs` | +| **StateManager** | Central state broadcast channel, revision tracking | `core/archipelago/src/state.rs` | +| **AuthManager** | User credentials, session validation, password hashing | `core/archipelago/src/auth.rs` | +| **Identity Manager** | Node Ed25519 keys, seed derivation, Tor address | `core/archipelago/src/identity_manager.rs` | +| **BootReconciler** | Periodic manifest sync loop, adoption, remediation | `core/archipelago/src/container/boot_reconciler.rs` | +| **Frontend Router** | Vue Router, page navigation, deep linking | `neode-ui/src/router/index.ts` | +| **Frontend Stores** | Pinia state (apps, settings, user, mesh) | `neode-ui/src/stores/` | +| **Frontend Components** | UI elements, modals, cards, layout primitives | `neode-ui/src/components/` | + +## Pattern Overview + +**Overall:** Multi-tier async architecture with centralized request dispatch and broadcast state synchronization. + +**Key Characteristics:** +- **Async-first (Tokio)** - All I/O operations are non-blocking; task spawning for background work +- **RPC-driven API** - Frontend communicates via JSON-RPC 2.0 (not REST); single `/api/v0` WebSocket + HTTP endpoint +- **State as broadcast** - Global state changes flow through Tokio broadcast channels to all connected WebSocket clients +- **Manifest-driven containers** - App lifecycle controlled by declarative YAML manifests (Archipelago-specific extensions) +- **Plugin architecture** - Apps are isolated Podman containers with declarative interfaces (web UI, ports, secrets) + +## Layers + +**HTTP/Transport Layer:** +- Purpose: Accept inbound connections, handle TLS termination, demultiplex HTTP/WebSocket +- Location: `core/archipelago/src/server.rs` +- Contains: Hyper listener, TCP accept loop, connection state tracking +- Depends on: Tokio, Hyper, TLS/mTLS libraries (rustls/openssl) +- Used by: All external clients (web UI, companion app, API consumers) + +**Request Routing & Auth Layer:** +- Purpose: Dispatch HTTP requests to handlers, validate sessions, enforce CSRF, rate-limit login +- Location: `core/archipelago/src/api/` (handler + rpc submodules) +- Contains: Route matching, middleware chain, cookie extraction, error formatting +- Depends on: Server, StateManager, SessionStore +- Used by: All request paths; gates API access + +**RPC Dispatch Layer:** +- Purpose: Deserialize JSON-RPC 2.0 requests, call appropriate service method, serialize responses +- Location: `core/archipelago/src/api/rpc/mod.rs` + subdirectories (auth.rs, container.rs, bitcoin.rs, etc.) +- Contains: Method table, parameter validation, response formatting, rate limit checks +- Depends on: All service modules +- Used by: Frontend (WebSocket + HTTP POST to /api/v0), internal tools + +**Service Layer:** +- Purpose: Implement business logic — container lifecycle, identity, auth, content sync, mesh discovery +- Location: `core/archipelago/src/api/rpc/*` (one RPC module per domain), plus `core/archipelago/src/` (background tasks) +- Contains: ~40 RPC method modules + 50+ core service modules (bootstrap.rs, health_monitor.rs, crash_recovery.rs, etc.) +- Depends on: StateManager, ContainerOrchestrator, config/secrets, external services (Bitcoin, Lightning, FIPS) +- Used by: RPC layer; other services for cross-cutting concerns (mesh, federation, webhooks) + +**State Management Layer:** +- Purpose: Hold canonical application state, broadcast changes to all connected clients, persist snapshots +- Location: `core/archipelago/src/state.rs` (StateManager + data_model.rs) +- Contains: RwLock, broadcast channel, revision counter +- Depends on: DataModel (serde-serializable struct tree) +- Used by: All services that mutate state (container ops, auth, settings) + +**Container Orchestration Layer:** +- Purpose: Podman lifecycle management, image verification, secret injection, crash recovery, adoption +- Location: `core/archipelago/src/container/prod_orchestrator.rs` (1M+ lines; split across boot_reconciler.rs, quadlet.rs, docker_packages.rs, etc.) +- Contains: Manifest parsing, image pull/verify, container create/start/stop, volume mounts, networking +- Depends on: Podman CLI + socket, config parser, image registries, local filesystem +- Used by: RPC container.* methods, BootReconciler loop, crash recovery + +**Frontend Layer (Vue 3):** +- Purpose: Render UI, dispatch RPC calls, maintain local UI state, handle user input +- Location: `neode-ui/src/` +- Contains: Views (pages), Components (reusable UI), Composables (logic hooks), Stores (Pinia), Router +- Depends on: Vue 3, Vue Router, Pinia, RPC client library (custom), D3/Leaflet (charts/maps) +- Used by: Browser clients (desktop, mobile, companion app via WebView) + +## Data Flow + +### Primary Request Path (User Action → Backend → State Sync) + +1. **Frontend user interaction** (click button, type input) → Vue component event handler + - Location: `neode-ui/src/views/*.vue` or `neode-ui/src/components/*.vue` + +2. **Composable dispatches RPC** (e.g., `useContainerInstall()` calls `rpc.container.install()`) + - Location: `neode-ui/src/composables/` (custom or imported from `api/rpc-client.ts`) + +3. **RPC client serializes → HTTP/WebSocket POST to /api/v0** + - Location: `neode-ui/src/api/rpc-client.ts` + - Payload: `{ jsonrpc: "2.0", method: "container.install", params: {...}, id: ... }` + +4. **HTTP Server receives, routes to ApiHandler** + - Location: `core/archipelago/src/server.rs` (listener) → `core/archipelago/src/api/handler/mod.rs` (dispatch) + +5. **ApiHandler checks auth**, extracts body, calls RpcHandler + - Location: `core/archipelago/src/api/handler/mod.rs:handle_request()` + +6. **RpcHandler dispatches by method name** to specific RPC module + - Location: `core/archipelago/src/api/rpc/mod.rs:call()` → routing to `core/archipelago/src/api/rpc/container.rs:install()` + +7. **Service method executes** (e.g., `container.rs:install()` calls orchestrator, updates state) + - Location: `core/archipelago/src/api/rpc/container.rs` (calls methods on ContainerOrchestrator) + +8. **StateManager.update_data()** broadcasts the new state to all WebSocket subscribers + - Location: `core/archipelago/src/state.rs:update_data()` → broadcast channel + - All connected WebSocket clients receive `{ rev: N, data: {...} }` update + +9. **Frontend receives state update**, updates Pinia stores, re-renders UI + - Location: `neode-ui/src/stores/` (Pinia stores mutate) → Vue reactivity chain → DOM update + +**State Management:** +- All reads from `StateManager` go through `get_snapshot()` which acquires read-lock +- All writes go through `update_data()` which acquires write-lock + increments revision +- Broadcast channel has ~100-message buffer; slow subscribers may lose old updates (by design — UI only needs latest) +- WebSocket clients re-sync on reconnect via `get_snapshot()` call (full state transfer) + +### Secondary Flow: Scheduled Reconciliation (Convergence Loop) + +1. **BootReconciler spawned at startup** in `main.rs` + - Location: `core/archipelago/src/main.rs` (line ~338-348) + +2. **Reconciler runs every `RECONCILER_DEFAULT_INTERVAL`** (~30s typical) + - Location: `core/archipelago/src/container/boot_reconciler.rs:run_forever()` + +3. **Compares desired manifests (disk + registry catalog) vs actual Podman state** + - Looks for: containers missing, containers orphaned, image updates, secret changes + +4. **Applies remediation** (create, delete, restart containers) + - Calls: orchestrator.reconcile_*() methods + +5. **Logs changes, broadcasts state update if anything changed** + - Frontend receives update, shows user the reconciled app state + +This ensures apps survive crashes, OTA updates, or manual Podman edits — the desired state always converges. + +## Key Abstractions + +**ContainerOrchestrator trait:** +- Purpose: Abstract container lifecycle behind a trait so Prod (Podman-based) and Dev (in-memory) modes can coexist +- Examples: `core/archipelago/src/container/prod_orchestrator.rs`, `core/archipelago/src/container/dev_orchestrator.rs` +- Pattern: Trait-based strategy; RpcHandler holds `Arc`, switches at runtime +- Methods: create, start, stop, delete, adopt, list, reconcile, install, upgrade + +**Manifest (YAML-based declarative app):** +- Purpose: Fully describe an app's container, dependencies, secrets, ports, UI in one file +- Examples: `/opt/archipelago/apps/*/manifest.yml` (on-disk) or registry-delivered catalogs +- Pattern: Custom extensions over OCI/Docker Compose (e.g., `interfaces.main.ui`, `generated_secrets`) +- Parsed into: `container::manifest::Manifest` struct, consumed by orchestrator + +**RPC Method Modules:** +- Purpose: Group related JSON-RPC methods by domain (auth, container, bitcoin, mesh, etc.) +- Examples: `core/archipelago/src/api/rpc/auth.rs`, `core/archipelago/src/api/rpc/bitcoin.rs` +- Pattern: Each module exports `pub async fn method_name(handler, params) -> Result` +- Registration: Hardcoded dispatch in `RpcHandler::call()` (no reflection; methods are explicit) + +**BlobStore (Content-Addressed):** +- Purpose: Store attachments/files by SHA-256 hash; issue time-limited capability tokens for access +- Examples: Used by mesh.send-content, federation attachments, backup archives +- Pattern: Capability-based access control (CBAC); tokens scoped to issuer pubkey + hash +- Located: `core/archipelago/src/blobs.rs` + `core/archipelago/src/content_server.rs` + +**StateManager + DataModel:** +- Purpose: Single source of truth for UI state; broadcast updates to all clients +- Pattern: Read-write lock over a serde-serializable struct tree; broadcast channel for efficiency +- Persistence: Most state is ephemeral (app listings, UI settings); durable state persists to disk separately +- Clients: Frontend (WebSocket subscriber), internal services (read via get_snapshot), monitoring/debug + +**Session Store:** +- Purpose: Track authenticated HTTP sessions (cookie → user identity mapping) +- Examples: SQLite-backed or in-memory store +- Pattern: Session token issued at login, validated on each request, expires after TTL +- Used by: ApiHandler auth check, rate limiter (per IP + per user) + +## Entry Points + +**Backend Daemon (Binary):** +- Location: `core/archipelago/src/main.rs` +- Triggers: `systemd start archipelago.service` or manual `./archipelago` on development node +- Responsibilities: Parse config, init tracing, load/reconcile containers, start HTTP server, spawn background tasks +- Key setup: Load identity → setup auth → spawn orchestrator → load manifests → start reconciler → start server + +**Frontend SPA:** +- Location: `neode-ui/src/main.ts` +- Triggers: Browser loads `/index.html` (served by HTTP server from `/opt/archipelago/web-ui/`) +- Responsibilities: Boot Vue app, setup Router, setup Pinia stores, establish WebSocket to backend +- Key setup: Mount app → router ready → fetch initial state → subscribe to updates + +**RPC Endpoints (HTTP + WebSocket):** +- Location: `core/archipelago/src/api/` (handler routes requests here) +- Endpoint: `/api/v0` (JSON-RPC 2.0 POST or WebSocket upgrade) +- Methods: ~200+ RPCs across domains (auth, container, bitcoin, mesh, federation, etc.) +- Example: `POST /api/v0` with body `{"jsonrpc": "2.0", "method": "auth.login", "params": {...}, "id": 1}` + +**Background Tasks (Spawned at startup):** +- BootReconciler: Periodic manifest reconciliation loop +- Health Monitor: Periodic app health checks + restart +- Update Scheduler: Periodic app update checks +- Mesh Service: P2P mesh listener + sender (federation, LoRa) +- Webhook Relay: Listens for inbound webhooks, broadcasts to subscribers +- WebSocket Listener: Upgraded HTTP connections → broadcast state subscriber +- See: `core/archipelago/src/main.rs` (lines ~400-450 show the spawned tasks) + +## Architectural Constraints + +- **Single event loop** — All I/O-bound work runs on a single Tokio multi-threaded runtime; no worker threads by default (some container ops are blocking, run in tokio::task::spawn_blocking) +- **Global state via broadcast** — StateManager broadcasts to all WebSocket clients; no request-response for state changes (async by design) +- **Container state mutability** — Podman state can drift from manifest (manual edits, crashes); reconciler runs periodically to converge +- **No in-process data consistency** — Multiple services can mutate StateManager concurrently; last write wins (fine for UI; critical ops use locks) +- **Shared blob store** — All services that need to share content use the same BlobStore instance (single cap_key, single root directory) +- **Rate limiting per IP + method** — Prevents brute-force login, but shared IPs see shared limits (edge case: family users, proxies) +- **Session cookie same-site** — WebSocket + HTTP POST must be same-origin; CORS headers controlled by ApiHandler + +## Anti-Patterns + +### Circular RPC Dispatches + +**What happens:** An RPC method calls back into another RPC method, forming a cycle (e.g., auth.login → container.list → auth.check_permission → auth.login) +**Why it's wrong:** Deadlocks on RwLocks, infinite loops on state broadcasts, unclear error messages, hard to debug +**Do this instead:** Pass check result as a side-effect from the outer method; compute permissions once at the start. Use composable patterns in frontend instead (e.g., `useCanInstall()` checks perms once per component mount). + +### Synchronous blocking in RPC handlers + +**What happens:** RPC method calls `.unwrap()` on Podman command result, blocking the entire event loop +**Why it's wrong:** One slow container op (e.g., large image pull) blocks all concurrent users +**Do this instead:** Use `tokio::task::spawn_blocking()` for I/O that may take >100ms. See `core/archipelago/src/container/docker_packages.rs` for examples. + +### Hardcoding paths in app RPC modules + +**What happens:** `bitcoin.rs` hardcodes `/opt/archipelago/data/bitcoin.conf` instead of using `config.data_dir` +**Why it's wrong:** Dev mode, tests, and alternate installs all fail with "not found" +**Do this instead:** Read from `Config` struct, which is passed to every RPC method. See `core/archipelago/src/api/rpc/bitcoin.rs:status()` for correct pattern. + +### Frontend state outside Pinia stores + +**What happens:** Components use component-local ref<> for app list, duplicate the StateManager's data +**Why it's wrong:** Stale data after OTA updates, inconsistent with other users on the same node, race conditions on install/uninstall +**Do this instead:** Always derive from Pinia stores (e.g., `useAppStore().apps`). Stores subscribe to WebSocket updates. See `neode-ui/src/stores/appStore.ts`. + +### Not handling WebSocket reconnection + +**What happens:** Frontend goes offline for 10s (network glitch), WebSocket closes, frontend doesn't re-sync state +**Why it's wrong:** UI shows stale data (app still "installing" when actually done), user clicks again, double-action happens +**Do this instead:** WebSocket reconnect handler should re-fetch full state (`node.status`, etc.), re-subscribe. See `neode-ui/src/api/rpc-client.ts` for the reconnect loop. + +## Error Handling + +**Strategy:** Defensive layering — errors are caught at each tier, logged, and converted to user-facing messages. + +**Patterns:** +- HTTP layer: 4xx/5xx with JSON error (no 500s for logic errors; only for crashes) +- RPC layer: Serialize error as `{ error: { code: N, message: "...", data: {...} } }` per JSON-RPC spec +- Service layer: Use `anyhow::Result` + `?` operator for early exit; convert to `RpcError` at handler boundary +- Frontend: Catch RPC errors, show toast/modal, log to console (never crash the app) + +**Critical paths:** +- Auth failure: 401 Unauthorized + "Invalid password" (no "user not found" to leak usernames) +- Container ops: If reconciler sees drift, logs it but continues (never crashes the daemon) +- Image pull failure: Fallback to last-cached version if network timeout (user is never blocked on external registries) +- Podman socket unavailable: Return 503 Service Unavailable (user sees "Archipelago is starting") + +--- + +*Architecture analysis: 2026-07-29* diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md new file mode 100644 index 00000000..33b335c3 --- /dev/null +++ b/.planning/codebase/CONCERNS.md @@ -0,0 +1,195 @@ +# Codebase Concerns + +**Analysis Date:** 2026-07-29 + +## Tech Debt + +**Federation node removal tombstone gap:** +- Issue: `federation::remove_node()` (`core/archipelago/src/federation/storage.rs:180-197`) calls `tombstone_did()` at line 193 but explicitly drops the error with `let _ = …`. If tombstone write fails (disk I/O, permission, transient), the peer is removed from `nodes.json` but never actually recorded as removed, so the next background sync/notify-join silently re-adds it. +- Files: `core/archipelago/src/federation/storage.rs:180-197`, `core/archipelago/src/api/rpc/federation/handlers.rs:272-300` +- Impact: Federation peers marked for removal can reappear after the next sync cycle, confusing the operator and potentially re-establishing unwanted connections. +- Fix approach: Surface the tombstone-write failure instead of swallowing it; consider retry logic with backoff; add integration test via `tests/multinode/smoke.sh` to verify removal sticks across sync cycles. + +**Container reconciler observability gap:** +- Issue: No metrics distinguish "settling after restart" from "flapping" — container thrashing is invisible until anecdotal reports. No per-app restart counter or log line when an app restarts >N times in M minutes. +- Files: `core/archipelago/src/container/prod_orchestrator.rs` (reconciler loop), `core/archipelago/src/health_monitor.rs` +- Impact: Silent restart storms go unnoticed; users see frequent service interruptions without diagnostics; operator can't distinguish normal convergence from a crash loop. +- Fix approach: Add per-app restart counter + log line when threshold exceeded; emit metric on each restart; wire restart count into health/status RPC output. + +**Failed systemd unit self-healing gap:** +- Issue: When a Quadlet-backed app's `.service` unit enters `failed` state (e.g., exit 255), the reconciler does not automatically `reset-failed` + `start` it. The unit sits failed until the operator manually intervenes or the service restarts. +- Files: `core/archipelago/src/container/prod_orchestrator.rs` (reconcile loop) +- Impact: Apps with transient failures go down and stay down; no automatic recovery; operator must manually reset or restart the orchestrator. +- Fix approach: Add reconcile step: quadlet-backed app whose `.service` is `failed` and not user-stopped → call `systemctl --user reset-failed ` + `start`; add backoff to avoid busy-loop on persistent failures. + +**Bitcoin RPC credentials not retrieved from config/secrets:** +- Issue: `core/container/src/bitcoin_simulator.rs:158` has a TODO marking hardcoded (or missing) RPC credentials in the Bitcoin simulator real-mode path. Credentials should be fetched from the secret store. +- Files: `core/container/src/bitcoin_simulator.rs:155-165` +- Impact: Bitcoin simulator in real mode (Testnet/Mainnet) cannot authenticate to the node; RPC calls fail. +- Fix approach: Inject `SecretsProvider` into `BitcoinSimulator::new()` or pass credentials as constructor args; fetch via `config/secrets` at runtime; handle credential rotation. + +**Container security policies not wired in:** +- Issue: `core/security/src/container_policies.rs` generates AppArmor/SELinux profiles but the `apply_profile()` function has a TODO at line 71: "Configure Podman to use the profile" — the profiles are generated but never applied to running containers. +- Files: `core/security/src/container_policies.rs:63-75` +- Impact: Security profiles exist but provide zero protection; containers run without the intended isolation constraints. +- Fix approach: Pass `--security-opt apparmor=` (or SELinux equivalent) to Podman at container creation; verify profile loads via `apparmor_status`; add CI check that profiles compile cleanly. + +**Dynamic resource adjustment not implemented:** +- Issue: `core/performance/src/resource_manager.rs:86` has a TODO for dynamic resource adjustment based on usage. The allocator is static; no adaptive rebalancing when load patterns shift. +- Files: `core/performance/src/resource_manager.rs:86-88` +- Impact: Resource allocation is rigid; a node with skewed usage (e.g., one app consuming all memory) has no mechanism to rebalance dynamically. +- Fix approach: Monitor per-app resource usage via cgroup stats; implement feedback loop to adjust limits; gate on production deployment (likely Phase 3+). + +## Known Bugs + +**Multinode RPC robustness gap:** +- Symptoms: The `node_rpc()` function in `tests/multinode/lib/multinode.bash` lacks `--max-time` on curl calls — a slow server-side RPC can hang the test suite indefinitely with zero feedback. +- Files: `tests/multinode/lib/multinode.bash` (exact line TBD; see grep for `node_rpc`) +- Trigger: Run multinode federation/mesh test against a slow or overloaded node; curl will block forever. +- Workaround: Manually kill the test process and diagnose the hanging RPC manually; no automatic timeout recovery. +- Fix approach: Add `--max-time 30` to all curl calls in `node_rpc()`; re-run `tests/multinode/smoke.sh` to verify. + +## Security Considerations + +**Secrets environment variable exposure risk:** +- Risk: Bitcoin and other service credentials are materialized as env vars in `ARCHIPELAGO_*` (e.g., `BITCOIN_RPC_PASSWORD`). Env vars are visible via `/proc//environ` and potentially logged. +- Files: `core/archipelago/src/container/prod_orchestrator.rs`, `core/container/src/manifest.rs`, `core/archipelago/src/api/rpc/package/config.rs` +- Current mitigation: Secrets are declared as `generated_secrets` in manifests and materialized 0600/rootless; the orchestrator avoids logging values. +- Recommendations: Audit all env-var passing to containers; consider switching high-sensitivity secrets (bitcoin RPC, LND macaroons) to file-based secrets mounted read-only; add audit logging for secret access. + +**Federation DID validation incomplete:** +- Risk: Federation peer DIDs are added via the RPC without cryptographic verification of ownership. A compromised peer could advertise arbitrary DIDs. +- Files: `core/archipelago/src/api/rpc/federation/handlers.rs` (add-node path), `core/archipelago/src/federation/storage.rs` +- Current mitigation: DIDs are stored locally; transitive federation discovery uses the tombstone list to block removed peers. +- Recommendations: Add DID-ownership proof (e.g., signed proof-of-identity) before accepting a peer's advertised DID; document the trust model; consider user warnings when adding peers. + +**AppArmor profiles overly permissive:** +- Risk: Generated AppArmor profiles use blanket `network,` instead of per-port/protocol rules. Readonly flag is checkbox only, not enforced per actual app needs. +- Files: `core/security/src/container_policies.rs:46-54` +- Current mitigation: None (profiles not applied). +- Recommendations: Refine per-app capabilities based on manifest's declared needs; add integration test verifying readonly mounts are enforced; apply profiles in development before prod. + +## Performance Bottlenecks + +**Container thrashing during reconcile:** +- Problem: Restarting `archipelago.service` SIGKILLs every container, forcing a full rebuild over several minutes. Uninstall + reinstall loops can cascade-trigger restarts. +- Files: `core/archipelago/src/container/prod_orchestrator.rs` (the reconciler's desired-state machine) +- Cause: Pre-Phase-3 architecture: containers run in systemd cgroup, not as independent Quadlet units. +- Improvement path: Phase-3 Quadlet default-flip (`config.rs:256`) — each app becomes an independent `.container` unit; restart only the affected app, not the entire cgroup. + +**Reconciler churn on boot:** +- Problem: Boot reconciler makes multiple passes reconciling drift; during each pass, containers may be recreated. Post-OTA health checks deliberately skip per-app container assertions because of restart-storm unpredictability. +- Files: `core/archipelago/src/container/prod_orchestrator.rs`, `core/archipelago/src/bootstrap.rs` +- Cause: Multi-pass reconciliation + no incremental diff detection. +- Improvement path: Consolidate reconciler into single pass for boot; cache manifest/config diffs to avoid redundant comparisons; add boot-only fast-path. + +**Bitcoin IBD on .198 stalled (disk I/O):** +- Problem: .198 bitcoin is mid-IBD with only 21% progress; disk is 448GB (below 1TB archival threshold); load is high (~3–5). +- Files: `tests/multinode-testing-plan.md` (documented issue) +- Cause: Undersized/slow disk; concurrent workload. +- Improvement path: User decision required: swap in a different node (already done for gate run, using .5 instead) or add storage + wait for sync. Not a code issue. + +## Fragile Areas + +**Uninstall + reinstall lifecycle:** +- Files: `core/archipelago/src/api/rpc/package/install.rs`, `core/archipelago/src/container/quadlet.rs:disable_remove()`, `neode-ui/src/components/AppCard.vue` +- Why fragile: Pre-2026-07-26, `quadlet::disable_remove()` called systemd + podman with no timeouts, causing hangs. Fixed by commit `71cc9ac4` (added `QUADLET_STOP_TIMEOUT`, SIGKILL escalation, reset-failed). AppCard was hardcoding uninstall bar to "stuck full-red" (fixed `9f17ba68`). Tests for reinstall/cascade are still opt-in. +- Safe modification: Any changes to the uninstall path must be tested via `cascade-uninstall.bats` (7/7 on .228); extend coverage to multi-container stacks (immich, btcpay). Verify on .228 before fleet roll. +- Test coverage: `tests/lifecycle/bats/cascade-uninstall.bats` exists but not in canonical gate; must opt-in with `ARCHY_GATE_CASCADE=1`. + +**Production orchestrator state machine:** +- Files: `core/archipelago/src/container/prod_orchestrator.rs` (6291 lines) +- Why fragile: Largest file in the codebase; owns install/start/stop/restart/remove/upgrade for every app; per-app mutex + RwLock concurrency model; complex dependency resolution, adoption scan, Quadlet rendering, and host-port-wait logic interleaved. +- Safe modification: Understand the per-app mutex protocol before touching state mutation; test all changes via the lifecycle gate on .228; use the adoption scan + manifest merge logic for any new manifest evolution. +- Test coverage: 667 unit tests green (2026-07-01); lifecycle gate covers ~8 core apps; ~30 apps untested in gate. + +**Mesh radio configuration + boot race:** +- Files: `core/archipelago/src/mesh/meshtastic.rs`, `core/archipelago/src/mesh/mod.rs`, tests at `tests/lifecycle/bats/meshtastic.bats` +- Why fragile: Radio boot-race fixed (2026-07-28, `a8c4694c`/`3f76b496`); on-air config apply must finish before device is used. Earlier versions had probe-boot-race + live config propagation issues. Must verify on real hardware. +- Safe modification: Any mesh changes require E2E test on real LoRa radios (dev-box ↔ x250-dev, or fleet broadcast); unit tests alone won't catch RF timing issues. +- Test coverage: 8-stage on-air smoke test in `tests/multinode/meshtastic.sh` (run manually; not in canonical gate). + +**Lightning payment state machine:** +- Files: `core/archipelago/src/api/rpc/lnd/wallet.rs:payinvoice()` +- Why fragile: Slow multi-hop payments (>15s) previously surfaced as "failed" while settling in background; client-side 15s timeout was aborting the wait. Fixed by commit `614a0f5a` (120s wait, pending status, lnd.paymentstatus poll). Must verify on Framework PT with real multi-hop. +- Safe modification: Any lnd state changes must test full payment lifecycle: invoice creation, encoding, send, multi-hop wait, settlement confirmation. Verify on Framework PT before release. +- Test coverage: Local LND payinvoice smoke test; no multinode lightning routing test in gate. + +## Scaling Limits + +**Uninstall progress bar truthfulness:** +- Current capacity: Uninstall now has timeouts (fixed 2026-07-26) but progress-bar still reports fake stages (full-red full-opacity). +- Limit: Long uninstalls (>30s) show no real progress; bar claims "uninstalling" for the full duration. +- Scaling path: Backend must emit real progress events (% complete, stage name); UI must poll + display truthfully; integrate into all 5 gate iterations (not just 1 throw-away app). + +**Federation node list deduplication on disk bloat:** +- Current capacity: `federation/storage.rs:dedup_nodes_by_onion()` reads entire nodes.json into memory each time a node is added/synced. At N federated peers, O(N) memory + O(N²) comparisons per operation. +- Limit: No hard limit measured; scales fine up to hundreds of peers. Beyond 1000+ peers, memory/time may become visible. +- Scaling path: Switch to a disk-backed database (e.g., rocksdb) for federation state if peer count grows; or implement incremental dedup on disk writes (preserve dedup state, only recompute on load). + +**Lifecycle gate iteration count:** +- Current capacity: `ARCHY_ITERATIONS=5` runs 5 full cycles (stop/start/restart/survive per app). Entire run takes ~8–12 hours on .228. +- Limit: Cannot easily scale to 10+ iterations without timeout risks; per-app timeout tuning is manual. +- Scaling path: Add per-app timeout tuning (manifest field); parallelize per-app tests where safe (currently serial to avoid contention). + +## Dependencies at Risk + +**Reticulum transport daemon process group:** +- Risk: Pre-fix (before `be50c886`), process group wasn't cleaned up on drop. Fork-bombs or dangling processes possible under error conditions. +- Impact: Stale reticulum processes accumulating over time; resource leaks on node. +- Migration plan: Code fix already deployed (commit `7a7fec21`); no active risk. Monitor fleet for stale python processes post-deployment. + +**Podman socket mount security model:** +- Risk: Apps mounting `/run/podman/podman.sock` get full container-management access. Not restricted by the security policy (AppArmor profiles not applied). +- Files: `core/archipelago/src/container/prod_orchestrator.rs:135-137` (detection), manifests for apps with podman mounts (e.g., portainer) +- Impact: A compromised app with podman socket access can start/stop/delete any container on the node. +- Recommendation: Restrict podman socket mounts to admin-only apps (portainer, docker-api tools); document risk; consider socket filtering layer (selinux context, etc.) once AppArmor is wired. + +**Bitcoin version multi-version branch not fleet-wide:** +- Risk: Branch `bitcoin-version-bulletproof` (base `095a76cd`) carries multi-version support but hasn't been deployed fleet-wide yet. .228 carries it; others still run single version. +- Impact: Users on single-version nodes can't switch versions; version mismatch across fleet breaks federation. +- Migration plan: Coordinated OTA + catalog publish + `:latest` repoint sequencing per `docs/bitcoin-version-bulletproof-rollout.md`. Awaiting user decision on timing. + +## Missing Critical Features + +**Developer tooling CLI suite:** +- Problem: Third-party developers need `archy app validate/render/local-install/lifecycle-test` tooling before external registry launches. +- Blocks: External marketplace (workstream C); external developer onboarding. +- Status: Not yet built; documented in APP-PACKAGING-MIGRATION-PLAN.md step 5. + +**Manifest-distributed registry flip:** +- Problem: Manifests still travel via OTA disk rsync. The signed catalog currently distributes only image overrides, not full manifests. Workstream B phases 1+2 done; not yet fleet-deployed. +- Blocks: Cannot confidently add/bump apps without re-signing the catalog. +- Status: Code ready; flip awaits authorization + timing call from user. + +**Phase-3 Quadlet default-flip:** +- Problem: Orchestrator still uses legacy cgroup-based container management; Phase-3 `use_quadlet_backends` switch exists but is opt-in only. +- Blocks: Resolves container thrashing; unlocks independent app restarts; unblocks lifecycle perfection (workstream F). +- Status: Code validated on .228/.198 (commit pending); ready to flip when multinode gate passes. + +## Test Coverage Gaps + +**~30 apps with zero app-specific assertions:** +- What's not tested: Apps like grafana, jellyfin, vaultwarden, penpot, nextcloud, photoprism, uptime-kuma, homeassistant, etc. have no app-specific health checks beyond "container running." +- Files: `tests/lifecycle/bats/all-apps-matrix.bats`, `tests/lifecycle/bats/all-apps-lifecycle.bats` (generic baseline coverage) +- Risk: App-specific bugs (API down, data corruption, dependency failure) go unnoticed until user encounters them. +- Priority: Medium — baseline coverage is a real safety net; app-specific assertions are a "nice to harden" backlog item, not a gate blocker. +- Approach: Add per-app health RPC endpoints or HTTP probes; wire into the gate as opt-in per-app test suites. + +**Progress UI assertions incomplete:** +- What's not tested: Install + uninstall must report monotonic, truthful progress. No stage/percentage assertions in the gate. +- Files: `neode-ui/src/components/AppCard.vue`, `core/archipelago/src/api/rpc/package/install.rs` (backend progress events) +- Risk: Silent hangs or fake progress bars are invisible to the gate. +- Priority: High — immich/grafana uninstall was stuck full-red (fixed); progress truthfulness is part of definition of done for workstream F. +- Approach: Backend must emit real progress events; UI must display & test them; integrate into canonical gate (currently opt-in). + +**All-apps matrix in cascade gate:** +- What's not tested: `ARCHY_GATE_CASCADE=1` runs ONE throwaway app's uninstall/reinstall. Must extend to multi-container stacks (immich, btcpay, mempool) and all ~40 installed apps. +- Files: `tests/lifecycle/bats/cascade-uninstall.bats` (single-app variant) +- Risk: Multi-container app uninstall bugs (e.g., orphan postgres container) go undetected. +- Priority: High — part of workstream F definition of done. +- Approach: Parametrize cascade test over all manifest IDs; run 5 cascades total (not 5 per app to save time); gate-pass requires zero ghost containers post-uninstall. + +--- + +*Analysis based on codebase state 2026-07-29. Issues tracked in `docs/UNIFIED-TASK-TRACKER.md` (day-to-day) and `docs/PRODUCTION-MASTER-PLAN.md` (historical narrative).* diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md new file mode 100644 index 00000000..2cd4f060 --- /dev/null +++ b/.planning/codebase/CONVENTIONS.md @@ -0,0 +1,159 @@ +# Coding Conventions + +**Analysis Date:** 2026-07-29 + +## Naming Patterns + +**Files:** +- TypeScript/Vue: PascalCase for components (e.g., `ToggleSwitch.vue`, `SendBitcoinModal.vue`), camelCase for composables and stores (e.g., `useFileType.ts`, `controller.ts`) +- Rust: snake_case for modules and files (e.g., `bitcoin_rpc.rs`, `storage_crypto.rs`) +- Test files: co-located with source in `__tests__/` subdirectories with `.test.ts` or `.spec.ts` suffix for Vitest, `.bats` for shell tests +- Constants in TypeScript use UPPER_SNAKE_CASE within modules (e.g., `IMAGE_EXTS`, `CATEGORY_COLORS` in `useFileType.ts`) + +**Functions:** +- TypeScript/Vue: camelCase for all functions (e.g., `getFileCategory`, `formatSize`, `useFileType`) +- Composables: `use` prefix for Vue composables (e.g., `useFileType`, `useToast`, `useMessageToast`) — exported as named exports or default exports +- Store functions (Pinia): defined with snake_case action names, exported from `defineStore` factory +- Rust: snake_case for all functions and methods (e.g., `doesnt_reallocate`, following Rust conventions) + +**Variables:** +- TypeScript: camelCase for local variables and reactive refs (e.g., `modelValue`, `isActive`, `gamepadCount`) +- Refs (Vue 3): prefix not required, but convention is lowercase start (e.g., `const ext = ref('jpg')`) +- Computed properties: camelCase, explicit `.value` suffix in templates when needed +- Parameters: camelCase, typed explicitly in TypeScript (e.g., `password: string`, `isDir: Ref`) + +**Types:** +- TypeScript: PascalCase for type aliases and interfaces (e.g., `RPCOptions`, `FileCategory`, `CatalogVersionInfo`) +- Union types: PascalCase (e.g., `PendingState = 'pending' | 'sent' | 'approved'`) +- Component props: typed with `defineProps<{ ... }>()` syntax in `';` + — a classic (non-module) script injected at end of head still executes + BEFORE the SPA's deferred module bundle, which is what the seeding needs. + Add `location = /__demo/indee-demo-signin.js { root /usr/share/nginx/html; }` + (or alias) inside the 2101 server so the seed script is served same-origin + to the iframe. Update the comment block that currently explains why + IndeeHub is not proxied (lines ~106-109) to describe the new :2101 design. + + Create neode-ui/docker/indee-demo-signin.js: a small plain-JS classic + script, clearly headed with a comment stating it is PUBLIC-DEMO-ONLY and + that the embedded key is a freshly generated THROWAWAY demo identity, not + a real secret. Generate ONE fresh secp256k1 keypair at implementation time + (e.g. `node -e` with a tiny script using any available schnorr/secp lib, or + a one-off `npx` of nostr-tools in the scratchpad — the generator itself is + not committed) and embed hex sk + hex pk as constants. The script: if + `localStorage.getItem('indeedhub-accounts')` is empty/absent, write the + two keys IndeeHub's boot-restore reads — `indeedhub-accounts` (JSON array + with ONE serialized private-key account: verify the exact `type` string + and common-field shape against the live bundle per verified_findings, shape + `{ id, type, pubkey, signer: { key } }` + whatever `loadCommonFields` + round-trips, give it a friendly name/metadata like "Archy Demo" if the + shape supports it) and `indeedhub-active-account` (that account's id). + Because the script runs on the :2101 origin inside the iframe, this + touches only the proxied app's isolated storage. IndeeHub then restores + the account on boot and self-signs with its own bundled signer — no + window.nostr and no parent bridge required. Do NOT define a partial + `window.nostr` in this approach (a pubkey-only shim with a broken + signEvent causes worse failures than no shim). + + FALLBACK (only if live testing in Task-3 verification shows the seeded + account shape is not accepted): seed an `"extension"`-type account + instead, define a `window.nostr` postMessage client in this same script + (request/response protocol matching useNostrBridge: post + `{type:'nostr-request', id, method, params}` to `window.parent`, resolve on + `{type:'nostr-response', id, ...}`), and implement `node.nostr-sign` / + `identity.nostr-sign` in mock-backend.js with real schnorr signatures over + the same throwaway key (add `nostr-tools` to neode-ui dependencies — it is + pure JS and Dockerfile.backend runs `npm install` over package.json). + Prefer the primary approach; only fall back with evidence. + + Wire the plumbing: `EXPOSE 2101` in Dockerfile.web (the seed script is + already inside `neode-ui/` so the existing `COPY neode-ui/ ./` + + dist copy do NOT ship it — add an explicit + `COPY neode-ui/docker/indee-demo-signin.js /usr/share/nginx/html/__demo/indee-demo-signin.js` + in the nginx stage of Dockerfile.web; it lands only in the demo web image, + never in real-node artifacts). Publish the port in docker-compose.demo.yml + (`"2101:2101"` on neode-web) and demo-deploy/docker-compose.yml (use an + env-overridable mapping consistent with its existing `DEMO_WEB_PORT` + style, e.g. `"${DEMO_INDEE_PORT:-2101}:2101"`, and document it in that + file's header comment). Read docker-entrypoint.sh first and make sure the + new server block survives its template substitution exactly like the + existing blocks (same escaping convention for nginx `$` variables); touch + the entrypoint only if its substitution list needs it. + + Do not put any host IP in any of these files; upstream hostname + indee.tx1138.com is fine. + + + docker run --rm -v "$PWD/neode-ui/docker/nginx-demo.conf:/etc/nginx/nginx.conf:ro" nginx:alpine nginx -t (or, if docker unavailable locally, `nginx -t -c` via a podman run — config must parse). Plus: grep -c "2101" neode-ui/docker/nginx-demo.conf docker-compose.demo.yml demo-deploy/docker-compose.yml neode-ui/Dockerfile.web — each ≥1; grep -q "indee-demo-signin" neode-ui/docker/nginx-demo.conf && grep -qi "throwaway" neode-ui/docker/indee-demo-signin.js + + nginx config parses with the new :2101 whole-origin proxy block (framing headers stripped, sub_filter injection, WS upgrade); seed script exists with labelled throwaway demo key and idempotent localStorage seeding; both compose files publish 2101; demo web image copies the script and exposes the port; no host IPs added anywhere. + + + + Task 2: demo frontend — iframe launch via :2101 and no identity-picker wall + neode-ui/src/composables/useDemoIntro.ts, neode-ui/src/views/appSession/useAppIdentity.ts + + In useDemoIntro.ts: remove `indeedhub` from `DEMO_EXTERNAL_URLS` (delete + the map entirely if it becomes empty, simplifying `isDemoExternal` to + return false — keep the exported function so call sites in appLauncher.ts + and AppSession.vue compile unchanged). Make `demoAppUrl('indeedhub')` + return the proxied origin built at runtime: + `${window.location.protocol}//${window.location.hostname}:2101/` + (hostname, never a hardcoded host/IP — works on any deploy host). Keep + `isDemoApp('indeedhub')` true (it must stay in the demoable set so the + NEW_TAB bypass in appLauncher.openSession and AppSession.mustOpenNewTab + keeps routing it into the in-app iframe session, and so the install + button stays enabled). Update the file-header comment block that + currently documents the external-tab workaround to describe the :2101 + whole-origin proxy design instead. SSR-safety is not a concern (Vite SPA) + but guard `typeof window !== 'undefined'` if other tests import the module + in node context — check the existing unit tests under + src/views/appSession/__tests__/ and src/stores/__tests__/ for assertions + about indeedhub being demo-external and update them to the new behavior. + + In useAppIdentity.ts: gate the picker for the demo. Import IS_DEMO from + useDemoIntro and in `onIframeLoadIdentity` / `handleIdentityRequest`, + when IS_DEMO is true, never set `showIdentityPicker` — the demo visitor + must not be interrupted by an identity modal (the embedded IndeeHub is + already signed in via the seeded account from Task 1, and `sendIdentity`'s + `identity.sign` RPC is not what logs it in). Real-node behavior + (picker on first launch) is untouched because IS_DEMO is compile-time + false there. + + + cd neode-ui && npx vitest run src/views/appSession src/stores --silent 2>&1 | tail -5 (all green) && VITE_DEMO=1 npm run build && grep -rq "2101" dist/assets && npm run build && grep -rq "indee.tx1138.com" dist/assets && echo BUNDLE-OK + + Demo build (VITE_DEMO=1) bundle contains the :2101 launch logic (grep hit proves the build didn't silently no-op — per CLAUDE.md); plain build still compiles and demo-gated branches do not alter non-demo behavior; unit tests updated and green; launching indeedhub in demo resolves to the same-host :2101 origin in the iframe session; identity picker suppressed only under IS_DEMO. + + + + Task 3: mock backend — IndeeHub pre-installed on fresh demo sessions + neode-ui/mock-backend.js + + Add an `indeedhub` entry to `staticDevApps` in mock-backend.js using the + existing `staticApp({...})` helper: id `indeedhub`, title `Indeehub` + (match the existing title map at ~line 537 and APP_TITLES), a short/long + description consistent with the marketplace copy ("Bitcoin documentary + streaming platform" per the existing entry), `state: 'running'`, + `lanPort: 8190` (matches the existing port map), icon + `/assets/img/app-icons/indeedhub.png`. Because per-session demo state is + `structuredClone(staticDevApps)`, this alone makes it installed+running on + every fresh session. Then reconcile the rest of the mock so nothing + contradicts installed status: check the marketplace/available-apps mock + responses and any install/uninstall handlers (~lines 540-740, 1900-1960, + 4900+) for `indeedhub` entries that would render it as not-installed or + double-listed, and check `DEMO_APP_PAGES` does NOT grow an indeedhub + placeholder (the demo launch URL bypasses /app/indeedhub/ entirely — the + iframe goes to the :2101 origin). Keep the existing `node.nostr-pubkey` + mock as-is unless Task 1's fallback path was taken (in which case align + its pubkey with the throwaway demo key and add the sign handlers described + there). + + + cd neode-ui && node -e "const s=require('fs').readFileSync('mock-backend.js','utf8'); if(!/staticDevApps[\s\S]*?indeedhub:\s*staticApp/.test(s)) process.exit(1)" && (DEMO=1 timeout 20 node mock-backend.js & sleep 4; curl -s -X POST localhost:5959/rpc/v1 -H 'content-type: application/json' -d '{"method":"server.data","id":1}' -H 'cookie: demo=fresh' | grep -o '"indeedhub"' | head -1; kill %1 2>/dev/null) — expect an indeedhub hit in fresh-session package-data (adapt the RPC method/auth to what the mock actually serves; a login with the demo password first is fine) + + A fresh demo session's package-data includes indeedhub as installed and running with launchable UI; My Apps shows it without an install step; no duplicate/contradictory indeedhub listing in marketplace mocks; mock backend boots cleanly with DEMO=1. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| demo nginx :2101 → indee.tx1138.com | demo host proxies an external site; upstream content is served under the demo host | +| iframe (:2101 origin) ↔ parent (:2100 origin) | cross-origin; parent NIP-07 bridge only used in fallback path | +| public visitors → demo host | anyone can drive the proxy | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-gjd-01 | Spoofing | throwaway demo nostr key | low | accept | key is a labelled public demo identity by design; generated fresh, never a real user key; anyone extracting it can only impersonate "the demo visitor" | +| T-gjd-02 | Info disclosure | private release-server IP in served content | high | mitigate | no host IPs added in any changed file; iframe URL derived from window.location.hostname; existing Docker-build scrub+fail guards remain the backstop | +| T-gjd-03 | Tampering | open reverse proxy on :2101 | medium | mitigate | proxy is pinned to a single upstream host (proxy_pass fixed hostname + proxy_ssl_name), no dynamic upstreams, no request-driven destinations — it cannot be used as an open proxy | +| T-gjd-04 | Elevation | header stripping (X-Frame-Options/CSP) | low | accept | stripping applies only to the :2101 demo proxy of one known site, demo image only; real-node builds never carry this config | +| T-gjd-SC | Tampering | npm installs | low | accept | primary path adds no dependencies; fallback path adds only nostr-tools (well-known, verify on npmjs.com before install) | + + + +Local (executor, before commit): +1. nginx config parses (Task 1 verify). +2. Unit tests green; VITE_DEMO=1 build contains ":2101" logic; plain build + unaffected (Task 2 verify). Note: demo-gated strings are tree-shaken out of + the plain build — that is EXPECTED; the bundle-grep for demo strings must be + done on the VITE_DEMO=1 build, which is exactly what the demo Docker image + builds (Dockerfile.web defaults ARG VITE_DEMO=1). +3. Fresh-session mock package-data includes indeedhub (Task 3 verify). +4. Optional full-stack smoke: `docker compose -f docker-compose.demo.yml up + --build` locally, browse http://localhost:2100 in a private window → + login `entertoexit` → IndeeHub installed → launch → iframe renders the + proxied site from http://localhost:2101 with a signed-in account. +5. `git status` — confirm nothing under indeedhub/ is staged, ever. + +Post-deploy on vps2 (orchestrator deploys; verify on http://146.59.87.168:2100): +1. `curl -sI http://146.59.87.168:2101/` returns 200 with NO X-Frame-Options + header and the injected `indee-demo-signin.js` tag in the HTML body + (`curl -s http://146.59.87.168:2101/ | grep indee-demo-signin`). If the + port is unreachable, the vps2 firewall needs 2101 opened — flag to + orchestrator. +2. Fresh private browser window → :2100 → login → IndeeHub shows installed/ + running on the dashboard/My Apps without any install action. +3. Launch IndeeHub → renders inside the in-app iframe (panel/overlay), not a + new tab; content browsable; no identity-picker modal. +4. Signed-in check: IndeeHub header shows an active account (avatar/profile + instead of a sign-in button). If the seeded account shape was rejected + (login wall still visible), execute the documented fallback (extension + account + window.nostr shim + mock signer) and redeploy. +5. View-source/network spot-check: no occurrence of the private + release-server IP in any served response. +6. Repeat-visit check: reload the iframe once — a service worker registered by + IndeeHub may serve cached HTML without the injected tag on later loads; + that is acceptable because localStorage is already seeded on first load, + but confirm sign-in persists. + + + +- Demo visitor on a fresh browser sees IndeeHub installed, launches it into + the in-app iframe, and browses indee.tx1138.com content signed in — zero + clicks spent on install/login/identity modals. +- Real-node build behavior unchanged (all changes IS_DEMO- or demo-image-gated). +- No secrets committed beyond the labelled throwaway demo key; nothing staged + under indeedhub/; demo serves no private release-server IP. +- Work committed in focused commits (infra / frontend / mock) with the + Co-Authored-By trailer and pushed via gitea-ai per CLAUDE.md; docs left to + the orchestrator. + + + +Create `.planning/quick/260729-gjd-demo-make-indee-tx1138-com-work-in-the-a/260729-gjd-SUMMARY.md` when done. + diff --git a/.planning/quick/260729-gjd-demo-make-indee-tx1138-com-work-in-the-a/260729-gjd-SUMMARY.md b/.planning/quick/260729-gjd-demo-make-indee-tx1138-com-work-in-the-a/260729-gjd-SUMMARY.md new file mode 100644 index 00000000..15f50ae7 --- /dev/null +++ b/.planning/quick/260729-gjd-demo-make-indee-tx1138-com-work-in-the-a/260729-gjd-SUMMARY.md @@ -0,0 +1,107 @@ +--- +phase: quick-260729-gjd +plan: 01 +subsystem: public-demo +tags: [demo, indeedhub, nginx, reverse-proxy, nostr, mock-backend] +requires: [] +provides: + - "IndeeHub whole-origin demo proxy on :2101 (framing headers stripped, sign-in seeded)" + - "Demo iframe launch of indeedhub via demoAppUrl → :2101/" + - "IndeeHub pre-installed/running on every fresh demo session" +affects: [demo-deploy, neode-ui demo image] +tech-stack: + added: [] + patterns: + - "Whole-origin per-port reverse proxy for frame-busting external SPAs (vs broken path-prefix sub_filter)" + - "localStorage seeding via injected classic script on the proxied origin (applesauce-accounts nsec account)" +key-files: + created: + - neode-ui/docker/indee-demo-signin.js + modified: + - neode-ui/docker/nginx-demo.conf + - neode-ui/Dockerfile.web + - docker-compose.demo.yml + - demo-deploy/docker-compose.yml + - neode-ui/src/composables/useDemoIntro.ts + - neode-ui/src/views/appSession/useAppIdentity.ts + - neode-ui/mock-backend.js +decisions: + - "Primary sign-in path used (seeded nsec account, self-signing) — NIP-07 bridge fallback NOT needed; verified against the live bundle" + - "Dropped `sub_filter_types text/html` (text/html is nginx's default sub_filter type; explicit listing produced a duplicate-MIME warning)" +metrics: + duration: "~50 min" + completed: 2026-07-29 +status: complete +--- + +# Quick Task 260729-gjd: IndeeHub in the Demo Summary + +**One-liner:** Whole-origin nginx proxy of indee.tx1138.com on :2101 with an injected throwaway-nsec sign-in seeder, demo iframe launch via same-host :2101, and IndeeHub pre-installed in every fresh mock-backend session. + +## Commits + +| Task | Commit | Scope | +|------|--------|-------| +| 1 | 69bc3d3f | nginx :2101 whole-origin proxy + indee-demo-signin.js seeder + Dockerfile.web COPY/EXPOSE + both compose files publish 2101 | +| 2 | 66d540f8 | useDemoIntro: DEMO_EXTERNAL_URLS → DEMO_PROXY_PORTS, demoAppUrl builds `//:2101/`; useAppIdentity: picker suppressed under IS_DEMO | +| 3 | d00ca624 | mock-backend.js staticDevApps gains indeedhub (running, lanPort 8190) → installed on every fresh session | + +## What was verified at exec time (live-bundle facts) + +- Live site still serves `X-Frame-Options: SAMEORIGIN`, no CSP; bundle `assets/index-BMWtjRCn.js`. +- Account serialization confirmed by de-minifying the live bundle: private-key account class has `static type="nsec"`, `toJSON` → `{ signer: { key: }, id, pubkey, metadata, type }`; the manager registers the nsec type (`MM(Fe)` registers `mr`) and restores from `indeedhub-accounts` + activates by id from `indeedhub-active-account`. `Vn`/`je` confirmed hex decode/encode. +- Pubkey math independently validated against BIP340 test vectors (sk=1 → Gx, sk=3 → F9308A01…) before embedding the generated pair. Mismatch would trigger the bundle's "Account signer mismatch" guard, so this was load-bearing. + +## Throwaway demo identity + +Freshly generated 2026-07-29 for this task (generator ran in scratchpad, not committed): +- pk `7261540160244ec65ce0bf86ba03997e9b1b3b35c277e416bf1c7ba4271fee31` +- sk embedded in `neode-ui/docker/indee-demo-signin.js`, clearly labelled PUBLIC-DEMO-ONLY / not a secret (threat T-gjd-01: accepted by design). Never a real user key. + +## Local verification results + +1. **nginx parse:** `nginx -t` clean in `nginx:alpine` (podman, with `--add-host neode-backend:127.0.0.1` to satisfy the pre-existing upstream reference). +2. **Live proxy smoke (podman, config + seeder mounted):** `curl` through :2101 → 200, **no X-Frame-Options / CSP**, injected ` + + diff --git a/Android/app/src/main/java/com/archipelago/app/ArchipelagoApp.kt b/Android/app/src/main/java/com/archipelago/app/ArchipelagoApp.kt new file mode 100644 index 00000000..ed001ab1 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ArchipelagoApp.kt @@ -0,0 +1,5 @@ +package com.archipelago.app + +import android.app.Application + +class ArchipelagoApp : Application() diff --git a/Android/app/src/main/java/com/archipelago/app/MainActivity.kt b/Android/app/src/main/java/com/archipelago/app/MainActivity.kt new file mode 100644 index 00000000..3e066864 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/MainActivity.kt @@ -0,0 +1,41 @@ +package com.archipelago.app + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import com.archipelago.app.ui.navigation.AppNavHost +import com.archipelago.app.ui.theme.ArchipelagoTheme +import kotlinx.coroutines.flow.MutableStateFlow + +class MainActivity : ComponentActivity() { + + // Pairing deep link (archipelago://pair?...) from the launch intent or a + // later one (launchMode=singleTask). Consumed by AppNavHost. + private val pendingPairUri = MutableStateFlow(null) + + override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() + enableEdgeToEdge() + super.onCreate(savedInstanceState) + pendingPairUri.value = intent?.dataString + setContent { + ArchipelagoTheme { + val pairUri by pendingPairUri.collectAsState() + AppNavHost( + pairUri = pairUri, + onPairUriConsumed = { pendingPairUri.value = null }, + ) + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + pendingPairUri.value = intent.dataString + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt b/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt new file mode 100644 index 00000000..6d0458aa --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt @@ -0,0 +1,241 @@ +package com.archipelago.app.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.dataStore: DataStore by preferencesDataStore(name = "server_prefs") + +data class ServerEntry( + val address: String, + val useHttps: Boolean, + val port: String = "", + val password: String = "", + val name: String = "", + /** Node's FIPS mesh ULA (IPv6) — reachable from anywhere once meshed. */ + val meshIp: String = "", + /** Node's FIPS npub — the durable identity. When present it, not the + * address, is what identifies the entry: FIPS peers on npubs, IPs are + * only dial hints (docs/companion-pairing-qr.md, npub-first contract). */ + val npub: String = "", +) { + /** Label to show in lists — the user-given name, or the address if unnamed. */ + fun displayName(): String = name.ifBlank { address } + + /** Bracket bare IPv6 literals (the mesh ULA) so they form valid URLs. */ + private fun urlHost(host: String): String = + if (host.contains(":") && !host.startsWith("[")) "[$host]" else host + + fun toUrl(): String { + val scheme = if (useHttps) "https" else "http" + val portSuffix = if (port.isNotBlank()) ":$port" else "" + return "$scheme://${urlHost(address)}$portSuffix" + } + + fun toWsUrl(): String { + val scheme = if (useHttps) "wss" else "ws" + val portSuffix = if (port.isNotBlank()) ":$port" else "" + return "$scheme://${urlHost(address)}$portSuffix" + } + + /** Mesh-address UI URL, or null when the node never advertised one. */ + fun toMeshUrl(): String? = + meshIp.takeIf { it.isNotBlank() }?.let { "http://${urlHost(it)}" } + + // name/meshIp/npub are trailing fields so entries saved before they + // existed (4/5/6 fields) still deserialize, defaulting to "". + fun serialize(): String = "$address|$useHttps|$port|$password|$name|$meshIp|$npub" + + /** Same node as [other]? npub identity wins; address/port/scheme is the + * fallback for LAN-only entries that never advertised FIPS. */ + fun sameNode(other: ServerEntry): Boolean = + (npub.isNotBlank() && npub == other.npub) || + (address == other.address && port == other.port && useHttps == other.useHttps) + + companion object { + fun deserialize(raw: String): ServerEntry? { + val parts = raw.split("|") + if (parts.size < 2) return null + return ServerEntry( + address = parts[0], + useHttps = parts[1].toBooleanStrictOrNull() ?: false, + port = parts.getOrElse(2) { "" }, + password = parts.getOrElse(3) { "" }, + name = parts.getOrElse(4) { "" }, + meshIp = parts.getOrElse(5) { "" }, + npub = parts.getOrElse(6) { "" }, + ) + } + } +} + +class ServerPreferences(private val context: Context) { + + private val activeAddressKey = stringPreferencesKey("active_address") + private val activeHttpsKey = booleanPreferencesKey("active_https") + private val activePortKey = stringPreferencesKey("active_port") + private val activePasswordKey = stringPreferencesKey("active_password") + private val activeNameKey = stringPreferencesKey("active_name") + private val activeMeshIpKey = stringPreferencesKey("active_mesh_ip") + private val activeNpubKey = stringPreferencesKey("active_npub") + private val savedServersKey = stringSetPreferencesKey("saved_servers") + private val introSeenKey = booleanPreferencesKey("intro_seen") + private val gestureHintSeenKey = booleanPreferencesKey("gesture_hint_seen") + + val activeServer: Flow = context.dataStore.data.map { prefs -> + val address = prefs[activeAddressKey] ?: return@map null + ServerEntry( + address = address, + useHttps = prefs[activeHttpsKey] ?: false, + port = prefs[activePortKey] ?: "", + password = prefs[activePasswordKey] ?: "", + name = prefs[activeNameKey] ?: "", + meshIp = prefs[activeMeshIpKey] ?: "", + npub = prefs[activeNpubKey] ?: "", + ) + } + + val savedServers: Flow> = context.dataStore.data.map { prefs -> + val raw = prefs[savedServersKey] ?: emptySet() + raw.mapNotNull { ServerEntry.deserialize(it) } + } + + val introSeen: Flow = context.dataStore.data.map { prefs -> + prefs[introSeenKey] ?: false + } + + /** One-shot flag for the three-finger-hold teaching overlay. */ + val gestureHintSeen: Flow = context.dataStore.data.map { prefs -> + prefs[gestureHintSeenKey] ?: false + } + + suspend fun setActiveServer(server: ServerEntry) { + context.dataStore.edit { prefs -> + prefs[activeAddressKey] = server.address + prefs[activeHttpsKey] = server.useHttps + prefs[activePortKey] = server.port + prefs[activePasswordKey] = server.password + prefs[activeNameKey] = server.name + prefs[activeMeshIpKey] = server.meshIp + prefs[activeNpubKey] = server.npub + } + addSavedServer(server) + } + + suspend fun clearActiveServer() { + context.dataStore.edit { prefs -> + prefs.remove(activeAddressKey) + prefs.remove(activeHttpsKey) + prefs.remove(activePortKey) + prefs.remove(activePasswordKey) + prefs.remove(activeNameKey) + prefs.remove(activeMeshIpKey) + prefs.remove(activeNpubKey) + } + } + + suspend fun addSavedServer(server: ServerEntry) { + context.dataStore.edit { prefs -> + val current = prefs[savedServersKey] ?: emptySet() + prefs[savedServersKey] = current + server.serialize() + } + } + + /** + * Replace a saved server in place. Matches the existing entry by node + * identity — npub first, address/port/scheme as the LAN-only fallback + * (ServerEntry.sameNode) — so edits that change the name, password or even + * every address still update the right record. An edit form that doesn't + * carry the npub keeps the stored one. If the edited server is also the + * active one, the active record is kept in sync. + */ + suspend fun updateSavedServer(original: ServerEntry, updated: ServerEntry) { + val toStore = updated.copy(npub = updated.npub.ifBlank { original.npub }) + context.dataStore.edit { prefs -> + val current = prefs[savedServersKey] ?: emptySet() + val filtered = current.filterNot { raw -> + ServerEntry.deserialize(raw)?.sameNode(original) == true + }.toSet() + prefs[savedServersKey] = filtered + toStore.serialize() + + val activeNpub = prefs[activeNpubKey] ?: "" + val isActive = (activeNpub.isNotBlank() && activeNpub == original.npub) || + ( + prefs[activeAddressKey] == original.address && + (prefs[activePortKey] ?: "") == original.port && + (prefs[activeHttpsKey] ?: false) == original.useHttps + ) + if (isActive) { + prefs[activeAddressKey] = toStore.address + prefs[activeHttpsKey] = toStore.useHttps + prefs[activePortKey] = toStore.port + prefs[activePasswordKey] = toStore.password + prefs[activeNameKey] = toStore.name + prefs[activeMeshIpKey] = toStore.meshIp + prefs[activeNpubKey] = toStore.npub + } + } + } + + /** + * Add a server, or update the entry for the same node — npub first, + * address/port/scheme as the LAN-only fallback (ServerEntry.sameNode) — + * used by QR pairing so re-scanning a node never duplicates it, even after + * the LAN renumbered and every address changed (npub-first contract in + * docs/companion-pairing-qr.md). A blank incoming password/name keeps the + * stored value (a real node's QR never carries the password). Returns the + * merged entry. + */ + suspend fun upsertServer(server: ServerEntry): ServerEntry { + var merged = server + context.dataStore.edit { prefs -> + val current = prefs[savedServersKey] ?: emptySet() + val existing = current.mapNotNull { ServerEntry.deserialize(it) } + .firstOrNull { it.sameNode(server) } + if (existing != null) { + merged = server.copy( + password = server.password.ifBlank { existing.password }, + name = server.name.ifBlank { existing.name }, + meshIp = server.meshIp.ifBlank { existing.meshIp }, + npub = server.npub.ifBlank { existing.npub }, + ) + } + val filtered = current.filterNot { raw -> + ServerEntry.deserialize(raw)?.sameNode(merged) == true + }.toSet() + prefs[savedServersKey] = filtered + merged.serialize() + } + return merged + } + + suspend fun removeSavedServer(server: ServerEntry) { + context.dataStore.edit { prefs -> + val current = prefs[savedServersKey] ?: emptySet() + // Match by node identity (npub, else address/port/scheme) rather + // than the exact serialized string, so a rename — or a legacy + // short-format entry — still removes the right record. + prefs[savedServersKey] = current.filterNot { raw -> + ServerEntry.deserialize(raw)?.sameNode(server) == true + }.toSet() + } + } + + suspend fun markIntroSeen() { + context.dataStore.edit { prefs -> + prefs[introSeenKey] = true + } + } + + suspend fun markGestureHintSeen() { + context.dataStore.edit { prefs -> + prefs[gestureHintSeenKey] = true + } + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/data/ServerQrParser.kt b/Android/app/src/main/java/com/archipelago/app/data/ServerQrParser.kt new file mode 100644 index 00000000..465d8838 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/data/ServerQrParser.kt @@ -0,0 +1,128 @@ +package com.archipelago.app.data + +import android.net.Uri +import com.archipelago.app.fips.AnchorPeer +import com.archipelago.app.fips.FipsPairInfo + +/** + * Result of parsing a pairing QR / deep link. + * + * UnsupportedVersion means the payload is structurally a pairing URI but its + * major version is newer than this app understands — the UI should tell the + * user to update the app rather than call the code invalid. + */ +sealed class PairResult { + data class Success( + val server: ServerEntry, + /** Mesh info when the node advertises FIPS (fnpub present). */ + val fips: FipsPairInfo? = null, + ) : PairResult() + object UnsupportedVersion : PairResult() + object Invalid : PairResult() +} + +/** + * Parser for the companion pairing QR / OS deep link. Contract: + * docs/companion-pairing-qr.md (repo root). + * + * archipelago://pair?v=1&url=&name=…[&tok=…][&pw=…][&fnpub=…&fip=…&fhost=…&fudp=…&ftcp=…] + * + * - `url` is a full origin including scheme (http for LAN/mDNS nodes, https + * for the public demo); trailing slashes are normalized away. + * - `tok` is a device token minted by the node; it goes through the password + * field on purpose — the whole password auto-login path (WebSocket auth + + * WebView form injection) then works unchanged, and the backend accepts + * device tokens wherever it accepts the password. `pw` (demo only) wins if + * both are ever present. + * - `fnpub`/`fip`/`fhost`/`fudp`/`ftcp` describe the node's FIPS mesh; their + * absence just means LAN-only pairing (older node, or FIPS not provisioned). + * - Unknown extra query params are tolerated (forward compat under v=1). + */ +object ServerQrParser { + private const val SUPPORTED_MAJOR = 1 + + fun parse(raw: String): PairResult { + val uri = try { + Uri.parse(raw.trim()) + } catch (_: Exception) { + return PairResult.Invalid + } + if (!"archipelago".equals(uri.scheme, ignoreCase = true)) return PairResult.Invalid + if (uri.isOpaque || !"pair".equals(uri.host, ignoreCase = true)) return PairResult.Invalid + + val major = uri.getQueryParameter("v") + ?.trim() + ?.takeWhile { it.isDigit() } + ?.toIntOrNull() + ?: return PairResult.Invalid + if (major != SUPPORTED_MAJOR) return PairResult.UnsupportedVersion + + val serverUrl = uri.getQueryParameter("url")?.trim()?.trimEnd('/') + if (serverUrl.isNullOrBlank()) return PairResult.Invalid + val server = Uri.parse(serverUrl) + val scheme = server.scheme?.lowercase() + if (scheme != "http" && scheme != "https") return PairResult.Invalid + val host = server.host + if (host.isNullOrBlank()) return PairResult.Invalid + + val fips = parseFips(uri) + val credential = uri.getQueryParameter("pw")?.takeIf { it.isNotBlank() } + ?: uri.getQueryParameter("tok") + ?: "" + + return PairResult.Success( + server = ServerEntry( + address = host, + useHttps = scheme == "https", + port = if (server.port != -1) server.port.toString() else "", + password = credential, + name = uri.getQueryParameter("name") ?: "", + meshIp = fips?.ula ?: "", + // npub is the durable identity — saved-server upserts match on + // it, so re-scanning after a LAN renumber updates in place. + npub = fips?.npub ?: "", + ), + fips = fips, + ) + } + + private fun parseFips(uri: Uri): FipsPairInfo? { + val npub = uri.getQueryParameter("fnpub")?.trim() + var host = uri.getQueryParameter("fhost")?.trim() + if (npub.isNullOrBlank() || host.isNullOrBlank()) return null + // A .fips fhost is a dead dial hint: Android's system DNS can't + // resolve .fips, so every handshake send fails and first connect + // falls back to slow anchor discovery. If the QR's `url` host is a + // real address, dial that instead (QRs minted while the node UI was + // browsed over the mesh carry npub….fips here). + if (host.endsWith(".fips")) { + val urlHost = uri.getQueryParameter("url") + ?.let { runCatching { Uri.parse(it).host }.getOrNull() } + if (!urlHost.isNullOrBlank() && !urlHost.endsWith(".fips")) host = urlHost + } + return FipsPairInfo( + npub = npub, + ula = uri.getQueryParameter("fip")?.trim().orEmpty(), + host = host, + udpPort = uri.getQueryParameter("fudp")?.toIntOrNull() ?: 2121, + tcpPort = uri.getQueryParameter("ftcp")?.toIntOrNull() ?: 8443, + anchors = parseAnchors(uri.getQueryParameter("fanchors")), + ) + } + + /** `fanchors` = comma-joined `npub@host:port/transport`; bad items skipped. */ + private fun parseAnchors(raw: String?): List { + if (raw.isNullOrBlank()) return emptyList() + return raw.split(",").mapNotNull { item -> + val at = item.indexOf('@') + val slash = item.lastIndexOf('/') + if (at <= 0 || slash <= at) return@mapNotNull null + val npub = item.substring(0, at).trim() + val addr = item.substring(at + 1, slash).trim() + val transport = item.substring(slash + 1).trim().lowercase() + if (npub.isBlank() || !addr.contains(":")) return@mapNotNull null + if (transport != "udp" && transport != "tcp") return@mapNotNull null + AnchorPeer(npub = npub, addr = addr, transport = transport) + } + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/fips/ArchyVpnService.kt b/Android/app/src/main/java/com/archipelago/app/fips/ArchyVpnService.kt new file mode 100644 index 00000000..f322d2fe --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/fips/ArchyVpnService.kt @@ -0,0 +1,324 @@ +package com.archipelago.app.fips + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Intent +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.net.VpnService +import android.os.Build +import android.util.Log +import com.archipelago.app.MainActivity +import com.archipelago.app.R +import com.archipelago.app.data.ServerPreferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * VpnService hosting the embedded FIPS mesh node. + * + * Split tunnel: only fd00::/8 (the FIPS ULA space) routes into the TUN, so + * normal phone traffic is untouched — this is mesh reachability, not a + * default-route VPN. The established fd is detached and handed to the Rust + * node (Node::start_with_tun_fd); the node owns it until stop. + */ +class ArchyVpnService : VpnService() { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var warmerJob: Job? = null + + // Seamless transport handoff (Wi-Fi ⇄ 5G ⇄ future BLE). Without this the + // tunnel's underlying network stays pinned to the interface that was + // default when the VPN came up; when the phone leaves Wi-Fi for 5G the + // mesh sockets ride a dead network and sessions never recover until the + // app is restarted (user-reported 2026-07-27). The callback (a) re-pins + // the tunnel to the new default network via setUnderlyingNetworks and + // (b) forces an immediate mesh re-home so discovery + sessions rebuild + // on the new path within seconds instead of waiting out dead-link + // timeouts. + private var connectivityManager: ConnectivityManager? = null + private var networkCallback: ConnectivityManager.NetworkCallback? = null + @Volatile + private var currentUnderlying: Network? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_STOP) { + shutdown() + return START_NOT_STICKY + } + startForeground(NOTIFICATION_ID, buildNotification()) + scope.launch { startMesh() } + return START_STICKY + } + + private suspend fun startMesh() { + if (!FipsNative.available) { + shutdown() + return + } + val prefs = FipsPreferences(this) + val identity = FipsManager.ensureIdentity(prefs) + val peersJson = prefs.combinedPeersJson() + if (identity == null || peersJson == "[]") { + Log.w(TAG, "mesh not configured — stopping") + shutdown() + return + } + // Party mode: fixed inbound UDP bind so a nearby phone can dial us + // directly over a shared LAN/hotspot (no internet required). + val listenPort = if (prefs.partyListen()) PartyQr.PARTY_UDP_PORT else 0 + + // A fresh app open re-triggers the service. Tearing a HEALTHY mesh + // down to rebuild it costs ~8s of anchor+session bring-up on every + // launch (observed live: stop 00:34:38 → session back 00:34:49) and + // is what made "freshly loading the app" slow. Keep a running node; + // restart only when it's dead or a pairing changed the peer set. + if (FipsNative.isRunning() && !FipsManager.peersDirty) { + Log.i(TAG, "mesh already running — keeping warm sessions") + startSessionWarmer() + return + } + FipsManager.peersDirty = false + // Re-establishing while running would strand the old fd; restart clean. + if (FipsNative.isRunning()) FipsNative.stop() + + val pfd = try { + Builder() + .setSession("Archipelago Mesh") + .setMtu(1280) + .addAddress(identity.address, 128) + .addRoute("fd00::", 8) + // The TUN is IPv6-only. Android blocks every address family + // the VPN has no address for — without this, bringing the + // mesh up cut ALL of the phone's IPv4 internet. + .allowFamily(android.system.OsConstants.AF_INET) + // And let apps that bind their own network skip the TUN + // entirely — this is mesh reachability, not a privacy VPN. + .allowBypass() + .apply { + // Android 10+ treats VPN networks as METERED by default, + // which flips the whole phone into data-saver behaviour + // (background sync off, "metered" warnings) while the + // mesh is up. It inherits the underlying network's real + // metered state instead. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) setMetered(false) + } + .establish() + } catch (e: Exception) { + Log.e(TAG, "VPN establish failed", e) + null + } + if (pfd == null) { + shutdown() + return + } + + val fd = pfd.detachFd() + val result = FipsNative.start(identity.secret, peersJson, fd, listenPort) + Log.i(TAG, "mesh start: $result (listen=$listenPort)") + if (result.contains("\"error\"")) { + shutdown() + } else { + startSessionWarmer() + registerNetworkHandoff() + // Phone-to-phone chat/beam + the phone's own mesh-served page. + FlareServer.start(this, identity.address, identity.npub, prefs.partyName()) + // Mutual pairing: when a phone that scanned OUR QR announces + // itself, store it as a party peer and re-run the mesh config so + // this side gets the peer + chat entry without scanning back. + FlareServer.onHello = { peer -> + scope.launch { + prefs.upsertPartyPeer(peer) + FipsManager.requestMeshRestart(this@ArchyVpnService) + } + } + } + } + + /** + * Pre-warm + keep-warm mesh sessions to every known node ULA. + * + * Discovery + first session through the public tree can take 15s+ + * (HANDOFF-2026-07-23 node diagnosis) — paying that cost here, the + * moment the tunnel is up, means the connect probe and WebView hit an + * established session instead of timing out on a cold one. The periodic + * touch afterwards keeps the session from idling out. Failed connects + * are expected and cheap; the attempt itself is what drives discovery. + */ + private fun startSessionWarmer() { + warmerJob?.cancel() + warmerJob = scope.launch { + val prefs = ServerPreferences(this@ArchyVpnService) + val fipsPrefs = FipsPreferences(this@ArchyVpnService) + var round = 0 + while (isActive && FipsNative.isRunning()) { + val targets = try { + prefs.savedServers.first() + .mapNotNull { it.meshIp.ifBlank { null } } + .map { it to 80 } + + // Party phones answer on the flare port, not :80. + fipsPrefs.partyPeers().map { it.ula to PartyQr.FLARE_PORT } + } catch (_: Exception) { + emptyList() + }.distinct() + if (round == 0) Log.i(TAG, "session warmer: ${targets.map { it.first }}") + // Probe all targets CONCURRENTLY with a short timeout — the + // old sequential 20s-per-target loop let one cold node starve + // every other target for the whole aggressive window. + targets.map { (ula, port) -> + launch { + try { + java.net.Socket().use { s -> + s.connect( + java.net.InetSocketAddress( + java.net.InetAddress.getByName(ula), + port, + ), + 5_000, + ) + } + } catch (_: Exception) { + // Cold path / node away — the attempt still drove + // session establishment; try again next round. + } + } + }.forEach { it.join() } + round++ + // Aggressive for the first ~minute (session bring-up), then a + // slow keep-warm tick that costs nearly nothing. + delay(if (round < 12) 5_000 else 60_000) + } + } + } + + /** + * Track the phone's default network and hand the mesh over to it as the + * phone roams (Wi-Fi ⇄ 5G, and later BLE). Two actions per change: + * 1. setUnderlyingNetworks(new) — the tunnel's packets follow the live + * network instead of dying on the one it launched with. + * 2. re-home the mesh — kick the session warmer so discovery + sessions + * rebuild on the new path immediately; the node's own fast-reconnect + * (1s) redials peers over the new route. + * onAvailable also fires for the FIRST network, which is how the initial + * underlying network gets set. + */ + private fun registerNetworkHandoff() { + if (networkCallback != null) return + val cm = getSystemService(ConnectivityManager::class.java) ?: return + connectivityManager = cm + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + val cb = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + handoffTo(network) + } + + override fun onLost(network: Network) { + // The lost network was our underlying one — clear the pin so + // the system falls back to whatever default remains; the next + // onAvailable re-pins explicitly. + if (network == currentUnderlying) { + currentUnderlying = null + runCatching { setUnderlyingNetworks(null) } + } + } + } + networkCallback = cb + // requestNetwork tracks the BEST network of the request; when the + // phone moves Wi-Fi→5G the callback re-fires onAvailable with the new + // one. (registerDefaultNetworkCallback would also work; requestNetwork + // lets us extend to BLE-capable transports later.) + runCatching { cm.requestNetwork(request, cb) } + } + + private fun handoffTo(network: Network) { + val changed = network != currentUnderlying + currentUnderlying = network + // Always re-assert; cheap and covers capability changes on the same + // Network object. + runCatching { setUnderlyingNetworks(arrayOf(network)) } + if (changed && FipsNative.isRunning()) { + Log.i(TAG, "network handoff → re-homing mesh on new default network") + // Fresh warmer pass drives immediate rediscovery/session rebuild + // on the new path instead of waiting out dead-link timeouts. + startSessionWarmer() + } + } + + private fun unregisterNetworkHandoff() { + val cm = connectivityManager + val cb = networkCallback + if (cm != null && cb != null) { + runCatching { cm.unregisterNetworkCallback(cb) } + } + networkCallback = null + connectivityManager = null + currentUnderlying = null + } + + private fun shutdown() { + warmerJob?.cancel() + unregisterNetworkHandoff() + FlareServer.stop() + FipsNative.stop() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + override fun onDestroy() { + unregisterNetworkHandoff() + FipsNative.stop() + scope.cancel() + super.onDestroy() + } + + override fun onRevoke() { + // User pulled VPN permission from system settings. + shutdown() + } + + private fun buildNotification(): Notification { + val manager = getSystemService(NotificationManager::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + "Mesh connection", + NotificationManager.IMPORTANCE_MIN, + ).apply { description = "Keeps the node reachable from anywhere" } + ) + } + val tapIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE, + ) + return Notification.Builder(this, CHANNEL_ID) + .setContentTitle("Connected to your Archipelago") + .setContentText("Secure mesh link active") + .setSmallIcon(R.mipmap.ic_launcher) + .setContentIntent(tapIntent) + .setOngoing(true) + .build() + } + + companion object { + const val ACTION_STOP = "com.archipelago.app.fips.STOP" + private const val CHANNEL_ID = "archy_mesh" + private const val NOTIFICATION_ID = 4841 + private const val TAG = "ArchyVpnService" + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/fips/FipsManager.kt b/Android/app/src/main/java/com/archipelago/app/fips/FipsManager.kt new file mode 100644 index 00000000..398356ec --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/fips/FipsManager.kt @@ -0,0 +1,103 @@ +package com.archipelago.app.fips + +import android.content.Context +import android.content.Intent +import android.net.VpnService +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Glue between pairing and the mesh: persists the node peer from a scanned + * QR and asks the UI to bring the tunnel up. There is deliberately no + * settings surface — scanning a node's QR is the entire configuration. + * + * The one unavoidable interaction is Android's VPN consent dialog + * (VpnService.prepare), which only an Activity can launch; [consentNeeded] + * signals AppNavHost to run it, once, on first pairing. + */ +object FipsManager { + + /** Set when a pairing registered mesh info and the VPN needs starting. */ + private val _consentNeeded = MutableStateFlow(false) + val consentNeeded: StateFlow = _consentNeeded + + /** True after a pairing changed the peer set while the node was running — + * tells the service a restart is genuinely needed (the ONLY case; a + * routine app open must keep the warm mesh, not rebuild it). */ + @Volatile + var peersDirty: Boolean = false + + fun consentHandled() { + _consentNeeded.value = false + } + + /** + * Persist mesh info from a pairing scan and request tunnel start. + * No-op on devices without the native lib (non-arm64). + */ + suspend fun registerNode(context: Context, info: FipsPairInfo?, alias: String) { + if (info == null || !FipsNative.available) return + val prefs = FipsPreferences(context) + ensureIdentity(prefs) + prefs.upsertNodePeer(info, alias) + peersDirty = true + // Restart the mesh with the new peer RIGHT NOW when consent already + // exists — relying on the consentNeeded collector left a running + // mesh on the OLD peer list whenever the collector wasn't active + // (fresh pairings looked dead until a full app restart). + if (VpnService.prepare(context) == null) { + startService(context) + } else { + _consentNeeded.value = true + } + } + + /** Generate-once mesh identity. Returns null only if the RNG/native fails. */ + suspend fun ensureIdentity(prefs: FipsPreferences): FipsNative.Identity? { + prefs.identity()?.let { return it } + val generated = FipsNative.parseIdentity(FipsNative.generateIdentity()) ?: return null + prefs.saveIdentity(generated) + return generated + } + + /** + * Start the mesh service if this device is paired and the user has already + * consented to the VPN (prepare() == null). Called on app start so the + * tunnel comes back without any interaction; first-time consent goes + * through AppNavHost instead. + */ + suspend fun autoStartIfReady(context: Context) { + if (!FipsNative.available) return + val prefs = FipsPreferences(context) + if (prefs.identity() == null || !prefs.hasPeers()) return + if (VpnService.prepare(context) != null) return // consent missing — don't prompt here + startService(context) + } + + fun startService(context: Context) { + val intent = Intent(context, ArchyVpnService::class.java) + context.startForegroundService(intent) + } + + /** + * Re-run the mesh with current prefs (party listen toggled, peer added). + * Marks the peer set dirty so startMesh genuinely restarts the node — + * otherwise the keep-warm fast path would skip the new config. + * First-timers go through the consent flow. + */ + fun requestMeshRestart(context: Context) { + if (!FipsNative.available) return + peersDirty = true + if (VpnService.prepare(context) == null) { + startService(context) + } else { + _consentNeeded.value = true + } + } + + fun stopService(context: Context) { + val intent = Intent(context, ArchyVpnService::class.java) + .setAction(ArchyVpnService.ACTION_STOP) + context.startService(intent) + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/fips/FipsNative.kt b/Android/app/src/main/java/com/archipelago/app/fips/FipsNative.kt new file mode 100644 index 00000000..cd52a13a --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/fips/FipsNative.kt @@ -0,0 +1,49 @@ +package com.archipelago.app.fips + +import org.json.JSONObject + +/** + * JNI binding to the embedded FIPS mesh node (Android/rust/archy-fips-core, + * built into libarchy_fips_core.so by the buildRustArm64 gradle task). + * + * All calls return JSON strings; failures come back as {"error": "…"} rather + * than exceptions. [available] is false on ABIs the .so isn't built for + * (anything but arm64) — every caller must gate on it so the app still runs + * as a plain companion there. + */ +object FipsNative { + val available: Boolean = try { + System.loadLibrary("archy_fips_core") + true + } catch (_: Throwable) { + false + } + + external fun generateIdentity(): String + external fun deriveIdentity(secret: String): String + + /** + * [listenPort] 0 = outbound-only (default posture). Non-zero binds UDP on + * that port so a nearby phone can dial us directly (party mode); the node + * stays leaf-only either way. + */ + external fun start(secret: String, peersJson: String, tunFd: Int, listenPort: Int): String + external fun stop() + external fun isRunning(): Boolean + external fun statusJson(): String + + data class Identity(val secret: String, val npub: String, val address: String) + + /** Parse an identity JSON reply; null on {"error": …} or malformed. */ + fun parseIdentity(json: String): Identity? = try { + val obj = JSONObject(json) + if (obj.has("error")) null + else Identity( + secret = obj.getString("secret"), + npub = obj.getString("npub"), + address = obj.getString("address"), + ) + } catch (_: Exception) { + null + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/fips/FipsPairInfo.kt b/Android/app/src/main/java/com/archipelago/app/fips/FipsPairInfo.kt new file mode 100644 index 00000000..34d403cc --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/fips/FipsPairInfo.kt @@ -0,0 +1,29 @@ +package com.archipelago.app.fips + +/** + * Node mesh parameters carried by the pairing QR (fnpub/fip/fhost/fudp/ftcp — + * docs/companion-pairing-qr.md). The phone's embedded FIPS node dials + * host:udpPort / host:tcpPort claiming nothing; the mesh accepts inbound peers + * without registration, so possession of these params is all pairing takes. + */ +data class FipsPairInfo( + val npub: String, + /** Node's fips0 ULA — where its UI stays reachable once meshed. May be empty. */ + val ula: String, + val host: String, + val udpPort: Int, + val tcpPort: Int, + /** + * Public rendezvous anchors (the node's seed-anchor list). The phone + * peers with these too, so it can route to the node via the mesh when + * the node's LAN endpoint isn't directly dialable. + */ + val anchors: List = emptyList(), +) + +/** One rendezvous anchor from the QR's `fanchors` param (npub@addr/transport). */ +data class AnchorPeer( + val npub: String, + val addr: String, + val transport: String, +) diff --git a/Android/app/src/main/java/com/archipelago/app/fips/FipsPreferences.kt b/Android/app/src/main/java/com/archipelago/app/fips/FipsPreferences.kt new file mode 100644 index 00000000..7065f82f --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/fips/FipsPreferences.kt @@ -0,0 +1,341 @@ +package com.archipelago.app.fips + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import org.json.JSONArray +import org.json.JSONObject +import androidx.datastore.preferences.preferencesDataStore + +private val Context.fipsDataStore: DataStore by preferencesDataStore(name = "fips_prefs") + +// Archipelago-operated public anchor (vps2). Baked in so EVERY pairing yields +// both paths — direct LAN p2p to the node AND a public rendezvous for +// away-from-home — even when the scanned node is old enough that its QR +// carries no fanchors. Keep in lockstep with +// core/archipelago/src/fips/anchors.rs (ARCHY_ANCHOR_*). +internal const val ARCHY_ANCHOR_NPUB = + "npub1dptaktwxv0mm245g2lqjykwm5ll0jpc6m3r4242ydfa9z7qe6urs3jvrak" +internal const val ARCHY_ANCHOR_ADDR = "146.59.87.168:8444" +internal const val ARCHY_ANCHOR_TRANSPORT = "tcp" + +/** + * Public FIPS network anchors (join.fips.network — the dual-transport TCP + * pair; keep in lockstep with core/archipelago/src/fips/anchors.rs + * fips_network_anchors()). Baked into every pairing at trailing priority so + * a degraded/unreachable vps2 anchor can never strand the phone: the mesh + * still joins the public tree and routes to the node through it. + */ +internal val PUBLIC_FIPS_ANCHORS = listOf( + Triple( + "npub10yffd020a4ag8zcy75f9pruq3rnghvvhd5hphl9s62zgp35s560qrksp9u", + "23.182.128.74:443", + "tcp", + ), + Triple( + "npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98", + "217.77.8.91:443", + "tcp", + ), +) + +/** + * Mesh identity + known node peers. Follows the same plaintext-DataStore + * storage model as ServerPreferences (the server password lives there the + * same way); the mesh secret only grants mesh membership, not node login. + */ +class FipsPreferences(private val context: Context) { + + private val secretKey = stringPreferencesKey("fips_secret") + private val npubKey = stringPreferencesKey("fips_npub") + private val addressKey = stringPreferencesKey("fips_address") + /** JSON array of node peers in fips PeerConfig shape (see NodePeer). */ + private val peersKey = stringPreferencesKey("fips_node_peers") + /** JSON array of phone party peers (PartyPeer shape, NOT PeerConfig). */ + private val partyPeersKey = stringPreferencesKey("fips_party_peers") + /** Party mode: accept a direct inbound mesh link (UDP 2121). */ + private val partyListenKey = booleanPreferencesKey("fips_party_listen") + /** Name shown in this phone's party QR and outgoing flares. */ + private val partyNameKey = stringPreferencesKey("fips_party_name") + + suspend fun identity(): FipsNative.Identity? { + val prefs = context.fipsDataStore.data.first() + val secret = prefs[secretKey] ?: return null + return FipsNative.Identity( + secret = secret, + npub = prefs[npubKey] ?: "", + address = prefs[addressKey] ?: "", + ) + } + + suspend fun saveIdentity(identity: FipsNative.Identity) { + context.fipsDataStore.edit { prefs -> + prefs[secretKey] = identity.secret + prefs[npubKey] = identity.npub + prefs[addressKey] = identity.address + } + } + + suspend fun peersJson(): String { + val prefs = context.fipsDataStore.data.first() + return prefs[peersKey] ?: "[]" + } + + suspend fun hasPeers(): Boolean = JSONArray(peersJson()).length() > 0 + + // ── Mesh Party (phone↔phone) ──────────────────────────────────────────── + + suspend fun partyListen(): Boolean = + context.fipsDataStore.data.first()[partyListenKey] ?: false + + val partyListenFlow: Flow + get() = context.fipsDataStore.data.map { it[partyListenKey] ?: false } + + suspend fun setPartyListen(enabled: Boolean) { + context.fipsDataStore.edit { it[partyListenKey] = enabled } + } + + suspend fun partyName(): String = + context.fipsDataStore.data.first()[partyNameKey] + ?: android.os.Build.MODEL.orEmpty().ifBlank { "Phone" } + + suspend fun setPartyName(name: String) { + context.fipsDataStore.edit { it[partyNameKey] = name.trim() } + } + + val partyPeersFlow: Flow> + get() = context.fipsDataStore.data.map { parsePartyPeers(it[partyPeersKey] ?: "[]") } + + suspend fun partyPeers(): List = + parsePartyPeers(context.fipsDataStore.data.first()[partyPeersKey] ?: "[]") + + /** Matched by npub, so re-scanning updates the direct-dial address in place. */ + suspend fun upsertPartyPeer(peer: PartyPeer) { + context.fipsDataStore.edit { prefs -> + val kept = parsePartyPeers(prefs[partyPeersKey] ?: "[]").filter { it.npub != peer.npub } + prefs[partyPeersKey] = toJson(kept + peer) + } + } + + suspend fun removePartyPeer(npub: String) { + context.fipsDataStore.edit { prefs -> + val kept = parsePartyPeers(prefs[partyPeersKey] ?: "[]").filter { it.npub != npub } + prefs[partyPeersKey] = toJson(kept) + } + } + + /** + * Node peers + direct-dial party peers, in the fips PeerConfig JSON the + * Rust node deserializes. Party peers get the best priority: on a shared + * LAN/hotspot the direct link beats every anchor path, and while off-LAN + * the failed dial is cheap (auto-reconnect keeps retrying, which is + * exactly what makes the link snap up the moment both phones share WiFi). + * Party peers without an underlay address are mesh-routed and need no + * entry here at all. + */ + suspend fun combinedPeersJson(): String { + val merged = JSONArray(peersJson()) + val party = partyPeers() + for (peer in party) { + if (peer.ip.isBlank() || peer.port <= 0) continue + merged.put(JSONObject().apply { + put("npub", peer.npub) + put("alias", hostSafeAlias(peer.name.ifBlank { "party-phone" })) + put("addresses", JSONArray().put(JSONObject().apply { + put("transport", "udp") + put("addr", "${peer.ip}:${peer.port}") + put("priority", 5) + })) + }) + } + // A party-only phone (never paired with a node) still needs a public + // rendezvous to reach its peers ACROSS the internet — without it, two + // bare phones would be hotspot/LAN-only. Node pairing normally bakes + // this anchor in; do the same when there are party peers. + if (party.isNotEmpty() && + (0 until merged.length()).none { + merged.optJSONObject(it)?.optString("npub") == ARCHY_ANCHOR_NPUB + } + ) { + merged.put(JSONObject().apply { + put("npub", ARCHY_ANCHOR_NPUB) + put("alias", "archipelago-anchor") + put("addresses", JSONArray().put(JSONObject().apply { + put("transport", ARCHY_ANCHOR_TRANSPORT) + put("addr", ARCHY_ANCHOR_ADDR) + put("priority", 40) + })) + }) + } + // Backfill anchor peers for entries paired before newer QR/app + // releases added them. `upsertNodePeer` persists these on re-scan, but + // startup must also self-heal old DataStore state so updating the APK is + // enough to get off-LAN redundancy. + if (merged.length() > 0) { + addAnchorIfMissing(merged, ARCHY_ANCHOR_NPUB, "archipelago-anchor", ARCHY_ANCHOR_ADDR, ARCHY_ANCHOR_TRANSPORT, 40) + for ((i, anchor) in PUBLIC_FIPS_ANCHORS.withIndex()) { + val (npub, addr, transport) = anchor + addAnchorIfMissing(merged, npub, "fips-network-anchor-${i + 1}", addr, transport, 50 + i * 10) + } + } + return merged.toString() + } + + private fun addAnchorIfMissing( + peers: JSONArray, + npub: String, + alias: String, + addr: String, + transport: String, + priority: Int, + ) { + if ((0 until peers.length()).any { peers.optJSONObject(it)?.optString("npub") == npub }) return + peers.put(JSONObject().apply { + put("npub", npub) + put("alias", alias) + put("addresses", JSONArray().put(JSONObject().apply { + put("transport", transport) + put("addr", addr) + put("priority", priority) + })) + }) + } + + private fun parsePartyPeers(json: String): List = try { + val arr = JSONArray(json) + (0 until arr.length()).mapNotNull { i -> + val o = arr.optJSONObject(i) ?: return@mapNotNull null + val npub = o.optString("npub") + val ula = o.optString("ula") + if (npub.isBlank() || ula.isBlank()) return@mapNotNull null + PartyPeer( + npub = npub, + ula = ula, + name = o.optString("name").ifBlank { "Phone" }, + ip = o.optString("ip"), + port = o.optInt("port"), + ) + } + } catch (_: Exception) { + emptyList() + } + + private fun toJson(peers: List): String { + val arr = JSONArray() + for (p in peers) { + arr.put(JSONObject().apply { + put("npub", p.npub) + put("ula", p.ula) + put("name", p.name) + put("ip", p.ip) + put("port", p.port) + }) + } + return arr.toString() + } + + /** + * Add or update the node peer plus its rendezvous anchors (each matched + * by npub, so re-pairing updates addresses instead of duplicating). + * Stored directly in the fips PeerConfig JSON shape the Rust side + * deserializes. The node's direct addresses get the best priorities; + * anchors trail so the mesh prefers the direct path when it works. + */ + suspend fun upsertNodePeer(info: FipsPairInfo, alias: String) { + val incoming = mutableListOf() + incoming += JSONObject().apply { + put("npub", info.npub) + put("alias", hostSafeAlias(alias.ifBlank { "Archipelago" })) + val addresses = JSONArray() + // .fips hosts are unresolvable on Android (no system .fips DNS): + // storing one gives the mesh a dial target that fails every + // handshake and stalls first connect on anchor discovery. + if (info.udpPort > 0 && !info.host.endsWith(".fips")) { + addresses.put(JSONObject().apply { + put("transport", "udp") + put("addr", "${info.host}:${info.udpPort}") + put("priority", 10) + }) + } + if (info.tcpPort > 0 && !info.host.endsWith(".fips")) { + addresses.put(JSONObject().apply { + put("transport", "tcp") + put("addr", "${info.host}:${info.tcpPort}") + put("priority", 20) + }) + } + put("addresses", addresses) + } + for (anchor in info.anchors) { + if (anchor.npub == info.npub) continue + if (anchor.addr.substringBeforeLast(":").endsWith(".fips")) continue + incoming += JSONObject().apply { + put("npub", anchor.npub) + put("alias", "mesh-anchor") + put("addresses", JSONArray().put(JSONObject().apply { + put("transport", anchor.transport) + put("addr", anchor.addr) + put("priority", 30) + })) + } + } + // Guarantee the public anchor: without it, a QR from an older node + // leaves the phone LAN-only and pairing/connecting dies off-LAN. + if (info.npub != ARCHY_ANCHOR_NPUB && + incoming.none { it.optString("npub") == ARCHY_ANCHOR_NPUB } + ) { + incoming += JSONObject().apply { + put("npub", ARCHY_ANCHOR_NPUB) + put("alias", "archipelago-anchor") + put("addresses", JSONArray().put(JSONObject().apply { + put("transport", ARCHY_ANCHOR_TRANSPORT) + put("addr", ARCHY_ANCHOR_ADDR) + put("priority", 40) + })) + } + } + // And the public FIPS network anchors at trailing priority, so one + // degraded rendezvous (vps2, 2026-07-24) can never strand the phone. + for ((i, anchor) in PUBLIC_FIPS_ANCHORS.withIndex()) { + val (npub, addr, transport) = anchor + if (info.npub == npub || incoming.any { it.optString("npub") == npub }) continue + incoming += JSONObject().apply { + put("npub", npub) + put("alias", "fips-network-anchor-${i + 1}") + put("addresses", JSONArray().put(JSONObject().apply { + put("transport", transport) + put("addr", addr) + put("priority", 50 + i * 10) + })) + } + } + val incomingNpubs = incoming.map { it.optString("npub") }.toSet() + context.fipsDataStore.edit { prefs -> + val current = JSONArray(prefs[peersKey] ?: "[]") + val merged = JSONArray() + for (i in 0 until current.length()) { + val existing = current.optJSONObject(i) ?: continue + if (existing.optString("npub") !in incomingNpubs) merged.put(existing) + } + incoming.forEach { merged.put(it) } + prefs[peersKey] = merged.toString() + } + } +} + +/** + * Peer aliases feed the fips host map as `.fips` hostnames; anything + * that isn't a valid DNS label ("Framework PT" — the space) gets rejected and + * silently drops the peer from name resolution. Slug it instead of losing it. + */ +internal fun hostSafeAlias(alias: String): String = + alias.lowercase() + .replace(Regex("[^a-z0-9.-]+"), "-") + .trim('-', '.') + .ifBlank { "archipelago" } diff --git a/Android/app/src/main/java/com/archipelago/app/fips/FlareServer.kt b/Android/app/src/main/java/com/archipelago/app/fips/FlareServer.kt new file mode 100644 index 00000000..a48a2294 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/fips/FlareServer.kt @@ -0,0 +1,398 @@ +package com.archipelago.app.fips + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import java.io.BufferedInputStream +import java.io.File +import java.io.InputStream +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.util.UUID +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** One chat/photo message in a party conversation, keyed by the peer's npub. */ +data class FlareMessage( + val id: String, + val peerNpub: String, + val fromMe: Boolean, + val name: String, + val text: String = "", + val photoPath: String = "", + val ts: Long, + val status: Status = Status.RECEIVED, +) { + enum class Status { SENDING, SENT, FAILED, RECEIVED } +} + +/** In-memory conversation store (demo scope — nothing persists across restarts). */ +object FlareStore { + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _messages + + fun add(message: FlareMessage) { + _messages.value = _messages.value + message + } + + fun setStatus(id: String, status: FlareMessage.Status) { + _messages.value = _messages.value.map { if (it.id == id) it.copy(status = status) else it } + } +} + +/** + * Minimal HTTP listener bound ONLY on this phone's mesh ULA — plain HTTP is + * fine there because FIPS is the encryption + peer-identity layer (same + * stance as the node's ULA-only peer listener). This is what makes the phone + * a *server* on the mesh: another phone (or `curl -6` from any mesh node) + * reaches it by npub-derived address with no port forwarding, DNS, or CA. + * + * FIPS authenticates the node, not the request (project doctrine), so inputs + * are still validated at this boundary: size caps, JSON shape, no + * client-controlled paths. + */ +object FlareServer { + private const val TAG = "FlareServer" + private const val MAX_PHOTO_BYTES = 8 * 1024 * 1024 + private const val MAX_TEXT_CHARS = 4_000 + private const val MAX_HEADER_BYTES = 16 * 1024 + + private var socket: ServerSocket? = null + private var pool: ExecutorService? = null + @Volatile private var identityName = "Phone" + @Volatile private var identityNpub = "" + @Volatile private var photoDir: File? = null + + /** Invoked when a peer announces itself (POST /hello) — pairing used to + * be one-way: only the SCANNING phone learned the other side, so the + * scanned phone had no peer, no chat entry, no way in. The VPN service + * wires this to upsert the peer + restart the mesh config. */ + @Volatile var onHello: ((PartyPeer) -> Unit)? = null + + @Synchronized + fun start(context: Context, ula: String, myNpub: String, myName: String) { + stop() + identityNpub = myNpub + identityName = myName + photoDir = File(context.cacheDir, "flare").apply { mkdirs() } + val pool = Executors.newCachedThreadPool().also { this.pool = it } + pool.execute { + try { + val server = ServerSocket().apply { + reuseAddress = true + bind(InetSocketAddress(InetAddress.getByName(ula), PartyQr.FLARE_PORT)) + } + socket = server + Log.i(TAG, "flare listening on [$ula]:${PartyQr.FLARE_PORT}") + while (!server.isClosed) { + val client = try { + server.accept() + } catch (_: Exception) { + break + } + pool.execute { handle(client) } + } + } catch (e: Exception) { + Log.e(TAG, "flare server died: $e") + } + } + } + + @Synchronized + fun stop() { + try { + socket?.close() + } catch (_: Exception) { + } + socket = null + pool?.shutdownNow() + pool = null + } + + private fun handle(client: Socket) { + client.use { sock -> + sock.soTimeout = 30_000 + try { + val input = BufferedInputStream(sock.getInputStream()) + val requestLine = readLine(input) ?: return + val parts = requestLine.trim().split(" ") + if (parts.size < 2) return respond(sock, 400, json("bad_request")) + val (method, path) = parts[0] to parts[1] + + var contentLength = 0 + var from = "" + var fromName = "" + var headerBytes = requestLine.length + while (true) { + val line = readLine(input) ?: return + if (line.isEmpty()) break + headerBytes += line.length + if (headerBytes > MAX_HEADER_BYTES) return respond(sock, 431, json("headers_too_large")) + val idx = line.indexOf(':') + if (idx <= 0) continue + val key = line.substring(0, idx).trim().lowercase() + val value = line.substring(idx + 1).trim() + when (key) { + "content-length" -> contentLength = value.toIntOrNull() ?: 0 + "x-from" -> from = value.take(80) + "x-name" -> fromName = value.take(80) + } + } + + when { + method == "GET" && (path == "/" || path.startsWith("/?")) -> + respondHtml(sock, profilePage()) + method == "POST" && path == "/hello" -> { + if (contentLength !in 1..MAX_HEADER_BYTES) return respond(sock, 413, json("too_large")) + val body = readExactly(input, contentLength) ?: return + receiveHello(String(body, Charsets.UTF_8)) + respond(sock, 200, """{"ok":true}""") + } + method == "POST" && path == "/flare" -> { + if (contentLength !in 1..MAX_HEADER_BYTES) return respond(sock, 413, json("too_large")) + val body = readExactly(input, contentLength) ?: return + receiveFlare(String(body, Charsets.UTF_8)) + respond(sock, 200, """{"ok":true}""") + } + method == "POST" && path == "/photo" -> { + if (contentLength !in 1..MAX_PHOTO_BYTES) return respond(sock, 413, json("too_large")) + if (!from.startsWith("npub1")) return respond(sock, 400, json("bad_request")) + val body = readExactly(input, contentLength) ?: return + receivePhoto(from, fromName, body) + respond(sock, 200, """{"ok":true}""") + } + else -> respond(sock, 404, json("not_found")) + } + } catch (e: Exception) { + Log.w(TAG, "request failed: $e") + } + } + } + + /** A peer that scanned OUR QR announces itself — mutual pairing. */ + private fun receiveHello(body: String) { + val o = try { + JSONObject(body) + } catch (_: Exception) { + return + } + val from = o.optString("from") + val ula = o.optString("ula") + if (!from.startsWith("npub1") || !ula.startsWith("fd")) return + if (from == identityNpub) return + val peer = PartyPeer( + npub = from.take(80), + ula = ula.take(64), + name = o.optString("name").take(24).ifBlank { "Phone" }, + ip = o.optString("ip").take(40), + port = o.optInt("port", 0), + ) + onHello?.invoke(peer) + // Seed the conversation so the chat has a visible entry on this side. + FlareStore.add( + FlareMessage( + id = UUID.randomUUID().toString(), + peerNpub = peer.npub, + fromMe = false, + name = peer.name, + text = "👋 ${peer.name} joined the party", + ts = System.currentTimeMillis(), + ) + ) + } + + private fun receiveFlare(body: String) { + val o = try { + JSONObject(body) + } catch (_: Exception) { + return + } + val from = o.optString("from") + if (!from.startsWith("npub1")) return + val text = o.optString("text").take(MAX_TEXT_CHARS) + if (text.isBlank()) return + FlareStore.add( + FlareMessage( + id = UUID.randomUUID().toString(), + peerNpub = from, + fromMe = false, + name = o.optString("name").take(80).ifBlank { "Phone" }, + text = text, + ts = System.currentTimeMillis(), + ) + ) + } + + private fun receivePhoto(from: String, fromName: String, bytes: ByteArray) { + // Server-generated filename — the sender never controls the path. + val dir = photoDir ?: return + val file = File(dir, "${UUID.randomUUID()}.jpg") + file.writeBytes(bytes) + FlareStore.add( + FlareMessage( + id = UUID.randomUUID().toString(), + peerNpub = from, + fromMe = false, + name = fromName.ifBlank { "Phone" }, + photoPath = file.absolutePath, + ts = System.currentTimeMillis(), + ) + ) + } + + private fun profilePage(): String { + val npub = identityNpub + val name = identityName + return """ + + + $name — on the mesh +
+

⚡ $name

+
$npub
+

This page is being served by a phone, addressed by its + cryptographic identity over the FIPS mesh.

+

No port forwarding. No DNS. No certificate authority. No cloud. + The key is the address — and the transport underneath can be + 5G, WiFi, or a hotspot with no internet at all.

+
+ """.trimIndent() + } + + // ── tiny HTTP plumbing ────────────────────────────────────────────────── + + /** Read one CRLF-terminated header line as ISO-8859-1; null on EOF. */ + private fun readLine(input: InputStream): String? { + val sb = StringBuilder() + while (true) { + val b = input.read() + if (b == -1) return if (sb.isEmpty()) null else sb.toString() + if (b == '\n'.code) return sb.toString().trimEnd('\r') + sb.append(b.toChar()) + if (sb.length > MAX_HEADER_BYTES) return null + } + } + + private fun readExactly(input: InputStream, length: Int): ByteArray? { + val buf = ByteArray(length) + var off = 0 + while (off < length) { + val n = input.read(buf, off, length - off) + if (n == -1) return null + off += n + } + return buf + } + + private fun json(code: String) = """{"error":{"code":"$code","message":"request rejected"}}""" + + private fun respond(sock: Socket, status: Int, body: String) = + writeResponse(sock, status, "application/json", body.toByteArray(Charsets.UTF_8)) + + private fun respondHtml(sock: Socket, body: String) = + writeResponse(sock, 200, "text/html; charset=utf-8", body.toByteArray(Charsets.UTF_8)) + + private fun writeResponse(sock: Socket, status: Int, contentType: String, body: ByteArray) { + val reason = when (status) { + 200 -> "OK"; 400 -> "Bad Request"; 404 -> "Not Found" + 413 -> "Payload Too Large"; 431 -> "Headers Too Large" + else -> "Error" + } + val head = "HTTP/1.1 $status $reason\r\n" + + "Content-Type: $contentType\r\n" + + "Content-Length: ${body.size}\r\n" + + "Connection: close\r\n\r\n" + sock.getOutputStream().apply { + write(head.toByteArray(Charsets.ISO_8859_1)) + write(body) + flush() + } + } +} + +/** Outbound flares: plain HTTP to the peer's ULA — FIPS encrypts underneath. */ +object FlareClient { + // Connect timeout must outlive cold mesh-session establishment (~15s via + // the public tree per HANDOFF-2026-07-23); the attempt itself drives + // session setup, same trick as the VPN service's session warmer. + private val http = OkHttpClient.Builder() + .connectTimeout(25, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() + + private fun base(peer: PartyPeer) = "http://[${peer.ula}]:${PartyQr.FLARE_PORT}" + + /** Announce myself to a freshly scanned peer so pairing becomes MUTUAL — + * their phone gets me as a peer + a chat entry without scanning back. + * Blocking — call from Dispatchers.IO. */ + fun sendHello( + peer: PartyPeer, + myNpub: String, + myName: String, + myUla: String, + myIp: String?, + myPort: Int, + ): Boolean = try { + val body = JSONObject() + .put("from", myNpub) + .put("name", myName) + .put("ula", myUla) + .put("ip", myIp ?: "") + .put("port", if (myIp != null) myPort else 0) + .toString() + .toRequestBody("application/json".toMediaType()) + http.newCall( + Request.Builder().url("${base(peer)}/hello").post(body).build() + ).execute().use { it.isSuccessful } + } catch (_: Exception) { + false + } + + /** Blocking — call from Dispatchers.IO. */ + fun sendText(peer: PartyPeer, myNpub: String, myName: String, text: String): Boolean = try { + val body = JSONObject() + .put("from", myNpub) + .put("name", myName) + .put("text", text) + .put("ts", System.currentTimeMillis()) + .toString() + .toRequestBody("application/json".toMediaType()) + http.newCall( + Request.Builder().url("${base(peer)}/flare").post(body).build() + ).execute().use { it.isSuccessful } + } catch (_: Exception) { + false + } + + /** Blocking — call from Dispatchers.IO. */ + fun sendPhoto(peer: PartyPeer, myNpub: String, myName: String, jpeg: ByteArray): Boolean = try { + http.newCall( + Request.Builder() + .url("${base(peer)}/photo") + .header("X-From", myNpub) + .header("X-Name", myName) + .post(jpeg.toRequestBody("image/jpeg".toMediaType())) + .build() + ).execute().use { it.isSuccessful } + } catch (_: Exception) { + false + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/fips/PartyQr.kt b/Android/app/src/main/java/com/archipelago/app/fips/PartyQr.kt new file mode 100644 index 00000000..b79e148e --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/fips/PartyQr.kt @@ -0,0 +1,102 @@ +package com.archipelago.app.fips + +import android.net.Uri +import java.net.Inet4Address +import java.net.NetworkInterface + +/** + * Phone↔phone mesh pairing ("Mesh Party") QR contract: + * + * archipelago://party?v=1&npub=&ula=&name=[&ip=&port=] + * + * npub + ula alone are enough to chat *through* the mesh (anchors route by + * node address, no underlay info needed). ip/port are present only while the + * showing phone has its inbound UDP listener up (party mode) — the scanner + * then also gets a direct-dial link that works on a shared LAN/hotspot with + * no internet at all. Same versioning stance as the node pairing QR + * (docs/companion-pairing-qr.md): unknown params tolerated under v=1. + */ +data class PartyPeer( + val npub: String, + val ula: String, + val name: String, + /** Direct-dial underlay endpoint; empty when the peer wasn't listening. */ + val ip: String = "", + val port: Int = 0, +) + +object PartyQr { + const val SCHEME_HOST = "party" + private const val SUPPORTED_MAJOR = 1 + + /** UDP port a party-mode phone listens on (matches the node's mesh port). */ + const val PARTY_UDP_PORT = 2121 + + /** Application-layer chat/beam port, bound only on the mesh ULA. */ + const val FLARE_PORT = 5680 + + fun build(npub: String, ula: String, name: String, ip: String?, port: Int): String { + val b = Uri.Builder() + .scheme("archipelago") + .authority(SCHEME_HOST) + .appendQueryParameter("v", "1") + .appendQueryParameter("npub", npub) + .appendQueryParameter("ula", ula) + .appendQueryParameter("name", name) + if (!ip.isNullOrBlank() && port > 0) { + b.appendQueryParameter("ip", ip) + b.appendQueryParameter("port", port.toString()) + } + return b.build().toString() + } + + /** Null when [raw] is not a valid party QR (foreign codes just keep scanning). */ + fun parse(raw: String): PartyPeer? { + val uri = try { + Uri.parse(raw.trim()) + } catch (_: Exception) { + return null + } + if (!"archipelago".equals(uri.scheme, ignoreCase = true)) return null + if (uri.isOpaque || !SCHEME_HOST.equals(uri.host, ignoreCase = true)) return null + val major = uri.getQueryParameter("v")?.takeWhile { it.isDigit() }?.toIntOrNull() ?: return null + if (major != SUPPORTED_MAJOR) return null + + val npub = uri.getQueryParameter("npub")?.trim().orEmpty() + val ula = uri.getQueryParameter("ula")?.trim().orEmpty() + if (!npub.startsWith("npub1") || !ula.startsWith("fd")) return null + return PartyPeer( + npub = npub, + ula = ula, + name = uri.getQueryParameter("name")?.trim().orEmpty().ifBlank { "Phone" }, + ip = uri.getQueryParameter("ip")?.trim().orEmpty(), + port = uri.getQueryParameter("port")?.toIntOrNull() ?: 0, + ) + } + + /** + * This phone's private IPv4 on WiFi or its own hotspot, for the QR's + * direct-dial hint. Hotspot interfaces (ap/swlan/softap) win over wlan so + * the hotspot-host phone advertises the address its guests can reach. + */ + fun localWifiIpv4(): String? { + val candidates = mutableListOf>() // ifname → addr + try { + for (nif in NetworkInterface.getNetworkInterfaces()) { + if (!nif.isUp || nif.isLoopback) continue + for (addr in nif.inetAddresses) { + if (addr is Inet4Address && addr.isSiteLocalAddress) { + candidates += nif.name to addr.hostAddress.orEmpty() + } + } + } + } catch (_: Exception) { + return null + } + val hotspot = candidates.firstOrNull { + it.first.startsWith("ap") || it.first.startsWith("swlan") || it.first.startsWith("softap") + } + return (hotspot ?: candidates.firstOrNull { it.first.startsWith("wlan") } ?: candidates.firstOrNull()) + ?.second + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/network/InputWebSocket.kt b/Android/app/src/main/java/com/archipelago/app/network/InputWebSocket.kt new file mode 100644 index 00000000..671a7af7 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/network/InputWebSocket.kt @@ -0,0 +1,203 @@ +package com.archipelago.app.network + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import java.security.cert.X509Certificate +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLContext +import javax.net.ssl.X509TrustManager + +enum class ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, AUTH_FAILED, ERROR } + +class InputWebSocket( + private val scope: CoroutineScope, +) { + private var ws: WebSocket? = null + private var reconnectJob: Job? = null + private var reconnectAttempt = 0 + private var serverUrl: String = "" + private var password: String = "" + private var sessionCookie: String? = null + + /** Player ID for arcade mode (0 = broadcast, 1 = P1, 2 = P2) */ + var playerId: Int = 0 + + /** + * Invoked when the kiosk asks us to open a URL in the phone's default + * browser ({"t":"o","url":"…"}). "Open in external browser" apps can't be + * usefully opened on the kiosk, so the kiosk forwards them here. + */ + var onExternalOpen: ((String) -> Unit)? = null + + private val _state = MutableStateFlow(ConnectionState.DISCONNECTED) + val state: StateFlow = _state + + private val trustManager = object : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + override fun getAcceptedIssuers(): Array = arrayOf() + } + + private val client: OkHttpClient by lazy { + val sc = SSLContext.getInstance("TLS") + sc.init(null, arrayOf(trustManager), java.security.SecureRandom()) + + OkHttpClient.Builder() + .sslSocketFactory(sc.socketFactory, trustManager) + .hostnameVerifier { _, _ -> true } + .pingInterval(30, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.MILLISECONDS) + .connectTimeout(10, TimeUnit.SECONDS) + .build() + } + + fun connect(httpUrl: String, pwd: String = "") { + disconnect() + serverUrl = httpUrl + password = pwd + sessionCookie = null + reconnectAttempt = 0 + scope.launch(Dispatchers.IO) { doAuth() } + } + + private suspend fun doAuth() { + _state.value = ConnectionState.CONNECTING + + if (password.isBlank()) { + doConnect() + return + } + + try { + val body = """{"method":"auth.login","params":{"password":"$password"}}""" + .toRequestBody("application/json".toMediaType()) + val req = Request.Builder() + .url("$serverUrl/rpc/v1") + .post(body) + .build() + + val response = withContext(Dispatchers.IO) { client.newCall(req).execute() } + + if (response.isSuccessful) { + sessionCookie = response.headers("Set-Cookie") + .mapNotNull { cookie -> + cookie.split(";") + .firstOrNull() + ?.trim() + ?.takeIf { it.startsWith("session=") } + ?.removePrefix("session=") + } + .firstOrNull() + response.close() + + if (sessionCookie != null) { + doConnect() + } else { + _state.value = ConnectionState.AUTH_FAILED + } + } else { + response.close() + _state.value = ConnectionState.AUTH_FAILED + } + } catch (_: Exception) { + _state.value = ConnectionState.ERROR + scheduleReconnect() + } + } + + private fun doConnect() { + val basePath = "/ws/remote-input" + if (playerId > 0) "?p=$playerId" else "" + val wsUrl = serverUrl + .replace("https://", "wss://") + .replace("http://", "ws://") + .trimEnd('/') + basePath + + val reqBuilder = Request.Builder().url(wsUrl) + sessionCookie?.let { reqBuilder.header("Cookie", "session=$it") } + + ws = client.newWebSocket(reqBuilder.build(), object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + _state.value = ConnectionState.CONNECTED + reconnectAttempt = 0 + } + + override fun onMessage(webSocket: WebSocket, text: String) { + // The only inbound message we act on is an external-open request + // forwarded from the kiosk: {"t":"o","url":"https://…"}. + try { + val obj = org.json.JSONObject(text) + if (obj.optString("t") == "o") { + val url = obj.optString("url") + if (url.startsWith("http://") || url.startsWith("https://")) { + onExternalOpen?.invoke(url) + } + } + } catch (_: Exception) {} + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + _state.value = ConnectionState.ERROR + scheduleReconnect() + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(1000, null) + _state.value = ConnectionState.DISCONNECTED + if (code != 1000) scheduleReconnect() + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + _state.value = ConnectionState.DISCONNECTED + } + }) + } + + private fun scheduleReconnect() { + reconnectJob?.cancel() + reconnectJob = scope.launch(Dispatchers.IO) { + val delayMs = minOf(1000L * (1 shl minOf(reconnectAttempt, 5)), 30_000L) + reconnectAttempt++ + delay(delayMs) + doAuth() + } + } + + fun disconnect() { + reconnectJob?.cancel() + ws?.close(1000, "bye") + ws = null + _state.value = ConnectionState.DISCONNECTED + } + + // ─── Input senders ────────────────────────────────────────── + + fun sendKey(key: String) { + val pField = if (playerId > 0) ""","p":$playerId""" else "" + ws?.send("""{"t":"k","k":"$key"$pField}""") + } + + fun sendMouseMove(dx: Int, dy: Int) { + ws?.send("""{"t":"m","x":$dx,"y":$dy}""") + } + + fun sendClick(button: Int = 1) { + ws?.send("""{"t":"c","b":$button}""") + } + + fun sendScroll(dy: Int) { + ws?.send("""{"t":"s","y":$dy}""") + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/ActionButtons.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/ActionButtons.kt new file mode 100644 index 00000000..75c7b797 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/ActionButtons.kt @@ -0,0 +1,56 @@ +package com.archipelago.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.Neo +import com.archipelago.app.ui.theme.neoInset +import com.archipelago.app.ui.theme.neoRaised + +private val R = 14.dp + +@Composable +fun ActionButtons( + onEscape: () -> Unit, + onEnter: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally) { + NeoBtn("ESC", Neo.textSecondary(), Modifier.fillMaxWidth().weight(1f), onEscape) + NeoBtn("ENTER", BitcoinOrange.copy(alpha = 0.7f), Modifier.fillMaxWidth().weight(1f), onEnter) + } +} + +@Composable +private fun NeoBtn(label: String, color: androidx.compose.ui.graphics.Color, modifier: Modifier, onClick: () -> Unit) { + var p by remember { mutableStateOf(false) } + val l = Neo.shadowLight(); val d = Neo.shadowDark() + Box( + modifier = modifier + .then(if (p) Modifier.neoInset(l, d, R, 1.dp, 2.dp) else Modifier.neoRaised(l, d, R, 2.dp, 4.dp)) + .clip(RoundedCornerShape(R)) + .background(Neo.surfaceRaised()) + .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { + Text(label, color = if (p) color else color.copy(alpha = 0.7f), fontSize = 12.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.5.sp) + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/DPad.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/DPad.kt new file mode 100644 index 00000000..ec40e657 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/DPad.kt @@ -0,0 +1,121 @@ +package com.archipelago.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.Neo +import com.archipelago.app.ui.theme.neoInset +import com.archipelago.app.ui.theme.neoRaised +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private val BTN = 50.dp +private val BTN_R = 12.dp +private val GAP = 8.dp +private val NOB = 24.dp + +@Composable +fun DPad( + onDirection: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val surface = Neo.surface() + val raised = Neo.surfaceRaised() + val l = Neo.shadowLight() + val d = Neo.shadowDark() + + // Recessed well + Box( + modifier = modifier + .neoInset(l, d, 20.dp, 2.dp, 4.dp) + .clip(RoundedCornerShape(20.dp)) + .background(surface) + .padding(14.dp), + contentAlignment = Alignment.Center, + ) { + // Cross layout with explicit spacing + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Btn(Icons.Default.KeyboardArrowUp, "Up", onDirection) + Box(modifier = Modifier.size(height = GAP, width = BTN)) // spacer + Row(verticalAlignment = Alignment.CenterVertically) { + Btn(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "Left", onDirection) + Box(modifier = Modifier.size(width = GAP, height = BTN)) // spacer + // Center nob + Box( + modifier = Modifier + .size(NOB) + .neoRaised(l, d, NOB / 2, 1.dp, 2.dp) + .clip(CircleShape) + .background(raised), + contentAlignment = Alignment.Center, + ) { + Box(Modifier.size(8.dp).clip(CircleShape).background(BitcoinOrange.copy(alpha = 0.15f))) + } + Box(modifier = Modifier.size(width = GAP, height = BTN)) // spacer + Btn(Icons.AutoMirrored.Filled.KeyboardArrowRight, "Right", onDirection) + } + Box(modifier = Modifier.size(height = GAP, width = BTN)) // spacer + Btn(Icons.Default.KeyboardArrowDown, "Down", onDirection) + } + } +} + +@Composable +private fun Btn(icon: ImageVector, key: String, onDir: (String) -> Unit) { + val scope = rememberCoroutineScope() + var job by remember { mutableStateOf(null) } + var p by remember { mutableStateOf(false) } + val bg = Neo.surfaceRaised() + val l = Neo.shadowLight() + val d = Neo.shadowDark() + val tint = Neo.textPrimary() + DisposableEffect(Unit) { onDispose { job?.cancel() } } + + Box( + modifier = Modifier + .size(BTN) + .then(if (p) Modifier.neoInset(l, d, BTN_R, 1.dp, 2.dp) else Modifier.neoRaised(l, d, BTN_R, 2.dp, 4.dp)) + .clip(RoundedCornerShape(BTN_R)) + .background(bg) + .pointerInput(key) { + detectTapGestures(onPress = { + p = true; onDir(key) + // 500ms initial delay so a normal tap sends one key, not two + // (a touch tap often exceeds 350ms → doubled nav sound). + job = scope.launch { delay(500); while (true) { onDir(key); delay(100) } } + tryAwaitRelease(); p = false; job?.cancel() + }) + }, + contentAlignment = Alignment.Center, + ) { + Icon(icon, key, Modifier.fillMaxSize(0.48f), tint = if (p) tint.copy(alpha = 0.9f) else tint.copy(alpha = 0.5f)) + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/GamepadLayout.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/GamepadLayout.kt new file mode 100644 index 00000000..c93991d6 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/GamepadLayout.kt @@ -0,0 +1,134 @@ +package com.archipelago.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.Neo +import com.archipelago.app.ui.theme.neoInset +import com.archipelago.app.ui.theme.neoRaised + +@Composable +fun GamepadLayout( + onKey: (String) -> Unit, + onThreeFingerHold: () -> Unit, + modifier: Modifier = Modifier, +) { + val surface = Neo.surface() + + Box( + modifier = modifier + .fillMaxSize() + .background(surface) + .pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + var t = 0L; var fired = false + do { + val ev = awaitPointerEvent() + val a = ev.changes.filter { !it.changedToUp() } + if (a.size >= 3 && t == 0L) t = System.currentTimeMillis() + if (a.size >= 3 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onThreeFingerHold() } + if (a.size < 3) t = 0L + } while (ev.changes.any { it.pressed }) + } + } + .padding(horizontal = 24.dp, vertical = 16.dp), + ) { + // D-pad — centered left + DPad( + onDirection = onKey, + modifier = Modifier.align(Alignment.CenterStart).size(200.dp), + ) + + // Face buttons — centered right (diamond) + Column( + modifier = Modifier.align(Alignment.CenterEnd), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + FaceBtn("esc", 64.dp) { onKey("Escape") } + Row(horizontalArrangement = Arrangement.spacedBy(28.dp)) { + FaceBtn("tab", 64.dp) { onKey("Tab") } + FaceBtn("enter", 64.dp, accent = true) { onKey("Return") } + } + FaceBtn("bksp", 64.dp) { onKey("BackSpace") } + } + + // Bottom: L, SELECT, START, R + Row( + modifier = Modifier.align(Alignment.BottomCenter), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PillBtn("L", 56.dp) { onKey("Prior") } + PillBtn("SELECT", 80.dp) { onKey("Escape") } + PillBtn("START", 80.dp) { onKey("Return") } + PillBtn("R", 56.dp) { onKey("Next") } + } + } +} + +@Composable +private fun FaceBtn(label: String, size: Dp, accent: Boolean = false, onClick: () -> Unit) { + var p by remember { mutableStateOf(false) } + val l = Neo.shadowLight(); val d = Neo.shadowDark() + val tc = if (accent) BitcoinOrange.copy(alpha = 0.7f) else Neo.textSecondary() + + Box( + modifier = Modifier + .size(size) + .then(if (p) Modifier.neoInset(l, d, size / 2, 1.dp, 3.dp) else Modifier.neoRaised(l, d, size / 2, 2.dp, 4.dp)) + .clip(CircleShape) + .background(Neo.surfaceRaised()) + .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { + Text(label, color = if (p) tc.copy(alpha = 1f) else tc, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp) + } +} + +@Composable +private fun PillBtn(label: String, w: Dp, onClick: () -> Unit) { + var p by remember { mutableStateOf(false) } + val l = Neo.shadowLight(); val d = Neo.shadowDark() + + Box( + modifier = Modifier + .width(w).height(34.dp) + .then(if (p) Modifier.neoInset(l, d, 8.dp, 1.dp, 2.dp) else Modifier.neoRaised(l, d, 8.dp, 2.dp, 4.dp)) + .clip(RoundedCornerShape(8.dp)) + .background(Neo.surfaceRaised()) + .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { + Text(label, color = Neo.textMuted(), fontSize = 9.sp, fontWeight = FontWeight.Medium, letterSpacing = 1.sp) + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/GestureHintOverlay.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/GestureHintOverlay.kt new file mode 100644 index 00000000..62390b1e --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/GestureHintOverlay.kt @@ -0,0 +1,147 @@ +package com.archipelago.app.ui.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.archipelago.app.R +import com.archipelago.app.ui.theme.BitcoinOrange +import kotlinx.coroutines.delay + +/** + * First-launch teaching overlay for the three-finger hold gesture. Three + * fingertip dots pulse in a "press" rhythm with an expanding ring while a + * short caption explains what the gesture opens. Dismissed by tapping + * anywhere (or automatically after a few seconds) — shown once, ever. + */ +@Composable +fun GestureHintOverlay(onDismiss: () -> Unit) { + // Auto-dismiss so a user who taps nothing is never stuck behind the scrim. + LaunchedEffect(Unit) { + delay(6500) + onDismiss() + } + + val transition = rememberInfiniteTransition(label = "gesture-hint") + // Fingertips press down together… + val press by transition.animateFloat( + initialValue = 1f, + targetValue = 0.86f, + animationSpec = infiniteRepeatable(tween(650), RepeatMode.Reverse), + label = "press", + ) + // …while a ring ripples outward on each press cycle. + val ripple by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(1300, easing = LinearEasing)), + label = "ripple", + ) + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.72f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss, + ), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + // Hand: three fingertip dots in a natural arc + ripple ring. + Box(Modifier.size(160.dp), contentAlignment = Alignment.Center) { + Box( + Modifier + .size(150.dp) + .scale(0.4f + ripple * 0.6f) + .border( + 2.dp, + BitcoinOrange.copy(alpha = (1f - ripple) * 0.8f), + CircleShape, + ), + ) + FingerDot(x = (-44).dp, y = 14.dp, scale = press) + FingerDot(x = 0.dp, y = (-12).dp, scale = press) + FingerDot(x = 44.dp, y = 8.dp, scale = press) + } + + Spacer(Modifier.height(28.dp)) + + Text( + text = stringResource(R.string.gesture_hint_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = Color.White, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(10.dp)) + Text( + text = stringResource(R.string.gesture_hint_body), + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.7f), + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 48.dp), + ) + + Spacer(Modifier.height(32.dp)) + + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(Color.White.copy(alpha = 0.12f)) + .clickable(onClick = onDismiss) + .padding(horizontal = 28.dp, vertical = 12.dp), + ) { + Text( + text = stringResource(R.string.gesture_hint_got_it), + style = MaterialTheme.typography.labelLarge, + color = Color.White, + ) + } + } + } +} + +@Composable +private fun FingerDot(x: androidx.compose.ui.unit.Dp, y: androidx.compose.ui.unit.Dp, scale: Float) { + Box( + Modifier + .offset(x = x, y = y) + .size(26.dp) + .scale(scale) + .background(Color.White.copy(alpha = 0.92f), CircleShape), + ) +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/MeshLoadingScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/MeshLoadingScreen.kt new file mode 100644 index 00000000..aac1270a --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/MeshLoadingScreen.kt @@ -0,0 +1,77 @@ +package com.archipelago.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.archipelago.app.ui.screens.PixelArtLogo +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.SurfaceBlack +import com.archipelago.app.ui.theme.TextMuted + +/** + * The branded "F*CK IPs" full-screen loader — shown whenever the app is + * dialing the node over the mesh (relaunch race, post-scan first connect), + * instead of an anonymous spinner. The point of the brand: what's loading + * is a connection to a cryptographic identity, not an IP. + */ +@Composable +fun MeshLoadingScreen(message: String = "Dialing your node by its key — no IPs harmed") { + Box( + Modifier + .fillMaxSize() + .background(SurfaceBlack), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + // The brand's circle-container logo (as on the connect screen / + // web login): pixel-art "a" centered in a black disc. + Box( + Modifier + .size(120.dp) + .clip(androidx.compose.foundation.shape.CircleShape) + .background(Color.Black) + .border( + 1.dp, + Color.White.copy(alpha = 0.14f), + androidx.compose.foundation.shape.CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + PixelArtLogo(Modifier.size(64.dp)) + } + Spacer(Modifier.height(20.dp)) + Text( + text = "F*CK IPs MESH", + color = BitcoinOrange, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 4.sp, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = message, + color = TextMuted, + fontSize = 13.sp, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(24.dp)) + CircularProgressIndicator(color = BitcoinOrange) + } + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt new file mode 100644 index 00000000..00931af1 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESController.kt @@ -0,0 +1,488 @@ +package com.archipelago.app.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.archipelago.app.R +import com.archipelago.app.ui.theme.ControllerStyle +import com.archipelago.app.ui.theme.NES +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.abs + +// ═══════════════════════════════════════════════════════════ +// Palettes +// ═══════════════════════════════════════════════════════════ + +data class NESPalette( + val body: Color, val face: Color, val ridge: Color, + val label: Color, val labelMuted: Color, + val dpad: Color, val dpadHi: Color, + val btn: Color, val btnPress: Color, + val capsule: Color, val capsulePress: Color, + val inlayBg: Color, val inlayBorder: Color, +) + +val ClassicPalette = NESPalette( + body = NES.ClassicBody, face = NES.ClassicFace, ridge = NES.ClassicRidge, + label = NES.ClassicLabel, labelMuted = NES.ClassicLabelMuted, + dpad = Color(0xFF0C0C0C), dpadHi = Color(0xFF1A1A1A), + btn = NES.ClassicButtonRed, btnPress = NES.ClassicButtonRedPress, + capsule = Color(0xFF1C1C1C), capsulePress = Color(0xFF0E0E0E), + inlayBg = Color(0xFF080808), inlayBorder = Color(0xFF999999), +) + +// Glassmorphism-black (OS design): translucent dark surfaces so the backdrop +// shows through the controller, subtle white-alpha borders, translucent-white +// buttons. Accents come from each button's ring. +val DarkPalette = NESPalette( + body = Color(0xA6121216), face = Color(0x8C0E0E12), ridge = Color(0x14FFFFFF), + label = Color(0xFF9A9A9A), labelMuted = Color(0xFF777777), + dpad = Color(0xFF202024), dpadHi = Color(0xFF33333A), + btn = Color(0x14FFFFFF), btnPress = Color(0x0AFFFFFF), + capsule = Color(0x12FFFFFF), capsulePress = Color(0x08FFFFFF), + inlayBg = Color(0x990A0A0A), inlayBorder = Color(0x1FFFFFFF), +) + +fun paletteFor(style: ControllerStyle) = if (style == ControllerStyle.CLASSIC) ClassicPalette else DarkPalette + +// ═══════════════════════════════════════════════════════════ +// Landscape NES Controller +// ═══════════════════════════════════════════════════════════ + +@Composable +fun NESController( + style: ControllerStyle = ControllerStyle.CLASSIC, + playerId: Int = 0, + onKey: (String) -> Unit, + onMenu: () -> Unit, + onPlayerToggle: () -> Unit = {}, + onToggleStyle: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + val c = paletteFor(style) + val isClassic = style == ControllerStyle.CLASSIC + + Box( + modifier = modifier + .fillMaxSize() + .threeFingerHold(onMenu) + .padding(horizontal = 40.dp, vertical = 24.dp), + contentAlignment = Alignment.Center, + ) { + // Controller body + Box( + Modifier + .fillMaxWidth(0.86f) + .aspectRatio(2.3f) + .shadow(32.dp, RoundedCornerShape(16.dp), ambientColor = Color(0xFF000000), spotColor = Color(0xFF000000)) + .clip(RoundedCornerShape(16.dp)) + .background( + Brush.verticalGradient(listOf(c.body, c.body)) + ) + .border(1.dp, Color.White.copy(alpha = if (isClassic) 0.08f else 0.04f), RoundedCornerShape(16.dp)), + ) { + // Top highlight edge + Box( + Modifier.fillMaxWidth().height(1.dp).align(Alignment.TopCenter) + .background(Color.White.copy(alpha = if (isClassic) 0.12f else 0.05f)) + ) + + // Face plate + Box( + Modifier + .fillMaxSize() + .padding(14.dp) + .clip(RoundedCornerShape(10.dp)) + .background(c.face) + .border(0.5.dp, Color.White.copy(alpha = 0.03f), RoundedCornerShape(10.dp)), + ) { + // Ridges + Ridges(c.ridge, Modifier.align(Alignment.CenterStart).width(7.dp).fillMaxHeight().padding(vertical = 12.dp)) + Ridges(c.ridge, Modifier.align(Alignment.CenterEnd).width(7.dp).fillMaxHeight().padding(vertical = 12.dp)) + + // D-Pad in inlay (more left margin) + Inlay(c, Modifier.align(Alignment.CenterStart).padding(start = 48.dp).size(140.dp)) { + OnePointDPad(c, 120.dp, onKey) + } + + // Center: Logo + START/SELECT + Column( + Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(id = R.drawable.ic_logo_wide), + contentDescription = "Archipelago", + modifier = Modifier.width(180.dp), + colorFilter = ColorFilter.tint(if (isClassic) NES.ClassicLabel else c.label), + ) + Spacer(Modifier.height(10.dp)) + Inlay(c, Modifier.padding(horizontal = 4.dp)) { + Row( + Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + CapsuleBtn("SELECT", c, 64.dp, 28.dp) { onKey("Escape") } + CapsuleBtn("START", c, 64.dp, 28.dp) { onKey("Return") } + } + } + } + + // A/B/C Buttons in inlay — triangle: C top, B+A bottom + Inlay(c, Modifier.align(Alignment.CenterEnd).padding(end = 48.dp).size(140.dp)) { + Column( + Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + // C on top + GlassFaceBtn("C", Color(0xFFBBBBBB), 44.dp) { onKey("c") } + Spacer(Modifier.height(6.dp)) + // B + A on bottom row + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + GlassFaceBtn("B", Color(0xFF60A5FA), 44.dp) { onKey("b") } + GlassFaceBtn("A", Color(0xFFF7931A), 44.dp) { onKey("a") } + } + } + } + + // Player toggle + settings (bottom center) + Row( + Modifier.align(Alignment.BottomCenter).padding(bottom = 4.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PlayerPill(c, playerId, onPlayerToggle) + SettingsBtn(c, Modifier, onMenu) + onToggleStyle?.let { StyleBtn(c, Modifier, it) } + } + } + } + } +} + +// ═══════════════════════════════════════════════════════════ +// Shared sub-components +// ═══════════════════════════════════════════════════════════ + +/** Inlay well — dark recessed area with border */ +@Composable +fun Inlay(c: NESPalette, modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Box( + modifier = modifier + .clip(RoundedCornerShape(10.dp)) + .background(c.inlayBg) + .border(3.dp, c.inlayBorder, RoundedCornerShape(10.dp)) + .padding(4.dp), + contentAlignment = Alignment.Center, + ) { content() } +} + +/** One-piece D-pad — single cross shape, touch detects direction */ +@Composable +fun OnePointDPad(c: NESPalette, size: Dp, onDir: (String) -> Unit) { + val scope = rememberCoroutineScope() + var job by remember { mutableStateOf(null) } + var activeDir by remember { mutableStateOf(null) } + DisposableEffect(Unit) { onDispose { job?.cancel() } } + + Canvas( + modifier = Modifier + .size(size) + .pointerInput(Unit) { + detectTapGestures( + onPress = { offset -> + val cx = this@pointerInput.size.width / 2f + val cy = this@pointerInput.size.height / 2f + val dx = offset.x - cx + val dy = offset.y - cy + val dead = cx * 0.24f + if (abs(dx) < dead && abs(dy) < dead) { + tryAwaitRelease(); return@detectTapGestures + } + val dir = if (abs(dx) > abs(dy)) { + if (dx > 0) "Right" else "Left" + } else { + if (dy > 0) "Down" else "Up" + } + activeDir = dir; onDir(dir) + job?.cancel() + // 500ms initial delay so a normal tap sends one key, not + // two (a touch tap often exceeds 300ms → doubled nav sound). + job = scope.launch { delay(500); while (true) { onDir(dir); delay(90) } } + tryAwaitRelease() + job?.cancel(); activeDir = null + }, + ) + }, + ) { + val w = size.toPx() + val arm = w * 0.33f // arm width = 1/3 of total + val offset = (w - arm) / 2f + + // Cross shape + val crossColor = c.dpad + + // Vertical bar + drawRoundRect( + color = crossColor, + topLeft = Offset(offset, 0f), + size = Size(arm, w), + cornerRadius = CornerRadius(4.dp.toPx()), + ) + // Horizontal bar + drawRoundRect( + color = crossColor, + topLeft = Offset(0f, offset), + size = Size(w, arm), + cornerRadius = CornerRadius(4.dp.toPx()), + ) + + // Top-edge lighting + drawRoundRect( + brush = Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)), + topLeft = Offset(offset, 0f), + size = Size(arm, w * 0.15f), + cornerRadius = CornerRadius(4.dp.toPx()), + ) + drawRoundRect( + brush = Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)), + topLeft = Offset(0f, offset), + size = Size(w, arm * 0.3f), + cornerRadius = CornerRadius(4.dp.toPx()), + ) + + // Active direction highlight + activeDir?.let { dir -> + val hi = c.dpadHi + when (dir) { + "Up" -> drawRoundRect(hi, Offset(offset, 0f), Size(arm, arm), CornerRadius(4.dp.toPx())) + "Down" -> drawRoundRect(hi, Offset(offset, w - arm), Size(arm, arm), CornerRadius(4.dp.toPx())) + "Left" -> drawRoundRect(hi, Offset(0f, offset), Size(arm, arm), CornerRadius(4.dp.toPx())) + "Right" -> drawRoundRect(hi, Offset(w - arm, offset), Size(arm, arm), CornerRadius(4.dp.toPx())) + } + } + + // Center circle + drawCircle(c.dpadHi, radius = w * 0.06f, center = Offset(w / 2f, w / 2f)) + } +} + +@Composable +fun Ridges(color: Color, modifier: Modifier) { + Canvas(modifier = modifier) { + val h = 1.5.dp.toPx(); val gap = 3.dp.toPx(); var y = 0f + while (y < size.height) { drawRect(color, Offset(0f, y), Size(size.width, h)); y += h + gap } + } +} + +/** A/B round button with lighting */ +@Composable +fun RoundBtn(c: NESPalette, sz: Dp = 52.dp, onClick: () -> Unit) { + var p by remember { mutableStateOf(false) } + Box( + Modifier + .size(sz) + .shadow(if (p) 1.dp else 4.dp, CircleShape) + .clip(CircleShape) + .background(Brush.verticalGradient( + if (p) listOf(c.btnPress, c.btn.copy(alpha = 0.85f)) + else listOf(c.btn, c.btn.copy(alpha = 0.8f)) + )) + .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { + if (!p) Box(Modifier.fillMaxSize().clip(CircleShape).background( + Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.18f), Color.Transparent)) + )) + } +} + +/** Colored round button — custom color instead of palette */ +@Composable +fun ColorBtn(color: Color, pressColor: Color, sz: Dp = 48.dp, onClick: () -> Unit) { + var p by remember { mutableStateOf(false) } + Box( + Modifier + .size(sz) + .shadow(if (p) 1.dp else 4.dp, CircleShape) + .clip(CircleShape) + .background(Brush.verticalGradient( + if (p) listOf(pressColor, color.copy(alpha = 0.85f)) + else listOf(color, color.copy(alpha = 0.8f)) + )) + .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { + if (!p) Box(Modifier.fillMaxSize().clip(CircleShape).background( + Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.18f), Color.Transparent)) + )) + } +} + +/** Glass face button — dark translucent fill, colored ring + letter (OS style) */ +@Composable +fun GlassFaceBtn(label: String, accent: Color, sz: Dp = 44.dp, onClick: () -> Unit) { + var p by remember { mutableStateOf(false) } + Box( + Modifier + .size(sz) + .clip(CircleShape) + .background( + Brush.verticalGradient( + if (p) listOf(Color.White.copy(alpha = 0.05f), Color.White.copy(alpha = 0.02f)) + else listOf(Color.White.copy(alpha = 0.10f), Color.White.copy(alpha = 0.03f)) + ) + ) + .border(1.5.dp, accent.copy(alpha = if (p) 0.95f else 0.55f), CircleShape) + .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { + Text(label, color = accent.copy(alpha = if (p) 1f else 0.85f), fontSize = 16.sp, fontWeight = FontWeight.Bold) + } +} + +/** START/SELECT capsule */ +@Composable +fun CapsuleBtn(label: String, c: NESPalette, w: Dp = 64.dp, h: Dp = 28.dp, onClick: () -> Unit) { + var p by remember { mutableStateOf(false) } + Box( + Modifier + .width(w).height(h) + .shadow(if (p) 0.dp else 2.dp, RoundedCornerShape(4.dp)) + .clip(RoundedCornerShape(4.dp)) + .background(Brush.verticalGradient( + if (p) listOf(c.capsulePress, c.capsule) + else listOf(c.capsule, c.capsule.copy(alpha = 0.85f)) + )) + .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { + if (!p) Box(Modifier.fillMaxSize().clip(RoundedCornerShape(4.dp)).background( + Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.05f), Color.Transparent)) + )) + Text(label, color = c.labelMuted, fontSize = 8.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp) + } +} + +/** Settings gear button (48dp — large enough for easy tap on TV) */ +@Composable +fun SettingsBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Unit) { + var p by remember { mutableStateOf(false) } + Box( + modifier = modifier + .size(48.dp) + .clip(CircleShape) + .background(if (p) c.capsulePress else c.capsule) + .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Default.Settings, "Settings", Modifier.size(28.dp), tint = c.labelMuted) + } +} + +/** Dark/Classic style toggle — lives next to the settings gear (the menu hub + * no longer carries it). */ +@Composable +fun StyleBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Unit) { + var p by remember { mutableStateOf(false) } + Box( + modifier = modifier + .size(48.dp) + .clip(CircleShape) + .background(if (p) c.capsulePress else c.capsule) + .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Default.Palette, "Controller style", Modifier.size(26.dp), tint = c.labelMuted) + } +} + +/** Player ID toggle pill (P1/P2/ALL) */ +@Composable +fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) { + val label = when (playerId) { 1 -> "P1"; 2 -> "P2"; else -> "ALL" } + val accent = when (playerId) { 1 -> Color(0xFF00F0FF); 2 -> Color(0xFFFF0080); else -> c.labelMuted } + var p by remember { mutableStateOf(false) } + Box( + modifier = Modifier + .height(28.dp) + .width(44.dp) + .clip(RoundedCornerShape(6.dp)) + .background(if (p) c.capsulePress else c.capsule) + .border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(6.dp)) + .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onToggle(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { + Text(label, color = accent, fontSize = 10.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp) + } +} + +/** Three-finger hold gesture modifier (two fingers stay free for scrolling) */ +fun Modifier.threeFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + var t = 0L; var fired = false + do { + val ev = awaitPointerEvent() + val a = ev.changes.filter { !it.changedToUp() } + if (a.size >= 3 && t == 0L) t = System.currentTimeMillis() + if (a.size >= 3 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onHold() } + if (a.size < 3) t = 0L + } while (ev.changes.any { it.pressed }) + } +} + diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESKeyboard.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESKeyboard.kt new file mode 100644 index 00000000..f5b1c756 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESKeyboard.kt @@ -0,0 +1,211 @@ +package com.archipelago.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.archipelago.app.ui.theme.ControllerStyle +import com.archipelago.app.ui.theme.NES +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private enum class NKLayer { ALPHA, NUM, SYM } +private val KEY_H = 42.dp +private val GAP = 4.dp + +@Composable +fun NESKeyboard( + style: ControllerStyle = ControllerStyle.CLASSIC, + onKey: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val c = paletteFor(style) + val isClassic = style == ControllerStyle.CLASSIC + val keyBg = c.dpad + val keyBgP = c.dpadHi + val keyTxt = c.labelMuted + val accent = if (isClassic) NES.ClassicLabel else c.labelMuted + + var layer by remember { mutableStateOf(NKLayer.ALPHA) } + var shifted by remember { mutableStateOf(false) } + var capsLock by remember { mutableStateOf(false) } + var ctrlHeld by remember { mutableStateOf(false) } + val up = shifted || capsLock + + fun emit(k: String) { + val key = if (ctrlHeld) "ctrl+$k" else k + onKey(key) + if (shifted && !capsLock) shifted = false + if (ctrlHeld) ctrlHeld = false + } + fun ch(cc: String) { emit(if (up && layer == NKLayer.ALPHA) "shift+$cc" else cc) } + + // NES body wrapping keyboard + Column( + modifier = modifier + .clip(RoundedCornerShape(14.dp)) + .background(c.body) + .padding(8.dp) + .clip(RoundedCornerShape(8.dp)) + .background(c.face) + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(GAP), + ) { + when (layer) { + NKLayer.ALPHA -> { + KeyRow("q w e r t y u i o p".split(" "), up, keyBg, keyBgP, keyTxt, ::ch) + KeyRow("a s d f g h j k l".split(" "), up, keyBg, keyBgP, keyTxt, ::ch, inset = 16.dp) + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + NKey(if (capsLock) "\u21EA" else "\u21E7", Modifier.weight(1.4f), keyBg, keyBgP, if (up) accent else keyTxt) { + if (capsLock) { capsLock = false; shifted = false } else if (shifted) capsLock = true else shifted = true + } + "z x c v b n m".split(" ").forEach { k -> + NKey(if (up) k.uppercase() else k, Modifier.weight(1f), keyBg, keyBgP, keyTxt, 17) { ch(k) } + } + NRepKey("\u232B", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { emit("BackSpace") } + } + } + NKLayer.NUM -> { + KeyRow("1 2 3 4 5 6 7 8 9 0".split(" "), false, keyBg, keyBgP, keyTxt, ::emit) + KeyRow("- / : ; ( ) \$ & @ \"".split(" "), false, keyBg, keyBgP, keyTxt, ::emit) + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + NKey("#+=", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { layer = NKLayer.SYM } + ". , ? ! '".split(" ").forEach { k -> + NKey(k, Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit(k) } + } + NRepKey("\u232B", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { emit("BackSpace") } + } + } + NKLayer.SYM -> { + KeyRow("[ ] { } # % ^ * + =".split(" "), false, keyBg, keyBgP, keyTxt, ::emit) + KeyRow("_ \\ | ~ < > ` @ !".split(" "), false, keyBg, keyBgP, keyTxt, ::emit) + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + NKey("123", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { layer = NKLayer.NUM } + ". , ? ! '".split(" ").forEach { k -> + NKey(k, Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit(k) } + } + NRepKey("\u232B", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { emit("BackSpace") } + } + } + } + // Bottom row + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + NKey(if (layer == NKLayer.ALPHA) "123" else "ABC", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { + layer = if (layer == NKLayer.ALPHA) NKLayer.NUM else NKLayer.ALPHA; shifted = false; capsLock = false + } + NKey("Ctrl", Modifier.weight(1.2f), keyBg, keyBgP, if (ctrlHeld) accent else keyTxt, 11) { + ctrlHeld = !ctrlHeld + } + NKey(",", Modifier.weight(0.8f), keyBg, keyBgP, keyTxt) { emit("comma") } + NKey("space", Modifier.weight(4f), keyBg, keyBgP, keyTxt, 12) { emit("space") } + NKey(".", Modifier.weight(0.8f), keyBg, keyBgP, keyTxt) { emit("period") } + NKey("\u23CE", Modifier.weight(1.4f), keyBg, keyBgP, accent, 15) { emit("Return") } + } + } +} + +/** Key row — each key gets equal weight */ +@Composable +private fun KeyRow( + keys: List, up: Boolean, + bg: Color, bgP: Color, txt: Color, + onKey: (String) -> Unit, inset: Dp = 0.dp, +) { + Row( + Modifier.fillMaxWidth().height(KEY_H).padding(horizontal = inset), + Arrangement.spacedBy(GAP), + ) { + keys.forEach { k -> + NKey( + label = if (up) k.uppercase() else k, + modifier = Modifier.weight(1f), + bg = bg, bgP = bgP, txt = txt, + fontSize = 17, + onTap = { onKey(k) }, + ) + } + } +} + +/** Single NES key — D-pad style flat dark button */ +@Composable +private fun NKey( + label: String, modifier: Modifier = Modifier, + bg: Color, bgP: Color, txt: Color, + fontSize: Int = 13, onTap: () -> Unit, +) { + var p by remember { mutableStateOf(false) } + Box( + modifier = modifier + .height(KEY_H) + .clip(RoundedCornerShape(4.dp)) + .background(Brush.verticalGradient(if (p) listOf(bgP, bg) else listOf(bg, bg.copy(alpha = 0.9f)))) + .then( + if (!p) Modifier.border(0.5.dp, + Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)), + RoundedCornerShape(4.dp)) + else Modifier + ) + .pointerInput(label) { + detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false }) + }, + contentAlignment = Alignment.Center, + ) { + Text(label, color = txt, fontSize = fontSize.sp, textAlign = TextAlign.Center, maxLines = 1) + } +} + +/** Repeatable NES key (backspace) */ +@Composable +private fun NRepKey( + label: String, modifier: Modifier, + bg: Color, bgP: Color, txt: Color, onTap: () -> Unit, +) { + var p by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + var job by remember { mutableStateOf(null) } + DisposableEffect(Unit) { onDispose { job?.cancel() } } + + Box( + modifier = modifier + .height(KEY_H) + .clip(RoundedCornerShape(4.dp)) + .background(Brush.verticalGradient(if (p) listOf(bgP, bg) else listOf(bg, bg.copy(alpha = 0.9f)))) + .pointerInput(Unit) { + detectTapGestures(onPress = { + p = true; onTap() + job = scope.launch { delay(400); while (true) { onTap(); delay(55) } } + tryAwaitRelease(); job?.cancel(); p = false + }) + }, + contentAlignment = Alignment.Center, + ) { + Text(label, color = txt, fontSize = 16.sp) + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt new file mode 100644 index 00000000..3e7ef978 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt @@ -0,0 +1,620 @@ +package com.archipelago.app.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.Dashboard +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.SportsEsports +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import com.archipelago.app.fips.FipsManager +import com.archipelago.app.fips.FipsNative +import com.archipelago.app.fips.FipsPreferences +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.archipelago.app.R +import com.archipelago.app.data.ServerEntry +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.SurfaceDark +import com.archipelago.app.ui.theme.TextMuted +import com.archipelago.app.ui.theme.TextPrimary + +// Glassmorphism palette (OS design): near-black surfaces, subtle white borders, +// Bitcoin-orange accent. +private val PanelBg = SurfaceDark // #0A0A0A +private val PanelBorder = Color.White.copy(alpha = 0.12f) +private val RowBg = Color.White.copy(alpha = 0.05f) +private val RowBorder = Color.White.copy(alpha = 0.08f) +private val FieldBg = Color.White.copy(alpha = 0.04f) + +private val PANEL_R = 20.dp +private val ROW_R = 14.dp +private val ROW_H = 54.dp +private val FIELD_H = 58.dp + +/** Glassmorphism modal menu — #0A0A0A surface, subtle white borders. */ +@Composable +fun NESMenu( + visible: Boolean, + servers: List, + activeServer: ServerEntry?, + onDismiss: () -> Unit, + onSelectServer: (ServerEntry) -> Unit, + onAddServer: (ServerEntry) -> Unit, + onScanQr: (() -> Unit)? = null, + onEditServer: (ServerEntry, ServerEntry) -> Unit, + onRemoveServer: (ServerEntry) -> Unit, + onRemote: () -> Unit, + onKeyboard: () -> Unit, + onBackToWebView: (() -> Unit)? = null, + onMeshParty: (() -> Unit)? = null, +) { + AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) { + // Contained hub overlay: a centred glass panel (not full-screen) that + // holds the card page and its sub-pages (Nodes, FIPS) and scrolls + // inside its own bounds when content is tall. Tapping the dimmed + // backdrop dismisses. + Box( + Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f)) + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() }, + contentAlignment = Alignment.Center, + ) { + AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) { + MenuPanel(servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty) + } + } + } +} + +@Composable +private fun MenuPanel( + servers: List, + activeServer: ServerEntry?, + onDismiss: () -> Unit, + onSelectServer: (ServerEntry) -> Unit, + onAddServer: (ServerEntry) -> Unit, + onScanQr: (() -> Unit)?, + onEditServer: (ServerEntry, ServerEntry) -> Unit, + onRemoveServer: (ServerEntry) -> Unit, + onRemote: () -> Unit, + onKeyboard: () -> Unit, + onBackToWebView: (() -> Unit)?, + onMeshParty: (() -> Unit)?, +) { + var showAdd by remember { mutableStateOf(false) } + // The saved server being edited, or null when adding a new one. + var editing by remember { mutableStateOf(null) } + var nm by remember { mutableStateOf("") } + var addr by remember { mutableStateOf("") } + var pwd by remember { mutableStateOf("") } + var https by remember { mutableStateOf(false) } + + fun resetForm() { + nm = ""; addr = ""; pwd = ""; https = false; showAdd = false; editing = null + } + + fun startEdit(server: ServerEntry) { + editing = server + nm = server.name; addr = server.address; pwd = server.password; https = server.useHttps + showAdd = false + } + + fun submit() { + if (addr.isBlank()) return + val orig = editing + if (orig != null) { + // Preserve port (compact form doesn't expose it); scheme is now editable. + onEditServer(orig, orig.copy(address = addr, useHttps = https, password = pwd, name = nm)) + } else { + onAddServer(ServerEntry(addr, https, password = pwd, name = nm)) + } + resetForm() + } + + var page by remember { mutableStateOf(HubPage.HUB) } + + Column( + modifier = Modifier + .widthIn(max = 420.dp) + .fillMaxWidth() + .padding(horizontal = 20.dp) + // Cap height just short of the full screen; the panel wraps short + // content and only scrolls in the rare case it outgrows this. + .heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.92f).dp) + .clip(RoundedCornerShape(PANEL_R)) + .background(PanelBg.copy(alpha = 0.86f)) + .border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R)) + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {} + .verticalScroll(rememberScrollState()) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Header: back (on sub-pages) or title, and a close on the hub. + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + if (page == HubPage.HUB) { + Text("Menu", color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp) + IconRound(Icons.Default.Close, "Close") { onDismiss() } + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + IconRound(Icons.AutoMirrored.Filled.ArrowBack, "Back") { resetForm(); page = HubPage.HUB } + Spacer(Modifier.width(12.dp)) + Text( + if (page == HubPage.NODES) "Nodes" else "FIPS Mesh", + color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp, + ) + } + IconRound(Icons.Default.Close, "Close") { onDismiss() } + } + } + Spacer(Modifier.height(4.dp)) + + when (page) { + HubPage.HUB -> { + // Card page — one card per destination. Dashboard first: it's a + // peer of the others so three-finger → hub → Dashboard returns + // to the node UI, same shape as every other option. + if (onBackToWebView != null) { + HubCard(Icons.Default.Dashboard, "Dashboard", "The node's web interface") { onBackToWebView() } + } + HubCard(Icons.Default.SportsEsports, "Remote", "Game controller for the node") { onRemote() } + HubCard(Icons.Default.Keyboard, "Keyboard", "Type into the node") { onKeyboard() } + HubCard(Icons.Default.Dns, "Nodes", activeServer?.displayName() ?: "Add or switch servers") { + page = HubPage.NODES + } + if (FipsNative.available) { + HubCard(Icons.Default.Bolt, "FIPS Mesh", "Mesh identity & status") { page = HubPage.FIPS } + } + if (onMeshParty != null) { + HubCard(Icons.Default.Groups, "Mesh Party", "Phone-to-phone chat & beam") { onMeshParty() } + } + // Dark/Classic style lives on the remote/keyboard screen next to + // the settings button — not here. + } + + HubPage.NODES -> { + servers.forEach { server -> + val active = server.serialize() == activeServer?.serialize() + MenuItem( + label = server.displayName(), + selected = active, + onClick = { onSelectServer(server) }, + onEdit = { startEdit(server) }, + onRemove = { onRemoveServer(server) }, + ) + } + if (servers.isEmpty()) { + Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp)) + } + + if (showAdd || editing != null) { + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ROW_R)) + .background(FieldBg) + .border(1.dp, RowBorder, RoundedCornerShape(ROW_R)) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + if (editing != null) "Edit Server" else "Add Server", + color = TextMuted, fontSize = 13.sp, letterSpacing = 1.sp, fontWeight = FontWeight.Medium, + ) + Text( + "Cancel", color = TextMuted, fontSize = 13.sp, + modifier = Modifier.clickable { resetForm() }.padding(start = 8.dp), + ) + } + GlassField( + value = nm, onValueChange = { nm = it }, + placeholder = "Name (optional)", + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next), + ) + GlassField( + value = addr, onValueChange = { addr = it.trim() }, + placeholder = "192.168.1.100", + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + GlassField( + value = pwd, onValueChange = { pwd = it }, + placeholder = "Password", + modifier = Modifier.weight(1f), + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go), + keyboardActions = KeyboardActions(onGo = { submit() }), + ) + Box( + Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f)) + .border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp)) + .clickable { submit() }, + contentAlignment = Alignment.Center, + ) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) } + } + // HTTPS scheme toggle (available on both add and edit). + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ROW_R)) + .clickable { https = !https } + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("Use HTTPS", color = TextMuted, fontSize = 13.sp) + Box( + Modifier + .width(46.dp).height(26.dp) + .clip(RoundedCornerShape(13.dp)) + .background(if (https) BitcoinOrange.copy(alpha = 0.9f) else RowBg) + .border(1.dp, if (https) BitcoinOrange else RowBorder, RoundedCornerShape(13.dp)), + contentAlignment = if (https) Alignment.CenterEnd else Alignment.CenterStart, + ) { + Box( + Modifier + .padding(horizontal = 3.dp) + .size(20.dp) + .clip(RoundedCornerShape(10.dp)) + .background(if (https) Color.White else TextMuted), + ) + } + } + } + } else { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Box(Modifier.weight(1f)) { + MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true }) + } + if (onScanQr != null) { + Box( + Modifier + .size(ROW_H) + .clip(RoundedCornerShape(ROW_R)) + .background(RowBg) + .border(1.dp, RowBorder, RoundedCornerShape(ROW_R)) + .clickable { onScanQr() }, + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.QrCodeScanner, + contentDescription = stringResource(R.string.add_server_qr), + tint = BitcoinOrange, + modifier = Modifier.size(24.dp), + ) + } + } + } + } + } + + HubPage.FIPS -> { + FipsSection(embedded = true) + } + } + } +} + +private enum class HubPage { HUB, NODES, FIPS } + +/** Big tappable destination card for the hub page: icon + title + subtitle. */ +@Composable +private fun HubCard( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + subtitle: String, + onClick: () -> Unit, +) { + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ROW_R)) + .background(RowBg) + .border(1.dp, RowBorder, RoundedCornerShape(ROW_R)) + .clickable { onClick() } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Box( + Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = title, tint = BitcoinOrange, modifier = Modifier.size(22.dp)) + } + Column(Modifier.weight(1f)) { + Text(title, color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + Text(subtitle, color = TextMuted, fontSize = 12.sp, maxLines = 1) + } + } +} + +/** Small circular icon button used in the hub header. */ +@Composable +private fun IconRound( + icon: androidx.compose.ui.graphics.vector.ImageVector, + desc: String, + onClick: () -> Unit, +) { + Box( + Modifier + .size(40.dp) + .clip(RoundedCornerShape(20.dp)) + .background(RowBg) + .border(1.dp, RowBorder, RoundedCornerShape(20.dp)) + .clickable { onClick() }, + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = desc, tint = TextPrimary, modifier = Modifier.size(20.dp)) + } +} + +/** Snapshot of the phone's mesh identity + state for the FIPS menu section. */ +private data class FipsInfo( + val available: Boolean, + val running: Boolean, + val npub: String, + val meshAddress: String, + val peerCount: Int, +) + +/** + * FIPS mesh oversight: shows what the phone's embedded mesh node is doing — + * running state, its mesh identity (npub), its mesh address (fd…ULA), how + * many peers/anchors it's configured with — and a one-tap Reconnect that + * re-homes the mesh (also the manual fix if a network handoff ever misses). + * Collapsed by default so the menu stays compact. + */ +@Composable +private fun FipsSection(embedded: Boolean = false) { + if (!FipsNative.available) return + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + var expanded by remember { mutableStateOf(embedded) } + var info by remember { mutableStateOf(null) } + + // Load identity/state when the section opens (cheap DataStore + JSON read). + LaunchedEffect(expanded) { + if (expanded && info == null) { + val prefs = FipsPreferences(context) + val id = prefs.identity() + val peers = runCatching { + org.json.JSONArray(prefs.peersJson()).length() + }.getOrDefault(0) + info = FipsInfo( + available = true, + running = FipsNative.isRunning(), + npub = id?.npub.orEmpty(), + meshAddress = id?.address.orEmpty(), + peerCount = peers, + ) + } + } + + Column(Modifier.fillMaxWidth()) { + // Embedded in the hub's FIPS sub-page the header row would duplicate + // the page title, so only the standalone (collapsible) form shows it. + if (!embedded) MenuItem( + label = "FIPS Mesh", + labelColor = BitcoinOrange, + onClick = { expanded = !expanded }, + ) + if (expanded) { + val i = info + Column( + Modifier + .fillMaxWidth() + .padding(top = 6.dp) + .clip(RoundedCornerShape(ROW_R)) + .background(FieldBg) + .border(1.dp, RowBorder, RoundedCornerShape(ROW_R)) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (i == null) { + Text("Loading…", color = TextMuted, fontSize = 13.sp) + } else { + FipsRow("Status", if (i.running) "Connected" else "Stopped", + valueColor = if (i.running) BitcoinOrange else TextMuted) + if (i.meshAddress.isNotBlank()) { + FipsRow("Mesh address", i.meshAddress, mono = true, + onCopy = { clipboard.setText(AnnotatedString(i.meshAddress)) }) + } + if (i.npub.isNotBlank()) { + FipsRow("Identity (npub)", i.npub, mono = true, + onCopy = { clipboard.setText(AnnotatedString(i.npub)) }) + } + FipsRow("Peers & anchors", i.peerCount.toString()) + Text( + "Your node reaches this phone over the mesh by its npub — no ports opened to the internet.", + color = TextMuted, fontSize = 11.sp, + ) + MenuItem( + label = "Reconnect mesh", + labelColor = BitcoinOrange, + onClick = { + FipsManager.requestMeshRestart(context) + info = null + if (!embedded) expanded = false + }, + ) + } + } + } + } +} + +@Composable +private fun FipsRow( + label: String, + value: String, + valueColor: Color = TextPrimary, + mono: Boolean = false, + onCopy: (() -> Unit)? = null, +) { + Row( + Modifier + .fillMaxWidth() + .then(if (onCopy != null) Modifier.clickable { onCopy() } else Modifier), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, color = TextMuted, fontSize = 12.sp, modifier = Modifier.width(120.dp)) + Text( + value, + color = valueColor, + fontSize = if (mono) 11.sp else 13.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + textAlign = TextAlign.End, + ) + if (onCopy != null) { + Text("⧉", color = TextMuted, fontSize = 13.sp, modifier = Modifier.padding(start = 8.dp)) + } + } +} + +@Composable +private fun MenuItem( + label: String, + selected: Boolean = false, + labelColor: Color = TextPrimary, + onClick: () -> Unit, + onEdit: (() -> Unit)? = null, + onRemove: (() -> Unit)? = null, +) { + Row( + Modifier + .fillMaxWidth() + .height(ROW_H) + .clip(RoundedCornerShape(ROW_R)) + .background(if (selected) BitcoinOrange.copy(alpha = 0.12f) else RowBg) + .border(1.dp, if (selected) BitcoinOrange.copy(alpha = 0.4f) else RowBorder, RoundedCornerShape(ROW_R)) + .clickable { onClick() } + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + label, + color = if (selected) BitcoinOrange else labelColor, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + ) + if (onEdit != null) { + Text( + "✎", + color = TextMuted, + fontSize = 16.sp, + modifier = Modifier.clickable { onEdit() }.padding(horizontal = 8.dp), + ) + } + if (onRemove != null) { + Text( + "✕", + color = TextMuted, + fontSize = 16.sp, + modifier = Modifier.clickable { onRemove() }.padding(horizontal = 8.dp), + ) + } + } +} + +/** Glass text field with centered input text. */ +@Composable +private fun GlassField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + visualTransformation: androidx.compose.ui.text.input.VisualTransformation = androidx.compose.ui.text.input.VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + placeholder = { + Text(placeholder, color = TextMuted, fontSize = 15.sp, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) + }, + modifier = modifier.fillMaxWidth().height(FIELD_H), + singleLine = true, + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + textStyle = TextStyle(color = TextPrimary, fontSize = 16.sp, textAlign = TextAlign.Center), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.White.copy(alpha = 0.3f), + unfocusedBorderColor = Color.White.copy(alpha = 0.12f), + cursorColor = BitcoinOrange, + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary, + ), + shape = RoundedCornerShape(12.dp), + ) +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESPortraitController.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESPortraitController.kt new file mode 100644 index 00000000..eaf3afc0 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESPortraitController.kt @@ -0,0 +1,163 @@ +package com.archipelago.app.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.archipelago.app.R +import com.archipelago.app.ui.theme.ControllerStyle +import com.archipelago.app.ui.theme.NES + +/** + * Portrait gamepad — vertical remote shape like Apple TV but NES-styled. + * Large trackpad top, D-pad middle, A/B + START/SELECT bottom. + */ +@Composable +fun NESPortraitController( + style: ControllerStyle = ControllerStyle.CLASSIC, + playerId: Int = 0, + onKey: (String) -> Unit, + onMouseMove: (Int, Int) -> Unit = { _, _ -> }, + onMouseClick: (Int) -> Unit = { _ -> }, + onMouseScroll: (Int) -> Unit = { _ -> }, + onMenu: () -> Unit, + onPlayerToggle: () -> Unit = {}, + onToggleStyle: (() -> Unit)? = null, +) { + val c = paletteFor(style) + val isClassic = style == ControllerStyle.CLASSIC + + Box( + Modifier + .fillMaxSize() + .threeFingerHold(onMenu) + .padding(horizontal = 40.dp, vertical = 24.dp), + contentAlignment = Alignment.Center, + ) { + // Remote body — tall vertical shape + Box( + Modifier + .fillMaxWidth(0.75f) + .fillMaxSize() + .shadow(28.dp, RoundedCornerShape(20.dp), ambientColor = Color.Black, spotColor = Color.Black) + .clip(RoundedCornerShape(20.dp)) + .background(Brush.verticalGradient(listOf(c.body, c.body))) + .border(1.dp, Color.White.copy(alpha = if (isClassic) 0.08f else 0.04f), RoundedCornerShape(20.dp)), + ) { + // Top highlight + Box( + Modifier.fillMaxWidth().height(1.dp).align(Alignment.TopCenter) + .background(Color.White.copy(alpha = if (isClassic) 0.12f else 0.05f)) + ) + + // Face plate + Column( + Modifier + .fillMaxSize() + .padding(14.dp) + .clip(RoundedCornerShape(14.dp)) + .background(c.face) + .border(0.5.dp, Color.White.copy(alpha = 0.03f), RoundedCornerShape(14.dp)) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween, + ) { + // Trackpad area (touch surface for mouse) + Trackpad( + onMove = { dx, dy -> onMouseMove(dx, dy) }, + onClick = { onMouseClick(it) }, + onScroll = { dy -> onMouseScroll(dy) }, + onThreeFingerHold = onMenu, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) + + Spacer(Modifier.height(12.dp)) + + // D-Pad + Inlay(c, Modifier.size(150.dp)) { + OnePointDPad(c, 130.dp, onKey) + } + + Spacer(Modifier.height(12.dp)) + + // Logo + Image( + painter = painterResource(id = R.drawable.ic_logo_wide), + contentDescription = "Archipelago", + modifier = Modifier.width(140.dp), + colorFilter = ColorFilter.tint(if (isClassic) NES.ClassicLabel else c.label), + ) + + Spacer(Modifier.height(12.dp)) + + // A/B/C Buttons — triangle: C top, B+A bottom + Inlay(c, Modifier.fillMaxWidth()) { + Column( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + GlassFaceBtn("C", Color(0xFFBBBBBB), 46.dp) { onKey("c") } + Spacer(Modifier.height(6.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { + GlassFaceBtn("B", Color(0xFF60A5FA), 46.dp) { onKey("b") } + GlassFaceBtn("A", Color(0xFFF7931A), 46.dp) { onKey("a") } + } + } + } + + Spacer(Modifier.height(10.dp)) + + // START / SELECT + Inlay(c, Modifier) { + Row( + Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + CapsuleBtn("SELECT", c, 64.dp, 28.dp) { onKey("Escape") } + CapsuleBtn("START", c, 64.dp, 28.dp) { onKey("Return") } + } + } + + Spacer(Modifier.height(6.dp)) + + // Player toggle + Settings + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + PlayerPill(c, playerId, onPlayerToggle) + Spacer(Modifier.width(10.dp)) + SettingsBtn(c, Modifier, onMenu) + onToggleStyle?.let { + Spacer(Modifier.width(10.dp)) + StyleBtn(c, Modifier, it) + } + } + } + } + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/QrScannerOverlay.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/QrScannerOverlay.kt new file mode 100644 index 00000000..61a7940a --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/QrScannerOverlay.kt @@ -0,0 +1,352 @@ +package com.archipelago.app.ui.components + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import com.archipelago.app.R +import com.archipelago.app.data.PairResult +import com.archipelago.app.data.ServerQrParser +import com.archipelago.app.ui.screens.GlassButton +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.TextMuted +import com.archipelago.app.ui.theme.TextPrimary +import com.google.zxing.BarcodeFormat +import com.google.zxing.BinaryBitmap +import com.google.zxing.DecodeHintType +import com.google.zxing.MultiFormatReader +import com.google.zxing.NotFoundException +import com.google.zxing.PlanarYUVLuminanceSource +import com.google.zxing.common.HybridBinarizer +import kotlinx.coroutines.delay +import java.util.concurrent.Executors + +/** + * Full-screen camera overlay that scans the node pairing QR + * (docs/companion-pairing-qr.md) and reports the decoded server entry. + * Handles the camera permission itself; foreign/invalid codes show a hint + * and scanning continues. + */ +@Composable +fun QrScannerOverlay( + visible: Boolean, + onDismiss: () -> Unit, + onServerScanned: (PairResult.Success) -> Unit, +) { + val context = LocalContext.current + var hasPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + ) + } + var hintRes by remember { mutableStateOf(null) } + var handled by remember { mutableStateOf(false) } + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> hasPermission = granted } + + LaunchedEffect(visible) { + if (visible) { + handled = false + hintRes = null + val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + hasPermission = granted + if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA) + } + } + + // Foreign-code hint fades after a moment so scanning feels live again. + LaunchedEffect(hintRes) { + if (hintRes != null) { + delay(2500) + hintRes = null + } + } + + AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) { + BackHandler { onDismiss() } + Box( + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + if (hasPermission) { + CameraQrPreview( + onDecoded = { text -> + if (!handled) { + when (val result = ServerQrParser.parse(text)) { + is PairResult.Success -> { + handled = true + onServerScanned(result) + } + is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr + is PairResult.Invalid -> hintRes = R.string.invalid_pairing_qr + } + } + }, + ) + // Aim frame + Box( + Modifier + .align(Alignment.Center) + .size(260.dp) + .border(2.dp, BitcoinOrange.copy(alpha = 0.85f), RoundedCornerShape(20.dp)), + ) + } else { + Column( + Modifier + .align(Alignment.Center) + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.camera_permission_needed), + color = TextPrimary, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + GlassButton( + text = stringResource(R.string.grant_camera_access), + onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) + } + } + + // Top bar: title + close + Row( + Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.scan_node_qr), + color = TextPrimary, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 12.dp), + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, stringResource(R.string.close), tint = TextPrimary) + } + } + + // Bottom hints + Column( + Modifier + .align(Alignment.BottomCenter) + .windowInsetsPadding(WindowInsets.safeDrawing) + .padding(horizontal = 32.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + hintRes?.let { res -> + Text( + text = stringResource(res), + color = BitcoinOrange, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + } + if (hasPermission) { + Text( + text = stringResource(R.string.scan_qr_hint), + color = TextMuted, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + } + } + } + } +} + +/** Shared by the pairing scanner and the wallet scan modal. */ +@Composable +internal fun CameraQrPreview(onDecoded: (String) -> Unit) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val currentOnDecoded by rememberUpdatedState(onDecoded) + val previewView = remember { + PreviewView(context).apply { + scaleType = PreviewView.ScaleType.FILL_CENTER + // TextureView, not the SurfaceView default: SurfaceView punches a + // hole in the window, which black-flashes inside Compose fades and + // ignores rounded-corner clipping (wallet modal). + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + } + } + + DisposableEffect(Unit) { + val analysisExecutor = Executors.newSingleThreadExecutor() + val mainExecutor = ContextCompat.getMainExecutor(context) + val providerFuture = ProcessCameraProvider.getInstance(context) + var provider: ProcessCameraProvider? = null + val focusScheduler = Executors.newSingleThreadScheduledExecutor() + + providerFuture.addListener({ + val p = providerFuture.get() + provider = p + val preview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + // Dense Lightning-invoice QRs need BOTH enough pixels per module and + // sharp focus. 1280x720 + a far-focused camera (e.g. Pixel 9a's main + // lens, which won't focus close) left dense invoices undecodable + // while sparse address QRs still read — the "scanner doesn't pick up + // invoices" report. 1920x1080 roughly doubles module resolution so a + // QR held at the camera's actual focus distance still resolves. + @Suppress("DEPRECATION") + val analysis = ImageAnalysis.Builder() + .setTargetResolution(android.util.Size(1920, 1080)) + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { + it.setAnalyzer( + analysisExecutor, + QrCodeAnalyzer { text -> mainExecutor.execute { currentOnDecoded(text) } }, + ) + } + try { + p.unbindAll() + val cam = p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis) + // Force a centre autofocus on a repeating tick. A hand-held QR is + // a static scene, so continuous-AF often never retriggers and the + // lens sits at its resting (far) focus — fatal for dense codes. + // A normalized centre point works before the view is measured. + val point = androidx.camera.core.SurfaceOrientedMeteringPointFactory(1f, 1f) + .createPoint(0.5f, 0.5f) + val focusAction = androidx.camera.core.FocusMeteringAction.Builder( + point, + androidx.camera.core.FocusMeteringAction.FLAG_AF, + ).disableAutoCancel().build() + focusScheduler.scheduleWithFixedDelay({ + runCatching { cam.cameraControl.startFocusAndMetering(focusAction) } + }, 0, 2, java.util.concurrent.TimeUnit.SECONDS) + } catch (_: Exception) { + // Camera unavailable — the user can dismiss and enter details manually. + } + }, mainExecutor) + + onDispose { + focusScheduler.shutdownNow() + provider?.unbindAll() + analysisExecutor.shutdown() + } + } + + AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize()) +} + +/** ZXing-based QR decoder over the camera's Y (luminance) plane. */ +private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAnalysis.Analyzer { + private val reader = MultiFormatReader().apply { + setHints( + mapOf( + DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE), + // Screen-displayed QRs come with moiré, glare, and soft focus at + // close range — the exhaustive search is worth the milliseconds. + DecodeHintType.TRY_HARDER to true, + ) + ) + } + + private var lastAttempt = 0L + + override fun analyze(image: ImageProxy) { + // Decode ~7x/s, not on every frame: TRY_HARDER (plus the inverted + // retry) pegs a core when run at camera rate, and that CPU contention + // is what made the preview itself stutter. KEEP_ONLY_LATEST means the + // frames skipped here are simply dropped, so decodes stay current. + val now = System.currentTimeMillis() + if (now - lastAttempt < 140) { + image.close() + return + } + lastAttempt = now + try { + val plane = image.planes[0] + val buffer = plane.buffer + // Copy into a rowStride-wide array; the last row of the plane buffer + // may be short of the full stride, so the tail stays zero-padded. + val data = ByteArray(plane.rowStride * image.height) + buffer.get(data, 0, minOf(buffer.remaining(), data.size)) + val source = PlanarYUVLuminanceSource( + data, plane.rowStride, image.height, + 0, 0, image.width, image.height, + false, + ) + val result = try { + reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))) + } catch (_: NotFoundException) { + // Dark-themed pages can render light-on-dark QRs — retry inverted. + reader.reset() + reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert()))) + } + onDecoded(result.text) + } catch (_: NotFoundException) { + // No QR in this frame — keep scanning. + } catch (_: Exception) { + // Malformed frame; skip it. + } finally { + reader.reset() + image.close() + } + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/ServerModal.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/ServerModal.kt new file mode 100644 index 00000000..b326f82e --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/ServerModal.kt @@ -0,0 +1,263 @@ +package com.archipelago.app.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Gamepad +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.RadioButtonChecked +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.Web +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.archipelago.app.data.ServerEntry +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.Neo +import com.archipelago.app.ui.theme.TextMuted +import com.archipelago.app.ui.theme.TextPrimary +import com.archipelago.app.ui.theme.neoRaised + +private val ROW_H = 48.dp +private val ROW_R = 12.dp + +@Composable +fun ServerModal( + visible: Boolean, + servers: List, + activeServer: ServerEntry?, + isGamepadMode: Boolean, + onDismiss: () -> Unit, + onSelectServer: (ServerEntry) -> Unit, + onAddServer: (ServerEntry) -> Unit, + onRemoveServer: (ServerEntry) -> Unit, + onToggleGamepadMode: () -> Unit, + onBackToWebView: (() -> Unit)? = null, +) { + AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.55f)) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + ) { onDismiss() }, + contentAlignment = Alignment.Center, + ) { + AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) { + ModalBody(servers, activeServer, isGamepadMode, onDismiss, onSelectServer, onAddServer, onRemoveServer, onToggleGamepadMode, onBackToWebView) + } + } + } +} + +@Composable +private fun ModalBody( + servers: List, + activeServer: ServerEntry?, + isGamepadMode: Boolean, + onDismiss: () -> Unit, + onSelectServer: (ServerEntry) -> Unit, + onAddServer: (ServerEntry) -> Unit, + onRemoveServer: (ServerEntry) -> Unit, + onToggleGamepadMode: () -> Unit, + onBackToWebView: (() -> Unit)?, +) { + val surface = Neo.surfaceRaised() + val light = Neo.shadowLight() + val dark = Neo.shadowDark() + var showAddForm by remember { mutableStateOf(false) } + var newAddress by remember { mutableStateOf("") } + var newPassword by remember { mutableStateOf("") } + + Column( + modifier = Modifier + .widthIn(max = 380.dp) + .neoRaised(light, dark, 24.dp, 6.dp, 12.dp) + .clip(RoundedCornerShape(24.dp)) + .background(surface) + .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {} + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Header + Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) { + Text("Servers", style = MaterialTheme.typography.titleMedium, color = Neo.textPrimary()) + IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) { + Icon(Icons.Default.Close, "Close", Modifier.size(16.dp), tint = Neo.textMuted()) + } + } + + // Server rows + servers.forEach { server -> + val isActive = server.serialize() == activeServer?.serialize() + ModalRow( + icon = if (isActive) Icons.Default.RadioButtonChecked else Icons.Default.RadioButtonUnchecked, + iconTint = if (isActive) BitcoinOrange else Neo.textMuted(), + label = server.address + if (server.port.isNotBlank()) ":${server.port}" else "", + onClick = { onSelectServer(server) }, + trailing = { + IconButton(onClick = { onRemoveServer(server) }, modifier = Modifier.size(28.dp)) { + Icon(Icons.Default.Close, "Remove", Modifier.size(14.dp), tint = Neo.textMuted()) + } + }, + ) + } + + if (servers.isEmpty()) { + Text("No servers", style = MaterialTheme.typography.bodyMedium, color = Neo.textMuted(), modifier = Modifier.padding(vertical = 4.dp)) + } + + // Add server + if (showAddForm) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ROW_R)) + .background(Neo.surface()) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = newAddress, onValueChange = { newAddress = it.trim() }, + placeholder = { Text("192.168.1.100") }, + modifier = Modifier.fillMaxWidth(), singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next), + colors = neoFieldColors(), + shape = RoundedCornerShape(10.dp), + textStyle = MaterialTheme.typography.bodyMedium, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = newPassword, onValueChange = { newPassword = it }, + placeholder = { Text("Password") }, + modifier = Modifier.weight(1f), singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go), + keyboardActions = KeyboardActions(onGo = { + if (newAddress.isNotBlank()) { + onAddServer(ServerEntry(newAddress, false, password = newPassword)) + newAddress = ""; newPassword = ""; showAddForm = false + } + }), + colors = neoFieldColors(), + shape = RoundedCornerShape(10.dp), + textStyle = MaterialTheme.typography.bodyMedium, + ) + Box( + modifier = Modifier.size(36.dp).clip(CircleShape).background(BitcoinOrange.copy(alpha = 0.15f)) + .clickable { + if (newAddress.isNotBlank()) { + onAddServer(ServerEntry(newAddress, false, password = newPassword)) + newAddress = ""; newPassword = ""; showAddForm = false + } + }, + contentAlignment = Alignment.Center, + ) { Icon(Icons.Default.Add, "Add", Modifier.size(16.dp), tint = BitcoinOrange) } + } + } + } else { + ModalRow(icon = Icons.Default.Add, iconTint = BitcoinOrange, label = "Add Server", labelColor = BitcoinOrange, onClick = { showAddForm = true }) + } + + HorizontalDivider(color = Neo.border(), modifier = Modifier.padding(vertical = 4.dp)) + + // Gamepad toggle — label says what you switch TO + ModalRow( + icon = if (isGamepadMode) Icons.Default.Keyboard else Icons.Default.Gamepad, + iconTint = Neo.textSecondary(), + label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad", + onClick = onToggleGamepadMode, + ) + + // Back to dashboard + if (onBackToWebView != null) { + ModalRow(icon = Icons.Default.Web, iconTint = Neo.textSecondary(), label = "Back to Dashboard", onClick = onBackToWebView) + } + } +} + +/** Uniform-height row used for all modal actions */ +@Composable +private fun ModalRow( + icon: ImageVector, + iconTint: Color, + label: String, + onClick: () -> Unit, + labelColor: Color = Neo.textPrimary(), + trailing: (@Composable () -> Unit)? = null, +) { + val bg = Neo.surface() + val light = Neo.shadowLight() + val dark = Neo.shadowDark() + + Row( + modifier = Modifier + .fillMaxWidth() + .height(ROW_H) + .neoRaised(light, dark, ROW_R, 2.dp, 5.dp) + .clip(RoundedCornerShape(ROW_R)) + .background(bg) + .clickable { onClick() } + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, null, Modifier.size(18.dp), tint = iconTint) + Spacer(Modifier.width(12.dp)) + Text(label, style = MaterialTheme.typography.bodyMedium, color = labelColor, modifier = Modifier.weight(1f)) + if (trailing != null) trailing() + } +} + +@Composable +private fun neoFieldColors() = OutlinedTextFieldDefaults.colors( + focusedBorderColor = BitcoinOrange.copy(alpha = 0.4f), + unfocusedBorderColor = Neo.border(), + cursorColor = BitcoinOrange, + focusedTextColor = Neo.textPrimary(), + unfocusedTextColor = Neo.textPrimary(), +) diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/Trackpad.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/Trackpad.kt new file mode 100644 index 00000000..bb02faa7 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/Trackpad.kt @@ -0,0 +1,116 @@ +package com.archipelago.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.unit.dp +import com.archipelago.app.ui.theme.Neo +import com.archipelago.app.ui.theme.neoInset + +private const val TAP_THRESHOLD = 12f +private const val TAP_TIMEOUT = 250L + +@Composable +fun Trackpad( + onMove: (dx: Int, dy: Int) -> Unit, + onClick: (button: Int) -> Unit, + onScroll: (dy: Int) -> Unit, + onThreeFingerHold: () -> Unit, + modifier: Modifier = Modifier, +) { + var fingers by remember { mutableIntStateOf(0) } + val surface = Neo.surface() + val light = Neo.shadowLight() + val dark = Neo.shadowDark() + val muted = Neo.textMuted() + + Box( + modifier = modifier + .neoInset(light, dark, 20.dp, 3.dp, 6.dp) + .clip(RoundedCornerShape(20.dp)) + .background(surface) + .pointerInput(Unit) { + awaitEachGesture { + val first = awaitFirstDown(requireUnconsumed = false) + var total = Offset.Zero + val t0 = System.currentTimeMillis() + var maxPtrs = 1 + var holdFired = false + var threeStart = 0L + var scrollAcc = 0f + fingers = 1 + + do { + val ev = awaitPointerEvent() + val active = ev.changes.filter { !it.changedToUp() } + maxPtrs = maxOf(maxPtrs, active.size) + fingers = active.size + + when { + // Three fingers = hold for menu; two = scroll. Kept + // on separate counts so a long two-finger scroll can + // never fire the menu mid-gesture. + active.size >= 3 -> { + if (threeStart == 0L) threeStart = System.currentTimeMillis() + if (!holdFired && System.currentTimeMillis() - threeStart > 500) { + holdFired = true + onThreeFingerHold() + } + ev.changes.forEach { it.consume() } + } + active.size == 2 -> { + threeStart = 0L + val dy = active.map { it.positionChange().y }.average().toFloat() + scrollAcc += dy + if (kotlin.math.abs(scrollAcc) > 12f) { + onScroll(if (scrollAcc > 0) 1 else -1) + scrollAcc = 0f + } + ev.changes.forEach { it.consume() } + } + active.size == 1 && maxPtrs == 1 -> { + val d = active.first().positionChange() + total += d + if (d != Offset.Zero) onMove(d.x.toInt(), d.y.toInt()) + active.first().consume() + } + } + } while (ev.changes.any { it.pressed }) + + fingers = 0 + val elapsed = System.currentTimeMillis() - t0 + if (maxPtrs == 1 && elapsed < TAP_TIMEOUT && total.getDistance() < TAP_THRESHOLD) { + onClick(1) + } + } + }, + contentAlignment = Alignment.Center, + ) { + Text( + text = when { + fingers >= 3 -> "hold for menu" + fingers == 2 -> "scroll" + else -> "" + }, + style = MaterialTheme.typography.labelSmall, + color = muted.copy(alpha = 0.4f), + ) + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/VirtualKeyboard.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/VirtualKeyboard.kt new file mode 100644 index 00000000..d09632de --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/VirtualKeyboard.kt @@ -0,0 +1,173 @@ +package com.archipelago.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.Neo +import com.archipelago.app.ui.theme.neoInset +import com.archipelago.app.ui.theme.neoRaised +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private enum class Layer { ALPHA, NUM, SYM } +private val KEY_H = 46.dp +private val KEY_R = 10.dp +private val GAP = 5.dp + +@Composable +fun VirtualKeyboard(onKey: (String) -> Unit, modifier: Modifier = Modifier) { + var layer by remember { mutableStateOf(Layer.ALPHA) } + var shifted by remember { mutableStateOf(false) } + var capsLock by remember { mutableStateOf(false) } + val up = shifted || capsLock + + fun emit(k: String) { onKey(k); if (shifted && !capsLock) shifted = false } + fun ch(c: String) { emit(if (up && layer == Layer.ALPHA) "shift+$c" else c) } + + Column( + modifier = modifier.background(Neo.surface()).padding(horizontal = 6.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(GAP), + ) { + when (layer) { + Layer.ALPHA -> { + CRow("q w e r t y u i o p".split(" "), up, ::ch) + CRow("a s d f g h j k l".split(" "), up, ::ch, inset = 18.dp) + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + SKey(if (capsLock) "\u21EA" else "\u21E7", Modifier.weight(1.4f), active = up) { + if (capsLock) { capsLock = false; shifted = false } else if (shifted) capsLock = true else shifted = true + } + "z x c v b n m".split(" ").forEach { c -> CKey(if (up) c.uppercase() else c, Modifier.weight(1f)) { ch(c) } } + RKey("\u232B", Modifier.weight(1.4f)) { emit("BackSpace") } + } + } + Layer.NUM -> { + SRow("1 2 3 4 5 6 7 8 9 0".split(" "), ::emit) + SRow("- / : ; ( ) \$ & @ \"".split(" "), ::emit) + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + SKey("#+=", Modifier.weight(1.4f)) { layer = Layer.SYM } + ". , ? ! '".split(" ").forEach { c -> CKey(c, Modifier.weight(1f)) { emit(c) } } + RKey("\u232B", Modifier.weight(1.4f)) { emit("BackSpace") } + } + } + Layer.SYM -> { + SRow("[ ] { } # % ^ * + =".split(" "), ::emit) + SRow("_ \\ | ~ < > ` @ !".split(" "), ::emit) + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + SKey("123", Modifier.weight(1.4f)) { layer = Layer.NUM } + ". , ? ! '".split(" ").forEach { c -> CKey(c, Modifier.weight(1f)) { emit(c) } } + RKey("\u232B", Modifier.weight(1.4f)) { emit("BackSpace") } + } + } + } + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + SKey(if (layer == Layer.ALPHA) "123" else "ABC", Modifier.weight(1.4f)) { + layer = if (layer == Layer.ALPHA) Layer.NUM else Layer.ALPHA; shifted = false; capsLock = false + } + CKey(",", Modifier.weight(1f)) { emit("comma") } + CKey("space", Modifier.weight(5f), fontSize = 13) { emit("space") } + CKey(".", Modifier.weight(1f)) { emit("period") } + AKey("\u23CE", Modifier.weight(1.4f)) { emit("Return") } + } + } +} + +@Composable +private fun CRow(keys: List, up: Boolean, onKey: (String) -> Unit, inset: Dp = 0.dp) { + Row(Modifier.fillMaxWidth().height(KEY_H).padding(horizontal = inset), Arrangement.spacedBy(GAP)) { + keys.forEach { c -> CKey(if (up) c.uppercase() else c, Modifier.weight(1f)) { onKey(c) } } + } +} +@Composable +private fun SRow(keys: List, onKey: (String) -> Unit) { + Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) { + keys.forEach { c -> CKey(c, Modifier.weight(1f)) { onKey(c) } } + } +} + +/** Character key */ +@Composable +private fun CKey(label: String, modifier: Modifier = Modifier, fontSize: Int = 19, onTap: () -> Unit) { + var p by remember { mutableStateOf(false) } + val bg = Neo.surfaceRaised(); val l = Neo.shadowLight(); val d = Neo.shadowDark(); val t = Neo.textPrimary() + Box( + modifier = modifier.height(KEY_H) + .then(if (p) Modifier.neoInset(l, d, KEY_R) else Modifier.neoRaised(l, d, KEY_R)) + .clip(RoundedCornerShape(KEY_R)).background(bg) + .pointerInput(label) { detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { Text(label, color = t.copy(alpha = if (p) 0.9f else 0.7f), fontSize = fontSize.sp, textAlign = TextAlign.Center, maxLines = 1) } +} + +/** Special key */ +@Composable +private fun SKey(label: String, modifier: Modifier = Modifier, active: Boolean = false, onTap: () -> Unit) { + var p by remember { mutableStateOf(false) } + val bg = Neo.surfaceRaised(); val l = Neo.shadowLight(); val d = Neo.shadowDark() + val tc = if (active) BitcoinOrange.copy(alpha = 0.8f) else Neo.textSecondary() + Box( + modifier = modifier.height(KEY_H) + .then(if (p) Modifier.neoInset(l, d, KEY_R) else Modifier.neoRaised(l, d, KEY_R)) + .clip(RoundedCornerShape(KEY_R)).background(bg) + .pointerInput(label) { detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { Text(label, color = tc, fontSize = 14.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center) } +} + +/** Accent key (return) */ +@Composable +private fun AKey(label: String, modifier: Modifier = Modifier, onTap: () -> Unit) { + var p by remember { mutableStateOf(false) } + val l = Neo.shadowLight(); val d = Neo.shadowDark() + Box( + modifier = modifier.height(KEY_H) + .then(if (p) Modifier.neoInset(l, d, KEY_R) else Modifier.neoRaised(l, d, KEY_R)) + .clip(RoundedCornerShape(KEY_R)).background(Neo.surfaceRaised()) + .pointerInput(Unit) { detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false }) }, + contentAlignment = Alignment.Center, + ) { Text(label, color = BitcoinOrange.copy(alpha = 0.7f), fontSize = 17.sp, fontWeight = FontWeight.Bold) } +} + +/** Repeatable key (backspace) */ +@Composable +private fun RKey(label: String, modifier: Modifier = Modifier, onTap: () -> Unit) { + var p by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope(); var job by remember { mutableStateOf(null) } + val l = Neo.shadowLight(); val d = Neo.shadowDark() + DisposableEffect(Unit) { onDispose { job?.cancel() } } + Box( + modifier = modifier.height(KEY_H) + .then(if (p) Modifier.neoInset(l, d, KEY_R) else Modifier.neoRaised(l, d, KEY_R)) + .clip(RoundedCornerShape(KEY_R)).background(Neo.surfaceRaised()) + .pointerInput(Unit) { detectTapGestures(onPress = { + p = true; onTap(); job = scope.launch { delay(400); while (true) { onTap(); delay(55) } } + tryAwaitRelease(); job?.cancel(); p = false + }) }, + contentAlignment = Alignment.Center, + ) { Text(label, color = Neo.textSecondary(), fontSize = 17.sp) } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/WalletQrScannerModal.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/WalletQrScannerModal.kt new file mode 100644 index 00000000..98c77c65 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/WalletQrScannerModal.kt @@ -0,0 +1,294 @@ +package com.archipelago.app.ui.components + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import com.archipelago.app.R +import com.archipelago.app.ui.screens.GlassButton +import com.archipelago.app.ui.theme.BitcoinOrange +import com.google.zxing.BarcodeFormat +import com.google.zxing.BinaryBitmap +import com.google.zxing.DecodeHintType +import com.google.zxing.MultiFormatReader +import com.google.zxing.NotFoundException +import com.google.zxing.RGBLuminanceSource +import com.google.zxing.common.HybridBinarizer + +/** + * Native replacement for the web wallet's scan pane — same visual design as + * neode-ui's WalletScanModal (dark glass card, square preview, orange + * viewfinder, status strip) but the camera and decoding run natively, so the + * preview doesn't lag the way getUserMedia does inside a WebView. + * + * Decoded text is handed back to the page ([onDecoded]) which does all the + * detection/spend logic; the page in turn streams status lines (animated-QR + * progress, "not recognised" errors) back in via [status] and closes the + * modal through the JS bridge once it accepts a code. + */ +@Composable +fun WalletQrScannerModal( + visible: Boolean, + status: Pair?, // message from the web page + isError + onDecoded: (String) -> Unit, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + var hasPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + ) + } + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> hasPermission = granted } + + // Local error from a failed image upload; a fresh web status replaces it. + var uploadError by remember { mutableStateOf(null) } + val noQrMessage = stringResource(R.string.no_qr_in_image) + val imagePicker = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent() + ) { uri -> + if (uri != null) { + val decoded = decodeQrFromUri(context, uri) + if (decoded != null) { + uploadError = null + onDecoded(decoded) + } else { + uploadError = noQrMessage + } + } + } + + LaunchedEffect(visible) { + if (visible) { + uploadError = null + val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + hasPermission = granted + if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA) + } + } + LaunchedEffect(status) { if (status != null) uploadError = null } + + AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) { + BackHandler { onDismiss() } + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.6f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss, + ), + contentAlignment = Alignment.Center, + ) { + Column( + Modifier + .padding(16.dp) + .widthIn(max = 420.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(Color(0xF212151C)) + .border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(24.dp)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, // swallow — only the scrim dismisses + ) + .padding(24.dp), + ) { + // Header — mirrors the web modal's title row + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.scan_to_send), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = Color.White, + ) + IconButton(onClick = onDismiss) { + Icon( + Icons.Default.Close, + stringResource(R.string.close), + tint = Color.White.copy(alpha = 0.7f), + ) + } + } + + Spacer(Modifier.height(8.dp)) + + // Square camera preview with the orange viewfinder + Box( + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(12.dp)) + .background(Color.Black.copy(alpha = 0.4f)) + .border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center, + ) { + if (hasPermission) { + // Throttle repeat frames: a static QR decodes ~20x/s but + // the page only needs one; animated QRs still stream + // because each frame's text differs. + var lastText by remember { mutableStateOf("") } + var lastSentAt by remember { mutableStateOf(0L) } + CameraQrPreview(onDecoded = { text -> + val now = System.currentTimeMillis() + if (text != lastText || now - lastSentAt > 250) { + lastText = text + lastSentAt = now + onDecoded(text) + } + }) + Box( + Modifier + .fillMaxSize(0.62f) + .border( + 2.dp, + BitcoinOrange.copy(alpha = 0.85f), + RoundedCornerShape(16.dp), + ), + ) + } else { + Column( + Modifier.padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.camera_permission_needed), + color = Color.White.copy(alpha = 0.7f), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + GlassButton( + text = stringResource(R.string.grant_camera_access), + onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) }, + modifier = Modifier.fillMaxWidth().height(48.dp), + ) + } + } + } + + Spacer(Modifier.height(16.dp)) + + // Status strip — same slot the web modal uses for hints/errors + val message = uploadError ?: status?.first + val isError = uploadError != null || status?.second == true + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Color.White.copy(alpha = 0.05f)) + .padding(12.dp) + .defaultMinSize(minHeight = 24.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = message?.takeIf { it.isNotBlank() } + ?: stringResource(R.string.scan_wallet_hint), + style = MaterialTheme.typography.bodySmall, + color = if (isError) Color(0xFFF87171) else Color.White.copy(alpha = 0.6f), + textAlign = TextAlign.Center, + ) + } + + Spacer(Modifier.height(16.dp)) + + GlassButton( + text = stringResource(R.string.upload_qr_image), + onClick = { imagePicker.launch("image/*") }, + modifier = Modifier.fillMaxWidth().height(48.dp), + ) + } + } + } +} + +/** Decode a QR from a picked image, downsampled so huge photos stay cheap. */ +private fun decodeQrFromUri(context: Context, uri: Uri): String? { + return try { + val resolver = context.contentResolver + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) } + var sample = 1 + val maxDim = maxOf(bounds.outWidth, bounds.outHeight) + while (maxDim / (sample * 2) >= 1600) sample *= 2 + val opts = BitmapFactory.Options().apply { inSampleSize = sample } + val bmp = resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, opts) } + ?: return null + val pixels = IntArray(bmp.width * bmp.height) + bmp.getPixels(pixels, 0, bmp.width, 0, 0, bmp.width, bmp.height) + val source = RGBLuminanceSource(bmp.width, bmp.height, pixels) + val reader = MultiFormatReader().apply { + setHints( + mapOf( + DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE), + DecodeHintType.TRY_HARDER to true, + ) + ) + } + try { + reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))).text + } catch (_: NotFoundException) { + // Light-on-dark QRs (dark-themed wallets) decode inverted. + reader.reset() + reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert()))).text + } + } catch (_: Exception) { + null + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt b/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt new file mode 100644 index 00000000..138c817b --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt @@ -0,0 +1,225 @@ +package com.archipelago.app.ui.navigation + +import android.net.VpnService +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.archipelago.app.data.PairResult +import com.archipelago.app.data.ServerEntry +import com.archipelago.app.data.ServerPreferences +import com.archipelago.app.data.ServerQrParser +import com.archipelago.app.fips.FipsManager +import com.archipelago.app.ui.screens.FlareScreen +import com.archipelago.app.ui.screens.IntroScreen +import com.archipelago.app.ui.screens.PartyScreen +import com.archipelago.app.ui.screens.RemoteInputScreen +import com.archipelago.app.ui.screens.ServerConnectScreen +import com.archipelago.app.ui.screens.WebViewScreen +import kotlinx.coroutines.launch + +object Routes { + const val INTRO = "intro" + const val SERVER_CONNECT = "server_connect" + const val WEB_VIEW = "web_view" + const val REMOTE_INPUT = "remote_input" + const val MESH_PARTY = "mesh_party" + const val FLARE = "flare" +} + +@Composable +fun AppNavHost( + pairUri: String? = null, + onPairUriConsumed: () -> Unit = {}, +) { + val context = LocalContext.current + val prefs = remember { ServerPreferences(context) } + val navController = rememberNavController() + val scope = rememberCoroutineScope() + + val introSeen by prefs.introSeen.collectAsState(initial = null) + val activeServer by prefs.activeServer.collectAsState(initial = null) + + // Pairing entry from a deep link that carried no password — prefills the + // connect form so the user lands on the password prompt for that server. + var pairPrefill by remember { mutableStateOf(null) } + + // Mesh tunnel: Android's VPN consent dialog is the single unavoidable + // interaction — it can only be launched from an Activity, so pairing + // paths raise FipsManager.consentNeeded and it is handled here, once. + val consentNeeded by FipsManager.consentNeeded.collectAsState() + val vpnConsentLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + FipsManager.consentHandled() + if (result.resultCode == android.app.Activity.RESULT_OK) { + FipsManager.startService(context) + } + } + LaunchedEffect(consentNeeded) { + if (!consentNeeded) return@LaunchedEffect + val consentIntent = VpnService.prepare(context) + if (consentIntent == null) { + FipsManager.consentHandled() + FipsManager.startService(context) + } else { + vpnConsentLauncher.launch(consentIntent) + } + } + + // Paired + previously consented → the mesh comes back silently on launch. + LaunchedEffect(Unit) { + FipsManager.autoStartIfReady(context) + } + + if (introSeen == null) return + + // Declared after the introSeen gate so it can't fire before the NavHost + // below has set the nav graph; pairUri stays pending until consumed here. + LaunchedEffect(pairUri) { + val raw = pairUri ?: return@LaunchedEffect + onPairUriConsumed() + when (val result = ServerQrParser.parse(raw)) { + is PairResult.Success -> { + // Pairing implies the app is installed and in use — skip the intro. + prefs.markIntroSeen() + val merged = prefs.upsertServer(result.server) + FipsManager.registerNode(context, result.fips, merged.displayName()) + if (merged.password.isNotBlank()) { + // Demo flow: password came with the link — connect in one step. + prefs.setActiveServer(merged) + navController.navigate(Routes.WEB_VIEW) { + popUpTo(0) { inclusive = true } + } + } else { + pairPrefill = merged + navController.navigate(Routes.SERVER_CONNECT) { + popUpTo(0) { inclusive = true } + } + } + } + else -> { + // Invalid or too-new pairing link — ignore; normal startup continues. + } + } + } + + val startDestination = when { + introSeen == false -> Routes.INTRO + activeServer != null -> Routes.WEB_VIEW + else -> Routes.SERVER_CONNECT + } + + NavHost( + navController = navController, + startDestination = startDestination, + ) { + composable(Routes.INTRO) { + IntroScreen( + onMeshParty = { + navController.navigate(Routes.MESH_PARTY) + }, + onContinue = { + scope.launch { + prefs.markIntroSeen() + navController.navigate(Routes.SERVER_CONNECT) { + popUpTo(Routes.INTRO) { inclusive = true } + } + } + }, + ) + } + + composable(Routes.SERVER_CONNECT) { + ServerConnectScreen( + onConnected = { _ -> + navController.navigate(Routes.WEB_VIEW) { + popUpTo(Routes.SERVER_CONNECT) { inclusive = true } + } + }, + initialServer = pairPrefill, + ) + } + + composable(Routes.WEB_VIEW) { + val server = activeServer + if (server == null) { + ServerConnectScreen( + onConnected = { _ -> + navController.navigate(Routes.WEB_VIEW) { + popUpTo(0) { inclusive = true } + } + }, + ) + } else { + WebViewScreen( + serverUrl = server.toUrl(), + serverPassword = server.password, + meshFallbackUrl = server.toMeshUrl(), + onDisconnect = { + scope.launch { + prefs.clearActiveServer() + navController.navigate(Routes.SERVER_CONNECT) { + popUpTo(0) { inclusive = true } + } + } + }, + onRemoteInput = { + navController.navigate(Routes.REMOTE_INPUT) + }, + onRemoteKeyboard = { + navController.navigate("${Routes.REMOTE_INPUT}?keyboard=true") + }, + onMeshParty = { + navController.navigate(Routes.MESH_PARTY) + }, + ) + } + } + + composable( + "${Routes.REMOTE_INPUT}?keyboard={keyboard}", + arguments = listOf( + navArgument("keyboard") { + type = NavType.BoolType + defaultValue = false + }, + ), + ) { entry -> + RemoteInputScreen( + onBack = { + navController.popBackStack() + }, + onMeshParty = { + navController.navigate(Routes.MESH_PARTY) + }, + startInKeyboard = entry.arguments?.getBoolean("keyboard") == true, + ) + } + + composable(Routes.MESH_PARTY) { + PartyScreen( + onBack = { navController.popBackStack() }, + onOpenChat = { navController.navigate(Routes.FLARE) }, + ) + } + + composable(Routes.FLARE) { + FlareScreen( + onBack = { navController.popBackStack() }, + ) + } + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/FlareScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/FlareScreen.kt new file mode 100644 index 00000000..f9f24a30 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/FlareScreen.kt @@ -0,0 +1,359 @@ +package com.archipelago.app.ui.screens + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Image +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.archipelago.app.fips.FipsManager +import com.archipelago.app.fips.FipsNative +import com.archipelago.app.fips.FipsPreferences +import com.archipelago.app.fips.FlareClient +import com.archipelago.app.fips.FlareMessage +import com.archipelago.app.fips.FlareStore +import com.archipelago.app.fips.PartyPeer +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.SurfaceDark +import com.archipelago.app.ui.theme.TextMuted +import com.archipelago.app.ui.theme.TextPrimary +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.UUID + +private val BubbleTheirs = Color.White.copy(alpha = 0.07f) +private val BubbleBorder = Color.White.copy(alpha = 0.08f) + +/** + * Flare — phone↔phone chat and photo beam over the FIPS mesh. Every byte is + * E2E encrypted by the mesh layer and addressed by npub; whether it travels + * via a public anchor (5G) or a direct hotspot link is invisible up here — + * which is the entire point. + */ +@Composable +fun FlareScreen(onBack: () -> Unit) { + val context = LocalContext.current + val prefs = remember { FipsPreferences(context) } + val scope = rememberCoroutineScope() + + var identity by remember { mutableStateOf(null) } + var myName by remember { mutableStateOf("Phone") } + val peers by prefs.partyPeersFlow.collectAsState(initial = emptyList()) + var selectedNpub by remember { mutableStateOf(null) } + val allMessages by FlareStore.messages.collectAsState() + var draft by remember { mutableStateOf("") } + + LaunchedEffect(Unit) { + identity = FipsManager.ensureIdentity(prefs) + myName = prefs.partyName() + } + LaunchedEffect(peers) { + if (selectedNpub == null || peers.none { it.npub == selectedNpub }) { + selectedNpub = peers.firstOrNull()?.npub + } + } + + val peer = peers.firstOrNull { it.npub == selectedNpub } + val messages = allMessages.filter { it.peerNpub == selectedNpub } + val listState = rememberLazyListState() + LaunchedEffect(messages.size) { + if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1) + } + + fun sendText() { + val target = peer ?: return + val me = identity ?: return + val text = draft.trim() + if (text.isEmpty()) return + draft = "" + val msg = FlareMessage( + id = UUID.randomUUID().toString(), + peerNpub = target.npub, + fromMe = true, + name = myName, + text = text, + ts = System.currentTimeMillis(), + status = FlareMessage.Status.SENDING, + ) + FlareStore.add(msg) + scope.launch(Dispatchers.IO) { + val ok = FlareClient.sendText(target, me.npub, myName, text) + FlareStore.setStatus(msg.id, if (ok) FlareMessage.Status.SENT else FlareMessage.Status.FAILED) + } + } + + fun sendPhoto(uri: Uri) { + val target = peer ?: return + val me = identity ?: return + scope.launch(Dispatchers.IO) { + val jpeg = compressPhoto(context, uri) ?: return@launch + // Local copy so our own bubble renders the sent photo. + val dir = File(context.cacheDir, "flare").apply { mkdirs() } + val local = File(dir, "${UUID.randomUUID()}.jpg").apply { writeBytes(jpeg) } + val msg = FlareMessage( + id = UUID.randomUUID().toString(), + peerNpub = target.npub, + fromMe = true, + name = myName, + photoPath = local.absolutePath, + ts = System.currentTimeMillis(), + status = FlareMessage.Status.SENDING, + ) + FlareStore.add(msg) + val ok = FlareClient.sendPhoto(target, me.npub, myName, jpeg) + FlareStore.setStatus(msg.id, if (ok) FlareMessage.Status.SENT else FlareMessage.Status.FAILED) + } + } + + val photoPicker = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent() + ) { uri -> uri?.let { sendPhoto(it) } } + + BackHandler { onBack() } + + Column( + Modifier + .fillMaxSize() + .background(SurfaceDark) + .statusBarsPadding() + .navigationBarsPadding() + .imePadding(), + ) { + // Header + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("‹ Back", color = TextMuted, fontSize = 15.sp, modifier = Modifier.clickable { onBack() }.padding(6.dp)) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("FLARE", color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 3.sp) + peer?.let { + Text( + it.name + if (it.ip.isNotBlank()) " · direct+mesh" else " · mesh", + color = BitcoinOrange, + fontSize = 11.sp, + ) + } + } + Spacer(Modifier.size(48.dp)) + } + + // Peer tabs when chatting with more than one phone + if (peers.size > 1) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + peers.forEach { p -> + val active = p.npub == selectedNpub + Text( + p.name, + color = if (active) BitcoinOrange else TextMuted, + fontSize = 13.sp, + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(if (active) BitcoinOrange.copy(alpha = 0.12f) else Color.Transparent) + .border( + 1.dp, + if (active) BitcoinOrange.copy(alpha = 0.4f) else BubbleBorder, + RoundedCornerShape(10.dp), + ) + .clickable { selectedNpub = p.npub } + .padding(horizontal = 12.dp, vertical = 6.dp), + ) + } + } + } + + if (peer == null) { + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + Text("No party peers yet — scan a phone first", color = TextMuted, fontSize = 14.sp) + } + } else { + LazyColumn( + state = listState, + modifier = Modifier.weight(1f).fillMaxWidth(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(messages, key = { it.id }) { msg -> + MessageBubble(msg) + } + } + + // Composer + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + Modifier + .size(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(BubbleTheirs) + .border(1.dp, BubbleBorder, RoundedCornerShape(12.dp)) + .clickable { photoPicker.launch("image/*") }, + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Default.Image, "Beam a photo", tint = BitcoinOrange, modifier = Modifier.size(22.dp)) + } + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, + placeholder = { Text("Send a flare…", color = TextMuted, fontSize = 14.sp) }, + modifier = Modifier.weight(1f), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { sendText() }), + textStyle = TextStyle(color = TextPrimary, fontSize = 15.sp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.White.copy(alpha = 0.3f), + unfocusedBorderColor = Color.White.copy(alpha = 0.12f), + cursorColor = BitcoinOrange, + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary, + ), + shape = RoundedCornerShape(12.dp), + ) + Box( + Modifier + .size(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(BitcoinOrange.copy(alpha = 0.15f)) + .border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp)) + .clickable { sendText() }, + contentAlignment = Alignment.Center, + ) { + Text("➤", color = BitcoinOrange, fontSize = 18.sp) + } + } + } + } +} + +@Composable +private fun MessageBubble(msg: FlareMessage) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = if (msg.fromMe) Arrangement.End else Arrangement.Start, + ) { + Column( + Modifier + .widthIn(max = 300.dp) + .clip(RoundedCornerShape(16.dp)) + .background(if (msg.fromMe) BitcoinOrange.copy(alpha = 0.14f) else BubbleTheirs) + .border( + 1.dp, + if (msg.fromMe) BitcoinOrange.copy(alpha = 0.35f) else BubbleBorder, + RoundedCornerShape(16.dp), + ) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + if (msg.photoPath.isNotBlank()) { + val bmp = remember(msg.photoPath) { BitmapFactory.decodeFile(msg.photoPath) } + bmp?.let { + Image( + bitmap = it.asImageBitmap(), + contentDescription = "Beamed photo", + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)), + contentScale = ContentScale.FillWidth, + ) + } + } + if (msg.text.isNotBlank()) { + Text(msg.text, color = TextPrimary, fontSize = 15.sp) + } + Text( + when (msg.status) { + FlareMessage.Status.SENDING -> "sending…" + FlareMessage.Status.SENT -> "sent · E2E via mesh" + FlareMessage.Status.FAILED -> "failed — tap to retry later" + FlareMessage.Status.RECEIVED -> msg.name + }, + color = if (msg.status == FlareMessage.Status.FAILED) BitcoinOrange else TextMuted, + fontSize = 10.sp, + modifier = Modifier.padding(top = 2.dp), + ) + } + } +} + +/** Decode, downscale (≤1600px) and JPEG-compress a picked photo off-main. */ +private suspend fun compressPhoto(context: android.content.Context, uri: Uri): ByteArray? = + withContext(Dispatchers.IO) { + try { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(uri)?.use { + BitmapFactory.decodeStream(it, null, bounds) + } + var sample = 1 + while (maxOf(bounds.outWidth, bounds.outHeight) / sample > 1600) sample *= 2 + val opts = BitmapFactory.Options().apply { inSampleSize = sample } + val bitmap = context.contentResolver.openInputStream(uri)?.use { + BitmapFactory.decodeStream(it, null, opts) + } ?: return@withContext null + val out = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 80, out) + bitmap.recycle() + out.toByteArray() + } catch (_: Exception) { + null + } + } diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/IntroScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/IntroScreen.kt new file mode 100644 index 00000000..db42dff9 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/IntroScreen.kt @@ -0,0 +1,256 @@ +package com.archipelago.app.ui.screens + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.archipelago.app.R +import com.archipelago.app.ui.theme.SurfaceBlack +import com.archipelago.app.ui.theme.TextMuted +import com.archipelago.app.ui.theme.TextPrimary +import kotlinx.coroutines.delay + +@Composable +fun IntroScreen( + onContinue: () -> Unit, + // Mesh Party works with no node at all (phone↔phone) — offered right on + // the first screen so a friend who just got the app can join a party. + onMeshParty: () -> Unit = {}, +) { + val logoAlpha = remember { Animatable(0f) } + var showContent by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + logoAlpha.animateTo(1f, animationSpec = tween(800)) + delay(300) + showContent = true + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(SurfaceBlack), + ) { + // Reddish synthwave backdrop + Image( + painter = painterResource(id = R.drawable.bg_synthwave), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + // Dark scrim so the title/buttons stay legible over the art + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.55f), + Color.Black.copy(alpha = 0.35f), + Color.Black.copy(alpha = 0.75f), + ), + ) + ), + ) + Column( + modifier = Modifier + .align(Alignment.Center) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.safeDrawing) + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + // Circular badge logo + Image( + painter = painterResource(id = R.drawable.ic_logo), + contentDescription = "Archipelago", + modifier = Modifier + .size(160.dp) + .alpha(logoAlpha.value), + ) + + Spacer(modifier = Modifier.height(48.dp)) + + AnimatedVisibility( + visible = showContent, + enter = fadeIn(tween(600)) + slideInVertically( + initialOffsetY = { it / 4 }, + animationSpec = tween(600), + ), + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = stringResource(R.string.welcome_title), + style = MaterialTheme.typography.headlineLarge, + color = Color(0xFFFAFAFA), + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.welcome_subtitle), + style = MaterialTheme.typography.bodyLarge, + color = Color(0xFFFAFAFA), + textAlign = TextAlign.Center, + lineHeight = 26.sp, + ) + + Spacer(modifier = Modifier.height(48.dp)) + + GlassButton( + text = stringResource(R.string.get_started), + onClick = onContinue, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + GlassButton( + text = stringResource(R.string.mesh_party), + onClick = onMeshParty, + modifier = Modifier.fillMaxWidth().height(48.dp), + ) + } + } + } + } +} + +/** The pixel-art "A" from AnimatedLogo.vue — 20 white squares */ +@Composable +fun PixelArtLogo(modifier: Modifier = Modifier) { + Canvas(modifier = modifier) { + val s = size.width / 1024f + val rects = listOf( + floatArrayOf(357.614f, 318f, 71.007f, 70.936f), + floatArrayOf(436.152f, 318f, 72.082f, 70.936f), + floatArrayOf(515.766f, 318f, 72.082f, 70.936f), + floatArrayOf(595.379f, 318f, 71.007f, 70.936f), + floatArrayOf(595.379f, 396.46f, 71.007f, 72.011f), + floatArrayOf(673.917f, 396.46f, 72.083f, 72.011f), + floatArrayOf(278f, 475.994f, 72.083f, 72.012f), + floatArrayOf(357.614f, 475.994f, 71.007f, 72.012f), + floatArrayOf(436.152f, 475.994f, 72.082f, 72.012f), + floatArrayOf(515.766f, 475.994f, 72.082f, 72.012f), + floatArrayOf(595.379f, 475.994f, 71.007f, 72.012f), + floatArrayOf(673.917f, 475.994f, 72.083f, 72.012f), + floatArrayOf(278f, 555.529f, 72.083f, 70.936f), + floatArrayOf(357.614f, 555.529f, 71.007f, 70.936f), + floatArrayOf(595.379f, 555.529f, 71.007f, 70.936f), + floatArrayOf(673.917f, 555.529f, 72.083f, 70.936f), + floatArrayOf(357.614f, 633.989f, 71.007f, 72.011f), + floatArrayOf(436.152f, 633.989f, 72.082f, 72.011f), + floatArrayOf(515.766f, 633.989f, 72.082f, 72.011f), + floatArrayOf(595.379f, 633.989f, 71.007f, 72.011f), + ) + for (r in rects) { + drawRect( + color = Color.White, + topLeft = Offset(r[0] * s, r[1] * s), + size = Size(r[2] * s, r[3] * s), + ) + } + } +} + +/** + * Glass-style button matching Archipelago's .glass-button. + * Custom press state (subtle brighten) instead of Material ripple. + */ +@Composable +fun GlassButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val pressAlpha by animateFloatAsState( + targetValue = if (isPressed) 1f else 0f, + animationSpec = tween(if (isPressed) 0 else 150), + label = "press", + ) + + // Lerp between rest and pressed states + val bgTop = 0.12f + pressAlpha * 0.08f // 0.12 → 0.20 + val bgBottom = 0.04f + pressAlpha * 0.06f // 0.04 → 0.10 + val borderA = 0.15f + pressAlpha * 0.10f // 0.15 → 0.25 + val textAlpha = 1f - pressAlpha * 0.2f // 1.0 → 0.8 + + Box( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background( + Brush.verticalGradient( + colors = listOf( + Color.White.copy(alpha = bgTop), + Color.White.copy(alpha = bgBottom), + ), + ) + ) + .border( + width = 1.dp, + color = Color.White.copy(alpha = borderA), + shape = RoundedCornerShape(12.dp), + ) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + color = Color.White.copy(alpha = textAlpha), + style = MaterialTheme.typography.labelLarge, + fontSize = 16.sp, + ) + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/PartyScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/PartyScreen.kt new file mode 100644 index 00000000..31494993 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/PartyScreen.kt @@ -0,0 +1,519 @@ +package com.archipelago.app.ui.screens + +import android.graphics.Bitmap +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.archipelago.app.fips.FipsManager +import com.archipelago.app.fips.FipsNative +import com.archipelago.app.fips.FipsPreferences +import com.archipelago.app.fips.FlareClient +import com.archipelago.app.fips.PartyPeer +import com.archipelago.app.fips.PartyQr +import com.archipelago.app.ui.components.CameraQrPreview +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.SurfaceDark +import com.archipelago.app.ui.theme.TextMuted +import com.archipelago.app.ui.theme.TextPrimary +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private val CardBg = Color.White.copy(alpha = 0.05f) +private val CardBorder = Color.White.copy(alpha = 0.08f) + +/** Public companion download (vps2 demo host — serves the same APK as the + * nodes' QR link). Rendered as the "Share this app" QR. */ +private const val APP_DOWNLOAD_URL = + "http://146.59.87.168:2100/packages/archipelago-companion.apk" + +/** + * Mesh Party — phone↔phone FIPS pairing. Show your QR, scan theirs, and the + * two embedded mesh nodes link up: through anchors when there's internet, + * directly over any shared WiFi/hotspot when there isn't. + */ +@Composable +fun PartyScreen( + onBack: () -> Unit, + onOpenChat: () -> Unit, +) { + val context = LocalContext.current + val prefs = remember { FipsPreferences(context) } + val scope = rememberCoroutineScope() + + var identity by remember { mutableStateOf(null) } + var name by remember { mutableStateOf("") } + var localIp by remember { mutableStateOf(null) } + var showScanner by remember { mutableStateOf(false) } + var showShareQr by remember { mutableStateOf(false) } + var scanHint by remember { mutableStateOf(null) } + + // Camera permission — fresh installs (and reinstalls: uninstall wipes + // grants) land here with no CAMERA grant, and the raw preview just showed + // black. Ask the moment the scanner opens. + var hasCamera by remember { + mutableStateOf( + androidx.core.content.ContextCompat.checkSelfPermission( + context, android.Manifest.permission.CAMERA, + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + ) + } + val cameraPermLauncher = androidx.activity.compose.rememberLauncherForActivityResult( + androidx.activity.result.contract.ActivityResultContracts.RequestPermission() + ) { hasCamera = it } + LaunchedEffect(showScanner) { + if (showScanner && !hasCamera) { + cameraPermLauncher.launch(android.Manifest.permission.CAMERA) + } + } + val listenOn by prefs.partyListenFlow.collectAsState(initial = false) + val peers by prefs.partyPeersFlow.collectAsState(initial = emptyList()) + + LaunchedEffect(Unit) { + identity = FipsManager.ensureIdentity(prefs) + name = prefs.partyName() + // The hotspot/WiFi address can change while this screen is open + // (e.g. the user flips the hotspot on mid-demo) — keep it fresh. + while (true) { + localIp = withContext(Dispatchers.IO) { PartyQr.localWifiIpv4() } + delay(3_000) + } + } + + val qrPayload = identity?.let { id -> + PartyQr.build( + npub = id.npub, + ula = id.address, + name = name.ifBlank { "Phone" }, + ip = if (listenOn) localIp else null, + port = PartyQr.PARTY_UDP_PORT, + ) + } + val qrBitmap = remember(qrPayload) { qrPayload?.let { renderQr(it) } } + + BackHandler { + when { + showScanner -> showScanner = false + showShareQr -> showShareQr = false + else -> onBack() + } + } + + Box(Modifier.fillMaxSize().background(SurfaceDark)) { + Column( + Modifier + .fillMaxSize() + .statusBarsPadding() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("‹ Back", color = TextMuted, fontSize = 15.sp, modifier = Modifier.clickable { onBack() }.padding(8.dp)) + Text("MESH PARTY", color = TextPrimary, fontSize = 17.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 3.sp) + Spacer(Modifier.size(56.dp)) + } + + // My QR card + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(CardBg) + .border(1.dp, CardBorder, RoundedCornerShape(20.dp)) + .padding(18.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + qrBitmap?.let { bmp -> + Box( + Modifier + .clip(RoundedCornerShape(16.dp)) + .background(Color.White) + .padding(12.dp), + ) { + Image( + bitmap = bmp.asImageBitmap(), + contentDescription = "My mesh party QR", + modifier = Modifier.size(220.dp), + ) + } + } ?: Text("Mesh identity unavailable on this device", color = TextMuted, fontSize = 14.sp) + + identity?.let { + Text( + it.npub.take(16) + "…" + it.npub.takeLast(6), + color = TextMuted, + fontSize = 12.sp, + textAlign = TextAlign.Center, + ) + } + + OutlinedTextField( + value = name, + onValueChange = { + name = it.take(24) + scope.launch { prefs.setPartyName(name) } + }, + placeholder = { Text("Your name", color = TextMuted, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) }, + modifier = Modifier.fillMaxWidth().height(56.dp), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + textStyle = TextStyle(color = TextPrimary, fontSize = 15.sp, textAlign = TextAlign.Center), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.White.copy(alpha = 0.3f), + unfocusedBorderColor = Color.White.copy(alpha = 0.12f), + cursorColor = BitcoinOrange, + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary, + ), + shape = RoundedCornerShape(12.dp), + ) + + // Direct-link toggle (hotspot mode) + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(Modifier.padding(end = 12.dp)) { + Text("Accept direct links", color = TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.Medium) + Text( + when { + listenOn && localIp != null -> "Dialable at $localIp:${PartyQr.PARTY_UDP_PORT} — no internet needed" + listenOn -> "Waiting for a WiFi/hotspot address…" + else -> "Off — mesh routes via anchors only" + }, + color = if (listenOn) BitcoinOrange else TextMuted, + fontSize = 12.sp, + ) + } + Switch( + checked = listenOn, + onCheckedChange = { on -> + scope.launch { + prefs.setPartyListen(on) + FipsManager.requestMeshRestart(context) + } + }, + colors = SwitchDefaults.colors( + checkedTrackColor = BitcoinOrange, + checkedThumbColor = Color.White, + ), + ) + } + } + + GlassButton( + text = "Scan a Phone", + onClick = { showScanner = true }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) + + if (peers.isNotEmpty()) { + Text("PARTY PEERS", color = TextMuted, fontSize = 12.sp, letterSpacing = 2.sp) + peers.forEach { peer -> + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(CardBg) + .border(1.dp, CardBorder, RoundedCornerShape(14.dp)) + .clickable { onOpenChat() } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(Modifier.padding(end = 8.dp)) { + Text(peer.name, color = TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.Medium) + Text( + peer.npub.take(14) + "…" + if (peer.ip.isNotBlank()) " · direct ${peer.ip}" else " · via mesh", + color = TextMuted, + fontSize = 11.sp, + ) + } + Text( + "✕", + color = TextMuted, + fontSize = 16.sp, + modifier = Modifier.clickable { + scope.launch { + prefs.removePartyPeer(peer.npub) + FipsManager.requestMeshRestart(context) + } + }.padding(8.dp), + ) + } + } + GlassButton( + text = "Open Flare Chat", + onClick = onOpenChat, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) + } else { + Text( + "Scan another phone's party QR (or let them scan yours) to link your mesh nodes — works over 5G via anchors, or over any shared WiFi/hotspot with zero internet.", + color = TextMuted, + fontSize = 13.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), + ) + } + Spacer(Modifier.height(12.dp)) + // Hand the app itself to a friend: shares this install's own APK + // through the system sheet (Quick Share/Bluetooth), so a nearby + // phone gets the companion with zero internet — the whole party + // premise. + GlassButton( + text = "Share this app", + onClick = { showShareQr = true }, + modifier = Modifier.fillMaxWidth().height(48.dp), + ) + Spacer(Modifier.height(12.dp)) + } + + // "Share this app" — a QR of the public download link, so the other + // phone scans it with its normal camera and installs over any + // internet. (The vps2 demo host serves the same APK the nodes do.) + AnimatedVisibility(visible = showShareQr, enter = fadeIn(), exit = fadeOut()) { + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.94f)) + .clickable { showShareQr = false }, + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + val dlQr = remember { renderQr(APP_DOWNLOAD_URL) } + dlQr?.let { bmp -> + Box( + Modifier + .clip(RoundedCornerShape(16.dp)) + .background(Color.White) + .padding(14.dp), + ) { + Image( + bitmap = bmp.asImageBitmap(), + contentDescription = "Companion download QR", + modifier = Modifier.size(240.dp), + ) + } + } + Spacer(Modifier.height(18.dp)) + Text( + "Scan with any camera to download\nthe Archipelago Companion", + color = TextPrimary, + fontSize = 15.sp, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(10.dp)) + Text( + "…or send the APK file directly", + color = BitcoinOrange, + fontSize = 13.sp, + modifier = Modifier.clickable { shareCompanionApk(context) }.padding(8.dp), + ) + Spacer(Modifier.height(6.dp)) + Text("Close", color = TextMuted, fontSize = 14.sp, modifier = Modifier.clickable { showShareQr = false }.padding(8.dp)) + } + } + } + + // Party QR scanner overlay + AnimatedVisibility(visible = showScanner, enter = fadeIn(), exit = fadeOut()) { + Box(Modifier.fillMaxSize().background(Color.Black)) { + if (!hasCamera) { + Column( + Modifier.align(Alignment.Center).padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + "Camera access is needed to scan a party QR.", + color = TextPrimary, + fontSize = 15.sp, + textAlign = TextAlign.Center, + ) + GlassButton( + text = "Grant camera access", + onClick = { cameraPermLauncher.launch(android.Manifest.permission.CAMERA) }, + modifier = Modifier.fillMaxWidth().height(48.dp), + ) + } + } else CameraQrPreview(onDecoded = { text -> + val peer = PartyQr.parse(text) + when { + peer == null -> scanHint = "Not a mesh party QR" + peer.npub == identity?.npub -> scanHint = "That's your own QR" + else -> { + showScanner = false + scanHint = null + scope.launch { + prefs.upsertPartyPeer(peer) + // Pick up the direct-dial PeerConfig (and the + // listener, if ours is on) immediately. + FipsManager.requestMeshRestart(context) + // Pairing must be MUTUAL: announce ourselves so + // the scanned phone gets us as a peer + a chat + // entry without scanning back. Retried while + // the fresh link/session comes up. + val me = identity + if (me != null) { + launch(Dispatchers.IO) { + for (attempt in 0 until 6) { + val ok = FlareClient.sendHello( + peer = peer, + myNpub = me.npub, + myName = name.ifBlank { "Phone" }, + myUla = me.address, + myIp = if (listenOn) localIp else null, + myPort = PartyQr.PARTY_UDP_PORT, + ) + if (ok) break + delay(3_000) + } + } + } + onOpenChat() + } + } + } + }) + Box( + Modifier + .align(Alignment.Center) + .size(260.dp) + .border(2.dp, BitcoinOrange.copy(alpha = 0.85f), RoundedCornerShape(20.dp)), + ) + Text( + "Close", + color = TextPrimary, + fontSize = 16.sp, + modifier = Modifier + .align(Alignment.TopEnd) + .statusBarsPadding() + .clickable { showScanner = false } + .padding(20.dp), + ) + scanHint?.let { + Text( + it, + color = BitcoinOrange, + fontSize = 14.sp, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 48.dp) + .fillMaxWidth(), + ) + } + } + } + } + + // Scan hints fade so the camera feels live again. + LaunchedEffect(scanHint) { + if (scanHint != null) { + delay(2500) + scanHint = null + } + } +} + +/** Render a QR payload as a bitmap (dark modules on white). */ +private fun renderQr(payload: String, size: Int = 640): Bitmap? = try { + val matrix = QRCodeWriter().encode( + payload, + BarcodeFormat.QR_CODE, + size, + size, + mapOf(EncodeHintType.MARGIN to 1), + ) + val pixels = IntArray(size * size) + for (y in 0 until size) { + for (x in 0 until size) { + pixels[y * size + x] = if (matrix[x, y]) 0xFF0A0A0A.toInt() else 0xFFFFFFFF.toInt() + } + } + Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888) +} catch (_: Exception) { + null +} + +/** Share this install's own APK via the system share sheet — a nearby friend + * gets the companion with no internet at all (Quick Share / Bluetooth). */ +private fun shareCompanionApk(context: android.content.Context) { + try { + val src = java.io.File(context.applicationInfo.sourceDir) + val dir = java.io.File(context.cacheDir, "share").apply { mkdirs() } + val out = java.io.File(dir, "archipelago-companion.apk") + src.copyTo(out, overwrite = true) + val uri = androidx.core.content.FileProvider.getUriForFile( + context, "${context.packageName}.fileprovider", out, + ) + val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply { + type = "application/vnd.android.package-archive" + putExtra(android.content.Intent.EXTRA_STREAM, uri) + addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity( + android.content.Intent.createChooser(send, "Share Archipelago Companion") + .addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } catch (_: Exception) { + // No share targets / copy failed — nothing sensible to do here. + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt new file mode 100644 index 00000000..9af0ba08 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt @@ -0,0 +1,287 @@ +package com.archipelago.app.ui.screens + +import android.content.res.Configuration +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.archipelago.app.R +import com.archipelago.app.data.ServerPreferences +import com.archipelago.app.fips.FipsManager +import com.archipelago.app.network.ConnectionState +import com.archipelago.app.network.InputWebSocket +import com.archipelago.app.ui.components.NESController +import com.archipelago.app.ui.components.NESKeyboard +import com.archipelago.app.ui.components.NESMenu +import com.archipelago.app.ui.components.NESPortraitController +import com.archipelago.app.ui.components.QrScannerOverlay +import com.archipelago.app.ui.components.Trackpad +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.ControllerStyle +import com.archipelago.app.ui.theme.ErrorRed +import com.archipelago.app.ui.theme.SuccessGreen +import com.archipelago.app.ui.theme.TextMuted +import kotlinx.coroutines.launch + +@Composable +fun RemoteInputScreen( + onBack: () -> Unit, + onMeshParty: (() -> Unit)? = null, + // Land on the keyboard instead of the gamepad (hub menu's Keyboard card). + startInKeyboard: Boolean = false, +) { + val context = LocalContext.current + val prefs = remember { ServerPreferences(context) } + val scope = rememberCoroutineScope() + val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + + val savedServers by prefs.savedServers.collectAsState(initial = emptyList()) + val activeServer by prefs.activeServer.collectAsState(initial = null) + + var isGamepadMode by remember { mutableStateOf(!startInKeyboard) } + var showModal by remember { mutableStateOf(false) } + var showQrScanner by remember { mutableStateOf(false) } + var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) } + var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2 + + val ws = remember { InputWebSocket(scope) } + + // When the kiosk forwards an "open in external browser" app, launch it in + // the phone's default browser. + DisposableEffect(ws) { + ws.onExternalOpen = { url -> + try { + val intent = android.content.Intent( + android.content.Intent.ACTION_VIEW, + android.net.Uri.parse(url), + ).apply { addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) } + context.startActivity(intent) + } catch (_: Exception) {} + } + onDispose { ws.onExternalOpen = null } + } + + fun togglePlayer() { + playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 } + ws.playerId = playerId + } + fun toggleStyle() { + controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC + } + val connectionState by ws.state.collectAsState() + val lifecycleOwner = LocalLifecycleOwner.current + + BackHandler { onBack() } + + // Connect on server change + reconnect when app resumes from background + DisposableEffect(lifecycleOwner, activeServer) { + val server = activeServer + if (server != null) { + ws.connect(server.toUrl(), server.password) + } + + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME && server != null) { + val state = ws.state.value + if (state != ConnectionState.CONNECTED && state != ConnectionState.CONNECTING) { + ws.connect(server.toUrl(), server.password) + } + } + } + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + ws.disconnect() + } + } + + Box( + Modifier + .fillMaxSize() + .background(Color(0xFF0C0C0C)), + ) { + // Reddish synthwave backdrop behind the controller + Image( + painter = painterResource(id = R.drawable.bg_synthwave), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + // Light scrim — the controller body provides its own contrast, so keep + // this subtle and let the backdrop show through around it. + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.4f), + Color.Black.copy(alpha = 0.25f), + Color.Black.copy(alpha = 0.45f), + ), + ) + ), + ) + Box(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing)) { + when { + isGamepadMode && isLandscape -> NESController( + style = controllerStyle, + playerId = playerId, + onKey = { ws.sendKey(it) }, + onMenu = { showModal = true }, + onPlayerToggle = ::togglePlayer, + onToggleStyle = ::toggleStyle, + ) + isGamepadMode && !isLandscape -> NESPortraitController( + style = controllerStyle, + playerId = playerId, + onKey = { ws.sendKey(it) }, + onMouseMove = { dx, dy -> ws.sendMouseMove(dx, dy) }, + onMouseClick = { ws.sendClick(it) }, + onMouseScroll = { ws.sendScroll(it) }, + onMenu = { showModal = true }, + onPlayerToggle = ::togglePlayer, + onToggleStyle = ::toggleStyle, + ) + else -> { + // Keyboard mode: trackpad fills top, keyboard pinned bottom + Box(Modifier.fillMaxSize()) { + Column(Modifier.fillMaxSize()) { + Trackpad( + onMove = { dx, dy -> ws.sendMouseMove(dx, dy) }, + onClick = { ws.sendClick(it) }, + onScroll = { ws.sendScroll(it) }, + onThreeFingerHold = { showModal = true }, + modifier = Modifier.fillMaxWidth().weight(1f) + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + NESKeyboard( + style = controllerStyle, + onKey = { ws.sendKey(it) }, + modifier = Modifier.fillMaxWidth(), + ) + } + // Settings + style icons top-right in keyboard mode + Row( + Modifier.align(Alignment.TopEnd).padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + com.archipelago.app.ui.components.SettingsBtn( + c = com.archipelago.app.ui.components.paletteFor(controllerStyle), + onClick = { showModal = true }, + ) + com.archipelago.app.ui.components.StyleBtn( + c = com.archipelago.app.ui.components.paletteFor(controllerStyle), + onClick = ::toggleStyle, + ) + } + } + } + } + + // Connection dot + Box( + Modifier.align(Alignment.TopStart).padding(6.dp).size(8.dp) + .clip(CircleShape).background( + when (connectionState) { + ConnectionState.CONNECTED -> SuccessGreen + ConnectionState.CONNECTING -> BitcoinOrange + ConnectionState.ERROR, ConnectionState.AUTH_FAILED -> ErrorRed + ConnectionState.DISCONNECTED -> TextMuted + } + ), + ) + } + + NESMenu( + visible = showModal, + servers = savedServers, + activeServer = activeServer, + onDismiss = { showModal = false }, + onSelectServer = { server -> + scope.launch { ws.disconnect(); prefs.setActiveServer(server) }; showModal = false + }, + onAddServer = { server -> + scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) } + }, + onScanQr = { showQrScanner = true }, + onEditServer = { original, updated -> + scope.launch { + prefs.updateSavedServer(original, updated) + // If the edited server is the live one, reconnect with the new + // address/credentials so the change takes effect immediately. + if (original.serialize() == activeServer?.serialize()) { + ws.disconnect() + prefs.setActiveServer(updated) + } + } + }, + onRemoveServer = { server -> + scope.launch { + prefs.removeSavedServer(server) + // Deleting the last server leaves nothing to control — drop the + // active server and return to the Connect screen. + val remaining = savedServers.count { it.serialize() != server.serialize() } + if (remaining == 0) { + ws.disconnect() + prefs.clearActiveServer() + showModal = false + onBack() + } + } + }, + onRemote = { isGamepadMode = true; showModal = false }, + onKeyboard = { isGamepadMode = false; showModal = false }, + onBackToWebView = { showModal = false; onBack() }, + onMeshParty = onMeshParty?.let { open -> { showModal = false; open() } }, + ) + + // Pairing-QR scan launched from the menu's Add Server row. The menu stays + // open behind the scanner so the new entry appears as soon as it closes. + QrScannerOverlay( + visible = showQrScanner, + onDismiss = { showQrScanner = false }, + onServerScanned = { scan -> + showQrScanner = false + scope.launch { + val merged = prefs.upsertServer(scan.server) + FipsManager.registerNode(context, scan.fips, merged.displayName()) + if (activeServer == null) prefs.setActiveServer(merged) + } + }, + ) + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt new file mode 100644 index 00000000..166f527f --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt @@ -0,0 +1,725 @@ +package com.archipelago.app.ui.screens + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.archipelago.app.R +import com.archipelago.app.data.PairResult +import com.archipelago.app.data.ServerEntry +import com.archipelago.app.data.ServerPreferences +import com.archipelago.app.fips.FipsManager +import com.archipelago.app.ui.components.MeshLoadingScreen +import com.archipelago.app.ui.components.QrScannerOverlay +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.ErrorRed +import com.archipelago.app.ui.theme.SurfaceBlack +import com.archipelago.app.ui.theme.SurfaceCard +import com.archipelago.app.ui.theme.SuccessGreen +import com.archipelago.app.ui.theme.TextMuted +import com.archipelago.app.ui.theme.TextPrimary +import com.archipelago.app.ui.theme.TextSecondary +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection +import java.net.URL +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLContext +import javax.net.ssl.X509TrustManager + +@Composable +fun ServerConnectScreen( + onConnected: (String) -> Unit, + onRemoteInput: () -> Unit = {}, + // Prefill from a pairing deep link (archipelago://pair) that carried no + // password — opens the manual form on the password prompt for that server. + initialServer: ServerEntry? = null, +) { + val context = LocalContext.current + val prefs = remember { ServerPreferences(context) } + val scope = rememberCoroutineScope() + val keyboard = LocalSoftwareKeyboardController.current + + var name by remember { mutableStateOf("") } + var address by remember { mutableStateOf("") } + var port by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var passwordVisible by remember { mutableStateOf(false) } + var useHttps by remember { mutableStateOf(false) } + var isConnecting by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + // The saved server currently being edited, or null when adding/connecting. + var editingServer by remember { mutableStateOf(null) } + // Landing shows Scan/Manual choice; the form appears in manual mode or while editing. + var manualMode by remember { mutableStateOf(false) } + var showScanner by remember { mutableStateOf(false) } + + val savedServers by prefs.savedServers.collectAsState(initial = emptyList()) + + fun clearForm() { + name = "" + address = "" + port = "" + password = "" + useHttps = false + passwordVisible = false + errorMessage = null + } + + fun startEdit(server: ServerEntry) { + editingServer = server + name = server.name + address = server.address + port = server.port + password = server.password + useHttps = server.useHttps + passwordVisible = false + errorMessage = null + } + + fun cancelEdit() { + editingServer = null + clearForm() + } + + fun saveEdit() { + val original = editingServer ?: return + if (address.isBlank()) { + errorMessage = "Enter a server address" + return + } + val updated = ServerEntry(address, useHttps, port, password, name) + scope.launch { + prefs.updateSavedServer(original, updated) + cancelEdit() + } + } + + fun connect(server: ServerEntry) { + if (isConnecting) return + if (server.address.isBlank()) { + errorMessage = "Enter a server address" + return + } + isConnecting = true + errorMessage = null + + scope.launch { + var reachable = testConnection(server) + + // LAN address didn't answer — phone off-LAN (5G) or DHCP moved the + // node. The scanned IP was only ever a dial hint; the node's real + // identity is its npub and its ULA is reachable from anywhere over + // the mesh. Bring the tunnel up and probe the ULA before failing. + if (!reachable && server.meshIp.isNotBlank()) { + FipsManager.autoStartIfReady(context) + val meshServer = server.copy( + address = server.meshIp, + useHttps = false, + port = "", + ) + // Mesh discovery + first session can take 15s+ through the + // public tree (HANDOFF-2026-07-23 node diagnosis), and on a + // first-ever pairing the VPN consent dialog is on screen at + // the same time — so probe patiently inside a 60s budget with + // per-attempt timeouts wide enough to ride out TCP + // retransmit backoff. The VPN service pre-warms the session + // in parallel (ArchyVpnService.startSessionWarmer). + val deadline = System.currentTimeMillis() + 60_000 + while (!reachable && System.currentTimeMillis() < deadline) { + reachable = testConnection(meshServer, timeoutMs = 15_000) + if (!reachable) delay(3000) + } + } + isConnecting = false + + if (reachable) { + prefs.setActiveServer(server) + onConnected(server.toUrl()) + } else { + errorMessage = context.getString(R.string.connection_failed) + } + } + } + + fun prefill(server: ServerEntry) { + name = server.name + address = server.address + port = server.port + password = server.password + useHttps = server.useHttps + } + + // Pairing QR scanned: dedupe against saved servers, then either auto-connect + // (payload carried a credential — demo password or a real node's device + // token) or land on the password prompt with everything else filled in. + // Mesh info (when present) is registered so the FIPS tunnel comes up too. + fun onQrScanned(scan: PairResult.Success) { + showScanner = false + scope.launch { + val merged = prefs.upsertServer(scan.server) + FipsManager.registerNode(context, scan.fips, merged.displayName()) + prefill(merged) + if (merged.password.isNotBlank()) { + connect(merged) + } else { + manualMode = true + } + } + } + + LaunchedEffect(initialServer) { + if (initialServer != null) { + prefill(prefs.upsertServer(initialServer)) + manualMode = true + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(SurfaceBlack), + ) { + // Reddish synthwave backdrop + Image( + painter = painterResource(id = R.drawable.bg_synthwave), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + // Dark scrim so the form stays legible over the art + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.6f), + Color.Black.copy(alpha = 0.45f), + Color.Black.copy(alpha = 0.8f), + ), + ) + ), + ) + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing) + .verticalScroll(state = rememberScrollState()) + .drawWithContent { drawContent() } + .padding(horizontal = 24.dp) + .padding(top = 48.dp, bottom = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + // Center the content vertically — the landing (logo + two buttons) is + // short and looks stranded at the top otherwise. Taller content (the + // manual form, saved servers) still scrolls from the top as normal. + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + ) { + // Circular badge logo + Image( + painter = painterResource(id = R.drawable.ic_logo), + contentDescription = "Archipelago", + modifier = Modifier.size(96.dp), + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = if (editingServer != null) stringResource(R.string.edit_server_title) else "Connect to Server", + style = MaterialTheme.typography.headlineMedium, + color = TextPrimary, + textAlign = TextAlign.Center, + ) + + val showForm = manualMode || editingServer != null + + Text( + text = if (showForm) stringResource(R.string.server_address_hint) else stringResource(R.string.connect_landing_hint), + style = MaterialTheme.typography.bodyMedium, + color = TextMuted, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + if (!showForm) { + // Landing: scan the pairing QR, or fall back to manual entry + GlassButton( + text = stringResource(R.string.scan_node_qr), + onClick = { showScanner = true }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) + GlassButton( + text = stringResource(R.string.enter_manually), + onClick = { + errorMessage = null + manualMode = true + }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) + } + + // Glass card with form + if (showForm) Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(Color.Black.copy(alpha = 0.6f)) + .background( + Brush.verticalGradient( + colors = listOf( + Color.White.copy(alpha = 0.06f), + Color.White.copy(alpha = 0.02f), + ), + ) + ) + .border(1.dp, Color.White.copy(alpha = 0.1f), RoundedCornerShape(16.dp)) + .padding(20.dp), + ) { + Column { + OutlinedTextField( + value = name, + onValueChange = { + name = it + errorMessage = null + }, + label = { Text(stringResource(R.string.server_name_label)) }, + placeholder = { Text(stringResource(R.string.server_name_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + ), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.White.copy(alpha = 0.3f), + unfocusedBorderColor = Color.White.copy(alpha = 0.12f), + cursorColor = Color.White, + focusedLabelColor = Color.White.copy(alpha = 0.7f), + unfocusedLabelColor = TextMuted, + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary, + ), + shape = RoundedCornerShape(12.dp), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedTextField( + value = address, + onValueChange = { + address = sanitizeAddress(it) + errorMessage = null + }, + label = { Text(stringResource(R.string.server_address_label)) }, + placeholder = { Text(stringResource(R.string.server_address_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Next, + ), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.White.copy(alpha = 0.3f), + unfocusedBorderColor = Color.White.copy(alpha = 0.12f), + cursorColor = Color.White, + focusedLabelColor = Color.White.copy(alpha = 0.7f), + unfocusedLabelColor = TextMuted, + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary, + ), + shape = RoundedCornerShape(12.dp), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedTextField( + value = port, + onValueChange = { + port = it.filter { c -> c.isDigit() }.take(5) + errorMessage = null + }, + label = { Text(stringResource(R.string.port_label)) }, + placeholder = { Text("80") }, + modifier = Modifier.weight(1f), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.White.copy(alpha = 0.3f), + unfocusedBorderColor = Color.White.copy(alpha = 0.12f), + cursorColor = Color.White, + focusedLabelColor = Color.White.copy(alpha = 0.7f), + unfocusedLabelColor = TextMuted, + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary, + ), + shape = RoundedCornerShape(12.dp), + ) + + OutlinedTextField( + value = password, + onValueChange = { + password = it + errorMessage = null + }, + label = { Text("Password") }, + modifier = Modifier.weight(2f), + singleLine = true, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = if (passwordVisible) "Hide password" else "Show password", + tint = TextMuted, + modifier = Modifier.size(20.dp), + ) + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + keyboardActions = KeyboardActions( + onGo = { + keyboard?.hide() + if (editingServer != null) { + saveEdit() + } else { + connect(ServerEntry(address, useHttps, port, password, name)) + } + }, + ), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.White.copy(alpha = 0.3f), + unfocusedBorderColor = Color.White.copy(alpha = 0.12f), + cursorColor = Color.White, + focusedLabelColor = Color.White.copy(alpha = 0.7f), + unfocusedLabelColor = TextMuted, + focusedTextColor = TextPrimary, + unfocusedTextColor = TextPrimary, + ), + shape = RoundedCornerShape(12.dp), + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = if (useHttps) Icons.Default.Lock else Icons.Default.LockOpen, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = if (useHttps) SuccessGreen else TextMuted, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.use_https), + style = MaterialTheme.typography.bodyMedium, + color = TextSecondary, + ) + } + Switch( + checked = useHttps, + onCheckedChange = { useHttps = it }, + colors = SwitchDefaults.colors( + checkedThumbColor = SurfaceBlack, + checkedTrackColor = BitcoinOrange, + uncheckedThumbColor = TextMuted, + uncheckedTrackColor = SurfaceCard, + ), + ) + } + } + } + + // Error + AnimatedVisibility(visible = errorMessage != null, enter = fadeIn(), exit = fadeOut()) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(ErrorRed.copy(alpha = 0.12f)) + .border(1.dp, ErrorRed.copy(alpha = 0.25f), RoundedCornerShape(12.dp)) + .padding(12.dp), + ) { + Text(text = errorMessage ?: "", color = ErrorRed, style = MaterialTheme.typography.bodyMedium) + } + } + + if (editingServer != null) { + // Save / Cancel while editing an existing saved server + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + GlassButton( + text = stringResource(R.string.cancel), + onClick = { + keyboard?.hide() + cancelEdit() + }, + modifier = Modifier.weight(1f).height(56.dp), + ) + GlassButton( + text = stringResource(R.string.save_changes), + onClick = { + keyboard?.hide() + saveEdit() + }, + modifier = Modifier.weight(1f).height(56.dp), + ) + } + } else if (manualMode) { + // Back to the Scan/Manual landing + Connect + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + GlassButton( + text = stringResource(R.string.back), + onClick = { + keyboard?.hide() + manualMode = false + clearForm() + }, + modifier = Modifier.weight(1f).height(56.dp), + ) + GlassButton( + text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect), + onClick = { + keyboard?.hide() + connect(ServerEntry(address, useHttps, port, password, name)) + }, + modifier = Modifier.weight(2f).height(56.dp), + ) + } + } + + if (isConnecting) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Color.White.copy(alpha = 0.6f), + strokeWidth = 2.dp, + ) + } + + // Saved servers (hidden while editing one to keep focus on the form) + if (editingServer == null && savedServers.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.saved_servers), + style = MaterialTheme.typography.labelMedium, + color = TextMuted, + letterSpacing = 1.sp, + modifier = Modifier.fillMaxWidth(), + ) + + savedServers.forEach { server -> + SavedServerItem( + server = server, + onConnect = { connect(it) }, + onEdit = { startEdit(it) }, + onRemove = { scope.launch { prefs.removeSavedServer(it) } }, + ) + } + } + } + + QrScannerOverlay( + visible = showScanner, + onDismiss = { showScanner = false }, + onServerScanned = { onQrScanned(it) }, + ) + + // Full-screen branded loader while the first connect runs — most + // visibly right after a pairing-QR scan, when the mesh may still be + // establishing (LAN probe → tunnel up → ULA probe can take a while). + // The small inline spinner stays for context; this owns the screen. + if (isConnecting) { + MeshLoadingScreen() + } + } +} + +@Composable +private fun SavedServerItem( + server: ServerEntry, + onConnect: (ServerEntry) -> Unit, + onEdit: (ServerEntry) -> Unit, + onRemove: (ServerEntry) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(Color.Black.copy(alpha = 0.6f)) + .background( + Brush.verticalGradient( + colors = listOf( + Color.White.copy(alpha = 0.06f), + Color.White.copy(alpha = 0.02f), + ), + ) + ) + .border(1.dp, Color.White.copy(alpha = 0.1f), RoundedCornerShape(12.dp)) + .clickable { onConnect(server) } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { + Icon( + imageVector = if (server.useHttps) Icons.Default.Lock else Icons.Default.LockOpen, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = if (server.useHttps) SuccessGreen else BitcoinOrange, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text(text = server.displayName(), style = MaterialTheme.typography.bodyMedium, color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis) + val secondary = buildString { + if (server.name.isNotBlank()) append(server.address) + if (server.port.isNotBlank()) { + if (isNotEmpty()) append(":${server.port}") else append("Port ${server.port}") + } + } + if (secondary.isNotBlank()) { + Text(text = secondary, style = MaterialTheme.typography.labelMedium, color = TextMuted, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + } + IconButton(onClick = { onEdit(server) }) { + Icon(imageVector = Icons.Default.Edit, contentDescription = stringResource(R.string.edit_server), modifier = Modifier.size(18.dp), tint = TextMuted) + } + IconButton(onClick = { onRemove(server) }) { + Icon(imageVector = Icons.Default.Close, contentDescription = stringResource(R.string.remove_server), modifier = Modifier.size(18.dp), tint = TextMuted) + } + } +} + +/** Strip protocol prefixes and trailing slashes from address input. */ +private fun sanitizeAddress(input: String): String { + return input.trim() + .removePrefix("https://") + .removePrefix("http://") + .trimEnd('/') +} + +/** Test RPC connectivity. Accepts self-signed certs for local LAN servers. + * [timeoutMs] is per-phase (connect / read) — mesh probes need far more + * patience than LAN ones (first session through the tree can take 15s+). */ +private suspend fun testConnection(server: ServerEntry, timeoutMs: Int = 5000): Boolean { + return withContext(Dispatchers.IO) { + try { + val url = URL("${server.toUrl()}/rpc/v1") + val connection = url.openConnection() as HttpURLConnection + + // Trust self-signed certs for local HTTPS (Archipelago nodes rarely have CA certs) + if (connection is HttpsURLConnection) { + val trustAll = arrayOf(object : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + override fun getAcceptedIssuers(): Array = arrayOf() + }) + val sc = SSLContext.getInstance("TLS") + sc.init(null, trustAll, java.security.SecureRandom()) + connection.sslSocketFactory = sc.socketFactory + connection.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true } + } + + connection.requestMethod = "POST" + connection.connectTimeout = timeoutMs + connection.readTimeout = timeoutMs + connection.setRequestProperty("Content-Type", "application/json") + connection.doOutput = true + val body = """{"method":"server.echo","params":{"message":"ping"}}""" + connection.outputStream.use { it.write(body.toByteArray()) } + val code = connection.responseCode + connection.disconnect() + code in 200..499 + } catch (_: Exception) { + false + } + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt new file mode 100644 index 00000000..dbf90741 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt @@ -0,0 +1,1369 @@ +package com.archipelago.app.ui.screens + +import android.Manifest +import android.annotation.SuppressLint +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.view.ViewGroup +import android.webkit.CookieManager +import android.webkit.PermissionRequest +import android.webkit.WebChromeClient +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.CloudOff +import androidx.compose.material.icons.filled.OpenInBrowser +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import android.webkit.ValueCallback +import com.archipelago.app.R +import com.archipelago.app.data.ServerPreferences +import com.archipelago.app.fips.FipsManager +import com.archipelago.app.ui.components.GestureHintOverlay +import com.archipelago.app.ui.components.MeshLoadingScreen +import com.archipelago.app.ui.components.NESMenu +import com.archipelago.app.ui.components.QrScannerOverlay +import com.archipelago.app.ui.components.WalletQrScannerModal +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.ErrorRed +import com.archipelago.app.ui.theme.SurfaceBlack +import com.archipelago.app.ui.theme.TextMuted +import com.archipelago.app.ui.theme.TextPrimary +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject + +/** Open a URL in the phone's default browser (genuinely external links). */ +private fun openExternalUrl(context: android.content.Context, url: String) { + try { + val intent = android.content.Intent( + android.content.Intent.ACTION_VIEW, + android.net.Uri.parse(url), + ).apply { + // Required when launching from a non-Activity/binder thread + // (the JS bridge below can run off the UI thread). + addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } catch (_: Exception) {} +} + +/** True when [url] points at the same host as the connected Archipelago node + * (ignoring port). Such URLs are node apps — e.g. one that can't be iframed — + * and should stay inside the app rather than bouncing out to the browser. */ +private fun isSameHost(url: String, base: String): Boolean { + return try { + val a = android.net.Uri.parse(url).host ?: return false + val b = android.net.Uri.parse(base).host ?: return false + a.equals(b, ignoreCase = true) + } catch (_: Exception) { + false + } +} + +/** Kiosk WebView retained across navigation (remote ⇄ dashboard) so leaving + * the kiosk and coming back reattaches the LIVE page — no reload, no + * re-login, no reconnect. Dropped on retry/disconnect/server change. */ +private object KioskWebView { + var instance: WebView? = null + var url: String? = null + + // Live-composition delegates for the JS bridges (see the factory) — the + // registered interface objects call through these, so reattaching the + // retained view re-points them instead of leaving stale closures. + var onRouteOutbound: (String) -> Unit = {} + var onOpenInApp: (String) -> Unit = {} + var onQrOpen: () -> Unit = {} + var onQrStatus: (String, Boolean) -> Unit = { _, _ -> } + var onQrClose: () -> Unit = {} + + fun drop() { + instance?.let { + (it.parent as? ViewGroup)?.removeView(it) + it.destroy() + } + instance = null + url = null + } +} + +/** Inject the safe-area CSS vars from the CURRENT window insets. Android + * WebView doesn't populate env(safe-area-inset-*); worse, on a cold start + * onPageFinished can run before the view is attached — rootWindowInsets is + * null then, and injecting 0px collapsed the UI's top/bottom margins (and + * put the tab bar inside the gesture zone, killing its taps). Called from + * onPageFinished, from the window-insets listener (fires when real insets + * arrive), and on reattach. */ +private fun injectSafeAreaVars(view: WebView) { + val insets = view.rootWindowInsets ?: return // listener re-fires when real + val density = view.resources.displayMetrics.density + val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt() + val sab = (insets.getInsets(android.view.WindowInsets.Type.navigationBars()).bottom / density).toInt() + view.evaluateJavascript( + """ + (function() { + var style = document.getElementById('archipelago-android-insets'); + if (!style) { + style = document.createElement('style'); + style.id = 'archipelago-android-insets'; + document.head.appendChild(style); + } + style.textContent = ':root { --safe-area-top: ${sat}px; --safe-area-bottom: ${sab}px; }'; + // Vue components sample the var into reactive state; tell them it + // changed (an authenticated session can mount before we run). + window.dispatchEvent(new CustomEvent('archy-insets', { detail: { top: ${sat}, bottom: ${sab} } })); + })(); + """.trimIndent(), + null, + ) +} + +/** In-app browser pages (node apps + same-node links) don't consume the + * neode-ui `--safe-area-top` var, so with the WebView drawing edge-to-edge + * their content ran up under the status bar. Pad the document body down by + * the status-bar height: the padded strip shows the page's OWN background + * (padding is inside the element), so the bar keeps the page colour while + * content starts below it — the pre-edge-to-edge look, without the black bar. + * + * Body padding only moves normal-flow content. fixed/sticky elements anchored + * at the viewport top (IndeeHub's floating header) stayed glued under the + * status bar, so we also push each of those down by the inset — once, marked + * via data attribute — and keep a throttled MutationObserver running so + * headers an SPA mounts after load get the same treatment. + * Idempotent; runs on start (early) and finish (after the app rewrites head). */ +private fun injectTopInset(view: WebView) { + val insets = view.rootWindowInsets ?: return + val density = view.resources.displayMetrics.density + val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt() + if (sat <= 0) return + view.evaluateJavascript( + """ + (function() { + var SAT = $sat; + var s = document.getElementById('archy-top-inset'); + if (!s) { + s = document.createElement('style'); + s.id = 'archy-top-inset'; + (document.head || document.documentElement).appendChild(s); + } + s.textContent = + 'body{padding-top:' + SAT + 'px!important;box-sizing:border-box!important;}'; + function push(el) { + if (el.dataset.archyInset) return; + var cs = getComputedStyle(el); + if (cs.position !== 'fixed' && cs.position !== 'sticky') return; + var top = parseFloat(cs.top); // 'auto' -> NaN skips bottom bars + if (isNaN(top) || top >= SAT) return; + el.style.setProperty('top', (top + SAT) + 'px', 'important'); + el.dataset.archyInset = '1'; + } + function sweep() { + if (!document.body) return; + // Fixed/sticky bars live shallow in the tree (portals mount on + // body); depth cap keeps the computed-style pass off big lists. + var els = document.body.querySelectorAll( + 'body > *, body > * > *, body > * > * > *, body > * > * > * > *'); + for (var i = 0; i < els.length; i++) push(els[i]); + } + sweep(); + if (!window.__archyInsetObserver) { + var queued = false, last = 0; + window.__archyInsetObserver = new MutationObserver(function() { + if (queued) return; + queued = true; + var wait = Math.max(0, 250 - (Date.now() - last)); + setTimeout(function() { + queued = false; + last = Date.now(); + sweep(); + }, wait); + }); + window.__archyInsetObserver.observe(document.documentElement, + { childList: true, subtree: true }); + } + })(); + """.trimIndent(), + null, + ) +} + +/** True when a TCP listener answers at [base]'s host:port within [timeoutMs]. */ +private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try { + val u = android.net.Uri.parse(base) + val port = if (u.port != -1) u.port else if (u.scheme == "https") 443 else 80 + java.net.Socket().use { + it.connect(java.net.InetSocketAddress(u.host, port), timeoutMs) + true + } +} catch (_: Exception) { + false +} + +/** Fastest answering origin: LAN inside a short window, else the mesh ULA + * (patient — a cold session may still be establishing). If NEITHER answers, + * fall back to the mesh URL when we have one — off-LAN the LAN IP is + * unreachable, and loading it just produced a confusing "can't reach + * 192.168.x.x" error page (user-reported 2026-07-27). Targeting the mesh URL + * instead means the load retries against the path that's actually coming up, + * and any error shows the mesh address rather than a dead LAN IP. */ +private suspend fun pickStartUrl(lanUrl: String, meshUrl: String?): String = + withContext(Dispatchers.IO) { + if (tcpAnswers(lanUrl, 2500)) return@withContext lanUrl + if (meshUrl != null && tcpAnswers(meshUrl, 12_000)) return@withContext meshUrl + meshUrl ?: lanUrl + } + +/** Apply the WebView settings shared by the kiosk view and the in-app browser. + * These are tuned for SPA performance and parity with the mobile browser; + * none of them alter how a page renders visually. */ +@SuppressLint("SetJavaScriptEnabled") +private fun WebView.applyArchipelagoSettings() { + // Pre-rasterize just outside the viewport so flinging the kiosk/app doesn't + // show blank checkerboarding — the single biggest scroll-smoothness win and + // a major part of the "feels slower than the browser" gap. (API 23+) + settings.setOffscreenPreRaster(true) + + settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + databaseEnabled = true + mediaPlaybackRequiresUserGesture = false + mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + useWideViewPort = true + loadWithOverviewMode = true + setSupportZoom(false) + builtInZoomControls = false + cacheMode = WebSettings.LOAD_DEFAULT + allowContentAccess = true + allowFileAccess = false + } + + // chrome://inspect profiling on debuggable builds only — lets us measure the + // real in-page bottleneck rather than guess. No effect on release builds. + val debuggable = 0 != (context.applicationInfo.flags and + android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) + if (debuggable) WebView.setWebContentsDebuggingEnabled(true) +} + +@SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility") +@Composable +fun WebViewScreen( + serverUrl: String, + onDisconnect: () -> Unit, + onRemoteInput: () -> Unit = {}, + // Like onRemoteInput but landing on the keyboard (the hub menu's Keyboard card). + onRemoteKeyboard: () -> Unit = {}, + // Opens the phone-to-phone Mesh Party screen; null hides its hub card. + onMeshParty: (() -> Unit)? = null, + // Stored password for this server (from QR pairing or manual entry). When + // non-blank, the login page is auto-filled and submitted — the one-step + // demo flow from docs/companion-pairing-qr.md. + serverPassword: String = "", + // Node's FIPS mesh URL (http://[fd…]). When the primary address fails on + // a main-frame load — typically the phone left the LAN — retry there + // before surfacing the error page: the mesh tunnel works from anywhere. + meshFallbackUrl: String? = null, +) { + var isLoading by remember { mutableStateOf(true) } + // First kiosk load (often over the FIPS mesh) gets the full branded + // loader; later navigations keep just the slim top progress bar. + var firstLoadDone by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + snapshotFlow { isLoading }.first { !it } + firstLoadDone = true + } + var loadProgress by remember { mutableIntStateOf(0) } + var triedMeshFallback by remember { mutableStateOf(false) } + var hasError by remember { mutableStateOf(false) } + var webView by remember { mutableStateOf(null) } + + // Race LAN vs mesh BEFORE the WebView exists: Chromium pointed at an + // unreachable LAN IP burns a minute+ in connect retries before + // onReceivedError fires the mesh fallback — the "stuck connecting" + // stall. A raw TCP probe answers in milliseconds at home and fails in + // ~2.5s off-LAN, so startup lands on the right origin in seconds. + var startUrl by remember(serverUrl) { mutableStateOf(null) } + var raceNonce by remember { mutableIntStateOf(0) } + LaunchedEffect(serverUrl, meshFallbackUrl, raceNonce) { + // A retained live session exists — reattach instantly: no race, no + // reload, no re-login (remote ⇄ dashboard round trip). + if (KioskWebView.instance != null && KioskWebView.url == serverUrl) { + isLoading = false + startUrl = serverUrl + return@LaunchedEffect + } + val picked = pickStartUrl(serverUrl, meshFallbackUrl) + // Starting on the mesh: don't bounce back to it on error (it IS it). + if (picked != serverUrl) triedMeshFallback = true + startUrl = picked + } + + // Web-page camera access (wallet QR scanner). The WebView's default + // WebChromeClient silently denies getUserMedia, so grant video capture — + // asking for the app-level CAMERA permission first when needed. + val webViewContext = LocalContext.current + var pendingWebPermission by remember { mutableStateOf(null) } + val webCameraPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + pendingWebPermission?.let { req -> + if (granted) req.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) else req.deny() + } + pendingWebPermission = null + } + + // A node app that refused iframing, opened in a local WebView overlay. + // null = no overlay. The kiosk WebView underneath stays alive (and warm) + // while this is shown, so closing it returns instantly with no reload. + var inAppUrl by remember { mutableStateOf(null) } + + // Same node = EITHER of its addresses. Over the mesh the kiosk's host is + // the ULA while app links may carry the LAN IP (and vice versa) — + // comparing against one host bounced same-node apps (Pine, Home + // Assistant, BTCPay) out to the phone's external browser. + fun isSameNode(url: String): Boolean = + isSameHost(url, serverUrl) || + (meshFallbackUrl != null && isSameHost(url, meshFallbackUrl)) + + // Native wallet QR scanner, opened by the web UI via the ArchipelagoQr + // bridge; status lines stream back from the page while it's up. + var walletScannerVisible by remember { mutableStateOf(false) } + var walletScannerStatus by remember { mutableStateOf?>(null) } + + // One-time three-finger-hold teaching overlay (initial=true: never flash + // it while DataStore is still loading). + val prefs = remember { ServerPreferences(webViewContext) } + + // Hub menu overlay state — the three-finger hold opens the menu right here + // over the dashboard (it used to jump to the remote screen). + val savedServers by prefs.savedServers.collectAsState(initial = emptyList()) + val activeServer by prefs.activeServer.collectAsState(initial = null) + var showHubMenu by remember { mutableStateOf(false) } + var showPairScanner by remember { mutableStateOf(false) } + + val gestureHintSeen by prefs.gestureHintSeen.collectAsState(initial = true) + var gestureHintDismissed by remember { mutableStateOf(false) } + // Don't teach the gesture on top of the login/splash — arm the overlay + // ~2 minutes after the kiosk first finishes loading, once the user has + // settled in. + var gestureHintReady by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + snapshotFlow { isLoading }.first { !it } + delay(120_000) + gestureHintReady = true + } + val scope = rememberCoroutineScope() + + // support — without a chooser implementation the + // WebView silently ignores file inputs (broke the wallet's upload path). + var pendingFileChooser by remember { mutableStateOf>?>(null) } + val fileChooserLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + pendingFileChooser?.onReceiveValue( + WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data), + ) + pendingFileChooser = null + } + + BackHandler(enabled = inAppUrl == null && webView?.canGoBack() == true) { + webView?.goBack() + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(SurfaceBlack), + ) { + if (hasError) { + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Default.CloudOff, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = TextMuted, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.server_unreachable), + style = MaterialTheme.typography.headlineMedium, + color = TextPrimary, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = stringResource(R.string.connection_failed), + style = MaterialTheme.typography.bodyMedium, + color = TextMuted, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(32.dp)) + + GlassButton( + text = stringResource(R.string.retry), + onClick = { + // Re-race LAN vs mesh — the network we're on may have + // changed since the last pick. Drop the retained view: + // an errored session must genuinely reload. + KioskWebView.drop() + webView = null + hasError = false + isLoading = true + triedMeshFallback = false + startUrl = null + raceNonce++ + }, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + GlassButton( + text = stringResource(R.string.disconnect), + onClick = { + KioskWebView.drop() + onDisconnect() + }, + modifier = Modifier.fillMaxWidth().height(48.dp), + ) + } + } else if (startUrl == null) { + // Racing LAN vs mesh (≤2.5s at home, a few seconds off-LAN) — + // far cheaper than letting Chromium retry a dead LAN IP. + MeshLoadingScreen() + } else { + // Edge-to-edge WebView — background bleeds behind status bar. + // Safe area values injected as CSS env() polyfill on each page load. + val initialUrl = startUrl ?: serverUrl + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + // Reattach the retained kiosk WebView (remote ⇄ dashboard + // must not reload the node UI). Everything configured + // below is idempotent, and re-running it rebinds clients, + // bridges and listeners to THIS composition's state — + // stale closures from the previous visit are replaced. + if (KioskWebView.url != serverUrl) KioskWebView.drop() + val reused = KioskWebView.instance + (reused ?: WebView(context)).apply { + (parent as? ViewGroup)?.removeView(this) + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + + isVerticalScrollBarEnabled = false + isHorizontalScrollBarEnabled = false + + val cookieManager = CookieManager.getInstance() + cookieManager.setAcceptCookie(true) + cookieManager.setAcceptThirdPartyCookies(this, true) + + applyArchipelagoSettings() + settings.apply { + setSupportMultipleWindows(true) // enables onCreateWindow for window.open + // Let JS open windows without a synchronous user-gesture + // chain; without this, window.open() from a Vue click + // handler silently no-ops and "Open in new tab" dies. + javaScriptCanOpenWindowsAutomatically = true + } + + val webViewRef = this + + // Re-inject the safe-area vars whenever REAL insets + // arrive — on cold start onPageFinished often beats + // window attachment and would otherwise bake in 0px. + setOnApplyWindowInsetsListener { v, insets -> + (v as? WebView)?.let { injectSafeAreaVars(it) } + v.onApplyWindowInsets(insets) + } + + // Decide where an outbound URL goes: + // - same host as the node → in-app WebView overlay + // (this is the "open in browser" target for apps the + // kiosk couldn't iframe — keep the user inside the app) + // - different host → the phone's real browser + fun routeOutbound(url: String) { + if (isSameNode(url)) { + inAppUrl = url + } else { + openExternalUrl(context, url) + } + } + + // Bridge callbacks are DELEGATED through the holder: + // the interface objects registered on a retained + // WebView survive recomposition, and re-registering + // them doesn't reliably swap the JS-visible object + // without a reload — with direct closures, a + // remote ⇄ dashboard round trip left the bridges + // writing to a dead composition's state ("apps don't + // launch"). These assignments re-point the live + // interface objects at THIS composition every attach. + KioskWebView.onRouteOutbound = { url -> routeOutbound(url) } + KioskWebView.onOpenInApp = { url -> inAppUrl = url } + KioskWebView.onQrOpen = { + walletScannerStatus = null + walletScannerVisible = true + } + KioskWebView.onQrStatus = { msg, err -> walletScannerStatus = msg to err } + KioskWebView.onQrClose = { walletScannerVisible = false } + + // JS bridge. The web UI calls: + // window.ArchipelagoNative.openExternal(url) — host-routed + // window.ArchipelagoNative.openInApp(url) — force in-app + // Falls back to window.open in a plain mobile browser. + if (reused == null) addJavascriptInterface( + object { + @android.webkit.JavascriptInterface + fun openExternal(url: String) { + webViewRef.post { KioskWebView.onRouteOutbound(url) } + } + + @android.webkit.JavascriptInterface + fun openInApp(url: String) { + webViewRef.post { KioskWebView.onOpenInApp(url) } + } + }, + "ArchipelagoNative", + ) + + // Wallet QR bridge. The web scan modal calls: + // window.ArchipelagoQr.open() — show the native scanner + // window.ArchipelagoQr.setStatus(msg, e) — mirror status/progress lines + // window.ArchipelagoQr.close() — code accepted, tear down + // Decodes flow back through window.__archyQrResult(text); + // a user cancel calls window.__archyQrCancelled(). + if (reused == null) addJavascriptInterface( + object { + @android.webkit.JavascriptInterface + fun open() { + webViewRef.post { KioskWebView.onQrOpen() } + } + + @android.webkit.JavascriptInterface + fun setStatus(message: String, isError: Boolean) { + webViewRef.post { KioskWebView.onQrStatus(message, isError) } + } + + @android.webkit.JavascriptInterface + fun close() { + webViewRef.post { KioskWebView.onQrClose() } + } + }, + "ArchipelagoQr", + ) + + webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + isLoading = true + hasError = false + } + + override fun onPageFinished(view: WebView?, url: String?) { + isLoading = false + if (view == null) return + + injectSafeAreaVars(view) + + // Auto-login with the stored password (QR pairing / + // saved server) — only on our own server's pages + // (LAN origin or the mesh address). + val onOwnServer = url != null && ( + url.startsWith(serverUrl) || + (meshFallbackUrl != null && url.startsWith(meshFallbackUrl)) + ) + if (serverPassword.isNotBlank() && onOwnServer) { + view.evaluateJavascript(buildAutoLoginScript(serverPassword), null) + } + } + + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError?, + ) { + if (request?.isForMainFrame == true) { + val mesh = meshFallbackUrl + if (mesh != null && !triedMeshFallback && + request.url?.toString()?.startsWith(mesh) != true + ) { + triedMeshFallback = true + isLoading = true + view?.loadUrl(mesh) + return + } + hasError = true + isLoading = false + } + } + + // Node apps (e.g. NetBird) terminate TLS with a + // self-signed cert — the dashboard needs a secure + // context for OIDC/window.crypto.subtle (#15). The + // WebView default is to CANCEL untrusted certs, so + // those apps render blank. The user explicitly trusts + // their own node, so proceed for same-host certs only; + // reject anything else (don't blanket-trust the web). + override fun onReceivedSslError( + view: WebView?, + handler: android.webkit.SslErrorHandler?, + error: android.net.http.SslError?, + ) { + val u = error?.url + if (u != null && isSameNode(u)) { + handler?.proceed() + } else { + handler?.cancel() + } + } + + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest?, + ): Boolean { + val url = request?.url?.toString() ?: return false + // Keep kiosk navigation (same origin incl. port) in place + if (url.startsWith(serverUrl)) return false + // Mesh address is the same server too + if (meshFallbackUrl != null && url.startsWith(meshFallbackUrl)) return false + // Same node (other port) → in-app; external → browser + routeOutbound(url) + return true + } + } + + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + loadProgress = newProgress + } + + override fun onShowFileChooser( + view: WebView?, + filePathCallback: ValueCallback>?, + fileChooserParams: FileChooserParams?, + ): Boolean { + pendingFileChooser?.onReceiveValue(null) + pendingFileChooser = filePathCallback + val intent = fileChooserParams?.createIntent() + if (intent == null) { + pendingFileChooser = null + return false + } + return try { + fileChooserLauncher.launch(intent) + true + } catch (_: Exception) { + pendingFileChooser = null + false + } + } + + // Wallet QR scanner: grant the page camera access. + // Only video capture is granted — anything else the + // page asks for is denied as before. + override fun onPermissionRequest(request: PermissionRequest) { + if (PermissionRequest.RESOURCE_VIDEO_CAPTURE !in request.resources) { + request.deny() + return + } + val hasCamera = ContextCompat.checkSelfPermission( + webViewContext, Manifest.permission.CAMERA, + ) == PackageManager.PERMISSION_GRANTED + if (hasCamera) { + request.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) + } else { + pendingWebPermission = request + webCameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + + // window.open() — e.g. the kiosk's "Open in new tab" + // for an app that can't be iframed. Capture the target + // URL via a throwaway WebView and route it ourselves. + override fun onCreateWindow( + view: WebView?, + isDialog: Boolean, + isUserGesture: Boolean, + resultMsg: android.os.Message?, + ): Boolean { + val transport = resultMsg?.obj as? WebView.WebViewTransport + ?: return false + + val popup = WebView(context).apply { + settings.javaScriptEnabled = true + webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest?, + ): Boolean { + val url = request?.url?.toString() ?: return true + routeOutbound(url) + return true + } + + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + if (url != null) routeOutbound(url) + view?.stopLoading() + } + } + } + transport.webView = popup + resultMsg.sendToTarget() + return true + } + } + + // Three-finger hold (500ms) → open the hub menu overlay + // in place (Remote/Keyboard cards do the navigating). + // Three fingers, not two: two-finger scroll/pinch on the + // page collided with the old two-finger hold. + var threeFingerStart = 0L + var threeFingerFired = false + setOnTouchListener { _, event -> + val pointerCount = event.pointerCount + when (event.actionMasked) { + android.view.MotionEvent.ACTION_POINTER_DOWN -> { + if (pointerCount >= 3) { + threeFingerStart = System.currentTimeMillis() + threeFingerFired = false + } + } + android.view.MotionEvent.ACTION_MOVE -> { + if (pointerCount >= 3 && !threeFingerFired && threeFingerStart > 0) { + if (System.currentTimeMillis() - threeFingerStart > 500) { + threeFingerFired = true + showHubMenu = true + } + } + } + android.view.MotionEvent.ACTION_UP, + android.view.MotionEvent.ACTION_POINTER_UP, + android.view.MotionEvent.ACTION_CANCEL -> { + if (event.pointerCount <= 3) { + threeFingerStart = 0L + } + } + } + false // don't consume — let WebView handle normally + } + + webView = this + if (reused == null) { + KioskWebView.instance = this + KioskWebView.url = serverUrl + loadUrl(initialUrl) + } else { + // Reattached views keep stale measurements until an + // input event — that was the top/bottom UI being + // wrong until a tap. Force a fresh pass, and re-sync + // the page's safe-area vars while we're at it. + post { + requestLayout() + invalidate() + injectSafeAreaVars(this) + } + } + } + }, + ) + + // Loading bar at top edge + AnimatedVisibility( + visible = isLoading, + enter = fadeIn(), + exit = fadeOut(), + ) { + LinearProgressIndicator( + progress = { loadProgress / 100f }, + modifier = Modifier.fillMaxWidth(), + color = BitcoinOrange, + trackColor = SurfaceBlack, + ) + } + + // Branded first-load screen while the mesh session comes up. + AnimatedVisibility( + visible = isLoading && !firstLoadDone, + enter = fadeIn(), + exit = fadeOut(), + ) { + Column( + Modifier.fillMaxSize().background(SurfaceBlack), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + buildAnnotatedString { + withStyle(SpanStyle(color = ErrorRed)) { append("F*CK") } + withStyle(SpanStyle(color = TextPrimary)) { append(" IPS") } + }, + fontSize = 40.sp, + fontWeight = FontWeight.Black, + letterSpacing = 4.sp, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(10.dp)) + Text( + "connecting to your archipelago", + color = TextMuted, + fontSize = 13.sp, + letterSpacing = 1.sp, + ) + Spacer(Modifier.height(28.dp)) + CircularProgressIndicator(color = BitcoinOrange) + } + } + + // In-app browser overlay for non-iframeable node apps. Rendered last + // so it sits above the kiosk WebView, which stays alive underneath. + inAppUrl?.let { target -> + InAppBrowser( + url = target, + serverUrl = serverUrl, + meshUrl = meshFallbackUrl, + onClose = { inAppUrl = null }, + ) + } + + // Native wallet QR scanner, opened by the page via ArchipelagoQr. + WalletQrScannerModal( + visible = walletScannerVisible, + status = walletScannerStatus, + onDecoded = { text -> + webView?.evaluateJavascript( + "window.__archyQrResult && window.__archyQrResult(${JSONObject.quote(text)})", + null, + ) + }, + onDismiss = { + walletScannerVisible = false + webView?.evaluateJavascript( + "window.__archyQrCancelled && window.__archyQrCancelled()", + null, + ) + }, + ) + + // First-launch teaching overlay for the three-finger hold — armed + // ~2 minutes after login so it never fights the splash/first look. + if (gestureHintReady && !gestureHintSeen && !gestureHintDismissed && + !isLoading && inAppUrl == null + ) { + GestureHintOverlay( + onDismiss = { + gestureHintDismissed = true + scope.launch { prefs.markGestureHintSeen() } + }, + ) + } + } + + // Hub menu overlay — opened by the three-finger hold, drawn above + // everything (also reachable from the error screen, where switching + // servers is exactly what's needed). + NESMenu( + visible = showHubMenu, + servers = savedServers, + activeServer = activeServer, + onDismiss = { showHubMenu = false }, + onSelectServer = { server -> + showHubMenu = false + scope.launch { prefs.setActiveServer(server) } + }, + onAddServer = { server -> + scope.launch { + prefs.addSavedServer(server) + if (activeServer == null) prefs.setActiveServer(server) + } + }, + onScanQr = { showPairScanner = true }, + onEditServer = { original, updated -> + scope.launch { + prefs.updateSavedServer(original, updated) + // Editing the live server reloads the kiosk with the new + // address/credentials via the activeServer recomposition. + if (original.serialize() == activeServer?.serialize()) { + prefs.setActiveServer(updated) + } + } + }, + onRemoveServer = { server -> + scope.launch { + prefs.removeSavedServer(server) + // Nothing left to show — back to the Connect screen. + val remaining = savedServers.count { it.serialize() != server.serialize() } + if (remaining == 0) { + prefs.clearActiveServer() + showHubMenu = false + onDisconnect() + } + } + }, + onRemote = { showHubMenu = false; onRemoteInput() }, + onKeyboard = { showHubMenu = false; onRemoteKeyboard() }, + onBackToWebView = { showHubMenu = false }, + onMeshParty = onMeshParty?.let { open -> { showHubMenu = false; open() } }, + ) + + // Pairing-QR scan launched from the menu's Nodes page; the menu stays + // open behind it so the new entry appears as soon as it closes. + QrScannerOverlay( + visible = showPairScanner, + onDismiss = { showPairScanner = false }, + onServerScanned = { scan -> + showPairScanner = false + scope.launch { + val merged = prefs.upsertServer(scan.server) + FipsManager.registerNode(webViewContext, scan.fips, merged.displayName()) + if (activeServer == null) prefs.setActiveServer(merged) + } + }, + ) + } +} + +/** Best-effort fetch of the origin's /favicon.ico, so the launched app's icon + * can be shown on the loading screen before the WebView reports onReceivedIcon + * (which only fires once the page's has parsed). Blocking — call on IO. */ +private fun fetchFavicon(pageUrl: String): Bitmap? { + return try { + val u = android.net.Uri.parse(pageUrl) + val scheme = u.scheme ?: return null + val host = u.host ?: return null + val portPart = if (u.port > 0) ":${u.port}" else "" + val conn = (java.net.URL("$scheme://$host$portPart/favicon.ico").openConnection() + as java.net.HttpURLConnection).apply { + connectTimeout = 4000 + readTimeout = 4000 + instanceFollowRedirects = true + } + conn.inputStream.use { BitmapFactory.decodeStream(it) } + } catch (_: Exception) { + null + } +} + +/** + * Lightweight in-app browser used when the kiosk hands off an app that can't be + * shown in an iframe. Loads the app in a local WebView with a centered loading + * screen (app favicon + progress bar) and a BOTTOM control bar mirroring the + * web mobile-iframe footer (back / forward / reload / open-in-browser / close). + * Same-host navigation stays here; any genuinely external link escapes to the + * phone's browser. + */ +@SuppressLint("SetJavaScriptEnabled") +@Composable +private fun InAppBrowser( + url: String, + serverUrl: String, + meshUrl: String? = null, + onClose: () -> Unit, +) { + val context = LocalContext.current + // Same-node check across BOTH node addresses (LAN + mesh ULA) — see the + // kiosk's isSameNode; a mismatch here bounced app links to the browser. + fun isSameNode(u: String): Boolean = + isSameHost(u, serverUrl) || (meshUrl != null && isSameHost(u, meshUrl)) + var browser by remember { mutableStateOf(null) } + // Loader title: never show a raw IP host — a mesh ULA like + // [fd79:1aa:…] is technically the host but reads as garbage on the + // loading screen. Show a neutral name until the page reports its + // real (onReceivedTitle upgrades it). + var title by remember { + mutableStateOf( + android.net.Uri.parse(url).host + ?.takeUnless { it.contains(':') || it.matches(Regex("^\\d+(\\.\\d+){3}$")) } + ?: "Archipelago", + ) + } + var favicon by remember { mutableStateOf<Bitmap?>(null) } + var progress by remember { mutableIntStateOf(0) } + var loading by remember { mutableStateOf(true) } + var canGoBack by remember { mutableStateOf(false) } + var canGoForward by remember { mutableStateOf(false) } + + // Same camera bridge as the main WebView — node apps opened in the overlay + // (e.g. anything with a QR scanner) get getUserMedia too. + var pendingWebPermission by remember { mutableStateOf<PermissionRequest?>(null) } + val webCameraPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + pendingWebPermission?.let { req -> + if (granted) req.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) else req.deny() + } + pendingWebPermission = null + } + + // Seed the loading-screen icon immediately from a best-effort favicon + // pre-fetch (main's app-icon work), then onReceivedIcon upgrades it — so the + // loader shows an icon right away instead of staying blank until the page + // parses its <head> (which is what made the loader look stuck). + LaunchedEffect(url) { + val fetched = withContext(Dispatchers.IO) { fetchFavicon(url) } + if (fetched != null && favicon == null) favicon = fetched + } + + // Back: walk the in-app history first, then close the overlay. + BackHandler { + val b = browser + if (b != null && b.canGoBack()) b.goBack() else onClose() + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(SurfaceBlack) + // Whole-overlay touch shield: every touch not handled by a child + // (control-bar gaps, inset strips) dies here instead of falling + // through to the kiosk's tab bar behind (a near-miss on Close + // was opening the AIUI tab underneath). + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, + ) + // Bottom inset handled by the touch-shield strip below the bar. + // No TOP inset padding: the WebView draws edge-to-edge behind the + // status bar so the app's own background fills it — the padded + // version painted an opaque black bar there (user-rejected look). + .windowInsetsPadding( + WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal) + ), + ) { + // WebView + loading overlay fill the area above the bottom control bar. + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + WebView(ctx).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + isVerticalScrollBarEnabled = false + isHorizontalScrollBarEnabled = false + + CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + applyArchipelagoSettings() + + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + progress = newProgress + } + + override fun onReceivedTitle(view: WebView?, t: String?) { + if (!t.isNullOrBlank()) title = t + } + + override fun onReceivedIcon(view: WebView?, icon: Bitmap?) { + if (icon != null) favicon = icon + } + + override fun onPermissionRequest(request: PermissionRequest) { + if (PermissionRequest.RESOURCE_VIDEO_CAPTURE !in request.resources) { + request.deny() + return + } + val hasCamera = ContextCompat.checkSelfPermission( + context, Manifest.permission.CAMERA, + ) == PackageManager.PERMISSION_GRANTED + if (hasCamera) { + request.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) + } else { + pendingWebPermission = request + webCameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + } + } + + webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) { + loading = true + view?.let { injectTopInset(it) } + } + + override fun onPageFinished(view: WebView?, u: String?) { + loading = false + canGoBack = view?.canGoBack() == true + canGoForward = view?.canGoForward() == true + view?.let { injectTopInset(it) } + } + + override fun doUpdateVisitedHistory(view: WebView?, u: String?, isReload: Boolean) { + canGoBack = view?.canGoBack() == true + canGoForward = view?.canGoForward() == true + } + + // Self-signed TLS on the node's apps (e.g. NetBird on + // :8087) would otherwise be cancelled by the WebView + // and render blank. Proceed for the user's own node + // (same host); reject any other untrusted cert. + override fun onReceivedSslError( + view: WebView?, + handler: android.webkit.SslErrorHandler?, + error: android.net.http.SslError?, + ) { + val u = error?.url + if (u != null && isSameNode(u)) { + handler?.proceed() + } else { + handler?.cancel() + } + } + + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest?, + ): Boolean { + val u = request?.url?.toString() ?: return false + // Stay in the overlay for same-node navigation; + // hand genuinely external links to the real browser. + if (isSameNode(u)) return false + openExternalUrl(ctx, u) + return true + } + } + + browser = this + loadUrl(url) + } + }, + ) + + // Centered loading screen — app favicon (or spinner) + title + bar. + if (loading) { + Column( + modifier = Modifier + .fillMaxSize() + .background(SurfaceBlack), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Box( + modifier = Modifier.size(84.dp).clip(RoundedCornerShape(20.dp)), + contentAlignment = Alignment.Center, + ) { + val fav = favicon + if (fav != null) { + Image( + bitmap = fav.asImageBitmap(), + contentDescription = title, + modifier = Modifier.fillMaxSize(), + ) + } else { + CircularProgressIndicator(color = BitcoinOrange) + } + } + Spacer(modifier = Modifier.height(18.dp)) + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(16.dp)) + LinearProgressIndicator( + progress = { progress / 100f }, + modifier = Modifier.width(220.dp), + color = BitcoinOrange, + trackColor = TextMuted.copy(alpha = 0.2f), + ) + } + } + } + + // Bottom control bar — mirrors the web mobile-iframe footer. + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .background(SurfaceBlack) + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = { browser?.goBack() }, enabled = canGoBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = if (canGoBack) TextPrimary else TextMuted.copy(alpha = 0.4f), + ) + } + IconButton(onClick = { browser?.goForward() }, enabled = canGoForward) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = "Forward", + tint = if (canGoForward) TextPrimary else TextMuted.copy(alpha = 0.4f), + ) + } + IconButton(onClick = { browser?.reload() }) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Reload", + tint = TextPrimary, + ) + } + IconButton(onClick = { openExternalUrl(context, browser?.url ?: url) }) { + Icon( + imageVector = Icons.Default.OpenInBrowser, + contentDescription = stringResource(R.string.open_in_browser), + tint = TextPrimary, + ) + } + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.close), + tint = TextPrimary, + ) + } + } + + // Touch-shield over the gesture-nav strip: solid black AND consumes + // taps — stray touches below the control bar landed on the kiosk's + // tab bar behind this overlay (opening the AIUI chat by accident). + Box( + Modifier + .fillMaxWidth() + .windowInsetsBottomHeight(WindowInsets.navigationBars) + .background(Color.Black) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, + ), + ) + } +} + +/** + * JS that fills the web UI login form (Login.vue's #login-password) with the + * stored password and submits it once the form is interactive — the one-step + * pairing flow. No-ops when the login step never appears (already + * authenticated, first-boot setup, TOTP). At most two attempts per page load, + * then it stops for good so a wrong stored password can't spam the node. + */ +private fun buildAutoLoginScript(password: String): String { + val quoted = org.json.JSONObject.quote(password) + return """ + (function () { + if (window.__archyAutoLogin) return; + window.__archyAutoLogin = true; + var pw = $quoted; + var attempts = 0; + var started = Date.now(); + var timer = setInterval(function () { + if (Date.now() - started > 45000) { clearInterval(timer); return; } + var el = document.getElementById('login-password'); + if (!el) { + // Field gone after we submitted = success or step change - stop. + if (attempts > 0) clearInterval(timer); + return; + } + if (el.disabled) return; // form waits on serverReady + if (attempts >= 2) { clearInterval(timer); return; } + attempts++; + var setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + setter.call(el, pw); + el.dispatchEvent(new Event('input', { bubbles: true })); + // Let Vue re-render before submitting: a synchronous Enter arrives + // while the login button is still disabled, and the web UI's + // controller-nav "Enter in input clicks the next enabled button" + // pattern then hits Replay Intro instead — restarting the intro + // cinematic on every connect (two frames = value flush + render). + requestAnimationFrame(function () { + requestAnimationFrame(function () { + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + }); + }); + }, 1500); + })(); + """.trimIndent() +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/theme/Color.kt b/Android/app/src/main/java/com/archipelago/app/ui/theme/Color.kt new file mode 100644 index 00000000..6e2ffef2 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/theme/Color.kt @@ -0,0 +1,24 @@ +package com.archipelago.app.ui.theme + +import androidx.compose.ui.graphics.Color + +// Archipelago brand palette — Bitcoin orange on dark +val BitcoinOrange = Color(0xFFF7931A) +val BitcoinOrangeLight = Color(0xFFFFB74D) +val BitcoinOrangeDark = Color(0xFFE07C00) + +val SurfaceBlack = Color(0xFF000000) +val SurfaceDark = Color(0xFF0A0A0A) +val SurfaceCard = Color(0xFF1A1A1A) +val SurfaceCardHover = Color(0xFF222222) +val SurfaceElevated = Color(0xFF2A2A2A) + +val TextPrimary = Color(0xFFF5F5F5) +val TextSecondary = Color(0xFFB0B0B0) +val TextMuted = Color(0xFF666666) + +val BorderSubtle = Color(0xFF2A2A2A) +val BorderDefault = Color(0xFF3A3A3A) + +val ErrorRed = Color(0xFFEF4444) +val SuccessGreen = Color(0xFF22C55E) diff --git a/Android/app/src/main/java/com/archipelago/app/ui/theme/NES.kt b/Android/app/src/main/java/com/archipelago/app/ui/theme/NES.kt new file mode 100644 index 00000000..9ff432fb --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/theme/NES.kt @@ -0,0 +1,44 @@ +package com.archipelago.app.ui.theme + +import androidx.compose.ui.graphics.Color + +/** NES/8BitDo controller palettes */ +object NES { + // ── Classic (light body, red buttons) ────────────── + val ClassicBody = Color(0xFFD4D0C8) // warm light gray plastic + val ClassicFace = Color(0xFF1C1C1C) // dark face plate + val ClassicAccent = Color(0xFF8A8A8A) // mid gray trim + val ClassicRidge = Color(0xFFBBB8B0) // grip lines + val ClassicButtonRed = Color(0xFFC1121C) // A/B red + val ClassicButtonRedPress = Color(0xFF8A0D14) + val ClassicButtonGray = Color(0xFF5A5A5A) // turbo buttons + val ClassicButtonGrayPress = Color(0xFF3A3A3A) + val ClassicDPad = Color(0xFF1A1A1A) + val ClassicDPadPress = Color(0xFF2A2A2A) + val ClassicLabel = Color(0xFFC1121C) // red text labels + val ClassicLabelMuted = Color(0xFF6A6A6A) + val ClassicSelect = Color(0xFF2A2A2A) // START/SELECT + + // ── Transparent Dark ─────────────────────────────── + val DarkBody = Color(0xFF2A2A2E) // smoky translucent dark + val DarkFace = Color(0xFF151518) // darker face + val DarkAccent = Color(0xFF3A3A3E) // trim + val DarkRidge = Color(0xFF222226) // grip lines + val DarkButtonMain = Color(0xFF3A3A3E) // all buttons dark + val DarkButtonMainPress = Color(0xFF222226) + val DarkDPad = Color(0xFF0E0E10) + val DarkDPadPress = Color(0xFF1A1A1E) + val DarkLabel = Color(0xFF5A5A60) // muted labels + val DarkLabelMuted = Color(0xFF3A3A3E) + val DarkSelect = Color(0xFF1A1A1E) + + // ── Menu UI (NES-style) ──────────────────────────── + val MenuBg = Color(0xFF000000) + val MenuPanel = Color(0xFF0B1B4A) // dark navy + val MenuBorder = Color(0xFFFFFFFF) + val MenuText = Color(0xFFFFFFFF) + val MenuSelected = Color(0xFFC1121C) + val MenuMuted = Color(0xFF7A7A7A) +} + +enum class ControllerStyle { CLASSIC, DARK } diff --git a/Android/app/src/main/java/com/archipelago/app/ui/theme/Neo.kt b/Android/app/src/main/java/com/archipelago/app/ui/theme/Neo.kt new file mode 100644 index 00000000..57431684 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/theme/Neo.kt @@ -0,0 +1,106 @@ +package com.archipelago.app.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +object Neo { + // ── Dark ─────────────────────────────────────────── + val DarkSurface = Color(0xFF0A0A0A) + val DarkSurfaceRaised = Color(0xFF0F0F11) + val DarkShadowLight = Color(0xFF151517) + val DarkShadowDark = Color(0xFF000000) + val DarkBorder = Color(0x0AFFFFFF) + + // ── Light ────────────────────────────────────────── + val LightSurface = Color(0xFFE0E0E4) + val LightSurfaceRaised = Color(0xFFE6E6EA) + val LightShadowLight = Color(0xFFF2F2F6) + val LightShadowDark = Color(0xFFB4B4BA) + val LightBorder = Color(0x0A000000) + + val LightTextPrimary = Color(0xFF141414) + val LightTextSecondary = Color(0xFF5A5A5A) + val LightTextMuted = Color(0xFF9A9A9A) + + // ── Accessors ────────────────────────────────────── + + @Composable @ReadOnlyComposable + fun surface() = if (isSystemInDarkTheme()) DarkSurface else LightSurface + + @Composable @ReadOnlyComposable + fun surfaceRaised() = if (isSystemInDarkTheme()) DarkSurfaceRaised else LightSurfaceRaised + + @Composable @ReadOnlyComposable + fun shadowLight() = if (isSystemInDarkTheme()) DarkShadowLight else LightShadowLight + + @Composable @ReadOnlyComposable + fun shadowDark() = if (isSystemInDarkTheme()) DarkShadowDark else LightShadowDark + + @Composable @ReadOnlyComposable + fun border() = if (isSystemInDarkTheme()) DarkBorder else LightBorder + + @Composable @ReadOnlyComposable + fun textPrimary() = if (isSystemInDarkTheme()) Color(0xFFD0D0D0) else LightTextPrimary + + @Composable @ReadOnlyComposable + fun textSecondary() = if (isSystemInDarkTheme()) Color(0xFF666666) else LightTextSecondary + + @Composable @ReadOnlyComposable + fun textMuted() = if (isSystemInDarkTheme()) Color(0xFF333333) else LightTextMuted +} + +/** Subtle neomorphic raised shadow */ +fun Modifier.neoRaised( + lightShadow: Color, + darkShadow: Color, + radius: Dp = 14.dp, + shadowOffset: Dp = 2.dp, + shadowBlur: Dp = 4.dp, +) = this.drawBehind { + val r = radius.toPx() + val off = shadowOffset.toPx() + val blur = shadowBlur.toPx() + drawIntoCanvas { canvas -> + val path = Path().apply { addRoundRect(RoundRect(0f, 0f, size.width, size.height, CornerRadius(r))) } + canvas.drawPath(path, Paint().also { + it.asFrameworkPaint().apply { isAntiAlias = true; color = android.graphics.Color.TRANSPARENT; setShadowLayer(blur, off, off, darkShadow.toArgb()) } + }) + canvas.drawPath(path, Paint().also { + it.asFrameworkPaint().apply { isAntiAlias = true; color = android.graphics.Color.TRANSPARENT; setShadowLayer(blur, -off, -off, lightShadow.toArgb()) } + }) + } +} + +/** Subtle neomorphic inset shadow */ +fun Modifier.neoInset( + lightShadow: Color, + darkShadow: Color, + radius: Dp = 14.dp, + shadowOffset: Dp = 1.dp, + shadowBlur: Dp = 3.dp, +) = this.drawBehind { + val r = radius.toPx() + val off = shadowOffset.toPx() + val blur = shadowBlur.toPx() + drawIntoCanvas { canvas -> + val path = Path().apply { addRoundRect(RoundRect(0f, 0f, size.width, size.height, CornerRadius(r))) } + canvas.drawPath(path, Paint().also { + it.asFrameworkPaint().apply { isAntiAlias = true; color = android.graphics.Color.TRANSPARENT; setShadowLayer(blur, -off, -off, darkShadow.toArgb()) } + }) + canvas.drawPath(path, Paint().also { + it.asFrameworkPaint().apply { isAntiAlias = true; color = android.graphics.Color.TRANSPARENT; setShadowLayer(blur, off, off, lightShadow.toArgb()) } + }) + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/theme/Theme.kt b/Android/app/src/main/java/com/archipelago/app/ui/theme/Theme.kt new file mode 100644 index 00000000..9fa7d247 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/theme/Theme.kt @@ -0,0 +1,56 @@ +package com.archipelago.app.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable + +private val DarkColorScheme = darkColorScheme( + primary = BitcoinOrange, + onPrimary = SurfaceBlack, + primaryContainer = BitcoinOrangeDark, + onPrimaryContainer = TextPrimary, + secondary = BitcoinOrangeLight, + onSecondary = SurfaceBlack, + background = SurfaceBlack, + onBackground = TextPrimary, + surface = SurfaceDark, + onSurface = TextPrimary, + surfaceVariant = SurfaceCard, + onSurfaceVariant = TextSecondary, + outline = BorderDefault, + outlineVariant = BorderSubtle, + error = ErrorRed, + onError = TextPrimary, +) + +private val LightColorScheme = lightColorScheme( + primary = BitcoinOrange, + onPrimary = SurfaceBlack, + primaryContainer = BitcoinOrangeLight, + onPrimaryContainer = SurfaceBlack, + secondary = BitcoinOrangeDark, + onSecondary = TextPrimary, + background = Neo.LightSurface, + onBackground = Neo.LightTextPrimary, + surface = Neo.LightSurfaceRaised, + onSurface = Neo.LightTextPrimary, + surfaceVariant = Neo.LightSurface, + onSurfaceVariant = Neo.LightTextSecondary, + outline = Neo.LightBorder, + outlineVariant = Neo.LightBorder, + error = ErrorRed, + onError = TextPrimary, +) + +@Composable +fun ArchipelagoTheme(content: @Composable () -> Unit) { + val colorScheme = if (isSystemInDarkTheme()) DarkColorScheme else LightColorScheme + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content, + ) +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/theme/Type.kt b/Android/app/src/main/java/com/archipelago/app/ui/theme/Type.kt new file mode 100644 index 00000000..c9444834 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/theme/Type.kt @@ -0,0 +1,60 @@ +package com.archipelago.app.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + displayLarge = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = (-0.5).sp, + ), + headlineLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 28.sp, + lineHeight = 36.sp, + ), + headlineMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 24.sp, + lineHeight = 32.sp, + ), + titleLarge = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 20.sp, + lineHeight = 28.sp, + ), + titleMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ), + bodyLarge = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + bodyMedium = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + labelLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), +) diff --git a/Android/app/src/main/res/drawable/bg_synthwave.jpg b/Android/app/src/main/res/drawable/bg_synthwave.jpg new file mode 100644 index 00000000..2f3afb80 Binary files /dev/null and b/Android/app/src/main/res/drawable/bg_synthwave.jpg differ diff --git a/Android/app/src/main/res/drawable/ic_launcher_background.xml b/Android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..a952248d --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,53 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Whole badge lives here (background renders to the mask edge with no + safe-zone cropping, unlike the foreground): dark fill + metallic ring pulled + inward to ~0.88 so the mask can't clip it + grid at ~0.58. Matches the + locally-rendered preview. Foreground is transparent. --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:aapt="http://schemas.android.com/aapt" + android:width="108dp" + android:height="108dp" + android:viewportWidth="752" + android:viewportHeight="752"> + + <path + android:fillColor="#0A0A0A" + android:pathData="M0,0h752v752H0z" /> + + <!-- Ring matching logo.svg's gradient (#000->#666). Scale 0.65 places it at + the home-screen's visible edge (calibrated from a device home screenshot; + launcher3 crops less than the Settings App-info view). --> + <group + android:pivotX="376" + android:pivotY="376" + android:scaleX="0.65" + android:scaleY="0.65"> + <path + android:fillColor="#00000000" + android:strokeWidth="22.8834" + android:pathData="M11.441,375.669a364.227,364.227 0 1,0 728.454,0a364.227,364.227 0 1,0 -728.454,0z"> + <aapt:attr name="android:strokeColor"> + <gradient + android:type="linear" + android:startX="751.337" + android:startY="751.338" + android:endX="0" + android:endY="0.000976562"> + <item android:offset="0" android:color="#FF000000" /> + <item android:offset="1" android:color="#FF666666" /> + </gradient> + </aapt:attr> + </path> + </group> + + <!-- White Archipelago grid --> + <group + android:pivotX="376" + android:pivotY="376" + android:scaleX="0.55" + android:scaleY="0.55"> + <path + android:fillColor="#FFFFFF" + android:pathData="M253.805,278.37V222.28H309.853V278.37H253.805ZM315.797,278.37V222.28H372.694V278.37H315.797ZM378.639,278.37V222.28H435.536V278.37H378.639ZM441.481,278.37V222.28H497.529V278.37H441.481ZM441.481,341.259V284.319H497.529V341.259H441.481ZM503.473,341.259V284.319H560.37V341.259H503.473ZM190.963,404.148V347.208H247.86V404.148H190.963ZM253.805,404.148V347.208H309.853V404.148H253.805ZM315.797,404.148V347.208H372.694V404.148H315.797ZM378.639,404.148V347.208H435.536V404.148H378.639ZM441.481,404.148V347.208H497.529V404.148H441.481ZM503.473,404.148V347.208H560.37V404.148H503.473ZM190.963,466.187V410.097H247.86V466.187H190.963ZM253.805,466.187V410.097H309.853V466.187H253.805ZM441.481,466.187V410.097H497.529V466.187H441.481ZM503.473,466.187V410.097H560.37V466.187H503.473ZM253.805,529.076V472.136H309.853V529.076H253.805ZM315.797,529.076V472.136H372.694V529.076H315.797ZM378.639,529.076V472.136H435.536V529.076H378.639ZM441.481,529.076V472.136H497.529V529.076H441.481Z" /> + </group> +</vector> diff --git a/Android/app/src/main/res/drawable/ic_launcher_foreground.xml b/Android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..4c640719 --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Transparent — the whole badge (ring + grid) is in the background layer so it + renders to the mask edge without safe-zone cropping. --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <path + android:fillColor="#00000000" + android:pathData="M0,0h108v108H0z" /> +</vector> diff --git a/Android/app/src/main/res/drawable/ic_logo.xml b/Android/app/src/main/res/drawable/ic_logo.xml new file mode 100644 index 00000000..275ad1d6 --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_logo.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Archipelago circular badge logo (from logo.svg): + dark circle with a black→grey gradient ring + white pixel-grid mark. --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:aapt="http://schemas.android.com/aapt" + android:width="120dp" + android:height="120dp" + android:viewportWidth="752" + android:viewportHeight="752"> + + <!-- Ringed circle (circle converted to a path; stroke carries the gradient) --> + <path + android:fillColor="#0A0A0A" + android:strokeWidth="22.8834" + android:pathData="M11.441,375.669a364.227,364.227 0 1,0 728.454,0a364.227,364.227 0 1,0 -728.454,0z"> + <aapt:attr name="android:strokeColor"> + <gradient + android:type="linear" + android:startX="751.337" + android:startY="751.338" + android:endX="0" + android:endY="0"> + <item android:offset="0" android:color="#FF000000" /> + <item android:offset="1" android:color="#FF666666" /> + </gradient> + </aapt:attr> + </path> + + <!-- White Archipelago pixel grid --> + <path + android:fillColor="#FFFFFF" + android:pathData="M253.805,278.37V222.28H309.853V278.37H253.805ZM315.797,278.37V222.28H372.694V278.37H315.797ZM378.639,278.37V222.28H435.536V278.37H378.639ZM441.481,278.37V222.28H497.529V278.37H441.481ZM441.481,341.259V284.319H497.529V341.259H441.481ZM503.473,341.259V284.319H560.37V341.259H503.473ZM190.963,404.148V347.208H247.86V404.148H190.963ZM253.805,404.148V347.208H309.853V404.148H253.805ZM315.797,404.148V347.208H372.694V404.148H315.797ZM378.639,404.148V347.208H435.536V404.148H378.639ZM441.481,404.148V347.208H497.529V404.148H441.481ZM503.473,404.148V347.208H560.37V404.148H503.473ZM190.963,466.187V410.097H247.86V466.187H190.963ZM253.805,466.187V410.097H309.853V466.187H253.805ZM441.481,466.187V410.097H497.529V466.187H441.481ZM503.473,466.187V410.097H560.37V466.187H503.473ZM253.805,529.076V472.136H309.853V529.076H253.805ZM315.797,529.076V472.136H372.694V529.076H315.797ZM378.639,529.076V472.136H435.536V529.076H378.639ZM441.481,529.076V472.136H497.529V529.076H441.481Z" /> +</vector> diff --git a/Android/app/src/main/res/drawable/ic_logo_wide.xml b/Android/app/src/main/res/drawable/ic_logo_wide.xml new file mode 100644 index 00000000..51122311 --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_logo_wide.xml @@ -0,0 +1,51 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="240dp" + android:height="30dp" + android:viewportWidth="2079" + android:viewportHeight="263"> + + <!-- A --> + <path android:fillColor="#FFFFFF" + android:pathData="M29.6,85.6V59.2H56V85.6H29.6ZM58.8,85.6V59.2H85.6V85.6H58.8ZM88.4,85.6V59.2H115.2V85.6H88.4ZM118,85.6V59.2H144.4V85.6H118ZM118,115.2V88.4H144.4V115.2H118ZM147.2,115.2V88.4H174V115.2H147.2ZM0,144.8V118H26.8V144.8H0ZM29.6,144.8V118H56V144.8H29.6ZM58.8,144.8V118H85.6V144.8H58.8ZM88.4,144.8V118H115.2V144.8H88.4ZM118,144.8V118H144.4V144.8H118ZM147.2,144.8V118H174V144.8H147.2ZM0,174V147.6H26.8V174H0ZM29.6,174V147.6H56V174H29.6ZM118,174V147.6H144.4V174H118ZM147.2,174V147.6H174V174H147.2ZM29.6,203.6V176.8H56V203.6H29.6ZM58.8,203.6V176.8H85.6V203.6H58.8ZM88.4,203.6V176.8H115.2V203.6H88.4ZM118,203.6V176.8H144.4V203.6H118Z" /> + + <!-- R --> + <path android:fillColor="#FFFFFF" + android:pathData="M243.663,85.6V59.2H270.062V85.6H243.663ZM272.863,85.6V59.2H299.663V85.6H272.863ZM302.463,85.6V59.2H329.263V85.6H302.463ZM332.062,85.6V59.2H358.462V85.6H332.062ZM332.062,115.2V88.4H358.462V115.2H332.062ZM361.263,115.2V88.4H388.062V115.2H361.263ZM214.062,115.2V88.4H240.863V115.2H214.062ZM243.663,115.2V88.4H270.062V115.2H243.663ZM214.062,144.8V118H240.863V144.8H214.062ZM243.663,144.8V118H270.062V144.8H243.663ZM214.062,174V147.6H240.863V174H214.062ZM243.663,174V147.6H270.062V174H243.663ZM243.663,203.6V176.8H270.062V203.6H243.663Z" /> + + <!-- C --> + <path android:fillColor="#FFFFFF" + android:pathData="M457.725,85.6V59.2H484.125V85.6H457.725ZM486.925,85.6V59.2H513.725V85.6H486.925ZM516.525,85.6V59.2H543.325V85.6H516.525ZM546.125,85.6V59.2H572.525V85.6H546.125ZM428.125,115.2V88.4H454.925V115.2H428.125ZM457.725,115.2V88.4H484.125V115.2H457.725ZM546.125,115.2V88.4H572.525V115.2H546.125ZM575.325,115.2V88.4H602.125V115.2H575.325ZM428.125,144.8V118H454.925V144.8H428.125ZM457.725,144.8V118H484.125V144.8H457.725ZM428.125,174V147.6H454.925V174H428.125ZM457.725,174V147.6H484.125V174H457.725ZM546.125,174V147.6H572.525V174H546.125ZM575.325,174V147.6H602.125V174H575.325ZM457.725,203.6V176.8H484.125V203.6H457.725ZM486.925,203.6V176.8H513.725V203.6H486.925ZM516.525,203.6V176.8H543.325V203.6H516.525ZM546.125,203.6V176.8H572.525V203.6H546.125Z" /> + + <!-- H --> + <path android:fillColor="#FFFFFF" + android:pathData="M671.787,26.8V0H698.188V26.8H671.787ZM642.188,56.4V29.6H668.987V56.4H642.188ZM671.787,56.4V29.6H698.188V56.4H671.787ZM642.188,85.6V59.2H668.987V85.6H642.188ZM671.787,85.6V59.2H698.188V85.6H671.787ZM700.987,85.6V59.2H727.787V85.6H700.987ZM730.588,85.6V59.2H757.388V85.6H730.588ZM760.188,85.6V59.2H786.588V85.6H760.188ZM642.188,115.2V88.4H668.987V115.2H642.188ZM671.787,115.2V88.4H698.188V115.2H671.787ZM760.188,115.2V88.4H786.588V115.2H760.188ZM789.388,115.2V88.4H816.188V115.2H789.388ZM642.188,144.8V118H668.987V144.8H642.188ZM671.787,144.8V118H698.188V144.8H671.787ZM760.188,144.8V118H786.588V144.8H760.188ZM789.388,144.8V118H816.188V144.8H789.388ZM642.188,174V147.6H668.987V174H642.188ZM671.787,174V147.6H698.188V174H671.787ZM760.188,174V147.6H786.588V174H760.188ZM789.388,174V147.6H816.188V174H789.388ZM671.787,203.6V176.8H698.188V203.6H671.787ZM760.188,203.6V176.8H786.588V203.6H760.188Z" /> + + <!-- I --> + <path android:fillColor="#FFFFFF" + android:pathData="M856.25,26.8V0H883.05V26.8H856.25ZM885.85,26.8V0H912.25V26.8H885.85ZM856.25,85.6V59.2H883.05V85.6H856.25ZM856.25,115.2V88.4H883.05V115.2H856.25ZM885.85,115.2V88.4H912.25V115.2H885.85ZM856.25,144.8V118H883.05V144.8H856.25ZM885.85,144.8V118H912.25V144.8H885.85ZM856.25,174V147.6H883.05V174H856.25ZM885.85,174V147.6H912.25V174H885.85ZM885.85,203.6V176.8H912.25V203.6H885.85Z" /> + + <!-- P --> + <path android:fillColor="#FFFFFF" + android:pathData="M981.944,85.6V59.2H1008.34V85.6H981.944ZM1011.14,85.6V59.2H1037.94V85.6H1011.14ZM1040.74,85.6V59.2H1067.54V85.6H1040.74ZM1070.34,85.6V59.2H1096.74V85.6H1070.34ZM952.344,115.2V88.4H979.144V115.2H952.344ZM981.944,115.2V88.4H1008.34V115.2H981.944ZM1070.34,115.2V88.4H1096.74V115.2H1070.34ZM1099.54,115.2V88.4H1126.34V115.2H1099.54ZM952.344,144.8V118H979.144V144.8H952.344ZM981.944,144.8V118H1008.34V144.8H981.944ZM1070.34,144.8V118H1096.74V144.8H1070.34ZM1099.54,144.8V118H1126.34V144.8H1099.54ZM952.344,174V147.6H979.144V174H952.344ZM981.944,174V147.6H1008.34V174H981.944ZM1070.34,174V147.6H1096.74V174H1070.34ZM1099.54,174V147.6H1126.34V174H1099.54ZM952.344,203.6V176.8H979.144V203.6H952.344ZM981.944,203.6V176.8H1008.34V203.6H981.944ZM1011.14,203.6V176.8H1037.94V203.6H1011.14ZM1040.74,203.6V176.8H1067.54V203.6H1040.74ZM1070.34,203.6V176.8H1096.74V203.6H1070.34ZM952.344,233.2V206.4H979.144V233.2H952.344ZM981.944,233.2V206.4H1008.34V233.2H981.944ZM981.944,262.4V236H1008.34V262.4H981.944Z" /> + + <!-- E --> + <path android:fillColor="#FFFFFF" + android:pathData="M1196.01,85.6V59.2H1222.41V85.6H1196.01ZM1225.21,85.6V59.2H1252.01V85.6H1225.21ZM1254.81,85.6V59.2H1281.61V85.6H1254.81ZM1284.41,85.6V59.2H1310.81V85.6H1284.41ZM1166.41,115.2V88.4H1193.21V115.2H1166.41ZM1196.01,115.2V88.4H1222.41V115.2H1196.01ZM1284.41,115.2V88.4H1310.81V115.2H1284.41ZM1313.61,115.2V88.4H1340.41V115.2H1313.61ZM1166.41,144.8V118H1193.21V144.8H1166.41ZM1196.01,144.8V118H1222.41V144.8H1196.01ZM1225.21,144.8V118H1252.01V144.8H1225.21ZM1254.81,144.8V118H1281.61V144.8H1254.81ZM1284.41,144.8V118H1310.81V144.8H1284.41ZM1313.61,144.8V118H1340.41V144.8H1313.61ZM1166.41,174V147.6H1193.21V174H1166.41ZM1196.01,174V147.6H1222.41V174H1196.01ZM1196.01,203.6V176.8H1222.41V203.6H1196.01ZM1225.21,203.6V176.8H1252.01V203.6H1225.21ZM1254.81,203.6V176.8H1281.61V203.6H1254.81ZM1284.41,203.6V176.8H1310.81V203.6H1284.41Z" /> + + <!-- L --> + <path android:fillColor="#FFFFFF" + android:pathData="M1380.47,26.8V0H1407.27V26.8H1380.47ZM1380.47,56.4V29.6H1407.27V56.4H1380.47ZM1410.07,56.4V29.6H1436.47V56.4H1410.07ZM1380.47,85.6V59.2H1407.27V85.6H1380.47ZM1410.07,85.6V59.2H1436.47V85.6H1410.07ZM1380.47,115.2V88.4H1407.27V115.2H1380.47ZM1410.07,115.2V88.4H1436.47V115.2H1410.07ZM1380.47,144.8V118H1407.27V144.8H1380.47ZM1410.07,144.8V118H1436.47V144.8H1410.07ZM1380.47,174V147.6H1407.27V174H1380.47ZM1410.07,174V147.6H1436.47V174H1410.07ZM1410.07,203.6V176.8H1436.47V203.6H1410.07Z" /> + + <!-- A (second) --> + <path android:fillColor="#FFFFFF" + android:pathData="M1506.16,85.6V59.2H1532.56V85.6H1506.16ZM1535.36,85.6V59.2H1562.16V85.6H1535.36ZM1564.96,85.6V59.2H1591.76V85.6H1564.96ZM1594.56,85.6V59.2H1620.96V85.6H1594.56ZM1594.56,115.2V88.4H1620.96V115.2H1594.56ZM1623.76,115.2V88.4H1650.56V115.2H1623.76ZM1476.56,144.8V118H1503.36V144.8H1476.56ZM1506.16,144.8V118H1532.56V144.8H1506.16ZM1535.36,144.8V118H1562.16V144.8H1535.36ZM1564.96,144.8V118H1591.76V144.8H1564.96ZM1594.56,144.8V118H1620.96V144.8H1594.56ZM1623.76,144.8V118H1650.56V144.8H1623.76ZM1476.56,174V147.6H1503.36V174H1476.56ZM1506.16,174V147.6H1532.56V174H1506.16ZM1594.56,174V147.6H1620.96V174H1594.56ZM1623.76,174V147.6H1650.56V174H1623.76ZM1506.16,203.6V176.8H1532.56V203.6H1506.16ZM1535.36,203.6V176.8H1562.16V203.6H1535.36ZM1564.96,203.6V176.8H1591.76V203.6H1564.96ZM1594.56,203.6V176.8H1620.96V203.6H1594.56Z" /> + + <!-- G --> + <path android:fillColor="#FFFFFF" + android:pathData="M1720.22,85.6V59.2H1746.62V85.6H1720.22ZM1749.43,85.6V59.2H1776.22V85.6H1749.43ZM1779.03,85.6V59.2H1805.82V85.6H1779.03ZM1808.62,85.6V59.2H1835.03V85.6H1808.62ZM1690.62,115.2V88.4H1717.43V115.2H1690.62ZM1720.22,115.2V88.4H1746.62V115.2H1720.22ZM1808.62,115.2V88.4H1835.03V115.2H1808.62ZM1837.82,115.2V88.4H1864.62V115.2H1837.82ZM1690.62,144.8V118H1717.43V144.8H1690.62ZM1720.22,144.8V118H1746.62V144.8H1720.22ZM1808.62,144.8V118H1835.03V144.8H1808.62ZM1837.82,144.8V118H1864.62V144.8H1837.82ZM1690.62,174V147.6H1717.43V174H1690.62ZM1720.22,174V147.6H1746.62V174H1720.22ZM1808.62,174V147.6H1835.03V174H1808.62ZM1837.82,174V147.6H1864.62V174H1837.82ZM1720.22,203.6V176.8H1746.62V203.6H1720.22ZM1749.43,203.6V176.8H1776.22V203.6H1749.43ZM1779.03,203.6V176.8H1805.82V203.6H1779.03ZM1808.62,203.6V176.8H1835.03V203.6H1808.62ZM1837.82,203.6V176.8H1864.62V203.6H1837.82ZM1808.62,233.2V206.4H1835.03V233.2H1808.62ZM1837.82,233.2V206.4H1864.62V233.2H1837.82ZM1720.22,262.4V236H1746.62V262.4H1720.22ZM1749.43,262.4V236H1776.22V262.4H1749.43ZM1779.03,262.4V236H1805.82V262.4H1779.03ZM1808.62,262.4V236H1835.03V262.4H1808.62Z" /> + + <!-- O --> + <path android:fillColor="#FFFFFF" + android:pathData="M1934.29,85.6V59.2H1960.69V85.6H1934.29ZM1963.49,85.6V59.2H1990.29V85.6H1963.49ZM1993.09,85.6V59.2H2019.89V85.6H1993.09ZM2022.69,85.6V59.2H2049.09V85.6H2022.69ZM1904.69,115.2V88.4H1931.49V115.2H1904.69ZM1934.29,115.2V88.4H1960.69V115.2H1934.29ZM2022.69,115.2V88.4H2049.09V115.2H2022.69ZM2051.89,115.2V88.4H2078.69V115.2H2051.89ZM1904.69,144.8V118H1931.49V144.8H1904.69ZM1934.29,144.8V118H1960.69V144.8H1934.29ZM2022.69,144.8V118H2049.09V144.8H2022.69ZM2051.89,144.8V118H2078.69V144.8H2051.89ZM1904.69,174V147.6H1931.49V174H1904.69ZM1934.29,174V147.6H1960.69V174H1934.29ZM2022.69,174V147.6H2049.09V174H2022.69ZM2051.89,174V147.6H2078.69V174H2051.89ZM1963.49,203.6V176.8H1990.29V203.6H1963.49ZM1993.09,203.6V176.8H2019.89V203.6H1993.09ZM1934.29,203.6V176.8H1960.69V203.6H1934.29ZM2022.69,203.6V176.8H2049.09V203.6H2022.69Z" /> + +</vector> diff --git a/Android/app/src/main/res/drawable/ic_nav_back.xml b/Android/app/src/main/res/drawable/ic_nav_back.xml new file mode 100644 index 00000000..fb5842af --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_nav_back.xml @@ -0,0 +1,12 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M15,19l-7,-7 7,-7" + android:strokeColor="#FFFFFF" + android:strokeWidth="2" + android:strokeLineCap="round" + android:strokeLineJoin="round" /> +</vector> diff --git a/Android/app/src/main/res/drawable/ic_nav_close.xml b/Android/app/src/main/res/drawable/ic_nav_close.xml new file mode 100644 index 00000000..3620ff4c --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_nav_close.xml @@ -0,0 +1,12 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M6,18L18,6M6,6l12,12" + android:strokeColor="#FFFFFF" + android:strokeWidth="2" + android:strokeLineCap="round" + android:strokeLineJoin="round" /> +</vector> diff --git a/Android/app/src/main/res/drawable/ic_nav_forward.xml b/Android/app/src/main/res/drawable/ic_nav_forward.xml new file mode 100644 index 00000000..89757edb --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_nav_forward.xml @@ -0,0 +1,12 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M9,5l7,7 -7,7" + android:strokeColor="#FFFFFF" + android:strokeWidth="2" + android:strokeLineCap="round" + android:strokeLineJoin="round" /> +</vector> diff --git a/Android/app/src/main/res/drawable/ic_nav_newtab.xml b/Android/app/src/main/res/drawable/ic_nav_newtab.xml new file mode 100644 index 00000000..e1c4eb2a --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_nav_newtab.xml @@ -0,0 +1,12 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M10,6H6a2,2 0,0 0,-2 2v10a2,2 0,0 0,2 2h10a2,2 0,0 0,2 -2v-4M14,4h6m0,0v6m0,-6L10,14" + android:strokeColor="#FFFFFF" + android:strokeWidth="2" + android:strokeLineCap="round" + android:strokeLineJoin="round" /> +</vector> diff --git a/Android/app/src/main/res/drawable/ic_nav_refresh.xml b/Android/app/src/main/res/drawable/ic_nav_refresh.xml new file mode 100644 index 00000000..27766e42 --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_nav_refresh.xml @@ -0,0 +1,12 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M4,4v6h6M20,20v-6h-6M5.64,15.36A8,8 0,0 0,18.36 18M18.36,8.64A8,8 0,0 0,5.64 6" + android:strokeColor="#FFFFFF" + android:strokeWidth="2" + android:strokeLineCap="round" + android:strokeLineJoin="round" /> +</vector> diff --git a/Android/app/src/main/res/drawable/ic_splash_logo.xml b/Android/app/src/main/res/drawable/ic_splash_logo.xml new file mode 100644 index 00000000..31eb8233 --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_splash_logo.xml @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Archipelago pixel-art "A" for splash screen --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="1024" + android:viewportHeight="1024"> + + <group + android:pivotX="512" + android:pivotY="512" + android:scaleX="0.55" + android:scaleY="0.55"> + + <path android:fillColor="#FFFFFF" android:pathData="M357.614,318h71.007v70.936h-71.007z" /> + <path android:fillColor="#FFFFFF" android:pathData="M436.152,318h72.082v70.936h-72.082z" /> + <path android:fillColor="#FFFFFF" android:pathData="M515.766,318h72.082v70.936h-72.082z" /> + <path android:fillColor="#FFFFFF" android:pathData="M595.379,318h71.007v70.936h-71.007z" /> + <path android:fillColor="#FFFFFF" android:pathData="M595.379,396.46h71.007v72.011h-71.007z" /> + <path android:fillColor="#FFFFFF" android:pathData="M673.917,396.46h72.083v72.011h-72.083z" /> + <path android:fillColor="#FFFFFF" android:pathData="M278,475.994h72.083v72.012h-72.083z" /> + <path android:fillColor="#FFFFFF" android:pathData="M357.614,475.994h71.007v72.012h-71.007z" /> + <path android:fillColor="#FFFFFF" android:pathData="M436.152,475.994h72.082v72.012h-72.082z" /> + <path android:fillColor="#FFFFFF" android:pathData="M515.766,475.994h72.082v72.012h-72.082z" /> + <path android:fillColor="#FFFFFF" android:pathData="M595.379,475.994h71.007v72.012h-71.007z" /> + <path android:fillColor="#FFFFFF" android:pathData="M673.917,475.994h72.083v72.012h-72.083z" /> + <path android:fillColor="#FFFFFF" android:pathData="M278,555.529h72.083v70.936h-72.083z" /> + <path android:fillColor="#FFFFFF" android:pathData="M357.614,555.529h71.007v70.936h-71.007z" /> + <path android:fillColor="#FFFFFF" android:pathData="M595.379,555.529h71.007v70.936h-71.007z" /> + <path android:fillColor="#FFFFFF" android:pathData="M673.917,555.529h72.083v70.936h-72.083z" /> + <path android:fillColor="#FFFFFF" android:pathData="M357.614,633.989h71.007v72.011h-71.007z" /> + <path android:fillColor="#FFFFFF" android:pathData="M436.152,633.989h72.082v72.011h-72.082z" /> + <path android:fillColor="#FFFFFF" android:pathData="M515.766,633.989h72.082v72.011h-72.082z" /> + <path android:fillColor="#FFFFFF" android:pathData="M595.379,633.989h71.007v72.011h-71.007z" /> + </group> +</vector> diff --git a/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..6b78462d --- /dev/null +++ b/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@drawable/ic_launcher_background" /> + <foreground android:drawable="@drawable/ic_launcher_foreground" /> +</adaptive-icon> diff --git a/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..6b78462d --- /dev/null +++ b/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@drawable/ic_launcher_background" /> + <foreground android:drawable="@drawable/ic_launcher_foreground" /> +</adaptive-icon> diff --git a/Android/app/src/main/res/values/colors.xml b/Android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..f3f45238 --- /dev/null +++ b/Android/app/src/main/res/values/colors.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <color name="black">#FF000000</color> + <color name="white">#FFFFFFFF</color> + <color name="bitcoin_orange">#FFF7931A</color> + <color name="surface_dark">#FF0A0A0A</color> + <color name="surface_card">#FF1A1A1A</color> + <color name="splash_background">#FF000000</color> +</resources> diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..6f92ff2c --- /dev/null +++ b/Android/app/src/main/res/values/strings.xml @@ -0,0 +1,52 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="app_name">Archipelago</string> + <string name="server_address_label">Server Address</string> + <string name="server_address_placeholder">192.168.1.100</string> + <string name="server_address_hint">Enter your Archipelago server IP or hostname</string> + <string name="connect">Connect</string> + <string name="connecting">Connecting…</string> + <string name="connection_failed">Could not reach server. Check the address and try again.</string> + <string name="connection_timeout">Connection timed out. Is the server running?</string> + <string name="welcome_title">Your Sovereign\nPersonal Server</string> + <string name="welcome_subtitle">Bitcoin node, app platform, and private cloud — all in one box you control.</string> + <string name="get_started">Get Started</string> + <string name="mesh_party">Mesh Party</string> + <string name="use_https">Use HTTPS</string> + <string name="port_label">Port (optional)</string> + <string name="saved_servers">Saved Servers</string> + <string name="no_saved_servers">No saved servers yet</string> + <string name="remove_server">Remove</string> + <string name="disconnect">Disconnect</string> + <string name="server_unreachable">Server unreachable</string> + <string name="retry">Retry</string> + <string name="remote_input">Remote Control</string> + <string name="remote_input_hint">Use your phone as a keyboard and mouse for the kiosk</string> + <string name="close">Close</string> + <string name="open_in_browser">Open in browser</string> + <string name="back">Back</string> + <string name="forward">Forward</string> + <string name="refresh">Refresh</string> + <string name="server_name_label">Server Name (optional)</string> + <string name="server_name_placeholder">My Archipelago</string> + <string name="edit_server">Edit</string> + <string name="scan_node_qr">Scan Node\'s QR</string> + <string name="enter_manually">Enter Manually</string> + <string name="connect_landing_hint">Scan the pairing QR from your node\'s Companion popup, or enter the address manually</string> + <string name="scan_qr_hint">Point the camera at the pairing QR shown in the Companion popup</string> + <string name="camera_permission_needed">Camera access is needed to scan the pairing QR. You can also enter the server details manually.</string> + <string name="grant_camera_access">Grant Camera Access</string> + <string name="invalid_pairing_qr">Not an Archipelago pairing code</string> + <string name="update_app_for_qr">This pairing code needs a newer app version — please update the companion app</string> + <string name="add_server_qr">Add server by QR</string> + <string name="edit_server_title">Edit Server</string> + <string name="save_changes">Save Changes</string> + <string name="cancel">Cancel</string> + <string name="gesture_hint_title">Hold with three fingers</string> + <string name="gesture_hint_body">Anywhere in the app — opens the remote control and menu</string> + <string name="gesture_hint_got_it">Got it</string> + <string name="scan_to_send">Scan to send</string> + <string name="scan_wallet_hint">Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code</string> + <string name="upload_qr_image">Upload image</string> + <string name="no_qr_in_image">No QR code found in that image — try another, closer and well-lit</string> +</resources> diff --git a/Android/app/src/main/res/values/themes.xml b/Android/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..3da59fcb --- /dev/null +++ b/Android/app/src/main/res/values/themes.xml @@ -0,0 +1,14 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <style name="Theme.Archipelago" parent="android:Theme.Material.NoActionBar"> + <item name="android:statusBarColor">@android:color/transparent</item> + <item name="android:navigationBarColor">@android:color/transparent</item> + <item name="android:windowBackground">@color/black</item> + </style> + + <style name="Theme.Archipelago.Splash" parent="Theme.SplashScreen"> + <item name="windowSplashScreenBackground">@color/splash_background</item> + <item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash_logo</item> + <item name="postSplashScreenTheme">@style/Theme.Archipelago</item> + </style> +</resources> diff --git a/Android/app/src/main/res/xml/file_paths.xml b/Android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..40e6b2d8 --- /dev/null +++ b/Android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- FileProvider scope for the party-screen "Share this app" APK handoff. --> +<paths> + <cache-path name="share" path="share/" /> +</paths> diff --git a/Android/app/src/main/res/xml/network_security_config.xml b/Android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..cdf19ccc --- /dev/null +++ b/Android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<network-security-config> + <!-- Allow cleartext for local network Archipelago servers --> + <base-config cleartextTrafficPermitted="true"> + <trust-anchors> + <certificates src="system" /> + </trust-anchors> + </base-config> +</network-security-config> diff --git a/Android/archipelago-0.3.0-debug.apk.zip b/Android/archipelago-0.3.0-debug.apk.zip new file mode 100644 index 00000000..be620f3c Binary files /dev/null and b/Android/archipelago-0.3.0-debug.apk.zip differ diff --git a/Android/build.gradle.kts b/Android/build.gradle.kts new file mode 100644 index 00000000..ac5880ab --- /dev/null +++ b/Android/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.4.0" apply false + id("org.jetbrains.kotlin.android") version "1.9.24" apply false +} diff --git a/Android/gradle.properties b/Android/gradle.properties new file mode 100644 index 00000000..8679d5b5 --- /dev/null +++ b/Android/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true +android.suppressUnsupportedCompileSdk=35 diff --git a/Android/gradle/wrapper/gradle-wrapper.jar b/Android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..e6441136 Binary files /dev/null and b/Android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/Android/gradle/wrapper/gradle-wrapper.properties b/Android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..b82aa23a --- /dev/null +++ b/Android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/Android/gradlew b/Android/gradlew new file mode 100755 index 00000000..1aa94a42 --- /dev/null +++ b/Android/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/Android/gradlew.bat b/Android/gradlew.bat new file mode 100644 index 00000000..7101f8e4 --- /dev/null +++ b/Android/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/Android/logo.svg b/Android/logo.svg new file mode 100644 index 00000000..f218f5a4 --- /dev/null +++ b/Android/logo.svg @@ -0,0 +1,10 @@ +<svg width="752" height="752" viewBox="0 0 752 752" fill="none" xmlns="http://www.w3.org/2000/svg"> +<circle cx="375.668" cy="375.669" r="364.227" fill="#0A0A0A" stroke="url(#paint0_linear_877_1990)" stroke-width="22.8834"/> +<path d="M253.805 278.37V222.28H309.853V278.37H253.805ZM315.797 278.37V222.28H372.694V278.37H315.797ZM378.639 278.37V222.28H435.536V278.37H378.639ZM441.481 278.37V222.28H497.529V278.37H441.481ZM441.481 341.259V284.319H497.529V341.259H441.481ZM503.473 341.259V284.319H560.37V341.259H503.473ZM190.963 404.148V347.208H247.86V404.148H190.963ZM253.805 404.148V347.208H309.853V404.148H253.805ZM315.797 404.148V347.208H372.694V404.148H315.797ZM378.639 404.148V347.208H435.536V404.148H378.639ZM441.481 404.148V347.208H497.529V404.148H441.481ZM503.473 404.148V347.208H560.37V404.148H503.473ZM190.963 466.187V410.097H247.86V466.187H190.963ZM253.805 466.187V410.097H309.853V466.187H253.805ZM441.481 466.187V410.097H497.529V466.187H441.481ZM503.473 466.187V410.097H560.37V466.187H503.473ZM253.805 529.076V472.136H309.853V529.076H253.805ZM315.797 529.076V472.136H372.694V529.076H315.797ZM378.639 529.076V472.136H435.536V529.076H378.639ZM441.481 529.076V472.136H497.529V529.076H441.481Z" fill="white"/> +<defs> +<linearGradient id="paint0_linear_877_1990" x1="751.337" y1="751.338" x2="0" y2="0.000976562" gradientUnits="userSpaceOnUse"> +<stop/> +<stop offset="1" stop-color="#666666"/> +</linearGradient> +</defs> +</svg> diff --git a/Android/rust/archy-fips-core/Cargo.lock b/Android/rust/archy-fips-core/Cargo.lock new file mode 100644 index 00000000..b99f5ba2 --- /dev/null +++ b/Android/rust/archy-fips-core/Cargo.lock @@ -0,0 +1,1690 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "archy-fips-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "fips", + "getrandom 0.2.17", + "hex", + "jni", + "libc", + "paranoid-android", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "207311705279250ba465076a1bac4b1ac982855fff73fc5f67e22158ac58cdc9" +dependencies = [ + "bitcoin-internals", + "hex-conservative 1.2.0", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" + +[[package]] +name = "bitcoin-io" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.2", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fips" +version = "0.3.0-dev" +source = "git+https://github.com/Zazawowow/fips-native?rev=07d21d4482be56b14295d2525e41f8386d1bfe6f#07d21d4482be56b14295d2525e41f8386d1bfe6f" +dependencies = [ + "bech32", + "chacha20poly1305", + "clap", + "dirs", + "futures", + "hex", + "hkdf", + "libc", + "rand 0.10.2", + "rtnetlink", + "secp256k1", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "simple-dns", + "socket2", + "thiserror 2.0.19", + "tokio", + "tokio-socks", + "tracing", + "tracing-subscriber", + "tun", +] + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.1", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-conservative" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445" +dependencies = [ + "bitflags", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.19", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "paranoid-android" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "101795d63d371b43e38d6e7254677657be82f17022f7f7893c268f33ac0caadc" +dependencies = [ + "lazy_static", + "ndk-sys", + "sharded-slab", + "smallvec", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20 0.10.1", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.19", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rtnetlink" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b960d5d873a75b5be9761b1e73b146f52dddcd27bac75263f40fba686d4d7b5" +dependencies = [ + "futures-channel", + "futures-util", + "log", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "nix 0.30.1", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "secp256k1" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.7", + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simple-dns" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" +dependencies = [ + "bitflags", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tun" +version = "0.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bb55e468c585e02ef89ba7a1a9848871ac942dfc64547ad4d1166b0f61a98be" +dependencies = [ + "bytes", + "cfg-if", + "futures", + "futures-core", + "ipnet", + "libc", + "log", + "nix 0.31.3", + "serde_json", + "thiserror 2.0.19", + "tokio", + "tokio-util", + "windows-sys 0.61.2", + "wintun-bindings", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "wintun-bindings" +version = "0.7.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc4494d02357537af05cf526be7b817a51752b688a78926af57379abd840d911" +dependencies = [ + "blocking", + "futures", + "libloading", + "log", + "thiserror 2.0.19", + "windows-sys 0.61.2", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Android/rust/archy-fips-core/Cargo.toml b/Android/rust/archy-fips-core/Cargo.toml new file mode 100644 index 00000000..e6cc8cf4 --- /dev/null +++ b/Android/rust/archy-fips-core/Cargo.toml @@ -0,0 +1,48 @@ +# Embedded FIPS mesh node for the Archipelago companion app. +# +# Built for Android via cargo-ndk (see Android/app/build.gradle.kts, task +# buildRustArm64) into app/src/main/jniLibs/arm64-v8a/libarchy_fips_core.so. +# Also builds on the host so `cargo test` covers the non-JNI logic. +# +# `fips` is pinned to the fips-native fork rev that this integration was +# developed against — the fork carries Android support upstream lacks +# (Tun::from_fd for a VpnService-owned fd, cfg(target_os = "android") paths). +# Override with a local checkout when hacking on fips itself: +# CARGO_NET_OFFLINE=false cargo ndk ... --config 'patch."https://github.com/9qeklajc/fips-native".fips.path="/path/to/fips-native/fips"' +[package] +name = "archy-fips-core" +version = "0.1.0" +edition = "2021" +license = "MIT" +publish = false + +[lib] +name = "archy_fips_core" +# rlib for host tests; cdylib for the Android shared library. +crate-type = ["lib", "cdylib"] + +[dependencies] +# default-features drops the ratatui TUI; tun-support enables Node::start_with_tun_fd. +# Zazawowow/fips-native `fast-join-pinned` = upstream pinned rev 46494a74 + +# discovery re-fire on topology change (fresh 5G join: first route no longer +# waits out doomed pre-join lookups). Upstream 9qeklajc denies pushes. +fips = { git = "https://github.com/Zazawowow/fips-native", rev = "07d21d4482be56b14295d2525e41f8386d1bfe6f", default-features = false, features = ["tun-support"] } +anyhow = "1.0" +serde_json = "1.0" +hex = "0.4" +# OS CSPRNG for identity generation (crypto rule: no thread-local RNG for keys). +getrandom = "0.2" +tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "macros"] } +tracing = "0.1" +# fcntl: force the VpnService TUN fd into blocking mode (see mesh::start). +libc = "0.2" + +# The JNI surface only exists on Android; host builds skip it and drive the +# mesh module directly (tests). +[target.'cfg(target_os = "android")'.dependencies] +jni = "0.21" +# Bridge `tracing` (ours + fips) to logcat: `adb logcat -s archy-fips`. +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +paranoid-android = "0.2" + +[workspace] diff --git a/Android/rust/archy-fips-core/src/jni_glue.rs b/Android/rust/archy-fips-core/src/jni_glue.rs new file mode 100644 index 00000000..5f891135 --- /dev/null +++ b/Android/rust/archy-fips-core/src/jni_glue.rs @@ -0,0 +1,129 @@ +//! JNI surface for `com.archipelago.app.fips.FipsNative` — JSON over strings, +//! no codegen (the myco / nostr-vpn embedding pattern). Errors come back as +//! `{"error": "…"}` so Kotlin never sees a raw exception from native code. + +use std::sync::Once; + +use jni::objects::{JClass, JString}; +use jni::sys::{jboolean, jint, jstring}; +use jni::JNIEnv; + +use crate::mesh; + +static LOG_INIT: Once = Once::new(); + +fn init_logging() { + LOG_INIT.call_once(|| { + use tracing_subscriber::layer::SubscriberExt; + use tracing_subscriber::util::SubscriberInitExt; + let _ = tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new("info")) + .with(paranoid_android::layer("archy-fips")) + .try_init(); + }); +} + +fn jstr(env: &mut JNIEnv, s: &JString) -> String { + env.get_string(s).map(|s| s.into()).unwrap_or_default() +} + +fn out(env: &JNIEnv, s: String) -> jstring { + env.new_string(s) + .map(|s| s.into_raw()) + .unwrap_or(std::ptr::null_mut()) +} + +fn err_json(e: impl std::fmt::Display) -> String { + serde_json::json!({ "error": e.to_string() }).to_string() +} + +fn identity_json(info: &mesh::IdentityInfo) -> String { + serde_json::json!({ + "secret": info.secret_hex, + "npub": info.npub, + "address": info.address, + }) + .to_string() +} + +/// Kotlin: `external fun generateIdentity(): String` +#[no_mangle] +pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_generateIdentity( + env: JNIEnv, + _class: JClass, +) -> jstring { + init_logging(); + let json = match mesh::generate_identity() { + Ok(info) => identity_json(&info), + Err(e) => err_json(e), + }; + out(&env, json) +} + +/// Kotlin: `external fun deriveIdentity(secret: String): String` +#[no_mangle] +pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_deriveIdentity( + mut env: JNIEnv, + _class: JClass, + secret: JString, +) -> jstring { + init_logging(); + let secret = jstr(&mut env, &secret); + let json = match mesh::derive_identity(&secret) { + Ok(info) => identity_json(&info), + Err(e) => err_json(e), + }; + out(&env, json) +} + +/// Kotlin: `external fun start(secret: String, peersJson: String, tunFd: Int, listenPort: Int): String` +/// Returns `{"npub": "...", "address": "..."}` or `{"error": "..."}`. +/// `listenPort` 0 = outbound-only; non-zero = fixed UDP bind (party mode). +#[no_mangle] +pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_start( + mut env: JNIEnv, + _class: JClass, + secret: JString, + peers_json: JString, + tun_fd: jint, + listen_port: jint, +) -> jstring { + init_logging(); + let secret = jstr(&mut env, &secret); + let peers = jstr(&mut env, &peers_json); + let listen_port = u16::try_from(listen_port).unwrap_or(0); + let json = match mesh::start(&secret, &peers, tun_fd, listen_port) { + Ok((npub, address)) => { + serde_json::json!({ "npub": npub, "address": address }).to_string() + } + Err(e) => err_json(e), + }; + out(&env, json) +} + +/// Kotlin: `external fun stop()` +#[no_mangle] +pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_stop( + _env: JNIEnv, + _class: JClass, +) { + mesh::stop(); +} + +/// Kotlin: `external fun isRunning(): Boolean` +#[no_mangle] +pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_isRunning( + _env: JNIEnv, + _class: JClass, +) -> jboolean { + mesh::is_running() as jboolean +} + +/// Kotlin: `external fun statusJson(): String` +#[no_mangle] +pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_statusJson( + env: JNIEnv, + _class: JClass, +) -> jstring { + out(&env, mesh::status_json()) +} diff --git a/Android/rust/archy-fips-core/src/lib.rs b/Android/rust/archy-fips-core/src/lib.rs new file mode 100644 index 00000000..98a22d7e --- /dev/null +++ b/Android/rust/archy-fips-core/src/lib.rs @@ -0,0 +1,17 @@ +//! Embedded FIPS mesh node for the Archipelago companion app. +//! +//! The phone runs a real, leaf-only FIPS node in-process: Android's +//! `VpnService` owns the TUN fd (routing only `fd00::/8`, so normal traffic +//! never touches the tunnel) and hands it to [`fips::Node::start_with_tun_fd`]. +//! Peering is outbound-only — the pairing QR carries the node's npub and +//! transport endpoints, and FIPS nodes accept inbound peers without prior +//! registration, so no server-side enrollment step exists. +//! +//! The JNI surface (`jni_glue`, Android-only) is deliberately tiny and +//! JSON-over-strings, mirroring the myco / nostr-vpn embedding pattern: +//! `generateIdentity`, `deriveIdentity`, `start`, `stop`, `isRunning`. + +pub mod mesh; + +#[cfg(target_os = "android")] +mod jni_glue; diff --git a/Android/rust/archy-fips-core/src/mesh.rs b/Android/rust/archy-fips-core/src/mesh.rs new file mode 100644 index 00000000..ed33db90 --- /dev/null +++ b/Android/rust/archy-fips-core/src/mesh.rs @@ -0,0 +1,295 @@ +//! Mesh lifecycle: identity, config assembly, and the node task. +//! +//! Host-buildable (no JNI) so the config/identity logic is unit-testable; +//! only [`start`] needs a real TUN fd and therefore only runs on-device. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use anyhow::{anyhow, Context, Result}; +use fips::config::{PeerConfig, TransportInstances, UdpConfig}; +use fips::{Config, Identity, Node}; +use tokio::sync::Notify; + +/// How long `start` waits for the node to come up (TUN attach + transports). +const START_TIMEOUT: Duration = Duration::from_secs(15); + +/// How long `stop` waits for the node task to drain. +const STOP_TIMEOUT: Duration = Duration::from_secs(5); + +struct MeshHandle { + runtime: tokio::runtime::Runtime, + task: Option<tokio::task::JoinHandle<()>>, + shutdown: Arc<Notify>, + running: Arc<AtomicBool>, + npub: String, + address: String, +} + +static MESH: Mutex<Option<MeshHandle>> = Mutex::new(None); + +#[derive(Debug, Clone)] +pub struct IdentityInfo { + pub secret_hex: String, + pub npub: String, + /// The phone's own ULA on the mesh (fd::/8), for VpnService.addAddress. + pub address: String, +} + +/// Generate a fresh mesh identity from the OS CSPRNG. +pub fn generate_identity() -> Result<IdentityInfo> { + // ~1 in 2^128 chance a candidate is off the curve; loop regardless. + loop { + let mut bytes = [0u8; 32]; + getrandom::getrandom(&mut bytes).context("OS RNG")?; + if let Ok(id) = Identity::from_secret_bytes(&bytes) { + return Ok(IdentityInfo { + secret_hex: hex::encode(bytes), + npub: id.npub(), + address: id.address().to_ipv6().to_string(), + }); + } + } +} + +/// Re-derive npub + ULA from a stored secret. +pub fn derive_identity(secret: &str) -> Result<IdentityInfo> { + let id = Identity::from_secret_str(secret).map_err(|e| anyhow!("bad secret: {e}"))?; + Ok(IdentityInfo { + secret_hex: secret.to_string(), + npub: id.npub(), + address: id.address().to_ipv6().to_string(), + }) +} + +/// Build the phone-side node config: leaf-only (never routes third-party +/// traffic — battery), no DNS responder, TUN enabled but attached to the +/// VpnService fd rather than created. +/// +/// `listen_port` 0 = ephemeral UDP (outbound-only, the default posture). +/// Non-zero = fixed UDP bind so a nearby phone can dial us directly over a +/// local link (party mode); leaf_only still guarantees we never carry +/// third-party transit even while accepting an inbound link. +pub fn build_config(secret: &str, peers: Vec<PeerConfig>, listen_port: u16) -> Config { + let mut cfg = Config::default(); + cfg.node.identity.nsec = Some(secret.to_string()); + cfg.node.identity.persistent = false; + cfg.node.leaf_only = true; + cfg.tun.enabled = true; + cfg.tun.mtu = Some(1280); + cfg.dns.enabled = false; + cfg.transports.udp = TransportInstances::Single(UdpConfig { + bind_addr: Some(format!("0.0.0.0:{listen_port}")), + ..Default::default() + }); + // TCP with no bind_addr = outbound-only (fallback when UDP is blocked). + cfg.transports.tcp = TransportInstances::Single(Default::default()); + // Fast-connect profile — a phone opens the app and expects the node NOW. + // Stock pacing is tuned for always-on routers: a failed discovery backs + // off 30s, session resends gap out to 8-16s, dead links redial at + // 5s→300s. Over 5G that stacked into a ~40s first connect (observed + // 2026-07-24: anchor +10s, session +40s). Retries only fire while a + // link/session is down, so steady-state traffic is unchanged. + cfg.node.retry.base_interval_secs = 1; // dead-link redial 1s,2s,4s… + cfg.node.retry.max_backoff_secs = 30; // …capped at 30s, not 5 min + cfg.node.retry.max_retries = 30; + cfg.node.rate_limit.handshake_resend_interval_ms = 400; + cfg.node.rate_limit.handshake_resend_backoff = 1.5; + cfg.node.rate_limit.handshake_max_resends = 10; + cfg.node.discovery.backoff_base_secs = 1; // failed lookup retries fast + cfg.node.discovery.backoff_max_secs = 30; + cfg.node.discovery.retry_interval_secs = 2; + cfg.node.discovery.max_attempts = 3; + // Lookups launched before the tree position settles are doomed; a 10s + // completion timeout made each one cost 10s before the 1s retry could + // fire (observed: 19s to a route on a fresh join). Fail fast instead — + // the resend-within-window above still gives each attempt two shots. + cfg.node.discovery.timeout_secs = 5; + cfg.peers = peers; + cfg +} + +/// Parse the peers JSON handed over from Kotlin. Shape = fips `PeerConfig`: +/// `[{"npub":"…","alias":"…","addresses":[{"transport":"udp","addr":"host:2121","priority":10},…]}]` +pub fn parse_peers(peers_json: &str) -> Result<Vec<PeerConfig>> { + serde_json::from_str(peers_json).context("peers JSON") +} + +/// Start the mesh node on the given TUN fd (from `VpnService.establish()`, +/// detached — the node owns it from here). Returns (npub, ula) on success. +/// Any previously running node is stopped first. +pub fn start(secret: &str, peers_json: &str, tun_fd: i32, listen_port: u16) -> Result<(String, String)> { + stop(); + + // Android hands the VpnService TUN fd over in non-blocking mode on some + // OS builds. The fips TUN reader is a dedicated blocking-read thread that + // treats EAGAIN as fatal — the loop died at startup ("TUN read error … + // Try again (os error 11)" on-device), so sessions came up but no packet + // ever entered the mesh. Force the fd into the blocking mode the reader + // is designed for. + unsafe { + let flags = libc::fcntl(tun_fd, libc::F_GETFL); + if flags >= 0 && (flags & libc::O_NONBLOCK) != 0 { + libc::fcntl(tun_fd, libc::F_SETFL, flags & !libc::O_NONBLOCK); + } + } + + let peers = parse_peers(peers_json)?; + let config = build_config(secret, peers, listen_port); + let mut node = Node::new(config).map_err(|e| anyhow!("node init: {e}"))?; + let npub = node.npub(); + let address = node.identity().address().to_ipv6().to_string(); + + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .thread_name("archy-fips") + .build() + .context("tokio runtime")?; + + let shutdown = Arc::new(Notify::new()); + let running = Arc::new(AtomicBool::new(false)); + let (started_tx, started_rx) = tokio::sync::oneshot::channel::<Result<()>>(); + + let task = { + let shutdown = shutdown.clone(); + let running = running.clone(); + runtime.spawn(async move { + match node.start_with_tun_fd(tun_fd).await { + Ok(()) => { + running.store(true, Ordering::SeqCst); + let _ = started_tx.send(Ok(())); + } + Err(e) => { + let _ = started_tx.send(Err(anyhow!("node start: {e}"))); + return; + } + } + tokio::select! { + result = node.run_rx_loop() => { + if let Err(e) = result { + tracing::error!("mesh rx loop error: {e}"); + } + } + _ = shutdown.notified() => {} + } + if let Err(e) = node.stop().await { + tracing::warn!("mesh stop: {e}"); + } + running.store(false, Ordering::SeqCst); + }) + }; + + let started = runtime + .block_on(async { tokio::time::timeout(START_TIMEOUT, started_rx).await }) + .map_err(|_| anyhow!("node start timed out"))? + .map_err(|_| anyhow!("node task died during start"))?; + if let Err(e) = started { + runtime.shutdown_background(); + return Err(e); + } + + *MESH.lock().unwrap() = Some(MeshHandle { + runtime, + task: Some(task), + shutdown, + running, + npub: npub.clone(), + address: address.clone(), + }); + Ok((npub, address)) +} + +/// Stop the mesh node if running. Idempotent. +pub fn stop() { + let Some(mut handle) = MESH.lock().unwrap().take() else { + return; + }; + handle.shutdown.notify_waiters(); + if let Some(task) = handle.task.take() { + let _ = handle + .runtime + .block_on(async { tokio::time::timeout(STOP_TIMEOUT, task).await }); + } + handle.runtime.shutdown_background(); +} + +pub fn is_running() -> bool { + MESH.lock() + .unwrap() + .as_ref() + .map(|h| h.running.load(Ordering::SeqCst)) + .unwrap_or(false) +} + +/// `{running, npub, address}` for the Kotlin status surface. +pub fn status_json() -> String { + let guard = MESH.lock().unwrap(); + match guard.as_ref() { + Some(h) => serde_json::json!({ + "running": h.running.load(Ordering::SeqCst), + "npub": h.npub, + "address": h.address, + }) + .to_string(), + None => serde_json::json!({ "running": false }).to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identity_roundtrip() { + let a = generate_identity().unwrap(); + let b = derive_identity(&a.secret_hex).unwrap(); + assert_eq!(a.npub, b.npub); + assert_eq!(a.address, b.address); + // ULA in fd::/8 + assert!(a.address.starts_with("fd")); + } + + #[test] + fn peers_json_parses_into_peer_config() { + let peers = parse_peers( + r#"[{ + "npub": "npub1abc", + "alias": "My Archipelago", + "addresses": [ + {"transport": "udp", "addr": "192.168.1.228:2121", "priority": 10}, + {"transport": "tcp", "addr": "192.168.1.228:8443", "priority": 20} + ] + }]"#, + ) + .unwrap(); + assert_eq!(peers.len(), 1); + assert_eq!(peers[0].addresses.len(), 2); + assert!(peers[0].is_auto_connect()); + } + + #[test] + fn config_is_leaf_only_with_tun() { + let id = generate_identity().unwrap(); + let cfg = build_config(&id.secret_hex, vec![], 0); + assert!(cfg.node.leaf_only); + assert!(cfg.tun.enabled); + assert_eq!(cfg.tun.mtu(), 1280); + assert!(!cfg.dns.enabled); + assert!(!cfg.transports.udp.is_empty()); + assert!(!cfg.transports.tcp.is_empty()); + } + + #[test] + fn listen_port_sets_fixed_udp_bind() { + let id = generate_identity().unwrap(); + let cfg = build_config(&id.secret_hex, vec![], 2121); + // Party mode keeps leaf_only — accepting a link is not routing transit. + assert!(cfg.node.leaf_only); + let TransportInstances::Single(udp) = &cfg.transports.udp else { + panic!("expected single UDP transport"); + }; + assert_eq!(udp.bind_addr.as_deref(), Some("0.0.0.0:2121")); + } +} diff --git a/Android/settings.gradle.kts b/Android/settings.gradle.kts new file mode 100644 index 00000000..06cab823 --- /dev/null +++ b/Android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Archipelago" +include(":app") diff --git a/Android/ship-companion.sh b/Android/ship-companion.sh new file mode 100755 index 00000000..cd32646c --- /dev/null +++ b/Android/ship-companion.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# +# Build the Android companion app and publish it as the served download +# (neode-ui/public/packages/archipelago-companion.apk — a plain APK a phone can +# install straight from the link), then commit + push. +# +# Use this INSTEAD of `git push` when shipping the companion app, so the +# downloadable APK on the node always matches what's on main. +# +# ./Android/ship-companion.sh +# +# The actual build/sign/verify/stage is done by scripts/publish-companion-apk.sh +# (single source of truth, shared with the pre-push hook). It does a CLEAN build, +# forces v1+v2+v3 signing, and ABORTS if any signature scheme is missing — so a +# broken or v2-only APK can never be shipped. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +export JAVA_HOME="${JAVA_HOME:-/opt/homebrew/opt/openjdk@17}" +export ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}" + +DEST="neode-ui/public/packages/archipelago-companion.apk" + +echo "==> Building + signing + verifying companion APK" +bash scripts/publish-companion-apk.sh + +[ -f "$DEST" ] || { echo "ERROR: served APK not found at $DEST" >&2; exit 1; } + +if git diff --cached --quiet -- "$DEST"; then + echo "==> Nothing to commit (APK unchanged)" +else + git commit -q -m "chore(android): update companion apk download" + echo "==> Committed" +fi + +echo "==> Pushing $(git branch --show-current)" +# SHIP_COMPANION lets the pre-push guard know the APK was just refreshed. +SHIP_COMPANION=1 git push origin "$(git branch --show-current)" +echo "==> Done — companion APK published and pushed." diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..c10242dd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1270 @@ +# Changelog + +## v1.7.125-alpha (2026-08-06) + +- **The Lightning, Bitcoin, Electrum and mesh screens work again behind the login gate.** Since the gate went up, those screens loaded their frame and then showed every number as unreachable. The gate was deliberately hiding your login from the apps it protects — right for third-party apps, wrong for the node's own screens, which need that login to fetch your data. The gate now removes only its own credential, and the node's own screens explicitly receive yours. The same mistake was also quietly signing you out of apps with their own logins — Vaultwarden, Nextcloud, Gitea — on every single request; that stops too. +- **IndeeHub heals itself.** Three separate faults fixed: its database helper was recreated with permissions too tight to read its own files (it had crashed and restarted roughly ten thousand times on one node); on another node two of its seven parts could never be recreated at all because of how the node asked for their storage — it would remove the old part and then fail to build its replacement, leaving the app half-missing forever; and a regenerated password could lock the app out of a database that keeps the original. The storage fault fixes the same trap for every future multi-part app. +- **A missing piece of a running app now gets put back automatically.** If one container of a multi-part app disappears while its siblings are still running, the node treats that as a hole to repair rather than a choice to respect, and rebuilds the missing piece. An app you actually uninstalled stays uninstalled. +- **Send and Receive open clean every time.** Whatever you typed last — an address, an amount, and above all an armed "send all funds" toggle — no longer quietly carries over into the next payment. Choosing "send all funds" also shows the amount being swept instead of a confusing 0. +- **A sweep that cannot happen now says why.** Trying to sweep a balance that is below Bitcoin's dust minimum (about 546 sats) or not yet confirmed used to fail with "check server logs"; it now explains that no transaction can be built from those coins. +- **The camera scanner option no longer vanishes on desktop.** Browsers only allow the live camera on secure (HTTPS) pages, and the scan window silently hid the camera choice on plain connections — which read as "the scanner is gone". The option now stays visible and explains itself, and the photo and paste routes always work. The companion app's built-in scanner is untouched. +- **App data folders can no longer be "repaired" into a state the app cannot use.** When the node fixed a folder's ownership through its fallback path, it wrote the container's raw user number instead of the translated one, so the fix reported success while the app still could not open its own files — one node's BotFights restarted every ten seconds over exactly this. The translation is now applied. +- Also: the app login page uses the Archipelago mark and stays centred on phones with the keyboard open, app icons in the install window are no longer cropped, and when the node fails to build a container it now records the actual reason instead of a one-line stub that hid the cause of the IndeeHub fault for days. +- Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release. + +## v1.7.124-alpha (2026-08-05) + +- **The most important fix in this release: some nodes were left switched off by their own update, and could not switch themselves back on.** The node replaces its program and then exits, expecting the system to start it again — but nodes installed from older images carried a setting that only restarts the program if it *crashes*. A clean, deliberate exit looked like success, so nothing restarted it, and the node sat dead showing "server starting" with nothing able to start it. One of ours was down for over two hours this way, and three of four checked had the same setting waiting to bite. Your node now repairs that setting itself the first time it starts, so it survives every future update. +- **Portainer opens again.** Its screen reported the app as not responding because the app was quietly refusing to start: nodes have been running Portainer 2.39.1, their stored data was written by that version, and the app list pinned a version from two years earlier — so when the container was rebuilt it landed on the old one, which will not read newer data. The correct version is now pinned, older installs upgrade cleanly, and no data was touched. +- **Bitcoin starts reliably again.** A leftover settings file in the Bitcoin folder — one the node itself kept rewriting and Bitcoin no longer reads — is treated as fatal by Bitcoin, so affected nodes restarted every few seconds forever. The node no longer writes that file, removes stale copies, and treats any that remain as harmless. +- **Every app screen opens from My Apps again.** The login gate refused to be displayed inside another page at all, which is exactly how My Apps opens an app, so protected apps appeared broken. It now allows only your own node to display it, and refuses everyone else — a distinction the old setting could not express. +- **The app login screen now looks like the node's own.** Same rotating artwork, the same panel, the Archipelago mark, and the app's real icon shown as a tile the way My Apps shows it, instead of a plain box with a letter. +- **The Mesh screen uses wide displays properly.** On very large screens it stacked all five panels on top of each other, clipping three of the headings to a sliver and squeezing the map into a letterbox — more screen producing a worse view. It now shows one panel at a time, filling the space, with the map running edge to edge. +- **You can choose how long you stay signed in.** Settings → Account now offers an inactivity timeout and a hard limit, plus an option to re-enter your password before sending funds. TV and kiosk screens are never signed out for sitting idle, because there is nobody there to sign them back in. +- Updates now come from `source.archipelago-foundation.org` rather than a bare address, with the old one kept as an automatic fallback for nodes whose clock or name lookup is off. Also included: clearer wallet errors from ecash mints, and mesh peers reconnecting via their last known address before falling back to the wider network. +- Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release. + +## v1.7.123-alpha (2026-08-05) + +- **Five more screens on your node were readable by anyone who could reach it, and the previous release's own check said they were fine.** The Bitcoin, Lightning, Electrum, FIPS mesh and Fedimint Guardian screens each answered on their port with no login. They were missed because they work differently from ordinary apps: they run directly on the node's network rather than behind its container plumbing, so there was no address to pin and their descriptions listed no port at all — and the node builds its list of what to protect from exactly those descriptions. It therefore neither protected them nor listed them as unprotected. A check that reports success while five screens are open is worse than no check, and this was found by scanning the node from another machine rather than asking the node about itself. +- **What was actually readable was the page, not your money.** Every request on those ports that could have returned a credential — the Lightning connection details, the wallet passthrough, container logs, and every node command — already required a login and still refused without one. The Lightning macaroon fix from v1.7.120 was verified directly rather than assumed. What leaked was the screen itself: layout and code, no wallet data, no keys. +- All five now serve only to the node itself, with the login gate in front of them, exactly like the twenty app screens closed in the previous release. +- **Every port on the node now has a stated policy — there are no undecided ones left.** Eleven ports previously had no instruction either way and stayed open by default. The BotFights arena, the router screen and the Pine voice screen now require the node password. The ones that genuinely cannot take a login page stay open with a written reason: Fedimint's guardian and gateway connections (federation members authenticate to the federation), NetBird's management and dashboard ports (your VPN devices carry their own credentials and cannot hold a browser session, and its dashboard needs its own certificate), Pine's secure listener, and the Lightning REST port, which wallets reach with a macaroon exactly as before. +- Fresh installs are covered too, not just existing nodes. The five screens are delivered as prebuilt images, so a newly flashed node would have come up open even after this fix. All five were rebuilt, published, and then pulled back and inspected to confirm the fix is really inside them. +- Two delivery faults fixed alongside, either of which would have silently undone the above: two of the five screens were reaching nodes through no update path at all, so edits to them never arrived; and a fourth copy of the Bitcoin screen's configuration was being rewritten on every health check, which would have re-opened that port after everything else was corrected. +- Known gaps, disclosed rather than buried: non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. Three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. The 5x real-node lifecycle gate was not run for this release. + +## v1.7.122-alpha (2026-08-04) + +- **Your apps now ask for your node password before they open — over your home network, Tailscale, the mesh and Tor alike.** Until now anyone who could reach your node could open Immich, Nextcloud, Vaultwarden, Jellyfin, Grafana and the rest simply by typing the address and port, with no login at all. Twenty app screens now sit behind the same login you use for the node, showing you which app you are opening, and honouring two-factor if you have it switched on. Logging in at an app address logs you into the dashboard too, so it is one password, not one per app. This completes the groundwork disclosed in v1.7.121. +- **The things that must stay open stayed open.** Zeus and other remote wallets still reach your Lightning node directly, Electrum wallets still connect, and Bitcoin still talks to its peers — those connections carry their own proof of identity and a login page would simply break them. Every one of these seventeen exceptions now has to state in writing why it is safe to leave open, so the list is something you can read rather than something you have to discover. +- **A private address on your node was answering the mesh without a password.** One app's port was marked as being for this machine only, and the part of the node that carries mesh traffic did not know that — it forwarded requests from the whole mesh straight to it. Found while verifying the work above on a real node, not in testing. That path now refuses anything marked machine-only, and the app is reachable only from the node itself, as intended. +- **Tor addresses no longer skip the login.** An app published as a .onion address was handed straight to the app, because a Tor visitor carries no session cookie to check. The login gate now takes those addresses first, closing the last of the four routes that went around it. +- Nodes fix themselves after this update. Apps installed before this system used its current container setup kept their old wide-open address even after the signed list told them to move, and each would otherwise have needed hand-holding on every node. Your node now notices the difference and rebuilds those apps itself, keeping their data, within about half a minute of starting. Verified by putting a node back into the old state deliberately and watching it repair. +- The node had been reading two different sets of instructions about its own apps — the signed list it downloads, and older copies on disk — which is how a port meant to stay private was briefly opened on a test node. Both now come from the signed list, and a port withdrawn from the login gate is released without needing a restart. +- **The key that signs these updates has been replaced.** The previous signing key was exposed where it should not have been, so it is treated as compromised and this release installs its replacement. This update is the last one signed with the old key, by necessity — it is the one that teaches your node the new one. +- Known gaps, disclosed rather than buried: eleven app ports still have no stated policy — BotFights, the Fedimint gateway, NetBird, the voice assistant's own screens and the router screen — and remain reachable without a login until each is decided deliberately; the node reports them rather than guessing, because guessing at an unstated setting caused both incidents behind this work. Three voice-assistant ports are still open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — will now meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release. + +## v1.7.121-alpha (2026-08-04) + +- **Making another node "Trusted" now asks for your node password.** Trust was being handed out by machines rather than by you: any node able to reach yours could join and mark itself Trusted, because the check proved only that the caller owned the key it had just presented — never that you had approved it. Trust also spread on its own, since every peer a Trusted node advertised was added as Trusted too, so one grant quietly propagated across the whole federation. Uninvited joins are now capped at Observer, advertised peers arrive as Observers, and raising anyone to Trusted — whether by generating an invite or by changing the dropdown on a node — requires your password. Lowering trust deliberately does not, because the safe action must never be the inconvenient one. Existing peers are left exactly as they are rather than silently demoted, and each one now records how its trust was granted so you can review them. +- **Nodes you have peered with can be messaged straight away.** Peering was not enough: you also had to be within LoRa radio range of the other node once before chat would work. The node picked how to send a message based on which radio was plugged in, and only one of those paths knew how to reach a peer over the mesh's internet transports — so on a node with a different radio, or no radio at all, messaging a peer you had just federated with simply failed until a radio contact happened to appear. Peered nodes are reachable without radio by definition, so that choice no longer depends on the hardware. Radio is still preferred when the other node is actually in range and the message fits. +- The dashboard no longer flickers a vertical line across its cards. A rendering seam appeared at random while moving the mouse, because the two large cards used a background-blur effect that this system already disables everywhere else on the dashboard — that browser mis-draws it inside the dashboard's animated container, and these two cards had been missed when the workaround was written. Diagnosed from a single screenshot rather than by trying to reproduce it. +- The Lightning screen will actually update from now on. Its image was set to "latest", and the container system will not re-fetch a label it already holds, so nodes kept the same Lightning screen forever no matter how many updates shipped. A separate copy of the same setting used only by brand-new installs also described the screen incorrectly, so fresh installs got a screen that never answered. +- Apps that provide their own screens stop rebuilding themselves in a loop. On this system's own node one of them rebuilt every thirty-five seconds indefinitely, burning processor time and restarting the app each round. The node decided a rebuild was needed by comparing file dates against the image's creation date, but a rebuild that changes nothing reuses the existing image and leaves that date untouched — so the condition that triggered the rebuild was still true afterwards, forever. Nodes taking this update repair themselves the first time they check. +- Groundwork you can see but that does not change access yet: the node can now tell you which of its app ports answer without a login, and every port that is deliberately open — Bitcoin's peer connections for syncing the chain, Lightning's wallet connections, the Electrum wallet protocol — now has to state in writing why it is safe, so the list of exceptions is something you can read rather than something you have to discover. The login gate that will sit in front of the rest is built and proven working end to end on a real node, but it is not yet closing any ports; that arrives with the signed app catalog that tells each app to hand its address over. +- Releases can no longer ship an unsigned update file. Signing was skippable, and when it was skipped the release was still committed and tagged — producing an update that every node correctly refuses to install. It had been caught by hand every cycle; now the release simply stops. +- Known gaps, disclosed rather than buried: the 5x real-node lifecycle gate was not run for this release. App ports other than the deliberate exceptions above are still reachable without a login — the gate reports them, and closing them needs the next signed catalog. Three voice-assistant ports are open without authentication and should not be; the correct fix puts them on a private network with the assistant instead, which needs testing on a node that runs both. Two nodes on the fleet still share SSH host keys (detection shipped, rotation remains a deliberate operator decision). + +## v1.7.120-alpha (2026-08-02) + +- **Security, and the reason to take this update: two ports on your node handed anyone who could reach them complete control of your money, with no password.** The Lightning app's port answered a plain web request with the LND admin macaroon, the TLS certificate and the node's onion address — everything needed to drain the wallet remotely, and the onion meant an attacker kept that ability even after losing access to your network. The Bitcoin app's port reached Bitcoin Core's control interface using credentials the node itself supplied on the caller's behalf, with a wallet loaded. Anything on your home network, your Tailscale network or the mesh could use either one. Both now require you to be logged in. If your node has been reachable by anyone you do not fully trust, treat the Lightning macaroon and the Bitcoin RPC password as known to them. +- The Bitcoin and Lightning app screens can no longer be published as public Tor addresses automatically. They were one app-id away from being handed a worldwide, permanent address as a silent side effect of being installed — which would have re-opened the hole above to the entire internet. Turning Tor on for them deliberately still works; it just never happens on its own. +- **Fixes shipped inside the program now actually reach apps that your system keeps running.** A container the node had been told to uninstall, but that the system service manager kept alive anyway, was quietly skipped by the part of the node that applies configuration — so it never received updates that shipped with the program. This was found the hard way: the Bitcoin control-interface fix above appeared to be installed and silently was not, while the Lightning half applied correctly, which is the most misleading way for a security fix to fail. Both halves are now proven to land on a real node. +- The Lightning and Bitcoin node screens have been rebuilt to match what umbrelOS offers. Lightning gains Overview, Channels, Activity, Insights, Connect and Settings tabs with a sats/BTC switch; Bitcoin gains Insights, Peers, Connect and Sharing. Along the way: every copy button on those screens silently did nothing (the browser blocks clipboard access inside an embedded page) and now works; the channels link led to a dead page; and Node ID showed a bare key instead of the full address someone can actually connect to. +- Updates to the Bitcoin screen show up without a hard refresh. The page was being cached by the browser, so a freshly updated screen kept rendering the previous one. +- The AI sidebar loads again. It was asking for its program files at an address that pointed at the main app's files, where they do not exist, so it silently loaded nothing. +- The navigation above the bottom bar no longer follows you between screens. Back buttons and the mesh tab bar stayed pinned over every other page once you had visited the screen that owns them. Keeping tabs loaded in the background — the change that made switching between them instant — means leaving a screen hides it rather than destroying it, and this floating navigation sits outside the screen it belongs to, so it was never being hidden with it. It is now tied to whether its own screen is on display. The speed is unchanged: the screens are still kept loaded, so returning to one is still instant. +- Wallet: Lightning actions are now offered based on whether you actually have a usable channel rather than just a running node, sending is gated the same way, and an invoice you cannot yet receive offers to install a Lightning node instead of simply failing. +- Onboarding and viewing fixes: the "I have written down my recovery words" tickbox is findable on short screens, paid pictures and videos open in the app's own viewer with a visible loading state instead of a blank browser tab, picture-in-picture survives changing tabs, and the FIPS/Tor labels on peer cards stay put instead of wrapping into the card below. +- Key-material hardening across the node: a node that is already set up refuses to have its identity replaced by an unauthenticated request; first-boot secret generation now fails loudly instead of silently continuing with shared keys; the node proves its TLS certificate and key are actually a matching pair; the Bitcoin Core wallet path that kept a second copy of your spending key outside the encrypted store has been removed; and every place the node generates a key, token or nonce now names its source of randomness explicitly, enforced at build time. +- Federation and mesh: a rotated gateway credential now reaches the already-running container instead of leaving the old one in place, sync failures are surfaced to you instead of being swallowed, and nodes can share their Lightning connection details with a chosen peer over the mesh — the groundwork for opening channels with nodes you already talk to. +- Known gaps, disclosed rather than buried: the 5x real-node lifecycle gate was not run for this release. Two nodes on the fleet still share SSH host keys with each other (detection shipped, rotation is a deliberate operator decision and has not been performed). Bitcoin Core can now reach Tor from its container, but is not yet routed through it — the network mode is becoming a setting you choose, and until then Core's peers remain on the clear internet. + +## v1.7.119-alpha (2026-07-31) + +- Wallet payments now work on nodes whose channels are private/unannounced. Every invoice-creation call site — the wallet's own Receive flow, and the seller-side paid-content/peer-files flow — only ever sent LND the amount and memo, so LND defaulted private to false and returned invoices with no route hints. Any node whose only usable channel is private or unannounced (the common shape for a channel someone opened to you) was silently unpayable through the wallet, and unpayable through paid file/content sales too. Both call sites now set LND's private flag correctly; this was broken in the field and is the main reason for this release. +- Tor and the mesh's Tor fallback are reliable again. The node's background "doctor" health-checker was fighting Tor over the permission bits on its own hidden-service directory: it compared the directory's mode against the literal string "700", but Tor's own setgid hidden-service mode is 2700 — a value the doctor's check never recognized as correct. Every ~5 minutes it "corrected" the mode back to 700 and restarted Tor to apply it, and Tor immediately reasserted 2700 — a permanent restart loop that meant Tor could never hold onto its consensus/HSDir cache long enough to be useful, breaking the mesh's Tor fallback path entirely. The check now compares only the owner/group/other bits that actually matter (both 700 and 2700 pass; genuinely wrong modes like 750 or 2755 are still corrected and restart Tor), plus a 30-minute restart backoff so no future condition can reproduce the storm. +- Wallet balances and your node's own FIPS identity key (npub) are no longer written to the browser's sessionStorage — caught by an audit of the page-caching work below. Every cache call site in the app now makes an explicit, reviewed decision about whether its data is allowed to persist across a reload, and a one-time migration purges any legacy, unaudited snapshot left behind by an older build. +- Server, Home, Mesh, Chat/AI chat, and the secondary screens (app details, marketplace, cloud, federation, monitoring, router/OpenWrt) now load instantly from cache when you revisit them and refresh quietly in the background, instead of blanking and re-fetching everything on every tab switch — this closes out the page-performance work started back in v1.7.116/117. +- App updates (including this one) now apply automatically in the background instead of waiting on a tap-to-update prompt, matching how kiosk/TV installs already behaved — the reload still waits for any in-progress splash/dashboard animation to finish first, so it won't land mid-motion. This was a direct, explicit decision made with the mid-payment-reload risk spelled out in advance; reverting to a confirmation prompt for beta is a one-line change if wanted later. +- Known gap, disclosed rather than buried: the project's 5x production lifecycle gate (install/UI/stop/start/restart/reinstall/reboot-survive/archipelago-restart-survive/uninstall, run on a real node — CLAUDE.md's own definition of done before a release tag) was NOT run for this release, because its target node was unreachable and running it here would have required rebooting a shared, live build machine out from under other active work. This release's own automated gates (release-gate harness, strict catalog-drift check, the full cargo test suite, a mount-level ISO smoke test, and a headless QEMU boot test) all still ran and passed — this is specifically about the separate 5x real-node lifecycle gate, which is still outstanding and should be run as soon as the node is reachable again. + +## v1.7.118-alpha (2026-07-29) + +- Fixes mesh radios dropping off on nodes that took the v1.7.117 update. Updates only ever replaced the main program, never the packaged radio helpers — so updated nodes were left running an older radio daemon that didn't understand a new option and quietly gave up, showing "device not connected" with a Connect button that did nothing. The node now checks what its radio daemon supports before using new options, and updates finally carry the radio helpers themselves, so every node gets current radio support with the update instead of only from a fresh install. +- The in-app "Flash LoRa" flow works on updated nodes. The RNode flashing tool was only ever included on freshly installed nodes; everywhere else flashing failed with a cryptic "No such file or directory". The tool now ships with updates and is included on new install images, and if it's somehow still missing the error says exactly what to do instead. +- Message notification badges finally remember what you've read. Unread counts were only kept in memory, so every visit re-counted old messages as new — including a phantom badge for chats with nothing new in them. Read-state is now saved on the device, opening a chat marks all its linked conversations read, and history no longer re-badges after a reload. +- Every mesh message now has a visible "⋯" button that opens the route view: watch the path your message took animate — sender and receiver appear, a pulse travels the link, and each relay hop lights up in order — with signal quality for radio links and delivery status. (Tapping the transport pill still works too.) + +## v1.7.117-alpha (2026-07-29) + +- Flash your LoRa radio from inside the app. The Mesh page now has a "Flash LoRa" button that opens a guided flow: pick the firmware family (MeshCore, Meshtastic, or Reticulum RNode) and your board, and the node downloads the latest release and flashes it with live progress — no external flasher website, no cables to a computer. The same flow appears when a freshly plugged-in radio is detected, and a long list of flashing pitfalls was fixed along the way: radios no longer boot-loop after a flash, failures show the real error instead of silently bouncing back, wedged flash jobs can't get stuck forever, and board auto-detection no longer misidentifies Heltec boards. +- Every Archipelago node now acts as a Reticulum relay. Nodes forward mesh traffic and re-broadcast peer announcements, so two radios that can't hear each other directly can still discover and message each other through any Archipelago node in between — your nodes become infrastructure for the whole neighbourhood mesh, including non-Archipelago apps like Sideband. +- Reticulum (RNode) radios are now first-class mesh citizens. Radios are reliably detected on node startup (a boot-timing race used to leave them unclaimed), settings changes apply live without a restart, your node's name propagates over the Reticulum network so other apps like Sideband see it properly, and a crashed Reticulum daemon is detected and restarted automatically. Photo and file attachments sent over Reticulum now actually arrive — four separate delivery bugs were found and fixed, verified end-to-end over real radio hardware. +- Messages to contacts that exist on both the internet mesh and a LoRa radio now prefer the radio when it's live, and attachments follow the same path — so co-located nodes talk over the air even when the internet path exists. +- Mesh chat polish: each message in the image viewer shows which transport carried it, a new hop-route view shows the path a message took, reactions moved into a tidy dropdown, and read-tracking now reflects what you've actually seen. The Refresh and Broadcast buttons give real feedback, and the radio-setup modal shows honest probe progress instead of freezing. +- The wallet transactions list works properly on phones now: it scrolls (it silently couldn't on touch screens before), and the All / On-chain / Lightning / Ecash filter tabs stay pinned at the top with a subtle blur while the list scrolls underneath. +- Backend services no longer masquerade as launchable apps. Anything without a real web interface — databases, APIs, background workers, including stacks you deploy by hand for testing — now files under Services with no Launch button. Apps declare their interface in their manifest; for everything else the node checks the port itself to see whether a browser page actually lives there. +- Lightning payments that take a while (slow multi-hop routes) are no longer reported as failed while they're still in flight. The wallet now waits properly, shows an honest "pending" state, and reports the true final outcome. +- Server pages feel instant: Server, Federation, Lightning channels, Monitoring, wallet, Cloud, and Credentials screens now render immediately from a shared cache and refresh live in the background (including push updates over the node's websocket), instead of blanking while every panel refetches. +- The node stays responsive under heavy load: the connection handler now sheds excess load instead of stalling everything behind it, and companion-app probes no longer trigger container builds during routine checks. +- FIPS mesh uptime hardening continues: the node's peer port is opened explicitly everywhere, LAN anchors use the right port, direct peering between co-located nodes works again, dials fail fast instead of hanging, and a connectivity watcher re-applies anchors immediately when the network comes back. +- FIPS startup is more reliable on nodes that have the packaged `fips.service` instead of Archipelago's `archipelago-fips.service`. Startup self-heal, onboarding, dashboard Start, and reconnect now use the systemd unit the node actually has, so FIPS no longer looks like it needs to be installed when it only needs to be started. +- App screens over the FIPS mesh now bind their relay only to the node's FIPS address instead of reserving the same host ports Podman needs. This keeps apps such as FileBrowser and Botfights from restart-looping because the backend was already holding their published ports. +- Companion app 0.5.25: a redesigned settings hub (three-finger tap opens it over the dashboard), seamless transport handoff with FIPS mesh settings, the wallet scanner reads dense invoice QR codes, app webviews clear the phone status bar with an HTTPS toggle on add/edit, and off-LAN loads fall back to the mesh URL instead of a dead LAN address. +- Public-source preparation now includes a Nostr Git hosting plan using `ngit`, NIP-34, and GRASP: anyone can clone, fork, review, and propose changes from their Archipelago node, while canonical merge authority stays with a small signed maintainer set in the style of Bitcoin Core. + +## v1.7.116-alpha (2026-07-27) + +- Nodes no longer get stuck on "server starting up" after an update or reboot. On a node running many apps, the backend used to spend minutes recovering containers before it told the system it was ready, and anything that touched it during that window could leave it down for good. It now reports ready immediately and recovers in the background, and it always restarts itself if it ever does go down. +- Installing apps no longer crashes the node. A change that made app screens reachable over the mesh was accidentally holding onto every app's network port in advance — so installing an app like Grafana, Photoprism, Uptime Kuma, or Jellyfin collided with it and the port-cleanup step took the whole backend down, rolling the install back. Installs are now clean and the backend can never be caught by that cleanup. +- Rolls up everything from v1.7.115: app screens and the dashboard load over the mesh out of the box (firewall openings shipped automatically, IPv6 support end to end), and nodes rejoin the mesh in seconds after their rendezvous point restarts. + +## v1.7.115-alpha (2026-07-26) + +- The companion app can reach your node's screen from anywhere again. The recent security hardening locked down the node's mesh interface so tightly that the dashboard itself was blocked — the phone would pair and connect, then sit on a blank screen. The node now explicitly opens its own web interface (and only that) through the mesh firewall on every install and upgrade, so the phone's view of your node works out of the box, on any network, and can't silently break in a future update. +- The node's web interface also answers on IPv6 everywhere it answers on IPv4 — the mesh runs entirely on IPv6, and one v4-only listener was enough to make a working connection show nothing. +- Nodes now come back onto the mesh in seconds instead of minutes after their rendezvous anchor restarts: the fast-reconnect tuning proven on the phone this week is now baked into every node's mesh configuration, and it survives upgrades. + +## v1.7.114-alpha (2026-07-26) + +- Plugging in a mesh radio no longer traps it in an endless reboot loop. The device detector itself was causing it: every scan pulsed the radio's reset line, the same board was probed twice under two names, and retries came so fast the radio never finished booting before the next reset hit. Detection now gives the board real time to boot, probes it once, backs off properly between attempts, and no longer fights the "device detected" popup for the port. Radios that could never connect now come up within a minute of being plugged in. +- The Lightning channels screen now has All / Active / Pending / Closed tabs. Pending gathers everything in motion (opening, closing, force-closing — each with its own status dot and a link to the closing transaction), and Closed is a real history: how each channel ended, what settled back to you, and the closing transaction for each. +- Sending bitcoin on-chain now puts you in charge of the network fee: pick Fast, Standard, or Slow (Standard is the default), or set your own target blocks or sats-per-vByte. The confirmation step shows the estimated fee for your chosen speed before any money moves. +- Type on-chain amounts in whichever unit you think in — a sats/BTC switch on the amount field converts as you type. +- Back up your seed by scanning it. Every recovery-phrase screen (onboarding, Settings, and the Lightning wallet seed) now has Words and QR code tabs — words always shown first. The QR for your node's recovery phrase uses the SeedQR standard, so hardware wallets like Passport Prime, SeedSigner, and Keystone can import it with a single scan (a plain-text option remains for wallets that read the phrase as text). The Lightning seed's QR is plain text with an honest note: it's an LND-format seed that restores into Lightning wallets like Zeus or Blixt, not into hardware wallets. + +## v1.7.113-alpha (2026-07-25) + +- Fixed a money bug in Cashu ecash sends: the token you handed a recipient could carry your own change proofs along with it, letting the same sats be credited twice. Change now stays in your wallet — only the amount you meant to send leaves it. +- Closing a Lightning channel is no longer a leap of faith. The close used to hang (or time out with an error) even though it had actually gone through; it now comes back within seconds with the closing transaction ID. Channels mid-close appear in the channel list as Closing or Force-closing with their transaction attached, and a new closed-channels history keeps past closes visible instead of letting them vanish from the list. +- The wallet card now leads with your total bitcoin across everything, and the on-chain balance gets its own chain icon so the rows read at a glance. +- The companion phone app (0.5.15) connects dramatically faster away from home: a cold connect over 5G dropped from 40+ seconds to about 5. First connects no longer stall on unreachable mesh dial hints, fresh joins fail fast and retry instead of waiting out long timeouts, and the phone re-announces itself the moment the network around it changes. The node side's mesh-join handling was hardened to match. + +## v1.7.112-alpha (2026-07-23) + +- Sound works on TVs out of the box. Fresh installs were missing the audio system entirely, and even when present a boot-time race left HDMI silent until the cable was unplugged and replugged. Both are fixed: installer images now ship the full audio stack, and a small background helper detects the silent-HDMI state and heals it automatically. +- Plug in a game controller and drive the whole TV interface with it — navigation, menus, and media playback all respond to the gamepad, and dialogs that pop up are controller-navigable too. +- The companion phone app took a huge leap (0.5.9). Your node and its apps now work from anywhere — on 5G or any internet connection, the phone reaches the node over the encrypted mesh with zero port forwarding or VPN setup. Startup away from home is instant, apps on your node open inside the app, the phone's native camera handles QR scanning, and a branded full-screen loader shows while the mesh connects. +- Mesh Party: two phones scan each other's QR and instantly get a direct encrypted chat and app sharing between them — plus a "Share this app" QR that anyone can scan with a normal camera to install the companion app. +- Pairing a second phone no longer silently logs out the first. Every device now keeps its own named access credential, ending the mystery reconnects when a household paired more than one phone. +- The companion pairing QR is scannable again (it had grown too dense for phone cameras) and now identifies your node by its identity key, so the app recognizes your node even after it moves or gets a new address. +- Every app your node serves on your home network is now also reachable over the mesh — remote access covers the apps themselves, not just the dashboard. +- Selling files: you now choose which payment methods you accept (Lightning, ecash, …) and buyers are only offered those — enforced by the node itself, not just the buttons. Paying twice for the same file is impossible now, purchases file themselves into a new Paid Files tab, purchased music always plays in the bottom-bar player, and videos get picture-in-picture. +- Sending Lightning is invoice-first: paste or scan an invoice and the amount fills in and locks by itself. An expired invoice now tells you plainly to ask for a fresh one instead of failing cryptically, and payment errors always reach your screen. +- Sending to a pasted address gets a confirmation step showing exactly what will happen before any money moves, and buying ecash is an explicit two-step — no more accidental purchases. +- Apps keep running when you change how they're displayed. Switching an app between windowed and fullscreen used to reload it from scratch (stopping any playing media); the app now stays live through the switch, and each app remembers its own preferred display mode. +- A watchdog notices when the Lightning (LND) node wedges and revives it before you do; Fedi ecash gets its own send option with scannable token QR codes. +- Fedimint's Lightning gateway and guardian now follow whichever bitcoin version is actually running instead of pointing at a stale address — switching bitcoin versions no longer strands them. +- If your router starts handing out different addresses, the Pine voice speaker re-links itself automatically instead of staying silent until someone re-configures it. +- Polish: mesh radios never show garbled device names anymore, the TV kiosk uses slim overlay scrollbars instead of fat grey bars, and the AI chat's background artwork shows through again. +- Your mesh messages now survive restarts. Chat history — channels and DMs alike — used to live only in memory, so a reboot or update wiped every conversation; worse, other nodes silently discarded the first messages you sent after a reboot. Everything is now saved on the node and restored on startup, and post-reboot messages deliver reliably. +- Plug in any LoRa radio and the node walks you through it. A setup window appears every time a radio is connected, shows what firmware is already on it (MeshCore, Meshtastic, or Reticulum RNode — with its current name, region, and channels where available), and offers two honest choices: "Set Up with Archipelago Settings" (a preview screen shows exactly what will be written before anything touches the radio) or "Keep As Is" (the radio is used untouched, and you can hot-swap radios freely). Swapping sticks mid-session now just works — including Reticulum RNodes, which fresh installer images now support out of the box. +- Incoming bitcoin appears in your wallet within seconds of being sent — balance and the yellow "unconfirmed" entry update live, no refresh, no waiting for the next poll. +- The speaker now announces the very first mesh message a node ever receives, and DMs announce just like channel messages (a safety guard against announcement storms was quietly swallowing them). Announcements also react about twice as fast. +- Opening a Lightning channel right after the node starts no longer fails with a scary red error. The node quietly retries while Lightning finishes waking up, and if it's still not ready you get a calm "still finishing its startup — try again shortly" notice instead. +- Viewing a transaction works on every node now, including small ones. Nodes with pruned bitcoin storage can't run the Mempool explorer app; transaction links now open your choice of external explorer instead (tx1138.com by default) — after a clear one-time warning that a third-party server will see which transaction you looked up. Set your preferred explorer in Wallet Settings → the new On-chain tab. +- Voice commands respond noticeably faster: speech recognition now transcribes in roughly half the time, with identical accuracy on short commands. +- Scanning a Lightning invoice with your phone's camera is far more reliable — dense invoice QR codes that the photo scanner missed are now read by the phone's native barcode engine. +- The companion app's pairing QR always contains an address your phone can actually reach. If you manage your node over a VPN (Tailscale), the QR used to embed the VPN address, and pairing silently failed; it now advertises the node's home-network address. +- Peer requests sent from Nostr discovery now actually arrive: your node checks for incoming requests every five minutes by itself (previously they sat unseen until someone manually pressed "Poll"), requests publish to all your configured relays instead of two hardcoded ones, and a failed send tells you instead of pretending it worked. +- The Connected Nodes list refreshes instantly. It previously froze for up to 30 seconds per offline peer while checking who's reachable, one peer at a time; the checks now run all at once in the background while the list shows immediately. +- Apps opened from inside a window (like a transaction from the wallet) now animate smoothly on top instead of loading invisibly underneath. +- Settings-style windows keep their tabs pinned at the top and their buttons pinned at the bottom; only the middle scrolls. The wallet's tabs are now Channels / Cashu / Fedi / Ark / On-chain so all five fit. +- On the TV screen, menus no longer flash open and instantly close. And the interface never follows your computer's light/dark preference anymore — dropdowns and other native controls stay dark on every device. +- Error messages tell you what's actually wrong: "Insufficient balance: need 80 sats, have 0 sats" now reaches your screen instead of "Operation failed. Check server logs." +- Installing Mempool no longer refuses to start while ElectrumX is mid-resync (it connects by itself once ElectrumX is ready), and installs no longer fail just because the system was momentarily busy. +- Much quieter logs: the node no longer tries to start containers that are already running (hundreds of harmless-but-alarming errors per day), and a node that's offline stops hammering unreachable servers every 30 seconds with rebuild attempts. +- Phones pairing with the companion app connect over the node's embedded mesh for remote access, with instant QR pairing and per-device access tokens (contributed alongside this release). + +## v1.7.111-alpha (2026-07-22) + +- Ask your node anything, out loud. Install Pine (the voice assistant app) alongside Home Assistant and everything wires itself automatically: speech recognition, the speaking voice, and a Claude-powered brain. Questions about your node — "what's the block height?", "how many peers am I connected to?", "is bitcoin synced?", "what's my Lightning balance?" — are answered instantly from the node itself without costing anything; anything else goes to Claude for a real conversation. New mesh radio messages are read out on your speaker as they arrive. +- Pine now ships a wake-word listener, so a paired speaker can sit on standby and activate when it hears its wake word instead of needing a button press. (A custom "Yo Archy" wake word is in the works.) +- Pine's launcher page shows your node's live status at a glance: software version, uptime, bitcoin sync progress, and mesh peers. +- Fixed: installing Pine could send Home Assistant into a crash loop on startup (a record the installer wrote was missing a timestamp field Home Assistant requires). Two noisy warnings that repeated in Home Assistant's log every half minute are silenced too. +- The companion phone app opens every app in its fast built-in browser view again, with native back/forward/reload controls, instead of embedding some apps inside the page where they scroll and render worse. This had quietly regressed. +- Turning on federation discovery now shows you exactly what you're about to sign: a panel explains the announcement before your key signs it, you can review the signing details any time from the discoverability strip, and the panel fits and scrolls properly on small phones. +- Fixed a bug on nodes using the newer app-management engine where Bitcoin's access credentials were written out incorrectly (a placeholder leaked through as the literal text "/bin/bash"), which broke the node's Bitcoin status display, Lightning's connection to the chain, and any app that reads Bitcoin data. +- Bitcoin's access credentials also moved out of the process command line into a protected file, so they're no longer visible to other software on the node. +- Desktop app windows have one-click buttons to switch between side panel, overlay, and fullscreen viewing. +- On the phone home screen, the wallet card moved up to sit right under My Apps. +- Home Assistant updated to 2026.7.3, which keeps voice satellites (like Pine's speaker) connected reliably. + +## v1.7.110-alpha (2026-07-21) + +- Pay by pointing your camera: the wallet has a new Scan button (on the wallet card and inside both the Send and Receive windows) that reads any payment QR code — Lightning invoices, Bitcoin addresses, Cashu tokens, and Fedimint invites — and takes you straight to the right send or redeem screen with everything filled in. It also understands the animated, multi-part QR codes some wallets show for long payloads. If your browser can't open a live camera preview (common when reaching the node over plain http), a "Take photo of QR" button snaps a picture with your phone's camera and reads the code from the photo instead. +- The TV screen got a complete overhaul. A deep bug made the display freeze on the intro artwork on 4K TVs — that's fixed, and along the way: the interface now picks a comfortable, sharp size for big screens (a 4K TV gets a full desktop layout at double sharpness), the artwork behind every page shows again instead of a black void, switching between tabs animates smoothly, the built-in AI assistant stays in its dark theme, and the Cashu and Ark wallet icons no longer render as empty squares. +- You can now choose how big the interface renders on your node's attached screen: Settings → Display offers Auto (recommended), Large UI, Balanced, and Native — changing it applies immediately. +- The companion phone app can steer the TV again. Remote input from the phone was being silently ignored on kiosk displays; the remote-control relay now runs there like everywhere else. +- The companion app is also ready to grant its built-in browser camera access, so the wallet scanner can work inside the app (ships with the next companion app build). +- Zero-amount Lightning invoices can now be paid: the wallet asks you for the amount and sends it along, instead of failing on invoices that leave the amount up to the payer. +- The Lightning setup guidance now reads the same everywhere: "Open a channel with Zeus Olympus node and start sending and receiving Lightning payments. Minimum 150,000 · maximum 1,500,000 on-chain sats required." +- Installer images now bundle a color-emoji font, so emoji anywhere in the interface render properly on the TV screen. + +## v1.7.109-alpha (2026-07-21) + +- Meet Pine, your node's voice assistant: a new app in the App Store that gives your node ears and a voice — speech-to-text and text-to-speech engines that run entirely on your own hardware, ready to wire into Home Assistant for private, offline voice control. Install it like any other app; nothing you say leaves your node. +- Your node can now program its MeshCore radio's RF settings — frequency, bandwidth, spreading factor, and coding rate — from Mesh → Device settings. Radios that were flashed with mismatched settings could hear that other radios exist but never decode their messages, and until now the only fix was a separate phone app. Set the values once and the node programs the radio automatically (it restarts once to apply); every radio on your mesh must use the same values to talk to each other. +- The Device settings panel is tidier: values you can edit (name, region, channel) are no longer also shown as separate read-only rows. + +## v1.7.108-alpha (2026-07-20) + +- Your node connects to the private mesh far more reliably. Nodes rely on a public rendezvous point to find each other, and the only one available was unreachable from many home and office networks — leaving some nodes unable to join the mesh at all. There is now a second, always-reachable rendezvous point, and your node tries every one it knows, so it joins the mesh in seconds instead of being stranded. +- Wi-Fi setup now heals itself on older nodes. Some nodes set up before a mid-year fix couldn't connect to a Wi-Fi network from the screen — it failed with a permissions error — because the piece that lets the node manage networking on your behalf was missing. Nodes now put that piece in place automatically on startup, so "scan, pick a network, type the password, connect" works without reinstalling. +- Your node rejoins the mesh within seconds after an update. Applying an update briefly restarts the mesh service, and previously a node could sit disconnected from other nodes for up to five minutes before it retried. +- The TV screen now fits your television. On a large or 4K TV the interface rendered tiny with no way to zoom on a keyboard-less screen; it now sizes itself to a comfortable, readable scale automatically (and small laptop panels are left unchanged). +- More TV-screen polish: the built-in assistant shows its dark theme instead of bright white panels, the on-screen hint for switching between the kiosk and a terminal now points at the right keys, the welcome logo no longer occasionally renders as garbled characters, and an accidental tap of the power button no longer shuts the node down — hold it to power off on purpose. +- Behind the scenes: fixed the installer image build so it no longer stops on a component that was removed from the product, and so it correctly includes the private relay it was meant to bundle. + +## v1.7.106-alpha (2026-07-20) + +- Nodes on the same network now find each other directly. Your node announces itself on your local network and connects straight to other Archipelago nodes nearby, instead of every connection having to be introduced by a public rendezvous server out on the internet. Peers in the same home or office stay connected to each other even when that server is unreachable, and they reach each other faster. +- On a phone, the peer files screen tells you how you're connected again. The badge showing whether a peer's files are arriving over the fast mesh or over Tor was only visible on desktop — on narrow screens it disappeared entirely. It now appears next to the peer name on mobile too. +- Your node's mesh settings can no longer be written in a way that breaks the mesh. The configuration file used to be assembled as free-form text, where one wrong setting would stop the mesh service from starting and quietly drop your node off the network. It's now generated from a checked description of the file, with tests that verify the exact output. +- When your node has trouble reaching another node, the logs now record the real reason instead of a generic summary. A failure to open a peer's files previously logged only "Failed to connect to peer" and threw away the actual cause, which made these problems very hard to diagnose. Nothing changes on screen, and no internal detail is exposed. +- Behind the scenes: the installer image now builds its mesh component at a fixed, known version instead of whatever upstream had published that day, so two images built from the same source are identical. + +## v1.7.105-alpha (2026-07-20) + +- Fixed a failure loop where a node that lost power or was moved could get stuck on a blank "can't reach your node" screen forever: startup recovery no longer spends minutes retrying containers that no longer exist, and a genuinely large recovery is no longer cut off half-way and forced to start over. The node now reaches its login screen even after the messiest shutdown. +- Phone tunnel setup (WireGuard) is now dependable: the QR screen automatically retries while a fresh install is still settling instead of dead-ending at "failed to fetch", and if your node has moved to a different network the QR and downloadable config now carry the node's current address instead of the old one. +- Fixed the white screen some laptop displays showed right after the intro on v1.7.104. +- The companion phone app no longer suggests installing the companion app from inside itself. +- The Tor page now lists onion addresses only for apps you actually have installed — fresh installs no longer come with six pre-made addresses for apps that were never set up. +- Running `archipelago --version` or `--help` on the command line now prints and exits instead of silently starting a second copy of the node, which could briefly disrupt running apps. +- Behind the scenes: installer image builds now stop loudly if VPN components are missing instead of producing a broken image, and a background file-permission sweep runs far less often, reducing disk churn on busy nodes. + +## v1.7.104-alpha (2026-07-19) + +- Software updates are now much safer to receive: the node will never install an update that isn't completely downloaded and verified byte-for-byte, closing a rare bug where an interrupted or cancelled download could leave a node unable to start. +- If a freshly installed update does fail to start, the node now notices and automatically restores the previous working version by itself — no manual rescue needed. +- The Electrum server now works with whichever Bitcoin you run: it finds Bitcoin Knots or Bitcoin Core automatically instead of assuming Knots. +- While the Electrum server is first building its index, its waiting screen now shows the ElectrumX app icon and live progress. + +## v1.7.103-alpha (2026-07-18) + +- Connecting from the phone app no longer replays the intro cinematic on a loop: signing in after scanning the pairing QR could accidentally trigger "Replay Intro" instead of logging you in. The companion app now lands you straight on your dashboard, and the Android app waits for the login screen to be ready before it types your password. +- Pressing Enter in any password box now does what you expect — it signs you in or moves to the next field, and can no longer "click" a nearby button by mistake when using a controller or the companion app. +- The public demo no longer interrupts you with an "Update Available" popup that reset the site back to the intro — demo visitors simply get the newest version on their next visit. + +## v1.7.102-alpha (2026-07-17) + +- The password you choose during setup is now truly your node's password: it also becomes the system login for console and SSH access, instead of leaving the factory default in place. If you ever renamed your node and the TV screen went black on the next boot, that's fixed too — renaming no longer breaks the kiosk display. +- Setting up Lightning is now a guided journey: a fund-your-wallet step that shows a live countdown while Bitcoin syncs, suggested channels you can open straight into the Zeus mobile wallet with one tap, and a "finish setup" prompt that walks you to the end — goals now complete when you've actually done the steps, not just when apps happen to be running. +- Pair your phone by pointing it at the screen: the companion app now connects by scanning a QR code — scan, and it fills in your node's address and logs you in. The App Store has a banner to grab the Android app, and the pairing flow can now also set up secure remote access so your phone reaches home from anywhere. +- First installs are far more dependable: app downloads that stall now retry instead of hanging forever (the old "first install fails, the second works" pattern), big multi-part apps show their real download progress instead of sitting at "Preparing", Lightning no longer fails its first install over temporary hiccups, and a brand-new node now comes up with its core apps — file cloud and ecash wallet — even with no internet connection. +- The installer image is about 160MB smaller and gets to a working screen faster, because the apps bundled for offline setup are now compressed. +- The first-run experience keeps its magic: the typing intro is back on fresh installs and can no longer be cut short by a mid-play refresh — updates now politely wait for the cinematic to finish — and dark backgrounds stay dark instead of flashing black or white. +- Your backups now include your secrets — including the key that protects your Lightning wallet's recovery seed — and there's a Download button to take a copy off the node; the seed-backup reminder now actually opens the backup flow when you tap it. +- Networking Profits grew into a full dashboard, network cards keep their action buttons in reach on every screen size, "Connect to Mesh" goes to the right page instead of a dead end, and the identity pages got a round of mobile polish. +- Behind the scenes: apps that report their own health are no longer second-guessed by a port probe (fewer false "restarting" states), and pressing arrow keys or a gamepad is once again the only thing that shows the controller focus ring. + +## v1.7.101-alpha (2026-07-15) + +- The wallet speaks Ark: a new Ark tab shows your Ark balance and history, you can send and receive over the Ark protocol, pay Lightning invoices from your Ark balance, and Ark payments appear in the transactions view with their own filter chip. +- Every app you install now automatically gets its own private .onion address — your apps are reachable over Tor a few seconds after install, with no manual "Add Service" step. +- "Add Service" in the Tor panel now works for every app, not just a fixed list — the node reads the app's actual web port, so apps like Gitea, Jellyfin, Nextcloud, and Uptime Kuma no longer fail with "see server logs". +- Renaming your node now genuinely renames it everywhere: the machine's hostname, its .local network name (re-announced immediately), the local hosts file, and the HTTPS certificate all follow — so http and https links using your node's name keep working right after a rename. +- The node no longer mistakes a VPN tunnel for its own address. On fresh installs with NetBird, apps could launch on an internal 10.x address instead of your LAN IP; the node now reads its address from the actual network route, fixing app launch links, generated app configs, and VPN setup. +- Your cloud got a real layout: Apps-style tabs with categories for Folders, My Files, and Peer Files, readable file rows, a search that also finds files shared by your federated peer nodes — and music now opens in the bottom-bar player instead of a broken preview window. +- The first-login experience flows again: the dashboard entrance animation is back — and you can actually hear it now (its sound was silently swallowed before, including on replays) — "Replay Intro" in Settings actually replays it, opening a direct link to an inner page no longer detours through the splash screen, the login screen keeps the intro video until your first login (switching to rotating backgrounds after), and the intro video streams three times lighter so it starts instantly. +- Changing DNS settings no longer blanks the page, and the DNS and WiFi dialogs now cover the whole app instead of only the right panel. +- The public demo is richer and truer: the intro plays on every fresh visit, Ark wallet flows, working DNS and Tor service management, and a library of peer content with previews that never break. +- Assorted fixes: failed installs clean up after themselves properly, and the transactions view fits mobile screens (capped at 60% of the visible viewport). + +## v1.7.100-alpha (2026-07-14) + +- Bitcoin now supports multiple versions of both Bitcoin Core and Bitcoin Knots: install the version you want, switch between them, pin a version, or let it auto-update — and switching is designed to be safe, with no surprise resyncs. +- Lightning grew up: your LND wallet's recovery seed is captured at setup and kept as an encrypted backup you can reveal from Settings, there's a new Channels tab with a fee control when opening channels, and on-chain and Lightning balances now show side by side. +- Installing Lightning (and other Bitcoin-dependent apps) on a fresh node no longer fails repeatedly — the node now waits until Bitcoin is genuinely ready to answer before starting them, and Bitcoin sizes its storage to your actual disk and its memory cache to your RAM, so small machines stop swapping and stalling. +- The wallet understands more money: Cashu v4 tokens are supported, you can pay for a peer's files from either your Cashu or Fedimint ecash, and the Transactions view now shows your Lightning, Cashu, and Fedimint activity together — with a payment confirmation screen and an automatic refund if a purchase fails. +- Mesh radios got a major upgrade: Meshtastic direct messages are now true end-to-end-encrypted radio messages that interoperate with off-the-shelf Meshtastic phone apps, your radio's region and a shared channel are provisioned automatically, and a new setup window appears when a radio is plugged in — with board pictures, full radio settings, and signal-strength indicators. +- Reticulum joins as a third mesh radio protocol with RNode LoRa hardware support, including sending images and voice messages over the radio — and every chat message now carries a small pill showing how it travelled (Mesh, FIPS, or Tor). +- Your node can manage an OpenWrt router: set up its internet uplink from the UI with a Wi-Fi network scan, turn it into a TollGate pay-for-Wi-Fi hotspot with a real captive portal, and sweep the router's earnings into your node's wallet. The gateway's status appears on the Home screen's Network tile. +- Peering is now trust-aware: "Invite a Peer" grants view-only Observer access while "Link Your Nodes" grants Trusted access, incoming requests ask for your confirmation with an optional message, Node Visibility is a single clear switch plus a list of discoverable nodes you can peer with, and the Fleet view shows your trusted nodes' health. +- Updates and apps are verified end-to-end: release updates are cryptographically signed and checked against a key baked into your node, app definitions arrive via the signed catalog, and container images are checked against trusted sources before anything installs or runs. +- Dozens of reliability fixes: failed installs no longer leave phantom app cards, uninstalling can't hang forever, apps you stopped stay stopped, crashed apps heal themselves (even "running" containers whose process actually died), the login page no longer refresh-loops, and the mobile layout fits real phone screens instead of hiding the last row behind the browser bar. + +### Also in this release + +- Ask your node things over the radio: send "!archy" for node status with no AI involved, or "!ai <your question>" in a direct message for an AI answer that comes back on the same path it arrived — with a model dropdown (Haiku, Sonnet, or Opus) and an "always allow" list in the Mesh AI Assistant panel. +- The off-grid mesh radio no longer posts cryptic identity codes ("ARCHY:") to the shared public channel every minute. +- Mesh contacts take care of themselves: new radios you hear are added automatically, "Clear All" really removes contacts (they return when in range), each contact shows a reachability dot, and the Peers list has a search box. +- You can message standard meshcore phone apps and they can message you — readable text both ways, private replies instead of public-channel broadcasts. +- Federated Archipelago nodes now appear on the Mesh Map. +- Apps open as an overlay on top of whatever page you're on, in every display mode, instead of yanking you to a different screen; the Services tab groups apps by category with proper icons. +- BTCPay Server keeps its plugins across restarts, connects to your node's own LND out of the box, and its invoices stay payable over private Lightning channels. +- Fedimint federations show up in Wallet Settings again (the client app's configuration error is fixed), and Wallet Settings has tabbed sections for Cashu and Fedimint. +- The phone companion app can upload and download files, edit saved server entries, opens non-embeddable apps in an in-app browser, and got a proper round launcher icon. +- Six placeholder "apps" that were just web bookmarks (484.kitchen, arch-presentation, call-the-operator, nwnn, syntropy-institute, t-zero) are gone from the store. +- The Bitcoin dashboard works fully offline (no more loading its styling from the internet), Gitea opens on the right port, and mempool, strfry, and Electrum stopped their restart/health-check loops. +- Kiosk displays: HDMI audio no longer stutters, and a bad display-clone state no longer sticks after reboot. +- Consistent dropdowns, toggles, tabs, and modal styling across Settings, Federation, and the rest of the UI; in Mesh chat, scrolling the conversation no longer also scrolls the contact list; "App Updates" and "App Registry" sit directly under Account in Settings. +- A fresh node no longer reinstalls apps just because their definition file exists on disk — only apps you actually installed come back. + +## v1.7.99-alpha (2026-06-17) + +- Your node can now hold Fedimint ecash as well as Cashu. Wallet Settings now has tabbed sections for each: keep your list of trusted Cashu mints, or paste a Fedimint invite code to join a federation, and the home wallet card shows both your Cashu and Fedimint balances side by side. A new "Fedimint Client" app in the catalog powers the federation side. +- You can now buy files shared by another node, right from their cloud. When you open a peer's paid file you get a simple "Buy this file" picker with several ways to pay — instantly from this node's ecash balance, from your node's own Lightning wallet, on-chain from your node, or by scanning a Lightning QR code with any outside wallet. Once payment settles, the file downloads automatically. +- Your node can now act as an AI assistant on the off-grid mesh radio network. If your node has a local AI model available (via Ollama), other people on the mesh can ask it a question by starting their message with "!ai" and get an answer back over the radio — handy where there's no internet. A new Mesh assistant panel lets you turn this on or off and shows whether a local AI model was detected. +- You can now view your node's 24-word recovery phrase whenever you need it. Settings has a new "Recovery phrase" option that, after you confirm your password (and 2FA code if you use one), reveals the words behind a tap-to-show blur with a copy button — so you can write them down and store them safely offline. +- Setting up a brand-new node is smoother and less alarming. If the node is still starting up while you generate or confirm your recovery phrase, it now quietly waits and retries instead of flashing a scary error, and offers a clear "Try again" button only when something genuinely goes wrong. The final setup screen also shows a gentle "securing your private connection…" status that turns to "ready" on its own, so you can tell the encrypted transport is coming up rather than stuck. +- The NetBird VPN app now actually logs in. It was failing to reach its sign-in screen because the dashboard needs a secure (HTTPS) connection that wasn't being provided; the node now serves it over HTTPS and opens it in a browser tab, so the login flow completes. +- When you use your phone to remote-control a node's attached screen, two-finger scrolling now works inside apps and panels, not just the main page. And tapping an app that's meant to open in an external browser now hands the link to your phone to open there, instead of trying to open it on the (often unattended) attached display. +- You can now choose whether your node shares Bitcoin block headers over the mesh. The Mesh Bitcoin panel has new switches to announce headers to peers and to accept headers from them, and your choices are remembered. +- Version numbers now display cleanly everywhere. In a few places the interface was showing a doubled "v" (like "vv1.7.98"); it now always shows a single, tidy version label. +- The "Back" buttons throughout the cloud and other detail screens now look and behave consistently on both desktop and mobile, including when browsing another node's files. +- For advanced testing, Settings now includes an optional "update & app source" choice between the usual trusted origin and an experimental peer-to-peer (DHT swarm) mode that pulls updates and app content from other nodes first, falling back to the origin automatically. The trusted origin remains the default. + +## v1.7.98-alpha (2026-06-16) + +- Apps that crash now recover on their own. Multi-part apps like Immich and IndeedHub could have one of their pieces stop and stay stopped until the whole node was rebooted; the node now checks every couple of minutes and restarts any crashed piece automatically (while still leaving apps you deliberately stopped alone). +- The on-screen kiosk display can no longer slow the whole node down. On machines without a graphics chip the kiosk browser could spin a CPU core at full tilt, starving everything else (including the wallet, which then timed out); it's now capped and uses lighter rendering on those machines. +- If an update download fails, you're taken back to the Download button to retry, instead of being stranded on an Install button for an update that didn't actually finish downloading. +- Your node's identity is clearer and always visible: Settings now shows your Node DID on every node (it previously only appeared if your browser had cached it) plus your node's npub, both with copy buttons. There's also a terminal tool to cryptographically prove all your node's keys come from your one seed phrase. +- The "all nodes over Tor" group chat sends quickly now — the "sending" spinner clears as soon as the reachable nodes have the message, instead of hanging on a slow or offline node. +- Message notifications now have a close button and open the relevant chat when tapped. +- The encrypted mesh transport (FIPS) turns itself on automatically after setup — no button to press — and connects to peers more reliably (it retries and keeps connections warm), so node-to-node features use the fast path more often instead of falling back to Tor. +- Your chat history with other nodes is saved reliably and now encrypted on disk, so it survives restarts and updates and can't be read from a stolen drive (only clearing chat removes it). +- Peer media shows a "connecting" loader before a video or audio file plays, and audio errors are accurate instead of blaming File Browser. +- The Fedimint app now displays with its proper styling, and the Connected Nodes screen stays compact — it shows a few nodes and scrolls, you can tap a node to jump to it in Federation, or tap Message to open its chat. +- App updates can now arrive on their own without waiting for a full system release, so individual apps can be improved and shipped faster. + +## v1.7.97-alpha (2026-06-16) + +- The Bitcoin sync status on the home screen no longer disappears for a moment when it refreshes. If the node was briefly busy, the panel used to vanish and pop back; it now stays put and simply shows "Updating…" until the next reading arrives, while a genuinely stopped node still correctly shows as not running. +- Bitcoin sync progress on the home screen now updates more promptly, so the percentage and block height keep pace with the node instead of lagging behind. +- The Lightning wallet "connect your wallet" screen loads its details and QR code again across all nodes, instead of failing to fetch them. +- Your list of trusted nodes is now clean: the same node no longer appears several times under different names, and removed nodes stay removed. In chat, a node that previously showed up as two separate contacts now appears just once. +- Browsing another node's cloud is smoother: music and video files from a peer now preview and play properly (including seeking partway through), and the connection now shows a small badge telling you whether it's using the fast encrypted mesh or the slower Tor network. +- Opening "My Folders" in the cloud now shows a clear, friendly message when the file app isn't running, instead of a confusing error. +- The Electrum server app opens on its own once it's ready, instead of sometimes leaving a loading spinner stuck on top of the screen. +- The Fedimint app now displays with its proper styling and icons, instead of appearing unstyled with a missing image. +- The Mempool app now connects to your Bitcoin node whether the node is Bitcoin Core or Bitcoin Knots, instead of only working with one of them. +- Nodes start up cleanly after a reboot. On some boots the node's main service was trying to start before its data drive had finished mounting, so it failed and retried about twenty times over roughly five minutes — showing a wall of "Failed to start" messages — before finally coming up. It now waits for the data drive to be ready first, so it starts on the first try. +- The background images throughout the interface now load faster — they've been made significantly smaller with no loss of quality. + +## v1.7.96-alpha (2026-06-15) + +- The screen attached to your node now shows the normal Archipelago interface and your dashboard after you sign in, instead of a separate, stripped-down grid of app icons that could appear in its place. That extra screen has been removed so the attached display matches what you see everywhere else. +- On a brand-new node, the attached screen now walks through the same welcome and setup steps you'd see on a phone or laptop, and shows the normal sign-in screen once the node is set up — so the on-device display always matches the rest of the interface. +- When adding a FIPS network anchor, you can now choose whether it connects over TCP (for a public anchor reached across the internet) or UDP (for one on your local network), instead of it always assuming the local-network option. +- Behind the scenes, a new automated two-node test now exercises real node-to-node features — browsing another node's shared files and handling a removed node — against live nodes before each release, so node-to-node problems are caught earlier. + +## v1.7.95-alpha (2026-06-15) + +- Browsing another node's shared files now works over the fast encrypted mesh. Opening a peer's cloud could fail with a generic "Operation failed" message because the request for their file list wasn't permitted over the mesh and came back as "not found" — and it never retried over Tor. The mesh now serves the file list directly, and if a peer can't answer over the mesh the node automatically falls back to Tor instead of giving up. +- Nodes you remove from your federation now stay removed. Previously a deleted node could quietly come back the next time you synced with another node that still listed it. Removed nodes are now remembered as removed and won't reappear on their own — only if you add them back yourself. +- The app credentials pop-up now appears as a normal centred box with a dimmed background over the whole screen, instead of stretching to fill the entire screen. + +## v1.7.94-alpha (2026-06-15) + +- Your node now joins the private encrypted mesh network on its own. A wrong built-in setting meant nodes were quietly never reaching the shared mesh meeting point, so everything between nodes fell back to the slower Tor network. Every node now connects to the mesh automatically on startup, so node-to-node features like file sharing use the faster encrypted mesh first and only fall back to Tor when a peer is genuinely offline. (Confirmed live: a node with its mesh setting wiped re-connected to the mesh by itself within a second of starting.) +- You can now bring the mesh networking software up to the latest stable version straight from the node, with one action — it fetches the new version, checks it's genuine before installing, and restarts the mesh on its own. (Confirmed live end to end: a node on an older build was upgraded to the current stable release and rejoined the mesh automatically.) +- The Lightning wallet screen connects again on nodes where it was showing a "failed to fetch" error instead of your balance and channels. The wallet app and the node now talk to each other correctly, and the connection quietly repairs itself if its details drift after a restart. + +## v1.7.93-alpha (2026-06-14) + +- Receiving Bitcoin and Lightning works again on nodes where the Lightning wallet was stuck locked. After some updates the wallet could come back locked with a password the node no longer had, so "generate a receive address" kept failing with a "wallet is locked" message that nothing could clear. The node now detects this and repairs itself automatically. +- Each node now secures its Lightning wallet with its own unique, randomly generated password instead of a shared built-in one, and remembers it safely so the wallet unlocks on its own after every restart or update — no more getting stuck locked. +- If a wallet is found locked with an unrecoverable password, the node rebuilds it cleanly so Bitcoin and Lightning start working again. (On these early-access nodes the wallet holds no funds, so nothing is lost — a wallet locked with an unknown password was already inaccessible.) +- The self-repair was validated end to end on live nodes: a stuck, locked wallet was detected, rebuilt, and came back unlocked on its own, and stayed unlocked across restarts. + +## v1.7.92-alpha (2026-06-14) + +- The Electrum server app no longer flashes a "can't connect, try again" error over its loading screen while it's still catching up. If ElectrumX is building its index or waiting on the Bitcoin node, you now just see the sync progress, and the app opens on its own once it's ready. +- Behind the scenes, the reboot-survival test now confirms the whole system is genuinely healthy after a restart — every app reachable, updates not stuck, core services answering — instead of only checking that containers came back, so update-related problems are caught before shipping. +- Settings → What's New now lists the notes for every recent release again. The screen had quietly fallen several versions behind, so the last eight releases of changes weren't showing up there — they're all back now, and a release check keeps it from drifting again. + +## v1.7.91-alpha (2026-06-14) + +- Apps you've installed now reliably show their "Open" button again. Some apps — including Jellyfin, BTCPay Server, Fedimint, Gitea and Portainer — were running fine but their launch link sometimes went missing, so there was no way to open them from the home screen. They now open correctly. +- Receiving Bitcoin is more dependable: if the wallet's internal connection details drift after a restart, it now repairs them on its own, and any error it does hit is reported clearly instead of as a generic failure or a misleading "wallet locked" message. +- Installing Bitcoin now sets itself up correctly without manual help — a security credential that could previously be missing and stop Bitcoin from starting is created automatically before it launches. +- The Electrum server app is back on the home screen and can be launched again. +- Behind the scenes, the release now runs an expanded automated test suite before shipping, so these kinds of issues are caught earlier. + +## v1.7.90-alpha (2026-06-13) + +- Generating a Bitcoin receive address works again — the wallet now requests the correct address type, fixing the "400 Bad Request" error when creating an address. +- In the companion app, the on-screen pointer can now click into apps and type — including the app store search box — instead of clicks and keystrokes not reaching app content. +- "Open in a new tab" from the companion app now opens the app in your phone's browser, instead of doing nothing. The normal mobile browser keeps working as before. +- The login/credentials pop-up on phones is once again a centered, properly sized window rather than stretching the full height of the screen. +- The Electrum server now recovers on its own if its index ever gets corrupted, and shows a clear progress screen (with percent complete and block height) while it builds its index, instead of a blank or broken page. +- Software updates are more reliable on slow internet connections — downloads are given much more time to finish before giving up. + +## v1.7.89-alpha (2026-06-12) + +- The AI assistant looks the way it always did again: no extra back button or close button on phones, and the desktop view fills the whole screen without a gap at the bottom. +- System updates are much more reliable: updates that previously got stuck partway or failed to install now complete cleanly, and a failed update can no longer block all future updates. +- After an update, the system now checks itself correctly on every node type, so working updates are no longer mistakenly undone. +- Generating a Bitcoin receive address works again on nodes where a network proxy previously got in the way. +- The Lightning wallet now recovers and unlocks itself properly after restarts. + +## v1.7.88-alpha (2026-06-12) + +- AIUI now loads immediately again instead of waiting on a production availability probe and cache-busted iframe URL, restoring the lighter launch behavior from before the regression. +- Bitcoin receive now uses LND's GET-based newaddress flow with the native SegWit address type, fixing the `501 Method Not Allowed` response from the previous POST attempt. +- Validation pending on the AIUI rollback; the rest of the release train remains unchanged. + +## v1.7.87-alpha (2026-06-12) + +- Bitcoin receive now calls LND's on-chain address endpoint with the correct REST method, and backend failures keep the specific address-generation error instead of collapsing into the generic operation-failed message. +- App launch credential interstitials now render as true full-screen overlays, and the launcher loading indicator uses the neutral brand palette instead of a blue spinner. +- Validation passed with `git diff --check`, `npm run type-check`, and the focused frontend tests for `bitcoinReceive` and `AppIconGrid`. + +## v1.7.86-alpha (2026-06-12) + +- Fleet now preserves the last known node list, alerts, and selection locally while telemetry refreshes in the background, so the dashboard no longer blanks on tab switches or update scans. +- Connected nodes and identities now reuse their last loaded data instead of reloading the visible list every time the user revisits the tab. +- The Fleet matrix and detail views now show actual node names and host information instead of raw node id prefixes. +- The network map only redraws when its graph data actually changes, which stops the D3 scene from visually resetting on every refresh tick. +- Mobile federation and system-update actions now stack full width, and the ElectrumX app health check allows a long startup window so slow sync nodes do not restart mid-index. +- Validation passed with `git diff --check`, focused frontend tests, and `npm run type-check`. + +## v1.7.85-alpha (2026-06-12) + +- ElectrumX now runs with less cache pressure and more memory headroom, reducing the restart loop seen during sync catch-up. +- Portainer is pinned to `2.19.4` instead of `latest`, avoiding schema-drift restarts from surprise image updates. +- LND receive-address creation now asks for a native SegWit address and returns clearer wallet/readiness failures when an address is not available. +- Fleet telemetry now carries server name, hostname, and server URL, and the Fleet dashboard shows those names instead of hashed node ids. +- Trusted federation peers are still auto-added transitively, but the local node no longer imports itself back into the fleet list. +- Validation passed locally for the touched frontend helpers, `git diff --check`, and Rust formatting. + +## v1.7.84-alpha (2026-06-11) + +- Bitcoin trusted-node relay approvals now generate restricted `txrelay` RPC credentials when needed and restart the active Bitcoin backend so bitcoind loads the new `rpcauth` whitelist. +- Kiosk mode now includes a browser safe-area path for HDMI displays that crop edges, and self-update refreshes kiosk launcher/systemd files so display fixes ship to existing nodes. The experimental X11 scaling safe-area is opt-in to avoid stretching TV output. +- Wi-Fi setup now reports scan errors instead of showing an empty network list, supports retrying scans from the modal, parses escaped `nmcli` SSIDs correctly, and can join open networks without forcing a WPA password. +- Bitcoin Core now matches Bitcoin Knots for restricted relay RPC support, including the txrelay secret injection and transaction broadcast whitelist. +- The restricted Bitcoin relay whitelist now includes `submitpackage` and `gettxout`, covering newer wallet/package-relay broadcast flows without opening wallet/admin RPC. +- The Bitcoin UI companion image is pinned to `1.7.84-alpha` across release metadata and the Quadlet fallback path, avoiding stale `latest` detection during OTA updates. +- Container scanning now uses an RAII in-flight guard so timeout and error paths cannot leave the scanner stuck in a permanently busy state. +- Validation passed with `cargo fmt`, `cargo check -p archipelago`, `git diff --check`, and focused source review of the relay message/approval path. + +## v1.7.83-alpha (2026-06-11) + +- App launch metadata now derives more consistently from app manifests, with typed launch interfaces and catalog generation updates that keep packaged apps aligned with their runtime ports and launch surfaces. +- Revoked or unsupported app surfaces were removed from the catalog and release path, including OnlyOffice and the unvalidated Saleor surface, so the Marketplace no longer exposes apps that cannot be safely supported in this release. +- The frontend production build now passes strict TypeScript checks after tightening app details, Web5, cloud refresh, and credential test typing. +- Mobile and desktop app surfaces received release polish: improved mobile app layout, safer mesh desktop/tablet scrolling, and the Home system card now routes directly to monitoring. +- Bitcoin UI status rendering now avoids false stale/reconnecting states when fresh block snapshots advance, and guards optional DOM updates so the standalone Bitcoin UI is more resilient. +- Deploy tooling now excludes local Codex scratch output, archived image-build artifacts, and upload screenshots from target syncs, and bounded optional IndeedHub fixups so a stuck Podman helper cannot hold the deploy. +- Validation passed with `npm run type-check`, production `npm run build`, backend `cargo build --release`, catalog/release manifest checks, focused frontend tests, and live `.198` deploy verification through the frontend/service restart phase. + +## v1.7.82-alpha (2026-05-22) + +- Saleor storefront proxying now forwards `X-Forwarded-Host`, fixing Next.js Server Actions requests that compared the browser origin with the internal `storefront-app:3000` upstream host. +- Saleor storefront media now routes `/thumbnail/` and `/media/` through the same `9011` proxy to the Saleor API, fixing product image optimizer failures caused by `localhost:8000` media URLs. +- The Saleor storefront container receives an explicit internal media origin so rewritten media URLs resolve inside the Podman network without exposing private API ports to browsers. +- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml`, `cargo check -p archipelago --manifest-path core/Cargo.toml`, and live checks on `100.114.134.21` for storefront HTML, static assets, GraphQL, media redirects, and optimized product images. + +## v1.7.81-alpha (2026-05-21) + +- Saleor storefront installs now use the prebuilt registry image instead of building the Next.js app on-device, avoiding Podman build failures during stack installation. +- Existing Saleor stacks are repaired on adoption by recreating missing storefront containers, forcing the storefront app to bind `0.0.0.0:3000`, and resolving nginx upstreams dynamically after container restarts. +- The shipped Saleor storefront image now includes public assets and omits Vercel-only Speed Insights injection, fixing broken static asset responses and the local `/_vercel/speed-insights/script.js` browser warning. +- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml`, `cargo check -p archipelago --manifest-path core/Cargo.toml`, and live checks on `100.114.134.21` for `9011` storefront, static assets, and proxied GraphQL. + +## v1.7.80-alpha (2026-05-21) + +- Saleor storefront proxying now falls back to the direct request scheme when no forwarded protocol header is present, fixing direct `http://node:9011` launches that could generate an invalid same-origin GraphQL URL. +- The Saleor storefront release path keeps public proxy support intact by still honoring forwarded HTTPS headers for Nginx Proxy Manager domains while repairing local/direct port launches. +- Validation passed with `cargo fmt --check` and `cargo check` for the Archipelago backend before release staging. + +## v1.7.79-alpha (2026-05-20) + +- Saleor now installs the official Saleor Storefront as part of the stack, built from the pinned `saleor/storefront` source and served as the customer-facing shop on port `9011`. +- Saleor app launches now open the storefront while the admin dashboard remains available on port `9010` with the generated `admin@example.com` credentials shown in Archipelago. +- Public Nginx Proxy Manager hosts forwarding to the Saleor storefront also expose same-origin `/graphql/`, so public storefront domains can talk to the local Saleor API without mixed-content or private-LAN reachability failures. +- Saleor stack metadata, marketplace descriptions, catalog ports, scanner exclusions, and app-session routing now describe the storefront/dashboard/API split explicitly. + +## v1.7.78-alpha (2026-05-20) + +- Public Nginx Proxy Manager hosts for Saleor now keep browser GraphQL calls same-origin at `/graphql/` and proxy them to the local API on `8000`, fixing `Failed to fetch` when a public domain such as `noderunner.shop` was loaded from devices that cannot reach the node's private LAN/tailnet API address. +- Saleor's validated stack changes are now release-ready: dashboard origins on port `9010` are explicitly allowed for dashboard/API calls, preserving the working test-node install path for production nodes. +- NetBird launches now stay pinned to the unified dashboard/proxy origin on port `8087` instead of following stale runtime-discovered server URLs on `8086`. +- NetBird's local nginx proxy now routes browser API, OAuth, relay, and WebSocket traffic through `host.containers.internal:8086` instead of a hard-coded rootless Podman gateway IP, and includes the upstream `management.ProxyService` gRPC path. +- The mobile credentials interstitial now keeps credential lists scrollable and action buttons reachable in both My Apps and the mobile app icon grid. +- Android WebView popup windows now hand external popup URLs to the system browser, covering app login/signup flows that open secondary windows. +- Validation passed with `git diff --check`, `cargo check -p archipelago`, and the focused `npm test -- src/views/appSession/__tests__/appSessionConfig.test.ts` suite. + +## v1.7.77-alpha (2026-05-20) + +- Saleor first-use now exposes generated credentials through Archipelago instead of leaving users at an unexplained dashboard login: App Details shows copyable `admin@example.com` credentials, and My Apps/mobile icon launches show a pre-launch credentials modal. +- Saleor installs now create or repair the `admin@example.com` staff account idempotently after sample data loads, use the correct dashboard mount path, and re-check stack containers after startup so stopped containers are caught. +- NetBird embedded login now uses the upstream-compatible IdP signing-key behavior and sends ID tokens from the dashboard to the management API, fixing the post-signup `Unauthenticated` state while preserving the unified local proxy/logout routes. +- Transient unnamed Podman helper containers created during app install tasks are hidden from My Apps, so generated names like `eager_keldysh` no longer appear as user applications. +- Validation passed with catalog/release JSON checks, `npm run type-check`, and `cargo fmt --all --check --manifest-path core/Cargo.toml`; live checks on `100.114.134.21` confirmed Saleor dashboard/API availability, generated Saleor admin login, NetBird OAuth availability, and NetBird logout redirects. + +## v1.7.76-alpha (2026-05-20) + +- Saleor installs now use dashboard port `9010`, avoiding the existing Portainer `9000` binding on the test node while keeping API `8000`, Mailpit `8025`, and Jaeger `16686` unchanged. +- Saleor's Valkey cache no longer bind-mounts `/var/lib/archipelago/saleor-cache`, and the dashboard container has the minimal rootless nginx capabilities it needs to chown cache files, bind port 80 inside the container, and drop workers to the nginx user. +- NetBird's browser proxy now sends API, OAuth, relay, WebSocket, and management traffic through the stable host-published server port at `169.254.1.2:8086`, avoiding stale rootless Podman DNS/IPs after `netbird-server` restarts. +- Mobile App Store category chips now stay visible above the tab bar, Discover is available on mobile, and category selection updates the page route/query so the selected category is actually shown. +- Apps that require a real browser tab now open directly from the app icon tap instead of first entering an in-shell app-session route, including BTCPay, Grafana, Home Assistant, Vaultwarden, Nextcloud, Portainer, OnlyOffice, Tailscale, Uptime Kuma, Gitea, and Nginx Proxy Manager. +- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`; live checks on `100.70.96.88` confirmed Saleor dashboard `9010`/API `8000` and NetBird API/OAuth routes survive `netbird-server` restart. + +## v1.7.75-alpha (2026-05-19) + +- Saleor is now published as a recommended commerce app with catalog metadata, icon, direct app-session launch on port `9000`, scanner metadata, image pins, and a full stack installer for dashboard, API, worker, PostgreSQL, Valkey, Mailpit, and Jaeger. +- Existing NetBird installs are repaired more aggressively by rewriting unified-origin config, recreating the dashboard/proxy containers, restarting the server, preserving data, and handling exact `/api` and `/oauth2` routes plus dashboard logout redirects through the local proxy. +- Desktop dashboard scrolling now hands focus back from the sidebar to the main content when the pointer or wheel moves over the main pane, preventing the sidebar scroll area from trapping wheel input on short screens. +- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml` before release. + +## v1.7.74-alpha (2026-05-19) + +- App-session right panels now re-focus the iframe after load and when the frame area is activated, so wheel/touch scrolling works immediately after switching tabs or selecting an app on shorter screens. +- NetBird now launches through a unified local origin on port `8087` that proxies the dashboard plus `/oauth2`, `/api`, relay, WebSocket, and gRPC routes to `netbird-server`, fixing the embedded login flow that previously ended in `Unauthenticated` or `404 page not found` after logout. +- Existing NetBird installs are repaired on adopt/start by rewriting `config.yaml`, `dashboard.env`, and the local nginx proxy config, then creating the missing `netbird-dashboard` and `netbird` proxy containers when needed while preserving NetBird data. +- Saleor is still pending and is not included in this release; its registry/installer work remains local until it can be validated separately. +- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`. + +## v1.7.73-alpha (2026-05-19) + +- Mobile app launches for iframe-blocked apps now open the direct app URL in a new browser tab immediately instead of landing in a broken in-shell webview that requires a second tap. +- Mobile My Apps/Websites tabs now react to route query changes, App Store pages label the mobile view as Discover, mobile filters have safe bottom spacing, and App Store search ignores the current category so searches cover all available apps. +- My Apps search now surfaces matching App Store entries when the app is not installed, making it possible to jump directly from a failed My Apps search to the installable app details. +- NetBird self-host installs now prefer a `100.x` tailnet/CGNAT address for dashboard, management, relay, STUN, and auth redirect origins when one is present; live repair on `100.89.209.89` updated the existing stack from LAN origins to `100.89.209.89` and restored `netbird-server`. +- App-session iframe frames now focus automatically and wrap the iframe in a scroll host so wheel/touch scrolling works in the active right frame without requiring an initial click. + +## v1.7.72-alpha (2026-05-19) + +- Settings What's New now includes the missing release notes for `v1.7.68-alpha` through `v1.7.71-alpha`, so the modal reflects the current OTA history instead of stopping at `v1.7.67-alpha`. +- The follow-up release carries the NetBird install fix, Gitea icon polish, mobile app-session fallback updates, and rounder app icon masks from `v1.7.71-alpha` with the Settings modal notes included. +- The local Cargo lockfile version metadata is kept in sync with the release bump after the previous release build updated it. + +## v1.7.71-alpha (2026-05-19) + +- NetBird stack installs now pre-create `/var/lib/archipelago/netbird/data` before binding it into `netbird-server`, fixing the failed install/start path seen on `100.70.96.88` where Podman rejected the missing host directory. +- NetBird start/restart ordering now starts `netbird-server` before the dashboard container so lifecycle actions bring the control plane up before the UI. +- App-session invalid IDs and panel-mode fallbacks now return to `/dashboard/apps`, avoiding the stale `/apps` route that could render a 404. +- Mobile launches for apps that block iframes now stay inside the Archipelago app-session fallback instead of automatically opening an external browser tab. +- Installed Gitea containers now report the packaged Gitea icon, and app icon masks use a rounder radius on mobile grids, app cards, and detail headers. +- Validation passed with `npm run type-check`, focused Vitest app-session/app-grid tests, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`. + +## v1.7.70-alpha (2026-05-19) + +- NetBird is being corrected from the peer/client daemon image to the self-hosted NetBird control-plane stack with a launchable dashboard on port `8087`, a combined management/signal/relay server on `8086`, and STUN on UDP `3478`. +- App sessions now always launch local apps through direct host ports and carry an explicit dashboard return target, so closing an iframe returns to the launching dashboard screen instead of falling through to browser history or a 404. +- Mobile app launches ignore stale desktop panel state and route into the full app-session webview consistently. +- The desktop sidebar now pins the logo/version at the top and controller/online/mode controls at the bottom, with only the navigation section scrolling on shorter screens. +- Validation passed with catalog JSON checks, `scripts/image-versions.sh` syntax check, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`. + +## v1.7.69-alpha (2026-05-19) + +- App installs now allow up to 10 minutes for the initial `package.install` RPC to return, matching slow container image pulls and preventing apps from disappearing from My Apps while the backend is still pulling or retrying mirrors. +- Live diagnostics on `100.70.96.88` confirmed the Gitea install did not fail; the primary registry pull timed out after 300 seconds, the fallback mirror succeeded, and Gitea came up healthy on `3001` while the frontend had already timed out at 15 seconds. +- Gitea and other Docker-image app installs now stay visible during slow registry pulls instead of being marked as failed by the browser before backend install progress can complete. +- Gitea is now categorized as a known Data app in My Apps, so a running Gitea container appears with installed apps instead of being filtered into the Websites/Services split. +- NetBird `0.71.2` is now available in the app catalog and fallback marketplace data as a recommended networking app using the official `docker.io/netbirdio/netbird:0.71.2` image. +- NetBird installs get persistent state under `/var/lib/archipelago/netbird`, `NET_ADMIN`/`NET_RAW`, `/dev/net/tun`, `slirp4netns`, image-version pinning, backend metadata, and health checks through `netbird status`. +- The Archipelago terminal now includes `nano` on new disk installs and ISO builds, and self-update installs it on existing nodes if it is missing. +- Validation passed with catalog JSON checks, shell syntax checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`. + +## v1.7.68-alpha (2026-05-19) + +- BTCPay Server now ships on the official `docker.io/btcpayserver/btcpayserver:2.3.9` image, fixing the plugin catalog crash caused by newer plugin dependency version metadata while preserving existing datadirs and Postgres databases. +- BTCPay release and first-boot health checks no longer depend on `curl` inside the container; they use a bash TCP probe that works with the official image out of the box. +- Host nginx now serves Nginx Proxy Manager HTTP-01 challenge files before the Archipelago SPA fallback and is marked as the default HTTP/HTTPS virtual host, so public proxy hosts can issue certificates without hijacking local API traffic. +- Nginx Proxy Manager first-boot, runtime repair, and container-doctor paths now pre-create the ACME webroot, keep bind mounts owned by the rootless Archipelago user, and sync issued public proxy hosts into host nginx vhosts. +- The Nginx Proxy Manager host-nginx sync now skips proxy hosts with missing certificate files and rolls back the generated nginx include if validation fails, preventing a bad certificate path from poisoning later nginx reloads. +- App session close buttons now return to the previous dashboard screen when possible and otherwise fall back to My Apps, avoiding the 404 page after closing an app launched from an invalid or stale history entry. +- System Update confirmation and mirror modals now teleport to the document body with a full-screen overlay, so they cover the whole app instead of only the right-hand dashboard panel. +- Mobile app launches stay inside Archipelago's app-session webview and hide desktop-only new-tab launch affordances, including apps such as Home Assistant that previously looked like they would leave the mobile shell. +- Live recovery on `100.70.96.88` upgraded only the `btcpay-server` container to `docker.io/btcpayserver/btcpayserver:2.3.9`, preserved the existing datadir and Postgres database, and confirmed the container is healthy after a pre-upgrade backup. +- Public validation confirmed `spay.tx1138.com`/`www` redirect to BTCPay login over HTTPS and `sapien.tx1138.com`/`www` serve the L484 page over HTTPS using the issued Let's Encrypt certificates. + +## v1.7.67-alpha (2026-05-18) + +- Home dashboard status cards now keep the last known good system, VPN, Bitcoin, and FIPS values while route changes or transient RPC failures are in flight, avoiding false "not configured" or "not running" flashes. +- Home, Web5 Monitoring, and the Monitoring page headline cards now share the same live system-stat snapshot for CPU, memory, disk, uptime, and load so the visible numbers agree across the UI. +- Settings What's New is filled through `v1.7.67-alpha`, including the missing historical `v1.7.44-alpha` through `v1.7.66-alpha` entries. +- Bitcoin/Knots/Core shell lifecycle specs now match the Rust app config memory policy: 8 GiB on normal hosts, 4 GiB on low-memory hosts, and pruned Knots uses a larger dbcache on hosts with enough RAM to improve IBD throughput. +- ElectrumX/electrs shell lifecycle specs now match the 4 GiB memory policy used by the Rust app config, reducing drift between first boot, reconcile, and app lifecycle paths. +- Live assessment of `100.70.96.88` identified the current IBD bottlenecks as CPU/thermal/I/O pressure rather than RAM exhaustion, with follow-up work planned for existing-node swap repair, kiosk Chromium CPU reduction, and reconcile failure cleanup. + +## v1.7.66-alpha (2026-05-18) + +- Nginx Proxy Manager stale-port repair now detects stopped or `Created` Podman records by inspecting `podman ps -a` port metadata, covering records where `podman port nginx-proxy-manager` returns no mapping until start. +- Live recovery on `100.70.96.88` removed only the stale Nginx Proxy Manager container record and recreated it with `8081:81`, `8084:80`, and `8444:443`, preserving `/var/lib/archipelago/nginx-proxy-manager` data. +- Validation confirmed Nginx Proxy Manager recovered as healthy and responds through direct admin port `8081`, host compatibility port `81`, and `/app/nginx-proxy-manager/`. + +## v1.7.65-alpha (2026-05-18) + +- Orchestrator-backed app starts now run the same pre-start repairs as the legacy Podman path, so Nginx Proxy Manager stale `81:81` container metadata is removed and recreated before the orchestrator tries to start it. +- Live diagnostics on `100.70.96.88` confirmed host nginx is healthy while Nginx Proxy Manager has no listeners on `8081`, `8084`, or `8444`, causing host nginx `502` responses for NPM proxy paths. + +## v1.7.64-alpha (2026-05-18) + +- Update apply rate limiting is relaxed for authenticated admins from 2 attempts per 10 minutes to 10 attempts per minute, preventing the System Update page from getting stuck behind `429 Too Many Requests` during legitimate OTA retry/troubleshooting flows. +- The corrected backend artifact rebuild protection from `v1.7.63-alpha` remains in place, so this release is built from a fresh Rust backend binary before publishing. + +## v1.7.63-alpha (2026-05-18) + +- Release automation now rebuilds the Rust backend after bumping the version and before hashing release artifacts, preventing OTA manifests from pointing at a stale backend binary. +- This corrected release carries the Nginx Proxy Manager stale-port repair in an updated backend binary, so nodes running `1.7.61-alpha` can actually receive and execute the fix. +- Validation confirmed the previously published `v1.7.62-alpha` backend artifact still contained `1.7.61-alpha`, explaining why nodes did not advance after applying that update. + +## v1.7.62-alpha (2026-05-18) + +- Nginx Proxy Manager start and restart now repair stale Podman containers that still publish the admin UI on host port `81`, which conflicts with host nginx on updated nodes. +- The repair recreates only the stale Nginx Proxy Manager container metadata while preserving `/var/lib/archipelago/nginx-proxy-manager` data and using the current `8081:81`, `8084:80`, and `8444:443` mappings. +- Runtime stale-listener cleanup for Nginx Proxy Manager is shared across start and restart paths so rootless port helper leftovers are still cleared before lifecycle retries. +- Validation passed with `cargo fmt --all --check --manifest-path core/Cargo.toml` and `cargo check -p archipelago --manifest-path core/Cargo.toml`. + +## v1.7.61-alpha (2026-05-18) + +- Multi-container stack installs now keep their app card in the `Installing` state for up to 20 minutes while dependency containers are being pulled and prepared. +- BTCPay Server installs no longer appear to vanish or fail after two minutes while Postgres and NBXplorer are still being created before the primary `btcpay-server` container exists. +- The stale-transition escape hatch remains short for start, stop, restart, update, and removal operations, so genuinely wedged lifecycle actions still recover quickly. +- Live validation on `100.70.96.88` confirmed BTCPay Server completed installation and responds on port `23000` with the expected HTTP redirect. + +## v1.7.60-alpha (2026-05-18) + +- Meshtastic serial detection now rejects malformed or incomplete handshakes instead of accepting unrelated serial devices as a fallback Meshtastic radio. +- Mesh radio auto-detection now skips known non-mesh serial devices such as Sierra Wireless LTE modems and Zooz/Z-Wave sticks, avoiding interference with production peripherals. +- Meshtastic config sync now sends `want_config_id` with the correct protobuf wire type, fixing radio-side `ignore malformed toradio` errors and allowing node-info/contact ingestion. +- The stable `/dev/mesh-radio` udev rule no longer claims every `ttyACM*` device; it only matches known mesh USB serial adapters and known USB CDC ACM radio vendors. +- Live validation on `100.70.96.88` confirmed Archipelago selects `/dev/ttyUSB0`, identifies the Meshtastic node, and refreshes 103 mesh contacts. + +## v1.7.59-alpha (2026-05-17) + +- Mobile app launching now keeps known container apps inside Archipelago's app-session flow instead of forcing desktop-only new-tab behavior on phones. +- App sessions on mobile now respect the status-bar safe area so foreground iframe content starts below the device chrome while the fullscreen backdrop remains edge-to-edge. +- Prepackaged website launch buttons now resolve their curated website URLs before website-container fallback logic, restoring launches for the L484 sites and adding the Arch Presentation bookmark. +- Meshtastic contact discovery now drains the radio config stream through completion and retries config sync when the contact cache is empty, so nearby nodes already known by the radio are more likely to appear in Archipelago. +- The Apps page now includes a compact sideload button and modal for installing trusted Docker images with optional title, description, and port mapping metadata. +- Sideloaded app title and description metadata now persist through the backend app-config file so refreshed package scans do not collapse custom apps back to generic IDs. +- Validation passed with `npm test -- appLauncher`, `npm run build`, `cargo check -p archipelago`, and `cargo fmt --all --check`. + +## v1.7.58-alpha (2026-05-17) + +- Mesh networking now supports Meshtastic radios over the Meshtastic serial API in addition to existing MeshCore Companion USB radios. +- The mesh listener now probes preferred and auto-detected serial paths for both MeshCore and Meshtastic firmware, preserving the existing reconnect loop so unplug/replug and firmware hot-swap behavior stays consistent. +- Meshtastic text packets are translated into the existing Archipelago mesh frame pipeline, so current RPC handlers, transport routing, message storage, typed-message decoding, and UI state continue to work without a separate frontend path. +- Meshtastic node information is surfaced as normal mesh contacts using stable synthetic public keys derived from Meshtastic node numbers, allowing peer refresh and message attribution to reuse existing MeshCore contact handling. +- Outbound Archipelago mesh messages can now be sent through Meshtastic as channel text packets using the same command path used by MeshCore channel broadcasts. +- Device status now reports the detected firmware family as `meshcore` or `meshtastic` from the shared listener abstraction. +- Radio udev rules now include USB CDC ACM serial devices (`ttyACM*`) alongside CP2102, CH340, and FTDI adapters so Meshtastic boards are more likely to appear through the stable `/dev/mesh-radio` symlink. +- Host nginx now serves `/assets/*` hashed frontend chunks as immutable static files with a hard 404 on misses instead of falling back to `index.html`, preventing strict MIME errors when a browser has a stale pre-update HTML shell. +- The SPA HTML shell and service-worker files now revalidate on every load, reducing stale frontend references after OTA updates. +- OTA runtime promotion now installs the bundled `nginx-archipelago.conf` into `/etc/nginx/sites-available/archipelago` and reloads nginx after a successful config test, so frontend cache/fallback fixes reach existing nodes without a manual deploy. +- Local validation passed with `cargo check -p archipelago`; live SSH testing against `100.70.96.88` was not completed because temporary public-key authentication was rejected on the target. + +## v1.7.57-alpha (2026-05-17) + +- Nginx Proxy Manager now avoids privileged rootless Podman host port `81`, preferring `8081:81` while host nginx keeps a compatibility proxy on `:81` for stale cached launch buttons. +- App installs now allocate ports by checking live host bind availability, falling back to a free high port when preferred ports are already occupied. +- Portainer-created launchable containers are separated into a `Websites` tab and launch through their discovered published host port instead of hard-coded app URLs. +- Internal BuildKit helper containers such as `buildx_buildkit_default` are hidden from the Apps UI. +- Portainer works out of the box on Debian 13/Podman installs by including `catatonit` and by preserving the Podman socket mount as a socket rather than creating it as a directory. + +## v1.7.56-alpha (2026-05-15) + +- Health notifications now clear when an app is no longer unhealthy, including stale alerts for removed containers such as Portainer. +- Fresh installs now include the full Wi-Fi userspace stack (`wpasupplicant`, `wireless-regdb`, `iw`, `rfkill`, `polkitd`, `pciutils`, and `usbutils`) so NetworkManager can scan and connect with Intel Wi-Fi cards out of the box. +- The installed system now grants the `archipelago` service user explicit NetworkManager PolicyKit access for web-triggered Wi-Fi scans and connection changes. +- Wi-Fi connect now replaces stale/partial NetworkManager profiles and creates an explicit WPA-PSK profile with the supplied password, avoiding no-secret retry failures after a failed attempt. +- Settings password changes now update the Linux/SSH password through non-interactive sudo, so the web password and SSH password stay in sync when the checkbox is enabled. +- Quadlet environment values with spaces or shell metacharacters are quoted consistently, preventing env drift recreate loops for apps like nostr-rs-relay and Grafana. +- Boot/bootstrap reconcile avoids restarting running Bitcoin containers while repairing RPC config, preserving IBD progress on active nodes. +- Exit code 137 is labeled as SIGKILL instead of assuming OOM, avoiding false OOM alerts for orchestrator-managed recreates. +- Container reconcile force-recreates Podman records stuck in `Stopping`, preserving bind-mounted app data while recovering wedged containers automatically. +- Container health reporting is honest for running containers: Archipelago surfaces Podman's actual health state instead of marking every running container healthy. +- Quadlet reconciliation restarts services when stale health gates, port bindings, network aliases, exec commands, or healthchecks drift from the current manifest. +- Bitcoin Knots sync performance improves on fresh installs and updates with 8Gi container memory, a 4Gi dbcache, and full CPU parallelism. +- ElectrumX initial indexing gets more headroom: CPU caps are removed, memory is raised to 4Gi, cache is raised to 3Gi, and oversized sends are allowed for heavier wallet/indexing workloads. +- Mempool/ElectrumX lifecycle qualification respects pruned/non-archival Bitcoin nodes instead of installing a half-running stack with unhealthy dependencies. +- LND wallet/RPC helpers are more tolerant of container-owned files and updated REST port metadata, improving LND lifecycle and wallet-connect flows. +- Marketplace/catalog metadata carries richer container config so remote lifecycle tests install apps using the same settings users get from the UI. +- The app screensaver no longer activates during media-heavy app sessions such as IndeeHub, Jellyfin, Immich, PhotoPrism, and File Browser; apps can also pause/resume it with media playback messages. +- A fresh `1.7.56-alpha` unbundled installer ISO is built from the same primary VPS2 release line for easy download and USB flashing. + +## v1.7.55-alpha (2026-05-13) + +- Container reconcile now force-recreates Podman records stuck in `Stopping`, preserving bind-mounted app data while recovering wedged containers automatically. +- `.198` is green after the container-layer hardening pass: focused and broad non-destructive lifecycle audits pass, raw Podman health/state sweep is clean, and direct app probes return healthy responses. +- Release-candidate artifacts are staged separately from live update publishing while Gitea artifact hosting is repaired. + +## v1.7.54-alpha (2026-05-06) + +- Existing installs now self-repair nginx backend proxy locations for `/bitcoin-status` and `/api/app-catalog`, including hosts where `sites-enabled/archipelago` is a copied active file instead of a symlink. +- LND UI is consistently served on `18083` across first boot, Tor config, companion Quadlet reconciliation, OTA runtime payloads, and ISO scripts; stale companion units/images are rewritten instead of only checking service active state. +- OTA frontend tarballs now carry a clean runtime payload with updated scripts, docker UI sources, and canonical nginx config, preventing startup promotion from reintroducing stale host assets. +- Release ISO builds now support the primary HTTP app registry when bundling core images, so unbundled media includes File Browser/Cloud support instead of requiring a post-install Marketplace download. +- `.116` was live-updated with the new backend and runtime scripts; focused non-destructive lifecycle audit passes for Bitcoin Knots, LND, BTCPay, Mempool, and Grafana. + +## v1.7.53-alpha (2026-05-05) + +- Bitcoin Knots/Core config generation no longer duplicates RPC bind and port settings between `bitcoin.conf` and container command args, fixing `Unable to bind all endpoints for RPC server` startup failures. +- Legacy Bitcoin container healthchecks no longer depend on `bitcoin-cli`, which is absent from current Knots images and can wedge Podman healthcheck runners. +- Update checks now prefer manifest OTA releases over stale git remotes unless `ARCHIPELAGO_GIT_UPDATES` is explicitly enabled, so installed nodes can see published releases from the VPS mirror. + +## v1.7.52-alpha (2026-05-05) + +- Tailscale now launches the local installed web UI on port `8240` and starts `tailscaled` before `tailscale web`, fixing unreachable installs after container creation. +- Grafana install/start/restart now repairs missing rootless host listeners on port `3000`, matching the existing SearXNG, Uptime Kuma, and Gitea recovery path. +- Debian 13/Trixie ISO and disk-install paths now force security updates from `trixie-security` during image/install creation so rebuilt release media includes patched base packages. +- Broad `.198` lifecycle audit passes with the current qualified app set; known absent blockers remain `electrumx`, `photoprism`, `dwn`, and `ollama`. + +## v1.7.49-alpha (2026-04-30) + +- Bitcoin Knots/Core UI now reports connection, reconnecting, syncing, and error states from a backend status bridge instead of showing a stale "Unable to connect" message while the node is warming up. +- ElectrumX UI now exposes indexed height, local Bitcoin height, known headers, status, and progress source so indexing/waiting states are readable during long initial sync. +- Added container doctor timer and smoke/lifecycle test coverage for Bitcoin Knots/Core, ElectrumX, Mempool, BTCPay/NBXplorer, and UI surface availability. +- Bitcoin Core and Bitcoin Knots are mutually exclusive variants, with a real Bitcoin Core manifest and corrected install conflict handling. +- IndeeHub now launches only on direct web UI port `7778`; the broken `/app/indeedhub/` path proxy was removed, and port `7777` remains the Nostr relay. +- BTCPay/NBXplorer Postgres environment formatting fixed so installs do not carry malformed connection strings. + +## v1.7.48-alpha (2026-04-29) + +- archipelago.service no longer fails to start with "Failed to set up mount namespacing: /run/containers: No such file or directory" on nodes where /run/containers wasn't pre-created. ExecStartPre now creates it. Existing nodes need a one-time `systemctl edit archipelago` to add the mkdir; ISO installs from this version forward have the fix baked in. + +## v1.7.47-alpha (2026-04-29) + +- Bitcoin Knots/Core sync is now significantly faster. The container now uses every available core for script verification (was capped at 2) and has 8GB of memory instead of 4GB so its 4GB UTXO cache has headroom for the mempool and peer connections. Existing nodes pick up the new limits on next install/update; freshly-installed nodes start at full speed. +- ElectrumX initial indexing is faster too. Its CPU cap is removed, container memory is 4GB, and its internal cache is now 3GB (default was 1.2GB). + +## v1.7.46-alpha (2026-04-29) + +- Health monitor no longer pages "Auto-restart failed" for orphaned containers. After a variant switch (bitcoin-core ↔ bitcoin-knots) the previous variant's container could survive uninstall and the health monitor would try restarting it forever. Now skipped silently with a debug log. +- Apps no longer disappear from My Apps when an install fails. The card stays visible with state=Stopped so the user can retry or uninstall, with the failure reason surfaced via the new install_progress.message field. +- "Downloading…" progress now actually advances during multi-image stack pulls. Was sticking at 20% until all pulls finished; now interpolates 20%→70% based on which image of N has landed. +- Pulled four docker.io images (bitcoin, gitea, nextcloud, valkey) into the lfg2025 registries on OVH and tx1138. Removes a docker.io dependency from first-boot installs. +- Resilience harness improvements: install-fail entries no longer vanish, install/uninstall/probe cells are timing-tolerant (60s retry on ui_probe and auth_probe), dep snapshots no longer leak companion containers into the dependent app's "new containers" set. + +## v1.7.45-alpha (2026-04-29) + +- Bitcoin RPC auth is durable. The dashboard reliably connects across container restart, image update, and reboot. Was failing on registry-pulled images that shipped a stale baked-in password. +- Multi-container apps show real install progress. IndeedHub (7), BTCPay (4), Mempool (3), Immich (3) — bar advances through Preparing → Pulling → Creating → Done instead of sitting at 0% until the very end. +- Apps no longer disappear from the dashboard mid-install. The container scanner now respects in-flight installs and updates instead of evicting an entry while its containers are still being created. +- IndeedHub installs cleanly on a fresh node. Five missing environment variables fixed; Nostr sign-in works on first install. +- Tailscale install no longer fails with "executable not found". Container command was a malformed shell string; now a proper command array. +- Removed three catalog entries that hung installs for ten minutes (dwn, endurain, ollama — no source images in our registries). Restored Nextcloud, sourced from docker.io. +- Bitcoin Core update path uses the correct image name (was pulling from a non-existent path). +- New ISO installs now allocate swap (sized to RAM, capped at 8GB, on the encrypted data partition). Without swap, container image builds and memory spikes were hitting OOM under load. + +## v1.7.44-alpha (2026-04-28) + +43de3b73 feat(orchestrator): complete container migration and release hardening +ce39430b feat(self-update): sync and rebuild UI containers on OTA +72dec5aa fix(lnd-ui): align container port across all specs +83aacdf2 chore(release): archive ISO build recipes, tarball-only releases + + +All notable changes to Archipelago will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.3.1] - 2026-03-25 + +### Security +- All crypto dependencies pinned to exact versions from Cargo.lock (supply chain hardening) + - ed25519-dalek 2.1 → 2.2.0, sha2 → 0.10.9, hmac → 0.12.1, argon2 → 0.5.3, chacha20poly1305 → 0.10.1, zeroize → 1.8.2, hkdf → 0.12.4, aes-gcm → 0.10.3 +- All container images pinned to exact patch versions (no more floating tags) + - postgres:15 → 15.17, redis:7 → 7.4.8, nginx:alpine → 1.29.6-alpine, uptime-kuma:1 → 1.23.17, nextcloud:29 → 29.0.16, valkey:8 → 8.1.6, mariadb:11.4 → 11.4.10, and 7 more + - DWN server pinned by SHA256 digest (only has `:main` branch tag) + +### Reliability +- Nostr relay connections now have 10s timeout — prevents indefinite hangs blocking RPC calls + - identity_manager.rs: publish_profile() + - nostr_discovery.rs: publish_node_revocation(), verify_revocation(), discover_archipelago_nodes() + - marketplace.rs: discover(), publish() + +### Infrastructure +- CI pipeline added (.github/workflows/ci.yml) — cargo fmt, clippy, tests + frontend type-check, build +- Update system now fetches from git.tx1138.com Gitea instance (configurable via ARCHIPELAGO_UPDATE_URL) +- Cleaned up stale git branches (app-store, overnight/2026-03-12, overnight/2026-03-13) + +## [1.3.0] - 2026-03-19 + +### Security + +#### Pentest Remediation (33 findings, all addressed) +- **Critical**: Backend now binds to 127.0.0.1 only — no more direct LAN access to port 5678 +- **Critical**: Fixed path traversal in Tor service management that could allow `sudo rm -rf` on arbitrary directories +- **Critical**: Fixed unauthenticated file read/delete via DWN recordId path traversal +- **High**: Federation peers now require cryptographic signature — unsigned peers rejected +- **High**: Login redirect XSS vulnerability fixed with proper URL validation +- **High**: Viewer role restricted to read-only node methods (was granting sign/export access) +- **High**: Backup restore/verify now validates IDs against path traversal +- **High**: Tar archive extraction validates every entry path (prevents tar slip attacks) +- **High**: S3 backup endpoints require HTTPS and reject private IP ranges +- **Medium**: Remember-me token secret now uses cryptographic random (not machine-id) +- **Medium**: Destructive operations (factory reset, onboarding reset) now require password re-verification +- **Medium**: Session token rotated after TOTP verification (prevents interception reuse) +- **Medium**: Webhook URL validation hardened against IPv6 bypass, DNS rebinding, redirect chains +- **Low**: CORS localhost:8100 only included in dev mode +- **Low**: CSP `unsafe-inline` removed from `script-src` +- **Low**: Content filenames validated against path separators and hidden file prefixes +- **Low**: Nostr relay URLs restricted to `wss://` with private IP rejection +- **Low**: Onion address validation enforces v3 format (56 base32 chars) +- **Low**: Router detection restricted to private IP ranges only + +#### Nginx Authentication +- Fixed session cookie name mismatch (`session_id` → `session`) across all nginx auth checks +- LND Connect info endpoint now properly authenticated + +### Container Reliability + +#### Memory Limits (prevents OOM crashes) +- All 37 containers in `first-boot-containers.sh` now have `--memory=` limits +- Automatic RAM tier detection — reduced limits on 8GB machines +- Prevents a single runaway container from crashing the entire system + +#### Smart Container States +- New `exited` state distinguishes crashed containers from intentionally stopped ones +- Crashed containers show red "crashed" badge with restart button +- Health-aware status: "healthy" (green), "starting up" (yellow spinner), "unhealthy" (orange pulse) +- Restart button added next to Stop on running containers + +#### Crash Recovery Improvements +- Boot recovery and health monitor now coordinate via shared flag (no more restart cascade) +- User-stopped containers tracked in `user-stopped.json` — survive reboots without auto-restart +- Boot recovery uses tiered ordering: databases → core → services → apps → UIs +- Health monitor waits for boot recovery to complete before starting checks + +### UI Improvements + +#### Home Dashboard +- Wallet card now matches Web5 wallet display +- New Transactions modal with full history (incoming/outgoing, amounts, confirmations) +- Transactions button in header — switches to "Incoming" badge when pending transactions exist +- Dev faucet button (dev mode only) with mutable wallet state +- Fixed system stats crash (`cpu_usage_percent` field name mismatch) + +#### Apps & App Details +- Container restart button (icon) next to Stop on all running apps +- Exited/crashed containers show "Restart" instead of "Start" with red styling +- Removed broken sticky header from Apps page +- Health-aware status badges throughout + +#### Mesh, Cloud, Settings & More +- Mesh view overhaul with improved layout +- Glass button styling updates across components +- New BaseModal and ToggleSwitch components +- Updated translations (English + Spanish) +- Spotlight search improvements + +### Infrastructure + +#### LND Connect +- Tor hidden service now exposes LND REST port (8080) for remote wallet connections +- Fixed in ISO build script, deploy script, and live servers + +#### Dev Environment +- Mock backend has mutable wallet state (faucet/send/receive actually change balances) +- Testnet stack option auto-starts Podman machine on macOS +- Boot mode simulation for testing startup screens + +## [1.2.0] - 2026-03-14 + +### Fixed + +#### Crash Loop Resolution +- Identified and fixed UFW blocking Podman subnet DNS resolution on .228 +- Fixed archy-nbxplorer, btcpay-server, mempool-web, immich crash loops (3500+ restarts) +- All 32 containers stable with zero crash loops after fix + +#### DWN Sync Performance +- Made `dwn.sync` endpoint non-blocking (background task with polling) +- Added 90-second overall sync timeout to prevent indefinite blocking +- Deduplicated peer onion addresses before syncing +- Batched message pushes (50/batch) instead of one-at-a-time over Tor +- Fixed HTTP handler to process all messages in batch (was only first) + +#### Backup Reliability +- Increased backup.create rate limit from 3/600 to 10/600 for testing +- Increased backup.restore rate limit from 2/600 to 5/600 + +#### Deploy Script +- Added `set -eo pipefail` for pipe error detection +- Fixed duplicate variable initialization +- Fail on missing binary in --both path (was silently ignored) +- Added post-deploy health check on .198 + +### Added + +#### Cross-Node Test Suite +- US-08: DWN sync tests — 50/50 pass (register, write, sync, query bidirectional) +- US-10: Backup/restore tests — 80/80 pass (create, list, verify, delete × 10 × 2 nodes) +- US-15: Boot recovery tests — .228 9/9 pass (32/32 containers survive 3 reboots) +- `trigger_sync_and_wait()` helper for polling async DWN sync + +#### did:dht Integration Planning +- Architecture document: `docs/did-dht-integration.md` +- BEP-44 mutable DHT items, DNS packet encoding, z-base-32 identifiers +- Publication/resolution flows, `mainline` crate selection, security notes + +#### DWN Protocol Definitions +- 4 Archipelago DWN protocols documented in `docs/dwn-protocols.md` +- Node Identity Announcements (public) +- File Sharing Catalog (public) +- Federation State (private) +- App Deployment Requests (private) +- Auto-registration of all 4 protocols on backend startup + +#### Deploy Script Improvements +- `--dry-run` flag shows what would be deployed without executing +- Works with all other flags (--live, --both, --frontend-only) + +#### ISO/First-Boot Improvements +- Auto-create swap file on first boot (50% RAM, min 2GB, max 8GB) +- Tiered container startup ordering in first-boot script +- Tier 1: Databases, Tier 2: Core Services (5s delay), Tier 3: Applications (5s delay) + +### Security + +#### Backend Hardening +- Rate limiting on federation endpoints (join 5/60s, invite 10/300s) +- DWN message data size limit (10MB max) +- Container security: cap-drop ALL, no-new-privileges, per-app memory limits +- Input validation: path traversal protection on identity/DID endpoints +- Error sanitization: internal paths stripped from error messages + +## [1.1.0] - 2026-03-13 + +### Added + +#### Nostr Identity in Onboarding +- Auto-generate secp256k1 Nostr keypair during identity creation +- Onboarding shows both DID (`did:key:z...`) and Nostr ID (`npub1...`) with copy buttons +- Real Ed25519 signature verification in onboarding verify step +- Real encrypted backup creation in onboarding backup step + +#### NIP-07 Iframe Signing +- `nostr-provider.js` injected into all proxied iframe apps via nginx `sub_filter` +- `window.nostr` interface: `getPublicKey()`, `signEvent()`, `getRelays()` +- Signing consent modal with "Remember for this app" option +- `node.nostr-sign` RPC endpoint — signs events with node-level Nostr key +- NIP-04 and NIP-44 encrypt/decrypt RPC endpoints for iframe apps +- noStrudel Nostr client added to marketplace as iframe app + +#### File Sharing Across Nodes +- Content catalog with add/remove/browse over Tor +- Three access modes: `free`, `peers_only` (DID-authenticated), `paid` (cashu tokens) +- Availability controls: `AllPeers`, `Nobody`, `Specific` (DID allowlist) +- Peer Files view in Cloud page for browsing federated peers' shared content +- Content download from peers via Tor SOCKS proxy + +#### DWN Multi-Node Sync +- Bidirectional DWN message replication over Tor between federated nodes +- Protocol and message sync via `/dwn` HTTP endpoint +- DWN sync status in Federation dashboard with "Sync Now" button +- DWN management section in Web5 page (protocols, messages, sync targets) + +#### Node Visualization Map +- D3.js force-directed network topology graph +- Nodes colored by trust level (green/amber/red), opacity by online status +- Self node centered, draggable peer nodes with tooltips +- List/Map tab switcher in Federation page with localStorage persistence + +#### Tor Address Rotation +- `tor.rotate-service` RPC: generates new .onion address with 24h transition +- Automatic propagation to Nostr relays and federation peers +- `tor.cleanup-rotated` for expired transition directories +- Per-app Tor toggle (`tor.toggle-app`) to enable/disable Tor per service +- Tor management UI in Settings with rotate button and per-app toggles + +#### Boot Container Recovery +- All stopped containers automatically started on backend boot +- Fixes clean reboot scenario where PID marker was removed by systemd + +#### Monitoring & Testing +- Federation health check script (cron every 5min, CSV + JSON output) +- Uptime monitor with authenticated RPC access +- `test-first-install.sh` — 8-check post-install verification +- `test-nip07.sh` — 11-check NIP-07 signing validation +- `test-tor-rotation.sh` — 10-check Tor rotation lifecycle +- `test-integration-full.sh` — 23-check full integration test +- `test-failure-recovery.sh` — 5-scenario failure injection + recovery + +### Fixed +- Health monitor webhook gate no longer blocks auto-restart and notifications +- Monitoring alerts now trigger webhook delivery (DiskWarning, ContainerCrash) +- Tor hostname reading with `tor-hostnames` readable cache (0700 system Tor dirs) +- Tor rotation clears hostname cache before reading new address +- Rotation restarts system Tor (not just archy-tor container) +- NIP-07 signing uses node-level key (matches `getPublicKey()`) +- DWN sync URL uses port 80 (nginx/Tor) instead of 5678 +- DWN `/dwn` POST endpoint allows unauthenticated peer sync +- DWN message handler supports both single and batch message formats + +## [0.8.0-rc1] - 2026-03-11 + +### Added + +#### W3C Identity & Credentials +- W3C DID Core v1.0 compliant DID Document generation (`did:key` method) +- DID Document verification and cross-node resolution over Tor +- JSON-LD Verifiable Credentials (VC Data Model 2.0, Ed25519Signature2020 proofs) +- Verifiable Presentation creation with selective disclosure +- Credentials management UI at `/dashboard/web5/credentials` + +#### Decentralized Web Node (DWN) +- DWN message store with CRUD, protocol registration, and query interface +- DWN HTTP API (`POST /dwn`, `GET /dwn/health`) +- Bidirectional peer sync over Tor via SOCKS proxy +- DWN management UI in Web5 page with protocol browser + +#### Multi-Node Federation +- Node federation protocol with invite codes (`fed1:` prefix), trust levels, state sync +- Federation dashboard at `/dashboard/server/federation` +- Federated app deployment to trusted peers over Tor +- Architecture documented in `docs/multi-node-architecture.md` + +#### Decentralized Marketplace +- NIP-78 Nostr-based app manifest discovery across relays +- Trust scoring (0-100) based on DID verification, relay consensus, federation trust +- App manifest publishing with Nostr secp256k1 signing +- Community marketplace tab in App Store with trust score badges + +#### Networking +- VPN integration (Tailscale + WireGuard) with keypair generation and status display +- Mesh networking via Meshtastic LoRa devices with node discovery +- DNS-over-HTTPS configuration (Cloudflare, Google, Quad9, Mullvad, Custom) +- WiFi/Ethernet configuration via `nmcli` with scan-and-connect modal +- Network interfaces display in Server page + +#### Hardware Wallet Support +- PSBT signing flow (create, QR display, finalize, broadcast) +- USB hardware wallet detection (ColdCard, Trezor, Ledger) +- Hardware wallet signing UI in LND views + +#### System Management +- System monitoring (CPU, RAM, disk gauges on Dashboard) +- Automatic update system with download, apply, rollback, and scheduling +- Disk space management with auto-cleanup at 90% usage +- Container health monitoring with auto-recovery (max 3 restart attempts) +- Crash recovery via PID-file detection and container snapshot restoration +- Graceful shutdown with in-flight request draining (5s timeout) + +#### Backup & Restore +- Full backup with tar.gz + ChaCha20-Poly1305 encryption +- Backup create, list, verify, restore, delete via RPC +- USB drive detection and backup-to-USB +- Backup UI in Settings page + +#### Kiosk Mode +- Chromium kiosk with auto-restart and watchdog service +- Recovery page at `/recovery` (no auth required) +- Kiosk keyboard shortcuts (Ctrl+Shift+R/H/Q) +- Systemd services for kiosk and watchdog + +#### ARM64 Support +- Cross-compilation for aarch64 with rustls-tls +- All 6 core apps verified with multi-arch images +- Parameterized ISO build script (`ARCH=arm64`) +- RPi 5 testing guide + +#### Testing +- 236 frontend tests across 17 test files (Vitest) +- 124+ backend tests (cargo test) +- Playwright visual regression suite (12 pages) +- Chaos testing (SIGKILL recovery, concurrent RPC, rapid restarts) +- App lifecycle testing and dependency chain verification +- 1-week continuous uptime monitoring + +#### Documentation +- Developer guide, API reference (100+ endpoints), app developer SDK guide +- 5 Architecture Decision Records (Podman, DID:key, Nostr, Tor, ChaCha20) +- Release process, canary deploy, quality baseline documentation + +### Changed +- Settings sections use `glass-card` instead of `path-option-card` +- Web3 card shows "Coming Soon" badges instead of fake data +- Network diagnostics moved from Settings to Server page +- Removed `core/startos/` (2MB of dead code, zero dependencies) + +### Fixed +- CSRF protection on all state-changing RPC calls +- CORS restricted to same-origin (removed `Access-Control-Allow-Origin: *`) +- Nginx security headers (X-Frame-Options, CSP, X-Content-Type-Options) +- All 24 silent catch blocks now log in dev mode +- Zero `console.log` outside dev gate, zero `any` types + +### Security +- CSRF token validation on all state-changing endpoints +- Same-origin CORS policy +- Nginx security headers (SAMEORIGIN, nosniff, CSP, Referrer-Policy) +- Container security hardened (readonly root, dropped caps, non-root, no-new-privileges) +- Secrets rotation with AES-256-GCM and automatic scheduling + +## [0.5.0-beta] - 2026-03-11 + +### Added + +#### Security Hardening +- Session inactivity expiry (24h), max 5 concurrent sessions with oldest eviction +- Session rotation on password change (invalidates all other sessions) +- Container security: `--cap-drop=ALL`, `--security-opt=no-new-privileges:true`, read-only root +- Secrets rotation with AES-256-GCM encryption and metadata tracking +- Path traversal prevention (nginx regex blocks + client-side sanitizePath) +- Cookie-based auth for File Browser (removed token from URLs) +- Login rate limiting (5 failures per 60s per IP) +- TOTP two-factor authentication with backup codes + +#### Performance +- Backend startup: ~100ms +- Frontend bundle: ~105 KB gzipped initial load +- WebSocket heartbeat (30s ping/pong) with exponential backoff reconnection +- Server-side 5-minute inactivity timeout for stale WebSocket connections +- Real-time install progress reporting via WebSocket during container pulls +- Connection state machine (connecting/connected/disconnecting/disconnected) + +#### Apps & Integrations +- Pinned all container images to specific versions (no `:latest` tags) +- Fedimint and Fedimint Gateway with auto-LND detection +- IndeedHub virtual app integration +- Expanded read-only root filesystem support (electrs, nostr-relay, ollama) +- Dependency chain validation (Bitcoin → Electrs → Mempool, Bitcoin → LND) + +#### Documentation +- Comprehensive user guide (docs/user-guide.md) +- Beta release checklist (docs/BETA-RELEASE-CHECKLIST.md) +- 72-hour stability test script + +### Fixed +- Penpot hardcoded secret key replaced with SHA256-derived key +- WebSocket reconnection reliability after network interruption + +## [0.1.0] - 2026-01-28 + +### 🎉 Initial Release + +The first production release of Archipelago - a next-generation Bitcoin Node OS for macOS. + +### Added + +#### Core Features +- **Native Rust Backend** - High-performance async server using Tokio and Hyper +- **Modern Vue.js Frontend** - Beautiful glassmorphism UI with Tailwind CSS +- **Docker Integration** - Seamless container orchestration via Docker Desktop +- **Real-time WebSocket** - Live updates for container status and system events +- **Authentication System** - Secure user login and session management + +#### Bitcoin & Lightning +- **Bitcoin Core** - Full node in regtest mode with custom UI +- **LND** - Lightning Network Daemon with dedicated interface +- **BTCPay Server** - Bitcoin payment processing +- **Mempool Explorer** - Blockchain visualization and analytics + +#### Applications +- **Penpot** - Open-source design and prototyping platform +- **Endurain** - Self-hosted fitness tracking +- **Morphos** - File conversion utility +- **Nextcloud** - Cloud storage and file management +- **Home Assistant** - Home automation hub +- **Grafana** - Metrics and monitoring dashboards +- **OnlyOffice** - Document editing suite +- **SearXNG** - Privacy-respecting search engine +- **Fedimint** - Federated e-cash system + +#### User Interface +- **Onboarding Flow** - Guided setup for new users +- **Dashboard** - Real-time system overview +- **My Apps** - Alphabetically sorted app management +- **Cloud Interface** - File management by type (Documents, Photos, Videos, Music) +- **Web5 Explorer** - Decentralized identity and data management +- **Settings** - System configuration and preferences +- **Custom Launch Pages** - Dedicated UIs for Bitcoin Core and LND + +#### Technical Features +- **Container Runtime Abstraction** - Support for Docker and Podman +- **Dynamic Package Discovery** - Automatic detection of running containers +- **Health Monitoring** - Container status and health checks +- **Data Persistence** - Docker volumes for app data +- **Network Isolation** - Secure container networking +- **Resource Management** - CPU and memory allocation + +### Architecture + +- **Backend**: Rust + Tokio + Hyper + WebSocket +- **Frontend**: Vue 3 + TypeScript + Vite + Pinia +- **Styling**: Tailwind CSS + Custom Glassmorphism +- **Containers**: Docker Compose + Dockerode API +- **Build System**: Cargo + npm + macOS App Bundle + +### Known Limitations + +- Requires Docker Desktop (23.0+) +- macOS only (Intel and Apple Silicon) +- Single-user mode +- No auto-updates (manual download required) +- Ollama excluded due to image size +- Manual Docker container management + +### System Requirements + +- macOS 10.15 (Catalina) or later +- 8GB RAM minimum (16GB recommended) +- 20GB free disk space (50GB+ for blockchain data) +- Docker Desktop 23.0 or later +- Internet connection for initial container downloads + +### Installation + +1. Download `Archipelago-0.1.0-macOS.dmg` +2. Open the DMG and drag Archipelago to Applications +3. Install Docker Desktop if not already installed +4. Launch Archipelago from Applications +5. Access the UI at http://localhost:8100 + +### Security + +- **Code Signed**: Yes (Developer ID) +- **Notarized**: Yes (Apple notarization) +- **Sandboxed**: No (requires full disk access for Docker) +- **Hardened Runtime**: Yes +- **Gatekeeper**: Compatible + +### Documentation + +- README.md - Project overview +- BUILD_MACOS.md - Build instructions +- DEPLOYMENT_CHECKLIST.md - Release process +- docs/ - Detailed documentation + +### Credits + +Built with: +- Rust (backend) +- Vue.js (frontend) +- Docker (containers) +- Alpine Linux (inspiration) +- Parmanode (Bitcoin scripts) +- And many open-source dependencies + +### License + +[Specify your license here] + +--- + +## Version History + +### 0.1.0 - 2026-01-28 +Initial public release + +--- + +## Future Roadmap + +See GitHub Issues for planned features: +- [ ] Auto-update system +- [ ] Multi-user support +- [ ] Native container runtime (no Docker Desktop) +- [ ] iOS companion app +- [ ] Hardware wallet integration +- [ ] Tor integration +- [ ] VPN/Tailscale support +- [ ] Backup/restore functionality +- [ ] Mac App Store distribution +- [ ] Windows and Linux builds + +## Contributing + +See CONTRIBUTING.md for development setup and guidelines. + +## Support + +- GitHub Issues: Report bugs and request features +- Documentation: See `/docs` directory +- Community: [Discord/Telegram/Forum link] diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..4f3d91ed --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,84 @@ +# Archipelago — agent guide + +## ✅ Single-node production gate is GREEN (2026-06-23) + +`tests/lifecycle/run-gate.sh` is **5/5 on .228, 0 failures** — the single-node exit +criterion is met and the priority banner is demoted. Next exit-criteria: the +**multinode pass** (`docs/multinode-testing-plan.md`) and workstreams B/C/D. + +**For day-to-day work, use `docs/UNIFIED-TASK-TRACKER.md`** — the consolidated, +priority-ordered "what's left" list across the 1.8.0 OTA and master-plan docs +(fastest/simplest tasks first). It supersedes hunting through the two source docs +below for open items; those remain the narrative/history. + +**Read `docs/PRODUCTION-MASTER-PLAN.md` first** — it is still the authoritative plan +for the north star: a world-class, **developer-ready app platform** where every app +is manifest-driven, manifests ship via the **signed registry** (not OTA disk files), +and **third-party developers publish apps via an external/decentralized registry** — +all rootless, secure, robust, and 100%-uptime-capable. It no longer overrides all +ad-hoc direction now that the gate is green, but it remains the source of truth for +sequencing the remaining workstreams. + +Detailed sub-plans (all linked from the master): +- App platform / packaging phases + security model → `docs/APP-PACKAGING-MIGRATION-PLAN.md` +- Registry-distributed manifests (in progress) → `docs/registry-manifest-design.md` +- External/decentralized marketplace for devs → `docs/marketplace-protocol.md` +- Current per-app state → `docs/archive/app-registry-status-2026-06-21.md` +- Production test gate (exit criterion) → `tests/lifecycle/TESTING.md` + +## Commit & push every unit of work (never violate) + +**The #1 process rule: work is not "done" until it is committed AND pushed.** This +exists because finished work has been lost/clobbered by sitting uncommitted in the +shared tree across agents and sessions. To prevent that: + +- **Commit each feature/fix the moment it works** — one focused, self-contained + commit per logical change (it compiles and its targeted tests pass). Do not let + unrelated changes accumulate uncommitted. +- **Push immediately after committing** so nothing lives only on one machine. `main` + is protected → push via `git push gitea-ai main` (account `ai`, see the memory + note); feature branches push to their own remote. +- **Never leave a stack of finished work uncommitted** overnight or when handing off + between agents — if you must pause mid-change, commit a clearly-labelled WIP + checkpoint rather than leaving it dirty. +- **Stage explicitly by path** (`git add <paths>`) when another agent's uncommitted + work shares the tree — never `git add -A` / `git commit -a`, which clobbers or + entangles their changes. +- **Never commit or push secrets** (mnemonics, private keys, API tokens). Signing is + done offline; artifacts (catalog/manifest) are signed, not the keys. +- Commit messages end with the `Co-Authored-By: Claude …` trailer. + +## Invariants (never violate) + +- **Rootless Podman only.** No rootful, no Docker-socket mounts, no privileged + containers unless explicitly approved. +- **No per-app Rust installers / no OS-level reliance.** Apps are declarative; + the orchestrator owns the lifecycle. `install_immich_stack` (hardcoded + `podman run` + `sudo chown`) is the anti-pattern being deleted, not a template. +- **Secrets are manifest-declared** (`generated_secrets`, materialised by + `container::secrets`, 0600/rootless) — never hardcoded, per-app, or logged. +- **Migrations never destroy data** — preserve `/var/lib/archipelago/<app>`, + secrets, credentials, ports, and adoption container names; keep a rollback path. +- **Verify on the real node .228 before any tag.** (Fleet-wide multinode + verification is a separate plan: `docs/multinode-testing-plan.md`.) + +## Build / verify + +- Rust workspace root is `core/` (no Cargo.toml at repo root). `cargo` from `core/`. +- If a `cargo test`/build hits `rust-lld: undefined hidden symbol`, it's + incremental-cache corruption — rebuild with `CARGO_INCREMENTAL=0`. +- Frontend: `neode-ui/` → `npm run build` outputs to `web/dist/neode-ui/`. + Grep the built bundle for new strings before shipping (build can silently no-op). +- App manifests load from disk on nodes at `/opt/archipelago/apps/*/manifest.yml` + (today); the goal is to distribute them via the signed catalog instead. + +## Production test gate (definition of done) + +`tests/lifecycle/run-gate.sh` green across install / UI / stop / start / restart / +reinstall / reboot-survive / archipelago-restart-survive / uninstall — **5× on +.228** (`ARCHY_ITERATIONS=5`). **Run the gate ON the node** (it uses local podman/systemctl/bitcoin +probes), not via RPC from another host. **✅ GREEN 2026-06-23 (5/5, 0 not-ok)** — keep it +green (re-run after orchestrator/lifecycle changes); regressions are top priority again. +**Multinode testing (.198 + the rest of the fleet) is a SEPARATE plan** — +`docs/multinode-testing-plan.md` — not part of this single-node gate criterion, and is +the next exit criterion now that single-node is green. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..90378f2b --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,19 @@ +# Code of Conduct + +## Our standard + +Be direct, respectful, and focused on the work. Healthy disagreement is welcome; +harassment, personal attacks, and discriminatory language are not. + +## Scope + +This code of conduct applies to project repositories, issue trackers, pull +requests, documentation, chat, and community spaces connected to Archipelago. + +## Enforcement + +Maintainers may edit, hide, or remove comments and may restrict participation +for behavior that makes collaboration unsafe or unproductive. + +Report conduct concerns privately through the repository owner account or the +private contact channel listed on the project homepage. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..ab6cdb0b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,100 @@ +# Contributing to Archipelago + +This project is preparing for public developer contribution. The highest-value +contributions are focused fixes, tests, app manifests, documentation +improvements, and clear bug reports with reproducible evidence. + +## Development setup + +### Frontend + +```bash +cd neode-ui +npm install +npm start +npm run type-check +npm test +``` + +### Backend + +```bash +cd core +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-features +``` + +Linux is required for host integration work involving Podman, systemd, +networking, or image builds. Frontend development works locally with the mock +backend. + +## App manifests + +App packages live under `apps/<app-id>/manifest.yml` and use the schema +documented in [docs/app-manifest-spec.md](docs/app-manifest-spec.md). Validate +before submitting: + +```bash +./scripts/validate-app-manifest.sh apps/<app-id>/manifest.yml +python3 scripts/generate-app-catalog.py +python3 scripts/check-app-catalog-drift.py --release --strict +``` + +App submissions must: + +- pin container image versions; +- avoid hardcoded secrets; +- use `security.no_new_privileges: true`; +- use `security.readonly_root: true` unless the manifest explains why writable + root is required; +- request only necessary Linux capabilities; +- store durable data under `/var/lib/archipelago/<app-id>/`; +- define truthful health checks and launch interfaces for user-facing UIs. + +## Code style + +- Rust: prefer `?` over `unwrap()`/`expect()` in production paths. +- Rust: use `tracing` for structured logs. +- TypeScript: avoid `any`; use explicit types or `unknown`. +- Vue: prefer `<script setup lang="ts">`. +- Keep changes scoped; do not mix drive-by refactors with behavioral changes. +- Remove dead code rather than commenting it out. +- Add tests for new behavior and regression tests for bug fixes. + +## Pull requests + +1. Open one focused PR per behavior or documentation change. +2. Explain what changed, why it changed, and how it was verified. +3. Include screenshots for UI changes. +4. Link relevant issues or docs. +5. Keep generated catalog changes in sync with manifest changes. + +Suggested commit format: + +```text +feat: add backup scheduling +fix: reject unsafe manifest volume +docs: clarify app deployment flow +test: cover catalog drift check +``` + +## Reporting bugs + +Include: + +- exact version or commit; +- host platform and architecture; +- steps to reproduce; +- expected and actual behavior; +- logs from the relevant component; +- screenshots for UI issues. + +## Security + +Do not report vulnerabilities in public issues. Follow [SECURITY.md](SECURITY.md). + +## License + +By contributing, you agree that your contribution is licensed under the +project's MIT License. diff --git a/INSTALL.sh b/INSTALL.sh new file mode 100755 index 00000000..33feaae8 --- /dev/null +++ b/INSTALL.sh @@ -0,0 +1,166 @@ +#!/bin/bash +# Archipelago Installation Script +# This script installs all dependencies needed to run the Archipelago project + +set -e + +echo "============================================" +echo "Archipelago Dependencies Installation" +echo "============================================" +echo "" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Function to print status +print_status() { + if [ $1 -eq 0 ]; then + echo -e "${GREEN}✓${NC} $2" + else + echo -e "${RED}✗${NC} $2" + fi +} + +# Check and install Homebrew (macOS package manager) +echo "Checking Homebrew..." +if command_exists brew; then + print_status 0 "Homebrew already installed" +else + echo -e "${YELLOW}Installing Homebrew...${NC}" + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + + # Add Homebrew to PATH for Apple Silicon Macs + if [[ $(uname -m) == 'arm64' ]]; then + echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile + eval "$(/opt/homebrew/bin/brew shellenv)" + fi + print_status 0 "Homebrew installed" +fi + +echo "" + +# Install Rust +echo "Checking Rust..." +if command_exists rustc && command_exists cargo; then + print_status 0 "Rust already installed ($(rustc --version))" +else + echo -e "${YELLOW}Installing Rust...${NC}" + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + source "$HOME/.cargo/env" + print_status 0 "Rust installed ($(rustc --version))" +fi + +echo "" + +# Install Node.js +echo "Checking Node.js..." +if command_exists node && command_exists npm; then + NODE_VERSION=$(node --version) + print_status 0 "Node.js already installed ($NODE_VERSION)" +else + echo -e "${YELLOW}Installing Node.js...${NC}" + brew install node + print_status 0 "Node.js installed ($(node --version))" +fi + +echo "" + +# Install Podman +echo "Checking Podman..." +if command_exists podman; then + print_status 0 "Podman already installed ($(podman --version))" +else + echo -e "${YELLOW}Installing Podman...${NC}" + brew install podman + print_status 0 "Podman installed" + + echo -e "${YELLOW}Initializing Podman machine...${NC}" + podman machine init + podman machine start + print_status 0 "Podman machine initialized and started" +fi + +echo "" + +# Install PostgreSQL +echo "Checking PostgreSQL..." +if command_exists psql; then + print_status 0 "PostgreSQL already installed" +else + echo -e "${YELLOW}Installing PostgreSQL...${NC}" + brew install postgresql@15 + print_status 0 "PostgreSQL installed" + + echo -e "${YELLOW}Starting PostgreSQL service...${NC}" + brew services start postgresql@15 + print_status 0 "PostgreSQL service started" +fi + +echo "" +echo "============================================" +echo "Installing Project Dependencies" +echo "============================================" +echo "" + +# Install frontend dependencies +echo "Installing frontend dependencies (neode-ui)..." +cd neode-ui +npm install +print_status 0 "Frontend dependencies installed" +cd .. + +echo "" + +# Install custom app dependencies +echo "Installing custom app dependencies..." + +for app in did-wallet morphos-server router; do + if [ -d "apps/$app" ]; then + echo " - Installing $app dependencies..." + cd "apps/$app" + npm install + cd ../.. + print_status 0 "$app dependencies installed" + fi +done + +echo "" + +# Build Rust backend +echo "Building Rust backend..." +cd core +cargo build +print_status 0 "Backend built successfully" +cd .. + +echo "" +echo "============================================" +echo "Installation Complete!" +echo "============================================" +echo "" +echo "Next steps:" +echo "" +echo "1. Start the backend:" +echo " cd core" +echo " cargo run --bin archipelago" +echo "" +echo "2. In another terminal, start the frontend:" +echo " cd neode-ui" +echo " npm run dev" +echo "" +echo "3. Open your browser to:" +echo " http://localhost:8100" +echo "" +echo "For more information, see:" +echo " - README.md" +echo " - docs/developer-guide.md" +echo " - apps/QUICKSTART.md" +echo "" diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..85278aa1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dorian and the Archipelago Project contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..b2dcc409 --- /dev/null +++ b/NOTICE @@ -0,0 +1,73 @@ +# Archipelago — Third-Party Notices + +Archipelago is licensed under the MIT License (see LICENSE). +This file lists third-party components included in this repository and its +release artifacts, with their licenses and required attributions. + +## Embedded / vendored components + +- **FIPS mesh networking** — https://github.com/jmcorgan/fips + Copyright (c) 2026 Johnathan Corgan. MIT License. + Used as the embedded mesh VPN in the OS (`fips` daemon, pinned v0.4.1) and + compiled into the Android companion app (`Android/rust/archy-fips-core`). + +- **QR Code Generator for JavaScript** — http://www.d-project.com/ + Copyright (c) 2009 Kazuhiko Arase. MIT License. + Vendored at `docker/lnd-ui/qrcode.js` and `docker/electrs-ui/qrcode.js` + (original headers preserved). + +- **nostr-rs-relay** — https://github.com/scsibug/nostr-rs-relay — MIT License. + Binary extracted into the OS image at `/opt/archipelago/bin/`. + +- **Reticulum (RNS) and LXMF** — https://github.com/markqvist/Reticulum + Copyright Mark Qvist. Distributed under the Reticulum License (an MIT-style + license with field-of-use restrictions: no use in systems designed to harm + human beings, and no use in AI/ML training datasets). The optional + `archy-reticulum-daemon` binary bundles RNS 1.3.5 and LXMF 1.0.1. The + Reticulum License is NOT an OSI-approved open-source license; it applies + only to that optional component, not to Archipelago itself. + +## Fonts + +- **Montserrat** — SIL Open Font License 1.1 + (`neode-ui/public/assets/fonts/Montserrat/OFL.txt`). +- **Open Sans** — Apache License 2.0 + (`neode-ui/public/assets/fonts/Open_Sans/LICENSE.txt`). + +## Artwork and icons + +- **Mesh device artwork** (`neode-ui/public/assets/img/mesh-devices/`): + device illustrations from the Meshtastic project — https://meshtastic.org + © Meshtastic contributors, GPL-3.0. Meshtastic® is a registered trademark + of Meshtastic LLC. See the ATTRIBUTION.md in that directory. +- Some UI icons are derived from **game-icons.net** (CC BY 3.0 — see + ATTRIBUTION.md in `neode-ui/public/assets/icon/`) and **pixelarticons** + (MIT, https://github.com/halfmage/pixelarticons). +- Third-party application logos under `neode-ui/public/assets/img/app-icons/` + and `service-icons/` are trademarks of their respective owners, used solely + to identify the corresponding applications. No endorsement is implied. + +## Original media + +All demo content (music, photos, posters in `demo/`), UI sound effects, +background images, and intro video in `neode-ui/public/assets/` are original +works created and owned by the Archipelago project author, released with the +project. The welcome voice line (`welcome-noderunner.mp3`) was generated with +ElevenLabs TTS under a commercial-use plan. + +## Redistributed software (ISO and container registry) + +The Archipelago OS image is based on Debian and redistributes Debian packages +(including the Linux kernel, GRUB, and non-free firmware/microcode blobs +required for hardware support); per-package license texts are preserved at +`/usr/share/doc/*/copyright` in the installed system, and corresponding source +is available via Debian (https://snapshot.debian.org) as referenced in each +release's notes. Container images offered through the app catalog and mirror +registry remain under their upstream licenses (including GPL/AGPL software +such as mempool, Nextcloud, Vaultwarden, SearXNG, PhotoPrism, Immich, +Jellyfin, MariaDB, AdGuard Home, and strfry); source links are provided in +the app catalog. The modified mempool-frontend image is built from +`docker/mempool-frontend/` in this repository (AGPL-3.0 corresponding source). + +Full per-crate and per-package license inventories for release binaries are +generated at build time (see THIRD-PARTY-LICENSES files in release artifacts). diff --git a/README.md b/README.md new file mode 100644 index 00000000..f0e24993 --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# Archipelago + +> Self-sovereign Bitcoin node OS and manifest-driven app platform. + +Archipelago is a bootable personal server OS for Bitcoin infrastructure, +self-hosted apps, mesh communication, decentralized identity, and federation. +Apps are packaged as declarative `manifest.yml` files and run as rootless +Podman containers managed by the Rust backend. + +[![Debian 13](https://img.shields.io/badge/Debian-13%20Trixie-a80030)](https://www.debian.org/) +[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) +[![Rust](https://img.shields.io/badge/rust-stable-orange)](https://www.rust-lang.org/) +[![Vue.js](https://img.shields.io/badge/vue.js-3.5-brightgreen)](https://vuejs.org/) +[![Version](https://img.shields.io/badge/version-1.8.0--alpha-blue)]() + +## What is here + +- `core/` - Rust workspace: backend API, container runtime, security, OpenWrt + helpers, and performance/resource management. +- `neode-ui/` - Vue 3 + TypeScript frontend. +- `apps/` - app manifests and custom app container sources. +- `docker/` - supporting container build contexts for UI companion surfaces. +- `image-recipe/` - bootable image/ISO build inputs. +- `Android/` - Android companion app. +- `scripts/` - development, release, deployment, and validation tooling. +- `docs/` - architecture, app packaging, operations, API, and roadmap docs. + +## Platform model + +Archipelago is built as a developer-ready app platform, not a fixed appliance: + +- Apps are declared in `apps/<app-id>/manifest.yml`. +- The Rust parser in `core/container/src/manifest.rs` is the canonical schema. +- The orchestrator compiles manifests to rootless Podman/Quadlet runtime state. +- App data lives under `/var/lib/archipelago/<app-id>/`. +- Secrets are generated or read from `/var/lib/archipelago/secrets/` and + injected through Podman secrets rather than static environment values. +- Release and app catalogs are signed and verified against a pinned trust + anchor. + +Start with: + +- [Architecture](docs/architecture.md) +- [Developer Guide](docs/developer-guide.md) +- [App Developer Guide](docs/app-developer-guide.md) +- [App Manifest Spec](docs/app-manifest-spec.md) +- [Nostr Git Source Hosting Plan](docs/nostr-git-source-hosting.md) +- [Operations Runbook](docs/operations-runbook.md) +- [Troubleshooting](docs/troubleshooting.md) + +## Quick start + +### Frontend + +```bash +cd neode-ui +npm install +npm start +``` + +The dev UI runs at `http://localhost:8100` with a mock backend on `:5959`. + +### Backend + +```bash +cd core +cargo build +cargo test --all-features +``` + +Linux is the supported backend runtime and release-build target. macOS is fine +for frontend work and many Rust compile/test loops, but host integration tests +that touch Podman, systemd, networking, or image build paths require Linux. + +### App manifests + +```bash +./scripts/validate-app-manifest.sh apps/filebrowser/manifest.yml +python3 scripts/generate-app-catalog.py +python3 scripts/check-app-catalog-drift.py --release --strict +``` + +`scripts/generate-app-catalog.py` requires Python with PyYAML installed. + +## Documentation map + +| Doc | Purpose | +|-----|---------| +| [Architecture](docs/architecture.md) | System layers, crates, data paths, security model | +| [Developer Guide](docs/developer-guide.md) | Local setup, code workflow, testing | +| [API Reference](docs/api-reference.md) | JSON-RPC API overview | +| [App Developer Guide](docs/app-developer-guide.md) | How to package and test apps | +| [App Manifest Spec](docs/app-manifest-spec.md) | Manifest schema and validation rules | +| [Nostr Git Source Hosting Plan](docs/nostr-git-source-hosting.md) | ngit/NIP-34 contribution workflow and maintainer model | +| [Apps README](apps/README.md) | Packaged app catalog overview | +| [Image Recipe](image-recipe/README.md) | Bootable image build flow | +| [Operations Runbook](docs/operations-runbook.md) | Production operations and recovery | +| [Open Source Readiness](docs/OPEN_SOURCE_READINESS.md) | Public-release cleanup checklist | +| [Roadmap](docs/ROADMAP.md) | Shipped, in-progress, and planned work | +| [Unified Task Tracker](docs/UNIFIED-TASK-TRACKER.md) | Launch hardening task list | +| [Archive](docs/archive/) | Historical plans, audits, and handoffs | + +## Contributing + +Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. For +security issues, follow [SECURITY.md](SECURITY.md) and do not open a public +issue. + +## License + +Archipelago is licensed under the [MIT License](LICENSE). Third-party notices +are listed in [NOTICE](NOTICE) and generated license inventories in component +release artifacts. diff --git a/RELEASE-NOTES-v1.0.0.md b/RELEASE-NOTES-v1.0.0.md new file mode 100644 index 00000000..ba8f7f91 --- /dev/null +++ b/RELEASE-NOTES-v1.0.0.md @@ -0,0 +1,112 @@ +# Archipelago v1.0.0 Release Notes + +**Release Date**: March 2026 +**Target Platform**: Debian 13 (Trixie) — x86_64 and ARM64 + +## What is Archipelago? + +Archipelago is a self-sovereign Bitcoin Node OS. Flash it to a USB drive, install on any x86_64 or ARM64 machine, and manage your personal server through a modern web interface. Run Bitcoin infrastructure, self-hosted apps, and Web5 identity — all from hardware you control. + +## Key Features + +### Bitcoin Infrastructure +- **Bitcoin Knots** full node with pruning support +- **LND** Lightning Network daemon with channel management UI +- **Electrs** Electrum server for wallet connectivity +- **BTCPay Server** for accepting Bitcoin payments +- **Mempool** block explorer and fee estimator +- **Fedimint** federation guardian and gateway + +### Self-Hosted Apps (20+) +- **Storage**: File Browser, Immich, PhotoPrism, Nextcloud +- **Productivity**: Penpot, OnlyOffice, Vaultwarden +- **Media**: Jellyfin +- **Search**: SearXNG (private search) +- **AI**: Ollama (local LLMs with Claude, GPT, and open models) +- **Network**: Tailscale VPN, Nginx Proxy Manager, Uptime Kuma +- **Home**: Home Assistant +- **Platform**: IndeedHub, Grafana monitoring + +### Web5 Identity +- DID-based digital identity (Ed25519 + secp256k1 dual key) +- Verifiable Credentials issuance and verification +- Decentralized Web Node (DWN) for data sync +- Nostr relay integration for node discovery + +### Federation +- DID-authenticated peer-to-peer federation +- Remote node monitoring and management +- Bilateral trust with single-use invite codes +- Tor hidden services for private communication + +### Security +- AES-256-GCM encrypted secrets at rest +- Container isolation: read-only root, capability dropping, non-root user +- TOTP two-factor authentication with backup codes +- Session management: HttpOnly cookies, SameSite=Strict, CSRF tokens +- Rate limiting on sensitive endpoints +- AppArmor profiles for container confinement +- Per-endpoint input validation + +### System +- Rust backend with JSON-RPC API (<1ms response time) +- Vue 3 frontend with glassmorphism design +- WebSocket real-time updates +- Automated OTA updates with rollback +- Tor hidden services for all apps +- Goal-based onboarding wizard +- Kiosk mode for dedicated hardware + +## Supported Hardware + +- **x86_64**: Any 64-bit PC, Intel NUC, mini PCs +- **ARM64**: Raspberry Pi 5, other ARM64 SBCs +- **Minimum**: 4GB RAM, 32GB storage (500GB+ recommended for Bitcoin) +- **Recommended**: 8GB+ RAM, 1TB+ NVMe SSD + +## Installation + +1. Download the ISO for your architecture +2. Flash to USB drive (use Balena Etcher or `dd`) +3. Boot from USB on target hardware +4. Follow the automated installer +5. Access the web UI at `http://<device-ip>` +6. Set your password and start the onboarding wizard + +## Known Limitations + +- Bitcoin initial block download takes 3-7 days depending on hardware +- Some apps (BTCPay Server, Home Assistant) open in new tab due to X-Frame-Options +- ARM64 builds may have slower container pulls due to less cached registry content +- Tor hidden service generation takes 1-2 minutes on first boot + +## Upgrade from Beta + +If upgrading from v0.5.0-beta: +1. Back up your data via Settings > Backup +2. The OTA update system will handle the upgrade automatically +3. If OTA fails, reflash with the v1.0.0 ISO (app data is preserved on separate partition) + +## Security Model + +Archipelago follows defense-in-depth: +- **Network**: Nginx reverse proxy, Tor hidden services, VPN support +- **Application**: Container isolation with Podman (rootless) +- **Data**: AES-256-GCM encryption for secrets, 0600 file permissions +- **Auth**: Argon2 password hashing, TOTP 2FA, session rotation +- **Updates**: SHA-256 verified downloads with rollback capability + +See `docs/adr/` for architectural decision records on security choices. + +## Contributing + +Archipelago is open source. To contribute: +1. Fork the repository +2. Create a feature branch (`feature/description`) +3. Follow the coding standards in `CLAUDE.md` +4. Submit a pull request with tests + +## License + +MIT License. See `LICENSE` for details. +# 2026-04-18 ISO build trigger diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..57ddeb97 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,38 @@ +# Security Policy + +## Reporting vulnerabilities + +Please do not open a public issue for a security vulnerability. + +Until a dedicated security intake address is published, report privately to the +project maintainer through the repository owner account or the private contact +channel listed on the project homepage. + +Include: + +- affected commit, version, or release; +- affected component; +- reproduction steps; +- expected impact; +- logs, proof of concept, or packet captures when relevant; +- whether the issue is already public. + +We aim to acknowledge credible reports within 48 hours and coordinate fixes +before public disclosure. + +## Scope + +Security-sensitive areas include: + +- authentication, session handling, CSRF, and rate limiting; +- release and app-catalog signature verification; +- container manifest validation and runtime compilation; +- Podman/Quadlet isolation, capabilities, volumes, and secret injection; +- backup encryption and key derivation; +- federation, Tor, Nostr, mesh, DID, and credential flows; +- Android companion pairing and device-token handling. + +## Supported versions + +Archipelago is currently pre-1.0 alpha software. Security fixes target the +current `main` branch and the latest published alpha release. diff --git a/app-catalog/README.md b/app-catalog/README.md new file mode 100644 index 00000000..e3367209 --- /dev/null +++ b/app-catalog/README.md @@ -0,0 +1,39 @@ +# Archipelago App Catalog + +Dynamic app catalog for the Archipelago marketplace. Nodes fetch this catalog to discover available apps. + +## How it works + +1. The Archipelago frontend fetches `catalog.json` from this repo +2. Apps listed here appear in every node's app store automatically +3. When a user installs an app, the backend pulls the Docker image and creates the container + +## Adding a new app + +Add an entry to `catalog.json`: + +```json +{ + "id": "my-app", + "title": "My App", + "version": "1.0.0", + "description": "What it does", + "icon": "/assets/img/app-icons/my-app.svg", + "author": "Author", + "category": "data", + "dockerImage": "146.59.87.168:3000/lfg2025/my-app:1.0.0", + "repoUrl": "https://github.com/...", + "containerConfig": { + "ports": ["8080:8080"], + "volumes": ["/var/lib/archipelago/my-app:/data"], + "env": ["NODE_ENV=production"] + } +} +``` + +For apps with hardcoded backend configs (Bitcoin, LND, etc.), `containerConfig` is optional. +For new apps, include `containerConfig` so the backend knows how to create the container. + +## Categories + +money, commerce, data, home, nostr, networking, community, development, l484 diff --git a/app-catalog/catalog.json b/app-catalog/catalog.json new file mode 100644 index 00000000..b6020cc1 --- /dev/null +++ b/app-catalog/catalog.json @@ -0,0 +1,552 @@ +{ + "version": 2, + "updated": "2026-04-22T00:00:00Z", + "registry": "146.59.87.168:3000/lfg2025", + "featured": { + "id": "indeedhub", + "banner": "/assets/img/featured/indeedhub-banner.jpg", + "headline": "Stream Sovereignty", + "description": "Bitcoin documentaries with Nostr identity.", + "tag": "NOSTR IDENTITY // YOUR NODE" + }, + "apps": [ + { + "id": "bitcoin-knots", + "title": "Bitcoin Knots", + "version": "28.1.0", + "description": "Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.", + "icon": "/assets/img/app-icons/bitcoin-knots.webp", + "author": "Bitcoin Knots", + "category": "money", + "tier": "core", + "dockerImage": "146.59.87.168:3000/lfg2025/bitcoin-knots:latest", + "repoUrl": "https://github.com/bitcoinknots/bitcoin" + }, + { + "id": "bitcoin-core", + "title": "Bitcoin Core", + "version": "28.4.0", + "description": "Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk.", + "icon": "/assets/img/app-icons/bitcoin-core.svg", + "author": "Bitcoin Core contributors", + "category": "money", + "tier": "optional", + "dockerImage": "146.59.87.168:3000/lfg2025/bitcoin:28.4", + "repoUrl": "https://github.com/bitcoin/bitcoin" + }, + { + "id": "lnd", + "title": "LND", + "version": "0.18.4", + "description": "Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.", + "icon": "/assets/img/app-icons/lnd.png", + "author": "Lightning Labs", + "category": "money", + "tier": "core", + "dockerImage": "146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta", + "repoUrl": "https://github.com/lightningnetwork/lnd", + "requires": [ + "bitcoin-knots" + ] + }, + { + "id": "btcpay-server", + "title": "BTCPay Server", + "version": "2.3.9", + "description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.", + "icon": "/assets/img/app-icons/btcpay-server.png", + "author": "BTCPay Server Foundation", + "category": "commerce", + "tier": "core", + "dockerImage": "docker.io/btcpayserver/btcpayserver:2.3.9", + "repoUrl": "https://github.com/btcpayserver/btcpayserver", + "requires": [ + "bitcoin-knots" + ] + }, + { + "id": "mempool", + "title": "Mempool Explorer", + "version": "3.0.0", + "description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.", + "icon": "/assets/img/app-icons/mempool.webp", + "author": "Mempool", + "category": "money", + "tier": "core", + "dockerImage": "146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.1", + "repoUrl": "https://github.com/mempool/mempool", + "requires": [ + "bitcoin-knots", + "electrumx" + ] + }, + { + "id": "electrumx", + "title": "ElectrumX", + "version": "1.18.0", + "description": "Electrum server indexing Bitcoin chain data for lightweight wallet queries.", + "icon": "/assets/img/app-icons/electrumx.png", + "author": "Luke Childs", + "category": "money", + "tier": "core", + "dockerImage": "146.59.87.168:3000/lfg2025/electrumx:v1.18.0", + "repoUrl": "https://github.com/spesmilo/electrumx", + "requires": [ + "bitcoin-knots" + ] + }, + { + "id": "indeedhub", + "title": "IndeeHub", + "version": "1.0.0", + "description": "Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.", + "icon": "/assets/img/app-icons/indeedhub.png", + "author": "IndeeHub", + "category": "community", + "dockerImage": "146.59.87.168:3000/lfg2025/indeedhub:1.0.0", + "repoUrl": "https://github.com/indeedhub/indeedhub" + }, + { + "id": "botfights", + "title": "BotFights", + "version": "1.2.11", + "description": "Bot competition arena with 2-player arcade fighting mode. AI bots battle in trivia challenges while humans duke it out with controllers. Built for Bitcoiners.", + "icon": "/assets/img/app-icons/botfights.svg", + "author": "BotFights", + "category": "community", + "dockerImage": "146.59.87.168:3000/lfg2025/botfights:1.2.11", + "repoUrl": "https://botfights.net", + "containerConfig": { + "ports": [ + "9100:9100" + ], + "volumes": [ + "/var/lib/archipelago/botfights:/app/server/data" + ], + "env": [ + "NODE_ENV=production", + "PORT=9100", + "FIGHT_LOOP_ENABLED=true", + "ARCHY_EMBEDDED=1" + ] + } + }, + { + "id": "gitea", + "title": "Gitea", + "version": "1.23", + "description": "Self-hosted Git service with built-in container registry, CI/CD, and package hosting.", + "icon": "/assets/img/app-icons/gitea.svg", + "author": "Gitea", + "category": "development", + "dockerImage": "docker.io/gitea/gitea:1.23", + "repoUrl": "https://gitea.com", + "containerConfig": { + "ports": [ + "3001:3000", + "2222:22" + ], + "volumes": [ + "/var/lib/archipelago/gitea/data:/data", + "/var/lib/archipelago/gitea/config:/etc/gitea" + ], + "env": [ + "GITEA__database__DB_TYPE=sqlite3", + "GITEA__server__SSH_PORT=2222", + "GITEA__server__SSH_LISTEN_PORT=22", + "GITEA__server__LFS_START_SERVER=true", + "GITEA__packages__ENABLED=true", + "GITEA__repository__ENABLE_PUSH_CREATE_USER=true", + "GITEA__repository__ENABLE_PUSH_CREATE_ORG=true", + "GITEA__security__X_FRAME_OPTIONS=" + ] + }, + "tier": "optional" + }, + { + "id": "filebrowser", + "title": "File Browser", + "version": "2.27.0", + "description": "Baseline Archipelago file manager service.", + "icon": "/assets/img/app-icons/file-browser.webp", + "author": "File Browser", + "category": "data", + "tier": "core", + "dockerImage": "146.59.87.168:3000/lfg2025/filebrowser:v2.27.0", + "repoUrl": "https://github.com/filebrowser/filebrowser", + "containerConfig": { + "ports": [ + "8083:80" + ], + "volumes": [ + "/var/lib/archipelago/filebrowser:/srv", + "/var/lib/archipelago/filebrowser-data:/data" + ], + "args": [ + "--database=/data/database.db", + "--root=/srv", + "--address=0.0.0.0", + "--port=80" + ] + } + }, + { + "id": "nostr-rs-relay", + "title": "Nostr Relay (Rust)", + "version": "0.8.0", + "description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.", + "icon": "/assets/img/app-icons/nostrudel.svg", + "author": "Nostr RS Relay", + "category": "community", + "tier": "recommended", + "dockerImage": "scsibug/nostr-rs-relay:0.8.9", + "repoUrl": "https://github.com/scsibug/nostr-rs-relay", + "containerConfig": { + "ports": [ + "8081:8080" + ], + "volumes": [ + "/var/lib/archipelago/nostr-relay:/usr/src/app/db" + ], + "env": [ + "RELAY_NAME=Archipelago Nostr Relay", + "RELAY_DESCRIPTION=Self-hosted Nostr relay on Archipelago" + ] + } + }, + { + "id": "vaultwarden", + "title": "Vaultwarden", + "version": "1.30.0", + "description": "Self-hosted password vault with zero-knowledge encryption.", + "icon": "/assets/img/app-icons/vaultwarden.webp", + "author": "Vaultwarden", + "category": "data", + "tier": "recommended", + "dockerImage": "146.59.87.168:3000/lfg2025/vaultwarden:1.30.0-alpine", + "repoUrl": "https://github.com/dani-garcia/vaultwarden", + "containerConfig": { + "ports": [ + "8082:80" + ], + "volumes": [ + "/var/lib/archipelago/vaultwarden:/data" + ] + } + }, + { + "id": "searxng", + "title": "SearXNG", + "version": "1.0.0", + "description": "Privacy-respecting metasearch engine. Search the web without tracking.", + "icon": "/assets/img/app-icons/searxng.png", + "author": "SearXNG", + "category": "data", + "tier": "recommended", + "dockerImage": "146.59.87.168:3000/lfg2025/searxng:latest", + "repoUrl": "https://github.com/searxng/searxng", + "containerConfig": { + "ports": [ + "8888:8080" + ], + "volumes": [ + "/var/lib/archipelago/searxng:/etc/searxng" + ] + } + }, + { + "id": "fedimint", + "title": "Fedimint Guardian", + "version": "0.10.0", + "description": "Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.", + "icon": "/assets/img/app-icons/fedimint.png", + "author": "Fedimint", + "category": "money", + "dockerImage": "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0", + "repoUrl": "https://github.com/fedimint/fedimint" + }, + { + "id": "fedimint-clientd", + "title": "Fedimint Client", + "version": "0.8.0", + "description": "Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.", + "icon": "/assets/img/app-icons/fedimint.png", + "author": "Fedimint", + "category": "money", + "tier": "core", + "dockerImage": "146.59.87.168:3000/lfg2025/fmcd:0.8.1", + "repoUrl": "https://github.com/minmoto/fmcd" + }, + { + "id": "fedimint-gateway", + "title": "Fedimint Gateway", + "version": "0.10.0", + "description": "Fedimint gateway service with automatic LND-or-LDK backend selection.", + "icon": "/assets/img/app-icons/fedimint.png", + "author": "Fedimint", + "category": "money", + "dockerImage": "146.59.87.168:3000/lfg2025/gatewayd:v0.10.0", + "repoUrl": "https://github.com/fedimint/fedimint", + "containerConfig": { + "ports": [ + "8176:8176", + "9737:9737" + ], + "volumes": [ + "/var/lib/archipelago/fedimint-gateway:/data", + "/var/lib/archipelago/lnd:/lnd:ro" + ] + } + }, + { + "id": "barkd", + "title": "Ark Wallet", + "version": "0.3.0", + "description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.", + "icon": "/assets/img/app-icons/bark.png", + "author": "Second", + "category": "money", + "dockerImage": "146.59.87.168:3000/lfg2025/barkd:0.3.0", + "repoUrl": "https://gitlab.com/ark-bitcoin/bark", + "containerConfig": { + "ports": [ + "3535:3535" + ], + "volumes": [ + "/var/lib/archipelago/barkd:/data" + ] + } + }, + { + "id": "jellyfin", + "title": "Jellyfin", + "version": "10.8.13", + "description": "Free media server. Stream movies, music, and photos.", + "icon": "/assets/img/app-icons/jellyfin.webp", + "author": "Jellyfin", + "category": "data", + "dockerImage": "146.59.87.168:3000/lfg2025/jellyfin:10.8.13", + "repoUrl": "https://github.com/jellyfin/jellyfin", + "containerConfig": { + "ports": [ + "8096:8096" + ], + "volumes": [ + "/var/lib/archipelago/jellyfin/config:/config", + "/var/lib/archipelago/jellyfin/cache:/cache" + ] + } + }, + { + "id": "immich", + "title": "Immich", + "version": "2.7.4", + "description": "Self-hosted photo and video backup with mobile apps and search.", + "icon": "/assets/img/app-icons/immich.png", + "author": "Immich", + "category": "data", + "dockerImage": "146.59.87.168:3000/lfg2025/immich-server:release", + "repoUrl": "https://github.com/immich-app/immich" + }, + { + "id": "homeassistant", + "title": "Home Assistant", + "version": "2026.7.3", + "description": "Open source home automation platform. Control and monitor your smart home devices.", + "icon": "/assets/img/app-icons/homeassistant.png", + "author": "Home Assistant", + "category": "home", + "dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2026.7.3", + "repoUrl": "https://github.com/home-assistant/core", + "containerConfig": { + "ports": [ + "8123:8123" + ], + "volumes": [ + "/var/lib/archipelago/home-assistant:/config" + ], + "env": [ + "TZ=UTC" + ] + } + }, + { + "id": "pine", + "title": "Pine", + "version": "1.3.0", + "description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.", + "icon": "/assets/img/app-icons/pine.svg", + "author": "Archipelago", + "category": "home", + "dockerImage": "docker.io/library/nginx:1.27-alpine", + "repoUrl": "https://github.com/rhasspy/wyoming" + }, + { + "id": "grafana", + "title": "Grafana", + "version": "10.2.0", + "description": "Analytics and monitoring platform. Visualize metrics and create dashboards.", + "icon": "/assets/img/app-icons/grafana.png", + "author": "Grafana Labs", + "category": "data", + "tier": "recommended", + "dockerImage": "grafana/grafana:10.2.0", + "repoUrl": "https://github.com/grafana/grafana", + "containerConfig": { + "ports": [ + "3000:3000" + ], + "volumes": [ + "/var/lib/archipelago/grafana:/var/lib/grafana" + ], + "env": [ + "GF_PATHS_DATA=/var/lib/grafana", + "GF_USERS_ALLOW_SIGN_UP=false" + ] + } + }, + { + "id": "tailscale", + "title": "Tailscale", + "version": "1.78.0", + "description": "Zero-config VPN with WireGuard mesh networking.", + "icon": "/assets/img/app-icons/tailscale.webp", + "author": "Tailscale", + "category": "networking", + "tier": "recommended", + "dockerImage": "146.59.87.168:3000/lfg2025/tailscale:stable", + "repoUrl": "https://github.com/tailscale/tailscale", + "containerConfig": { + "ports": [ + "8240:8240" + ], + "volumes": [ + "/var/lib/archipelago/tailscale:/var/lib/tailscale" + ], + "env": [ + "TS_STATE_DIR=/var/lib/tailscale" + ], + "args": [ + "sh", + "-c", + "tailscaled --tun=userspace-networking & for i in $(seq 1 30); do [ -S /var/run/tailscale/tailscaled.sock ] && break; sleep 1; done; tailscale web --listen 0.0.0.0:8240 & wait" + ] + } + }, + { + "id": "portainer", + "title": "Portainer", + "version": "2.19.4", + "description": "Container management web UI for the local Podman socket.", + "icon": "/assets/img/app-icons/portainer.webp", + "author": "Portainer", + "category": "development", + "tier": "optional", + "dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.39.1", + "repoUrl": "https://github.com/portainer/portainer", + "containerConfig": { + "ports": [ + "9000:9000" + ], + "volumes": [ + "/var/lib/archipelago/portainer:/data", + "/run/user/1000/podman/podman.sock:/var/run/docker.sock" + ], + "notes": "Uses the manifest-owned Podman socket bind mount preparation path." + } + }, + { + "id": "netbird", + "title": "NetBird", + "version": "2.38.0", + "description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.", + "icon": "/assets/img/app-icons/netbird.svg", + "author": "NetBird", + "category": "networking", + "tier": "recommended", + "dockerImage": "docker.io/library/nginx:1.27-alpine", + "repoUrl": "https://github.com/netbirdio/netbird", + "containerConfig": { + "ports": [ + "8087:80", + "8086:80", + "3478:3478/udp" + ], + "volumes": [ + "/var/lib/archipelago/netbird:/var/lib/netbird" + ], + "notes": "Installed as a two-container stack: netbird dashboard on 8087 and netbird-server control plane on 8086 plus UDP 3478. For production clients, publish a DNS name over HTTPS with gRPC/WebSocket routing." + } + }, + { + "id": "uptime-kuma", + "title": "Uptime Kuma", + "version": "1.23.0", + "description": "Self-hosted uptime monitoring.", + "icon": "/assets/img/app-icons/uptime-kuma.webp", + "author": "Uptime Kuma", + "category": "data", + "tier": "recommended", + "dockerImage": "146.59.87.168:3000/lfg2025/uptime-kuma:1", + "repoUrl": "https://github.com/louislam/uptime-kuma", + "containerConfig": { + "ports": [ + "3002:3001" + ], + "volumes": [ + "/var/lib/archipelago/uptime-kuma:/app/data" + ], + "env": [ + "TZ=UTC" + ], + "args": [ + "--", + "node", + "server/server.js" + ] + } + }, + { + "id": "photoprism", + "title": "PhotoPrism", + "version": "240915", + "description": "AI-powered photo management with facial recognition.", + "icon": "/assets/img/app-icons/photoprism.svg", + "author": "PhotoPrism", + "category": "data", + "dockerImage": "146.59.87.168:3000/lfg2025/photoprism:240915", + "repoUrl": "https://github.com/photoprism/photoprism", + "containerConfig": { + "ports": [ + "2342:2342" + ], + "volumes": [ + "/var/lib/archipelago/photoprism:/photoprism/storage" + ], + "env": [ + "PHOTOPRISM_ADMIN_PASSWORD=archipelago", + "PHOTOPRISM_DEFAULT_LOCALE=en" + ] + } + }, + { + "id": "nextcloud", + "title": "Nextcloud", + "version": "29", + "description": "Your own private cloud. File sync, calendars, contacts.", + "icon": "/assets/img/app-icons/nextcloud.webp", + "author": "Nextcloud", + "category": "data", + "dockerImage": "146.59.87.168:3000/lfg2025/nextcloud:29", + "repoUrl": "https://github.com/nextcloud/server", + "containerConfig": { + "ports": [ + "8085:80" + ], + "volumes": [ + "/var/lib/archipelago/nextcloud:/var/www/html" + ] + } + } + ] +} diff --git a/apps/DEVELOPMENT.md b/apps/DEVELOPMENT.md new file mode 100644 index 00000000..53a364ed --- /dev/null +++ b/apps/DEVELOPMENT.md @@ -0,0 +1,93 @@ +# Archipelago Apps — Development Guide + +## App Overview + +### Bitcoin & Lightning +| App | Ports | Version | +|-----|-------|---------| +| bitcoin-knots | 8332 (RPC), 8333 (P2P) | v28.1 | +| lnd | 9735 (P2P), 10009 (gRPC), 8080 (REST) | v0.17.4-beta | +| btcpay-server | 23000 (HTTP) | v1.13.5 | +| mempool | 4080 (HTTP) | v2.5.0 | +| electrumx | 50001 (TCP), 50002 (SSL) | latest | +| fedimint | 8173 (API), 8174 (Web) | v0.10.0 | + +### Nostr +| App | Ports | Version | +|-----|-------|---------| +| nostr-rs-relay | 8081 (WebSocket) | v0.9.0 | +| nostrudel | 8082 (HTTP) | v0.40.0 | + +### Self-Hosted +| App | Port | Version | +|-----|------|---------| +| nextcloud | 8084 | v28 | +| jellyfin | 8096 | v10.8.13 | +| immich | 2283 | release | +| photoprism | 2342 | v240915 | +| vaultwarden | 8222 | v1.30.0-alpine | +| homeassistant | 8123 | v2024.1 | +| filebrowser | 8083 | v2.27.0 | +| searxng | 8888 | 2024.11.17 | +| ollama | 11434 | v0.5.4 | +| grafana | 3001 | v10.2.0 | +| portainer | 9000 | v2.19.4 | +| penpot | 8089 | v2.4 | + +## Building Apps + +```bash +cd apps +./build.sh # Build all custom apps +./build.sh <app-id> # Build specific app +``` + +Custom apps with local source: `router`, `did-wallet`. All other apps use official container images. + +## App Structure + +Each app directory contains: +- `manifest.yml` — Container configuration +- `Dockerfile` — Image definition (custom apps only) +- `README.md` — App-specific docs (custom apps only) +- `src/` — Source code (custom apps only) + +## Running in Development + +The Archipelago backend manages containers via rootless Podman. Install and start apps through the web UI Marketplace or via RPC: + +```bash +curl -X POST http://localhost:5959/rpc/v1 \ + -H "Content-Type: application/json" \ + -d '{"method": "container-install", "params": {"manifest_path": "apps/router/manifest.yml"}}' +``` + +### Manual Testing (Podman) + +```bash +# Build +./build.sh router + +# Run directly with Podman +podman run -p 18084:8080 \ + -v /tmp/archipelago-dev/router:/app/data \ + localhost/archipelago/router:latest +``` + +## Integration Checklist + +Adding a new app requires updates in multiple places: + +- add `apps/<app-id>/manifest.yml`; +- add a Dockerfile and source directory only when the app is built locally; +- choose non-conflicting ports from [PORTS.md](./PORTS.md); +- declare `interfaces.main` for user-facing web UIs; +- declare generated secrets instead of hardcoding credentials; +- run `./scripts/validate-app-manifest.sh apps/<app-id>/manifest.yml`; +- regenerate catalogs with `python3 scripts/generate-app-catalog.py`; +- verify drift with `python3 scripts/check-app-catalog-drift.py --release --strict`; +- test install, launch, stop, start, restart, uninstall, and reinstall. + +## Port Assignments + +See [PORTS.md](./PORTS.md) for complete mapping. Dev ports are offset by +10000. diff --git a/apps/PORTS.md b/apps/PORTS.md new file mode 100644 index 00000000..fab15e1f --- /dev/null +++ b/apps/PORTS.md @@ -0,0 +1,85 @@ +# Port Assignments Reference + +This document lists all port assignments for Archipelago apps. + +## Production Ports + +| App | Port(s) | Protocol | Service | Dev Port(s) | +|-----|---------|----------|---------|-------------| +| bitcoin-core | 8332, 8333 | TCP | RPC, P2P | 18332, 18333 | +| btcpay-server | 80, 443 | TCP | HTTP, HTTPS | 10080, 10443 | +| home-assistant | 8123 | TCP | Web UI | 18123 | +| grafana | 3001 | TCP | Web UI | 13001 | +| endurain | 8085 | TCP | Web UI | 18085 | +| fedimint | 8173, 8174 | TCP | API, Web UI | 18173, 18174 | +| morphos-server | 8086 | TCP | Web UI | 18086 | +| lightning-stack | 9737, 10010, 8087 | TCP | P2P, gRPC, REST | 19737, 20010, 18087 | +| mempool | 4080 | TCP | Web UI | 14080 | +| ollama | 11434 | TCP | API | 21434 | +| searxng | 8888 | TCP | Web UI | 18888 | +| penpot | 8089 | TCP | Web UI | 18089 | +| lnd | 9735, 10009, 18080 | TCP | P2P, gRPC, REST | 19735, 20009, 28080 | +| core-lightning | 9736, 9835 | TCP | P2P, gRPC | 19736, 19835 | +| nostr-rs-relay | 8081 | TCP | HTTP/WebSocket | 18081 | +| strfry | 8082 | TCP | HTTP/WebSocket | 18082 | +| did-wallet | 8083 | TCP | Web UI | 18083 | +| router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 | +| meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 | +| podsteadr | 8095, 1935, 8889, 8189, 8890, 8098 | TCP/UDP | Web UI/API/RSS, RTMP ingest, WebRTC/WHIP ingest, WebRTC ICE (udp), HLS playback, Blossom blobs | 18095, 11935, 18889, 18189, 18890, 18098 | + +## Development Ports (Offset: +10000) + +In development mode, all ports are offset by 10000 to avoid conflicts with production services. + +### Quick Access URLs (Development) + +| App | Dev URL | +|-----|----------| +| Bitcoin Core RPC | http://localhost:18332 | +| BTCPay Server | http://localhost:10080 | +| Home Assistant | http://localhost:18123 | +| Grafana | http://localhost:13001 | +| Endurain | http://localhost:18085 | +| Fedimint | http://localhost:18174 | +| MorphOS Server | http://localhost:18086 | +| Lightning Stack | http://localhost:18087 | +| Mempool | http://localhost:14080 | +| Ollama | http://localhost:21434 | +| SearXNG | http://localhost:18888 | +| Penpot | http://localhost:18089 | +| LND REST | http://localhost:18080 | +| Core Lightning | http://localhost:19835 | +| Nostr RS Relay | http://localhost:18081 | +| Strfry | http://localhost:18082 | +| DID Wallet | http://localhost:18083 | +| Router | http://localhost:18084 | +| Meshtastic | http://localhost:14403 | + +## Port Conflict Resolution + +All apps use unique base ports to prevent conflicts. The port offset system ensures: +- No conflicts in production (each app has unique ports) +- No conflicts in development (offset applied automatically) +- Easy port management via PortManager + +## Changing Port Offset + +The port offset is configurable via environment variable: + +```bash +ARCHIPELAGO_PORT_OFFSET=10000 +``` + +Or in the Archipelago config: + +```toml +[dev] +port_offset = 10000 +``` + +## Port Ranges + +- **Bitcoin/Lightning**: 8000-10000 range +- **Web Services**: 3000-9000 range +- **System Services**: 10000+ range +- **Custom Apps**: 8000-9000 range diff --git a/apps/QUICKSTART.md b/apps/QUICKSTART.md new file mode 100644 index 00000000..46863db0 --- /dev/null +++ b/apps/QUICKSTART.md @@ -0,0 +1,107 @@ +# Quick Start Guide - Archipelago Apps + +This guide will help you get all prepackaged apps running in your development environment. + +## Prerequisites + +1. **Container Runtime**: Podman or Docker + ```bash + # Check if available + podman --version # or docker --version + ``` + +2. **Node.js** (for custom apps): v18+ + ```bash + node --version + ``` + +3. **Archipelago Backend**: Running in dev mode + ```bash + cd core + ARCHIPELAGO_DEV_MODE=true cargo run --bin archipelago + ``` + +## Building Apps + +### Build All Apps + +```bash +cd apps +./build.sh +``` + +This will build all apps that have Dockerfiles. Standard apps (bitcoin-core, lnd, etc.) will use their official images, while custom apps (router, did-wallet) will be built from source. + +### Build Specific App + +```bash +./build.sh router +./build.sh did-wallet +``` + +## Running Apps via Archipelago + +Once the backend is running, you can install and start apps via: + +1. **UI**: Navigate to http://localhost:8100 and use the Apps/Marketplace interface +2. **RPC**: Use the container-install RPC method + +```bash +curl -X POST http://localhost:5959/rpc/v1 \ + -H "Content-Type: application/json" \ + -d '{ + "method": "container-install", + "params": { + "manifest_path": "apps/router/manifest.yml" + } + }' +``` + +## Port Access + +In development mode, apps are accessible on offset ports: + +- **Router**: http://localhost:18084 +- **DID Wallet**: http://localhost:18083 +- **Nostr RS Relay**: http://localhost:18081 +- **Strfry**: http://localhost:18082 + +See [PORTS.md](./PORTS.md) for complete port mapping. + +## Development Workflow + +### For Custom Apps (router, did-wallet) + +1. **Make changes** to source code in `apps/<app-id>/src/` +2. **Rebuild** the container: + ```bash + ./build.sh <app-id> + ``` +3. **Restart** the container via Archipelago UI or RPC + +### For Standard Apps + +Standard apps use official images. To customize: +1. Create a custom Dockerfile that extends the official image +2. Add your customizations +3. Update the manifest to use your custom image + +## Testing Locally + +You can test apps directly without Archipelago: + +```bash +# Build +./build.sh router + +# Run +docker run -p 18084:8080 \ + -v /tmp/archipelago-dev/router:/app/data \ + archipelago/router:latest +``` + +## Next Steps + +- Read [DEVELOPMENT.md](./DEVELOPMENT.md) for detailed development information +- Check [PORTS.md](./PORTS.md) for port assignments +- Review individual app READMEs for app-specific details diff --git a/apps/README.md b/apps/README.md new file mode 100644 index 00000000..d2f077d0 --- /dev/null +++ b/apps/README.md @@ -0,0 +1,45 @@ +# Archipelago App Manifests + +Containerized applications for the Archipelago Bitcoin Node OS. All apps run in rootless Podman with security hardening (cap-drop ALL, readonly root, non-root user, memory limits). + +## App Categories + +### Bitcoin & Lightning +- **bitcoin-knots** — Full Bitcoin node (v28.1) +- **lnd** — Lightning Network Daemon (v0.17.4-beta) +- **btcpay-server** — Payment processor (v1.13.5) +- **mempool** — Block explorer and fee estimator (v2.5.0) +- **electrumx** — Electrum server +- **fedimint** — Federated Bitcoin minting (v0.10.0) + +### Nostr +- **nostr-rs-relay** — High-performance Rust relay (v0.9.0) +- **nostrudel** — Nostr web client (v0.40.0) + +### Web5 & Identity +- **did-wallet** — Web5 DID Wallet + +### Self-Hosted Services +- **podsteadr** — Nostr-native podcast publishing and livestreaming (RTMP/WebRTC ingest, HLS, RSS, Blossom media) +- **nextcloud** (v28), **jellyfin** (v10.8.13), **immich** (release), **photoprism** (v240915) +- **vaultwarden** (v1.30.0-alpine), **penpot** (v2.4) +- **homeassistant** (v2024.1), **filebrowser** (v2.27.0), **searxng** (2024.11.17) +- **ollama** (v0.5.4), **grafana** (v10.2.0), **portainer** (v2.19.4) + +### Networking +- **tailscale** (stable), **nginx-proxy-manager** (v2.12.1) + +### Custom & External +- **indeedhub** — Bitcoin documentary streaming (custom build) +- **router** — Mesh routing and network management +- **botfights** — External web app + +## Manifest Format + +Each app has a `manifest.yml` defining container image, resources, dependencies, security policies, health checks, and network config. See [`docs/app-manifest-spec.md`](../docs/app-manifest-spec.md) for the spec. + +## Quick Reference + +- [PORTS.md](./PORTS.md) — Complete port mapping +- [QUICKSTART.md](./QUICKSTART.md) — Build and run apps +- [DEVELOPMENT.md](./DEVELOPMENT.md) — Development workflow diff --git a/apps/aiui/manifest.yml b/apps/aiui/manifest.yml new file mode 100644 index 00000000..d07ceca4 --- /dev/null +++ b/apps/aiui/manifest.yml @@ -0,0 +1,39 @@ +app: + id: aiui + name: AI Assistant + version: 0.1.0 + description: Conversational AI interface for Archipelago. Quarantined — communicates only via context broker. + internal: true # System-managed, not shown in App Store + + container: + image: localhost/archipelago-aiui:latest + pull_policy: always + + resources: + cpu_limit: 1 + memory_limit: 512Mi + disk_limit: 1Gi + + security: + capabilities: [] + readonly_root: true + no_new_privileges: true + user: 1000 + seccomp_profile: default + network_policy: isolated # No outbound network — all data comes via context broker + apparmor_profile: aiui + + ports: + - host: 5180 + container: 80 + protocol: tcp + bind: 127.0.0.1 # Only accessible via nginx proxy, not externally + auth: local + + health_check: + type: http + endpoint: http://localhost:80 + path: / + interval: 60s + timeout: 5s + retries: 3 diff --git a/apps/archy-btcpay-db/manifest.yml b/apps/archy-btcpay-db/manifest.yml new file mode 100644 index 00000000..77f3867c --- /dev/null +++ b/apps/archy-btcpay-db/manifest.yml @@ -0,0 +1,49 @@ +app: + id: archy-btcpay-db + name: BTCPay Postgres + version: "15.17" + description: Postgres backend for BTCPay and NBXplorer. + + container: + image: 146.59.87.168:3000/lfg2025/postgres:15.17 + pull_policy: if-not-present + network: archy-net + data_uid: "100998:100998" + secret_env: + - key: POSTGRES_PASSWORD + secret_file: btcpay-db-password + + dependencies: + - storage: 20Gi + + resources: + memory_limit: 1Gi + disk_limit: 20Gi + + security: + capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE] + readonly_root: false + network_policy: isolated + + ports: [] + + volumes: + - type: bind + source: /var/lib/archipelago/postgres-btcpay + target: /var/lib/postgresql/data + options: [rw] + + environment: + - POSTGRES_DB=btcpay + - POSTGRES_USER=btcpay + + health_check: + type: tcp + endpoint: localhost:5432 + interval: 30s + timeout: 5s + retries: 3 + + bitcoin_integration: + rpc_access: none + sync_required: false diff --git a/apps/archy-mempool-db/manifest.yml b/apps/archy-mempool-db/manifest.yml new file mode 100644 index 00000000..3597f5c6 --- /dev/null +++ b/apps/archy-mempool-db/manifest.yml @@ -0,0 +1,51 @@ +app: + id: archy-mempool-db + name: Mempool MariaDB + version: 11.4.10 + description: MariaDB backend for the mempool explorer stack. + + container: + image: 146.59.87.168:3000/lfg2025/mariadb:11.4.10 + pull_policy: if-not-present + network: archy-net + data_uid: "100998:100998" + secret_env: + - key: MYSQL_PASSWORD + secret_file: mempool-db-password + - key: MYSQL_ROOT_PASSWORD + secret_file: mysql-root-db-password + + dependencies: + - storage: 20Gi + + resources: + memory_limit: 512Mi + disk_limit: 20Gi + + security: + capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE] + readonly_root: false + network_policy: isolated + + ports: [] + + volumes: + - type: bind + source: /var/lib/archipelago/mysql-mempool + target: /var/lib/mysql + options: [rw] + + environment: + - MYSQL_DATABASE=mempool + - MYSQL_USER=mempool + + health_check: + type: tcp + endpoint: localhost:3306 + interval: 30s + timeout: 5s + retries: 3 + + bitcoin_integration: + rpc_access: none + sync_required: false diff --git a/apps/archy-mempool-web/manifest.yml b/apps/archy-mempool-web/manifest.yml new file mode 100644 index 00000000..d18acc76 --- /dev/null +++ b/apps/archy-mempool-web/manifest.yml @@ -0,0 +1,49 @@ +app: + id: archy-mempool-web + name: Mempool Web + version: 3.0.1 + description: Frontend web UI for mempool explorer. + container_name: mempool + + container: + image: 146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.1 + pull_policy: if-not-present + network: archy-net + + dependencies: + - app_id: mempool-api + version: ">=3.0.0" + + resources: + memory_limit: 512Mi + + security: + capabilities: [] + readonly_root: false + network_policy: isolated + + ports: + - host: 4080 + container: 8080 + protocol: tcp + bind: 127.0.0.1 + auth: gated + + environment: + - FRONTEND_HTTP_PORT=8080 + - BACKEND_MAINNET_HTTP_HOST=mempool-api + + health_check: + type: http + # 127.0.0.1 not localhost: the image's wget resolves localhost to ::1 (IPv6) + # first, but nginx binds 0.0.0.0:8080 (IPv4) only -> localhost probe gets + # "connection refused" -> perpetual unhealthy -> health_monitor restart loop. + endpoint: http://127.0.0.1:8080 + path: / + interval: 30s + timeout: 5s + retries: 3 + + bitcoin_integration: + rpc_access: none + sync_required: false diff --git a/apps/archy-nbxplorer/manifest.yml b/apps/archy-nbxplorer/manifest.yml new file mode 100644 index 00000000..a13c1bb7 --- /dev/null +++ b/apps/archy-nbxplorer/manifest.yml @@ -0,0 +1,66 @@ +app: + id: archy-nbxplorer + name: NBXplorer + version: 2.6.0 + description: BTCPay blockchain indexer service. + + container: + image: 146.59.87.168:3000/lfg2025/nbxplorer:2.6.0 + pull_policy: if-not-present + network: archy-net + secret_env: + - key: NBXPLORER_BTCRPCPASSWORD + secret_file: bitcoin-rpc-password + - key: BTCPAY_DB_PASS + secret_file: btcpay-db-password + + dependencies: + - app_id: bitcoin-core + version: ">=26.0" + - app_id: archy-btcpay-db + version: ">=15.17" + + resources: + memory_limit: 2Gi + disk_limit: 20Gi + + security: + capabilities: [] + readonly_root: false + network_policy: isolated + + ports: + - host: 32838 + container: 32838 + protocol: tcp + bind: 127.0.0.1 + auth: local + + volumes: + - type: bind + source: /var/lib/archipelago/nbxplorer + target: /data + options: [rw] + + environment: + - NBXPLORER_DATADIR=/data + - NBXPLORER_NETWORK=mainnet + - NBXPLORER_CHAINS=btc + - NBXPLORER_BIND=0.0.0.0:32838 + - NBXPLORER_BTCRPCURL=http://bitcoin-knots:8332 + - NBXPLORER_BTCRPCUSER=archipelago + - NBXPLORER_BTCNODEENDPOINT=bitcoin-knots:8333 + - NBXPLORER_NOAUTH=1 + - NBXPLORER_POSTGRES=Username=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=nbxplorer + + health_check: + type: http + endpoint: http://localhost:32838 + path: / + interval: 30s + timeout: 30s + retries: 5 + + bitcoin_integration: + rpc_access: read-only + sync_required: true diff --git a/apps/barkd/Dockerfile b/apps/barkd/Dockerfile new file mode 100644 index 00000000..6421fbd9 --- /dev/null +++ b/apps/barkd/Dockerfile @@ -0,0 +1,32 @@ +# barkd — Ark protocol wallet daemon (https://gitlab.com/ark-bitcoin/bark). +# No official upstream image exists (their GitLab registry is empty), so we +# package the pinned, checksum-verified release binary ourselves and push to +# the node registry — same approach as fmcd. Keep the version in lockstep with +# the REST shapes coded in core/archipelago/src/wallet/ark_client.rs (0.3.0). +FROM debian:bookworm-slim + +ARG BARKD_VERSION=0.3.0 +ARG BARKD_SHA256=8562fa27386bae666ed62fa95c92d40f7bdb20d22525f75799adfc16adaaedb3 + +RUN apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates curl && \ + curl -fsSL "https://gitlab.com/api/v4/projects/ark-bitcoin%2Fbark/packages/generic/release-assets/bark-${BARKD_VERSION}/barkd-${BARKD_VERSION}-linux-x86_64" \ + -o /usr/local/bin/barkd && \ + echo "${BARKD_SHA256} /usr/local/bin/barkd" | sha256sum -c - && \ + chmod a+x /usr/local/bin/barkd && \ + apt-get purge -y curl && apt-get autoremove -y && \ + apt-get clean && rm -rf /var/lib/apt/lists/* + +COPY entrypoint.sh /entrypoint.sh +RUN chmod a+x /entrypoint.sh + +# The wallet itself is created over REST by the node's Ark bridge +# (wallet.ark-* RPCs) — the container just runs the daemon. +ENV BARKD_DATADIR=/data \ + BARKD_BIND_HOST=0.0.0.0 \ + BARKD_BIND_PORT=3535 + +EXPOSE 3535 +VOLUME /data + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/apps/barkd/entrypoint.sh b/apps/barkd/entrypoint.sh new file mode 100644 index 00000000..1f8481cd --- /dev/null +++ b/apps/barkd/entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Install the node-provided auth secret (64-char hex from the manifest's +# generated barkd-secret) so the wallet bridge can derive the matching Bearer +# token, then start the daemon. Without BARKD_SECRET, barkd generates its own +# random token in the datadir and the bridge won't authenticate — so treat a +# failed refresh as fatal rather than starting an unreachable daemon. +set -eu + +if [ -n "${BARKD_SECRET:-}" ]; then + # `secret refresh` prints the Bearer token on stdout — never log it. + barkd secret refresh --secret "$BARKD_SECRET" >/dev/null + unset BARKD_SECRET +fi + +exec barkd diff --git a/apps/barkd/manifest.yml b/apps/barkd/manifest.yml new file mode 100644 index 00000000..171ed869 --- /dev/null +++ b/apps/barkd/manifest.yml @@ -0,0 +1,78 @@ +app: + id: barkd + name: Ark Wallet + version: 0.3.0 + description: Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures. + + container: + # barkd packaged from the pinned upstream release binary (no usable + # upstream image exists — their registry is empty). Built from + # apps/barkd/Dockerfile and pushed to the node registry. Pin the tag to + # match the REST shapes coded in core/archipelago/src/wallet/ark_client.rs + # (validated against barkd 0.3.0 on signet, 2026-07-14). + image: 146.59.87.168:3000/lfg2025/barkd:0.3.0 + pull_policy: if-not-present + network: archy-net + # The entrypoint installs the shared secret below via `barkd secret + # refresh` (so the wallet bridge can derive the matching Bearer token) and + # execs the daemon. The Ark wallet itself is created over REST by the + # bridge on first use (wallet.ark-* RPCs) with the node's ark_config + # (default: Second's public signet server) — no host provisioning needed. + generated_secrets: + - name: barkd-secret + kind: hex32 + secret_env: + - key: BARKD_SECRET + secret_file: barkd-secret + data_uid: "1000:1000" + + dependencies: + - storage: 1Gi + + resources: + # barkd is a single wallet daemon (SQLite + a gRPC conn to the Ark server + # + esplora polling); steady state is tiny. Cap it so a stuck sync can't + # starve the node. + cpu_limit: 1 + memory_limit: 512Mi + disk_limit: 1Gi + + security: + readonly_root: true + # Needs outbound HTTPS to the Ark server (ark.signet.2nd.dev) and the + # esplora chain source, plus the published REST port for the wallet + # bridge. No inbound requirements beyond that. + network_policy: bridge + + ports: + # barkd REST bound to 3535 in-container (BARKD_BIND_PORT); 3535 is free on + # the host (see port_allocator.rs). The Rust bridge targets + # http://127.0.0.1:3535. + - host: 3535 + container: 3535 + protocol: tcp + bind: 127.0.0.1 + auth: local + + volumes: + # Holds the wallet DB, mnemonic and auth token. ARK funds are recoverable + # on-chain from this datadir (unilateral exit) — include it in backups. + - type: bind + source: /var/lib/archipelago/barkd + target: /data + options: [rw] + + environment: + - BARKD_DATADIR=/data + - BARKD_BIND_HOST=0.0.0.0 + - BARKD_BIND_PORT=3535 + + # All /api/v1/* routes require the Bearer token, so an HTTP probe would 401 + # forever — use a TCP probe like fmcd (the host-side lifecycle layer + # verifies reachability). + health_check: + type: tcp + endpoint: localhost:3535 + interval: 30s + timeout: 5s + retries: 3 diff --git a/apps/bitcoin-core/Dockerfile b/apps/bitcoin-core/Dockerfile new file mode 100644 index 00000000..04df9fdf --- /dev/null +++ b/apps/bitcoin-core/Dockerfile @@ -0,0 +1,34 @@ +# Bitcoin Core — minimal rootless image built from the OFFICIAL upstream release. +# +# The CANONICAL, verified build path is scripts/build-bitcoin-image.sh, which +# downloads the upstream tarball, verifies SHA-256 + the OpenPGP signature +# (fail-closed), and tags/pushes <registry>/bitcoin:<version>. This Dockerfile +# mirrors that image for a manual/local build and replaces the old stale +# community base (`FROM bitcoin/bitcoin:24.0`). +# +# Build (binaries must be pre-fetched + verified into ./bin — see the script): +# scripts/build-bitcoin-image.sh core 31.0 +FROM debian:bookworm-slim +ARG BITCOIN_VERSION=31.0 +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends ca-certificates; \ + rm -rf /var/lib/apt/lists/*; \ + useradd -m -u 1000 -s /bin/bash bitcoin; \ + mkdir -p /home/bitcoin/.bitcoin; \ + chown -R bitcoin:bitcoin /home/bitcoin +# bin/ holds the SHA-256 + GPG-verified bitcoind / bitcoin-cli (Guix-built, +# x86_64-linux-gnu) extracted from the official release tarball. +COPY bin/bitcoind /usr/local/bin/bitcoind +COPY bin/bitcoin-cli /usr/local/bin/bitcoin-cli +RUN chmod 0755 /usr/local/bin/bitcoind /usr/local/bin/bitcoin-cli +# Run as (container) root, like the legacy hand-built :latest image. Rootless +# Podman maps container-root to the unprivileged host service user; the manifest +# grants CAP_DAC_OVERRIDE so bitcoind can read its data dir, which the +# orchestrator chowns to the data_uid (host 100101 / container uid 102), not to +# this image's `bitcoin` user. A non-root USER can't read existing chain data and +# bitcoind crash-loops with "Error initializing block database". +WORKDIR /home/bitcoin +VOLUME ["/home/bitcoin/.bitcoin"] +EXPOSE 8332 8333 +ENTRYPOINT ["bitcoind"] diff --git a/apps/bitcoin-core/manifest.yml b/apps/bitcoin-core/manifest.yml new file mode 100644 index 00000000..5e9db91a --- /dev/null +++ b/apps/bitcoin-core/manifest.yml @@ -0,0 +1,119 @@ +app: + id: bitcoin-core + name: Bitcoin Core + version: 28.4.0 + description: Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk. + + container_name: bitcoin-core + + container: + image: 146.59.87.168:3000/lfg2025/bitcoin:28.4 + pull_policy: if-not-present + network: archy-net + entrypoint: ["sh", "-lc"] + custom_args: + # Sync-speed flags: -par=0 uses every core (was capped at 2 by + # --cpus=2, now removed for bitcoin/electrumx). -dbcache sized to + # the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container + # --memory=8g (config.rs::get_memory_limit) leaves headroom for + # mempool + connections. + # + # -printtoconsole=0: foreground bitcoind defaults console logging ON, + # which pushed every IBD "UpdateTip" line through conmon into journald + # (>1 GB/day on a fresh node). bitcoind still writes debug.log in the + # datadir (/var/lib/archipelago/bitcoin/debug.log, self-shrunk on + # restart) — use that for deep debugging; podman logs only carries + # entrypoint/startup errors. + - >- + BITCOIND="$(command -v bitcoind || true)"; + if [ -z "$BITCOIND" ]; then + BITCOIND="$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)"; + fi; + if [ -z "$BITCOIND" ]; then + echo "bitcoind not found in image" >&2; + exit 127; + fi; + RPC_USER="$(printenv BITCOIN_RPC_USER)"; + RPC_PASS="$(printenv BITCOIN_RPC_PASS)"; + RPC_CONF="/tmp/rpc.conf"; + umask 077; + { echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF"; + if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then + echo "archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF" >&2; + fi; + RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)"; + DISK_GB_VALUE="$(printenv DISK_GB || true)"; + RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256"; + RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0"; + if [ -n "$RPC_TXRELAY_AUTH" ]; then + RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips"; + fi; + if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then + exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; + else + exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; + fi + derived_env: + - key: DISK_GB + template: "{{DISK_GB}}" + secret_env: + - key: BITCOIN_RPC_PASS + secret_file: bitcoin-rpc-password + - key: BITCOIN_RPC_TXRELAY_RPCAUTH + secret_file: bitcoin-rpc-txrelay-rpcauth + data_uid: "100101:100101" + + dependencies: + - storage: 500Gi + + resources: + cpu_limit: 0 + memory_limit: 4Gi + disk_limit: 500Gi + + security: + capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE] + readonly_root: false + network_policy: isolated + + ports: + # RPC is auth-only: publish host-local ONLY - the LAN cannot reach + # nodeIP:8332. In-node consumers (lnd, fedimint, btcpay, mempool-api) + # dial the container's archy-net alias directly (bitcoin-core:8332), + # which needs no publish at all. Do NOT bind the archy-net gateway + # (10.89.0.1): rootlessport binds in the HOST netns where that address + # does not exist, and the whole unit crash-loops (2026-07-09, .228). + # P2P 8333 stays public. + - host: 8332 + container: 8332 + protocol: tcp + bind: 127.0.0.1 + auth: local + - host: 8333 + container: 8333 + protocol: tcp + auth: none + auth_rationale: >- + Bitcoin p2p gossip. Peers are anonymous by design and speak the Bitcoin wire protocol, not HTTP. + + volumes: + - type: bind + source: /var/lib/archipelago/bitcoin + target: /home/bitcoin/.bitcoin + options: [rw] + + environment: + - BITCOIN_RPC_USER=archipelago + + health_check: + type: tcp + endpoint: localhost:8332 + interval: 30s + timeout: 5s + retries: 3 + + bitcoin_integration: + rpc_access: admin + sync_required: true + testnet_support: false + pruning_support: true diff --git a/apps/bitcoin-knots/Dockerfile b/apps/bitcoin-knots/Dockerfile new file mode 100644 index 00000000..74025b9c --- /dev/null +++ b/apps/bitcoin-knots/Dockerfile @@ -0,0 +1,35 @@ +# Bitcoin Knots — minimal rootless image built from the OFFICIAL upstream release. +# +# Knots previously had NO Dockerfile (the :latest tag was built/pushed by hand). +# The CANONICAL, verified build path is scripts/build-bitcoin-image.sh, which +# downloads the upstream tarball, verifies SHA-256 + the OpenPGP signature +# (fail-closed, Luke-Jr release key), and tags/pushes +# <registry>/bitcoin-knots:<version>. Knots version strings embed a build date, +# e.g. 29.3.knots20260508 — the full string is the tag. +# +# Build (binaries must be pre-fetched + verified into ./bin — see the script): +# scripts/build-bitcoin-image.sh knots 29.3.knots20260508 +FROM debian:bookworm-slim +ARG KNOTS_VERSION=29.3.knots20260508 +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends ca-certificates; \ + rm -rf /var/lib/apt/lists/*; \ + useradd -m -u 1000 -s /bin/bash bitcoin; \ + mkdir -p /home/bitcoin/.bitcoin; \ + chown -R bitcoin:bitcoin /home/bitcoin +# bin/ holds the SHA-256 + GPG-verified bitcoind / bitcoin-cli (Knots, Guix-built, +# x86_64-linux-gnu) extracted from the official release tarball. +COPY bin/bitcoind /usr/local/bin/bitcoind +COPY bin/bitcoin-cli /usr/local/bin/bitcoin-cli +RUN chmod 0755 /usr/local/bin/bitcoind /usr/local/bin/bitcoin-cli +# Run as (container) root, like the legacy hand-built :latest image. Rootless +# Podman maps container-root to the unprivileged host service user; the manifest +# grants CAP_DAC_OVERRIDE so bitcoind can read its data dir, which the +# orchestrator chowns to the data_uid (host 100101 / container uid 102), not to +# this image's `bitcoin` user. A non-root USER can't read existing chain data and +# bitcoind crash-loops with "Error initializing block database". +WORKDIR /home/bitcoin +VOLUME ["/home/bitcoin/.bitcoin"] +EXPOSE 8332 8333 +ENTRYPOINT ["bitcoind"] diff --git a/apps/bitcoin-knots/manifest.yml b/apps/bitcoin-knots/manifest.yml new file mode 100644 index 00000000..f74c6052 --- /dev/null +++ b/apps/bitcoin-knots/manifest.yml @@ -0,0 +1,119 @@ +app: + id: bitcoin-knots + name: Bitcoin Knots + version: 28.1.0 + description: Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk. + + container_name: bitcoin-knots + + container: + image: 146.59.87.168:3000/lfg2025/bitcoin-knots:latest + pull_policy: if-not-present + network: archy-net + entrypoint: ["sh", "-lc"] + custom_args: + # Sync-speed flags: -par=0 uses every core (was capped at 2 by + # --cpus=2, now removed for bitcoin/electrumx). -dbcache sized to + # the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container + # --memory=8g (config.rs::get_memory_limit) leaves headroom for + # mempool + connections. + # + # -printtoconsole=0: foreground bitcoind defaults console logging ON, + # which pushed every IBD "UpdateTip" line through conmon into journald + # (>1 GB/day on a fresh node). bitcoind still writes debug.log in the + # datadir (/var/lib/archipelago/bitcoin/debug.log, self-shrunk on + # restart) — use that for deep debugging; podman logs only carries + # entrypoint/startup errors. + - >- + BITCOIND="$(command -v bitcoind || true)"; + if [ -z "$BITCOIND" ]; then + BITCOIND="$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)"; + fi; + if [ -z "$BITCOIND" ]; then + echo "bitcoind not found in image" >&2; + exit 127; + fi; + RPC_USER="$(printenv BITCOIN_RPC_USER)"; + RPC_PASS="$(printenv BITCOIN_RPC_PASS)"; + RPC_CONF="/tmp/rpc.conf"; + umask 077; + { echo "rpcuser=$RPC_USER"; echo "rpcpassword=$RPC_PASS"; } > "$RPC_CONF"; + if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then + echo "archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF" >&2; + fi; + RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)"; + DISK_GB_VALUE="$(printenv DISK_GB || true)"; + RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256"; + RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0"; + if [ -n "$RPC_TXRELAY_AUTH" ]; then + RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips"; + fi; + if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then + exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; + else + exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS; + fi + derived_env: + - key: DISK_GB + template: "{{DISK_GB}}" + secret_env: + - key: BITCOIN_RPC_PASS + secret_file: bitcoin-rpc-password + - key: BITCOIN_RPC_TXRELAY_RPCAUTH + secret_file: bitcoin-rpc-txrelay-rpcauth + data_uid: "100101:100101" + + dependencies: + - storage: 500Gi + + resources: + cpu_limit: 0 + memory_limit: 8Gi + disk_limit: 500Gi + + security: + capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE] + readonly_root: false + network_policy: isolated + + ports: + # RPC is auth-only: publish host-local ONLY - the LAN cannot reach + # nodeIP:8332. In-node consumers (lnd, fedimint, btcpay, mempool-api) + # dial the container's archy-net alias directly (bitcoin-knots:8332), + # which needs no publish at all. Do NOT bind the archy-net gateway + # (10.89.0.1): rootlessport binds in the HOST netns where that address + # does not exist, and the whole unit crash-loops (2026-07-09, .228). + # P2P 8333 stays public. + - host: 8332 + container: 8332 + protocol: tcp + bind: 127.0.0.1 + auth: local + - host: 8333 + container: 8333 + protocol: tcp + auth: none + auth_rationale: >- + Bitcoin p2p gossip. Peers are anonymous by design and speak the Bitcoin wire protocol, not HTTP. + + volumes: + - type: bind + source: /var/lib/archipelago/bitcoin + target: /home/bitcoin/.bitcoin + options: [rw] + + environment: + - BITCOIN_RPC_USER=archipelago + + health_check: + type: tcp + endpoint: localhost:8332 + interval: 30s + timeout: 5s + retries: 3 + + bitcoin_integration: + rpc_access: admin + sync_required: true + testnet_support: false + pruning_support: true diff --git a/apps/bitcoin-ui/manifest.yml b/apps/bitcoin-ui/manifest.yml new file mode 100644 index 00000000..fc96095b --- /dev/null +++ b/apps/bitcoin-ui/manifest.yml @@ -0,0 +1,71 @@ +app: + id: bitcoin-ui + name: Bitcoin UI + version: 1.0.0 + description: | + Archipelago-native HTTP proxy + static site for interacting with the + Bitcoin Core / Bitcoin Knots JSON-RPC. Runs nginx inside a container + and reverse-proxies /bitcoin-rpc/ to 127.0.0.1:8332 on the host. The + upstream Authorization header is substituted from + /var/lib/archipelago/secrets/bitcoin-rpc-password by the prod + orchestrator's pre-start hook, rendered into an nginx.conf that is + bind-mounted read-only at container start. + + container: + build: + context: /opt/archipelago/docker/bitcoin-ui + dockerfile: Dockerfile + tag: localhost/bitcoin-ui:local + + dependencies: + - app_id: bitcoin-core + + resources: + memory_limit: 128Mi + + security: + readonly_root: false + network_policy: host + + # Host networking: nginx listens on 8334 directly on the host IP, and + # proxies to 127.0.0.1:8332 which is where the bitcoin backend binds + # its RPC. `ports:` is intentionally empty because host networking + # bypasses port mapping. + # Declared so the APP GATE can see this port. Host networking means Podman + # publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here + # is a statement of where the container's own nginx listens — 127.0.0.1 — + # not a publish instruction. Without this declaration the gate had no idea + # the port existed: it was neither protected nor listed as unprotected, and + # served the Bitcoin screen unauthenticated on every interface. + ports: + - host: 8334 + container: 8334 + protocol: tcp + bind: 127.0.0.1 + auth: gated + # First-party companion UI: its nginx forwards the node session cookie + # to the daemon's authenticated endpoints; without passthrough the gate + # strips it and every data call 401s while the page shell renders. + session_passthrough: true + + volumes: + # Bind-mount the rendered nginx.conf read-only. The prod orchestrator + # renders /var/lib/archipelago/bitcoin-ui/nginx.conf on every install + # and every reconcile pass, substituting the base64 RPC auth from + # the plaintext password secret. If the rendered bytes change (the + # password rotated, or the template was updated by OTA), the + # reconciler restarts this container so nginx re-reads the config. + - type: bind + source: /var/lib/archipelago/bitcoin-ui/nginx.conf + target: /etc/nginx/conf.d/default.conf + options: [ro] + + environment: [] + + health_check: + type: http + endpoint: http://127.0.0.1:8334 + path: / + interval: 30s + timeout: 5s + retries: 3 diff --git a/apps/botfights/manifest.yml b/apps/botfights/manifest.yml new file mode 100644 index 00000000..a7e3e1fd --- /dev/null +++ b/apps/botfights/manifest.yml @@ -0,0 +1,131 @@ +app: + id: botfights + name: BotFights + version: 1.2.11 + description: Bot competition arena with 2-player arcade fighting mode. AI bots battle in trivia challenges while humans duke it out with controllers. Built for Bitcoiners. + category: community + + container: + image: 146.59.87.168:3000/lfg2025/botfights:1.2.11 + pull_policy: always + # Auto-generated on first install (random hex, 0600, rootless-owned). The + # 1.2.x image's server/src/middleware/jwt.ts throws at module import when + # JWT_SECRET is unset and NODE_ENV=production, so a fresh install without + # this crash-loops immediately. hex32 (not base64, unlike netbird) because + # jwt.ts uses the value directly as an HMAC key with no decode step. + generated_secrets: + - name: botfights-jwt-secret + kind: hex32 + secret_env: + - key: JWT_SECRET + secret_file: botfights-jwt-secret + # Was missing entirely (found live during a fresh install on a second + # node): without it, the orchestrator's bind-dir ownership fixup only + # fires via a same-owner-as-anchor fallback that assumes an app with no + # data_uid runs as container-internal root — but this app runs as a + # non-root system user, so that fallback doesn't apply either. The bind + # mount ended up unwritable, crash-looping the container on startup + # (SqliteError: unable to open database file). Same pattern as + # apps/fedimint-clientd/manifest.yml and apps/barkd/manifest.yml. + # + # 999, not 1001: the image's Dockerfile does `useradd --system` with no + # explicit UID, which lands at 999 (confirmed via `podman exec botfights + # id` — uid=999(botfights) gid=999(botfights)), not the security.user + # value below. security.user is not currently read by the non-Quadlet + # install path this app uses (only quadlet.rs consumes + # security.{capabilities,readonly_root,no_new_privileges,network_policy} + # for companion containers) — it's descriptive metadata here, not + # enforced. A first pass at this fix used 1001 (copying the + # fedimint-clientd/barkd pattern without verifying against this image) + # and still crash-looped; corrected after inspecting the running + # container's actual UID. + data_uid: "999:999" + + dependencies: + - storage: 500Mi + + resources: + cpu_limit: 2 + memory_limit: 512Mi + disk_limit: 500Mi + + security: + capabilities: [] + readonly_root: true + no_new_privileges: true + user: 999 + seccomp_profile: default + network_policy: bridge + apparmor_profile: default + + ports: + - host: 9100 + container: 9100 + protocol: tcp # Web UI + API + bind: 127.0.0.1 + auth: gated + + volumes: + # A bare relative source (was "botfights-data", no leading slash) is + # inconsistent with every other app's manifest, which uses an absolute + # host path — found live during a fresh install on a second node: + # resolved to /var/lib/archipelago/botfights on archi-dev-box (by + # accident of that node's specific state) but /home/archipelago/ + # botfights-data on a different node, which doesn't exist there, + # crash-looping the container on a real SqliteError: unable to open + # database file. Absolute path removes the ambiguity entirely, matching + # apps/netbird-server/manifest.yml and every other app's convention. + - type: bind + source: /var/lib/archipelago/botfights + target: /app/server/data + - type: tmpfs + target: /tmp + options: [rw,noexec,nosuid,size=64m] + + environment: + - NODE_ENV=production + - PORT=9100 + # Default-on shared public arena federation (BOT-03/D-03): this node's + # BotFights becomes a thin client of the Foundation's well-known arena — + # all nodes see all fighters, fights cross nodes. This is a rendezvous, + # not an authority: any node can host its own arena (same image, just + # without this var set), and an operator can remove this line entirely to + # run a fully standalone, node-local arena instead. + - ARENA_UPSTREAM_URL=https://botfights.archipelago-foundation.org + # Tells the app it is embedded (first-party) in the node dashboard's + # iframe, so it can safely disable X-Frame-Options: SAMEORIGIN — see + # server/src/app.ts in the botfight repo. Without this the 1.2.x image's + # default security headers block the dashboard iframe entirely. + - ARCHY_EMBEDDED=1 + + health_check: + type: http + endpoint: http://localhost:9100 + path: /api/health + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + interfaces: + main: + name: Web UI + description: Bot arena and arcade fighter with controller support + type: ui + port: 9100 + protocol: http + path: / + + metadata: + author: Dorian + repo: https://botfights.net + icon: /assets/img/app-icons/botfights.svg + license: MIT + tags: + - bitcoin + - gaming + - arcade + - fighter + - bots + - competition + - controller diff --git a/apps/btcpay-server/Dockerfile b/apps/btcpay-server/Dockerfile new file mode 100644 index 00000000..920002f1 --- /dev/null +++ b/apps/btcpay-server/Dockerfile @@ -0,0 +1,5 @@ +# BTCPay Server - uses official image +FROM btcpayserver/btcpayserver:1.12.0 + +# Default configuration is in the image +# No additional setup needed diff --git a/apps/btcpay-server/manifest.yml b/apps/btcpay-server/manifest.yml new file mode 100644 index 00000000..703a9f0c --- /dev/null +++ b/apps/btcpay-server/manifest.yml @@ -0,0 +1,97 @@ +app: + id: btcpay-server + name: BTCPay Server + version: 2.3.9 + description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries. + + container: + image: docker.io/btcpayserver/btcpayserver:2.3.9 + pull_policy: if-not-present + network: archy-net + secret_env: + - key: BTCPAY_BTCRPCPASSWORD + secret_file: bitcoin-rpc-password + - key: BTCPAY_DB_PASS + secret_file: btcpay-db-password + # Internal LND node. Generated by the daemon (lnd macaroon as hex + + # tls.cert thumbprint) — see container::lnd::ensure_btcpay_lnd_connection_secret. + # Optional: nodes without LND run btcpay without an internal node. + - key: BTCPAY_BTCLIGHTNING + secret_file: btcpay-lnd-connection + optional: true + derived_env: + - key: BTCPAY_HOST + template: "{{HOST_IP}}:23000" + + dependencies: + - app_id: bitcoin-core + version: ">=26.0" + - app_id: archy-btcpay-db + version: ">=15.17" + - app_id: archy-nbxplorer + version: ">=2.6.0" + + resources: + cpu_limit: 2 + memory_limit: 2Gi + disk_limit: 20Gi + + security: + capabilities: [] + readonly_root: false + network_policy: isolated + + ports: + - host: 23000 + container: 49392 + protocol: tcp + bind: 127.0.0.1 + auth: gated + + volumes: + - type: bind + source: /var/lib/archipelago/btcpay + target: /datadir + options: [rw] + + environment: + - ASPNETCORE_URLS=http://0.0.0.0:49392 + - BTCPAY_PROTOCOL=http + - BTCPAY_CHAINS=btc + # Plugins must live on the persistent volume: the image default + # (/root/.btcpayserver/Plugins) is container-local, so every recreate + # silently wiped installed plugins. + - BTCPAY_PLUGINDIR=/datadir/Plugins + - BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838 + - BTCPAY_BTCRPCURL=http://bitcoin-knots:8332 + - BTCPAY_BTCRPCUSER=archipelago + - BTCPAY_POSTGRES=Username=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=btcpay + + health_check: + type: http + endpoint: http://localhost:49392 + path: / + interval: 30s + timeout: 30s + retries: 5 + + bitcoin_integration: + rpc_access: read-only + sync_required: true + + lightning_integration: + payment_processing: false + invoice_management: true + + interfaces: + main: + name: Web UI + description: BTCPay Server dashboard + type: ui + port: 23000 + protocol: http + path: / + + metadata: + launch: + open_in_new_tab: true diff --git a/apps/build.sh b/apps/build.sh new file mode 100755 index 00000000..9d22b9a1 --- /dev/null +++ b/apps/build.sh @@ -0,0 +1,85 @@ +#!/bin/bash +# Build script for Archipelago apps +# Usage: ./build.sh [app-id] [--dev] + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APPS_DIR="$SCRIPT_DIR" + +# Determine container runtime +RUNTIME="auto" +if command -v podman >/dev/null 2>&1 && podman info >/dev/null 2>&1; then + RUNTIME="podman" +elif command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + RUNTIME="docker" +else + echo "❌ No container runtime available (Podman or Docker required)" + exit 1 +fi + +echo "🐳 Using runtime: $RUNTIME" + +# Build function +build_app() { + local app_id=$1 + local app_dir="$APPS_DIR/$app_id" + local dev_mode=$2 + + if [ ! -d "$app_dir" ]; then + echo "❌ App directory not found: $app_dir" + return 1 + fi + + if [ ! -f "$app_dir/Dockerfile" ]; then + echo "⚠️ No Dockerfile found for $app_id, skipping..." + return 0 + fi + + echo "" + echo "🔨 Building $app_id..." + + local image_tag="archipelago/$app_id:latest" + if [ "$dev_mode" = "--dev" ]; then + image_tag="archipelago/$app_id:dev" + fi + + cd "$app_dir" + + # For Node.js apps, install dependencies first + if [ -f "package.json" ]; then + echo " Installing Node.js dependencies..." + npm install --production=false + fi + + # Build the image + echo " Building container image: $image_tag" + $RUNTIME build -t "$image_tag" . + + if [ $? -eq 0 ]; then + echo "✅ $app_id built successfully: $image_tag" + else + echo "❌ Failed to build $app_id" + return 1 + fi +} + +# Main logic +if [ $# -eq 0 ]; then + # Build all apps + echo "🔨 Building all Archipelago apps..." + for app_dir in "$APPS_DIR"/*/; do + if [ -d "$app_dir" ] && [ -f "$app_dir/manifest.yml" ]; then + app_id=$(basename "$app_dir") + build_app "$app_id" "$@" + fi + done +else + # Build specific app + app_id=$1 + shift + build_app "$app_id" "$@" +fi + +echo "" +echo "✅ Build complete!" diff --git a/apps/core-lightning/Dockerfile b/apps/core-lightning/Dockerfile new file mode 100644 index 00000000..6a5c3595 --- /dev/null +++ b/apps/core-lightning/Dockerfile @@ -0,0 +1,5 @@ +# Core Lightning - uses official image +FROM elementsproject/lightningd:v23.08.2 + +# Default configuration is in the image +# No additional setup needed diff --git a/apps/core-lightning/manifest.yml b/apps/core-lightning/manifest.yml new file mode 100644 index 00000000..2d98f11e --- /dev/null +++ b/apps/core-lightning/manifest.yml @@ -0,0 +1,69 @@ +app: + id: core-lightning + name: Core Lightning (CLN) + version: 23.08.2 + description: Lightning Network implementation in C. Lightweight alternative to LND. + + container: + image: elementsproject/lightningd:v23.08.2 + image_signature: cosign://... + pull_policy: verify-signature + + dependencies: + - app_id: bitcoin-core + version: ">=26.0" + + resources: + cpu_limit: 1 + memory_limit: 512Mi + disk_limit: 5Gi + + security: + capabilities: [NET_BIND_SERVICE] + readonly_root: true + no_new_privileges: true + user: 1000 + seccomp_profile: default + network_policy: isolated + apparmor_profile: core-lightning + + ports: + - host: 9736 + container: 9735 + protocol: tcp # P2P (using 9736 to avoid conflict with LND) + auth: none + auth_rationale: >- + Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself. + - host: 9835 + container: 9835 + protocol: tcp # gRPC + auth: none + auth_rationale: >- + Core Lightning gRPC, authenticated by mutual TLS client certificates. + + volumes: + - type: bind + source: /var/lib/archipelago/core-lightning + target: /home/clightning/.lightning + options: [rw] + + environment: + - BITCOIND_RPCURL=http://bitcoin-core:8332 + - BITCOIND_RPCUSER=${BITCOIN_RPC_USER} + - BITCOIND_RPCPASS=${BITCOIN_RPC_PASSWORD} + - NETWORK=bitcoin + + health_check: + type: exec + endpoint: lightning-cli getinfo + interval: 30s + timeout: 5s + retries: 3 + + bitcoin_integration: + rpc_access: admin + sync_required: true + + lightning_integration: + channel_management: true + payment_routing: true diff --git a/apps/did-wallet/.dockerignore b/apps/did-wallet/.dockerignore new file mode 100644 index 00000000..e052d6d1 --- /dev/null +++ b/apps/did-wallet/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +*.log +.git +.gitignore +README.md diff --git a/apps/did-wallet/Dockerfile b/apps/did-wallet/Dockerfile new file mode 100644 index 00000000..bbb81841 --- /dev/null +++ b/apps/did-wallet/Dockerfile @@ -0,0 +1,39 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ +RUN npm ci + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM node:20-alpine + +WORKDIR /app + +# Copy built application +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./ +COPY --from=builder /app/public ./public + +# Create non-root user +RUN addgroup -g 1000 appuser && \ + adduser -D -u 1000 -G appuser appuser && \ + mkdir -p /app/wallet && \ + chown -R appuser:appuser /app + +USER appuser + +EXPOSE 8080 + +ENV WALLET_STORAGE=/app/wallet +ENV DWN_ENDPOINT=http://web5-dwn:3000 + +CMD ["node", "dist/index.js"] diff --git a/apps/did-wallet/README.md b/apps/did-wallet/README.md new file mode 100644 index 00000000..46479738 --- /dev/null +++ b/apps/did-wallet/README.md @@ -0,0 +1,35 @@ +# DID Wallet + +Web5 wallet with Decentralized Identifier (DID) support. + +## Building + +```bash +# From the apps directory +./build.sh did-wallet + +# Or manually +cd did-wallet +docker build -t archipelago/did-wallet:latest . +``` + +## Development + +```bash +cd did-wallet +npm install +npm run dev +``` + +## Ports + +- **8083**: Web UI (dev: 18083) + +## Running Locally + +```bash +docker run -p 8083:8080 \ + -v /tmp/archipelago-dev/did-wallet:/app/wallet \ + -e DWN_ENDPOINT=http://localhost:13000 \ + archipelago/did-wallet:latest +``` diff --git a/apps/did-wallet/manifest.yml b/apps/did-wallet/manifest.yml new file mode 100644 index 00000000..413df0a2 --- /dev/null +++ b/apps/did-wallet/manifest.yml @@ -0,0 +1,56 @@ +app: + id: did-wallet + name: Web5 DID Wallet + version: 1.0.0 + description: Web5 wallet with Decentralized Identifier (DID) support. Manage your digital identity and Web5 assets. + + container: + image: archipelago/did-wallet:1.0.0 + image_signature: cosign://... + pull_policy: if-not-present + + dependencies: + - storage: 2Gi + + resources: + cpu_limit: 1 + memory_limit: 512Mi + disk_limit: 2Gi + + security: + capabilities: [] + readonly_root: true + no_new_privileges: true + user: 1000 + seccomp_profile: default + network_policy: isolated + apparmor_profile: did-wallet + + ports: + - host: 8088 + container: 8080 + protocol: tcp # Web UI + bind: 127.0.0.1 + auth: gated + + volumes: + - type: bind + source: /var/lib/archipelago/did-wallet + target: /app/wallet + options: [rw] + + environment: + - WALLET_STORAGE=/app/wallet + + health_check: + type: http + endpoint: http://127.0.0.1:8080 + path: /health + interval: 30s + timeout: 5s + retries: 3 + + web5_integration: + did_support: true + wallet_functionality: true + bitcoin_integration: true diff --git a/apps/did-wallet/package-lock.json b/apps/did-wallet/package-lock.json new file mode 100644 index 00000000..98d51801 --- /dev/null +++ b/apps/did-wallet/package-lock.json @@ -0,0 +1,2747 @@ +{ + "name": "did-wallet", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "did-wallet", + "version": "1.0.0", + "dependencies": { + "@web5/api": "^0.9.0", + "express": "^4.18.2" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.10.0", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } + }, + "node_modules/@assemblyscript/loader": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/@assemblyscript/loader/-/loader-0.9.4.tgz", + "integrity": "sha512-HazVq9zwTVwGmqdwYzu7WyQ6FQVZ7SwET0KKQuKm55jD0IfUpZgN0OPIiZG3zV1iSrVYcN0bdwLRXI/VNCYsUA==", + "license": "Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@decentralized-identity/ion-sdk": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@decentralized-identity/ion-sdk/-/ion-sdk-1.0.4.tgz", + "integrity": "sha512-pOWrlTH5ChxUKRHOgfG2ZeTioWEFJXADyErCQOJ0BqYNDKfP+CM09Vss+9ei6PNOABQlcDn0mEDFZtpO+DXl8A==", + "license": "Apache-2.0", + "dependencies": { + "@noble/ed25519": "^2.0.0", + "@noble/secp256k1": "^2.0.0", + "canonicalize": "^2.0.0", + "multiformats": "^12.1.3", + "uri-js": "^4.4.1" + } + }, + "node_modules/@decentralized-identity/ion-sdk/node_modules/multiformats": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-12.1.3.tgz", + "integrity": "sha512-eajQ/ZH7qXZQR2AgtfpmSMizQzmyYVmCql7pdhldPuYQi4atACekbJaQplk6dWyIi10jCaFnd6pqvcEFXjbaJw==", + "license": "Apache-2.0 OR MIT", + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@dnsquery/dns-packet": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@dnsquery/dns-packet/-/dns-packet-6.1.1.tgz", + "integrity": "sha512-WXTuFvL3G+74SchFAtz3FgIYVOe196ycvGsMgvSH/8Goptb1qpIQtIuM4SOK9G9lhMWYpHxnXyy544ZhluFOew==", + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.4", + "utf8-codec": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@ipld/dag-cbor": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@ipld/dag-cbor/-/dag-cbor-9.0.3.tgz", + "integrity": "sha512-A2UFccS0+sARK9xwXiVZIaWbLbPxLGP3UZOjBeOMWfDY04SXi8h1+t4rHBzOlKYF/yWNm3RbFLyclWO7hZcy4g==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "cborg": "^2.0.1", + "multiformats": "^12.0.1" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@ipld/dag-cbor/node_modules/multiformats": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-12.1.3.tgz", + "integrity": "sha512-eajQ/ZH7qXZQR2AgtfpmSMizQzmyYVmCql7pdhldPuYQi4atACekbJaQplk6dWyIi10jCaFnd6pqvcEFXjbaJw==", + "license": "Apache-2.0 OR MIT", + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@ipld/dag-pb": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@ipld/dag-pb/-/dag-pb-4.1.5.tgz", + "integrity": "sha512-w4PZ2yPqvNmlAir7/2hsCRMqny1EY5jj26iZcSgxREJexmbAc2FI21jp26MqiNdfgAxvkCnf2N/TJI18GaDNwA==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "multiformats": "^13.1.0" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@ipld/dag-pb/node_modules/multiformats": { + "version": "13.4.2", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.2.tgz", + "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==", + "license": "Apache-2.0 OR MIT" + }, + "node_modules/@isaacs/ttlcache": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", + "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@js-temporal/polyfill": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.4.4.tgz", + "integrity": "sha512-2X6bvghJ/JAoZO52lbgyAPFj8uCflhTo2g7nkFzEQdXd/D8rEeD4HtmTEpmtGCva260fcd66YNXBOYdnmHqSOg==", + "license": "ISC", + "dependencies": { + "jsbi": "^4.3.0", + "tslib": "^2.4.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "license": "MIT" + }, + "node_modules/@multiformats/murmur3": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@multiformats/murmur3/-/murmur3-2.2.0.tgz", + "integrity": "sha512-G5qVQxMWJ/Rja7hAx7cgyPcqnFmuOm0aHJ6TgJ+1odbdMu5BxpCCKH9y/Kw5spNbO9aYk8Jf41NbHkoHYDyZHQ==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "multiformats": "^13.0.0" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@multiformats/murmur3/node_modules/multiformats": { + "version": "13.4.2", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.2.tgz", + "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==", + "license": "Apache-2.0 OR MIT" + }, + "node_modules/@noble/ciphers": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.4.1.tgz", + "integrity": "sha512-QCOA9cgf3Rc33owG0AYBB9wszz+Ul2kramWN8tXG44Gyciud/tbkEqvxRF/IpqQaBpRBNi9f4jdNxqB2CQCIXg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.3.0.tgz", + "integrity": "sha512-t01iSXPuN+Eqzb4eBX0S5oubSqXbK/xXa1Ne18Hj8f9pStxztHCE2gfboSp/dZRLSqfuLpRK2nDXDK+W9puocA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.3" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/ed25519": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-2.0.0.tgz", + "integrity": "sha512-/extjhkwFupyopDrt80OMWKdLgP429qLZj+z6sYJz90rF2Iz0gjZh2ArMKPImUl13Kx+0EXI2hN9T/KJV0/Zng==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT" + }, + "node_modules/@noble/hashes": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz", + "integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/secp256k1": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-2.0.0.tgz", + "integrity": "sha512-rUGBd95e2a45rlmFTqQJYEFA4/gdIARFfuTuTqLglz0PZ6AKyzyXsEZZq7UZn8hZsvaBgpCzKKBJizT2cJERXw==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT" + }, + "node_modules/@scure/base": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz", + "integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.2.tgz", + "integrity": "sha512-HYf9TUXG80beW+hGAt3TRM8wU6pQoYur9iNypTROm42dorCGmLnFe3eWjz3gOq6G62H2WRh0FCzAR1PI+29zIA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.3.2", + "@scure/base": "~1.1.4" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@tbd54566975/dwn-sdk-js": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@tbd54566975/dwn-sdk-js/-/dwn-sdk-js-0.3.5.tgz", + "integrity": "sha512-1OZFxZSSpMpA186wqPOMbvhE7GJAZ5agVjGusC2KF2FQ4HxM26nlW6ZVigYVnfN/ddrrrey3t3hE7BiGQ9m2yg==", + "license": "Apache-2.0", + "dependencies": { + "@ipld/dag-cbor": "9.0.3", + "@js-temporal/polyfill": "0.4.4", + "@noble/ciphers": "0.5.3", + "@noble/ed25519": "2.0.0", + "@noble/secp256k1": "2.0.0", + "@web5/dids": "1.1.0", + "abstract-level": "1.0.3", + "ajv": "8.12.0", + "blockstore-core": "4.2.0", + "cross-fetch": "4.0.0", + "eciesjs": "0.4.5", + "interface-blockstore": "5.2.3", + "interface-store": "5.1.2", + "ipfs-unixfs-exporter": "13.1.5", + "ipfs-unixfs-importer": "15.1.5", + "level": "8.0.0", + "lodash": "4.17.21", + "lru-cache": "9.1.2", + "ms": "2.1.3", + "multiformats": "11.0.2", + "randombytes": "2.1.0", + "readable-stream": "4.5.2", + "ulidx": "2.1.0", + "uuid": "8.3.2", + "varint": "6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@tbd54566975/dwn-sdk-js/node_modules/@noble/ciphers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz", + "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@tbd54566975/dwn-sdk-js/node_modules/abstract-level": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/abstract-level/-/abstract-level-1.0.3.tgz", + "integrity": "sha512-t6jv+xHy+VYwc4xqZMn2Pa9DjcdzvzZmQGRjTFc8spIbRGHgBrEKbPq+rYXc7CCo0lxgYvSgKVg9qZAhpVQSjA==", + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "catering": "^2.1.0", + "is-buffer": "^2.0.5", + "level-supports": "^4.0.0", + "level-transcoder": "^1.0.1", + "module-error": "^1.0.1", + "queue-microtask": "^1.2.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", + "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@web5/agent": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@web5/agent/-/agent-0.3.8.tgz", + "integrity": "sha512-uQLHqlYzAuO/y/l24oDHoTFjxsq3RF6sYt8iGsqUZr6OxDo3YaSBQiu/3Kd/dRv3wXDe1neeu6aoCG5Lp6x48g==", + "license": "Apache-2.0", + "dependencies": { + "@noble/ciphers": "0.4.1", + "@scure/bip39": "1.2.2", + "@tbd54566975/dwn-sdk-js": "0.3.5", + "@web5/common": "1.0.0", + "@web5/crypto": "1.0.0", + "@web5/dids": "1.1.0", + "abstract-level": "1.0.4", + "ed25519-keygen": "0.4.11", + "isomorphic-ws": "^5.0.0", + "level": "8.0.0", + "ms": "2.1.3", + "readable-web-to-node-stream": "3.0.2", + "ulidx": "2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@web5/api": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/@web5/api/-/api-0.9.4.tgz", + "integrity": "sha512-ubAvlEkiXMgpkagSmh4RAch5RqdH587Sd9By3eLHpbI7pqJVCTi3rrSIhCJ3I3PLswRlXx78Llb+7nRHB2rvpQ==", + "license": "Apache-2.0", + "dependencies": { + "@web5/agent": "0.3.8", + "@web5/common": "1.0.0", + "@web5/crypto": "1.0.0", + "@web5/dids": "1.1.0", + "@web5/user-agent": "0.3.8" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@web5/common": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@web5/common/-/common-1.0.0.tgz", + "integrity": "sha512-3JHF6X5o0h+3oAVQeBC4XpMoZeEYZYdEmQdgpOfKv/rnSru2yHQSAM+0wbIvEFcSCmelBT3u7rUAcpJjelLB0w==", + "license": "Apache-2.0", + "dependencies": { + "@isaacs/ttlcache": "1.4.1", + "level": "8.0.0", + "multiformats": "11.0.2", + "readable-stream": "4.4.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@web5/common/node_modules/readable-stream": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.4.2.tgz", + "integrity": "sha512-Lk/fICSyIhodxy1IDK2HazkeGjSmezAWX2egdtJnYhtzKEsBPJowlI6F6LPb5tqIQILrMbx22S5o3GuJavPusA==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@web5/crypto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@web5/crypto/-/crypto-1.0.0.tgz", + "integrity": "sha512-z1CsgycTqiXEsS6pPlJDDLGAeGsgzfdBeWvyxLXTgh08Q8ACULmEGRXjSsgWHFn6DO6MpWFn55h/hF4wZZRxvA==", + "license": "Apache-2.0", + "dependencies": { + "@noble/ciphers": "0.4.1", + "@noble/curves": "1.3.0", + "@noble/hashes": "1.3.3", + "@web5/common": "1.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@web5/dids": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@web5/dids/-/dids-1.1.0.tgz", + "integrity": "sha512-d9pKf/DW+ziUiV5g3McC71utyAhQyT1tYGPbQSYWt2ji6FHGNC6tffHMfLXXK/W+vbwV3eNTn06JqTXRaYhxBA==", + "license": "Apache-2.0", + "dependencies": { + "@decentralized-identity/ion-sdk": "1.0.4", + "@dnsquery/dns-packet": "6.1.1", + "@web5/common": "1.0.0", + "@web5/crypto": "1.0.0", + "abstract-level": "1.0.4", + "bencode": "4.0.0", + "buffer": "6.0.3", + "level": "8.0.1", + "ms": "2.1.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@web5/dids/node_modules/level": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/level/-/level-8.0.1.tgz", + "integrity": "sha512-oPBGkheysuw7DmzFQYyFe8NAia5jFLAgEnkgWnK3OXAuJr8qFT+xBQIwokAZPME2bhPFzS8hlYcL16m8UZrtwQ==", + "license": "MIT", + "dependencies": { + "abstract-level": "^1.0.4", + "browser-level": "^1.0.1", + "classic-level": "^1.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/level" + } + }, + "node_modules/@web5/user-agent": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@web5/user-agent/-/user-agent-0.3.8.tgz", + "integrity": "sha512-gGkahalx5WBuMW23WrWX7b0EWHnAxWj+ZSPoGiK5i5TiXUi5B/LEHAH4kmD8OZBlNua6SefK5rAz0AY4nEGZ8A==", + "license": "Apache-2.0", + "dependencies": { + "@web5/agent": "0.3.8", + "@web5/common": "1.0.0", + "@web5/crypto": "1.0.0", + "@web5/dids": "1.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/abort-error": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/abort-error/-/abort-error-1.0.1.tgz", + "integrity": "sha512-fxqCblJiIPdSXIUrxI0PL+eJG49QdP9SQ70qtB65MVAoMr2rASlOyAbJFOylfB467F/f+5BCLJJq58RYi7mGfg==", + "license": "Apache-2.0 OR MIT" + }, + "node_modules/abstract-level": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/abstract-level/-/abstract-level-1.0.4.tgz", + "integrity": "sha512-eUP/6pbXBkMbXFdx4IH2fVgvB7M0JvR7/lIL33zcs0IBcwjdzSSl31TOJsaCzmKSSDF9h8QYSOJux4Nd4YJqFg==", + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "catering": "^2.1.0", + "is-buffer": "^2.0.5", + "level-supports": "^4.0.0", + "level-transcoder": "^1.0.1", + "module-error": "^1.0.1", + "queue-microtask": "^1.2.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bencode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/bencode/-/bencode-4.0.0.tgz", + "integrity": "sha512-AERXw18df0pF3ziGOCyUjqKZBVNH8HV3lBxnx5w0qtgMIk4a1wb9BkcCQbkp9Zstfrn/dzRwl7MmUHHocX3sRQ==", + "license": "MIT", + "dependencies": { + "uint8-util": "^2.2.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/bl": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz", + "integrity": "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==", + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/blockstore-core": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/blockstore-core/-/blockstore-core-4.2.0.tgz", + "integrity": "sha512-F8BCobc75D+9/+hUD+5cixbU6zmZA+lBgNiuBkNlJqRgmAaBBvLOQF6Ad9Jei0Nvmy2a1jaF4CiN76W1apIghA==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "err-code": "^3.0.1", + "interface-blockstore": "^5.0.0", + "interface-store": "^5.0.0", + "multiformats": "^11.0.2" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/browser-level": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/browser-level/-/browser-level-1.0.1.tgz", + "integrity": "sha512-XECYKJ+Dbzw0lbydyQuJzwNXtOpbMSq737qxJN11sIRTErOMShvDpbzTlgju7orJKvx4epULolZAuJGLzCmWRQ==", + "license": "MIT", + "dependencies": { + "abstract-level": "^1.0.2", + "catering": "^2.1.1", + "module-error": "^1.0.2", + "run-parallel-limit": "^1.1.0" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/canonicalize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-2.1.0.tgz", + "integrity": "sha512-F705O3xrsUtgt98j7leetNhTWPe+5S72rlL5O4jA1pKqBVQ/dT1O1D6PFxmSXvc0SUOinWS57DKx0I3CHrXJHQ==", + "license": "Apache-2.0", + "bin": { + "canonicalize": "bin/canonicalize.js" + } + }, + "node_modules/catering": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/catering/-/catering-2.1.1.tgz", + "integrity": "sha512-K7Qy8O9p76sL3/3m7/zLKbRkyOlSZAgzEaLhyj2mXS8PsCud2Eo4hAb8aLtZqHh0QGqLcb9dlJSu6lHRVENm1w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cborg": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/cborg/-/cborg-2.0.5.tgz", + "integrity": "sha512-xVW1rSIw1ZXbkwl2XhJ7o/jAv0vnVoQv/QlfQxV8a7V5PlA4UU/AcIiXqmpyybwNWy/GPQU1m/aBVNIWr7/T0w==", + "license": "Apache-2.0", + "bin": { + "cborg": "cli.js" + } + }, + "node_modules/classic-level": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/classic-level/-/classic-level-1.4.1.tgz", + "integrity": "sha512-qGx/KJl3bvtOHrGau2WklEZuXhS3zme+jf+fsu6Ej7W7IP/C49v7KNlWIsT1jZu0YnfzSIYDGcEWpCa1wKGWXQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "abstract-level": "^1.0.2", + "catering": "^2.1.0", + "module-error": "^1.0.1", + "napi-macros": "^2.2.2", + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eciesjs": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.5.tgz", + "integrity": "sha512-2zSRIygO48LpdS95Rwt9ryIkJNO37IdbkjRsnYyAn7gx7e4WPBNimnk6jGNdx2QQYr/VJRPnSVdwQpO5bycYZw==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "^0.3.0", + "@noble/curves": "^1.2.0", + "@noble/hashes": "^1.3.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/eciesjs/node_modules/@noble/ciphers": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.3.0.tgz", + "integrity": "sha512-ldbrnOjmNRwFdXcTM6uXDcxpMIFrbzAWNnpBPp4oTJTFF0XByGD6vf45WrehZGXRQTRVV+Zm8YP+EgEf+e4cWA==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ed25519-keygen": { + "version": "0.4.11", + "resolved": "https://registry.npmjs.org/ed25519-keygen/-/ed25519-keygen-0.4.11.tgz", + "integrity": "sha512-UKxebk/eoW/0yy6BcyCkgAvN2/VzwVXiMVHgKNYBMX6T0fJRAE3WWvH2inyuBvMIJaOqlkc3utylUvL8yW6SOg==", + "deprecated": "Switch to micro-key-producer: the package has been merged into it", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.3.0", + "@noble/hashes": "~1.3.3", + "@scure/base": "~1.1.5", + "micro-packed": "~0.5.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/err-code": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-3.0.1.tgz", + "integrity": "sha512-GiaH0KJUewYok+eeY05IIgjtAe4Yltygk9Wqp1V5yVWLdhf0hYZchRjNIT9bb0mSwRcIusT3cx7PJUf3zEIfUA==", + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hamt-sharding": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/hamt-sharding/-/hamt-sharding-3.0.6.tgz", + "integrity": "sha512-nZeamxfymIWLpVcAN0CRrb7uVq3hCOGj9IcL6NMA6VVCVWqj+h9Jo/SmaWuS92AEDf1thmHsM5D5c70hM3j2Tg==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "sparse-array": "^1.3.1", + "uint8arrays": "^5.0.1" + } + }, + "node_modules/hamt-sharding/node_modules/multiformats": { + "version": "13.4.2", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.2.tgz", + "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==", + "license": "Apache-2.0 OR MIT" + }, + "node_modules/hamt-sharding/node_modules/uint8arrays": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-5.1.0.tgz", + "integrity": "sha512-vA6nFepEmlSKkMBnLBaUMVvAC4G3CTmO58C12y4sq6WPDOR7mOFYOi7GlrQ4djeSbP6JG9Pv9tJDM97PedRSww==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "multiformats": "^13.0.0" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/interface-blockstore": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/interface-blockstore/-/interface-blockstore-5.2.3.tgz", + "integrity": "sha512-15cN+ZFdcVXdXo6I/SrSzFDsuJyDTyEI52XuvXQlR/G5fe3cK8p0tvVjfu5diRQH1XqNgmJEdMPixyt0xgjtvQ==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "interface-store": "^5.0.0", + "multiformats": "^11.0.2" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/interface-store": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/interface-store/-/interface-store-5.1.2.tgz", + "integrity": "sha512-q2sLoqC+UdaWnjwGyghsH0jwqqVk226lsG207e3QwPB8sAZYmYIWUnJwJH3JjFNNRV9e6CUTmm+gDO0Xg4KRiw==", + "license": "Apache-2.0 OR MIT", + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/ipfs-unixfs": { + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/ipfs-unixfs/-/ipfs-unixfs-11.2.5.tgz", + "integrity": "sha512-uasYJ0GLPbViaTFsOLnL9YPjX5VmhnqtWRriogAHOe4ApmIi9VAOFBzgDHsUW2ub4pEa/EysbtWk126g2vkU/g==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "protons-runtime": "^5.5.0", + "uint8arraylist": "^2.4.8" + } + }, + "node_modules/ipfs-unixfs-exporter": { + "version": "13.1.5", + "resolved": "https://registry.npmjs.org/ipfs-unixfs-exporter/-/ipfs-unixfs-exporter-13.1.5.tgz", + "integrity": "sha512-O5aMawsHoe4DaYk5FFil2EPrNOaU3pkHC6qUR5JMnW7es93W3b/RjJoO7AyDL1rpb+M3K0oRu86Yc5wLNQQ8jg==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "@ipld/dag-cbor": "^9.0.0", + "@ipld/dag-pb": "^4.0.0", + "@multiformats/murmur3": "^2.0.0", + "err-code": "^3.0.1", + "hamt-sharding": "^3.0.0", + "interface-blockstore": "^5.0.0", + "ipfs-unixfs": "^11.0.0", + "it-filter": "^3.0.2", + "it-last": "^3.0.2", + "it-map": "^3.0.3", + "it-parallel": "^3.0.0", + "it-pipe": "^3.0.1", + "it-pushable": "^3.1.0", + "multiformats": "^11.0.0", + "p-queue": "^7.3.0", + "progress-events": "^1.0.0", + "uint8arrays": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/ipfs-unixfs-importer": { + "version": "15.1.5", + "resolved": "https://registry.npmjs.org/ipfs-unixfs-importer/-/ipfs-unixfs-importer-15.1.5.tgz", + "integrity": "sha512-TXaOI0M5KNpq2+qLw8AIYd0Lnc0gWTKCBqUd9eErBUwaP3Fna4qauF+JX9Rj2UrwaOvG/1xbF8Vm+92eOcKWMA==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "@ipld/dag-pb": "^4.0.0", + "@multiformats/murmur3": "^2.0.0", + "err-code": "^3.0.1", + "hamt-sharding": "^3.0.0", + "interface-blockstore": "^5.0.0", + "interface-store": "^5.0.1", + "ipfs-unixfs": "^11.0.0", + "it-all": "^3.0.2", + "it-batch": "^3.0.2", + "it-first": "^3.0.2", + "it-parallel-batch": "^3.0.1", + "multiformats": "^11.0.0", + "progress-events": "^1.0.0", + "rabin-wasm": "^0.1.4", + "uint8arraylist": "^2.4.3", + "uint8arrays": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/isomorphic-ws": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", + "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/it-all": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/it-all/-/it-all-3.0.9.tgz", + "integrity": "sha512-fz1oJJ36ciGnu2LntAlE6SA97bFZpW7Rnt0uEc1yazzR2nKokZLr8lIRtgnpex4NsmaBcvHF+Z9krljWFy/mmg==", + "license": "Apache-2.0 OR MIT" + }, + "node_modules/it-batch": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/it-batch/-/it-batch-3.0.9.tgz", + "integrity": "sha512-z6p89Q8gm2urBtF3JcpnbJogacijWk3m1uc3xZYI3x0eJUoYLUbgF8IxJ2fnuVObV7yRv3SixfwGCufaZY1NCg==", + "license": "Apache-2.0 OR MIT" + }, + "node_modules/it-filter": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/it-filter/-/it-filter-3.1.4.tgz", + "integrity": "sha512-80kWEKgiFEa4fEYD3mwf2uygo1dTQ5Y5midKtL89iXyjinruA/sNXl6iFkTcdNedydjvIsFhWLiqRPQP4fAwWQ==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "it-peekable": "^3.0.0" + } + }, + "node_modules/it-first": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/it-first/-/it-first-3.0.9.tgz", + "integrity": "sha512-ZWYun273Gbl7CwiF6kK5xBtIKR56H1NoRaiJek2QzDirgen24u8XZ0Nk+jdnJSuCTPxC2ul1TuXKxu/7eK6NuA==", + "license": "Apache-2.0 OR MIT" + }, + "node_modules/it-last": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/it-last/-/it-last-3.0.9.tgz", + "integrity": "sha512-AtfUEnGDBHBEwa1LjrpGHsJMzJAWDipD6zilvhakzJcm+BCvNX8zlX2BsHClHJLLTrsY4lY9JUjc+TQV4W7m1w==", + "license": "Apache-2.0 OR MIT" + }, + "node_modules/it-map": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/it-map/-/it-map-3.1.4.tgz", + "integrity": "sha512-QB9PYQdE9fUfpVFYfSxBIyvKynUCgblb143c+ktTK6ZuKSKkp7iH58uYFzagqcJ5HcqIfn1xbfaralHWam+3fg==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "it-peekable": "^3.0.0" + } + }, + "node_modules/it-merge": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/it-merge/-/it-merge-3.0.12.tgz", + "integrity": "sha512-nnnFSUxKlkZVZD7c0jYw6rDxCcAQYcMsFj27thf7KkDhpj0EA0g9KHPxbFzHuDoc6US2EPS/MtplkNj8sbCx4Q==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "it-queueless-pushable": "^2.0.0" + } + }, + "node_modules/it-parallel": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/it-parallel/-/it-parallel-3.0.13.tgz", + "integrity": "sha512-85PPJ/O8q97Vj9wmDTSBBXEkattwfQGruXitIzrh0RLPso6RHfiVqkuTqBNufYYtB1x6PSkh0cwvjmMIkFEPHA==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "p-defer": "^4.0.1" + } + }, + "node_modules/it-parallel-batch": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/it-parallel-batch/-/it-parallel-batch-3.0.9.tgz", + "integrity": "sha512-TszXWqqLG8IG5DUEnC4cgH9aZI6CsGS7sdkXTiiacMIj913bFy7+ohU3IqsFURCcZkpnXtNLNzrYnXISsKBhbQ==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "it-batch": "^3.0.0" + } + }, + "node_modules/it-peekable": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/it-peekable/-/it-peekable-3.0.8.tgz", + "integrity": "sha512-7IDBQKSp/dtBxXV3Fj0v3qM1jftJ9y9XrWLRIuU1X6RdKqWiN60syNwP0fiDxZD97b8SYM58dD3uklIk1TTQAw==", + "license": "Apache-2.0 OR MIT" + }, + "node_modules/it-pipe": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/it-pipe/-/it-pipe-3.0.1.tgz", + "integrity": "sha512-sIoNrQl1qSRg2seYSBH/3QxWhJFn9PKYvOf/bHdtCBF0bnghey44VyASsWzn5dAx0DCDDABq1hZIuzKmtBZmKA==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "it-merge": "^3.0.0", + "it-pushable": "^3.1.2", + "it-stream-types": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/it-pushable": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/it-pushable/-/it-pushable-3.2.3.tgz", + "integrity": "sha512-gzYnXYK8Y5t5b/BnJUr7glfQLO4U5vyb05gPx/TyTw+4Bv1zM9gFk4YsOrnulWefMewlphCjKkakFvj1y99Tcg==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "p-defer": "^4.0.0" + } + }, + "node_modules/it-queueless-pushable": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/it-queueless-pushable/-/it-queueless-pushable-2.0.3.tgz", + "integrity": "sha512-USa5EzTvmQswOcVE7+o6qsj2o2G+6KHCxSogPOs23sGYkDWFidhqVO7dAvv6ve/Z+Q+nvxpEa9rrRo6VEK7w4Q==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "abort-error": "^1.0.1", + "p-defer": "^4.0.1", + "race-signal": "^2.0.0" + } + }, + "node_modules/it-stream-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/it-stream-types/-/it-stream-types-2.0.2.tgz", + "integrity": "sha512-Rz/DEZ6Byn/r9+/SBCuJhpPATDF9D+dz5pbgSUyBsCDtza6wtNATrz/jz1gDyNanC3XdLboriHnOC925bZRBww==", + "license": "Apache-2.0 OR MIT" + }, + "node_modules/jsbi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.2.tgz", + "integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==", + "license": "Apache-2.0" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/layerr": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/layerr/-/layerr-2.1.0.tgz", + "integrity": "sha512-xDD9suWxfBYeXgqffRVH/Wqh+mqZrQcqPRn0I0ijl7iJQ7vu8gMGPt1Qop59pEW/jaIDNUN7+PX1Qk40+vuflg==", + "license": "MIT" + }, + "node_modules/level": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/level/-/level-8.0.0.tgz", + "integrity": "sha512-ypf0jjAk2BWI33yzEaaotpq7fkOPALKAgDBxggO6Q9HGX2MRXn0wbP1Jn/tJv1gtL867+YOjOB49WaUF3UoJNQ==", + "license": "MIT", + "dependencies": { + "browser-level": "^1.0.1", + "classic-level": "^1.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/level" + } + }, + "node_modules/level-supports": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/level-supports/-/level-supports-4.0.1.tgz", + "integrity": "sha512-PbXpve8rKeNcZ9C1mUicC9auIYFyGpkV9/i6g76tLgANwWhtG2v7I4xNBUlkn3lE2/dZF3Pi0ygYGtLc4RXXdA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/level-transcoder": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/level-transcoder/-/level-transcoder-1.0.1.tgz", + "integrity": "sha512-t7bFwFtsQeD8cl8NIoQ2iwxA0CL/9IFw7/9gAjOonH0PWTTiRfY7Hq+Ejbsxh86tXobDQ6IOiddjNYIfOBs06w==", + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "module-error": "^1.0.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-9.1.2.tgz", + "integrity": "sha512-ERJq3FOzJTxBbFjZ7iDs+NiK4VI9Wz+RdrrAB8dio1oV+YvdPzUEE4QNiT2VD51DkIbCYRUUzCRkssXCHqSnKQ==", + "license": "ISC", + "engines": { + "node": "14 || >=16.14" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micro-packed": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/micro-packed/-/micro-packed-0.5.3.tgz", + "integrity": "sha512-zWRoH+qUb/ZMp9gVZhexvRGCENDM5HEQF4sflqpdilUHWK2/zKR7/MT8GBctnTwbhNJwy1iuk5q6+TYP7/twYA==", + "license": "MIT", + "dependencies": { + "@scure/base": "~1.1.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/module-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/module-error/-/module-error-1.0.2.tgz", + "integrity": "sha512-0yuvsqSCv8LbaOKhnsQ/T5JhyFlCYLPXK3U2sgV10zoKQwzs/MyfuQUOZQ1V/6OCOJsK/TRgNVrPuPDqtdMFtA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multiformats": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-11.0.2.tgz", + "integrity": "sha512-b5mYMkOkARIuVZCpvijFj9a6m5wMVLC7cf/jIPd5D/ARDOfLC5+IFkbgDXQgcU2goIsTD/O9NY4DI/Mt4OGvlg==", + "license": "Apache-2.0 OR MIT", + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/napi-macros": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/napi-macros/-/napi-macros-2.2.2.tgz", + "integrity": "sha512-hmEVtAGYzVQpCKdbQea4skABsdXW4RUh5t5mJ2zzqowJS2OyXZTU1KhDVFhx+NlWZ4ap9mqR9TcDO3LTTttd+g==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/p-defer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-4.0.1.tgz", + "integrity": "sha512-Mr5KC5efvAK5VUptYEIopP1bakB85k2IWXaRC0rsh1uwn1L6M0LVml8OIQ4Gudg4oyZakf7FmeRLkMMtZW1i5A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-7.4.1.tgz", + "integrity": "sha512-vRpMXmIkYF2/1hLBKisKeVYJZ8S2tZ0zEAmIJgdVKP2nq0nh4qCdf8bgw+ZgKrkh71AOCaqzwbJJk1WtdcF3VA==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^5.0.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-5.1.0.tgz", + "integrity": "sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/progress-events": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/progress-events/-/progress-events-1.0.1.tgz", + "integrity": "sha512-MOzLIwhpt64KIVN64h1MwdKWiyKFNc/S6BoYKPIVUHFg0/eIEyBulhWCgn678v/4c0ri3FdGuzXymNCv02MUIw==", + "license": "Apache-2.0 OR MIT" + }, + "node_modules/protons-runtime": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/protons-runtime/-/protons-runtime-5.6.0.tgz", + "integrity": "sha512-/Kde+sB9DsMFrddJT/UZWe6XqvL7SL5dbag/DBCElFKhkwDj7XKt53S+mzLyaDP5OqS0wXjV5SA572uWDaT0Hg==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "uint8-varint": "^2.0.2", + "uint8arraylist": "^2.4.3", + "uint8arrays": "^5.0.1" + } + }, + "node_modules/protons-runtime/node_modules/multiformats": { + "version": "13.4.2", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.2.tgz", + "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==", + "license": "Apache-2.0 OR MIT" + }, + "node_modules/protons-runtime/node_modules/uint8arrays": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-5.1.0.tgz", + "integrity": "sha512-vA6nFepEmlSKkMBnLBaUMVvAC4G3CTmO58C12y4sq6WPDOR7mOFYOi7GlrQ4djeSbP6JG9Pv9tJDM97PedRSww==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "multiformats": "^13.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/rabin-wasm": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/rabin-wasm/-/rabin-wasm-0.1.5.tgz", + "integrity": "sha512-uWgQTo7pim1Rnj5TuWcCewRDTf0PEFTSlaUjWP4eY9EbLV9em08v89oCz/WO+wRxpYuO36XEHp4wgYQnAgOHzA==", + "license": "MIT", + "dependencies": { + "@assemblyscript/loader": "^0.9.4", + "bl": "^5.0.0", + "debug": "^4.3.1", + "minimist": "^1.2.5", + "node-fetch": "^2.6.1", + "readable-stream": "^3.6.0" + }, + "bin": { + "rabin-wasm": "cli/bin.js" + } + }, + "node_modules/rabin-wasm/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/rabin-wasm/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/race-signal": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/race-signal/-/race-signal-2.0.0.tgz", + "integrity": "sha512-P31bLhE4ByBX/70QDXMutxnqgwrF1WUXea1O8DXuviAgkdbQ1iQMQotNgzJIBC9yUSn08u/acZrMUhgw7w6GpA==", + "license": "Apache-2.0 OR MIT" + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readable-web-to-node-stream": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz", + "integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/readable-web-to-node-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel-limit": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/run-parallel-limit/-/run-parallel-limit-1.1.0.tgz", + "integrity": "sha512-jJA7irRNM91jaKc3Hcl1npHsFLOXOoTkPCUL1JEa1R82O2miplXXRaGdjW/KM/98YQWDhJLiSs793CnXfblJUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sparse-array": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/sparse-array/-/sparse-array-1.3.2.tgz", + "integrity": "sha512-ZT711fePGn3+kQyLuv1fpd3rNSkNF8vd5Kv2D+qnOANeyKs3fx6bUMGWRPvgTTcYV64QMqZKZwcuaQSP3AZ0tg==", + "license": "ISC" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uint8-util": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/uint8-util/-/uint8-util-2.2.6.tgz", + "integrity": "sha512-r+ZjS8CzPhtPF771ROOadUoqC40OVdiMKBI8lTfJQWb4W7+73sMBwMYmai/uvNcmZ7tBJJyZSad03yMWIt3RQg==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, + "node_modules/uint8-varint": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/uint8-varint/-/uint8-varint-2.0.4.tgz", + "integrity": "sha512-FwpTa7ZGA/f/EssWAb5/YV6pHgVF1fViKdW8cWaEarjB8t7NyofSWBdOTyFPaGuUG4gx3v1O3PQ8etsiOs3lcw==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "uint8arraylist": "^2.0.0", + "uint8arrays": "^5.0.0" + } + }, + "node_modules/uint8-varint/node_modules/multiformats": { + "version": "13.4.2", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.2.tgz", + "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==", + "license": "Apache-2.0 OR MIT" + }, + "node_modules/uint8-varint/node_modules/uint8arrays": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-5.1.0.tgz", + "integrity": "sha512-vA6nFepEmlSKkMBnLBaUMVvAC4G3CTmO58C12y4sq6WPDOR7mOFYOi7GlrQ4djeSbP6JG9Pv9tJDM97PedRSww==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "multiformats": "^13.0.0" + } + }, + "node_modules/uint8arraylist": { + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/uint8arraylist/-/uint8arraylist-2.4.8.tgz", + "integrity": "sha512-vc1PlGOzglLF0eae1M8mLRTBivsvrGsdmJ5RbK3e+QRvRLOZfZhQROTwH/OfyF3+ZVUg9/8hE8bmKP2CvP9quQ==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "uint8arrays": "^5.0.1" + } + }, + "node_modules/uint8arraylist/node_modules/multiformats": { + "version": "13.4.2", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-13.4.2.tgz", + "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==", + "license": "Apache-2.0 OR MIT" + }, + "node_modules/uint8arraylist/node_modules/uint8arrays": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-5.1.0.tgz", + "integrity": "sha512-vA6nFepEmlSKkMBnLBaUMVvAC4G3CTmO58C12y4sq6WPDOR7mOFYOi7GlrQ4djeSbP6JG9Pv9tJDM97PedRSww==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "multiformats": "^13.0.0" + } + }, + "node_modules/uint8arrays": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-4.0.10.tgz", + "integrity": "sha512-AnJNUGGDJAgFw/eWu/Xb9zrVKEGlwJJCaeInlf3BkecE/zcTobk5YXYIPNQJO1q5Hh1QZrQQHf0JvcHqz2hqoA==", + "license": "Apache-2.0 OR MIT", + "dependencies": { + "multiformats": "^12.0.1" + } + }, + "node_modules/uint8arrays/node_modules/multiformats": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/multiformats/-/multiformats-12.1.3.tgz", + "integrity": "sha512-eajQ/ZH7qXZQR2AgtfpmSMizQzmyYVmCql7pdhldPuYQi4atACekbJaQplk6dWyIi10jCaFnd6pqvcEFXjbaJw==", + "license": "Apache-2.0 OR MIT", + "engines": { + "node": ">=16.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/ulidx": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ulidx/-/ulidx-2.1.0.tgz", + "integrity": "sha512-DlMi97oP9HASI3kLCjBlOhAG1SoisUrEqC2PJ7itiFbq9q5Zo0JejupXeu2Gke99W62epNzA4MFNToNiq8A5LA==", + "license": "MIT", + "dependencies": { + "layerr": "^2.0.1" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utf8-codec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/utf8-codec/-/utf8-codec-1.0.0.tgz", + "integrity": "sha512-S/QSLezp3qvG4ld5PUfXiH7mCFxLKjSVZRFkB3DOjgwHuJPFDkInAXc/anf7BAbHt/D38ozDzL+QMZ6/7gsI6w==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/varint": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", + "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/apps/did-wallet/package.json b/apps/did-wallet/package.json new file mode 100644 index 00000000..375b3917 --- /dev/null +++ b/apps/did-wallet/package.json @@ -0,0 +1,21 @@ +{ + "name": "did-wallet", + "version": "1.0.0", + "description": "Web5 DID Wallet for Archipelago", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "ts-node src/index.ts" + }, + "dependencies": { + "express": "^4.18.2", + "@web5/api": "^0.9.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.10.0", + "typescript": "^5.3.3", + "ts-node": "^10.9.2" + } +} diff --git a/apps/did-wallet/public/index.html b/apps/did-wallet/public/index.html new file mode 100644 index 00000000..2e30b125 --- /dev/null +++ b/apps/did-wallet/public/index.html @@ -0,0 +1,23 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>DID Wallet + + + +

Web5 DID Wallet

+

Decentralized Identity Wallet for Archipelago

+
+

Wallet interface coming soon...

+
+ + diff --git a/apps/did-wallet/src/index.ts b/apps/did-wallet/src/index.ts new file mode 100644 index 00000000..dfd2c02a --- /dev/null +++ b/apps/did-wallet/src/index.ts @@ -0,0 +1,37 @@ +import express from 'express'; + +const app = express(); +const port = 8080; + +// Middleware +app.use(express.json()); +app.use(express.static('public')); + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ status: 'ok', service: 'did-wallet' }); +}); + +// Wallet API endpoints +app.get('/api/wallet/info', (req, res) => { + res.json({ + status: 'ok', + wallet: { + dids: [], + balance: 0 + } + }); +}); + +app.post('/api/wallet/did/create', async (req, res) => { + // Placeholder for DID creation + res.json({ + status: 'ok', + did: 'did:key:placeholder' + }); +}); + +// Start server +app.listen(port, '0.0.0.0', () => { + console.log(`DID Wallet listening on port ${port}`); +}); diff --git a/apps/did-wallet/tsconfig.json b/apps/did-wallet/tsconfig.json new file mode 100644 index 00000000..fa8ee324 --- /dev/null +++ b/apps/did-wallet/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/electrs-ui/manifest.yml b/apps/electrs-ui/manifest.yml new file mode 100644 index 00000000..d1bff168 --- /dev/null +++ b/apps/electrs-ui/manifest.yml @@ -0,0 +1,53 @@ +app: + id: electrs-ui + name: Electrs UI + version: 1.0.0 + description: | + Archipelago-native HTTP frontend for electrs/electrumx status. Runs + nginx inside a container, serves static assets, and proxies + /electrs-status to the archipelago backend on 127.0.0.1:5678. + + container: + build: + context: /opt/archipelago/docker/electrs-ui + dockerfile: Dockerfile + tag: localhost/electrs-ui:local + + dependencies: [] + + resources: + memory_limit: 64Mi + + security: + readonly_root: false + network_policy: host + + # Host networking: nginx listens on 50002 directly on the host IP. + # Declared so the APP GATE can see this port. Host networking means Podman + # publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here + # is a statement of where the container's own nginx listens — 127.0.0.1 — + # not a publish instruction. Without this declaration the gate had no idea + # the port existed: it was neither protected nor listed as unprotected, and + # served the Electrs screen unauthenticated on every interface. + ports: + - host: 50002 + container: 50002 + protocol: tcp + bind: 127.0.0.1 + auth: gated + # First-party companion UI: its nginx forwards the node session cookie + # to the daemon's authenticated endpoints; without passthrough the gate + # strips it and every data call 401s while the page shell renders. + session_passthrough: true + + volumes: [] + + environment: [] + + health_check: + type: http + endpoint: http://127.0.0.1:50002 + path: / + interval: 30s + timeout: 5s + retries: 3 diff --git a/apps/electrumx/manifest.yml b/apps/electrumx/manifest.yml new file mode 100644 index 00000000..ffa929db --- /dev/null +++ b/apps/electrumx/manifest.yml @@ -0,0 +1,90 @@ +app: + id: electrumx + name: ElectrumX + version: 1.18.0 + description: Electrum server indexing Bitcoin chain data for lightweight wallet queries. + + container: + image: 146.59.87.168:3000/lfg2025/electrumx:v1.18.0 + pull_policy: if-not-present + network: archy-net + data_uid: "1000:1000" + entrypoint: ["sh", "-lc"] + # The bitcoin backend container is bitcoin-knots OR bitcoin-core depending + # on which version the node runs (multi-version switch) — probe which name + # resolves on archy-net instead of hardcoding knots, which left electrumx + # permanently disconnected (block index 0) on core nodes. + custom_args: + - >- + for h in bitcoin-knots bitcoin-core; do + if getent hosts "$h" >/dev/null 2>&1; then BTC_HOST="$h"; break; fi; + done; + export DAEMON_URL="http://archipelago:$(printenv BITCOIN_RPC_PASS)@${BTC_HOST:-bitcoin-knots}:8332/"; + exec electrumx_server + secret_env: + - key: BITCOIN_RPC_PASS + secret_file: bitcoin-rpc-password + + dependencies: + - app_id: bitcoin-knots + version: ">=26.0" + - storage: 50Gi + - bitcoin:archival + + resources: + cpu_limit: 0 + memory_limit: 6Gi + disk_limit: 50Gi + + security: + capabilities: [DAC_OVERRIDE] + readonly_root: false + network_policy: isolated + + ports: + - host: 50001 + container: 50001 + protocol: tcp + auth: none + auth_rationale: >- + Electrum wire protocol over TCP. Electrum wallets speak it directly and cannot hold a session cookie. + + volumes: + - type: bind + source: /var/lib/archipelago/electrumx + target: /data + options: [rw] + + environment: + - COIN=Bitcoin + - DB_DIRECTORY=/data + - SERVICES=tcp://:50001,rpc://0.0.0.0:8000 + - CACHE_MB=1024 + - MAX_SEND=10000000 + + # The ElectrumX dashboard tile is served by the host-networked companion UI + # (archy-electrs-ui) on port 50002, NOT by this container. Declaring it here + # lets the catalog generator emit electrumx -> 50002 into GENERATED_APP_PORTS + # so the tile resolves a launch URL without relying on the hand-maintained + # override in appSessionConfig.ts (which the generator can clobber). The + # backend only validates this block — it does not proxy/health-check it. + interfaces: + main: + name: Web UI + description: ElectrumX server status and connection details + type: ui + port: 50002 + protocol: http + + health_check: + type: tcp + endpoint: localhost:50001 + interval: 30s + timeout: 5s + retries: 3 + start_period: 10m + + bitcoin_integration: + rpc_access: read-only + sync_required: true + pruning_support: false diff --git a/apps/fedimint-clientd/manifest.yml b/apps/fedimint-clientd/manifest.yml new file mode 100644 index 00000000..898b31be --- /dev/null +++ b/apps/fedimint-clientd/manifest.yml @@ -0,0 +1,102 @@ +app: + id: fedimint-clientd + name: Fedimint Client + version: 0.8.0 + description: Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API. + + container: + # fmcd built from source (github.com/minmoto/fmcd v0.8.0, fedimint-client + # 0.8.2 — iroh-capable). No usable upstream image exists, so we build + push + # this to the node registry. Pin the tag to match the REST shapes coded in + # core/archipelago/src/wallet/fedimint_client.rs (validated against 0.8.2). + image: 146.59.87.168:3000/lfg2025/fmcd:0.8.1 + pull_policy: if-not-present + network: archy-net + # No entrypoint override: the image's resilient `fmcd-run` launcher loops + # fmcd and retries on join failure (fmcd needs >=1 federation to boot), so an + # unreachable default never crash-loops. All config comes from FMCD_* env + # below. Nodes can join more federations via wallet.fedimint-join. + # Auto-generated on first install (random hex, 0600, rootless-owned) so the + # app needs no host provisioning. The wallet bridge reads the same file. + generated_secrets: + - name: fmcd-password + kind: hex16 + secret_env: + - key: FMCD_PASSWORD + secret_file: fmcd-password + data_uid: "1000:1000" + + # NOTE: this is a CLIENT, not the guardian — it does not require the local + # `fedimint` app. It joins external federations (default below), so it can be + # bundled standalone on every node. + dependencies: + - storage: 2Gi + + resources: + # fmcd's embedded iroh networking can hot-loop on relay/hole-punch retries + # on NAT'd nodes that reach the federation neither directly nor via iroh's + # public relays, pegging its whole allotment. Cap it low so a stuck instance + # can't starve the node (steady-state is <3% of a core; joins are brief); + # the fmcd-run watchdog additionally restarts a sustained-hot process. + cpu_limit: 1 + memory_limit: 1Gi + disk_limit: 2Gi + + security: + # fmcd's `fmcd-run` launcher chowns its /data (existing federation DB) on + # every start. With the default `cap_drop: ALL` and no caps added back, that + # chown fails and fmcd dies "Operation not permitted (os error 1)" — but ONLY + # once /data holds a joined federation (a fresh/empty dir needs no chown, so + # it appeared to work). Restore the standard container capability set so the + # startup chown succeeds (#7). Verified by bisection on .116: these caps make + # fmcd boot + serve /v2/*; DAC_OVERRIDE or SETUID/SETGID alone do NOT. + capabilities: ["CHOWN", "DAC_OVERRIDE", "FOWNER", "SETUID", "SETGID"] + readonly_root: true + # NOT isolated: fmcd needs outbound UDP + Mainline DHT (port 6881) + iroh + # relays to reach iroh-transport federations. `bridge` gives NAT'd outbound + # (UDP/DHT/iroh hole-punch all work) plus the published 8178→8080 port the + # wallet bridge targets. ("open" is not a valid policy — it made the loader + # skip this whole manifest, so fmcd never ran and federations never joined.) + # Lock down once the default federation's reachability model is finalized. + network_policy: bridge + + ports: + # fmcd REST bound to 8080 in-container; 8080 collides with LND REST on the + # host, so map to 8178. The Rust bridge targets http://127.0.0.1:8178. + - host: 8178 + container: 8080 + protocol: tcp + bind: 127.0.0.1 + auth: local + + volumes: + # Same dir the first-boot bundled path uses + where the wallet bridge reads + # the password (/var/lib/archipelago/fmcd/password) — keep install paths aligned. + - type: bind + source: /var/lib/archipelago/fmcd + target: /data + options: [rw] + + environment: + - FMCD_ADDR=0.0.0.0:8080 + - FMCD_MODE=rest + - FMCD_DATA_DIR=/data + # Default federation joined out-of-the-box (guardian on .116, iroh + # transport; validated to join with fmcd 0.8.2). iroh does NAT traversal so + # it's reachable fleet-wide. Keep in sync with DEFAULT_FEDERATION_INVITE in + # core/.../wallet/fedimint_client.rs. CAVEAT: iroh is experimental — validate + # join reliability from a real second node before relying on auto-bundle. + - FMCD_INVITE_CODE=fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp + + # fmcd serves only authenticated /v2/* routes — there is no unauthenticated + # /health endpoint, so an http probe to /health 404s forever and pins the + # container in "(starting)". fmcd's own image also ships neither curl nor wget. + # Use a TCP probe: the Quadlet renderer skips it (no HealthCmd emitted) and the + # host-side lifecycle layer verifies reachability, so the container reports + # "running" instead of a perpetual false-negative "(starting)". + health_check: + type: tcp + endpoint: localhost:8080 + interval: 30s + timeout: 5s + retries: 3 diff --git a/apps/fedimint-gateway/manifest.yml b/apps/fedimint-gateway/manifest.yml new file mode 100644 index 00000000..42ed8239 --- /dev/null +++ b/apps/fedimint-gateway/manifest.yml @@ -0,0 +1,98 @@ +app: + id: fedimint-gateway + name: Fedimint Gateway + version: 0.10.0 + description: Fedimint gateway service with automatic LND-or-LDK backend selection. + + container: + image: 146.59.87.168:3000/lfg2025/gatewayd:v0.10.0 + pull_policy: if-not-present + network: archy-net + entrypoint: ["sh", "-lc"] + # The bitcoind host comes from $FM_BITCOIND_URL, filled by the + # {{BITCOIN_HOST}} derived-env below — it resolves to whichever bitcoin + # container is actually running (Knots, Core, or any future distro archy + # ships), so the gateway is never pinned to one node's container name. + # (Was hardcoded http://host.archipelago:8332 — the host gateway IP where + # bitcoind does not listen — which crash-looped the gateway, 2026-07-22.) + custom_args: + - >- + if [ -f /lnd/tls.cert ] && [ -f /lnd/data/chain/bitcoin/mainnet/admin.macaroon ]; then + exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url "$FM_BITCOIND_URL" --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" lnd --lnd-rpc-host lnd:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/data/chain/bitcoin/mainnet/admin.macaroon; + else + exec gatewayd --data-dir /data --listen 0.0.0.0:8176 --bcrypt-password-hash "$FEDI_HASH" --network bitcoin --bitcoind-url "$FM_BITCOIND_URL" --bitcoind-username "$FM_BITCOIND_USERNAME" --bitcoind-password "$FM_BITCOIND_PASSWORD" ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway; + fi + derived_env: + - key: FM_BITCOIND_URL + template: "http://{{BITCOIN_HOST}}:8332" + # The gateway's admin API is gated by a bcrypt password hash. Generate it on + # first install (random password + its bcrypt hash, both 0600 rootless-owned) + # so the app installs from its manifest alone — `fedimint-gateway-hash` holds + # the hash passed to gatewayd, `fedimint-gateway-hash.pw` the plaintext for + # any client that must authenticate. Self-heals a wrongly root-owned hash. + generated_secrets: + - name: fedimint-gateway-hash + kind: bcrypt + secret_env: + - key: FM_BITCOIND_PASSWORD + secret_file: bitcoin-rpc-password + - key: FEDI_HASH + secret_file: fedimint-gateway-hash + data_uid: "1000:1000" + + dependencies: + - app_id: bitcoin-core + version: ">=26.0" + - app_id: fedimint + version: ">=0.10.0" + + resources: + cpu_limit: 2 + memory_limit: 2Gi + disk_limit: 10Gi + + security: + capabilities: [] + readonly_root: true + network_policy: isolated + + ports: + - host: 8176 + container: 8176 + protocol: tcp + auth: none + auth_rationale: >- + Fedimint gateway API, protected by its own bcrypt password (--bcrypt-password-hash) + and reached by federation peers and clients that cannot hold a browser session. + - host: 9737 + container: 9737 + protocol: tcp + auth: none + auth_rationale: >- + LDK Lightning p2p for the gateway. The BOLT-8 noise handshake authenticates and + encrypts the connection itself. + + volumes: + - type: bind + source: /var/lib/archipelago/fedimint-gateway + target: /data + options: [rw] + - type: bind + source: /var/lib/archipelago/lnd + target: /lnd + options: [ro] + + environment: + - FM_BITCOIND_USERNAME=archipelago + + health_check: + type: http + endpoint: http://localhost:8176 + path: / + interval: 30s + timeout: 5s + retries: 3 + + bitcoin_integration: + rpc_access: admin + sync_required: true diff --git a/apps/fedimint/Dockerfile b/apps/fedimint/Dockerfile new file mode 100644 index 00000000..1f6849bc --- /dev/null +++ b/apps/fedimint/Dockerfile @@ -0,0 +1,5 @@ +# Fedimint - uses official image +FROM fedimint/fedimint:0.3.0 + +# Default configuration is in the image +# No additional setup needed diff --git a/apps/fedimint/manifest.yml b/apps/fedimint/manifest.yml new file mode 100644 index 00000000..f69a7ddc --- /dev/null +++ b/apps/fedimint/manifest.yml @@ -0,0 +1,114 @@ +app: + id: fedimint + name: Fedimint Guardian + version: 0.10.0 + description: Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody. + + container: + image: 146.59.87.168:3000/lfg2025/fedimintd:v0.10.0 + pull_policy: if-not-present + network: archy-net + entrypoint: ["sh", "-lc"] + custom_args: + - |- + until state="$(curl -sS --connect-timeout 5 -m 45 -u "$FM_BITCOIND_USERNAME:$FM_BITCOIND_PASSWORD" -H "Content-Type: application/json" --data-binary '{"jsonrpc":"1.0","id":"fedimint-wait","method":"getblockchaininfo","params":[]}' "$FM_BITCOIND_URL/")" && echo "$state" | grep -q '"initialblockdownload":false'; do + echo "Waiting for Bitcoin RPC sync at $FM_BITCOIND_URL..."; + sleep 30; + done; + exec fedimintd + derived_env: + - key: FM_P2P_URL + template: fedimint://{{HOST_MDNS}}:8173 + - key: FM_API_URL + template: ws://{{HOST_MDNS}}:8174 + # Resolves to whichever bitcoin container is running (Knots/Core/future + # distro) instead of a hardcoded name — the guardian works on any node + # regardless of which Bitcoin software it runs. + - key: FM_BITCOIND_URL + template: "http://{{BITCOIN_HOST}}:8332" + secret_env: + - key: FM_BITCOIND_PASSWORD + secret_file: bitcoin-rpc-password + data_uid: "1000:1000" + + dependencies: + - app_id: bitcoin-core + version: ">=26.0" + - storage: 20Gi + + resources: + cpu_limit: 4 + memory_limit: 4Gi + disk_limit: 20Gi + + security: + capabilities: [] + readonly_root: true + network_policy: isolated + + ports: + - host: 8173 + container: 8173 + protocol: tcp + auth: none + auth_rationale: >- + Fedimint guardian consensus. Other guardians speak the federation's own + authenticated protocol here; a login page would break consensus. + - host: 8174 + container: 8174 + protocol: tcp + auth: none + auth_rationale: >- + Fedimint guardian API for federation clients, which authenticate to the + federation itself and cannot hold a browser session. + # Public launch port 8175 is owned by archy-fedimint-ui, which serves a + # wait page while Bitcoin syncs and proxies here after fedimintd starts. + # 8175 is NOT declared here. It is served by the archy-fedimint-ui + # companion, a different container, and declaring it on this app made the + # orchestrator try to publish 8175 from fedimintd — colliding with the + # companion that already holds it, so start_container failed forever and + # fedimint crash-looped (100.82.34.38, 2026-08-05). The companion's nginx + # is pinned to 127.0.0.1, which is what actually closes that port; the + # gate reports it rather than fronting it. + - host: 8177 + container: 8175 + protocol: tcp + bind: 127.0.0.1 + auth: local + + volumes: + - type: bind + source: /var/lib/archipelago/fedimint + target: /data + options: [rw] + + environment: + - FM_DATA_DIR=/data + # FM_BITCOIND_URL comes from derived_env ({{BITCOIN_HOST}}) above, not a + # hardcoded name — do not re-add it here. + - FM_BITCOIND_USERNAME=archipelago + - FM_BITCOIN_NETWORK=bitcoin + - FM_BIND_P2P=0.0.0.0:8173 + - FM_BIND_API=0.0.0.0:8174 + - FM_BIND_UI=0.0.0.0:8175 + + health_check: + type: http + endpoint: http://localhost:8175 + path: / + interval: 30s + timeout: 5s + retries: 3 + + interfaces: + main: + name: Guardian UI + description: Fedimint Guardian wait/proxy UI + type: ui + port: 8175 + protocol: http + path: / + + bitcoin_integration: + rpc_access: admin + sync_required: true diff --git a/apps/filebrowser/manifest.yml b/apps/filebrowser/manifest.yml new file mode 100644 index 00000000..a47b9431 --- /dev/null +++ b/apps/filebrowser/manifest.yml @@ -0,0 +1,55 @@ +app: + id: filebrowser + name: File Browser + version: 2.27.0 + description: Baseline Archipelago file manager service. + + container: + image: 146.59.87.168:3000/lfg2025/filebrowser:v2.27.0 + pull_policy: if-not-present + network: archy-net + custom_args: ["--config", "/data/.filebrowser.json"] + data_uid: "100000:100000" + + dependencies: + - storage: 10Gi + + resources: + memory_limit: 256Mi + disk_limit: 10Gi + + security: + capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE] + readonly_root: false + network_policy: isolated + + ports: + - host: 8083 + container: 80 + protocol: tcp + bind: 127.0.0.1 + auth: gated + + volumes: + - type: bind + source: /var/lib/archipelago/filebrowser + target: /srv + options: [rw] + - type: bind + source: /var/lib/archipelago/filebrowser-data + target: /data + options: [rw] + + environment: [] + + health_check: + type: http + endpoint: http://localhost:80 + path: /health + interval: 30s + timeout: 5s + retries: 3 + + bitcoin_integration: + rpc_access: none + sync_required: false diff --git a/apps/fips-ui/manifest.yml b/apps/fips-ui/manifest.yml new file mode 100644 index 00000000..c4c8474a --- /dev/null +++ b/apps/fips-ui/manifest.yml @@ -0,0 +1,57 @@ +app: + id: fips-ui + name: FIPS Mesh + version: 1.0.0 + description: | + Archipelago-native dashboard for the FIPS mesh transport. Runs nginx + inside a container with host networking, serves a static dashboard on + :8336, and reverse-proxies /rpc/v1 to the archipelago backend on + 127.0.0.1:5678. All FIPS controls (status, seed anchors, reconnect, + restart, and stable-channel daemon updates) go through the existing + fips.* RPC methods, authenticated by the browser's own archipelago + session — there is no separate secret to manage. + + container: + build: + context: /opt/archipelago/docker/fips-ui + dockerfile: Dockerfile + tag: localhost/fips-ui:local + + resources: + memory_limit: 128Mi + + security: + readonly_root: false + network_policy: host + + # Host networking: nginx listens on 8336 directly on the host IP and + # proxies to 127.0.0.1:5678 (the archipelago RPC). `ports:` is + # intentionally empty because host networking bypasses port mapping. + # Declared so the APP GATE can see this port. Host networking means Podman + # publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here + # is a statement of where the container's own nginx listens — 127.0.0.1 — + # not a publish instruction. Without this declaration the gate had no idea + # the port existed: it was neither protected nor listed as unprotected, and + # served the FIPS mesh screen unauthenticated on every interface. + ports: + - host: 8336 + container: 8336 + protocol: tcp + bind: 127.0.0.1 + auth: gated + # First-party companion UI: its nginx forwards the node session cookie + # to the daemon's authenticated endpoints; without passthrough the gate + # strips it and every data call 401s while the page shell renders. + session_passthrough: true + + volumes: [] + + environment: [] + + health_check: + type: http + endpoint: http://127.0.0.1:8336 + path: / + interval: 30s + timeout: 5s + retries: 3 diff --git a/apps/gitea/manifest.yml b/apps/gitea/manifest.yml new file mode 100644 index 00000000..0a4a2293 --- /dev/null +++ b/apps/gitea/manifest.yml @@ -0,0 +1,92 @@ +app: + id: gitea + name: Gitea + version: "1.23" + description: Self-hosted Git service with built-in container registry, CI/CD, and package hosting. + category: development + + container: + image: docker.io/gitea/gitea:1.23 + pull_policy: if-not-present + + dependencies: + - storage: 500Mi + + resources: + memory_limit: 256Mi + disk_limit: 500Mi + + security: + capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE] + readonly_root: false + no_new_privileges: false + network_policy: bridge + + ports: + - host: 3001 + container: 3000 + protocol: tcp + bind: 127.0.0.1 + auth: gated + - host: 2222 + container: 22 + protocol: tcp + auth: none + auth_rationale: >- + Git over SSH, authenticated by the user's own SSH keypair. Not HTTP, so the gate cannot serve a login page here. + + volumes: + - type: bind + source: /var/lib/archipelago/gitea/data + target: /data + options: [rw] + - type: bind + source: /var/lib/archipelago/gitea/config + target: /etc/gitea + options: [rw] + + environment: + - GITEA__database__DB_TYPE=sqlite3 + - GITEA__server__SSH_PORT=2222 + - GITEA__server__SSH_LISTEN_PORT=22 + - GITEA__server__LFS_START_SERVER=true + - GITEA__packages__ENABLED=true + - GITEA__repository__ENABLE_PUSH_CREATE_USER=true + - GITEA__repository__ENABLE_PUSH_CREATE_ORG=true + + health_check: + type: http + endpoint: http://localhost:3000 + path: / + interval: 120s + timeout: 30s + retries: 5 + + interfaces: + main: + name: Web UI + description: Gitea web interface + type: ui + port: 3001 + protocol: http + path: / + + metadata: + icon: /assets/img/app-icons/gitea.svg + repo: https://gitea.com + tier: optional + launch: + open_in_new_tab: true + features: + - Git repositories with web UI + - Built-in container/package registry + - Issue tracking and pull requests + - CI/CD via Gitea Actions + - Lightweight SQLite deployment + + nginx_proxy: + listen: 3000 + proxy_pass: http://127.0.0.1:3001 + extra_headers: + - proxy_hide_header X-Frame-Options + - proxy_hide_header Content-Security-Policy diff --git a/apps/grafana/Dockerfile b/apps/grafana/Dockerfile new file mode 100644 index 00000000..0c73772a --- /dev/null +++ b/apps/grafana/Dockerfile @@ -0,0 +1,5 @@ +# Grafana - uses official image +FROM grafana/grafana:10.2.0 + +# Default configuration is in the image +# No additional setup needed diff --git a/apps/grafana/manifest.yml b/apps/grafana/manifest.yml new file mode 100644 index 00000000..3ef6938a --- /dev/null +++ b/apps/grafana/manifest.yml @@ -0,0 +1,59 @@ +app: + id: grafana + name: Grafana + version: 10.2.0 + description: Analytics and monitoring platform. Visualize metrics and create dashboards. + + container: + image: grafana/grafana:10.2.0 + image_signature: cosign://... + pull_policy: if-not-present + data_uid: "472:472" + + dependencies: + - storage: 5Gi + + resources: + cpu_limit: 2 + memory_limit: 1Gi + disk_limit: 5Gi + + security: + capabilities: [] + readonly_root: true + no_new_privileges: true + user: 1000 + seccomp_profile: default + network_policy: isolated + apparmor_profile: grafana + + ports: + - host: 3000 + container: 3000 + protocol: tcp # Web UI + bind: 127.0.0.1 + auth: gated + + volumes: + - type: bind + source: /var/lib/archipelago/grafana + target: /var/lib/grafana + options: [rw] + + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD} + - GF_SERVER_ROOT_URL=http://localhost:3000 + - GF_INSTALL_PLUGINS= + + health_check: + type: http + endpoint: http://localhost:3000 + path: /api/health + interval: 30s + timeout: 30s + retries: 5 + + metadata: + launch: + open_in_new_tab: true diff --git a/apps/home-assistant/Dockerfile b/apps/home-assistant/Dockerfile new file mode 100644 index 00000000..877cbfac --- /dev/null +++ b/apps/home-assistant/Dockerfile @@ -0,0 +1,5 @@ +# Home Assistant - uses official image +FROM homeassistant/home-assistant:2026.7.3 + +# Default configuration is in the image +# No additional setup needed diff --git a/apps/home-assistant/manifest.yml b/apps/home-assistant/manifest.yml new file mode 100644 index 00000000..7a4c3a2f --- /dev/null +++ b/apps/home-assistant/manifest.yml @@ -0,0 +1,69 @@ +app: + id: homeassistant + name: Home Assistant + version: 2026.7.3 + description: Open source home automation platform. Control and monitor your smart home devices. + + container: + image: 146.59.87.168:3000/lfg2025/home-assistant:2026.7.3 + pull_policy: if-not-present + network: pasta + + dependencies: + - storage: 10Gi + + resources: + cpu_limit: 2 + memory_limit: 512Mi + disk_limit: 10Gi + + security: + capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE, NET_RAW] + readonly_root: false # Home Assistant needs write access + no_new_privileges: true + user: 1000 + seccomp_profile: default + network_policy: isolated + apparmor_profile: home-assistant + + ports: + - host: 8123 + container: 8123 + protocol: tcp # Web UI + bind: 127.0.0.1 + auth: gated + + volumes: + - type: bind + source: /var/lib/archipelago/home-assistant + target: /config + options: [rw] + + devices: [] + + environment: + - TZ=UTC + + health_check: + type: tcp + endpoint: localhost:8123 + interval: 30s + timeout: 5s + retries: 3 + + interfaces: + main: + name: Web UI + description: Home Assistant dashboard + type: ui + port: 8123 + protocol: http + path: / + + metadata: + icon: /assets/img/app-icons/homeassistant.png + category: home + author: Home Assistant + repo: https://github.com/home-assistant/core + launch: + open_in_new_tab: true diff --git a/apps/immich-postgres/manifest.yml b/apps/immich-postgres/manifest.yml new file mode 100644 index 00000000..88f42506 --- /dev/null +++ b/apps/immich-postgres/manifest.yml @@ -0,0 +1,58 @@ +app: + id: immich-postgres + name: Immich Postgres + version: "14-vectorchord0.4.3-pgvectors0.2.0" + description: Postgres (pgvecto.rs / vectorchord) backend for Immich. + + # Container named immich_postgres (underscore) to match the runtime's existing + # per-app references (lifecycle/health/crash-recovery/config) and serve as the + # server's DB_HOSTNAME alias. Top-level key → serde(flatten) → extensions → + # compute_container_name. + container_name: immich_postgres + + container: + image: 146.59.87.168:3000/lfg2025/immich-postgres:14-vectorchord0.4.3-pgvectors0.2.0 + pull_policy: if-not-present + network: archy-net + # postgres drops to its own uid (container 999 → host 100998 under rootless), + # so the data dir must be owned by that mapped uid — mirrors archy-btcpay-db. + # Verified on .228: the live immich-db is owned 100998. Without this a FRESH + # install's dir would be service-user-owned and postgres would EACCES. + data_uid: "100998:100998" + generated_secrets: + - name: immich-db-password + kind: hex32 + secret_env: + - key: POSTGRES_PASSWORD + secret_file: immich-db-password + + dependencies: + - storage: 40Gi + + resources: + memory_limit: 2Gi + disk_limit: 40Gi + + security: + capabilities: [CHOWN, DAC_OVERRIDE, FOWNER, SETGID, SETUID] + readonly_root: false + network_policy: isolated + + ports: [] + + volumes: + - type: bind + source: /var/lib/archipelago/immich-db + target: /var/lib/postgresql/data + options: [rw] + + environment: + - POSTGRES_USER=postgres + - POSTGRES_DB=immich + + health_check: + type: tcp + endpoint: localhost:5432 + interval: 30s + timeout: 5s + retries: 3 diff --git a/apps/immich-redis/manifest.yml b/apps/immich-redis/manifest.yml new file mode 100644 index 00000000..2450a615 --- /dev/null +++ b/apps/immich-redis/manifest.yml @@ -0,0 +1,37 @@ +app: + id: immich-redis + name: Immich Redis + version: "7-alpine" + description: Valkey (Redis-compatible) cache for Immich. + + # Container named immich_redis (underscore) to match runtime per-app references + # and serve as the server's REDIS_HOSTNAME alias on archy-net. + container_name: immich_redis + + container: + image: 146.59.87.168:3000/lfg2025/valkey:7-alpine + pull_policy: if-not-present + network: archy-net + + dependencies: [] + + resources: + memory_limit: 128Mi + + security: + capabilities: [SETGID, SETUID] + readonly_root: false + network_policy: isolated + + ports: [] + + volumes: [] + + environment: [] + + health_check: + type: tcp + endpoint: localhost:6379 + interval: 30s + timeout: 5s + retries: 3 diff --git a/apps/immich/manifest.yml b/apps/immich/manifest.yml new file mode 100644 index 00000000..a09b23dd --- /dev/null +++ b/apps/immich/manifest.yml @@ -0,0 +1,82 @@ +app: + id: immich + name: Immich + version: "2.7.4" + description: Self-hosted photo and video backup with mobile apps and search. + + # app_id "immich" = the user-facing launcher (matches the catalog entry's title + # + icon). The container is named "immich_server" so it matches the runtime's + # existing per-app container references (lifecycle/health/crash-recovery/ports); + # `container_name` is a top-level app key (captured by serde(flatten) into + # extensions, read by compute_container_name). It reaches its backends by their + # underscore aliases on archy-net (DB_HOSTNAME / REDIS_HOSTNAME below). + container_name: immich_server + + container: + image: 146.59.87.168:3000/lfg2025/immich-server:release + pull_policy: if-not-present + network: archy-net + secret_env: + - key: DB_PASSWORD + secret_file: immich-db-password + + dependencies: + - app_id: immich-postgres + - app_id: immich-redis + - storage: 200Gi + + resources: + memory_limit: 2Gi + disk_limit: 200Gi + + security: + # Runs as container root over a data tree the legacy installer chowned + # to the subuid range (host 100000 = container uid 1). Without + # DAC_OVERRIDE the server EACCESes writing upload/encoded-video the + # moment the container is recreated against this manifest (latent until + # the 2026-07-05 secret-env migration recreated it). Same cap set as + # immich-postgres minus the setuid pair it doesn't use. + capabilities: [CHOWN, DAC_OVERRIDE, FOWNER] + readonly_root: false + network_policy: isolated + + ports: + - host: 2283 + container: 2283 + protocol: tcp + bind: 127.0.0.1 + auth: gated + + volumes: + - type: bind + source: /var/lib/archipelago/immich + target: /usr/src/app/upload + options: [rw] + + environment: + - DB_HOSTNAME=immich_postgres + - DB_USERNAME=postgres + - DB_DATABASE_NAME=immich + - REDIS_HOSTNAME=immich_redis + - UPLOAD_LOCATION=/usr/src/app/upload + + health_check: + type: http + endpoint: http://localhost:2283 + path: /api/server/ping + interval: 30s + timeout: 5s + retries: 20 + + interfaces: + main: + name: Web UI + description: Immich photo library + type: ui + port: 2283 + protocol: http + path: / + + metadata: + launch: + open_in_new_tab: true diff --git a/apps/indeedhub-api/manifest.yml b/apps/indeedhub-api/manifest.yml new file mode 100644 index 00000000..fb557280 --- /dev/null +++ b/apps/indeedhub-api/manifest.yml @@ -0,0 +1,77 @@ +app: + id: indeedhub-api + name: IndeedHub API + version: "1.0.0" + description: IndeedHub backend API (Nostr auth, media, payments). + category: community + + # Hyphen name matches runtime references + the live container (adoption); + # alias `api` is the short hostname the frontend nginx proxies to + # (http://api:4000). Reaches its backends by their short aliases + # (postgres/redis/minio) on indeedhub-net — unchanged from the legacy installer. + container_name: indeedhub-api + + container: + image: 146.59.87.168:3000/lfg2025/indeedhub-api:1.0.0 + pull_policy: if-not-present + network: indeedhub-net + network_aliases: [api] + # The JWT signing secret is owned here (no backend container owns it); the + # db + minio passwords are owned by indeedhub-postgres / indeedhub-minio and + # only consumed here. ensure_generated_secrets no-ops when a file already + # exists, so live values on .228 are preserved (postgres pw is fixed at + # PGDATA init — regenerating would lock the API out). + generated_secrets: + - name: indeedhub-jwt + kind: hex32 + secret_env: + - key: DATABASE_PASSWORD + secret_file: indeedhub-db-password + - key: AWS_SECRET_KEY + secret_file: indeedhub-minio-password + - key: NOSTR_JWT_SECRET + secret_file: indeedhub-jwt + + dependencies: + - app_id: indeedhub-postgres + - app_id: indeedhub-redis + - app_id: indeedhub-minio + + resources: + memory_limit: 2Gi + + security: + capabilities: [] + readonly_root: false + network_policy: isolated + + ports: [] + + volumes: [] + + environment: + - PORT=4000 + - DATABASE_HOST=postgres + - DATABASE_PORT=5432 + - DATABASE_USER=indeedhub + - DATABASE_NAME=indeedhub + - QUEUE_HOST=redis + - QUEUE_PORT=6379 + - S3_ENDPOINT=http://minio:9000 + - AWS_REGION=us-east-1 + - AWS_ACCESS_KEY=indeeadmin + - S3_PUBLIC_BUCKET_NAME=indeedhub-public + - S3_PRIVATE_BUCKET_NAME=indeedhub-private + - S3_PUBLIC_BUCKET_URL=/storage + - NOSTR_JWT_EXPIRES_IN=7d + # Fixed across the fleet (envelope-encryption master key baked by the legacy + # installer); not node-specific, so a plain env literal, not a secret. + - AES_MASTER_SECRET=0123456789abcdef0123456789abcdef + - ENVIRONMENT=production + + health_check: + type: tcp + endpoint: localhost:4000 + interval: 30s + timeout: 5s + retries: 10 diff --git a/apps/indeedhub-ffmpeg/manifest.yml b/apps/indeedhub-ffmpeg/manifest.yml new file mode 100644 index 00000000..2f93f148 --- /dev/null +++ b/apps/indeedhub-ffmpeg/manifest.yml @@ -0,0 +1,51 @@ +app: + id: indeedhub-ffmpeg + name: IndeedHub FFmpeg Worker + version: "1.0.0" + description: IndeedHub background media transcoding worker. + category: community + + # Hyphen name matches runtime references + the live container (adoption). No + # network_alias: nothing connects TO the worker — it only dials out to + # postgres/redis/minio (resolved by their aliases on indeedhub-net). + container_name: indeedhub-ffmpeg + + container: + image: 146.59.87.168:3000/lfg2025/indeedhub-ffmpeg:1.0.0 + pull_policy: if-not-present + network: indeedhub-net + secret_env: + - key: DATABASE_PASSWORD + secret_file: indeedhub-db-password + - key: AWS_SECRET_KEY + secret_file: indeedhub-minio-password + + dependencies: + - app_id: indeedhub-api + + resources: + memory_limit: 4Gi + + security: + capabilities: [] + readonly_root: false + network_policy: isolated + + ports: [] + + volumes: [] + + environment: + - DATABASE_HOST=postgres + - DATABASE_PORT=5432 + - DATABASE_USER=indeedhub + - DATABASE_NAME=indeedhub + - QUEUE_HOST=redis + - QUEUE_PORT=6379 + - S3_ENDPOINT=http://minio:9000 + - AWS_REGION=us-east-1 + - AWS_ACCESS_KEY=indeeadmin + - S3_PUBLIC_BUCKET_NAME=indeedhub-public + - S3_PRIVATE_BUCKET_NAME=indeedhub-private + - ENVIRONMENT=production + - AES_MASTER_SECRET=0123456789abcdef0123456789abcdef diff --git a/apps/indeedhub-minio/manifest.yml b/apps/indeedhub-minio/manifest.yml new file mode 100644 index 00000000..79e0d267 --- /dev/null +++ b/apps/indeedhub-minio/manifest.yml @@ -0,0 +1,60 @@ +app: + id: indeedhub-minio + name: IndeedHub MinIO + version: "RELEASE.2024-11-07T00-52-20Z" + description: MinIO S3-compatible object storage for IndeedHub media. + category: community + + # Hyphen name matches runtime references + the live container (adoption); + # alias `minio` is the short hostname the api/ffmpeg use (S3_ENDPOINT= + # http://minio:9000) AND the frontend nginx proxies to (http://minio:9000). + container_name: indeedhub-minio + + container: + image: 146.59.87.168:3000/lfg2025/minio:RELEASE.2024-11-07T00-52-20Z + pull_policy: if-not-present + network: indeedhub-net + network_aliases: [minio] + # `server /data` — the minio entrypoint args from the legacy installer. + custom_args: [server, /data] + generated_secrets: + - name: indeedhub-minio-password + kind: hex32 + secret_env: + - key: MINIO_ROOT_PASSWORD + secret_file: indeedhub-minio-password + + dependencies: + - storage: 50Gi + + resources: + memory_limit: 1Gi + disk_limit: 50Gi + + security: + capabilities: [] + readonly_root: false + network_policy: isolated + + ports: [] + + # Named volume matches the live indeedhub-minio-data volume on .228. + volumes: + - type: volume + source: indeedhub-minio-data + target: /data + options: [rw] + + # MINIO_ROOT_USER "indeeadmin" is the fixed admin identity baked by the legacy + # installer (api/ffmpeg use it as AWS_ACCESS_KEY); the password is the + # generated secret above. Not secret, so it stays a plain env value. + environment: + - MINIO_ROOT_USER=indeeadmin + + health_check: + type: http + endpoint: http://localhost:9000 + path: /minio/health/live + interval: 30s + timeout: 5s + retries: 5 diff --git a/apps/indeedhub-postgres/manifest.yml b/apps/indeedhub-postgres/manifest.yml new file mode 100644 index 00000000..8d09211f --- /dev/null +++ b/apps/indeedhub-postgres/manifest.yml @@ -0,0 +1,59 @@ +app: + id: indeedhub-postgres + name: IndeedHub Postgres + version: "16.13-alpine" + description: Postgres database backend for IndeedHub. + category: community + + # Container named indeedhub-postgres (hyphen) to match the runtime's existing + # per-app references (health_monitor tiers/deps, crash_recovery) and the live + # .228 install, so the orchestrator ADOPTS the running container instead of + # recreating it. `network_aliases: [postgres]` keeps the short hostname the + # api/ffmpeg/relay reach by (DATABASE_HOST=postgres) resolvable on + # indeedhub-net, reproducing the legacy `--network-alias postgres`. + container_name: indeedhub-postgres + + container: + image: 146.59.87.168:3000/lfg2025/postgres:16.13-alpine + pull_policy: if-not-present + network: indeedhub-net + network_aliases: [postgres] + generated_secrets: + - name: indeedhub-db-password + kind: hex32 + secret_env: + - key: POSTGRES_PASSWORD + secret_file: indeedhub-db-password + + dependencies: + - storage: 10Gi + + resources: + memory_limit: 1Gi + disk_limit: 10Gi + + security: + capabilities: [CHOWN, DAC_OVERRIDE, FOWNER, SETGID, SETUID] + readonly_root: false + network_policy: isolated + + ports: [] + + # Named podman volume (matches the live indeedhub-postgres-data volume on .228); + # preserves all existing database content across the migration. + volumes: + - type: volume + source: indeedhub-postgres-data + target: /var/lib/postgresql/data + options: [rw] + + environment: + - POSTGRES_USER=indeedhub + - POSTGRES_DB=indeedhub + + health_check: + type: tcp + endpoint: localhost:5432 + interval: 30s + timeout: 5s + retries: 3 diff --git a/apps/indeedhub-redis/manifest.yml b/apps/indeedhub-redis/manifest.yml new file mode 100644 index 00000000..a2b84c29 --- /dev/null +++ b/apps/indeedhub-redis/manifest.yml @@ -0,0 +1,51 @@ +app: + id: indeedhub-redis + name: IndeedHub Redis + version: "7.4.8-alpine" + description: Redis queue/cache backend for IndeedHub. + category: community + + # Hyphen name matches runtime references + the live container (adoption); + # alias `redis` is the short hostname the api/ffmpeg reach (QUEUE_HOST=redis). + container_name: indeedhub-redis + + container: + image: 146.59.87.168:3000/lfg2025/redis:7.4.8-alpine + pull_policy: if-not-present + network: indeedhub-net + network_aliases: [redis] + + dependencies: + - storage: 1Gi + + resources: + memory_limit: 256Mi + + security: + # The alpine entrypoint runs as container-root, `find`s /data to chown + # anything not owned by the redis user, then su-execs to it. Under the + # orchestrator's --cap-drop=ALL, root cannot traverse the 0700 + # appendonlydir owned by uid 999 without DAC_OVERRIDE (observed + # crash-looping ~4k restarts on archi-dev-box) — CHOWN is what the find's + # -exec chown needs on adopted legacy data. + capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID] + readonly_root: false + network_policy: isolated + + ports: [] + + # Named volume matches the live indeedhub-redis-data volume on .228. + volumes: + - type: volume + source: indeedhub-redis-data + target: /data + options: [rw] + + environment: [] + + health_check: + type: tcp + endpoint: localhost:6379 + interval: 30s + timeout: 5s + retries: 3 diff --git a/apps/indeedhub-relay/manifest.yml b/apps/indeedhub-relay/manifest.yml new file mode 100644 index 00000000..842fe0c4 --- /dev/null +++ b/apps/indeedhub-relay/manifest.yml @@ -0,0 +1,47 @@ +app: + id: indeedhub-relay + name: IndeedHub Nostr Relay + version: "0.9.0" + description: nostr-rs-relay backing IndeedHub's Nostr identity + comments. + category: community + + # Hyphen name matches runtime references + the live container (adoption); + # alias `relay` is the short hostname the frontend nginx proxies to + # (http://relay:8080 for the /relay websocket). + container_name: indeedhub-relay + + container: + image: 146.59.87.168:3000/lfg2025/nostr-rs-relay:0.9.0 + pull_policy: if-not-present + network: indeedhub-net + network_aliases: [relay] + + dependencies: + - storage: 2Gi + + resources: + memory_limit: 256Mi + disk_limit: 2Gi + + security: + capabilities: [] + readonly_root: false + network_policy: isolated + + ports: [] + + # Named volume matches the live indeedhub-relay-data volume on .228. + volumes: + - type: volume + source: indeedhub-relay-data + target: /usr/src/app/db + options: [rw] + + environment: [] + + health_check: + type: tcp + endpoint: localhost:8080 + interval: 30s + timeout: 5s + retries: 3 diff --git a/apps/indeedhub/Dockerfile b/apps/indeedhub/Dockerfile new file mode 100644 index 00000000..5bb67427 --- /dev/null +++ b/apps/indeedhub/Dockerfile @@ -0,0 +1,78 @@ +# Multi-stage Dockerfile for Indeehub Frontend (Next.js) +# Build: podman build -t localhost/indeedhub:latest -f apps/indeedhub/Dockerfile /path/to/indeehub-frontend +# Run: podman run -d --name indeedhub -p 8190:3000 localhost/indeedhub:latest + +# ── Stage 1: Dependencies ── +FROM node:20-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci --ignore-scripts + +# ── Stage 2: Build ── +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Inject standalone output mode for containerized deployment +RUN sed -i 's/reactStrictMode: true,/reactStrictMode: true, output: "standalone",/' next.config.js + +# Build-time environment — connects to Indeehub production services +ENV NEXT_PUBLIC_APP_ENVIRONMENT=production +ENV NEXT_PUBLIC_APP_URL=http://localhost:8190 +ENV NEXT_PUBLIC_API_URL=https://staging-api.indeehub.studio +ENV NEXT_PUBLIC_S3_PRIVATE_BUCKET=indeehub-private +ENV NEXT_PUBLIC_S3_PUBLIC_BUCKET=indeehub-public +ENV NEXT_PUBLIC_ENABLE_APPROVAL_FLOW=false +ENV NEXT_TELEMETRY_DISABLED=1 + +# Remove shaka-player .d.ts files that break the build (per package.json build script) +RUN rm -f ./node_modules/shaka-player/dist/*.d.ts + +# Patch: replace home page with error-resilient version that doesn't crash +# when the Webflow landing page URL is unreachable from the container. +RUN printf '%s\n' \ + "import axios from 'axios';" \ + "import { HomeClient } from './page.client';" \ + "" \ + "export const dynamic = 'force-dynamic';" \ + "" \ + "export default async function Home() {" \ + " try {" \ + " const response = await axios('https://indeehub-30479a.webflow.io/', { timeout: 8000 });" \ + " if (response.status !== 200) throw new Error('Bad status');" \ + " const html = String(response.data)" \ + " .replace('https://cdn.prod.website-files.com/img/favicon.ico', '/favicon.ico')" \ + " .replace('https://cdn.prod.website-files.com/img/webclip.png', '/favicon.ico');" \ + " return ;" \ + " } catch {" \ + " return IndeeHub

IndeeHub

Loading content...

\" />;" \ + " }" \ + "}" > src/app/page.tsx + +RUN npm run build + +# ── Stage 3: Runner ── +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +# Copy standalone build output +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=40s \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1 + +CMD ["node", "server.js"] diff --git a/apps/indeedhub/README.md b/apps/indeedhub/README.md new file mode 100644 index 00000000..62c8a2de --- /dev/null +++ b/apps/indeedhub/README.md @@ -0,0 +1,53 @@ +# Indeehub — Bitcoin Documentary Streaming + +Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. + +Self-hosted Next.js app with Nostr identity sign-in via Archipelago's NIP-07 provider. + +## Building the Image + +The app image is built from the **indeehub-frontend** project at `~/Projects/indeehub-frontend`. + +### Option 1: Use the build script + +```bash +# From archy repo root +./apps/indeedhub/build-from-prototype.sh +``` + +### Option 2: Build from source directory + +```bash +cd ~/Projects/indeehub-frontend +podman build -t localhost/indeedhub:latest -f ~/Projects/archy/apps/indeedhub/Dockerfile . +``` + +## Installing from App Store + +1. **Build the image** using one of the options above (must exist before install) +2. Go to **Dashboard -> App Store** (Marketplace) +3. Find **Indeehub** and click **Install** +4. On first launch, pick a Nostr identity to sign in with +5. The app appears in **My Apps** once the container is running + +## Port + +- Web UI: 8190 (maps to container port 3000) + +## Container + +- Image: `localhost/indeedhub:latest` (built locally, not pulled from a registry) +- Runtime: Node.js 20 (Next.js standalone) +- Port: 8190 -> 3000 +- Read-only root filesystem with tmpfs for /tmp and .next/cache + +## Nostr Identity + +On first launch, Archipelago shows a cypherpunk identity picker modal. Select which of your identities to use for NIP-07 signing. The NIP-07 provider is injected automatically via nginx proxy. + +## Services + +The app connects to the following external services (configured at build time): +- Indeehub API (content, auth, streaming) +- AWS S3 (media storage via CloudFront CDN) +- Nostr relays (via NIP-07 provider from Archipelago) diff --git a/apps/indeedhub/build-from-prototype.sh b/apps/indeedhub/build-from-prototype.sh new file mode 100755 index 00000000..50f5bdb7 --- /dev/null +++ b/apps/indeedhub/build-from-prototype.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Build Indeehub container image from the indeehub-frontend project +# Usage: ./build-from-prototype.sh [path-to-indeehub-frontend] + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEFAULT_FRONTEND="$HOME/Projects/indeehub-frontend" +FRONTEND_DIR="${1:-$DEFAULT_FRONTEND}" +IMAGE_TAG="localhost/indeedhub:latest" + +if [ ! -d "$FRONTEND_DIR" ]; then + echo "Indeehub frontend not found at: $FRONTEND_DIR" + echo " Set path: $0 /path/to/indeehub-frontend" + exit 1 +fi + +if [ ! -f "$FRONTEND_DIR/package.json" ]; then + echo "No package.json found in $FRONTEND_DIR — is this the right directory?" + exit 1 +fi + +# Determine container runtime +RUNTIME="podman" +if ! command -v podman >/dev/null 2>&1; then + RUNTIME="docker" +fi + +echo "Building Indeehub from $FRONTEND_DIR using $SCRIPT_DIR/Dockerfile" +$RUNTIME build -t "$IMAGE_TAG" -f "$SCRIPT_DIR/Dockerfile" "$FRONTEND_DIR" + +echo "Built $IMAGE_TAG" +echo "" +echo "You can now install Indeehub from the App Store in Archipelago." +echo "Or run directly: $RUNTIME run -d --name indeedhub -p 8190:3000 $IMAGE_TAG" diff --git a/apps/indeedhub/manifest.yml b/apps/indeedhub/manifest.yml new file mode 100644 index 00000000..a71f6777 --- /dev/null +++ b/apps/indeedhub/manifest.yml @@ -0,0 +1,106 @@ +app: + id: indeedhub + name: IndeeHub + version: "1.0.0" + description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity. + category: community + + # The user-facing launcher (app_id "indeedhub"). Container is named "indeedhub" + # (matches the runtime's per-app references + the live container, so the + # orchestrator adopts it). Its nginx (listen 7777) proxies to the backends by + # their short aliases on indeedhub-net: api:4000, minio:9000, relay:8080. + container_name: indeedhub + + container: + image: 146.59.87.168:3000/lfg2025/indeedhub:1.0.0 + pull_policy: if-not-present + network: indeedhub-net + + dependencies: + - app_id: indeedhub-api + - storage: 1Gi + + resources: + memory_limit: 512Mi + disk_limit: 1Gi + + security: + # nginx master runs as root and drops workers to the nginx user (uid/gid + # 101) — needs SET{UID,GID}; CHOWN + DAC_OVERRIDE let it own + write the + # proxy cache under the tmpfs /var/cache/nginx. The orchestrator does + # --cap-drop=ALL, so (unlike the legacy `podman run` default caps) these + # must be declared or nginx workers die with "setgid(101) failed". + capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID] + readonly_root: false + network_policy: isolated + + ports: + - host: 7778 + container: 7777 + protocol: tcp # Web UI. Port 7777 on the host is reserved for the Nostr relay. + bind: 127.0.0.1 + auth: gated + + # Writable scratch the baked nginx needs; matches the legacy installer's + # --tmpfs /run + /var/cache/nginx. + volumes: + - type: tmpfs + target: /run + options: [rw, nosuid, nodev, size=16m] + - type: tmpfs + target: /var/cache/nginx + options: [rw, nosuid, nodev, size=32m] + + environment: [] + + # Defensive + idempotent. The current indeedhub:1.0.0 image already bakes the + # iframe-friendly nginx (X-Frame-Options omitted, nostr-provider.js present + + # #' /etc/nginx/conf.d/default.conf"] + - exec: ["nginx", "-s", "reload"] + + # TCP liveness on the nginx port, NOT an http GET of /. nginx binds 7777 at + # startup (before workers), so this passes immediately and stays green under + # load. An http check of / runs the SPA + sub_filter and false-fails when the + # node is busy → the reconciler then treats the frontend as wedged and + # recreates it in a loop (observed churning the frontend on the loaded .198). + health_check: + type: tcp + endpoint: localhost:7777 + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + + interfaces: + main: + name: Web UI + description: Stream Bitcoin documentaries with Nostr identity + type: ui + port: 7778 + protocol: http + path: / + + metadata: + author: Indeehub Team + icon: /assets/img/app-icons/indeedhub.png + website: https://indeedhub.com + repo: https://github.com/indeedhub/indeedhub + license: MIT + tags: + - bitcoin + - documentary + - streaming + - media + - education + - nostr diff --git a/apps/indeedhub/push-to-registry.sh b/apps/indeedhub/push-to-registry.sh new file mode 100755 index 00000000..5818bc0f --- /dev/null +++ b/apps/indeedhub/push-to-registry.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# Build and push Indeehub container image to a registry +# Usage: ./push-to-registry.sh [version] +# +# Environment variables: +# REGISTRY - Registry host (default: ghcr.io) +# NAMESPACE - Registry namespace (default: archipelago-os) +# RUNTIME - Container runtime (default: podman) + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FRONTEND_DIR="${INDEEHUB_FRONTEND:-$HOME/Projects/indeehub-frontend}" +VERSION="${1:-latest}" +REGISTRY="${REGISTRY:-146.59.87.168:3000}" +NAMESPACE="${NAMESPACE:-lfg2025}" +IMAGE_NAME="indeedhub" +RUNTIME="${RUNTIME:-podman}" + +FULL_TAG="${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${VERSION}" +LATEST_TAG="${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:latest" + +if [ ! -d "$FRONTEND_DIR" ]; then + echo "Indeehub frontend not found at: $FRONTEND_DIR" + echo "Set INDEEHUB_FRONTEND=/path/to/indeehub-frontend" + exit 1 +fi + +echo "=== Indeehub Container Registry Push ===" +echo "Source: $FRONTEND_DIR" +echo "Image: $FULL_TAG" +echo "Runtime: $RUNTIME" +echo "" + +# Step 1: Build for linux/amd64 (target architecture) +echo "[1/3] Building image..." +$RUNTIME build --platform linux/amd64 \ + -t "$FULL_TAG" \ + -t "$LATEST_TAG" \ + -t "localhost/${IMAGE_NAME}:latest" \ + -t "localhost/${IMAGE_NAME}:${VERSION}" \ + -f "$SCRIPT_DIR/Dockerfile" \ + "$FRONTEND_DIR" + +echo "[2/3] Pushing to registry..." +# Login check +if ! $RUNTIME login --get-login "$REGISTRY" >/dev/null 2>&1; then + echo "" + echo "Not logged in to $REGISTRY." + echo "Run: $RUNTIME login $REGISTRY" + exit 1 +fi + +$RUNTIME push "$FULL_TAG" +if [ "$VERSION" != "latest" ]; then + $RUNTIME push "$LATEST_TAG" +fi + +echo "" +echo "[3/3] Done!" +echo "" +echo "Image pushed: $FULL_TAG" +if [ "$VERSION" != "latest" ]; then + echo "Also tagged: $LATEST_TAG" +fi +echo "" +echo "Federated nodes can now install via:" +echo " podman pull $FULL_TAG" +echo "" +echo "Update marketplace dockerImage to: $FULL_TAG" diff --git a/apps/jellyfin/manifest.yml b/apps/jellyfin/manifest.yml new file mode 100644 index 00000000..94ab1424 --- /dev/null +++ b/apps/jellyfin/manifest.yml @@ -0,0 +1,63 @@ +app: + id: jellyfin + name: Jellyfin + version: 10.8.13 + description: Free media server. Stream movies, music, and photos. + + container: + image: 146.59.87.168:3000/lfg2025/jellyfin:10.8.13 + pull_policy: if-not-present + network: pasta + + dependencies: + - storage: 10Gi + + resources: + memory_limit: 1Gi + disk_limit: 10Gi + + security: + capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE] + readonly_root: false + network_policy: isolated + + ports: + - host: 8096 + container: 8096 + protocol: tcp + bind: 127.0.0.1 + auth: gated + + volumes: + - type: bind + source: /var/lib/archipelago/jellyfin/config + target: /config + options: [rw] + - type: bind + source: /var/lib/archipelago/jellyfin/cache + target: /cache + options: [rw] + + environment: [] + + health_check: + type: tcp + endpoint: localhost:8096 + interval: 30s + timeout: 5s + retries: 3 + + interfaces: + main: + name: Web UI + description: Jellyfin media dashboard + type: ui + port: 8096 + protocol: http + path: / + + metadata: + icon: /assets/img/app-icons/jellyfin.webp + category: data + author: Jellyfin + repo: https://github.com/jellyfin/jellyfin diff --git a/apps/lightning-stack/Dockerfile b/apps/lightning-stack/Dockerfile new file mode 100644 index 00000000..83c5d177 --- /dev/null +++ b/apps/lightning-stack/Dockerfile @@ -0,0 +1,5 @@ +# Lightning Stack - uses official image +FROM lightninglabs/lightning-stack:v0.12.0 + +# Default configuration is in the image +# No additional setup needed diff --git a/apps/lightning-stack/manifest.yml b/apps/lightning-stack/manifest.yml new file mode 100644 index 00000000..e9befcd6 --- /dev/null +++ b/apps/lightning-stack/manifest.yml @@ -0,0 +1,79 @@ +app: + id: lightning-stack + name: Lightning Stack + version: 0.12.0 + description: Complete Lightning Network implementation. Includes LND, CLN, and management tools. + + container: + image: lightninglabs/lightning-stack:v0.12.0 + image_signature: cosign://... + pull_policy: if-not-present + + dependencies: + - app_id: bitcoin-core + version: ">=24.0" + - storage: 50Gi + + resources: + cpu_limit: 4 + memory_limit: 4Gi + disk_limit: 50Gi + + security: + capabilities: [NET_BIND_SERVICE] + readonly_root: true + no_new_privileges: true + user: 1000 + seccomp_profile: default + network_policy: isolated + apparmor_profile: lightning-stack + + ports: + - host: 9738 + container: 9735 + protocol: tcp # P2P + auth: none + auth_rationale: >- + Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself. + - host: 10010 + container: 10009 + protocol: tcp # gRPC + auth: none + auth_rationale: >- + LND gRPC, authenticated by macaroon over TLS. Remote wallets depend on reaching this directly. + # Mirrors lnd's 18080 exemption — same LND REST API, same macaroon auth. + - host: 8091 + container: 8080 + protocol: tcp # REST/Web UI + auth: none + auth_rationale: >- + LND REST, authenticated by macaroon over TLS. A browser login page would break + Zeus and every non-browser wallet client, exactly as for lnd's 18080. + + volumes: + - type: bind + source: /var/lib/archipelago/lightning-stack + target: /root/.lightning + options: [rw] + + environment: + - BITCOIND_HOST=bitcoin-core + - BITCOIND_RPCUSER=${BITCOIN_RPC_USER} + - BITCOIND_RPCPASS=${BITCOIN_RPC_PASSWORD} + - NETWORK=mainnet + + health_check: + type: http + endpoint: http://127.0.0.1:8080 + path: /v1/getinfo + interval: 30s + timeout: 5s + retries: 3 + + bitcoin_integration: + rpc_access: admin + sync_required: true + + lightning_integration: + channel_management: true + payment_routing: true diff --git a/apps/lnd-ui/manifest.yml b/apps/lnd-ui/manifest.yml new file mode 100644 index 00000000..397f1b0b --- /dev/null +++ b/apps/lnd-ui/manifest.yml @@ -0,0 +1,65 @@ +app: + id: lnd-ui + name: LND UI + version: 1.0.0 + description: | + Archipelago-native HTTP frontend for LND. Runs nginx inside a + container and serves static assets. LND connection info is fetched + via an absolute URL that the host nginx routes to the archipelago + backend on 127.0.0.1:5678, so no upstream auth is baked in. + + container: + build: + context: /opt/archipelago/docker/lnd-ui + dockerfile: Dockerfile + tag: localhost/lnd-ui:local + + dependencies: + - app_id: lnd + + resources: + memory_limit: 64Mi + + security: + readonly_root: false + network_policy: host + + # Host networking: the container's nginx listens on 18083 directly (see + # docker/lnd-ui/nginx.conf), because it has to proxy the archipelago backend + # on 127.0.0.1:5678 same-origin — a bridge container cannot reach that, and + # the cross-origin fallback broke the app on http-only nodes. `ports:` is + # intentionally empty because host networking bypasses port mapping, exactly + # as in apps/bitcoin-ui/manifest.yml. + # + # This previously declared `bridge` with 18083:80, which publishes the host + # port to a container port where nothing listens. scripts/container-specs.sh + # carried the identical mistake and was fixed alongside this; recreating from + # it on archi-dev-box left :18083 refusing connections. + # Declared so the APP GATE can see this port. Host networking means Podman + # publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here + # is a statement of where the container's own nginx listens — 127.0.0.1 — + # not a publish instruction. Without this declaration the gate had no idea + # the port existed: it was neither protected nor listed as unprotected, and + # served the LND screen unauthenticated on every interface. + ports: + - host: 18083 + container: 18083 + protocol: tcp + bind: 127.0.0.1 + auth: gated + # First-party companion UI: its nginx forwards the node session cookie + # to the daemon's authenticated endpoints; without passthrough the gate + # strips it and every data call 401s while the page shell renders. + session_passthrough: true + + volumes: [] + + environment: [] + + health_check: + type: http + endpoint: http://127.0.0.1:18083 + path: / + interval: 30s + timeout: 5s + retries: 3 diff --git a/apps/lnd/Dockerfile b/apps/lnd/Dockerfile new file mode 100644 index 00000000..4f7c90a4 --- /dev/null +++ b/apps/lnd/Dockerfile @@ -0,0 +1,5 @@ +# LND - uses official image +FROM lightninglabs/lnd:v0.18.0 + +# Default configuration is in the image +# No additional setup needed diff --git a/apps/lnd/manifest.yml b/apps/lnd/manifest.yml new file mode 100644 index 00000000..bde08f95 --- /dev/null +++ b/apps/lnd/manifest.yml @@ -0,0 +1,80 @@ +app: + id: lnd + name: LND + version: 0.18.4 + description: Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments. + + container: + image: 146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta + pull_policy: if-not-present + network: archy-net + # BITCOIND_HOST must follow the node's actual Bitcoin container — Knots or + # Core — resolved at apply time from host facts. Hardcoding either breaks + # LND's chain backend connection on the other (lnd.conf is likewise + # resolved in lnd::ensure_config). + derived_env: + - key: BITCOIND_HOST + template: "{{BITCOIN_HOST}}" + secret_env: + - key: BITCOIND_RPCPASS + secret_file: bitcoin-rpc-password + data_uid: "100000:100000" + + dependencies: + - app_id: bitcoin-core + version: ">=26.0" + + resources: + cpu_limit: 2 + memory_limit: 1Gi + disk_limit: 10Gi + + security: + capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_RAW] + readonly_root: false + network_policy: isolated + + ports: + - host: 9735 + container: 9735 + protocol: tcp + auth: none + auth_rationale: >- + Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself. + - host: 10009 + container: 10009 + protocol: tcp + auth: none + auth_rationale: >- + LND gRPC, authenticated by macaroon over TLS. Zeus and other remote wallets depend on reaching this directly. + - host: 18080 + container: 8080 + protocol: tcp + auth: none + auth_rationale: >- + LND REST, authenticated by macaroon over TLS. A browser login page would break Zeus and every non-browser wallet client. + + volumes: + - type: bind + source: /var/lib/archipelago/lnd + target: /root/.lnd + options: [rw] + + environment: + - BITCOIND_RPCUSER=archipelago + - NETWORK=mainnet + + health_check: + type: tcp + endpoint: localhost:10009 + interval: 30s + timeout: 5s + retries: 3 + + bitcoin_integration: + rpc_access: admin + sync_required: true + + lightning_integration: + channel_management: true + payment_routing: true diff --git a/apps/mempool-api/manifest.yml b/apps/mempool-api/manifest.yml new file mode 100644 index 00000000..9bf8fe27 --- /dev/null +++ b/apps/mempool-api/manifest.yml @@ -0,0 +1,77 @@ +app: + id: mempool-api + name: Mempool API + version: 3.0.0 + description: Backend API for mempool explorer. + + container: + image: 146.59.87.168:3000/lfg2025/mempool-backend:v3.0.0 + pull_policy: if-not-present + network: archy-net + # CORE_RPC_HOST must follow the node's actual Bitcoin container — Knots or + # Core — resolved at apply time from host facts (B12). Hardcoding either + # breaks mempool's RPC connection on the other. + derived_env: + - key: CORE_RPC_HOST + template: "{{BITCOIN_HOST}}" + secret_env: + - key: CORE_RPC_PASSWORD + secret_file: bitcoin-rpc-password + - key: DATABASE_PASSWORD + secret_file: mempool-db-password + + dependencies: + - app_id: bitcoin-knots + version: ">=26.0" + - app_id: electrumx + version: ">=1.18.0" + - app_id: archy-mempool-db + version: ">=11.4.10" + - bitcoin:archival + + resources: + memory_limit: 2Gi + disk_limit: 20Gi + + security: + capabilities: [] + readonly_root: false + network_policy: isolated + + ports: + - host: 8999 + container: 8999 + protocol: tcp + bind: 127.0.0.1 + auth: local + + volumes: + - type: bind + source: /var/lib/archipelago/mempool + target: /data + options: [rw] + + environment: + - MEMPOOL_BACKEND=electrum + - ELECTRUM_HOST=electrumx + - ELECTRUM_PORT=50001 + - ELECTRUM_TLS_ENABLED=false + - CORE_RPC_PORT=8332 + - CORE_RPC_USERNAME=archipelago + - DATABASE_ENABLED=true + - DATABASE_HOST=archy-mempool-db + - DATABASE_DATABASE=mempool + - DATABASE_USERNAME=mempool + + health_check: + type: http + endpoint: http://localhost:8999 + path: /api/v1/backend-info + interval: 30s + timeout: 5s + retries: 3 + + bitcoin_integration: + rpc_access: read-only + sync_required: true + pruning_support: false diff --git a/apps/mempool/Dockerfile b/apps/mempool/Dockerfile new file mode 100644 index 00000000..7f2da46a --- /dev/null +++ b/apps/mempool/Dockerfile @@ -0,0 +1,5 @@ +# Mempool - uses official image +FROM mempool/mempool:v2.5.0 + +# Default configuration is in the image +# No additional setup needed diff --git a/apps/mempool/manifest.yml b/apps/mempool/manifest.yml new file mode 100644 index 00000000..ee64420b --- /dev/null +++ b/apps/mempool/manifest.yml @@ -0,0 +1,62 @@ +app: + id: mempool + name: Mempool Explorer + version: 3.0.0 + description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization. + + container: + image: 146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.1 + image_signature: cosign://... + pull_policy: if-not-present + + dependencies: + - app_id: bitcoin-core + version: ">=24.0" + - storage: 20Gi + - bitcoin:archival + + resources: + cpu_limit: 2 + memory_limit: 2Gi + disk_limit: 20Gi + + security: + capabilities: [] + readonly_root: true + no_new_privileges: true + user: 1000 + seccomp_profile: default + network_policy: isolated + apparmor_profile: mempool + + ports: + - host: 4080 + container: 8080 # mempool-frontend nginx listens on 8080 (FRONTEND_HTTP_PORT=8080) + protocol: tcp # Web UI + bind: 127.0.0.1 + auth: gated + + volumes: + - type: bind + source: /var/lib/archipelago/mempool + target: /data + options: [rw] + + environment: + - MEMPOOL_BACKEND=electrum + - MEMPOOL_BITCOIN_HOST=bitcoin-core + - MEMPOOL_BITCOIN_PORT=8332 + - MEMPOOL_BITCOIN_USER=${BITCOIN_RPC_USER} + - MEMPOOL_BITCOIN_PASSWORD=${BITCOIN_RPC_PASSWORD} + + health_check: + type: http + endpoint: http://localhost:4080 + path: /api/health + interval: 30s + timeout: 5s + retries: 3 + + bitcoin_integration: + rpc_access: read-only + sync_required: true diff --git a/apps/morphos-server/.dockerignore b/apps/morphos-server/.dockerignore new file mode 100644 index 00000000..e052d6d1 --- /dev/null +++ b/apps/morphos-server/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +*.log +.git +.gitignore +README.md diff --git a/apps/morphos-server/Dockerfile b/apps/morphos-server/Dockerfile new file mode 100644 index 00000000..59bd227e --- /dev/null +++ b/apps/morphos-server/Dockerfile @@ -0,0 +1,37 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ +RUN npm ci --only=production + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM node:20-alpine + +WORKDIR /app + +# Copy built application +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./ + +# Create non-root user +RUN addgroup -g 1000 appuser && \ + adduser -D -u 1000 -G appuser appuser && \ + mkdir -p /app/data && \ + chown -R appuser:appuser /app + +USER appuser + +EXPOSE 8080 + +ENV MORPHOS_DATA_DIR=/app/data + +CMD ["node", "dist/index.js"] diff --git a/apps/morphos-server/manifest.yml b/apps/morphos-server/manifest.yml new file mode 100644 index 00000000..32975649 --- /dev/null +++ b/apps/morphos-server/manifest.yml @@ -0,0 +1,52 @@ +app: + id: morphos-server + name: MorphOS Server + version: 1.0.0 + description: MorphOS server platform. Decentralized application server. + + container: + image: archipelago/morphos-server:1.0.0 + image_signature: cosign://... + pull_policy: if-not-present + + dependencies: + - storage: 5Gi + + resources: + cpu_limit: 2 + memory_limit: 2Gi + disk_limit: 5Gi + + security: + capabilities: [] + readonly_root: true + no_new_privileges: true + user: 1000 + seccomp_profile: default + network_policy: isolated + apparmor_profile: morphos-server + + ports: + - host: 8089 + container: 8080 + protocol: tcp # Web UI + bind: 127.0.0.1 + auth: gated + + volumes: + - type: bind + source: /var/lib/archipelago/morphos-server + target: /app/data + options: [rw] + + environment: + - MORPHOS_ENV=production + - MORPHOS_DATA_DIR=/app/data + + health_check: + type: http + endpoint: http://127.0.0.1:8080 + path: /health + interval: 30s + timeout: 5s + retries: 3 diff --git a/apps/morphos-server/package-lock.json b/apps/morphos-server/package-lock.json new file mode 100644 index 00000000..4d5cfb5d --- /dev/null +++ b/apps/morphos-server/package-lock.json @@ -0,0 +1,1161 @@ +{ + "name": "morphos-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "morphos-server", + "version": "1.0.0", + "dependencies": { + "express": "^4.18.2" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.10.0", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", + "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/apps/morphos-server/package.json b/apps/morphos-server/package.json new file mode 100644 index 00000000..17d3a90e --- /dev/null +++ b/apps/morphos-server/package.json @@ -0,0 +1,20 @@ +{ + "name": "morphos-server", + "version": "1.0.0", + "description": "MorphOS server platform", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "ts-node src/index.ts" + }, + "dependencies": { + "express": "^4.18.2" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.10.0", + "typescript": "^5.3.3", + "ts-node": "^10.9.2" + } +} diff --git a/apps/morphos-server/src/index.ts b/apps/morphos-server/src/index.ts new file mode 100644 index 00000000..5fd95a24 --- /dev/null +++ b/apps/morphos-server/src/index.ts @@ -0,0 +1,27 @@ +import express from 'express'; + +const app = express(); +const port = 8080; + +// Middleware +app.use(express.json()); + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ status: 'ok', service: 'morphos-server', version: '1.0.0' }); +}); + +// API endpoints +app.get('/api/info', (req, res) => { + res.json({ + name: 'MorphOS Server', + version: '1.0.0', + status: 'running' + }); +}); + +// Start server +app.listen(port, '0.0.0.0', () => { + console.log(`MorphOS Server listening on port ${port}`); + console.log(`Data directory: ${process.env.MORPHOS_DATA_DIR || '/app/data'}`); +}); diff --git a/apps/morphos-server/tsconfig.json b/apps/morphos-server/tsconfig.json new file mode 100644 index 00000000..fa8ee324 --- /dev/null +++ b/apps/morphos-server/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/netbird-dashboard/manifest.yml b/apps/netbird-dashboard/manifest.yml new file mode 100644 index 00000000..dbbb2e67 --- /dev/null +++ b/apps/netbird-dashboard/manifest.yml @@ -0,0 +1,77 @@ +app: + id: netbird-dashboard + name: NetBird Dashboard + version: "2.38.0" + description: NetBird management dashboard (SPA). Internal stack member served through the netbird proxy. + category: networking + + # Hyphen name matches runtime references + the live container (adoption). + # Alias `netbird-dashboard` is the short hostname the proxy's nginx proxies to. + container_name: netbird-dashboard + + container: + image: docker.io/netbirdio/dashboard:v2.38.0 + pull_policy: if-not-present + network: netbird-net + network_aliases: [netbird-dashboard] + # The dashboard SPA bakes its API/OIDC base URL from these at container + # start. They must point at the proxy's public HTTPS origin (8087) so the + # browser uses a secure context (window.crypto.subtle / OIDC PKCE, #15). + # {{HOST_IP}} is the node's primary host IP, resolved at apply time. + derived_env: + - key: NETBIRD_MGMT_API_ENDPOINT + template: "https://{{HOST_IP}}:8087" + - key: NETBIRD_MGMT_GRPC_API_ENDPOINT + template: "https://{{HOST_IP}}:8087" + - key: AUTH_AUTHORITY + template: "https://{{HOST_IP}}:8087/oauth2" + + dependencies: + - app_id: netbird-server + + resources: + memory_limit: 256Mi + + security: + # cap-drop=ALL is applied by the orchestrator. The dashboard image runs + # nginx (master as root, drops workers) binding :80 — needs the worker-drop + # caps + NET_BIND_SERVICE for the privileged port. + capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID, NET_BIND_SERVICE] + readonly_root: false + network_policy: isolated + + # Internal only — reached container-to-container by the proxy via netbird-net. + ports: [] + + volumes: [] + + environment: + - AUTH_AUDIENCE=netbird-dashboard + - AUTH_CLIENT_ID=netbird-dashboard + - AUTH_CLIENT_SECRET= + - USE_AUTH0=false + - AUTH_SUPPORTED_SCOPES=openid profile email groups + - AUTH_REDIRECT_URI=/nb-auth + - AUTH_SILENT_REDIRECT_URI=/nb-silent-auth + - NETBIRD_TOKEN_SOURCE=idToken + - NGINX_SSL_PORT=443 + - LETSENCRYPT_DOMAIN=none + + health_check: + type: tcp + endpoint: localhost:80 + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + + metadata: + author: NetBird + icon: /assets/img/app-icons/netbird.svg + website: https://netbird.io + repo: https://github.com/netbirdio/dashboard + license: BSD-3-Clause + tags: + - networking + - vpn + - dashboard diff --git a/apps/netbird-server/manifest.yml b/apps/netbird-server/manifest.yml new file mode 100644 index 00000000..287be710 --- /dev/null +++ b/apps/netbird-server/manifest.yml @@ -0,0 +1,130 @@ +app: + id: netbird-server + name: NetBird Server + version: "0.71.2" + description: NetBird combined management / signal / relay server with an embedded identity provider and STUN. Backend for the self-hosted NetBird mesh VPN. + category: networking + + # Hyphen name matches the runtime references (crash_recovery / dependencies / + # config startup order) + the live container, so on an existing node the + # orchestrator ADOPTS the running server rather than recreating it (data + + # the sqlite store under /var/lib/netbird preserved). Alias `netbird-server` + # is the short hostname the proxy's nginx proxies/grpc-passes to. + container_name: netbird-server + + container: + image: docker.io/netbirdio/netbird-server:0.71.2 + pull_policy: if-not-present + network: netbird-net + network_aliases: [netbird-server] + # The relay authSecret and the sqlite store encryptionKey are base64 keys + # (the server base64-decodes them to recover raw bytes — hex would decode to + # the wrong value). Generated once and reused: ensure_generated_secrets + # no-ops when the file already exists, so a re-render of config.yaml on an + # adopted node keeps the same keys (regenerating would orphan the store). + generated_secrets: + - name: netbird-relay-auth-secret + kind: base64 + - name: netbird-store-encryption-key + kind: base64 + # Pass the rendered config explicitly, mirroring the legacy `--config` arg. + custom_args: ["--config", "/etc/netbird/config.yaml"] + + dependencies: + - storage: 1Gi + + resources: + memory_limit: 1Gi + + security: + # cap-drop=ALL is applied by the orchestrator. The server binds :80 + # (management/signal/relay HTTP + gRPC) inside the container — a privileged + # port — so it needs NET_BIND_SERVICE. STUN is 3478/udp (unprivileged). + capabilities: [NET_BIND_SERVICE] + readonly_root: false + network_policy: isolated + + ports: + - host: 8086 + container: 80 + protocol: tcp # management API + embedded OIDC issuer (/oauth2) + auth: none + auth_rationale: >- + NetBird management API and its OIDC issuer. Enrolled devices authenticate + themselves with setup keys and JWTs, and they cannot hold a browser session — + a login page here would disconnect every VPN client on the network. + - host: 3478 + container: 3478 + protocol: udp # STUN — must be UDP; tcp here breaks relay discovery + auth: none + auth_rationale: >- + STUN over UDP for NAT traversal; it must answer unauthenticated probes to do its job at all. + + volumes: + - type: bind + source: /var/lib/archipelago/netbird/data + target: /var/lib/netbird + options: [rw] + # The rendered config.yaml, read-only. Re-rendered on every reconcile from + # host facts + the base64 secrets; idempotent (stable bytes → no restart). + - type: bind + source: /var/lib/archipelago/netbird/config.yaml + target: /etc/netbird/config.yaml + options: [ro] + + environment: [] + + # The server's config. {{HOST_IP}} is the node's primary host IP (the proxy's + # public origin is https on 8087 — the dashboard needs a secure context for + # OIDC PKCE, issue #15). {{secret:...}} are read 0600 from the secrets dir. + files: + - path: /var/lib/archipelago/netbird/config.yaml + overwrite: true + content: | + server: + listenAddress: ":80" + exposedAddress: "https://{{HOST_IP}}:8087" + stunPorts: + - 3478 + metricsPort: 9090 + healthcheckAddress: ":9000" + logLevel: "info" + logFile: "console" + authSecret: "{{secret:netbird-relay-auth-secret}}" + dataDir: "/var/lib/netbird" + auth: + issuer: "https://{{HOST_IP}}:8087/oauth2" + localAuthDisabled: false + signKeyRefreshEnabled: false + dashboardRedirectURIs: + - "https://{{HOST_IP}}:8087/nb-auth" + - "https://{{HOST_IP}}:8087/nb-silent-auth" + dashboardPostLogoutRedirectURIs: + - "https://{{HOST_IP}}:8087/" + cliRedirectURIs: + - "http://localhost:53000/" + store: + engine: "sqlite" + encryptionKey: "{{secret:netbird-store-encryption-key}}" + + # TCP liveness on the management port. Binds at startup, stays green; an http + # check of /oauth2 would false-fail while the issuer warms up. + health_check: + type: tcp + endpoint: localhost:80 + interval: 30s + timeout: 5s + retries: 10 + start_period: 30s + + metadata: + author: NetBird + icon: /assets/img/app-icons/netbird.svg + website: https://netbird.io + repo: https://github.com/netbirdio/netbird + license: BSD-3-Clause + tags: + - networking + - vpn + - wireguard + - mesh diff --git a/apps/netbird/manifest.yml b/apps/netbird/manifest.yml new file mode 100644 index 00000000..32cf44d5 --- /dev/null +++ b/apps/netbird/manifest.yml @@ -0,0 +1,187 @@ +app: + id: netbird + name: NetBird + version: "2.38.0" + description: Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server. + category: networking + + # The user-facing launcher (app_id + container both "netbird", matching the + # runtime references + the live container so the orchestrator adopts it). This + # is the nginx that terminates TLS on 8087 and fans out to the dashboard + + # server by their short aliases on netbird-net. + container_name: netbird + + container: + image: docker.io/library/nginx:1.27-alpine + pull_policy: if-not-present + network: netbird-net + # Self-signed TLS cert materialised before create — the dashboard needs a + # secure context (window.crypto.subtle / OIDC PKCE, issue #15), so the proxy + # serves HTTPS. Idempotent: kept as-is when crt+key already exist (a user + # accepts it once). SAN defaults to the host IP + 127.0.0.1 + localhost. + generated_certs: + - crt: /var/lib/archipelago/netbird/tls.crt + key: /var/lib/archipelago/netbird/tls.key + + dependencies: + - app_id: netbird-server + - app_id: netbird-dashboard + - storage: 1Gi + + resources: + memory_limit: 256Mi + + security: + # cap-drop=ALL is applied by the orchestrator. nginx (master as root, drops + # workers) binds :443 — needs the worker-drop caps + NET_BIND_SERVICE. + capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID, NET_BIND_SERVICE] + readonly_root: false + network_policy: isolated + + ports: + # 8087 publishes the TLS listener (container :443). HTTPS is required for the + # dashboard's secure context (issue #15). + - host: 8087 + container: 443 + protocol: tcp + auth: none + auth_rationale: >- + NetBird dashboard over TLS, with its own login. The gate speaks plain HTTP, + so fronting this port would break the secure context the dashboard requires + (issue #15) and the certificate clients pin. + + volumes: + - type: bind + source: /var/lib/archipelago/netbird/nginx.conf + target: /etc/nginx/conf.d/default.conf + options: [ro] + - type: bind + source: /var/lib/archipelago/netbird/tls.crt + target: /etc/nginx/tls.crt + options: [ro] + - type: bind + source: /var/lib/archipelago/netbird/tls.key + target: /etc/nginx/tls.key + options: [ro] + + environment: [] + + # The proxy config. {{NETWORK_GATEWAY}} is the netbird-net bridge gateway = + # Podman's aardvark DNS. nginx uses it as an explicit `resolver` with VARIABLE + # upstreams so it re-resolves container names per request — without it nginx + # pins a container IP at startup and 502s forever once that IP moves on a + # restart/reboot (issue #15, observed live on .198). Every #15 fix below + # (CORS $http_origin reflect, grpc pass, nb-auth/nb-silent-auth rewrite to + # index.html, /relay websocket) is preserved verbatim from the legacy config. + files: + - path: /var/lib/archipelago/netbird/nginx.conf + overwrite: true + content: | + server { + listen 443 ssl; + server_name _; + + # netbird's dashboard needs a secure context (window.crypto.subtle for + # OIDC PKCE), so the proxy terminates TLS with a self-signed cert (#15). + ssl_certificate /etc/nginx/tls.crt; + ssl_certificate_key /etc/nginx/tls.key; + + # Rootless Podman can hand a container a new IP across restarts/reboots. + # nginx resolves a literal upstream name ONCE at startup and caches it, + # so after the IP moves every request 502s with "host unreachable" + # (issue #15, observed live on .198: nginx pinned to a dead + # netbird-dashboard IP). Fix: point `resolver` at the netbird-net + # gateway (Podman's aardvark DNS) and use VARIABLE upstreams, which + # forces nginx to re-resolve the container names at request time. + resolver {{NETWORK_GATEWAY}} valid=10s ipv6=off; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + + location ~ ^/(relay|ws-proxy/) { + set $nb_server netbird-server; + proxy_pass http://$nb_server:80; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 1d; + } + + location ~ ^/(api|oauth2)(/|$) { + # The dashboard is a SPA whose API/OIDC base URL is baked at build + # time to one host:port. A single box is reached via several + # addresses, so those fetches are cross-origin and the browser + # blocks them with no Access-Control-Allow-Origin (#15, live on + # .198). Reflect the caller's Origin and answer the CORS preflight. + if ($request_method = OPTIONS) { + add_header Access-Control-Allow-Origin $http_origin always; + add_header Access-Control-Allow-Credentials true always; + add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always; + add_header Access-Control-Max-Age 86400 always; + add_header Content-Length 0; + return 204; + } + add_header Access-Control-Allow-Origin $http_origin always; + add_header Access-Control-Allow-Credentials true always; + add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always; + add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always; + set $nb_server netbird-server; + proxy_pass http://$nb_server:80; + } + + location ~ ^/(signalexchange\.SignalExchange|management\.ManagementService|management\.ProxyService)/ { + set $nb_server netbird-server; + grpc_pass grpc://$nb_server:80; + grpc_read_timeout 1d; + grpc_send_timeout 1d; + } + + # OIDC callback routes are client-side SPA routes with NO prebuilt page + # in the dashboard bundle, so proxying them straight through 404s — + # which crashes the dashboard's auth init and shows "Unauthenticated" + # with dead buttons (#15, live on .198: /nb-auth + /nb-silent-auth + # returned 404). Serve index.html at these paths (URL unchanged) so + # react-oidc boots and completes the login / silent-SSO. + location ~ ^/(nb-auth|nb-silent-auth) { + set $nb_dashboard netbird-dashboard; + rewrite ^.*$ /index.html break; + proxy_pass http://$nb_dashboard:80; + } + + location / { + set $nb_dashboard netbird-dashboard; + proxy_pass http://$nb_dashboard:80; + } + } + + health_check: + type: tcp + endpoint: localhost:443 + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + + interfaces: + main: + name: Dashboard + description: Manage your self-hosted NetBird mesh VPN + type: ui + port: 8087 + protocol: https + path: / + + metadata: + author: NetBird + icon: /assets/img/app-icons/netbird.svg + website: https://netbird.io + repo: https://github.com/netbirdio/netbird + license: BSD-3-Clause + tags: + - networking + - vpn + - wireguard + - mesh diff --git a/apps/nextcloud/manifest.yml b/apps/nextcloud/manifest.yml new file mode 100644 index 00000000..6fb16d44 --- /dev/null +++ b/apps/nextcloud/manifest.yml @@ -0,0 +1,61 @@ +app: + id: nextcloud + name: Nextcloud + version: "29" + description: Your own private cloud. File sync, calendars, contacts. + + container: + image: 146.59.87.168:3000/lfg2025/nextcloud:29 + pull_policy: if-not-present + network: pasta + + dependencies: + - storage: 10Gi + + resources: + memory_limit: 1Gi + disk_limit: 10Gi + + security: + capabilities: [CHOWN, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE] + readonly_root: false + network_policy: isolated + + ports: + - host: 8085 + container: 80 + protocol: tcp + bind: 127.0.0.1 + auth: gated + + volumes: + - type: bind + source: /var/lib/archipelago/nextcloud + target: /var/www/html + options: [rw] + + environment: [] + + health_check: + type: tcp + endpoint: localhost:80 + interval: 30s + timeout: 5s + retries: 3 + + interfaces: + main: + name: Web UI + description: Nextcloud file and collaboration dashboard + type: ui + port: 8085 + protocol: http + path: / + + metadata: + icon: /assets/img/app-icons/nextcloud.webp + category: data + author: Nextcloud + repo: https://github.com/nextcloud/server + launch: + open_in_new_tab: true diff --git a/apps/nostr-rs-relay/Dockerfile b/apps/nostr-rs-relay/Dockerfile new file mode 100644 index 00000000..14182fdd --- /dev/null +++ b/apps/nostr-rs-relay/Dockerfile @@ -0,0 +1,5 @@ +# Nostr RS Relay - uses official image +FROM scsibug/nostr-rs-relay:latest + +# Default configuration is in the image +# No additional setup needed diff --git a/apps/nostr-rs-relay/manifest.yml b/apps/nostr-rs-relay/manifest.yml new file mode 100644 index 00000000..5bfb5bde --- /dev/null +++ b/apps/nostr-rs-relay/manifest.yml @@ -0,0 +1,60 @@ +app: + id: nostr-rs-relay + name: Nostr Relay (Rust) + version: 0.8.0 + description: High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits. + + container: + image: scsibug/nostr-rs-relay:0.8.9 + image_signature: cosign://... + pull_policy: verify-signature + data_uid: "1000:1000" + + dependencies: + - storage: 10Gi # For event storage + + resources: + cpu_limit: 2 + memory_limit: 1Gi + disk_limit: 10Gi + + security: + capabilities: [] + readonly_root: true + no_new_privileges: true + user: 1000 + seccomp_profile: default + network_policy: isolated + apparmor_profile: nostr-relay + + ports: + - host: 18081 + container: 8080 + protocol: tcp # HTTP/WebSocket + bind: 127.0.0.1 + auth: gated + + volumes: + - type: bind + source: /var/lib/archipelago/nostr-relay + target: /usr/src/app/db + options: [rw] + + environment: + - RELAY_NAME=Archipelago Nostr Relay + - RELAY_DESCRIPTION=Self-hosted Nostr relay on Archipelago + - MAX_EVENTS=1000000 + - MAX_SUBSCRIPTIONS=100 + + health_check: + type: http + endpoint: http://localhost:8080 + path: / + interval: 30s + timeout: 30s + retries: 5 + + nostr_integration: + relay_type: public + monetization_enabled: true # Earn networking profits + event_storage: sqlite diff --git a/apps/photoprism/manifest.yml b/apps/photoprism/manifest.yml new file mode 100644 index 00000000..88cbcb73 --- /dev/null +++ b/apps/photoprism/manifest.yml @@ -0,0 +1,62 @@ +app: + id: photoprism + name: PhotoPrism + version: "240915" + description: AI-powered photo management with facial recognition. + + container: + image: 146.59.87.168:3000/lfg2025/photoprism:240915 + pull_policy: if-not-present + + dependencies: + - storage: 10Gi + + resources: + memory_limit: 1Gi + disk_limit: 10Gi + + security: + capabilities: [CHOWN, SETUID, SETGID] + readonly_root: false + network_policy: isolated + + ports: + - host: 2342 + container: 2342 + protocol: tcp + bind: 127.0.0.1 + auth: gated + + volumes: + - type: bind + source: /var/lib/archipelago/photoprism + target: /photoprism/storage + options: [rw] + + environment: + - PHOTOPRISM_ADMIN_PASSWORD=archipelago + - PHOTOPRISM_DEFAULT_LOCALE=en + + health_check: + type: tcp + endpoint: localhost:2342 + interval: 60s + timeout: 5s + retries: 3 + + interfaces: + main: + name: Web UI + description: PhotoPrism photo library + type: ui + port: 2342 + protocol: http + path: / + + metadata: + icon: /assets/img/app-icons/photoprism.svg + category: data + author: PhotoPrism + repo: https://github.com/photoprism/photoprism + launch: + open_in_new_tab: true diff --git a/apps/pine-openwakeword/manifest.yml b/apps/pine-openwakeword/manifest.yml new file mode 100644 index 00000000..796c1a12 --- /dev/null +++ b/apps/pine-openwakeword/manifest.yml @@ -0,0 +1,73 @@ +app: + id: pine-openwakeword + name: Pine Wake Word (openWakeWord) + version: "2.1.0" + description: Wyoming-protocol openWakeWord wake-word engine. Internal Pine voice-assistant stack member — lets Assist pipelines run wake-word detection on the node (groundwork for the custom "Yo Archy" wake word; stock models like "ok nabu" ship with the image). + category: home + + # Hyphen name matches the runtime references (stack member table / startup + # order) so the orchestrator adopts a matching running container instead of + # recreating it. + container_name: pine-openwakeword + + container: + image: docker.io/rhasspy/wyoming-openwakeword:2.1.0 + pull_policy: if-not-present + network: archy-net + network_aliases: [pine-openwakeword] + # The image entrypoint binds tcp://0.0.0.0:10400. Preload the stock + # "ok nabu" model; /custom is where a trained custom model (yo_archy) + # drops in later — the engine picks up new .tflite files on restart. + custom_args: ["--preload-model", "ok_nabu", "--custom-model-dir", "/custom"] + + dependencies: + - storage: 512Mi + + resources: + memory_limit: 512Mi + + security: + # cap-drop=ALL is applied by the orchestrator. A plain Python Wyoming server + # on an unprivileged port needs no added capabilities. + capabilities: [] + readonly_root: false + no_new_privileges: true + network_policy: isolated + + ports: + # Published so Home Assistant (on the pasta net) can reach the engine via + # host.containers.internal:10400 (the Wyoming integration endpoint). + - host: 10400 + container: 10400 + protocol: tcp + auth: none + auth_rationale: >- + Wyoming voice protocol, a binary local-only stream consumed by Home Assistant; not HTTP and not browser-reachable. + + volumes: + - type: bind + source: /var/lib/archipelago/pine-openwakeword + target: /custom + options: [rw] + + environment: [] + + health_check: + type: tcp + endpoint: localhost:10400 + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + + metadata: + author: Rhasspy / Home Assistant + icon: /assets/img/app-icons/pine.svg + website: https://github.com/rhasspy/wyoming-openwakeword + repo: https://github.com/rhasspy/wyoming-openwakeword + license: MIT + tags: + - home + - voice + - wake-word + - wyoming diff --git a/apps/pine-piper/manifest.yml b/apps/pine-piper/manifest.yml new file mode 100644 index 00000000..9b7cff4c --- /dev/null +++ b/apps/pine-piper/manifest.yml @@ -0,0 +1,73 @@ +app: + id: pine-piper + name: Pine Piper (TTS) + version: "2.2.2" + description: Wyoming-protocol Piper text-to-speech engine. Internal Pine voice-assistant stack member — gives Home Assistant Assist a natural voice for spoken responses on the PineVoice satellite. + category: home + + # Hyphen name matches the runtime references (stack member table / startup + # order) + the live container, so on an existing node the orchestrator ADOPTS + # the running engine rather than recreating it (downloaded voices under /data + # preserved). + container_name: pine-piper + + container: + image: docker.io/rhasspy/wyoming-piper:2.2.2 + pull_policy: if-not-present + network: archy-net + network_aliases: [pine-piper] + # The image entrypoint already binds tcp://0.0.0.0:10200; this arg only + # picks the voice (mirrors the pine ha-stack.yml compose command). + custom_args: ["--voice", "en_GB-alba-medium"] + + dependencies: + - storage: 1Gi + + resources: + memory_limit: 512Mi + + security: + # cap-drop=ALL is applied by the orchestrator. A plain Python Wyoming server + # on an unprivileged port needs no added capabilities. + capabilities: [] + readonly_root: false # downloads the voice into /data on first run + no_new_privileges: true + network_policy: isolated + + ports: + # Published so Home Assistant (on the pasta net) can reach the engine via + # host.containers.internal:10200 (the Wyoming integration endpoint). + - host: 10200 + container: 10200 + protocol: tcp + auth: none + auth_rationale: >- + Wyoming voice protocol, a binary local-only stream consumed by Home Assistant; not HTTP and not browser-reachable. + + volumes: + - type: bind + source: /var/lib/archipelago/pine-piper + target: /data + options: [rw] + + environment: [] + + health_check: + type: tcp + endpoint: localhost:10200 + interval: 30s + timeout: 5s + retries: 5 + start_period: 60s # first start downloads the voice + + metadata: + author: Rhasspy / Home Assistant + icon: /assets/img/app-icons/pine.svg + website: https://github.com/rhasspy/wyoming-piper + repo: https://github.com/rhasspy/wyoming-piper + license: MIT + tags: + - home + - voice + - text-to-speech + - wyoming diff --git a/apps/pine-whisper/manifest.yml b/apps/pine-whisper/manifest.yml new file mode 100644 index 00000000..4f9ebdc9 --- /dev/null +++ b/apps/pine-whisper/manifest.yml @@ -0,0 +1,81 @@ +app: + id: pine-whisper + name: Pine Whisper (STT) + # App revision 3.4.2 = upstream wyoming-whisper 3.4.1 image + tuned args + # (--beam-size 1). Bumped past the image version so catalog-driven nodes + # pick up the args change; the pre-release form "3.4.1-1" would compare + # LOWER than 3.4.1 under semver and never roll out. + version: "3.4.2" + description: Wyoming-protocol faster-whisper speech-to-text engine. Internal Pine voice-assistant stack member — turns speech captured by a PineVoice satellite into text for Home Assistant Assist. + category: home + + # Hyphen name matches the runtime references (stack member table / startup + # order) + the live container, so on an existing node the orchestrator ADOPTS + # the running engine rather than recreating it (downloaded models under /data + # preserved). + container_name: pine-whisper + + container: + image: docker.io/rhasspy/wyoming-whisper:3.4.1 + pull_policy: if-not-present + network: archy-net + network_aliases: [pine-whisper] + # The image entrypoint already binds tcp://0.0.0.0:10300; these args only + # pick the model + language (mirrors the pine ha-stack.yml compose command). + # --beam-size 1: the image default is 5 on x86 (1 on ARM). Benchmarked on + # framework-pt (i5-1135G7, base-int8): beam 1 transcribes the same text + # ~45% faster — the standard low-latency setting for short voice commands + # (HA's own whisper add-on defaults to 1). + custom_args: ["--model", "base-int8", "--language", "en", "--beam-size", "1"] + + dependencies: + - storage: 2Gi + + resources: + memory_limit: 2Gi + + security: + # cap-drop=ALL is applied by the orchestrator. A plain Python Wyoming server + # on an unprivileged port needs no added capabilities. + capabilities: [] + readonly_root: false # downloads the whisper model into /data on first run + no_new_privileges: true + network_policy: isolated + + ports: + # Published so Home Assistant (on the pasta net) can reach the engine via + # host.containers.internal:10300 (the Wyoming integration endpoint). + - host: 10300 + container: 10300 + protocol: tcp + auth: none + auth_rationale: >- + Wyoming voice protocol, a binary local-only stream consumed by Home Assistant; not HTTP and not browser-reachable. + + volumes: + - type: bind + source: /var/lib/archipelago/pine-whisper + target: /data + options: [rw] + + environment: [] + + health_check: + type: tcp + endpoint: localhost:10300 + interval: 30s + timeout: 5s + retries: 5 + start_period: 60s # first start downloads the model + + metadata: + author: Rhasspy / Home Assistant + icon: /assets/img/app-icons/pine.svg + website: https://github.com/rhasspy/wyoming-faster-whisper + repo: https://github.com/rhasspy/wyoming-faster-whisper + license: MIT + tags: + - home + - voice + - speech-to-text + - wyoming diff --git a/apps/pine/manifest.yml b/apps/pine/manifest.yml new file mode 100644 index 00000000..c673df3e --- /dev/null +++ b/apps/pine/manifest.yml @@ -0,0 +1,402 @@ +app: + id: pine + name: Pine + version: "1.3.0" + description: A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else. + category: home + + # The user-facing launcher (app_id + container both "pine", matching the + # runtime references + the live container so the orchestrator adopts it). A + # tiny nginx that serves the "Connect Pine to WiFi" provisioner page for the + # voice stack. The two Wyoming engines (pine-whisper, pine-piper) are internal + # stack members. + container_name: pine + + container: + image: docker.io/library/nginx:1.27-alpine + pull_policy: if-not-present + network: archy-net + network_aliases: [pine] + # The provisioner uses Web Bluetooth (Improv-over-BLE) to push WiFi creds to + # the PineVoice speaker. navigator.bluetooth only exists in a SECURE CONTEXT + # (https or localhost), so the launcher terminates TLS with a self-signed + # cert — otherwise the "Connect Pine to WiFi" button is inert on the LAN. + # Idempotent: kept as-is when crt+key already exist. Mirrors the netbird + # secure-context fix (#15). + generated_certs: + - crt: /var/lib/archipelago/pine/tls.crt + key: /var/lib/archipelago/pine/tls.key + + dependencies: + - app_id: pine-whisper + - app_id: pine-piper + - app_id: pine-openwakeword + - storage: 128Mi + + resources: + memory_limit: 64Mi + + security: + # cap-drop=ALL is applied by the orchestrator. nginx (master as root, drops + # workers) binds :443 inside the container — needs the worker-drop caps + + # NET_BIND_SERVICE for the privileged port. + capabilities: [CHOWN, DAC_OVERRIDE, SETGID, SETUID, NET_BIND_SERVICE] + readonly_root: false + no_new_privileges: true + network_policy: isolated + + ports: + # 10380 (http) is the Open target — the UI launches apps as + # http://host:10380 (resolveAppUrl). nginx there 301-redirects to the https + # listener on 10381, so the new tab lands on a secure context where + # navigator.bluetooth (the "Connect Pine to WiFi" provisioner) works. + - host: 10380 + container: 80 + protocol: tcp + bind: 127.0.0.1 + auth: gated + - host: 10381 + container: 443 + protocol: tcp + auth: none + auth_rationale: >- + Pine's TLS listener. The gate speaks plain HTTP, so fronting this port would + break the secure context navigator.bluetooth needs for WiFi provisioning. + The plain-HTTP entry point (10380) is gated, and it is what the UI opens. + + volumes: + - type: bind + source: /var/lib/archipelago/pine/nginx.conf + target: /etc/nginx/conf.d/default.conf + options: [ro] + - type: bind + source: /var/lib/archipelago/pine/tls.crt + target: /etc/nginx/tls.crt + options: [ro] + - type: bind + source: /var/lib/archipelago/pine/tls.key + target: /etc/nginx/tls.key + options: [ro] + - type: bind + source: /var/lib/archipelago/pine/index.html + target: /usr/share/nginx/html/index.html + options: [ro] + + environment: [] + + files: + - path: /var/lib/archipelago/pine/nginx.conf + overwrite: true + content: | + server { + listen 80; + server_name _; + return 301 https://$host:10381$request_uri; + } + server { + listen 443 ssl; + server_name _; + ssl_certificate /etc/nginx/tls.crt; + ssl_certificate_key /etc/nginx/tls.key; + root /usr/share/nginx/html; + index index.html; + # Live node facts for the status card — proxied to the node's + # public status tier so the (https) page can fetch same-origin. + location = /node-status { + proxy_pass http://host.containers.internal:80/api/pine/status; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_connect_timeout 5s; + proxy_read_timeout 10s; + } + location / { try_files $uri $uri/ /index.html; } + } + - path: /var/lib/archipelago/pine/index.html + overwrite: true + content: | + + + + + + Pine — connect your speaker + + + +
+
+ +

Pine

+

Connect your speaker — everything stays on your node.

+
+ +
+
Whisperspeech-to-text ready on :10300
+
Pipertext-to-speech ready on :10200
+
Wake word“Hey Jarvis” on the speaker (openWakeWord on :10400)
+
Speakerput it in pairing mode — ring LED blinking yellow
+
+ +
+
Nodechecking…
+
Bitcoin
+
Peers
+
+ +
+ This page isn’t running over HTTPS, so the browser blocks Bluetooth. + Open it via its https://…:10380 address (accept the self-signed + certificate) and the button below will work. +
+ +
+ + + + + +
Ready. Click the button, then pick “PineVoice” in the Bluetooth popup.
+
+ +

After WiFi joins, one manual step remains — pair the + speaker in Home Assistant: Settings → Devices & services → + Add Wyoming Protocol, host = the speaker’s IP, port + 10700. Whisper, Piper, openWakeWord and the Assist pipeline + are wired up automatically when Pine installs. Wake word: + “Hey Jarvis.” Ask node things like “what’s the block + height?”, “how many peers?”, “is the node + synced?” or “what’s my lightning balance?” — and when a + Claude API key is set on the node, anything else gets answered by + Claude. New mesh messages are announced on the speaker too.

+

Troubleshooting: if it hears you (LED reacts) but answers + are silent, unplug and replug the speaker — an interrupted answer can + wedge its audio output until it reboots.

+
+ + + + + + health_check: + type: tcp + endpoint: localhost:443 + interval: 30s + timeout: 5s + retries: 5 + start_period: 10s + + interfaces: + main: + name: Pine + description: Connect your speaker to WiFi and check the voice assistant + type: ui + port: 10380 + protocol: http + path: / + + metadata: + author: Archipelago + icon: /assets/img/app-icons/pine.svg + website: https://github.com/rhasspy/wyoming + repo: https://github.com/rhasspy/wyoming + license: MIT + category: home + launch: + open_in_new_tab: true + tags: + - home + - voice + - assistant + - privacy diff --git a/apps/podsteadr-blossom/manifest.yml b/apps/podsteadr-blossom/manifest.yml new file mode 100644 index 00000000..8d559668 --- /dev/null +++ b/apps/podsteadr-blossom/manifest.yml @@ -0,0 +1,125 @@ +app: + id: podsteadr-blossom + name: podsteadr Blossom + version: "4" + description: Blossom (BUD-02) sha256-addressed media blob server backing podsteadr's episode uploads and covers. + category: media + + # Hyphenated name matches the podsteadr repo's docker-compose container_name + # (podsteadr-blossom); alias `blossom` is the short hostname podsteadr's + # server reaches it by (BLOSSOM_URL_INTERNAL=http://blossom:3000). + container_name: podsteadr-blossom + + container: + image: ghcr.io/hzrd149/blossom-server:4 + pull_policy: if-not-present + network: podsteadr-net + network_aliases: [blossom] + # Image runs as container-root (no USER directive) writing to a + # bind-mounted /app/data — CHOWN/DAC_OVERRIDE cover the fresh-bind-dir + # ownership gap the same way apps/botfights and apps/immich document. + # Unverified against a real install; check first-boot logs. + data_uid: "0:0" + + dependencies: + - storage: 20Gi + + resources: + cpu_limit: 1 + memory_limit: 512Mi + disk_limit: 20Gi + + security: + capabilities: [CHOWN, DAC_OVERRIDE, FOWNER] + readonly_root: false + no_new_privileges: true + network_policy: isolated + + ports: + - host: 8098 + container: 3000 + protocol: tcp + auth: none + auth_rationale: >- + Media blobs (episode audio/video, covers) must be publicly fetchable + by podcast clients as RSS enclosure URLs — that's the entire purpose + of this port. Uploads are separately gated by blossom's own BUD-02 + signed-nostr-event auth (upload.requireAuth below), not a node + session; reads are intentionally public per the config's own header + comment. + + volumes: + - type: bind + source: /var/lib/archipelago/podsteadr-blossom/data + target: /app/data + options: [rw] + - type: bind + source: /var/lib/archipelago/podsteadr-blossom/config/config.yml + target: /app/config.yml + options: [ro] + + environment: [] + + files: + - path: /var/lib/archipelago/podsteadr-blossom/config/config.yml + overwrite: true + content: | + # blossom-server (v4.x) configuration for podsteadr. + # Uploads require a signed nostr auth event (BUD-02, kind 24242); + # reads are public so podcast apps can fetch enclosures. + # + # NOTE (blossom-server 4.4.1 gotcha, do not rediscover): `rules:` MUST + # be nested under `storage:` — a top-level `rules:` key is silently + # ignored, the ruleset ends up empty, and every upload fails 401 + # "Server dose not accept video/mp4 blobs" (typo is theirs). The + # GitHub master branch is a Deno rewrite with a different schema + # (storage.rules, BUD-11, range support); the `:4` image is the older + # node/koa codebase this config targets. + + publicDomain: "" + + databasePath: data/sqlite.db + + dashboard: + enabled: false + + discovery: + nostr: + enabled: false + relays: [] + upstream: + enabled: false + domains: [] + + storage: + backend: local + local: + dir: ./data/blobs + removeWhenNoOwners: false + # "expiration" is time since a blob was last accessed — unaccessed + # blobs get pruned after this. Podcast media should effectively + # never expire, so keep this long. + rules: + - type: "*" + expiration: 10 years + + upload: + enabled: true + requireAuth: true + requirePubkeyInRule: false + + list: + requireAuth: false + allowListOthers: true + + tor: + enabled: false + proxy: "" + + health_check: + # No documented health endpoint; TCP liveness on the app port. + type: tcp + endpoint: localhost:3000 + interval: 30s + timeout: 5s + retries: 3 diff --git a/apps/podsteadr-mediamtx/manifest.yml b/apps/podsteadr-mediamtx/manifest.yml new file mode 100644 index 00000000..6293b39e --- /dev/null +++ b/apps/podsteadr-mediamtx/manifest.yml @@ -0,0 +1,164 @@ +app: + id: podsteadr-mediamtx + name: podsteadr MediaMTX + version: "1.20.0" + description: MediaMTX ingest/output backend for podsteadr — RTMP + WebRTC/WHIP ingest, HLS playback, stream recording. + category: media + + # Hyphenated name matches the podsteadr repo's docker-compose container_name + # (podsteadr-mediamtx); alias `mediamtx` is the short hostname podsteadr's + # server reaches it by (MEDIAMTX_API_URL=http://mediamtx:9997) and the one + # baked into mediamtx.yml's authHTTPAddress callback below. + container_name: podsteadr-mediamtx + + container: + image: docker.io/bluenviron/mediamtx:1.20.0 + pull_policy: if-not-present + network: podsteadr-net + network_aliases: [mediamtx] + derived_env: + # Browsers need a reachable ICE host candidate for WebRTC/WHIP; without + # this, the offer only advertises container-internal addresses and + # publish/playback negotiation fails for anyone off-host. + - key: MTX_WEBRTCADDITIONALHOSTS + template: "{{HOST_MDNS}}" + + dependencies: + - storage: 10Gi + + resources: + cpu_limit: 1 + memory_limit: 512Mi + disk_limit: 10Gi + + security: + # Stock mediamtx image runs as container-root (no USER directive) but + # only ever writes to the bind-mounted /recordings — CHOWN/DAC_OVERRIDE + # cover the fresh-bind-dir-ownership gap the same way apps/botfights and + # apps/immich document (root uid inside the container does not + # automatically bypass DAC checks once cap-drop ALL applies). Unverified + # against a real install; check first-boot logs on initial deploy. + capabilities: [CHOWN, DAC_OVERRIDE] + readonly_root: true + no_new_privileges: true + network_policy: isolated + + ports: + - host: 1935 + container: 1935 + protocol: tcp + auth: none + auth_rationale: >- + RTMP ingest (OBS). Not HTTP, so the node's session gate has no login + page to serve here; publish auth is delegated to podsteadr's own + HTTP auth webhook (authHTTPAddress below), which checks a per-stream + secret key never exposed in this port mapping. + - host: 8889 + container: 8889 + protocol: tcp + auth: none + auth_rationale: >- + WebRTC/WHIP ingest — browsers publish directly with a per-stream + bearer secret checked by podsteadr's auth webhook, the same + protocol-level auth as the RTMP port above. + - host: 8189 + container: 8189 + protocol: udp + auth: none + auth_rationale: >- + WebRTC ICE/UDP media transport. Raw UDP has no HTTP session concept + for the gate to enforce. + - host: 8890 + container: 8888 + protocol: tcp + auth: none + auth_rationale: >- + Public HLS playback URL, handed out to viewers and podcast/livestream + clients outside the node (zap.stream, third-party players). A login + page here would break every external viewer; playback is read-only. + + volumes: + - type: bind + # Shared with apps/podsteadr (mounted read-only there) so the app can + # list and remux finished recordings for one-click episode publishing. + source: /var/lib/archipelago/podsteadr/recordings + target: /recordings + options: [rw] + - type: bind + source: /var/lib/archipelago/podsteadr-mediamtx/config/mediamtx.yml + target: /mediamtx.yml + options: [ro] + + environment: [] + + files: + - path: /var/lib/archipelago/podsteadr-mediamtx/config/mediamtx.yml + overwrite: true + content: | + # MediaMTX configuration for podsteadr. + # Ingest: RTMP (OBS) + WebRTC/WHIP (browser). Output: HLS. Publish auth is + # delegated to podsteadr via HTTP; stream status is polled from the API. + + logLevel: info + + api: yes + apiAddress: :9997 + + # ---- authentication ------------------------------------------------------ + authMethod: http + authHTTPAddress: http://podsteadr-app:8095/api/mediamtx/auth + authHTTPExclude: + - action: api + - action: metrics + - action: pprof + + # ---- protocols ----------------------------------------------------------- + rtsp: no + srt: no + moq: no + + rtmp: yes + rtmpAddress: :1935 + + hls: yes + hlsAddress: :8888 + # Standard HLS, not lowLatency: LL-HLS's small per-part buffering window has very little + # tolerance for B-frame reordering (common in most OBS encoder presets), and a real test + # stream crashed the muxer twice in ~2 minutes with "too many reordered frames" / "unable to + # extract DTS" once frame timing got even slightly irregular. Standard HLS buffers a full + # segment before finalizing, which absorbs that jitter — a few extra seconds of latency + # instead of intermittent muxer crashes / viewer buffering. + hlsVariant: mpegts + hlsAlwaysRemux: yes + hlsAllowOrigins: ["*"] + + webrtc: yes + webrtcAddress: :8889 + webrtcLocalUDPAddress: :8189 + webrtcAllowOrigins: ["*"] + + # ---- recording ----------------------------------------------------------- + pathDefaults: + record: yes + recordPath: /recordings/%path/%Y-%m-%d_%H-%M-%S-%f + recordFormat: fmp4 + recordPartDuration: 1s + recordSegmentDuration: 1h + recordDeleteAfter: 168h + + paths: + # Streams live at live/; publish requires the stream secret, + # which podsteadr checks in the auth webhook. + "~^live/[A-Za-z0-9]+$": {} + + health_check: + # Stock mediamtx image has no shell, so an in-container HTTP probe of the + # API isn't meaningfully cheaper than TCP; RTMP liveness is enough (same + # polling-not-hooks rationale as podsteadr's own status poller, which + # exists precisely because runOn*-style shell hooks aren't available on + # this image). + type: tcp + endpoint: localhost:1935 + interval: 30s + timeout: 5s + retries: 3 diff --git a/apps/podsteadr/Dockerfile b/apps/podsteadr/Dockerfile new file mode 100644 index 00000000..68e8ecf3 --- /dev/null +++ b/apps/podsteadr/Dockerfile @@ -0,0 +1,44 @@ +# Vendored copy of the podsteadr repo's own Dockerfile (source lives outside +# this tree — http://146.59.87.168:3000/ssmithx/podsteadr). Re-sync by hand if +# the upstream Dockerfile changes; build with build-from-prototype.sh, which +# passes the podsteadr repo root as build context (this Dockerfile expects +# frontend/ and server/ subdirectories at the context root, not this apps/ +# directory). +# +# ---- frontend ---- +FROM node:22-bookworm-slim AS frontend-build +WORKDIR /build/frontend +COPY frontend/package*.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +# ---- server ---- +FROM node:22-bookworm-slim AS server-build +WORKDIR /build/server +COPY server/package*.json ./ +RUN npm ci +COPY server/ ./ +RUN npm run build && npm prune --omit=dev + +# ---- runtime ---- +FROM node:22-bookworm-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg curl \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=server-build /build/server/node_modules ./node_modules +COPY --from=server-build /build/server/package.json ./package.json +COPY --from=server-build /build/server/dist ./dist +COPY --from=frontend-build /build/frontend/dist ./public +# Named volumes inherit ownership from the image path: keep /data writable by node +RUN mkdir -p /data && chown node:node /data +USER node +ENV NODE_ENV=production \ + PORT=8095 \ + DATA_DIR=/data \ + STATIC_DIR=/app/public +EXPOSE 8095 +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD curl -fsS http://localhost:8095/api/health || exit 1 +CMD ["node", "dist/index.js"] diff --git a/apps/podsteadr/README.md b/apps/podsteadr/README.md new file mode 100644 index 00000000..68be1111 --- /dev/null +++ b/apps/podsteadr/README.md @@ -0,0 +1,85 @@ +# podsteadr — Nostr-native Podcasting & Livestreaming + +Self-hosted, nostr-native podcast publishing and livestreaming. Log in with a +NIP-07 nostr identity (no passwords, no email), upload an mp4 to publish an +RSS 2.0 feed with Podcasting 2.0 lightning payment info, or go live via OBS +(RTMP) or the browser (WebRTC/WHIP) — the stream is announced on nostr as a +NIP-53 live event and viewers watch over HLS. + +This is a three-container stack: + +| App | Manifest | Role | +|---|---|---| +| `podsteadr` | `apps/podsteadr/manifest.yml` | Fastify API + built Vue UI + RSS feeds | +| `podsteadr-mediamtx` | `apps/podsteadr-mediamtx/manifest.yml` | RTMP/WHIP ingest, HLS output, recording | +| `podsteadr-blossom` | `apps/podsteadr-blossom/manifest.yml` | BUD-02 sha256-addressed media blobs | + +All three join a dedicated `podsteadr-net` bridge network and resolve each +other by short DNS aliases (`podsteadr-app`, `mediamtx`, `blossom`). + +## Building the Image + +The app image is built from the **podsteadr** repo, source of truth at +`http://146.59.87.168:3000/ssmithx/podsteadr`. + +### Option 1: Use the build script + +```bash +# From archy repo root +./apps/podsteadr/build-from-prototype.sh +``` + +### Option 2: Build from source directory + +```bash +cd ~/podsteadr +podman build -t localhost/podsteadr:1.0.0 -f ~/archy/apps/podsteadr/Dockerfile . +``` + +### Publishing to the shared registry + +```bash +./apps/podsteadr/push-to-registry.sh 1.0.0 +``` + +Then update `apps/podsteadr/manifest.yml`'s `container.image` to the pushed +tag so other nodes pull instead of building locally. + +## Ports + +See `apps/PORTS.md`. Summary: 8095 (web UI/API/RSS), 1935 (RTMP), 8889 +(WebRTC/WHIP), 8189/udp (WebRTC ICE), 8890 (HLS), 8098 (Blossom). + +All of podsteadr's ports are `auth: none` — this is a public podcast/livestream +server, not a private personal app; RSS feeds, HLS playback, and blob reads +must stay reachable by third-party clients with no Archipelago session, and +the app enforces its own NIP-98 signed-request auth for sensitive routes and +per-stream secret keys for RTMP/WHIP publish. See the `auth_rationale` on each +port mapping. + +## Nostr Identity + +podsteadr's frontend vendors a copy of Archipelago's `nostr-provider.js` shim +and references it directly from `index.html` (its Fastify server isn't the +nginx-served SPA shape the platform auto-patches — see "Nostr Signer Bridge" +in `docs/app-developer-guide.md`). `apps/podsteadr/manifest.yml` declares a +`post_install` hook that re-copies the canonical +`/opt/archipelago/web-ui/nostr-provider.js` over the vendored copy on every +install/reinstall, so it doesn't go stale across OTA releases. + +## Data + +- `/var/lib/archipelago/podsteadr` — SQLite DB, server's own nostr key, + covers, and (read-only here) shared stream recordings. +- `/var/lib/archipelago/podsteadr/recordings` — stream recordings (writable + by `podsteadr-mediamtx`, read-only for `podsteadr`), 7-day retention. +- `/var/lib/archipelago/podsteadr-blossom/data` — media blobs. + +## Known gotchas + +See the podsteadr repo's `docs/STATUS.md` for the full list (blossom v4 +config `rules:` nesting, no HTTP range support in blossom 4.x, split-horizon +blossom URL, MediaMTX has no shell so status is polled not hooked, standard +vs. low-latency HLS). The blossom and mediamtx config files embedded in +`apps/podsteadr-blossom/manifest.yml` / `apps/podsteadr-mediamtx/manifest.yml` +already carry the load-bearing ones inline as comments. diff --git a/apps/podsteadr/build-from-prototype.sh b/apps/podsteadr/build-from-prototype.sh new file mode 100755 index 00000000..dfa90950 --- /dev/null +++ b/apps/podsteadr/build-from-prototype.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Build the podsteadr container image from the podsteadr repo. +# Usage: ./build-from-prototype.sh [path-to-podsteadr-repo] + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEFAULT_REPO="$HOME/podsteadr" +REPO_DIR="${1:-$DEFAULT_REPO}" +IMAGE_TAG="localhost/podsteadr:1.0.0" + +if [ ! -d "$REPO_DIR" ]; then + echo "podsteadr repo not found at: $REPO_DIR" + echo " Set path: $0 /path/to/podsteadr" + exit 1 +fi + +if [ ! -f "$REPO_DIR/server/package.json" ] || [ ! -f "$REPO_DIR/frontend/package.json" ]; then + echo "No server/package.json or frontend/package.json found in $REPO_DIR — is this the right directory?" + exit 1 +fi + +# Determine container runtime +RUNTIME="podman" +if ! command -v podman >/dev/null 2>&1; then + RUNTIME="docker" +fi + +echo "Building podsteadr from $REPO_DIR using $SCRIPT_DIR/Dockerfile" +$RUNTIME build -t "$IMAGE_TAG" -f "$SCRIPT_DIR/Dockerfile" "$REPO_DIR" + +echo "Built $IMAGE_TAG" +echo "" +echo "You can now install podsteadr from the App Store in Archipelago." +echo "Or run directly: $RUNTIME run -d --name podsteadr-app -p 8095:8095 $IMAGE_TAG" diff --git a/apps/podsteadr/manifest.yml b/apps/podsteadr/manifest.yml new file mode 100644 index 00000000..51848e3a --- /dev/null +++ b/apps/podsteadr/manifest.yml @@ -0,0 +1,143 @@ +app: + id: podsteadr + name: podsteadr + version: "1.0.0" + description: Self-hosted, nostr-native podcast publishing and livestreaming. Log in with Nostr, upload episodes or go live via OBS/WebRTC, publish to RSS with Podcasting 2.0 lightning payments. + category: media + + # Container/DNS-alias name deliberately NOT "podsteadr" — on a host whose own + # hostname happens to be "podsteadr", the host's own /etc/hosts self-hostname + # entry (127.0.1.1, e.g. from cloud-init) shadows the container network's DNS + # alias for other containers looking up "podsteadr", and mediamtx's auth-webhook + # callback resolves to the host's loopback instead of this container — every + # RTMP publish gets rejected with "connection refused" (observed on + # podsteadr.atobitcoin.io, 2026-07-30; see docker-compose.yml in the podsteadr + # repo for the original writeup). Carried forward unchanged into the manifest. + container_name: podsteadr-app + + container: + # Built locally from the podsteadr repo (source lives outside this tree — + # see apps/podsteadr/README.md + build-from-prototype.sh), same pattern as + # apps/indeedhub. Not yet pushed to the shared registry; push-to-registry.sh + # is there for when fleet-wide install is needed. + image: localhost/podsteadr:1.0.0 + pull_policy: if-not-present + network: podsteadr-net + network_aliases: [podsteadr-app] + derived_env: + - key: PUBLIC_URL + template: "http://{{HOST_MDNS}}:8095" + - key: MEDIAMTX_RTMP_PUBLIC + template: "rtmp://{{HOST_MDNS}}:1935" + - key: MEDIAMTX_WHIP_PUBLIC + template: "http://{{HOST_MDNS}}:8889" + - key: MEDIAMTX_HLS_PUBLIC + template: "http://{{HOST_MDNS}}:8890" + - key: BLOSSOM_URL_DEFAULT + template: "http://{{HOST_MDNS}}:8098" + # node:22-bookworm-slim's built-in `node` user is uid:gid 1000:1000. The + # image's own Dockerfile chowns /data to node:node, but that only affects + # the image layer — the actual runtime mount is the bind volume below, so + # the host directory needs the same ownership or the read-only-root, + # non-root `node` process can't open the SQLite DB (unverified against a + # real node install; flagging per this repo's convention of documenting + # bind-mount ownership assumptions, e.g. apps/botfights/manifest.yml). + data_uid: "1000:1000" + + dependencies: + - app_id: podsteadr-mediamtx + - app_id: podsteadr-blossom + - storage: 2Gi + + resources: + cpu_limit: 2 + memory_limit: 1Gi + disk_limit: 2Gi + + security: + capabilities: [] + readonly_root: true + no_new_privileges: true + network_policy: isolated + + ports: + - host: 8095 + container: 8095 + protocol: tcp + auth: none + auth_rationale: >- + podsteadr is a public podcast/livestream server: RSS feeds and the + marketplace/catalog API must stay fetchable by third-party podcast + clients, crawlers, and other podsteadr instances with no Archipelago + session, and the app already gates its own sensitive routes with + NIP-98 signed-request auth (see server/src/plugins/nostr-auth.ts in + the podsteadr repo). Putting the node's session gate in front would + block every external RSS/API consumer without adding real protection. + + volumes: + - type: bind + source: /var/lib/archipelago/podsteadr + target: /data + options: [rw] + # Shares podsteadr-mediamtx's recordings directory (rw there, ro here) so + # the app can list/remux finished recordings for one-click episode + # publishing without granting it write access to live segments. + - type: bind + source: /var/lib/archipelago/podsteadr/recordings + target: /recordings + options: [ro] + + environment: + - NODE_ENV=production + - PORT=8095 + - DATA_DIR=/data + - RECORDINGS_DIR=/recordings + - MEDIAMTX_API_URL=http://mediamtx:9997 + - BLOSSOM_URL_INTERNAL=http://blossom:3000 + - NOSTR_RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.nostr.band + - CASHU_MINT_URL_DEFAULT=https://mint.minibits.cash/Bitcoin + + # podsteadr's Fastify server (fastify-static) isn't the nginx-served SPA + # shape the platform auto-patches for NIP-07 injection (see "Nostr Signer + # Bridge" in docs/app-developer-guide.md) — its frontend already + # self-references /nostr-provider.js from index.html and vendors a copy at + # build time (podsteadr commit 133558d). That vendored copy goes stale + # across archy OTA releases, so re-copy the canonical host script over it + # on every install/reinstall instead of trusting the baked-in one. + hooks: + post_install: + - copy_from_host: + src: "web-ui/nostr-provider.js" + dest: /app/public/nostr-provider.js + + health_check: + type: http + endpoint: http://localhost:8095 + path: /api/health + interval: 30s + timeout: 5s + retries: 3 + + interfaces: + main: + name: Web UI + description: Podcast dashboard, upload/live wizard, and stream management + type: ui + port: 8095 + protocol: http + path: / + + metadata: + author: podsteadr + icon: /assets/img/app-icons/podsteadr.png + repo: http://146.59.87.168:3000/ssmithx/podsteadr + license: MIT + tags: + - nostr + - podcast + - livestream + - media + - rss + - lightning + launch: + open_in_new_tab: false diff --git a/apps/podsteadr/push-to-registry.sh b/apps/podsteadr/push-to-registry.sh new file mode 100755 index 00000000..06af8dde --- /dev/null +++ b/apps/podsteadr/push-to-registry.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Build and push the podsteadr container image to a registry. +# Usage: ./push-to-registry.sh [version] +# +# Environment variables: +# REGISTRY - Registry host (default: 146.59.87.168:3000, same as indeedhub/botfights) +# NAMESPACE - Registry namespace (default: lfg2025) +# RUNTIME - Container runtime (default: podman) + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="${PODSTEADR_REPO:-$HOME/podsteadr}" +VERSION="${1:-1.0.0}" +REGISTRY="${REGISTRY:-146.59.87.168:3000}" +NAMESPACE="${NAMESPACE:-lfg2025}" +IMAGE_NAME="podsteadr" +RUNTIME="${RUNTIME:-podman}" + +FULL_TAG="${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${VERSION}" + +if [ ! -d "$REPO_DIR" ]; then + echo "podsteadr repo not found at: $REPO_DIR" + echo "Set PODSTEADR_REPO=/path/to/podsteadr" + exit 1 +fi + +echo "=== podsteadr Container Registry Push ===" +echo "Source: $REPO_DIR" +echo "Image: $FULL_TAG" +echo "Runtime: $RUNTIME" +echo "" + +echo "[1/3] Building image..." +$RUNTIME build --platform linux/amd64 \ + -t "$FULL_TAG" \ + -t "localhost/${IMAGE_NAME}:${VERSION}" \ + -f "$SCRIPT_DIR/Dockerfile" \ + "$REPO_DIR" + +echo "[2/3] Pushing to registry..." +if ! $RUNTIME login --get-login "$REGISTRY" >/dev/null 2>&1; then + echo "" + echo "Not logged in to $REGISTRY." + echo "Run: $RUNTIME login $REGISTRY" + exit 1 +fi + +$RUNTIME push "$FULL_TAG" + +echo "" +echo "[3/3] Done!" +echo "" +echo "Image pushed: $FULL_TAG" +echo "" +echo "Update apps/podsteadr/manifest.yml's container.image to $FULL_TAG so" +echo "nodes pull it instead of building locally." diff --git a/apps/portainer/manifest.yml b/apps/portainer/manifest.yml new file mode 100644 index 00000000..868e114e --- /dev/null +++ b/apps/portainer/manifest.yml @@ -0,0 +1,66 @@ +app: + id: portainer + name: Portainer + version: 2.19.4 + description: Container management web UI for the local Podman socket. + category: development + + container: + image: 146.59.87.168:3000/lfg2025/portainer:2.39.1 + pull_policy: if-not-present + data_uid: "1000:1000" + + dependencies: + - storage: 1Gi + + resources: + memory_limit: 256Mi + disk_limit: 1Gi + + security: + capabilities: [CHOWN, SETUID, SETGID, DAC_OVERRIDE] + readonly_root: false + no_new_privileges: true + network_policy: isolated + + ports: + - host: 9000 + container: 9000 + protocol: tcp + bind: 127.0.0.1 + auth: gated + + volumes: + - type: bind + source: /var/lib/archipelago/portainer + target: /data + options: [rw] + - type: bind + source: /var/lib/archipelago/portainer/compose + target: /data/compose + options: [rw] + - type: bind + source: /run/user/1000/podman/podman.sock + target: /var/run/docker.sock + options: [rw] + + environment: [] + + interfaces: + main: + name: Web UI + description: Portainer web interface + type: ui + port: 9000 + protocol: http + path: / + + metadata: + icon: /assets/img/app-icons/portainer.webp + tier: optional + launch: + open_in_new_tab: true + features: + - Container management dashboard + - Local Podman socket access + - Compose stack storage diff --git a/apps/router/.dockerignore b/apps/router/.dockerignore new file mode 100644 index 00000000..e052d6d1 --- /dev/null +++ b/apps/router/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +*.log +.git +.gitignore +README.md diff --git a/apps/router/Dockerfile b/apps/router/Dockerfile new file mode 100644 index 00000000..8c8d0b22 --- /dev/null +++ b/apps/router/Dockerfile @@ -0,0 +1,40 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ +RUN npm ci --only=production + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM node:20-alpine + +WORKDIR /app + +# Install runtime dependencies +RUN apk add --no-cache \ + dbus \ + avahi \ + avahi-tools + +# Copy built application +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./ + +# Create non-root user +RUN addgroup -g 1000 appuser && \ + adduser -D -u 1000 -G appuser appuser && \ + chown -R appuser:appuser /app + +USER appuser + +EXPOSE 8080 5353 1900 + +CMD ["node", "dist/index.js"] diff --git a/apps/router/README.md b/apps/router/README.md new file mode 100644 index 00000000..21c2972d --- /dev/null +++ b/apps/router/README.md @@ -0,0 +1,38 @@ +# Archipelago Router + +Mesh routing and local network management for Archipelago. + +## Building + +```bash +# From the apps directory +./build.sh router + +# Or manually +cd router +docker build -t archipelago/router:latest . +# or +podman build -t archipelago/router:latest . +``` + +## Development + +```bash +cd router +npm install +npm run dev +``` + +## Ports + +- **8084**: Web UI (dev: 18084) +- **5353**: mDNS/Bonjour (dev: 15353) +- **1900**: SSDP (dev: 11900) + +## Running Locally + +```bash +docker run -p 8084:8080 -p 5353:5353/udp -p 1900:1900/udp \ + -v /tmp/archipelago-dev/router:/app/data \ + archipelago/router:latest +``` diff --git a/apps/router/manifest.yml b/apps/router/manifest.yml new file mode 100644 index 00000000..bada2040 --- /dev/null +++ b/apps/router/manifest.yml @@ -0,0 +1,75 @@ +app: + id: router + name: Mesh Router + version: 1.0.0 + description: Mesh routing and local network management. Provides device discovery, routing, and network topology visualization. + + container: + image: archipelago/router:1.0.0 + image_signature: cosign://... + pull_policy: if-not-present + + dependencies: + - storage: 500Mi + + resources: + cpu_limit: 2 + memory_limit: 512Mi + disk_limit: 500Mi + + security: + capabilities: [NET_ADMIN, NET_RAW] # Required for network management + readonly_root: true + no_new_privileges: true + user: 1000 + seccomp_profile: default + network_policy: host # Requires host network for routing + apparmor_profile: router + + ports: + - host: 8084 + container: 8080 + protocol: tcp # Web UI + bind: 127.0.0.1 + auth: gated + - host: 5353 + container: 5353 + protocol: udp # mDNS/Bonjour + auth: none + auth_rationale: >- + mDNS is UDP multicast service discovery; gating it would break .local name resolution for every device on the LAN. + - host: 1900 + container: 1900 + protocol: udp # SSDP + auth: none + auth_rationale: >- + SSDP/UPnP discovery is UDP multicast — there is no HTTP request to gate and no client that could hold a session. + + volumes: + - type: bind + source: /var/lib/archipelago/router + target: /app/data + options: [rw] + - type: bind + source: /var/run/dbus + target: /var/run/dbus + options: [ro] + + environment: + - NETWORK_INTERFACE=eth0 + - MESH_ENABLED=true + - DEVICE_DISCOVERY=true + + health_check: + type: http + endpoint: http://localhost:8084 + path: /health + interval: 30s + timeout: 5s + retries: 3 + + networking: + mesh_enabled: true + local_network_access: true + device_discovery: true + routing_protocols: [olsr, babel] diff --git a/apps/router/package-lock.json b/apps/router/package-lock.json new file mode 100644 index 00000000..659156ad --- /dev/null +++ b/apps/router/package-lock.json @@ -0,0 +1,1486 @@ +{ + "name": "archipelago-router", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "archipelago-router", + "version": "1.0.0", + "dependencies": { + "bonjour": "^3.5.0", + "express": "^4.18.2", + "network-interfaces": "^1.1.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.10.0", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", + "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", + "integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bonjour": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/bonjour/-/bonjour-3.5.0.tgz", + "integrity": "sha512-RaVTblr+OnEli0r/ud8InrU7D+G0y6aJhlxaLa6Pwty4+xoxboF1BsUI45tujvRpbj9dQVoglChqonGAsjEBYg==", + "license": "MIT", + "dependencies": { + "array-flatten": "^2.1.0", + "deep-equal": "^1.0.1", + "dns-equal": "^1.0.0", + "dns-txt": "^2.0.2", + "multicast-dns": "^6.0.1", + "multicast-dns-service-types": "^1.1.0" + } + }, + "node_modules/buffer-indexof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-indexof/-/buffer-indexof-1.1.1.tgz", + "integrity": "sha512-4/rOEg86jivtPTeOUUT61jJO1Ya1TrR/OkqCSZDyq84WJh3LuuiphBYJN+fm5xufIk4XAFcEwte/8WzC8If/1g==", + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/deep-equal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", + "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", + "license": "MIT", + "dependencies": { + "is-arguments": "^1.1.1", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "regexp.prototype.flags": "^1.5.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dns-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", + "integrity": "sha512-z+paD6YUQsk+AbGCEM4PrOXSss5gd66QfcVBFTKR/HpFL9jCqikS94HYwKww6fQyO7IxrIIyUu+g0Ka9tUS2Cg==", + "license": "MIT" + }, + "node_modules/dns-packet": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-1.3.4.tgz", + "integrity": "sha512-BQ6F4vycLXBvdrJZ6S3gZewt6rcrks9KBgM9vrhW+knGRqc8uEdT7fuCwloc7nny5xNoMJ17HGH0R/6fpo8ECA==", + "license": "MIT", + "dependencies": { + "ip": "^1.1.0", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/dns-txt": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/dns-txt/-/dns-txt-2.0.2.tgz", + "integrity": "sha512-Ix5PrWjphuSoUXV/Zv5gaFHjnaJtb02F2+Si3Ht9dyJ87+Z/lMmy+dpNHtTGraNK958ndXq2i+GLkWsWHcKaBQ==", + "license": "MIT", + "dependencies": { + "buffer-indexof": "^1.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz", + "integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==", + "license": "MIT" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multicast-dns": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-6.2.3.tgz", + "integrity": "sha512-ji6J5enbMyGRHIAkAOu3WdV8nggqviKCEKtXcOqfphZZtQrmHKycfynJ2V7eVPUA4NhJ6V7Wf4TmGbTwKE9B6g==", + "license": "MIT", + "dependencies": { + "dns-packet": "^1.3.1", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, + "node_modules/multicast-dns-service-types": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz", + "integrity": "sha512-cnAsSVxIDsYt0v7HmC0hWZFwwXSh+E6PgCrREDuN/EsjgLwA5XRmlMHhSiDPrt6HxY1gTivEa/Zh7GtODoLevQ==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/network-interfaces": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/network-interfaces/-/network-interfaces-1.1.0.tgz", + "integrity": "sha512-fBk/Cm/RminFKhyUYKolI5nWI2de1m0pHlikz1mnTDbbe/1d2+ti+x/pWlOYuK8o/9p9vyK912+66h2NXGNUwQ==", + "license": "MIT" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/apps/router/package.json b/apps/router/package.json new file mode 100644 index 00000000..3189a7fe --- /dev/null +++ b/apps/router/package.json @@ -0,0 +1,22 @@ +{ + "name": "archipelago-router", + "version": "1.0.0", + "description": "Mesh routing and local network management for Archipelago", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "ts-node src/index.ts" + }, + "dependencies": { + "express": "^4.18.2", + "bonjour": "^3.5.0", + "network-interfaces": "^1.1.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.10.0", + "typescript": "^5.3.3", + "ts-node": "^10.9.2" + } +} diff --git a/apps/router/src/index.ts b/apps/router/src/index.ts new file mode 100644 index 00000000..cb9601c8 --- /dev/null +++ b/apps/router/src/index.ts @@ -0,0 +1,59 @@ +import express from 'express'; +import bonjour from 'bonjour'; + +const app = express(); +const port = 8080; + +// Initialize Bonjour for mDNS +const bonjourInstance = bonjour(); + +// Publish Archipelago Router service +bonjourInstance.publish({ + name: 'Archipelago Router', + type: 'http', + port: port, + txt: { + version: '1.0.0', + mesh: 'enabled', + discovery: 'enabled' + } +}); + +// Middleware +app.use(express.json()); + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ status: 'ok', service: 'archipelago-router' }); +}); + +// Network topology endpoint +app.get('/api/topology', (req, res) => { + res.json({ + nodes: [], + links: [], + timestamp: Date.now() + }); +}); + +// Device discovery endpoint +app.get('/api/devices', (req, res) => { + res.json({ + devices: [], + count: 0 + }); +}); + +// Start server +app.listen(port, '0.0.0.0', () => { + console.log(`Archipelago Router listening on port ${port}`); + console.log('mDNS service published'); +}); + +// Graceful shutdown +process.on('SIGTERM', () => { + console.log('Shutting down...'); + bonjourInstance.unpublishAll(() => { + process.exit(0); + }); +}); diff --git a/apps/router/tsconfig.json b/apps/router/tsconfig.json new file mode 100644 index 00000000..fa8ee324 --- /dev/null +++ b/apps/router/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/searxng/Dockerfile b/apps/searxng/Dockerfile new file mode 100644 index 00000000..b7bf715e --- /dev/null +++ b/apps/searxng/Dockerfile @@ -0,0 +1,5 @@ +# SearXNG - uses official image +FROM searxng/searxng:2024.1.0 + +# Default configuration is in the image +# No additional setup needed diff --git a/apps/searxng/manifest.yml b/apps/searxng/manifest.yml new file mode 100644 index 00000000..0727ff36 --- /dev/null +++ b/apps/searxng/manifest.yml @@ -0,0 +1,51 @@ +app: + id: searxng + name: SearXNG + version: 1.0.0 + description: Privacy-respecting metasearch engine. Search the web without tracking. + + container: + image: 146.59.87.168:3000/lfg2025/searxng:latest + pull_policy: if-not-present + + dependencies: + - storage: 2Gi + + resources: + cpu_limit: 2 + memory_limit: 1Gi + disk_limit: 2Gi + + security: + capabilities: [] + readonly_root: true + no_new_privileges: true + user: 1000 + seccomp_profile: default + network_policy: isolated + apparmor_profile: searxng + + ports: + - host: 8888 + container: 8080 + protocol: tcp # Web UI + bind: 127.0.0.1 + auth: gated + + volumes: + - type: bind + source: /var/lib/archipelago/searxng + target: /etc/searxng + options: [rw] + + environment: + - SEARXNG_HOSTNAME=localhost + - SEARXNG_BIND_ADDRESS=0.0.0.0:8080 + + health_check: + type: http + endpoint: http://localhost:8080 + path: / + interval: 30s + timeout: 30s + retries: 5 diff --git a/apps/strfry/Dockerfile b/apps/strfry/Dockerfile new file mode 100644 index 00000000..67d7f1de --- /dev/null +++ b/apps/strfry/Dockerfile @@ -0,0 +1,5 @@ +# Strfry - uses official image +FROM strfry/strfry:latest + +# Default configuration is in the image +# No additional setup needed diff --git a/apps/strfry/manifest.yml b/apps/strfry/manifest.yml new file mode 100644 index 00000000..eac12a1d --- /dev/null +++ b/apps/strfry/manifest.yml @@ -0,0 +1,215 @@ +app: + id: strfry + name: Strfry Nostr Relay + version: 0.9.0 + description: Lightweight Nostr relay written in C++. Alternative to nostr-rs-relay with lower resource usage. + + container: + image: dockurr/strfry:1.0.4 + image_signature: cosign://... + pull_policy: verify-signature + + dependencies: + - storage: 5Gi + + resources: + cpu_limit: 1 + memory_limit: 512Mi + disk_limit: 5Gi + + security: + capabilities: [] + readonly_root: true + no_new_privileges: true + seccomp_profile: default + network_policy: isolated + apparmor_profile: nostr-relay + + ports: + - host: 8090 + container: 7777 + protocol: tcp # HTTP/WebSocket (strfry listens on 7777) + bind: 127.0.0.1 + auth: gated + + volumes: + - type: bind + source: /var/lib/archipelago/strfry + target: /app/strfry-db + options: [rw] + # Image default config demands a 1M NOFILES rlimit, above the rootless + # user-manager hard cap (524288) — ship the config with nofiles = 0. + # Mounting it also skips the entrypoint's copy into /etc, which a + # readonly_root container cannot do. + - type: bind + source: /var/lib/archipelago/strfry-config/strfry.conf + target: /etc/strfry.conf + options: [ro] + + files: + - path: /var/lib/archipelago/strfry-config/strfry.conf + overwrite: true + content: | + ## + ## Default strfry config + ## + + # Directory that contains the strfry LMDB database (restart required) + db = "./strfry-db/" + + dbParams { + # Maximum number of threads/processes that can simultaneously have LMDB transactions open (restart required) + maxreaders = 256 + + # Size of mmap() to use when loading LMDB (default is 10TB, does *not* correspond to disk-space used) (restart required) + mapsize = 10995116277760 + + # Disables read-ahead when accessing the LMDB mapping. Reduces IO activity when DB size is larger than RAM. (restart required) + noReadAhead = false + } + + events { + # Maximum size of normalised JSON, in bytes + maxEventSize = 65536 + + # Events newer than this will be rejected + rejectEventsNewerThanSeconds = 900 + + # Events older than this will be rejected + rejectEventsOlderThanSeconds = 94608000 + + # Ephemeral events older than this will be rejected + rejectEphemeralEventsOlderThanSeconds = 60 + + # Ephemeral events will be deleted from the DB when older than this + ephemeralEventsLifetimeSeconds = 300 + + # Maximum number of tags allowed + maxNumTags = 2000 + + # Maximum size for tag values, in bytes + maxTagValSize = 1024 + } + + relay { + # Interface to listen on. Use 0.0.0.0 to listen on all interfaces (restart required) + bind = "0.0.0.0" + + # Port to open for the nostr websocket protocol (restart required) + port = 7777 + + # Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required) + nofiles = 0 + + # HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case) + realIpHeader = "" + + info { + # NIP-11: Name of this server. Short/descriptive (< 30 characters) + name = "Archipelago Strfry Relay" + + # NIP-11: Detailed information about relay, free-form + description = "Self-hosted strfry Nostr relay on Archipelago." + + # NIP-11: Administrative nostr pubkey, for contact purposes + pubkey = "" + + # NIP-11: Alternative administrative contact (email, website, etc) + contact = "" + + # NIP-11: URL pointing to an image to be used as an icon for the relay + icon = "" + + # List of supported lists as JSON array, or empty string to use default. Example: "[1,2]" + nips = "" + } + + # Maximum accepted incoming websocket frame size (should be larger than max event) (restart required) + maxWebsocketPayloadSize = 131072 + + # Maximum number of filters allowed in a REQ + maxReqFilterSize = 200 + + # Websocket-level PING message frequency (should be less than any reverse proxy idle timeouts) (restart required) + autoPingSeconds = 55 + + # If TCP keep-alive should be enabled (detect dropped connections to upstream reverse proxy) + enableTcpKeepalive = false + + # How much uninterrupted CPU time a REQ query should get during its DB scan + queryTimesliceBudgetMicroseconds = 10000 + + # Maximum records that can be returned per filter + maxFilterLimit = 500 + + # Maximum number of subscriptions (concurrent REQs) a connection can have open at any time + maxSubsPerConnection = 20 + + writePolicy { + # If non-empty, path to an executable script that implements the writePolicy plugin logic + plugin = "/app/write-policy.py" + } + + compression { + # Use permessage-deflate compression if supported by client. Reduces bandwidth, but slight increase in CPU (restart required) + enabled = true + + # Maintain a sliding window buffer for each connection. Improves compression, but uses more memory (restart required) + slidingWindow = true + } + + logging { + # Dump all incoming messages + dumpInAll = false + + # Dump all incoming EVENT messages + dumpInEvents = false + + # Dump all incoming REQ/CLOSE messages + dumpInReqs = false + + # Log performance metrics for initial REQ database scans + dbScanPerf = false + + # Log reason for invalid event rejection? Can be disabled to silence excessive logging + invalidEvents = true + } + + numThreads { + # Ingester threads: route incoming requests, validate events/sigs (restart required) + ingester = 3 + + # reqWorker threads: Handle initial DB scan for events (restart required) + reqWorker = 3 + + # reqMonitor threads: Handle filtering of new events (restart required) + reqMonitor = 3 + + # negentropy threads: Handle negentropy protocol messages (restart required) + negentropy = 2 + } + + negentropy { + # Support negentropy protocol messages + enabled = true + + # Maximum records that sync will process before returning an error + maxSyncEvents = 1000000 + } + } + + health_check: + type: http + # In-container probe: must target the CONTAINER port (7777), not the host + # mapping (8090), and 127.0.0.1 explicitly — `localhost` resolves to ::1 + # inside the image while strfry binds IPv4 0.0.0.0 only (verified on .228: + # localhost:7777 refused, 127.0.0.1:7777/health = 200). + endpoint: http://127.0.0.1:7777 + path: /health + interval: 30s + timeout: 5s + retries: 3 + + nostr_integration: + relay_type: public + monetization_enabled: true diff --git a/apps/uptime-kuma/manifest.yml b/apps/uptime-kuma/manifest.yml new file mode 100644 index 00000000..391b3c88 --- /dev/null +++ b/apps/uptime-kuma/manifest.yml @@ -0,0 +1,56 @@ +app: + id: uptime-kuma + name: Uptime Kuma + version: 1.23.0 + description: Self-hosted uptime monitoring. + + container: + image: 146.59.87.168:3000/lfg2025/uptime-kuma:1 + pull_policy: if-not-present + network: pasta + custom_args: ["--", "node", "server/server.js"] + + dependencies: + - storage: 1Gi + + resources: + memory_limit: 256Mi + disk_limit: 1Gi + + security: + capabilities: [CHOWN, FOWNER, SETUID, SETGID] + readonly_root: false + network_policy: isolated + + ports: + - host: 3002 + container: 3001 + protocol: tcp + bind: 127.0.0.1 + auth: gated + + volumes: + - type: bind + source: /var/lib/archipelago/uptime-kuma + target: /app/data + options: [rw] + + environment: + - TZ=UTC + + health_check: + type: http + endpoint: localhost:3001 + path: / + interval: 30s + timeout: 5s + retries: 3 + + metadata: + icon: /assets/img/app-icons/uptime-kuma.webp + category: data + tier: recommended + author: Uptime Kuma + repo: https://github.com/louislam/uptime-kuma + launch: + open_in_new_tab: true diff --git a/apps/vaultwarden/manifest.yml b/apps/vaultwarden/manifest.yml new file mode 100644 index 00000000..1e85629f --- /dev/null +++ b/apps/vaultwarden/manifest.yml @@ -0,0 +1,62 @@ +app: + id: vaultwarden + name: Vaultwarden + version: 1.30.0 + description: Self-hosted password vault with zero-knowledge encryption. + + container: + image: 146.59.87.168:3000/lfg2025/vaultwarden:1.30.0-alpine + pull_policy: if-not-present + network: pasta + + dependencies: + - storage: 1Gi + + resources: + memory_limit: 256Mi + disk_limit: 1Gi + + security: + capabilities: [CHOWN, SETUID, SETGID, NET_BIND_SERVICE] + readonly_root: false + network_policy: isolated + + ports: + - host: 8082 + container: 80 + protocol: tcp + bind: 127.0.0.1 + auth: gated + + volumes: + - type: bind + source: /var/lib/archipelago/vaultwarden + target: /data + options: [rw] + + environment: [] + + health_check: + type: tcp + endpoint: localhost:80 + interval: 30s + timeout: 5s + retries: 3 + + interfaces: + main: + name: Web UI + description: Vaultwarden web vault + type: ui + port: 8082 + protocol: http + path: / + + metadata: + icon: /assets/img/app-icons/vaultwarden.webp + category: data + tier: recommended + author: Vaultwarden + repo: https://github.com/dani-garcia/vaultwarden + launch: + open_in_new_tab: true diff --git a/core/.cargo/config.toml b/core/.cargo/config.toml new file mode 100644 index 00000000..adbdfd7c --- /dev/null +++ b/core/.cargo/config.toml @@ -0,0 +1,22 @@ +# Cargo configuration for Archipelago cross-compilation +# +# Native builds (x86_64 on x86_64) work automatically. +# ARM64 cross-compilation requires the aarch64-unknown-linux-gnu toolchain. +# +# Install the target: +# rustup target add aarch64-unknown-linux-gnu +# +# Install the cross-linker (Debian/Ubuntu): +# sudo apt install gcc-aarch64-linux-gnu +# +# Build for ARM64: +# cargo build --release --target aarch64-unknown-linux-gnu + +[target.aarch64-unknown-linux-gnu] +linker = "aarch64-linux-gnu-gcc" + +# OpenSSL cross-compilation environment (set before building) +# These are automatically set by the build scripts but documented here: +# OPENSSL_DIR=/usr/aarch64-linux-gnu +# PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig +# PKG_CONFIG_ALLOW_CROSS=1 diff --git a/core/.env.example b/core/.env.example new file mode 100644 index 00000000..c7ae929b --- /dev/null +++ b/core/.env.example @@ -0,0 +1,20 @@ +# Backend Configuration +# Copy this to .env and adjust as needed + +# Data directory for development +DATADIR=/tmp/archipelago-dev + +# RPC server binding address +RPC_BIND=127.0.0.1:5959 + +# Logging level (trace, debug, info, warn, error) +LOG_LEVEL=debug + +# Database URL (PostgreSQL) +DATABASE_URL=postgresql://localhost/archipelago_dev + +# Optional: Development mode +ARCHIPELAGO_DEV_MODE=true + +# Optional: Port offset for apps in dev mode +ARCHIPELAGO_DEV_PORT_OFFSET=10000 diff --git a/core/Cargo.lock b/core/Cargo.lock new file mode 100644 index 00000000..4b2f02eb --- /dev/null +++ b/core/Cargo.lock @@ -0,0 +1,6955 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + +[[package]] +name = "archipelago" +version = "1.7.125-alpha" +dependencies = [ + "anyhow", + "archipelago-container", + "archipelago-openwrt", + "archipelago-performance", + "archipelago-security", + "argon2", + "async-trait", + "base64 0.21.7", + "bcrypt", + "bip39", + "bitcoin", + "blake3", + "bs58", + "bytes", + "chacha20poly1305", + "chrono", + "ciborium", + "curve25519-dalek 4.1.3", + "data-encoding", + "ed25519-dalek 2.2.0", + "flate2", + "futures-util", + "hex", + "hkdf", + "hmac", + "http-body 1.0.1", + "http-body-util", + "hyper 0.14.32", + "hyper-util", + "hyper-ws-listener", + "iroh", + "iroh-blobs", + "libc", + "mainline", + "mdns-sd", + "nostr-sdk", + "qrcode", + "rand 0.8.5", + "reed-solomon-erasure", + "regex", + "reqwest 0.11.27", + "rustls-pemfile", + "rustls-webpki 0.101.7", + "sd-notify", + "serde", + "serde_bytes", + "serde_json", + "serde_yaml", + "serial2-tokio", + "sha2 0.10.9", + "socket2 0.5.10", + "tar", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tokio-rustls 0.24.1", + "tokio-test", + "tokio-tungstenite 0.20.1", + "toml", + "totp-rs", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", + "zbase32", + "zeroize", + "zip", +] + +[[package]] +name = "archipelago-container" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "futures", + "hex", + "hyper 0.14.32", + "indexmap", + "log", + "reqwest 0.11.27", + "serde", + "serde_json", + "serde_yaml", + "sha2 0.10.9", + "thiserror 1.0.69", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "archipelago-openwrt" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "reqwest 0.11.27", + "serde", + "serde_json", + "ssh2", + "thiserror 1.0.69", + "tokio", + "tokio-test", + "tracing", +] + +[[package]] +name = "archipelago-performance" +version = "0.1.0" +dependencies = [ + "anyhow", + "log", + "serde", + "thiserror 1.0.69", + "tokio", + "tracing", +] + +[[package]] +name = "archipelago-security" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "anyhow", + "chrono", + "hex", + "log", + "rand 0.8.5", + "serde", + "serde_json", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tracing", + "uuid", + "zeroize", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "async-utility" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a34a3b57207a7a1007832416c3e4862378c8451b4e8e093e436f48c2d3d2c151" +dependencies = [ + "futures-util", + "gloo-timers", + "tokio", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-wsocket" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7d8c7d34a225ba919dd9ba44d4b9106d20142da545e086be8ae21d1897e043" +dependencies = [ + "async-utility", + "futures", + "futures-util", + "js-sys", + "tokio", + "tokio-rustls 0.26.4", + "tokio-socks", + "tokio-tungstenite 0.26.2", + "url", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", +] + +[[package]] +name = "atomic-destructor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64 0.22.1", + "http 1.4.0", + "log", + "url", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "bao-tree" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06384416b1825e6e04fde63262fda2dc408f5b64c02d04e0d8b70ae72c17a52b" +dependencies = [ + "blake3", + "bytes", + "futures-lite", + "genawaiter", + "iroh-io", + "positioned-io", + "range-collections", + "self_cell", + "serde", + "smallvec", + "tokio", +] + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base58ck" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" +dependencies = [ + "bitcoin-internals 0.3.0", + "bitcoin_hashes 0.14.1", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7" +dependencies = [ + "base64 0.22.1", + "blowfish", + "getrandom 0.2.17", + "subtle", + "zeroize", +] + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "binary-merge" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597bb81c80a54b6a4381b23faba8d7774b144c94cbd1d6fe3f1329bd776554ab" + +[[package]] +name = "bip39" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33415e24172c1b7d6066f6d999545375ab8e1d95421d6784bdfff9496f292387" +dependencies = [ + "bitcoin_hashes 0.13.0", + "rand 0.8.5", + "rand_core 0.6.4", + "serde", + "unicode-normalization", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitcoin" +version = "0.32.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6bc65742dea50536e35ad42492b234c27904a27f0abdcbce605015cb4ea026" +dependencies = [ + "base58ck", + "bech32", + "bitcoin-internals 0.3.0", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes 0.14.1", + "hex-conservative 0.2.2", + "hex_lit", + "secp256k1", +] + +[[package]] +name = "bitcoin-internals" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9425c3bf7089c983facbae04de54513cce73b41c7f9ff8c845b54e7bc64ebbfb" + +[[package]] +name = "bitcoin-internals" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" + +[[package]] +name = "bitcoin-io" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" + +[[package]] +name = "bitcoin-units" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2" +dependencies = [ + "bitcoin-internals 0.3.0", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1930a4dabfebb8d7d9992db18ebe3ae2876f0a305fab206fd168df931ede293b" +dependencies = [ + "bitcoin-internals 0.2.0", + "hex-conservative 0.1.2", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.2", + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq 0.4.2", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +dependencies = [ + "serde", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f359e08ca85e7bd759e1fd933ff2bccd81864c60a8fba0e259c7f822b0924bf" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto 0.3.0", + "rand_core 0.10.1", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "data-encoding-macro" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" +dependencies = [ + "data-encoding", + "syn 2.0.114", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "const-oid 0.10.2", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.114", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", + "unicode-xid", +] + +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8 0.11.0", + "serdect", + "signature 3.0.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60" +dependencies = [ + "curve25519-dalek 5.0.0-rc.0", + "ed25519 3.0.0", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "signature 3.0.0", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-assoc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastbloom" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8" +dependencies = [ + "foldhash 0.2.0", + "libm", + "portable-atomic", + "siphasher", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin 0.9.8", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-buffered" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin 0.10.0", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "genawaiter" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86bd0361bcbde39b13475e6e36cb24c329964aa2611be285289d1e4b751c1a0" +dependencies = [ + "futures-core", + "genawaiter-macro", + "genawaiter-proc-macro", + "proc-macro-hack", +] + +[[package]] +name = "genawaiter-macro" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b32dfe1fdfc0bbde1f22a5da25355514b5e450c33a6af6770884c8750aedfbc" + +[[package]] +name = "genawaiter-proc-macro" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784f84eebc366e15251c4a8c3acee82a6a6f427949776ecb88377362a9621738" +dependencies = [ + "proc-macro-error", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin 0.9.8", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212ab92002354b4819390025006c897e8140934349e8635c9b077f47b4dcbd20" + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "h2 0.4.13", + "hickory-proto", + "http 1.4.0", + "idna", + "ipnet", + "jni 0.22.4", + "rand 0.10.1", + "rustls 0.23.36", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tokio-rustls 0.26.4", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "prefix-trie", + "rand 0.10.1", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni 0.22.4", + "moka", + "ndk-context", + "once_cell", + "parking_lot 0.12.5", + "rand 0.10.1", + "resolv-conf", + "rustls 0.23.36", + "smallvec", + "system-configuration 0.7.0", + "thiserror 2.0.18", + "tokio", + "tokio-rustls 0.26.4", + "tracing", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.0", + "hyper 1.8.1", + "hyper-util", + "rustls 0.23.36", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.2", + "system-configuration 0.6.1", + "tokio", + "tower-layer", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "hyper-ws-listener" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcbfe4981e45b0a7403a55d4af12f8d30e173e722409658c3857243990e72180" +dependencies = [ + "anyhow", + "base64 0.21.7", + "env_logger", + "futures", + "hyper 0.14.32", + "log", + "sha-1", + "tokio", + "tokio-tungstenite 0.20.1", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "igd-next" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581" +dependencies = [ + "attohttpc", + "bytes", + "futures", + "http 1.4.0", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "log", + "rand 0.10.1", + "tokio", + "url", + "xmltree", +] + +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "inplace-vec-builder" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf64c2edc8226891a71f127587a2861b132d2b942310843814d5001d99a1d307" +dependencies = [ + "smallvec", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.2", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "serde", +] + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "iroh" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6435544bb3a5c4e6ff7affaa0c0aa0d1bca45bd700226329d5059d3eb54f9dff" +dependencies = [ + "backon", + "blake3", + "bytes", + "cfg_aliases", + "ctutils", + "data-encoding", + "derive_more", + "ed25519-dalek 3.0.0-rc.0", + "futures-util", + "getrandom 0.4.2", + "hickory-resolver", + "http 1.4.0", + "ipnet", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "iroh-relay", + "n0-error", + "n0-future", + "n0-watcher", + "netwatch", + "noq", + "noq-proto", + "noq-udp", + "papaya", + "pin-project", + "portable-atomic", + "portmapper", + "rand 0.10.1", + "reqwest 0.13.4", + "rustc-hash", + "rustls 0.23.36", + "rustls-pki-types", + "serde", + "smallvec", + "strum", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", +] + +[[package]] +name = "iroh-base" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c95e4459d9bb828a77084277abd308aa2b58a096652b079bddfd6ef2361f53" +dependencies = [ + "curve25519-dalek 5.0.0-rc.0", + "data-encoding", + "data-encoding-macro", + "derive_more", + "ed25519-dalek 3.0.0-rc.0", + "getrandom 0.4.2", + "n0-error", + "rand 0.10.1", + "serde", + "url", + "zeroize", +] + +[[package]] +name = "iroh-blobs" +version = "0.103.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be50b0e2d0a9ba65cee4e0dfb708b3704e02ad12bd4c14c6307e94245943126" +dependencies = [ + "arrayvec", + "bao-tree", + "bytes", + "cfg_aliases", + "chrono", + "constant_time_eq 0.4.2", + "data-encoding", + "derive_more", + "genawaiter", + "getrandom 0.4.2", + "hex", + "iroh", + "iroh-base", + "iroh-io", + "iroh-metrics", + "iroh-tickets", + "iroh-util", + "irpc", + "n0-error", + "n0-future", + "nested_enum_utils", + "noq", + "postcard", + "rand 0.10.1", + "range-collections", + "redb", + "ref-cast", + "reflink-copy", + "self_cell", + "serde", + "smallvec", + "tokio", + "tracing", +] + +[[package]] +name = "iroh-dns" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754f7e0c1f67938e1d671007264ffef158f14a9f795a7cc219ea68ea09a9d4c9" +dependencies = [ + "arc-swap", + "cfg_aliases", + "derive_more", + "hickory-resolver", + "iroh-base", + "n0-error", + "n0-future", + "ndk-context", + "portable-atomic", + "rand 0.10.1", + "rustls 0.23.36", + "simple-dns", + "strum", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "iroh-io" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a5feb781017b983ff1b155cd1faf8174da2acafd807aa482876da2d7e6577a" +dependencies = [ + "bytes", + "futures-lite", + "pin-project", + "smallvec", + "tokio", +] + +[[package]] +name = "iroh-metrics" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef" +dependencies = [ + "iroh-metrics-derive", + "itoa", + "n0-error", + "portable-atomic", + "ryu", + "serde", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae5f0c4405d1fbc9fb16ff422ca40620e93dc36c30ecaba0c2aee3992b7bd48" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "iroh-relay" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c12e48fef252fd04f8e6b6a8802b377baf72548d62ae4838816624cd0e06b79" +dependencies = [ + "blake3", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more", + "getrandom 0.4.2", + "hickory-resolver", + "http 1.4.0", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "lru 0.18.0", + "n0-error", + "n0-future", + "noq", + "noq-proto", + "num_enum", + "pin-project", + "postcard", + "rand 0.10.1", + "reqwest 0.13.4", + "rustls 0.23.36", + "rustls-pki-types", + "serde", + "serde_bytes", + "strum", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", + "tokio-websockets", + "tracing", + "url", + "vergen-gitcl", + "webpki-roots 1.0.6", + "ws_stream_wasm", +] + +[[package]] +name = "iroh-tickets" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da53233419ca36bf521ed45683b7748366f9b233032891eefc2d70567a84ac54" +dependencies = [ + "data-encoding", + "derive_more", + "iroh-base", + "n0-error", + "postcard", + "serde", +] + +[[package]] +name = "iroh-util" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20e41eb982f15230c55f0a70a74a514360e1f565b07861924fd0e8db172b3d00" +dependencies = [ + "derive_more", + "iroh", + "n0-error", + "n0-future", + "tokio", + "tracing", +] + +[[package]] +name = "irpc" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3623d6ff582b415904b29bbe6ebcb4a4f9a262ccdee05a45fdd003ef0950c386" +dependencies = [ + "futures-buffered", + "futures-util", + "irpc-derive", + "n0-error", + "n0-future", + "noq", + "postcard", + "rcgen", + "rustls 0.23.36", + "serde", + "smallvec", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "irpc-derive" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c254013736de16472140d26904e6ac98e8f3887284dcf4af40f88c77411b56" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.114", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.114", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "bitflags 2.13.0", + "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999beba7b6e8345721bd280141ed958096a2e4abdf74f67ff4ce49b4b54e47a" +dependencies = [ + "hashbrown 0.12.3", +] + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" + +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" + +[[package]] +name = "lru" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" + +[[package]] +name = "mainline" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b751ffb57303217bcae8f490eee6044a5b40eadf6ca05ff476cad37e7b7970d" +dependencies = [ + "bytes", + "crc", + "ed25519-dalek 2.2.0", + "flume", + "lru 0.12.5", + "rand 0.8.5", + "serde", + "serde_bencode", + "serde_bytes", + "sha1_smol", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "mdns-sd" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d797ab3274a16f4940f9650a29838e940223aeff31773df5c2827ad82150182f" +dependencies = [ + "fastrand", + "flume", + "if-addrs", + "log", + "mio", + "socket-pktinfo", + "socket2 0.6.2", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot 0.12.5", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "n0-error" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37e81176a83a77d2514528b91bdafc70ef88aab428f0e1b91aebb8d99888895" +dependencies = [ + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2acd8b070213b0299282f884b4beba4e7b52d624fdcd504a3ad3665390c11e1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more", + "futures-buffered", + "futures-lite", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "n0-watcher" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc618745ad0b7414b149d0517ad8b5573b2fb4d4e2717add3d2446ce1fdd826" +dependencies = [ + "derive_more", + "n0-error", + "n0-future", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "negentropy" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" + +[[package]] +name = "nested_enum_utils" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d5475271bdd36a4a2769eac1ef88df0f99428ea43e52dfd8b0ee5cb674695f" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "netdev" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d31e7286c21ceaf0ddb1d881964011214555ea0b317cc2eb1a1d68d861386fc" +dependencies = [ + "block2", + "dispatch2", + "dlopen2", + "ipnet", + "jni 0.21.1", + "libc", + "mac-addr", + "ndk-context", + "netlink-packet-core", + "netlink-packet-route 0.29.0", + "netlink-sys", + "objc2", + "objc2-core-foundation", + "objc2-core-wlan", + "objc2-foundation", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.61.2", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6" +dependencies = [ + "bitflags 2.13.0", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-packet-route" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" +dependencies = [ + "bitflags 2.13.0", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "netwatch" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8487d26d691cd98d5c17b2adb4b1fd4b31cccc820da1eac827d483295d7bb94a" +dependencies = [ + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more", + "ipnet", + "js-sys", + "libc", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netlink-packet-core", + "netlink-packet-route 0.31.0", + "netlink-proto", + "netlink-sys", + "noq-udp", + "objc2-core-foundation", + "objc2-system-configuration", + "pin-project-lite", + "serde", + "socket2 0.6.2", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows", + "windows-result", + "wmi", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "noq" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f0c73794bfde94db01379c46990b9a773993fca2b61a66184ce148b7c7a187" +dependencies = [ + "bytes", + "cfg_aliases", + "derive_more", + "noq-proto", + "noq-udp", + "pin-project-lite", + "rustc-hash", + "rustls 0.23.36", + "socket2 0.6.2", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "noq-proto" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775be06b8d66c2c64db60140bf54dee8410f67b73c81cc1e1e32f11dfdaae501" +dependencies = [ + "aes-gcm", + "bytes", + "derive_more", + "enum-assoc", + "fastbloom", + "getrandom 0.4.2", + "identity-hash", + "lru-slab", + "rand 0.10.1", + "rand_pcg", + "ring", + "rustc-hash", + "rustls 0.23.36", + "rustls-pki-types", + "rustls-platform-verifier", + "slab", + "sorted-index-buffer", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "noq-udp" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd5a37756f168cf350d68a97c4f0158bdf3c76f10175123941569b09ab51f011" +dependencies = [ + "cfg_aliases", + "libc", + "socket2 0.6.2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "nostr" +version = "0.44.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aa5e3b6a278ed061835fe1ee293b71641e6bf8b401cfe4e1834bbf4ef0a34e1" +dependencies = [ + "aes", + "base64 0.22.1", + "bech32", + "bip39", + "bitcoin_hashes 0.14.1", + "cbc", + "chacha20 0.9.1", + "chacha20poly1305", + "getrandom 0.2.17", + "hex", + "instant", + "scrypt", + "secp256k1", + "serde", + "serde_json", + "unicode-normalization", + "url", +] + +[[package]] +name = "nostr-database" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" +dependencies = [ + "lru 0.16.3", + "nostr", + "tokio", +] + +[[package]] +name = "nostr-gossip" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6" +dependencies = [ + "nostr", +] + +[[package]] +name = "nostr-relay-pool" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b1073ccfbaea5549fb914a9d52c68dab2aecda61535e5143dd73e95445a804b" +dependencies = [ + "async-utility", + "async-wsocket", + "atomic-destructor", + "hex", + "lru 0.16.3", + "negentropy", + "nostr", + "nostr-database", + "tokio", + "tracing", +] + +[[package]] +name = "nostr-sdk" +version = "0.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393" +dependencies = [ + "async-utility", + "nostr", + "nostr-database", + "nostr-gossip", + "nostr-relay-pool", + "tokio", + "tracing", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-core-wlan" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-security", + "objc2-security-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "bitflags 2.13.0", + "dispatch2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-security", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "papaya" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" +dependencies = [ + "equivalent", + "seize", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "winapi", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.0", + "spki 0.8.0", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64 0.22.1", + "indexmap", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +dependencies = [ + "serde", +] + +[[package]] +name = "portmapper" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc716c56a0a50f7e4e25f41446419599d47c6197cc5c9858174220e97c272e6" +dependencies = [ + "base64 0.22.1", + "bytes", + "derive_more", + "hyper-util", + "igd-next", + "iroh-metrics", + "libc", + "n0-error", + "n0-future", + "netwatch", + "num_enum", + "rand 0.10.1", + "serde", + "smallvec", + "socket2 0.6.2", + "time", + "tokio", + "tokio-util", + "tower-layer", + "tracing", + "url", +] + +[[package]] +name = "positioned-io" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ec4b80060f033312b99b6874025d9503d2af87aef2dd4c516e253fbfcdada7" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "postcard-derive", + "serde", +] + +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f33027081eba0a6d8aba6d1b1c3a3be58cbb12106341c2d5759fcd9b5277e7" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a5b4b77fdb63c1eca72173d68d24501c54ab1269409f6b672c85deb18af69de" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "syn-mid", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" +dependencies = [ + "image", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20 0.10.0", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "range-collections" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "861706ea9c4aded7584c5cd1d241cec2ea7f5f50999f236c22b65409a1f1a0d0" +dependencies = [ + "binary-merge", + "inplace-vec-builder", + "ref-cast", + "serde", + "smallvec", +] + +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redb" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e925444704b5f17d32bf42f5b6e2df050bceebc3dcd6e71cc73dafe8092e839" +dependencies = [ + "libc", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "reed-solomon-erasure" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7263373d500d4d4f505d43a2a662d475a894aa94503a1ee28e9188b5f3960d4f" +dependencies = [ + "libm", + "lru 0.7.8", + "parking_lot 0.11.2", + "smallvec", + "spin 0.9.8", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "reflink-copy" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13362233b147e57674c37b802d216b7c5e3dcccbed8967c84f0d8d223868ae27" +dependencies = [ + "cfg-if", + "libc", + "rustix", + "windows", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-rustls 0.24.2", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls 0.21.12", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", + "tokio", + "tokio-rustls 0.24.1", + "tokio-socks", + "tokio-util", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots 0.25.4", + "winreg", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-rustls 0.27.9", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls 0.23.36", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper 1.0.2", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.9", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls 0.23.36", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.9", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2 0.10.9", +] + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "sd-notify" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b943eadf71d8b69e661330cb0e2656e31040acf21ee7708e2c238a0ec6af2bf4" +dependencies = [ + "libc", +] + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "bitcoin_hashes 0.14.1", + "rand 0.8.5", + "secp256k1-sys", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bencode" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70dfc7b7438b99896e7f8992363ab8e2c4ba26aa5ec675d32d1c3c2c33d413e" +dependencies = [ + "serde", + "serde_bytes", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "serial2" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1401f562d358cdfdbdf8946e51a7871ede1db68bd0fd99bedc79e400241550" +dependencies = [ + "cfg-if", + "libc", + "winapi", +] + +[[package]] +name = "serial2-tokio" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b253fd088ff95a617a48e4f01e5543be9072e48663cc4e5a9544f0b258de1e36" +dependencies = [ + "libc", + "serial2", + "tokio", + "winapi", +] + +[[package]] +name = "sha-1" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple-dns" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket-pktinfo" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "927136cc2ae6a1b0e66ac6b1210902b75c3f726db004a73bc18686dcd0dcd22f" +dependencies = [ + "libc", + "socket2 0.6.2", + "windows-sys 0.60.2", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "sorted-index-buffer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" + +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.0", +] + +[[package]] +name = "ssh2" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f84d13b3b8a0d4e91a2629911e951db1bb8671512f5c09d7d4ba34500ba68c8" +dependencies = [ + "bitflags 2.13.0", + "libc", + "libssh2-sys", + "parking_lot 0.12.5", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn-mid" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea305d57546cc8cd04feb14b62ec84bf17f50e3f7b12560d7bfa9265f39d9ed" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys 0.5.0", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys 0.6.0", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys 0.6.0", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +dependencies = [ + "deranged", + "js-sys", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot 0.12.5", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.36", + "tokio", +] + +[[package]] +name = "tokio-socks" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-test" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" +dependencies = [ + "futures-core", + "tokio", + "tokio-stream", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.20.1", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls 0.23.36", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tungstenite 0.26.2", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-websockets" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.4.2", + "http 1.4.0", + "httparse", + "rand 0.10.1", + "ring", + "rustls-pki-types", + "sha1_smol", + "simdutf8", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.14", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "totp-rs" +version = "5.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f124352108f58ef88299e909f6e9470f1cdc8d2a1397963901b4a6366206bf72" +dependencies = [ + "base32", + "constant_time_eq 0.3.1", + "hmac", + "rand 0.9.2", + "sha1", + "sha2 0.10.9", + "url", + "urlencoding", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e3dac10fd62eaf6617d3a904ae222845979aec67c615d1c842b4002c7666fb9" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 0.2.12", + "httparse", + "log", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "url", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.9.2", + "rustls 0.23.36", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vergen" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "vergen-lib", +] + +[[package]] +name = "vergen-gitcl" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen", + "vergen-lib", +] + +[[package]] +name = "vergen-lib" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.13.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.114", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.13.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "wmi" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" +dependencies = [ + "chrono", + "futures", + "log", + "serde", + "thiserror 2.0.18", + "windows", + "windows-core", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version", + "send_wrapper", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zbase32" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9079049688da5871a7558ddacb7f04958862c703e68258594cb7a862b5e33f" + +[[package]] +name = "zerocopy" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + +[[package]] +name = "zmij" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/core/Cargo.toml b/core/Cargo.toml new file mode 100644 index 00000000..24823e5c --- /dev/null +++ b/core/Cargo.toml @@ -0,0 +1,23 @@ +[workspace] +resolver = "2" + +members = [ + "archipelago", + "container", + "openwrt", + "performance", + "security", +] + +# Profiles at workspace root (members' [profile] are ignored in virtual workspaces) +[profile.release] +opt-level = 3 + +[profile.dev] +opt-level = 0 + +[profile.test] +opt-level = 3 + +# Archipelago workspace - no StartOS dependencies +# All patches removed - we use standard crates.io dependencies diff --git a/core/THIRD-PARTY-LICENSES.md b/core/THIRD-PARTY-LICENSES.md new file mode 100644 index 00000000..24e7ff53 --- /dev/null +++ b/core/THIRD-PARTY-LICENSES.md @@ -0,0 +1,656 @@ +# Third-Party Rust Crate Licenses — Archipelago core + +Generated from `cargo metadata` (all features) on 2026-07-23. 649 external crates. +Full license texts ship with release artifacts (cargo-about; see docs/LICENSE-COMPLIANCE-AUDIT.md). + +| Crate | Version | License | Source | +|---|---|---|---| +| adler2 | 2.0.1 | 0BSD OR MIT OR Apache-2.0 | https://github.com/oyvindln/adler2 | +| aead | 0.5.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits | +| aes | 0.8.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/block-ciphers | +| aes-gcm | 0.10.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/AEADs | +| ahash | 0.7.8 | MIT OR Apache-2.0 | https://github.com/tkaitchuck/ahash | +| aho-corasick | 1.1.4 | Unlicense OR MIT | https://github.com/BurntSushi/aho-corasick | +| allocator-api2 | 0.2.21 | MIT OR Apache-2.0 | https://github.com/zakarumych/allocator-api2 | +| android_system_properties | 0.1.5 | MIT/Apache-2.0 | https://github.com/nical/android_system_properties | +| anyhow | 1.0.100 | MIT OR Apache-2.0 | https://github.com/dtolnay/anyhow | +| arc-swap | 1.9.1 | MIT OR Apache-2.0 | https://github.com/vorner/arc-swap | +| argon2 | 0.5.3 | MIT OR Apache-2.0 | https://github.com/RustCrypto/password-hashes/tree/master/argon2 | +| arrayref | 0.3.9 | BSD-2-Clause | https://github.com/droundy/arrayref | +| arrayvec | 0.7.6 | MIT OR Apache-2.0 | https://github.com/bluss/arrayvec | +| asn1-rs | 0.7.2 | MIT OR Apache-2.0 | https://github.com/rusticata/asn1-rs.git | +| asn1-rs-derive | 0.6.0 | MIT OR Apache-2.0 | https://github.com/rusticata/asn1-rs.git | +| asn1-rs-impl | 0.2.0 | MIT/Apache-2.0 | https://github.com/rusticata/asn1-rs.git | +| async-trait | 0.1.89 | MIT OR Apache-2.0 | https://github.com/dtolnay/async-trait | +| async-utility | 0.3.1 | MIT | https://github.com/yukibtc/async-utility.git | +| async-wsocket | 0.13.1 | MIT | https://github.com/yukibtc/async-wsocket.git | +| async_io_stream | 0.3.3 | Unlicense | https://github.com/najamelan/async_io_stream | +| atomic-destructor | 0.3.0 | MIT | https://github.com/yukibtc/atomic-destructor.git | +| atomic-polyfill | 1.0.3 | MIT OR Apache-2.0 | https://github.com/embassy-rs/atomic-polyfill | +| atomic-waker | 1.1.2 | Apache-2.0 OR MIT | https://github.com/smol-rs/atomic-waker | +| attohttpc | 0.30.1 | MPL-2.0 | https://github.com/sbstp/attohttpc | +| autocfg | 1.5.0 | Apache-2.0 OR MIT | https://github.com/cuviper/autocfg | +| backon | 1.6.0 | Apache-2.0 | https://github.com/Xuanwo/backon | +| bao-tree | 0.16.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/bao-tree | +| base16ct | 1.0.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats | +| base32 | 0.5.1 | MIT OR Apache-2.0 | https://github.com/andreasots/base32 | +| base58ck | 0.1.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ | +| base64 | 0.21.7 | MIT OR Apache-2.0 | https://github.com/marshallpierce/rust-base64 | +| base64 | 0.22.1 | MIT OR Apache-2.0 | https://github.com/marshallpierce/rust-base64 | +| base64ct | 1.8.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats | +| bcrypt | 0.15.1 | MIT | https://github.com/Keats/rust-bcrypt | +| bech32 | 0.11.1 | MIT | https://github.com/rust-bitcoin/rust-bech32 | +| binary-merge | 0.1.2 | MIT OR Apache-2.0 | https://github.com/rklaehn/binary-merge | +| bip39 | 2.1.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bip39/ | +| bit-vec | 0.9.1 | Apache-2.0 OR MIT | https://github.com/contain-rs/bit-vec | +| bitcoin | 0.32.5 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ | +| bitcoin-internals | 0.2.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ | +| bitcoin-internals | 0.3.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ | +| bitcoin-io | 0.1.4 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin | +| bitcoin-units | 0.1.2 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin/ | +| bitcoin_hashes | 0.13.0 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin | +| bitcoin_hashes | 0.14.1 | CC0-1.0 | https://github.com/rust-bitcoin/rust-bitcoin | +| bitflags | 1.3.2 | MIT/Apache-2.0 | https://github.com/bitflags/bitflags | +| bitflags | 2.13.0 | MIT OR Apache-2.0 | https://github.com/bitflags/bitflags | +| blake2 | 0.10.6 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes | +| blake3 | 1.8.5 | CC0-1.0 OR Apache-2.0 OR Apache-2.0 WITH LLVM-exception | https://github.com/BLAKE3-team/BLAKE3 | +| block-buffer | 0.10.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils | +| block-buffer | 0.12.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils | +| block-padding | 0.3.3 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils | +| block2 | 0.6.2 | MIT | https://github.com/madsmtm/objc2 | +| blowfish | 0.9.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/block-ciphers | +| bs58 | 0.5.1 | MIT/Apache-2.0 | https://github.com/Nullus157/bs58-rs | +| bumpalo | 3.19.1 | MIT OR Apache-2.0 | https://github.com/fitzgen/bumpalo | +| bytemuck | 1.25.0 | Zlib OR Apache-2.0 OR MIT | https://github.com/Lokathor/bytemuck | +| byteorder | 1.5.0 | Unlicense OR MIT | https://github.com/BurntSushi/byteorder | +| byteorder-lite | 0.1.0 | Unlicense OR MIT | https://github.com/image-rs/byteorder-lite | +| bytes | 1.11.0 | MIT | https://github.com/tokio-rs/bytes | +| cbc | 0.1.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/block-modes | +| cc | 1.2.54 | MIT OR Apache-2.0 | https://github.com/rust-lang/cc-rs | +| cesu8 | 1.1.0 | Apache-2.0/MIT | https://github.com/emk/cesu8-rs | +| cfg-if | 1.0.4 | MIT OR Apache-2.0 | https://github.com/rust-lang/cfg-if | +| cfg_aliases | 0.2.1 | MIT | https://github.com/katharostech/cfg_aliases | +| chacha20 | 0.10.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/stream-ciphers | +| chacha20 | 0.9.1 | Apache-2.0 OR MIT | https://github.com/RustCrypto/stream-ciphers | +| chacha20poly1305 | 0.10.1 | Apache-2.0 OR MIT | https://github.com/RustCrypto/AEADs/tree/master/chacha20poly1305 | +| chrono | 0.4.43 | MIT OR Apache-2.0 | https://github.com/chronotope/chrono | +| ciborium | 0.2.2 | Apache-2.0 | https://github.com/enarx/ciborium | +| ciborium-io | 0.2.2 | Apache-2.0 | https://github.com/enarx/ciborium | +| ciborium-ll | 0.2.2 | Apache-2.0 | https://github.com/enarx/ciborium | +| cipher | 0.4.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits | +| cmov | 0.5.4 | Apache-2.0 OR MIT | https://github.com/RustCrypto/utils | +| cobs | 0.3.0 | MIT OR Apache-2.0 | https://github.com/jamesmunns/cobs.rs | +| combine | 4.6.7 | MIT | https://github.com/Marwes/combine | +| const-oid | 0.10.2 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats | +| const-oid | 0.9.6 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats/tree/master/const-oid | +| constant_time_eq | 0.3.1 | CC0-1.0 OR MIT-0 OR Apache-2.0 | https://github.com/cesarb/constant_time_eq | +| constant_time_eq | 0.4.2 | CC0-1.0 OR MIT-0 OR Apache-2.0 | https://github.com/cesarb/constant_time_eq | +| convert_case | 0.10.0 | MIT | https://github.com/rutrum/convert-case | +| cordyceps | 0.3.4 | MIT | https://github.com/hawkw/mycelium | +| core-foundation | 0.10.1 | MIT OR Apache-2.0 | https://github.com/servo/core-foundation-rs | +| core-foundation | 0.9.4 | MIT OR Apache-2.0 | https://github.com/servo/core-foundation-rs | +| core-foundation-sys | 0.8.7 | MIT OR Apache-2.0 | https://github.com/servo/core-foundation-rs | +| cpufeatures | 0.2.17 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils | +| cpufeatures | 0.3.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils | +| crc | 3.4.0 | MIT OR Apache-2.0 | https://github.com/mrhooray/crc-rs.git | +| crc-catalog | 2.4.0 | MIT OR Apache-2.0 | https://github.com/akhilles/crc-catalog.git | +| crc32fast | 1.5.0 | MIT OR Apache-2.0 | https://github.com/srijs/rust-crc32fast | +| critical-section | 1.2.0 | MIT OR Apache-2.0 | https://github.com/rust-embedded/critical-section | +| crossbeam-channel | 0.5.15 | MIT OR Apache-2.0 | https://github.com/crossbeam-rs/crossbeam | +| crossbeam-epoch | 0.9.18 | MIT OR Apache-2.0 | https://github.com/crossbeam-rs/crossbeam | +| crossbeam-utils | 0.8.21 | MIT OR Apache-2.0 | https://github.com/crossbeam-rs/crossbeam | +| crunchy | 0.2.4 | MIT | https://github.com/eira-fransham/crunchy | +| crypto-common | 0.1.7 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits | +| crypto-common | 0.2.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits | +| ctr | 0.9.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/block-modes | +| ctutils | 0.4.2 | Apache-2.0 OR MIT | https://github.com/RustCrypto/utils | +| curve25519-dalek | 4.1.3 | BSD-3-Clause | https://github.com/dalek-cryptography/curve25519-dalek/tree/main/curve25519-dalek | +| curve25519-dalek | 5.0.0-rc.0 | BSD-3-Clause | https://github.com/dalek-cryptography/curve25519-dalek/tree/main/curve25519-dalek | +| curve25519-dalek-derive | 0.1.1 | MIT/Apache-2.0 | https://github.com/dalek-cryptography/curve25519-dalek | +| darling | 0.20.11 | MIT | https://github.com/TedDriggs/darling | +| darling_core | 0.20.11 | MIT | https://github.com/TedDriggs/darling | +| darling_macro | 0.20.11 | MIT | https://github.com/TedDriggs/darling | +| data-encoding | 2.11.0 | MIT | https://github.com/ia0/data-encoding | +| data-encoding-macro | 0.1.20 | MIT | https://github.com/ia0/data-encoding | +| data-encoding-macro-internal | 0.1.18 | MIT | https://github.com/ia0/data-encoding | +| der | 0.7.10 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats/tree/master/der | +| der | 0.8.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats | +| der-parser | 10.0.0 | MIT OR Apache-2.0 | https://github.com/rusticata/der-parser.git | +| deranged | 0.5.8 | MIT OR Apache-2.0 | https://github.com/jhpratt/deranged | +| derive_builder | 0.20.2 | MIT OR Apache-2.0 | https://github.com/colin-kiegel/rust-derive-builder | +| derive_builder_core | 0.20.2 | MIT OR Apache-2.0 | https://github.com/colin-kiegel/rust-derive-builder | +| derive_builder_macro | 0.20.2 | MIT OR Apache-2.0 | https://github.com/colin-kiegel/rust-derive-builder | +| derive_more | 2.1.1 | MIT | https://github.com/JelteF/derive_more | +| derive_more-impl | 2.1.1 | MIT | https://github.com/JelteF/derive_more | +| diatomic-waker | 0.2.3 | MIT OR Apache-2.0 | https://github.com/asynchronics/diatomic-waker | +| digest | 0.10.7 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits | +| digest | 0.11.3 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits | +| dispatch2 | 0.3.1 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 | +| displaydoc | 0.2.5 | MIT OR Apache-2.0 | https://github.com/yaahc/displaydoc | +| dlopen2 | 0.8.2 | MIT | https://github.com/OpenByteDev/dlopen2 | +| ed25519 | 2.2.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/signatures/tree/master/ed25519 | +| ed25519 | 3.0.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/signatures | +| ed25519-dalek | 2.2.0 | BSD-3-Clause | https://github.com/dalek-cryptography/curve25519-dalek/tree/main/ed25519-dalek | +| ed25519-dalek | 3.0.0-rc.0 | BSD-3-Clause | https://github.com/dalek-cryptography/curve25519-dalek/tree/main/ed25519-dalek | +| either | 1.15.0 | MIT OR Apache-2.0 | https://github.com/rayon-rs/either | +| embedded-io | 0.4.0 | MIT OR Apache-2.0 | https://github.com/embassy-rs/embedded-io | +| embedded-io | 0.6.1 | MIT OR Apache-2.0 | https://github.com/rust-embedded/embedded-hal | +| encoding_rs | 0.8.35 | (Apache-2.0 OR MIT) AND BSD-3-Clause | https://github.com/hsivonen/encoding_rs | +| enum-assoc | 1.3.0 | MIT OR Apache-2.0 | https://github.com/Eolu/enum-assoc | +| env_logger | 0.10.2 | MIT OR Apache-2.0 | https://github.com/rust-cli/env_logger | +| equivalent | 1.0.2 | Apache-2.0 OR MIT | https://github.com/indexmap-rs/equivalent | +| errno | 0.3.14 | MIT OR Apache-2.0 | https://github.com/lambda-fairy/rust-errno | +| fastbloom | 0.17.0 | MIT OR Apache-2.0 | https://github.com/tomtomwombat/fastbloom/ | +| fastrand | 2.3.0 | Apache-2.0 OR MIT | https://github.com/smol-rs/fastrand | +| fiat-crypto | 0.2.9 | MIT OR Apache-2.0 OR BSD-1-Clause | https://github.com/mit-plv/fiat-crypto | +| fiat-crypto | 0.3.0 | MIT OR Apache-2.0 OR BSD-1-Clause | https://github.com/mit-plv/fiat-crypto | +| filetime | 0.2.27 | MIT/Apache-2.0 | https://github.com/alexcrichton/filetime | +| find-msvc-tools | 0.1.8 | MIT OR Apache-2.0 | https://github.com/rust-lang/cc-rs | +| flate2 | 1.1.9 | MIT OR Apache-2.0 | https://github.com/rust-lang/flate2-rs | +| flume | 0.11.1 | Apache-2.0/MIT | https://github.com/zesterer/flume | +| fnv | 1.0.7 | Apache-2.0 / MIT | https://github.com/servo/rust-fnv | +| foldhash | 0.1.5 | Zlib | https://github.com/orlp/foldhash | +| foldhash | 0.2.0 | Zlib | https://github.com/orlp/foldhash | +| form_urlencoded | 1.2.2 | MIT OR Apache-2.0 | https://github.com/servo/rust-url | +| futures | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs | +| futures-buffered | 0.2.13 | MIT | https://github.com/conradludgate/futures-buffered | +| futures-channel | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs | +| futures-core | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs | +| futures-executor | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs | +| futures-io | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs | +| futures-lite | 2.6.1 | Apache-2.0 OR MIT | https://github.com/smol-rs/futures-lite | +| futures-macro | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs | +| futures-sink | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs | +| futures-task | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs | +| futures-util | 0.3.31 | MIT OR Apache-2.0 | https://github.com/rust-lang/futures-rs | +| genawaiter | 0.99.1 | MIT | https://github.com/whatisaphone/genawaiter | +| genawaiter-macro | 0.99.1 | MIT/Apache-2.0 | https://github.com/whatisaphone/genawaiter | +| genawaiter-proc-macro | 0.99.1 | MIT/Apache-2.0 | https://github.com/whatisaphone/genawaiter | +| generator | 0.8.9 | MIT/Apache-2.0 | https://github.com/Xudong-Huang/generator-rs.git | +| generic-array | 0.14.7 | MIT | https://github.com/fizyk20/generic-array.git | +| getrandom | 0.2.17 | MIT OR Apache-2.0 | https://github.com/rust-random/getrandom | +| getrandom | 0.3.4 | MIT OR Apache-2.0 | https://github.com/rust-random/getrandom | +| getrandom | 0.4.2 | MIT OR Apache-2.0 | https://github.com/rust-random/getrandom | +| ghash | 0.5.1 | Apache-2.0 OR MIT | https://github.com/RustCrypto/universal-hashes | +| gloo-timers | 0.3.0 | MIT OR Apache-2.0 | https://github.com/rustwasm/gloo/tree/master/crates/timers | +| h2 | 0.3.27 | MIT | https://github.com/hyperium/h2 | +| h2 | 0.4.13 | MIT | https://github.com/hyperium/h2 | +| half | 2.7.1 | MIT OR Apache-2.0 | https://github.com/VoidStarKat/half-rs | +| hash32 | 0.2.1 | MIT OR Apache-2.0 | https://github.com/japaric/hash32 | +| hashbrown | 0.12.3 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown | +| hashbrown | 0.15.5 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown | +| hashbrown | 0.16.1 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown | +| hashbrown | 0.17.1 | MIT OR Apache-2.0 | https://github.com/rust-lang/hashbrown | +| heapless | 0.7.17 | MIT OR Apache-2.0 | https://github.com/japaric/heapless | +| heck | 0.5.0 | MIT OR Apache-2.0 | https://github.com/withoutboats/heck | +| hermit-abi | 0.5.2 | MIT OR Apache-2.0 | https://github.com/hermit-os/hermit-rs | +| hex | 0.4.3 | MIT OR Apache-2.0 | https://github.com/KokaKiwi/rust-hex | +| hex-conservative | 0.1.2 | CC0-1.0 | https://github.com/rust-bitcoin/hex-conservative | +| hex-conservative | 0.2.2 | CC0-1.0 | https://github.com/rust-bitcoin/hex-conservative | +| hex_lit | 0.1.1 | MITNFA | https://github.com/Kixunil/hex_lit | +| hickory-net | 0.26.1 | MIT OR Apache-2.0 | https://github.com/hickory-dns/hickory-dns | +| hickory-proto | 0.26.1 | MIT OR Apache-2.0 | https://github.com/hickory-dns/hickory-dns | +| hickory-resolver | 0.26.1 | MIT OR Apache-2.0 | https://github.com/hickory-dns/hickory-dns | +| hkdf | 0.12.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/KDFs/ | +| hmac | 0.12.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/MACs | +| http | 0.2.12 | MIT OR Apache-2.0 | https://github.com/hyperium/http | +| http | 1.4.0 | MIT OR Apache-2.0 | https://github.com/hyperium/http | +| http-body | 0.4.6 | MIT | https://github.com/hyperium/http-body | +| http-body | 1.0.1 | MIT | https://github.com/hyperium/http-body | +| http-body-util | 0.1.3 | MIT | https://github.com/hyperium/http-body | +| httparse | 1.10.1 | MIT OR Apache-2.0 | https://github.com/seanmonstar/httparse | +| httpdate | 1.0.3 | MIT OR Apache-2.0 | https://github.com/pyfisch/httpdate | +| humantime | 2.3.0 | MIT OR Apache-2.0 | https://github.com/chronotope/humantime | +| hybrid-array | 0.4.12 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hybrid-array | +| hyper | 0.14.32 | MIT | https://github.com/hyperium/hyper | +| hyper | 1.8.1 | MIT | https://github.com/hyperium/hyper | +| hyper-rustls | 0.24.2 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/hyper-rustls | +| hyper-rustls | 0.27.9 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/hyper-rustls | +| hyper-util | 0.1.19 | MIT | https://github.com/hyperium/hyper-util | +| hyper-ws-listener | 0.3.0 | MIT | | +| iana-time-zone | 0.1.64 | MIT OR Apache-2.0 | https://github.com/strawlab/iana-time-zone | +| iana-time-zone-haiku | 0.1.2 | MIT OR Apache-2.0 | https://github.com/strawlab/iana-time-zone | +| icu_collections | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| icu_locale_core | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| icu_normalizer | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| icu_normalizer_data | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| icu_properties | 2.1.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| icu_properties_data | 2.1.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| icu_provider | 2.1.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| id-arena | 2.3.0 | MIT/Apache-2.0 | https://github.com/fitzgen/id-arena | +| ident_case | 1.0.1 | MIT/Apache-2.0 | https://github.com/TedDriggs/ident_case | +| identity-hash | 0.1.0 | Apache-2.0 OR MIT | https://github.com/offsetting/identity-hash | +| idna | 1.1.0 | MIT OR Apache-2.0 | https://github.com/servo/rust-url/ | +| idna_adapter | 1.2.1 | Apache-2.0 OR MIT | https://github.com/hsivonen/idna_adapter | +| if-addrs | 0.15.0 | MIT OR BSD-3-Clause | https://github.com/messense/if-addrs | +| igd-next | 0.17.1 | MIT | https://github.com/dariusc93/rust-igd | +| image | 0.25.9 | MIT OR Apache-2.0 | https://github.com/image-rs/image | +| indexmap | 2.13.0 | Apache-2.0 OR MIT | https://github.com/indexmap-rs/indexmap | +| inout | 0.1.4 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils | +| inplace-vec-builder | 0.1.1 | MIT OR Apache-2.0 | https://github.com/rklaehn/inplace-vec-builder | +| instant | 0.1.13 | BSD-3-Clause | https://github.com/sebcrozet/instant | +| ipconfig | 0.3.4 | MIT/Apache-2.0 | https://github.com/liranringel/ipconfig | +| ipnet | 2.12.0 | MIT OR Apache-2.0 | https://github.com/krisprice/ipnet | +| iri-string | 0.7.12 | MIT OR Apache-2.0 | https://github.com/lo48576/iri-string | +| iroh | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh | +| iroh-base | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh | +| iroh-blobs | 0.103.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-blobs | +| iroh-dns | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh | +| iroh-io | 0.6.2 | Apache-2.0 OR MIT | https://github.com/n0-computer/iroh | +| iroh-metrics | 1.0.1 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-metrics | +| iroh-metrics-derive | 1.0.1 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-metrics | +| iroh-relay | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh | +| iroh-tickets | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-tickets | +| iroh-util | 0.6.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh-util | +| irpc | 0.17.0 | Apache-2.0/MIT | https://github.com/n0-computer/irpc | +| irpc-derive | 0.17.0 | Apache-2.0/MIT | https://github.com/n0-computer/irpc | +| is-terminal | 0.4.17 | MIT | https://github.com/sunfishcode/is-terminal | +| itoa | 1.0.17 | MIT OR Apache-2.0 | https://github.com/dtolnay/itoa | +| jni | 0.21.1 | MIT/Apache-2.0 | https://github.com/jni-rs/jni-rs | +| jni | 0.22.4 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-rs | +| jni-macros | 0.22.4 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-rs | +| jni-sys | 0.3.1 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-sys | +| jni-sys | 0.4.1 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-sys | +| jni-sys-macros | 0.4.1 | MIT OR Apache-2.0 | https://github.com/jni-rs/jni-sys | +| js-sys | 0.3.85 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/js-sys | +| lazy_static | 1.5.0 | MIT OR Apache-2.0 | https://github.com/rust-lang-nursery/lazy-static.rs | +| leb128fmt | 0.1.0 | MIT OR Apache-2.0 | https://github.com/bluk/leb128fmt | +| libc | 0.2.180 | MIT OR Apache-2.0 | https://github.com/rust-lang/libc | +| libm | 0.2.16 | MIT | https://github.com/rust-lang/compiler-builtins | +| libredox | 0.1.14 | MIT | https://gitlab.redox-os.org/redox-os/libredox.git | +| libssh2-sys | 0.3.1 | MIT OR Apache-2.0 | https://github.com/alexcrichton/ssh2-rs | +| libz-sys | 1.1.29 | MIT OR Apache-2.0 | https://github.com/rust-lang/libz-sys | +| linux-raw-sys | 0.11.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/sunfishcode/linux-raw-sys | +| litemap | 0.8.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| lock_api | 0.4.14 | MIT OR Apache-2.0 | https://github.com/Amanieu/parking_lot | +| log | 0.4.29 | MIT OR Apache-2.0 | https://github.com/rust-lang/log | +| loom | 0.7.2 | MIT | https://github.com/tokio-rs/loom | +| lru | 0.12.5 | MIT | https://github.com/jeromefroe/lru-rs.git | +| lru | 0.16.3 | MIT | https://github.com/jeromefroe/lru-rs.git | +| lru | 0.18.0 | MIT | https://github.com/jeromefroe/lru-rs.git | +| lru | 0.7.8 | MIT | https://github.com/jeromefroe/lru-rs.git | +| lru-slab | 0.1.2 | MIT OR Apache-2.0 OR Zlib | https://github.com/Ralith/lru-slab | +| mac-addr | 0.3.0 | MIT | https://github.com/shellrow/mac-addr | +| mainline | 2.0.1 | MIT | https://github.com/nuhvi/mainline | +| matchers | 0.2.0 | MIT | https://github.com/hawkw/matchers | +| mdns-sd | 0.18.2 | Apache-2.0 OR MIT | https://github.com/keepsimple1/mdns-sd | +| memchr | 2.7.6 | Unlicense OR MIT | https://github.com/BurntSushi/memchr | +| mime | 0.3.17 | MIT OR Apache-2.0 | https://github.com/hyperium/mime | +| minimal-lexical | 0.2.1 | MIT/Apache-2.0 | https://github.com/Alexhuszagh/minimal-lexical | +| miniz_oxide | 0.8.9 | MIT OR Zlib OR Apache-2.0 | https://github.com/Frommi/miniz_oxide/tree/master/miniz_oxide | +| mio | 1.1.1 | MIT | https://github.com/tokio-rs/mio | +| moka | 0.12.15 | (MIT OR Apache-2.0) AND Apache-2.0 | https://github.com/moka-rs/moka | +| moxcms | 0.7.11 | BSD-3-Clause OR Apache-2.0 | https://github.com/awxkee/moxcms.git | +| n0-error | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/n0-error | +| n0-error-macros | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/n0-error | +| n0-future | 0.3.2 | MIT OR Apache-2.0 | https://github.com/n0-computer/n0-future | +| n0-watcher | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/n0-watcher | +| ndk-context | 0.1.1 | MIT OR Apache-2.0 | https://github.com/rust-windowing/android-ndk-rs | +| negentropy | 0.5.0 | MIT | https://github.com/rust-nostr/negentropy.git | +| nested_enum_utils | 0.2.3 | MIT OR Apache-2.0 | https://github.com/n0-computer/nested-enum-utils | +| netdev | 0.44.0 | MIT | https://github.com/shellrow/netdev | +| netlink-packet-core | 0.8.1 | MIT | https://github.com/rust-netlink/netlink-packet-core | +| netlink-packet-route | 0.29.0 | MIT | https://github.com/rust-netlink/netlink-packet-route | +| netlink-packet-route | 0.31.0 | MIT | https://github.com/rust-netlink/netlink-packet-route | +| netlink-proto | 0.12.0 | MIT | https://github.com/rust-netlink/netlink-proto | +| netlink-sys | 0.8.8 | MIT | https://github.com/rust-netlink/netlink-sys | +| netwatch | 0.19.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/net-tools | +| nom | 7.1.3 | MIT | https://github.com/Geal/nom | +| noq | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/noq | +| noq-proto | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/noq | +| noq-udp | 1.0.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/noq | +| nostr | 0.44.2 | MIT | https://github.com/rust-nostr/nostr.git | +| nostr-database | 0.44.0 | MIT | https://github.com/rust-nostr/nostr.git | +| nostr-gossip | 0.44.0 | MIT | https://github.com/rust-nostr/nostr.git | +| nostr-relay-pool | 0.44.0 | MIT | https://github.com/rust-nostr/nostr.git | +| nostr-sdk | 0.44.1 | MIT | https://github.com/rust-nostr/nostr.git | +| nu-ansi-term | 0.50.3 | MIT | https://github.com/nushell/nu-ansi-term | +| num-bigint | 0.4.6 | MIT OR Apache-2.0 | https://github.com/rust-num/num-bigint | +| num-conv | 0.2.2 | MIT OR Apache-2.0 | https://github.com/jhpratt/num-conv | +| num-integer | 0.1.46 | MIT OR Apache-2.0 | https://github.com/rust-num/num-integer | +| num-traits | 0.2.19 | MIT OR Apache-2.0 | https://github.com/rust-num/num-traits | +| num_enum | 0.7.6 | BSD-3-Clause OR MIT OR Apache-2.0 | https://github.com/illicitonion/num_enum | +| num_enum_derive | 0.7.6 | BSD-3-Clause OR MIT OR Apache-2.0 | https://github.com/illicitonion/num_enum | +| num_threads | 0.1.7 | MIT OR Apache-2.0 | https://github.com/jhpratt/num_threads | +| objc2 | 0.6.4 | MIT | https://github.com/madsmtm/objc2 | +| objc2-core-foundation | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 | +| objc2-core-wlan | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 | +| objc2-encode | 4.1.0 | MIT | https://github.com/madsmtm/objc2 | +| objc2-foundation | 0.3.2 | MIT | https://github.com/madsmtm/objc2 | +| objc2-security | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 | +| objc2-security-foundation | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 | +| objc2-system-configuration | 0.3.2 | Zlib OR Apache-2.0 OR MIT | https://github.com/madsmtm/objc2 | +| oid-registry | 0.8.1 | MIT OR Apache-2.0 | https://github.com/rusticata/oid-registry.git | +| once_cell | 1.21.3 | MIT OR Apache-2.0 | https://github.com/matklad/once_cell | +| opaque-debug | 0.3.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/utils | +| openssl-probe | 0.2.1 | MIT OR Apache-2.0 | https://github.com/rustls/openssl-probe | +| openssl-sys | 0.9.117 | MIT | https://github.com/rust-openssl/rust-openssl | +| papaya | 0.2.4 | MIT | https://github.com/ibraheemdev/papaya | +| parking | 2.2.1 | Apache-2.0 OR MIT | https://github.com/smol-rs/parking | +| parking_lot | 0.11.2 | Apache-2.0/MIT | https://github.com/Amanieu/parking_lot | +| parking_lot | 0.12.5 | MIT OR Apache-2.0 | https://github.com/Amanieu/parking_lot | +| parking_lot_core | 0.8.6 | Apache-2.0/MIT | https://github.com/Amanieu/parking_lot | +| parking_lot_core | 0.9.12 | MIT OR Apache-2.0 | https://github.com/Amanieu/parking_lot | +| password-hash | 0.5.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits/tree/master/password-hash | +| paste | 1.0.15 | MIT OR Apache-2.0 | https://github.com/dtolnay/paste | +| pbkdf2 | 0.12.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/password-hashes/tree/master/pbkdf2 | +| pem | 3.0.6 | MIT | https://github.com/jcreekmore/pem-rs.git | +| pem-rfc7468 | 1.0.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats | +| percent-encoding | 2.3.2 | MIT OR Apache-2.0 | https://github.com/servo/rust-url/ | +| pharos | 0.5.3 | Unlicense | https://github.com/najamelan/pharos | +| pin-project | 1.1.13 | Apache-2.0 OR MIT | https://github.com/taiki-e/pin-project | +| pin-project-internal | 1.1.13 | Apache-2.0 OR MIT | https://github.com/taiki-e/pin-project | +| pin-project-lite | 0.2.16 | Apache-2.0 OR MIT | https://github.com/taiki-e/pin-project-lite | +| pin-utils | 0.1.0 | MIT OR Apache-2.0 | https://github.com/rust-lang-nursery/pin-utils | +| pkcs8 | 0.10.2 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats/tree/master/pkcs8 | +| pkcs8 | 0.11.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats | +| pkg-config | 0.3.33 | MIT OR Apache-2.0 | https://github.com/rust-lang/pkg-config-rs | +| plain | 0.2.3 | MIT/Apache-2.0 | https://github.com/randomites/plain | +| plist | 1.9.0 | MIT | https://github.com/ebarnard/rust-plist/ | +| poly1305 | 0.8.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/universal-hashes | +| polyval | 0.6.2 | Apache-2.0 OR MIT | https://github.com/RustCrypto/universal-hashes | +| portable-atomic | 1.13.1 | Apache-2.0 OR MIT | https://github.com/taiki-e/portable-atomic | +| portmapper | 0.19.0 | MIT OR Apache-2.0 | https://github.com/n0-computer/net-tools | +| positioned-io | 0.3.5 | MIT | https://github.com/vasi/positioned-io | +| postcard | 1.1.3 | MIT OR Apache-2.0 | https://github.com/jamesmunns/postcard | +| postcard-derive | 0.2.2 | MIT OR Apache-2.0 | https://github.com/jamesmunns/postcard | +| potential_utf | 0.1.4 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| powerfmt | 0.2.0 | MIT OR Apache-2.0 | https://github.com/jhpratt/powerfmt | +| ppv-lite86 | 0.2.21 | MIT OR Apache-2.0 | https://github.com/cryptocorrosion/cryptocorrosion | +| prefix-trie | 0.8.4 | MIT OR Apache-2.0 | https://github.com/tiborschneider/prefix-trie | +| prettyplease | 0.2.37 | MIT OR Apache-2.0 | https://github.com/dtolnay/prettyplease | +| proc-macro-crate | 3.5.0 | MIT OR Apache-2.0 | https://github.com/bkchr/proc-macro-crate | +| proc-macro-error | 0.4.12 | MIT OR Apache-2.0 | https://gitlab.com/CreepySkeleton/proc-macro-error | +| proc-macro-error-attr | 0.4.12 | MIT OR Apache-2.0 | https://gitlab.com/CreepySkeleton/proc-macro-error | +| proc-macro-hack | 0.5.20+deprecated | MIT OR Apache-2.0 | https://github.com/dtolnay/proc-macro-hack | +| proc-macro2 | 1.0.106 | MIT OR Apache-2.0 | https://github.com/dtolnay/proc-macro2 | +| pxfm | 0.1.28 | BSD-3-Clause OR Apache-2.0 | https://github.com/awxkee/pxfm | +| qrcode | 0.14.1 | MIT OR Apache-2.0 | https://github.com/kennytm/qrcode-rust | +| quick-xml | 0.39.4 | MIT | https://github.com/tafia/quick-xml | +| quote | 1.0.44 | MIT OR Apache-2.0 | https://github.com/dtolnay/quote | +| r-efi | 5.3.0 | MIT OR Apache-2.0 OR LGPL-2.1-or-later | https://github.com/r-efi/r-efi | +| r-efi | 6.0.0 | MIT OR Apache-2.0 OR LGPL-2.1-or-later | https://github.com/r-efi/r-efi | +| rand | 0.10.1 | MIT OR Apache-2.0 | https://github.com/rust-random/rand | +| rand | 0.8.5 | MIT OR Apache-2.0 | https://github.com/rust-random/rand | +| rand | 0.9.2 | MIT OR Apache-2.0 | https://github.com/rust-random/rand | +| rand_chacha | 0.3.1 | MIT OR Apache-2.0 | https://github.com/rust-random/rand | +| rand_chacha | 0.9.0 | MIT OR Apache-2.0 | https://github.com/rust-random/rand | +| rand_core | 0.10.1 | MIT OR Apache-2.0 | https://github.com/rust-random/rand_core | +| rand_core | 0.6.4 | MIT OR Apache-2.0 | https://github.com/rust-random/rand | +| rand_core | 0.9.5 | MIT OR Apache-2.0 | https://github.com/rust-random/rand | +| rand_pcg | 0.10.2 | MIT OR Apache-2.0 | https://github.com/rust-random/rngs | +| range-collections | 0.4.6 | MIT OR Apache-2.0 | https://github.com/rklaehn/range-collections | +| rcgen | 0.14.8 | MIT OR Apache-2.0 | https://github.com/rustls/rcgen | +| redb | 4.1.0 | MIT OR Apache-2.0 | https://github.com/cberner/redb | +| redox_syscall | 0.2.16 | MIT | https://gitlab.redox-os.org/redox-os/syscall | +| redox_syscall | 0.5.18 | MIT | https://gitlab.redox-os.org/redox-os/syscall | +| redox_syscall | 0.7.3 | MIT | https://gitlab.redox-os.org/redox-os/syscall | +| reed-solomon-erasure | 6.0.0 | MIT | https://github.com/darrenldl/reed-solomon-erasure | +| ref-cast | 1.0.25 | MIT OR Apache-2.0 | https://github.com/dtolnay/ref-cast | +| ref-cast-impl | 1.0.25 | MIT OR Apache-2.0 | https://github.com/dtolnay/ref-cast | +| reflink-copy | 0.1.29 | MIT/Apache-2.0 | https://github.com/cargo-bins/reflink-copy | +| regex | 1.12.2 | MIT OR Apache-2.0 | https://github.com/rust-lang/regex | +| regex-automata | 0.4.13 | MIT OR Apache-2.0 | https://github.com/rust-lang/regex | +| regex-syntax | 0.8.8 | MIT OR Apache-2.0 | https://github.com/rust-lang/regex | +| reqwest | 0.11.27 | MIT OR Apache-2.0 | https://github.com/seanmonstar/reqwest | +| reqwest | 0.13.4 | MIT OR Apache-2.0 | https://github.com/seanmonstar/reqwest | +| resolv-conf | 0.7.6 | MIT OR Apache-2.0 | https://github.com/hickory-dns/resolv-conf | +| ring | 0.17.14 | Apache-2.0 AND ISC | https://github.com/briansmith/ring | +| rustc-hash | 2.1.2 | Apache-2.0 OR MIT | https://github.com/rust-lang/rustc-hash | +| rustc_version | 0.4.1 | MIT OR Apache-2.0 | https://github.com/djc/rustc-version-rs | +| rusticata-macros | 4.1.0 | MIT/Apache-2.0 | https://github.com/rusticata/rusticata-macros.git | +| rustix | 1.1.3 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/rustix | +| rustls | 0.21.12 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/rustls | +| rustls | 0.23.36 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/rustls | +| rustls-native-certs | 0.8.4 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/rustls-native-certs | +| rustls-pemfile | 1.0.4 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/pemfile | +| rustls-pki-types | 1.14.0 | MIT OR Apache-2.0 | https://github.com/rustls/pki-types | +| rustls-platform-verifier | 0.7.0 | MIT OR Apache-2.0 | https://github.com/rustls/rustls-platform-verifier | +| rustls-platform-verifier-android | 0.1.1 | MIT OR Apache-2.0 | https://github.com/rustls/rustls-platform-verifier | +| rustls-webpki | 0.101.7 | ISC | https://github.com/rustls/webpki | +| rustls-webpki | 0.103.9 | ISC | https://github.com/rustls/webpki | +| rustversion | 1.0.22 | MIT OR Apache-2.0 | https://github.com/dtolnay/rustversion | +| ryu | 1.0.22 | Apache-2.0 OR BSL-1.0 | https://github.com/dtolnay/ryu | +| salsa20 | 0.10.2 | MIT OR Apache-2.0 | https://github.com/RustCrypto/stream-ciphers | +| same-file | 1.0.6 | Unlicense/MIT | https://github.com/BurntSushi/same-file | +| schannel | 0.1.29 | MIT | https://github.com/steffengy/schannel-rs | +| scoped-tls | 1.0.1 | MIT/Apache-2.0 | https://github.com/alexcrichton/scoped-tls | +| scopeguard | 1.2.0 | MIT OR Apache-2.0 | https://github.com/bluss/scopeguard | +| scrypt | 0.11.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/password-hashes/tree/master/scrypt | +| sct | 0.7.1 | Apache-2.0 OR ISC OR MIT | https://github.com/rustls/sct.rs | +| sd-notify | 0.4.5 | MIT OR Apache-2.0 | https://github.com/lnicola/sd-notify | +| secp256k1 | 0.29.1 | CC0-1.0 | https://github.com/rust-bitcoin/rust-secp256k1/ | +| secp256k1-sys | 0.10.1 | CC0-1.0 | https://github.com/rust-bitcoin/rust-secp256k1/ | +| security-framework | 3.7.0 | MIT OR Apache-2.0 | https://github.com/kornelski/rust-security-framework | +| security-framework-sys | 2.17.0 | MIT OR Apache-2.0 | https://github.com/kornelski/rust-security-framework | +| seize | 0.5.1 | MIT | https://github.com/ibraheemdev/seize | +| self_cell | 1.2.2 | Apache-2.0 OR GPL-2.0-only | https://github.com/Voultapher/self_cell | +| semver | 1.0.27 | MIT OR Apache-2.0 | https://github.com/dtolnay/semver | +| send_wrapper | 0.6.0 | MIT/Apache-2.0 | https://github.com/thk1/send_wrapper | +| serde | 1.0.228 | MIT OR Apache-2.0 | https://github.com/serde-rs/serde | +| serde_bencode | 0.2.4 | MIT | https://github.com/toby/serde-bencode | +| serde_bytes | 0.11.19 | MIT OR Apache-2.0 | https://github.com/serde-rs/bytes | +| serde_core | 1.0.228 | MIT OR Apache-2.0 | https://github.com/serde-rs/serde | +| serde_derive | 1.0.228 | MIT OR Apache-2.0 | https://github.com/serde-rs/serde | +| serde_json | 1.0.149 | MIT OR Apache-2.0 | https://github.com/serde-rs/json | +| serde_spanned | 0.6.9 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml | +| serde_urlencoded | 0.7.1 | MIT/Apache-2.0 | https://github.com/nox/serde_urlencoded | +| serde_yaml | 0.9.34+deprecated | MIT OR Apache-2.0 | https://github.com/dtolnay/serde-yaml | +| serdect | 0.4.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats | +| serial2 | 0.2.34 | BSD-2-Clause OR Apache-2.0 | https://github.com/de-vri-es/serial2-rs | +| serial2-tokio | 0.1.21 | BSD-2-Clause OR Apache-2.0 | https://github.com/de-vri-es/serial2-tokio-rs | +| sha-1 | 0.10.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes | +| sha1 | 0.10.6 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes | +| sha1_smol | 1.0.1 | BSD-3-Clause | https://github.com/mitsuhiko/sha1-smol | +| sha2 | 0.10.9 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes | +| sha2 | 0.11.0 | MIT OR Apache-2.0 | https://github.com/RustCrypto/hashes | +| sharded-slab | 0.1.7 | MIT | https://github.com/hawkw/sharded-slab | +| shlex | 1.3.0 | MIT OR Apache-2.0 | https://github.com/comex/rust-shlex | +| signal-hook-registry | 1.4.8 | MIT OR Apache-2.0 | https://github.com/vorner/signal-hook | +| signature | 2.2.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/traits/tree/master/signature | +| signature | 3.0.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/traits | +| simd-adler32 | 0.3.8 | MIT | https://github.com/mcountryman/simd-adler32 | +| simd_cesu8 | 1.1.1 | Apache-2.0 OR MIT | https://github.com/seancroach/simd_cesu8 | +| simdutf8 | 0.1.5 | MIT OR Apache-2.0 | https://github.com/rusticstuff/simdutf8 | +| simple-dns | 0.11.3 | MIT | https://github.com/balliegojr/simple-dns | +| siphasher | 1.0.3 | MIT/Apache-2.0 | https://github.com/jedisct1/rust-siphash | +| slab | 0.4.11 | MIT | https://github.com/tokio-rs/slab | +| smallvec | 1.15.1 | MIT OR Apache-2.0 | https://github.com/servo/rust-smallvec | +| socket-pktinfo | 0.3.2 | MIT | https://github.com/pixsper/socket-pktinfo | +| socket2 | 0.5.10 | MIT OR Apache-2.0 | https://github.com/rust-lang/socket2 | +| socket2 | 0.6.2 | MIT OR Apache-2.0 | https://github.com/rust-lang/socket2 | +| sorted-index-buffer | 0.2.1 | MIT OR Apache-2.0 | https://github.com/n0-computer/iroh | +| spez | 0.1.2 | BSD-2-Clause | https://github.com/m-ou-se/spez | +| spin | 0.10.0 | MIT | https://github.com/mvdnes/spin-rs.git | +| spin | 0.9.8 | MIT | https://github.com/mvdnes/spin-rs.git | +| spki | 0.7.3 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats/tree/master/spki | +| spki | 0.8.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/formats | +| ssh2 | 0.9.5 | MIT OR Apache-2.0 | https://github.com/alexcrichton/ssh2-rs | +| stable_deref_trait | 1.2.1 | MIT OR Apache-2.0 | https://github.com/storyyeller/stable_deref_trait | +| strsim | 0.11.1 | MIT | https://github.com/rapidfuzz/strsim-rs | +| strum | 0.28.0 | MIT | https://github.com/Peternator7/strum | +| strum_macros | 0.28.0 | MIT | https://github.com/Peternator7/strum | +| subtle | 2.6.1 | BSD-3-Clause | https://github.com/dalek-cryptography/subtle | +| syn | 1.0.109 | MIT OR Apache-2.0 | https://github.com/dtolnay/syn | +| syn | 2.0.114 | MIT OR Apache-2.0 | https://github.com/dtolnay/syn | +| syn-mid | 0.5.4 | Apache-2.0 OR MIT | https://github.com/taiki-e/syn-mid | +| sync_wrapper | 0.1.2 | Apache-2.0 | https://github.com/Actyx/sync_wrapper | +| sync_wrapper | 1.0.2 | Apache-2.0 | https://github.com/Actyx/sync_wrapper | +| synstructure | 0.13.2 | MIT | https://github.com/mystor/synstructure | +| system-configuration | 0.5.1 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs | +| system-configuration | 0.6.1 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs | +| system-configuration | 0.7.0 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs | +| system-configuration-sys | 0.5.0 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs | +| system-configuration-sys | 0.6.0 | MIT OR Apache-2.0 | https://github.com/mullvad/system-configuration-rs | +| tagptr | 0.2.0 | MIT/Apache-2.0 | https://github.com/oliver-giersch/tagptr.git | +| tar | 0.4.44 | MIT OR Apache-2.0 | https://github.com/alexcrichton/tar-rs | +| tempfile | 3.24.0 | MIT OR Apache-2.0 | https://github.com/Stebalien/tempfile | +| termcolor | 1.4.1 | Unlicense OR MIT | https://github.com/BurntSushi/termcolor | +| thiserror | 1.0.69 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror | +| thiserror | 2.0.18 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror | +| thiserror-impl | 1.0.69 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror | +| thiserror-impl | 2.0.18 | MIT OR Apache-2.0 | https://github.com/dtolnay/thiserror | +| thread_local | 1.1.9 | MIT OR Apache-2.0 | https://github.com/Amanieu/thread_local-rs | +| time | 0.3.49 | MIT OR Apache-2.0 | https://github.com/time-rs/time | +| time-core | 0.1.9 | MIT OR Apache-2.0 | https://github.com/time-rs/time | +| time-macros | 0.2.29 | MIT OR Apache-2.0 | https://github.com/time-rs/time | +| tinystr | 0.8.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| tinyvec | 1.10.0 | Zlib OR Apache-2.0 OR MIT | https://github.com/Lokathor/tinyvec | +| tinyvec_macros | 0.1.1 | MIT OR Apache-2.0 OR Zlib | https://github.com/Soveu/tinyvec_macros | +| tokio | 1.49.0 | MIT | https://github.com/tokio-rs/tokio | +| tokio-macros | 2.6.0 | MIT | https://github.com/tokio-rs/tokio | +| tokio-rustls | 0.24.1 | MIT/Apache-2.0 | https://github.com/rustls/tokio-rustls | +| tokio-rustls | 0.26.4 | MIT OR Apache-2.0 | https://github.com/rustls/tokio-rustls | +| tokio-socks | 0.5.2 | MIT | https://github.com/sticnarf/tokio-socks | +| tokio-stream | 0.1.18 | MIT | https://github.com/tokio-rs/tokio | +| tokio-test | 0.4.5 | MIT | https://github.com/tokio-rs/tokio | +| tokio-tungstenite | 0.20.1 | MIT | https://github.com/snapview/tokio-tungstenite | +| tokio-tungstenite | 0.26.2 | MIT | https://github.com/snapview/tokio-tungstenite | +| tokio-util | 0.7.18 | MIT | https://github.com/tokio-rs/tokio | +| tokio-websockets | 0.13.2 | MIT | https://github.com/Gelbpunkt/tokio-websockets/ | +| toml | 0.8.23 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml | +| toml_datetime | 0.6.11 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml | +| toml_datetime | 1.1.1+spec-1.1.0 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml | +| toml_edit | 0.22.27 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml | +| toml_edit | 0.25.12+spec-1.1.0 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml | +| toml_parser | 1.1.2+spec-1.1.0 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml | +| toml_write | 0.1.2 | MIT OR Apache-2.0 | https://github.com/toml-rs/toml | +| totp-rs | 5.7.0 | MIT | https://github.com/constantoine/totp-rs | +| tower | 0.5.3 | MIT | https://github.com/tower-rs/tower | +| tower-http | 0.6.8 | MIT | https://github.com/tower-rs/tower-http | +| tower-layer | 0.3.3 | MIT | https://github.com/tower-rs/tower | +| tower-service | 0.3.3 | MIT | https://github.com/tower-rs/tower | +| tracing | 0.1.44 | MIT | https://github.com/tokio-rs/tracing | +| tracing-attributes | 0.1.31 | MIT | https://github.com/tokio-rs/tracing | +| tracing-core | 0.1.36 | MIT | https://github.com/tokio-rs/tracing | +| tracing-log | 0.2.0 | MIT | https://github.com/tokio-rs/tracing | +| tracing-subscriber | 0.3.22 | MIT | https://github.com/tokio-rs/tracing | +| try-lock | 0.2.5 | MIT | https://github.com/seanmonstar/try-lock | +| tungstenite | 0.20.1 | MIT OR Apache-2.0 | https://github.com/snapview/tungstenite-rs | +| tungstenite | 0.26.2 | MIT OR Apache-2.0 | https://github.com/snapview/tungstenite-rs | +| typenum | 1.20.1 | MIT OR Apache-2.0 | https://github.com/paholg/typenum | +| unicode-ident | 1.0.22 | (MIT OR Apache-2.0) AND Unicode-3.0 | https://github.com/dtolnay/unicode-ident | +| unicode-normalization | 0.1.22 | MIT/Apache-2.0 | https://github.com/unicode-rs/unicode-normalization | +| unicode-segmentation | 1.13.3 | MIT OR Apache-2.0 | https://github.com/unicode-rs/unicode-segmentation | +| unicode-xid | 0.2.6 | MIT OR Apache-2.0 | https://github.com/unicode-rs/unicode-xid | +| universal-hash | 0.5.1 | MIT OR Apache-2.0 | https://github.com/RustCrypto/traits | +| unsafe-libyaml | 0.2.11 | MIT | https://github.com/dtolnay/unsafe-libyaml | +| untrusted | 0.9.0 | ISC | https://github.com/briansmith/untrusted | +| url | 2.5.8 | MIT OR Apache-2.0 | https://github.com/servo/rust-url | +| urlencoding | 2.1.3 | MIT | https://github.com/kornelski/rust_urlencoding | +| utf-8 | 0.7.6 | MIT OR Apache-2.0 | https://github.com/SimonSapin/rust-utf8 | +| utf8_iter | 1.0.4 | Apache-2.0 OR MIT | https://github.com/hsivonen/utf8_iter | +| uuid | 1.19.0 | Apache-2.0 OR MIT | https://github.com/uuid-rs/uuid | +| valuable | 0.1.1 | MIT | https://github.com/tokio-rs/valuable | +| vcpkg | 0.2.15 | MIT/Apache-2.0 | https://github.com/mcgoo/vcpkg-rs | +| vergen | 9.1.0 | MIT OR Apache-2.0 | https://github.com/rustyhorde/vergen | +| vergen-gitcl | 9.1.0 | MIT OR Apache-2.0 | https://github.com/rustyhorde/vergen | +| vergen-lib | 9.1.0 | MIT OR Apache-2.0 | https://github.com/rustyhorde/vergen | +| version_check | 0.9.5 | MIT/Apache-2.0 | https://github.com/SergioBenitez/version_check | +| walkdir | 2.5.0 | Unlicense/MIT | https://github.com/BurntSushi/walkdir | +| want | 0.3.1 | MIT | https://github.com/seanmonstar/want | +| wasi | 0.11.1+wasi-snapshot-preview1 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasi | +| wasip2 | 1.0.2+wasi-0.2.9 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasi-rs | +| wasip3 | 0.4.0+wasi-0.3.0-rc-2026-01-06 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasi-rs | +| wasm-bindgen | 0.2.108 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen | +| wasm-bindgen-futures | 0.4.58 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/futures | +| wasm-bindgen-macro | 0.2.108 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/macro | +| wasm-bindgen-macro-support | 0.2.108 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/macro-support | +| wasm-bindgen-shared | 0.2.108 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/shared | +| wasm-encoder | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasm-encoder | +| wasm-metadata | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasm-metadata | +| wasm-streams | 0.4.2 | MIT OR Apache-2.0 | https://github.com/MattiasBuelens/wasm-streams/ | +| wasm-streams | 0.5.0 | MIT OR Apache-2.0 | https://github.com/MattiasBuelens/wasm-streams/ | +| wasmparser | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasmparser | +| web-sys | 0.3.85 | MIT OR Apache-2.0 | https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/web-sys | +| web-time | 1.1.0 | MIT OR Apache-2.0 | https://github.com/daxpedda/web-time | +| webpki-root-certs | 1.0.7 | CDLA-Permissive-2.0 | https://github.com/rustls/webpki-roots | +| webpki-roots | 0.25.4 | MPL-2.0 | https://github.com/rustls/webpki-roots | +| webpki-roots | 0.26.11 | CDLA-Permissive-2.0 | https://github.com/rustls/webpki-roots | +| webpki-roots | 1.0.6 | CDLA-Permissive-2.0 | https://github.com/rustls/webpki-roots | +| widestring | 1.2.1 | MIT OR Apache-2.0 | https://github.com/VoidStarKat/widestring-rs | +| winapi | 0.3.9 | MIT/Apache-2.0 | https://github.com/retep998/winapi-rs | +| winapi-i686-pc-windows-gnu | 0.4.0 | MIT/Apache-2.0 | https://github.com/retep998/winapi-rs | +| winapi-util | 0.1.11 | Unlicense OR MIT | https://github.com/BurntSushi/winapi-util | +| winapi-x86_64-pc-windows-gnu | 0.4.0 | MIT/Apache-2.0 | https://github.com/retep998/winapi-rs | +| windows | 0.62.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows-collections | 0.3.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows-core | 0.62.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows-future | 0.3.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows-implement | 0.60.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows-interface | 0.59.3 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows-link | 0.2.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows-numerics | 0.3.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows-registry | 0.6.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows-result | 0.4.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows-strings | 0.5.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows-sys | 0.45.0 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows-sys | 0.48.0 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows-sys | 0.52.0 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows-sys | 0.60.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows-sys | 0.61.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows-targets | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows-targets | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows-targets | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows-targets | 0.53.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows-threading | 0.2.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_aarch64_gnullvm | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_aarch64_gnullvm | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_aarch64_gnullvm | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_aarch64_gnullvm | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_aarch64_msvc | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_aarch64_msvc | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_aarch64_msvc | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_aarch64_msvc | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_i686_gnu | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_i686_gnu | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_i686_gnu | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_i686_gnu | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_i686_gnullvm | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_i686_gnullvm | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_i686_msvc | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_i686_msvc | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_i686_msvc | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_i686_msvc | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_x86_64_gnu | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_x86_64_gnu | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_x86_64_gnu | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_x86_64_gnu | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_x86_64_gnullvm | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_x86_64_gnullvm | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_x86_64_gnullvm | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_x86_64_gnullvm | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_x86_64_msvc | 0.42.2 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_x86_64_msvc | 0.48.5 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_x86_64_msvc | 0.52.6 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| windows_x86_64_msvc | 0.53.1 | MIT OR Apache-2.0 | https://github.com/microsoft/windows-rs | +| winnow | 0.7.14 | MIT | https://github.com/winnow-rs/winnow | +| winnow | 1.0.3 | MIT | https://github.com/winnow-rs/winnow | +| winreg | 0.50.0 | MIT | https://github.com/gentoo90/winreg-rs | +| wit-bindgen | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wit-bindgen | +| wit-bindgen-core | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wit-bindgen | +| wit-bindgen-rust | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wit-bindgen | +| wit-bindgen-rust-macro | 0.51.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wit-bindgen | +| wit-component | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wit-component | +| wit-parser | 0.244.0 | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT | https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wit-parser | +| wmi | 0.18.4 | MIT OR Apache-2.0 | https://github.com/ohadravid/wmi-rs | +| writeable | 0.6.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| ws_stream_wasm | 0.7.5 | Unlicense | https://github.com/najamelan/ws_stream_wasm | +| x509-parser | 0.18.1 | MIT OR Apache-2.0 | https://github.com/rusticata/x509-parser.git | +| xattr | 1.6.1 | MIT OR Apache-2.0 | https://github.com/Stebalien/xattr | +| xml-rs | 0.8.28 | MIT | https://github.com/kornelski/xml-rs | +| xmltree | 0.10.3 | MIT | https://github.com/eminence/xmltree-rs | +| yasna | 0.6.0 | MIT OR Apache-2.0 | https://github.com/qnighy/yasna.rs | +| yoke | 0.8.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| yoke-derive | 0.8.1 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| zbase32 | 0.1.2 | LGPL-3.0+ | https://gitlab.com/pgerber/zbase32-rust | +| zerocopy | 0.8.33 | BSD-2-Clause OR Apache-2.0 OR MIT | https://github.com/google/zerocopy | +| zerocopy-derive | 0.8.33 | BSD-2-Clause OR Apache-2.0 OR MIT | https://github.com/google/zerocopy | +| zerofrom | 0.1.6 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| zerofrom-derive | 0.1.6 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| zeroize | 1.9.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/utils | +| zeroize_derive | 1.5.0 | Apache-2.0 OR MIT | https://github.com/RustCrypto/utils | +| zerotrie | 0.2.3 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| zerovec | 0.11.5 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| zerovec-derive | 0.11.2 | Unicode-3.0 | https://github.com/unicode-org/icu4x | +| zmij | 1.0.16 | MIT | https://github.com/dtolnay/zmij | diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml new file mode 100644 index 00000000..4760f754 --- /dev/null +++ b/core/archipelago/Cargo.toml @@ -0,0 +1,145 @@ +[package] +name = "archipelago" +version = "1.7.125-alpha" +edition = "2021" +description = "Archipelago Bitcoin Node OS - Native backend" +authors = ["Archipelago Team"] + +[[bin]] +name = "archipelago" +path = "src/main.rs" + +[features] +default = [] +# DHT Phase 2: iroh-blobs peer swarm engine. OFF by default — it pulls a heavy +# QUIC dependency tree, so it ships behind a flag for PoC/measurement on a +# scratch node before any fleet rollout. With the flag off, swarm::providers() +# is empty and every fetch goes straight to the origin HTTP path (today's +# behaviour). Attach the optional iroh / iroh-blobs deps to this feature when +# wiring the IrohProvider. +iroh-swarm = ["dep:iroh", "dep:iroh-blobs"] + +[dependencies] +# Core dependencies +tokio = { version = "1", features = ["full"] } +# Mesh port mirror: needs IPV6_V6ONLY on [::] listeners so they coexist with +# the containers' own 0.0.0.0 binds (std/tokio don't expose the sockopt). +socket2 = "0.5" +libc = "0.2" # process-group signalling for the supervised reticulum daemon +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +anyhow = "1.0" +thiserror = "1.0" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# HTTP and WebSocket +hyper = { version = "0.14", features = ["full", "http1"] } +hyper-util = { version = "0.1", features = ["full", "http1"] } +http-body-util = "0.1" +http-body = "1.0" +tower = "0.5" +tower-http = { version = "0.6", features = ["cors", "trace"] } +hyper-ws-listener = "0.3.0" +tokio-tungstenite = "0.20" +futures-util = "0.3" + +# Our modules +archipelago-container = { path = "../container" } +archipelago-openwrt = { path = "../openwrt" } +archipelago-security = { path = "../security" } +archipelago-performance = { path = "../performance" } + + +# Database (optional for now - can use SQLite or skip) +# sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-rustls"] } + +# Authentication +bcrypt = "0.15" +sha2 = "0.10.9" +blake3 = "1" +hmac = "0.12.1" +uuid = { version = "1.0", features = ["v4"] } +regex = "1.10" + +# Node identity (Ed25519 + X25519 key agreement) +ed25519-dalek = { version = "2.2.0", features = ["rand_core"] } +curve25519-dalek = "4.1.3" +rand = "0.8.5" +hex = "0.4" +bs58 = "0.5" +chrono = "0.4" + +# BIP-39 mnemonic seed generation + BIP-32 HD key derivation +bip39 = { version = "=2.1.0", features = ["rand"] } +bitcoin = { version = "=0.32.5", features = ["rand-std"] } + +# Configuration +toml = "0.8" +serde_yaml = "0.9" + +# HTTP client (for LND REST proxy, Tor SOCKS for peer messaging) +# Uses rustls-tls for cross-compilation (no OpenSSL dependency) +# App-gate TLS. Pinned to the rustls 0.21 line that reqwest already resolves, +# so this adds no new vendor and no second rustls major to the tree. +tokio-rustls = "0.24" +rustls-pemfile = "1.0" +# Verifying that the gate's key actually pairs with its certificate; rustls +# does not check this itself. Same version rustls 0.21 already resolves. +webpki = { package = "rustls-webpki", version = "0.101" } +reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls", "stream"] } + +# Nostr (node discovery + NIP-44 encrypted peer handshake) +nostr-sdk = { version = "0.44", features = ["nip04", "nip44"] } + +# Backup encryption (DID identity export) + TOTP 2FA encryption +argon2 = "0.5.3" +chacha20poly1305 = "0.10.1" +base64 = "0.21" + +# Full system backup (tar archive + gzip compression) +tar = "0.4" +flate2 = "1.0" + +# TOTP 2FA +totp-rs = { version = "5.7", features = ["otpauth", "gen_secret"] } +qrcode = "0.14" +data-encoding = "2.6" +zeroize = { version = "1.8.2", features = ["derive"] } + +# Mainline DHT (did:dht — BitTorrent DHT for decentralized identity) +mainline = "2" +zbase32 = "0.1" +bytes = "1" + +# Mesh networking (Meshcore serial protocol over USB LoRa radios) +serial2-tokio = "0.1" + +# LoRa radio firmware flashing: Meshtastic ships per-board images inside a +# per-platform release zip (see mesh/flash.rs). +zip = { version = "2", default-features = false, features = ["deflate"] } + +# Double Ratchet key derivation (Phase 3: encrypted mesh messaging) +hkdf = "0.12.4" + +# Transport abstraction (Phase 2: mesh as federation transport) +ciborium = "0.2.2" +serde_bytes = "0.11" +reed-solomon-erasure = "6.0" +mdns-sd = "0.18" + +# Systemd watchdog notification +sd-notify = "0.4" + +# Trait objects for async methods (container orchestrator trait, Step 4) +async-trait = "0.1" + +# DHT Phase 2: iroh-blobs peer swarm engine. OPTIONAL — only pulled in by the +# `iroh-swarm` feature (off by default). Heavy QUIC dep tree; kept behind the +# flag so the default fleet build is unaffected until the PoC is measured. +iroh = { version = "1", optional = true } +iroh-blobs = { version = "0.103", optional = true } + +[dev-dependencies] +tokio-test = "0.4" +tempfile = "3.10" diff --git a/core/archipelago/src/api/handler/blob.rs b/core/archipelago/src/api/handler/blob.rs new file mode 100644 index 00000000..23b72677 --- /dev/null +++ b/core/archipelago/src/api/handler/blob.rs @@ -0,0 +1,234 @@ +//! HTTP handlers for the content-addressed blob store. +//! +//! - `POST /api/blob` — session-authenticated. Raw body is the blob; +//! headers set mime/filename. Returns `{cid, size, mime}`. +//! - `GET /blob/?cap=&exp=&peer=` — peer-facing. +//! Capability verified against the stored HMAC key; bytes streamed back. + +use super::{build_response, ApiHandler}; +use crate::blobs::BlobStore; +use anyhow::Result; +use hyper::{Body, HeaderMap, Response, StatusCode}; +use std::path::Path; +use std::sync::Arc; + +/// Read the archipelago .onion address if Tor has published one, so uploads +/// that need to be publicly reachable (profile pictures, banners) can return +/// a URL a peer outside the LAN can actually fetch. Returns `None` before +/// onboarding or when Tor isn't running — callers fall back to the local +/// self-test URL. +async fn read_self_onion(data_dir: &Path) -> Option { + let hostnames = data_dir.join("tor-hostnames").join("archipelago"); + let legacy = Path::new("/var/lib/archipelago/tor-hostnames/archipelago"); + for p in [hostnames.as_path(), legacy] { + if let Ok(s) = tokio::fs::read_to_string(p).await { + let trimmed = s.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + } + None +} + +impl ApiHandler { + pub(super) async fn handle_blob_upload( + store: &Arc, + self_pubkey_hex: &str, + data_dir: &Path, + headers: &HeaderMap, + body: hyper::body::Bytes, + ) -> Result> { + let mime = headers + .get("x-blob-mime") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream") + .to_string(); + let filename = headers + .get("x-blob-filename") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + // Optional caller-supplied thumbnail (small, base64) — e.g. the mesh + // chat's image-quality picker generates a tiny client-side preview so + // a ContentRef receiver can render something before fetching the full + // blob. Best-effort: a malformed header is just ignored, not fatal. + let thumb_bytes = headers + .get("x-blob-thumb") + .and_then(|v| v.to_str().ok()) + .and_then(|b64| { + use base64::{engine::general_purpose::STANDARD, Engine as _}; + STANDARD.decode(b64).ok() + }); + + let bytes = body.to_vec(); + // Uploads through /api/blob come from the node owner's session and + // are almost always intended for external consumption (profile + // pictures, banners). Store them public so `/blob/` serves + // without a capability check — external Nostr clients fetching a + // kind-0 `picture` URL have no cap and can't get one. + match store.put(&bytes, &mime, filename, thumb_bytes, true).await { + Ok(meta) => { + let exp = + (chrono::Utc::now().timestamp() as u64) + crate::blobs::DEFAULT_CAP_TTL_SECS; + let cap = store.issue_capability(&meta.cid, self_pubkey_hex, exp); + let self_test_url = format!( + "/blob/{}?cap={}&exp={}&peer={}", + meta.cid, cap, exp, self_pubkey_hex + ); + let public_url = match read_self_onion(data_dir).await { + Some(onion) => format!("http://{}/blob/{}", onion, meta.cid), + // Pre-onboarding / Tor-not-up: surface the local path so + // the UI doesn't break; publishing to Nostr should wait + // until Tor is live anyway. + None => format!("/blob/{}", meta.cid), + }; + let resp = serde_json::json!({ + "cid": meta.cid, + "size": meta.size, + "mime": meta.mime, + "filename": meta.filename, + "public_url": public_url, + "self_test_url": self_test_url, + }); + Ok(build_response( + StatusCode::OK, + "application/json", + Body::from(serde_json::to_vec(&resp).unwrap_or_default()), + )) + } + Err(e) => Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + Body::from(format!("blob upload failed: {}", e)), + )), + } + } + + /// Share-to-mesh iframe intent. Mirrors `handle_blob_upload` but adds + /// CORS headers for the requesting app origin and returns a small JSON + /// payload the app forwards to its parent via postMessage: + /// `{ type: "share-to-mesh", cid, size, mime, filename }`. + pub(super) async fn handle_share_to_mesh( + store: &Arc, + self_pubkey_hex: &str, + headers: &HeaderMap, + body: hyper::body::Bytes, + origin: &str, + ) -> Result> { + let mime = headers + .get("x-blob-mime") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream") + .to_string(); + let filename = headers + .get("x-blob-filename") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + let bytes = body.to_vec(); + let meta = match store.put(&bytes, &mime, filename, None, false).await { + Ok(m) => m, + Err(e) => { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + Body::from(format!("share-to-mesh failed: {}", e)), + )); + } + }; + // Self-signed capability so the app can preview/download its own + // upload before the user has picked a peer. + let exp = (chrono::Utc::now().timestamp() as u64) + crate::blobs::DEFAULT_CAP_TTL_SECS; + let cap = store.issue_capability(&meta.cid, self_pubkey_hex, exp); + let self_url = format!( + "/blob/{}?cap={}&exp={}&peer={}", + meta.cid, cap, exp, self_pubkey_hex + ); + let resp = serde_json::json!({ + "type": "share-to-mesh", + "cid": meta.cid, + "size": meta.size, + "mime": meta.mime, + "filename": meta.filename, + "self_url": self_url, + }); + let body_vec = serde_json::to_vec(&resp).unwrap_or_default(); + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .header("Access-Control-Allow-Origin", origin) + .header("Access-Control-Allow-Credentials", "true") + .header("Vary", "Origin") + .body(Body::from(body_vec)) + .unwrap_or_else(|_| Response::new(Body::from("internal error")))) + } + + pub(super) async fn handle_blob_download( + store: &Arc, + path: &str, + query: &str, + ) -> Result> { + let cid = path.strip_prefix("/blob/").unwrap_or(""); + if cid.is_empty() || !cid.chars().all(|c| c.is_ascii_hexdigit()) || cid.len() != 64 { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + Body::from("invalid cid"), + )); + } + + // Public blobs (profile pictures, banners) bypass the capability + // check — their CID is published on Nostr relays where any reader + // can see it, and external readers have no way to obtain a cap. + // Only blobs explicitly marked public at upload time qualify. + let is_public = store.meta(cid).await.map(|m| m.public).unwrap_or(false); + + if !is_public { + let mut cap = None; + let mut exp: Option = None; + let mut peer = None; + for pair in query.split('&') { + let mut it = pair.splitn(2, '='); + match (it.next(), it.next()) { + (Some("cap"), Some(v)) => cap = Some(v.to_string()), + (Some("exp"), Some(v)) => exp = v.parse().ok(), + (Some("peer"), Some(v)) => peer = Some(v.to_string()), + _ => {} + } + } + let (Some(cap), Some(exp), Some(peer)) = (cap, exp, peer) else { + return Ok(build_response( + StatusCode::UNAUTHORIZED, + "text/plain", + Body::from("missing cap/exp/peer"), + )); + }; + + if let Err(e) = store.verify_capability(cid, &peer, exp, &cap) { + tracing::warn!("blob cap rejected: cid={} peer={} reason={}", cid, peer, e); + return Ok(build_response( + StatusCode::FORBIDDEN, + "text/plain", + Body::from(format!("capability rejected: {}", e)), + )); + } + } + + let bytes = match store.get(cid).await { + Ok(b) => b, + Err(_) => { + return Ok(build_response( + StatusCode::NOT_FOUND, + "text/plain", + Body::from("blob not found"), + )) + } + }; + let mime = store + .meta(cid) + .await + .map(|m| m.mime) + .unwrap_or_else(|_| "application/octet-stream".to_string()); + Ok(build_response(StatusCode::OK, &mime, Body::from(bytes))) + } +} diff --git a/core/archipelago/src/api/handler/content.rs b/core/archipelago/src/api/handler/content.rs new file mode 100644 index 00000000..65a2862f --- /dev/null +++ b/core/archipelago/src/api/handler/content.rs @@ -0,0 +1,499 @@ +use super::build_response; +use crate::config::Config; +use crate::content_server; +use anyhow::Result; +use hyper::{Response, StatusCode}; + +use super::{is_valid_app_id, ApiHandler}; + +impl ApiHandler { + pub(super) async fn handle_content_catalog(config: &Config) -> Result> { + match content_server::load_catalog(&config.data_dir).await { + Ok(catalog) => { + // Only expose public metadata for available items + let items: Vec = catalog + .items + .iter() + .filter(|i| !matches!(i.availability, content_server::Availability::Nobody)) + .map(|i| { + serde_json::json!({ + "id": i.id, + "filename": i.filename, + "mime_type": i.mime_type, + "size_bytes": i.size_bytes, + "description": i.description, + "access": i.access, + }) + }) + .collect(); + let body = + serde_json::to_vec(&serde_json::json!({ "items": items })).unwrap_or_default(); + Ok(build_response( + StatusCode::OK, + "application/json", + hyper::Body::from(body), + )) + } + Err(e) => { + let body = serde_json::json!({ "error": e.to_string() }); + let body_bytes = serde_json::to_vec(&body).unwrap_or_default(); + Ok(build_response( + StatusCode::INTERNAL_SERVER_ERROR, + "application/json", + hyper::Body::from(body_bytes), + )) + } + } + } + + pub(super) async fn handle_content_request( + path: &str, + headers: &hyper::HeaderMap, + config: &Config, + ) -> Result> { + let content_id = path.strip_prefix("/content/").unwrap_or(""); + if content_id.is_empty() || !is_valid_app_id(content_id) { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + hyper::Body::from("Invalid content ID"), + )); + } + + // Extract payment token from X-Payment-Token header + let payment_token = headers + .get("x-payment-token") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + // Extract a paid-entitlement gate token from X-Invoice-Hash (Lightning) + // or X-Onchain-Address (on-chain) — both authorize the download if this + // node issued+settled them, and both resolve against the same shared + // entitlement store keyed by the token string (#46). + let invoice_hash = headers + .get("x-invoice-hash") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + .or_else(|| { + headers + .get("x-onchain-address") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + }); + + // Extract federation peer DID from X-Federation-DID header + let peer_did = headers + .get("x-federation-did") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + // Parse Range header for streaming support + let range = headers + .get("range") + .and_then(|v| v.to_str().ok()) + .and_then(content_server::parse_range_header); + + match content_server::serve_content( + &config.data_dir, + content_id, + payment_token.as_deref(), + invoice_hash.as_deref(), + peer_did.as_deref(), + range, + ) + .await + { + Ok(content_server::ServeResult::Ok(bytes, mime_type)) => { + let len = bytes.len(); + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", mime_type) + .header("Content-Length", len.to_string()) + .header("Accept-Ranges", "bytes") + .body(hyper::Body::from(bytes)) + .unwrap()) + } + Ok(content_server::ServeResult::Partial { + bytes, + mime_type, + start, + end, + total, + }) => Ok(Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .header("Content-Type", mime_type) + .header("Content-Length", bytes.len().to_string()) + .header( + "Content-Range", + format!("bytes {}-{}/{}", start, end, total), + ) + .header("Accept-Ranges", "bytes") + .body(hyper::Body::from(bytes)) + .unwrap()), + Ok(content_server::ServeResult::PaymentRequired(price_sats)) => { + let body = serde_json::json!({ + "error": "Payment required", + "price_sats": price_sats, + "payment_header": "X-Payment-Token", + }); + let body_bytes = serde_json::to_vec(&body).unwrap_or_default(); + Ok(build_response( + StatusCode::PAYMENT_REQUIRED, + "application/json", + hyper::Body::from(body_bytes), + )) + } + Ok(content_server::ServeResult::Forbidden) => Ok(build_response( + StatusCode::FORBIDDEN, + "application/json", + hyper::Body::from( + r#"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"#, + ), + )), + Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response( + StatusCode::NOT_FOUND, + "text/plain", + hyper::Body::from("Content not found"), + )), + } + } + + /// Seller side (#46): mint a Lightning invoice for a paid catalog item so a + /// buyer can pay from any external wallet. Path: GET /content/{id}/invoice. + /// Records a pending entitlement keyed by the invoice's payment hash. + pub(super) async fn handle_content_invoice(&self, path: &str) -> Result> { + let content_id = path + .strip_prefix("/content/") + .and_then(|s| s.strip_suffix("/invoice")) + .unwrap_or(""); + if content_id.is_empty() || !is_valid_app_id(content_id) { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + hyper::Body::from("Invalid content ID"), + )); + } + + let catalog = content_server::load_catalog(&self.config.data_dir) + .await + .unwrap_or_default(); + let item = match catalog.items.iter().find(|i| i.id == content_id) { + Some(i) => i, + None => { + return Ok(build_response( + StatusCode::NOT_FOUND, + "text/plain", + hyper::Body::from("Content not found"), + )) + } + }; + let price_sats = match &item.access { + content_server::AccessControl::Paid { price_sats, .. } => *price_sats, + _ => { + // Not a paid item — no invoice to issue. + return Ok(build_response( + StatusCode::BAD_REQUEST, + "application/json", + hyper::Body::from(r#"{"error":"Item is not paid"}"#), + )); + } + }; + if !content_server::method_accepted(&item.access, "lightning") { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "application/json", + hyper::Body::from( + r#"{"error":"The seller does not accept Lightning for this item"}"#, + ), + )); + } + + let memo = format!("Archipelago peer file {content_id}"); + match self + .rpc_handler + .create_invoice(price_sats as i64, &memo) + .await + { + Ok((bolt11, payment_hash)) if !payment_hash.is_empty() => { + crate::content_invoice::record_pending(&payment_hash, content_id, price_sats).await; + let body = serde_json::json!({ + "bolt11": bolt11, + "payment_hash": payment_hash, + "price_sats": price_sats, + }); + Ok(build_response( + StatusCode::OK, + "application/json", + hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()), + )) + } + Ok(_) => Ok(build_response( + StatusCode::INTERNAL_SERVER_ERROR, + "application/json", + hyper::Body::from(r#"{"error":"Invoice missing payment hash"}"#), + )), + Err(e) => { + // Surface the FULL error chain ({:#}) — the generic top-level + // message hid the real cause (e.g. the LND REST connection + // failing), which made this 503 undiagnosable. + tracing::warn!("content invoice creation failed: {e:#}"); + let body = serde_json::json!({ + "error": format!("Could not create invoice: {e:#}") + }); + Ok(build_response( + StatusCode::SERVICE_UNAVAILABLE, + "application/json", + hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()), + )) + } + } + } + + /// Seller side (#46): report whether a previously-issued invoice has settled. + /// Path: GET /content/{id}/invoice-status/{payment_hash}. On settlement the + /// entitlement is marked paid so the buyer can then download the file. + pub(super) async fn handle_content_invoice_status( + &self, + path: &str, + ) -> Result> { + let rest = path.strip_prefix("/content/").unwrap_or(""); + let (content_id, payment_hash) = match rest.split_once("/invoice-status/") { + Some((id, hash)) => (id, hash), + None => { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + hyper::Body::from("Invalid request"), + )) + } + }; + if content_id.is_empty() || !is_valid_app_id(content_id) || payment_hash.is_empty() { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + hyper::Body::from("Invalid request"), + )); + } + + // The hash must be one we issued for exactly this content item. + match crate::content_invoice::lookup(payment_hash).await { + Some((cid, _)) if cid == content_id => {} + _ => { + return Ok(build_response( + StatusCode::NOT_FOUND, + "application/json", + hyper::Body::from(r#"{"error":"Unknown invoice"}"#), + )) + } + } + + // Already paid? Otherwise ask our LND and persist the result. + let mut paid = crate::content_invoice::is_paid_for(payment_hash, content_id).await; + if !paid { + if let Ok(true) = self.rpc_handler.invoice_is_settled(payment_hash).await { + crate::content_invoice::mark_paid(payment_hash).await; + paid = true; + } + } + + let body = serde_json::json!({ "paid": paid }); + Ok(build_response( + StatusCode::OK, + "application/json", + hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()), + )) + } + + /// Seller side (#46): issue a fresh on-chain address for a paid catalog item + /// so a buyer can pay on-chain. Path: GET /content/{id}/onchain. Records a + /// pending entitlement keyed by the address; price doubles as expected amount. + pub(super) async fn handle_content_onchain(&self, path: &str) -> Result> { + let content_id = path + .strip_prefix("/content/") + .and_then(|s| s.strip_suffix("/onchain")) + .unwrap_or(""); + if content_id.is_empty() || !is_valid_app_id(content_id) { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + hyper::Body::from("Invalid content ID"), + )); + } + let catalog = content_server::load_catalog(&self.config.data_dir) + .await + .unwrap_or_default(); + let price_sats = match catalog.items.iter().find(|i| i.id == content_id) { + Some(i) => match &i.access { + content_server::AccessControl::Paid { price_sats, .. } => { + if !content_server::method_accepted(&i.access, "onchain") { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "application/json", + hyper::Body::from( + r#"{"error":"The seller does not accept on-chain payment for this item"}"#, + ), + )); + } + *price_sats + } + _ => { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "application/json", + hyper::Body::from(r#"{"error":"Item is not paid"}"#), + )) + } + }, + None => { + return Ok(build_response( + StatusCode::NOT_FOUND, + "text/plain", + hyper::Body::from("Content not found"), + )) + } + }; + + match self.rpc_handler.new_onchain_address().await { + Ok(address) if !address.is_empty() => { + crate::content_invoice::record_pending(&address, content_id, price_sats).await; + let body = serde_json::json!({ + "address": address, + "amount_sats": price_sats, + }); + Ok(build_response( + StatusCode::OK, + "application/json", + hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()), + )) + } + _ => { + let body = serde_json::json!({ + "error": "Could not generate an on-chain address (is the wallet ready?)" + }); + Ok(build_response( + StatusCode::SERVICE_UNAVAILABLE, + "application/json", + hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()), + )) + } + } + } + + /// Seller side (#46): report whether an on-chain payment to a previously- + /// issued address has arrived (>= price, >= 1 conf). Path: + /// GET /content/{id}/onchain-status/{address}. Marks the entitlement paid. + pub(super) async fn handle_content_onchain_status( + &self, + path: &str, + ) -> Result> { + let rest = path.strip_prefix("/content/").unwrap_or(""); + let (content_id, address) = match rest.split_once("/onchain-status/") { + Some((id, addr)) => (id, addr), + None => { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + hyper::Body::from("Invalid request"), + )) + } + }; + if content_id.is_empty() || !is_valid_app_id(content_id) || address.is_empty() { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + hyper::Body::from("Invalid request"), + )); + } + // The address must be one we issued for exactly this content item. + let price = match crate::content_invoice::lookup(address).await { + Some((cid, price)) if cid == content_id => price, + _ => { + return Ok(build_response( + StatusCode::NOT_FOUND, + "application/json", + hyper::Body::from(r#"{"error":"Unknown address"}"#), + )) + } + }; + + let mut paid = crate::content_invoice::is_paid_for(address, content_id).await; + if !paid { + if let Ok(true) = self.rpc_handler.onchain_received(address, price).await { + crate::content_invoice::mark_paid(address).await; + paid = true; + } + } + let body = serde_json::json!({ "paid": paid }); + Ok(build_response( + StatusCode::OK, + "application/json", + hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()), + )) + } + + /// Serve a degraded preview of paid content (blurred image or first 2% of video). + pub(super) async fn handle_content_preview( + path: &str, + config: &Config, + ) -> Result> { + // Path format: /content/{id}/preview + let content_id = path + .strip_prefix("/content/") + .and_then(|s| s.strip_suffix("/preview")) + .unwrap_or(""); + + if content_id.is_empty() || !is_valid_app_id(content_id) { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "text/plain", + hyper::Body::from("Invalid content ID"), + )); + } + + match content_server::serve_content_preview(&config.data_dir, content_id).await { + Ok(content_server::PreviewResult::FullContent(bytes, mime_type)) => { + let len = bytes.len(); + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", mime_type) + .header("Content-Length", len.to_string()) + .body(hyper::Body::from(bytes)) + .unwrap()) + } + Ok(content_server::PreviewResult::BlurPreview(bytes, mime_type)) => { + let len = bytes.len(); + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", mime_type) + .header("Content-Length", len.to_string()) + .header("X-Content-Preview", "blur") + .body(hyper::Body::from(bytes)) + .unwrap()) + } + Ok(content_server::PreviewResult::TruncatedPreview(bytes, mime_type, total_size)) => { + let len = bytes.len(); + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", mime_type) + .header("Content-Length", len.to_string()) + .header("X-Content-Preview", "truncated") + .header("X-Content-Total-Size", total_size.to_string()) + .body(hyper::Body::from(bytes)) + .unwrap()) + } + Ok(content_server::PreviewResult::PreviewUnavailable) => Ok(Response::builder() + .status(StatusCode::UNSUPPORTED_MEDIA_TYPE) + .header("Content-Type", "text/plain") + .header("X-Content-Preview", "unavailable") + .body(hyper::Body::from( + "Preview unavailable for this media (needs re-encoding)", + )) + .unwrap()), + Ok(content_server::PreviewResult::NotFound) | Err(_) => Ok(build_response( + StatusCode::NOT_FOUND, + "text/plain", + hyper::Body::from("Preview not available"), + )), + } + } +} diff --git a/core/archipelago/src/api/handler/dwn.rs b/core/archipelago/src/api/handler/dwn.rs new file mode 100644 index 00000000..9bf4778b --- /dev/null +++ b/core/archipelago/src/api/handler/dwn.rs @@ -0,0 +1,201 @@ +use super::build_response; +use crate::config::Config; +use crate::network::dwn_store::DwnStore; +use anyhow::Result; +use hyper::{Response, StatusCode}; + +use super::ApiHandler; + +impl ApiHandler { + /// DWN health endpoint — returns store stats. + pub(super) async fn handle_dwn_health(config: &Config) -> Result> { + match DwnStore::new(&config.data_dir).await { + Ok(store) => { + let stats = store + .stats() + .await + .unwrap_or(crate::network::dwn_store::StoreStats { + message_count: 0, + protocol_count: 0, + total_bytes: 0, + }); + let body = serde_json::json!({ + "status": "ok", + "message_count": stats.message_count, + "protocol_count": stats.protocol_count, + "total_bytes": stats.total_bytes, + }); + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .body(hyper::Body::from(body.to_string())) + .unwrap()) + } + Err(_) => Ok(build_response( + StatusCode::SERVICE_UNAVAILABLE, + "application/json", + hyper::Body::from(r#"{"status":"unavailable"}"#), + )), + } + } + + /// DWN message processing endpoint — handles RecordsWrite, RecordsQuery, RecordsRead, RecordsDelete. + /// Supports batch processing: all messages in the array are processed. + pub(super) async fn handle_dwn_message( + body: hyper::body::Bytes, + config: &Config, + ) -> Result> { + let request: serde_json::Value = match serde_json::from_slice(&body) { + Ok(v) => v, + Err(e) => { + let err = serde_json::json!({"error": format!("Invalid JSON: {}", e)}); + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("Content-Type", "application/json") + .body(hyper::Body::from(err.to_string())) + .unwrap()); + } + }; + + // Collect all messages to process + let messages: Vec = if request.get("message").is_some() { + vec![request["message"].clone()] + } else if let Some(msgs) = request["messages"].as_array() { + msgs.clone() + } else { + vec![serde_json::Value::Null] + }; + + let store = DwnStore::new(&config.data_dir).await?; + let mut results = Vec::new(); + + for message in &messages { + let interface = message["descriptor"]["interface"].as_str().unwrap_or(""); + let method = message["descriptor"]["method"].as_str().unwrap_or(""); + + let result = match (interface, method) { + ("Records", "Write") => { + let author = message["author"].as_str().unwrap_or("unknown"); + let protocol = message["descriptor"]["protocol"].as_str(); + let schema = message["descriptor"]["schema"].as_str(); + let data_format = message["descriptor"]["dataFormat"].as_str(); + let data = message.get("data").cloned(); + // Deduplicate: check if recordId already exists + if let Some(record_id) = message["recordId"].as_str() { + if store.read_message(record_id).await.ok().flatten().is_some() { + serde_json::json!({"status": {"code": 200, "detail": "Already exists"}}) + } else { + match store + .write_message(author, protocol, schema, data_format, data) + .await + { + Ok(msg) => { + serde_json::json!({"status": {"code": 202}, "entry": msg}) + } + Err(e) => { + serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}) + } + } + } + } else { + match store + .write_message(author, protocol, schema, data_format, data) + .await + { + Ok(msg) => serde_json::json!({"status": {"code": 202}, "entry": msg}), + Err(e) => { + serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}) + } + } + } + } + ("Records", "Query") => { + let query = crate::network::dwn_store::MessageQuery { + protocol: message["descriptor"]["filter"]["protocol"] + .as_str() + .map(|s| s.to_string()), + schema: message["descriptor"]["filter"]["schema"] + .as_str() + .map(|s| s.to_string()), + author: message["descriptor"]["filter"]["author"] + .as_str() + .map(|s| s.to_string()), + date_from: message["descriptor"]["filter"]["dateFrom"] + .as_str() + .map(|s| s.to_string()), + date_to: message["descriptor"]["filter"]["dateTo"] + .as_str() + .map(|s| s.to_string()), + limit: message["descriptor"]["filter"]["limit"] + .as_u64() + .map(|n| n as usize), + }; + match store.query_messages(&query).await { + Ok(messages) => { + serde_json::json!({"status": {"code": 200}, "entries": messages}) + } + Err(e) => { + serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}) + } + } + } + ("Records", "Read") => { + let record_id = message["descriptor"]["recordId"].as_str().unwrap_or(""); + match store.read_message(record_id).await { + Ok(Some(msg)) => { + serde_json::json!({"status": {"code": 200}, "entry": msg}) + } + Ok(None) => { + serde_json::json!({"status": {"code": 404, "detail": "Record not found"}}) + } + Err(e) => { + serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}) + } + } + } + ("Records", "Delete") => { + let record_id = message["descriptor"]["recordId"].as_str().unwrap_or(""); + match store.delete_message(record_id).await { + Ok(true) => serde_json::json!({"status": {"code": 200}}), + Ok(false) => { + serde_json::json!({"status": {"code": 404, "detail": "Record not found"}}) + } + Err(e) => { + serde_json::json!({"status": {"code": 500, "detail": e.to_string()}}) + } + } + } + _ => { + serde_json::json!({"status": {"code": 400, "detail": format!("Unknown method: {}.{}", interface, method)}}) + } + }; + + results.push(result); + } + + // Return single result for single message, array for batch + let (response_body, http_status) = if results.len() == 1 { + let result = &results[0]; + let status_code = result["status"]["code"].as_u64().unwrap_or(200); + let http_status = match status_code { + 202 => StatusCode::ACCEPTED, + 400 => StatusCode::BAD_REQUEST, + 404 => StatusCode::NOT_FOUND, + 500 => StatusCode::INTERNAL_SERVER_ERROR, + _ => StatusCode::OK, + }; + (result.to_string(), http_status) + } else { + ( + serde_json::json!({"replies": results}).to_string(), + StatusCode::OK, + ) + }; + + Ok(build_response( + http_status, + "application/json", + hyper::Body::from(response_body), + )) + } +} diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs new file mode 100644 index 00000000..fef490a8 --- /dev/null +++ b/core/archipelago/src/api/handler/mod.rs @@ -0,0 +1,736 @@ +mod blob; +mod content; +mod dwn; +mod node_message; +mod proxy; +mod remote_input; +mod remote_relay; +mod websocket; + +use crate::api::rpc::RpcHandler; +use crate::blobs::BlobStore; +use crate::config::Config; +use crate::container::{ContainerOrchestrator, DevContainerOrchestrator}; +use crate::monitoring::MetricsStore; +use crate::session::{self, SessionStore}; +use crate::state::StateManager; +use anyhow::Result; +use hyper::{Method, Request, Response, StatusCode}; +use sha2::{Digest, Sha256}; +use std::sync::Arc; +use tokio::sync::broadcast; +use tracing::debug; + +/// Build an HTTP response without unwrap. Falls back to a plain 500 if builder fails. +// Used by handler submodules after unwrap elimination +#[allow(dead_code)] +pub(super) fn build_response( + status: StatusCode, + content_type: &str, + body: hyper::Body, +) -> Response { + Response::builder() + .status(status) + .header("Content-Type", content_type) + .body(body) + .unwrap_or_else(|_| Response::new(hyper::Body::from("Internal error"))) +} + +pub struct ApiHandler { + config: Config, + rpc_handler: Arc, + state_manager: Arc, + metrics_store: Arc, + session_store: SessionStore, + /// Broadcast channel for relaying companion app input to remote browsers. + input_relay_tx: broadcast::Sender, + /// Reverse broadcast channel: the kiosk browser publishes "open this URL + /// externally" requests here, and the companion (phone) socket forwards them + /// to the phone's default browser. Lets "open in external browser" apps — + /// which the kiosk can't usefully open itself — launch on the controller. + external_open_tx: broadcast::Sender, + /// Content-addressed blob store for attachments shared over mesh/federation. + blob_store: Arc, + /// Our own node pubkey (hex) — used to self-sign debug/test capabilities. + self_pubkey_hex: String, +} + +impl ApiHandler { + pub async fn new( + config: Config, + state_manager: Arc, + metrics_store: Arc, + orchestrator: Option>, + dev_orchestrator: Option>, + ) -> Result { + let session_store = SessionStore::new().await; + let rpc_handler = Arc::new( + RpcHandler::new( + config.clone(), + state_manager.clone(), + metrics_store.clone(), + session_store.clone(), + orchestrator, + dev_orchestrator, + ) + .await?, + ); + let (input_relay_tx, _) = broadcast::channel(64); + let (external_open_tx, _) = broadcast::channel(16); + + // Derive a blob-store capability key from the node's Ed25519 signing + // key. SHA-256 domain-separated so rotating the identity rotates + // every outstanding capability token (intentional — prevents a + // replaced node from honouring old caps). + let identity_dir = config.data_dir.join("identity"); + let identity = crate::identity::NodeIdentity::load_or_create(&identity_dir).await?; + let mut hasher = Sha256::new(); + hasher.update(identity.signing_key().to_bytes()); + hasher.update(b"|archipelago-blob-cap-v1"); + let mut cap_key = [0u8; 32]; + cap_key.copy_from_slice(&hasher.finalize()); + let blob_store = Arc::new(BlobStore::open(&config.data_dir, cap_key).await?); + let self_pubkey_hex = hex::encode(identity.signing_key().verifying_key().as_bytes()); + + // Share blob store with the RPC layer so mesh.send-content / + // mesh.fetch-content can reach the same instance (single cap_key, + // single on-disk root) without re-opening it. + rpc_handler + .set_blob_store(blob_store.clone(), self_pubkey_hex.clone()) + .await; + + Ok(Self { + config, + rpc_handler, + state_manager, + metrics_store, + session_store, + input_relay_tx, + external_open_tx, + blob_store, + self_pubkey_hex, + }) + } + + /// Access the RPC handler (for service initialization after construction). + pub fn rpc_handler(&self) -> &Arc { + &self.rpc_handler + } + + /// Check if the request has a valid session cookie. + async fn is_authenticated(&self, headers: &hyper::HeaderMap) -> bool { + match session::extract_session_cookie(headers) { + Some(token) => self.session_store.validate(&token).await, + None => false, + } + } + + /// Server-side fetch of the upstream app catalog so the browser can + /// load it without fighting CORS (upstream Gitea emits no ACAO) or + /// CSP (the fallback IP-port URL isn't in `connect-src`). The upstream + /// list is derived from the operator's configured container registries + /// so switching mirrors in Settings changes the App Store source too — + /// each active registry contributes one Gitea `raw/branch/main/catalog.json` + /// URL (http or https per `tls_verify`), tried in priority order. + /// If registry config can't be loaded, falls back to the hardcoded OVH + /// URL so the App Store still renders on nodes that haven't persisted + /// a registry config yet. 15s total timeout. + async fn handle_app_catalog_proxy(&self) -> Result> { + let mut upstreams: Vec = Vec::new(); + if let Ok(config) = crate::container::registry::load_registries(&self.config.data_dir).await + { + for reg in config.active_registries() { + let scheme = if reg.tls_verify { "https" } else { "http" }; + // Gitea raw URL: :////app-catalog/raw/branch/main/catalog.json. + // reg.url already includes the namespace (e.g. "host/lfg2025"), + // so we just tack on the repo + raw path. + upstreams.push(format!( + "{}://{}/app-catalog/raw/branch/main/catalog.json", + scheme, reg.url + )); + } + } + if upstreams.is_empty() { + upstreams.push( + "http://146.59.87.168:3000/lfg2025/app-catalog/raw/branch/main/catalog.json" + .to_string(), + ); + } + + let client = match reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + { + Ok(c) => c, + Err(e) => { + return Ok(build_response( + hyper::StatusCode::INTERNAL_SERVER_ERROR, + "text/plain", + hyper::Body::from(format!("client build failed: {}", e)), + )); + } + }; + for url in &upstreams { + match client.get(url).send().await { + Ok(resp) if resp.status().is_success() => { + if let Ok(bytes) = resp.bytes().await { + return Ok(Response::builder() + .status(hyper::StatusCode::OK) + .header("Content-Type", "application/json") + .header("Cache-Control", "public, max-age=3600") + .body(hyper::Body::from(bytes)) + .unwrap_or_else(|_| { + Response::new(hyper::Body::from("proxy response build failed")) + })); + } + } + _ => continue, + } + } + Ok(build_response( + hyper::StatusCode::BAD_GATEWAY, + "text/plain", + hyper::Body::from("all upstream catalog URLs failed"), + )) + } + + /// Serve an encrypted backup archive (`/backups/.bak`) as a + /// browser download. The archive is passphrase-encrypted at rest; the + /// session gate at the route controls who can fetch it. + async fn handle_backup_download(&self, path: &str) -> Result> { + let id = path.strip_prefix("/api/blob/backup/").unwrap_or(""); + // Backup ids are UUIDs — reject anything that could traverse paths. + if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') { + return Ok(build_response( + StatusCode::BAD_REQUEST, + "application/json", + hyper::Body::from(r#"{"error":"invalid backup id"}"#), + )); + } + let file = self + .config + .data_dir + .join("backups") + .join(format!("{id}.bak")); + match tokio::fs::read(&file).await { + Ok(bytes) => Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/octet-stream") + .header( + "Content-Disposition", + format!("attachment; filename=\"archipelago-backup-{id}.bak\""), + ) + .header("Content-Length", bytes.len()) + .body(hyper::Body::from(bytes)) + .unwrap_or_else(|_| Response::new(hyper::Body::from("Internal error")))), + Err(_) => Ok(build_response( + StatusCode::NOT_FOUND, + "application/json", + hyper::Body::from(r#"{"error":"backup not found"}"#), + )), + } + } + + /// Build a 401 Unauthorized JSON response. + fn unauthorized() -> Response { + let body = serde_json::json!({ "error": "Unauthorized" }); + let body_bytes = serde_json::to_vec(&body).unwrap_or_default(); + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header("Content-Type", "application/json") + .body(hyper::Body::from(body_bytes)) + .unwrap() + } + + /// A 401 that still carries CORS headers, for endpoints fetched + /// cross-origin by same-node app UIs (e.g. the LND wallet UI on its own + /// port). Without the ACAO header the browser surfaces an opaque CORS + /// error instead of the 401, so the app can't tell it just needs auth. + /// `origin` is the already-validated reflect value from `app_cors_origin` + /// (empty string when the origin isn't allowed → no CORS header added). + fn unauthorized_cors(origin: &str) -> Response { + let body = serde_json::json!({ "error": "Unauthorized" }); + let body_bytes = serde_json::to_vec(&body).unwrap_or_default(); + let mut builder = Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header("Content-Type", "application/json") + .header("Vary", "Origin"); + if !origin.is_empty() { + builder = builder + .header("Access-Control-Allow-Origin", origin) + .header("Access-Control-Allow-Credentials", "true"); + } + builder.body(hyper::Body::from(body_bytes)).unwrap() + } + + /// Allowed CORS origins derived from the config host IP. + fn allowed_origins(&self) -> Vec { + let mut origins = vec![ + format!("http://{}", self.config.host_ip), + format!("https://{}", self.config.host_ip), + ]; + if self.config.dev_mode { + origins.push("http://localhost:8100".to_string()); // Vite dev server + } + origins + } + + /// Validate the Origin header against allowed origins. + /// Returns the matched origin if valid, None if cross-origin is not allowed. + fn validate_origin(&self, headers: &hyper::HeaderMap) -> Option { + let origin = headers.get("origin").and_then(|v| v.to_str().ok())?; + let allowed = self.allowed_origins(); + if allowed.iter().any(|a| a == origin) { + Some(origin.to_string()) + } else { + None + } + } + + /// Permissive origin check for the share-to-mesh iframe intent: any scheme + /// http(s):// followed by the configured host_ip, optionally `:port`. Apps + /// proxied under other ports (APP_PORTS) call this from within the same + /// node, so they share host_ip but not port. The session cookie still has + /// to be valid — this is a sanity check, not the primary auth. + fn validate_app_origin(&self, headers: &hyper::HeaderMap) -> Option { + let origin = headers.get("origin").and_then(|v| v.to_str().ok())?; + // Allow localhost dev server too so the Vite frontend can exercise it. + if self.config.dev_mode && origin == "http://localhost:8100" { + return Some(origin.to_string()); + } + let host_ip = &self.config.host_ip; + let matches = |scheme: &str| -> bool { + let prefix = format!("{}{}", scheme, host_ip); + if origin == prefix { + return true; + } + let with_port = format!("{}:", prefix); + origin.starts_with(&with_port) + && origin[with_port.len()..] + .bytes() + .all(|b| b.is_ascii_digit()) + }; + if matches("http://") || matches("https://") { + Some(origin.to_string()) + } else { + None + } + } + + /// CORS origin to echo for same-node app → backend calls (e.g. the LND + /// wallet UI, served on its own APP_PORTS port). Such apps share the node's + /// host but use a different port, so the strict allowlist (`host_ip`, no + /// port) rejects them and the browser gets no `Access-Control-Allow-Origin` + /// header ("blocked by CORS policy"). Reflect the Origin when its host + /// matches the request's own `Host` header — i.e. the app lives on the same + /// address the node is being reached by, which transparently covers the LAN + /// IP, the Tailscale IP, localhost, and the `.onion` address without needing + /// to enumerate them. Auth is still enforced by the session cookie; this + /// only authorizes the browser to *read* the reply. Returns "" (no echoed + /// origin) when there is no match. + fn app_cors_origin(&self, headers: &hyper::HeaderMap) -> String { + if let Some(origin) = self.validate_origin(headers) { + return origin; + } + let Some(origin) = headers.get("origin").and_then(|v| v.to_str().ok()) else { + return String::new(); + }; + // host portion (no scheme, no port) of an `scheme://host[:port]` value + let host_of = |s: &str| -> Option { + let after_scheme = s.split_once("://").map(|(_, r)| r).unwrap_or(s); + let host_port = after_scheme.split('/').next().unwrap_or(after_scheme); + let host = host_port + .rsplit_once(':') + .map(|(h, _)| h) + .unwrap_or(host_port); + (!host.is_empty()).then(|| host.to_string()) + }; + let origin_host = host_of(origin); + let req_host = headers + .get(hyper::header::HOST) + .and_then(|v| v.to_str().ok()) + .and_then(host_of); + match (origin_host, req_host) { + (Some(o), Some(r)) if o == r => origin.to_string(), + _ => String::new(), + } + } + + pub async fn handle_request(&self, req: Request) -> Result> { + let path = req.uri().path().to_string(); + let method = req.method().clone(); + + // Handle CORS preflight for all routes + if method == Method::OPTIONS { + let mut builder = Response::builder() + .status(StatusCode::NO_CONTENT) + .header("Vary", "Origin"); + let preflight_origin = self.app_cors_origin(req.headers()); + if !preflight_origin.is_empty() { + builder = builder + .header("Access-Control-Allow-Origin", &preflight_origin) + .header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + .header("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token") + .header("Access-Control-Allow-Credentials", "true"); + } + return Ok(builder.body(hyper::Body::empty()).unwrap()); + } + + // WebSocket upgrade — validate session before upgrading + if method == Method::GET && path == "/ws/db" { + if !self.is_authenticated(req.headers()).await { + tracing::warn!("401 WebSocket /ws/db — session invalid or missing"); + return Ok(Self::unauthorized()); + } + return Self::handle_websocket( + req, + self.state_manager.clone(), + self.metrics_store.clone(), + ) + .await; + } + + // Remote input WebSocket — companion app sends keyboard/mouse events + if method == Method::GET && path == "/ws/remote-input" { + if !self.is_authenticated(req.headers()).await { + tracing::warn!("401 WebSocket /ws/remote-input — session invalid or missing"); + return Ok(Self::unauthorized()); + } + return Self::handle_remote_input( + req, + self.input_relay_tx.clone(), + self.external_open_tx.subscribe(), + ) + .await; + } + + // Remote relay WebSocket — browser receives companion input events + if method == Method::GET && path == "/ws/remote-relay" { + if !self.is_authenticated(req.headers()).await { + tracing::warn!("401 WebSocket /ws/remote-relay — session invalid or missing"); + return Ok(Self::unauthorized()); + } + return Self::handle_remote_relay( + req, + self.input_relay_tx.subscribe(), + self.external_open_tx.clone(), + ) + .await; + } + + // Convert body to bytes for non-WS routes + let headers = req.headers().clone(); + let query_string = req.uri().query().map(|s| s.to_string()).unwrap_or_default(); + let (parts, body) = req.into_parts(); + let body_bytes = hyper::body::to_bytes(body) + .await + .map_err(|e| anyhow::anyhow!("Failed to read body: {}", e))?; + let req_with_bytes = Request::from_parts(parts, hyper::Body::from(body_bytes.clone())); + + debug!("{} {}", method, path); + + match (method, path.as_str()) { + // RPC — auth is handled inside rpc handler per-method + (Method::POST, "/rpc/v1") => self.rpc_handler.clone().handle(req_with_bytes).await, + + // Health — unauthenticated, returns JSON with service status + (Method::GET, "/health") => { + let recovery_complete = crate::crash_recovery::is_recovery_complete(); + let uptime = crate::crash_recovery::uptime_seconds(); + let health_status = if recovery_complete { "ok" } else { "degraded" }; + let status = serde_json::json!({ + "status": health_status, + "crash_recovery_complete": recovery_complete, + "uptime_seconds": uptime, + "version": env!("CARGO_PKG_VERSION"), + "services": { + "rpc": true, + "sessions": true, + } + }); + Ok(Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .body(hyper::Body::from( + serde_json::to_vec(&status).unwrap_or_default(), + )) + .unwrap()) + } + + // Node message — P2P endpoint (authenticated by source validation, not cookie) + (Method::POST, "/archipelago/node-message") => { + Self::handle_node_message(body_bytes).await + } + + // Mesh typed envelope relay over federation — peers POST + // pre-encoded TypedEnvelope wire bytes here when the envelope is + // too large for a single LoRa frame (primarily ContentRef). No + // session auth: the body carries a pubkey + ed25519 signature + // over the wire bytes which we verify before dispatching. + (Method::POST, "/archipelago/mesh-typed") => { + Self::handle_mesh_typed_relay(self.rpc_handler.clone(), body_bytes).await + } + + // Backup archive download — session-gated. Lives under /api/blob/ + // so the existing nginx `location /api/blob` prefix proxies it on + // every fleet node without a config change. + (Method::GET, p) if p.starts_with("/api/blob/backup/") => { + if !self.is_authenticated(&headers).await { + return Ok(Self::unauthorized()); + } + self.handle_backup_download(p).await + } + + // Blob upload — local/session use only. Session-authenticated so + // only the node owner can push attachments into the blob store. + (Method::POST, "/api/blob") => { + if !self.is_authenticated(&headers).await { + return Ok(Self::unauthorized()); + } + Self::handle_blob_upload( + &self.blob_store, + &self.self_pubkey_hex, + &self.config.data_dir, + &headers, + body_bytes, + ) + .await + } + + // Share-to-mesh intent — marketplace app iframes POST a file here + // to stage it as a mesh attachment. Same body format as /api/blob + // (raw bytes + X-Blob-Mime/X-Blob-Filename headers). The app is + // expected to postMessage `{type:'share-to-mesh', cid, ...}` to + // its parent window afterwards so the Mesh view can pick it up. + // Authenticated by session cookie + a relaxed Origin check (any + // port on the archipelago host is allowed, so proxied apps on + // their own ports can reach it with credentials:'include'). + (Method::POST, "/api/share-to-mesh") => { + if !self.is_authenticated(&headers).await { + return Ok(Self::unauthorized()); + } + let origin = match self.validate_app_origin(&headers) { + Some(o) => o, + None => { + return Ok(build_response( + StatusCode::FORBIDDEN, + "text/plain", + hyper::Body::from("origin not allowed"), + )) + } + }; + Self::handle_share_to_mesh( + &self.blob_store, + &self.self_pubkey_hex, + &headers, + body_bytes, + &origin, + ) + .await + } + + // Blob download — peer-facing. No session required; authenticated + // by HMAC capability token signed when the blob ref was shared. + (Method::GET, p) if p.starts_with("/blob/") => { + Self::handle_blob_download(&self.blob_store, p, &query_string).await + } + + // Content preview — degraded previews for paid content (no auth, no payment) + (Method::GET, p) if p.starts_with("/content/") && p.ends_with("/preview") => { + Self::handle_content_preview(p, &self.config).await + } + + // Lightning-invoice peer-file sale (#46): mint invoice / poll settlement + (Method::GET, p) if p.starts_with("/content/") && p.ends_with("/invoice") => { + self.handle_content_invoice(p).await + } + (Method::GET, p) if p.starts_with("/content/") && p.contains("/invoice-status/") => { + self.handle_content_invoice_status(p).await + } + + // On-chain peer-file sale (#46): issue address / poll for payment + (Method::GET, p) if p.starts_with("/content/") && p.contains("/onchain-status/") => { + self.handle_content_onchain_status(p).await + } + (Method::GET, p) if p.starts_with("/content/") && p.ends_with("/onchain") => { + self.handle_content_onchain(p).await + } + + // Content serving — peers access shared content over Tor (no session auth) + (Method::GET, p) if p.starts_with("/content/") => { + Self::handle_content_request(p, &headers, &self.config).await + } + + // Content catalog — list available content (no session auth, for peers) + (Method::GET, "/content") => Self::handle_content_catalog(&self.config).await, + + // Electrs status — unauthenticated (read-only sync status) + (Method::GET, "/electrs-status") => Self::handle_electrs_status().await, + (Method::GET, "/bitcoin-status") => Self::handle_bitcoin_status().await, + + // App-catalog proxy — fetches catalog.json from the configured + // upstream URLs server-side so the browser doesn't hit CORS + // (upstream Gitea has no ACAO header) or CSP (IP-port upstream + // falls outside `connect-src`). Session-authenticated so only + // the logged-in node owner can spin up fetches. + (Method::GET, "/api/app-catalog") => { + if !self.is_authenticated(&headers).await { + return Ok(Self::unauthorized()); + } + self.handle_app_catalog_proxy().await + } + + // Pine node status — public tier (version/uptime/height/sync/peer + // counts) is unauthenticated like /bitcoin-status; Lightning + // balances + latest mesh message additionally require the bearer + // token the pine/HA seeder minted (or a valid session). + (Method::GET, "/api/pine/status") => { + let bearer = headers + .get(hyper::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .unwrap_or(""); + let authorized = self.rpc_handler.pine_status_token_ok(bearer).await + || self.is_authenticated(&headers).await; + let body = self.rpc_handler.pine_status_json(authorized).await; + Ok(build_response( + StatusCode::OK, + "application/json", + hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()), + )) + } + + // Session probe for app-container nginx `auth_request` gates. + // + // App UIs run their own nginx and proxy selected paths into this + // backend. Some of those paths inject credentials the caller never + // supplied (bitcoin-ui's /bitcoin-rpc/ adds Bitcoin Core's Basic + // auth), which makes the proxy itself the authorization boundary — + // and nginx has no way to validate a session cookie on its own. This + // endpoint gives it one: 204 when the request carries a valid + // session, 401 otherwise. Body is deliberately empty; `auth_request` + // discards it and it must never become an oracle. + (Method::GET, "/auth/session-check") => { + if !self.is_authenticated(&headers).await { + return Ok(Self::unauthorized()); + } + Ok(Response::builder() + .status(StatusCode::NO_CONTENT) + .header("Cache-Control", "no-store") + .body(hyper::Body::empty()) + .unwrap()) + } + + // LND connect info — REQUIRES A SESSION. This response is a complete + // remote-control package for the node's Lightning wallet: the admin + // macaroon, the TLS cert, the gRPC/REST ports and the onion address. + // Anyone who receives it can drain the wallet from anywhere, and the + // onion means they keep that ability after losing network access. + // + // It used to carry no backend check, on two premises that were both + // false in production: + // + // "nginx validates the session cookie" — the MAIN nginx does. But + // the lnd-ui app container runs its OWN nginx on :18083 that + // proxies /lnd-connect-info straight here, forwarding whatever + // cookies arrived, including none. That second front door never + // performed the presence check the premise depended on. + // + // "the backend is bound to 127.0.0.1 so only nginx can reach it" — + // true of the backend socket, but irrelevant: :18083 is a reachable + // proxy INTO it, it binds 0.0.0.0, and it is explicitly on the + // fips0 mesh allowlist (fips/app_ports.rs). So an unauthenticated + // GET from any mesh peer, LAN host or Tailscale peer returned the + // admin macaroon. Verified live on archi-dev-box 2026-08-02. + // + // The lesson generalises: an auth check performed by one reverse + // proxy is not an auth check, because it only holds for traffic that + // arrived through that proxy. Authorisation belongs at the resource. + // Do not remove this in favour of a front-door check again. + // + // 401s carry CORS headers for the same reason /proxy/lnd/ does: the + // wallet UI fetches this cross-origin, so a bare 401 without them + // surfaces in the browser as an unreadable CORS failure. + (Method::GET, "/lnd-connect-info") => { + let origin = self.app_cors_origin(&headers); + if !self.is_authenticated(&headers).await { + return Ok(Self::unauthorized_cors(&origin)); + } + Self::handle_lnd_connect_info(self.rpc_handler.clone(), &origin).await + } + + // Container logs — requires session + (Method::GET, path) if path.starts_with("/api/container/logs") => { + if !self.is_authenticated(&headers).await { + return Ok(Self::unauthorized()); + } + let origin = self.validate_origin(&headers).unwrap_or_default(); + Self::handle_container_logs_http(self.rpc_handler.clone(), path, &origin).await + } + + // Peer content streaming proxy — Range-streams a peer's media file + // so