diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9a4deb9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +fuzz/corpus/** binary +crates/carapaced/tests/fixtures/*.b64 text eol=lf +crates/carapaced/tests/fixtures/*.tsv text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dea70d6..2b45cc6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,11 +15,132 @@ permissions: contents: read jobs: + cbor-oracle: + runs-on: ubuntu-latest + steps: + - name: Checkout Carapace + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + path: Carapace + + - name: Install Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: Carapace/requirements-cbor-vectors.txt + + - name: Install pinned oracle dependencies + run: python -m pip install --requirement Carapace/requirements-cbor-vectors.txt + + - name: Check independent CBOR vectors + working-directory: Carapace + run: python cbor_vectors.py --check + + gui: + runs-on: ubuntu-latest + steps: + - name: Checkout Carapace + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + path: Carapace + + - name: Install pinned Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24.18.0 + cache: npm + cache-dependency-path: Carapace/gui/package-lock.json + + - name: Install pinned npm + run: npm install --global npm@11.16.0 + + - name: Verify Node.js tools + run: | + test "$(node --version)" = "v24.18.0" + test "$(npm --version)" = "11.16.0" + + - name: Install locked GUI dependencies + working-directory: Carapace/gui + run: npm ci + + - name: Check GUI source + working-directory: Carapace/gui + run: npm run check + + - name: Test GUI contracts + working-directory: Carapace/gui + run: npm test + + - name: Install pinned browser runtime + working-directory: Carapace/gui + run: npx playwright install --with-deps chromium + + - name: Test GUI in a real browser + working-directory: Carapace/gui + run: npm run test:browser + + - name: Build embedded GUI + working-directory: Carapace/gui + run: npm run build + + - name: Verify embedded GUI is current + working-directory: Carapace + run: git diff --exit-code -- crates/carapace-api/static + + supply-chain: + runs-on: ubuntu-latest + steps: + - name: Checkout Carapace + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + path: Carapace + + - name: Read pinned Chela revision + id: chela-pin + shell: bash + run: | + revision="$(tr -d '\r\n' < Carapace/chela-revision.txt)" + [[ "$revision" =~ ^[0-9a-f]{40}$ ]] + echo "revision=$revision" >> "$GITHUB_OUTPUT" + + - name: Checkout chela + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + repository: SecretSplitKit/Chela + ref: ${{ steps.chela-pin.outputs.revision }} + token: ${{ secrets.CHELA_CHECKOUT_TOKEN || github.token }} + path: chela + + - name: Check tracked files for secrets + working-directory: Carapace + run: scripts/check-tracked-secrets.sh + + - name: Check supply-chain configuration + working-directory: Carapace + run: scripts/check-supply-chain-config.sh + + - name: Check dependency licenses, sources, bans, and advisories + uses: EmbarkStudios/cargo-deny-action@d755fbddac377c2d538f556dd0f9c7728c7f73e4 # v2.0.14 + with: + rust-version: 1.95.0 + manifest-path: Carapace/Cargo.toml + arguments: --all-features + command-arguments: --config /github/workspace/Carapace/deny.toml + + - name: Prepare lock file for cargo audit + run: cp Carapace/Cargo.lock Cargo.lock + + - name: Audit Rust dependencies + uses: rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998 # v2.0.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + test: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-24.04, ubuntu-24.04-arm, macos-15-intel, macos-15, windows-2025, windows-11-arm] runs-on: ${{ matrix.os }} steps: # Carapace depends on chela-engine/chela-bip39/chela-share via a path @@ -30,20 +151,77 @@ jobs: with: path: Carapace + - name: Read pinned Chela revision + id: chela-pin + shell: bash + run: | + revision="$(tr -d '\r\n' < Carapace/chela-revision.txt)" + [[ "$revision" =~ ^[0-9a-f]{40}$ ]] + echo "revision=$revision" >> "$GITHUB_OUTPUT" + echo "CHELA_REV=$revision" >> "$GITHUB_ENV" + - name: Checkout chela uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: repository: SecretSplitKit/Chela - ref: main - # The default GITHUB_TOKEN is scoped to THIS repo only and cannot read the - # separate private Chela repo. Provide a PAT/App token as the - # CHELA_CHECKOUT_TOKEN secret; the fallback works only if Chela is public. + ref: ${{ steps.chela-pin.outputs.revision }} + # The default GITHUB_TOKEN is scoped to this repository. Use the + # optional token if Chela becomes private. token: ${{ secrets.CHELA_CHECKOUT_TOKEN || github.token }} path: chela - - name: Install stable Rust + - name: Verify Chela revision + shell: bash + run: | + actual_revision="$(git -C chela rev-parse HEAD)" + test "$actual_revision" = "$CHELA_REV" + + - name: Check release workflow + shell: bash + working-directory: Carapace + run: scripts/check-release-workflow.sh + + - name: Check shared restore layout + shell: bash + working-directory: Carapace + run: scripts/check-restore-layout.sh + + - name: Check requirement-to-test inventory + shell: bash + working-directory: Carapace + run: scripts/check-test-inventory.sh + + - name: Check operational log sources + shell: bash + working-directory: Carapace + run: scripts/check-operational-logs.sh + + - name: Check state fixture coverage and integrity + shell: bash + working-directory: Carapace + run: scripts/state-fixtures.sh check + + - name: Install fuzz toolchain + if: runner.os == 'Linux' + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + toolchain: nightly-2026-06-01 + + - name: Run all fuzz targets with bounded budgets + shell: bash + working-directory: Carapace + run: | + if [[ "$RUNNER_OS" == "Linux" ]]; then + cargo install cargo-fuzz --version 0.13.1 --locked + scripts/check-fuzz-targets.sh --smoke + else + scripts/check-fuzz-targets.sh + fi + + - name: Install pinned Rust uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable with: + toolchain: 1.95.0 components: clippy, rustfmt - name: Cache cargo @@ -67,4 +245,4 @@ jobs: - name: cargo test working-directory: Carapace - run: cargo test --workspace + run: cargo test --workspace --locked diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1052c57..9e935c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,40 +6,158 @@ on: env: CARGO_TERM_COLOR: always - BIN_NAME: carapaced -# Least privilege by default; the build job elevates to contents: write only for -# the release-asset upload. +# Build jobs are read-only. Only the publication gate can write release assets. permissions: + actions: read contents: read jobs: + quality: + runs-on: ubuntu-latest + steps: + - name: Checkout Carapace + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + path: Carapace + - name: Read pinned Chela revision + id: chela-pin + shell: bash + run: | + revision="$(tr -d '\r\n' < Carapace/chela-revision.txt)" + [[ "$revision" =~ ^[0-9a-f]{40}$ ]] + echo "revision=$revision" >> "$GITHUB_OUTPUT" + - name: Checkout chela + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + repository: SecretSplitKit/Chela + ref: ${{ steps.chela-pin.outputs.revision }} + token: ${{ secrets.CHELA_CHECKOUT_TOKEN || github.token }} + path: chela + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + toolchain: 1.95.0 + components: clippy, rustfmt + - name: Check Rust formatting and lints + working-directory: Carapace + run: | + cargo fmt --all -- --check + cargo clippy --workspace --all-targets -- -D warnings + - name: Check secrets and supply-chain policy + working-directory: Carapace + run: | + scripts/check-tracked-secrets.sh + scripts/check-supply-chain-config.sh + - name: Check dependency policy + uses: EmbarkStudios/cargo-deny-action@d755fbddac377c2d538f556dd0f9c7728c7f73e4 # v2.0.14 + with: + rust-version: 1.95.0 + manifest-path: Carapace/Cargo.toml + arguments: --all-features + command-arguments: --config /github/workspace/Carapace/deny.toml + - name: Install Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Check schema and independent vectors + working-directory: Carapace + run: | + python -m pip install --requirement requirements-cbor-vectors.txt + python cbor_vectors.py --check + cargo test --workspace --locked + - name: Check source layout, logs, inventory, and fixtures + working-directory: Carapace + run: | + scripts/check-release-workflow.sh + scripts/check-restore-layout.sh + scripts/check-test-inventory.sh + scripts/check-operational-logs.sh + scripts/state-fixtures.sh check + - name: Install pinned fuzz toolchain + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + toolchain: nightly-2026-06-01 + - name: Run all fuzz targets with bounded budgets + working-directory: Carapace + run: | + cargo install cargo-fuzz --version 0.13.1 --locked + scripts/check-fuzz-targets.sh --smoke + - name: Install pinned Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24.18.0 + cache: npm + cache-dependency-path: Carapace/gui/package-lock.json + - name: Check GUI and browser behavior + working-directory: Carapace/gui + run: | + npm install --global npm@11.16.0 + npm ci + npm run check + npm test + npx playwright install --with-deps chromium + npm run test:browser + npm run build + - name: Verify embedded GUI is current + working-directory: Carapace + run: git diff --exit-code -- crates/carapace-api/static + + native-tests: + needs: quality + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, ubuntu-24.04-arm, macos-15-intel, macos-15, windows-2025, windows-11-arm] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout Carapace + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + path: Carapace + - name: Read pinned Chela revision + id: chela-pin + shell: bash + run: | + revision="$(tr -d '\r\n' < Carapace/chela-revision.txt)" + [[ "$revision" =~ ^[0-9a-f]{40}$ ]] + echo "revision=$revision" >> "$GITHUB_OUTPUT" + - name: Checkout chela + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + repository: SecretSplitKit/Chela + ref: ${{ steps.chela-pin.outputs.revision }} + token: ${{ secrets.CHELA_CHECKOUT_TOKEN || github.token }} + path: chela + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + toolchain: 1.95.0 + - name: Run native workspace tests + working-directory: Carapace + run: cargo test --workspace --locked + build: - permissions: - contents: write # softprops/action-gh-release attaches assets to the release + needs: [quality, native-tests] + env: + MACOSX_DEPLOYMENT_TARGET: "11.0" strategy: fail-fast: false matrix: include: - - os: ubuntu-latest + - os: ubuntu-24.04 target: x86_64-unknown-linux-gnu - - os: ubuntu-latest + - os: ubuntu-24.04-arm target: aarch64-unknown-linux-gnu - cross: true - - os: macos-latest + - os: macos-15-intel target: x86_64-apple-darwin - - os: macos-latest + - os: macos-15 target: aarch64-apple-darwin - - os: windows-latest + - os: windows-2025 target: x86_64-pc-windows-msvc - - os: windows-latest + - os: windows-11-arm target: aarch64-pc-windows-msvc - cross: true runs-on: ${{ matrix.os }} - # aarch64 cross targets rely on toolchain components (linux: apt - # cross-gcc, windows: MSVC ARM64 tools) that aren't guaranteed on every - # runner image; don't let a cross-target hiccup block the native builds. - continue-on-error: ${{ matrix.cross == true }} steps: # Path dependency on chela-engine/chela-bip39/chela-share # (`../../../chela/...`) requires both repos as siblings. @@ -48,42 +166,95 @@ jobs: with: path: Carapace + - name: Read pinned Chela revision + id: chela-pin + shell: bash + run: | + revision="$(tr -d '\r\n' < Carapace/chela-revision.txt)" + [[ "$revision" =~ ^[0-9a-f]{40}$ ]] + echo "revision=$revision" >> "$GITHUB_OUTPUT" + echo "CHELA_REV=$revision" >> "$GITHUB_ENV" + - name: Checkout chela uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: repository: SecretSplitKit/Chela - ref: main + ref: ${{ steps.chela-pin.outputs.revision }} # The default GITHUB_TOKEN cannot read the separate private Chela repo; # provide a PAT/App token as CHELA_CHECKOUT_TOKEN. The fallback works only # if Chela is public. token: ${{ secrets.CHELA_CHECKOUT_TOKEN || github.token }} path: chela + - name: Verify Chela revision + shell: bash + run: | + actual_revision="$(git -C chela rev-parse HEAD)" + test "$actual_revision" = "$CHELA_REV" + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable with: + toolchain: 1.95.0 targets: ${{ matrix.target }} - - name: Install aarch64 cross-compiler (Linux) - if: matrix.target == 'aarch64-unknown-linux-gnu' + - name: Build + working-directory: Carapace + run: cargo build --release --locked --target ${{ matrix.target }} -p carapace-api --bin carapaced -p carapace --bin carapace + + - name: Verify expected binaries (Unix) + if: runner.os != 'Windows' + shell: bash + working-directory: Carapace run: | - sudo apt-get update - sudo apt-get install -y gcc-aarch64-linux-gnu - echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV" - echo "CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV" + test -s "target/${{ matrix.target }}/release/carapaced" + test -x "target/${{ matrix.target }}/release/carapaced" + test -s "target/${{ matrix.target }}/release/carapace" + test -x "target/${{ matrix.target }}/release/carapace" - - name: Build + - name: Verify expected binaries (Windows) + if: runner.os == 'Windows' + shell: pwsh working-directory: Carapace - run: cargo build --release --locked --target ${{ matrix.target }} -p carapaced + run: | + $daemon = Get-Item "target/${{ matrix.target }}/release/carapaced.exe" + $client = Get-Item "target/${{ matrix.target }}/release/carapace.exe" + if ($daemon.Length -eq 0 -or $client.Length -eq 0) { + throw "A release executable is empty." + } + + - name: Generate CycloneDX SBOM + uses: anchore/sbom-action@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0.23.1 + with: + path: Carapace + format: cyclonedx-json + output-file: Carapace/sbom-${{ matrix.target }}.cdx.json + upload-artifact: false - name: Stage artifact (Unix) if: runner.os != 'Windows' shell: bash working-directory: Carapace run: | - staging="${BIN_NAME}-${{ matrix.target }}" + staging="carapace-${{ matrix.target }}" mkdir -p "$staging" - cp "target/${{ matrix.target }}/release/${BIN_NAME}" "$staging/" + cp "target/${{ matrix.target }}/release/carapaced" "$staging/" + cp "target/${{ matrix.target }}/release/carapace" "$staging/" + cp "sbom-${{ matrix.target }}.cdx.json" "$staging/SBOM.cdx.json" + echo "chela_revision=${CHELA_REV}" > "$staging/BUILD-METADATA.txt" + if [[ "${{ matrix.target }}" == *-linux-gnu ]]; then + carapaced_glibc_minimum="$(strings "target/${{ matrix.target }}/release/carapaced" | sed -n 's/.*GLIBC_\([0-9][0-9.]*\).*/\1/p' | sort -V | tail -1)" + carapace_glibc_minimum="$(strings "target/${{ matrix.target }}/release/carapace" | sed -n 's/.*GLIBC_\([0-9][0-9.]*\).*/\1/p' | sort -V | tail -1)" + test -n "$carapaced_glibc_minimum" + test -n "$carapace_glibc_minimum" + echo "carapaced_glibc_minimum=${carapaced_glibc_minimum}" >> "$staging/BUILD-METADATA.txt" + echo "carapace_glibc_minimum=${carapace_glibc_minimum}" >> "$staging/BUILD-METADATA.txt" + else + vtool -show-build "target/${{ matrix.target }}/release/carapaced" | grep -F 'minos 11.0' + vtool -show-build "target/${{ matrix.target }}/release/carapace" | grep -F 'minos 11.0' + echo "carapaced_macos_minimum=11.0" >> "$staging/BUILD-METADATA.txt" + echo "carapace_macos_minimum=11.0" >> "$staging/BUILD-METADATA.txt" + fi tar czf "${staging}.tar.gz" "$staging" echo "ASSET=Carapace/${staging}.tar.gz" >> "$GITHUB_ENV" @@ -92,19 +263,106 @@ jobs: shell: pwsh working-directory: Carapace run: | - $staging = "${env:BIN_NAME}-${{ matrix.target }}" + $staging = "carapace-${{ matrix.target }}" New-Item -ItemType Directory -Path $staging | Out-Null - Copy-Item "target/${{ matrix.target }}/release/${env:BIN_NAME}.exe" "$staging/" + Copy-Item "target/${{ matrix.target }}/release/carapaced.exe" "$staging/" + Copy-Item "target/${{ matrix.target }}/release/carapace.exe" "$staging/" + Copy-Item "sbom-${{ matrix.target }}.cdx.json" "$staging/SBOM.cdx.json" + Set-Content -Path "$staging/BUILD-METADATA.txt" -Value "chela_revision=${env:CHELA_REV}" Compress-Archive -Path $staging -DestinationPath "${staging}.zip" echo "ASSET=Carapace/${staging}.zip" >> $env:GITHUB_ENV + - name: Test packaged programs (Unix) + if: runner.os != 'Windows' + working-directory: Carapace + shell: bash + run: | + set -euo pipefail + bin="carapace-${{ matrix.target }}" + state="$(mktemp -d)" + "$bin/carapaced" run --state-dir "$state" --insecure-plaintext-keys --api-port 0 >daemon.log 2>&1 & + pid=$! + trap 'kill "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true; rm -rf "$state"' EXIT + for _ in {1..60}; do + test -s "$state/api-url" && break + kill -0 "$pid" + sleep 1 + done + test -s "$state/api-url" + curl --fail --silent --show-error "$(cat "$state/api-url")/api/health" | grep -q '"ok":true' + "$bin/carapace" --state-dir "$state" status >/dev/null + + - name: Test packaged programs (Windows) + if: runner.os == 'Windows' + working-directory: Carapace + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $bin = "carapace-${{ matrix.target }}" + $state = Join-Path $env:RUNNER_TEMP ([guid]::NewGuid()) + New-Item -ItemType Directory -Path $state | Out-Null + $daemon = Start-Process "$bin/carapaced.exe" -ArgumentList 'run','--state-dir',$state,'--insecure-plaintext-keys','--api-port','0' -PassThru -RedirectStandardOutput daemon.log -RedirectStandardError daemon-error.log + try { + $urlFile = Join-Path $state 'api-url' + for ($i = 0; $i -lt 60 -and -not (Test-Path $urlFile); $i++) { + if ($daemon.HasExited) { throw "carapaced exited early: $(Get-Content daemon-error.log)" } + Start-Sleep -Seconds 1 + } + if (-not (Test-Path $urlFile)) { throw 'carapaced did not publish api-url' } + $health = Invoke-RestMethod "$(Get-Content $urlFile)/api/health" + if (-not $health.ok) { throw 'health endpoint did not return ok' } + & "$bin/carapace.exe" --state-dir $state status | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'carapace status failed' } + } finally { + Stop-Process -Id $daemon.Id -ErrorAction SilentlyContinue + Remove-Item -Recurse -Force $state + } + - name: Upload artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: ${{ env.BIN_NAME }}-${{ matrix.target }} + name: carapace-${{ matrix.target }} path: ${{ env.ASSET }} - - name: Attach to GitHub release + publish: + needs: [quality, native-tests, build] + runs-on: ubuntu-latest + permissions: + actions: read + attestations: write + contents: write + id-token: write + steps: + - name: Download all release artifacts + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + path: release-assets + merge-multiple: true + + - name: Create SHA-256 checksums + working-directory: release-assets + run: sha256sum carapace-* > SHA256SUMS + + - name: Create approved detached signature + working-directory: release-assets + env: + RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }} + RELEASE_SIGNING_FINGERPRINT: ${{ secrets.RELEASE_SIGNING_FINGERPRINT }} + run: | + test -n "$RELEASE_SIGNING_KEY" + test -n "$RELEASE_SIGNING_FINGERPRINT" + printf '%s' "$RELEASE_SIGNING_KEY" | gpg --batch --import + actual="$(gpg --batch --with-colons --fingerprint "$RELEASE_SIGNING_FINGERPRINT" | awk -F: '$1 == "fpr" { print $10; exit }')" + test "$actual" = "$RELEASE_SIGNING_FINGERPRINT" + gpg --batch --local-user "$RELEASE_SIGNING_FINGERPRINT" --detach-sign --armor --output SHA256SUMS.asc SHA256SUMS + + - name: Attest release artifact provenance + uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 + with: + subject-path: release-assets/carapace-* + + - name: Attach complete artifact set to GitHub release uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3 with: - files: ${{ env.ASSET }} + fail_on_unmatched_files: true + files: release-assets/* diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..ca5c350 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.18.0 diff --git a/Cargo.lock b/Cargo.lock index d53cebd..8f1c22b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # 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" @@ -97,7 +103,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -108,7 +114,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -117,6 +123,17 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "797f94b6a53d7d10b56dc18290e0d40a2158352f108bb4ff32350825081a9f29" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "arc-swap" version = "1.9.2" @@ -189,6 +206,126 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[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-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -415,6 +552,15 @@ 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" @@ -424,6 +570,19 @@ dependencies = [ "objc2", ] +[[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 = "bumpalo" version = "3.20.3" @@ -466,14 +625,19 @@ dependencies = [ "axum", "carapace-wire", "carapaced", + "ed25519-dalek 2.2.0", "getrandom 0.2.17", "hex", + "rpassword", "rust-embed", + "rustix", "serde", "serde_json", "subtle", "tempfile", "tokio", + "tower", + "zeroize", ] [[package]] @@ -499,6 +663,7 @@ version = "0.1.0" dependencies = [ "blake3", "carapace-crypto", + "carapace-restore", "carapace-wire", "ed25519-dalek 2.2.0", "tempfile", @@ -564,6 +729,18 @@ dependencies = [ "hex", ] +[[package]] +name = "carapace-restore" +version = "0.1.0" +dependencies = [ + "blake3", + "getrandom 0.2.17", + "rustix", + "tempfile", + "unicode-normalization", + "windows-sys 0.61.2", +] + [[package]] name = "carapace-share" version = "0.1.0" @@ -579,6 +756,7 @@ version = "0.1.0" dependencies = [ "blake3", "carapace-crypto", + "carapace-restore", "carapace-wire", "chacha20poly1305", "ed25519-dalek 2.2.0", @@ -601,6 +779,7 @@ name = "carapaced" version = "0.1.0" dependencies = [ "anyhow", + "base64", "blake3", "carapace-crypto", "carapace-disclose", @@ -608,21 +787,34 @@ dependencies = [ "carapace-net", "carapace-recovery", "carapace-replica", + "carapace-restore", "carapace-share", "carapace-vault", "carapace-wire", "chela-share", "ed25519-dalek 2.2.0", + "flate2", "getrandom 0.2.17", "iroh", "iroh-blobs", + "keyring", "notify", "redb", + "rustix", "tempfile", "tokio", "zeroize", ] +[[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.67" @@ -664,9 +856,9 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -824,6 +1016,15 @@ dependencies = [ "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 = "const-oid" version = "0.9.6" @@ -934,6 +1135,15 @@ dependencies = [ "libc", ] +[[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" @@ -1131,7 +1341,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 2.0.118", + "syn 1.0.109", ] [[package]] @@ -1388,6 +1598,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + [[package]] name = "enum-assoc" version = "1.3.0" @@ -1399,6 +1615,27 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1412,7 +1649,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "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]] @@ -1467,6 +1724,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[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 = "fnv" version = "1.0.7" @@ -1522,7 +1789,7 @@ dependencies = [ "diatomic-waker", "futures-core", "pin-project-lite", - "spin 0.10.0", + "spin 0.10.1", ] [[package]] @@ -1742,9 +2009,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -1795,7 +2062,7 @@ dependencies = [ "hash32", "rustc_version", "serde", - "spin 0.9.8", + "spin 0.9.9", "stable_deref_trait", ] @@ -1805,6 +2072,12 @@ 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" @@ -2236,6 +2509,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -2670,6 +2944,27 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyring" +version = "4.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0298a59b384c540e408a600c8b375a09b49c3f97debc080e2c30675d79a6368a" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + [[package]] name = "kqueue" version = "1.2.0" @@ -2756,9 +3051,9 @@ dependencies = [ [[package]] name = "lru" -version = "0.18.1" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" dependencies = [ "hashbrown 0.17.1", ] @@ -2796,6 +3091,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -2818,6 +3122,16 @@ 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.2.1" @@ -3137,7 +3451,21 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", ] [[package]] @@ -3150,6 +3478,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -3165,6 +3502,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3332,6 +3690,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "p256" version = "0.13.2" @@ -3459,6 +3827,17 @@ 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 = "pkcs8" version = "0.10.2" @@ -3492,6 +3871,20 @@ dependencies = [ "time", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -3728,7 +4121,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20 0.10.1", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -3853,6 +4246,18 @@ dependencies = [ "windows", ] +[[package]] +name = "regex" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.15" @@ -3946,6 +4351,27 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rtoolbox" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "rust-embed" version = "8.12.0" @@ -4016,7 +4442,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4108,7 +4534,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4183,6 +4609,25 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secret-service" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "getrandom 0.2.17", + "hkdf", + "num", + "once_cell", + "serde", + "sha2 0.10.9", + "zbus", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -4213,7 +4658,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4298,6 +4743,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -4419,6 +4875,12 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simd_cesu8" version = "1.1.1" @@ -4472,7 +4934,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4494,18 +4956,18 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] [[package]] name = "spin" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" [[package]] name = "spki" @@ -4588,6 +5050,17 @@ dependencies = [ "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 = "syn-mid" version = "0.5.4" @@ -4656,7 +5129,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5079,6 +5552,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.60.2", +] + [[package]] name = "unicase" version = "2.9.0" @@ -5091,6 +5575,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -5186,6 +5679,7 @@ checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a" dependencies = [ "getrandom 0.4.3", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -5406,7 +5900,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5488,6 +5982,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-numerics" version = "0.3.1" @@ -5545,6 +6052,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -5889,6 +6405,78 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + +[[package]] +name = "zbus_macros" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.118", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.54" @@ -5988,3 +6576,43 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.118", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.118", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index 12ef416..af51742 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = ["crates/*"] [workspace.package] edition = "2021" +rust-version = "1.95" license = "Apache-2.0 OR MIT" repository = "https://github.com/SecretSplitKit/Carapace" diff --git a/README.md b/README.md index 3e1eff5..1fbf1b3 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ See `carapace-protocol.md` for the normative spec. ## Building -Requires a stable Rust toolchain (edition 2021). +Requires Rust 1.95.0, Node.js 24.18.0, and npm 11.16.0. Carapace depends on `chela-engine`, `chela-bip39`, and `chela-share` via path dependencies (`../../../chela/...` from `crates/carapace-recovery`), so the @@ -46,7 +46,9 @@ mkdir some-parent-dir && cd some-parent-dir git clone https://github.com/SecretSplitKit/Carapace.git Carapace git clone https://github.com/SecretSplitKit/Chela.git chela cd Carapace -cargo build --release +git -C ../chela checkout "$(cat chela-revision.txt)" +cd gui && npm ci && npm run build && cd .. +cargo build --release --locked ``` ### Verify @@ -57,7 +59,18 @@ cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace ``` -## Running the daemon +## Running on a desktop or laptop + +Launch `carapaced` with no arguments to use the platform data directory and open +the interface in your default browser. Production identities use protected local +key storage. Saved folders resume watching when the daemon restarts. + +The default data directory is `%LOCALAPPDATA%\Carapace` on Windows, +`~/Library/Application Support/Carapace` on macOS, and +`${XDG_DATA_HOME:-~/.local/share}/carapace` on Linux. The automatic launch uses +loopback API port `43821`. Use `carapaced --help` for command options. + +For scripts, use an explicit state directory; add `--open` to open the browser: ```sh carapaced run --state-dir [--publish --vid <64-hex>] @@ -65,7 +78,37 @@ carapaced run --state-dir [--publish --vid <64-hex>] `--state-dir` holds the daemon's persisted identity/state and is required. On start it binds the endpoint, serves the blob store and control protocol, -and prints this device's node id and dialable address. With `--publish +and prints this device's node id, API URL, and dialable address. With `--publish `, it ingests and publishes that directory as a vault (generating a new -vault id, or reusing one passed via `--vid`). It then idles, serving peers, -until Ctrl-C. +vault id, or reusing one passed via `--vid`). The daemon watches saved folders and syncs enrolled devices until Ctrl-C. +The HTTP control API stays on loopback; the peer endpoint listens on the local +network. Use `--bind` to select a specific address. The explicit development-only +`--insecure-plaintext-keys` mode remains loopback-only. + + +## Protecting your account + +Add friends with invite tickets, choose a folder, and select friends to hold its +encrypted replicas. Under Recovery, choose trustees and the recovery threshold. +The overview distinguishes delivered and verified shares; a newly created split +alone is not proof of recovery. Replica membership alone does not prove that every +current file is available. Rehearse a restore before depending on Carapace. + +Use **Account and devices** to transfer an account to another computer. Create an +encrypted package, send its passphrase separately, import into a new state folder, +and return the new device card to the original computer. Restart using the new +state folder. This does not switch the running account automatically. + +For recovery after losing devices, start `carapaced claimant --state-dir ` +and open the printed local URL. Give its public request to a trustee, confirm the +account identifier independently, and follow the approval and restart steps. +Keep the claimant state folder so an interrupted attempt can resume. Connections +across separate networks need reachable peers or configured relays. + +See [key storage](docs/key-storage.md), [claimant recovery](docs/claimant-recovery-boundary.md), +and [supported platforms](docs/supported-platforms.md) for operational details. + +The current restore limits are 4 GiB per file, 64 GiB per folder operation, and +100,000 file entries. Publication checks these limits too. Folders with names +that collide under supported case/Unicode rules or contain unsupported paths +are rejected explicitly; Carapace does not silently rename them. diff --git a/cbor_vectors.py b/cbor_vectors.py index 8a0ac34..b034364 100644 --- a/cbor_vectors.py +++ b/cbor_vectors.py @@ -10,9 +10,20 @@ sorted bytewise-lexicographically on their encoded form - bool/null as simple values; floats PROHIBITED """ +import argparse import hashlib +import re +from pathlib import Path from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +parser = argparse.ArgumentParser(description="Generate or check Carapace CBOR vectors") +parser.add_argument( + "--check", + action="store_true", + help="compare all generated vectors with the Rust golden source without writing files", +) +args = parser.parse_args() + # ---------------- deterministic CBOR encoder (reference) ---------------- def enc_uint(n, major=0): @@ -332,30 +343,32 @@ def hexwrap(b, indent=""): lines.append(hexwrap(e)) lines.append("```") lines.append("") -with open("appendix_b8_fragment.md", "w") as fh: - fh.write("\n".join(lines)) +if not args.check: + with open("appendix_b8_fragment.md", "w") as fh: + fh.write("\n".join(lines)) -print(f"\nPART 2: {len(part2)} frames + {len(docs)} documents generated, all signatures verified OK") -print("fragment written to appendix_b8_fragment.md") + print(f"\nPART 2: {len(part2)} frames + {len(docs)} documents generated, all signatures verified OK") + print("fragment written to appendix_b8_fragment.md") # ---------------- output ---------------- -print("== test keys ==") -for label, kp in [("USER_A", USER_A), ("USER_B", USER_B), - ("NODE_A1", NODE_A1), ("NODE_B1", NODE_B1)]: - print(f"{label}: seed={kp.private_bytes_raw().hex()}") - print(f"{label}: pub ={kp.public_key().public_bytes_raw().hex()}") -print(f"T0={T0}") -print() -for name, mt, body, f, note in vectors: - print(f"== {name} (type {mt}) == {note}") - print(f"frame ({len(f)} bytes):") - h = f.hex() - for i in range(0, len(h), 64): - print(" " + h[i:i+64]) +if not args.check: + print("== test keys ==") + for label, kp in [("USER_A", USER_A), ("USER_B", USER_B), + ("NODE_A1", NODE_A1), ("NODE_B1", NODE_B1)]: + print(f"{label}: seed={kp.private_bytes_raw().hex()}") + print(f"{label}: pub ={kp.public_key().public_bytes_raw().hex()}") + print(f"T0={T0}") print() -print("== InviteTicket URI ==") -print(ticket_uri) + for name, mt, body, f, note in vectors: + print(f"== {name} (type {mt}) == {note}") + print(f"frame ({len(f)} bytes):") + h = f.hex() + for i in range(0, len(h), 64): + print(" " + h[i:i+64]) + print() + print("== InviteTicket URI ==") + print(ticket_uri) # sanity: verify every signature verifies from cryptography.exceptions import InvalidSignature @@ -367,4 +380,33 @@ def hexwrap(b, indent=""): signer = pubmap[body.get(22, body[0])].public_key() body_wo = {k2: v for k2, v in body.items() if k2 != 23} signer.verify(body[23], DOMAIN + enc([mt, body_wo])) -print("\nall signatures verified OK") +def check_vectors(generated_vectors, generated_docs): + """Compare all oracle values with the committed Rust golden source.""" + rust_source = Path(__file__).resolve().parent / "crates/carapace-wire/tests/vectors.rs" + text = rust_source.read_text(encoding="utf-8") + rust_frames = re.findall(r'assert_frame\(\s*"([0-9a-f]+)"', text) + rust_documents = re.findall( + r'assert_eq!\(\s*"([0-9a-f]+)",\s*hex::encode\([^)]*\.to_bytes\(\)\)', + text, + ) + generated = [(name, data.hex()) for name, _, _, data, _ in generated_vectors] + generated.extend((name, data.hex()) for name, _, data, _ in generated_docs) + rust = rust_frames + rust_documents + if len(rust) != len(generated): + raise SystemExit( + f"vector count mismatch: oracle generated {len(generated)}, Rust has {len(rust)}" + ) + mismatches = [ + f"{index + 1} {name}" + for index, ((name, actual), expected) in enumerate(zip(generated, rust)) + if actual != expected + ] + if mismatches: + raise SystemExit("CBOR vector mismatch: " + ", ".join(mismatches)) + print(f"checked {len(generated)} independent CBOR vectors against {rust_source}") + + +if args.check: + check_vectors(vectors + part2, docs) +else: + print("\nall signatures verified OK") diff --git a/chela-revision.txt b/chela-revision.txt new file mode 100644 index 0000000..bd731fc --- /dev/null +++ b/chela-revision.txt @@ -0,0 +1 @@ +2f56b8333bd8b3882d47ce55c4fbba65542a70ce diff --git a/crates/carapace-api/Cargo.toml b/crates/carapace-api/Cargo.toml index f679afe..5ca3509 100644 --- a/crates/carapace-api/Cargo.toml +++ b/crates/carapace-api/Cargo.toml @@ -2,6 +2,7 @@ name = "carapace-api" version = "0.1.0" edition.workspace = true +rust-version.workspace = true license.workspace = true [lints] @@ -19,7 +20,14 @@ rust-embed = { version = "8", features = ["mime-guess"] } hex = "0.4" getrandom = "0.2" anyhow = "1" +rpassword = "7" +zeroize = "1" +tempfile = "3" + +[target.'cfg(unix)'.dependencies] +rustix = { version = "1.1.4", features = ["fs"] } [dev-dependencies] -tempfile = "3" hex = "0.4" +ed25519-dalek = "2" +tower = "0.5" diff --git a/crates/carapace-api/src/account_transfer.rs b/crates/carapace-api/src/account_transfer.rs new file mode 100644 index 0000000..8e799e5 --- /dev/null +++ b/crates/carapace-api/src/account_transfer.rs @@ -0,0 +1,44 @@ +//! Token-gated explicit same-account device transfer. +use crate::{handlers, AppState}; +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use carapace_wire::messages::Message; +use serde::Deserialize; +use serde_json::json; +use zeroize::Zeroizing; + +#[derive(Deserialize)] +pub struct ExportRequest { + passphrase: String, +} +pub async fn export(State(st): State, Json(request): Json) -> Response { + let passphrase = Zeroizing::new(request.passphrase); + match st.daemon.account_export(passphrase.as_bytes()) { + Ok((package,card)) => Json(json!({"package_hex":hex::encode(package),"user_id":hex::encode(card.user),"card_hex":hex::encode(card.encode_frame())})).into_response(), + Err(_) => handlers::error_response(StatusCode::BAD_REQUEST,"account export failed; a nonempty passphrase is required"), + } +} + +#[derive(Deserialize)] +pub struct ImportRequest { + package_hex: String, + passphrase: String, + destination: String, +} +pub async fn import(State(st): State, Json(request): Json) -> Response { + let passphrase = Zeroizing::new(request.passphrase); + let package = match hex::decode(request.package_hex) { + Ok(bytes) => bytes, + Err(_) => { + return handlers::error_response(StatusCode::BAD_REQUEST, "invalid transfer package") + } + }; + match st.daemon.account_import(&package,passphrase.as_bytes(),std::path::Path::new(&request.destination)).await { + Ok(report)=>Json(json!({"user_id":hex::encode(report.user_id),"state_dir":report.state_dir,"card_hex":hex::encode(report.card.encode_frame()),"source_card_hex":hex::encode(report.source_card.encode_frame()),"restart_required":true})).into_response(), + Err(error)=>{ crate::ops::log("account.import_failed",None); let _ = error; handlers::error_response(StatusCode::BAD_REQUEST,"account import failed; check the package, passphrase and fresh destination") } + } +} diff --git a/crates/carapace-api/src/auth.rs b/crates/carapace-api/src/auth.rs index 33daf5f..f19969a 100644 --- a/crates/carapace-api/src/auth.rs +++ b/crates/carapace-api/src/auth.rs @@ -86,7 +86,7 @@ pub fn is_loopback_origin(origin: &str) -> bool { /// DNS-rebinding + CSRF guard applied to EVERY request (including the health check /// and the static GUI). Rejects a non-loopback `Host`, and a present-but-non-loopback /// `Origin`. -pub async fn guard_host_origin(req: Request, next: Next) -> Result { +pub async fn guard_host_origin(req: Request, next: Next) -> Result { let headers = req.headers(); let host_ok = headers .get(header::HOST) @@ -94,11 +94,17 @@ pub async fn guard_host_origin(req: Request, next: Next) -> Result>, req: Request, next: Next, -) -> Result { +) -> Result { let presented = bearer(req.headers().get(header::AUTHORIZATION)); match presented { Some(t) if ct_eq_str(t, &token) => Ok(next.run(req).await), - _ => Err(StatusCode::UNAUTHORIZED), + _ => Err(crate::handlers::error_response( + StatusCode::UNAUTHORIZED, + "missing or invalid token", + )), } } diff --git a/crates/carapace-api/src/bin/carapaced.rs b/crates/carapace-api/src/bin/carapaced.rs index 3c2392e..0ac9835 100644 --- a/crates/carapace-api/src/bin/carapaced.rs +++ b/crates/carapace-api/src/bin/carapaced.rs @@ -2,35 +2,194 @@ //! //! Usage: //! carapaced run --state-dir [--publish [--watch] --vid <64-hex>] [--api-port ] +//! carapaced claimant --state-dir [--api-port ] //! //! `run` loads/generates the device state, starts the daemon (serving the blob //! store + `carapace/1` control protocol), optionally publishes a vault, starts the //! loopback control API (127.0.0.1 + per-session bearer token), prints the API URL //! and where the token lives, and idles until Ctrl-C. +use std::ffi::OsStr; use std::net::SocketAddr; use std::path::PathBuf; +use std::process::Command; use std::sync::Arc; use anyhow::{bail, Context, Result}; use carapaced::{Daemon, NetConfig, State}; +use zeroize::Zeroizing; + +#[path = "carapaced/state_ops.rs"] +mod state_ops; /// Default bind for the embedded relay when `--relay` is given no explicit socket. const DEFAULT_RELAY_BIND: &str = "0.0.0.0:9991"; +const HELP: &str = "Usage: carapaced [run|claimant|inspect-state|initialize-empty|restore-backup|reset-security-state|migrate-legacy-state|migrate-keys] [OPTIONS]\nNo arguments: use the platform data directory and open the browser.\nrun options: --state-dir PATH --api-port PORT --open --publish DIR --watch --vid HEX --bind IP:PORT --relay [IP:PORT] --relay-url URL --relay-host HOST --terminal-passphrase --insecure-plaintext-keys"; + +trait PassphrasePrompt { + fn read(&self) -> Result>; +} + +struct ControllingTerminalPrompt; + +impl PassphrasePrompt for ControllingTerminalPrompt { + fn read(&self) -> Result> { + #[cfg(unix)] + let _terminal = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open("/dev/tty") + .context("terminal-passphrase mode requires a controlling terminal")?; + let passphrase = Zeroizing::new( + rpassword::prompt_password("Carapace identity passphrase: ") + .context("read passphrase from the controlling terminal")?, + ); + ensure_nonempty_passphrase(passphrase) + } +} + +fn ensure_nonempty_passphrase(passphrase: Zeroizing) -> Result> { + if passphrase.is_empty() { + bail!("the terminal passphrase is empty"); + } + Ok(passphrase) +} + +fn read_operator_passphrase(prompt: &dyn PassphrasePrompt) -> Result> { + ensure_nonempty_passphrase(prompt.read()?) +} + #[tokio::main] async fn main() -> Result<()> { let mut args = std::env::args().skip(1); match args.next().as_deref() { Some("run") => run(args.collect()).await, - Some(other) => bail!("unknown command {other:?}; try: carapaced run --state-dir "), - None => bail!( - "usage: carapaced run --state-dir [--publish --vid <64-hex>] [--api-port ]" - ), + Some("--help" | "-h") => { + println!("{HELP}"); + Ok(()) + } + Some("--version" | "-V") => { + println!("carapaced {}", env!("CARGO_PKG_VERSION")); + Ok(()) + } + Some("claimant") => claimant(args.collect()).await, + Some("inspect-state") => state_ops::inspect_state(args.collect()), + Some("initialize-empty") => state_ops::initialize_empty(args.collect()), + Some("restore-backup") => state_ops::restore_backup(args.collect()), + Some("reset-security-state") => state_ops::reset_security_state(args.collect()), + Some("migrate-legacy-state") => state_ops::migrate_legacy_state(args.collect()), + Some("migrate-keys") => migrate_keys(args.collect()), + Some(other) => { + bail!("unknown command {other:?}; try: carapaced run, claimant, or a state operator command") + } + None => { + run(vec![ + "--state-dir".into(), + default_state_dir()? + .to_str() + .context("default state path is not UTF-8")? + .into(), + "--api-port".into(), + "43821".into(), + "--bind".into(), + "0.0.0.0:0".into(), + "--open".into(), + ]) + .await + } + } +} + +#[derive(Debug, PartialEq, Eq)] +struct ClaimantArgs { + state_dir: PathBuf, + api_port: u16, +} + +fn parse_claimant_args(rest: Vec) -> Result { + let mut state_dir = None; + let mut api_port = 0; + let mut it = rest.into_iter(); + while let Some(flag) = it.next() { + match flag.as_str() { + "--state-dir" => { + state_dir = Some(it.next().context("--state-dir needs a value")?.into()) + } + "--api-port" => { + api_port = it + .next() + .context("--api-port needs a value")? + .parse() + .context("--api-port must be a u16 port")? + } + other => bail!("unknown claimant flag {other:?}"), + } + } + Ok(ClaimantArgs { + state_dir: state_dir.context("--state-dir is required")?, + api_port, + }) +} + +async fn claimant(rest: Vec) -> Result<()> { + let args = parse_claimant_args(rest)?; + let api = carapace_api::serve_claimant(&args.state_dir, args.api_port).await?; + println!("claimant API: {}", api.url()); + println!( + "claimant API token: {} (bearer, 0600)", + args.state_dir.join("claimant-api-token").display() + ); + println!("claimant recovery is active; press Ctrl-C to stop"); + wait_for_stop().await?; + api.shutdown(); + Ok(()) +} + +fn migrate_keys(rest: Vec) -> Result<()> { + let mut state_dir: Option = None; + let mut confirmed: Option = None; + let mut it = rest.into_iter(); + while let Some(flag) = it.next() { + match flag.as_str() { + "--state-dir" => { + state_dir = Some(it.next().context("--state-dir needs a value")?.into()) + } + "--confirm-state-dir" => { + confirmed = Some( + it.next() + .context("--confirm-state-dir needs a value")? + .into(), + ) + } + other => bail!("unknown migrate-keys flag {other:?}"), + } + } + let state_dir = state_dir.context("--state-dir is required")?; + let confirmed = confirmed.context("--confirm-state-dir is required")?; + let canonical = std::fs::canonicalize(&state_dir) + .with_context(|| format!("resolve state directory {state_dir:?}"))?; + let confirmed_canonical = std::fs::canonicalize(&confirmed) + .with_context(|| format!("resolve confirmed state directory {confirmed:?}"))?; + if canonical != confirmed_canonical { + bail!("confirmed state directory does not match --state-dir"); } + let backup = State::migrate_legacy_keys(&canonical)?; + println!( + "key migration complete; protected backup: {}", + backup.display() + ); + Ok(()) } async fn run(rest: Vec) -> Result<()> { + if rest + .iter() + .any(|arg| matches!(arg.as_str(), "--help" | "-h")) + { + println!("{HELP}"); + return Ok(()); + } let mut state_dir: Option = None; let mut publish: Option = None; let mut watch = false; @@ -40,6 +199,9 @@ async fn run(rest: Vec) -> Result<()> { let mut relays: Vec = Vec::new(); let mut run_relay: Option = None; let mut relay_host: Option = None; + let mut insecure_plaintext_keys = false; + let mut terminal_passphrase = false; + let mut open = false; let mut it = rest.into_iter().peekable(); while let Some(flag) = it.next() { @@ -80,6 +242,9 @@ async fn run(rest: Vec) -> Result<()> { "--relay-host" => { relay_host = Some(it.next().context("--relay-host needs a host or ip")?) } + "--insecure-plaintext-keys" => insecure_plaintext_keys = true, + "--terminal-passphrase" => terminal_passphrase = true, + "--open" => open = true, // A friend's self-hosted relay URL to consume (repeatable). These form // this node's usable relay set for relay fallback (§6). "--relay-url" => relays.push( @@ -93,11 +258,30 @@ async fn run(rest: Vec) -> Result<()> { } let state_dir = state_dir.context("--state-dir is required")?; - let state = State::load_or_generate(&state_dir)?; let networked = bind.is_some() || run_relay.is_some() || !relays.is_empty(); - let daemon = Arc::new(if networked { + if insecure_plaintext_keys && networked { + bail!("--insecure-plaintext-keys cannot be used with non-loopback or relay operation"); + } + if insecure_plaintext_keys && terminal_passphrase { + bail!("--terminal-passphrase cannot be combined with --insecure-plaintext-keys"); + } + let state = if insecure_plaintext_keys { + eprintln!( + "carapace: WARNING insecure development mode stores identity secrets in local files" + ); + State::load_or_generate_insecure(&state_dir)? + } else if terminal_passphrase { + eprintln!( + "carapace: WARNING terminal-passphrase mode stays locked after unattended restart; recovery takeover delay and alarms cannot run while locked" + ); + let passphrase = read_operator_passphrase(&ControllingTerminalPrompt)?; + State::load_protected_local(&state_dir, passphrase.as_bytes())? + } else { + State::load_or_generate(&state_dir)? + }; + let daemon = Arc::new(if networked || !insecure_plaintext_keys { let cfg = NetConfig { - bind, + bind: bind.or(Some(SocketAddr::from((std::net::Ipv4Addr::UNSPECIFIED, 0)))), relays, run_relay, relay_host, @@ -116,11 +300,6 @@ async fn run(rest: Vec) -> Result<()> { println!("relay_url: {url}"); } - // Held for the process lifetime when `--watch` is set; dropping it stops the - // §11 filesystem watcher. Must be dropped before `Arc::try_unwrap` below so the - // watcher's Weak doesn't outlive its intent (it never blocks unwrap, but drop - // it explicitly so watching stops the moment we begin shutdown). - let mut vault_watcher = None; if let Some(dir) = publish { let vid = match vid_hex { Some(h) => parse_vid(&h)?, @@ -128,16 +307,15 @@ async fn run(rest: Vec) -> Result<()> { }; let epoch = daemon.publish_vault(&dir, vid).await?; println!("published vid {} at epoch {epoch}", hex(&vid)); - if watch { - vault_watcher = Some(Arc::clone(&daemon).watch_vault(vid, dir.clone())?); - println!("watching {} for changes", dir.display()); - } } else if watch { bail!("--watch requires --publish "); } let api = carapace_api::serve(Arc::clone(&daemon), &state_dir, api_port).await?; println!("control API: {}", api.url()); + if open { + open_browser(&api.url()); + } println!( "api token: {} (bearer, 0600)", state_dir.join("api-token").display() @@ -145,7 +323,6 @@ async fn run(rest: Vec) -> Result<()> { println!("serving; press Ctrl-C to stop"); wait_for_stop().await?; - drop(vault_watcher); // stop the §11 watcher before shutting the daemon down api.shutdown(); // `shutdown(&self)` flushes the blob store + closes the endpoint regardless of // how many `Arc` clones the API server / watcher tasks still hold — the @@ -176,6 +353,50 @@ async fn wait_for_stop() -> Result<()> { } } +fn default_state_dir() -> Result { + default_state_dir_for( + std::env::consts::OS, + std::env::var_os("HOME").as_deref(), + std::env::var_os("LOCALAPPDATA").as_deref(), + std::env::var_os("XDG_DATA_HOME").as_deref(), + ) +} + +fn default_state_dir_for( + os: &str, + home: Option<&OsStr>, + local_app_data: Option<&OsStr>, + xdg_data_home: Option<&OsStr>, +) -> Result { + match os { + "windows" => { + Ok(PathBuf::from(local_app_data.context("LOCALAPPDATA is not set")?).join("Carapace")) + } + "macos" => Ok(PathBuf::from(home.context("HOME is not set")?) + .join("Library/Application Support/Carapace")), + _ => Ok(match xdg_data_home { + Some(dir) => PathBuf::from(dir), + None => PathBuf::from(home.context("HOME and XDG_DATA_HOME are not set")?) + .join(".local/share"), + } + .join("carapace")), + } +} + +fn open_browser(url: &str) { + #[cfg(target_os = "windows")] + let result = Command::new("rundll32") + .args(["url.dll,FileProtocolHandler", url]) + .spawn(); + #[cfg(target_os = "macos")] + let result = Command::new("open").arg(url).spawn(); + #[cfg(all(unix, not(target_os = "macos")))] + let result = Command::new("xdg-open").arg(url).spawn(); + if let Err(error) = result { + eprintln!("could not open the web interface: {error}"); + } +} + fn parse_vid(h: &str) -> Result<[u8; 32]> { if h.len() != 64 { bail!("--vid must be 64 hex chars"); @@ -195,3 +416,82 @@ fn hex(b: &[u8]) -> String { } s } + +#[cfg(test)] +mod tests { + use super::*; + + fn values(items: &[&str]) -> Vec { + items.iter().map(|item| (*item).to_string()).collect() + } + + #[test] + fn desktop_directories_follow_platform_conventions() { + use std::ffi::OsStr; + assert_eq!( + default_state_dir_for( + "windows", + None, + Some(OsStr::new(r"C:\Users\Alice\AppData\Local")), + None + ) + .unwrap(), + PathBuf::from(r"C:\Users\Alice\AppData\Local").join("Carapace") + ); + assert_eq!( + default_state_dir_for("macos", Some(OsStr::new("/Users/alice")), None, None).unwrap(), + PathBuf::from("/Users/alice/Library/Application Support/Carapace") + ); + assert_eq!( + default_state_dir_for("linux", Some(OsStr::new("/home/alice")), None, None).unwrap(), + PathBuf::from("/home/alice/.local/share/carapace") + ); + assert!(default_state_dir_for("windows", None, None, None).is_err()); + } + + #[test] + fn claimant_arguments_default_to_an_ephemeral_port() { + let parsed = parse_claimant_args(values(&["--state-dir", "/tmp/claimant"])).unwrap(); + assert_eq!(parsed.state_dir, PathBuf::from("/tmp/claimant")); + assert_eq!(parsed.api_port, 0); + } + + #[test] + fn claimant_arguments_accept_an_api_port() { + let parsed = parse_claimant_args(values(&[ + "--state-dir", + "/tmp/claimant", + "--api-port", + "4711", + ])) + .unwrap(); + assert_eq!(parsed.api_port, 4711); + } + + #[test] + fn claimant_arguments_reject_missing_and_extra_values() { + let missing = parse_claimant_args(Vec::new()).unwrap_err(); + assert!(missing.to_string().contains("--state-dir is required")); + let extra = + parse_claimant_args(values(&["--state-dir", "/tmp/c", "--bind", "x"])).unwrap_err(); + assert!(extra.to_string().contains("unknown claimant flag")); + } + + struct FakePrompt(&'static str); + impl PassphrasePrompt for FakePrompt { + fn read(&self) -> Result> { + Ok(Zeroizing::new(self.0.to_string())) + } + } + + #[test] + fn terminal_passphrase_prompt_rejects_empty_input() { + assert!(read_operator_passphrase(&FakePrompt("")).is_err()); + assert_eq!( + read_operator_passphrase(&FakePrompt("operator secret")) + .unwrap() + .as_str(), + "operator secret" + ); + } +} diff --git a/crates/carapace-api/src/bin/carapaced/state_ops.rs b/crates/carapace-api/src/bin/carapaced/state_ops.rs new file mode 100644 index 0000000..32d8d73 --- /dev/null +++ b/crates/carapace-api/src/bin/carapaced/state_ops.rs @@ -0,0 +1,581 @@ +//! Read-only state operator commands for the `carapaced` binary. + +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use carapaced::{ + initialize_empty_state, inspect_existing_state, inspect_state_database, + migrate_legacy_state as migrate_legacy_database, State, +}; + +const AUDIT_LOG: &str = "operator-audit.log"; +const RESET_CONFIRMATION: &str = "RESET SECURITY STATE"; + +pub(crate) fn inspect_state(rest: Vec) -> Result<()> { + let (state_dir, insecure) = parse_inspect_args(rest)?; + let state = load_existing_identity(&state_dir, insecure)?; + let report = inspect_existing_state(&state)?; + + println!("state directory: {}", state_dir.display()); + println!("identity: valid"); + println!("database: valid"); + println!("owned vaults: {}", report.owned_vaults); + println!("held replicas: {}", report.held_replicas); + println!("held shares: {}", report.held_shares); + println!("recovery sets: {}", report.recovery_sets); + println!("ceremonies: {}", report.ceremonies); + Ok(()) +} + +pub(crate) fn migrate_legacy_state(rest: Vec) -> Result<()> { + let (state_dir, insecure) = parse_confirmed_existing_args(rest)?; + let state = load_existing_identity(&state_dir, insecure)?; + let backup = migrate_legacy_database(&state)?; + append_audit( + &state_dir, + "migrate-legacy-state", + "validated and migrated legacy state to schema 2; preserved backup", + )?; + println!( + "legacy state migration complete; protected backup: {}", + backup.display() + ); + Ok(()) +} + +fn parse_confirmed_existing_args(rest: Vec) -> Result<(PathBuf, bool)> { + let mut state_dir = None; + let mut confirmed = None; + let mut insecure = false; + let mut it = rest.into_iter(); + while let Some(flag) = it.next() { + match flag.as_str() { + "--state-dir" => state_dir = Some(next_path(&mut it, "--state-dir")?), + "--confirm-state-dir" => confirmed = Some(next_path(&mut it, "--confirm-state-dir")?), + "--insecure-plaintext-keys" => insecure = true, + other => bail!("unknown legacy migration flag {other:?}"), + } + } + Ok((confirmed_dir(state_dir, confirmed)?, insecure)) +} + +pub(crate) fn initialize_empty(rest: Vec) -> Result<()> { + let (state_dir, insecure) = parse_initialize_args(rest)?; + let state = load_or_create_identity(&state_dir, insecure)?; + append_audit( + &state_dir, + "initialize-empty-start", + "validated explicit state directory", + )?; + initialize_empty_state(&state)?; + append_audit( + &state_dir, + "initialize-empty", + "created identity-bound empty state", + )?; + println!("initialized empty state: {}", state_dir.display()); + Ok(()) +} + +pub(crate) fn restore_backup(rest: Vec) -> Result<()> { + let mut state_dir = None; + let mut backup = None; + let mut confirmed = None; + let mut insecure = false; + let mut it = rest.into_iter(); + while let Some(flag) = it.next() { + match flag.as_str() { + "--state-dir" => state_dir = Some(next_path(&mut it, "--state-dir")?), + "--backup" => backup = Some(next_path(&mut it, "--backup")?), + "--confirm-state-dir" => confirmed = Some(next_path(&mut it, "--confirm-state-dir")?), + "--insecure-plaintext-keys" => insecure = true, + other => bail!("unknown restore-backup flag {other:?}"), + } + } + let state_dir = confirmed_dir(state_dir, confirmed)?; + let backup = backup.context("--backup is required")?; + let state = load_existing_identity(&state_dir, insecure)?; + inspect_state_database(&state, &backup).context("validate backup before restore")?; + append_audit( + &state_dir, + "restore-backup-start", + &format!("validated backup {}", backup.display()), + )?; + + let active = state_dir.join("state.redb"); + let prior = state_dir.join("state.redb.before-restore"); + let staged = state_dir.join("state.redb.restore-staged"); + if prior.exists() || staged.exists() { + bail!("a prior restore artifact exists; inspect it before retrying"); + } + copy_durable(&backup, &staged)?; + inspect_state_database(&state, &staged).context("validate staged backup")?; + if active.exists() { + std::fs::rename(&active, &prior).context("preserve current state database")?; + } + if let Err(error) = std::fs::rename(&staged, &active) { + if prior.exists() { + let _ = std::fs::rename(&prior, &active); + } + return Err(error).context("activate restored state database"); + } + if let Err(error) = inspect_existing_state(&state) { + let failed = state_dir.join("state.redb.failed-restore"); + let _ = std::fs::rename(&active, &failed); + if prior.exists() { + std::fs::rename(&prior, &active).context("roll back failed restore")?; + } + sync_dir(&state_dir)?; + return Err(error).context("validate activated state database; prior state restored"); + } + sync_dir(&state_dir)?; + append_audit( + &state_dir, + "restore-backup", + &format!("restored {}; prior database preserved", backup.display()), + )?; + println!("restored state database: {}", active.display()); + if prior.exists() { + println!("prior database: {}", prior.display()); + } + Ok(()) +} + +pub(crate) fn reset_security_state(rest: Vec) -> Result<()> { + let (state_dir, insecure) = parse_reset_args(rest)?; + let state = load_existing_identity(&state_dir, insecure)?; + inspect_existing_state(&state).context("validate current state before reset")?; + + eprintln!("WARNING: reset-security-state clears rollback and replay history, fetch authorization, friendships, replica placement, and recovery ceremony state."); + eprintln!("The current database and encrypted blobs will move to a recoverable backup."); + eprintln!("Confirmed state directory: {}", state_dir.display()); + append_audit( + &state_dir, + "reset-security-state-start", + "validated current state and explicit destructive confirmation", + )?; + + let backup_dir = state_dir.join("security-reset-backup"); + if backup_dir.exists() { + bail!("reset backup already exists at {backup_dir:?}"); + } + create_private_dir(&backup_dir)?; + let active = state_dir.join("state.redb"); + let backup_db = backup_dir.join("state.redb"); + std::fs::rename(&active, &backup_db).context("preserve state database for reset")?; + let blobs = state_dir.join("blobs"); + let backup_blobs = backup_dir.join("blobs"); + if blobs.exists() { + if let Err(error) = std::fs::rename(&blobs, &backup_blobs) { + let _ = std::fs::rename(&backup_db, &active); + return Err(error).context("preserve blob store for reset"); + } + } + if let Err(error) = initialize_empty_state(&state) { + let _ = std::fs::remove_file(&active); + let _ = std::fs::rename(&backup_db, &active); + if backup_blobs.exists() { + let _ = std::fs::rename(&backup_blobs, &blobs); + } + return Err(error).context("initialize replacement security state"); + } + sync_dir(&state_dir)?; + append_audit( + &state_dir, + "reset-security-state", + "reset rollback, authorization, friendship, replica, and recovery state; preserved prior database and blobs", + )?; + println!( + "security state reset; recoverable backup: {}", + backup_dir.display() + ); + Ok(()) +} + +fn parse_inspect_args(rest: Vec) -> Result<(PathBuf, bool)> { + let mut state_dir = None; + let mut insecure = false; + let mut it = rest.into_iter(); + while let Some(flag) = it.next() { + match flag.as_str() { + "--state-dir" => { + state_dir = Some(it.next().context("--state-dir needs a value")?.into()) + } + "--insecure-plaintext-keys" => insecure = true, + other => bail!("unknown inspect-state flag {other:?}"), + } + } + Ok((state_dir.context("--state-dir is required")?, insecure)) +} + +fn parse_initialize_args(rest: Vec) -> Result<(PathBuf, bool)> { + let mut state_dir = None; + let mut confirmed = None; + let mut insecure = false; + let mut it = rest.into_iter(); + while let Some(flag) = it.next() { + match flag.as_str() { + "--state-dir" => state_dir = Some(next_path(&mut it, "--state-dir")?), + "--confirm-state-dir" => confirmed = Some(next_path(&mut it, "--confirm-state-dir")?), + "--insecure-plaintext-keys" => insecure = true, + other => bail!("unknown initialize-empty flag {other:?}"), + } + } + Ok((confirmed_dir(state_dir, confirmed)?, insecure)) +} + +fn parse_reset_args(rest: Vec) -> Result<(PathBuf, bool)> { + let mut state_dir = None; + let mut confirmed = None; + let mut phrase = None; + let mut insecure = false; + let mut it = rest.into_iter(); + while let Some(flag) = it.next() { + match flag.as_str() { + "--state-dir" => state_dir = Some(next_path(&mut it, "--state-dir")?), + "--confirm-state-dir" => confirmed = Some(next_path(&mut it, "--confirm-state-dir")?), + "--confirm-reset" => phrase = Some(it.next().context("--confirm-reset needs a value")?), + "--insecure-plaintext-keys" => insecure = true, + other => bail!("unknown reset-security-state flag {other:?}"), + } + } + if phrase.as_deref() != Some(RESET_CONFIRMATION) { + bail!("--confirm-reset must be exactly {RESET_CONFIRMATION:?}"); + } + Ok((confirmed_dir(state_dir, confirmed)?, insecure)) +} + +fn confirmed_dir(state_dir: Option, confirmed: Option) -> Result { + let state_dir = state_dir.context("--state-dir is required")?; + let confirmed = confirmed.context("--confirm-state-dir is required")?; + let state_dir = std::fs::canonicalize(&state_dir) + .with_context(|| format!("resolve state directory {state_dir:?}"))?; + let confirmed = std::fs::canonicalize(&confirmed) + .with_context(|| format!("resolve confirmed state directory {confirmed:?}"))?; + if state_dir != confirmed { + bail!("confirmed state directory does not match --state-dir"); + } + Ok(state_dir) +} + +fn next_path(it: &mut impl Iterator, flag: &str) -> Result { + Ok(it + .next() + .with_context(|| format!("{flag} needs a value"))? + .into()) +} + +fn load_or_create_identity(state_dir: &Path, insecure: bool) -> Result { + if insecure { + State::load_or_generate_insecure(state_dir) + } else { + State::load_or_generate(state_dir) + } +} + +fn load_existing_identity(state_dir: &Path, insecure: bool) -> Result { + let credential = state_dir.join("credential.id"); + let node = state_dir.join("node.key"); + let root = state_dir.join("root.key"); + + if insecure { + if !node.is_file() || !root.is_file() { + bail!( + "insecure state inspection requires existing root.key and node.key files in {state_dir:?}" + ); + } + return State::load_or_generate_insecure(state_dir); + } + + if !credential.is_file() { + bail!( + "secure state inspection requires an existing credential.id in {state_dir:?}; use --insecure-plaintext-keys only for an existing development state" + ); + } + if node.exists() || root.exists() { + bail!("legacy key files exist beside credential.id in {state_dir:?}"); + } + State::load_or_generate(state_dir) +} + +fn copy_durable(source: &Path, destination: &Path) -> Result<()> { + #[cfg(unix)] + let mut input = { + use rustix::fs::{openat, Mode, OFlags, CWD}; + let descriptor = openat( + CWD, + source, + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(std::io::Error::from) + .with_context(|| format!("open backup without following links {source:?}"))?; + std::fs::File::from(descriptor) + }; + #[cfg(not(unix))] + let mut input = + std::fs::File::open(source).with_context(|| format!("open backup {source:?}"))?; + let mut output = create_private_file(destination)?; + std::io::copy(&mut input, &mut output) + .with_context(|| format!("copy backup to {destination:?}"))?; + output.sync_all().context("sync staged state database")?; + sync_dir( + destination + .parent() + .context("staged database has no parent")?, + ) +} + +fn append_audit(state_dir: &Path, action: &str, detail: &str) -> Result<()> { + use std::io::Write; + let path = state_dir.join(AUDIT_LOG); + let mut file = open_private_append(&path)?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .context("system clock is before Unix epoch")? + .as_secs(); + let action = action.replace(['\t', '\r', '\n'], " "); + let detail = detail.replace(['\t', '\r', '\n'], " "); + writeln!(file, "{now}\t{action}\t{detail}").context("write operator audit record")?; + file.sync_all().context("sync operator audit record")?; + sync_dir(state_dir) +} + +#[cfg(unix)] +fn create_private_file(path: &Path) -> Result { + use std::os::unix::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .with_context(|| format!("create private file {path:?}")) +} + +#[cfg(not(unix))] +fn create_private_file(path: &Path) -> Result { + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .with_context(|| format!("create private file {path:?}")) +} + +#[cfg(unix)] +fn open_private_append(path: &Path) -> Result { + use std::os::unix::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .append(true) + .create(true) + .mode(0o600) + .open(path) + .with_context(|| format!("open private audit log {path:?}")) +} + +#[cfg(not(unix))] +fn open_private_append(path: &Path) -> Result { + std::fs::OpenOptions::new() + .append(true) + .create(true) + .open(path) + .with_context(|| format!("open audit log {path:?}")) +} + +#[cfg(unix)] +fn create_private_dir(path: &Path) -> Result<()> { + use std::os::unix::fs::DirBuilderExt; + std::fs::DirBuilder::new() + .mode(0o700) + .create(path) + .with_context(|| format!("create private backup directory {path:?}")) +} + +#[cfg(not(unix))] +fn create_private_dir(path: &Path) -> Result<()> { + std::fs::create_dir(path).with_context(|| format!("create backup directory {path:?}")) +} + +#[cfg(unix)] +fn sync_dir(path: &Path) -> Result<()> { + std::fs::File::open(path) + .with_context(|| format!("open directory {path:?} for sync"))? + .sync_all() + .with_context(|| format!("sync directory {path:?}")) +} + +#[cfg(not(unix))] +fn sync_dir(_path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inspect_requires_an_explicit_state_directory() { + let error = parse_inspect_args(Vec::new()).unwrap_err(); + assert!(error.to_string().contains("--state-dir is required")); + } + + #[test] + fn legacy_migration_requires_the_matching_state_directory() { + let root = tempfile::tempdir().unwrap(); + let state = root.path().join("state"); + let other = root.path().join("other"); + std::fs::create_dir(&state).unwrap(); + std::fs::create_dir(&other).unwrap(); + let missing = parse_confirmed_existing_args(vec![ + "--state-dir".to_string(), + state.display().to_string(), + ]) + .unwrap_err(); + assert!(missing + .to_string() + .contains("--confirm-state-dir is required")); + + let mismatch = parse_confirmed_existing_args(vec![ + "--state-dir".to_string(), + state.display().to_string(), + "--confirm-state-dir".to_string(), + other.display().to_string(), + ]) + .unwrap_err(); + assert!(mismatch + .to_string() + .contains("confirmed state directory does not match")); + } + + #[test] + fn inspect_refuses_to_create_an_identity() { + let dir = tempfile::tempdir().unwrap(); + let error = match load_existing_identity(dir.path(), false) { + Err(error) => error, + Ok(_) => panic!("inspection unexpectedly created an identity"), + }; + + assert!(error.to_string().contains("credential.id")); + assert!(!dir.path().join("credential.id").exists()); + assert!(!dir.path().join("root.key").exists()); + assert!(!dir.path().join("node.key").exists()); + } + + #[test] + fn insecure_inspect_requires_a_complete_existing_pair() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("root.key"), [0u8; 32]).unwrap(); + + let error = match load_existing_identity(dir.path(), true) { + Err(error) => error, + Ok(_) => panic!("inspection unexpectedly accepted an incomplete identity"), + }; + assert!(error.to_string().contains("root.key and node.key")); + assert!(!dir.path().join("node.key").exists()); + } + + fn insecure_args(command: &str, dir: &Path) -> Vec { + let mut args = vec![ + "--state-dir".to_string(), + dir.display().to_string(), + "--insecure-plaintext-keys".to_string(), + ]; + if matches!(command, "initialize" | "restore" | "reset") { + args.extend(["--confirm-state-dir".to_string(), dir.display().to_string()]); + } + if command == "reset" { + args.extend([ + "--confirm-reset".to_string(), + RESET_CONFIRMATION.to_string(), + ]); + } + args + } + + #[test] + fn initialize_creates_valid_state_and_an_audit_record() { + let dir = tempfile::tempdir().unwrap(); + initialize_empty(insecure_args("initialize", dir.path())).unwrap(); + + assert!(dir.path().join("state.redb").is_file()); + assert!(dir.path().join(AUDIT_LOG).is_file()); + inspect_state(insecure_args("inspect", dir.path())).unwrap(); + assert!(initialize_empty(insecure_args("initialize", dir.path())).is_err()); + } + + #[test] + fn reset_preserves_database_and_blob_data() { + let dir = tempfile::tempdir().unwrap(); + initialize_empty(insecure_args("initialize", dir.path())).unwrap(); + std::fs::create_dir(dir.path().join("blobs")).unwrap(); + std::fs::write(dir.path().join("blobs/retained"), b"ciphertext").unwrap(); + + reset_security_state(insecure_args("reset", dir.path())).unwrap(); + + let backup = dir.path().join("security-reset-backup"); + assert!(backup.join("state.redb").is_file()); + assert_eq!( + std::fs::read(backup.join("blobs/retained")).unwrap(), + b"ciphertext" + ); + assert!(dir.path().join("state.redb").is_file()); + inspect_state(insecure_args("inspect", dir.path())).unwrap(); + let audit = std::fs::read_to_string(dir.path().join(AUDIT_LOG)).unwrap(); + assert!(audit.contains("reset-security-state-start")); + assert!(audit.contains("\treset-security-state\t")); + } + + #[test] + fn reset_requires_the_exact_confirmation_phrase() { + let dir = tempfile::tempdir().unwrap(); + let mut args = insecure_args("restore", dir.path()); + args.extend(["--confirm-reset".to_string(), "yes".to_string()]); + + let error = parse_reset_args(args).unwrap_err(); + assert!(error.to_string().contains(RESET_CONFIRMATION)); + } + + #[test] + fn restore_validates_before_it_replaces_current_state() { + let dir = tempfile::tempdir().unwrap(); + initialize_empty(insecure_args("initialize", dir.path())).unwrap(); + let backup = dir.path().join("external-backup.redb"); + std::fs::copy(dir.path().join("state.redb"), &backup).unwrap(); + let mut args = insecure_args("restore", dir.path()); + args.extend(["--backup".to_string(), backup.display().to_string()]); + + restore_backup(args).unwrap(); + + assert!(dir.path().join("state.redb.before-restore").is_file()); + inspect_state(insecure_args("inspect", dir.path())).unwrap(); + assert!(std::fs::read_to_string(dir.path().join(AUDIT_LOG)) + .unwrap() + .contains("\trestore-backup\t")); + } + + #[test] + fn restore_and_reset_interruption_leave_start_without_completion() { + let restore_dir = tempfile::tempdir().unwrap(); + initialize_empty(insecure_args("initialize", restore_dir.path())).unwrap(); + let backup = restore_dir.path().join("external-backup.redb"); + std::fs::copy(restore_dir.path().join("state.redb"), &backup).unwrap(); + std::fs::write( + restore_dir.path().join("state.redb.restore-staged"), + b"stop", + ) + .unwrap(); + let mut restore_args = insecure_args("restore", restore_dir.path()); + restore_args.extend(["--backup".to_string(), backup.display().to_string()]); + assert!(restore_backup(restore_args).is_err()); + let restore_audit = std::fs::read_to_string(restore_dir.path().join(AUDIT_LOG)).unwrap(); + assert!(restore_audit.contains("restore-backup-start")); + assert!(!restore_audit.contains("\trestore-backup\t")); + + let reset_dir = tempfile::tempdir().unwrap(); + initialize_empty(insecure_args("initialize", reset_dir.path())).unwrap(); + std::fs::create_dir(reset_dir.path().join("security-reset-backup")).unwrap(); + assert!(reset_security_state(insecure_args("reset", reset_dir.path())).is_err()); + let reset_audit = std::fs::read_to_string(reset_dir.path().join(AUDIT_LOG)).unwrap(); + assert!(reset_audit.contains("reset-security-state-start")); + assert!(!reset_audit.contains("\treset-security-state\t")); + } +} diff --git a/crates/carapace-api/src/claimant.rs b/crates/carapace-api/src/claimant.rs new file mode 100644 index 0000000..1ff52c2 --- /dev/null +++ b/crates/carapace-api/src/claimant.rs @@ -0,0 +1,793 @@ +//! A separate loopback API for a device that does not yet have an identity. +//! +//! This router cannot call normal daemon handlers because its state has no `Daemon`. +//! The process owns the claimant ceremony key and node seed. The browser receives only +//! their public values. On completion, the process reconstructs `K_root` in memory and +//! sends it directly to the operating-system credential-store activation boundary. + +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, + sync::Arc, +}; + +use anyhow::{Context, Result}; +use axum::{ + extract::State, + http::{header, StatusCode}, + response::{IntoResponse, Response}, + Json, +}; +use carapace_wire::{messages::Message as _, messages::Signed as _, RecoveryOpen}; +use carapaced::{activate_recovered_identity, ClaimantDevice}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tokio::sync::Mutex; + +const CLAIMANT_HTML: &[u8] = include_bytes!("../static/claimant.html"); +const CLAIMANT_SCRIPT: &[u8] = include_bytes!("../static/claimant.js"); +const CLAIMANT_STYLESHEET: &[u8] = include_bytes!("../static/claimant.css"); + +fn claimant_asset(path: &str) -> Option<&'static [u8]> { + match path { + "claimant.html" => Some(CLAIMANT_HTML), + "claimant.js" => Some(CLAIMANT_SCRIPT), + "claimant.css" => Some(CLAIMANT_STYLESHEET), + _ => None, + } +} + +/// State for the claimant-only server. It deliberately has no normal daemon. +#[derive(Clone)] +pub struct ClaimantState { + pub(crate) claimant: Arc>>, + pub(crate) state_dir: Arc, + pub(crate) token: Arc, + persist_session: bool, +} + +impl ClaimantState { + pub(crate) fn new(state_dir: PathBuf, token: Arc) -> Result { + Ok(Self { + claimant: Arc::new(Mutex::new(Some(ClaimantDevice::load_or_create( + &state_dir, + )?))), + state_dir: Arc::new(state_dir), + token, + persist_session: true, + }) + } +} + +#[derive(Clone, Deserialize, Serialize)] +pub(crate) struct TrusteeAddress { + node: String, + #[serde(default)] + addrs: Vec, + #[serde(default)] + relay_url: Option, +} + +#[derive(Clone, Deserialize, Serialize)] +pub(crate) struct RecoveryAnnounceRef { + vid: String, + epoch: u64, + digest: String, +} + +#[derive(Deserialize)] +pub(crate) struct CompleteRequest { + open_hex: String, + #[serde(default)] + sponsor_sig: String, + confirmed_subject: String, + roster: Vec, + trustees: Vec, + #[serde(default)] + announce_refs: Vec, +} + +#[derive(Deserialize)] +pub(crate) struct PreviewRequest { + open_hex: String, +} + +fn bad(message: impl Into) -> ClaimantError { + ClaimantError(StatusCode::BAD_REQUEST, message.into()) +} + +#[derive(Debug)] +pub(crate) struct ClaimantError(StatusCode, String); + +impl IntoResponse for ClaimantError { + fn into_response(self) -> Response { + crate::handlers::error_response(self.0, self.1) + } +} + +/// Serve the claimant-only shell. The token is a fixed-length hexadecimal value. +pub(crate) async fn shell(State(state): State) -> Response { + let Some(asset) = claimant_asset("claimant.html") else { + return StatusCode::NOT_FOUND.into_response(); + }; + let html = String::from_utf8_lossy(asset).replace("__CARAPACE_CLAIMANT_TOKEN__", &state.token); + ( + [ + (header::CONTENT_TYPE, "text/html; charset=utf-8"), + (header::CACHE_CONTROL, "no-store"), + (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), + ( + header::CONTENT_SECURITY_POLICY, + "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'", + ), + ], + html, + ) + .into_response() +} + +/// Serve one fixed claimant asset. No normal GUI asset fallback is available here. +pub(crate) async fn asset(path: &'static str, content_type: &'static str) -> Response { + let Some(asset) = claimant_asset(path) else { + return StatusCode::NOT_FOUND.into_response(); + }; + ( + [ + (header::CONTENT_TYPE, content_type), + (header::CACHE_CONTROL, "no-store"), + (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), + ], + asset, + ) + .into_response() +} + +pub(crate) async fn script() -> Response { + asset("claimant.js", "text/javascript; charset=utf-8").await +} + +pub(crate) async fn stylesheet() -> Response { + asset("claimant.css", "text/css; charset=utf-8").await +} + +/// Return the public inputs that a sponsor must put in `RecoveryOpen`. +pub(crate) async fn status(State(state): State) -> Json { + let guard = state.claimant.lock().await; + match guard.as_ref() { + Some(claimant) => { + let ceremony_enc = hex::encode(claimant.ceremony_enc()); + let new_node = hex::encode(claimant.new_node()); + let handoff = json!({ + "type": "carapace.claimant-handoff", + "version": 1, + "ceremony_enc": ceremony_enc.clone(), + "new_node": new_node.clone(), + }) + .to_string(); + Json(json!({ + "phase": "waiting_for_open", + "handoff": handoff, + "ceremony_enc": ceremony_enc, + "new_node": new_node, + })) + } + None => Json(json!({ + "phase": "activation_complete", + "restart_required": true, + })), + } +} + +/// Cancel the current attempt, drop its secret keys, and create a fresh retry session. +pub(crate) async fn cancel( + State(state): State, +) -> Result, ClaimantError> { + let mut guard = state.claimant.lock().await; + if guard.is_none() { + return Err(ClaimantError( + StatusCode::CONFLICT, + "claimant activation is complete or active".into(), + )); + } + let fresh = (if state.persist_session { + ClaimantDevice::reset_persisted(&state.state_dir) + } else { + ClaimantDevice::new() + }) + .map_err(|_| { + ClaimantError( + StatusCode::INTERNAL_SERVER_ERROR, + "could not create a fresh claimant session".into(), + ) + })?; + let old = guard.replace(fresh); + drop(old); + crate::ops::log("claimant.cancelled", None); + Ok(Json(json!({ + "phase": "waiting_for_open", + "cancelled": true, + "retry_ready": true, + }))) +} + +fn decode_open(open_hex: &str) -> Result { + let open_bytes = + hex::decode(open_hex).map_err(|error| bad(format!("invalid open hex: {error}")))?; + let open = RecoveryOpen::decode_frame(&open_bytes) + .map_err(|error| bad(format!("invalid recovery open: {error}")))?; + open.verify() + .map_err(|_| bad("the recovery open signature is invalid"))?; + Ok(open) +} + +/// Verify a signed open and show its subject before any share collection or activation. +pub(crate) async fn preview( + State(state): State, + Json(request): Json, +) -> Result, ClaimantError> { + let open = decode_open(&request.open_hex)?; + let guard = state.claimant.lock().await; + let claimant = guard.as_ref().ok_or_else(|| { + ClaimantError( + StatusCode::CONFLICT, + "claimant activation is complete".into(), + ) + })?; + if open.ceremony_enc != claimant.ceremony_enc() || open.new_node != claimant.new_node() { + return Err(bad("the recovery open does not name this claimant session")); + } + Ok(Json(json!({ + "subject": hex::encode(open.subject), + "sponsor": hex::encode(open.by), + "claimant_display": open.claimant_display, + "reason": open.reason, + "session_bound": true, + }))) +} + +/// Collect sealed shares, reconstruct in memory, and activate secure local state. +pub(crate) async fn complete( + State(state): State, + Json(request): Json, +) -> Result, ClaimantError> { + let open = decode_open(&request.open_hex)?; + let confirmed_subject = decode_32("confirmed subject", &request.confirmed_subject)?; + if confirmed_subject != open.subject { + return Err(bad( + "the confirmed subject does not match the signed recovery open", + )); + } + let roster = request + .roster + .iter() + .map(|value| decode_32("roster user", value)) + .collect::, _>>()?; + let trustees = request + .trustees + .iter() + .map(|trustee| { + Ok(( + decode_32("trustee node", &trustee.node)?, + trustee.addrs.clone(), + trustee.relay_url.clone(), + )) + }) + .collect::, ClaimantError>>()?; + if roster.is_empty() { + return Err(bad("the trustee roster is empty")); + } + if !roster.contains(&open.by) { + return Err(bad("the recovery-open signer is not in the trustee roster")); + } + if trustees.is_empty() { + return Err(bad("the trustee address list is empty")); + } + + // Take the claimant out during the network operation. This prevents two requests + // from collecting and activating the same ceremony at the same time. + let claimant = state.claimant.lock().await.take().ok_or_else(|| { + ClaimantError( + StatusCode::CONFLICT, + "claimant activation is already complete or active".into(), + ) + })?; + + if open.ceremony_enc != claimant.ceremony_enc() || open.new_node != claimant.new_node() { + *state.claimant.lock().await = Some(claimant); + return Err(bad("the recovery open does not name this claimant session")); + } + + let refs = request + .announce_refs + .iter() + .map(|reference| { + Ok(carapace_wire::AnnounceRef { + vid: decode_32("vault id", &reference.vid)?, + epoch: reference.epoch, + digest: decode_32("manifest digest", &reference.digest)?, + }) + }) + .collect::, ClaimantError>>(); + let signature = hex::decode(&request.sponsor_sig) + .ok() + .and_then(|bytes| <[u8; 64]>::try_from(bytes).ok()); + let verified = refs + .as_ref() + .ok() + .zip(signature.as_ref()) + .is_some_and(|(refs, signature)| { + carapaced::verify_claimant_package(&open, &roster, &trustees, refs, signature).is_ok() + }); + if !verified { + *state.claimant.lock().await = Some(claimant); + return Err(bad("the sponsor package metadata signature is invalid")); + } + let recovered = match claimant + .recover_at_with_relays(&open, &roster, &trustees) + .await + { + Ok(recovered) => recovered, + Err(error) => { + let _ = error; + crate::ops::log("claimant.recovery_failed", None); + *state.claimant.lock().await = Some(claimant); + return Err(ClaimantError( + StatusCode::BAD_GATEWAY, + "recovery did not complete".into(), + )); + } + }; + if recovered.user_id != open.subject || recovered.new_node != open.new_node { + *state.claimant.lock().await = Some(claimant); + return Err(bad( + "the recovered identity does not match the recovery open", + )); + } + + let user_id = recovered.user_id; + let new_node = recovered.new_node; + let node_seed = claimant.node_seed(); + if persist_restart_handoff( + &state.state_dir, + &request, + &carapaced::State::from_seeds(node_seed, *recovered.k_root), + ) + .is_err() + { + *state.claimant.lock().await = Some(claimant); + return Err(ClaimantError( + StatusCode::INTERNAL_SERVER_ERROR, + "the restart recovery handoff could not be saved".into(), + )); + } + let activated = + match activate_recovered_identity(&state.state_dir, node_seed, *recovered.k_root) + .context("activate recovered identity") + { + Ok(activated) => activated, + Err(error) => { + let _ = error; + let _ = fs::remove_file(state.state_dir.join("recovery-restart-handoff.json")); + crate::ops::log("claimant.activation_failed", None); + *state.claimant.lock().await = Some(claimant); + return Err(ClaimantError( + StatusCode::INTERNAL_SERVER_ERROR, + "recovered identity activation failed".into(), + )); + } + }; + drop(activated); + drop(recovered); + drop(claimant); + + Ok(Json(json!({ + "phase": "activation_complete", + "user_id": hex::encode(user_id), + "new_node": hex::encode(new_node), + "restart_required": true, + }))) +} + +#[derive(Serialize, Deserialize)] +struct RestartHandoff { + r#type: String, + version: u8, + open_hex: String, + sponsor_sig: String, + roster: Vec, + trustees: Vec, + announce_refs: Vec, +} + +fn persist_restart_handoff( + state_dir: &Path, + request: &CompleteRequest, + identity: &carapaced::State, +) -> Result<()> { + carapaced::State::secure_directory(state_dir)?; + let bytes = serde_json::to_vec(&RestartHandoff { + r#type: "carapace.recovery-restart-handoff".into(), + version: 2, + open_hex: request.open_hex.clone(), + sponsor_sig: request.sponsor_sig.clone(), + roster: request.roster.clone(), + trustees: request.trustees.clone(), + announce_refs: request.announce_refs.clone(), + })?; + let sealed = identity.seal_recovery_handoff(&bytes)?; + let paths = [ + state_dir.join("recovery-restart-handoff.json"), + state_dir.join("recovery-restart-handoff.seal"), + ]; + for path in &paths { + match fs::symlink_metadata(path) { + Ok(metadata) => anyhow::ensure!( + !metadata.file_type().is_symlink(), + "handoff path is a symlink" + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + for (path, contents) in paths.iter().zip([bytes.as_slice(), sealed.as_slice()]) { + // Random create-new names inherit the private directory ACL on Windows; + // tempfile creates mode 0600 files on Unix. Persist atomically replaces + // an earlier handoff on both platforms, including activation retries. + let mut temporary = tempfile::NamedTempFile::new_in(state_dir)?; + temporary.write_all(contents)?; + temporary.as_file().sync_all()?; + temporary.persist(path)?; + } + #[cfg(unix)] + fs::File::open(state_dir)?.sync_all()?; + Ok(()) +} + +pub(crate) fn restart_metadata( + bytes: &[u8], + subject: [u8; 32], +) -> Result<( + Vec, + Vec, +)> { + let handoff: RestartHandoff = serde_json::from_slice(bytes)?; + anyhow::ensure!( + handoff.r#type == "carapace.recovery-restart-handoff" && handoff.version == 2, + "unsupported unsigned restart handoff" + ); + let open = RecoveryOpen::decode_frame(&hex::decode(handoff.open_hex)?)?; + anyhow::ensure!( + open.subject == subject, + "restart handoff belongs to another account" + ); + let key = |text: &str| -> Result<[u8; 32]> { + hex::decode(text)? + .try_into() + .map_err(|_| anyhow::anyhow!("invalid public key length")) + }; + let roster = handoff + .roster + .iter() + .map(|user| key(user)) + .collect::>>()?; + let trustees = handoff + .trustees + .into_iter() + .map(|t| Ok((key(&t.node)?, t.addrs, t.relay_url))) + .collect::>>()?; + let refs = handoff + .announce_refs + .into_iter() + .map(|r| { + Ok(carapace_wire::AnnounceRef { + vid: key(&r.vid)?, + epoch: r.epoch, + digest: key(&r.digest)?, + }) + }) + .collect::>>()?; + let signature = hex::decode(handoff.sponsor_sig)? + .try_into() + .map_err(|_| anyhow::anyhow!("invalid sponsor signature length"))?; + carapaced::verify_claimant_package(&open, &roster, &trustees, &refs, &signature)?; + Ok((trustees, refs)) +} + +fn decode_32(label: &str, value: &str) -> Result<[u8; 32], ClaimantError> { + let bytes = hex::decode(value).map_err(|error| bad(format!("invalid {label} hex: {error}")))?; + bytes + .try_into() + .map_err(|_| bad(format!("{label} must contain 32 bytes"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{body::to_bytes, http::Request}; + use ed25519_dalek::SigningKey; + use tower::ServiceExt; + + fn test_state(dir: PathBuf) -> ClaimantState { + ClaimantState { + claimant: Arc::new(Mutex::new(Some(ClaimantDevice::new().unwrap()))), + state_dir: Arc::new(dir), + token: Arc::from("test-token"), + persist_session: false, + } + } + + #[tokio::test] + async fn status_exposes_only_public_claimant_values() { + let dir = tempfile::tempdir().unwrap(); + let Json(value) = status(State(test_state(dir.path().to_path_buf()))).await; + let object = value.as_object().unwrap(); + assert_eq!(object.get("phase").unwrap(), "waiting_for_open"); + assert_eq!( + object.get("ceremony_enc").unwrap().as_str().unwrap().len(), + 64 + ); + assert_eq!(object.get("new_node").unwrap().as_str().unwrap().len(), 64); + let handoff: Value = serde_json::from_str(object["handoff"].as_str().unwrap()).unwrap(); + assert_eq!(handoff["type"], "carapace.claimant-handoff"); + assert_eq!(handoff["version"], 1); + assert_eq!(handoff["ceremony_enc"], object["ceremony_enc"]); + assert_eq!(handoff["new_node"], object["new_node"]); + for secret_name in ["k_root", "node_seed", "ceremony_private", "shares"] { + assert!( + !object.contains_key(secret_name), + "status exposed {secret_name}" + ); + } + } + + #[tokio::test] + async fn complete_rejects_an_open_for_another_claimant_before_network_access() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path().to_path_buf()); + let sponsor = SigningKey::from_bytes(&[5; 32]); + let mut open = RecoveryOpen { + ceremony_id: [1; 16], + subject: [2; 32], + rsid: 1, + claimant_display: "Test claimant".into(), + ceremony_enc: [3; 32], + new_node: [4; 32], + reason: "Test recovery".into(), + opened_at: 1, + by: [0; 32], + sig: [0; 64], + }; + open.sign(&sponsor); + let request = CompleteRequest { + sponsor_sig: String::new(), + open_hex: hex::encode(open.encode_frame()), + confirmed_subject: hex::encode(open.subject), + roster: vec![hex::encode(sponsor.verifying_key().to_bytes())], + trustees: vec![TrusteeAddress { + node: hex::encode([6; 32]), + relay_url: None, + addrs: Vec::new(), + }], + announce_refs: Vec::new(), + }; + let error = complete(State(state.clone()), Json(request)) + .await + .unwrap_err(); + assert_eq!(error.0, StatusCode::BAD_REQUEST); + assert!(error.1.contains("does not name this claimant session")); + assert!( + state.claimant.lock().await.is_some(), + "claimant must remain usable" + ); + } + + #[tokio::test] + async fn complete_rejects_an_invalid_open_signature_before_network_access() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path().to_path_buf()); + let (ceremony_enc, new_node) = { + let guard = state.claimant.lock().await; + let claimant = guard.as_ref().unwrap(); + (claimant.ceremony_enc(), claimant.new_node()) + }; + let sponsor = SigningKey::from_bytes(&[7; 32]); + let open = RecoveryOpen { + ceremony_id: [1; 16], + subject: [2; 32], + rsid: 1, + claimant_display: "Test claimant".into(), + ceremony_enc, + new_node, + reason: "Test recovery".into(), + opened_at: 1, + by: sponsor.verifying_key().to_bytes(), + sig: [0; 64], + }; + let request = CompleteRequest { + sponsor_sig: String::new(), + open_hex: hex::encode(open.encode_frame()), + confirmed_subject: hex::encode(open.subject), + roster: vec![hex::encode(sponsor.verifying_key().to_bytes())], + trustees: vec![TrusteeAddress { + node: hex::encode([6; 32]), + relay_url: None, + addrs: Vec::new(), + }], + announce_refs: Vec::new(), + }; + let error = complete(State(state.clone()), Json(request)) + .await + .unwrap_err(); + assert_eq!(error.0, StatusCode::BAD_REQUEST); + assert_eq!(error.1, "the recovery open signature is invalid"); + assert!(state.claimant.lock().await.is_some()); + } + + #[tokio::test] + async fn preview_shows_the_signed_subject_and_complete_requires_that_confirmation() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path().to_path_buf()); + let (ceremony_enc, new_node) = { + let guard = state.claimant.lock().await; + let claimant = guard.as_ref().unwrap(); + (claimant.ceremony_enc(), claimant.new_node()) + }; + let sponsor = SigningKey::from_bytes(&[8; 32]); + let mut open = RecoveryOpen { + ceremony_id: [1; 16], + subject: [9; 32], + rsid: 1, + claimant_display: "Test claimant".into(), + ceremony_enc, + new_node, + reason: "Test recovery".into(), + opened_at: 1, + by: [0; 32], + sig: [0; 64], + }; + open.sign(&sponsor); + let open_hex = hex::encode(open.encode_frame()); + let Json(value) = preview( + State(state.clone()), + Json(PreviewRequest { + open_hex: open_hex.clone(), + }), + ) + .await + .unwrap(); + assert_eq!(value["subject"], hex::encode(open.subject)); + assert_eq!(value["session_bound"], true); + + let error = complete( + State(state.clone()), + Json(CompleteRequest { + sponsor_sig: String::new(), + open_hex, + confirmed_subject: hex::encode([7; 32]), + roster: vec![hex::encode(sponsor.verifying_key().to_bytes())], + trustees: vec![TrusteeAddress { + node: hex::encode([6; 32]), + relay_url: None, + addrs: Vec::new(), + }], + announce_refs: Vec::new(), + }), + ) + .await + .unwrap_err(); + assert_eq!(error.0, StatusCode::BAD_REQUEST); + assert!(error.1.contains("confirmed subject does not match")); + assert!(state.claimant.lock().await.is_some()); + } + + #[tokio::test] + async fn claimant_router_serves_only_the_claimant_shell_and_api() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path().to_path_buf()); + let app = crate::claimant_app(state); + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/") + .header(header::HOST, "127.0.0.1") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + let body = to_bytes(response.into_body(), 1_000_000).await.unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("Claimant mode")); + assert!(html.contains("carapace-claimant-token")); + for forbidden in ["K_root", "node_seed", "ceremony_private", "share_json"] { + assert!( + !html.contains(forbidden), + "claimant shell exposed {forbidden}" + ); + } + + let response = app + .oneshot( + Request::builder() + .uri("/api/status") + .header(header::HOST, "127.0.0.1") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn cancellation_replaces_keys_and_leaves_a_safe_retry_session() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path().to_path_buf()); + let before = state.claimant.lock().await.as_ref().unwrap().ceremony_enc(); + let Json(result) = cancel(State(state.clone())).await.unwrap(); + let after = state.claimant.lock().await.as_ref().unwrap().ceremony_enc(); + assert_ne!(before, after); + assert_eq!(result["cancelled"], true); + assert_eq!(result["retry_ready"], true); + } + + #[test] + fn restart_handoff_is_public_versioned_and_private_on_unix() { + let dir = tempfile::tempdir().unwrap(); + let trustees = vec![TrusteeAddress { + node: hex::encode([3; 32]), + relay_url: None, + addrs: vec!["127.0.0.1:9000".into()], + }]; + let refs = vec![RecoveryAnnounceRef { + vid: hex::encode([4; 32]), + epoch: 7, + digest: hex::encode([5; 32]), + }]; + let mut request = CompleteRequest { + open_hex: String::new(), + sponsor_sig: String::new(), + confirmed_subject: String::new(), + roster: vec![], + trustees, + announce_refs: refs, + }; + persist_restart_handoff( + dir.path(), + &request, + &carapaced::State::from_seeds([1; 32], [2; 32]), + ) + .unwrap(); + let path = dir.path().join("recovery-restart-handoff.json"); + let text = fs::read_to_string(&path).unwrap(); + assert!(text.contains("carapace.recovery-restart-handoff")); + assert!(text.contains("\"epoch\":7")); + for secret in ["k_root", "node_seed", "share_json", "ceremony_private"] { + assert!(!text.contains(secret)); + } + let seal_path = dir.path().join("recovery-restart-handoff.seal"); + let first_seal = fs::read(&seal_path).unwrap(); + request.announce_refs[0].epoch = 8; + persist_restart_handoff( + dir.path(), + &request, + &carapaced::State::from_seeds([1; 32], [2; 32]), + ) + .unwrap(); + let retried: RestartHandoff = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_eq!(retried.announce_refs[0].epoch, 8); + assert_ne!(fs::read(&seal_path).unwrap(), first_seal); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } +} diff --git a/crates/carapace-api/src/handlers.rs b/crates/carapace-api/src/handlers.rs index 5f58cd6..d1371ab 100644 --- a/crates/carapace-api/src/handlers.rs +++ b/crates/carapace-api/src/handlers.rs @@ -10,15 +10,16 @@ use axum::{ ws::{Message, WebSocket, WebSocketUpgrade}, Path, Query, State, }, - http::{header, StatusCode, Uri}, + http::{header, HeaderMap, StatusCode, Uri}, response::{IntoResponse, Response}, Json, }; use carapace_wire::messages::Message as _; use carapace_wire::{FileGrant, InviteTicket}; use carapaced::{Daemon, PendingResplitStatus, RecoveryScope, ResplitStatus}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use std::sync::atomic::{AtomicU64, Ordering}; use crate::{auth, AppState}; @@ -40,7 +41,7 @@ pub async fn static_asset(State(st): State, uri: Uri) -> Response { // An unmatched `/api/*` path is a missing endpoint, not a client route: 404 JSON, // never the token-injected shell. Only non-`/api` paths get the SPA fallback. if path == "api" || path.starts_with("api/") { - return (StatusCode::NOT_FOUND, Json(json!({ "error": "not found" }))).into_response(); + return error_response(StatusCode::NOT_FOUND, "not found"); } // `index.html` must always route through injection, never be served raw. if path.is_empty() || path == "index.html" { @@ -120,12 +121,14 @@ fn serve_index(token: &str) -> Response { base-uri 'none'; \ frame-ancestors 'none'" ); + let cookie = format!("carapace_session={token}; HttpOnly; SameSite=Strict; Path=/api/events"); ( [ (header::CONTENT_TYPE, "text/html; charset=utf-8"), (header::CONTENT_SECURITY_POLICY, csp.as_str()), (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), (header::CACHE_CONTROL, "no-store"), + (header::SET_COOKIE, cookie.as_str()), ], rendered, ) @@ -134,24 +137,92 @@ fn serve_index(token: &str) -> Response { // ---- error type -------------------------------------------------------- +/// Sequence for references that connect a safe client error to the server log. +static ERROR_REFERENCE: AtomicU64 = AtomicU64::new(1); + /// A handler error rendered as a JSON body with an HTTP status. -pub struct ApiError(StatusCode, String); +pub struct ApiError { + status: StatusCode, + client_message: String, + internal: Option, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ApiErrorCode { + BadRequest, + Unauthorized, + Forbidden, + NotFound, + Conflict, + UpstreamFailure, + Internal, +} + +#[derive(Serialize)] +pub(crate) struct ApiErrorBody { + pub(crate) code: ApiErrorCode, + pub(crate) error: String, +} + +pub(crate) fn error_response(status: StatusCode, message: impl Into) -> Response { + let code = match status { + StatusCode::BAD_REQUEST => ApiErrorCode::BadRequest, + StatusCode::UNAUTHORIZED => ApiErrorCode::Unauthorized, + StatusCode::FORBIDDEN => ApiErrorCode::Forbidden, + StatusCode::NOT_FOUND => ApiErrorCode::NotFound, + StatusCode::CONFLICT => ApiErrorCode::Conflict, + StatusCode::BAD_GATEWAY | StatusCode::SERVICE_UNAVAILABLE => ApiErrorCode::UpstreamFailure, + _ => ApiErrorCode::Internal, + }; + ( + status, + Json(ApiErrorBody { + code, + error: message.into(), + }), + ) + .into_response() +} + +impl ApiError { + fn client(status: StatusCode, message: impl Into) -> Self { + Self { + status, + client_message: message.into(), + internal: None, + } + } +} impl IntoResponse for ApiError { fn into_response(self) -> Response { - (self.0, Json(json!({ "error": self.1 }))).into_response() + let message = match self.internal { + Some(detail) => { + let reference = ERROR_REFERENCE.fetch_add(1, Ordering::Relaxed); + let _ = detail; + crate::ops::log("api.internal_error", Some(reference)); + format!("internal server error; reference {reference}") + } + None => self.client_message, + }; + error_response(self.status, message) } } impl From for ApiError { fn from(e: anyhow::Error) -> Self { - ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")) + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + client_message: "internal server error".to_string(), + internal: Some(format!("{e:#}")), + } } } /// A 400 from a bad-input message. fn bad(msg: impl Into) -> ApiError { - ApiError(StatusCode::BAD_REQUEST, msg.into()) + ApiError::client(StatusCode::BAD_REQUEST, msg) } fn hexs(b: &[u8]) -> String { @@ -184,47 +255,257 @@ pub async fn health() -> Json { Json(json!({ "ok": true })) } +#[derive(Clone, Debug, Serialize)] +pub struct PeerOptionResponse { + user: String, + display: String, + node: String, + addrs: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct PublishedVaultResponse { + vid: String, + epoch: u64, + name: String, + dir: String, + watching: bool, + syncing: bool, + last_error: Option, + last_success: Option, + recovery_backup: Option, +} + +#[derive(Clone, Debug, Serialize)] +struct FriendGrantResponse { + user: String, + grant_bytes: u64, +} +#[derive(Clone, Debug, Serialize)] +struct FriendsResponse { + count: usize, + list: Vec, + grants: Vec, +} +#[derive(Clone, Debug, Serialize)] +struct VaultsResponse { + published: Vec, + held_replicas: Vec, +} +#[derive(Clone, Debug, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum RecoveryScopeResponse { + Root, + Vault { vid: String }, +} +#[derive(Clone, Debug, Serialize)] +struct TrusteeDeliveryResponse { + user: String, + delivered: bool, +} +#[derive(Clone, Debug, Serialize)] +struct RecoverySetResponse { + rsid: u64, + scope: RecoveryScopeResponse, + threshold: usize, + issued: usize, + trustees: Vec, + warnings: Vec, +} +#[derive(Clone, Debug, Serialize)] +struct RecoveryHealthResponse { + rsid: u64, + live: usize, + target: usize, + recommendation: String, + needed: usize, +} +#[derive(Clone, Debug, Serialize)] +struct ShareHealthResponse { + recovery_sets_owned: usize, + shares_held: usize, + sets: Vec, + recovery: Vec, +} +#[derive(Clone, Debug, Serialize)] +struct AnnounceRefResponse { + vid: String, + epoch: u64, +} +#[derive(Clone, Debug, Serialize)] +struct MintedGrantResponse { + rsid: u64, + subject: String, + trustees: Vec, + refs: Vec, +} +#[derive(Clone, Debug, Serialize)] +struct RecoveryGrantsResponse { + minted: Vec, + held: Vec, +} +#[derive(Clone, Debug, Serialize)] +struct CeremonyResponse { + ceremony_id: String, + subject: String, + sponsor: String, + claimant_display: String, + reason: String, + phase: String, + approvals: usize, + threshold: usize, + is_self_subject: bool, + takeover: bool, + trustee: bool, + approved: bool, + alarm: bool, +} +#[derive(Clone, Debug, Serialize)] +struct ResplitFriendResponse { + node: String, + role: String, + online: bool, + done: bool, + status: &'static str, +} +#[derive(Clone, Debug, Serialize)] +pub(crate) struct ResplitResponse { + old_rsid: u64, + new_rsid: u64, + ex_trustee: String, + phase: String, + new_attested: usize, + new_total: usize, + new_set_live: bool, + old_destroyed: usize, + old_total: usize, + remaining: Vec, +} +#[derive(Clone, Debug, Serialize)] +struct PendingTrusteeResponse { + user: String, + node: Option, + online: bool, +} +#[derive(Clone, Debug, Serialize)] +struct PendingResplitResponse { + old_rsid: u64, + ex_trustee: String, + suggested: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct StatusSnapshot { + user_id: String, + identity_storage: String, + sync_errors: Vec, + node_id: String, + addr: Vec, + relay_url: Option, + friends: FriendsResponse, + peers: Vec, + vaults: VaultsResponse, + share_health: ShareHealthResponse, + recovery_grants: RecoveryGrantsResponse, + ceremonies: Vec, + resplits: Vec, + pending_resplits: Vec, + reachability: &'static str, + relay_networks: usize, + relay_diversity_warning: bool, + por_latency_anomaly_count: usize, +} + /// Build the status snapshot pushed over WS and returned by `GET /api/status`. -fn status_snapshot(d: &Daemon) -> Value { +fn status_snapshot(d: &Daemon) -> StatusSnapshot { let friends: Vec = d.friend_ids().iter().map(|f| hexs(f)).collect(); - let vaults: Vec = d - .published_vaults() + let friend_grants: Vec = d + .friend_grants() .iter() - .map(|(v, e)| json!({ "vid": hexs(v), "epoch": e })) + .map(|report| FriendGrantResponse { + user: hexs(&report.user), + grant_bytes: report.grant_bytes, + }) + .collect(); + let vaults = vault_rows(d); + let peers = d + .peer_options() + .iter() + .map(|peer| PeerOptionResponse { + user: hexs(&peer.user), + display: peer.display.clone(), + node: hexs(&peer.node), + addrs: peer.addrs.clone(), + }) .collect(); let held: Vec = d.held_replica_vids().iter().map(|v| hexs(v)).collect(); let (sets, shares) = d.share_health_counts(); + let recovery_sets: Vec = d + .recovery_sets() + .iter() + .map(|set| { + let scope = match set.scope { + RecoveryScope::Root => RecoveryScopeResponse::Root, + RecoveryScope::Vault(vid) => RecoveryScopeResponse::Vault { vid: hexs(&vid) }, + }; + RecoverySetResponse { + rsid: set.rsid, + scope, + threshold: set.threshold, + issued: set.issued, + trustees: set + .trustees + .iter() + .map(|(user, delivered)| TrusteeDeliveryResponse { + user: hexs(user), + delivered: *delivered, + }) + .collect(), + warnings: set + .warnings + .iter() + .map(|warning| format!("{warning:?}")) + .collect(), + } + }) + .collect(); // W4 (§10.2): per owned recovery set, the attested-live count, target, and drift // recommendation (healthy / extend / resplit) the maintenance loop keeps current. - let recovery: Vec = d + let recovery: Vec = d .recovery_health() .iter() - .map(|r| { - json!({ - "rsid": r.rsid, - "live": r.live, - "target": r.target, - "recommendation": r.recommendation, - "needed": r.needed, - }) + .map(|r| RecoveryHealthResponse { + rsid: r.rsid, + live: r.live, + target: r.target, + recommendation: r.recommendation.to_owned(), + needed: r.needed, }) .collect(); // W3 (§8, §7.3): per owned recovery set, which trustees hold a minted ShareGrant // and the announce-ref freshness (vid + epoch) the maintenance loop keeps current. - let grants: Vec = d + let grants: Vec = d .recovery_grants() .iter() - .map(|g| { - json!({ - "rsid": g.rsid, - "subject": hexs(&g.subject), - "trustees": g.trustees.iter() - .map(|(u, delivered)| json!({ "user": hexs(u), "delivered": delivered })) - .collect::>(), - "refs": g.refs.iter() - .map(|(vid, epoch)| json!({ "vid": hexs(vid), "epoch": epoch })) - .collect::>(), - }) + .map(|g| MintedGrantResponse { + rsid: g.rsid, + subject: hexs(&g.subject), + trustees: g + .trustees + .iter() + .map(|(u, delivered)| TrusteeDeliveryResponse { + user: hexs(u), + delivered: *delivered, + }) + .collect(), + refs: g + .refs + .iter() + .map(|(vid, epoch)| AnnounceRefResponse { + vid: hexs(vid), + epoch: *epoch, + }) + .collect(), }) .collect(); // W3 trustee side: subject users whose grants this daemon holds for others. @@ -233,71 +514,261 @@ fn status_snapshot(d: &Daemon) -> Value { // W4 (§6 MUST): warn when the usable relay set spans fewer than 2 distinct // networks - a single relay is a single point of failure and metadata choke. let relay_networks = d.relay_network_count(); - json!({ - "node_id": hexs(&d.node_id()), - "addr": d.dialable_addr_strings(), - "relay_url": relay_url, - "friends": { "count": friends.len(), "list": friends }, - "vaults": { "published": vaults, "held_replicas": held }, - "share_health": { "recovery_sets_owned": sets, "shares_held": shares, "recovery": recovery }, - "recovery_grants": { "minted": grants, "held": held_grants }, - // W2 (§8.5): live recovery ceremonies + the anti-silent-takeover alarm. - "ceremonies": ceremony_rows(d), - // W5 (§9.3 step 4): open trustee re-splits after an unfriend, with the live - // reachability of the remaining friends who get the new share / destroy step. - "resplits": d.resplit_statuses().iter().map(resplit_json).collect::>(), - // §9.3.4: re-splits detected on unfriend but awaiting the user's prompt to start. - "pending_resplits": d.pending_resplit_statuses().iter().map(pending_resplit_json).collect::>(), - "reachability": if relay_url.is_some() { "relay" } else { "direct" }, - "relay_networks": relay_networks, - "relay_diversity_warning": relay_networks < 2, - }) + StatusSnapshot { + node_id: hexs(&d.node_id()), + user_id: hexs(&d.user_id()), + identity_storage: d.identity_storage_label().to_string(), + sync_errors: d + .live_peer_errors() + .iter() + .map(|(node, error)| json!({"node":hexs(node),"error":error})) + .collect(), + addr: d.dialable_addr_strings(), + relay_url: relay_url.clone(), + friends: FriendsResponse { + count: friends.len(), + list: friends, + grants: friend_grants, + }, + peers, + vaults: VaultsResponse { + published: vaults, + held_replicas: held, + }, + share_health: ShareHealthResponse { + recovery_sets_owned: sets, + shares_held: shares, + sets: recovery_sets, + recovery, + }, + recovery_grants: RecoveryGrantsResponse { + minted: grants, + held: held_grants, + }, + ceremonies: ceremony_rows(d), + resplits: d.resplit_statuses().iter().map(resplit_json).collect(), + pending_resplits: d + .pending_resplit_statuses() + .iter() + .map(pending_resplit_json) + .collect(), + reachability: if relay_url.is_some() { + "relay" + } else { + "direct" + }, + relay_networks, + relay_diversity_warning: relay_networks < 2, + por_latency_anomaly_count: d.por_latency_anomaly_count(), + } } /// One re-split's §9.3 step-4 prompt surface as JSON: phase, new-set liveness gate, /// old-set destroy progress, and each remaining friend's online/queued status. -fn resplit_json(rs: &ResplitStatus) -> Value { - json!({ - "old_rsid": rs.old_rsid, - "new_rsid": rs.new_rsid, - "ex_trustee": hexs(&rs.ex_trustee), - "phase": rs.phase, - // New set going live is the destroy gate (>= M + slack attested). - "new_attested": rs.new_attested, - "new_total": rs.new_total, - "new_set_live": rs.new_live, - "old_destroyed": rs.old_destroyed, - "old_total": rs.old_total, - "remaining": rs.remaining.iter().map(|f| json!({ - "node": hexs(&f.node), - "role": f.role, - "online": f.online, - "done": f.done, - "status": if f.done { "done" } else if f.online { "online" } else { "will_queue" }, - })).collect::>(), - }) +fn resplit_json(rs: &ResplitStatus) -> ResplitResponse { + ResplitResponse { + old_rsid: rs.old_rsid, + new_rsid: rs.new_rsid, + ex_trustee: hexs(&rs.ex_trustee), + phase: rs.phase.to_owned(), + new_attested: rs.new_attested, + new_total: rs.new_total, + new_set_live: rs.new_live, + old_destroyed: rs.old_destroyed, + old_total: rs.old_total, + remaining: rs + .remaining + .iter() + .map(|f| ResplitFriendResponse { + node: hexs(&f.node), + role: f.role.to_owned(), + online: f.online, + done: f.done, + status: if f.done { + "done" + } else if f.online { + "online" + } else { + "will_queue" + }, + }) + .collect(), + } } /// One PENDING re-split's §9.3.4 prompt surface as JSON: the ex-trustee and the suggested /// new trustee set with each member's live reachability, so the GUI can render the prompt /// and pre-fill `POST /api/recovery/{rsid}/resplit-start`. -fn pending_resplit_json(p: &PendingResplitStatus) -> Value { - json!({ - "old_rsid": p.old_rsid, - "ex_trustee": hexs(&p.ex_trustee), - "suggested": p.suggested.iter().map(|t| json!({ - "user": hexs(&t.user), - "node": t.node.map(|n| hexs(&n)), - "online": t.online, - })).collect::>(), - }) +fn pending_resplit_json(p: &PendingResplitStatus) -> PendingResplitResponse { + PendingResplitResponse { + old_rsid: p.old_rsid, + ex_trustee: hexs(&p.ex_trustee), + suggested: p + .suggested + .iter() + .map(|t| PendingTrusteeResponse { + user: hexs(&t.user), + node: t.node.map(|n| hexs(&n)), + online: t.online, + }) + .collect(), + } } /// `GET /api/status`. -pub async fn status(State(st): State) -> Json { +pub async fn status(State(st): State) -> Json { Json(status_snapshot(&st.daemon)) } +const METRIC_COUNT_LIMIT: usize = 1_000_000; + +#[derive(Serialize)] +struct MetricLimits { + count_ceiling: usize, +} +#[derive(Serialize)] +struct StorageMetrics { + owned_vaults: u64, + held_replica_vaults: u64, + refetch_needed: u64, +} +#[derive(Serialize)] +struct ReplicaMetrics { + peer_capacity_count: u64, + held_count: u64, + assignments: u64, + granted_capacity_bytes: u64, +} +#[derive(Serialize)] +struct MaintenanceMetrics { + running: bool, + recovery_sets_checked: u64, + relay_networks: u64, + last_completed_at: u64, + last_failure_at: u64, + last_failure_count: u64, + consecutive_failed_rounds: u64, +} +#[derive(Serialize)] +struct GarbageCollectionMetrics { + last_succeeded: bool, + live_set_capacity: usize, +} +#[derive(Serialize)] +struct CeremonyMetrics { + active_or_retained: u64, + active_tracked: u64, + capacity_ceiling: u64, + per_subject_capacity: u64, + fanout_capacity: u64, + tombstones: u64, + tombstone_capacity: u64, + subject_rate_keys: u64, + sponsor_rate_keys: u64, + at_capacity: bool, + tombstones_at_capacity: bool, +} +#[derive(Serialize)] +struct MigrationMetrics { + required: bool, + state_schema_ready: bool, + legacy_migration_is_explicit: bool, +} +#[derive(Serialize)] +struct RecoveryMetrics { + owned_sets: u64, + held_shares: u64, + open_resplits: u64, + pending_resplits: u64, +} +#[derive(Serialize)] +pub struct MetricsSnapshot { + schema: u8, + limits: MetricLimits, + storage: StorageMetrics, + replicas: ReplicaMetrics, + maintenance: MaintenanceMetrics, + garbage_collection: GarbageCollectionMetrics, + ceremonies: CeremonyMetrics, + migrations: MigrationMetrics, + recovery: RecoveryMetrics, +} + +fn bounded_count(value: usize) -> u64 { + value.min(METRIC_COUNT_LIMIT) as u64 +} + +/// `GET /api/metrics`: authenticated, identity-free operational health and capacity. +pub async fn metrics(State(st): State) -> Json { + let published = st.daemon.published_vaults().len(); + let held_replicas = st.daemon.held_replica_vids().len(); + let friends = st.daemon.friend_ids().len(); + let (recovery_sets, held_shares) = st.daemon.share_health_counts(); + let recovery = st.daemon.recovery_health(); + let ceremonies = ceremony_rows(&st.daemon); + let resplits = st.daemon.resplit_statuses(); + let pending_resplits = st.daemon.pending_resplit_statuses(); + let capacity = st.daemon.operational_capacity(); + let history = st.daemon.operational_history(); + Json(MetricsSnapshot { + schema: 1, + limits: MetricLimits { + count_ceiling: METRIC_COUNT_LIMIT, + }, + storage: StorageMetrics { + owned_vaults: bounded_count(published), + held_replica_vaults: bounded_count(held_replicas), + refetch_needed: bounded_count(capacity.storage_refetch_needed), + }, + replicas: ReplicaMetrics { + peer_capacity_count: bounded_count(friends), + held_count: bounded_count(held_replicas), + assignments: bounded_count(capacity.replica_assignments), + granted_capacity_bytes: capacity.replica_grant_bytes, + }, + maintenance: MaintenanceMetrics { + running: true, + recovery_sets_checked: bounded_count(recovery.len()), + relay_networks: bounded_count(st.daemon.relay_network_count()), + last_completed_at: history.last_maintenance_at, + last_failure_at: history.last_failure_at, + last_failure_count: bounded_count(history.last_failure_count), + consecutive_failed_rounds: history + .consecutive_failed_rounds + .min(METRIC_COUNT_LIMIT as u64), + }, + garbage_collection: GarbageCollectionMetrics { + last_succeeded: history.last_gc_succeeded, + live_set_capacity: 1_000_000, + }, + ceremonies: CeremonyMetrics { + active_or_retained: bounded_count(ceremonies.len()), + active_tracked: bounded_count(capacity.ceremony_active), + capacity_ceiling: bounded_count(capacity.ceremony_capacity), + per_subject_capacity: bounded_count(capacity.ceremony_per_subject_capacity), + fanout_capacity: bounded_count(capacity.ceremony_fanout_capacity), + tombstones: bounded_count(capacity.ceremony_tombstones), + tombstone_capacity: bounded_count(capacity.ceremony_tombstone_capacity), + subject_rate_keys: bounded_count(capacity.ceremony_subject_rate_keys), + sponsor_rate_keys: bounded_count(capacity.ceremony_sponsor_rate_keys), + at_capacity: capacity.ceremony_active >= capacity.ceremony_capacity, + tombstones_at_capacity: capacity.ceremony_tombstones + >= capacity.ceremony_tombstone_capacity, + }, + migrations: MigrationMetrics { + required: false, + state_schema_ready: true, + legacy_migration_is_explicit: true, + }, + recovery: RecoveryMetrics { + owned_sets: bounded_count(recovery_sets), + held_shares: bounded_count(held_shares), + open_resplits: bounded_count(resplits.len()), + pending_resplits: bounded_count(pending_resplits.len()), + }, + }) +} + // ---- vaults ------------------------------------------------------------ #[derive(Deserialize)] @@ -324,13 +795,7 @@ pub async fn publish_vault( /// `GET /api/vaults`. pub async fn list_vaults(State(st): State) -> Json { - let vaults: Vec = st - .daemon - .published_vaults() - .iter() - .map(|(v, e)| json!({ "vid": hexs(v), "epoch": e })) - .collect(); - Json(json!({ "published": vaults })) + Json(json!({ "published": vault_rows(&st.daemon) })) } // ---- friends ----------------------------------------------------------- @@ -407,10 +872,10 @@ pub async fn unfriend( pub async fn resplit_status( Path(rsid): Path, State(st): State, -) -> Result, ApiError> { +) -> Result, ApiError> { match st.daemon.resplit_status(rsid) { Some(rs) => Ok(Json(resplit_json(&rs))), - None => Err(ApiError( + None => Err(ApiError::client( StatusCode::NOT_FOUND, format!("no open re-split for recovery set {rsid}"), )), @@ -433,7 +898,7 @@ pub async fn recovery_paper( let html = st .daemon .paper_cards(rsid) - .map_err(|e| ApiError(StatusCode::NOT_FOUND, format!("{e:#}")))?; + .map_err(|e| ApiError::client(StatusCode::NOT_FOUND, format!("{e:#}")))?; Ok(( [ (header::CONTENT_TYPE, "text/html; charset=utf-8"), @@ -461,7 +926,7 @@ pub async fn resplit_start( Path(rsid): Path, State(st): State, body: Option>, -) -> Result, ApiError> { +) -> Result, ApiError> { let override_set = match body.and_then(|Json(b)| b.trustees) { Some(hexes) => { let mut users = Vec::with_capacity(hexes.len()); @@ -480,7 +945,7 @@ pub async fn resplit_start( .iter() .any(|p| p.old_rsid == rsid); if !tracked { - return Err(ApiError( + return Err(ApiError::client( StatusCode::NOT_FOUND, format!("no pending or open re-split for recovery set {rsid}"), )); @@ -501,6 +966,74 @@ pub struct PeerReq { addrs: Vec, } +#[derive(Deserialize)] +pub struct SyncReq { + peer: PeerReq, + out_dir: String, +} + +#[derive(Deserialize)] +pub struct RestartRestoreReq { + out_dir: String, +} + +/// Restore retained vaults after claimant activation. The durable public handoff +/// limits results to the maximum signed-grant epoch for each vault. +pub async fn restart_restore( + State(st): State, + Json(req): Json, +) -> Result, ApiError> { + let path = st.state_dir.join("recovery-restart-handoff.json"); + let bytes = std::fs::read(&path).map_err(|_| { + ApiError::client( + StatusCode::NOT_FOUND, + "no claimant restart handoff is available", + ) + })?; + let seal = std::fs::read(st.state_dir.join("recovery-restart-handoff.seal")) + .map_err(|_| bad("authenticated recovery handoff is missing"))?; + st.daemon + .verify_recovery_handoff(&bytes, &seal) + .map_err(|_| bad("claimant restart handoff was replaced or modified"))?; + let (trustees, refs) = crate::claimant::restart_metadata(&bytes, st.daemon.user_id()) + .map_err(|_| bad("claimant restart handoff authentication failed"))?; + let maximum_refs = carapaced::max_epoch_refs(&refs).len(); + let report = st + .daemon + .recover_retained_at_with_relays(&trustees, &refs, std::path::Path::new(&req.out_dir)) + .await?; + let restored = &report.restored; + Ok(Json(json!({ + "restored": restored.iter().map(|vault| json!({ + "vid": hexs(&vault.vid), + "epoch": vault.epoch, + "out_dir": vault.out_dir.display().to_string(), + })).collect::>(), + "complete": report.errors.is_empty(), + "errors": report.errors.iter().map(|(vid,error)|json!({"vid":hexs(vid),"error":error})).collect::>(), + "maximum_epoch_refs": maximum_refs, + }))) +} + +/// `POST /api/sync`: pull owned-vault documents and blobs from another authorized device. +pub async fn sync_owned( + State(st): State, + Json(req): Json, +) -> Result, ApiError> { + let node = parse_hex32(&req.peer.node)?; + let restored = st + .daemon + .sync_from_at(node, &req.peer.addrs, std::path::Path::new(&req.out_dir)) + .await?; + Ok(Json(json!({ + "restored": restored.iter().map(|v| json!({ + "vid": hexs(&v.vid), + "epoch": v.epoch, + "out_dir": v.out_dir.display().to_string(), + })).collect::>(), + }))) +} + #[derive(Deserialize)] pub struct PlaceReq { peers: Vec, @@ -766,10 +1299,41 @@ pub async fn ceremony_open( unix_now(), )?; let reached = st.daemon.ceremony_fanout(&open).await.unwrap_or(0); + let package = st.daemon.claimant_package(&open)?; + let trustee_hints = package.trustees; + let announce_refs = package.announce_refs; + let roster: Vec = trustee_hints + .iter() + .map(|trustee| hexs(&trustee.user)) + .collect(); + let trustees: Vec = trustee_hints + .iter() + .map(|trustee| { + json!({ + "node": hexs(&trustee.node), + "addrs": trustee.addrs, + "relay_url": trustee.relay_url, + }) + }) + .collect(); + let sponsor_package = json!({ + "type": "carapace.sponsor-ceremony", + "version": 1, + "sponsor_sig": hexs(&package.sponsor_sig), + "open_hex": hexs(&open.encode_frame()), + "roster": roster, + "trustees": trustees, + "announce_refs": announce_refs.iter().map(|reference| json!({ + "vid": hexs(&reference.vid), + "epoch": reference.epoch, + "digest": hexs(&reference.digest), + })).collect::>(), + }); Ok(Json(json!({ "ceremony_id": hexs(&id), "open_hex": hexs(&open.encode_frame()), "fanout_reached": reached, + "sponsor_package": sponsor_package.to_string(), }))) } @@ -815,26 +1379,23 @@ pub async fn ceremony_abort( /// The recovery-ceremony status rows (§8.5 step 2/6) for the status surface: each /// ceremony this device has seen, its phase, approvals, and the alarm flags. -fn ceremony_rows(d: &Daemon) -> Vec { +fn ceremony_rows(d: &Daemon) -> Vec { d.ceremony_statuses() .iter() - .map(|c| { - json!({ - "ceremony_id": hexs(&c.ceremony_id), - "subject": hexs(&c.subject), - "sponsor": hexs(&c.sponsor), - "claimant_display": c.claimant_display, - "reason": c.reason, - "phase": c.phase, - "approvals": c.approvals, - "threshold": c.threshold, - "is_self_subject": c.is_self_subject, - "takeover": c.takeover, - "trustee": c.trustee, - "approved": c.approved, - // The anti-silent-takeover banner: a live ceremony against OUR account. - "alarm": c.is_self_subject && !c.takeover, - }) + .map(|c| CeremonyResponse { + ceremony_id: hexs(&c.ceremony_id), + subject: hexs(&c.subject), + sponsor: hexs(&c.sponsor), + claimant_display: c.claimant_display.clone(), + reason: c.reason.clone(), + phase: c.phase.to_owned(), + approvals: c.approvals, + threshold: c.threshold, + is_self_subject: c.is_self_subject, + takeover: c.takeover, + trustee: c.trustee, + approved: c.approved, + alarm: c.is_self_subject && !c.takeover, }) .collect() } @@ -846,22 +1407,25 @@ pub async fn ceremony_status(State(st): State) -> Json { // ---- events (WebSocket) ------------------------------------------------ -#[derive(Deserialize)] -pub struct TokenQuery { - token: Option, -} - -/// `GET /api/events` (WebSocket). Browsers cannot set an `Authorization` header on a -/// WS handshake, so the token is passed as the `token` query parameter and validated -/// in CONSTANT TIME before the upgrade. A missing/wrong token => 401, no upgrade. +/// `GET /api/events` (WebSocket). The browser sends the short-lived, HTTP-only session +/// cookie that the loopback shell sets. This keeps the bearer value out of URLs and logs. pub async fn events( ws: WebSocketUpgrade, - Query(q): Query, + headers: HeaderMap, State(st): State, ) -> Response { - let presented = q.token.unwrap_or_default(); - if !auth::ct_eq_str(&presented, &st.token) { - return (StatusCode::UNAUTHORIZED, "missing or invalid token").into_response(); + let presented = headers + .get(header::COOKIE) + .and_then(|value| value.to_str().ok()) + .and_then(|cookies| { + cookies.split(';').find_map(|cookie| { + let (name, value) = cookie.trim().split_once('=')?; + (name == "carapace_session").then_some(value) + }) + }) + .unwrap_or_default(); + if !auth::ct_eq_str(presented, &st.token) { + return error_response(StatusCode::UNAUTHORIZED, "missing or invalid token"); } ws.on_upgrade(move |socket| push_status(socket, st)) } @@ -870,11 +1434,147 @@ pub async fn events( /// closes. A periodic full snapshot is the v1 live-status feed (§ design doc). async fn push_status(mut socket: WebSocket, st: AppState) { let mut ticker = tokio::time::interval(Duration::from_secs(5)); - loop { - let snap = status_snapshot(&st.daemon).to_string(); + while let Ok(snap) = serde_json::to_string(&status_snapshot(&st.daemon)) { if socket.send(Message::Text(snap.into())).await.is_err() { break; } ticker.tick().await; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gui_status_contract_fixture_matches_typed_server_shape() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../gui/tests/fixtures/status-contract.json" + )) + .unwrap(); + let expected = serde_json::to_value(StatusSnapshot { + node_id: String::new(), + user_id: String::new(), + identity_storage: String::new(), + sync_errors: Vec::new(), + addr: Vec::new(), + relay_url: None, + friends: FriendsResponse { + count: 0, + list: Vec::new(), + grants: Vec::new(), + }, + peers: Vec::new(), + vaults: VaultsResponse { + published: Vec::new(), + held_replicas: Vec::new(), + }, + share_health: ShareHealthResponse { + recovery_sets_owned: 0, + shares_held: 0, + sets: Vec::new(), + recovery: Vec::new(), + }, + recovery_grants: RecoveryGrantsResponse { + minted: Vec::new(), + held: Vec::new(), + }, + ceremonies: Vec::new(), + resplits: Vec::new(), + pending_resplits: Vec::new(), + reachability: "direct", + relay_networks: 0, + relay_diversity_warning: true, + por_latency_anomaly_count: 0, + }) + .unwrap(); + assert_eq!(fixture, expected); + } + + #[test] + fn internal_error_detail_is_not_the_client_message() { + let error = ApiError::from(anyhow::anyhow!( + "secret path /private/state/root.key could not be read" + )); + + assert_eq!(error.status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(error.client_message, "internal server error"); + assert!(error + .internal + .as_deref() + .is_some_and(|detail| detail.contains("/private/state/root.key"))); + assert!(!error.client_message.contains("root.key")); + } + + #[test] + fn expected_client_error_has_no_internal_detail() { + let error = bad("invalid recovery set"); + + assert_eq!(error.status, StatusCode::BAD_REQUEST); + assert_eq!(error.client_message, "invalid recovery set"); + assert!(error.internal.is_none()); + } +} + +#[derive(Deserialize)] +pub struct DirectoryQuery { + path: Option, +} + +/// Browse local folders through the same authenticated boundary as publishing. +pub async fn directories(Query(query): Query) -> Result, ApiError> { + let dir = query + .path + .map(std::path::PathBuf::from) + .or_else(|| { + std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(Into::into) + }) + .ok_or_else(|| bad("Choose a folder path"))?; + tokio::task::spawn_blocking(move || { + let dir = std::fs::canonicalize(dir)?; + let mut children = Vec::new(); + for entry in std::fs::read_dir(&dir)? { + let entry = entry?; + if entry.file_type()?.is_dir() { + children.push(json!({"name":entry.file_name().to_string_lossy(), "path":entry.path().to_string_lossy()})); + } + } + children.sort_by(|a,b| a["name"].as_str().cmp(&b["name"].as_str())); + Ok::<_, anyhow::Error>(Json(json!({"path":dir.to_string_lossy(),"parent":dir.parent().map(|p|p.to_string_lossy()),"directories":children}))) + }).await.map_err(|e| bad(e.to_string()))?.map_err(Into::into) +} + +fn vault_rows(d: &Daemon) -> Vec { + d.live_vault_statuses() + .into_iter() + .map(|v| PublishedVaultResponse { + vid: hexs(&v.vid), + epoch: v.epoch, + name: v.name, + dir: v.dir.to_string_lossy().into_owned(), + watching: v.watching, + syncing: v.syncing, + last_error: v.last_error, + last_success: v.last_success, + recovery_backup: v.recovery_backup.map(|p| p.to_string_lossy().into_owned()), + }) + .collect() +} + +pub async fn device_card(State(st): State) -> Json { + Json(json!({"card_hex":hexs(&st.daemon.own_device_card().encode_frame())})) +} +#[derive(Deserialize)] +pub struct DeviceReq { + card_hex: String, +} +pub async fn enroll_device( + State(st): State, + Json(req): Json, +) -> Result, ApiError> { + let bytes = hex::decode(&req.card_hex).map_err(|_| bad("Invalid device card"))?; + let card = + carapace_wire::ContactCard::decode_frame(&bytes).map_err(|_| bad("Invalid device card"))?; + st.daemon.enroll_own_device(card).await?; + Ok(Json(json!({"enrolled":true}))) +} diff --git a/crates/carapace-api/src/lib.rs b/crates/carapace-api/src/lib.rs index 21fee19..46ce6b7 100644 --- a/crates/carapace-api/src/lib.rs +++ b/crates/carapace-api/src/lib.rs @@ -6,19 +6,21 @@ //! `Host` check (DNS-rebinding defense), and a loopback `Origin` check (CSRF //! defense). See [`auth`] for the guards and [`handlers`] for the endpoints. //! -//! Public routes (no token): `GET /api/health`, the WebSocket `GET /api/events` -//! (which validates the token from a query parameter instead, since browsers cannot -//! set an `Authorization` header on a WS handshake), and the embedded static GUI. +//! Public routes (no bearer header): `GET /api/health`, the WebSocket `GET /api/events` +//! (which validates an HTTP-only, same-site session cookie), and the embedded static GUI. //! //! The GUI is embedded with `rust-embed` from `static/` (the SvelteKit build). The //! served `index.html` gets the session token injected as `window.__CARAPACE_TOKEN__` //! under a strict per-response CSP nonce; see [`handlers::static_asset`]. +mod account_transfer; mod auth; +mod claimant; mod handlers; +mod ops; use std::net::{Ipv4Addr, SocketAddr}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{ensure, Context, Result}; @@ -36,6 +38,8 @@ pub struct AppState { pub daemon: Arc, /// The per-session bearer token (hex of 32 CSPRNG bytes). pub token: Arc, + /// Local state directory for public restart-handoff metadata. + pub state_dir: Arc, } /// A running control API. Dropping or [`ApiServer::shutdown`] stops it. @@ -47,6 +51,29 @@ pub struct ApiServer { handle: tokio::task::JoinHandle<()>, /// The daemon's background maintenance loop (§10.1/§10.2), torn down with the API. _maintenance: MaintenanceHandle, + _live_sync: carapaced::LiveSyncHandle, +} + +/// A running claimant-only API. It never owns a normal daemon. +pub struct ClaimantApiServer { + /// The per-session bearer token, also written to `/claimant-api-token`. + pub token: String, + /// The actual bound loopback address. + pub local_addr: SocketAddr, + handle: tokio::task::JoinHandle<()>, +} + +impl ClaimantApiServer { + /// The base URL for the claimant client. + #[must_use] + pub fn url(&self) -> String { + format!("http://{}", self.local_addr) + } + + /// Stop the claimant server. A completed activation needs a normal daemon restart. + pub fn shutdown(self) { + self.handle.abort(); + } } impl ApiServer { @@ -62,12 +89,35 @@ impl ApiServer { } } +impl Drop for ClaimantApiServer { + fn drop(&mut self) { + self.handle.abort(); + } +} + +impl Drop for ApiServer { + fn drop(&mut self) { + self.handle.abort(); + } +} + /// Assemble the router: token-gated `/api` action routes, the public health check /// and WebSocket events feed, and the embedded GUI fallback, all wrapped in the /// global Host/Origin guard. pub fn app(state: AppState) -> Router { let protected = Router::new() .route("/api/status", get(handlers::status)) + .route("/api/directories", get(handlers::directories)) + .route("/api/account/export", post(account_transfer::export)) + .route("/api/account/import", post(account_transfer::import)) + .route("/api/devices/self", get(handlers::device_card)) + .route("/api/devices", post(handlers::enroll_device)) + .route("/api/metrics", get(handlers::metrics)) + .route("/api/sync", post(handlers::sync_owned)) + .route( + "/api/recovery/restart-restore", + post(handlers::restart_restore), + ) .route( "/api/vaults", get(handlers::list_vaults).post(handlers::publish_vault), @@ -128,6 +178,28 @@ pub fn app(state: AppState) -> Router { .with_state(state) } +/// Assemble the claimant-only router. Its type cannot route to normal daemon handlers. +fn claimant_app(state: claimant::ClaimantState) -> Router { + let protected = Router::new() + .route("/api/claimant/status", get(claimant::status)) + .route("/api/claimant/preview", post(claimant::preview)) + .route("/api/claimant/complete", post(claimant::complete)) + .route("/api/claimant/cancel", post(claimant::cancel)) + .layer(middleware::from_fn_with_state( + state.token.clone(), + auth::require_token, + )); + let shell = Router::new() + .route("/", get(claimant::shell)) + .route("/claimant.js", get(claimant::script)) + .route("/claimant.css", get(claimant::stylesheet)); + Router::new() + .merge(protected) + .merge(shell) + .layer(middleware::from_fn(auth::guard_host_origin)) + .with_state(state) +} + /// Generate a 32-byte CSPRNG token, hex-encode it, and write it to /// `/api-token` with `0600` permissions on unix. fn mint_and_write_token(state_dir: &Path) -> Result { @@ -169,11 +241,33 @@ fn write_token_file(path: &Path, token: &str) -> Result<()> { .with_context(|| format!("write {path:?}")) } -#[cfg(not(unix))] +#[cfg(windows)] +fn write_token_file(path: &Path, token: &str) -> Result<()> { + use std::io::Write; + use std::os::windows::fs::MetadataExt; + carapaced::State::secure_directory(path.parent().context("token file has no parent")?)?; + match std::fs::symlink_metadata(path) { + Ok(metadata) => { + ensure!( + metadata.is_file() && metadata.file_attributes() & 0x400 == 0, + "token file is a reparse point or non-file" + ); + std::fs::remove_file(path)?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path)?; + file.write_all(token.as_bytes())?; + file.sync_all()?; + Ok(()) +} + +#[cfg(all(not(unix), not(windows)))] fn write_token_file(path: &Path, token: &str) -> Result<()> { - // ponytail: no OS-ACL restriction here (needs a Windows-specific crate). The - // token still gates every request; tighten the file ACL on non-unix if the - // state dir is shared. std::fs::write(path, token).with_context(|| format!("write {path:?}")) } @@ -213,16 +307,19 @@ pub async fn serve(daemon: Arc, state_dir: &Path, port: u16) -> Result`; the loop holds // only a `Weak` and is torn down when this `ApiServer` drops. let maintenance = Arc::clone(&daemon).run_maintenance(MaintenanceConfig::default()); + let live_sync = Arc::clone(&daemon).run_live_sync(Default::default()); let state = AppState { daemon, token: Arc::from(token.as_str()), + state_dir: Arc::new(state_dir.to_path_buf()), }; let app = app(state); let handle = tokio::spawn(async move { if let Err(e) = axum::serve(listener, app).await { - eprintln!("carapace-api: server exited: {e}"); + let _ = e; + ops::log("server.control_exit_error", None); } }); @@ -231,6 +328,52 @@ pub async fn serve(daemon: Arc, state_dir: &Path, port: u16) -> Result Result { + std::fs::create_dir_all(state_dir) + .with_context(|| format!("create claimant state directory {state_dir:?}"))?; + let token = { + let mut raw = [0u8; 32]; + getrandom::getrandom(&mut raw) + .map_err(|error| anyhow::anyhow!("generate claimant API token: {error}"))?; + let token = hex::encode(raw); + write_token_file(&state_dir.join("claimant-api-token"), &token)?; + token + }; + let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port)); + ensure!( + addr.ip().is_loopback(), + "refusing non-loopback claimant bind {addr}" + ); + let listener = tokio::net::TcpListener::bind(addr) + .await + .with_context(|| format!("bind claimant API on {addr}"))?; + let local_addr = listener.local_addr().context("read claimant API address")?; + let url_path = state_dir.join("claimant-api-url"); + std::fs::write(&url_path, format!("http://{local_addr}")) + .with_context(|| format!("write {url_path:?}"))?; + + let state = claimant::ClaimantState::new(state_dir.to_path_buf(), Arc::from(token.as_str()))?; + let app = claimant_app(state); + let handle = tokio::spawn(async move { + if let Err(error) = axum::serve(listener, app).await { + let _ = error; + ops::log("server.claimant_exit_error", None); + } + }); + Ok(ClaimantApiServer { + token, + local_addr, + handle, }) } @@ -274,3 +417,17 @@ mod tests { assert_eq!(mode & 0o777, 0o600, "rewritten token must be 0600"); } } + +#[cfg(all(test, windows))] +mod windows_tests { + use super::write_token_file; + + #[test] + fn token_creation_enforces_private_acls_and_preserves_value() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("api-token"); + write_token_file(&path, "secret").unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "secret"); + carapaced::State::secure_directory(dir.path()).unwrap(); + } +} diff --git a/crates/carapace-api/src/ops.rs b/crates/carapace-api/src/ops.rs new file mode 100644 index 0000000..ab73349 --- /dev/null +++ b/crates/carapace-api/src/ops.rs @@ -0,0 +1,34 @@ +//! Secret-redacted operational events. + +use serde_json::{json, Value}; + +/// Build one stable, structured operational event. +/// +/// Callers must supply only fixed event identifiers and bounded numeric fields. This +/// function does not accept free-form error text, paths, or identity values. +pub(crate) fn event(event_id: &'static str, reference: Option) -> Value { + json!({ + "component": "carapace-api", + "event_id": event_id, + "reference": reference, + }) +} + +/// Write one event as a single JSON line to the local process log. +pub(crate) fn log(event_id: &'static str, reference: Option) { + eprintln!("{}", event(event_id, reference)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn event_contract_has_only_redacted_stable_fields() { + let value = event("api.internal_error", Some(7)); + assert_eq!(value["component"], "carapace-api"); + assert_eq!(value["event_id"], "api.internal_error"); + assert_eq!(value["reference"], 7); + assert_eq!(value.as_object().unwrap().len(), 3); + } +} diff --git a/crates/carapace-api/static/_app/immutable/assets/0.BIBOqY7u.css b/crates/carapace-api/static/_app/immutable/assets/0.BNChGHTQ.css similarity index 98% rename from crates/carapace-api/static/_app/immutable/assets/0.BIBOqY7u.css rename to crates/carapace-api/static/_app/immutable/assets/0.BNChGHTQ.css index d2b68ce..a900e14 100644 --- a/crates/carapace-api/static/_app/immutable/assets/0.BIBOqY7u.css +++ b/crates/carapace-api/static/_app/immutable/assets/0.BNChGHTQ.css @@ -1 +1 @@ -@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-mono-cyrillic-ext-400-normal.xuaO2J-f.woff2)format("woff2"),url(./ibm-plex-mono-cyrillic-ext-400-normal.DMdlQ8Kv.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-mono-cyrillic-400-normal.BSMlKf0J.woff2)format("woff2"),url(./ibm-plex-mono-cyrillic-400-normal.CEL4l2ZJ.woff)format("woff");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-mono-vietnamese-400-normal.BulugwFq.woff2)format("woff2"),url(./ibm-plex-mono-vietnamese-400-normal.DDuiU_S-.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-mono-latin-ext-400-normal.BmRBH3aV.woff2)format("woff2"),url(./ibm-plex-mono-latin-ext-400-normal.D3D2R8hC.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-mono-latin-400-normal.DMJ8VG8y.woff2)format("woff2"),url(./ibm-plex-mono-latin-400-normal.CvHOgSBP.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-mono-cyrillic-ext-500-normal.BqneJy0T.woff2)format("woff2"),url(./ibm-plex-mono-cyrillic-ext-500-normal.BIfNGwUT.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-mono-cyrillic-500-normal.Bq9vWWag.woff2)format("woff2"),url(./ibm-plex-mono-cyrillic-500-normal.Ael50iVv.woff)format("woff");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-mono-vietnamese-500-normal.DZ4AoWbu.woff2)format("woff2"),url(./ibm-plex-mono-vietnamese-500-normal.C8zxqsMH.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-mono-latin-ext-500-normal.CAhNIIs5.woff2)format("woff2"),url(./ibm-plex-mono-latin-ext-500-normal.CZ70TYgx.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-mono-latin-500-normal.DSY6xOcd.woff2)format("woff2"),url(./ibm-plex-mono-latin-500-normal.CB9ihrfo.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:600;src:url(./ibm-plex-mono-cyrillic-ext-600-normal.V-xxqcpd.woff2)format("woff2"),url(./ibm-plex-mono-cyrillic-ext-600-normal.9HEixskS.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:600;src:url(./ibm-plex-mono-cyrillic-600-normal.CTOM6hUh.woff2)format("woff2"),url(./ibm-plex-mono-cyrillic-600-normal.fLZuRloM.woff)format("woff");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:600;src:url(./ibm-plex-mono-vietnamese-600-normal.D2EvbN8M.woff2)format("woff2"),url(./ibm-plex-mono-vietnamese-600-normal.iLQfcSjf.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:600;src:url(./ibm-plex-mono-latin-ext-600-normal.D38SheWl.woff2)format("woff2"),url(./ibm-plex-mono-latin-ext-600-normal.DmB0ttJJ.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:600;src:url(./ibm-plex-mono-latin-600-normal.BgSNZQsw.woff2)format("woff2"),url(./ibm-plex-mono-latin-600-normal.DWFSQ4vo.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:400;src:url(./hanken-grotesk-cyrillic-ext-400-normal.BLTEyOai.woff2)format("woff2"),url(./hanken-grotesk-cyrillic-ext-400-normal.C910xUUL.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:400;src:url(./hanken-grotesk-vietnamese-400-normal.BLrFBAHj.woff2)format("woff2"),url(./hanken-grotesk-vietnamese-400-normal.C-iWyKLC.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:400;src:url(./hanken-grotesk-latin-ext-400-normal.DR7lHpW4.woff2)format("woff2"),url(./hanken-grotesk-latin-ext-400-normal.DI-aIsWt.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:400;src:url(./hanken-grotesk-latin-400-normal.BG6hkEXj.woff2)format("woff2"),url(./hanken-grotesk-latin-400-normal.CjyVwvJV.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:500;src:url(./hanken-grotesk-cyrillic-ext-500-normal.DJxU5DEV.woff2)format("woff2"),url(./hanken-grotesk-cyrillic-ext-500-normal.klSdyF8A.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:500;src:url(./hanken-grotesk-vietnamese-500-normal.DxwlvJEc.woff2)format("woff2"),url(./hanken-grotesk-vietnamese-500-normal.DkDHNoXI.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:500;src:url(./hanken-grotesk-latin-ext-500-normal.DLb9JDK8.woff2)format("woff2"),url(./hanken-grotesk-latin-ext-500-normal.BNvrJ0Ju.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:500;src:url(./hanken-grotesk-latin-500-normal.DrDcrrxK.woff2)format("woff2"),url(./hanken-grotesk-latin-500-normal.Bo-NxEKf.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:600;src:url(./hanken-grotesk-cyrillic-ext-600-normal.gC6IfhiA.woff2)format("woff2"),url(./hanken-grotesk-cyrillic-ext-600-normal.CZKgvMB-.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:600;src:url(./hanken-grotesk-vietnamese-600-normal.Cp8QQjQf.woff2)format("woff2"),url(./hanken-grotesk-vietnamese-600-normal.DHaFH8q1.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:600;src:url(./hanken-grotesk-latin-ext-600-normal.FY8kSObK.woff2)format("woff2"),url(./hanken-grotesk-latin-ext-600-normal.DHIm05DD.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:600;src:url(./hanken-grotesk-latin-600-normal.CIXX6EOa.woff2)format("woff2"),url(./hanken-grotesk-latin-600-normal.NEn2C4Q3.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:700;src:url(./hanken-grotesk-cyrillic-ext-700-normal.t5HJuhUd.woff2)format("woff2"),url(./hanken-grotesk-cyrillic-ext-700-normal.CANZoffZ.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:700;src:url(./hanken-grotesk-vietnamese-700-normal.C4RNfQp6.woff2)format("woff2"),url(./hanken-grotesk-vietnamese-700-normal.CslVg6dq.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:700;src:url(./hanken-grotesk-latin-ext-700-normal.CK2OfQqO.woff2)format("woff2"),url(./hanken-grotesk-latin-ext-700-normal.BXHnjv6S.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:700;src:url(./hanken-grotesk-latin-700-normal.CeQ8H3UY.woff2)format("woff2"),url(./hanken-grotesk-latin-700-normal.6IGCzoPh.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;--ink:#14100c;--plate:#1f1913;--plate-raised:#241d16;--hairline:#2e251b;--bronze:#c8843c;--bronze-strong:#e0a05c;--verdigris:#4e8d7c;--coral:#e0674a;--bone:#eae0d2;--muted:#9a8f7e;--font-sans:"Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;--font-mono:"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;--step--1:.833rem;--step-0:1rem;--step-1:1.2rem;--step-2:1.44rem;--step-3:1.728rem;--step-4:2.074rem;--step-5:2.488rem;--radius:10px;--transition:.16s ease;background:var(--ink);color:var(--bone)}:root[data-theme=light]{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--ink:#f4ede0;--plate:#e7dac2;--plate-raised:#f0e6d3;--hairline:#cdb994;--bronze:#9c5f22;--bronze-strong:#7a4a1a;--verdigris:#2f6e5c;--coral:#b83f27;--bone:#241d16;--muted:#6b5f4d}@media (prefers-color-scheme:light){:root:not([data-theme=dark]){--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--ink:#f4ede0;--plate:#e7dac2;--plate-raised:#f0e6d3;--hairline:#cdb994;--bronze:#9c5f22;--bronze-strong:#7a4a1a;--verdigris:#2f6e5c;--coral:#b83f27;--bone:#241d16;--muted:#6b5f4d}}*{box-sizing:border-box}html,body{min-height:100%;margin:0;padding:0}body{background:var(--ink);color:var(--bone);font-family:var(--font-sans);font-size:var(--step-0);transition:background var(--transition), color var(--transition);line-height:1.5}h1,h2,h3{font-family:var(--font-sans);margin:0 0 .5em;font-weight:700;line-height:1.2}h1{font-size:var(--step-4)}h2{font-size:var(--step-2)}h3{font-size:var(--step-1)}code,.mono{font-family:var(--font-mono);font-variant-ligatures:none}a{color:var(--bronze-strong)}button,input,select,textarea{font-family:inherit;font-size:inherit;color:inherit}button{background:var(--plate-raised);border:1px solid var(--hairline);color:var(--bone);border-radius:var(--radius);cursor:pointer;transition:background var(--transition), border-color var(--transition);padding:.55em 1em}button:hover{border-color:var(--bronze)}button.primary{background:var(--bronze);border-color:var(--bronze);color:var(--ink);font-weight:600}button.primary:hover{background:var(--bronze-strong);border-color:var(--bronze-strong)}button:disabled{opacity:.5;cursor:not-allowed}input,select,textarea{background:var(--plate);border:1px solid var(--hairline);border-radius:calc(var(--radius) * .6);padding:.5em .7em}:focus-visible{outline:2px solid var(--bronze);outline-offset:2px}.card{background:var(--plate);border:1px solid var(--hairline);border-radius:var(--radius);padding:1.25rem}.muted{color:var(--muted)}.healthy{color:var(--verdigris)}.at-risk{color:var(--coral)}.visually-hidden{clip:rect(0 0 0 0);white-space:nowrap;width:1px;height:1px;position:absolute;overflow:hidden}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.001ms!important;animation-duration:.001ms!important;animation-iteration-count:1!important}} +@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-mono-cyrillic-ext-400-normal.xuaO2J-f.woff2)format("woff2"),url(./ibm-plex-mono-cyrillic-ext-400-normal.DMdlQ8Kv.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-mono-cyrillic-400-normal.BSMlKf0J.woff2)format("woff2"),url(./ibm-plex-mono-cyrillic-400-normal.CEL4l2ZJ.woff)format("woff");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-mono-vietnamese-400-normal.BulugwFq.woff2)format("woff2"),url(./ibm-plex-mono-vietnamese-400-normal.DDuiU_S-.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-mono-latin-ext-400-normal.BmRBH3aV.woff2)format("woff2"),url(./ibm-plex-mono-latin-ext-400-normal.D3D2R8hC.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(./ibm-plex-mono-latin-400-normal.DMJ8VG8y.woff2)format("woff2"),url(./ibm-plex-mono-latin-400-normal.CvHOgSBP.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-mono-cyrillic-ext-500-normal.BqneJy0T.woff2)format("woff2"),url(./ibm-plex-mono-cyrillic-ext-500-normal.BIfNGwUT.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-mono-cyrillic-500-normal.Bq9vWWag.woff2)format("woff2"),url(./ibm-plex-mono-cyrillic-500-normal.Ael50iVv.woff)format("woff");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-mono-vietnamese-500-normal.DZ4AoWbu.woff2)format("woff2"),url(./ibm-plex-mono-vietnamese-500-normal.C8zxqsMH.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-mono-latin-ext-500-normal.CAhNIIs5.woff2)format("woff2"),url(./ibm-plex-mono-latin-ext-500-normal.CZ70TYgx.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(./ibm-plex-mono-latin-500-normal.DSY6xOcd.woff2)format("woff2"),url(./ibm-plex-mono-latin-500-normal.CB9ihrfo.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:600;src:url(./ibm-plex-mono-cyrillic-ext-600-normal.V-xxqcpd.woff2)format("woff2"),url(./ibm-plex-mono-cyrillic-ext-600-normal.9HEixskS.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:600;src:url(./ibm-plex-mono-cyrillic-600-normal.CTOM6hUh.woff2)format("woff2"),url(./ibm-plex-mono-cyrillic-600-normal.fLZuRloM.woff)format("woff");unicode-range:U+301,U+400-45F,U+490-491,U+4B0-4B1,U+2116}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:600;src:url(./ibm-plex-mono-vietnamese-600-normal.D2EvbN8M.woff2)format("woff2"),url(./ibm-plex-mono-vietnamese-600-normal.iLQfcSjf.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:600;src:url(./ibm-plex-mono-latin-ext-600-normal.D38SheWl.woff2)format("woff2"),url(./ibm-plex-mono-latin-ext-600-normal.DmB0ttJJ.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:600;src:url(./ibm-plex-mono-latin-600-normal.BgSNZQsw.woff2)format("woff2"),url(./ibm-plex-mono-latin-600-normal.DWFSQ4vo.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:400;src:url(./hanken-grotesk-cyrillic-ext-400-normal.BLTEyOai.woff2)format("woff2"),url(./hanken-grotesk-cyrillic-ext-400-normal.C910xUUL.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:400;src:url(./hanken-grotesk-vietnamese-400-normal.BLrFBAHj.woff2)format("woff2"),url(./hanken-grotesk-vietnamese-400-normal.C-iWyKLC.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:400;src:url(./hanken-grotesk-latin-ext-400-normal.DR7lHpW4.woff2)format("woff2"),url(./hanken-grotesk-latin-ext-400-normal.DI-aIsWt.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:400;src:url(./hanken-grotesk-latin-400-normal.BG6hkEXj.woff2)format("woff2"),url(./hanken-grotesk-latin-400-normal.CjyVwvJV.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:500;src:url(./hanken-grotesk-cyrillic-ext-500-normal.DJxU5DEV.woff2)format("woff2"),url(./hanken-grotesk-cyrillic-ext-500-normal.klSdyF8A.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:500;src:url(./hanken-grotesk-vietnamese-500-normal.DxwlvJEc.woff2)format("woff2"),url(./hanken-grotesk-vietnamese-500-normal.DkDHNoXI.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:500;src:url(./hanken-grotesk-latin-ext-500-normal.DLb9JDK8.woff2)format("woff2"),url(./hanken-grotesk-latin-ext-500-normal.BNvrJ0Ju.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:500;src:url(./hanken-grotesk-latin-500-normal.DrDcrrxK.woff2)format("woff2"),url(./hanken-grotesk-latin-500-normal.Bo-NxEKf.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:600;src:url(./hanken-grotesk-cyrillic-ext-600-normal.gC6IfhiA.woff2)format("woff2"),url(./hanken-grotesk-cyrillic-ext-600-normal.CZKgvMB-.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:600;src:url(./hanken-grotesk-vietnamese-600-normal.Cp8QQjQf.woff2)format("woff2"),url(./hanken-grotesk-vietnamese-600-normal.DHaFH8q1.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:600;src:url(./hanken-grotesk-latin-ext-600-normal.FY8kSObK.woff2)format("woff2"),url(./hanken-grotesk-latin-ext-600-normal.DHIm05DD.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:600;src:url(./hanken-grotesk-latin-600-normal.CIXX6EOa.woff2)format("woff2"),url(./hanken-grotesk-latin-600-normal.NEn2C4Q3.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:700;src:url(./hanken-grotesk-cyrillic-ext-700-normal.t5HJuhUd.woff2)format("woff2"),url(./hanken-grotesk-cyrillic-ext-700-normal.CANZoffZ.woff)format("woff");unicode-range:U+460-52F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:700;src:url(./hanken-grotesk-vietnamese-700-normal.C4RNfQp6.woff2)format("woff2"),url(./hanken-grotesk-vietnamese-700-normal.CslVg6dq.woff)format("woff");unicode-range:U+102-103,U+110-111,U+128-129,U+168-169,U+1A0-1A1,U+1AF-1B0,U+300-301,U+303-304,U+308-309,U+323,U+329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:700;src:url(./hanken-grotesk-latin-ext-700-normal.CK2OfQqO.woff2)format("woff2"),url(./hanken-grotesk-latin-ext-700-normal.BXHnjv6S.woff)format("woff");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Hanken Grotesk;font-style:normal;font-display:swap;font-weight:700;src:url(./hanken-grotesk-latin-700-normal.CeQ8H3UY.woff2)format("woff2"),url(./hanken-grotesk-latin-700-normal.6IGCzoPh.woff)format("woff");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;--ink:#14100c;--plate:#1f1913;--plate-raised:#241d16;--hairline:#2e251b;--bronze:#c8843c;--bronze-strong:#e0a05c;--verdigris:#4e8d7c;--coral:#e0674a;--bone:#eae0d2;--muted:#9a8f7e;--font-sans:"Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, sans-serif;--font-mono:"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;--step--1:.833rem;--step-0:1rem;--step-1:1.2rem;--step-2:1.44rem;--step-3:1.728rem;--step-4:2.074rem;--step-5:2.488rem;--radius:10px;--transition:.16s ease;background:var(--ink);color:var(--bone)}:root[data-theme=light]{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--ink:#f4ede0;--plate:#e7dac2;--plate-raised:#f0e6d3;--hairline:#cdb994;--bronze:#754515;--bronze-strong:#60370f;--verdigris:#2f6e5c;--coral:#b83f27;--bone:#241d16;--muted:#6b5f4d}@media (prefers-color-scheme:light){:root:not([data-theme=dark]){--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--ink:#f4ede0;--plate:#e7dac2;--plate-raised:#f0e6d3;--hairline:#cdb994;--bronze:#754515;--bronze-strong:#60370f;--verdigris:#2f6e5c;--coral:#b83f27;--bone:#241d16;--muted:#6b5f4d}}*{box-sizing:border-box}html,body{min-height:100%;margin:0;padding:0}body{background:var(--ink);color:var(--bone);font-family:var(--font-sans);font-size:var(--step-0);transition:background var(--transition), color var(--transition);line-height:1.5}h1,h2,h3{font-family:var(--font-sans);margin:0 0 .5em;font-weight:700;line-height:1.2}h1{font-size:var(--step-4)}h2{font-size:var(--step-2)}h3{font-size:var(--step-1)}code,.mono{font-family:var(--font-mono);font-variant-ligatures:none}a{color:var(--bronze-strong)}button,input,select,textarea{font-family:inherit;font-size:inherit;color:inherit}button{background:var(--plate-raised);border:1px solid var(--hairline);color:var(--bone);border-radius:var(--radius);cursor:pointer;transition:background var(--transition), border-color var(--transition);padding:.55em 1em}button:hover{border-color:var(--bronze)}button.primary{background:var(--bronze);border-color:var(--bronze);color:var(--ink);font-weight:600}button.primary:hover{background:var(--bronze-strong);border-color:var(--bronze-strong)}button:disabled{opacity:.5;cursor:not-allowed}input,select,textarea{background:var(--plate);border:1px solid var(--hairline);border-radius:calc(var(--radius) * .6);padding:.5em .7em}:focus-visible{outline:2px solid var(--bronze);outline-offset:2px}.card{background:var(--plate);border:1px solid var(--hairline);border-radius:var(--radius);padding:1.25rem}.muted{color:var(--muted)}.healthy{color:var(--verdigris)}.at-risk{color:var(--coral)}.visually-hidden{clip:rect(0 0 0 0);white-space:nowrap;width:1px;height:1px;position:absolute;overflow:hidden}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.001ms!important;animation-duration:.001ms!important;animation-iteration-count:1!important}} diff --git a/crates/carapace-api/static/_app/immutable/assets/2.BkMzBKKi.css b/crates/carapace-api/static/_app/immutable/assets/2.BkMzBKKi.css new file mode 100644 index 0000000..1e36e09 --- /dev/null +++ b/crates/carapace-api/static/_app/immutable/assets/2.BkMzBKKi.css @@ -0,0 +1 @@ +.banner.svelte-vde8u4{background:var(--plate);border:1px solid var(--coral);color:var(--coral);border-radius:var(--radius);justify-content:space-between;align-items:center;gap:1rem;margin-bottom:1rem;padding:.75rem 1rem;display:flex}.banner.svelte-vde8u4 button:where(.svelte-vde8u4){border-color:var(--coral);color:var(--coral);background:0 0;flex-shrink:0}.shell.svelte-v2pnom{grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1.25rem;display:grid}.plate-group.svelte-v2pnom{background:var(--plate);border:1px solid var(--hairline);border-radius:var(--radius);animation:.42s backwards svelte-v2pnom-reveal;animation-delay:calc(var(--i) * 80ms);padding:1.25rem}@keyframes svelte-v2pnom-reveal{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}.segments.svelte-v2pnom{margin-bottom:.9rem;display:flex}.segment.svelte-v2pnom{border:2px solid var(--hairline);clip-path:polygon(14% 0,100% 0,86% 100%,0 100%);background:0 0;flex:1;height:34px;margin-left:-10px}.segment.svelte-v2pnom:first-child{margin-left:0}.state-healthy.svelte-v2pnom .segment.filled:where(.svelte-v2pnom){background:var(--verdigris);border-color:var(--verdigris)}.state-at-risk.svelte-v2pnom .segment.filled:where(.svelte-v2pnom){background:var(--coral);border-color:var(--coral)}.state-at-risk.svelte-v2pnom .segment:where(.svelte-v2pnom):not(.filled){border-color:var(--coral);border-style:dashed}.state-empty.svelte-v2pnom .segment:where(.svelte-v2pnom){border-style:dashed}.label.svelte-v2pnom{font-size:var(--step--1);text-transform:uppercase;letter-spacing:.06em;color:var(--muted)}.value.svelte-v2pnom{font-size:var(--step-3);margin:.15em 0;font-weight:600}.state-healthy.svelte-v2pnom .value:where(.svelte-v2pnom){color:var(--verdigris)}.state-at-risk.svelte-v2pnom .value:where(.svelte-v2pnom){color:var(--coral)}.note.svelte-v2pnom{font-size:var(--step--1)}label.svelte-icfi5e{margin-top:.7rem;display:block}input.svelte-icfi5e,textarea.svelte-icfi5e{width:100%}textarea.svelte-icfi5e{min-height:5rem}button.svelte-icfi5e{margin:.6rem 0}details.svelte-icfi5e{margin:1rem 0}code.svelte-icfi5e{overflow-wrap:anywhere}.hex.svelte-3sdcoe{cursor:pointer;color:inherit;background:0 0;border:none;align-items:baseline;gap:.5em;padding:0;display:inline-flex}.hex.svelte-3sdcoe:hover .hint:where(.svelte-3sdcoe),.hex.svelte-3sdcoe:focus-visible .hint:where(.svelte-3sdcoe){color:var(--bronze)}.hint.svelte-3sdcoe{font-size:var(--step--1);font-family:var(--font-sans)}.node.svelte-156cahi{flex-wrap:wrap;gap:2rem;margin:1.5rem 0;display:flex}.label.svelte-156cahi{font-size:var(--step--1);text-transform:uppercase;letter-spacing:.06em;margin-bottom:.3em}.actions.svelte-156cahi{flex-wrap:wrap;gap:.75rem;display:flex}.button-link.svelte-156cahi{background:var(--plate-raised);border:1px solid var(--hairline);color:var(--bone);border-radius:var(--radius);transition:border-color var(--transition);padding:.55em 1em;text-decoration:none}.button-link.svelte-156cahi:hover,.button-link.svelte-156cahi:focus-visible{border-color:var(--bronze)}ul.svelte-57drpi{max-height:16rem;padding:0;list-style:none;overflow:auto}li.svelte-57drpi{margin:.3rem 0}input.svelte-57drpi{min-width:min(25rem,100%)}.publish-form.svelte-1t2bjk0 label:where(.svelte-1t2bjk0){font-size:var(--step--1);color:var(--muted);margin-bottom:.4rem;display:block}.publish-form.svelte-1t2bjk0+.publish-form:where(.svelte-1t2bjk0){margin-top:1rem}.publish-form.svelte-1t2bjk0>input:where(.svelte-1t2bjk0){width:100%;margin-bottom:.6rem}.publish-form.svelte-1t2bjk0>button:where(.svelte-1t2bjk0){margin-top:.4rem}.row.svelte-1t2bjk0{flex-wrap:wrap;align-items:center;gap:.6rem;display:flex}.row.svelte-1t2bjk0 input:where(.svelte-1t2bjk0){flex:1;min-width:180px}.list.svelte-1t2bjk0{flex-direction:column;gap:.75rem;margin-top:1.5rem;display:flex}.vault-row.svelte-1t2bjk0{grid-template-columns:1fr 2fr auto;align-items:start;gap:1.5rem;display:grid}@media (width<=640px){.vault-row.svelte-1t2bjk0{grid-template-columns:1fr;gap:.9rem}.row.svelte-1t2bjk0>input:where(.svelte-1t2bjk0),.peer-row.svelte-1t2bjk0>input:where(.svelte-1t2bjk0){width:100%;min-width:0}}.member-list.svelte-1t2bjk0{flex-direction:column;gap:.2rem;margin:.3rem 0 0;padding:0;list-style:none;display:flex}.label.svelte-1t2bjk0{font-size:var(--step--1);text-transform:uppercase;letter-spacing:.06em}.peer-row.svelte-1t2bjk0{margin-bottom:.5rem}.grid.svelte-1owrzkc{grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem;margin:1rem 0;display:grid}form.svelte-1owrzkc label:where(.svelte-1owrzkc),.ticket.svelte-1owrzkc label:where(.svelte-1owrzkc){font-size:var(--step--1);margin:.6rem 0 .3rem;display:block}form.svelte-1owrzkc input:where(.svelte-1owrzkc){width:100%}form.svelte-1owrzkc button:where(.svelte-1owrzkc){margin-top:1rem}.row.svelte-1owrzkc{gap:.5rem;display:flex}.row.svelte-1owrzkc input:where(.svelte-1owrzkc){flex:1}.list.svelte-1owrzkc{flex-direction:column;gap:.6rem;display:flex}.friend-row.svelte-1owrzkc{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.5rem;display:flex}.confirm.svelte-1owrzkc{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}button.danger.svelte-1owrzkc{border-color:var(--coral);color:var(--coral)}button.danger.svelte-1owrzkc:hover{background:var(--coral);color:var(--ink)}.row.svelte-imw02{flex-wrap:wrap;align-items:center;gap:1rem;margin-bottom:.75rem;display:flex}.row.svelte-imw02 label:where(.svelte-imw02){font-size:var(--step--1);color:var(--muted);align-items:center;gap:.4rem;display:flex}fieldset.svelte-imw02{border:1px solid var(--hairline);border-radius:var(--radius);margin:0 0 .75rem;padding:.75rem}fieldset.svelte-imw02>label:where(.svelte-imw02),.trustee-list.svelte-imw02 label:where(.svelte-imw02){align-items:center;gap:.5rem;margin:.35rem 0;display:flex}legend.svelte-imw02{font-weight:600}.trustee-list.svelte-imw02{grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:0 1rem;display:grid}.list.svelte-imw02{flex-direction:column;gap:.5rem;display:flex}.set-row.svelte-imw02{gap:1.5rem;display:flex}.share-row.svelte-imw02{align-items:center;gap:.6rem;margin-bottom:.5rem;display:flex}.share-text.svelte-imw02{word-break:break-all;background:var(--plate-raised);border-radius:6px;flex:1;padding:.4em .6em}.grid.svelte-imw02{grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:1rem;display:grid}.grid.svelte-imw02 label:where(.svelte-imw02){font-size:var(--step--1);margin:.5rem 0 .3rem;display:block}.grid.svelte-imw02 input:where(.svelte-imw02),.grid.svelte-imw02 select:where(.svelte-imw02){width:100%;margin-bottom:.5rem}.ceremony.alarm.svelte-imw02{border-color:var(--coral)}.alarm-text.svelte-imw02{color:var(--coral);font-weight:600}dl.svelte-imw02{gap:.5rem;display:grid}dl.svelte-imw02 div:where(.svelte-imw02){grid-template-columns:minmax(6rem,.25fr) 1fr;gap:.75rem;display:grid}dt.svelte-imw02{color:var(--muted)}dd.svelte-imw02{overflow-wrap:anywhere;min-width:0;margin:0}button.danger.svelte-imw02{border-color:var(--coral);color:var(--coral)}.confirm-action.svelte-imw02{align-items:center;gap:.5rem;display:flex!important}.confirm-action.svelte-imw02 input:where(.svelte-imw02){width:auto;margin:0}@media (width<=640px){.share-row.svelte-imw02,.set-row.svelte-imw02{flex-direction:column;align-items:stretch}dl.svelte-imw02 div:where(.svelte-imw02){grid-template-columns:1fr;gap:.1rem}}.resplit-head.svelte-imw02{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:1rem;display:flex}.phase.svelte-imw02{font-size:var(--step--1);border:1px solid var(--hairline);border-radius:999px;padding:.2em .6em}.phase.complete.svelte-imw02{color:var(--verdigris);border-color:var(--verdigris)}.phase.ready_to_destroy.svelte-imw02{color:var(--bronze-strong);border-color:var(--bronze)}.phase.required.svelte-imw02{color:var(--coral);border-color:var(--coral)}.gauges.svelte-imw02{grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1rem;margin-top:.75rem;display:grid}.reach.svelte-imw02{flex-direction:column;gap:.4rem;margin-top:.4rem;display:flex}.reach-row.svelte-imw02{flex-wrap:wrap;align-items:center;gap:.6rem;display:flex}.dot.svelte-imw02{background:var(--muted);border-radius:50%;flex:none;width:.6rem;height:.6rem}.dot.done.svelte-imw02{background:var(--verdigris)}.dot.online.svelte-imw02{background:var(--bronze)}.dot.will_queue.svelte-imw02,.dot.offline.svelte-imw02{background:var(--muted)}.role.svelte-imw02{font-size:var(--step--1);color:var(--muted)}form.svelte-d3lnpc label:where(.svelte-d3lnpc){font-size:var(--step--1);margin:.6rem 0 .3rem;display:block}form.svelte-d3lnpc input:where(.svelte-d3lnpc),form.svelte-d3lnpc select:where(.svelte-d3lnpc),form.svelte-d3lnpc textarea:where(.svelte-d3lnpc){width:100%}form.svelte-d3lnpc button:where(.svelte-d3lnpc){margin-top:1rem}.row.svelte-d3lnpc{align-items:center;gap:.5rem;display:flex}.share-text.svelte-d3lnpc{word-break:break-all;background:var(--plate-raised);border-radius:6px;flex:1;padding:.4em .6em}.confirm-action.svelte-d3lnpc{align-items:center;gap:.5rem;display:flex}.confirm-action.svelte-d3lnpc input:where(.svelte-d3lnpc){width:auto}@media (width<=640px){.row.svelte-d3lnpc{flex-direction:column;align-items:stretch}}.app.svelte-1uha8ag{max-width:960px;margin:0 auto;padding:1.5rem}header.svelte-1uha8ag{border-bottom:1px solid var(--hairline);flex-wrap:wrap;align-items:center;gap:1.5rem;margin-bottom:2rem;padding-bottom:1rem;display:flex}.brand.svelte-1uha8ag{font-weight:700;font-size:var(--step-2);align-items:center;gap:.5rem;display:flex}.mark.svelte-1uha8ag{color:var(--bronze)}nav.svelte-1uha8ag{flex-wrap:wrap;flex:1;gap:.25rem;display:flex}nav.svelte-1uha8ag a:where(.svelte-1uha8ag){color:var(--muted);border-radius:var(--radius);transition:color var(--transition), background var(--transition);padding:.4em .8em;text-decoration:none}nav.svelte-1uha8ag a:where(.svelte-1uha8ag):hover,nav.svelte-1uha8ag a:where(.svelte-1uha8ag):focus-visible{color:var(--bone);background:var(--plate)}nav.svelte-1uha8ag a.active:where(.svelte-1uha8ag){color:var(--bronze);background:var(--plate);font-weight:600}.status-and-theme.svelte-1uha8ag{align-items:center;gap:.75rem;display:flex}.live-dot.svelte-1uha8ag{background:var(--coral);border-radius:50%;width:10px;height:10px;display:inline-block}.live-dot.live.svelte-1uha8ag{background:var(--verdigris)}.connection-text.svelte-1uha8ag{font-size:var(--step--1);color:var(--muted)}@media (width<=640px){.app.svelte-1uha8ag{padding:1rem}header.svelte-1uha8ag{gap:.75rem}nav.svelte-1uha8ag{flex-basis:100%;order:3}nav.svelte-1uha8ag a:where(.svelte-1uha8ag){padding:.55em .65em}.status-and-theme.svelte-1uha8ag{margin-left:auto}} diff --git a/crates/carapace-api/static/_app/immutable/assets/2.Ccp3aMT0.css b/crates/carapace-api/static/_app/immutable/assets/2.Ccp3aMT0.css deleted file mode 100644 index cada163..0000000 --- a/crates/carapace-api/static/_app/immutable/assets/2.Ccp3aMT0.css +++ /dev/null @@ -1 +0,0 @@ -.banner.svelte-vde8u4{background:var(--plate);border:1px solid var(--coral);color:var(--coral);border-radius:var(--radius);justify-content:space-between;align-items:center;gap:1rem;margin-bottom:1rem;padding:.75rem 1rem;display:flex}.banner.svelte-vde8u4 button:where(.svelte-vde8u4){border-color:var(--coral);color:var(--coral);background:0 0;flex-shrink:0}.shell.svelte-v2pnom{grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1.25rem;display:grid}.plate-group.svelte-v2pnom{background:var(--plate);border:1px solid var(--hairline);border-radius:var(--radius);animation:.42s backwards svelte-v2pnom-reveal;animation-delay:calc(var(--i) * 80ms);padding:1.25rem}@keyframes svelte-v2pnom-reveal{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}.segments.svelte-v2pnom{margin-bottom:.9rem;display:flex}.segment.svelte-v2pnom{border:2px solid var(--hairline);clip-path:polygon(14% 0,100% 0,86% 100%,0 100%);background:0 0;flex:1;height:34px;margin-left:-10px}.segment.svelte-v2pnom:first-child{margin-left:0}.state-healthy.svelte-v2pnom .segment.filled:where(.svelte-v2pnom){background:var(--verdigris);border-color:var(--verdigris)}.state-at-risk.svelte-v2pnom .segment.filled:where(.svelte-v2pnom){background:var(--coral);border-color:var(--coral)}.state-at-risk.svelte-v2pnom .segment:where(.svelte-v2pnom):not(.filled){border-color:var(--coral);border-style:dashed}.state-empty.svelte-v2pnom .segment:where(.svelte-v2pnom){border-style:dashed}.label.svelte-v2pnom{font-size:var(--step--1);text-transform:uppercase;letter-spacing:.06em;color:var(--muted)}.value.svelte-v2pnom{font-size:var(--step-3);margin:.15em 0;font-weight:600}.state-healthy.svelte-v2pnom .value:where(.svelte-v2pnom){color:var(--verdigris)}.state-at-risk.svelte-v2pnom .value:where(.svelte-v2pnom){color:var(--coral)}.note.svelte-v2pnom{font-size:var(--step--1)}.hex.svelte-3sdcoe{cursor:pointer;color:inherit;background:0 0;border:none;align-items:baseline;gap:.5em;padding:0;display:inline-flex}.hex.svelte-3sdcoe:hover .hint:where(.svelte-3sdcoe),.hex.svelte-3sdcoe:focus-visible .hint:where(.svelte-3sdcoe){color:var(--bronze)}.hint.svelte-3sdcoe{font-size:var(--step--1);font-family:var(--font-sans)}.node.svelte-156cahi{flex-wrap:wrap;gap:2rem;margin:1.5rem 0;display:flex}.label.svelte-156cahi{font-size:var(--step--1);text-transform:uppercase;letter-spacing:.06em;margin-bottom:.3em}.actions.svelte-156cahi{flex-wrap:wrap;gap:.75rem;display:flex}.button-link.svelte-156cahi{background:var(--plate-raised);border:1px solid var(--hairline);color:var(--bone);border-radius:var(--radius);transition:border-color var(--transition);padding:.55em 1em;text-decoration:none}.button-link.svelte-156cahi:hover,.button-link.svelte-156cahi:focus-visible{border-color:var(--bronze)}.publish-form.svelte-1t2bjk0 label:where(.svelte-1t2bjk0){font-size:var(--step--1);color:var(--muted);margin-bottom:.4rem;display:block}.row.svelte-1t2bjk0{flex-wrap:wrap;align-items:center;gap:.6rem;display:flex}.row.svelte-1t2bjk0 input:where(.svelte-1t2bjk0){flex:1;min-width:180px}.list.svelte-1t2bjk0{flex-direction:column;gap:.75rem;margin-top:1.5rem;display:flex}.vault-row.svelte-1t2bjk0{grid-template-columns:1fr 2fr auto;align-items:start;gap:1.5rem;display:grid}.member-list.svelte-1t2bjk0{flex-direction:column;gap:.2rem;margin:.3rem 0 0;padding:0;list-style:none;display:flex}.label.svelte-1t2bjk0{font-size:var(--step--1);text-transform:uppercase;letter-spacing:.06em}.peer-row.svelte-1t2bjk0{margin-bottom:.5rem}.grid.svelte-1owrzkc{grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem;margin:1rem 0;display:grid}form.svelte-1owrzkc label:where(.svelte-1owrzkc),.ticket.svelte-1owrzkc label:where(.svelte-1owrzkc){font-size:var(--step--1);margin:.6rem 0 .3rem;display:block}form.svelte-1owrzkc input:where(.svelte-1owrzkc){width:100%}form.svelte-1owrzkc button:where(.svelte-1owrzkc){margin-top:1rem}.row.svelte-1owrzkc{gap:.5rem;display:flex}.row.svelte-1owrzkc input:where(.svelte-1owrzkc){flex:1}.list.svelte-1owrzkc{flex-direction:column;gap:.6rem;display:flex}.friend-row.svelte-1owrzkc{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.5rem;display:flex}.confirm.svelte-1owrzkc{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}button.danger.svelte-1owrzkc{border-color:var(--coral);color:var(--coral)}button.danger.svelte-1owrzkc:hover{background:var(--coral);color:var(--ink)}.row.svelte-imw02{flex-wrap:wrap;align-items:center;gap:1rem;margin-bottom:.75rem;display:flex}.row.svelte-imw02 label:where(.svelte-imw02){font-size:var(--step--1);color:var(--muted);align-items:center;gap:.4rem;display:flex}.list.svelte-imw02{flex-direction:column;gap:.5rem;display:flex}.set-row.svelte-imw02{gap:1.5rem;display:flex}.share-row.svelte-imw02{align-items:center;gap:.6rem;margin-bottom:.5rem;display:flex}.share-text.svelte-imw02{word-break:break-all;background:var(--plate-raised);border-radius:6px;flex:1;padding:.4em .6em}.grid.svelte-imw02{grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:1rem;display:grid}.grid.svelte-imw02 label:where(.svelte-imw02){font-size:var(--step--1);margin:.5rem 0 .3rem;display:block}.grid.svelte-imw02 input:where(.svelte-imw02){width:100%;margin-bottom:.5rem}.resplit-head.svelte-imw02{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:1rem;display:flex}.phase.svelte-imw02{font-size:var(--step--1);border:1px solid var(--hairline);border-radius:999px;padding:.2em .6em}.phase.complete.svelte-imw02{color:var(--verdigris);border-color:var(--verdigris)}.phase.ready_to_destroy.svelte-imw02{color:var(--bronze-strong);border-color:var(--bronze)}.phase.required.svelte-imw02{color:var(--coral);border-color:var(--coral)}.gauges.svelte-imw02{grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1rem;margin-top:.75rem;display:grid}.reach.svelte-imw02{flex-direction:column;gap:.4rem;margin-top:.4rem;display:flex}.reach-row.svelte-imw02{flex-wrap:wrap;align-items:center;gap:.6rem;display:flex}.dot.svelte-imw02{background:var(--muted);border-radius:50%;flex:none;width:.6rem;height:.6rem}.dot.done.svelte-imw02{background:var(--verdigris)}.dot.online.svelte-imw02{background:var(--bronze)}.dot.will_queue.svelte-imw02,.dot.offline.svelte-imw02{background:var(--muted)}.role.svelte-imw02{font-size:var(--step--1);color:var(--muted)}form.svelte-d3lnpc label:where(.svelte-d3lnpc){font-size:var(--step--1);margin:.6rem 0 .3rem;display:block}form.svelte-d3lnpc input:where(.svelte-d3lnpc),form.svelte-d3lnpc select:where(.svelte-d3lnpc),form.svelte-d3lnpc textarea:where(.svelte-d3lnpc){width:100%}form.svelte-d3lnpc button:where(.svelte-d3lnpc){margin-top:1rem}.row.svelte-d3lnpc{align-items:center;gap:.5rem;display:flex}.share-text.svelte-d3lnpc{word-break:break-all;background:var(--plate-raised);border-radius:6px;flex:1;padding:.4em .6em}.app.svelte-1uha8ag{max-width:960px;margin:0 auto;padding:1.5rem}header.svelte-1uha8ag{border-bottom:1px solid var(--hairline);flex-wrap:wrap;align-items:center;gap:1.5rem;margin-bottom:2rem;padding-bottom:1rem;display:flex}.brand.svelte-1uha8ag{font-weight:700;font-size:var(--step-2);align-items:center;gap:.5rem;display:flex}.mark.svelte-1uha8ag{color:var(--bronze)}nav.svelte-1uha8ag{flex-wrap:wrap;flex:1;gap:.25rem;display:flex}nav.svelte-1uha8ag a:where(.svelte-1uha8ag){color:var(--muted);border-radius:var(--radius);transition:color var(--transition), background var(--transition);padding:.4em .8em;text-decoration:none}nav.svelte-1uha8ag a:where(.svelte-1uha8ag):hover,nav.svelte-1uha8ag a:where(.svelte-1uha8ag):focus-visible{color:var(--bone);background:var(--plate)}nav.svelte-1uha8ag a.active:where(.svelte-1uha8ag){color:var(--bronze);background:var(--plate);font-weight:600}.status-and-theme.svelte-1uha8ag{align-items:center;gap:.75rem;display:flex}.live-dot.svelte-1uha8ag{background:var(--coral);border-radius:50%;width:10px;height:10px;display:inline-block}.live-dot.live.svelte-1uha8ag{background:var(--verdigris)} diff --git a/crates/carapace-api/static/_app/immutable/chunks/BGevx8A_.js b/crates/carapace-api/static/_app/immutable/chunks/BGevx8A_.js new file mode 100644 index 0000000..c2df053 --- /dev/null +++ b/crates/carapace-api/static/_app/immutable/chunks/BGevx8A_.js @@ -0,0 +1 @@ +import{G as e,K as t,M as n,N as r,X as i,j as a,r as o,t as s}from"./D7CCBGlz.js";import"./CCeg2KC3.js";var c=class{constructor(e,t){this.status=e,typeof t==`string`?this.body={message:t}:t?this.body=t:this.body={message:`Error: ${e}`}}toString(){return JSON.stringify(this.body)}},l=class{constructor(e,t){try{new Headers({location:t})}catch{throw Error(`Invalid redirect location ${JSON.stringify(t)}: this string contains characters that cannot be used in HTTP headers`)}this.status=e,this.location=t}},u=class extends Error{constructor(e,t,n){super(n),this.status=e,this.text=t}};new URL(`sveltekit-internal://`);function d(e,t){return e===`/`||t===`ignore`?e:t===`never`?e.endsWith(`/`)?e.slice(0,-1):e:t===`always`&&!e.endsWith(`/`)?e+`/`:e}function f(e){return e.split(`%25`).map(decodeURI).join(`%25`)}function p(e){for(let t in e)e[t]=decodeURIComponent(e[t]);return e}function m({href:e}){return e.split(`#`)[0]}function h(){}function g(...e){let t=5381;for(let n of e)if(typeof n==`string`){let e=n.length;for(;e;)t=t*33^n.charCodeAt(--e)}else if(ArrayBuffer.isView(n)){let e=new Uint8Array(n.buffer,n.byteOffset,n.byteLength),r=e.length;for(;r;)t=t*33^e[--r]}else throw TypeError(`value must be a string or TypedArray`);return(t>>>0).toString(36)}new TextEncoder;function _(e){let t=atob(e),n=new Uint8Array(t.length);for(let e=0;e((e instanceof Request?e.method:t?.method||`GET`)!==`GET`&&y.delete(x(e)),v(e,t));var y=new Map;function ee(e,t){let n=x(e,t),r=document.querySelector(n);if(r?.textContent){r.remove();let{body:e,...t}=JSON.parse(r.textContent);r.getAttribute(`data-b64`)!==null&&(e=_(e));let i=r.getAttribute(`data-ttl`);return i&&y.set(n,{body:e,init:t,ttl:1e3*Number(i)}),Promise.resolve(new Response(e,t))}return window.fetch(e,t)}function b(e,t,n){if(y.size>0){let t=x(e,n),r=y.get(t);if(r){if(performance.now(){let n=/^\[\.\.\.(\w+)(?:=(\w+))?\]$/.exec(e);if(n)return t.push({name:n[1],matcher:n[2],optional:!1,rest:!0,chained:!0}),`(?:/([^]*))?`;let r=/^\[\[(\w+)(?:=(\w+))?\]\]$/.exec(e);if(r)return t.push({name:r[1],matcher:r[2],optional:!0,rest:!1,chained:!0}),`(?:/([^/]+))?`;if(!e)return;let i=e.split(/\[(.+?)\](?!\])/);return`/`+i.map((e,n)=>{if(n%2){if(e.startsWith(`x+`))return se(String.fromCharCode(parseInt(e.slice(2),16)));if(e.startsWith(`u+`))return se(String.fromCharCode(...e.slice(2).split(`-`).map(e=>parseInt(e,16))));let[,r,a,o,s]=te.exec(e);return t.push({name:o,matcher:s,optional:!!r,rest:!!a,chained:a?n===1&&i[0]===``:!1}),a?`([^]*?)`:r?`([^/]*)?`:`([^/]+?)`}return se(e)}).join(``)}).join(``)}/?$`),params:t}}function ie(e){return e!==``&&!/^\([^)]+\)$/.test(e)}function ae(e){return e.slice(1).split(`/`).filter(ie)}function oe(e,t,n){let r={},i=e.slice(1),a=i.filter(e=>e!==void 0),o=0;for(let e=0;ee).join(`/`),o=0),c===void 0)if(s.rest)c=``;else continue;if(!s.matcher||n[s.matcher](c)){r[s.name]=c;let n=t[e+1],l=i[e+1];n&&!n.rest&&n.optional&&l&&s.chained&&(o=0),!n&&!l&&Object.keys(r).length===a.length&&(o=0);continue}if(s.optional&&s.chained){o++;continue}return}if(!o)return r}function se(e){return e.normalize().replace(/[[\]]/g,`\\$&`).replace(/%/g,`%25`).replace(/\//g,`%2[Ff]`).replace(/\?/g,`%3[Ff]`).replace(/#/g,`%23`).replace(/[.*+?^${}()|\\]/g,`\\$&`)}function ce({nodes:e,server_loads:t,dictionary:n,matchers:r}){let i=new Set(t);return Object.entries(n).map(([t,[n,i,s]])=>{let{pattern:c,params:l}=re(t),u={id:t,exec:e=>{let t=c.exec(e);if(t)return oe(t,l,r)},errors:[1,...s||[]].map(t=>e[t]),layouts:[0,...i||[]].map(o),leaf:a(n)};return u.errors.length=u.layouts.length=Math.max(u.errors.length,u.layouts.length),u});function a(t){let n=t<0;return n&&(t=~t),[n,e[t]]}function o(t){return t===void 0?t:[i.has(t),e[t]]}}function le(e,t=JSON.parse){try{return t(sessionStorage[e])}catch{}}function ue(e,t,n=JSON.stringify){let r=n(t);try{sessionStorage[e]=r}catch{}}var S=globalThis.__sveltekit_1wnehg?.base??``,de=globalThis.__sveltekit_1wnehg?.assets??S??``,fe=`0.0.1`,pe=`sveltekit:snapshot`,me=`sveltekit:scroll`,he=`sveltekit:states`,C=`sveltekit:history`,w=`sveltekit:navigation`,T={tap:1,hover:2,viewport:3,eager:4,off:-1,false:-1},ge=location.origin;function _e(e){if(e instanceof URL)return e;let t=document.baseURI;if(!t){let e=document.getElementsByTagName(`base`);t=e.length?e[0].href:document.URL}return new URL(e,t)}function E(){return{x:pageXOffset,y:pageYOffset}}function D(e,t){return e.getAttribute(`data-sveltekit-${t}`)}var ve={...T,"":T.hover};function ye(e){let t=e.assignedSlot??e.parentNode;return t?.nodeType===11&&(t=t.host),t}function be(e,t){for(;e&&e!==t;){if(e.nodeName.toUpperCase()===`A`&&e.hasAttribute(`href`))return e;e=ye(e)}}function xe(e,t,n){let r;try{if(r=new URL(e instanceof SVGAElement?e.href.baseVal:e.href,document.baseURI),n&&r.hash.match(/^#[^/]/)){let e=location.hash.split(`#`)[1]||`/`;r.hash=`#${e}${r.hash}`}}catch{}let i=e instanceof SVGAElement?e.target.baseVal:e.target,a=!r||!!i||k(r,t,n)||(e.getAttribute(`rel`)||``).split(/\s+/).includes(`external`),o=r?.origin===ge&&e.hasAttribute(`download`);return{url:r,external:a,target:i,download:o}}function O(e){let t=null,n=null,r=null,i=null,a=null,o=null,s=e;for(;s&&s!==document.documentElement;)r===null&&(r=D(s,`preload-code`)),i===null&&(i=D(s,`preload-data`)),t===null&&(t=D(s,`keepfocus`)),n===null&&(n=D(s,`noscroll`)),a===null&&(a=D(s,`reload`)),o===null&&(o=D(s,`replacestate`)),s=ye(s);function c(e){switch(e){case``:case`true`:return!0;case`off`:case`false`:return!1;default:return}}return{preload_code:ve[r??`off`],preload_data:ve[i??`off`],keepfocus:c(t),noscroll:c(n),reload:c(a),replace_state:c(o)}}function Se(e){let t=i(e),n=!0;function r(){n=!0,t.update(e=>e)}function a(e){n=!1,t.set(e)}function o(e){let r;return t.subscribe(t=>{(r===void 0||n&&t!==r)&&e(r=t)})}return{notify:r,set:a,subscribe:o}}var Ce={v:h};function we(){let{set:e,subscribe:t}=i(!1);async function n(){clearTimeout(void 0);try{let t=await fetch(`${de}/_app/version.json`,{headers:{pragma:`no-cache`,"cache-control":`no-cache`}});if(!t.ok)return!1;let n=(await t.json()).version!==fe;return n&&(e(!0),Ce.v(),clearTimeout(void 0)),n}catch{return!1}}return{subscribe:t,check:n}}function k(e,t,n){return e.origin!==ge||!e.pathname.startsWith(t)?!0:n?e.pathname!==location.pathname:!1}function Te(e){}var Ee=new Set([`load`,`prerender`,`csr`,`ssr`,`trailingSlash`,`config`]);[...Ee],[...new Set([...Ee])];function De(e){return e.filter(e=>e!=null)}function A(e,t){return e+`/`+t}function Oe(e){return e instanceof c||e instanceof u?e.status:500}function ke(e){return e instanceof u?e.text:`Internal Error`}var j,M,N,Ae=o.toString().includes(`$$`)||/function \w+\(\) \{\}/.test(o.toString()),je=`a:`;Ae?(j={data:{},form:null,error:null,params:{},route:{id:null},state:{},status:-1,url:new URL(je)},M={current:null},N={current:!1}):(j=new class{#e=t({});get data(){return a(this.#e)}set data(t){e(this.#e,t)}#t=t(null);get form(){return a(this.#t)}set form(t){e(this.#t,t)}#n=t(null);get error(){return a(this.#n)}set error(t){e(this.#n,t)}#r=t({});get params(){return a(this.#r)}set params(t){e(this.#r,t)}#i=t({id:null});get route(){return a(this.#i)}set route(t){e(this.#i,t)}#a=t({});get state(){return a(this.#a)}set state(t){e(this.#a,t)}#o=t(-1);get status(){return a(this.#o)}set status(t){e(this.#o,t)}#s=t(new URL(je));get url(){return a(this.#s)}set url(t){e(this.#s,t)}},M=new class{#e=t(null);get current(){return a(this.#e)}set current(t){e(this.#e,t)}},N=new class{#e=t(!1);get current(){return a(this.#e)}set current(t){e(this.#e,t)}},Ce.v=()=>N.current=!0);function Me(e){Object.assign(j,e)}var{onMount:Ne,tick:Pe}=s,Fe=new Set([`icon`,`shortcut icon`,`apple-touch-icon`]),P=null,F=le(`sveltekit:scroll`)??{},I=le(`sveltekit:snapshot`)??{},L={url:Se({}),page:Se({}),navigating:i(null),updated:we()};function Ie(e){F[e]=E()}function Le(e,t){let n=e+1;for(;F[n];)delete F[n],n+=1;for(n=t+1;I[n];)delete I[n],n+=1}function R(e,t=!1){return t?location.replace(e.href):location.href=e.href,new Promise(h)}async function Re(){if(`serviceWorker`in navigator){let e=await navigator.serviceWorker.getRegistration(S||`/`);e&&await e.update()}}var ze,Be,z,B,Ve,V,He={},Ue={},H=[],We=[],U=null;function Ge(){U?.fork?.then(e=>e?.discard()),U=null,Q={element:void 0,href:void 0}}var Ke=new Map,qe=new Set,Je=new Set,W=new Set,G={branch:[],error:null,url:null,nav:null},Ye=!1,Xe=!1,Ze=!0,K=!1,q=!1,Qe=!1,$e=!1,et,J,Y,X,tt=new Set,nt=new Map,rt=new Map;async function it(e,t,n){if(globalThis.__sveltekit_1wnehg.data){let{q:e={},p:t={},l:n={},f:r={}}=globalThis.__sveltekit_1wnehg.data;for(let t in e)He[t]=e[t];for(let e in n)He[e]=n[e];for(let e in r)He[e]=r[e];for(let e in t)Ue[e]=t[e]}document.URL!==location.href&&(location.href=location.href),V=e,await e.hooks.init?.(),ze=ce(e),B=document.documentElement,Ve=t,Be=e.nodes[0],z=e.nodes[1],Be(),z(),J=history.state?.[C],Y=history.state?.[w],J||(J=Y=Date.now(),history.replaceState({...history.state,[C]:J,[w]:Y},``));let r=F[J];function i(){r&&(history.scrollRestoration=`manual`,scrollTo(r.x,r.y))}n?(i(),await Mt(Ve,n)):(await Z({type:`enter`,url:_e(V.hash?Rt(new URL(location.href)):location.href),replace_state:!0}),i()),jt()}function at(){H.length=0,$e=!1}function ot(e){We.some(e=>e?.snapshot)&&(I[e]=We.map(e=>e?.snapshot?.capture()))}function st(e){I[e]?.forEach((e,t)=>{We[t]?.snapshot?.restore(e)})}function ct(){Ie(J),ue(me,F),ot(Y),ue(pe,I)}async function lt(e,t,n,i){let a,o;t.invalidateAll&&Ge(),await Z({type:`goto`,url:_e(e),keepfocus:t.keepFocus,noscroll:t.noScroll,replace_state:t.replaceState,state:t.state,redirect_count:n,nav_token:i,accept:()=>{if(t.invalidateAll){$e=!0,a=new Set;for(let[e,t]of nt)for(let[n,r]of t)r.resource?.reset(),a.add(A(e,n));o=new Set;for(let[e,t]of rt)for(let n of t.keys())o.add(A(e,n))}t.invalidate&&t.invalidate.forEach(At)}}),t.invalidateAll&&r().then(r).then(()=>{for(let[e,t]of nt)for(let[n,{resource:r}]of t)a?.has(A(e,n))&&r.start();for(let[e,t]of rt)for(let[n,{resource:r}]of t)o?.has(A(e,n))&&r.reconnect()})}async function ut(e){if(e.id!==U?.id){Ge();let t={};tt.add(t),U={id:e.id,token:t,promise:bt({...e,preload:t}).then(e=>(tt.delete(t),e.type===`loaded`&&e.state.error&&Ge(),e)),fork:null}}return U.promise}async function dt(e){let t=(await wt(e,!1))?.route;t&&await Promise.all([...t.layouts,t.leaf].filter(Boolean).map(e=>e[1]()))}async function ft(e,t,n){let r={params:G.params,route:{id:G.route?.id??null},url:new URL(location.href)};if(G={...e.state,nav:r},Me(e.props.page),et=new V.root({target:t,props:{...e.props,stores:L,components:We},hydrate:n,sync:!1,transformError:void 0}),await Promise.resolve(),n){let e={from:null,to:{...r,scroll:F[J]??E()},willUnload:!1,type:`enter`,complete:Promise.resolve()};W.forEach(t=>t(e))}st(Y),Xe=!0}async function pt({url:e,params:t,branch:n,errors:r,status:i,error:a,route:o,form:s}){let c=`never`;if(S&&(e.pathname===S||e.pathname===S+`/`))c=`always`;else for(let e of n)e?.slash!==void 0&&(c=e.slash);e.pathname=d(e.pathname,c),e.search=e.search;let l={type:`loaded`,state:{url:e,params:t,branch:n,error:a,route:o},props:{constructors:De(n).map(e=>e.node.component),page:Lt(j)}};s!==void 0&&(l.props.form=s);let u={},f=!j,p=0;for(let e=0;et(new URL(e))))return!0;return!1}function _t(e,t){return e?.type===`data`?e:e?.type===`skip`?t??null:null}function vt(e,t){if(!e)return new Set(t.searchParams.keys());let n=new Set([...e.searchParams.keys(),...t.searchParams.keys()]);for(let r of n){let i=e.searchParams.getAll(r),a=t.searchParams.getAll(r);i.every(e=>a.includes(e))&&a.every(e=>i.includes(e))&&n.delete(r)}return n}function yt({error:e,url:t,route:n,params:r}){return{type:`loaded`,state:{error:e,url:t,route:n,params:r,branch:[]},props:{page:Lt(j),constructors:[]}}}async function bt({id:e,invalidating:t,url:n,params:r,route:i,preload:a}){if(U?.id===e)return tt.delete(U.token),U.promise;let{errors:o,layouts:s,leaf:u}=i,d=[...s,u];o.forEach(e=>e?.().catch(h)),d.forEach(e=>e?.[1]().catch(h));let f=G.url?e!==Et(G.url):!1,p=G.route?i.id!==G.route.id:!1,m=vt(G.url,n),g=!1,_=d.map(async(e,t)=>{if(!e)return;let a=G.branch[t];return e[1]===a?.loader&&!gt(g,p,f,m,a.universal?.uses,r)?a:(g=!0,mt({loader:e[1],url:n,params:r,route:i,parent:async()=>{let e={};for(let n=0;nPromise.resolve({}),server_data_node:_t(null)}),{node:await z(),loader:z,universal:null,server:null,data:null}],status:e,error:t,errors:[],route:null})}catch(t){if(t instanceof l){await lt(new URL(t.location,location.href),{},0);return}let a=await V.get_error_template(),o=await $(t,{url:n,params:i,route:r}),s=a({status:e,message:String(o?.message??``).replace(/&/g,`&`).replace(//g,`>`)}),c=new DOMParser().parseFromString(s,`text/html`);throw document.documentElement.replaceChild(document.adoptNode(c.head),document.head),document.documentElement.replaceChild(document.adoptNode(c.body),document.body),t}}async function Ct(e){let t=e.href;if(Ke.has(t))return Ke.get(t);let n;try{let r=(async()=>{let t=await V.hooks.reroute({url:new URL(e),fetch:async(t,n)=>ht(t,n,e).promise})??e;if(typeof t==`string`){let n=new URL(e);V.hash?n.hash=t:n.pathname=t,t=n}return t})();Ke.set(t,r),n=await r}catch{Ke.delete(t);return}return n}async function wt(e,t){if(e&&!k(e,S,V.hash)){let n=await Ct(e);if(!n)return;let r=Tt(n);for(let n of ze){let i=n.exec(r);if(i)return{id:Et(e),invalidating:t,route:n,params:p(i),url:e}}}}function Tt(e){return f(V.hash?e.hash.replace(/^#/,``).replace(/[?#].+/,``):e.pathname.slice(S.length))||`/`}function Et(e){return(V.hash?e.hash.replace(/^#/,``):e.pathname)+e.search}function Dt({url:e,type:t,intent:n,delta:r,event:i,scroll:a}){let o=!1,s=It(G,n,e,t,a??null);r!==void 0&&(s.navigation.delta=r),i!==void 0&&(s.navigation.event=i);let c={...s.navigation,cancel:()=>{o=!0,s.reject(Error(`navigation cancelled`))}};return K||qe.forEach(e=>e(c)),o?null:s}async function Z({type:e,url:t,popped:i,keepfocus:a,noscroll:o,replace_state:s,state:c={},redirect_count:l=0,nav_token:d={},accept:f=h,block:p=h,event:m}){let g=X;X=d;let _=await wt(t,!1),v=e===`enter`?It(G,_,t,e):Dt({url:t,type:e,delta:i?.delta,intent:_,scroll:i?.scroll,event:m});if(!v){p(),X===d&&(X=g);return}let y=J,ee=Y;f(),K=!0,Xe&&v.navigation.type!==`enter`&&L.navigating.set(M.current=v.navigation);let b=_&&await bt(_);if(!b){if(k(t,S,V.hash))return await R(t,s);b=await Ot(t,{id:null},await $(new u(404,`Not Found`,`Not found: ${t.pathname}`),{url:t,params:{},route:{id:null}}),404,s)}if(t=_?.url||t,X!==d){v.reject(Error(`navigation aborted`));return}if(!b)return;if(b.type===`redirect`){if(l<20){await Z({type:e,url:new URL(b.location,t),popped:i,keepfocus:a,noscroll:o,replace_state:s,state:c,redirect_count:l+1,nav_token:d}),v.fulfil(void 0);return}if(b=await St({status:500,error:await $(Error(`Redirect loop`),{url:t,params:{},route:{id:null}}),url:t,route:{id:null}}),!b)return}else if(b.props.page.status>=400&&await L.updated.check())return await Re(),await R(t,s);if(at(),Ie(y),ot(ee),b.props.page.url.pathname!==t.pathname&&(t.pathname=b.props.page.url.pathname),c=i?i.state:c,!i){let e=+!s,n={[C]:J+=e,[w]:Y+=e,[he]:c};(s?history.replaceState:history.pushState).call(history,n,``,t),s||Le(J,Y)}let x=_&&U?.id===_.id?U.fork:null;U?.fork&&!x?Ge():(U=null,Q={element:void 0,href:void 0}),b.props.page.state=c;let te;if(Xe){let e=(await Promise.all(Array.from(Je,e=>e(v.navigation)))).filter(e=>typeof e==`function`);if(e.length>0){function t(){e.forEach(e=>{W.delete(e)})}e.push(t),e.forEach(e=>{W.add(e)})}let r=v.navigation.to;G={...b.state,nav:{params:r.params,route:r.route,url:r.url}},b.props.page&&(b.props.page.url=t),!a&&document.activeElement instanceof HTMLElement&&document.activeElement!==document.body&&document.activeElement.blur();let i=x&&await x;i?te=i.commit():(P=null,et.$set(b.props),P&&Object.assign(b.props.page,P),Me(b.props.page),te=n?.()),Qe=!0}else await ft(b,Ve,!1);let{activeElement:ne}=document;if(await te,await r(),await r(),X!==d){v.reject(Error(`navigation aborted`));return}b.props.page&&P&&Object.assign(b.props.page,P);let re=null;if(Ze){let e=i?i.scroll:o?E():null;e?scrollTo(e.x,e.y):(re=t.hash&&document.getElementById(zt(t)))?re.scrollIntoView():scrollTo(0,0)}let ie=document.activeElement!==ne&&document.activeElement!==document.body;!a&&!ie&&Ft(t,!re),Ze=!0,K=!1,v.fulfil(void 0),v.navigation.to&&(v.navigation.to.scroll=E()),W.forEach(e=>e(v.navigation)),e===`popstate`&&st(Y),L.navigating.set(M.current=null)}async function Ot(e,t,n,r,i){return e.origin===ge&&e.pathname===location.pathname&&!Ye?await St({status:r,error:n,url:e,route:t}):await R(e,i)}var Q={element:void 0,href:void 0};function kt(){let e,t;B.addEventListener(`mousemove`,t=>{let n=t.target;clearTimeout(e),e=setTimeout(()=>{i(n,T.hover)},20)});function n(e){e.defaultPrevented||i(e.composedPath()[0],T.tap)}B.addEventListener(`mousedown`,n),B.addEventListener(`touchstart`,n,{passive:!0});let r=new IntersectionObserver(e=>{for(let t of e)t.isIntersecting&&(dt(new URL(t.target.href)),r.unobserve(t.target))},{threshold:0});async function i(e,n){let r=be(e,B),i=r===Q.element&&r?.href===Q.href&&n>=t;if(!r||i)return;let{url:a,external:o,download:s}=xe(r,S,V.hash);if(o||s)return;let c=O(r),l=a&&Et(G.url)===Et(a);if(!(c.reload||l))if(n<=c.preload_data){Q={element:r,href:r.href},t=T.tap;let e=await wt(a,!1);if(!e)return;ut(e)}else n<=c.preload_code&&(Q={element:r,href:r.href},t=n,dt(a))}function a(){r.disconnect();for(let e of B.querySelectorAll(`a`)){let{url:t,external:n,download:i}=xe(e,S,V.hash);if(n||i)continue;let a=O(e);a.reload||(a.preload_code===T.viewport&&r.observe(e),a.preload_code===T.eager&&dt(t))}}W.add(a),a()}function $(e,t){if(e instanceof c)return e.body;let n=Oe(e),r=ke(e);return V.hooks.handleError({error:e,event:t,status:n,message:r})??{message:r}}function At(e){if(typeof e==`function`)H.push(e);else{let{href:t}=new URL(e,location.href);H.push(e=>e.href===t)}}function jt(){history.scrollRestoration=`manual`,addEventListener(`beforeunload`,e=>{let t=!1;if(ct(),!K){let e=It(G,void 0,null,`leave`),n={...e.navigation,cancel:()=>{t=!0,e.reject(Error(`navigation cancelled`))}};qe.forEach(e=>e(n))}t?(e.preventDefault(),e.returnValue=``):history.scrollRestoration=`auto`}),addEventListener(`visibilitychange`,()=>{document.visibilityState===`hidden`&&ct()}),navigator.connection?.saveData||kt(),B.addEventListener(`click`,async t=>{if(t.button||t.which!==1||t.metaKey||t.ctrlKey||t.shiftKey||t.altKey||t.defaultPrevented)return;let n=be(t.composedPath()[0],B);if(!n)return;let{url:r,external:i,target:a,download:o}=xe(n,S,V.hash);if(!r)return;if(a===`_parent`||a===`_top`){if(window.parent!==window)return}else if(a&&a!==`_self`)return;let s=O(n);if(!(n instanceof SVGAElement)&&r.protocol!==location.protocol&&!(r.protocol===`https:`||r.protocol===`http:`)||o)return;let[c,l]=(V.hash?r.hash.replace(/^#/,``):r.href).split(`#`),u=c===m(location);if(i||s.reload&&(!u||!l)){Dt({url:r,type:`link`,event:t})?K=!0:t.preventDefault();return}if(l!==void 0&&u){let[,i]=G.url.href.split(`#`);if(i===l){if(t.preventDefault(),l===``||l===`top`&&n.ownerDocument.getElementById(`top`)===null)scrollTo({top:0});else{let e=n.ownerDocument.getElementById(decodeURIComponent(l));e&&(e.scrollIntoView(),e.focus())}return}if(q=!0,Ie(J),e(r),!s.replace_state)return;q=!1}t.preventDefault(),await new Promise(e=>{requestAnimationFrame(()=>{setTimeout(e,0)}),setTimeout(e,100)}),await Z({type:`link`,url:r,keepfocus:s.keepfocus,noscroll:s.noscroll,replace_state:s.replace_state??r.href===location.href,event:t})}),B.addEventListener(`submit`,e=>{if(e.defaultPrevented)return;let t=HTMLFormElement.prototype.cloneNode.call(e.target),n=e.submitter;if((n?.formTarget||t.target)===`_blank`||(n?.formMethod||t.method)!==`get`)return;let r=new URL(n?.hasAttribute(`formaction`)&&n?.formAction||t.action);if(k(r,S,!1))return;let i=e.target,a=O(i);if(a.reload)return;e.preventDefault(),e.stopPropagation();let o=new FormData(i,n);r.search=new URLSearchParams(o).toString(),Z({type:`form`,url:r,keepfocus:a.keepfocus,noscroll:a.noscroll,replace_state:a.replace_state??r.href===location.href,event:e})}),addEventListener(`popstate`,async t=>{if(!Pt)if(t.state?.[`sveltekit:history`]){let n=t.state[C];if(X={},n===J)return;let r=F[n],i=t.state[`sveltekit:states`]??{},a=new URL(t.state[`sveltekit:pageurl`]??location.href),o=t.state[w],s=G.url?m(location)===m(G.url):!1;if(o===Y&&(Qe||s)){i!==j.state&&(j.state=i),e(a),F[J]=E(),r&&scrollTo(r.x,r.y),J=n;return}let c=n-J;await Z({type:`popstate`,url:a,popped:{state:i,scroll:r,delta:c},accept:()=>{J=n,Y=o},block:()=>{history.go(-c)},nav_token:X,event:t})}else q||(e(new URL(location.href)),V.hash&&location.reload())}),addEventListener(`hashchange`,()=>{q&&(q=!1,history.replaceState({...history.state,[C]:++J,[w]:Y},``,location.href))});for(let e of document.querySelectorAll(`link`))Fe.has(e.rel)&&(e.href=e.href);addEventListener(`pageshow`,e=>{e.persisted&&L.navigating.set(M.current=null)});function e(e){G.url=j.url=e,L.page.set(Lt(j)),L.page.notify()}}async function Mt(e,{status:t=200,error:n,node_ids:r,params:i,route:a,server_route:o,data:s,form:c}){Ye=!0;let u=new URL(location.href),d;({params:i={},route:a={id:null}}=await wt(u,!1)||{}),d=ze.find(({id:e})=>e===a.id);let f,p=!0;try{let e=r.map(async(t,n)=>{let r=s[n];return r?.uses&&(r.uses=Nt(r.uses)),mt({loader:V.nodes[t],url:u,params:i,route:a,parent:async()=>{let t={};for(let r=0;r{let a=history.state;Pt=!0,location.replace(new URL(`#${n}`,location.href)),history.replaceState(a,``,e),t&&scrollTo(r,i),Pt=!1})}else{let e=document.body,t=e.getAttribute(`tabindex`);e.tabIndex=-1,e.focus({preventScroll:!0,focusVisible:!1}),t===null?e.removeAttribute(`tabindex`):e.setAttribute(`tabindex`,t)}let r=getSelection();if(r&&r.type!==`None`){let e=[];for(let t=0;t{if(r.rangeCount===e.length){for(let t=0;t{a=e,o=t});return s.catch(h),{navigation:{from:{params:e.params,route:{id:e.route?.id??null},url:e.url,scroll:E()},to:n&&{params:t?.params??null,route:{id:t?.route?.id??null},url:n,scroll:i},willUnload:!t,type:r,complete:s},fulfil:a,reject:o}}function Lt(e){return{data:e.data,error:e.error,form:e.form,params:e.params,route:e.route,state:e.state,status:e.status,url:e.url}}function Rt(e){let t=new URL(e);return t.hash=decodeURIComponent(e.hash),t}function zt(e){let t;if(V.hash){let[,,n]=e.hash.split(`#`,3);t=n??``}else t=e.hash.slice(1);return decodeURIComponent(t)}export{N as a,j as i,L as n,Te as o,M as r,it as t}; \ No newline at end of file diff --git a/crates/carapace-api/static/_app/immutable/chunks/Cfkx4BKF.js b/crates/carapace-api/static/_app/immutable/chunks/Cfkx4BKF.js deleted file mode 100644 index e1b2d58..0000000 --- a/crates/carapace-api/static/_app/immutable/chunks/Cfkx4BKF.js +++ /dev/null @@ -1 +0,0 @@ -import{G as e,K as t,M as n,N as r,X as i,j as a,r as o,t as s}from"./D7CCBGlz.js";import"./CCeg2KC3.js";var c=class{constructor(e,t){this.status=e,typeof t==`string`?this.body={message:t}:t?this.body=t:this.body={message:`Error: ${e}`}}toString(){return JSON.stringify(this.body)}},l=class{constructor(e,t){try{new Headers({location:t})}catch{throw Error(`Invalid redirect location ${JSON.stringify(t)}: this string contains characters that cannot be used in HTTP headers`)}this.status=e,this.location=t}},u=class extends Error{constructor(e,t,n){super(n),this.status=e,this.text=t}};new URL(`sveltekit-internal://`);function d(e,t){return e===`/`||t===`ignore`?e:t===`never`?e.endsWith(`/`)?e.slice(0,-1):e:t===`always`&&!e.endsWith(`/`)?e+`/`:e}function f(e){return e.split(`%25`).map(decodeURI).join(`%25`)}function p(e){for(let t in e)e[t]=decodeURIComponent(e[t]);return e}function m({href:e}){return e.split(`#`)[0]}function h(){}function g(...e){let t=5381;for(let n of e)if(typeof n==`string`){let e=n.length;for(;e;)t=t*33^n.charCodeAt(--e)}else if(ArrayBuffer.isView(n)){let e=new Uint8Array(n.buffer,n.byteOffset,n.byteLength),r=e.length;for(;r;)t=t*33^e[--r]}else throw TypeError(`value must be a string or TypedArray`);return(t>>>0).toString(36)}new TextEncoder;function _(e){let t=atob(e),n=new Uint8Array(t.length);for(let e=0;e((e instanceof Request?e.method:t?.method||`GET`)!==`GET`&&y.delete(x(e)),v(e,t));var y=new Map;function ee(e,t){let n=x(e,t),r=document.querySelector(n);if(r?.textContent){r.remove();let{body:e,...t}=JSON.parse(r.textContent);r.getAttribute(`data-b64`)!==null&&(e=_(e));let i=r.getAttribute(`data-ttl`);return i&&y.set(n,{body:e,init:t,ttl:1e3*Number(i)}),Promise.resolve(new Response(e,t))}return window.fetch(e,t)}function b(e,t,n){if(y.size>0){let t=x(e,n),r=y.get(t);if(r){if(performance.now(){let n=/^\[\.\.\.(\w+)(?:=(\w+))?\]$/.exec(e);if(n)return t.push({name:n[1],matcher:n[2],optional:!1,rest:!0,chained:!0}),`(?:/([^]*))?`;let r=/^\[\[(\w+)(?:=(\w+))?\]\]$/.exec(e);if(r)return t.push({name:r[1],matcher:r[2],optional:!0,rest:!1,chained:!0}),`(?:/([^/]+))?`;if(!e)return;let i=e.split(/\[(.+?)\](?!\])/);return`/`+i.map((e,n)=>{if(n%2){if(e.startsWith(`x+`))return se(String.fromCharCode(parseInt(e.slice(2),16)));if(e.startsWith(`u+`))return se(String.fromCharCode(...e.slice(2).split(`-`).map(e=>parseInt(e,16))));let[,r,a,o,s]=te.exec(e);return t.push({name:o,matcher:s,optional:!!r,rest:!!a,chained:a?n===1&&i[0]===``:!1}),a?`([^]*?)`:r?`([^/]*)?`:`([^/]+?)`}return se(e)}).join(``)}).join(``)}/?$`),params:t}}function ie(e){return e!==``&&!/^\([^)]+\)$/.test(e)}function ae(e){return e.slice(1).split(`/`).filter(ie)}function oe(e,t,n){let r={},i=e.slice(1),a=i.filter(e=>e!==void 0),o=0;for(let e=0;ee).join(`/`),o=0),c===void 0)if(s.rest)c=``;else continue;if(!s.matcher||n[s.matcher](c)){r[s.name]=c;let n=t[e+1],l=i[e+1];n&&!n.rest&&n.optional&&l&&s.chained&&(o=0),!n&&!l&&Object.keys(r).length===a.length&&(o=0);continue}if(s.optional&&s.chained){o++;continue}return}if(!o)return r}function se(e){return e.normalize().replace(/[[\]]/g,`\\$&`).replace(/%/g,`%25`).replace(/\//g,`%2[Ff]`).replace(/\?/g,`%3[Ff]`).replace(/#/g,`%23`).replace(/[.*+?^${}()|\\]/g,`\\$&`)}function ce({nodes:e,server_loads:t,dictionary:n,matchers:r}){let i=new Set(t);return Object.entries(n).map(([t,[n,i,s]])=>{let{pattern:c,params:l}=re(t),u={id:t,exec:e=>{let t=c.exec(e);if(t)return oe(t,l,r)},errors:[1,...s||[]].map(t=>e[t]),layouts:[0,...i||[]].map(o),leaf:a(n)};return u.errors.length=u.layouts.length=Math.max(u.errors.length,u.layouts.length),u});function a(t){let n=t<0;return n&&(t=~t),[n,e[t]]}function o(t){return t===void 0?t:[i.has(t),e[t]]}}function le(e,t=JSON.parse){try{return t(sessionStorage[e])}catch{}}function ue(e,t,n=JSON.stringify){let r=n(t);try{sessionStorage[e]=r}catch{}}var S=globalThis.__sveltekit_18z83oy?.base??``,de=globalThis.__sveltekit_18z83oy?.assets??S??``,fe=`1784071871063`,pe=`sveltekit:snapshot`,me=`sveltekit:scroll`,he=`sveltekit:states`,C=`sveltekit:history`,w=`sveltekit:navigation`,T={tap:1,hover:2,viewport:3,eager:4,off:-1,false:-1},ge=location.origin;function _e(e){if(e instanceof URL)return e;let t=document.baseURI;if(!t){let e=document.getElementsByTagName(`base`);t=e.length?e[0].href:document.URL}return new URL(e,t)}function E(){return{x:pageXOffset,y:pageYOffset}}function D(e,t){return e.getAttribute(`data-sveltekit-${t}`)}var ve={...T,"":T.hover};function ye(e){let t=e.assignedSlot??e.parentNode;return t?.nodeType===11&&(t=t.host),t}function be(e,t){for(;e&&e!==t;){if(e.nodeName.toUpperCase()===`A`&&e.hasAttribute(`href`))return e;e=ye(e)}}function xe(e,t,n){let r;try{if(r=new URL(e instanceof SVGAElement?e.href.baseVal:e.href,document.baseURI),n&&r.hash.match(/^#[^/]/)){let e=location.hash.split(`#`)[1]||`/`;r.hash=`#${e}${r.hash}`}}catch{}let i=e instanceof SVGAElement?e.target.baseVal:e.target,a=!r||!!i||k(r,t,n)||(e.getAttribute(`rel`)||``).split(/\s+/).includes(`external`),o=r?.origin===ge&&e.hasAttribute(`download`);return{url:r,external:a,target:i,download:o}}function O(e){let t=null,n=null,r=null,i=null,a=null,o=null,s=e;for(;s&&s!==document.documentElement;)r===null&&(r=D(s,`preload-code`)),i===null&&(i=D(s,`preload-data`)),t===null&&(t=D(s,`keepfocus`)),n===null&&(n=D(s,`noscroll`)),a===null&&(a=D(s,`reload`)),o===null&&(o=D(s,`replacestate`)),s=ye(s);function c(e){switch(e){case``:case`true`:return!0;case`off`:case`false`:return!1;default:return}}return{preload_code:ve[r??`off`],preload_data:ve[i??`off`],keepfocus:c(t),noscroll:c(n),reload:c(a),replace_state:c(o)}}function Se(e){let t=i(e),n=!0;function r(){n=!0,t.update(e=>e)}function a(e){n=!1,t.set(e)}function o(e){let r;return t.subscribe(t=>{(r===void 0||n&&t!==r)&&e(r=t)})}return{notify:r,set:a,subscribe:o}}var Ce={v:h};function we(){let{set:e,subscribe:t}=i(!1);async function n(){clearTimeout(void 0);try{let t=await fetch(`${de}/_app/version.json`,{headers:{pragma:`no-cache`,"cache-control":`no-cache`}});if(!t.ok)return!1;let n=(await t.json()).version!==fe;return n&&(e(!0),Ce.v(),clearTimeout(void 0)),n}catch{return!1}}return{subscribe:t,check:n}}function k(e,t,n){return e.origin!==ge||!e.pathname.startsWith(t)?!0:n?e.pathname!==location.pathname:!1}function Te(e){}var Ee=new Set([`load`,`prerender`,`csr`,`ssr`,`trailingSlash`,`config`]);[...Ee],[...new Set([...Ee])];function De(e){return e.filter(e=>e!=null)}function A(e,t){return e+`/`+t}function Oe(e){return e instanceof c||e instanceof u?e.status:500}function ke(e){return e instanceof u?e.text:`Internal Error`}var j,M,N,Ae=o.toString().includes(`$$`)||/function \w+\(\) \{\}/.test(o.toString()),je=`a:`;Ae?(j={data:{},form:null,error:null,params:{},route:{id:null},state:{},status:-1,url:new URL(je)},M={current:null},N={current:!1}):(j=new class{#e=t({});get data(){return a(this.#e)}set data(t){e(this.#e,t)}#t=t(null);get form(){return a(this.#t)}set form(t){e(this.#t,t)}#n=t(null);get error(){return a(this.#n)}set error(t){e(this.#n,t)}#r=t({});get params(){return a(this.#r)}set params(t){e(this.#r,t)}#i=t({id:null});get route(){return a(this.#i)}set route(t){e(this.#i,t)}#a=t({});get state(){return a(this.#a)}set state(t){e(this.#a,t)}#o=t(-1);get status(){return a(this.#o)}set status(t){e(this.#o,t)}#s=t(new URL(je));get url(){return a(this.#s)}set url(t){e(this.#s,t)}},M=new class{#e=t(null);get current(){return a(this.#e)}set current(t){e(this.#e,t)}},N=new class{#e=t(!1);get current(){return a(this.#e)}set current(t){e(this.#e,t)}},Ce.v=()=>N.current=!0);function Me(e){Object.assign(j,e)}var{onMount:Ne,tick:Pe}=s,Fe=new Set([`icon`,`shortcut icon`,`apple-touch-icon`]),P=null,F=le(`sveltekit:scroll`)??{},I=le(`sveltekit:snapshot`)??{},L={url:Se({}),page:Se({}),navigating:i(null),updated:we()};function Ie(e){F[e]=E()}function Le(e,t){let n=e+1;for(;F[n];)delete F[n],n+=1;for(n=t+1;I[n];)delete I[n],n+=1}function R(e,t=!1){return t?location.replace(e.href):location.href=e.href,new Promise(h)}async function Re(){if(`serviceWorker`in navigator){let e=await navigator.serviceWorker.getRegistration(S||`/`);e&&await e.update()}}var ze,Be,z,B,Ve,V,He={},Ue={},H=[],We=[],U=null;function Ge(){U?.fork?.then(e=>e?.discard()),U=null,Q={element:void 0,href:void 0}}var Ke=new Map,qe=new Set,Je=new Set,W=new Set,G={branch:[],error:null,url:null,nav:null},Ye=!1,Xe=!1,Ze=!0,K=!1,q=!1,Qe=!1,$e=!1,et,J,Y,X,tt=new Set,nt=new Map,rt=new Map;async function it(e,t,n){if(globalThis.__sveltekit_18z83oy.data){let{q:e={},p:t={},l:n={},f:r={}}=globalThis.__sveltekit_18z83oy.data;for(let t in e)He[t]=e[t];for(let e in n)He[e]=n[e];for(let e in r)He[e]=r[e];for(let e in t)Ue[e]=t[e]}document.URL!==location.href&&(location.href=location.href),V=e,await e.hooks.init?.(),ze=ce(e),B=document.documentElement,Ve=t,Be=e.nodes[0],z=e.nodes[1],Be(),z(),J=history.state?.[C],Y=history.state?.[w],J||(J=Y=Date.now(),history.replaceState({...history.state,[C]:J,[w]:Y},``));let r=F[J];function i(){r&&(history.scrollRestoration=`manual`,scrollTo(r.x,r.y))}n?(i(),await Mt(Ve,n)):(await Z({type:`enter`,url:_e(V.hash?Rt(new URL(location.href)):location.href),replace_state:!0}),i()),jt()}function at(){H.length=0,$e=!1}function ot(e){We.some(e=>e?.snapshot)&&(I[e]=We.map(e=>e?.snapshot?.capture()))}function st(e){I[e]?.forEach((e,t)=>{We[t]?.snapshot?.restore(e)})}function ct(){Ie(J),ue(me,F),ot(Y),ue(pe,I)}async function lt(e,t,n,i){let a,o;t.invalidateAll&&Ge(),await Z({type:`goto`,url:_e(e),keepfocus:t.keepFocus,noscroll:t.noScroll,replace_state:t.replaceState,state:t.state,redirect_count:n,nav_token:i,accept:()=>{if(t.invalidateAll){$e=!0,a=new Set;for(let[e,t]of nt)for(let[n,r]of t)r.resource?.reset(),a.add(A(e,n));o=new Set;for(let[e,t]of rt)for(let n of t.keys())o.add(A(e,n))}t.invalidate&&t.invalidate.forEach(At)}}),t.invalidateAll&&r().then(r).then(()=>{for(let[e,t]of nt)for(let[n,{resource:r}]of t)a?.has(A(e,n))&&r.start();for(let[e,t]of rt)for(let[n,{resource:r}]of t)o?.has(A(e,n))&&r.reconnect()})}async function ut(e){if(e.id!==U?.id){Ge();let t={};tt.add(t),U={id:e.id,token:t,promise:bt({...e,preload:t}).then(e=>(tt.delete(t),e.type===`loaded`&&e.state.error&&Ge(),e)),fork:null}}return U.promise}async function dt(e){let t=(await wt(e,!1))?.route;t&&await Promise.all([...t.layouts,t.leaf].filter(Boolean).map(e=>e[1]()))}async function ft(e,t,n){let r={params:G.params,route:{id:G.route?.id??null},url:new URL(location.href)};if(G={...e.state,nav:r},Me(e.props.page),et=new V.root({target:t,props:{...e.props,stores:L,components:We},hydrate:n,sync:!1,transformError:void 0}),await Promise.resolve(),n){let e={from:null,to:{...r,scroll:F[J]??E()},willUnload:!1,type:`enter`,complete:Promise.resolve()};W.forEach(t=>t(e))}st(Y),Xe=!0}async function pt({url:e,params:t,branch:n,errors:r,status:i,error:a,route:o,form:s}){let c=`never`;if(S&&(e.pathname===S||e.pathname===S+`/`))c=`always`;else for(let e of n)e?.slash!==void 0&&(c=e.slash);e.pathname=d(e.pathname,c),e.search=e.search;let l={type:`loaded`,state:{url:e,params:t,branch:n,error:a,route:o},props:{constructors:De(n).map(e=>e.node.component),page:Lt(j)}};s!==void 0&&(l.props.form=s);let u={},f=!j,p=0;for(let e=0;et(new URL(e))))return!0;return!1}function _t(e,t){return e?.type===`data`?e:e?.type===`skip`?t??null:null}function vt(e,t){if(!e)return new Set(t.searchParams.keys());let n=new Set([...e.searchParams.keys(),...t.searchParams.keys()]);for(let r of n){let i=e.searchParams.getAll(r),a=t.searchParams.getAll(r);i.every(e=>a.includes(e))&&a.every(e=>i.includes(e))&&n.delete(r)}return n}function yt({error:e,url:t,route:n,params:r}){return{type:`loaded`,state:{error:e,url:t,route:n,params:r,branch:[]},props:{page:Lt(j),constructors:[]}}}async function bt({id:e,invalidating:t,url:n,params:r,route:i,preload:a}){if(U?.id===e)return tt.delete(U.token),U.promise;let{errors:o,layouts:s,leaf:u}=i,d=[...s,u];o.forEach(e=>e?.().catch(h)),d.forEach(e=>e?.[1]().catch(h));let f=G.url?e!==Et(G.url):!1,p=G.route?i.id!==G.route.id:!1,m=vt(G.url,n),g=!1,_=d.map(async(e,t)=>{if(!e)return;let a=G.branch[t];return e[1]===a?.loader&&!gt(g,p,f,m,a.universal?.uses,r)?a:(g=!0,mt({loader:e[1],url:n,params:r,route:i,parent:async()=>{let e={};for(let n=0;nPromise.resolve({}),server_data_node:_t(null)}),{node:await z(),loader:z,universal:null,server:null,data:null}],status:e,error:t,errors:[],route:null})}catch(t){if(t instanceof l){await lt(new URL(t.location,location.href),{},0);return}let a=await V.get_error_template(),o=await $(t,{url:n,params:i,route:r}),s=a({status:e,message:String(o?.message??``).replace(/&/g,`&`).replace(//g,`>`)}),c=new DOMParser().parseFromString(s,`text/html`);throw document.documentElement.replaceChild(document.adoptNode(c.head),document.head),document.documentElement.replaceChild(document.adoptNode(c.body),document.body),t}}async function Ct(e){let t=e.href;if(Ke.has(t))return Ke.get(t);let n;try{let r=(async()=>{let t=await V.hooks.reroute({url:new URL(e),fetch:async(t,n)=>ht(t,n,e).promise})??e;if(typeof t==`string`){let n=new URL(e);V.hash?n.hash=t:n.pathname=t,t=n}return t})();Ke.set(t,r),n=await r}catch{Ke.delete(t);return}return n}async function wt(e,t){if(e&&!k(e,S,V.hash)){let n=await Ct(e);if(!n)return;let r=Tt(n);for(let n of ze){let i=n.exec(r);if(i)return{id:Et(e),invalidating:t,route:n,params:p(i),url:e}}}}function Tt(e){return f(V.hash?e.hash.replace(/^#/,``).replace(/[?#].+/,``):e.pathname.slice(S.length))||`/`}function Et(e){return(V.hash?e.hash.replace(/^#/,``):e.pathname)+e.search}function Dt({url:e,type:t,intent:n,delta:r,event:i,scroll:a}){let o=!1,s=It(G,n,e,t,a??null);r!==void 0&&(s.navigation.delta=r),i!==void 0&&(s.navigation.event=i);let c={...s.navigation,cancel:()=>{o=!0,s.reject(Error(`navigation cancelled`))}};return K||qe.forEach(e=>e(c)),o?null:s}async function Z({type:e,url:t,popped:i,keepfocus:a,noscroll:o,replace_state:s,state:c={},redirect_count:l=0,nav_token:d={},accept:f=h,block:p=h,event:m}){let g=X;X=d;let _=await wt(t,!1),v=e===`enter`?It(G,_,t,e):Dt({url:t,type:e,delta:i?.delta,intent:_,scroll:i?.scroll,event:m});if(!v){p(),X===d&&(X=g);return}let y=J,ee=Y;f(),K=!0,Xe&&v.navigation.type!==`enter`&&L.navigating.set(M.current=v.navigation);let b=_&&await bt(_);if(!b){if(k(t,S,V.hash))return await R(t,s);b=await Ot(t,{id:null},await $(new u(404,`Not Found`,`Not found: ${t.pathname}`),{url:t,params:{},route:{id:null}}),404,s)}if(t=_?.url||t,X!==d){v.reject(Error(`navigation aborted`));return}if(!b)return;if(b.type===`redirect`){if(l<20){await Z({type:e,url:new URL(b.location,t),popped:i,keepfocus:a,noscroll:o,replace_state:s,state:c,redirect_count:l+1,nav_token:d}),v.fulfil(void 0);return}if(b=await St({status:500,error:await $(Error(`Redirect loop`),{url:t,params:{},route:{id:null}}),url:t,route:{id:null}}),!b)return}else if(b.props.page.status>=400&&await L.updated.check())return await Re(),await R(t,s);if(at(),Ie(y),ot(ee),b.props.page.url.pathname!==t.pathname&&(t.pathname=b.props.page.url.pathname),c=i?i.state:c,!i){let e=+!s,n={[C]:J+=e,[w]:Y+=e,[he]:c};(s?history.replaceState:history.pushState).call(history,n,``,t),s||Le(J,Y)}let x=_&&U?.id===_.id?U.fork:null;U?.fork&&!x?Ge():(U=null,Q={element:void 0,href:void 0}),b.props.page.state=c;let te;if(Xe){let e=(await Promise.all(Array.from(Je,e=>e(v.navigation)))).filter(e=>typeof e==`function`);if(e.length>0){function t(){e.forEach(e=>{W.delete(e)})}e.push(t),e.forEach(e=>{W.add(e)})}let r=v.navigation.to;G={...b.state,nav:{params:r.params,route:r.route,url:r.url}},b.props.page&&(b.props.page.url=t),!a&&document.activeElement instanceof HTMLElement&&document.activeElement!==document.body&&document.activeElement.blur();let i=x&&await x;i?te=i.commit():(P=null,et.$set(b.props),P&&Object.assign(b.props.page,P),Me(b.props.page),te=n?.()),Qe=!0}else await ft(b,Ve,!1);let{activeElement:ne}=document;if(await te,await r(),await r(),X!==d){v.reject(Error(`navigation aborted`));return}b.props.page&&P&&Object.assign(b.props.page,P);let re=null;if(Ze){let e=i?i.scroll:o?E():null;e?scrollTo(e.x,e.y):(re=t.hash&&document.getElementById(zt(t)))?re.scrollIntoView():scrollTo(0,0)}let ie=document.activeElement!==ne&&document.activeElement!==document.body;!a&&!ie&&Ft(t,!re),Ze=!0,K=!1,v.fulfil(void 0),v.navigation.to&&(v.navigation.to.scroll=E()),W.forEach(e=>e(v.navigation)),e===`popstate`&&st(Y),L.navigating.set(M.current=null)}async function Ot(e,t,n,r,i){return e.origin===ge&&e.pathname===location.pathname&&!Ye?await St({status:r,error:n,url:e,route:t}):await R(e,i)}var Q={element:void 0,href:void 0};function kt(){let e,t;B.addEventListener(`mousemove`,t=>{let n=t.target;clearTimeout(e),e=setTimeout(()=>{i(n,T.hover)},20)});function n(e){e.defaultPrevented||i(e.composedPath()[0],T.tap)}B.addEventListener(`mousedown`,n),B.addEventListener(`touchstart`,n,{passive:!0});let r=new IntersectionObserver(e=>{for(let t of e)t.isIntersecting&&(dt(new URL(t.target.href)),r.unobserve(t.target))},{threshold:0});async function i(e,n){let r=be(e,B),i=r===Q.element&&r?.href===Q.href&&n>=t;if(!r||i)return;let{url:a,external:o,download:s}=xe(r,S,V.hash);if(o||s)return;let c=O(r),l=a&&Et(G.url)===Et(a);if(!(c.reload||l))if(n<=c.preload_data){Q={element:r,href:r.href},t=T.tap;let e=await wt(a,!1);if(!e)return;ut(e)}else n<=c.preload_code&&(Q={element:r,href:r.href},t=n,dt(a))}function a(){r.disconnect();for(let e of B.querySelectorAll(`a`)){let{url:t,external:n,download:i}=xe(e,S,V.hash);if(n||i)continue;let a=O(e);a.reload||(a.preload_code===T.viewport&&r.observe(e),a.preload_code===T.eager&&dt(t))}}W.add(a),a()}function $(e,t){if(e instanceof c)return e.body;let n=Oe(e),r=ke(e);return V.hooks.handleError({error:e,event:t,status:n,message:r})??{message:r}}function At(e){if(typeof e==`function`)H.push(e);else{let{href:t}=new URL(e,location.href);H.push(e=>e.href===t)}}function jt(){history.scrollRestoration=`manual`,addEventListener(`beforeunload`,e=>{let t=!1;if(ct(),!K){let e=It(G,void 0,null,`leave`),n={...e.navigation,cancel:()=>{t=!0,e.reject(Error(`navigation cancelled`))}};qe.forEach(e=>e(n))}t?(e.preventDefault(),e.returnValue=``):history.scrollRestoration=`auto`}),addEventListener(`visibilitychange`,()=>{document.visibilityState===`hidden`&&ct()}),navigator.connection?.saveData||kt(),B.addEventListener(`click`,async t=>{if(t.button||t.which!==1||t.metaKey||t.ctrlKey||t.shiftKey||t.altKey||t.defaultPrevented)return;let n=be(t.composedPath()[0],B);if(!n)return;let{url:r,external:i,target:a,download:o}=xe(n,S,V.hash);if(!r)return;if(a===`_parent`||a===`_top`){if(window.parent!==window)return}else if(a&&a!==`_self`)return;let s=O(n);if(!(n instanceof SVGAElement)&&r.protocol!==location.protocol&&!(r.protocol===`https:`||r.protocol===`http:`)||o)return;let[c,l]=(V.hash?r.hash.replace(/^#/,``):r.href).split(`#`),u=c===m(location);if(i||s.reload&&(!u||!l)){Dt({url:r,type:`link`,event:t})?K=!0:t.preventDefault();return}if(l!==void 0&&u){let[,i]=G.url.href.split(`#`);if(i===l){if(t.preventDefault(),l===``||l===`top`&&n.ownerDocument.getElementById(`top`)===null)scrollTo({top:0});else{let e=n.ownerDocument.getElementById(decodeURIComponent(l));e&&(e.scrollIntoView(),e.focus())}return}if(q=!0,Ie(J),e(r),!s.replace_state)return;q=!1}t.preventDefault(),await new Promise(e=>{requestAnimationFrame(()=>{setTimeout(e,0)}),setTimeout(e,100)}),await Z({type:`link`,url:r,keepfocus:s.keepfocus,noscroll:s.noscroll,replace_state:s.replace_state??r.href===location.href,event:t})}),B.addEventListener(`submit`,e=>{if(e.defaultPrevented)return;let t=HTMLFormElement.prototype.cloneNode.call(e.target),n=e.submitter;if((n?.formTarget||t.target)===`_blank`||(n?.formMethod||t.method)!==`get`)return;let r=new URL(n?.hasAttribute(`formaction`)&&n?.formAction||t.action);if(k(r,S,!1))return;let i=e.target,a=O(i);if(a.reload)return;e.preventDefault(),e.stopPropagation();let o=new FormData(i,n);r.search=new URLSearchParams(o).toString(),Z({type:`form`,url:r,keepfocus:a.keepfocus,noscroll:a.noscroll,replace_state:a.replace_state??r.href===location.href,event:e})}),addEventListener(`popstate`,async t=>{if(!Pt)if(t.state?.[`sveltekit:history`]){let n=t.state[C];if(X={},n===J)return;let r=F[n],i=t.state[`sveltekit:states`]??{},a=new URL(t.state[`sveltekit:pageurl`]??location.href),o=t.state[w],s=G.url?m(location)===m(G.url):!1;if(o===Y&&(Qe||s)){i!==j.state&&(j.state=i),e(a),F[J]=E(),r&&scrollTo(r.x,r.y),J=n;return}let c=n-J;await Z({type:`popstate`,url:a,popped:{state:i,scroll:r,delta:c},accept:()=>{J=n,Y=o},block:()=>{history.go(-c)},nav_token:X,event:t})}else q||(e(new URL(location.href)),V.hash&&location.reload())}),addEventListener(`hashchange`,()=>{q&&(q=!1,history.replaceState({...history.state,[C]:++J,[w]:Y},``,location.href))});for(let e of document.querySelectorAll(`link`))Fe.has(e.rel)&&(e.href=e.href);addEventListener(`pageshow`,e=>{e.persisted&&L.navigating.set(M.current=null)});function e(e){G.url=j.url=e,L.page.set(Lt(j)),L.page.notify()}}async function Mt(e,{status:t=200,error:n,node_ids:r,params:i,route:a,server_route:o,data:s,form:c}){Ye=!0;let u=new URL(location.href),d;({params:i={},route:a={id:null}}=await wt(u,!1)||{}),d=ze.find(({id:e})=>e===a.id);let f,p=!0;try{let e=r.map(async(t,n)=>{let r=s[n];return r?.uses&&(r.uses=Nt(r.uses)),mt({loader:V.nodes[t],url:u,params:i,route:a,parent:async()=>{let t={};for(let r=0;r{let a=history.state;Pt=!0,location.replace(new URL(`#${n}`,location.href)),history.replaceState(a,``,e),t&&scrollTo(r,i),Pt=!1})}else{let e=document.body,t=e.getAttribute(`tabindex`);e.tabIndex=-1,e.focus({preventScroll:!0,focusVisible:!1}),t===null?e.removeAttribute(`tabindex`):e.setAttribute(`tabindex`,t)}let r=getSelection();if(r&&r.type!==`None`){let e=[];for(let t=0;t{if(r.rangeCount===e.length){for(let t=0;t{a=e,o=t});return s.catch(h),{navigation:{from:{params:e.params,route:{id:e.route?.id??null},url:e.url,scroll:E()},to:n&&{params:t?.params??null,route:{id:t?.route?.id??null},url:n,scroll:i},willUnload:!t,type:r,complete:s},fulfil:a,reject:o}}function Lt(e){return{data:e.data,error:e.error,form:e.form,params:e.params,route:e.route,state:e.state,status:e.status,url:e.url}}function Rt(e){let t=new URL(e);return t.hash=decodeURIComponent(e.hash),t}function zt(e){let t;if(V.hash){let[,,n]=e.hash.split(`#`,3);t=n??``}else t=e.hash.slice(1);return decodeURIComponent(t)}export{N as a,j as i,L as n,Te as o,M as r,it as t}; \ No newline at end of file diff --git a/crates/carapace-api/static/_app/immutable/entry/app.BJNxxL8q.js b/crates/carapace-api/static/_app/immutable/entry/app.DVOi12fH.js similarity index 87% rename from crates/carapace-api/static/_app/immutable/entry/app.BJNxxL8q.js rename to crates/carapace-api/static/_app/immutable/entry/app.DVOi12fH.js index 144b1b8..3a7c2d5 100644 --- a/crates/carapace-api/static/_app/immutable/entry/app.BJNxxL8q.js +++ b/crates/carapace-api/static/_app/immutable/entry/app.DVOi12fH.js @@ -1,2 +1,2 @@ -const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["../nodes/0.DSqSDKgT.js","../chunks/D7CCBGlz.js","../chunks/xihTtKlq.js","../assets/0.BIBOqY7u.css","../nodes/1.X1iBfBii.js","../chunks/Cfkx4BKF.js","../chunks/CCeg2KC3.js","../nodes/2.CaWSwQlO.js","../assets/2.Ccp3aMT0.css"])))=>i.map(i=>d[i]); -import{$ as e,C as t,D as n,E as r,G as i,H as a,I as o,K as s,L as c,N as l,Q as u,R as d,S as f,T as p,U as m,V as h,a as g,i as _,j as v,nt as y,o as b,q as x,r as S,v as C,w}from"../chunks/D7CCBGlz.js";import"../chunks/xihTtKlq.js";var T=`modulepreload`,E=function(e,t){return new URL(e,t).href},D={},O=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function s(e){return import.meta.resolve?import.meta.resolve(e):new URL(e,import.meta.url).href}r=o(t.map(t=>{if(t=E(t,n),t=s(t),t in D)return;D[t]=!0;let r=t.endsWith(`.css`);for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}let i=document.createElement(`link`);if(i.rel=r?`stylesheet`:T,r||(i.as=`script`),i.crossOrigin=``,i.href=t,a&&i.setAttribute(`nonce`,a),document.head.appendChild(i),r)return new Promise((e,n)=>{i.addEventListener(`load`,e),i.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},k={},A=r(`
`),j=r(` `,1);function M(r,_){e(_,!0);let T=g(_,`components`,23,()=>[]),E=g(_,`data_0`,3,null),D=g(_,`data_1`,3,null);d(()=>_.stores.page.set(_.page)),c(()=>{_.stores,_.page,_.constructors,T(),_.form,E(),D(),_.stores.page.notify()});let O=s(!1),k=s(!1),M=s(null);S(()=>{let e=_.stores.page.subscribe(()=>{v(O)&&(i(k,!0),l().then(()=>{i(M,document.title||`untitled page`,!0)}))});return i(O,!0),e});let N=x(()=>_.constructors[1]);var P=j(),F=a(P),I=e=>{let t=x(()=>_.constructors[0]);var n=p();C(a(n),()=>v(t),(e,t)=>{b(t(e,{get data(){return E()},get form(){return _.form},get params(){return _.page.params},children:(e,t)=>{var n=p();C(a(n),()=>v(N),(e,t)=>{b(t(e,{get data(){return D()},get form(){return _.form},get params(){return _.page.params}}),e=>T()[1]=e,()=>T()?.[1])}),w(e,n)},$$slots:{default:!0}}),e=>T()[0]=e,()=>T()?.[0])}),w(e,n)},L=e=>{let t=x(()=>_.constructors[0]);var n=p();C(a(n),()=>v(t),(e,t)=>{b(t(e,{get data(){return E()},get form(){return _.form},get params(){return _.page.params}}),e=>T()[0]=e,()=>T()?.[0])}),w(e,n)};f(F,e=>{_.constructors[1]?e(I):e(L,-1)});var R=m(F,2),z=e=>{var r=A(),i=h(r),a=e=>{var r=n();o(()=>t(r,v(M))),w(e,r)};f(i,e=>{v(k)&&e(a)}),y(r),w(e,r)};f(R,e=>{v(O)&&e(z)}),w(r,P),u()}var N=_(M),P=[()=>O(()=>import(`../nodes/0.DSqSDKgT.js`),__vite__mapDeps([0,1,2,3]),import.meta.url),()=>O(()=>import(`../nodes/1.X1iBfBii.js`),__vite__mapDeps([4,1,5,6,2]),import.meta.url),()=>O(()=>import(`../nodes/2.CaWSwQlO.js`),__vite__mapDeps([7,1,6,2,8]),import.meta.url)],F=[],I={"/":[2]},L={handleError:(({error:e})=>{console.error(e)}),reroute:(()=>{}),transport:{}},R=Object.fromEntries(Object.entries(L.transport).map(([e,t])=>[e,t.decode])),z=Object.fromEntries(Object.entries(L.transport).map(([e,t])=>[e,t.encode])),B=!1,V=(e,t)=>R[e](t),H=()=>O(()=>import(`../chunks/Bjy-W4x2.js`).then(e=>e.default),[],import.meta.url);export{V as decode,R as decoders,I as dictionary,z as encoders,H as get_error_template,B as hash,L as hooks,k as matchers,P as nodes,N as root,F as server_loads}; \ No newline at end of file +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["../nodes/0.CCSq_TiJ.js","../chunks/D7CCBGlz.js","../chunks/xihTtKlq.js","../assets/0.BNChGHTQ.css","../nodes/1.DTHmk4CM.js","../chunks/BGevx8A_.js","../chunks/CCeg2KC3.js","../nodes/2.CUBHPqD7.js","../assets/2.BkMzBKKi.css"])))=>i.map(i=>d[i]); +import{$ as e,C as t,D as n,E as r,G as i,H as a,I as o,K as s,L as c,N as l,Q as u,R as d,S as f,T as p,U as m,V as h,a as g,i as _,j as v,nt as y,o as b,q as x,r as S,v as C,w}from"../chunks/D7CCBGlz.js";import"../chunks/xihTtKlq.js";var T=`modulepreload`,E=function(e,t){return new URL(e,t).href},D={},O=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function s(e){return import.meta.resolve?import.meta.resolve(e):new URL(e,import.meta.url).href}r=o(t.map(t=>{if(t=E(t,n),t=s(t),t in D)return;D[t]=!0;let r=t.endsWith(`.css`);for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}let i=document.createElement(`link`);if(i.rel=r?`stylesheet`:T,r||(i.as=`script`),i.crossOrigin=``,i.href=t,a&&i.setAttribute(`nonce`,a),document.head.appendChild(i),r)return new Promise((e,n)=>{i.addEventListener(`load`,e),i.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},k={},A=r(`
`),j=r(` `,1);function M(r,_){e(_,!0);let T=g(_,`components`,23,()=>[]),E=g(_,`data_0`,3,null),D=g(_,`data_1`,3,null);d(()=>_.stores.page.set(_.page)),c(()=>{_.stores,_.page,_.constructors,T(),_.form,E(),D(),_.stores.page.notify()});let O=s(!1),k=s(!1),M=s(null);S(()=>{let e=_.stores.page.subscribe(()=>{v(O)&&(i(k,!0),l().then(()=>{i(M,document.title||`untitled page`,!0)}))});return i(O,!0),e});let N=x(()=>_.constructors[1]);var P=j(),F=a(P),I=e=>{let t=x(()=>_.constructors[0]);var n=p();C(a(n),()=>v(t),(e,t)=>{b(t(e,{get data(){return E()},get form(){return _.form},get params(){return _.page.params},children:(e,t)=>{var n=p();C(a(n),()=>v(N),(e,t)=>{b(t(e,{get data(){return D()},get form(){return _.form},get params(){return _.page.params}}),e=>T()[1]=e,()=>T()?.[1])}),w(e,n)},$$slots:{default:!0}}),e=>T()[0]=e,()=>T()?.[0])}),w(e,n)},L=e=>{let t=x(()=>_.constructors[0]);var n=p();C(a(n),()=>v(t),(e,t)=>{b(t(e,{get data(){return E()},get form(){return _.form},get params(){return _.page.params}}),e=>T()[0]=e,()=>T()?.[0])}),w(e,n)};f(F,e=>{_.constructors[1]?e(I):e(L,-1)});var R=m(F,2),z=e=>{var r=A(),i=h(r),a=e=>{var r=n();o(()=>t(r,v(M))),w(e,r)};f(i,e=>{v(k)&&e(a)}),y(r),w(e,r)};f(R,e=>{v(O)&&e(z)}),w(r,P),u()}var N=_(M),P=[()=>O(()=>import(`../nodes/0.CCSq_TiJ.js`),__vite__mapDeps([0,1,2,3]),import.meta.url),()=>O(()=>import(`../nodes/1.DTHmk4CM.js`),__vite__mapDeps([4,1,5,6,2]),import.meta.url),()=>O(()=>import(`../nodes/2.CUBHPqD7.js`),__vite__mapDeps([7,1,6,2,8]),import.meta.url)],F=[],I={"/":[2]},L={handleError:(({error:e})=>{console.error(e)}),reroute:(()=>{}),transport:{}},R=Object.fromEntries(Object.entries(L.transport).map(([e,t])=>[e,t.decode])),z=Object.fromEntries(Object.entries(L.transport).map(([e,t])=>[e,t.encode])),B=!1,V=(e,t)=>R[e](t),H=()=>O(()=>import(`../chunks/Bjy-W4x2.js`).then(e=>e.default),[],import.meta.url);export{V as decode,R as decoders,I as dictionary,z as encoders,H as get_error_template,B as hash,L as hooks,k as matchers,P as nodes,N as root,F as server_loads}; \ No newline at end of file diff --git a/crates/carapace-api/static/_app/immutable/entry/start.CbdnOVWP.js b/crates/carapace-api/static/_app/immutable/entry/start.CbdnOVWP.js deleted file mode 100644 index d197d45..0000000 --- a/crates/carapace-api/static/_app/immutable/entry/start.CbdnOVWP.js +++ /dev/null @@ -1 +0,0 @@ -import{o as e,t}from"../chunks/Cfkx4BKF.js";export{e as load_css,t as start}; \ No newline at end of file diff --git a/crates/carapace-api/static/_app/immutable/entry/start.DYdfW4aU.js b/crates/carapace-api/static/_app/immutable/entry/start.DYdfW4aU.js new file mode 100644 index 0000000..88b11c5 --- /dev/null +++ b/crates/carapace-api/static/_app/immutable/entry/start.DYdfW4aU.js @@ -0,0 +1 @@ +import{o as e,t}from"../chunks/BGevx8A_.js";export{e as load_css,t as start}; \ No newline at end of file diff --git a/crates/carapace-api/static/_app/immutable/nodes/0.DSqSDKgT.js b/crates/carapace-api/static/_app/immutable/nodes/0.CCSq_TiJ.js similarity index 100% rename from crates/carapace-api/static/_app/immutable/nodes/0.DSqSDKgT.js rename to crates/carapace-api/static/_app/immutable/nodes/0.CCSq_TiJ.js diff --git a/crates/carapace-api/static/_app/immutable/nodes/1.X1iBfBii.js b/crates/carapace-api/static/_app/immutable/nodes/1.DTHmk4CM.js similarity index 89% rename from crates/carapace-api/static/_app/immutable/nodes/1.X1iBfBii.js rename to crates/carapace-api/static/_app/immutable/nodes/1.DTHmk4CM.js index f81df96..617d8fe 100644 --- a/crates/carapace-api/static/_app/immutable/nodes/1.X1iBfBii.js +++ b/crates/carapace-api/static/_app/immutable/nodes/1.DTHmk4CM.js @@ -1 +1 @@ -import{$ as e,C as t,E as n,H as r,I as i,Q as a,U as o,V as s,nt as c,w as l}from"../chunks/D7CCBGlz.js";import{i as u,n as d}from"../chunks/Cfkx4BKF.js";import"../chunks/xihTtKlq.js";var f={get data(){return u.data},get error(){return u.error},get form(){return u.form},get params(){return u.params},get route(){return u.route},get state(){return u.state},get status(){return u.status},get url(){return u.url}};d.updated.check;var p=f,m=n(`

`,1);function h(n,u){e(u,!0);var d=m(),f=r(d),h=s(f,!0);c(f);var g=o(f,2),_=s(g,!0);c(g),i(()=>{t(h,p.status),t(_,p.error?.message)}),l(n,d),a()}export{h as component}; \ No newline at end of file +import{$ as e,C as t,E as n,H as r,I as i,Q as a,U as o,V as s,nt as c,w as l}from"../chunks/D7CCBGlz.js";import{i as u,n as d}from"../chunks/BGevx8A_.js";import"../chunks/xihTtKlq.js";var f={get data(){return u.data},get error(){return u.error},get form(){return u.form},get params(){return u.params},get route(){return u.route},get state(){return u.state},get status(){return u.status},get url(){return u.url}};d.updated.check;var p=f,m=n(`

`,1);function h(n,u){e(u,!0);var d=m(),f=r(d),h=s(f,!0);c(f);var g=o(f,2),_=s(g,!0);c(g),i(()=>{t(h,p.status),t(_,p.error?.message)}),l(n,d),a()}export{h as component}; \ No newline at end of file diff --git a/crates/carapace-api/static/_app/immutable/nodes/2.CUBHPqD7.js b/crates/carapace-api/static/_app/immutable/nodes/2.CUBHPqD7.js new file mode 100644 index 0000000..a680ad4 --- /dev/null +++ b/crates/carapace-api/static/_app/immutable/nodes/2.CUBHPqD7.js @@ -0,0 +1,26 @@ +import{$ as e,A as t,B as n,C as r,D as i,E as a,F as o,G as s,H as c,I as l,J as u,K as d,L as f,O as p,Q as m,S as h,T as g,U as _,V as v,W as y,X as b,Y as x,_ as S,a as C,b as w,c as T,d as E,f as D,g as O,h as k,j as A,k as j,l as M,m as N,n as P,nt as F,p as ee,q as te,r as I,rt as L,s as R,tt as z,u as B,w as V,x as H,z as ne}from"../chunks/D7CCBGlz.js";import"../chunks/CCeg2KC3.js";import"../chunks/xihTtKlq.js";function U(){return typeof window<`u`&&window.__CARAPACE_TOKEN__?window.__CARAPACE_TOKEN__:``}var W=b(null);function G(e){W.set(e)}function K(){W.set(null)}var re=``,q=class extends Error{status;constructor(e,t){super(e),this.status=t}};async function J(e,t){let n;try{n=await fetch(re+e,{...t,headers:{...t?.body?{"Content-Type":`application/json`}:{},Authorization:`Bearer ${U()}`,...t?.headers}})}catch(t){let n=`Could not reach the daemon (${e}). Is it running? (${t.message})`;throw G(n),new q(n)}if(n.status===401||n.status===403){let e=n.status===401?`The daemon rejected this session (bad or missing token). Reload the page.`:`The daemon refused this request (Host/Origin guard). Reload the page.`;throw G(e),new q(e,n.status)}if(!n.ok){let t=n.statusText;try{let e=await n.json();typeof e?.error==`string`&&(t=e.error)}catch{}throw G(`${e} failed: ${t}`),new q(t,n.status)}return await n.json()}async function Y(e,t){let n;try{n=await fetch(re+e,{...t,headers:{Authorization:`Bearer ${U()}`,...t?.headers}})}catch(t){let n=`Could not reach the daemon (${e}). Is it running? (${t.message})`;throw G(n),new q(n)}if(n.status===401||n.status===403){let e=n.status===401?`The daemon rejected this session (bad or missing token). Reload the page.`:`The daemon refused this request (Host/Origin guard). Reload the page.`;throw G(e),new q(e,n.status)}if(!n.ok){let t=n.statusText;try{let e=await n.json();typeof e?.error==`string`&&(t=e.error)}catch{}throw G(`${e} failed: ${t}`),new q(t,n.status)}return n.text()}var X=e=>J(e),Z=(e,t)=>J(e,{method:`POST`,body:t===void 0?void 0:JSON.stringify(t)}),Q={exportAccount:e=>Z(`/api/account/export`,{passphrase:e}),importAccount:e=>Z(`/api/account/import`,e),enrollDevice:e=>Z(`/api/devices`,{card_hex:e}),directories:e=>X(`/api/directories${e?`?path=${encodeURIComponent(e)}`:``}`),health:()=>X(`/api/health`),status:()=>X(`/api/status`),syncOwned:(e,t)=>Z(`/api/sync`,{peer:e,out_dir:t}),listVaults:()=>X(`/api/vaults`),publishVault:(e,t)=>Z(`/api/vaults`,{dir:e,vid:t}),listReplicas:e=>X(`/api/vaults/${e}/replicas`),placeReplicas:(e,t,n)=>Z(`/api/vaults/${e}/replicas`,{peers:t,r:n}),discloseFiles:(e,t,n)=>Z(`/api/vaults/${e}/grants`,{paths:t,audience:n}),fetchGrant:(e,t,n)=>Z(`/api/grants/fetch`,{grant_hex:e,owner:t,out_dir:n}),listFriends:()=>X(`/api/friends`),issueTicket:()=>Z(`/api/friends/ticket`),addFriend:(e,t,n)=>Z(`/api/friends`,{ticket_hex:e,addrs:t,grant_bytes:n}),unfriend:e=>Z(`/api/friends/${e}/unfriend`),resplitStatus:e=>X(`/api/recovery/${e}/resplit-status`),ceremonyStatus:()=>X(`/api/recovery/ceremony`),paperCards:e=>Y(`/api/recovery/${e}/paper`),resplitStart:(e,t)=>Z(`/api/recovery/${e}/resplit-start`,t?{trustees:t}:void 0),recoverySplit:(e,t,n,r,i)=>Z(`/api/recovery/split`,{rsid:e,scope:t,m:n,n:r,allow_over_cap:i}),recoverySplitToTrustees:(e,t,n,r,i)=>Z(`/api/recovery/split`,{rsid:e,scope:t,m:n,trustees:r,allow_over_cap:i}),recoveryResplit:(e,t,n,r,i)=>Z(`/api/recovery/resplit`,{rsid:e,scope:t,m:n,n:r,allow_over_cap:i}),recoveryResplitToTrustees:(e,t,n,r,i)=>Z(`/api/recovery/resplit`,{rsid:e,scope:t,m:n,trustees:r,allow_over_cap:i}),recoveryExtend:(e,t,n)=>Z(`/api/recovery/extend`,{rsid:e,count:t,allow_over_cap:n}),restartRestore:e=>Z(`/api/recovery/restart-restore`,{out_dir:e}),ceremonyOpen:e=>Z(`/api/recovery/ceremony/open`,e),ceremonyApprove:e=>Z(`/api/recovery/ceremony/approve`,{ceremony_id:e}),ceremonyAbort:e=>Z(`/api/recovery/ceremony/abort`,{ceremony_id:e})},ie=b(null),ae=b(!1),oe=null,se=1e3,$=!0,ce=0,le,ue=b(!1);function de(){if($||typeof window>`u`)return;let e=`${location.protocol===`https:`?`wss:`:`ws:`}//${location.host}/api/events`,t=new WebSocket(e);oe=t;let n=()=>!$&&oe===t;t.onopen=()=>{n()&&(ue.set(!1),se=1e3)},t.onmessage=e=>{if(n())try{ie.set(JSON.parse(e.data)),ae.set(!0)}catch{}},t.onclose=async()=>{if(n()){ae.set(!1);try{let e=await fetch(`/api/status`,{headers:{Authorization:`Bearer ${U()}`}});if(!n())return;if(e.status===401){ue.set(!0);return}}catch{}n()&&(le=setTimeout(de,se),se=Math.min(se*2,15e3))}},t.onerror=()=>{t.close()}}function fe(){if(!$)return;$=!1;let e=++ce;se=1e3,Q.status().then(t=>{!$&&ce===e&&ie.set(t)}).catch(()=>{}),de()}function pe(){$=!0,ce++,clearTimeout(le),ae.set(!1),oe?.close(),oe=null}var me=a(``);function he(e){let t=()=>x(W,`$lastError`,n),[n,i]=u();var a=g(),o=c(a),s=e=>{var n=me(),i=v(n),a=v(i,!0);F(i);var o=_(i,2);F(n),l(()=>r(a,t())),j(`click`,o,function(...e){K?.apply(this,e)}),V(e,n)};h(o,e=>{t()&&e(s)}),V(e,a),i()}p([`click`]);function ge(e,t,n){let r=e.filter(e=>e.scope.kind===`root`).map(e=>{let n=t.find(t=>t.rsid===e.rsid);return{threshold:e.threshold,delivered:e.trustees.filter(e=>e.delivered).length,live:n?.live??0,target:n?.target??e.threshold+1}}),i=r.reduce((e,t)=>!e||Math.min(t.live,t.delivered)/t.target0&&r.every(e=>e.live>=e.target&&e.delivered>=e.target);return{key:`shares`,label:`Recovery trustees`,achieved:i?Math.min(i.live,i.delivered):0,target:i?.target??1,valueLabel:i?`${i.live}/${i.target} verified`:`Not set up`,state:n?r.length===0?`empty`:a?`healthy`:`at-risk`:`at-risk`,note:n?i?`${i.threshold} trustees needed to recover; ${i.delivered} shares delivered. Rehearse a restore before relying on recovery.`:`Choose trustees to protect your account`:`Disconnected — protection cannot be confirmed`}}var _e=a(``),ve=a(`
`),ye=a(`
`);function be(e,t){function n(e){let t=Math.max(e.target,e.achieved,1);return Array.from({length:t},(t,n)=>({filled:nt.plates,e=>e.key,(e,t,i)=>{var a=ve(),o=v(a);w(o,21,()=>n(A(t)),H,(e,t)=>{var n=_e();let r;l(()=>r=k(n,1,`segment svelte-v2pnom`,null,r,{filled:A(t).filled})),V(e,n)}),F(o);var s=_(o,2),c=v(s,!0);F(s);var u=_(s,2),d=v(u,!0);F(u);var f=_(u,2),p=v(f,!0);F(f),F(a),l(()=>{k(a,1,`plate-group state-${A(t).state??``}`,`svelte-v2pnom`),N(a,`--i: ${A(i)??``}`),r(c,A(t).label),r(d,A(t).valueLabel),r(p,A(t).note)}),V(e,a)}),F(i),V(e,i)}function xe(e,t=8,n=6){return e.length<=t+n+1?e:`${e.slice(0,t)}…${e.slice(-n)}`}async function Se(e){try{return await navigator.clipboard.writeText(e),!0}catch{return G(`Could not copy to the clipboard. Select and copy the value manually.`),!1}}var Ce=[`B`,`KiB`,`MiB`,`GiB`,`TiB`];function we(e){if(!Number.isFinite(e)||e<0)return`—`;if(e===0)return`0 B`;let t=Math.min(Math.floor(Math.log2(e)/10),Ce.length-1),n=e/2**(10*t);return`${n>=10||t===0?Math.round(n):n.toFixed(1)} ${Ce[t]}`}var Te=a(`

`),Ee=a(`

Account and devices

Account identifier:

Add another computer

Create an encrypted transfer package on your existing computer. Anyone with the package and passphrase can access your account. Send them separately.

On the new computer

Paste the package above. Import creates a new account folder using this computer's protected key storage.

Return the new device card to the existing computer

`);function De(n,i){e(i,!0);let a=()=>x(ie,`$status`,o),[o,c]=u(),f=d(``),p=d(``),g=d(``),y=d(``),b=d(``),S=d(!1),C=d(``);async function w(){s(S,!0);try{s(g,(await Q.exportAccount(A(f))).package_hex,!0),s(C,`Copy this encrypted package to your new computer. Send the passphrase separately.`)}catch{}finally{s(S,!1),s(f,``),s(p,``)}}async function T(){s(S,!0);try{let e=await Q.importAccount({package_hex:A(g).trim(),passphrase:A(f),destination:A(y).trim()});s(b,e.card_hex,!0),s(C,`Account created at ${e.state_dir}. Return its device card to your existing computer. Stop Carapace here, then run carapaced run --state-dir with that folder to begin sync.`)}catch{}finally{s(S,!1),s(f,``)}}async function E(){s(S,!0);try{await Q.enrollDevice(A(b).trim()),s(C,`Device enrolled. Its folders will sync when both computers are reachable.`),s(b,``)}catch{}finally{s(S,!1)}}var D=Ee(),O=_(v(D),2),k=_(v(O)),N=v(k,!0);F(k),F(O);var P=_(O,2),ee=v(P,!0);F(P);var te=_(P,2),I=_(v(te),4),L=_(v(I));B(L);var R=_(L,3);B(R);var z=_(R,2);F(I);var H=_(I,3);ne(H);var U=_(H,2),W=_(U,6),G=_(v(W));B(G);var K=_(G,3);B(K);var re=_(K,2);F(W);var q=_(W,4);ne(q);var J=_(q,2),Y=_(J,2);F(te);var X=_(te,2),Z=e=>{var t=Te(),n=v(t,!0);F(t),l(()=>r(n,A(C))),V(e,t)};h(X,e=>{A(C)&&e(Z)}),F(D),l(()=>{r(N,a()?.user_id??`Connecting…`),r(ee,a()?.identity_storage===`operating_system_credentials`?`Account keys are protected by this computer’s credential store.`:a()?.identity_storage===`passphrase_protected_files`?`Account keys are encrypted with a passphrase.`:`Development mode: account keys are not protected by the credential store.`),z.disabled=A(S)||!A(f)||A(f)!==A(p),U.disabled=!A(g),re.disabled=A(S)||!A(g)||!A(f)||!A(y),J.disabled=!A(b),Y.disabled=A(S)||!A(b)}),t(`submit`,I,e=>{e.preventDefault(),w()}),M(L,()=>A(f),e=>s(f,e)),M(R,()=>A(p),e=>s(p,e)),M(H,()=>A(g),e=>s(g,e)),j(`click`,U,()=>Se(A(g))),t(`submit`,W,e=>{e.preventDefault(),T()}),M(G,()=>A(f),e=>s(f,e)),M(K,()=>A(y),e=>s(y,e)),M(q,()=>A(b),e=>s(b,e)),j(`click`,J,()=>Se(A(b))),j(`click`,Y,E),V(n,D),m(),c()}p([`click`]);var Oe=a(``);function ke(t,n){e(n,!0);let i=C(n,`head`,3,8),a=C(n,`tail`,3,6),o=d(!1);async function c(){await Se(n.value)&&(s(o,!0),setTimeout(()=>s(o,!1),1200))}var u=Oe(),f=v(u),p=_(f),h=v(p,!0);F(p),F(u),l(e=>{E(u,`title`,n.value),E(u,`aria-label`,`Copy ${n.value??``}`),r(f,`${e??``} `),r(h,A(o)?`copied`:`copy`)},[()=>xe(n.value,i(),a())]),j(`click`,u,c),V(t,u),m()}p([`click`]);var Ae=a(`
This node
Connected friends
Vaults published
`,1),je=a(`

Waiting for the daemon…

`),Me=a(`

Your protection

`);function Ne(t,n){e(n,!0);let i=()=>x(ie,`$status`,o),a=()=>x(ae,`$live`,o),[o,p]=u(),g=d(null);f(()=>{let e=i()?.vaults.published??[];if(e.length===0){s(g,null);return}Promise.all(e.map(e=>Q.listReplicas(e.vid).catch(()=>({members:[]})))).then(e=>{s(g,Math.min(...e.map(e=>e.members.length)),!0)}).catch(()=>{s(g,null)})});let y=te(()=>{let e=i();if(!e)return[];let t=e.vaults.published.length,n=A(g)??0,r={key:`replicas`,label:`Replicas`,achieved:t===0?0:Math.min(n,3),target:3,valueLabel:t===0?`—`:`${n}/3`,state:a()?`empty`:`at-risk`,note:t===0?`No vaults published yet`:`Weakest vault has ${n} enrolled replica${n===1?``:`s`}. Current file availability is not yet verified.`},o=ge(e.share_health.sets,e.share_health.recovery,a()),s=e.addr.length,c=e.relay_networks,l=e.relay_diversity_warning||c<2;return[r,o,{key:`relays`,label:`Reachability`,achieved:Math.min(c,2),target:2,valueLabel:`${c}`,state:a()?s===0?`empty`:l?`at-risk`:`healthy`:`at-risk`,note:l?`Only ${c} relay network${c===1?``:`s`} - add a friend's relay so you can still be reached if one drops`:`${e.reachability} · ${c} relay networks, ${s} dialable address${s===1?``:`es`}`}]});var b=Me(),S=_(v(b),2),C=e=>{var t=Ae(),n=c(t);be(n,{get plates(){return A(y)}});var a=_(n,2),o=v(a);ke(_(v(o),2),{get value(){return i().node_id},head:12,tail:8}),F(o);var s=_(o,2),u=_(v(s),2),d=v(u,!0);F(u),F(s);var f=_(s,2),p=_(v(f),2),m=v(p,!0);F(p),F(f),F(a),De(_(a,4),{}),l(()=>{r(d,i().friends.count),r(m,i().vaults.published.length)}),V(e,t)},w=e=>{V(e,je())};h(S,e=>{i()?e(C):e(w,-1)}),F(b),V(t,b),m(),p()}var Pe=a(``),Fe=a(`
  • `),Ie=a(`

      `,1),Le=a(`
      `),Re=a(`
      `);function ze(t,n){e(n,!0);let i=C(n,`value`,15,``),a=C(n,`id`,3,`folder`),o=d(!1),u=d(!1),f=d(null);async function p(e){s(o,!0),s(u,!0);try{s(f,await Q.directories(e),!0)}catch{}finally{s(u,!1)}}var g=Re(),y=v(g);B(y);var b=_(y,2),x=_(b,2),S=e=>{var t=Le(),n=v(t),a=e=>{var t=Ie(),n=c(t),a=v(n,!0);F(n);var d=_(n,2),m=e=>{var t=Pe();l(()=>t.disabled=A(u)),j(`click`,t,()=>p(A(f).parent)),V(e,t)};h(d,e=>{A(f).parent&&e(m)});var g=_(d,2);w(g,21,()=>A(f).directories,e=>e.path,(e,t)=>{var n=Fe(),i=v(n),a=v(i,!0);F(i),F(n),l(()=>{i.disabled=A(u),r(a,A(t).name)}),j(`click`,i,()=>p(A(t).path)),V(e,n)}),F(g);var y=_(g,2);l(()=>{r(a,A(f).path),y.disabled=A(u)}),j(`click`,y,()=>{i(A(f).path),s(o,!1)}),V(e,t)};h(n,e=>{A(f)&&e(a)});var d=_(n,2);F(t),j(`click`,d,()=>s(o,!1)),V(e,t)};h(x,e=>{A(o)&&e(S)}),F(g),l(()=>{E(y,`id`,a()),b.disabled=A(u)}),M(y,i),j(`click`,b,()=>p(i()||void 0)),V(t,g),m()}p([`click`]);var Be=a(``),Ve=a(``),He=a(`

      Sync is in progress.

      `),Ue=a(`

      The sync completed. There were no new vault versions to restore.

      `),We=a(`
    • `),Ge=a(`

        `,1),Ke=a(`

        Loading vaults…

        `),qe=a(`

        No vaults published yet. Publish a directory above to start protecting it.

        `),Je=a(`

        `),Ye=a(`

        `),Xe=a(`

        Files saved before retrying an interrupted restore: . Check this folder for edits to keep.

        `),Ze=a(`
      • `),Qe=a(`
          `),$e=a(`no replicas placed - vault exists only here`),et=a(`

          Replica members
          `),tt=a(`
          `),nt=a(``),rt=a(``),it=a(`
          `),at=a(`

          `),ot=a(`

          Place replicas for

          Peer-dependent operation · safe to retry if a peer is offline

          Select verified friends
          Advanced manual peers
          `),st=a(`

          Vaults

          A vault is a directory you've published for friends to hold replicas of. Publishing ingests + and encrypts it locally; placing replicas is what actually copies it out.

          Local operation

          Sync from your other device

          This peer-dependent action pulls authorized vault updates and restores them below the selected directory.

          Advanced manual peer
          `);function ct(n,i){e(i,!0);let a=()=>x(ie,`$status`,o),[o,p]=u(),b=[],S=d(y([])),C=d(y({})),D=d(!0),O=d(``),k=d(!1),N=d(``),P=d(``),te=d(``),I=d(``),L=d(!1),R=d(null),ne=d(``),U=d(null),W=d(3),G=d(y([{node:``,addrs:``}])),K=d(!1),re=d(null),q=d(y([]));async function J(){s(D,!0);try{s(S,(await Q.listVaults()).published,!0);let e=await Promise.all(A(S).map(async e=>{try{return[e.vid,(await Q.listReplicas(e.vid)).members]}catch{return[e.vid,A(C)[e.vid]??[]]}}));s(C,Object.fromEntries(e),!0)}catch{}s(D,!1)}J(),f(()=>{a()?.vaults.published.length,J()});async function Y(){if(A(O).trim()){s(k,!0),s(ne,``);try{await Q.publishVault(A(O).trim()),s(O,``),await J()}catch(e){s(ne,e.message,!0)}finally{s(k,!1)}}}async function X(){let e=a()?.peers.find(e=>e.node===A(te));if(e&&(s(N,e.node,!0),s(P,e.addrs.join(`,`),!0)),!(!A(N).trim()||!A(I).trim())){s(L,!0),s(R,null),s(ne,``);try{let e=A(P).split(`,`).map(e=>e.trim()).filter(Boolean),t=await Q.syncOwned({node:A(N).trim(),addrs:e},A(I).trim());s(R,t.restored,!0),await J()}catch(e){s(ne,e.message,!0)}finally{s(L,!1)}}}function Z(e){s(U,e,!0),s(re,null),s(G,[{node:``,addrs:``}],!0),s(q,[],!0)}function ae(){s(G,[...A(G),{node:``,addrs:``}],!0)}function oe(e){s(G,A(G).filter((t,n)=>n!==e),!0)}async function se(){if(A(U)){s(K,!0);try{let e=(a()?.peers??[]).filter(e=>A(q).includes(e.node)),t=A(G).filter(e=>e.node.trim()).map(e=>({node:e.node.trim(),addrs:e.addrs.split(`,`).map(e=>e.trim()).filter(Boolean)})),n=[...e.map(e=>({node:e.node,addrs:e.addrs})),...t],r=await Q.placeReplicas(A(U),n,A(W));s(re,r.placed,!0),await J()}catch(e){s(ne,e.message,!0)}finally{s(K,!1)}}}var $=st(),ce=_(v($),4),le=e=>{var t=Be(),n=v(t,!0);F(t),l(()=>r(n,A(ne))),V(e,t)};h(ce,e=>{A(ne)&&e(le)});var ue=_(ce,2),de=_(v(ue),4),fe=v(de);ze(fe,{id:`vault-dir`,get value(){return A(O)},set value(e){s(O,e,!0)}});var pe=_(fe,2),me=v(pe,!0);F(pe),F(de),F(ue);var he=_(ue,2),ge=_(v(he),6),_e=v(ge);_e.value=_e.__value=``,w(_(_e),1,()=>a()?.peers??[],e=>e.node,(e,t)=>{var n=Ve(),i=v(n);F(n);var a={};l(e=>{r(i,`${(A(t).display||`Friend`)??``} · ${e??``}…`),a!==(a=A(t).node)&&(n.value=(n.__value=A(t).node)??``)},[()=>A(t).node.slice(0,10)]),V(e,n)}),F(ge);var ve=_(ge,2),ye=_(v(ve),3);B(ye);var be=_(ye,3);B(be),F(ve);var xe=_(ve,4);B(xe);var Se=_(xe,2),Ce=_(Se,2),we=v(Ce),Te=e=>{V(e,He())},Ee=e=>{var t=g(),n=c(t),i=e=>{V(e,Ue())},a=e=>{var t=Ge(),n=c(t),i=v(n);F(n);var a=_(n,2);w(a,21,()=>A(R),e=>e.vid,(e,t)=>{var n=We(),i=v(n),a=v(i);F(i);var o=_(i),s=_(o),c=v(s,!0);F(s),F(n),l(e=>{r(a,`${e??``}…`),r(o,`, epoch ${A(t).epoch??``}, in `),r(c,A(t).out_dir)},[()=>A(t).vid.slice(0,12)]),V(e,n)}),F(a),l(()=>r(i,`Restored ${A(R).length??``} vault(s).`)),V(e,t)};h(n,e=>{A(R).length===0?e(i):e(a,-1)}),V(e,t)};h(we,e=>{A(L)?e(Te):A(R)&&e(Ee,1)}),F(Ce),F(he);var De=_(he,2),Oe=e=>{V(e,Ke())},Ae=e=>{V(e,qe())},je=e=>{var t=tt();w(t,21,()=>A(S),e=>e.vid,(e,t)=>{var n=et(),i=v(n),a=v(i),o=v(a,!0);F(a);var s=_(a,3),c=e=>{var n=Je(),i=v(n,!0);F(n),l(()=>r(i,A(t).dir)),V(e,n)};h(s,e=>{A(t).dir&&e(c)});var u=_(s,2),d=v(u,!0);F(u);var f=_(u,2),p=e=>{var n=Ye(),i=v(n,!0);F(n),l(()=>r(i,A(t).last_error)),V(e,n)};h(f,e=>{A(t).last_error&&e(p)});var m=_(f,2),g=e=>{var n=Xe(),i=_(v(n)),a=v(i,!0);F(i),z(),F(n),l(()=>r(a,A(t).recovery_backup)),V(e,n)};h(m,e=>{A(t).recovery_backup&&e(g)});var y=_(m,2);ke(y,{get value(){return A(t).vid}});var b=_(y,2),x=v(b);F(b),F(i);var S=_(i,2),T=_(v(S),2),E=e=>{var n=Qe();w(n,20,()=>A(C)[A(t).vid],e=>e,(e,t)=>{var n=Ze();ke(v(n),{get value(){return t}}),F(n),V(e,n)}),F(n),V(e,n)},D=e=>{V(e,$e())};h(T,e=>{A(C)[A(t).vid]?.length?e(E):e(D,-1)}),F(S);var O=_(S,2);F(n),l(()=>{r(o,A(t).name),r(d,A(t).syncing?`Syncing…`:A(t).watching?`Watching for changes`:`Not currently watching`),r(x,`epoch ${A(t).epoch??``}`)}),j(`click`,O,()=>Z(A(t).vid)),V(e,n)}),F(t),V(e,t)};h(De,e=>{A(D)?e(Oe):A(S).length===0?e(Ae,1):e(je,-1)});var Me=_(De,2),Ne=e=>{var t=ot(),n=v(t),i=_(v(n)),o=v(i);F(i),F(n);var c=_(n,4);w(_(v(c),2),1,()=>a()?.peers??[],e=>e.node,(e,t)=>{var n=nt(),i=v(n);B(i);var a,o=_(i);F(n),l(e=>{a!==(a=A(t).node)&&(i.value=(i.__value=A(t).node)??``),r(o,` ${(A(t).display||`Friend`)??``} · ${e??``}…`)},[()=>A(t).node.slice(0,10)]),T(b,[],i,()=>(A(t).node,A(q)),e=>s(q,e)),V(e,n)}),F(c);var u=_(c,2),d=_(v(u));w(d,17,()=>A(G),H,(e,t,n)=>{var r=it(),i=v(r);B(i);var a=_(i,2);B(a);var o=_(a,2),s=e=>{var t=rt();j(`click`,t,()=>oe(n)),V(e,t)};h(o,e=>{A(G).length>1&&e(s)}),F(r),M(i,()=>A(t).node,e=>A(t).node=e),M(a,()=>A(t).addrs,e=>A(t).addrs=e),V(e,r)});var f=_(d);F(u);var p=_(u,2),m=_(v(p),2);B(m);var g=_(m,2),y=v(g,!0);F(g);var x=_(g,2);F(p);var S=_(p,2),C=e=>{var t=at(),n=v(t);F(t),l(()=>r(n,`Placed on ${A(re).length??``} peer${A(re).length===1?``:`s`}.`)),V(e,t)};h(S,e=>{A(re)&&e(C)}),F(t),l(e=>{r(o,`${e??``}…`),g.disabled=A(K),r(y,A(K)?`Placing…`:`Place`)},[()=>A(U).slice(0,12)]),j(`click`,f,ae),M(m,()=>A(W),e=>s(W,e)),j(`click`,g,se),j(`click`,x,()=>s(U,null)),V(e,t)};h(Me,e=>{A(U)&&e(Ne)}),F($),l((e,t)=>{pe.disabled=e,r(me,A(k)?`Publishing…`:`Publish vault`),E(he,`aria-busy`,A(L)),Se.disabled=t},[()=>A(k)||!A(O).trim(),()=>A(L)||!A(te)&&!A(N).trim()||!A(I).trim()]),t(`submit`,ue,e=>(e.preventDefault(),Y())),t(`submit`,he,e=>(e.preventDefault(),X())),ee(ge,()=>A(te),e=>s(te,e)),M(ye,()=>A(N),e=>s(N,e)),M(be,()=>A(P),e=>s(P,e)),M(xe,()=>A(I),e=>s(I,e)),V(n,$),m(),p()}p([`click`]);var lt=a(`
          `),ut=a(`

          `),dt=a(`

          Re-split required

          Recovery & trustees.

          `,1),ft=a(`

          `),pt=a(`
          `),mt=a(`

          Loading…

          `),ht=a(`

          No friends yet. Issue an invite ticket to add your first one.

          `),gt=a(`Remove this friend? `),_t=a(``),vt=a(`
          `),yt=a(`
          `),bt=a(`

          Friends

          Friends hold encrypted replicas of your vaults and, if you make them trustees, pieces of your + recovery key.

          Invite a friend

          Local operation · the ticket becomes peer-dependent when your friend accepts it

          Add a friend from a ticket

          Peer-dependent operation · an unused ticket is safe to retry

          Your friends

          Storage grants above come from durable daemon state. Trustee and relay roles appear in their + own recovery and replica status views.

          `);function xt(n,a){e(a,!0);let o=()=>x(ie,`$status`,p),[p,g]=u(),b=d(y([])),S=d(!0),C=d(null),T=d(!1),E=d(``),O=d(``),N=d(1),P=d(!1),ee=d(null),I=d(!1),L=d(null),R=d(null),H=d(null);function ne(e){return o()?.friends.grants.find(t=>t.user===e)?.grant_bytes??null}async function U(){s(S,!0);let e=await Q.listFriends().catch(()=>({count:0,list:[]}));s(b,e.list,!0),s(S,!1)}U(),f(()=>{o()?.friends.count,U()});async function W(){s(T,!0);try{let e=await Q.issueTicket();s(C,e,!0)}finally{s(T,!1)}}async function G(){A(C)&&await Se(A(C).uri)&&(s(I,!0),setTimeout(()=>s(I,!1),1200))}async function K(){if(A(E).trim()){s(P,!0),s(ee,null);try{let e=A(O).split(`,`).map(e=>e.trim()).filter(Boolean),t=Math.round(A(N)*1024**3),n=await Q.addFriend(A(E).trim(),e.length?e:void 0,t);s(ee,`Friend added (${n.friend.slice(0,12)}…).`),s(E,``),s(O,``),await U()}finally{s(P,!1)}}}async function re(e){s(R,e,!0);try{let t=await Q.unfriend(e);s(L,null),t.was_friend&&s(H,{friend:e,resplit:t.resplit_triggered,rsids:t.recovery_set_ids},!0),await U()}finally{s(R,null)}}var q=bt(),J=_(v(q),4),Y=v(J),X=_(v(Y),4),Z=v(X,!0);F(X);var ae=_(X,2),oe=e=>{var t=lt(),n=_(v(t),2),i=v(n);B(i);var a=_(i,2),o=v(a,!0);F(a),F(n),F(t),l(()=>{D(i,A(C).uri),r(o,A(I)?`Copied`:`Copy`)}),j(`click`,a,G),V(e,t)};h(ae,e=>{A(C)&&e(oe)}),F(Y);var se=_(Y,2),$=_(v(se),4),ce=_(v($),2);B(ce);var le=_(ce,4);B(le);var ue=_(le,4);B(ue);var de=_(ue,2),fe=v(de,!0);F(de),F($);var pe=_($,2),me=e=>{var t=ut(),n=v(t,!0);F(t),l(()=>r(n,A(ee))),V(e,t)};h(pe,e=>{A(ee)&&e(me)}),F(se),F(J);var he=_(J,2),ge=e=>{var t=pt(),n=v(t),i=e=>{var t=dt(),n=_(c(t),2),i=v(n);z(2),F(n),l((e,t)=>r(i,`${e??``}… was a trustee. A trustee re-split is now running for + recovery set${A(H).rsids.length>1?`s`:``} + ${t??``}. Both the old and new sets stay usable until the new set is + live and the old shares are destroyed - track it under `),[()=>A(H).friend.slice(0,12),()=>A(H).rsids.join(`, `)]),V(e,t)},a=e=>{var t=ft(),n=v(t);F(t),l(e=>r(n,`${e??``}… removed. They held no recovery shares, so no re-split + was needed.`),[()=>A(H).friend.slice(0,12)]),V(e,t)};h(n,e=>{A(H).resplit?e(i):e(a,-1)});var o=_(n,2);F(t),l(()=>k(t,1,`card ${A(H).resplit?`at-risk`:`healthy`}`)),j(`click`,o,()=>s(H,null)),V(e,t)};h(he,e=>{A(H)&&e(ge)});var _e=_(he,4),ve=e=>{V(e,mt())},ye=e=>{V(e,ht())},be=e=>{var t=yt();w(t,20,()=>A(b),e=>e,(e,t)=>{var n=vt(),a=v(n);ke(a,{get value(){return t}});var o=_(a,2),c=v(o),u=e=>{var n=i();l(e=>r(n,`${e??``} storage grant`),[()=>we(ne(t))]),V(e,n)},d=te(()=>ne(t)!==null),f=e=>{V(e,i(`storage grant unavailable`))};h(c,e=>{A(d)?e(u):e(f,-1)}),F(o);var p=_(o,2),m=e=>{var n=gt(),i=_(v(n),2),a=v(i,!0);F(i);var o=_(i,2);F(n),l(()=>{i.disabled=A(R)===t,r(a,A(R)===t?`Removing…`:`Confirm unfriend`),o.disabled=A(R)===t}),j(`click`,i,()=>re(t)),j(`click`,o,()=>s(L,null)),V(e,n)},g=e=>{var n=_t();j(`click`,n,()=>s(L,t,!0)),V(e,n)};h(p,e=>{A(L)===t?e(m):e(g,-1)}),F(n),V(e,n)}),F(t),V(e,t)};h(_e,e=>{A(S)?e(ve):A(b).length===0?e(ye,1):e(be,-1)}),z(2),F(q),l(e=>{X.disabled=A(T),r(Z,A(T)?`Issuing…`:`Create invite ticket`),de.disabled=e,r(fe,A(P)?`Adding…`:`Add friend`)},[()=>A(P)||!A(E).trim()]),j(`click`,X,W),t(`submit`,$,e=>(e.preventDefault(),K())),M(ce,()=>A(E),e=>s(E,e)),M(le,()=>A(O),e=>s(O,e)),M(ue,()=>A(N),e=>s(N,e)),V(n,q),m(),g()}p([`click`]);var St=a(`
          Live on this node

          `),Ct=a(`

          Restore is in progress.

          `),wt=a(`

          `),Tt=a(``),Et=a(``),Dt=a(``),Ot=a(`
          Reason
          Approvals
          Sponsor
          `),kt=a(`

          Active recovery ceremonies

          Review the claimant and reason out of band before you approve a ceremony. Abort any ceremony + against your identity that you did not start.

          `,1),At=a(`
          `),jt=a(`

          Paper cards (offline backstop)

          Print a paper card for each recovery set (§8, §10.2). A card recovers from its words + alone - offline, with no Carapace software - so it is the backstop that never goes + offline. The card shows a share's secret words; print it, then keep or destroy the copy.

          `,1),Mt=a(`
          `),Nt=a(`
          re-split required

          was a trustee of this recovery set and + was unfriended. Their retained share must be neutralized by re-splitting to a fresh set.

          Suggested new trustee set - live reachability

          `),Pt=a(`

          Re-split required

          An unfriended trustee still held a share of the recovery set below (§9.3.4). Start the + re-split to hand a fresh share to a new trustee set - the old shares are only destroyed + once that new set is live.

          `,1),Ft=a(`· live`),It=a(`· not live yet`),Lt=a(`· destroy refused until new set is live`),Rt=a(`
          `),zt=a(`

          New set attested (destroy gate: M + slack)

          Old shares destroyed (ack)

          Remaining friends - live reachability
          `),Bt=a(`

          Trustee re-splits in progress

          An unfriended trustee's share is being neutralized (§9.3 step 4). Both the old and new + recovery sets stay usable until the new set is live and the old shares are destroyed - + neither door closes early.

          `,1),Vt=a(` `),Ht=a(`
          `),Ut=a(`

          Authoritative recovery sets

          `,1),Wt=a(``),Gt=a(`

          Advanced recovery-set id
          `,1),Kt=a(``),qt=a(` `,1),Jt=a(``),Yt=a(`

          Add friends before you create recovery protection.

          `),Xt=a(``),Zt=a(`
          `),Qt=a(`
          `),$t=a(` `,1),en=a(` `),tn=a(`
        • `),nn=a(`

            `,1),rn=a(`

            All selected trustees received their shares.

            `),an=a(`

            Verified share delivery

            `),on=a(`

            `),sn=a(`
            `),cn=a(``),ln=a(`

            Shares - send one to each trustee

            `),un=a(``),dn=a(`
            `),fn=a(`

            This device does not hold a recovery grant. It cannot sponsor a ceremony.

            `),pn=a(``),mn=a(`

            Verified sponsor ceremony package - hand to the claimant
            `,1),hn=a(`

            `,1),gn=a(`

            Send this abort to your trustees.

            `,1),_n=a(`

            Recovery & trustees

            Split your key into pieces so a group of trustees can rebuild it if you lose access. + The normal flow sends a signed recovery grant to each selected friend and tracks delivery. + The advanced manual flow shows bearer shares that you must protect and deliver yourself.

            Restore retained vaults after identity recovery

            Discovery-dependent operation · it is safe to retry after a network failure

            Use the public restart handoff saved by claimant activation. Carapace contacts the verified trustee hints and accepts only the maximum announced epoch for each vault.

            Split or re-split

            Share delivery

            Add a trustee (extend)

            Peer-dependent operation · offline delivery stays queued and is safe to retry

            Recovery ceremony

            A ceremony is opened by a signed request a trustee receives out of band (from the person + recovering, or the daemon that observed them). The recovery delay and required approvals are + enforced by the daemon per the grant that authorized the ceremony; paste the pieces below as + they arrive.

            Open (sponsor)

            Use the recovery request from the claimant. Confirm their identity and new device through + a separate trusted channel before you open the ceremony.

            Advanced manual values

            Approve

            Abort

            `);function vn(n,a){e(a,!0);let o=()=>x(ie,`$status`,f),[f,p]=u(),g=[],b=[],S=[],C=[],D=d(`split`),N=d(1),P=d(!1),I=d(`root`),L=d(``),U=d(2),W=d(3),G=d(`friends`),K=d(y([])),re=d(!1),q=d(!1),J=d(null),Y=d(y([])),X=d(null),Z=te(()=>Math.max(0,...o()?.share_health.sets.map(e=>e.rsid)??[])+1),ae=d(1),oe=d(1),se=d(!1),$=d(!1),ce=d(null),le=d(``),ue=d(``),de=d(``),fe=d(``),pe=d(``),me=d(``),he=d(``),ge=d(!1),_e=d(null);function ve(){s(fe,``);try{let e=JSON.parse(A(de));if(e.type!==`carapace.claimant-handoff`||e.version!==1||typeof e.ceremony_enc!=`string`||typeof e.new_node!=`string`||!/^[0-9a-f]{64}$/i.test(e.ceremony_enc)||!/^[0-9a-f]{64}$/i.test(e.new_node))throw Error(`Package type, version, or public values are invalid.`);s(pe,e.ceremony_enc,!0),s(me,e.new_node,!0)}catch(e){s(fe,e instanceof Error?e.message:`The handoff package is invalid.`,!0)}}let ye=d(``),be=d(!1),xe=d(null),Ce=d(``),we=d(!1),Te=d(!1),Ee=d(null),De=d(``),Oe=d(!1),ke=d(null);async function Ae(){s(Oe,!0);try{let e=await Q.restartRestore(A(De).trim());s(ke,{restored:e.restored.length,refs:e.maximum_epoch_refs},!0)}finally{s(Oe,!1)}}function je(e){switch(e){case`awaiting_new_set`:return`Standing up new set`;case`ready_to_destroy`:return`New set live - destroying old shares`;case`complete`:return`Complete`;default:return e}}function Me(e){return o()?.peers.find(t=>t.user===e)?.display||`Friend ${e.slice(0,10)}…`}let Ne=d(null);function Pe(e){return e.filter(e=>e.online).length}async function Fe(e){s(Ne,e,!0);try{await Q.resplitStart(e)}finally{s(Ne,null)}}let Ie=d(null);async function Le(e){s(Ie,e,!0);try{let t=await Q.paperCards(e),n=URL.createObjectURL(new Blob([t],{type:`text/html`}));window.open(n,`_blank`,`noopener`),setTimeout(()=>URL.revokeObjectURL(n),6e4)}finally{s(Ie,null)}}async function Re(e,t){await Se(e)&&(t(!0),setTimeout(()=>t(!1),1200))}async function ze(){s(q,!0),s(J,null),s(Y,[],!0),s(X,null);try{let e=A(I)===`root`?{kind:`root`}:{kind:`vault`,vid:A(L).trim()};if(A(G)===`friends`){let t=await(A(D)===`split`?Q.recoverySplitToTrustees:Q.recoveryResplitToTrustees)(A(D)===`split`&&!A(P)?A(Z):A(N),e,A(U),A(K),A(re));s(X,{delivered:t.delivered,undelivered:t.undelivered},!0),s(Y,t.warnings,!0)}else{let t=await(A(D)===`split`?Q.recoverySplit:Q.recoveryResplit)(A(D)===`split`&&!A(P)?A(Z):A(N),e,A(U),A(W),A(re));s(J,t.shares,!0),s(Y,t.warnings,!0)}}finally{s(q,!1)}}async function Be(){s($,!0),s(ce,null);try{let e=await Q.recoveryExtend(A(ae),A(oe),A(se));s(ce,e.shares,!0)}finally{s($,!1)}}async function Ve(){s(ge,!0),s(_e,null);try{s(_e,await Q.ceremonyOpen({subject:A(le).trim(),claimant_display:A(ue).trim(),ceremony_enc:A(pe).trim(),new_node:A(me).trim(),reason:A(he).trim()}),!0)}finally{s(ge,!1)}}async function He(){s(be,!0),s(xe,null);try{s(xe,await Q.ceremonyApprove(A(ye).trim()),!0)}finally{s(be,!1)}}async function Ue(){s(Te,!0),s(Ee,null);try{s(Ee,(await Q.ceremonyAbort(A(Ce).trim())).abort_hex,!0)}finally{s(Te,!1)}}let We=d(y({})),Ge=d(!1),Ke=d(!1);function qe(e){s(ye,e,!0),document.getElementById(`approve-id`)?.focus()}function Je(e){s(Ce,e,!0),s(we,!1),document.getElementById(`abort-id`)?.focus()}var Ye=_n(),Xe=_(v(Ye),4),Ze=e=>{var t=St(),n=_(v(t),2),i=v(n);F(n),F(t),l(()=>r(i,`${o().share_health.recovery_sets_owned??``} recovery set(s) split · + ${o().share_health.shares_held??``} share(s) held here in trust for others`)),V(e,t)};h(Xe,e=>{o()&&e(Ze)});var Qe=_(Xe,2),$e=_(v(Qe),6),et=_(v($e),2);B(et);var tt=_(et,2),nt=v(tt,!0);F(tt),F($e);var rt=_($e,2),it=v(rt),at=e=>{V(e,Ct())},ot=e=>{var t=wt(),n=v(t);F(t),l(()=>r(n,`Restored ${A(ke).restored??``} vault(s) from ${A(ke).refs??``} maximum-epoch reference(s).`)),V(e,t)};h(it,e=>{A(Oe)?e(at):A(ke)&&e(ot,1)}),F(rt),F(Qe);var st=_(Qe,2),ct=e=>{var t=kt(),n=_(c(t),4);w(n,5,()=>o().ceremonies,e=>e.ceremony_id,(e,t)=>{var n=Ot();let i;var a=v(n),o=v(a),s=v(o),c=v(s,!0);F(s);var u=_(s,2),d=v(u,!0);F(u),F(o);var f=_(o,2),p=v(f,!0);F(f),F(a);var m=_(a,2),g=e=>{V(e,Tt())};h(m,e=>{A(t).alarm&&e(g)});var y=_(m,2),b=v(y),x=_(v(b)),S=v(x,!0);F(x),F(b);var C=_(b,2),w=_(v(C)),T=v(w);F(w),F(C);var E=_(C,2),D=_(v(E)),O=v(D,!0);F(D),F(E),F(y);var M=_(y,2),N=v(M),P=e=>{var n=Et();j(`click`,n,()=>qe(A(t).ceremony_id)),V(e,n)};h(N,e=>{A(t).trustee&&!A(t).approved&&e(P)});var ee=_(N,2),te=e=>{var n=Dt();j(`click`,n,()=>Je(A(t).ceremony_id)),V(e,n)};h(ee,e=>{A(t).is_self_subject&&!A(t).takeover&&e(te)}),F(M),F(n),l(()=>{i=k(n,1,`card ceremony svelte-imw02`,null,i,{alarm:A(t).alarm}),r(c,A(t).claimant_display||`Unnamed claimant`),r(d,A(t).ceremony_id),r(p,A(t).phase),r(S,A(t).reason||`No reason supplied`),r(T,`${A(t).approvals??``} / ${A(t).threshold??``}`),r(O,A(t).sponsor)}),V(e,n)}),F(n),V(e,t)};h(st,e=>{o()?.ceremonies?.length&&e(ct)});var lt=_(st,2),ut=e=>{var t=jt(),n=_(c(t),4);w(n,5,()=>o().recovery_grants.minted,e=>e.rsid,(e,t)=>{var n=At(),i=v(n),a=v(i);F(i);var o=_(i,2),s=v(o);F(o);var c=_(o,2),u=v(c,!0);F(c),F(n),l(()=>{r(a,`rsid ${A(t).rsid??``}`),r(s,`${A(t).trustees.length??``} share(s)`),c.disabled=A(Ie)===A(t).rsid,r(u,A(Ie)===A(t).rsid?`Opening…`:`Print / export paper cards`)}),j(`click`,c,()=>Le(A(t).rsid)),V(e,n)}),F(n),V(e,t)};h(lt,e=>{o()?.recovery_grants?.minted?.length&&e(ut)});var dt=_(lt,2),ft=e=>{var t=Pt();w(_(c(t),4),1,()=>o().pending_resplits,e=>e.old_rsid,(e,t)=>{var n=Nt(),a=v(n),o=v(a),s=v(o);F(o),z(2),F(a);var c=_(a,2),u=v(c),d=v(u);F(u),z(),F(c);var f=_(c,4);w(f,21,()=>A(t).suggested,e=>e.user,(e,t)=>{var n=Mt(),i=v(n),a=_(i,2),o=v(a);F(a);var s=_(a,2),c=v(s,!0);F(s),F(n),l(e=>{k(i,1,`dot ${A(t).online?`online`:`offline`}`,`svelte-imw02`),E(i,`title`,A(t).online?`online`:`offline`),r(o,`${e??``}…`),r(c,A(t).online?`online`:`offline`)},[()=>A(t).user.slice(0,12)]),V(e,n)}),F(f);var p=_(f,2),m=v(p),g=_(m),y=e=>{V(e,i(`will complete immediately once started.`))},b=te(()=>Pe(A(t).suggested)===A(t).suggested.length&&A(t).suggested.length>0),x=e=>{V(e,i(`will complete progressively as offline trustees come online.`))};h(g,e=>{A(b)?e(y):e(x,-1)}),F(p);var S=_(p,2),C=v(S,!0);F(S),F(n),l((e,n,i)=>{r(s,`rsid ${A(t).old_rsid??``}`),r(d,`${e??``}…`),k(p,1,n),r(m,`${i??``} / ${A(t).suggested.length??``} suggested trustee(s) online - `),S.disabled=A(Ne)===A(t).old_rsid,r(C,A(Ne)===A(t).old_rsid?`Starting…`:`Start re-split (use suggested set)`)},[()=>A(t).ex_trustee.slice(0,12),()=>O(Pe(A(t).suggested)===A(t).suggested.length&&A(t).suggested.length>0?`healthy`:`muted`),()=>Pe(A(t).suggested)]),j(`click`,S,()=>Fe(A(t).old_rsid)),V(e,n)}),V(e,t)};h(dt,e=>{o()?.pending_resplits?.length&&e(ft)});var pt=_(dt,2),mt=e=>{var t=Bt();w(_(c(t),4),1,()=>o().resplits,e=>e.old_rsid,(e,t)=>{var n=zt(),a=v(n),o=v(a),s=v(o);F(o);var c=_(o,2),u=v(c,!0);F(c),F(a);var d=_(a,2),f=v(d);F(d);var p=_(d,2),m=v(p),g=_(v(m),2),y=v(g),b=_(y),x=e=>{V(e,Ft())},S=e=>{V(e,It())};h(b,e=>{A(t).new_set_live?e(x):e(S,-1)}),F(g),F(m);var C=_(m,2),T=_(v(C),2),D=v(T),O=_(D),j=e=>{V(e,Lt())};h(O,e=>{A(t).new_set_live||e(j)}),F(T),F(C),F(p);var M=_(p,4);w(M,21,()=>A(t).remaining,e=>e.node,(e,t)=>{var n=Rt(),a=v(n),o=_(a,2),s=v(o);F(o);var c=_(o,2),u=v(c,!0);F(c);var d=_(c,2),f=v(d),p=e=>{V(e,i(`done`))},m=e=>{var n=i();l(()=>r(n,`online - ${A(t).role===`new`?`sending share`:`sending destroy`}`)),V(e,n)},g=e=>{V(e,i(`offline - queued`))};h(f,e=>{A(t).done?e(p):A(t).online?e(m,1):e(g,-1)}),F(d),F(n),l(e=>{k(a,1,`dot ${A(t).status??``}`,`svelte-imw02`),E(a,`title`,A(t).status),r(s,`${e??``}…`),k(c,1,`role ${A(t).role??``}`,`svelte-imw02`),r(u,A(t).role===`new`?`gets new share`:`gets destroy step`)},[()=>A(t).node.slice(0,12)]),V(e,n)}),F(M),F(n),l((e,n)=>{r(s,`rsid ${A(t).old_rsid??``} → ${A(t).new_rsid??``}`),k(c,1,`phase ${A(t).phase??``}`,`svelte-imw02`),r(u,e),r(f,`ex-trustee ${n??``}…`),r(y,`${A(t).new_attested??``} / ${A(t).new_total??``} `),r(D,`${A(t).old_destroyed??``} / ${A(t).old_total??``} `)},[()=>je(A(t).phase),()=>A(t).ex_trustee.slice(0,12)]),V(e,n)}),V(e,t)};h(pt,e=>{o()?.resplits?.length&&e(mt)});var ht=_(pt,2),gt=e=>{var t=Ut(),n=_(c(t),2);w(n,5,()=>o().share_health.sets,e=>e.rsid,(e,t)=>{var n=Ht(),i=v(n),a=v(i);F(i);var o=_(i,2),s=v(o,!0);F(o);var c=_(o,2),u=v(c);F(c);var d=_(c,2),f=v(d);F(d);var p=_(d,2),m=e=>{var n=Vt(),i=v(n,!0);F(n),l(e=>r(i,e),[()=>A(t).warnings.join(`, `)]),V(e,n)};h(p,e=>{A(t).warnings.length&&e(m)}),F(n),l((e,n)=>{r(a,`rsid ${A(t).rsid??``}`),r(s,e),r(u,`${A(t).threshold??``}-of-${A(t).issued??``}`),r(f,`${n??``}/${A(t).trustees.length??``} trustee grants delivered`)},[()=>A(t).scope.kind===`root`?`your root key`:`vault ${A(t).scope.vid.slice(0,10)}…`,()=>A(t).trustees.filter(e=>e.delivered).length]),V(e,n)}),F(n),V(e,t)};h(ht,e=>{o()?.share_health.sets.length&&e(gt)});var _t=_(ht,4),vt=v(_t),yt=v(vt),bt=v(yt);B(bt),bt.value=bt.__value=`split`,z(),F(yt);var xt=_(yt,2),vn=v(xt);B(vn),vn.value=vn.__value=`resplit`,z(),F(xt),F(vt);var yn=_(vt,2),bn=e=>{var t=Gt(),n=c(t),i=v(n);F(n);var a=_(n,2),o=_(v(a)),u=v(o);B(u),z(),F(o);var d=_(o),f=e=>{var t=Wt();B(t),M(t,()=>A(N),e=>s(N,e)),V(e,t)};h(d,e=>{A(P)&&e(f)}),F(a),l(()=>r(i,`Carapace will create recovery set ${A(Z)??``}.`)),R(u,()=>A(P),e=>s(P,e)),V(e,t)},xn=e=>{var t=qt(),n=_(c(t),2);w(n,5,()=>o()?.share_health.sets??[],e=>e.rsid,(e,t)=>{var n=Kt(),i=v(n);F(n);var a={};l(()=>{r(i,`${A(t).scope.kind===`root`?`Identity recovery`:`Vault recovery`} · ${A(t).threshold??``}-of-${A(t).issued??``}`),a!==(a=A(t).rsid)&&(n.value=(n.__value=A(t).rsid)??``)}),V(e,n)}),F(n),ee(n,()=>A(N),e=>s(N,e)),V(e,t)};h(yn,e=>{A(D)===`split`?e(bn):e(xn,-1)});var Sn=_(yn,2),Cn=v(Sn),wn=v(Cn);B(wn),wn.value=wn.__value=`root`,z(),F(Cn);var Tn=_(Cn,2),En=v(Tn);B(En),En.value=En.__value=`vault`,z(),F(Tn);var Dn=_(Tn,2),On=e=>{var t=Jt(),n=v(t);n.value=n.__value=``,w(_(n),1,()=>o()?.vaults.published??[],e=>e.vid,(e,t)=>{var n=Kt(),i=v(n,!0);F(n);var a={};l(()=>{r(i,A(t).name),a!==(a=A(t).vid)&&(n.value=(n.__value=A(t).vid)??``)}),V(e,n)}),F(t),ee(t,()=>A(L),e=>s(L,e)),V(e,t)};h(Dn,e=>{A(I)===`vault`&&e(On)}),F(Sn);var kn=_(Sn,2),An=_(v(kn),2),jn=v(An);B(jn),jn.value=jn.__value=`friends`,z(),F(An);var Mn=_(An,2),Nn=v(Mn);B(Nn),Nn.value=Nn.__value=`manual`,z(),F(Mn),F(kn);var Pn=_(kn,2),Fn=e=>{var t=Qt(),n=v(t),i=v(n);F(n);var a=_(n,2),c=e=>{V(e,Yt())},u=e=>{var t=Zt();w(t,5,()=>o().friends.list,e=>e,(e,t)=>{var n=Xt(),i=v(n);B(i);var a,o=_(i,2),c=v(o,!0);F(o),F(n),l(e=>{E(n,`title`,A(t)),a!==(a=A(t))&&(i.value=(i.__value=A(t))??``),r(c,e)},[()=>Me(A(t))]),T(C,[],i,()=>(A(t),A(K)),e=>s(K,e)),V(e,n)}),F(t),V(e,t)};h(a,e=>{o()?.friends.list.length?e(u,-1):e(c)}),F(t),l(()=>r(i,`Select trustees (${A(K).length??``} selected)`)),V(e,t)};h(Pn,e=>{A(G)===`friends`&&e(Fn)});var In=_(Pn,2),Ln=_(v(In),2);B(Ln);var Rn=_(Ln,2),zn=e=>{var t=$t(),n=_(c(t),2);B(n),M(n,()=>A(W),e=>s(W,e)),V(e,t)},Bn=e=>{var t=en(),n=v(t);F(t),l(()=>r(n,`of ${A(K).length??``} selected`)),V(e,t)};h(Rn,e=>{A(G)===`manual`?e(zn):e(Bn,-1)}),F(In);var Vn=_(In,2),Hn=v(Vn),Un=v(Hn);B(Un),z(),F(Hn),F(Vn);var Wn=_(Vn,2),Gn=v(Wn,!0);F(Wn),F(_t);var Kn=_(_t,2),qn=e=>{var t=an();let n;var i=_(v(t),2),a=v(i);F(i);var o=_(i,2),s=e=>{var t=nn(),n=c(t),i=v(n);F(n);var a=_(n,2);w(a,20,()=>A(X).undelivered,e=>e,(e,t)=>{var n=tn(),i=v(n,!0);F(n),l(()=>r(i,t)),V(e,n)}),F(a),l(()=>r(i,`Could not reach ${A(X).undelivered.length??``} trustee(s). Carapace will retry delivery during maintenance.`)),V(e,t)},u=e=>{V(e,rn())};h(o,e=>{A(X).undelivered.length?e(s):e(u,-1)}),F(t),l(()=>{n=k(t,1,`card`,null,n,{"at-risk":A(X).undelivered.length>0}),r(a,`${A(X).delivered.length??``} trustee(s) confirmed that they stored a signed recovery grant.`)}),V(e,t)};h(Kn,e=>{A(X)&&e(qn)});var Jn=_(Kn,2),Yn=e=>{var t=sn();w(t,20,()=>A(Y),e=>e,(e,t)=>{var n=on(),i=v(n,!0);F(n),l(()=>r(i,t)),V(e,n)}),F(t),V(e,t)};h(Jn,e=>{A(Y).length&&e(Yn)});var Xn=_(Jn,2),Zn=e=>{var t=ln();w(_(v(t),2),17,()=>A(J),H,(e,t,n)=>{var i=cn(),a=v(i),o=v(a,!0);F(a);var c=_(a,2),u=v(c,!0);F(c),F(i),l(()=>{r(o,A(t)),r(u,A(We)[n]?`Copied`:`Copy`)}),j(`click`,c,()=>Re(A(t),e=>s(We,{...A(We),[n]:e},!0))),V(e,i)}),F(t),V(e,t)};h(Xn,e=>{A(J)&&e(Zn)});var Qn=_(Xn,6),$n=v(Qn),er=_(v($n),2);w(er,5,()=>o()?.share_health.sets??[],e=>e.rsid,(e,t)=>{var n=Kt(),i=v(n);F(n);var a={};l(()=>{r(i,`${A(t).scope.kind===`root`?`Identity recovery`:`Vault recovery`} · ${A(t).threshold??``}-of-${A(t).issued??``}`),a!==(a=A(t).rsid)&&(n.value=(n.__value=A(t).rsid)??``)}),V(e,n)}),F(er);var tr=_(er,4);B(tr);var nr=_(tr,2),rr=v(nr);B(rr),z(),F(nr),F($n);var ir=_($n,2),ar=v(ir,!0);F(ir),F(Qn);var or=_(Qn,2),sr=e=>{var t=dn();w(t,21,()=>A(ce),H,(e,t)=>{var n=un(),i=v(n),a=v(i,!0);F(i),F(n),l(()=>r(a,A(t))),V(e,n)}),F(t),V(e,t)};h(or,e=>{A(ce)&&e(sr)});var cr=_(or,6),lr=v(cr),ur=_(v(lr),6),dr=v(ur);dr.value=dr.__value=``,w(_(dr),1,()=>o()?.recovery_grants.held??[],e=>e,(e,t)=>{var n=Kt(),i=v(n,!0);F(n);var a={};l(e=>{r(i,e),a!==(a=A(t))&&(n.value=(n.__value=A(t))??``)},[()=>Me(A(t))]),V(e,n)}),F(ur);var fr=_(ur,2),pr=e=>{V(e,fn())};h(fr,e=>{o()?.recovery_grants.held.length||e(pr)});var mr=_(fr,4);B(mr);var hr=_(mr,4);ne(hr);var gr=_(hr,2),_r=_(gr,2),vr=e=>{var t=pn(),n=v(t,!0);F(t),l(()=>r(n,A(fe))),V(e,t)};h(_r,e=>{A(fe)&&e(vr)});var yr=_(_r,2),br=_(v(yr),4);B(br);var xr=_(br,4);B(xr),F(yr);var Sr=_(yr,4);B(Sr);var Cr=_(Sr,2),wr=v(Cr,!0);F(Cr);var Tr=_(Cr,2),Er=e=>{var t=mn(),n=c(t),i=v(n);F(n);var a=_(n,4),o=v(a),u=v(o,!0);F(o);var d=_(o,2),f=v(d,!0);F(d),F(a),l(e=>{r(i,`id ${e??``}… · fanned out to ${A(_e).fanout_reached??``} peer(s)`),r(u,A(_e).sponsor_package),r(f,A(Ge)?`Copied`:`Copy`)},[()=>A(_e).ceremony_id.slice(0,12)]),j(`click`,d,()=>Re(A(_e).sponsor_package,e=>s(Ge,e,!0))),V(e,t)};h(Tr,e=>{A(_e)&&e(Er)}),F(lr);var Dr=_(lr,2),Or=_(v(Dr),4);B(Or);var kr=_(Or,2),Ar=v(kr,!0);F(kr);var jr=_(kr,2),Mr=e=>{var t=hn(),n=c(t),i=v(n);F(n);var a=_(n,2),o=v(a),u=v(o,!0);F(o);var d=_(o,2),f=v(d,!0);F(d),F(a),l(()=>{r(i,`Approval broadcast to ${A(xe).broadcast_reached??``} co-trustee(s).`),r(u,A(xe).approve_hex),r(f,A(Ke)?`Copied`:`Copy`)}),j(`click`,d,()=>Re(A(xe).approve_hex,e=>s(Ke,e,!0))),V(e,t)};h(jr,e=>{A(xe)&&e(Mr)}),F(Dr);var Nr=_(Dr,2),Pr=_(v(Nr),4);B(Pr);var Fr=_(Pr,2),Ir=v(Fr);B(Ir),z(),F(Fr);var Lr=_(Fr,2),Rr=v(Lr,!0);F(Lr);var zr=_(Lr,2),Br=e=>{var t=gn(),n=c(t),i=v(n,!0);F(n),z(2),l(()=>r(i,A(Ee))),V(e,t)};h(zr,e=>{A(Ee)&&e(Br)}),F(Nr),F(cr),F(Ye),l((e,t,n)=>{tt.disabled=e,r(nt,A(Oe)?`Restoring…`:`Discover and restore retained vaults`),Wn.disabled=A(q)||A(G)===`friends`&&(A(K).length===0||A(U)>A(K).length),r(Gn,A(q)?`Splitting…`:A(D)===`split`?`Split key`:`Re-split key`),ir.disabled=A($),r(ar,A($)?`Issuing…`:`Issue new share(s)`),Cr.disabled=t,r(wr,A(ge)?`Opening…`:`Open ceremony`),kr.disabled=A(be),r(Ar,A(be)?`Recording…`:`Record approval`),Lr.disabled=n,r(Rr,A(Te)?`Signing…`:`Abort as subject`)},[()=>A(Oe)||!A(De).trim(),()=>A(ge)||!A(le)||!A(ue).trim()||!A(pe).trim()||!A(me).trim()||!A(he).trim(),()=>A(Te)||!A(Ce).trim()||!A(we)]),t(`submit`,$e,e=>(e.preventDefault(),Ae())),M(et,()=>A(De),e=>s(De,e)),t(`submit`,_t,e=>(e.preventDefault(),ze())),T(g,[],bt,()=>A(D),e=>s(D,e)),T(g,[],vn,()=>A(D),e=>s(D,e)),T(b,[],wn,()=>A(I),e=>s(I,e)),T(b,[],En,()=>A(I),e=>s(I,e)),T(S,[],jn,()=>A(G),e=>s(G,e)),T(S,[],Nn,()=>A(G),e=>s(G,e)),M(Ln,()=>A(U),e=>s(U,e)),R(Un,()=>A(re),e=>s(re,e)),t(`submit`,Qn,e=>(e.preventDefault(),Be())),ee(er,()=>A(ae),e=>s(ae,e)),M(tr,()=>A(oe),e=>s(oe,e)),R(rr,()=>A(se),e=>s(se,e)),t(`submit`,lr,e=>(e.preventDefault(),Ve())),ee(ur,()=>A(le),e=>s(le,e)),M(mr,()=>A(ue),e=>s(ue,e)),M(hr,()=>A(de),e=>s(de,e)),j(`click`,gr,ve),M(br,()=>A(pe),e=>s(pe,e)),M(xr,()=>A(me),e=>s(me,e)),M(Sr,()=>A(he),e=>s(he,e)),t(`submit`,Dr,e=>(e.preventDefault(),He())),M(Or,()=>A(ye),e=>s(ye,e)),t(`submit`,Nr,e=>(e.preventDefault(),Ue())),M(Pr,()=>A(Ce),e=>s(Ce,e)),R(Ir,()=>A(we),e=>s(we,e)),V(n,Ye),m(),p()}p([`click`]);var yn=a(``),bn=a(``),xn=a(`

            Send this to each person in the audience.

            `,1),Sn=a(`
          • `),Cn=a(`

              `,1),wn=a(`

              Shared files

              Share files from a vault

              Local operation · creates a non-recallable encrypted snapshot

              A share is a snapshot of these files at the vault's current epoch. It cannot + be recalled once handed over - editing the files afterward only affects future shares, not + this one.

              Recipients
              Advanced manual recipient identities

              Fetch a file someone shared with you

              Peer-dependent operation · no partial output is activated, so a failed fetch is safe to retry

              Advanced manual peer
              `);function Tn(n,i){e(i,!0);let a=()=>x(ie,`$status`,o),[o,f]=u(),p=[],g=d(y([]));Q.listVaults().then(e=>s(g,e.published,!0)).catch(()=>{});let b=d(``),S=d(``),C=d(``),E=d(y([])),D=d(!1),O=d(null),k=d(!1),N=d(!1);async function P(){if(!(!A(b)||!A(S).trim()||A(E).length===0&&!A(C).trim())){s(D,!0),s(O,null);try{let e=A(S).split(` +`).map(e=>e.trim()).filter(Boolean),t=A(C).split(`,`).map(e=>e.trim()).filter(Boolean),n=[...new Set([...A(E),...t])],r=await Q.discloseFiles(A(b),e,n);s(O,r.grant_hex,!0)}finally{s(D,!1)}}}async function te(){A(O)&&await Se(A(O))&&(s(k,!0),setTimeout(()=>s(k,!1),1200))}let I=d(``),L=d(``),H=d(``),U=d(``),W=d(``),G=d(!1),K=d(null);async function re(){let e=a()?.peers.find(e=>e.node===A(U));if(e&&(s(L,e.node,!0),s(H,e.addrs.join(`,`),!0)),!(!A(I).trim()||!A(L).trim()||!A(W).trim())){s(G,!0),s(K,null);try{let e=A(H).split(`,`).map(e=>e.trim()).filter(Boolean),t=await Q.fetchGrant(A(I).trim(),{node:A(L).trim(),addrs:e},A(W).trim());s(K,t.written,!0)}finally{s(G,!1)}}}var q=wn(),J=_(v(q),2),Y=_(v(J),6),X=_(v(Y),2),Z=v(X);Z.value=Z.__value=``,w(_(Z),17,()=>A(g),e=>e.vid,(e,t)=>{var n=yn(),i=v(n);F(n);var a={};l(()=>{r(i,`${A(t).name??``} (epoch ${A(t).epoch??``})`),a!==(a=A(t).vid)&&(n.value=(n.__value=A(t).vid)??``)}),V(e,n)}),F(X);var ae=_(X,4);ne(ae);var oe=_(ae,2);w(_(v(oe)),1,()=>a()?.peers??[],e=>e.node,(e,t)=>{var n=bn(),i=v(n);B(i);var a,o=_(i);F(n),l(e=>{a!==(a=A(t).user)&&(i.value=(i.__value=A(t).user)??``),r(o,` ${e??``}`)},[()=>A(t).display||`Friend ${A(t).user.slice(0,10)}…`]),T(p,[],i,()=>(A(t).user,A(E)),e=>s(E,e)),V(e,n)}),F(oe);var se=_(oe,2),$=_(v(se),2);B($),F(se);var ce=_(se,2),le=v(ce);B(le),z(),F(ce);var ue=_(ce,2),de=v(ue,!0);F(ue),F(Y);var fe=_(Y,2),pe=e=>{var t=xn(),n=c(t),i=v(n),a=v(i,!0);F(i);var o=_(i,2),s=v(o,!0);F(o),F(n),z(2),l(()=>{r(a,A(O)),r(s,A(k)?`Copied`:`Copy`)}),j(`click`,o,te),V(e,t)};h(fe,e=>{A(O)&&e(pe)}),F(J);var me=_(J,2),he=_(v(me),4),ge=_(v(he),2);B(ge);var _e=_(ge,3),ve=v(_e);ve.value=ve.__value=``,w(_(ve),1,()=>a()?.peers??[],e=>e.node,(e,t)=>{var n=yn(),i=v(n,!0);F(n);var a={};l(()=>{r(i,A(t).display||`Friend`),a!==(a=A(t).node)&&(n.value=(n.__value=A(t).node)??``)}),V(e,n)}),F(_e);var ye=_(_e,2),be=_(v(ye),2);B(be);var xe=_(be,2);B(xe),F(ye);var Ce=_(ye,4);B(Ce);var we=_(Ce,2),Te=v(we,!0);F(we),F(he);var Ee=_(he,2),De=e=>{var t=Cn(),n=c(t),i=v(n);F(n);var a=_(n,2);w(a,20,()=>A(K),e=>e,(e,t)=>{var n=Sn(),i=v(n,!0);F(n),l(()=>r(i,t)),V(e,n)}),F(a),l(()=>r(i,`Wrote ${A(K).length??``} file(s):`)),V(e,t)};h(Ee,e=>{A(K)&&e(De)}),F(me),F(q),l(e=>{ue.disabled=e,r(de,A(D)?`Sharing…`:`Share files`),we.disabled=A(G),r(Te,A(G)?`Fetching…`:`Fetch files`)},[()=>A(D)||!A(b)||!A(S).trim()||A(E).length===0&&!A(C).trim()||!A(N)]),t(`submit`,Y,e=>(e.preventDefault(),P())),ee(X,()=>A(b),e=>s(b,e)),M(ae,()=>A(S),e=>s(S,e)),M($,()=>A(C),e=>s(C,e)),R(le,()=>A(N),e=>s(N,e)),t(`submit`,he,e=>(e.preventDefault(),re())),M(ge,()=>A(I),e=>s(I,e)),ee(_e,()=>A(U),e=>s(U,e)),M(be,()=>A(L),e=>s(L,e)),M(xe,()=>A(H),e=>s(H,e)),M(Ce,()=>A(W),e=>s(W,e)),V(n,q),m(),f()}p([`click`]);var En=a(` `),Dn=a(`

              `),On=a(`

              `),kn=a(`
              Carapace
              `);function An(t,i){e(i,!0);let a=()=>x(ae,`$live`,p),c=()=>x(ue,`$sessionExpired`,p),f=()=>x(ie,`$status`,p),[p,g]=u(),b={"":`Overview`,vaults:`Vaults`,friends:`Friends`,recovery:`Recovery`,shared:`Shared files`};function C(){let e=typeof location<`u`?location.hash.replace(/^#\/?/,``):``;return e in b?e:``}let T=d(y(C()));function D(){s(T,C(),!0)}let O=d(null);function M(e){e?document.documentElement.setAttribute(`data-theme`,e):document.documentElement.removeAttribute(`data-theme`)}function N(){let e=typeof matchMedia<`u`&&matchMedia(`(prefers-color-scheme: light)`).matches,t=A(O)?A(O)===`light`:e;s(O,t?`dark`:`light`,!0),localStorage.setItem(`carapace-theme`,A(O)),M(A(O))}I(()=>{window.addEventListener(`hashchange`,D);let e=localStorage.getItem(`carapace-theme`);return(e===`dark`||e===`light`)&&(s(O,e,!0),M(A(O))),fe(),()=>window.removeEventListener(`hashchange`,D)}),P(()=>{pe()});var ee=kn();S(`1uha8ag`,e=>{o(()=>{n.title=`Carapace`})});var R=v(ee),z=_(v(R),2);w(z,21,()=>Object.entries(b),([e,t])=>e,(e,t)=>{var n=te(()=>L(A(t),2));let i=()=>A(n)[0],a=()=>A(n)[1];var o=En();let s;var c=v(o,!0);F(o),l(()=>{E(o,`href`,i()?`#/${i()}`:`#/`),s=k(o,1,`svelte-1uha8ag`,null,s,{active:A(T)===i()}),r(c,a())}),V(e,o)}),F(z);var B=_(z,2),H=v(B);let ne;var U=_(H,2),W=v(U,!0);F(U);var G=_(U,2),K=v(G,!0);F(G),F(B),F(R);var re=_(R,2),q=v(re),J=e=>{var t=Dn(),n=v(t,!0);F(t),l(()=>r(n,c()?`This session expired. Reload to reconnect.`:`Disconnected. Displaying the last known state while reconnecting.`)),V(e,t)};h(q,e=>{a()||e(J)});var Y=_(q,2);w(Y,1,()=>f()?.sync_errors??[],e=>e.node,(e,t)=>{var n=On(),i=v(n);F(n),l(()=>r(i,`Sync: ${A(t).error??``}`)),V(e,n)});var X=_(Y,2);he(X,{});var Z=_(X,2),Q=e=>{ct(e,{})},oe=e=>{xt(e,{})},se=e=>{vn(e,{})},$=e=>{Tn(e,{})},ce=e=>{Ne(e,{})};h(Z,e=>{A(T)===`vaults`?e(Q):A(T)===`friends`?e(oe,1):A(T)===`recovery`?e(se,2):A(T)===`shared`?e($,3):e(ce,-1)}),F(re),F(ee),l(()=>{ne=k(H,1,`live-dot svelte-1uha8ag`,null,ne,{live:a()}),E(H,`title`,a()?`Live updates connected`:`Reconnecting…`),r(W,a()?`Live`:`Reconnecting…`),r(K,A(O)===`light`?`Molt (light)`:`Dark`)}),j(`click`,G,N),V(t,ee),m(),g()}p([`click`]);export{An as component}; \ No newline at end of file diff --git a/crates/carapace-api/static/_app/immutable/nodes/2.CaWSwQlO.js b/crates/carapace-api/static/_app/immutable/nodes/2.CaWSwQlO.js deleted file mode 100644 index 10fcd26..0000000 --- a/crates/carapace-api/static/_app/immutable/nodes/2.CaWSwQlO.js +++ /dev/null @@ -1,25 +0,0 @@ -import{$ as e,A as t,B as n,C as r,D as i,E as a,F as o,G as s,H as c,I as l,J as u,K as d,L as f,O as p,Q as m,S as h,T as g,U as _,V as v,W as y,X as b,Y as ee,_ as x,a as S,b as C,c as w,d as T,f as E,g as D,h as O,j as k,k as A,l as j,m as M,n as te,nt as N,p as P,q as ne,r as F,rt as I,s as L,tt as R,u as z,w as B,x as V,z as re}from"../chunks/D7CCBGlz.js";import"../chunks/CCeg2KC3.js";import"../chunks/xihTtKlq.js";function H(){return typeof window<`u`&&window.__CARAPACE_TOKEN__?window.__CARAPACE_TOKEN__:``}var U=b(null);function W(e){U.set(e)}function ie(){U.set(null)}var ae=``,G=class extends Error{status;constructor(e,t){super(e),this.status=t}};async function K(e,t){let n;try{n=await fetch(ae+e,{...t,headers:{...t?.body?{"Content-Type":`application/json`}:{},Authorization:`Bearer ${H()}`,...t?.headers}})}catch(t){let n=`Could not reach the daemon (${e}). Is it running? (${t.message})`;throw W(n),new G(n)}if(n.status===401||n.status===403){let e=n.status===401?`The daemon rejected this session (bad or missing token). Reload the page.`:`The daemon refused this request (Host/Origin guard). Reload the page.`;throw W(e),new G(e,n.status)}if(!n.ok){let t=n.statusText;try{let e=await n.json();typeof e?.error==`string`&&(t=e.error)}catch{}throw W(`${e} failed: ${t}`),new G(t,n.status)}return await n.json()}async function q(e,t){let n;try{n=await fetch(ae+e,{...t,headers:{Authorization:`Bearer ${H()}`,...t?.headers}})}catch(t){let n=`Could not reach the daemon (${e}). Is it running? (${t.message})`;throw W(n),new G(n)}if(n.status===401||n.status===403){let e=n.status===401?`The daemon rejected this session (bad or missing token). Reload the page.`:`The daemon refused this request (Host/Origin guard). Reload the page.`;throw W(e),new G(e,n.status)}if(!n.ok){let t=n.statusText;try{let e=await n.json();typeof e?.error==`string`&&(t=e.error)}catch{}throw W(`${e} failed: ${t}`),new G(t,n.status)}return n.text()}var J=e=>K(e),Y=(e,t)=>K(e,{method:`POST`,body:t===void 0?void 0:JSON.stringify(t)}),X={health:()=>J(`/api/health`),status:()=>J(`/api/status`),listVaults:()=>J(`/api/vaults`),publishVault:(e,t)=>Y(`/api/vaults`,{dir:e,vid:t}),listReplicas:e=>J(`/api/vaults/${e}/replicas`),placeReplicas:(e,t,n)=>Y(`/api/vaults/${e}/replicas`,{peers:t,r:n}),discloseFiles:(e,t,n)=>Y(`/api/vaults/${e}/grants`,{paths:t,audience:n}),fetchGrant:(e,t,n)=>Y(`/api/grants/fetch`,{grant_hex:e,owner:t,out_dir:n}),listFriends:()=>J(`/api/friends`),issueTicket:()=>Y(`/api/friends/ticket`),addFriend:(e,t,n)=>Y(`/api/friends`,{ticket_hex:e,addrs:t,grant_bytes:n}),unfriend:e=>Y(`/api/friends/${e}/unfriend`),resplitStatus:e=>J(`/api/recovery/${e}/resplit-status`),paperCards:e=>q(`/api/recovery/${e}/paper`),resplitStart:(e,t)=>Y(`/api/recovery/${e}/resplit-start`,t?{trustees:t}:void 0),recoverySplit:(e,t,n,r,i)=>Y(`/api/recovery/split`,{rsid:e,scope:t,m:n,n:r,allow_over_cap:i}),recoveryResplit:(e,t,n,r,i)=>Y(`/api/recovery/resplit`,{rsid:e,scope:t,m:n,n:r,allow_over_cap:i}),recoveryExtend:(e,t,n)=>Y(`/api/recovery/extend`,{rsid:e,count:t,allow_over_cap:n}),ceremonyOpen:e=>Y(`/api/recovery/ceremony/open`,e),ceremonyApprove:e=>Y(`/api/recovery/ceremony/approve`,{ceremony_id:e}),ceremonyAbort:e=>Y(`/api/recovery/ceremony/abort`,{ceremony_id:e})},oe=b(null),se=b(!1),Z=null,Q=1e3;function $(){if(typeof window>`u`)return;let e=`${location.protocol===`https:`?`wss:`:`ws:`}//${location.host}/api/events?token=${encodeURIComponent(H())}`;Z=new WebSocket(e),Z.onopen=()=>{se.set(!0),Q=1e3},Z.onmessage=e=>{try{oe.set(JSON.parse(e.data))}catch{}},Z.onclose=()=>{se.set(!1),setTimeout($,Q),Q=Math.min(Q*2,15e3)},Z.onerror=()=>{Z?.close()}}function ce(){X.status().then(e=>oe.set(e)).catch(()=>{}),$()}function le(){Z?.close(),Z=null}var ue=a(``);function de(e){let t=()=>ee(U,`$lastError`,n),[n,i]=u();var a=g(),o=c(a),s=e=>{var n=ue(),i=v(n),a=v(i,!0);N(i);var o=_(i,2);N(n),l(()=>r(a,t())),A(`click`,o,function(...e){ie?.apply(this,e)}),B(e,n)};h(o,e=>{t()&&e(s)}),B(e,a),i()}p([`click`]);var fe=`carapace-gui-notes-v1`;function pe(){if(typeof localStorage>`u`)return{friendStorageGrants:{},recoverySets:{}};try{let e=localStorage.getItem(fe);return e?JSON.parse(e):{friendStorageGrants:{},recoverySets:{}}}catch{return{friendStorageGrants:{},recoverySets:{}}}}var me=b(pe());me.subscribe(e=>{typeof localStorage>`u`||localStorage.setItem(fe,JSON.stringify(e))});function he(e,t){me.update(n=>({...n,friendStorageGrants:{...n.friendStorageGrants,[e]:t}}))}function ge(e){me.update(t=>({...t,recoverySets:{...t.recoverySets,[String(e.rsid)]:e}}))}var _e=a(``),ve=a(`
              `),ye=a(`
              `);function be(e,t){function n(e){let t=Math.max(e.target,e.achieved,1);return Array.from({length:t},(t,n)=>({filled:nt.plates,e=>e.key,(e,t,i)=>{var a=ve(),o=v(a);C(o,21,()=>n(k(t)),V,(e,t)=>{var n=_e();let r;l(()=>r=O(n,1,`segment svelte-v2pnom`,null,r,{filled:k(t).filled})),B(e,n)}),N(o);var s=_(o,2),c=v(s,!0);N(s);var u=_(s,2),d=v(u,!0);N(u);var f=_(u,2),p=v(f,!0);N(f),N(a),l(()=>{O(a,1,`plate-group state-${k(t).state??``}`,`svelte-v2pnom`),M(a,`--i: ${k(i)??``}`),r(c,k(t).label),r(d,k(t).valueLabel),r(p,k(t).note)}),B(e,a)}),N(i),B(e,i)}function xe(e,t=8,n=6){return e.length<=t+n+1?e:`${e.slice(0,t)}…${e.slice(-n)}`}async function Se(e){try{return await navigator.clipboard.writeText(e),!0}catch{return!1}}var Ce=[`B`,`KiB`,`MiB`,`GiB`,`TiB`];function we(e){if(!Number.isFinite(e)||e<0)return`—`;if(e===0)return`0 B`;let t=Math.min(Math.floor(Math.log2(e)/10),Ce.length-1),n=e/2**(10*t);return`${n>=10||t===0?Math.round(n):n.toFixed(1)} ${Ce[t]}`}var Te=a(``);function Ee(t,n){e(n,!0);let i=S(n,`head`,3,8),a=S(n,`tail`,3,6),o=d(!1);async function c(){await Se(n.value)&&(s(o,!0),setTimeout(()=>s(o,!1),1200))}var u=Te(),f=v(u),p=_(f),h=v(p,!0);N(p),N(u),l(e=>{T(u,`title`,n.value),T(u,`aria-label`,`Copy ${n.value??``}`),r(f,`${e??``} `),r(h,k(o)?`copied`:`copy`)},[()=>xe(n.value,i(),a())]),A(`click`,u,c),B(t,u),m()}p([`click`]);var De=a(`
              This node
              Friends storing your vaults
              Vaults published
              `,1),Oe=a(`

              Waiting for the daemon…

              `),ke=a(`

              Shell integrity

              `);function Ae(t,n){e(n,!0);let i=()=>ee(oe,`$status`,o),a=()=>ee(me,`$notes`,o),[o,p]=u(),g=d(null);f(()=>{let e=i()?.vaults.published??[];if(e.length===0){s(g,null);return}Promise.all(e.map(e=>X.listReplicas(e.vid).catch(()=>({members:[]})))).then(e=>{s(g,Math.min(...e.map(e=>e.members.length)),!0)}).catch(()=>{s(g,null)})});let y=ne(()=>{let e=i();if(!e)return[];let t=e.vaults.published.length,n=k(g)??0,r={key:`replicas`,label:`Replicas`,achieved:t===0?0:Math.min(n,3),target:3,valueLabel:t===0?`—`:`${n}/3`,state:t===0?`empty`:n>=3?`healthy`:`at-risk`,note:t===0?`No vaults published yet`:`Weakest vault held by ${n} friend${n===1?``:`s`}`},o=e.share_health.recovery_sets_owned,s=Object.values(a().recoverySets).find(e=>e.scope.kind===`root`),c={key:`shares`,label:`Recovery shares`,achieved:+(o>0),target:1,valueLabel:s?`${s.m}-of-${s.n}`:o>0?`split`:`—`,state:o>0?`healthy`:`empty`,note:o>0?`${e.share_health.shares_held} share${e.share_health.shares_held===1?``:`s`} held here in trust for others`:`Your key has no trustees yet - nobody could rebuild it`},l=e.addr.length,u=e.relay_networks,d=e.relay_diversity_warning||u<2;return[r,c,{key:`relays`,label:`Reachability`,achieved:Math.min(u,2),target:2,valueLabel:`${u}`,state:l===0?`empty`:d?`at-risk`:`healthy`,note:d?`Only ${u} relay network${u===1?``:`s`} - add a friend's relay so you can still be reached if one drops`:`${e.reachability} · ${u} relay networks, ${l} dialable address${l===1?``:`es`}`}]});var b=ke(),x=_(v(b),2),S=e=>{var t=De(),n=c(t);be(n,{get plates(){return k(y)}});var a=_(n,2),o=v(a);Ee(_(v(o),2),{get value(){return i().node_id},head:12,tail:8}),N(o);var s=_(o,2),u=_(v(s),2),d=v(u,!0);N(u),N(s);var f=_(s,2),p=_(v(f),2),m=v(p,!0);N(p),N(f),N(a),R(2),l(()=>{r(d,i().friends.count),r(m,i().vaults.published.length)}),B(e,t)},C=e=>{B(e,Oe())};h(x,e=>{i()?e(S):e(C,-1)}),N(b),B(t,b),m(),p()}var je=a(`

              Loading vaults…

              `),Me=a(`

              No vaults published yet. Publish a directory above to start protecting it.

              `),Ne=a(`
            • `),Pe=a(`
                `),Fe=a(`no replicas placed - vault exists only here`),Ie=a(`
                Replica members
                `),Le=a(`
                `),Re=a(``),ze=a(`
                `),Be=a(`

                `),Ve=a(`

                Place replicas for

                The daemon doesn't remember a friend's network address for you - list each peer's node - id and dialable address(es) again here.

                `),He=a(`

                Vaults

                A vault is a directory you've published for friends to hold replicas of. Publishing ingests - and encrypts it locally; placing replicas is what actually copies it out.

                `);function Ue(n,i){e(i,!0);let a=()=>ee(oe,`$status`,o),[o,c]=u(),p=d(y([])),g=d(y({})),b=d(!0),x=d(``),S=d(!1),w=d(null),T=d(3),E=d(y([{node:``,addrs:``}])),D=d(!1),O=d(null);async function M(){s(b,!0);let e=await X.listVaults().catch(()=>({published:[]}));s(p,e.published,!0);let t=await Promise.all(k(p).map(async e=>[e.vid,(await X.listReplicas(e.vid).catch(()=>({members:[]}))).members]));s(g,Object.fromEntries(t),!0),s(b,!1)}M(),f(()=>{a()?.vaults.published.length,M()});async function te(){if(k(x).trim()){s(S,!0);try{await X.publishVault(k(x).trim()),s(x,``),await M()}finally{s(S,!1)}}}function P(e){s(w,e,!0),s(O,null),s(E,[{node:``,addrs:``}],!0)}function ne(){s(E,[...k(E),{node:``,addrs:``}],!0)}function F(e){s(E,k(E).filter((t,n)=>n!==e),!0)}async function I(){if(k(w)){s(D,!0);try{let e=k(E).filter(e=>e.node.trim()).map(e=>({node:e.node.trim(),addrs:e.addrs.split(`,`).map(e=>e.trim()).filter(Boolean)})),t=await X.placeReplicas(k(w),e,k(T));s(O,t.placed,!0),await M()}finally{s(D,!1)}}}var L=He(),R=_(v(L),4),re=_(v(R),2),H=v(re);z(H);var U=_(H,2),W=v(U,!0);N(U),N(re),N(R);var ie=_(R,2),ae=e=>{B(e,je())},G=e=>{B(e,Me())},K=e=>{var t=Le();C(t,21,()=>k(p),e=>e.vid,(e,t)=>{var n=Ie(),i=v(n),a=v(i);Ee(a,{get value(){return k(t).vid}});var o=_(a,2),s=v(o);N(o),N(i);var c=_(i,2),u=_(v(c),2),d=e=>{var n=Pe();C(n,20,()=>k(g)[k(t).vid],e=>e,(e,t)=>{var n=Ne();Ee(v(n),{get value(){return t}}),N(n),B(e,n)}),N(n),B(e,n)},f=e=>{B(e,Fe())};h(u,e=>{k(g)[k(t).vid]?.length?e(d):e(f,-1)}),N(c);var p=_(c,2);N(n),l(()=>r(s,`epoch ${k(t).epoch??``}`)),A(`click`,p,()=>P(k(t).vid)),B(e,n)}),N(t),B(e,t)};h(ie,e=>{k(b)?e(ae):k(p).length===0?e(G,1):e(K,-1)});var q=_(ie,2),J=e=>{var t=Ve(),n=v(t),i=_(v(n)),a=v(i);N(i),N(n);var o=_(n,4);C(o,17,()=>k(E),V,(e,t,n)=>{var r=ze(),i=v(r);z(i);var a=_(i,2);z(a);var o=_(a,2),s=e=>{var t=Re();A(`click`,t,()=>F(n)),B(e,t)};h(o,e=>{k(E).length>1&&e(s)}),N(r),j(i,()=>k(t).node,e=>k(t).node=e),j(a,()=>k(t).addrs,e=>k(t).addrs=e),B(e,r)});var c=_(o,2),u=_(c,2),d=_(v(u),2);z(d);var f=_(d,2),p=v(f,!0);N(f);var m=_(f,2);N(u);var g=_(u,2),y=e=>{var t=Be(),n=v(t);N(t),l(()=>r(n,`Placed on ${k(O).length??``} peer${k(O).length===1?``:`s`}.`)),B(e,t)};h(g,e=>{k(O)&&e(y)}),N(t),l(e=>{r(a,`${e??``}…`),f.disabled=k(D),r(p,k(D)?`Placing…`:`Place`)},[()=>k(w).slice(0,12)]),A(`click`,c,ne),j(d,()=>k(T),e=>s(T,e)),A(`click`,f,I),A(`click`,m,()=>s(w,null)),B(e,t)};h(q,e=>{k(w)&&e(J)}),N(L),l(e=>{U.disabled=e,r(W,k(S)?`Publishing…`:`Publish vault`)},[()=>k(S)||!k(x).trim()]),t(`submit`,R,e=>(e.preventDefault(),te())),j(H,()=>k(x),e=>s(x,e)),B(n,L),m(),c()}p([`click`]);var We=a(`
                `),Ge=a(`

                `),Ke=a(`

                Re-split required

                Recovery & trustees.

                `,1),qe=a(`

                `),Je=a(`
                `),Ye=a(`

                Loading…

                `),Xe=a(`

                No friends yet. Issue an invite ticket to add your first one.

                `),Ze=a(`Remove this friend? `),Qe=a(``),$e=a(`
                `),et=a(`
                `),tt=a(`

                Friends

                Friends hold encrypted replicas of your vaults and, if you make them trustees, pieces of your - recovery key.

                Invite a friend

                Add a friend from a ticket

                Your friends

                The daemon doesn't yet report a friend's storage/trustee/relay role or agreed limit back to the - GUI - the figures above are only what this browser set when adding the friend.

                `);function nt(n,a){e(a,!0);let o=()=>ee(oe,`$status`,g),p=()=>ee(me,`$notes`,g),[g,b]=u(),x=d(y([])),S=d(!0),w=d(null),T=d(!1),D=d(``),M=d(``),te=d(1),P=d(!1),ne=d(null),F=d(!1),I=d(null),L=d(null),V=d(null);async function re(){s(S,!0);let e=await X.listFriends().catch(()=>({count:0,list:[]}));s(x,e.list,!0),s(S,!1)}re(),f(()=>{o()?.friends.count,re()});async function H(){s(T,!0);try{let e=await X.issueTicket();s(w,e,!0)}finally{s(T,!1)}}async function U(){k(w)&&await Se(k(w).uri)&&(s(F,!0),setTimeout(()=>s(F,!1),1200))}async function W(){if(k(D).trim()){s(P,!0),s(ne,null);try{let e=k(M).split(`,`).map(e=>e.trim()).filter(Boolean),t=Math.round(k(te)*1024**3),n=await X.addFriend(k(D).trim(),e.length?e:void 0,t);he(n.friend,t),s(ne,`Friend added (${n.friend.slice(0,12)}…).`),s(D,``),s(M,``),await re()}finally{s(P,!1)}}}async function ie(e){s(L,e,!0);try{let t=await X.unfriend(e);s(I,null),t.was_friend&&s(V,{friend:e,resplit:t.resplit_triggered,rsids:t.recovery_set_ids},!0),await re()}finally{s(L,null)}}var ae=tt(),G=_(v(ae),4),K=v(G),q=_(v(K),2),J=v(q,!0);N(q);var Y=_(q,2),se=e=>{var t=We(),n=_(v(t),2),i=v(n);z(i);var a=_(i,2),o=v(a,!0);N(a),N(n),N(t),l(()=>{E(i,k(w).uri),r(o,k(F)?`Copied`:`Copy`)}),A(`click`,a,U),B(e,t)};h(Y,e=>{k(w)&&e(se)}),N(K);var Z=_(K,2),Q=_(v(Z),2),$=_(v(Q),2);z($);var ce=_($,4);z(ce);var le=_(ce,4);z(le);var ue=_(le,2),de=v(ue,!0);N(ue),N(Q);var fe=_(Q,2),pe=e=>{var t=Ge(),n=v(t,!0);N(t),l(()=>r(n,k(ne))),B(e,t)};h(fe,e=>{k(ne)&&e(pe)}),N(Z),N(G);var ge=_(G,2),_e=e=>{var t=Je(),n=v(t),i=e=>{var t=Ke(),n=_(c(t),2),i=v(n);R(2),N(n),l((e,t)=>r(i,`${e??``}… was a trustee. A trustee re-split is now running for - recovery set${k(V).rsids.length>1?`s`:``} - ${t??``}. Both the old and new sets stay usable until the new set is - live and the old shares are destroyed - track it under `),[()=>k(V).friend.slice(0,12),()=>k(V).rsids.join(`, `)]),B(e,t)},a=e=>{var t=qe(),n=v(t);N(t),l(e=>r(n,`${e??``}… removed. They held no recovery shares, so no re-split - was needed.`),[()=>k(V).friend.slice(0,12)]),B(e,t)};h(n,e=>{k(V).resplit?e(i):e(a,-1)});var o=_(n,2);N(t),l(()=>O(t,1,`card ${k(V).resplit?`at-risk`:`healthy`}`)),A(`click`,o,()=>s(V,null)),B(e,t)};h(ge,e=>{k(V)&&e(_e)});var ve=_(ge,4),ye=e=>{B(e,Ye())},be=e=>{B(e,Xe())},xe=e=>{var t=et();C(t,20,()=>k(x),e=>e,(e,t)=>{var n=$e(),a=v(n);Ee(a,{get value(){return t}});var o=_(a,2),c=v(o),u=e=>{var n=i();l(e=>r(n,`≈${e??``} agreed (recorded in this browser)`),[()=>we(p().friendStorageGrants[t])]),B(e,n)},d=e=>{B(e,i(`storage limit not recorded here`))};h(c,e=>{p().friendStorageGrants[t]===void 0?e(d,-1):e(u)}),N(o);var f=_(o,2),m=e=>{var n=Ze(),i=_(v(n),2),a=v(i,!0);N(i);var o=_(i,2);N(n),l(()=>{i.disabled=k(L)===t,r(a,k(L)===t?`Removing…`:`Confirm unfriend`),o.disabled=k(L)===t}),A(`click`,i,()=>ie(t)),A(`click`,o,()=>s(I,null)),B(e,n)},g=e=>{var n=Qe();A(`click`,n,()=>s(I,t,!0)),B(e,n)};h(f,e=>{k(I)===t?e(m):e(g,-1)}),N(n),B(e,n)}),N(t),B(e,t)};h(ve,e=>{k(S)?e(ye):k(x).length===0?e(be,1):e(xe,-1)}),R(2),N(ae),l(e=>{q.disabled=k(T),r(J,k(T)?`Issuing…`:`Create invite ticket`),ue.disabled=e,r(de,k(P)?`Adding…`:`Add friend`)},[()=>k(P)||!k(D).trim()]),A(`click`,q,H),t(`submit`,Q,e=>(e.preventDefault(),W())),j($,()=>k(D),e=>s(D,e)),j(ce,()=>k(M),e=>s(M,e)),j(le,()=>k(te),e=>s(te,e)),B(n,ae),m(),b()}p([`click`]);var rt=a(`
                Live on this node

                `),it=a(`
                `),at=a(`

                Paper cards (offline backstop)

                Print a paper card for each recovery set (§8, §10.2). A card recovers from its words - alone - offline, with no Carapace software - so it is the backstop that never goes - offline. The card shows a share's secret words; print it, then keep or destroy the copy.

                `,1),ot=a(`
                `),st=a(`
                re-split required

                was a trustee of this recovery set and - was unfriended. Their retained share must be neutralized by re-splitting to a fresh set.

                Suggested new trustee set - live reachability

                `),ct=a(`

                Re-split required

                An unfriended trustee still held a share of the recovery set below (§9.3.4). Start the - re-split to hand a fresh share to a new trustee set - the old shares are only destroyed - once that new set is live.

                `,1),lt=a(`· live`),ut=a(`· not live yet`),dt=a(`· destroy refused until new set is live`),ft=a(`
                `),pt=a(`

                New set attested (destroy gate: M + slack)

                Old shares destroyed (ack)

                Remaining friends - live reachability
                `),mt=a(`

                Trustee re-splits in progress

                An unfriended trustee's share is being neutralized (§9.3 step 4). Both the old and new - recovery sets stay usable until the new set is live and the old shares are destroyed - - neither door closes early.

                `,1),ht=a(`
                `),gt=a(`

                Recovery sets split from this browser

                `,1),_t=a(``),vt=a(`

                `),yt=a(`
                `),bt=a(``),xt=a(`

                Shares - send one to each trustee

                `),St=a(``),Ct=a(`
                `),wt=a(`

                Signed open - hand to the claimant
                `,1),Tt=a(`

                `,1),Et=a(`

                Send this abort to your trustees.

                `,1),Dt=a(`

                Recovery & trustees

                Split your key into pieces so a group of trustees can rebuild it if you lose access. - Each share below is a bearer secret: send one to each trustee yourself - the daemon - doesn't track who you gave it to.

                Split or re-split

                Add a trustee (extend)

                Recovery ceremony

                A ceremony is opened by a signed request a trustee receives out of band (from the person - recovering, or the daemon that observed them). The recovery delay and required approvals are - enforced by the daemon per the grant that authorized the ceremony; paste the pieces below as - they arrive.

                Open (sponsor)

                Approve

                Abort

                `);function Ot(n,a){e(a,!0);let o=()=>ee(me,`$notes`,p),f=()=>ee(oe,`$status`,p),[p,g]=u(),b=[],x=[],S=d(`split`),E=d(1),M=d(`root`),te=d(``),P=d(2),F=d(3),I=d(!1),re=d(!1),H=d(null),U=d(y([])),W=d(1),ie=d(1),ae=d(!1),G=d(!1),K=d(null),q=d(``),J=d(``),Y=d(``),se=d(``),Z=d(``),Q=d(!1),$=d(null),ce=d(``),le=d(!1),ue=d(null),de=d(``),fe=d(!1),pe=d(null);function he(e){switch(e){case`awaiting_new_set`:return`Standing up new set`;case`ready_to_destroy`:return`New set live - destroying old shares`;case`complete`:return`Complete`;default:return e}}let _e=d(null);function ve(e){return e.filter(e=>e.online).length}async function ye(e){s(_e,e,!0);try{await X.resplitStart(e)}finally{s(_e,null)}}let be=d(null);async function xe(e){s(be,e,!0);try{let t=await X.paperCards(e),n=URL.createObjectURL(new Blob([t],{type:`text/html`}));window.open(n,`_blank`,`noopener`),setTimeout(()=>URL.revokeObjectURL(n),6e4)}finally{s(be,null)}}async function Ce(e,t){await Se(e)&&(t(!0),setTimeout(()=>t(!1),1200))}async function we(){s(re,!0),s(H,null),s(U,[],!0);try{let e=k(M)===`root`?{kind:`root`}:{kind:`vault`,vid:k(te).trim()},t=await(k(S)===`split`?X.recoverySplit:X.recoveryResplit)(k(E),e,k(P),k(F),k(I));s(H,t.shares,!0),s(U,t.warnings,!0),ge({rsid:k(E),scope:e,m:k(P),n:k(F),createdAt:Date.now()})}finally{s(re,!1)}}async function Te(){s(G,!0),s(K,null);try{let e=await X.recoveryExtend(k(W),k(ie),k(ae));s(K,e.shares,!0);let t=o().recoverySets[String(k(W))];t&&ge({...t,n:t.n+k(ie)})}finally{s(G,!1)}}async function Ee(){s(Q,!0),s($,null);try{s($,await X.ceremonyOpen({subject:k(q).trim(),claimant_display:k(J).trim(),ceremony_enc:k(Y).trim(),new_node:k(se).trim(),reason:k(Z).trim()}),!0)}finally{s(Q,!1)}}async function De(){s(le,!0),s(ue,null);try{s(ue,await X.ceremonyApprove(k(ce).trim()),!0)}finally{s(le,!1)}}async function Oe(){s(fe,!0),s(pe,null);try{s(pe,(await X.ceremonyAbort(k(de).trim())).abort_hex,!0)}finally{s(fe,!1)}}let ke=d(y({})),Ae=d(!1),je=d(!1);var Me=Dt(),Ne=_(v(Me),4),Pe=e=>{var t=rt(),n=_(v(t),2),i=v(n);N(n),N(t),l(()=>r(i,`${f().share_health.recovery_sets_owned??``} recovery set(s) split · - ${f().share_health.shares_held??``} share(s) held here in trust for others`)),B(e,t)};h(Ne,e=>{f()&&e(Pe)});var Fe=_(Ne,2),Ie=e=>{var t=at(),n=_(c(t),4);C(n,5,()=>f().recovery_grants.minted,e=>e.rsid,(e,t)=>{var n=it(),i=v(n),a=v(i);N(i);var o=_(i,2),s=v(o);N(o);var c=_(o,2),u=v(c,!0);N(c),N(n),l(()=>{r(a,`rsid ${k(t).rsid??``}`),r(s,`${k(t).trustees.length??``} share(s)`),c.disabled=k(be)===k(t).rsid,r(u,k(be)===k(t).rsid?`Opening…`:`Print / export paper cards`)}),A(`click`,c,()=>xe(k(t).rsid)),B(e,n)}),N(n),B(e,t)};h(Fe,e=>{f()?.recovery_grants?.minted?.length&&e(Ie)});var Le=_(Fe,2),Re=e=>{var t=ct();C(_(c(t),4),1,()=>f().pending_resplits,e=>e.old_rsid,(e,t)=>{var n=st(),a=v(n),o=v(a),s=v(o);N(o),R(2),N(a);var c=_(a,2),u=v(c),d=v(u);N(u),R(),N(c);var f=_(c,4);C(f,21,()=>k(t).suggested,e=>e.user,(e,t)=>{var n=ot(),i=v(n),a=_(i,2),o=v(a);N(a);var s=_(a,2),c=v(s,!0);N(s),N(n),l(e=>{O(i,1,`dot ${k(t).online?`online`:`offline`}`,`svelte-imw02`),T(i,`title`,k(t).online?`online`:`offline`),r(o,`${e??``}…`),r(c,k(t).online?`online`:`offline`)},[()=>k(t).user.slice(0,12)]),B(e,n)}),N(f);var p=_(f,2),m=v(p),g=_(m),y=e=>{B(e,i(`will complete immediately once started.`))},b=ne(()=>ve(k(t).suggested)===k(t).suggested.length&&k(t).suggested.length>0),ee=e=>{B(e,i(`will complete progressively as offline trustees come online.`))};h(g,e=>{k(b)?e(y):e(ee,-1)}),N(p);var x=_(p,2),S=v(x,!0);N(x),N(n),l((e,n,i)=>{r(s,`rsid ${k(t).old_rsid??``}`),r(d,`${e??``}…`),O(p,1,n),r(m,`${i??``} / ${k(t).suggested.length??``} suggested trustee(s) online - `),x.disabled=k(_e)===k(t).old_rsid,r(S,k(_e)===k(t).old_rsid?`Starting…`:`Start re-split (use suggested set)`)},[()=>k(t).ex_trustee.slice(0,12),()=>D(ve(k(t).suggested)===k(t).suggested.length&&k(t).suggested.length>0?`healthy`:`muted`),()=>ve(k(t).suggested)]),A(`click`,x,()=>ye(k(t).old_rsid)),B(e,n)}),B(e,t)};h(Le,e=>{f()?.pending_resplits?.length&&e(Re)});var ze=_(Le,2),Be=e=>{var t=mt();C(_(c(t),4),1,()=>f().resplits,e=>e.old_rsid,(e,t)=>{var n=pt(),a=v(n),o=v(a),s=v(o);N(o);var c=_(o,2),u=v(c,!0);N(c),N(a);var d=_(a,2),f=v(d);N(d);var p=_(d,2),m=v(p),g=_(v(m),2),y=v(g),b=_(y),ee=e=>{B(e,lt())},x=e=>{B(e,ut())};h(b,e=>{k(t).new_set_live?e(ee):e(x,-1)}),N(g),N(m);var S=_(m,2),w=_(v(S),2),E=v(w),D=_(E),A=e=>{B(e,dt())};h(D,e=>{k(t).new_set_live||e(A)}),N(w),N(S),N(p);var j=_(p,4);C(j,21,()=>k(t).remaining,e=>e.node,(e,t)=>{var n=ft(),a=v(n),o=_(a,2),s=v(o);N(o);var c=_(o,2),u=v(c,!0);N(c);var d=_(c,2),f=v(d),p=e=>{B(e,i(`done`))},m=e=>{var n=i();l(()=>r(n,`online - ${k(t).role===`new`?`sending share`:`sending destroy`}`)),B(e,n)},g=e=>{B(e,i(`offline - queued`))};h(f,e=>{k(t).done?e(p):k(t).online?e(m,1):e(g,-1)}),N(d),N(n),l(e=>{O(a,1,`dot ${k(t).status??``}`,`svelte-imw02`),T(a,`title`,k(t).status),r(s,`${e??``}…`),O(c,1,`role ${k(t).role??``}`,`svelte-imw02`),r(u,k(t).role===`new`?`gets new share`:`gets destroy step`)},[()=>k(t).node.slice(0,12)]),B(e,n)}),N(j),N(n),l((e,n)=>{r(s,`rsid ${k(t).old_rsid??``} → ${k(t).new_rsid??``}`),O(c,1,`phase ${k(t).phase??``}`,`svelte-imw02`),r(u,e),r(f,`ex-trustee ${n??``}…`),r(y,`${k(t).new_attested??``} / ${k(t).new_total??``} `),r(E,`${k(t).old_destroyed??``} / ${k(t).old_total??``} `)},[()=>he(k(t).phase),()=>k(t).ex_trustee.slice(0,12)]),B(e,n)}),B(e,t)};h(ze,e=>{f()?.resplits?.length&&e(Be)});var Ve=_(ze,2),He=e=>{var t=gt(),n=_(c(t),2);C(n,5,()=>Object.values(o().recoverySets),e=>e.rsid,(e,t)=>{var n=ht(),i=v(n),a=v(i);N(i);var o=_(i,2),s=v(o,!0);N(o);var c=_(o,2),u=v(c);N(c),N(n),l(e=>{r(a,`rsid ${k(t).rsid??``}`),r(s,e),r(u,`${k(t).m??``}-of-${k(t).n??``}`)},[()=>k(t).scope.kind===`root`?`your root key`:`vault ${k(t).scope.vid.slice(0,10)}…`]),B(e,n)}),N(n),B(e,t)},Ue=ne(()=>Object.keys(o().recoverySets).length>0);h(Ve,e=>{k(Ue)&&e(He)});var We=_(Ve,4),Ge=v(We),Ke=v(Ge),qe=v(Ke);z(qe),qe.value=qe.__value=`split`,R(),N(Ke);var Je=_(Ke,2),Ye=v(Je);z(Ye),Ye.value=Ye.__value=`resplit`,R(),N(Je),N(Ge);var Xe=_(Ge,2),Ze=_(v(Xe),2);z(Ze),N(Xe);var Qe=_(Xe,2),$e=v(Qe),et=v($e);z(et),et.value=et.__value=`root`,R(),N($e);var tt=_($e,2),nt=v(tt);z(nt),nt.value=nt.__value=`vault`,R(),N(tt);var Ot=_(tt,2),kt=e=>{var t=_t();z(t),j(t,()=>k(te),e=>s(te,e)),B(e,t)};h(Ot,e=>{k(M)===`vault`&&e(kt)}),N(Qe);var At=_(Qe,2),jt=_(v(At),2);z(jt);var Mt=_(jt,4);z(Mt),N(At);var Nt=_(At,2),Pt=v(Nt),Ft=v(Pt);z(Ft),R(),N(Pt),N(Nt);var It=_(Nt,2),Lt=v(It,!0);N(It),N(We);var Rt=_(We,2),zt=e=>{var t=yt();C(t,20,()=>k(U),e=>e,(e,t)=>{var n=vt(),i=v(n,!0);N(n),l(()=>r(i,t)),B(e,n)}),N(t),B(e,t)};h(Rt,e=>{k(U).length&&e(zt)});var Bt=_(Rt,2),Vt=e=>{var t=xt();C(_(v(t),2),17,()=>k(H),V,(e,t,n)=>{var i=bt(),a=v(i),o=v(a,!0);N(a);var c=_(a,2),u=v(c,!0);N(c),N(i),l(()=>{r(o,k(t)),r(u,k(ke)[n]?`Copied`:`Copy`)}),A(`click`,c,()=>Ce(k(t),e=>s(ke,{...k(ke),[n]:e},!0))),B(e,i)}),N(t),B(e,t)};h(Bt,e=>{k(H)&&e(Vt)});var Ht=_(Bt,4),Ut=v(Ht),Wt=_(v(Ut),2);z(Wt);var Gt=_(Wt,4);z(Gt);var Kt=_(Gt,2),qt=v(Kt);z(qt),R(),N(Kt),N(Ut);var Jt=_(Ut,2),Yt=v(Jt,!0);N(Jt),N(Ht);var Xt=_(Ht,2),Zt=e=>{var t=Ct();C(t,21,()=>k(K),V,(e,t)=>{var n=St(),i=v(n),a=v(i,!0);N(i),N(n),l(()=>r(a,k(t))),B(e,n)}),N(t),B(e,t)};h(Xt,e=>{k(K)&&e(Zt)});var Qt=_(Xt,6),$t=v(Qt),en=_(v($t),4);z(en);var tn=_(en,4);z(tn);var nn=_(tn,4);z(nn);var rn=_(nn,4);z(rn);var an=_(rn,4);z(an);var on=_(an,2),sn=v(on,!0);N(on);var cn=_(on,2),ln=e=>{var t=wt(),n=c(t),i=v(n);N(n);var a=_(n,4),o=v(a),u=v(o,!0);N(o);var d=_(o,2),f=v(d,!0);N(d),N(a),l(e=>{r(i,`id ${e??``}… · fanned out to ${k($).fanout_reached??``} peer(s)`),r(u,k($).open_hex),r(f,k(Ae)?`Copied`:`Copy`)},[()=>k($).ceremony_id.slice(0,12)]),A(`click`,d,()=>Ce(k($).open_hex,e=>s(Ae,e,!0))),B(e,t)};h(cn,e=>{k($)&&e(ln)}),N($t);var un=_($t,2),dn=_(v(un),4);z(dn);var fn=_(dn,2),pn=v(fn,!0);N(fn);var mn=_(fn,2),hn=e=>{var t=Tt(),n=c(t),i=v(n);N(n);var a=_(n,2),o=v(a),u=v(o,!0);N(o);var d=_(o,2),f=v(d,!0);N(d),N(a),l(()=>{r(i,`Approval broadcast to ${k(ue).broadcast_reached??``} co-trustee(s).`),r(u,k(ue).approve_hex),r(f,k(je)?`Copied`:`Copy`)}),A(`click`,d,()=>Ce(k(ue).approve_hex,e=>s(je,e,!0))),B(e,t)};h(mn,e=>{k(ue)&&e(hn)}),N(un);var gn=_(un,2),_n=_(v(gn),4);z(_n);var vn=_(_n,2),yn=v(vn,!0);N(vn);var bn=_(vn,2),xn=e=>{var t=Et(),n=c(t),i=v(n,!0);N(n),R(2),l(()=>r(i,k(pe))),B(e,t)};h(bn,e=>{k(pe)&&e(xn)}),N(gn),N(Qt),N(Me),l(()=>{It.disabled=k(re),r(Lt,k(re)?`Splitting…`:k(S)===`split`?`Split key`:`Re-split key`),Jt.disabled=k(G),r(Yt,k(G)?`Issuing…`:`Issue new share(s)`),on.disabled=k(Q),r(sn,k(Q)?`Opening…`:`Open ceremony`),fn.disabled=k(le),r(pn,k(le)?`Recording…`:`Record approval`),vn.disabled=k(fe),r(yn,k(fe)?`Signing…`:`Abort as subject`)}),t(`submit`,We,e=>(e.preventDefault(),we())),w(b,[],qe,()=>k(S),e=>s(S,e)),w(b,[],Ye,()=>k(S),e=>s(S,e)),j(Ze,()=>k(E),e=>s(E,e)),w(x,[],et,()=>k(M),e=>s(M,e)),w(x,[],nt,()=>k(M),e=>s(M,e)),j(jt,()=>k(P),e=>s(P,e)),j(Mt,()=>k(F),e=>s(F,e)),L(Ft,()=>k(I),e=>s(I,e)),t(`submit`,Ht,e=>(e.preventDefault(),Te())),j(Wt,()=>k(W),e=>s(W,e)),j(Gt,()=>k(ie),e=>s(ie,e)),L(qt,()=>k(ae),e=>s(ae,e)),t(`submit`,$t,e=>(e.preventDefault(),Ee())),j(en,()=>k(q),e=>s(q,e)),j(tn,()=>k(J),e=>s(J,e)),j(nn,()=>k(Y),e=>s(Y,e)),j(rn,()=>k(se),e=>s(se,e)),j(an,()=>k(Z),e=>s(Z,e)),t(`submit`,un,e=>(e.preventDefault(),De())),j(dn,()=>k(ce),e=>s(ce,e)),t(`submit`,gn,e=>(e.preventDefault(),Oe())),j(_n,()=>k(de),e=>s(de,e)),B(n,Me),m(),g()}p([`click`]);var kt=a(``),At=a(`

                Send this to each person in the audience.

                `,1),jt=a(`
              • `),Mt=a(`

                  `,1),Nt=a(`

                  Shared files

                  Share files from a vault

                  A share is a snapshot of these files at the vault's current epoch. It cannot - be recalled once handed over - editing the files afterward only affects future shares, not - this one.

                  Fetch a file someone shared with you

                  `);function Pt(n,i){e(i,!0);let a=d(y([]));X.listVaults().then(e=>s(a,e.published,!0)).catch(()=>{});let o=d(``),u=d(``),f=d(``),p=d(!1),g=d(null),b=d(!1);async function ee(){if(!(!k(o)||!k(u).trim()||!k(f).trim())){s(p,!0),s(g,null);try{let e=k(u).split(` -`).map(e=>e.trim()).filter(Boolean),t=k(f).split(`,`).map(e=>e.trim()).filter(Boolean),n=await X.discloseFiles(k(o),e,t);s(g,n.grant_hex,!0)}finally{s(p,!1)}}}async function x(){k(g)&&await Se(k(g))&&(s(b,!0),setTimeout(()=>s(b,!1),1200))}let S=d(``),w=d(``),T=d(``),E=d(``),D=d(!1),O=d(null);async function M(){if(!(!k(S).trim()||!k(w).trim()||!k(E).trim())){s(D,!0),s(O,null);try{let e=k(T).split(`,`).map(e=>e.trim()).filter(Boolean),t=await X.fetchGrant(k(S).trim(),{node:k(w).trim(),addrs:e},k(E).trim());s(O,t.written,!0)}finally{s(D,!1)}}}var te=Nt(),ne=_(v(te),2),F=_(v(ne),4),I=_(v(F),2),L=v(I);L.value=L.__value=``,C(_(L),17,()=>k(a),e=>e.vid,(e,t)=>{var n=kt(),i=v(n);N(n);var a={};l(e=>{r(i,`${e??``}… (epoch ${k(t).epoch??``})`),a!==(a=k(t).vid)&&(n.value=(n.__value=k(t).vid)??``)},[()=>k(t).vid.slice(0,16)]),B(e,n)}),N(I);var V=_(I,4);re(V);var H=_(V,4);z(H);var U=_(H,2),W=v(U,!0);N(U),N(F);var ie=_(F,2),ae=e=>{var t=At(),n=c(t),i=v(n),a=v(i,!0);N(i);var o=_(i,2),s=v(o,!0);N(o),N(n),R(2),l(()=>{r(a,k(g)),r(s,k(b)?`Copied`:`Copy`)}),A(`click`,o,x),B(e,t)};h(ie,e=>{k(g)&&e(ae)}),N(ne);var G=_(ne,2),K=_(v(G),2),q=_(v(K),2);z(q);var J=_(q,4);z(J);var Y=_(J,4);z(Y);var oe=_(Y,4);z(oe);var se=_(oe,2),Z=v(se,!0);N(se),N(K);var Q=_(K,2),$=e=>{var t=Mt(),n=c(t),i=v(n);N(n);var a=_(n,2);C(a,20,()=>k(O),e=>e,(e,t)=>{var n=jt(),i=v(n,!0);N(n),l(()=>r(i,t)),B(e,n)}),N(a),l(()=>r(i,`Wrote ${k(O).length??``} file(s):`)),B(e,t)};h(Q,e=>{k(O)&&e($)}),N(G),N(te),l(()=>{U.disabled=k(p),r(W,k(p)?`Sharing…`:`Share files`),se.disabled=k(D),r(Z,k(D)?`Fetching…`:`Fetch files`)}),t(`submit`,F,e=>(e.preventDefault(),ee())),P(I,()=>k(o),e=>s(o,e)),j(V,()=>k(u),e=>s(u,e)),j(H,()=>k(f),e=>s(f,e)),t(`submit`,K,e=>(e.preventDefault(),M())),j(q,()=>k(S),e=>s(S,e)),j(J,()=>k(w),e=>s(w,e)),j(Y,()=>k(T),e=>s(T,e)),j(oe,()=>k(E),e=>s(E,e)),B(n,te),m()}p([`click`]);var Ft=a(` `),It=a(`
                  Carapace
                  `);function Lt(t,i){e(i,!0);let a=()=>ee(se,`$live`,c),[c,f]=u(),p={"":`Overview`,vaults:`Vaults`,friends:`Friends`,recovery:`Recovery`,shared:`Shared files`};function g(){let e=typeof location<`u`?location.hash.replace(/^#\/?/,``):``;return e in p?e:``}let b=d(y(g()));function S(){s(b,g(),!0)}let w=d(null);function E(e){e?document.documentElement.setAttribute(`data-theme`,e):document.documentElement.removeAttribute(`data-theme`)}function D(){let e=typeof matchMedia<`u`&&matchMedia(`(prefers-color-scheme: light)`).matches,t=k(w)?k(w)===`light`:e;s(w,t?`dark`:`light`,!0),localStorage.setItem(`carapace-theme`,k(w)),E(k(w))}F(()=>{window.addEventListener(`hashchange`,S);let e=localStorage.getItem(`carapace-theme`);return(e===`dark`||e===`light`)&&(s(w,e,!0),E(k(w))),ce(),()=>window.removeEventListener(`hashchange`,S)}),te(()=>{le()});var j=It();x(`1uha8ag`,e=>{o(()=>{n.title=`Carapace`})});var M=v(j),P=_(v(M),2);C(P,21,()=>Object.entries(p),([e,t])=>e,(e,t)=>{var n=ne(()=>I(k(t),2));let i=()=>k(n)[0],a=()=>k(n)[1];var o=Ft();let s;var c=v(o,!0);N(o),l(()=>{T(o,`href`,i()?`#/${i()}`:`#/`),s=O(o,1,`svelte-1uha8ag`,null,s,{active:k(b)===i()}),r(c,a())}),B(e,o)}),N(P);var L=_(P,2),R=v(L);let z;var V=_(R,2),re=v(V,!0);N(V),N(L),N(M);var H=_(M,2),U=v(H);de(U,{});var W=_(U,2),ie=e=>{Ue(e,{})},ae=e=>{nt(e,{})},G=e=>{Ot(e,{})},K=e=>{Pt(e,{})},q=e=>{Ae(e,{})};h(W,e=>{k(b)===`vaults`?e(ie):k(b)===`friends`?e(ae,1):k(b)===`recovery`?e(G,2):k(b)===`shared`?e(K,3):e(q,-1)}),N(H),N(j),l(()=>{z=O(R,1,`live-dot svelte-1uha8ag`,null,z,{live:a()}),T(R,`title`,a()?`Live updates connected`:`Reconnecting…`),r(re,k(w)===`light`?`Molt (light)`:`Dark`)}),A(`click`,V,D),B(t,j),m(),f()}p([`click`]);export{Lt as component}; \ No newline at end of file diff --git a/crates/carapace-api/static/_app/version.json b/crates/carapace-api/static/_app/version.json index 9b24b99..8309af1 100644 --- a/crates/carapace-api/static/_app/version.json +++ b/crates/carapace-api/static/_app/version.json @@ -1 +1 @@ -{"version":"1784071871063"} \ No newline at end of file +{"version":"0.0.1"} \ No newline at end of file diff --git a/crates/carapace-api/static/claimant.css b/crates/carapace-api/static/claimant.css new file mode 100644 index 0000000..c7b9a2c --- /dev/null +++ b/crates/carapace-api/static/claimant.css @@ -0,0 +1,14 @@ +:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #151719; color: #f3eee5; } +body { margin: 0; } +main { max-width: 52rem; margin: auto; padding: 1.25rem; } +header, section { border: 1px solid #53504a; border-radius: .75rem; padding: 1.25rem; margin-bottom: 1rem; background: #202326; } +.eyebrow { color: #d9a86c; text-transform: uppercase; letter-spacing: .08em; } +label { display: block; margin: 1rem 0 .4rem; } +textarea { box-sizing: border-box; width: 100%; margin-top: .4rem; padding: .7rem; color: inherit; background: #111315; border: 1px solid #77736b; border-radius: .35rem; font: .9rem ui-monospace, monospace; } +button { margin-top: .35rem; padding: .65rem .9rem; color: #151719; background: #d9a86c; border: 0; border-radius: .35rem; font-weight: 700; cursor: pointer; } +button:focus-visible, textarea:focus-visible { outline: 3px solid #6fc7bd; outline-offset: 2px; } +button:disabled { opacity: .55; cursor: wait; } +#error { margin: 1rem 0; padding: 1rem; border: 2px solid #e68172; } +#notice, #progress { min-height: 1.4em; } +@media (max-width: 40rem) { main { padding: .65rem; } header, section { padding: .9rem; } } +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; } } diff --git a/crates/carapace-api/static/claimant.html b/crates/carapace-api/static/claimant.html new file mode 100644 index 0000000..f5fdc8a --- /dev/null +++ b/crates/carapace-api/static/claimant.html @@ -0,0 +1,80 @@ + + + + + + + Carapace identity recovery + + + + +
                  +
                  +

                  Claimant mode

                  +

                  Recover this Carapace identity

                  +

                  This isolated server cannot open vaults or use normal daemon actions.

                  +
                  + +
                  + + +
                  +

                  1. Give the public session values to a sponsor

                  +

                  Verify the sponsor through a separate trusted channel. This package contains public values only.

                  + + +
                  + Advanced manual values + + +
                  +
                  + +
                  +

                  2. Import the signed open and trustee endpoints

                  +

                  The signed open must contain the exact public values above. Add one trustee user per line.

                  +
                  + + + +
                  + Advanced manual values + + + +
                  + + +
                  +

                  +
                  + + +
                  + + diff --git a/crates/carapace-api/static/claimant.js b/crates/carapace-api/static/claimant.js new file mode 100644 index 0000000..2bb220a --- /dev/null +++ b/crates/carapace-api/static/claimant.js @@ -0,0 +1,140 @@ +const token = document.querySelector('meta[name="carapace-claimant-token"]')?.content ?? ''; +const errorBox = document.querySelector('#error'); +const notice = document.querySelector('#notice'); +const progress = document.querySelector('#progress'); + +function showError(message) { + errorBox.textContent = message; + errorBox.hidden = false; +} + +async function request(path, init) { + const response = await fetch(path, { + ...init, + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...init?.headers } + }); + const body = await response.json(); + if (!response.ok) throw new Error(typeof body.error === 'string' ? body.error : 'The claimant request failed.'); + return body; +} + +async function copyPublicValue(id) { + try { + await navigator.clipboard.writeText(document.querySelector(`#${id}`).value); + notice.textContent = 'Copied.'; + } catch { + showError('Could not copy. Select and copy the public value manually.'); + } +} + +function lines(id) { + return document.querySelector(`#${id}`).value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean); +} + +function trusteeRows() { + return lines('trustees').map((line) => { + const [node, rawAddresses = '', relay = ''] = line.split('|', 3); + return { node: node.trim(), addrs: rawAddresses.split(',').map((value) => value.trim()).filter(Boolean), relay_url: relay.trim() || null }; + }); +} + +let announceRefs = []; +let sponsorSignature = ''; + +function importSponsorPackage() { + const value = JSON.parse(document.querySelector('#sponsor-package').value); + if (value.type !== 'carapace.sponsor-ceremony' || value.version !== 1 || + typeof value.sponsor_sig !== 'string' || typeof value.open_hex !== 'string' || !Array.isArray(value.roster) || !Array.isArray(value.trustees)) { + throw new Error('The sponsor ceremony package type, version, or fields are invalid.'); + } + sponsorSignature = value.sponsor_sig; + document.querySelector('#open-hex').value = value.open_hex; + document.querySelector('#roster').value = value.roster.join('\n'); + document.querySelector('#trustees').value = value.trustees.map((trustee) => + `${trustee.node}|${Array.isArray(trustee.addrs) ? trustee.addrs.join(',') : ''}|${trustee.relay_url || ''}`).join('\n'); + announceRefs = Array.isArray(value.announce_refs) ? value.announce_refs : []; +} + +let confirmedSubject = ''; + +async function previewOpen() { + errorBox.hidden = true; + importSponsorPackage(); + const preview = await request('/api/claimant/preview', { + method: 'POST', body: JSON.stringify({ open_hex: document.querySelector('#open-hex').value.trim() }) + }); + if (!preview.session_bound) throw new Error('The signed open is not bound to this session.'); + confirmedSubject = preview.subject; + document.querySelector('#subject-id').textContent = preview.subject; + document.querySelector('#subject-confirmation').hidden = false; + document.querySelector('#confirm-subject').checked = false; + document.querySelector('#recover').disabled = true; +} + +async function loadSession() { + const session = await request('/api/claimant/status'); + if (session.phase === 'activation_complete') { + document.querySelector('#restart').hidden = false; + document.querySelector('#complete-form').hidden = true; + return; + } + document.querySelector('#ceremony-enc').value = session.ceremony_enc; + document.querySelector('#new-node').value = session.new_node; + document.querySelector('#handoff').value = session.handoff; +} + +document.querySelectorAll('[data-copy]').forEach((button) => { + button.addEventListener('click', () => copyPublicValue(button.dataset.copy)); +}); + +document.querySelector('#preview-open').addEventListener('click', () => { + previewOpen().catch((error) => showError(error instanceof Error ? error.message : 'Could not verify the signed open.')); +}); + +document.querySelector('#sponsor-package').addEventListener('input', () => { + confirmedSubject = ''; + announceRefs = []; + document.querySelector('#subject-confirmation').hidden = true; + document.querySelector('#recover').disabled = true; +}); + +document.querySelector('#cancel').addEventListener('click', async () => { + errorBox.hidden = true; + await request('/api/claimant/cancel', { method: 'POST', body: '{}' }); + for (const id of ['sponsor-package', 'open-hex', 'roster', 'trustees']) document.querySelector(`#${id}`).value = ''; + confirmedSubject = ''; + announceRefs = []; + document.querySelector('#subject-confirmation').hidden = true; + document.querySelector('#recover').disabled = true; + progress.textContent = 'The old session keys were cleared. A fresh retry session is ready.'; + await loadSession(); +}); + +document.querySelector('#confirm-subject').addEventListener('change', (event) => { + document.querySelector('#recover').disabled = !(event.target.checked && confirmedSubject); +}); + +document.querySelector('#complete-form').addEventListener('submit', async (event) => { + event.preventDefault(); + errorBox.hidden = true; + const button = document.querySelector('#recover'); + button.disabled = true; + progress.textContent = 'Contacting trustees. Keep this window open.'; + try { + const result = await request('/api/claimant/complete', { + method: 'POST', + body: JSON.stringify({ open_hex: document.querySelector('#open-hex').value.trim(), confirmed_subject: confirmedSubject, sponsor_sig: sponsorSignature, roster: lines('roster'), trustees: trusteeRows(), announce_refs: announceRefs }) + }); + if (!result.restart_required) throw new Error('Activation did not request a safe restart.'); + progress.textContent = 'Recovered identity activation is complete.'; + document.querySelector('#restart').hidden = false; + document.querySelector('#complete-form').hidden = true; + document.querySelector('#restart').focus?.(); + } catch (error) { + showError(error instanceof Error ? error.message : 'Recovery did not complete.'); + progress.textContent = 'Recovery stopped without activation.'; + button.disabled = false; + } +}); + +loadSession().catch(() => showError('Could not load the claimant session. Reload this local page.')); diff --git a/crates/carapace-api/static/index.html b/crates/carapace-api/static/index.html index c2e4e0d..65ddede 100644 --- a/crates/carapace-api/static/index.html +++ b/crates/carapace-api/static/index.html @@ -4,29 +4,29 @@ - - + + - + - + - +
                  +
                  +

                  Account and devices

                  +

                  Account identifier: {$status?.user_id ?? 'Connecting…'}

                  +

                  {$status?.identity_storage === 'operating_system_credentials' ? 'Account keys are protected by this computer’s credential store.' : $status?.identity_storage === 'passphrase_protected_files' ? 'Account keys are encrypted with a passphrase.' : 'Development mode: account keys are not protected by the credential store.'}

                  +
                  Add another computer +

                  Create an encrypted transfer package on your existing computer. Anyone with the package and passphrase can access your account. Send them separately.

                  +
                  { e.preventDefault(); exportAccount(); }}> + + + +
                  + + +

                  On the new computer

                  +

                  Paste the package above. Import creates a new account folder using this computer's protected key storage.

                  +
                  { e.preventDefault(); importAccount(); }}> + + + +
                  +

                  Return the new device card to the existing computer

                  + + + +
                  + {#if message}

                  {message}

                  {/if} +
                  + diff --git a/gui/src/lib/components/FolderPicker.svelte b/gui/src/lib/components/FolderPicker.svelte new file mode 100644 index 0000000..56d4aac --- /dev/null +++ b/gui/src/lib/components/FolderPicker.svelte @@ -0,0 +1,33 @@ + +
                  + + + {#if browsing} +
                  + {#if listing} +

                  {listing.path}

                  + {#if listing.parent}{/if} +
                    {#each listing.directories as dir (dir.path)}
                  • {/each}
                  + + {/if} + +
                  + {/if} +
                  + diff --git a/gui/src/lib/components/FriendsView.svelte b/gui/src/lib/components/FriendsView.svelte index a415b94..6028711 100644 --- a/gui/src/lib/components/FriendsView.svelte +++ b/gui/src/lib/components/FriendsView.svelte @@ -1,7 +1,6 @@
                  -

                  Shell integrity

                  +

                  Your protection

                  {#if $status} @@ -92,7 +80,7 @@
                  -
                  Friends storing your vaults
                  +
                  Connected friends
                  {$status.friends.count}
                  @@ -106,6 +94,7 @@ Add a friend Split your key
                  + {:else}

                  Waiting for the daemon…

                  {/if} diff --git a/gui/src/lib/components/RecoveryView.svelte b/gui/src/lib/components/RecoveryView.svelte index 7a59095..0e0b8d9 100644 --- a/gui/src/lib/components/RecoveryView.svelte +++ b/gui/src/lib/components/RecoveryView.svelte @@ -1,20 +1,24 @@

                  Recovery & trustees

                  Split your key into pieces so a group of trustees can rebuild it if you lose access. - Each share below is a bearer secret: send one to each trustee yourself - the daemon - doesn't track who you gave it to. + The normal flow sends a signed recovery grant to each selected friend and tracks delivery. + The advanced manual flow shows bearer shares that you must protect and deliver yourself.

                  {#if $status} @@ -179,6 +240,55 @@ {/if} +
                  + Restore retained vaults after identity recovery +

                  Discovery-dependent operation · it is safe to retry after a network failure

                  +

                  Use the public restart handoff saved by claimant activation. Carapace contacts the verified trustee hints and accepts only the maximum announced epoch for each vault.

                  +
                  (event.preventDefault(), runRestartRestore())}> + + + +
                  +
                  {#if restartRestoring}

                  Restore is in progress.

                  {:else if restartResult}

                  Restored {restartResult.restored} vault(s) from {restartResult.refs} maximum-epoch reference(s).

                  {/if}
                  +
                  + + {#if $status?.ceremonies?.length} +

                  Active recovery ceremonies

                  +

                  + Review the claimant and reason out of band before you approve a ceremony. Abort any ceremony + against your identity that you did not start. +

                  +
                  + {#each $status.ceremonies as ceremony (ceremony.ceremony_id)} +
                  +
                  +
                  + {ceremony.claimant_display || 'Unnamed claimant'} +
                  {ceremony.ceremony_id}
                  +
                  + {ceremony.phase} +
                  + {#if ceremony.alarm} + + {/if} +
                  +
                  Reason
                  {ceremony.reason || 'No reason supplied'}
                  +
                  Approvals
                  {ceremony.approvals} / {ceremony.threshold}
                  +
                  Sponsor
                  {ceremony.sponsor}
                  +
                  +
                  + {#if ceremony.trustee && !ceremony.approved} + + {/if} + {#if ceremony.is_self_subject && !ceremony.takeover} + + {/if} +
                  +
                  + {/each} +
                  + {/if} + {#if $status?.recovery_grants?.minted?.length}

                  Paper cards (offline backstop)

                  @@ -313,14 +423,16 @@ {/each} {/if} - {#if Object.keys($notes.recoverySets).length > 0} -

                  Recovery sets split from this browser

                  + {#if $status?.share_health.sets.length} +

                  Authoritative recovery sets

                  - {#each Object.values($notes.recoverySets) as rs (rs.rsid)} + {#each $status.share_health.sets as rs (rs.rsid)}
                  rsid {rs.rsid} {rs.scope.kind === 'root' ? 'your root key' : `vault ${rs.scope.vid.slice(0, 10)}…`} - {rs.m}-of-{rs.n} + {rs.threshold}-of-{rs.issued} + {rs.trustees.filter((trustee) => trustee.delivered).length}/{rs.trustees.length} trustee grants delivered + {#if rs.warnings.length}{rs.warnings.join(', ')}{/if}
                  {/each}
                  @@ -336,10 +448,15 @@ Re-split (raise M or replace trustees) -
                  - - -
                  + {#if mode === 'split'} +

                  Carapace will create recovery set {nextRsid}.

                  +
                  Advanced recovery-set id{#if manualSetId}{/if}
                  + {:else} + + + {/if}
                  {#if scopeKind === 'vault'} - + {/if} -
                  -
                  - - - - +
                  +
                  + Share delivery + + +
                  + {#if deliveryMode === 'friends'} +
                  + Select trustees ({selectedTrustees.length} selected) + {#if !$status?.friends.list.length} +

                  Add friends before you create recovery protection.

                  + {:else} +
                  + {#each $status.friends.list as friend (friend)} + + {/each} +
                  + {/if} +
                  + {/if} +
                  + + + {#if deliveryMode === 'manual'} + + + {:else} + of {selectedTrustees.length} selected + {/if}
                  - - + - {#if splitWarnings.length} + {#if deliveryResult} +
                  0} style="margin-top: 1rem"> +

                  Verified share delivery

                  +

                  {deliveryResult.delivered.length} trustee(s) confirmed that they stored a signed recovery grant.

                  + {#if deliveryResult.undelivered.length} +

                  Could not reach {deliveryResult.undelivered.length} trustee(s). Carapace will retry delivery during maintenance.

                  +
                    {#each deliveryResult.undelivered as trustee (trustee)}
                  • {trustee}
                  • {/each}
                  + {:else} +

                  All selected trustees received their shares.

                  + {/if} +
                  + {/if} + + {#if splitWarnings.length}
                  {#each splitWarnings as w (w)}

                  {w}

                  {/each}
                  @@ -386,10 +549,11 @@ {/if}

                  Add a trustee (extend)

                  +

                  Peer-dependent operation · offline delivery stays queued and is safe to retry

                  (e.preventDefault(), runExtend())}>
                  - - + + @@ -418,23 +582,50 @@
                  (e.preventDefault(), runOpen())}>

                  Open (sponsor)

                  - - +

                  + Use the recovery request from the claimant. Confirm their identity and new device through + a separate trusted channel before you open the ceremony. +

                  + + + {#if !$status?.recovery_grants.held.length} +

                  + This device does not hold a recovery grant. It cannot sponsor a ceremony. +

                  + {/if} - - - - + + + + {#if handoffError}{/if} +
                  + Advanced manual values + + + + +
                  - + {#if openResult}

                  id {openResult.ceremony_id.slice(0, 12)}… · fanned out to {openResult.fanout_reached} peer(s)

                  -
                  Signed open - hand to the claimant
                  +
                  Verified sponsor ceremony package - hand to the claimant
                  @@ -457,11 +648,15 @@ {/if} -
                  (e.preventDefault(), runAbort())}> + (e.preventDefault(), runAbort())}>

                  Abort

                  - - + + + {#if abortHex}

                  Send this abort to your trustees.

                  @@ -487,6 +682,31 @@ color: var(--muted); } + fieldset { + border: 1px solid var(--hairline); + border-radius: var(--radius); + margin: 0 0 0.75rem; + padding: 0.75rem; + } + + fieldset > label, + .trustee-list label { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0.35rem 0; + } + + legend { + font-weight: 600; + } + + .trustee-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 0 1rem; + } + .list { display: flex; flex-direction: column; @@ -530,6 +750,70 @@ margin-bottom: 0.5rem; } + .grid select { + width: 100%; + margin-bottom: 0.5rem; + } + + .ceremony.alarm { + border-color: var(--coral); + } + + .alarm-text { + color: var(--coral); + font-weight: 600; + } + + dl { + display: grid; + gap: 0.5rem; + } + + dl div { + display: grid; + grid-template-columns: minmax(6rem, 0.25fr) 1fr; + gap: 0.75rem; + } + + dt { + color: var(--muted); + } + + dd { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; + } + + button.danger { + border-color: var(--coral); + color: var(--coral); + } + + .confirm-action { + display: flex !important; + align-items: center; + gap: 0.5rem; + } + + .confirm-action input { + width: auto; + margin: 0; + } + + @media (max-width: 640px) { + .share-row, + .set-row { + align-items: stretch; + flex-direction: column; + } + + dl div { + grid-template-columns: 1fr; + gap: 0.1rem; + } + } + .resplit-head { display: flex; justify-content: space-between; diff --git a/gui/src/lib/components/SharedView.svelte b/gui/src/lib/components/SharedView.svelte index 1203ac5..c3bb9e1 100644 --- a/gui/src/lib/components/SharedView.svelte +++ b/gui/src/lib/components/SharedView.svelte @@ -1,6 +1,7 @@ + + +
                  +
                  +

                  Claimant mode

                  +

                  Recover this Carapace identity

                  +

                  This isolated server cannot open vaults or use normal daemon actions.

                  +
                  + +
                  + + +
                  +

                  1. Give the public session values to a sponsor

                  +

                  Verify the sponsor through a separate trusted channel. This package contains public values only.

                  + + +
                  + Advanced manual values + + +
                  +
                  + +
                  +

                  2. Import the signed open and trustee endpoints

                  +

                  The signed open must contain the exact public values above. Add one trustee user per line.

                  + + + + +
                  + Advanced manual values + + + +
                  + + + +

                  +
                  + + +
                  + + diff --git a/gui/static/claimant.js b/gui/static/claimant.js new file mode 100644 index 0000000..2bb220a --- /dev/null +++ b/gui/static/claimant.js @@ -0,0 +1,140 @@ +const token = document.querySelector('meta[name="carapace-claimant-token"]')?.content ?? ''; +const errorBox = document.querySelector('#error'); +const notice = document.querySelector('#notice'); +const progress = document.querySelector('#progress'); + +function showError(message) { + errorBox.textContent = message; + errorBox.hidden = false; +} + +async function request(path, init) { + const response = await fetch(path, { + ...init, + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', ...init?.headers } + }); + const body = await response.json(); + if (!response.ok) throw new Error(typeof body.error === 'string' ? body.error : 'The claimant request failed.'); + return body; +} + +async function copyPublicValue(id) { + try { + await navigator.clipboard.writeText(document.querySelector(`#${id}`).value); + notice.textContent = 'Copied.'; + } catch { + showError('Could not copy. Select and copy the public value manually.'); + } +} + +function lines(id) { + return document.querySelector(`#${id}`).value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean); +} + +function trusteeRows() { + return lines('trustees').map((line) => { + const [node, rawAddresses = '', relay = ''] = line.split('|', 3); + return { node: node.trim(), addrs: rawAddresses.split(',').map((value) => value.trim()).filter(Boolean), relay_url: relay.trim() || null }; + }); +} + +let announceRefs = []; +let sponsorSignature = ''; + +function importSponsorPackage() { + const value = JSON.parse(document.querySelector('#sponsor-package').value); + if (value.type !== 'carapace.sponsor-ceremony' || value.version !== 1 || + typeof value.sponsor_sig !== 'string' || typeof value.open_hex !== 'string' || !Array.isArray(value.roster) || !Array.isArray(value.trustees)) { + throw new Error('The sponsor ceremony package type, version, or fields are invalid.'); + } + sponsorSignature = value.sponsor_sig; + document.querySelector('#open-hex').value = value.open_hex; + document.querySelector('#roster').value = value.roster.join('\n'); + document.querySelector('#trustees').value = value.trustees.map((trustee) => + `${trustee.node}|${Array.isArray(trustee.addrs) ? trustee.addrs.join(',') : ''}|${trustee.relay_url || ''}`).join('\n'); + announceRefs = Array.isArray(value.announce_refs) ? value.announce_refs : []; +} + +let confirmedSubject = ''; + +async function previewOpen() { + errorBox.hidden = true; + importSponsorPackage(); + const preview = await request('/api/claimant/preview', { + method: 'POST', body: JSON.stringify({ open_hex: document.querySelector('#open-hex').value.trim() }) + }); + if (!preview.session_bound) throw new Error('The signed open is not bound to this session.'); + confirmedSubject = preview.subject; + document.querySelector('#subject-id').textContent = preview.subject; + document.querySelector('#subject-confirmation').hidden = false; + document.querySelector('#confirm-subject').checked = false; + document.querySelector('#recover').disabled = true; +} + +async function loadSession() { + const session = await request('/api/claimant/status'); + if (session.phase === 'activation_complete') { + document.querySelector('#restart').hidden = false; + document.querySelector('#complete-form').hidden = true; + return; + } + document.querySelector('#ceremony-enc').value = session.ceremony_enc; + document.querySelector('#new-node').value = session.new_node; + document.querySelector('#handoff').value = session.handoff; +} + +document.querySelectorAll('[data-copy]').forEach((button) => { + button.addEventListener('click', () => copyPublicValue(button.dataset.copy)); +}); + +document.querySelector('#preview-open').addEventListener('click', () => { + previewOpen().catch((error) => showError(error instanceof Error ? error.message : 'Could not verify the signed open.')); +}); + +document.querySelector('#sponsor-package').addEventListener('input', () => { + confirmedSubject = ''; + announceRefs = []; + document.querySelector('#subject-confirmation').hidden = true; + document.querySelector('#recover').disabled = true; +}); + +document.querySelector('#cancel').addEventListener('click', async () => { + errorBox.hidden = true; + await request('/api/claimant/cancel', { method: 'POST', body: '{}' }); + for (const id of ['sponsor-package', 'open-hex', 'roster', 'trustees']) document.querySelector(`#${id}`).value = ''; + confirmedSubject = ''; + announceRefs = []; + document.querySelector('#subject-confirmation').hidden = true; + document.querySelector('#recover').disabled = true; + progress.textContent = 'The old session keys were cleared. A fresh retry session is ready.'; + await loadSession(); +}); + +document.querySelector('#confirm-subject').addEventListener('change', (event) => { + document.querySelector('#recover').disabled = !(event.target.checked && confirmedSubject); +}); + +document.querySelector('#complete-form').addEventListener('submit', async (event) => { + event.preventDefault(); + errorBox.hidden = true; + const button = document.querySelector('#recover'); + button.disabled = true; + progress.textContent = 'Contacting trustees. Keep this window open.'; + try { + const result = await request('/api/claimant/complete', { + method: 'POST', + body: JSON.stringify({ open_hex: document.querySelector('#open-hex').value.trim(), confirmed_subject: confirmedSubject, sponsor_sig: sponsorSignature, roster: lines('roster'), trustees: trusteeRows(), announce_refs: announceRefs }) + }); + if (!result.restart_required) throw new Error('Activation did not request a safe restart.'); + progress.textContent = 'Recovered identity activation is complete.'; + document.querySelector('#restart').hidden = false; + document.querySelector('#complete-form').hidden = true; + document.querySelector('#restart').focus?.(); + } catch (error) { + showError(error instanceof Error ? error.message : 'Recovery did not complete.'); + progress.textContent = 'Recovery stopped without activation.'; + button.disabled = false; + } +}); + +loadSession().catch(() => showError('Could not load the claimant session. Reload this local page.')); diff --git a/gui/tests/browser/ui.spec.ts b/gui/tests/browser/ui.spec.ts new file mode 100644 index 0000000..fe3ed8b --- /dev/null +++ b/gui/tests/browser/ui.spec.ts @@ -0,0 +1,138 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect, test, type Page } from '@playwright/test'; + +const user = 'a'.repeat(64); +const status = { + node_id: 'b'.repeat(64), + addr: ['127.0.0.1:9999'], + friends: { count: 1, list: [user], grants: [{ user, grant_bytes: 1073741824 }] }, + peers: [{ user, display: 'Alex', node: 'b'.repeat(64), addrs: ['127.0.0.1:9999'] }], + vaults: { published: [], held_replicas: [] }, + share_health: { recovery_sets_owned: 0, shares_held: 0, sets: [], recovery: [] }, + recovery_grants: { minted: [], held: [] }, + ceremonies: [], + resplits: [], + pending_resplits: [], + reachability: 'local', + relay_networks: 0, + relay_diversity_warning: false +}; + +async function mockDaemon(page: Page) { + await page.route('**/api/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path === '/api/status') return route.fulfill({ json: status }); + if (path === '/api/friends' && route.request().method() === 'GET') { + return route.fulfill({ json: { count: 1, list: [user] } }); + } + if (path === `/api/friends/${user}/unfriend`) { + return route.fulfill({ json: { was_friend: true, resplit_triggered: false, recovery_set_ids: [] } }); + } + return route.fulfill({ status: 404, json: { code: 'not_found', error: 'not found' } }); + }); +} + +test.beforeEach(async ({ page }) => mockDaemon(page)); + +test('keyboard navigation, destructive confirmation, and accessibility work', async ({ page }) => { + await page.goto('/#/friends'); + await expect(page.getByRole('heading', { name: 'Friends', exact: true })).toBeVisible(); + await page.keyboard.press('Tab'); + await expect(page.locator(':focus')).toBeVisible(); + await page.getByRole('button', { name: 'Unfriend' }).click(); + await expect(page.getByRole('button', { name: 'Confirm unfriend' })).toBeVisible(); + await page.getByRole('button', { name: 'Cancel' }).click(); + await expect(page.getByRole('button', { name: 'Confirm unfriend' })).toHaveCount(0); + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations).toEqual([]); +}); + +test('the narrow viewport has no horizontal overflow and keeps focus visible', async ({ page }) => { + await page.goto('/#/recovery'); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + expect(overflow).toBeLessThanOrEqual(1); + const focusTarget = page.getByRole('link', { name: 'Recovery' }); + await focusTarget.focus(); + await expect(focusTarget).toBeFocused(); + const focusBox = await focusTarget.boundingBox(); + expect(focusBox).not.toBeNull(); + expect(focusBox!.x).toBeGreaterThanOrEqual(0); + expect(focusBox!.x + focusBox!.width).toBeLessThanOrEqual(await page.evaluate(() => innerWidth)); +}); + +test('changing operation status is announced in a live region', async ({ page }) => { + await page.route('**/api/sync', async (route) => { + await new Promise((resolve) => setTimeout(resolve, 150)); + await route.fulfill({ json: { restored: [] } }); + }); + await page.goto('/#/vaults'); + await page.locator('#sync-peer').selectOption('b'.repeat(64)); + await page.locator('#sync-out').fill('/tmp/restore'); + await page.getByRole('button', { name: 'Sync and restore' }).click(); + const live = page.locator('[aria-live="polite"]').filter({ hasText: 'Sync' }); + await expect(live).toContainText('Sync is in progress.'); + await expect(live).toContainText('There were no new vault versions to restore.'); +}); + +test('claimant workflow verifies the subject and moves focus after activation', async ({ page }, testInfo) => { + test.skip(testInfo.project.name === 'mobile-chromium', 'The separate responsive test covers the mobile viewport.'); + await page.route('**/api/claimant/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path.endsWith('/status')) return route.fulfill({ json: { phase: 'ready', ceremony_enc: 'enc', new_node: 'node', handoff: 'handoff' } }); + if (path.endsWith('/preview')) return route.fulfill({ json: { session_bound: true, subject: user } }); + return route.fulfill({ json: { restart_required: true } }); + }); + await page.goto('/claimant.html'); + await page.locator('#sponsor-package').fill(JSON.stringify({ + type: 'carapace.sponsor-ceremony', version: 1, open_hex: '00', sponsor_sig: 'd'.repeat(128), roster: [user], + trustees: [{ node: 'b'.repeat(64), addrs: ['127.0.0.1:1'] }] + })); + await page.getByRole('button', { name: 'Import package and show subject' }).click(); + await expect(page.locator('#subject-id')).toHaveText(user); + await page.locator('#confirm-subject').check(); + await page.getByRole('button', { name: 'Collect approvals and activate' }).click(); + await expect(page.locator('#restart')).toBeVisible(); + await expect(page.locator('#restart')).toBeFocused(); +}); + +test('claimant cancellation clears inputs and starts a fresh retry session', async ({ page }, testInfo) => { + test.skip(testInfo.project.name === 'mobile-chromium', 'The responsive test covers the mobile viewport.'); + await page.route('**/api/claimant/status', (route) => route.fulfill({ json: { phase: 'ready', ceremony_enc: 'enc', new_node: 'node', handoff: 'handoff' } })); + await page.route('**/api/claimant/cancel', (route) => route.fulfill({ json: { cancelled: true, retry_ready: true } })); + await page.goto('/claimant.html'); + await page.locator('#sponsor-package').fill('sensitive attempt data'); + await page.getByRole('button', { name: 'Cancel and clear this attempt' }).click(); + await expect(page.locator('#sponsor-package')).toHaveValue(''); + await expect(page.locator('#progress')).toContainText('fresh retry session is ready'); +}); + +test('claimant clipboard success and failure give truthful feedback', async ({ page, context, browserName }, testInfo) => { + test.skip(testInfo.project.name === 'mobile-chromium', 'Clipboard permission emulation runs in desktop Chromium.'); + await page.route('**/api/claimant/status', (route) => route.fulfill({ json: { phase: 'ready', ceremony_enc: 'enc', new_node: 'node', handoff: 'handoff' } })); + if (browserName === 'chromium') await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await page.goto('/claimant.html'); + await page.getByRole('button', { name: 'Copy handoff package' }).click(); + await expect(page.locator('#notice')).toHaveText('Copied.'); + await page.evaluate(() => Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: () => Promise.reject(new Error('denied')) } })); + await page.getByRole('button', { name: 'Copy handoff package' }).click(); + await expect(page.getByRole('alert')).toContainText('Select and copy'); +}); + +test('folder browsing publishes the selected path without editing an identifier', async ({ page }) => { + const folder = '/home/alex/My documents'; + await page.route('**/api/directories*', (route) => route.fulfill({ json: {path:folder,parent:'/home/alex',directories:[]} })); + let published: unknown; + await page.route('**/api/vaults', async (route) => { + if (route.request().method() === 'POST') { + published = route.request().postDataJSON(); + return route.fulfill({json:{vid:'c'.repeat(64),epoch:1}}); + } + return route.fulfill({json:{published:[]}}); + }); + await page.goto('/#/vaults'); + await page.getByRole('button',{name:'Choose folder…'}).click(); + await page.getByRole('button',{name:'Use this folder'}).click(); + await expect(page.locator('#vault-dir')).toHaveValue(folder); + await page.getByRole('button',{name:'Publish vault',exact:true}).click(); + await expect.poll(() => published).toEqual({dir:folder}); +}); diff --git a/gui/tests/error-banner.component.test.ts b/gui/tests/error-banner.component.test.ts new file mode 100644 index 0000000..02f2653 --- /dev/null +++ b/gui/tests/error-banner.component.test.ts @@ -0,0 +1,14 @@ +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { describe, expect, it } from 'vitest'; +import ErrorBanner from '$lib/components/ErrorBanner.svelte'; +import { reportError } from '$lib/errors'; + +describe('ErrorBanner', () => { + it('renders an accessible alert and dismisses it', async () => { + reportError('The request failed safely.'); + render(ErrorBanner); + expect(screen.getByRole('alert')).toHaveTextContent('The request failed safely.'); + await fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); +}); diff --git a/gui/tests/fixtures/status-contract.json b/gui/tests/fixtures/status-contract.json new file mode 100644 index 0000000..8ae4208 --- /dev/null +++ b/gui/tests/fixtures/status-contract.json @@ -0,0 +1,35 @@ +{ + "node_id": "", + "addr": [], + "relay_url": null, + "friends": { + "count": 0, + "list": [], + "grants": [] + }, + "peers": [], + "vaults": { + "published": [], + "held_replicas": [] + }, + "share_health": { + "recovery_sets_owned": 0, + "shares_held": 0, + "sets": [], + "recovery": [] + }, + "recovery_grants": { + "minted": [], + "held": [] + }, + "ceremonies": [], + "resplits": [], + "pending_resplits": [], + "reachability": "direct", + "relay_networks": 0, + "relay_diversity_warning": true, + "por_latency_anomaly_count": 0, + "user_id": "", + "sync_errors": [], + "identity_storage": "" +} diff --git a/gui/tests/protection.test.mjs b/gui/tests/protection.test.mjs new file mode 100644 index 0000000..b878319 --- /dev/null +++ b/gui/tests/protection.test.mjs @@ -0,0 +1,12 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { recoveryPlate } from '../src/lib/protection.ts'; +test('protection requires root shares delivered and verified on a live connection', () => { + const set = {rsid:1,scope:{kind:'root'},threshold:2,issued:3,trustees:[],warnings:[]}; + assert.equal(recoveryPlate([set], [], true).state, 'at-risk'); + const delivered = {...set,trustees:[1,2,3].map(user=>({user:String(user),delivered:true}))}; + const health = [{rsid:1,live:3,target:3,recommendation:'healthy',needed:0}]; + assert.equal(recoveryPlate([delivered],health,true).state,'healthy'); + assert.equal(recoveryPlate([delivered],health,false).state,'at-risk'); + assert.equal(recoveryPlate([{...delivered,scope:{kind:'vault',vid:'x'}}],health,true).state,'empty'); +}); diff --git a/gui/tests/runtime.component.test.ts b/gui/tests/runtime.component.test.ts new file mode 100644 index 0000000..4d8f84f --- /dev/null +++ b/gui/tests/runtime.component.test.ts @@ -0,0 +1,50 @@ +import { get } from 'svelte/store'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { lastError } from '$lib/errors'; +import { copyToClipboard } from '$lib/format'; + +describe('clipboard feedback', () => { + beforeEach(() => lastError.set(null)); + + it('reports success only after the clipboard accepts the value', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }); + expect(await copyToClipboard('public-value')).toBe(true); + expect(writeText).toHaveBeenCalledWith('public-value'); + expect(get(lastError)).toBeNull(); + }); + + it('shows safe manual-copy guidance after clipboard rejection', async () => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) } }); + expect(await copyToClipboard('public-value')).toBe(false); + expect(get(lastError)).toMatch(/Select and copy the value manually/); + }); +}); + +describe('status WebSocket lifecycle', () => { + beforeEach(() => { vi.useFakeTimers(); vi.resetModules(); }); + afterEach(() => vi.useRealTimers()); + + it('retries an unexpected close and does not retry a deliberate stop', async () => { + const sockets: FakeSocket[] = []; + class FakeSocket { + onopen: (() => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + constructor(public url: string) { sockets.push(this); } + close() { this.onclose?.(); } + } + vi.stubGlobal('WebSocket', FakeSocket); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })); + const feed = await import('$lib/statusStore'); + feed.startStatusFeed(); + expect(sockets).toHaveLength(1); + sockets[0].onclose?.(); + await vi.advanceTimersByTimeAsync(1_000); + expect(sockets).toHaveLength(2); + feed.stopStatusFeed(); + await vi.advanceTimersByTimeAsync(30_000); + expect(sockets).toHaveLength(2); + }); +}); diff --git a/gui/tests/setup.ts b/gui/tests/setup.ts new file mode 100644 index 0000000..ba23909 --- /dev/null +++ b/gui/tests/setup.ts @@ -0,0 +1,5 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/svelte'; +import { afterEach } from 'vitest'; + +afterEach(cleanup); diff --git a/gui/tests/statusStore.test.mjs b/gui/tests/statusStore.test.mjs new file mode 100644 index 0000000..70ec83d --- /dev/null +++ b/gui/tests/statusStore.test.mjs @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import ts from 'typescript'; + +test('stopped feeds cannot overwrite a new session or schedule stale reconnects', async () => { + const sockets = [], requests = [], probes = [], timers = new Map(); + const context = vm.createContext({ + writable: value => ({ value, set(next) { this.value = next; } }), + apiToken: () => 'session', window: {}, location: { protocol: 'http:', host: 'localhost' }, + api: { status: () => new Promise(resolve => requests.push(resolve)) }, + fetch: () => new Promise(resolve => probes.push(resolve)), + WebSocket: class { constructor() { sockets.push(this); } close() { this.onclose?.(); } }, + setTimeout: callback => { const id = timers.size + 1; timers.set(id, callback); return id; }, + clearTimeout: id => timers.delete(id), + }); + const source = readFileSync(new URL('../src/lib/statusStore.ts', import.meta.url), 'utf8') + .replace(/^import .*;\n/gm, '').replace(/export /g, ''); + vm.runInContext(ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText + + '\nglobalThis.feed = { startStatusFeed, stopStatusFeed, status, live, sessionExpired };', context); + const feed = context.feed; + feed.startStatusFeed(); + const closing = sockets[0].onclose(); + feed.stopStatusFeed(); + feed.startStatusFeed(); + requests[1]({ marker: 'current' }); + await Promise.resolve(); + requests[0]({ marker: 'stale' }); + probes[0]({ status: 401 }); + await closing; + assert.equal(feed.status.value.marker, 'current'); + assert.equal(feed.sessionExpired.value, false); + assert.equal(timers.size, 0); + sockets[0].onmessage({ data: '{"marker":"stale socket"}' }); + assert.equal(feed.status.value.marker, 'current'); + const expired = sockets[1].onclose(); + probes[1]({ status: 401 }); + await expired; + assert.equal(feed.sessionExpired.value, true); + assert.equal(timers.size, 0); + feed.stopStatusFeed(); +}); diff --git a/gui/tests/ui-contracts.test.mjs b/gui/tests/ui-contracts.test.mjs new file mode 100644 index 0000000..f2a3376 --- /dev/null +++ b/gui/tests/ui-contracts.test.mjs @@ -0,0 +1,146 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; + +const source = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8'); + +test('API calls use the authenticated same-origin request boundary', () => { + const api = source('src/lib/api.ts'); + assert.match(api, /const BASE = ''/); + assert.match(api, /Authorization: `Bearer \$\{apiToken\(\)\}`/); + for (const endpoint of [ + "'/api/status'", + "'/api/sync'", + "'/api/vaults'", + "'/api/friends'", + "'/api/recovery/split'", + "'/api/recovery/ceremony/open'", + "'/api/recovery/ceremony/approve'", + "'/api/recovery/ceremony/abort'" + ]) { + assert.ok(api.includes(endpoint), `missing API contract ${endpoint}`); + } + assert.match(api, /res\.status === 401 \|\| res\.status === 403/); +}); + +test('the shell and alerts expose keyboard and accessibility semantics', () => { + const page = source('src/routes/+page.svelte'); + const errorBanner = source('src/lib/components/ErrorBanner.svelte'); + const shellHero = source('src/lib/components/ShellHero.svelte'); + assert.match(page, /